Harness spec 3.1.0: the prefix stays still, in the CLI
ci / check (push) Canceled after 0s

The system prompt is made once per session (start, compaction, model
switch), kept in session_meta and resent byte for byte; it holds no
date. What changes mid-session -- the date, a mode the person chose, a
plan or memory or instruction file they edited, a personality, MCP
instructions that arrived later -- is a <session-note> on the next user
message, stored with it. The tool list is fixed for the session:
plan_submit is always offered and answers unavailable outside plan
mode, the wrap-up keeps the tools and runs no call, a withdrawn tool
stays listed and answers its refusal; the first request waits up to
mcp_wait_ms for MCP servers. Compaction asks for its summary as the
next turn of the same conversation. Cached input tokens are parsed
(incl. llama.cpp timings), stored per request and shown in /context.

Spec: context.md §7/§10, loop.json context.note (+ plan_ended,
plan_own, personal, empty, mcp_wait_ms), modes.json plan.refused,
compact.md without the transcript heading. Conformance area prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-05 13:22:02 +00:00
1 parent ecae6247fd
commit 8e06590d57
36 files changed
+1533 -169

No files matched your search

+4 -1
View File
@@ -42,6 +42,7 @@ import type { Message } from "../provider/types.ts"
import type { Hub } from "./hub.ts"
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts"
import { isNote } from "../session/prefix.ts"
export const ACP_VERSION = 1
/** The link's version (LLeMbas `services/device_link.py:PROTOCOL`, harness/acp.md *Versions*). 3:
@@ -369,7 +370,9 @@ export function planReplyFrom(answer: any, maxMode?: Mode): PlanReply {
}
/** Text the expander added to a user message (an @file, an image, a listing, a `!` command's output). */
const ATTACHMENT = /^<(file|directory|shell|image) /
// What the model was given beside the words typed — attachments, and a session note
// (session/prefix.ts) — is not shown as typed.
const ATTACHMENT = { test: (t: string) => /^<(file|directory|shell|image) /.test(t) || isNote(t) }
/** A stored session as turns the web UI can draw: each person's message, then what the
* model said, thought and ran in answer, up to the next one. A message's attachments are chips,
+58 -24
View File
@@ -17,7 +17,8 @@ import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
import { hardlineRules } from "./permission/hardline.ts"
import { defaultEffort } from "./provider/effort.ts"
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
import { assembleSystem, instructionFiles, knowledgeScope } from "./prompt/assemble.ts"
import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
import type { Changing } from "./session/prefix.ts"
import { describeRepo, repoState } from "./git/repo.ts"
import { findProject, persistProjectRule, trustOf, type Project } from "./project/root.ts"
import type { Message } from "./provider/types.ts"
@@ -41,7 +42,7 @@ import { Library, type Embedder } from "./library/store.ts"
import { instances as lembasInstances, keyFile as lembasKeyFile } from "./lembas/login.ts"
import { PERSONAL_KEYS, personalConfig, pushPersonalization } from "./lembas/personal.ts"
import type { Plan } from "./plan/plan.ts"
import { LIBRARY_SERVER } from "./mcp/index.ts"
import { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts"
import type { McpServer } from "./config/schema.ts"
import { embedderFor } from "./library/embed.ts"
import { effortsFor } from "./provider/effort.ts"
@@ -253,8 +254,9 @@ export function createApp(o: AppOptions): App {
return s ? describeRepo(s) : undefined
}
// Memory and skills go into the system prompt as they stood when the session started: a write
// during the session reaches the file, not the prompt (Hermes' frozen snapshot).
// Memory and skills go into the system prompt as they stood when it was made: a write during the
// session reaches the file, not the prompt (Hermes' frozen snapshot) — the person's own edit
// reaches the model as a note (session/prefix.ts).
const memoryOn = loaded.config.memory?.enabled !== false
const memory = new MemoryStore({ memory: loaded.config.memory?.memory_chars ?? DEFAULT_LIMITS.memory, user: loaded.config.memory?.user_chars ?? DEFAULT_LIMITS.user })
const projectDir = () => (trusted && existsSync(project.dir) ? project.dir : undefined)
@@ -263,17 +265,18 @@ export function createApp(o: AppOptions): App {
? new MemoryStore({ memory: loaded.config.memory?.memory_chars ?? DEFAULT_LIMITS.memory, user: 0 }, join(project.dir, "local"), { memory: "PROJECT MEMORY (this project only)" })
: undefined
const skillList = () => loadSkills({ projectDir: projectDir(), external: loaded.config.skills?.external_dirs, disabled: loaded.config.skills?.disabled })
// Everything in the system prompt that could change during a session is taken once, here, for
// the same reason as the git block: the prompt must stay byte for byte the same, or the server
// reads the whole conversation again.
// What the system prompt holds that nothing during the session updates: taken as the session
// starts, for the same reason as the git block. The engine makes the prompt once and resends it
// byte for byte (harness context.md §10); what may change and is said in a note when it does —
// memory, instruction files, personality, MCP instructions — is read by `changing` below.
const freeze = () => ({
// The server's memory, when it is the server's: as last read for this session (readServerMemory).
memory: !memoryOn ? undefined : serverMemory !== undefined ? serverMemory.text : [memory.snapshot(), projectMemory?.block("memory") ?? ""].filter(Boolean).join("\n\n"),
skills: skillLines(skillList()),
git: gitSummary(),
tasks: projectDir() && !unsafeProjectFile(join(project.dir, "tasks.md"), project.root) ? boardSummary(project.dir) : undefined,
files: instructionFiles(o.cwd, project.root, loaded.instructions),
})
/** The memory block as it is now: the server's when it is the server's (as last read for this
* session, readServerMemory), else this machine's files; undefined when memory is off. */
const memoryNow = () => (!memoryOn ? undefined : serverMemory !== undefined ? serverMemory.text : [memory.snapshot(), projectMemory?.block("memory") ?? ""].filter(Boolean).join("\n\n"))
// `library: lembas`: the server keeps the memory (acp.md *Memory*); its entries for the prompt are
// read from it as a session starts. Until the read answers, none.
const memoryOnServer = loaded.config.library === "lembas" && memoryOn
@@ -299,7 +302,7 @@ export function createApp(o: AppOptions): App {
const lembasLibrary = memoryOnServer ? lembasLibraryServer(loaded.warnings) : undefined
if (lembasLibrary) loaded.mcp[LIBRARY_SERVER] = lembasLibrary
// Without a way to the server's memory, this machine's is used for now.
if (memoryOnServer && !lembasLibrary) serverMemory = undefined, frozen = freeze()
if (memoryOnServer && !lembasLibrary) serverMemory = undefined
const mcp = new McpManager(loaded.mcp, {
root: project.root,
version: VERSION,
@@ -340,8 +343,29 @@ export function createApp(o: AppOptions): App {
const prototypesBlock = () => (context.prototypes?.offered || (!context.prototypes && loaded.config.prototypes === true) ? { sources: context.prototypes?.sources ?? "" } : undefined)
/** The three personal keys this session uses: the client's for it, else the config's. */
const personal = () => (context.personalization ? personalConfig({ enabled: true, available: true, ...context.personalization }) : loaded.config)
const system = (m: Mode, current: ResolvedModel, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan) =>
assembleSystem({
/** What may change during the session, as it is now (session/prefix.ts `Changing`): what the
* prompt is made from, and what a note compares. A subagent's prompt holds no memory and no
* personality; a worktree subagent reads its own checkout's instruction files. */
type Source = { files: { path: string; text: string; chain?: true }[]; memory?: string; personality?: string; userInstructions?: string; instance?: { name: string; instructions: string }; mcp: Record<string, string> }
const changing = (subagent?: boolean, at?: { cwd: string; root: string }): Changing => {
const files = instructionFiles(at?.cwd ?? o.cwd, at?.root ?? project.root, loaded.instructions)
const p = subagent ? {} : personalityOf(personal())
const src: Source = {
files,
memory: subagent ? undefined : memoryNow(),
...p,
...(!subagent && context.instructions ? { instance: { name: contextFrom, instructions: context.instructions } } : {}),
mcp: mcp.serverInstructions(),
}
const named: Record<string, string> = Object.fromEntries(files.map((f) => [f.path, f.text]))
// What a note names the person's own instructions by (loop.json context.note.personal).
if (src.userInstructions) named[LOOP.context.note.personal] = src.userInstructions
if (src.instance) named[`Instructions from ${src.instance.name}`] = src.instance.instructions
return { memory: src.memory ?? "", personality: src.personality ?? "", files: named, mcp: src.mcp, source: src }
}
const system = (m: Mode, current: ResolvedModel, ch: Changing, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan) => {
const src = ch.source as Source
return assembleSystem({
modelRef: current.ref,
family: familyOf(current),
cwd: at?.cwd ?? o.cwd,
@@ -355,22 +379,26 @@ export function createApp(o: AppOptions): App {
// A worktree subagent works elsewhere: its own snapshot, taken as it starts.
git: at ? gitSummary() : frozen.git,
tasks: frozen.tasks,
files: at ? undefined : frozen.files,
files: src.files,
subagent,
memory: frozen.memory,
memory: src.memory,
// The skills block tells of skill_view; a subagent without the tool gets no block.
skills: subagent && !subagent.skills ? undefined : frozen.skills,
manageSkills: true,
// Read on every prompt: /personality and /settings change it without a restart.
...personalityOf(personal()),
mcp: mcp.instructions(),
// As the prompt is made; /personality and /settings later change it by a note.
personality: src.personality,
userInstructions: src.userInstructions,
mcp: mcpInstructions(src.mcp),
roster: roster(current.ref),
plan,
...(context.instructions ? { instance: { name: contextFrom, instructions: context.instructions } } : {}),
...(src.instance ? { instance: src.instance } : {}),
library: libraryBlock(),
prototypes: prototypesBlock(),
context: subagent ? subagent.recall : true,
})
}
/** A mode's text for a note, as the prompt would have it. */
const modeNote = (m: Mode) => modeText(m, planDir, o.unattended ? UNATTENDED[m] : undefined)
// The other configured models, for the task tool's `model`, or a switch.
const roster = (current: string) =>
loaded.refs
@@ -507,10 +535,13 @@ export function createApp(o: AppOptions): App {
jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) },
},
steerMark: MARKS.to_subagent,
system: (mode, current, plan) =>
changing: () => changing(true, at),
modeText: modeNote,
system: (mode, current, plan, ch) =>
system(
mode,
current,
ch,
{
name: spec.name,
instructions: wt
@@ -572,7 +603,11 @@ export function createApp(o: AppOptions): App {
compaction,
perm,
toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner } },
system: (m, current, plan) => system(m, current, undefined, undefined, plan),
system: (m, current, plan, ch) => system(m, current, ch, undefined, undefined, plan),
changing: () => changing(),
modeText: modeNote,
// The configured MCP servers, up to loop.json's wait: their tools in the first list.
beforeFirst: () => Promise.race([mcpReady.catch(() => {}), Bun.sleep(LOOP.context.mcp_wait_ms)]),
persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined,
spawn,
})
@@ -597,14 +632,13 @@ export function createApp(o: AppOptions): App {
})
const mcpReady = mcp.start()
/** The server's memory entries for the prompt (`lembas://memory`), read as a session starts, in
* the session's scope: put into the prompt while nothing has been sent yet, kept for the next
* session otherwise — the prompt does not change under a session. */
* the session's scope: in the prompt when it is made after this, else said in a note on the next
* user message — the prompt does not change under a session. */
const readServerMemory = async () => {
if (!lembasLibrary || !serverMemory) return
try {
const text = await mcp.readResource(LIBRARY_SERVER, MEMORY_RESOURCE, engine.o.toolCtx.libraryScope)
serverMemory = { text }
if (!engine.messages.length && memoryOn) frozen = { ...frozen, memory: text }
} catch (e) {
bus.emit({ type: "notice", level: "warn", message: `memory: the server's entries could not be read (${(e as Error).message}); none are in this session's prompt` })
}
+16 -2
View File
@@ -76,8 +76,10 @@ export interface ToolSpecDef {
offered?: "subagent"
argument_aliases?: Record<string, string>
/** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file
* can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's). */
requires?: ("display" | "page" | "forge")[]
* can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's);
* `plan` (3.1.0): plan mode. From 3.1.0 one that can come and go during a session does not
* leave the tool out: it is offered and answers `unavailable` while unmet (context.md §10). */
requires?: ("display" | "page" | "forge" | "plan")[]
/** What a call answers, word for word, when a requirement went away after the offer. */
unavailable?: string
/** `always`: put to the person in every mode, auto included; never remembered. */
@@ -131,6 +133,18 @@ export const LOOP: {
dedupe: { by_hash: string[]; by_result: string[] }
compaction: { heading: string; line: string; rows: number }
reasoning: string
/** A note on the next user message (spec 3.1.0, context.md §10): its tags and its lines, in
* the order they are said. */
note: { open: string; close: string; date: string; mode: string; plan: string; memory: string; instructions: string; personality: string; mcp: string; empty: string
/** What a note names the person's own instructions by (the instructions block's `{{path}}`). */
personal: string
/** For a chat, whose modes have no text of their own; the CLI says modes/<mode>.md. */
plan_ended: string
/** For a chat, which does not resend earlier tool results; the CLI's model has them. */
plan_own: string
}
/** How long a session waits for its configured MCP servers before its first request. */
mcp_wait_ms: number
}
} = LOOP_JSON
+14 -2
View File
@@ -25,6 +25,7 @@ import type { ImagePart } from "../provider/types.ts"
import { ToolError, truncate, type Tool, type ToolResult } from "../tool/tool.ts"
import { match } from "../permission/wildcard.ts"
import { codeFrom, forget, OAuthProvider, waitForCallback, CALLBACK_PORT } from "./oauth.ts"
import { mcpBlock } from "../session/prefix.ts"
export type ServerConfig = McpServer & { source: "global" | "project" }
export type Status = "disabled" | "connecting" | "connected" | "failed" | "needs_auth"
@@ -480,8 +481,13 @@ export class McpManager {
/** The system prompt's block of server instructions, or "". */
instructions(): string {
const parts = [...this.servers.values()].filter((s) => s.status === "connected" && s.instructions).map((s) => `<server name="${s.name}">\n${s.instructions}\n</server>`)
return parts.length ? `Instructions from the MCP servers you have tools from (their tools are named mcp__<server>__…):\n<mcp_instructions>\n${parts.join("\n")}\n</mcp_instructions>` : ""
return mcpInstructions(this.serverInstructions())
}
/** Each connected server's own instructions, by server: what the prompt shows (mcpInstructions),
* and what a session note compares (session/prefix.ts). */
serverInstructions(): Record<string, string> {
return Object.fromEntries([...this.servers.values()].filter((s) => s.status === "connected" && s.instructions).map((s) => [s.name, s.instructions!]))
}
/** Sign in to a remote server: show the page, catch the redirect (or take a pasted one). */
@@ -554,3 +560,9 @@ function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T>
let t: ReturnType<typeof setTimeout>
return Promise.race([p, new Promise<T>((_, reject) => (t = setTimeout(() => reject(new Error(message)), ms)))]).finally(() => clearTimeout(t))
}
/** MCP servers' instructions as the system prompt carries them, "" for none. */
export function mcpInstructions(by: Record<string, string>): string {
const parts = Object.entries(by).map(([name, text]) => mcpBlock(name, text))
return parts.length ? `Instructions from the MCP servers you have tools from (their tools are named mcp__<server>__…):\n<mcp_instructions>\n${parts.join("\n")}\n</mcp_instructions>` : ""
}
+23 -8
View File
@@ -231,8 +231,8 @@ export interface PromptContext {
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
* `report`: it has the report tool. */
subagent?: { name: string; instructions: string; report?: boolean }
/** The memory snapshot taken when the session started ("" when both files are empty);
* undefined when memory is off. */
/** The memory as it was when the prompt was made ("" when both files are empty); undefined
* when memory is off. A later change by the person goes as a note. */
memory?: string
/** The skills list (skillLines), taken when the session started. */
skills?: string
@@ -246,7 +246,8 @@ export interface PromptContext {
mcp?: string
/** The other configured models, one line each (with their notes). */
roster?: string
/** The session's plan (plan_write / plan_update), put into the prompt while it exists. */
/** The session's plan (plan_write / plan_update) as it stood when the prompt was made; a change
* after that is the model's own tool result, or a note when the person made it. */
plan?: Plan
/** An instance's own instructions for this session (the chat's authored prompt, over the link),
* appended after the shared text under `## Instructions from <name>`. */
@@ -263,6 +264,13 @@ export interface PromptContext {
const MAX_INSTRUCTION_CHARS = 40_000
/** The system prompt (harness spec; its stability is context.md §10, from 3.1.0). It is made once
* per session — at the start, after a compaction, on a model switch — and the engine resends
* those bytes on every request, so nothing in it may be a clock or anything else that moves on
* its own: no date (a note on the first user message says it, session/prefix.ts), the permission
* mode's text as it was at the start, the plan as it stood then, memory, instruction files,
* personality and MCP instructions as they were read for it. What changes later reaches the model
* as a `<session-note>` on the next user message, never by making this again. */
export function assembleSystem(ctx: PromptContext): string {
const vars = {
agent_name: ctx.agentName ?? "LLeMbas",
@@ -297,7 +305,7 @@ export function assembleSystem(ctx: PromptContext): string {
`Project root: ${ctx.root}`,
`Git repository: ${ctx.isGit ? "yes" : "no"}`,
`Platform: ${platform()} ${release()}`,
`Today: ${new Date().toISOString().slice(0, 10)}`,
// No date: it would change at midnight and the whole session be read again (§10 rule 2).
`Model: ${ctx.modelRef}`,
"</env>",
].join("\n"),
@@ -320,8 +328,8 @@ export function assembleSystem(ctx: PromptContext): string {
if (ctx.mcp) sections.push(ctx.mcp)
if (!ctx.subagent) sections.push(agentsBlock(ctx, files))
// Memory and skills: snapshots taken when the session started, so the prompt does not change
// under a session (Hermes' frozen snapshot).
// Memory and skills: as they were when the prompt was made, so the prompt does not change under
// a session (Hermes' frozen snapshot); the person's later edit to memory goes as a note.
if (ctx.memory !== undefined && !ctx.subagent) {
sections.push(promptText("blocks/memory.md"))
if (ctx.memory) sections.push(ctx.memory)
@@ -353,8 +361,7 @@ export function assembleSystem(ctx: PromptContext): string {
read_only: ctx.mode === "plan" ? SUBAGENT_READ_ONLY : "",
}),
)
else sections.push(fillPrompt(promptText(`modes/${ctx.mode}.md`), vars))
if (ctx.unattended) sections.push(fillPrompt(promptText("modes/unattended.md"), { ...vars, refused: ctx.unattended }))
else sections.push(modeText(ctx.mode, ctx.planDir, ctx.unattended))
// What the person said about themselves goes last, under the web UI's headings and in its order.
// Not for a subagent: it answers the main agent, not the person.
if (ctx.userInstructions && !ctx.subagent) sections.push(`## How the person you are talking to wants to be helped\n\n${ctx.userInstructions}`)
@@ -362,6 +369,14 @@ export function assembleSystem(ctx: PromptContext): string {
return sections.join("\n\n")
}
/** A permission mode's text (modes/<mode>.md), with the unattended block when nobody can approve
* (`refused`: what will be refused, in words): the prompt's own as it was made, and the body of a
* note when the mode changes later (session/prefix.ts). */
export function modeText(mode: Mode, planDir: string, refused?: string): string {
const own = fillPrompt(promptText(`modes/${mode}.md`), { plan_dir: planDir })
return refused ? `${own}\n\n${fillPrompt(promptText("modes/unattended.md"), { plan_dir: planDir, refused })}` : own
}
const GIT_QUESTION =
"do you commit on your own once a change is finished and checked, only when they ask, or never; and which branch the work goes on (the current one, or a new branch for each task)."
const GIT_QUESTION_NO_REPO = "this folder is not a git repository yet — should it be one, and if so, do you commit on your own once a change is finished and checked, or only when they ask."
+19 -3
View File
@@ -207,6 +207,10 @@ export class OpenAIChatClient implements Client {
const addText = (kind: "text" | "reasoning", text: string) => out.add(kind, text)
let finish = "stop"
let usage: StreamEvent | undefined
// llama.cpp's own count, in its last chunk: `timings` with `cache_n` (read from its cache) and
// `prompt_n` (read now). The cached part of a request when `usage` does not say it.
let timings: { cache: number; read: number; out?: number } | undefined
let progressCache: number | undefined
for await (const { data } of sseJson(stream)) {
const chunk = data as any
@@ -217,6 +221,9 @@ export class OpenAIChatClient implements Client {
// string code; statusOf() still reads it as the server error it is.
throw new ProviderError(`${m.ref}: ${String(msg).trim()}`, statusOf(err), undefined, !out.output)
}
const tm = chunk?.timings
if (tm && typeof tm === "object" && Number.isFinite(tm.cache_n))
timings = { cache: Number(tm.cache_n), read: Number(tm.prompt_n) || 0, ...(Number.isFinite(tm.predicted_n) ? { out: Number(tm.predicted_n) } : {}) }
if (chunk?.usage && typeof chunk.usage === "object") {
const u = chunk.usage
usage = {
@@ -232,8 +239,10 @@ export class OpenAIChatClient implements Client {
// llama.cpp, while it reads the prompt: { total, cache, processed, time_ms }. Anything not
// shaped like that is ignored, never an error.
const pp = chunk?.prompt_progress
if (pp && typeof pp === "object" && Number.isFinite(pp.total) && pp.total > 0 && !out.output)
yield { type: "progress", total: Number(pp.total), cache: Number(pp.cache) || 0, processed: Number(pp.processed) || 0, ms: Number(pp.time_ms) || 0 }
if (pp && typeof pp === "object" && Number.isFinite(pp.total) && pp.total > 0 && !out.output) {
progressCache = Number(pp.cache) || 0
yield { type: "progress", total: Number(pp.total), cache: progressCache, processed: Number(pp.processed) || 0, ms: Number(pp.time_ms) || 0 }
}
const choice = chunk?.choices?.[0]
if (!choice) continue
const delta = choice.delta ?? choice.message ?? {}
@@ -272,9 +281,16 @@ export class OpenAIChatClient implements Client {
if (calls.length && finish !== "length") finish = "tool_calls"
const message = out.message()
const parts = message.parts
// The cached input tokens (harness context.md §10 rule 6): the usage's own field
// (`prompt_tokens_details.cached_tokens`, or `cache_read_input_tokens` where an Anthropic-shaped
// count comes through), else llama.cpp's timings, else its last progress report.
const cached = timings?.cache ?? progressCache
if (usage?.type === "usage" && usage.usage.cached === undefined && cached !== undefined) usage.usage.cached = cached
if (!usage && timings?.out !== undefined)
usage = { type: "usage", usage: { input: timings.cache + timings.read, output: timings.out, cached: timings.cache } }
if (!usage) {
const out = parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.name + p.args : p.text), 0)
usage = { type: "usage", usage: { input: estimateValue(body.messages), output: out, estimated: true } }
usage = { type: "usage", usage: { input: estimateValue(body.messages), output: out, estimated: true, ...(cached !== undefined ? { cached } : {}) } }
}
yield usage
yield { type: "finish", reason: finish, message }
+10 -1
View File
@@ -41,7 +41,7 @@ export function contextBreakdown(engine: Engine, system: string, instructions: {
if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images })
const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0)
const reported = engine.usage ? engine.contextUsed() : undefined
return { rows, estimated, reported, window: contextWindow(engine.model), model: engine.model.ref }
return { rows, estimated, reported, window: contextWindow(engine.model), model: engine.model.ref, last: engine.lastRequest }
}
/** Plain text, for the transcript. */
@@ -58,5 +58,14 @@ export function renderBreakdown(b: ReturnType<typeof contextBreakdown>): string
const used = b.reported ?? b.estimated
lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${bar(used)}${b.window ? ` ${Math.round((used / b.window) * 100)}%` : ""}`)
lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since)" : " (estimated: ≈4 characters a token — no reply yet to count from)")
// What the server reused of the last request (harness context.md §10 rule 6): a slow reply with
// little of it cached was a re-read.
const last = b.last?.usage
if (last && !last.estimated)
lines.push(
last.cached !== undefined
? ` last request${b.last!.kind === "compaction" ? " (compaction)" : ""}: ${fmt(last.input)} in, ${fmt(last.cached)} of it cached${last.input ? ` (${Math.round((last.cached / last.input) * 100)}%)` : ""}`
: ` last request: ${fmt(last.input)} in — the server did not say how much was cached`,
)
return lines.join("\n")
}
+222 -65
View File
@@ -4,6 +4,7 @@
// backstop, not a budget (LLeMbas generation._run / _wrap_up).
// The text is the harness spec's, so LLeMbas's Continue sends the same words.
import continueText from "../../harness/prompts/tasks/continue.md" with { type: "text" }
import { isAbsolute } from "node:path"
import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts"
import type { Effort, Mode } from "../config/schema.ts"
import { evaluate, shadowsDeny, type Context as PermContext, type Rule } from "../permission/evaluate.ts"
@@ -12,13 +13,15 @@ import type { Plan } from "../plan/plan.ts"
import { LOOP } from "../harness.ts"
import { fillPrompt } from "../prompt/fill.ts"
import { splitPurpose, toSpec, ToolError, type Tool, type ToolContext, type ToolResult } from "../tool/tool.ts"
import { fill, promptText } from "../prompt/assemble.ts"
import { promptText } from "../prompt/assemble.ts"
import { resolveCall } from "../tool/names.ts"
import { contextWindow } from "../provider/index.ts"
import { summaryTurns, type Store } from "./store.ts"
import { estimateTokens, estimateValue } from "../provider/tokens.ts"
import { duration } from "../duration.ts"
import { memoryResults, presentIds, ResultContext } from "./results.ts"
import { render } from "../plan/plan.ts"
import { compactAsk, isNote, NO_CHANGING, noteFor, offeredFor, PREFIX_META, summaryOf, today, toldAt, unavailable, type Changing, type Kept, type Now, type Requirement, type Told } from "./prefix.ts"
export interface EngineOptions {
bus: Bus
@@ -28,8 +31,20 @@ export interface EngineOptions {
tools: Tool[]
store?: Store
sessionId?: string
/** The system prompt for a step; `plan`: the session's plan, for `{{plan_context}}`. */
system: (mode: Mode, model: ResolvedModel, plan?: Plan) => string
/** The session's system prompt, made from `changing` (what may change during the session, as it
* is now); `plan`: the session's plan, for `{{plan_context}}`. Called once per session, after a
* compaction and on a model switch — never per request (harness context.md §10). */
system: (mode: Mode, model: ResolvedModel, plan: Plan | undefined, changing: Changing) => string
/** What may change during the session, as it is now: compared at every user message, and a
* difference said in a note. Absent: nothing (a subagent's prompt holds none of it). */
changing?: () => Changing
/** The text a note gives for a permission mode (modes/<mode>.md as the prompt fills it). */
modeText?: (mode: Mode) => string
/** Awaited before a prompt is made at a turn's start: the app waits up to loop.json
* `context.mcp_wait_ms` for its MCP servers. */
beforeFirst?: () => Promise<void>
/** Today's date, as the note says it (tests set it). */
today?: () => string
mode: Mode
effort: Effort | null
perm: Omit<PermContext, "mode">
@@ -126,8 +141,9 @@ export class Engine {
plan: Plan | undefined
private sessionRules: Rule[] = []
private recentCalls: string[] = []
/** Tools refused with no one able to approve them: no longer offered. */
private withdrawn = new Set<string>()
/** Tools refused with no one able to approve them, and the refusal: still listed (the tool list
* is the session's, harness context.md §10), and every further call answers that refusal. */
private withdrawn = new Map<string, string>()
/** This prompt's spending, against `budget`: time waiting for the user, tool output, tokens written. */
private turnWaitedMs = 0
private turnOutputBytes = 0
@@ -140,6 +156,16 @@ export class Engine {
* (session/results.ts, harness context.md). */
results: ResultContext
/** The prefix (harness context.md §10): the system prompt this session sends, the tools left out
* of its list, and what the model has been told — made at the first request, kept with the
* session, made again only after a compaction or on a model switch. */
private kept: Kept | undefined
/** What was told when the prompt was made: where a rewind (/undo) goes back to, since the notes
* after it may be gone from the conversation. */
private keptBase: Told | undefined
/** The last request's usage, cached input tokens included, and what it was for. */
lastRequest: { usage: Usage; kind: "step" | "compaction" } | undefined
constructor(readonly o: EngineOptions) {
this.mode = o.mode
this._effort = o.effort
@@ -148,6 +174,121 @@ export class Engine {
this.sessionId = o.sessionId
this.plan = this.storedPlan()
this.results = this.resultsFor(o.sessionId)
this.kept = this.storedPrefix()
}
/** The prefix the store holds for this session, if any: a resumed session sends what it kept. */
private storedPrefix(): Kept | undefined {
if (!this.o.store || !this.sessionId) return undefined
const k = this.o.store.meta<Kept | null>(this.sessionId, PREFIX_META)
if (!k || typeof k !== "object" || typeof k.system !== "string" || !Array.isArray(k.left) || !k.told) return undefined
this.keptBase = { ...structuredClone(k.told), date: undefined }
return k
}
private saveKept() {
if (this.o.store && this.sessionId) this.o.store.setMeta(this.sessionId, PREFIX_META, this.kept ?? null)
}
/** What could ever be met in this session (§10 rule 4), decided as the prompt is made: plan mode
* always can be entered; vision, a project directory, knowledge with documents and a display
* are as they are now, for the whole session. */
private could(): Set<Requirement> {
const could = new Set<Requirement>(["plan"])
if (this.model.spec.vision === true) could.add("vision")
if (this.o.toolCtx.projectDir !== undefined) could.add("project")
if (this.o.toolCtx.show !== undefined) could.add("display")
const L = this.o.toolCtx.library
const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? []
if (have.some((b) => !L!.bases || L!.bases.includes(b))) could.add("knowledge")
return could
}
/** What is met now, for a call: the mode for plan, the display as it is; the rest are fixed. */
private met(): Set<Requirement> {
const met = this.could()
if (this.mode !== "plan") met.delete("plan")
if (this.o.toolCtx.show === undefined) met.delete("display")
return met
}
/** Everything the note compares, as it is now. */
private now(changing = this.o.changing?.() ?? NO_CHANGING): Now {
const { source: _source, ...rest } = changing
return { ...structuredClone(rest), date: (this.o.today ?? today)(), mode: this.mode, plan: this.plan ? render(this.plan) : "" }
}
/** The session's prefix, made now if there is none yet (or it was made for another model). */
prefix(): Kept {
if (this.kept && this.kept.model === this.model.ref) return this.kept
const changing = this.o.changing?.() ?? NO_CHANGING
const now = this.now(changing)
const could = this.could()
// Left out for the whole session: what it can never meet, and task where nothing can spawn.
// Everything else — MCP tools arriving as their servers connect among them — is offered.
const left = this.o.tools.filter((t) => (t.name === "task" && !this.o.spawn) || !offeredFor(t.requires ? [t.requires] : undefined, could)).map((t) => t.name)
const told = toldAt(now)
// A model switch keeps the conversation, and with it the date already said.
if (this.kept?.told.date) told.date = this.kept.told.date
this.keptBase = { ...structuredClone(told), date: undefined }
this.kept = { system: this.o.system(this.mode, this.model, this.plan, changing), left, model: this.model.ref, told }
this.saveKept()
return this.kept
}
/** A system prompt as it would be made now, kept nowhere (what /context and tests look at
* before a session has made its own). */
compose(mode: Mode = this.mode, plan: Plan | undefined = this.plan): string {
return this.o.system(mode, this.model, plan, this.o.changing?.() ?? NO_CHANGING)
}
/** The system prompt this session sends (made now if it has none yet). */
systemPrompt(): string {
return this.prefix().system
}
/** The tools a request offers: the session's list, the same on every request — the wrap-up's
* included, and a tool withdrawn after a final refusal too (it answers that refusal). */
private toolSpecs() {
const left = new Set(this.prefix().left)
return this.o.tools.filter((t) => !left.has(t.name)).map(toSpec)
}
/** The note for the next user message (§10 rule 3), "" when nothing changed; what it tells is
* the model's from then on. */
private takeNote(): string {
const kept = this.prefix()
const { note, told } = noteFor(kept.told, this.now(), (m) => this.o.modeText?.(m) ?? fillPrompt(promptText(`modes/${m}.md`), { plan_dir: this.o.toolCtx.planDir ?? "" }))
if (note) {
kept.told = told
this.saveKept()
}
return note
}
/** Something the model learnt from its own tool's result (a plan it wrote, the mode its
* plan_submit set, a memory or file it changed): told, so no note says it again. */
private learnt(fields: Partial<Told>) {
if (!this.kept) return
Object.assign(this.kept.told, structuredClone(fields))
this.saveKept()
}
/** The conversation was cut back (/undo, a rewind): the notes after the cut may be gone, so what
* the model knows is the prompt again, and the next user message says the rest. */
rewound() {
if (!this.kept || !this.keptBase) return
this.kept.told = structuredClone(this.keptBase)
this.saveKept()
}
/** A request's usage, kept with the session (cached input tokens included, §10 rule 6). */
private recordUsage(usage: Usage, kind: "step" | "compaction") {
this.lastRequest = { usage, kind }
if (this.o.store && this.sessionId)
try {
this.o.store.request(this.sessionId, { model: this.model.ref, kind, ...usage })
} catch {}
}
/** A session's results: kept with it in the store, or in memory without one. A stub may point
@@ -164,10 +305,12 @@ export class Engine {
}
/** Replace the plan (null clears it): kept with the session, and said on the bus so every
* front end's card follows. */
setPlan(plan: Plan | null) {
* front end's card follows. `by`: the model's own tool, whose result shows it the plan; a
* change by the person reaches the model as a note on the next user message. */
setPlan(plan: Plan | null, by: "person" | "model" = "person") {
this.plan = plan ?? undefined
if (this.o.store && this.sessionId) this.o.store.setMeta(this.sessionId, PLAN_META, plan)
if (by === "model") this.learnt({ plan: this.plan ? render(this.plan) : "" })
this.o.bus.emit({ type: "plan", plan: this.plan })
}
@@ -182,7 +325,8 @@ export class Engine {
this.o.bus.emit({ type: "model", ref: this.model.ref, effort: v ?? "off", connection: this.model.connectionName })
}
/** Switch model mid-session. The conversation carries over; the effort resets to the new model's. */
/** Switch model mid-session. The conversation carries over; the effort resets to the new model's.
* The prompt is made again for it at the next request (prefix(): it was made for another). */
setModel(model: ResolvedModel, client: Client, effort: Effort | null) {
const changed = model.ref !== this.model.ref || effort !== this._effort
this.model = model
@@ -201,8 +345,12 @@ export class Engine {
this.withdrawn.clear()
this.results = this.resultsFor(sessionId)
// A new session's plan is whatever the store holds for it: none for a fresh one, the old one
// on a resume.
// on a resume; its prefix likewise.
this.plan = this.storedPlan()
this.kept = undefined
this.keptBase = undefined
this.lastRequest = undefined
this.kept = this.storedPrefix()
this.o.bus.emit({ type: "plan", plan: this.plan })
this.o.toolCtx.readFiles.clear()
this.o.toolCtx.fileStamps.clear()
@@ -227,17 +375,17 @@ export class Engine {
}
}
/** The summary, asked for as the next turn of this same conversation (harness context.md §7):
* the same system prompt and tools, every message as it is, then tasks/compact.md with an empty
* transcript and previous summary. Nothing of it is shown or stored as a turn; a tool call in the reply is not run. */
private async summarise(signal: AbortSignal): Promise<string> {
const previous = this.messages[0]?.role === "user" && this.messages[0].parts[0]?.type === "text" && this.messages[0].parts[0].text.startsWith("The earlier part of this conversation was compacted")
? this.messages[0].parts[0].text
: ""
const prompt = fill(promptText("tasks/compact.md"), {
previous_summary: previous ? `## Summary of even earlier turns\n\n${previous}` : "",
transcript: transcript(this.messages, 60_000),
})
// No previous summary in the ask: an earlier one is already the conversation's first message.
const ask: Message = { role: "user", parts: [{ type: "text", text: compactAsk() }] }
let summary = ""
for await (const ev of this.client.stream({ system: "", messages: [{ role: "user", parts: [{ type: "text", text: prompt }] }], tools: [], effort: null, signal }))
if (ev.type === "finish") summary = ev.message.parts.filter((p) => p.type === "text").map((p) => (p as { text: string }).text).join("").trim()
for await (const ev of this.client.stream({ system: this.systemPrompt(), messages: mergeUsers([...this.messages, ask]), tools: this.toolSpecs(), effort: this.effort, signal })) {
if (ev.type === "finish") summary = summaryOf(ev.message.parts)
else if (ev.type === "usage") this.recordUsage(ev.usage, "compaction")
}
if (!summary) throw new Error("the model returned an empty summary")
// The harness's own table of the reads still in the conversation (context.md §7), after the
// summary; the conversation is then the summary alone, so no stub can point at a result before it.
@@ -247,6 +395,11 @@ export class Engine {
this.messages = summaryTurns(summary)
this.usage = undefined
this.usageAt = 0
// Everything is read again now anyway: the prompt is made again at the next request, and the
// next user message says the date again (what said it is in the summary, if anywhere).
this.kept = undefined
this.keptBase = undefined
this.saveKept()
// Said on the bus: the web UI marks the point in a shared session, as the TUI does.
this.o.bus.emit({ type: "compacted", summary })
return summary
@@ -293,14 +446,18 @@ export class Engine {
await this.summarise(signal)
// The task is never left to the summary: the prompt that started this turn comes back word
// for word (found on a real run, where a small model's summary lost the task entirely).
const original = this.turnPrompt?.role === "user" ? this.turnPrompt.parts : []
// Its note is not: what it said may be out of date, and the new prompt's first user message
// gets a note of its own.
const original = this.turnPrompt?.role === "user" ? this.turnPrompt.parts.filter((p) => p.type !== "text" || !isNote(p.text)) : []
const steers = this.turnSteers.map((s) => ({ type: "text" as const, text: `(Sent while you were working:) ${s}` }))
const note = this.takeNote()
this.push({
role: "user",
parts: [
...original,
...steers,
{ type: "text", text: "(The conversation was compacted to fit the context window; the summary above records what was done so far. This was the request — continue it from where the summary leaves off.)" },
...(note ? [{ type: "text" as const, text: note }] : []),
],
})
this.o.bus.emit({ type: "notice", message: "compacted — carrying on from the summary, with your request kept word for word" })
@@ -332,23 +489,6 @@ export class Engine {
return undefined
}
/** Tools a model can use at all: view_image only with vision, plan_submit only in plan mode
* (`requires: "plan"`; plan_write and plan_update are offered in every mode). */
private offered(name: string): boolean {
const t = this.o.tools.find((x) => x.name === name)
if (name === "task" && !this.o.spawn) return false
if (!t?.requires) return true
if (t.requires === "vision") return this.model.spec.vision === true
if (t.requires === "project") return this.o.toolCtx.projectDir !== undefined
if (t.requires === "display") return this.o.toolCtx.show !== undefined
if (t.requires === "knowledge") {
const L = this.o.toolCtx.library
const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? []
return have.some((b) => !L!.bases || L!.bases.includes(b))
}
return this.mode === "plan"
}
cancel() {
this.abort?.abort()
}
@@ -449,12 +589,21 @@ export class Engine {
const { bus } = this.o
this.abort = new AbortController()
const signal = this.abort.signal
this.push({ role: "user", parts: [{ type: "text", text }, ...extra.map((t) => (typeof t === "string" ? { type: "text" as const, text: t } : t))], ...(turnId ? { turnId } : {}), ...(machine ? { machine } : {}) })
// Before a session's first request: the configured MCP servers get a moment to connect, so
// their tools are in the first list (§10 rule 4).
if (!this.kept || this.kept.model !== this.model.ref) await this.o.beforeFirst?.()
// What changed since the prompt was made goes after the person's words, as a note (§10 rule 3).
const note = this.takeNote()
this.push({
role: "user",
parts: [{ type: "text", text }, ...extra.map((t) => (typeof t === "string" ? { type: "text" as const, text: t } : t)), ...(note ? [{ type: "text" as const, text: note }] : [])],
...(turnId ? { turnId } : {}),
...(machine ? { machine } : {}),
})
this.turnPrompt = this.messages[this.messages.length - 1]
this.turnSteers = []
this.autoCompactions = 0
const toolMap = new Map(this.o.tools.map((t) => [t.name, t]))
const allSpecs = this.o.tools.map(toSpec)
const maxSteps = this.o.maxSteps ?? LOOP.steps.default
const started = performance.now()
this.turnWaitedMs = 0
@@ -486,10 +635,13 @@ export class Engine {
// What was said before a stop: kept, so /continue picks up from there.
let partial = ""
try {
// The same system prompt and tool list on every request of the session (§10).
const stream = this.client.stream({
system: this.o.system(this.mode, this.model, this.plan),
system: this.systemPrompt(),
messages: mergeUsers(wrapUp ? [...this.messages, { role: "user", parts: [{ type: "text", text: wrapUpText(ranOut ? ranOut.limit : "step limit") }] }] : this.messages),
tools: wrapUp ? [] : allSpecs.filter((s) => !this.withdrawn.has(s.name) && this.offered(s.name)),
// The wrap-up too (loop.json wrap_up): its message says the tools are withdrawn, and a
// call in its reply is not run.
tools: this.toolSpecs(),
effort: this.effort,
signal,
})
@@ -533,8 +685,16 @@ export class Engine {
bus.emit({ type: "done", reason: "error" })
return "error"
}
// The wrap-up's answer is its text before any call: a call there is not run, and none is kept
// without a result to go with it.
if (wrapUp && assistant.parts.some((p) => p.type === "tool_call")) {
const first = assistant.parts.findIndex((p) => p.type === "tool_call")
assistant = { ...assistant, parts: assistant.parts.slice(0, first) }
bus.emit({ type: "notice", message: "the wrap-up reply called a tool; it was not run" })
}
this.push(assistant)
if (usage) {
this.recordUsage(usage, "step")
this.turnCompletion += usage.output
// Counted once the reply is in the history, so the reply itself is not estimated again.
this.usage = usage
@@ -608,11 +768,13 @@ export class Engine {
question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id)) : undefined,
plan: {
get: () => this.plan,
set: (p) => this.setPlan(p),
set: (p) => this.setPlan(p, "model"),
ask: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id)) : undefined,
},
setMode: (m) => {
this.mode = m
// Its own plan_submit's result tells the model the mode.
this.learnt({ mode: m })
bus.emit({ type: "mode", mode: m })
},
spawn: this.o.spawn,
@@ -647,6 +809,16 @@ export class Engine {
)
}
this.bgNow = undefined
// A memory or instruction file changed by this step's tools is the model's own doing, which
// their results showed it: no note says it again. Changed between turns, it is the person's.
if (runnable.some((r) => r.writes) && this.o.changing && this.kept) {
const now = this.now()
// Files by their path only: the person's own instructions and an instance's (named by a
// heading) are not something a tool of the model writes.
const told = this.kept.told.files
const files = Object.fromEntries([...Object.entries(told).filter(([k]) => !isAbsolute(k)), ...Object.entries(now.files).filter(([k]) => isAbsolute(k))])
this.learnt({ memory: now.memory, files, ...(runnable.some((r) => r.tool.name === "settings") ? { personality: now.personality } : {}) })
}
const images: ImagePart[] = []
for (const call of calls) {
const r = results.get(call.id) ?? { output: "Not run: the reply was cancelled.", isError: true }
@@ -702,6 +874,12 @@ export class Engine {
const resolved = resolveCall(call.name, raw)
tool = toolMap.get(resolved.name) ?? tool
raw = resolved.raw
// Offered for the whole session; while what it needs is not met, the spec's sentence (§10 rule 4).
const no = unavailable(tool.name, tool.requires ? [tool.requires] : undefined, this.met())
if (no) return { result: { output: no, isError: true } }
// Withdrawn after a final refusal: still listed, and the refusal is the answer.
const refused = this.withdrawn.get(tool.name)
if (refused !== undefined) return { result: { output: refused, isError: true }, denied: true }
// What the call is for goes to the user when they are asked; the tool never sees an added `purpose`.
const { args: own, purpose } = splitPurpose(tool, raw)
const parsed = tool.schema.safeParse(own)
@@ -746,15 +924,10 @@ export class Engine {
if (reply.kind === "deny" && withdraw) {
// Every tool behind the same permission goes: edit and write share "edit".
const siblings = this.o.tools.filter((t) => t.access === tool.access)
for (const t of siblings) this.withdrawn.add(t.name)
const gone = siblings.map((t) => t.name).join(", ")
return {
result: {
output: `Refused: ${reply.feedback ?? "nobody can approve this"} It will be refused every time in this session, so ${gone} ${gone.includes(",") ? "are" : "is"} no longer offered. Do not look for a way around it. Finish with what you can do without it, and say exactly what you would have done — the change, the command — so the user can do it.`,
isError: true,
},
denied: true,
}
const output = `Refused: ${reply.feedback ?? "nobody can approve this"} It will be refused every time in this session: every further call to ${gone} answers this same refusal. Do not look for a way around it. Finish with what you can do without it, and say exactly what you would have done — the change, the command — so the user can do it.`
for (const t of siblings) this.withdrawn.set(t.name, output)
return { result: { output, isError: true }, denied: true }
}
if (reply.kind === "deny") {
const output = reply.feedback
@@ -790,19 +963,3 @@ export class Engine {
return { tool, args, paths: request.command === undefined ? (request.paths ?? []) : [], writes: request.class !== "read", edited }
}
}
/** The conversation as plain text for the summariser, newest kept when it is too long. */
export function transcript(messages: Message[], maxChars: number): string {
const lines: string[] = []
for (const m of messages) {
if (m.role === "tool") lines.push(`[${m.name} result${m.isError ? ", error" : ""}]\n${m.content.slice(0, 2000)}`)
else
for (const p of m.parts) {
if (p.type === "text") lines.push(`${m.role === "user" ? "User" : "Assistant"}: ${p.text}`)
else if (p.type === "tool_call") lines.push(`[assistant called ${p.name} ${p.args.slice(0, 400)}]`)
}
}
const all = lines.join("\n\n")
return all.length <= maxChars ? all : "[…earlier turns cut…]\n\n" + all.slice(-maxChars)
}
+155
View File
@@ -0,0 +1,155 @@
// The prefix (harness spec 3.1.0, context.md §10): what the model has already read stays exactly as
// it was, so its server can reuse it. The system prompt and the tool list are made once per session
// and resent byte for byte; what changes during the session goes as a note on the next user
// message. This file holds the rules and the texts, without the engine: what the model was last
// told, what a note says about the difference, which tools are offered, what a call answers when
// its requirement is not met, and what compaction asks for. The engine keeps the state
// (session/engine.ts); the conformance area `prefix` drives these functions directly.
import type { Mode } from "../config/schema.ts"
import { LOOP, TOOL_SPECS } from "../harness.ts"
import { fillPrompt } from "../prompt/fill.ts"
import { fill, promptText } from "../prompt/assemble.ts"
/** The note's texts (loop.json `context.note`). */
export const NOTE = LOOP.context.note
/** What a note says in place of something that is now empty: no plan, no memory, a file removed. */
export const NOTHING = NOTE.empty
/** The session_meta key the prefix is kept under (the prompt, the tools left out, what was told). */
export const PREFIX_META = "prefix"
/** What the system prompt holds that can change during a session: the app reads it as it is now,
* builds the prompt from it, and the engine compares it at every user message. Each is "" (or
* empty) where there is none. */
export interface Changing {
/** The memory block as the prompt shows it. */
memory: string
/** The personality as the prompt shows it. */
personality: string
/** Instruction texts by what names them: an instruction file by its path; the person's own
* instructions and an instance's by their heading. */
files: Record<string, string>
/** MCP servers' instructions, by server. */
mcp: Record<string, string>
/** What the app needs to make the prompt from these same values; never compared or kept. */
source?: unknown
}
export const NO_CHANGING: Changing = { memory: "", personality: "", files: {}, mcp: {} }
/** What the model was last told of each: by the system prompt when it was made, by a note since, or
* by its own tool's result (a plan it wrote, a mode its plan_submit set). `date` is absent until a
* note has said it. */
export interface Told extends Changing {
date?: string
mode: Mode
/** The plan as rendered (plan.ts `render`), or "". */
plan: string
}
/** What is true now, against which a Told is compared. */
export interface Now extends Changing {
date: string
mode: Mode
plan: string
}
/** The prefix a session keeps (session_meta `prefix`): the prompt it sends, the tools left out of
* its list for the whole session (every other tool is offered, MCP tools as their servers
* connect), the model it was made for, and what the model has been told. */
export interface Kept {
system: string
left: string[]
model: string
told: Told
}
/** Told as of a prompt made now: everything as it is, except the date, which no prompt holds. */
export function toldAt(now: Now): Told {
const { date: _date, source: _source, ...rest } = now
return structuredClone(rest)
}
/** The note for a user message: a line for each thing that differs from what the model was told,
* in loop.json's order, inside the note's tags — "" when nothing does. `modeText` is the mode's
* text (modes/<mode>.md, filled as the prompt fills it). Returns what is told after it. */
export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string): { note: string; told: Told } {
const lines: string[] = []
const say = (text: string, vars: Record<string, string>) => lines.push(fillPrompt(text, vars))
const or = (s: string) => (s.trim() ? s : NOTHING)
if (told.date !== now.date) say(NOTE.date, { date: now.date })
if (told.mode !== now.mode) say(NOTE.mode, { mode: now.mode, mode_text: modeText(now.mode) })
if (told.plan !== now.plan) say(NOTE.plan, { plan: or(now.plan) })
if (told.memory !== now.memory) say(NOTE.memory, { memory: or(now.memory) })
for (const path of union(told.files, now.files))
if (told.files[path] !== now.files[path]) say(NOTE.instructions, { path, text: or(now.files[path] ?? "") })
if (told.personality !== now.personality) say(NOTE.personality, { personality: or(now.personality) })
// Only a server that has connected or changed since: one that went away takes its tools with it.
const arrived = Object.keys(now.mcp).filter((name) => told.mcp[name] !== now.mcp[name])
if (arrived.length) say(NOTE.mcp, { instructions: arrived.map((name) => mcpBlock(name, now.mcp[name]!)).join("\n") })
return { note: lines.length ? noteBlock(lines) : "", told: { ...toldAt(now), date: now.date } }
}
/** The block a note is: its lines between the tags, a blank line between two. */
export function noteBlock(lines: string[]): string {
return `${NOTE.open}\n${lines.join("\n\n")}\n${NOTE.close}`
}
/** Whether a text part of a user message is a note (not shown as the person's words). */
export function isNote(text: string): boolean {
return text.startsWith(NOTE.open) && text.trimEnd().endsWith(NOTE.close)
}
/** One MCP server's instructions as the prompt and a note show them. */
export function mcpBlock(name: string, text: string): string {
return `<server name="${name}">\n${text}\n</server>`
}
function union(a: Record<string, string>, b: Record<string, string>): string[] {
return [...new Set([...Object.keys(a), ...Object.keys(b)])]
}
/** Today's date where this runs, as the note says it (YYYY-MM-DD). */
export function today(at = new Date()): string {
const p = (n: number) => String(n).padStart(2, "0")
return `${at.getFullYear()}-${p(at.getMonth() + 1)}-${p(at.getDate())}`
}
/** A requirement a tool can have (the spec's `requires`, and the CLI's own: vision, project,
* knowledge). */
export type Requirement = "vision" | "plan" | "project" | "knowledge" | "display" | "page" | "forge"
/** Rule 4: the tool list is fixed for the session. A tool is offered when every requirement it has
* is one the session could meet at some point (`could`), decided once as the prompt is made; a
* call while one is not met now answers `unavailable`. */
export function offeredFor(requires: readonly Requirement[] | undefined, could: ReadonlySet<Requirement>): boolean {
return (requires ?? []).every((r) => could.has(r))
}
/** What a call answers when a requirement of its tool is not met now: the spec's text, word for
* word, or undefined when it is met (or the tool has no such text). */
export function unavailable(tool: string, requires: readonly Requirement[] | undefined, met: ReadonlySet<Requirement>): string | undefined {
if ((requires ?? []).every((r) => met.has(r))) return undefined
return TOOL_SPECS[tool]?.unavailable
}
/** §7: what the summary is asked with, as one more user message after the conversation —
* tasks/compact.md with `{{transcript}}` and `{{previous_summary}}` empty, since the model has the
* conversation, an earlier summary included, itself. */
export function compactAsk(): string {
return fill(promptText("tasks/compact.md"), { previous_summary: "", transcript: "" })
.replace(/\n{3,}/g, "\n\n")
.trim()
}
/** The summary in a reply to the compaction request: its text before any tool call (a call in it
* is not run), "" when there is none. */
export function summaryOf(parts: { type: string; text?: string }[]): string {
const first = parts.findIndex((p) => p.type === "tool_call")
return (first < 0 ? parts : parts.slice(0, first))
.filter((p) => p.type === "text")
.map((p) => p.text ?? "")
.join("")
.trim()
}
+30 -2
View File
@@ -2,7 +2,8 @@ import { Database } from "bun:sqlite"
import { mkdirSync } from "node:fs"
import { join } from "node:path"
import { paths } from "../config/paths.ts"
import type { Message } from "../provider/types.ts"
import type { Message, Usage } from "../provider/types.ts"
import { isNote } from "./prefix.ts"
// Sessions and their messages, in ~/.local/share/lembas/sessions.db. Message text is also
// indexed with FTS5 so past sessions can be searched (session_search).
@@ -53,6 +54,11 @@ CREATE TABLE IF NOT EXISTS results (
json TEXT NOT NULL, text TEXT NOT NULL,
PRIMARY KEY (session_id, id)
);
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
created INTEGER NOT NULL, model TEXT NOT NULL, kind TEXT NOT NULL,
input INTEGER NOT NULL, output INTEGER NOT NULL, reasoning INTEGER, cached INTEGER, estimated INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS session_meta (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (session_id, key)
@@ -70,7 +76,16 @@ export const CWD_META = "cwd"
export function plainText(m: Message): string {
if (m.role === "tool") return m.content
return m.parts.map((p) => (p.type === "text" || p.type === "reasoning" ? p.text : p.type === "tool_call" ? `${p.name} ${p.args}` : "")).join("\n")
// A session note is the harness's, not the person's words: not searched.
return m.parts.map((p) => (p.type === "text" ? (m.role === "user" && isNote(p.text) ? "" : p.text) : p.type === "reasoning" ? p.text : p.type === "tool_call" ? `${p.name} ${p.args}` : "")).join("\n")
}
/** One request's usage as the store keeps it (harness context.md §10 rule 6): what it was for — a
* step of a turn, or a compaction's summary — and the cached input tokens where the server said. */
export interface RequestRow extends Usage {
model: string
kind: "step" | "compaction"
created?: number
}
export interface Row {
@@ -225,6 +240,19 @@ export class Store {
}
}
/** Record one request's usage. */
request(sessionId: string, r: RequestRow) {
this.db
.query("INSERT INTO requests (session_id, created, model, kind, input, output, reasoning, cached, estimated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run(sessionId, Date.now(), r.model, r.kind, r.input, r.output, r.reasoning ?? null, r.cached ?? null, r.estimated ? 1 : 0)
}
/** A session's requests, oldest first. */
requests(sessionId: string): RequestRow[] {
const rows = this.db.query("SELECT created, model, kind, input, output, reasoning, cached, estimated FROM requests WHERE session_id = ? ORDER BY id").all(sessionId) as (Omit<RequestRow, "estimated" | "reasoning" | "cached"> & { estimated: number; reasoning: number | null; cached: number | null })[]
return rows.map(({ estimated, reasoning, cached, ...r }) => ({ ...r, ...(reasoning !== null ? { reasoning } : {}), ...(cached !== null ? { cached } : {}), ...(estimated ? { estimated: true } : {}) }))
}
/** Something a session keeps besides its messages, as JSON. */
meta<T>(sessionId: string, key: string): T | undefined {
const r = this.db.query("SELECT value FROM session_meta WHERE session_id = ? AND key = ?").get(sessionId, key) as { value: string } | null
+3
View File
@@ -96,6 +96,7 @@ export class Turns {
this.snaps.restore(turn.before, files)
}
const messages = this.engine.messages.splice(turn.at)
this.engine.rewound()
if (this.store && this.engine.sessionId && turn.lastRow >= turn.firstRow) this.store.setKind(this.engine.sessionId, turn.firstRow, turn.lastRow, "reverted")
this.undone.push({ turn, current, messages })
return { prompt: turn.prompt, files }
@@ -129,6 +130,7 @@ export class Turns {
const first = dropped[0]!
const last = dropped[dropped.length - 1]!
this.engine.messages.splice(first.at)
this.engine.rewound()
if (this.store && sessionId && last.lastRow >= first.firstRow) this.store.setKind(sessionId, first.firstRow, last.lastRow, "reverted")
this.undone = []
return dropped.map((t) => t.turnId)
@@ -142,6 +144,7 @@ export class Turns {
this.store.setKind(sessionId, row, Number.MAX_SAFE_INTEGER, "reverted")
const at = this.engine.messages.findIndex((m) => m.role === "user" && m.turnId === turnId)
if (at >= 0) this.engine.messages.splice(at)
this.engine.rewound()
this.done = []
this.undone = []
return ids
+3 -2
View File
@@ -1,5 +1,6 @@
// plan_submit: the plan written with plan_write, presented for approval — plan mode only, where
// it is the approval. The plan itself is the session's (src/plan/plan.ts); a copy goes under the
// plan_submit: the plan written with plan_write, presented for approval — in plan mode, where it is
// the approval. Offered in every mode (spec 3.1.0, `requires: plan`): outside plan mode the engine
// answers the spec's `unavailable` text and nothing runs. The plan itself is the session's (src/plan/plan.ts); a copy goes under the
// project's plans directory for the record, since that is the one place plan mode may write.
import { mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
+3 -1
View File
@@ -7,6 +7,7 @@ import type { Message } from "../provider/types.ts"
import type { Row, SessionRow, Store } from "../session/store.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts"
import { isNote } from "../session/prefix.ts"
/** A user's words made safe for FTS5 MATCH: quoted phrases kept, operators' punctuation dropped,
* terms with dots or hyphens quoted (FTS5 would read them as syntax). */
@@ -35,7 +36,8 @@ export function readable(json: string): { role: string; text: string } {
const m = JSON.parse(json) as Message
if (m.role === "tool") return { role: "tool", text: `[${m.name} result, ${m.content.length} chars]` }
const text = m.parts
.map((p) => (p.type === "text" ? p.text : p.type === "tool_call" ? `[called ${p.name}]` : p.type === "image" ? "[image]" : ""))
// A session note is the harness's, not what was said.
.map((p) => (p.type === "text" ? (isNote(p.text) ? "" : p.text) : p.type === "tool_call" ? `[called ${p.name}]` : p.type === "image" ? "[image]" : ""))
.filter(Boolean)
.join("\n")
return { role: m.role, text }
+5 -3
View File
@@ -118,9 +118,11 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
access: string
/** Runs alone, never alongside other calls of the same step (it waits on the user). */
exclusive?: boolean
/** Offered only when this holds: a model with vision, plan mode, a project directory, knowledge
* bases with documents, or a display (a client over the link that shows files: the spec's
* `requires: display`). */
/** What the tool needs: a model with vision, plan mode, a project directory, knowledge bases with
* documents, or a display (a client over the link that shows files: the spec's `requires:
* display`). The tool list is the session's (harness context.md §10): one the session could
* never meet leaves the tool out; one that comes and goes (plan mode, a display) keeps it in, and
* a call while it is unmet answers the spec's `unavailable` text. */
requires?: "vision" | "plan" | "project" | "knowledge" | "display"
description: string
schema: S
+2 -1
View File
@@ -627,7 +627,8 @@ export const COMMANDS: Command[] = [
description: "where the context window goes",
run(c) {
const e = c.app.engine
const system = e.o.system(e.mode, e.model)
// The prompt the session sends (made now if no request has gone yet).
const system = e.systemPrompt()
const git = /<git>\n([\s\S]*?)\n<\/git>/.exec(system)?.[1]
c.view.info(renderBreakdown(contextBreakdown(e, system, instructionFiles(e.o.toolCtx.cwd, c.app.project.root, c.app.loaded.instructions), git, e.o.tools)))
},
+4 -1
View File
@@ -16,6 +16,7 @@ import type { JobInfo } from "../jobs/registry.ts"
import { splitPrototypes, titleFor, type PrototypeKind } from "../prototype/fence.ts"
import { savePrototype } from "../prototype/store.ts"
import { CHARS_PER_TOKEN } from "../provider/tokens.ts"
import { isNote } from "../session/prefix.ts"
export type Item =
/** `mid`: sent while the agent worked, and given to it at a step boundary. `machine`: the harness
@@ -121,7 +122,9 @@ export interface ViewState {
jobs: TrackedJob[]
}
const ATTACHMENT = /^<(file|directory|shell|image) /
// What the model was given beside the words typed — attachments, and a session note
// (session/prefix.ts) — is not shown as typed.
const ATTACHMENT = { test: (t: string) => /^<(file|directory|shell|image) /.test(t) || isNote(t) }
/** Running first, then by start — the registry's order. */
const byRunning = (a: JobInfo, b: JobInfo) => Number(b.state === "running") - Number(a.state === "running") || a.started - b.started
let seq = 0