Proactive context management: a task ledger, a searchable history and zero-summary window switches.

Claude Code mod(function hooks 插件):让长会话跨多个上下文窗口继续干活,而不靠模型写摘要。它给模型一本任务账本和一份可检索的完整历史,换窗口时由代码拼出交接信息,不调用模型。
状态:实验性,0.1.0。针对 Claude Code 2.1.29x 的 Mods API 开发和测试。给模型看的工具说明和交接文字目前是中文。
done 必须附上能证明它的工具结果编号;in_progress 必须写 next_step(省略时沿用该任务上一条)。协调者派来的任务可以用 request_ref 指向那条转交,它在交接首页单列为“派发来的任务”,和用户亲口提的请求分开。[#123] 这样的编号,模型之后可以按编号读原文,或按关键词、工具名、文件、窗口搜索,不用重跑同样的命令。工具参数全文存在历史行的 args 字段;超长的正文或参数另存为 big-*.txt,历史行里只留开头。依赖 cache-keeper(读它记录的缓存状态),两个都要装:
/plugin install cache-keeper --marketplace wdsewdser-bit/claude-code-mods
/plugin install ctx-mod --marketplace wdsewdser-bit/claude-code-mods
装好后默认什么都不做。只有启动 Claude Code 时带着环境变量 CTX_MOD=1,它才会注册工具并接管换窗:
# macOS / Linux
CTX_MOD=1 claude
# Windows PowerShell
$env:CTX_MOD = '1'; claude
| 工具 | 作用 |
|---|---|
context | status 报告上下文用量、距上次换窗的请求数、本小时换窗次数、进行中任务数;new 申请换窗口 |
ledger_append | 追加一条账本记录:task_id、title、status(in_progress / done / superseded / blocked)、text、next_step、anchors、evidence、request_ref |
ledger_read | 读账本:view 取 home(首页)、current(当前窗口的全部条目)或 archive:N(归档页);给了 query 就按词搜索 |
history | search 按词、角色、工具名、文件、窗口、入口(door)查历史编号。默认跳过引擎的背景行(附件、钩子上下文、备注),指定 door 就能搜到它们;不指定窗口时先搜最近 3 个窗口,没命中再往前找,结果里写明搜了哪些窗口。read 按编号读原文,field 填 input 时读那次工具调用的完整参数 |
mod 还会在系统提示里加一段说明,告诉模型这些工具怎么配合使用。
| 命令 | 作用 |
|---|---|
/ctx-mod | 显示是否启用和当前用量;未设 CTX_MOD=1 时只提示如何启用 |
/ctx-mod switch | 立即换到新窗口,不调用模型 |
默认按 40 万 token 的窗口算,到 75%、83%、88%(30 万、33.2 万、35.2 万)时分三级提醒模型先记账再换窗。故意放得晚:换窗会丢掉工作细节,所以尽量把一个窗口用完。
held.json 扣下,换完窗口再提交,因为反正要整段重写缓存。换窗期间新来的消息排队在后面。held.json 写不下时消息照常提交,不丢。进程中断或重新打开会话后遗留的扣留消息不会自动重发,只告诉用户。这些数字都在 hooks/lib/types.ts 的 DEFAULTS 里。
每个会话一个目录:~/.claude/ctx-mod/<session-id>/。跨会话的 ~/.claude/ctx-mod/costly.json 记录哪些工具组加载后会重建缓存,写入时用 costly.json.lock 加锁。
| 文件 | 内容 |
|---|---|
history-wNNN.jsonl | 第 N 个窗口的历史,一行一条 |
big-<编号>-<字段>-<n>.txt | 超长正文或工具参数的完整原文 |
ledger.jsonl | 当前这一页账本 |
archive-NNN.jsonl | 翻页归档的账本 |
handoff-wNNN.md | 进入第 N 个窗口时的交接信息原文 |
events.jsonl | 每次换窗的记录 |
meta.json | 编号、窗口号、文件活动、派发名单、重建记录等状态 |
held.json | 缓存过期时扣下、还没提交的用户消息 |
这些文件含有会话的全部内容,包括工具输出。mod 不会把它们发到任何地方。
保留期为 30 天。清理由独立脚本完成,mod 本身不删文件(引擎的文件接口没有删除操作):
# 先列出将被删除的目录(dry run,不删除)
powershell -File scripts\prune-sessions.ps1
# 确认后真正删除
powershell -File scripts\prune-sessions.ps1 -Apply
脚本只删数据根目录下 GUID 命名、最后写入早于 30 天的会话目录;根目录下的 costly.json 和 probe-*.jsonl 不动。它不会自动运行,需要你手动执行,或自行配置计划任务。
costly.json 跨会话表已加锁,但抢不到锁就放弃这次记录,仍是尽力而为;它影响加载前的拦截和交接提示。claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .
.claude-plugin/types/ 是引擎加载 mod 时生成的类型定义,不入库。从磁盘加载开发中的 mod:claude --plugin-dir <这个目录>。
hooks/register.ts 1272 lines1// The engine wiring: the hooks of ctx-mod. Everything that decides anything
2// lives under `lib/`; this file only observes the engine, hands the events to a
3// Session and answers. Phase 0's probe is kept under phase0/ as text: an event
4// takes one unmatched hook per module, so the two cannot be loaded together.
5
6import type { CommandRunResult, EngineInterface, Register, SessionCompactResult, SessionMessage } from 'claude-code'
7
8import { formatArgs, isBackgroundRow, nonTextNote } from './lib/history.ts'
9import { createSession, isHandoffText, looksLikePerson, stripInjected } from './lib/session.ts'
10import type { AgentNote, Env, ImportedMessage, Io, Session } from './lib/session.ts'
11import { describeFill, isCold, limitOf, shouldContinue } from './lib/usage.ts'
12import { DEFAULTS, OWN_TOOL_PREFIX } from './lib/types.ts'
13
14/** The section of the system prompt, the same all session long. */
15const SYSTEM_TEXT =
16 '这个会话可能跨多个上下文窗口。你有一个任务账本(mcp__ctx-mod__ledger_append、mcp__ctx-mod__ledger_read)和完整历史(mcp__ctx-mod__history)。工具结果末尾形如 [#123] 的是历史编号。每完成一个阶段,就追加一条账本记录:用 task_id 标识任务,status 填 in_progress / done / superseded / blocked。标 done 时必须在 evidence 里附上证明它的工具结果的历史编号。用户提出新请求时,如果它取代了旧任务,就把旧任务标为 superseded。对还没做完的任务,写明 next_step,并用 anchors 记下接下来要改的位置:文件路径,加一小段能在文件里唯一定位的原文。收到收尾或交接通知时,先更新账本。上下文用量可以用 mcp__ctx-mod__context 查看;它提示可以换窗口时,先记账再调用它的 new。申请换窗口之前,先用 ledger_append 把进行中任务的进度写清楚,text 里写明后面要用到的关键事实(产物路径、已确认的结论、约定的参数),有子代理在跑时写明各自的 id、负责什么、产物放哪。'
17
18/** Submitted after a switch the model itself asked for, so the new window carries on. */
19const CONTINUE_TEXT = 'ctx-mod:已换到新的上下文窗口。先用一句话复述当前请求和进度,然后继续。'
20
21const COMMAND = 'ctx-mod'
22const PREFIX = OWN_TOOL_PREFIX
23const KEEPER_CACHE = { plugin: 'cache-keeper', key: 'cache' } as const
24
25/** How many main-thread tool calls are kept, to name a result's tool and input. */
26const CALLS_KEPT = 200
27
28/** A content block, structurally the engine's ApiContentBlock (which `claude-code` does not export). */
29type Block = { type: string; [field: string]: unknown }
30
31/** Whether the module acts at all: `CTX_MOD=1`, read at `session.start`. */
32let enabled = false
33
34/**
35 * Why a window switch is under way, while one is: `'model'` (the model asked),
36 * `'cold'` (a cold-cache prompt was held back), `'manual-switch'` (a person ran
37 * `/ctx-mod switch`), null (a person's own compaction).
38 */
39let switchReason: string | null = null
40/** Set by `session.compact` when this mod answered a compaction, so `turn.complete` sees it. */
41let committed = false
42/** Whether a held-prompt switch is under way: later prompts queue behind it instead of starting another. */
43let isHoldingFlow = false
44/** The next main-thread request is the first of a new window: note its prefix. */
45let awaitingPrefix = false
46// The main-loop step last counted as a request, so the same step seen twice counts once.
47let lastStep: { turnId: string; index: number } | null = null
48
49/** One Session per session id, over the files under that session's own directory. */
50const sessions = new Map<string, Session>()
51/** The compaction limit per session, computed once; a key present means it was asked for. */
52const limits = new Map<string, number | undefined>()
53/** The main thread's recent tool calls, by tool_use_id, to name a result's tool and input. */
54// Prompts a person submitted and the conversation has not stored yet. The row
55// the engine then appends does not always say who sent it (a headless
56// session's own prompt arrives there as `unclassified`), so the submission,
57// which does, is matched to its row by text.
58const personTexts: string[] = []
59// The last background row filed per door/name, so an unchanged repeat in the same window is not filed again.
60const lastBackground = new Map<string, { window: number; text: string }>()
61// An automatic compaction overtook a switch the model had been granted: the
62// window is switched already, and the turn's end still owes the prompt to go on.
63let continueAfterTurn = false
64const calls = new Map<string, { tool: string; input: Record<string, unknown> }>()
65
66const messageOf = (problem: unknown): string =>
67 problem instanceof Error ? problem.message : String(problem)
68
69const attempt = async <T>(run: () => Promise<T>, fallback: T): Promise<T> => {
70 try {
71 return await run()
72 } catch {
73 return fallback
74 }
75}
76
77/** A failing hook logs and then walks the engine's own path, never the model's. */
78const logError = ($: EngineInterface, where: string, problem: unknown): void => {
79 try {
80 $.ui.log(`ctx-mod: ${where}: ${messageOf(problem)}`, { to: 'debug' })
81 } catch {
82 // A log that fails must not become the failure.
83 }
84}
85
86const idOf = async ($: EngineInterface): Promise<string | undefined> =>
87 attempt(async () => await $.session.id(), undefined)
88
89/** The longest input summary one history row carries, in characters. */
90const MAX_BRIEF_CHARS = 300
91
92/** How much of a SendMessage body a summary falls back to. */
93const MESSAGE_HEAD = 120
94
95const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
96
97/**
98 * A one-line summary of what a tool call was asked to do, so a later window can
99 * find the call by its command or pattern instead of running the same probe
100 * again. Only the tools whose result text never spells the call out are covered:
101 * Read, Edit and Write already carry their path on the row.
102 */
103export const briefOf = (tool: string, input: Readonly<Record<string, unknown>>): string | undefined => {
104 const str = (key: string): string | undefined => {
105 const value = input[key]
106
107 return typeof value === 'string' ? value : undefined
108 }
109 const clip = (text: string): string => {
110 const flat = oneLine(text)
111
112 return flat.length > MAX_BRIEF_CHARS ? flat.slice(0, MAX_BRIEF_CHARS) : flat
113 }
114
115 switch (tool) {
116 case 'Bash':
117 case 'PowerShell': {
118 const command = str('command')
119
120 return command === undefined ? undefined : clip(command)
121 }
122 case 'Grep':
123 case 'Glob': {
124 const pattern = str('pattern')
125 if (pattern === undefined) return undefined
126 const path = str('path')
127
128 return clip(path === undefined ? pattern : `${pattern} in ${path}`)
129 }
130 case 'Agent': {
131 const type = str('subagent_type')
132 const description = str('description')
133 if (type === undefined || description === undefined) return undefined
134
135 return clip(`${type}: ${description}`)
136 }
137 case 'SendMessage': {
138 const to = str('to')
139 if (to === undefined) return undefined
140 const body = str('summary') ?? str('message')
141
142 return clip(body === undefined ? `to ${to}` : `to ${to}: ${body.slice(0, MESSAGE_HEAD)}`)
143 }
144 case 'ToolSearch':
145 case 'WebSearch': {
146 const query = str('query')
147
148 return query === undefined ? undefined : clip(query)
149 }
150 case 'WebFetch': {
151 const url = str('url')
152
153 return url === undefined ? undefined : clip(url)
154 }
155 default:
156 return undefined
157 }
158}
159
160/**
161 * The session's live agents as the handoff lists them. A listing that throws is
162 * an empty one: a handoff without the subagents still beats no handoff at all.
163 */
164const agentsOf = async ($: EngineInterface): Promise<AgentNote[]> =>
165 attempt(
166 async () =>
167 (await $.agent.list()).map(info => ({
168 id: info.id,
169 type: info.type,
170 description: info.description,
171 state: info.status,
172 })),
173 [],
174 )
175
176/** A tool use's arguments as a plain record, whatever shape the engine stored. */
177const argsOfUse = (value: unknown): Readonly<Record<string, unknown>> =>
178 typeof value === 'object' && value !== null && !Array.isArray(value)
179 ? (value as Record<string, unknown>)
180 : {}
181
182/** One engine row as the lib's own shape; the tool name is `tool` there, not `name`. */
183const importedOf = (message: SessionMessage): ImportedMessage => ({
184 role: message.role,
185 text: message.text,
186 toolUses: message.toolUses.map(use => {
187 const brief = briefOf(use.tool, argsOfUse(use.input))
188 const args = formatArgs(argsOfUse(use.input))
189
190 return {
191 tool: use.tool,
192 input: use.input,
193 ...(typeof use.tool_use_id === 'string' && use.tool_use_id !== '' ? { callId: use.tool_use_id } : {}),
194 ...(args === '' ? {} : { args }),
195 ...(brief === undefined ? {} : { brief }),
196 ...(use.text === undefined ? {} : { text: use.text }),
197 ...(use.isError === true ? { isError: true as const } : {}),
198 }
199 }),
200})
201
202/**
203 * Copies the conversation the engine already holds into a session the mod is
204 * seeing for the first time: without it a session that predates the mod has
205 * nothing on disk to hand over.
206 */
207const importOldConversation = async ($: EngineInterface, session: Session): Promise<void> => {
208 try {
209 const imported = await session.importMessages((await $.session.messages()).map(importedOf))
210 if (imported > 0) $.ui.log(`ctx-mod: 补录了 ${imported} 条引擎对话里还没记录的行`, { to: 'debug' })
211 } catch (error) {
212 logError($, 'session.start messages', error)
213 }
214}
215
216/** The most characters of a held message a notice quotes. */
217const MAX_NOTICE_CHARS = 20_000
218
219/** How the person is told a held message never entered: the lead line, then their own words. */
220const unsentNotice = (lead: string, text: string): string =>
221 text.length <= MAX_NOTICE_CHARS
222 ? `${lead}${text}`
223 : `${lead}${text.slice(0, MAX_NOTICE_CHARS)}(后面还有 ${text.length - MAX_NOTICE_CHARS} 个字符没有显示)`
224
225const RESUBMIT_LEAD = 'ctx-mod:换窗口后没能重新提交你的消息,请重发:'
226const LEFTOVER_LEAD = 'ctx-mod:上次换窗口时扣下的一条消息没有送达,请重发:'
227
228/** Puts a system line in the conversation; false when it could not be shown. */
229const tellPerson = async ($: EngineInterface, text: string): Promise<boolean> => {
230 try {
231 const stored = await $.session.append({
232 message: { type: 'system', content: [{ type: 'text', text }] },
233 })
234
235 return stored.deny === undefined
236 } catch (error) {
237 logError($, 'prompt.submit notice', error)
238
239 return false
240 }
241}
242
243/**
244 * Messages a earlier run held back and never got to submit again (a reload cancelled the
245 * timer, or the process ended): told to the person, not re-sent, since how long ago they
246 * were typed is unknown.
247 */
248const announceLeftovers = async ($: EngineInterface, session: Session): Promise<void> => {
249 try {
250 for (const item of session.heldItems()) {
251 if (!(await tellPerson($, unsentNotice(LEFTOVER_LEAD, item.text)))) break
252 await session.release(item.id)
253 }
254 } catch (error) {
255 logError($, 'session.start held', error)
256 }
257}
258
259/**
260 * Submits the held messages again as the person's own, oldest first, until none is left (ones
261 * held during the switch are in the same run). A message that did not enter is shown to the
262 * person; when even that fails it stays on disk and the run stops, so it is not retried forever.
263 */
264const deliverHeld = async ($: EngineInterface, session: Session): Promise<void> => {
265 for (;;) {
266 const item = session.heldItems()[0]
267 if (item === undefined) return
268 let isDelivered = false
269 try {
270 const entered = await $.prompt.submit({ text: item.text, asUser: true })
271 isDelivered = !(typeof entered === 'object' && entered !== null && 'drop' in entered)
272 if (!isDelivered) logError($, 'prompt.submit resubmit', 'the re-submitted prompt was dropped')
273 } catch (error) {
274 logError($, 'prompt.submit resubmit', error)
275 }
276 // The person's words are gone unless the session says so.
277 if (!isDelivered && !(await tellPerson($, unsentNotice(RESUBMIT_LEAD, item.text)))) return
278 await session.release(item.id)
279 }
280}
281
282/** The one session of this conversation, opened on first use. */
283const sessionOf = async ($: EngineInterface): Promise<Session | undefined> => {
284 const id = await idOf($)
285 if (id === undefined) return undefined
286 const held = sessions.get(id)
287 if (held !== undefined) {
288 await held.ready().catch(() => undefined)
289
290 return held
291 }
292
293 const profile = await attempt(async () => await $.env.get('USERPROFILE'), undefined)
294 const raw =
295 typeof profile === 'string' && profile !== ''
296 ? profile
297 : await attempt(async () => await $.env.get('HOME'), undefined)
298 const home = typeof raw === 'string' ? raw.replace(/[\\/]+$/, '') : ''
299 // `$.fs` does not expand `~`: the data directory is built from the home path.
300 const dir = home === '' ? `.claude/ctx-mod/${id}` : `${home}/.claude/ctx-mod/${id}`
301 const io: Io = {
302 read: path => $.fs.read(path),
303 write: (path, text) => $.fs.write(path, text),
304 exists: path => $.fs.exists(path),
305 cwd: () => $.session.cwd(),
306 now: () => Date.now(),
307 // The names in a directory, for the cross-session measures: each session's own file is one of them.
308 list: async path => (await $.fs.list(path)).filter(entry => entry.kind === 'file').map(entry => entry.name),
309 onWriteError: (path, error) => logError($, `write ${path}`, error),
310 }
311 const session = createSession(dir, io, DEFAULTS)
312 sessions.set(id, session)
313 // The window, the ledger's freshness and the pending switch are read
314 // synchronously: nobody gets the session before its state is off the disk.
315 await session.ready().catch(() => undefined)
316 // Only a session that has just been opened can be behind: a cached one has
317 // seen every row through `session.append` already.
318 await importOldConversation($, session)
319 // Only on opening: a cached session's held messages belong to a switch still running.
320 await announceLeftovers($, session)
321
322 return session
323}
324
325/** The compaction threshold the engine reports, computed once per session. */
326const limitFor = async ($: EngineInterface, id: string): Promise<number | undefined> => {
327 if (!limits.has(id)) {
328 limits.set(
329 id,
330 await attempt(async () => {
331 const usage = await $.session.usage({ breakdown: 'summary' })
332
333 return limitOf(usage.context.breakdown?.autoCompactThreshold, DEFAULTS)
334 }, undefined),
335 )
336 }
337
338 return limits.get(id)
339}
340
341/** The figures a decision reads, each one degrading to nothing rather than throwing. */
342const envOf = async ($: EngineInterface, id: string): Promise<Env> => {
343 const tokens = await attempt(async () => (await $.session.usage()).context.tokens, undefined)
344 const fiveHourPercent = await attempt(
345 async () => (await $.session.usage()).rateLimits.find(limit => limit.kind === 'five_hour')?.percentUsed,
346 undefined,
347 )
348 const cache = await attempt(
349 async () => (await $.state.get(KEEPER_CACHE)).value ?? null,
350 null,
351 )
352
353 return {
354 tokens,
355 limit: (await limitFor($, id)) ?? limitOf(undefined, DEFAULTS),
356 cache,
357 fiveHourPercent,
358 }
359}
360
361/** A person's own prompt: typed, bridged or an SDK host's, never a notification. */
362// A tool call's arguments ride on `e` beside the engine's own keys: an argument
363// named like one of those would be read as the envelope's, so they are taken off.
364const ENVELOPE = ['tool', 'tool_use_id', 'consent', 'agentId', 'agent', 'requestMeta'] as const
365const argsOf = (e: unknown): Readonly<Record<string, unknown>> => {
366 const args: Record<string, unknown> = { ...(e as Record<string, unknown>) }
367 for (const key of ENVELOPE) delete args[key]
368
369 return args
370}
371
372const isPersonOrigin = (origin: { kind: string }): boolean =>
373 origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'sdk'
374
375/** A prompt this mod itself re-submitted as the person's own words. */
376const isOurUser = (origin: { kind: string }): boolean => {
377 const plugin = origin as { kind: string; name?: unknown; asUser?: unknown }
378
379 return plugin.kind === 'plugin' && plugin.name === COMMAND && plugin.asUser === true
380}
381
382const textOf = (content: readonly Block[]): string =>
383 content
384 .filter(block => block.type === 'text' && typeof block.text === 'string')
385 .map(block => String(block.text))
386 .join('')
387
388/** A tool_result block's text: the string itself, or its text blocks joined. */
389const resultText = (content: unknown): string => {
390 if (typeof content === 'string') return content
391 if (!Array.isArray(content)) return ''
392 const parts: string[] = []
393 for (const part of content) {
394 if (typeof part === 'object' && part !== null && typeof (part as Block).text === 'string') {
395 parts.push(String((part as Block).text))
396 }
397 }
398
399 return parts.join('')
400}
401
402/**
403 * The tools a ToolSearch result loaded: each tool_reference part names one. A part
404 * whose name is not a string is skipped rather than guessed at.
405 */
406const toolReferencesOf = (content: unknown): string[] => {
407 if (!Array.isArray(content)) return []
408 const names: string[] = []
409 for (const part of content) {
410 if (typeof part !== 'object' || part === null) continue
411 const ref = part as { type?: unknown; tool_name?: unknown }
412 if (ref.type === 'tool_reference' && typeof ref.tool_name === 'string') names.push(ref.tool_name)
413 }
414
415 return names
416}
417
418const firstString = (
419 input: Readonly<Record<string, unknown>>,
420 keys: readonly string[],
421): string | undefined => {
422 for (const key of keys) {
423 if (typeof input[key] === 'string') return input[key] as string
424 }
425
426 return undefined
427}
428
429/** The last 200 calls, so a result can name the tool and input it answers. */
430const remember = (id: string, tool: string, input: Record<string, unknown>): void => {
431 calls.set(id, { tool, input })
432 while (calls.size > CALLS_KEPT) {
433 const oldest = calls.keys().next()
434 if (oldest.done === true) break
435 calls.delete(oldest.value)
436 }
437}
438
439/**
440 * A `tool.call` matcher for one of this mod's own tools. The engine's tool-name
441 * union is built from the tools connected when the types were generated, so a
442 * name this mod registers itself does not appear there: the cast is the fix.
443 */
444const toolMatcher = (name: string): { tool: never } => ({ tool: name as never })
445
446/** Refused on every one of this mod's tools: only the main conversation may call them. */
447const AGENT_ONLY = { result: '这个工具只能由主会话调用。', isError: true as const }
448
449/**
450 * `/ctx-mod switch`: a window change a person asked for, with no model in the
451 * loop. The compaction is asked for on a timer, as a held cold-cache prompt
452 * asks for one, because a command hook may not run a command itself; nothing is
453 * submitted after the switch, so the next thing the window carries is whatever
454 * the person types.
455 */
456const manualSwitch = ($: EngineInterface, session: Session): CommandRunResult => {
457 // A session a coordinator started has work to hand over before any person has spoken in it.
458 if (session.latestPersonSeq() === null && session.latestRelaySeq() === null) {
459 return { text: 'ctx-mod:还没有可交接的内容,不换窗口。' }
460 }
461 $.clock.after(300, () => {
462 void (async () => {
463 switchReason = 'manual-switch'
464 try {
465 await $.command.run({ command: 'compact' })
466 } catch (error) {
467 logError($, 'command.run switch', error)
468 try {
469 await $.session.append({
470 message: {
471 type: 'system',
472 content: [{ type: 'text', text: 'ctx-mod:换窗口没有成功,上下文保持原样。' }],
473 },
474 })
475 } catch (notice) {
476 logError($, 'command.run notice', notice)
477 }
478 } finally {
479 switchReason = null
480 }
481 })()
482 })
483
484 return { text: 'ctx-mod:正在换到新窗口(不调用模型)。' }
485}
486
487/**
488 * Whether the call comes from a subagent. Read off the event itself: `argsOf`
489 * takes the envelope's keys — `agentId` among them — off the arguments.
490 */
491const isSubagentCall = (e: unknown): boolean => (e as { agentId?: unknown }).agentId !== undefined
492
493const TOOLS = [
494 {
495 name: 'context',
496 description:
497 '查看上下文用量,或申请换到新的上下文窗口。action 为 "new" 时申请换窗口;引擎批准后必须立即结束本回合,不要再调用任何工具。',
498 inputSchema: {
499 type: 'object',
500 properties: {
501 action: {
502 type: 'string',
503 enum: ['status', 'new'],
504 description: '要做的事:status(默认)只报告用量;new 申请换窗口。',
505 },
506 reason: { type: 'string', description: '申请换窗口的原因(可选)。' },
507 expect_more_work: {
508 type: 'boolean',
509 description: '换窗口后是否还有更多工作(可选,默认 true)。',
510 },
511 },
512 },
513 },
514 {
515 name: 'ledger_append',
516 description:
517 '向任务账本追加一条记录。每完成一个阶段就写一条:task_id 标识任务,status 取 in_progress / done / superseded / blocked,text 用一句话说明这一步(不能为空)。标 done 时必须在 evidence 里给出证明它的工具结果历史编号。对 in_progress 的任务必须写 next_step,并用 anchors 记下接下来要改的位置。next_step 和 anchors 省略表示沿用上一条(交接里会标明沿用自哪条),传空字符串或空数组表示清空。',
518 inputSchema: {
519 type: 'object',
520 properties: {
521 task_id: { type: 'string', description: '任务标识;同一个任务的多条记录用同一个 id。' },
522 title: { type: 'string', description: '任务的一句话标题。' },
523 status: {
524 type: 'string',
525 enum: ['in_progress', 'done', 'superseded', 'blocked'],
526 description: '任务状态。',
527 },
528 text: { type: 'string', description: '这一步的要点,一句话。' },
529 next_step: { type: 'string', description: '下一步要做什么(任务未完成时填写)。' },
530 anchors: {
531 type: 'array',
532 description: '接下来要改的位置列表。',
533 items: {
534 type: 'object',
535 properties: {
536 path: { type: 'string', description: '文件路径。' },
537 match: { type: 'string', description: '文件里能唯一定位的一小段原文。' },
538 note: { type: 'string', description: '这个位置的说明。' },
539 },
540 required: ['path', 'match', 'note'],
541 },
542 },
543 evidence: {
544 type: 'array',
545 items: { type: 'number' },
546 description: '证明 done 的工具结果历史编号(形如 [#123] 里的数字)。',
547 },
548 superseded_by: { type: 'string', description: '取代它的任务 id(status 为 superseded 时填写)。' },
549 request_ref: {
550 type: 'number',
551 description:
552 '这条记录回应的请求的历史编号:用户亲口提的那条消息,或派发这个任务的那条转交(协调者、定时任务)。省略时用这两类里最近的一条,回执会写明对应到了哪一条。',
553 },
554 },
555 required: ['task_id', 'title', 'status', 'text'],
556 },
557 },
558 {
559 name: 'ledger_read',
560 description:
561 '读取任务账本。view 可以是 home(默认,首页概览)、current(当前窗口的全部条目)或 archive:N(第 N 页归档);给了 query 就在全部条目里搜索。',
562 inputSchema: {
563 type: 'object',
564 properties: {
565 view: { type: 'string', description: 'home、current 或 archive:N,默认 home。' },
566 query: { type: 'string', description: '在 task_id、标题、正文和下一步里搜索的词。' },
567 },
568 },
569 },
570 {
571 name: 'history',
572 description:
573 '检索或读取完整历史。action 为 "search" 时按条件查历史编号,为 "read" 时按编号读原文。工具结果末尾形的 [#123] 就是历史编号。读原文时 field 填 "input" 可以看到那次工具调用的完整参数(子代理的任务原文、发出的消息、完整的命令)。搜索默认不显示引擎自己塞进来的背景信息(附件、钩子上下文、备注);要找它们就用 door 指定,如 "attachment"、"hook-context"、"note"。搜索不指定 window 时默认搜最近 3 个窗口;这几个窗口里没有命中会自动往更早的窗口找,找到有命中的窗口就停,结果末尾会写明实际搜了哪些窗口,没搜的窗口用 window 指定。',
574 inputSchema: {
575 type: 'object',
576 properties: {
577 action: { type: 'string', enum: ['search', 'read'], description: 'search 或 read。' },
578 query: { type: 'string', description: '搜索的词(search)。' },
579 window: { type: 'number', description: '只看某个窗口(search)。' },
580 role: { type: 'string', enum: ['user', 'assistant', 'tool'], description: '只看某种角色(search)。' },
581 tool_name: { type: 'string', description: '只看某个工具的结果,填工具名如 Read(search)。' },
582 path: { type: 'string', description: '只看某个文件(search)。' },
583 door: {
584 type: 'string',
585 description: '只看从某个入口进来的行,如 delivery、attachment、hook-context、note、compaction、command;给了它,背景信息也会被搜到(search)。',
586 },
587 limit: { type: 'number', description: '最多返回多少条(search)。' },
588 seq: { type: 'number', description: '要读的历史编号(read)。' },
589 field: {
590 type: 'string',
591 enum: ['text', 'input'],
592 description: 'text(默认)读结果正文;input 读那次工具调用的完整参数(read)。',
593 },
594 offset: { type: 'number', description: '从第几个字符开始读(read)。' },
595 length: { type: 'number', description: '读多少个字符(read)。' },
596 },
597 required: ['action'],
598 },
599 },
600] as const
601
602export const register: Register = (on, options) => {
603
604 on('session.start', async ($, e, next) => {
605 try {
606 enabled = (await $.env.get('CTX_MOD')) === '1'
607 // The command is always there, so a person who installed the mod and
608 // typed /ctx-mod is told how to turn it on instead of meeting an unknown command.
609 await $.command.register({
610 name: COMMAND,
611 description: '显示 ctx-mod 的状态;/ctx-mod switch 立即换到新窗口(不调用模型)',
612 })
613 if (enabled) {
614 for (const tool of TOOLS) {
615 await $.tool.register({ ...tool, isDeferred: false })
616 }
617 await sessionOf($)
618 }
619 } catch (error) {
620 logError($, 'session.start', error)
621 }
622
623 return next(e)
624 })
625
626 on('command.run', { command: COMMAND }, async ($, e, next) => {
627 if (!enabled) {
628 return { text: 'ctx-mod 未启用:用环境变量 CTX_MOD=1 启动 Claude Code 后生效(例如 `CTX_MOD=1 claude`)。' }
629 }
630 try {
631 const session = await sessionOf($)
632 if (session === undefined) return { text: 'ctx-mod:拿不到会话。' }
633 // `args` is absent on a bare `/ctx-mod`, and on a run whose arguments the
634 // surface never carried.
635 if (typeof e.args === 'string' && e.args.trim() === 'switch') return manualSwitch($, session)
636 const id = await idOf($)
637 const env = id === undefined ? undefined : await envOf($, id)
638 const head = 'ctx-mod 已启用(CTX_MOD=1)。'
639
640 return { text: env === undefined ? head : `${head}\n${session.status(env)}` }
641 } catch (error) {
642 logError($, 'command.run', error)
643
644 return { text: `ctx-mod: ${messageOf(error)}` }
645 }
646 })
647
648 on('prompt.compose', async ($, e, next) => {
649 if (!enabled) return next(e)
650 const result = await next(e)
651 try {
652 return {
653 ...result,
654 sections: [
655 ...result.sections,
656 { id: 'ctx-mod:context', text: SYSTEM_TEXT, scope: 'session' as const },
657 ],
658 }
659 } catch (error) {
660 logError($, 'prompt.compose', error)
661
662 return result
663 }
664 })
665
666 on('tool.call', { tool: 'mcp__ctx-mod__context' as never }, async ($, e, next) => {
667 if (!enabled) return next(e)
668 if (isSubagentCall(e)) return AGENT_ONLY
669 const input = argsOf(e)
670 try {
671 const session = await sessionOf($)
672 const id = await idOf($)
673 if (session === undefined || id === undefined) return next(e)
674 const env = await envOf($, id)
675
676 return input.action === 'new'
677 ? session.requestSwitch(input, env)
678 : { result: session.status(env) }
679 } catch (error) {
680 logError($, 'tool.call context', error)
681
682 return next(e)
683 }
684 })
685
686 on('tool.call', { tool: 'mcp__ctx-mod__ledger_append' as never }, async ($, e, next) => {
687 if (!enabled) return next(e)
688 if (isSubagentCall(e)) return AGENT_ONLY
689 const input = argsOf(e)
690 try {
691 const session = await sessionOf($)
692 const id = await idOf($)
693 if (session === undefined || id === undefined) return next(e)
694
695 return await session.ledgerAppend(input, await envOf($, id))
696 } catch (error) {
697 logError($, 'tool.call ledger_append', error)
698
699 return next(e)
700 }
701 })
702
703 on('tool.call', { tool: 'mcp__ctx-mod__ledger_read' as never }, async ($, e, next) => {
704 if (!enabled) return next(e)
705 if (isSubagentCall(e)) return AGENT_ONLY
706 const input = argsOf(e)
707 try {
708 const session = await sessionOf($)
709 if (session === undefined) return next(e)
710
711 return await session.ledgerRead(input)
712 } catch (error) {
713 logError($, 'tool.call ledger_read', error)
714
715 return next(e)
716 }
717 })
718
719 on('tool.call', { tool: 'mcp__ctx-mod__history' as never }, async ($, e, next) => {
720 if (!enabled) return next(e)
721 if (isSubagentCall(e)) return AGENT_ONLY
722 const input = argsOf(e)
723 try {
724 const session = await sessionOf($)
725 if (session === undefined) return next(e)
726
727 return await session.historyTool(input)
728 } catch (error) {
729 logError($, 'tool.call history', error)
730
731 return next(e)
732 }
733 })
734
735 on('tool.call', async ($, e, next) => {
736 if (!enabled) return next(e)
737 if (e.agentId !== undefined) return next(e)
738 // A tool of this mod's own is answered by its matcher above; this hook is
739 // for everything else, and it runs after that one.
740 if (String(e.tool).startsWith(PREFIX)) return next(e)
741 try {
742 remember(String(e.tool_use_id), String(e.tool), e as unknown as Record<string, unknown>)
743 const session = await sessionOf($)
744 if (session !== undefined && session.pending() !== null) {
745 return { deny: '正在换窗口:请立即结束本回合,不要再调用工具。' }
746 }
747 // A load that would rebuild the cache waits for a window switch while the context is large.
748 if (session !== undefined && String(e.tool) === 'ToolSearch') {
749 const id = await idOf($)
750 const tokens = id === undefined ? undefined : (await envOf($, id)).tokens
751 const refusal = session.gateLoad(argsOf(e).query, tokens)
752 if (refusal !== null) return { deny: refusal }
753 }
754 } catch (error) {
755 logError($, 'tool.call before', error)
756 }
757
758 const result = await next(e)
759 try {
760 const session = await sessionOf($)
761 const id = await idOf($)
762 if (session !== undefined && id !== undefined) {
763 const env = await envOf($, id)
764 const text = session.reminder(env)
765 if (text !== null) {
766 await $.session.append({
767 message: { type: 'user', content: [{ type: 'text', text }] },
768 })
769 }
770 }
771 } catch (error) {
772 logError($, 'tool.call after', error)
773 }
774
775 return result
776 }).catch(($, e, next) => next(e))
777
778 on('session.append', async ($, e, next) => {
779 if (!enabled) return next(e)
780 if (e.agentId !== undefined) return next(e)
781 try {
782 const session = await sessionOf($)
783 if (session === undefined) return next(e)
784
785 if (e.door === 'prompt') {
786 const said = textOf(e.message.content)
787 // The engine's own session-start hook lands in this door too, wrapped in
788 // a `<system-reminder>` block: a person's row must survive stripping it
789 // and still not look like an injected notice. The text held in
790 // `personTexts` came from `prompt.submit`, which never sees that block,
791 // so the match is made on the stripped text as well.
792 const stripped = stripInjected(said)
793 let at = personTexts.indexOf(said.trim())
794 if (at === -1 && stripped !== '') at = personTexts.indexOf(stripped)
795 if (at !== -1) personTexts.splice(at, 1)
796 // With the source confirmed, only the engine's own envelopes are ruled out by their opening; any other `<` is the person's words.
797 const isConfirmed = at !== -1 || isPersonOrigin(e.origin) || isOurUser(e.origin)
798 const isPerson = isConfirmed && stripped !== '' && looksLikePerson(stripped, true)
799 await session.record({
800 uuid: e.uuid,
801 role: 'user',
802 ...(isPerson ? { isPerson: true as const } : {}),
803 // Kept as the engine stamped it: a coordinator's hand-off is told by this, not by its words.
804 origin: e.origin.kind,
805 // The handoff a switch answers with comes back through this door, not `compaction`;
806 // naming it lets a search by that door find what each window opened with.
807 ...(isHandoffText(said) ? { door: 'compaction' } : {}),
808 text: textOf(e.message.content),
809 })
810
811 return next(e)
812 }
813
814 if (e.door === 'response') {
815 const text = textOf(e.message.content)
816 if (text !== '') await session.record({ uuid: e.uuid, role: 'assistant', text })
817
818 return next(e)
819 }
820
821 if (e.door === 'tool-result') {
822 const out: Block[] = []
823 for (const block of e.message.content) {
824 if (block.type !== 'tool_result') {
825 out.push(block)
826 continue
827 }
828 const callId = String(block.tool_use_id ?? '')
829 const found = calls.get(callId)
830 const tool = found?.tool ?? (e.origin.kind === 'tool' ? e.origin.tool : 'unknown')
831 const input = found?.input ?? {}
832 const isError = block.is_error === true
833 const path = firstString(input, ['file_path', 'notebook_path', 'path'])
834 const brief = briefOf(tool, input)
835 // `input` is the whole call event; the arguments are what is left once the envelope is off.
836 const args = found === undefined ? '' : formatArgs(argsOf(found.input))
837 // A ToolSearch result names the deferred tools it loaded; the next window
838 // is told which of them were used, so it can load them in its first step.
839 const loaded = tool === 'ToolSearch' ? toolReferencesOf(block.content) : []
840 const text = resultText(block.content)
841 // Whatever is not text (an image, a document) must not vanish without a trace.
842 const note = nonTextNote(Array.isArray(block.content) ? block.content : [], loaded.length > 0 ? ['tool_reference'] : [])
843 // A reference-only result has no text; name what it loaded so the row is not blank.
844 const body = text === '' && loaded.length > 0 ? `已加载:${loaded.join('、')}` : text
845 const record = await session.record({
846 uuid: e.uuid,
847 role: 'tool',
848 tool,
849 ...(path === undefined ? {} : { path }),
850 ...(brief === undefined ? {} : { input: brief }),
851 ...(args === '' ? {} : { args }),
852 ...(callId === '' ? {} : { callId }),
853 ...(isError ? { isError: true as const } : {}),
854 ...(loaded.length === 0 ? {} : { loaded }),
855 text: note === '' ? body : body === '' ? note : `${body}\n${note}`,
856 })
857 session.activity(tool, input, record.seq, isError)
858
859 // This mod's own results are recorded, never numbered.
860 // A result that carries anything but text (ToolSearch's tool
861 // references, images) is refused by the API once text is mixed in.
862 const isPlain =
863 typeof block.content === 'string' ||
864 (Array.isArray(block.content) &&
865 block.content.every(part => (part as { type?: unknown }).type === 'text'))
866 if (tool.startsWith(PREFIX) || !isPlain) {
867 out.push(block)
868 continue
869 }
870 const suffix = `[#${record.seq}]`
871 out.push(
872 typeof block.content === 'string'
873 ? { ...block, content: `${block.content}\n${suffix}` }
874 : {
875 ...block,
876 content: [...(Array.isArray(block.content) ? block.content : []), { type: 'text', text: suffix }],
877 },
878 )
879 }
880
881 return next({ ...e, message: { ...e.message, content: out } })
882 }
883
884 // Every other door: the row is kept when a request carries it, which is what `role` says.
885 // A notice and the like have none and never reach the model, so they stay out.
886 const role = e.message.role
887 if (role !== undefined) {
888 const body = textOf(e.message.content)
889 const note = nonTextNote(e.message.content)
890 const text = note === '' ? body : body === '' ? note : `${body}\n${note}`
891 if (text !== '') {
892 const name = e.message.name
893 // A message the person typed mid-turn comes back as a queued_command attachment, wrapped in
894 // text nobody has seen; the origin says `engine`, so it is told by the words they submitted.
895 // The engine files it under `delivery`, not `attachment`: the name is what tells it.
896 let said: string | undefined
897 if ((e.door === 'attachment' || e.door === 'delivery') && name === 'queued_command') {
898 const at = personTexts.findIndex(typed => typed !== '' && body.includes(typed))
899 if (at !== -1) {
900 const typed = personTexts.splice(at, 1)[0] ?? ''
901 const stripped = stripInjected(typed)
902 if (stripped !== '' && looksLikePerson(stripped, true)) said = typed
903 }
904 }
905 // The engine injects the same background attachment on every request; one copy per window
906 // shows what was injected then, and a repeat only burns a number and a shard rewrite.
907 const isBackground = isBackgroundRow({ door: e.door, ...(name === undefined ? {} : { name }) })
908 const backgroundKey = `${e.door}/${name ?? ''}`
909 const window = session.window()
910 const previous = lastBackground.get(backgroundKey)
911 if (isBackground && previous !== undefined && previous.window === window && previous.text === text) {
912 return next(e)
913 }
914 await session.record({
915 uuid: e.uuid,
916 role,
917 ...(said === undefined ? {} : { isPerson: true as const }),
918 door: e.door,
919 ...(name === undefined ? {} : { name }),
920 text: said ?? text,
921 })
922 if (isBackground) lastBackground.set(backgroundKey, { window, text })
923 }
924 }
925 } catch (error) {
926 logError($, 'session.append', error)
927 }
928
929 return next(e)
930 })
931
932 on('agent.spawn', async ($, e, next) => {
933 if (!enabled) return next(e)
934 const result = await next(e)
935 try {
936 const id = result.agentId
937 // Only the main loop's own spawns are noted: a spawn from inside a subagent is
938 // not one a new window can resume, and a fork carries the parent's conversation
939 // rather than a task of its own.
940 if (typeof id === 'string' && id !== '' && e.parentAgentId === undefined && e.fork !== true) {
941 const session = await sessionOf($)
942 await session?.noteAgent({ id, type: e.subagentType, description: e.description, model: result.model })
943 }
944 } catch (error) {
945 logError($, 'agent.spawn', error)
946 }
947
948 return result
949 })
950
951 // `turn.step` streams, so its hook is the generator the engine reads: every chunk
952 // passes through untouched, and the recording reads only the result that comes back.
953 on('turn.step', async function* ($, e, next) {
954 if (!enabled) return yield* next(e)
955 const result = yield* next(e)
956 try {
957 // Only the main loop's requests are measured: a subagent has a cache of its own.
958 const usage = result.usage
959 if (e.agentId === undefined) {
960 const session = await sessionOf($)
961 // One step is one model request, whether or not it called a tool; a retried
962 // or replayed step carries the same turn and index and is not a second one.
963 const isNewStep = session !== undefined && (lastStep === null || lastStep.turnId !== e.turnId || lastStep.index !== e.index)
964 if (usage !== null) {
965 const measured = {
966 input: typeof usage.input_tokens === 'number' ? usage.input_tokens : 0,
967 read: typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0,
968 write: typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0,
969 }
970 await session?.noteStep(measured)
971 // The first step after a switch carries the new window's whole prefix.
972 if (session !== undefined && awaitingPrefix) {
973 session.notePrefix(measured.input + measured.read + measured.write)
974 awaitingPrefix = false
975 }
976 }
977 // Refreshed only after noteStep, which judges the previous request's age and must still see it.
978 if (session !== undefined && isNewStep) {
979 lastStep = { turnId: e.turnId, index: e.index }
980 session.noteRequest()
981 }
982 }
983 } catch (error) {
984 logError($, 'turn.step', error)
985 }
986
987 return result
988 })
989
990 on('turn.complete', async ($, e, next) => {
991 if (!enabled) return next(e)
992 if (e.agentId !== undefined) {
993 // A subagent's own turn: its noted row is tagged as ended, which the handoff reads.
994 try {
995 const session = await sessionOf($)
996 await session?.agentEnded(e.agentId)
997 } catch (error) {
998 logError($, 'turn.complete agent', error)
999 }
1000
1001 return next(e)
1002 }
1003 const result = await next(e)
1004 let session: Session | undefined
1005 try {
1006 session = await sessionOf($)
1007 const id = await idOf($)
1008 if (continueAfterTurn) {
1009 continueAfterTurn = false
1010 if (e.reason === 'answer' && id !== undefined) {
1011 const env = await envOf($, id)
1012 if (shouldContinue(env.fiveHourPercent, env.cache, DEFAULTS)) {
1013 await $.prompt.submit({ text: CONTINUE_TEXT })
1014 }
1015 }
1016 } else if (session !== undefined && session.pending() !== null) {
1017 if (e.reason !== 'answer') {
1018 session.clearPending()
1019 } else {
1020 switchReason = 'model'
1021 committed = false
1022 try {
1023 await $.command.run({ command: 'compact' })
1024 } catch (error) {
1025 // A compaction that threw committed nothing; the pending the model
1026 // was granted must still be cleared, or every call is refused.
1027 logError($, 'turn.complete compact', error)
1028 } finally {
1029 switchReason = null
1030 }
1031 if (!committed) {
1032 session.clearPending()
1033 } else if (id !== undefined) {
1034 const env = await envOf($, id)
1035 if (shouldContinue(env.fiveHourPercent, env.cache, DEFAULTS)) {
1036 await $.prompt.submit({ text: CONTINUE_TEXT })
1037 }
1038 }
1039 }
1040 }
1041 } catch (error) {
1042 logError($, 'turn.complete', error)
1043 } finally {
1044 // Every main turn ends by flushing, however the switch it may have started went.
1045 if (session !== undefined) {
1046 try {
1047 await session.flush()
1048 } catch (error) {
1049 logError($, 'turn.complete flush', error)
1050 }
1051 }
1052 }
1053
1054 return result
1055 })
1056
1057 on('session.compact', async ($, e, next) => {
1058 if (!enabled) return next(e)
1059 if (e.agentId !== undefined || e.trigger === 'precompute') return next(e)
1060 const mine = switchReason !== null && e.trigger === 'manual'
1061 let session: Session | undefined
1062 let env: Env | undefined
1063 let id: string | undefined
1064 try {
1065 id = await idOf($)
1066 session = await sessionOf($)
1067 if (id === undefined || session === undefined) return next(e)
1068 env = await envOf($, id)
1069 let isZeroSummary = mine || (e.trigger === 'auto' && session.canSkipSummary())
1070 if (isZeroSummary) {
1071 // A zero-summary switch drops the conversation on the promise that the files hold it, so that must be true.
1072 await session.flush()
1073 const unsaved = session.unsaved()
1074 if (unsaved.length > 0) {
1075 logError($, 'session.compact', `not on disk, so core summarizes instead: ${unsaved.join(', ')}`)
1076 isZeroSummary = false
1077 }
1078 }
1079 if (isZeroSummary) {
1080 // A zero-summary switch: this mod answers in place of core.
1081 if (!mine && session.pending() !== null) continueAfterTurn = true
1082 const text = await session.buildHandoff({
1083 isMidTurn: e.trigger === 'auto',
1084 agents: await agentsOf($),
1085 })
1086 await session.commitSwitch({
1087 trigger: mine ? switchReason ?? 'manual' : 'auto',
1088 tokensBefore: env.tokens,
1089 handoffText: text,
1090 })
1091 committed = true
1092 awaitingPrefix = true
1093
1094 return { messages: [{ role: 'user', text, toolUses: [] }] }
1095 }
1096 } catch (error) {
1097 logError($, 'session.compact before', error)
1098
1099 return next(e)
1100 }
1101
1102 const result: SessionCompactResult = await next(e)
1103 try {
1104 if (result.messages !== undefined && session !== undefined && env !== undefined) {
1105 // Core made a summary: keep it and append this mod's own handoff after.
1106 const text = await session.buildHandoff({
1107 isMidTurn: e.trigger === 'auto',
1108 agents: await agentsOf($),
1109 })
1110 await session.commitSwitch({
1111 trigger: e.trigger === 'auto' ? 'auto-summary' : 'manual',
1112 tokensBefore: env.tokens,
1113 handoffText: text,
1114 })
1115 // When the disk check sent a switch the model asked for down this path, `turn.complete` still has to see it happened.
1116 if (mine) committed = true
1117 awaitingPrefix = true
1118
1119 return { ...result, messages: [...result.messages, { role: 'user', text, toolUses: [] }] }
1120 }
1121 } catch (error) {
1122 logError($, 'session.compact after', error)
1123 }
1124
1125 return result
1126 })
1127
1128 on('prompt.submit', async ($, e, next) => {
1129 if (!enabled) return next(e)
1130 let holds = false
1131 let starts = false
1132 let isExpired = false
1133 let heldSession: Session | undefined
1134 let heldIdOf = 0
1135 try {
1136 const session = await sessionOf($)
1137 const id = await idOf($)
1138 if (
1139 session !== undefined &&
1140 id !== undefined &&
1141 isPersonOrigin(e.origin) &&
1142 (e.attachments?.length ?? 0) === 0
1143 ) {
1144 // During a switch every such message waits its turn; otherwise only a cold cache holds one.
1145 let wants = isHoldingFlow
1146 if (!wants) {
1147 const env = await envOf($, id)
1148 wants =
1149 env.tokens !== undefined &&
1150 env.tokens >= DEFAULTS.coldMinTokens &&
1151 isCold({ now: Date.now(), cache: env.cache, lastRequestAt: session.lastRequestAt() }, DEFAULTS)
1152 isExpired = wants
1153 }
1154 // Held only once it is on disk: a message that could not be saved goes in as usual.
1155 const heldId = wants ? await session.hold(e.text) : null
1156 if (heldId !== null) {
1157 holds = true
1158 heldSession = session
1159 heldIdOf = heldId
1160 // Decided after the awaits, with no gap before the timer: only one run may own the switch.
1161 starts = !isHoldingFlow
1162 if (starts) isHoldingFlow = true
1163 }
1164 }
1165 } catch (error) {
1166 logError($, 'prompt.submit before', error)
1167 }
1168
1169 // Only a submission that goes on to be stored is worth matching by its text:
1170 // a held prompt is dropped, never reaches `session.append`, and its row is
1171 // then matched by some later prompt that happens to say the same words.
1172 if ((isPersonOrigin(e.origin) || isOurUser(e.origin)) && !holds) {
1173 personTexts.push(e.text.trim())
1174 if (personTexts.length > 20) personTexts.shift()
1175 }
1176 if (!holds) return next(e)
1177
1178 // A compaction cannot be asked for from inside this hook: a timer asks once
1179 // it returned, then the held prompt is submitted again as the person's own.
1180 if (!starts && heldSession !== undefined) {
1181 return { drop: 'ctx-mod:正在换窗口,这条消息排在后面,换完后按顺序处理' }
1182 }
1183 if (heldSession === undefined) return next(e)
1184 const session = heldSession
1185 try {
1186 $.clock.after(300, () => {
1187 void (async () => {
1188 try {
1189 // Only a message held for the expired cache calls for a switch: one that queued behind a switch which has since ended just goes in.
1190 if (isExpired) {
1191 switchReason = 'cold'
1192 try {
1193 await $.command.run({ command: 'compact' })
1194 } catch (error) {
1195 logError($, 'prompt.submit timer', error)
1196 } finally {
1197 switchReason = null
1198 }
1199 }
1200 await deliverHeld($, session)hooks/lib/history.ts 747 lines1// Pure history-plane logic: shard naming and packing, UTF-8 sizing without
2// TextEncoder, clipping, search, paging and the whole-file write queue. Nothing
3// here touches the engine, fs or a clock, so the callers inject the writer and
4// this module stays testable in a bare interpreter.
5
6import { OWN_TOOL_PREFIX } from './types.ts'
7import type { HistoryRecord, Overflow, Role } from './types.ts'
8
9/** Shards rotate well before the engine's 4 MiB read ceiling, so a whole file still reads. */
10export const MAX_SHARD_BYTES = 2 * 1024 * 1024
11
12/** One record past this is clipped, so a single row can never fill a shard by itself. */
13export const MAX_RECORD_CHARS = 200_000
14
15/** A tool call's arguments past this are clipped in the row; the rest goes to the overflow files. */
16export const MAX_ARGS_CHARS = 50_000
17
18/**
19 * One overflow file holds at most this many characters: UTF-8 makes that about
20 * 3 MB at worst, which stays under the engine's 4 MiB read ceiling.
21 */
22export const OVERFLOW_PART_CHARS = 1_000_000
23
24/** Three digits keep shard names sorting in window order for windows up to 999. */
25const windowTag = (window: number): string => `w${String(window).padStart(3, '0')}`
26
27/**
28 * Part 0 is the plain name. Parts 1..25 take the letters b..z, which keeps a
29 * rotation short; later parts fall back to digits, which never run out.
30 */
31const partTag = (part: number): string => {
32 if (part <= 0) return ''
33 if (part <= 25) return `-${String.fromCharCode(97 + part)}`
34
35 return `-p${part}`
36}
37
38/** The file a window's shard part lives in; the caller writes the whole file at once. */
39export const shardName = (window: number, part: number): string =>
40 `history-${windowTag(window)}${partTag(part)}.jsonl`
41
42/**
43 * The UTF-8 length of a string, counted code point by code point: TextEncoder
44 * and Buffer are both out of reach in the engine's module sandbox.
45 */
46export const byteLength = (text: string): number => {
47 let bytes = 0
48 for (const char of text) {
49 const point = char.codePointAt(0) ?? 0
50 if (point < 0x80) bytes += 1
51 else if (point < 0x800) bytes += 2
52 else if (point < 0x10000) bytes += 3
53 else bytes += 4
54 }
55
56 return bytes
57}
58
59/** Whether a UTF-16 unit is the first half of a surrogate pair. */
60const isHighSurrogate = (unit: number): boolean => unit >= 0xd800 && unit <= 0xdbff
61
62/** The head of an over-long field plus one line saying how long the original was and where the rest lives. */
63const clipField = (text: string, limit: number): string => {
64 let head = text.slice(0, limit)
65 // Never cut a surrogate pair in half: the lone half would be unencodable.
66 if (isHighSurrogate(head.charCodeAt(head.length - 1))) head = head.slice(0, -1)
67
68 return `${head}\n[已截断:原文 ${text.length} 字符,可分页读全文,搜索只查这一段]`
69}
70
71/**
72 * Cuts a text into the overflow files' contents. A part ends one unit early
73 * when its last unit would split a surrogate pair, so a part's start is not
74 * always a multiple of the part size: `cuts` then lists where each later part
75 * starts, and is left out while the plain arithmetic holds.
76 */
77export const splitParts = (text: string): { parts: string[]; cuts?: number[] } => {
78 const parts: string[] = []
79 const cuts: number[] = []
80 let isShifted = false
81 let start = 0
82 while (start < text.length) {
83 let end = Math.min(text.length, start + OVERFLOW_PART_CHARS)
84 if (end < text.length && isHighSurrogate(text.charCodeAt(end - 1))) end -= 1
85 parts.push(text.slice(start, end))
86 if (end < text.length) {
87 cuts.push(end)
88 if (end !== parts.length * OVERFLOW_PART_CHARS) isShifted = true
89 }
90 start = end
91 }
92
93 return isShifted ? { parts, cuts } : { parts }
94}
95
96const overflowOf = (text: string): Overflow => {
97 const { parts, cuts } = splitParts(text)
98
99 return { chars: text.length, parts: parts.length, ...(cuts === undefined ? {} : { cuts }) }
100}
101
102/** Where an overflow file begins in the original text. */
103const partStart = (overflow: Overflow, part: number): number =>
104 part <= 0 ? 0 : overflow.cuts?.[part - 1] ?? part * OVERFLOW_PART_CHARS
105
106/** The overflow file that holds one field of one row; `field` is `text` or `args`. */
107export const overflowName = (seq: number, field: 'text' | 'args', part: number): string =>
108 `big-${seq}-${field}-${part}.txt`
109
110/** The parts that hold any of the characters from `start` up to `end`, in order. */
111export const partsCovering = (overflow: Overflow, start: number, end: number): number[] => {
112 const wanted: number[] = []
113 for (let part = 0; part < overflow.parts; part += 1) {
114 const from = partStart(overflow, part)
115 const to = part + 1 < overflow.parts ? partStart(overflow, part + 1) : overflow.chars
116 if (to > start && from < end) wanted.push(part)
117 }
118
119 return wanted
120}
121
122/** Where the first of the given parts begins; the caller joins the parts it read and slices from here. */
123export const partOrigin = (overflow: Overflow, part: number): number => partStart(overflow, part)
124
125/**
126 * The record as it is kept in a shard: a text or arguments field past its limit
127 * keeps only its head plus one line saying how long the original was, and the
128 * row notes how many overflow files the whole original goes into. Everything a
129 * consumer reads from the row stays as short as it ever was.
130 */
131export const clipRecord = (record: HistoryRecord): HistoryRecord => {
132 const clipped: HistoryRecord = { ...record }
133 const overflow: NonNullable<HistoryRecord['overflow']> = {}
134 if (record.text.length > MAX_RECORD_CHARS) {
135 clipped.text = clipField(record.text, MAX_RECORD_CHARS)
136 overflow.text = overflowOf(record.text)
137 }
138 if (record.args !== undefined && record.args.length > MAX_ARGS_CHARS) {
139 clipped.args = clipField(record.args, MAX_ARGS_CHARS)
140 overflow.args = overflowOf(record.args)
141 }
142 if (overflow.text !== undefined || overflow.args !== undefined) clipped.overflow = overflow
143
144 return clipped
145}
146
147/**
148 * A tool call's arguments as plain text: one `key: value` segment per top-level
149 * key, strings as they are (they may span lines), anything else as JSON. The
150 * live path and the import path both write it, so a row reads the same either way.
151 */
152export const formatArgs = (args: Readonly<Record<string, unknown>>): string => {
153 const segments: string[] = []
154 for (const [key, value] of Object.entries(args)) {
155 let shown: string | undefined
156 if (typeof value === 'string') {
157 shown = value
158 } else {
159 try {
160 shown = JSON.stringify(value)
161 } catch {
162 // A value JSON cannot hold (a cycle, a BigInt) is still worth its name.
163 shown = String(value)
164 }
165 }
166 if (shown !== undefined) segments.push(`${key}: ${shown}`)
167 }
168
169 return segments.join('\n')
170}
171
172/**
173 * One line naming the blocks of a row that are not text, so an image or a
174 * document does not vanish from the history without a trace; empty when every
175 * block is text. A part that carries its own text is counted as text, as the
176 * extraction of the text does; `skip` leaves out types the row already names.
177 */
178export const nonTextNote = (blocks: readonly unknown[], skip: readonly string[] = []): string => {
179 const counts = new Map<string, number>()
180 for (const block of blocks) {
181 const part = typeof block === 'object' && block !== null ? (block as { type?: unknown; text?: unknown }) : {}
182 if (typeof (part as { text?: unknown }).text === 'string') continue
183 const type = typeof part.type === 'string' ? part.type : 'unknown'
184 if (type === 'text' || skip.includes(type)) continue
185 counts.set(type, (counts.get(type) ?? 0) + 1)
186 }
187 if (counts.size === 0) return ''
188
189 return `[非文本内容:${[...counts].map(([type, count]) => `${type}×${count}`).join('、')}]`
190}
191
192/** One shard file: its window, its rotation index, its rows and their running byte cost. */
193export type Shard = { window: number; part: number; rows: HistoryRecord[]; bytes: number }
194
195export const emptyShard = (window: number, part = 0): Shard => ({ window, part, rows: [], bytes: 0 })
196
197/** What one row costs in a shard: its JSON, plus the newline that joins the lines. */
198const recordBytes = (record: HistoryRecord): number => byteLength(JSON.stringify(record)) + 1
199
200/**
201 * Places one record into a shard, returning the shard to keep and whether a new
202 * file was opened. A record from another window starts that window's part 0, and
203 * one that would push a non-empty shard past the ceiling rotates within the same
204 * window; a record too big for any shard still lands alone in an empty one.
205 *
206 * Appending into the current shard pushes onto that shard's own `rows` and hands
207 * back the same array, which keeps a run of records linear rather than quadratic
208 * in the array length. The shard passed in is therefore consumed: work with the
209 * returned shard from then on, and never read the old one again. A rotation or a
210 * window change still builds a fresh shard and leaves the old one whole, since
211 * the caller has already written and stored it under its own name.
212 */
213export const appendRecord = (
214 shard: Shard,
215 record: HistoryRecord,
216): { shard: Shard; isNew: boolean } => {
217 const bytes = recordBytes(record)
218
219 if (record.window !== shard.window) {
220 return { shard: { ...emptyShard(record.window, 0), rows: [record], bytes }, isNew: true }
221 }
222
223 if (shard.rows.length > 0 && shard.bytes + bytes > MAX_SHARD_BYTES) {
224 return { shard: { ...emptyShard(shard.window, shard.part + 1), rows: [record], bytes }, isNew: true }
225 }
226
227 shard.rows.push(record)
228 shard.bytes += bytes
229
230 return { shard, isNew: false }
231}
232
233export type SearchQuery = {
234 query: string
235 window?: number
236 role?: Role
237 tool?: string
238 path?: string
239 /** Only rows that came in by this door; it also lifts the default skip of the background rows. */
240 door?: string
241 limit?: number
242}
243
244export type SearchHit = {
245 seq: number
246 window: number
247 role: Role
248 tool?: string
249 path?: string
250 door?: string
251 name?: string
252 /** The tool call's input summary, when the row has one. */
253 input?: string
254 excerpt: string
255}
256
257const EXCERPT_PAD = 80
258const EXCERPT_HEAD = 160
259const SEARCH_LIMIT_DEFAULT = 20
260const SEARCH_LIMIT_MAX = 100
261
262/**
263 * The cap the caller asked for, or the default. A value that is not a finite
264 * positive number — NaN, a string, Infinity, zero, a negative — must never be
265 * read as "no cap" and hand back the whole history, which is exactly what a
266 * comparison against NaN does.
267 */
268const searchLimit = (limit: unknown): number => {
269 if (typeof limit !== 'number' || !Number.isFinite(limit) || limit < 1) return SEARCH_LIMIT_DEFAULT
270
271 return Math.min(Math.floor(limit), SEARCH_LIMIT_MAX)
272}
273
274/** A window filter means a finite number; anything else is no filter at all. */
275const windowFilter = (window: unknown): number | undefined =>
276 typeof window === 'number' && Number.isFinite(window) ? window : undefined
277
278/** Only the three role names filter; anything else is ignored, never turned into an empty answer. */
279const roleFilter = (role: unknown): Role | undefined =>
280 role === 'user' || role === 'assistant' || role === 'tool' ? role : undefined
281
282/** A text filter is a string or nothing; a non-string must not throw. */
283const textFilter = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined)
284
285/** Whether a row is one of this mod's own tool calls; those stay on disk but are not the work. */
286export const isOwnRow = (row: HistoryRecord): boolean =>
287 typeof row.tool === 'string' && row.tool.startsWith(OWN_TOOL_PREFIX)
288
289/**
290 * Whether a row is the engine's own background (an attachment, a hook's context,
291 * a note) rather than something said or done. A person's message that arrived
292 * as a `queued_command` attachment is the exception: it is the work.
293 */
294export const isBackgroundRow = (row: Pick<HistoryRecord, 'door' | 'name'>): boolean =>
295 row.door === 'hook-context' ||
296 row.door === 'note' ||
297 (row.door === 'attachment' && row.name !== 'queued_command')
298
299/** A path is written with either separator; folding them makes one filter work for both. */
300const flattenPath = (path: string): string => path.replace(/\\/g, '/').toLowerCase()
301
302/** Newlines inside a one-line excerpt would break the table the caller prints. */
303const oneLine = (text: string): string => text.replace(/[\r\n]/g, ' ')
304
305/** The window around the first match, the match itself included. */
306const excerptAround = (text: string, at: number, width: number): string =>
307 oneLine(text.slice(Math.max(0, at - EXCERPT_PAD), Math.min(text.length, at + width + EXCERPT_PAD)))
308
309/**
310 * Literal, case-insensitive search over the records, newest first. An empty
311 * query skips text matching and only applies the filters, so it doubles as
312 * "what happened in this window". Every field is read defensively: the callers
313 * cast a model's tool input straight to `SearchQuery`, so a missing or wrongly
314 * typed field means "no filter", never a throw and never a widened answer.
315 * This mod's own tool rows are skipped unless the caller names a tool filter:
316 * a search is about the work, and a tool filter is how they are still found.
317 * The engine's background rows are skipped the same way unless a door is named.
318 */
319export const search = (rows: readonly HistoryRecord[], query: SearchQuery): SearchHit[] => {
320 const limit = searchLimit(query.limit)
321 const window = windowFilter(query.window)
322 const role = roleFilter(query.role)
323 const tool = textFilter(query.tool)
324 const door = textFilter(query.door)
325 const skipOwn = tool === undefined
326 const filterPath = textFilter(query.path)
327 const needle = (typeof query.query === 'string' ? query.query : '').toLowerCase()
328 const pathNeedle = filterPath === undefined ? undefined : flattenPath(filterPath)
329 const hits: SearchHit[] = []
330
331 for (const row of [...rows].sort((a, b) => b.seq - a.seq)) {
332 if (hits.length >= limit) break
333 if (window !== undefined && row.window !== window) continue
334 if (role !== undefined && row.role !== role) continue
335 if (tool !== undefined && row.tool !== tool) continue
336 if (skipOwn && isOwnRow(row)) continue
337 if (door !== undefined ? row.door !== door : isBackgroundRow(row)) continue
338 if (pathNeedle !== undefined) {
339 if (row.path === undefined || !flattenPath(row.path).includes(pathNeedle)) continue
340 }
341
342 let excerpt: string
343 if (needle === '') {
344 excerpt = oneLine(row.text.slice(0, EXCERPT_HEAD))
345 } else {
346 const at = row.text.toLowerCase().indexOf(needle)
347 // A word that only survived in the call's arguments, or in their summary, still
348 // matches: the point of those fields is to find a past call by what it was asked.
349 const inArgs = at < 0 ? (row.args ?? '').toLowerCase().indexOf(needle) : -1
350 const inInput = at < 0 && inArgs < 0 ? (row.input ?? '').toLowerCase().indexOf(needle) : -1
351 if (at < 0 && inArgs < 0 && inInput < 0) continue
352 excerpt =
353 at >= 0
354 ? excerptAround(row.text, at, needle.length)
355 : inArgs >= 0
356 ? excerptAround(row.args ?? '', inArgs, needle.length)
357 : excerptAround(row.input ?? '', inInput, needle.length)
358 }
359
360 const hit: SearchHit = { seq: row.seq, window: row.window, role: row.role, excerpt }
361 if (row.tool !== undefined) hit.tool = row.tool
362 if (row.path !== undefined) hit.path = row.path
363 if (row.input !== undefined) hit.input = row.input
364 if (row.door !== undefined) hit.door = row.door
365 if (row.name !== undefined) hit.name = row.name
366 hits.push(hit)
367 }
368
369 return hits
370}
371
372export type ReadResult =
373 | { found: false }
374 | {
375 found: true
376 seq: number
377 window: number
378 role: Role
379 tool?: string
380 path?: string
381 ts: number
382 /** Which field the slice is of: the result's text, or the call's arguments. */
383 field: ReadField
384 text: string
385 total: number
386 hasMore: boolean
387 /** Said before the text when the field is not what the caller asked for in full. */
388 note?: string
389 /** Set when the field was cut at the row: the whole original is in the overflow files. */
390 overflow?: Overflow
391 }
392
393type FoundResult = Extract<ReadResult, { found: true }>
394
395/** What `history` read can page through: a row's text, or the arguments of the call behind it. */
396export type ReadField = 'text' | 'input'
397
398const READ_LENGTH_DEFAULT = 4_000
399const READ_LENGTH_MAX = 20_000
400
401/** A count that must be a finite number to be usable, floor included; otherwise the fallback. */
402const countOr = (value: unknown, fallback: number): number =>
403 typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback
404
405/** The slice a read of `total` characters covers, with the same defaults and caps for every field. */
406export const sliceBounds = (total: number, offset: unknown, length: unknown): { start: number; end: number } => {
407 const start = Math.max(0, countOr(offset, 0))
408 const asked = countOr(length, READ_LENGTH_DEFAULT)
409
410 return { start, end: Math.min(total, start + Math.max(0, Math.min(asked, READ_LENGTH_MAX))) }
411}
412
413/**
414 * Reads one record's text in slices, so a clipped-in-shard row can be paged
415 * back in full without pulling megabytes through a single tool result. The
416 * seq, offset and length come from a cast tool input, so a value that is not a
417 * finite number reads as "not found", "from the start" and "the default slice"
418 * rather than as a NaN that would silently slice nothing. What a row keeps
419 * inline is all this reads: when it names an `overflow`, the caller reads the
420 * files behind it and slices there.
421 */
422export const readRecord = (
423 rows: readonly HistoryRecord[],
424 seq: number,
425 offset = 0,
426 length = READ_LENGTH_DEFAULT,
427 field: ReadField = 'text',
428): ReadResult => {
429 if (typeof seq !== 'number' || !Number.isFinite(seq)) return { found: false }
430 const row = rows.find(candidate => candidate.seq === seq)
431 if (row === undefined) return { found: false }
432
433 const isInput = field === 'input'
434 // A row from before the arguments were kept has only the summary, and says so.
435 const body = isInput ? row.args ?? row.input ?? '' : row.text
436 const { start, end } = sliceBounds(body.length, offset, length)
437
438 const result: FoundResult = {
439 found: true,
440 seq: row.seq,
441 window: row.window,
442 role: row.role,
443 ts: row.ts,
444 field,
445 text: body.slice(start, end),
446 total: body.length,
447 hasMore: end < body.length,
448 }
449 if (row.tool !== undefined) result.tool = row.tool
450 if (row.path !== undefined) result.path = row.path
451 if (isInput && row.args === undefined) {
452 result.note =
453 row.input === undefined ? '这一行没有记录调用参数。' : '这是旧记录,只留了参数摘要,没有参数全文。'
454 }
455 const overflow = isInput ? row.overflow?.args : row.overflow?.text
456 if (overflow !== undefined) result.overflow = overflow
457
458 return result
459}
460
461export type Action = { seq: number; tool: string; path?: string; input?: string; isError: boolean; head: string }
462
463const HEAD_CHARS = 80
464
465/**
466 * The tail of one window's tool calls, oldest first, as a short recap of what was
467 * done. This mod's own calls are dropped before the tail is taken, so the count
468 * is spent on the work rather than on the ledger and history calls that record it.
469 */
470export const recentActions = (
471 rows: readonly HistoryRecord[],
472 window: number,
473 count: number,
474): Action[] => {
475 if (count <= 0) return []
476
477 const picked = [...rows]
478 .filter(row => row.window === window && row.role === 'tool' && !isOwnRow(row))
479 .sort((a, b) => a.seq - b.seq)
480 .slice(-count)
481
482 return picked.map(row => {
483 const action: Action = {
484 seq: row.seq,
485 tool: row.tool ?? '',
486 isError: row.isError === true,
487 head: oneLine(row.text.slice(0, HEAD_CHARS)),
488 }
489 if (row.path !== undefined) action.path = row.path
490 if (row.input !== undefined) action.input = row.input
491
492 return action
493 })
494}
495
496/** One deferred tool a window loaded and used: an MCP server's tools share one line. */
497export type LoadedTool = { name: string; calls: number }
498
499const MAX_LOADED_TOOLS = 8
500const MCP_PREFIX = 'mcp__'
501const SELECT_PREFIX = 'select:'
502
503/**
504 * The group a tool is listed under: an MCP tool under its server (`mcp__<server>__*`),
505 * any other name stands alone. Exported so the cache measure and the handoff name a
506 * load by the same group.
507 */
508export const toolGroupOf = (name: string): string => {
509 if (!name.startsWith(MCP_PREFIX)) return name
510 const rest = name.slice(MCP_PREFIX.length)
511 const cut = rest.indexOf('__')
512
513 return cut <= 0 || cut + 2 >= rest.length ? name : `${MCP_PREFIX}${rest.slice(0, cut)}__*`
514}
515
516/**
517 * The groups a ToolSearch query would load, out of the `known` ones, in `known`'s order. A
518 * `select:` query names its tools. Any other query is a keyword, matched against each group's
519 * core name (the server of an MCP group, the name of any other); the dashes and underscores
520 * of both sides are read as spaces in a second try, so `computer use` finds `computer-use`.
521 */
522export const groupsWanted = (query: string, known: readonly string[]): string[] => {
523 const groups = [...new Set(known)]
524 const text = query.trim()
525 if (text.startsWith(SELECT_PREFIX)) {
526 const named = new Set(
527 text
528 .slice(SELECT_PREFIX.length)
529 .split(',')
530 .map(part => part.trim())
531 .filter(part => part !== '')
532 .map(toolGroupOf),
533 )
534
535 return groups.filter(group => named.has(group))
536 }
537
538 const needle = text.toLowerCase()
539 const spaced = needle.replace(/[-_]/g, ' ')
540
541 return groups.filter(group => {
542 const core = (group.startsWith(MCP_PREFIX) && group.endsWith('__*') ? group.slice(MCP_PREFIX.length, -3) : group).toLowerCase()
543
544 return needle.includes(core) || spaced.includes(core.replace(/[-_]/g, ' '))
545 })
546}
547
548/**
549 * The names one ToolSearch row loaded. Rows written before `loaded` existed only
550 * kept the query, so for those the `select:` names are read back out of it.
551 */
552const loadedOf = (row: HistoryRecord): string[] => {
553 if (Array.isArray(row.loaded)) return row.loaded.filter(name => typeof name === 'string')
554 const query = row.input ?? ''
555 if (!query.startsWith(SELECT_PREFIX)) return []
556
557 return query
558 .slice(SELECT_PREFIX.length)
559 .split(',')
560 .map(part => part.trim())
561 .filter(part => part !== '')
562}
563
564/**
565 * The deferred tools one window loaded and then actually called, most useful
566 * first by when they were loaded. A tool loaded and never used is left out, and
567 * an MCP server's tools are summed into one line so a big toolkit stays short.
568 */
569export const loadedTools = (rows: readonly HistoryRecord[], window: number): LoadedTool[] => {
570 const inWindow = [...rows]
571 .filter(row => row.window === window && row.role === 'tool')
572 .sort((a, b) => a.seq - b.seq)
573
574 const calls = new Map<string, number>()
575 for (const row of inWindow) {
576 const name = row.tool ?? ''
577 calls.set(name, (calls.get(name) ?? 0) + 1)
578 }
579
580 const loaded = new Set<string>()
581 for (const row of inWindow) {
582 if (row.tool !== 'ToolSearch') continue
583 for (const name of loadedOf(row)) loaded.add(name)
584 }
585
586 const tools = new Map<string, number>()
587 for (const name of loaded) {
588 const used = calls.get(name) ?? 0
589 if (used === 0) continue
590 const key = toolGroupOf(name)
591 tools.set(key, (tools.get(key) ?? 0) + used)
592 }
593
594 return [...tools]
595 .slice(0, MAX_LOADED_TOOLS)
596 .map(([name, count]) => ({ name, calls: count }))
597}
598
599/**
600 * The person's own last prompts, across windows: what to carry into a new window.
601 * Only rows marked as a person's count, so the engine's deliveries, notices and
602 * attachments never become a request; a message typed mid-turn is marked when
603 * the hook recognises it.
604 */
605export const personRequests = (rows: readonly HistoryRecord[], count: number): HistoryRecord[] => {
606 if (count <= 0) return []
607
608 return [...rows]
609 .filter(row => row.isPerson === true)
610 .sort((a, b) => a.seq - b.seq)
611 .slice(-count)
612}
613
614/**
615 * The model's reply each request came after, by the request's seq: a "可以" says
616 * nothing without the question it answers. `rows` are in seq order.
617 *
618 * Only a reply made since the person last spoke counts: a second message in a row,
619 * or one typed mid-turn before the model said anything, answers no new reply, and
620 * pointing it at the previous turn's would mislead. Such a request is left out.
621 */
622export const repliesAnswered = (
623 rows: readonly HistoryRecord[],
624 requests: readonly HistoryRecord[],
625): Map<number, HistoryRecord> => {
626 const answered = new Map<number, HistoryRecord>()
627 for (const request of requests) {
628 let reply: HistoryRecord | undefined
629 for (const row of rows) {
630 if (row.seq >= request.seq) break
631 if (row.isPerson === true) reply = undefined
632 // A row with a door is the engine's delivery or note, never the model's own reply.
633 else if (row.role === 'assistant' && row.door === undefined && row.text.trim() !== '') reply = row
634 }
635 if (reply !== undefined) answered.set(request.seq, reply)
636 }
637
638 return answered
639}
640
641/** One whole-file write waiting its turn. */
642type Job = {
643 path: string
644 text: string
645 done: Promise<boolean>
646 finish: (ok: boolean) => void
647}
648
649export type Queue = {
650 /**
651 * Queues one whole-file write, merging into a same-path write that has not started.
652 * Resolves to whether the write that finally ran succeeded, so a caller can tell a
653 * file on disk from one that only exists in memory; it never rejects.
654 */
655 push: (path: string, text: string) => Promise<boolean>
656 /** Resolves once every write queued so far has finished. */
657 idle: () => Promise<void>
658}
659
660/**
661 * Serializes whole-file writes, since the engine can only overwrite a file
662 * whole: one write runs at a time, a not-yet-started write to the same path is
663 * replaced by the newer text, and a failing write is reported but never stalls
664 * the queue or rejects the caller's promise.
665 */
666export const createQueue = (
667 write: (path: string, text: string) => Promise<void>,
668 onError?: (path: string, error: unknown) => void,
669): Queue => {
670 const pending: Job[] = []
671 const idlers: (() => void)[] = []
672 let isRunning = false
673
674 const settleIdle = (): void => {
675 if (isRunning || pending.length > 0) return
676 while (idlers.length > 0) idlers.pop()?.()
677 }
678
679 /**
680 * Reports a failed write without letting the reporter break the queue: a
681 * throwing `onError` would otherwise escape the run loop and leave every
682 * queued push, and its promise, waiting forever.
683 */
684 const report = (path: string, error: unknown): void => {
685 try {
686 onError?.(path, error)
687 } catch {
688 // The reporter's own failure is the caller's to notice, not the queue's to stall on.
689 }
690 }
691
692 const run = async (): Promise<void> => {
693 if (isRunning) return
694 isRunning = true
695 try {
696 for (;;) {
697 const job = pending.shift()
698 if (job === undefined) break
699 let ok = false
700 try {
701 await write(job.path, job.text)
702 ok = true
703 } catch (error) {
704 report(job.path, error)
705 } finally {
706 // Settled on either path, so no push is ever left hanging.
707 job.finish(ok)
708 }
709 }
710 } finally {
711 isRunning = false
712 settleIdle()
713 }
714 }
715
716 const push = (path: string, text: string): Promise<boolean> => {
717 const open = pending.find(job => job.path === path)
718 if (open !== undefined) {
719 open.text = text
720 return open.done
721 }
722
723 let settle = (_ok: boolean): void => {}
724 const done = new Promise<boolean>(resolve => {
725 settle = resolve
726 })
727 const job: Job = { path, text, done, finish: ok => settle(ok) }
728 pending.push(job)
729 // The loop already contains its own failures; the catch is the last guard
730 // that a drain can never surface as an unhandled rejection.
731 void run().catch(() => {})
732
733 return job.done
734 }
735
736 const idle = (): Promise<void> =>
737 new Promise<void>(resolve => {
738 if (!isRunning && pending.length === 0) {
739 resolve()
740 return
741 }
742 idlers.push(resolve)
743 })
744
745 return { push, idle }
746}
747hooks/lib/session.ts 2104 lines1// One session's state: it owns the files under `dir`, wires the pure library
2// modules together and is the only thing the engine hooks talk to. Nothing here
3// reaches the engine, the clock or the disk on its own — every file and every
4// timestamp arrives through the injected `Io`, so the module runs in a bare
5// interpreter with no fs, no DOM and no timers.
6
7import type {
8 Anchor,
9 CacheState,
10 Config,
11 FileActivity,
12 HistoryRecord,
13 LedgerEntry,
14 LedgerInput,
15 Overflow,
16 Role,
17 TaskStatus,
18} from './types.ts'
19import { estimateTokens, isRelay, parseJsonl, toJsonl } from './types.ts'
20import {
21 appendRecord,
22 byteLength,
23 clipRecord,
24 createQueue,
25 emptyShard,
26 loadedTools,
27 personRequests,
28 repliesAnswered,
29 readRecord,
30 recentActions,
31 search,
32 groupsWanted,
33 isOwnRow,
34 overflowName,
35 partOrigin,
36 partsCovering,
37 shardName,
38 sliceBounds,
39 splitParts,
40 toolGroupOf,
41} from './history.ts'
42import type { LoadedTool, SearchHit, Shard } from './history.ts'
43import { archiveName, hasFreshEntry, pageOf, renderHome, stamp, tasksOf, turnPage, validate } from './ledger.ts'
44import type { AnchorCheck, ArchivePage, TaskState } from './ledger.ts'
45import { activityOf, filesOf, locate, pathKey, pathsToRead, relativePath, renderWorkset, snippetsOf } from './workset.ts'
46import type { FileContent, OpenTask } from './workset.ts'
47import { assemble, ENDED_STATE, renderAgents, renderCoverage, renderLoadReason, renderTools, rewriteOf } from './handoff.ts'
48import type { RebuiltTool } from './handoff.ts'
49import { describeFill, gateSwitch, isCold, judgeSwitch, levelOf, percentOf } from './usage.ts'
50
51export type Io = {
52 read: (path: string) => Promise<string>
53 write: (path: string, text: string) => Promise<void>
54 now: () => number
55 /** Told about every failed write, so the caller can log what the queue would otherwise swallow. */
56 onWriteError?: (path: string, error: unknown) => void
57 /** Whether the path exists; lets a failed read be told from an absent file. */
58 exists?: (path: string) => Promise<boolean>
59 /** Moves `from` over `to` in one step; without it a file is written in place. */
60 rename?: (from: string, to: string) => Promise<void>
61 /** Deletes the file; a missing file is not an error. */
62 remove?: (path: string) => Promise<void>
63 /** The names of the files in a directory; without it only the older shared table is read. */
64 list?: (path: string) => Promise<readonly string[]>
65 /**
66 * The session's working directory. Only the handoff text uses it, to show paths inside it
67 * relative to it; what is stored on disk keeps the absolute paths. Left out, paths stay as they are.
68 */
69 cwd?: () => string | undefined | Promise<string | undefined>
70}
71
72export type Env = {
73 /** The context tokens the engine last reported; unknown on the first request. */
74 tokens: number | undefined
75 /** The compaction threshold the fill is measured against. */
76 limit: number
77 /** cache-keeper's published state, when that mod is loaded at all. */
78 cache: CacheState | null
79 fiveHourPercent: number | undefined
80}
81
82export type ToolResult = { result: string; isError?: true }
83
84/** A switch the model asked for and the gate stood behind, until the turn ends. */
85export type Pending = { reason: string; at: number }
86
87/** One conversation row as the engine hands it over; seq, ts and window are ours. */
88export type RecordInput = {
89 uuid: string
90 role: Role
91 isPerson?: true
92 /** The engine's origin kind of a prompt row, when the caller has it. */
93 origin?: string
94 tool?: string
95 path?: string
96 /** A one-line summary of the tool call's input, when the caller has one. */
97 input?: string
98 isError?: true
99 /** The deferred tools a ToolSearch call loaded, when the caller has them. */
100 loaded?: string[]
101 /** The door of a row outside prompt, response and tool-result; those three carry none. */
102 door?: string
103 /** The attachment type behind such a row. */
104 name?: string
105 /** The tool call's arguments in full, as `formatArgs` writes them. */
106 args?: string
107 /** The `tool_use_id` of the call behind a tool row. */
108 callId?: string
109 text: string
110}
111
112/**
113 * One subagent as the handoff needs it. The lib declares its own shape instead
114 * of importing the engine's `AgentInfo`, so it stays loadable without the
115 * `claude-code` types; the register maps the engine's rows onto this.
116 */
117export type AgentNote = { id: string; type: string; description: string; state: string }
118
119/**
120 * A subagent the main loop started, kept in the session: the engine lists an agent
121 * only while it runs, but SendMessage still reaches it by `id` after it ends, so a
122 * later window has to be told about it. `ended` is set when its own turn completes.
123 */
124export type AgentSeen = {
125 id: string
126 type: string
127 description: string
128 model?: string
129 window: number
130 ts: number
131 ended?: boolean
132}
133
134/**
135 * A conversation row as the engine hands it over, kept to the fields this mod
136 * files: the lib declares its own shape rather than importing the engine's, so
137 * it stays loadable without the `claude-code` types.
138 */
139export type ImportedMessage = {
140 role: 'user' | 'assistant'
141 text: string
142 toolUses: readonly {
143 tool: string
144 input?: unknown
145 /** The tool call's input summary, computed by whoever built the message. */
146 brief?: string
147 /** The engine's id of the call, which later lines the conversation up with the rows. */
148 callId?: string
149 /** The call's arguments in full, as `formatArgs` writes them. */
150 args?: string
151 text?: string
152 isError?: boolean
153 }[]
154}
155
156/** How `openingOf` in handoff.ts begins; kept as a pattern so the window number may vary. */
157const HANDOFF_OPENING = /^这是第\s*\d+\s*个窗口,上一个窗口的对话不在上下文里/
158
159/** Whether a text is this mod's own handoff, the row a window opens with. */
160export const isHandoffText = (text: string): boolean => HANDOFF_OPENING.test(text.trim())
161
162/** Openings that are never a person's words, even from a confirmed source: a slash command is typed in the composer too. */
163const ENVELOPE_OPENINGS = [
164 '<command-name>',
165 '<command-message>',
166 '<command-args>',
167 '<local-command-stdout>',
168 '<local-command-stderr>',
169 '<local-command-caveat>',
170 '<task-notification>',
171 '<system-reminder>',
172] as const
173
174/** A project thread's wake, up to the tag of the message that triggered it: that tag says who wrote it. */
175const WAKE_TRIGGER = /<wake\b[^>]*>[\s\S]*?(<message\b[^>]*\btrigger="true"[^>]*>)/
176
177/**
178 * Whether a text is a wake some agent's message triggered, not a person's. A
179 * wake arrives from the same origin whoever wrote the message, so only the
180 * `from` on the triggering message tells the two apart.
181 */
182const isAgentWake = (text: string): boolean => {
183 const trigger = WAKE_TRIGGER.exec(text)?.[1]
184
185 return trigger !== undefined && !/\bfrom="human"/.test(trigger)
186}
187
188/**
189 * Whether a user row came from a person. The engine files its own notices —
190 * system reminders, command envelopes, task notifications, the continuation
191 * header — in the same door, and the handoff must not read them as a request.
192 * When the origin already says a person sent it, a text that merely starts with
193 * `<` (a pasted `<task>` or HTML) is theirs; without an origin any `<` is doubted.
194 */
195export const looksLikePerson = (text: string, isConfirmed = false): boolean => {
196 const head = text.trim()
197
198 return !(
199 (isConfirmed ? ENVELOPE_OPENINGS.some(opening => head.startsWith(opening)) : head.startsWith('<')) ||
200 isAgentWake(head) ||
201 head.startsWith('This session is being continued from a previous conversation') ||
202 head.startsWith('Caveat:') ||
203 head.startsWith('ctx-mod:') ||
204 // The opening line of this mod's own handoff, which a window starts with as a user row.
205 HANDOFF_OPENING.test(head)
206 )
207}
208
209/**
210 * The person rows as the home page shows them. Rows written before the
211 * injected-head rule may still carry the engine's `<system-reminder>` block, and
212 * the home page must show only what the person actually wrote.
213 */
214const displayRequests = (rows: readonly HistoryRecord[]): HistoryRecord[] =>
215 rows.map(row => {
216 const text = stripInjected(row.text)
217
218 return text === row.text ? row : { ...row, text }
219 })
220
221/** How long an approved switch stays valid: past this the model's turn never ended. */
222const PENDING_MS = 120_000
223
224/** The cross-session table's file name; it sits beside the sessions' own directories. */
225const COSTLY_FILE = 'costly.json'
226/** The directory of the sessions' own cross-session files; each session writes only its own. */
227const SHARED_DIR = 'costly'
228
229/** The held-back user messages, kept in the session's directory until they are delivered. */
230const HELD_FILE = 'held.json'
231
232type HeldItem = { id: number; text: string; at: number }
233
234/** Reads held.json; a missing or damaged file means nothing is held, never an error. */
235const heldFrom = (text: string | null): HeldItem[] => {
236 if (text === null) return []
237 try {
238 const parsed: unknown = JSON.parse(text)
239 const items = typeof parsed === 'object' && parsed !== null ? (parsed as { items?: unknown }).items : undefined
240 if (!Array.isArray(items)) return []
241 const kept: HeldItem[] = []
242 for (const item of items) {
243 if (typeof item !== 'object' || item === null) continue
244 const row = item as Record<string, unknown>
245 if (typeof row.id !== 'number' || !Number.isInteger(row.id) || typeof row.text !== 'string') continue
246 kept.push({ id: row.id, text: row.text, at: typeof row.at === 'number' ? row.at : 0 })
247 }
248
249 return kept.sort((a, b) => a.id - b.id)
250 } catch {
251 return []
252 }
253}
254
255/**
256 * How long a measure holds: past this the table forgets it, so a load is no longer held
257 * back by it, and a rebuild that still happens is measured and recorded again.
258 */
259const SHARED_MS = 30 * 24 * 3_600_000
260
261const REMINDER_OPEN = '<system-reminder>'
262const REMINDER_CLOSE = '</system-reminder>'
263
264/**
265 * The text with the engine's injected notes stripped off its head. The engine
266 * files a session-start hook's `<system-reminder>` block in the same door as a
267 * person's words, so a request has to be read without it; a block that is never
268 * closed is left alone, since the rest then cannot be trusted to be the person's.
269 */
270export const stripInjected = (text: string): string => {
271 let rest = text
272 for (;;) {
273 const head = rest.trimStart()
274 if (!head.startsWith(REMINDER_OPEN)) break
275 const end = head.indexOf(REMINDER_CLOSE)
276 if (end < 0) return text
277 rest = head.slice(end + REMINDER_CLOSE.length)
278 }
279
280 return rest.trim()
281}
282
283/** How much of the previous window's last assistant reply the handoff carries. */
284const LAST_REPLY_CHARS = 6_000
285
286/** What the model reads when a switch is approved: it has to end the turn now. */
287const APPROVED =
288 '已批准换窗口。请立即结束本回合:不要再调用任何工具,用一句话说明你停在哪里即可。'
289
290/** Activities kept in memory (and in meta): enough for the working set, not the whole session. */
291const MAX_ACTIVITIES = 500
292
293/** Subagents kept for the handoff; past this the oldest are dropped, which keeps meta.json small. */
294const MAX_ROSTER = 50
295
296/**
297 * A request rebuilt the cache when it read under this share of the request before it,
298 * and by more than the minimum: a small drop in a small window is not a rebuild.
299 */
300const REBUILT_READ_SHARE = 0.9
301const REBUILT_MIN_DROP = 5_000
302
303/** The assumptions used when a session has never switched and cannot say what its prefix costs. */
304const DEFAULT_PREFIX = 30_000
305const DEFAULT_HANDOFF = 8_000
306
307/** The requests assumed to remain when the model does not say more work is coming. */
308const REMAINING_REQUESTS = 100
309const REMAINING_REQUESTS_LAST = 5
310
311/** How many ledger entries one query may return. */
312const MAX_QUERY_HITS = 30
313
314/** How many windows a default history search covers. */
315const SEARCH_WINDOWS = 3
316
317const STATUS_LABEL: Record<TaskStatus, string> = {
318 in_progress: '进行中',
319 done: '已完成',
320 superseded: '已被取代',
321 blocked: '受阻',
322}
323
324type State = {
325 window: number
326 part: number
327 seq: number
328 ledgerSeq: number
329 switchTimes: number[]
330 requestsSinceSwitch: number
331 /** Only remembered in memory: a reloaded session has no live turn to measure the cache against. */
332 lastRequestAt: number | null
333 reminded: { soft: boolean; urgent: boolean }
334 archive: ArchivePage[]
335 activities: FileActivity[]
336 roster: AgentSeen[]
337 /** Tool group -> the tokens its first load rewrote, the latest such load's; persisted, so a reloaded session still knows. */
338 costlyLoads: Record<string, number>
339 /** Groups a ToolSearch loaded since the last main-loop request: not persisted, judged by the next request's usage. */
340 pendingLoads: string[]
341 /** The main loop's last request context (input + read + write); not persisted. Null at a window's start. */
342 lastContext: number | null
343 /** Groups the gate has held back in this window: each is held back once, the second try goes through. Not persisted. */
344 gateAsked: string[]
345 /** Groups the gate held back and no load has taken yet: a switch made now is for them. Not persisted. */
346 gateWanted: string[]
347 lastPrefix: number | null
348 lastHandoff: number | null
349 /** The current ledger page, exactly what ledger.jsonl holds. */
350 page: LedgerEntry[]
351 pending: Pending | null
352}
353
354export type Session = {
355 record: (row: RecordInput) => Promise<HistoryRecord>
356 activity: (tool: string, input: Readonly<Record<string, unknown>>, seq: number, isError: boolean) => void
357 noteRequest: () => void
358 reminder: (env: Env) => string | null
359 ledgerAppend: (input: unknown, env: Env) => Promise<ToolResult>
360 ledgerRead: (input: unknown) => Promise<ToolResult>
361 historyTool: (input: unknown) => Promise<ToolResult>
362 status: (env: Env) => string
363 requestSwitch: (input: unknown, env: Env) => ToolResult
364 /**
365 * Holds back a ToolSearch that would load a costly group while the context is large: the
366 * returned text sends the model to switch windows first. `null` lets the call through.
367 * `await ready()` first.
368 */
369 gateLoad: (query: unknown, tokens: number | undefined) => string | null
370 /** Loads the files and the recent requests: `await` it before the sync readers. */
371 ready: () => Promise<void>
372 /** The switch the model is waiting for, if any; `await ready()` is not needed (memory only). */
373 pending: () => Pending | null
374 clearPending: () => void
375 /** Copies a conversation that predates this mod; a no-op once anything is on disk. */
376 importMessages: (messages: readonly ImportedMessage[]) => Promise<number>
377 buildHandoff: (opts: { isMidTurn: boolean; agents?: readonly AgentNote[] }) => Promise<string>
378 commitSwitch: (info: { trigger: string; tokensBefore: number | undefined; handoffText: string }) => Promise<void>
379 notePrefix: (tokens: number) => void
380 /** The window the conversation is in; `await ready()` first, or it reads 1. */
381 window: () => number
382 /** Waits for the queued writes, then retries once whatever failed to write. */
383 flush: () => Promise<void>
384 /** The files (names only, sorted) whose last write failed and has not succeeded since. */
385 unsaved: () => string[]
386 /**
387 * Keeps a user's message on disk before it is held back, so a reload cannot lose it. Resolves to
388 * the item's id, or null when the write failed and the message must not be held.
389 */
390 hold: (text: string) => Promise<number | null>
391 /** The held messages, oldest first; `await ready()` first. */
392 heldItems: () => { id: number; text: string; at: number }[]
393 /** Drops one held message and writes the rest back; an unknown id changes nothing. */
394 release: (id: number) => Promise<void>
395 /** The latest person prompt's seq; `await ready()` first. */
396 latestPersonSeq: () => number | null
397 /** The latest hand-off's seq (a coordinator's or a schedule's, never a person's); `await ready()` first. */
398 latestRelaySeq: () => number | null
399 /** Whether the ledger gained an entry after the latest person prompt of this window; `await ready()` first. */
400 isLedgerFresh: () => boolean
401 /** Whether a compaction may skip the engine's summary: a fresh ledger with a short unrecorded tail; `await ready()` first. */
402 canSkipSummary: () => boolean
403 /** The time of the last noted request, or null before the first one; `await ready()` first. */
404 lastRequestAt: () => number | null
405 /** Notes a subagent the main loop started, by id; a repeat of the id updates its row. `await ready()` is not needed. */
406 noteAgent: (input: { id: string; type: string; description: string; model?: string }) => Promise<void>
407 /** Tags a noted subagent as ended; an id never noted changes nothing. */
408 agentEnded: (id: string) => Promise<void>
409 /** Reads one main-loop request's usage: a request right after a deferred load that rewrote the cache names that load's group. `await ready()` is not needed. */
410 noteStep: (usage: { input: number; read: number; write: number }) => Promise<void>
411 /** The costly groups this session judges by: the cross-session table with this session's own measures on top. `await ready()` first. */
412 costlyGroups: () => Record<string, number>
413}
414
415const emptyState = (): State => ({
416 window: 1,
417 part: 0,
418 seq: 0,
419 ledgerSeq: 0,
420 switchTimes: [],
421 requestsSinceSwitch: 0,
422 lastRequestAt: null,
423 reminded: { soft: false, urgent: false },
424 archive: [],
425 activities: [],
426 roster: [],
427 costlyLoads: {},
428 pendingLoads: [],
429 lastContext: null,
430 gateAsked: [],
431 gateWanted: [],
432 lastPrefix: null,
433 lastHandoff: null,
434 page: [],
435 pending: null,
436})
437
438const errorOf = (result: string): ToolResult => ({ isError: true, result })
439
440/** The message of whatever a failing `io.read` threw, whatever shape it had. */
441const messageOf = (problem: unknown): string => {
442 if (problem instanceof Error) return problem.message
443 if (typeof problem === 'string') return problem
444
445 return ''
446}
447
448const asInt = (value: unknown, fallback: number): number =>
449 typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : fallback
450
451const asNumberArray = (value: unknown): number[] =>
452 Array.isArray(value) ? value.filter((item): item is number => typeof item === 'number') : []
453
454const asRecordArray = <T>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
455
456/** One roster row as meta.json holds it, or null when it lacks what a row needs. */
457const seenOf = (value: unknown): AgentSeen | null => {
458 if (typeof value !== 'object' || value === null) return null
459 const row = value as Record<string, unknown>
460 if (typeof row.id !== 'string' || typeof row.type !== 'string' || typeof row.description !== 'string') return null
461 if (typeof row.window !== 'number' || !Number.isFinite(row.window)) return null
462 if (typeof row.ts !== 'number' || !Number.isFinite(row.ts)) return null
463
464 return {
465 id: row.id,
466 type: row.type,
467 description: row.description,
468 ...(typeof row.model === 'string' ? { model: row.model } : {}),
469 window: row.window,
470 ts: row.ts,
471 ...(row.ended === true ? { ended: true } : {}),
472 }
473}
474
475/** A meta.json from before the roster has none, which reads as an empty roster; a damaged row is dropped alone. */
476const rosterFrom = (value: unknown): AgentSeen[] =>
477 (Array.isArray(value) ? value : []).map(seenOf).filter((seen): seen is AgentSeen => seen !== null).slice(-MAX_ROSTER)
478
479/**
480 * The costly loads as meta.json holds them. A meta.json from before the measure has
481 * none, and a damaged entry is dropped alone: only a finite, positive count is a rewrite.
482 */
483const costlyFrom = (value: unknown): Record<string, number> => {
484 if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}
485 const costly: Record<string, number> = {}
486 for (const [name, rewritten] of Object.entries(value)) {
487 if (typeof rewritten === 'number' && Number.isFinite(rewritten) && rewritten > 0) costly[name] = rewritten
488 }
489
490 return costly
491}
492
493/** One group's measure as the cross-session table holds it; a hand-written entry may leave `context` out. */
494type SharedCost = { write: number; context?: number; at: number }
495
496/** Where the cross-session measures are: the older shared table, and each session's own file under one directory. */
497type SharedPaths = { legacy: string; own: string; shards: string }
498
499/**
500 * The paths of the cross-session measures: one level above this session's directory, so every
501 * session under the same mod directory reads the same measures. Each session only ever writes its
502 * own file, so no two writers share one. A root with no slash has no parent, and then there are none.
503 */
504const sharedPathsOf = (root: string): SharedPaths | null => {
505 const cut = root.lastIndexOf('/')
506 if (cut < 0) return null
507 const dir = root.slice(0, cut)
508 const shards = `${dir}/${SHARED_DIR}`
509
510 return { legacy: `${dir}/${COSTLY_FILE}`, own: `${shards}/${root.slice(cut + 1)}.json`, shards }
511}
512
513/**
514 * The table as the file holds it, keeping only the measures that are a finite positive
515 * rewrite stamped within the expiry. A missing or damaged file is an empty table, never a throw.
516 */
517const sharedFrom = (text: string | null, now: number): Record<string, SharedCost> => {
518 const table: Record<string, SharedCost> = {}
519 if (text === null) return table
520 let parsed: unknown
521 try {
522 parsed = JSON.parse(text)
523 } catch {
524 return table
525 }
526 const groups =
527 typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>).groups : undefined
528 if (typeof groups !== 'object' || groups === null || Array.isArray(groups)) return table
529 for (const [name, value] of Object.entries(groups)) {
530 if (typeof value !== 'object' || value === null) continue
531 const { write, context, at } = value as Record<string, unknown>
532 if (typeof write !== 'number' || !Number.isFinite(write) || write <= 0) continue
533 if (typeof at !== 'number' || !Number.isFinite(at) || now - at > SHARED_MS) continue
534 table[name] = {
535 write,
536 ...(typeof context === 'number' && Number.isFinite(context) ? { context } : {}),
537 at,
538 }
539 }
540
541 return table
542}
543
544/**
545 * The table as the file holds it, or null when it is there but cannot be trusted: unreadable is
546 * not the same as empty, and a file that cannot be trusted is never written over.
547 */
548const tableOf = (text: string | null | undefined, now: number): Record<string, SharedCost> | null => {
549 if (text === null) return {}
550 if (text === undefined) return null
551 try {
552 const parsed: unknown = JSON.parse(text)
553 const groups = typeof parsed === 'object' && parsed !== null ? (parsed as { groups?: unknown }).groups : undefined
554 if (typeof groups !== 'object' || groups === null || Array.isArray(groups)) return null
555 } catch {
556 return null
557 }
558
559 return sharedFrom(text, now)
560}
561
562/** Keeps, for each group, the newer of the two measures: the last one written wins whichever file holds it. */
563const newerInto = (into: Record<string, SharedCost>, from: Record<string, SharedCost>): void => {
564 for (const [name, cost] of Object.entries(from)) {
565 const held = into[name]
566 if (held === undefined || held.at < cost.at) into[name] = cost
567 }
568}
569
570/**
571 * Every measure the sessions have written, and this session's own file. A file that cannot be read
572 * is left out; a session's own file that cannot be trusted comes back as null, so it is not written over.
573 */
574const readShared = async (
575 io: Io,
576 paths: SharedPaths,
577): Promise<{ table: Record<string, SharedCost>; own: Record<string, SharedCost> | null }> => {
578 const now = io.now()
579 const table = tableOf(await readAt(io, paths.legacy), now) ?? {}
580 let names: readonly string[] = []
581 if (io.list !== undefined) {
582 try {
583 names = await io.list(paths.shards)
584 } catch {
585 names = []
586 }
587 }
588 for (const name of names) {
589 if (!name.endsWith('.json')) continue
590 newerInto(table, tableOf(await readAt(io, `${paths.shards}/${name}`), now) ?? {})
591 }
592 const own = tableOf(await readAt(io, paths.own), now)
593 newerInto(table, own ?? {})
594
595 return { table, own }
596}
597
598/**
599 * The file's text: null when the file is absent, undefined when it cannot be read though it may be
600 * there. Only an absent file is empty; one that failed to read must not be written over.
601 */
602const readAt = async (io: Io, path: string): Promise<string | null | undefined> => {
603 try {
604 return await io.read(path)
605 } catch (problem) {
606 const isGone = io.exists === undefined ? isMissingError(problem) : !(await io.exists(path).catch(() => true))
607
608 return isGone ? null : undefined
609 }
610}
611
612/** Whether a failed read says the file is simply not there (as opposed to unreadable). */
613const isMissingError = (error: unknown): boolean => {
614 if (typeof error !== 'object' || error === null) return false
615 const { code, message } = error as { code?: unknown; message?: unknown }
616 if (code === 'ENOENT' || code === 'ENOTDIR') return true
617
618 return typeof message === 'string' && /ENOENT|no such file|not found|does not exist/i.test(message)
619}
620
621const sharedJson = (table: Record<string, SharedCost>): string => JSON.stringify({ version: 1, groups: table })
622
623/** A tool use's arguments, as the activity reader and the path reader both want them. */
624const asInput = (value: unknown): Readonly<Record<string, unknown>> =>
625 typeof value === 'object' && value !== null && !Array.isArray(value)
626 ? (value as Record<string, unknown>)
627 : {}
628
629/** The file a tool call named, in the order the engine's own tools spell it. */
630const pathOf = (input: Readonly<Record<string, unknown>>): string | undefined => {
631 for (const key of ['file_path', 'notebook_path', 'path']) {
632 if (typeof input[key] === 'string') return input[key] as string
633 }
634
635 return undefined
636}
637
638const k = (tokens: number): number => Math.round(tokens / 1000)
639
640/** A damaged meta.json is an empty session, never a throw. */
641/** The handoff a window opened with; every switch writes one, so it also marks that the window exists. */
642const handoffName = (window: number): string => `handoff-w${String(window).padStart(3, '0')}.md`
643
644const stateFrom = (raw: Record<string, unknown> | null): State => {
645 const state = emptyState()
646 if (raw === null) return state
647
648 state.window = Math.max(1, asInt(raw.window, 1))
649 state.part = asInt(raw.part, 0)
650 state.seq = asInt(raw.seq, 0)
651 state.ledgerSeq = asInt(raw.ledgerSeq, 0)
652 state.switchTimes = asNumberArray(raw.switchTimes)
653 state.requestsSinceSwitch = asInt(raw.requestsSinceSwitch, 0)
654 state.lastRequestAt = typeof raw.lastRequestAt === 'number' && Number.isFinite(raw.lastRequestAt) ? raw.lastRequestAt : null
655 state.archive = asRecordArray<ArchivePage>(raw.archive)
656 state.activities = asRecordArray<FileActivity>(raw.activities)
657 state.roster = rosterFrom(raw.roster)
658 state.costlyLoads = costlyFrom(raw.costlyLoads)
659 state.lastPrefix = typeof raw.lastPrefix === 'number' ? raw.lastPrefix : null
660 state.lastHandoff = typeof raw.lastHandoff === 'number' ? raw.lastHandoff : null
661 const reminded = typeof raw.reminded === 'object' && raw.reminded !== null ? (raw.reminded as Record<string, unknown>) : {}
662 state.reminded = { soft: reminded.soft === true, urgent: reminded.urgent === true }
663
664 return state
665}
666
667/**
668 * The subagents the handoff names: the engine's live list as it gave it, then the
669 * noted ones it no longer lists, as ended and newest first. An id both sides hold
670 * keeps the engine's row, so each agent is named once.
671 */
672const agentsForHandoff = (live: readonly AgentNote[], roster: readonly AgentSeen[]): AgentNote[] => {
673 const listed = new Set(live.map(agent => agent.id))
674 const ended = roster
675 .filter(seen => !listed.has(seen.id))
676 .sort((left, right) => right.ts - left.ts)
677 .map((seen): AgentNote => ({ id: seen.id, type: seen.type, description: seen.description, state: ENDED_STATE }))
678
679 return [...live, ...ended]
680}
681
682/**
683 * The deferred tools the window loaded and used whose first load rebuilt the cache,
684 * each with the tokens it rewrote. A group no request has measured is left out: the
685 * handoff advises only the loads that cost the whole cached prefix.
686 */
687const costlyToolsOf = (tools: readonly LoadedTool[], costly: Record<string, number>): RebuiltTool[] =>
688 tools.flatMap(tool => {
689 if (!Object.hasOwn(costly, tool.name)) return []
690 const rebuilt = costly[tool.name]
691
692 return rebuilt === undefined ? [] : [{ name: tool.name, calls: tool.calls, rebuilt }]
693 })
694
695/** The most unrecorded steps the handoff lists one by one. */
696const MAX_TAIL_ACTIONS = 25
697
698export const createSession =(dir: string, io: Io, config: Config): Session => {
699 const root = dir.replace(/\/+$/, '')
700 const pathIn = (name: string): string => `${root}/${name}`
701 /**
702 * Paths whose last write failed, with the text that did not land: a read that comes
703 * back empty can say why, and `flush` can try the same content once more.
704 */
705 const failedWrites = new Map<string, string>()
706 /** Writes through to the engine, keeping the failure visible to the readers. */
707 const writeThrough = async (path: string, text: string): Promise<void> => {
708 try {
709 await io.write(path, text)
710 failedWrites.delete(path)
711 } catch (problem) {
712 failedWrites.set(path, text)
713 try {
714 io.onWriteError?.(path, problem)
715 } catch {
716 // A reporter that throws must not change how the write fails.
717 }
718
719 throw problem
720 }
721 }
722 const queue = createQueue(writeThrough)
723 const state = emptyState()
724 /** Every shard this session has in memory, the current one included: old windows stay searchable. */
725 const shards = new Map<string, Shard>()
726 /** Windows whose every part is in `shards`. */
727 const fullWindows = new Set<number>()
728 const archives = new Map<number, LedgerEntry[]>()
729 /**
730 * The events file as this session knows it. Its own reads go straight to the
731 * disk and miss a write still queued, so every switch appends to this text.
732 */
733 let eventsText: string | null = null
734 /** Where the cross-session measures are, or null when this session's directory has no parent to hold them. */
735 const shared = sharedPathsOf(root)
736 /** The measures as this session last read or wrote them: never persisted, read again at each load. */
737 let sharedCostly: Record<string, SharedCost> = {}
738 /** This session's own measures as its file holds them; null when that file cannot be trusted, so it is not written. */
739 let ownCostly: Record<string, SharedCost> | null = {}
740 /** The messages held back and not yet delivered, as held.json has them. */
741 let held: HeldItem[] = []
742 const heldJson = (): string => JSON.stringify({ items: held })
743
744 const readText = async (path: string): Promise<string | null> => {
745 try {
746 return await io.read(path)
747 } catch {
748 // Absent, unreadable or too large: every caller here treats them the same.
749 return null
750 }
751 }
752
753 const allRows = (): HistoryRecord[] => {
754 const rows: HistoryRecord[] = []
755 for (const shard of shards.values()) rows.push(...shard.rows)
756
757 return rows.sort((left, right) => left.seq - right.seq)
758 }
759
760 const bySeq = (seq: number): HistoryRecord | undefined => {
761 for (const shard of shards.values()) {
762 const found = shard.rows.find(row => row.seq === seq)
763 if (found !== undefined) return found
764 }
765
766 return undefined
767 }
768
769 const latestPersonRow = (): HistoryRecord | null => {
770 let best: HistoryRecord | null = null
771 for (const shard of shards.values()) {
772 for (const row of shard.rows) {
773 if (row.isPerson !== true) continue
774 if (best === null || row.seq > best.seq) best = row
775 }
776 }
777
778 return best
779 }
780
781 /** The latest hand-off among the loaded rows: a task's source in a session no person started. */
782 const latestRelayRow = (): HistoryRecord | null => {
783 let best: HistoryRecord | null = null
784 for (const shard of shards.values()) {
785 for (const row of shard.rows) {
786 if (!isRelay(row)) continue
787 if (best === null || row.seq > best.seq) best = row
788 }
789 }
790
791 return best
792 }
793
794 const latestAssistantRow = (): HistoryRecord | null => {
795 let best: HistoryRecord | null = null
796 for (const shard of shards.values()) {
797 for (const row of shard.rows) {
798 // A row with a door is the engine's delivery or note, never the model's own reply.
799 if (row.role !== 'assistant' || row.door !== undefined) continue
800 if (best === null || row.seq > best.seq) best = row
801 }
802 }
803
804 return best
805 }
806
807 const metaJson = (): string =>
808 JSON.stringify({
809 window: state.window,
810 part: state.part,
811 seq: state.seq,
812 ledgerSeq: state.ledgerSeq,
813 switchTimes: state.switchTimes,
814 requestsSinceSwitch: state.requestsSinceSwitch,
815 reminded: state.reminded,
816 archive: state.archive,
817 activities: state.activities,
818 roster: state.roster,
819 costlyLoads: state.costlyLoads,
820 lastRequestAt: state.lastRequestAt,
821 lastPrefix: state.lastPrefix,
822 lastHandoff: state.lastHandoff,
823 })
824
825 /** Only ever writes what is loaded: saving before the load would erase the file. */
826 const saveMeta = (): void => {
827 if (!loaded) return
828 void queue.push(pathIn('meta.json'), metaJson())
829 }
830
831 /**
832 * Reads one window's shards from part 0 up to the first absent part, and says
833 * how many there were. A shard's size says nothing about a later part: a
834 * rotation happens before the ceiling is reached, so a rotated shard is
835 * always under it.
836 */
837 const readWindow = async (window: number): Promise<number> => {
838 let parts = 0
839 for (; ; parts += 1) {
840 const name = shardName(window, parts)
841 const text = await readText(pathIn(name))
842 if (text === null) break
843 shards.set(name, { window, part: parts, rows: parseJsonl<HistoryRecord>(text), bytes: byteLength(text) })
844 }
845
846 return parts
847 }
848
849 const loadWindow = async (window: number): Promise<void> => {
850 if (fullWindows.has(window)) return
851 await readWindow(window)
852 fullWindows.add(window)
853 }
854
855 /** The highest seq among one window's rows in memory, or 0 when it has none. */
856 const topSeqOf = (window: number): number => {
857 let top = 0
858 for (const shard of shards.values()) {
859 if (shard.window !== window) continue
860 for (const row of shard.rows) {
861 if (Number.isInteger(row.seq) && row.seq > top) top = row.seq
862 }
863 }
864
865 return top
866 }
867
868 const loadArchive = async (page: number): Promise<LedgerEntry[] | null> => {
869 const cached = archives.get(page)
870 if (cached !== undefined) return cached
871 const text = await readText(pathIn(archiveName(page)))
872 if (text === null) return null
873 const entries = parseJsonl<LedgerEntry>(text)
874 archives.set(page, entries)
875
876 return entries
877 }
878
879 const load = async (): Promise<void> => {
880 const text = await readText(pathIn('meta.json'))
881 let raw: Record<string, unknown> | null = null
882 if (text !== null) {
883 try {
884 const parsed: unknown = JSON.parse(text)
885 raw = typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null
886 } catch {
887 raw = null
888 }
889 }
890 Object.assign(state, stateFrom(raw))
891
892 // meta.json is written after the files it describes and can be lost, stale
893 // or damaged, so the files have the last word on everything they can tell:
894 // the window, the part, the seq and the archive pages. A seq or an archive
895 // page handed out twice would make one name mean two things.
896 for (;;) {
897 const next = state.window + 1
898 const isOpened =
899 (await readText(pathIn(handoffName(next)))) !== null ||
900 (await readText(pathIn(shardName(next, 0)))) !== null
901 if (!isOpened) break
902 state.window = next
903 state.requestsSinceSwitch = 0
904 state.reminded = { soft: false, urgent: false }
905 }
906
907 state.part = Math.max(0, (await readWindow(state.window)) - 1)
908 fullWindows.add(state.window)
909 // A window that was only just opened has no row yet; the seq is then in an earlier one.
910 let top = topSeqOf(state.window)
911 for (let window = state.window - 1; top === 0 && window >= 1; window -= 1) {
912 await loadWindow(window)
913 top = topSeqOf(window)
914 }
915 state.seq = Math.max(state.seq, top)
916
917 const ledgerText = await readText(pathIn('ledger.jsonl'))
918 state.page = ledgerText === null ? [] : parseJsonl<LedgerEntry>(ledgerText)
919 for (const entry of state.page) state.ledgerSeq = Math.max(state.ledgerSeq, entry.seq)
920 for (let page = state.archive.length + 1; ; page += 1) {
921 const entries = await loadArchive(page)
922 if (entries === null) break
923 state.archive.push(pageOf(page, entries))
924 for (const entry of entries) state.ledgerSeq = Math.max(state.ledgerSeq, entry.seq)
925 }
926 // Another session may have measured a group this one has not met, so the whole table is read.
927 if (shared !== null) {
928 const loaded = await readShared(io, shared)
929 sharedCostly = loaded.table
930 ownCostly = loaded.own
931 }
932 held = heldFrom(await readText(pathIn(HELD_FILE)))
933 }
934
935 let loading: Promise<void> | null = null
936 let loaded = false
937 const ensureLoaded = (): Promise<void> => {
938 if (loading === null) {
939 loading = load().then(() => {
940 loaded = true
941 })
942 }
943
944 return loading
945 }
946
947 /**
948 * What every synchronous reader below needs in memory: the files, and the
949 * recent person requests that may sit in an earlier window.
950 */
951 const ready = async (): Promise<void> => {
952 await ensureLoaded()
953 await ensureRequests()
954 }
955
956 /**
957 * Pulls older windows in until every referenced seq and at least one person
958 * prompt are in memory, so `ledger.validate` can look the whole session up.
959 */
960 const ensureHistory = async (seqs: readonly number[]): Promise<void> => {
961 const wanted = [...new Set(seqs.filter(seq => Number.isInteger(seq) && seq > 0))]
962 for (let window = state.window - 1; window >= 1; window -= 1) {
963 if (wanted.every(seq => bySeq(seq) !== undefined) && latestPersonRow() !== null) break
964 await loadWindow(window)
965 }
966 }
967
968 /**
969 * Pulls older windows in until the most recent person prompts are in memory:
970 * a reloaded session holds only the current window's shard, and the handoff,
971 * the page turn and the home view all have to name the requests of the session.
972 */
973 const ensureRequests = async (): Promise<void> => {
974 for (let window = state.window - 1; window >= 1; window -= 1) {
975 if (personRequests(allRows(), config.recentRequests).length >= config.recentRequests) break
976 await loadWindow(window)
977 }
978 }
979
980 /**
981 * Pulls older windows in until each recent request has the reply it came after in
982 * memory. That reply sits in the request's own window or the one before, so the
983 * session's very first request never drags every window in.
984 */
985 const ensureAnswered = async (): Promise<void> => {
986 for (let window = state.window - 1; window >= 1; window -= 1) {
987 const rows = allRows()
988 const requests = personRequests(rows, config.recentRequests)
989 const answered = repliesAnswered(rows, requests)
990 if (requests.every(request => answered.has(request.seq) || request.window - 1 > window)) break
991 await loadWindow(window)
992 }
993 }
994
995 /**
996 * Pulls older windows in until the last assistant reply is in memory: a
997 * reloaded session holds only the current window's shard, and the handoff has
998 * to carry what the model last concluded.
999 */
1000 const ensureReplies = async (): Promise<void> => {
1001 for (let window = state.window - 1; window >= 1; window -= 1) {
1002 if (latestAssistantRow() !== null) break
1003 await loadWindow(window)
1004 }
1005 }
1006
1007 /**
1008 * The hand-off the open work came from, for the home page: the one an open
1009 * task points at, else, in a session no person has spoken in, the latest one.
1010 */
1011 const relayForHome = async (): Promise<HistoryRecord | undefined> => {
1012 const refs = tasksOf(state.page)
1013 .filter(task => task.status === 'in_progress' || task.status === 'blocked')
1014 .flatMap(task => task.entries.map(entry => entry.request_ref))
1015 await ensureHistory(refs)
1016 let best: HistoryRecord | undefined
1017 for (const seq of refs) {
1018 const row = bySeq(seq)
1019 if (row !== undefined && isRelay(row) && (best === undefined || row.seq > best.seq)) best = row
1020 }
1021 if (best === undefined && latestPersonRow() === null) best = latestRelayRow() ?? undefined
1022
1023 return best === undefined ? undefined : displayRequests([best])[0]
1024 }
1025
1026 /** The last thing the model said, cut to its tail when it is too long to carry. */
1027 const lastReplyOf = (): string | null => {
1028 const row = latestAssistantRow()
1029 if (row === null) return null
1030 if (row.text.length <= LAST_REPLY_CHARS) return row.text
1031
1032 return `(前文已省略,用 history 工具查看 #${row.seq})\n${row.text.slice(row.text.length - LAST_REPLY_CHARS)}`
1033 }
1034
1035 const shardAt = (window: number, part: number): Shard =>
1036 shards.get(shardName(window, part)) ?? emptyShard(window, part)
1037
1038 const record = async (row: RecordInput): Promise<HistoryRecord> => {
1039 await ensureLoaded()
1040 const seq = state.seq + 1
1041 const full: HistoryRecord = {
1042 seq,
1043 uuid: row.uuid,
1044 ts: io.now(),
1045 window: state.window,
1046 role: row.role,
1047 text: row.text,
1048 }
1049 if (row.isPerson === true) full.isPerson = true
1050 if (row.origin !== undefined) full.origin = row.origin
1051 if (row.tool !== undefined) full.tool = row.tool
1052 if (row.path !== undefined) full.path = row.path
1053 if (row.input !== undefined) full.input = row.input
1054 if (row.isError === true) full.isError = true
1055 if (row.loaded !== undefined) full.loaded = row.loaded
1056 if (row.door !== undefined) full.door = row.door
1057 if (row.name !== undefined) full.name = row.name
1058 if (row.args !== undefined) full.args = row.args
1059 if (row.callId !== undefined) full.callId = row.callId
1060 // A load is judged by the next request's usage, which says whether the cache was rebuilt.
1061 if (row.tool === 'ToolSearch' && row.loaded !== undefined) {
1062 for (const name of row.loaded) {
1063 const group = toolGroupOf(name)
1064 if (!state.pendingLoads.includes(group)) state.pendingLoads.push(group)
1065 // A group the model loaded itself, instead of switching first, no longer needs the switch.
1066 state.gateWanted = state.gateWanted.filter(wanted => wanted !== group)
1067 }
1068 }
1069
1070 const clipped = clipRecord(full)
1071 const shard = appendRecord(shardAt(state.window, state.part), clipped).shard
1072 state.seq = seq
1073 state.window = shard.window
1074 state.part = shard.part
1075 const name = shardName(shard.window, shard.part)
1076 shards.set(name, shard)
1077 fullWindows.add(shard.window)
1078 // The whole original of a clipped field goes out before the row that points at it. A part
1079 // that fails to land is kept by `writeThrough` like any other file, so `flush` retries it.
1080 for (const field of ['text', 'args'] as const) {
1081 if (clipped.overflow?.[field] === undefined) continue
1082 splitParts(field === 'text' ? full.text : full.args ?? '').parts.forEach((part, index) => {
1083 void queue.push(pathIn(overflowName(seq, field, index)), part)
1084 })
1085 }
1086 void queue.push(pathIn(name), toJsonl(shard.rows))
1087 saveMeta()
1088
1089 return clipped
1090 }
1091
1092 const activity = (tool: string, input: Readonly<Record<string, unknown>>, seq: number, isError: boolean): void => {
1093 const found = activityOf(tool, input, { seq, window: state.window, isError })
1094 if (found === null) return
1095 state.activities.push(found)
1096 if (state.activities.length > MAX_ACTIVITIES) {
1097 state.activities.splice(0, state.activities.length - MAX_ACTIVITIES)
1098 }
1099 // The activity is part of what the next window reads, so it has to be on disk
1100 // even when no further row is recorded this turn.
1101 saveMeta()
1102 }
1103
1104 /**
1105 * Notes a subagent the main loop started. The engine lists an agent only while it
1106 * runs, yet SendMessage still reaches it by this id after it ends, so the handoff
1107 * has to keep its id, type and task. A repeat of the id updates the row instead.
1108 */
1109 const noteAgent = async (input: { id: string; type: string; description: string; model?: string }): Promise<void> => {
1110 await ensureLoaded()
1111 const known = state.roster.find(seen => seen.id === input.id)
1112 if (known === undefined) {
1113 state.roster.push({
1114 id: input.id,
1115 type: input.type,
1116 description: input.description,
1117 ...(input.model === undefined ? {} : { model: input.model }),
1118 window: state.window,
1119 ts: io.now(),
1120 })
1121 if (state.roster.length > MAX_ROSTER) state.roster.splice(0, state.roster.length - MAX_ROSTER)
1122 } else {
1123 known.type = input.type
1124 known.description = input.description
1125 if (input.model === undefined) delete known.model
1126 else known.model = input.model
1127 delete known.ended
1128 }
1129 saveMeta()
1130 }
1131
1132 /**
1133 * Tags a noted subagent as ended once its own turn completes. An id the roster
1134 * does not hold changes nothing, and a tag already set is not written again.
1135 */
1136 const agentEnded = async (id: string): Promise<void> => {
1137 await ensureLoaded()
1138 const known = state.roster.find(seen => seen.id === id)
1139 if (known === undefined || known.ended === true) return
1140 known.ended = true
1141 saveMeta()
1142 }
1143
1144 /**
1145 * Judges the loads still waiting on the next main-loop request. Loading a tool group
1146 * changes the tool list the cached prefix was made over, so the first request after
1147 * a load reads far less from the cache than the request before it. A group loaded
1148 * again in the same window does not rebuild, so only the first load is recorded.
1149 */
1150 /**
1151 * Puts one measured group into the cross-session measures, in this session's own file: no other
1152 * session writes that file, so there is no lock. The file is written whole, from what this session
1153 * has recorded, and a file that could not be read at load is left as it is.
1154 */
1155 const recordShared = async (group: string, write: number, context: number): Promise<void> => {
1156 if (shared === null) return
1157 const file = shared.own
1158 const report = (problem: unknown): void => {
1159 try {
1160 io.onWriteError?.(file, problem)
1161 } catch {
1162 // A reporter that throws must not change how the write fails.
1163 }
1164 }
1165 if (ownCostly === null) {
1166 report(new Error('the session file could not be read or parsed: left as it is, this measure was not recorded'))
1167
1168 return
1169 }
1170 const at = io.now()
1171 // Only this session writes this file, so it drops its own measures that have gone stale.
1172 const fresh = Object.fromEntries(Object.entries(ownCostly).filter(([, cost]) => at - cost.at <= SHARED_MS))
1173 const own = { ...fresh, [group]: { write, context, at } }
1174 try {
1175 const text = sharedJson(own)
1176 if (io.rename === undefined) {
1177 await io.write(file, text)
1178 } else {
1179 const temp = `${file}.${at}-${Math.random().toString(36).slice(2)}.tmp`
1180 await io.write(temp, text)
1181 try {
1182 await io.rename(temp, file)
1183 } catch (problem) {
1184 await io.remove?.(temp).catch(() => undefined)
1185 throw problem
1186 }
1187 }
1188 ownCostly = own
1189 sharedCostly = { ...sharedCostly, [group]: { write, context, at } }
1190 } catch (problem) {
1191 report(problem)
1192 }
1193 }
1194
1195 const noteStep = async (usage: { input: number; read: number; write: number }): Promise<void> => {
1196 await ensureLoaded()
1197 const before = state.lastContext
1198 // A cache that expired on its own (the wait, not a load, emptied it) is no load's doing: nothing is recorded.
1199 const expired = isCold({ now: io.now(), cache: null, lastRequestAt: state.lastRequestAt }, config)
1200 // Two groups loaded in one step rewrote the cache together: neither can be blamed for it alone.hooks/lib/usage.ts 198 lines1// How full the context window is, and the arithmetic behind moving to a new
2// one. Pure decisions only: no clock of its own (every `now` is passed in) and
3// no engine calls, so the whole file can be tested as plain functions.
4
5import type { CacheState, Config } from './types.ts'
6
7export type Level = 'none' | 'boundary' | 'soft' | 'urgent'
8
9/** The engine compacts on its own this far below the window; switching wants that room back. */
10const THRESHOLD_MARGIN = 33_000
11
12/**
13 * The limit the fill is measured against: the engine's own auto-compact
14 * threshold when it reports a usable one, otherwise the configured window less
15 * the margin the engine keeps for itself.
16 */
17export const limitOf = (autoCompactThreshold: number | undefined, config: Config): number =>
18 autoCompactThreshold !== undefined && autoCompactThreshold > 0
19 ? autoCompactThreshold
20 : Math.max(1, config.compactWindow - THRESHOLD_MARGIN)
21
22/** A whole percent for the status line; a limit of zero means no fill rather than no reading. */
23export const percentOf = (tokens: number, limit: number): number =>
24 limit <= 0 ? 0 : Math.floor((tokens / limit) * 100)
25
26/**
27 * How loud the reminder is: a high percent outranks a task boundary, and the
28 * boundary only counts once there are enough tokens for a switch to be worth it.
29 */
30export const levelOf = (tokens: number, limit: number, config: Config): Level => {
31 const percent = percentOf(tokens, limit)
32 if (percent >= config.urgentPct) return 'urgent'
33 if (percent >= config.softPct) return 'soft'
34 if (percent >= config.boundaryPct) return 'boundary'
35
36 return 'none'
37}
38
39/** The status line, which must still name the window when the token count is not known yet. */
40export const describeFill = (
41 tokens: number | undefined,
42 limit: number,
43 window: number,
44): string => {
45 if (tokens === undefined) return `ctx ? · w${window}`
46 const k = (n: number): number => Math.round(n / 1000)
47
48 return `ctx ${percentOf(tokens, limit)}% · ${k(tokens)}k/${k(limit)}k · w${window}`
49}
50
51export type Judgement = {
52 isWorth: boolean
53 savingPerRequest: number
54 switchCost: number
55 breakEvenRequests: number
56 /** One Chinese sentence carrying the numbers the decision came from. */
57 reason: string
58}
59
60/**
61 * Whether the switch pays for itself: the prefix and the handoff are written
62 * again (a cache write) while the context they replace would otherwise be read
63 * on every remaining request, so the saving has to beat the cost inside the
64 * requests the session has left, with a safety margin on top.
65 */
66export const judgeSwitch = (
67 input: { before: number; prefix: number; handoff: number; reread: number; remainingRequests: number },
68 config: Config,
69): Judgement => {
70 const savingPerRequest = Math.max(0, input.before - (input.prefix + input.handoff)) * config.wRead
71 const switchCost = (input.handoff + input.reread) * config.wWrite
72 const breakEvenRequests = savingPerRequest > 0 ? switchCost / savingPerRequest : Infinity
73 const isWorth = input.remainingRequests >= breakEvenRequests * config.breakEvenSafety
74
75 return {
76 isWorth,
77 savingPerRequest,
78 switchCost,
79 breakEvenRequests,
80 reason: reasonOf(input, config, savingPerRequest, switchCost, breakEvenRequests, isWorth),
81 }
82}
83
84const reasonOf = (
85 input: { before: number; prefix: number; handoff: number; remainingRequests: number },
86 config: Config,
87 savingPerRequest: number,
88 switchCost: number,
89 breakEvenRequests: number,
90 isWorth: boolean,
91): string => {
92 const saving = Math.round(savingPerRequest)
93 const cost = Math.round(switchCost)
94 if (savingPerRequest <= 0) {
95 return `换窗口省不下 token:当前上下文 ${input.before} 不比「稳定前缀 ${input.prefix} + 交接 ${input.handoff}」多。`
96 }
97 if (isWorth) {
98 return `每次请求省约 ${saving} token 当量,切换一次约 ${cost},${breakEvenRequests.toFixed(1)} 次请求回本,剩余 ${input.remainingRequests} 次够用。`
99 }
100
101 return `每次请求省约 ${saving} token 当量,切换一次约 ${cost},需 ${breakEvenRequests.toFixed(1)} 次请求回本(含 ${config.breakEvenSafety} 倍余量为 ${(breakEvenRequests * config.breakEvenSafety).toFixed(1)} 次),剩余 ${input.remainingRequests} 次不够。`
102}
103
104const FIVE_MINUTES_SEC = 300
105
106/**
107 * Whether the cached prefix is certainly gone: an explicit expiry settles it,
108 * and otherwise the last request decides — an unknown last request counts as
109 * warm, because pinging a live cache is cheap and dropping a live one is not.
110 */
111export const isCold = (
112 input: { now: number; cache: CacheState | null; lastRequestAt: number | null },
113 config: Config,
114): boolean => {
115 if (input.cache !== null && input.cache.expiresAt !== null) {
116 return input.now >= input.cache.expiresAt
117 }
118 if (input.lastRequestAt === null) return false
119 // A cache that says it lives five minutes (overage) must not be held to the hour.
120 const lifetimeSec = input.cache?.ttl === '5m' ? FIVE_MINUTES_SEC : config.cacheTtlSec
121
122 return input.now - input.lastRequestAt >= lifetimeSec * 1000
123}
124
125export type Gate = { isAllowed: true } | { isAllowed: false; reason: string }
126
127/**
128 * Whether this is a moment the model may switch windows at: rate limits first
129 * (they are the person's budget, not a preference), then the cooldown that
130 * stops a session from rebuilding its cache on every request, then the ledger
131 * that makes a new window able to pick the work up, and only then the payoff —
132 * with `urgent` allowed to skip everything but the hourly cap and the ledger.
133 */
134export const gateSwitch = (
135 input: {
136 requestsSinceSwitch: number
137 switchTimes: readonly number[]
138 now: number
139 hasFreshLedger: boolean
140 level: Level
141 judgement: Judgement | null
142 /** A switch the gate asked for in front of a load: it skips the cooldown and the payoff, like urgent. */
143 isForLoad?: boolean
144 /** Why the ledger is not fresh, when the caller knows better than the fixed sentence. */
145 staleReason?: string
146 },
147 config: Config,
148): Gate => {
149 const recent = input.switchTimes.filter(at => at > input.now - 3_600_000 && at <= input.now).length
150 if (recent >= config.maxSwitchesPerHour) {
151 return {
152 isAllowed: false,
153 reason: `最近一小时已换窗口 ${recent} 次,达到上限 ${config.maxSwitchesPerHour} 次,现在不再换。`,
154 }
155 }
156 const skipsPayoff = input.level === 'urgent' || input.isForLoad === true
157 if (input.requestsSinceSwitch < config.cooldownRequests && !skipsPayoff) {
158 return {
159 isAllowed: false,
160 reason: `距上次换窗口只过了 ${input.requestsSinceSwitch} 次请求,未到冷却门槛 ${config.cooldownRequests} 次(当前级别 ${input.level},只有 urgent 能越过冷却)。`,
161 }
162 }
163 if (!input.hasFreshLedger) {
164 return {
165 isAllowed: false,
166 reason:
167 input.staleReason === undefined
168 ? '账本还不新:先用 ledger_append 把已完成并有依据的进度、下一步和关键文件记进台账,再换窗口。'
169 : `${input.staleReason}先用 ledger_append 写明这些步骤的结论、下一步和关键文件,再换窗口。`,
170 }
171 }
172 if (skipsPayoff) return { isAllowed: true }
173 if (input.judgement === null) {
174 return { isAllowed: false, reason: '没有交接成本的估算,判断不了换窗口是否划算,先不换。' }
175 }
176 if (!input.judgement.isWorth) {
177 return { isAllowed: false, reason: `换窗口不划算:${input.judgement.reason}` }
178 }
179
180 return { isAllowed: true }
181}
182
183/**
184 * Whether a fresh window may carry on by itself: not after a wrap-up handoff
185 * (that one waits for the person) and not while the 5-hour window is nearly
186 * spent, where the next switch would just repeat the same wrap-up.
187 */
188export const shouldContinue = (
189 fiveHourPercent: number | undefined,
190 cache: CacheState | null,
191 config: Config,
192): boolean => {
193 if (cache?.bridgePhase === 'wrapped') return false
194 if (fiveHourPercent !== undefined && fiveHourPercent >= config.noContinueAtPercent) return false
195
196 return true
197}
198hooks/lib/types.ts 292 lines1// The shapes every part of ctx-mod shares. Pure data: no engine calls here.
2
3export type Config = {
4 /** The compaction window to assume when the engine reports no threshold. */
5 compactWindow: number
6 /** Percent of the limit past which a finished task is a good moment to switch windows. */
7 boundaryPct: number
8 /** Percent of the limit for the soft and the urgent reminder. */
9 softPct: number
10 urgentPct: number
11 /** A cold cache switches the window only above this fill. */
12 coldMinTokens: number
13 /** Cache lifetime to assume when cache-keeper's state cannot be read. */
14 cacheTtlSec: number
15 /** At or above this 5-hour percent a switch never continues by itself. */
16 noContinueAtPercent: number
17 /** Main-loop model requests (one per turn step, tool call or not) that must pass between two switches. */
18 cooldownRequests: number
19 maxSwitchesPerHour: number
20 /**
21 * Hard caps, in estimated tokens — except handoffTokens, which is a target: the opening, home page,
22 * last reply, subagents and tool notes are never cut, so a handoff can run past it.
23 */
24 handoffTokens: number
25 snippetTokens: number
26 homeTokens: number
27 pageTokens: number
28 /** Lines of context on each side of an anchor. */
29 snippetLines: number
30 recentActions: number
31 recentRequests: number
32 /** Token-equivalent weights of a cache read and a cache write. */
33 wRead: number
34 wWrite: number
35 breakEvenSafety: number
36 /** Longest `text` one ledger entry may carry, in characters. */
37 maxTextChars: number
38 /** Above this context, a load that rebuilds the cache waits for a window switch first. */
39 gateMinTokens: number
40 /** When the model asks for a switch, the most tool results allowed after the latest ledger entry. */
41 freshTailSteps: number
42 /** On an automatic compaction, more unrecorded tool results than this send the summary back to the engine. */
43 autoTailSteps: number
44}
45
46export const DEFAULTS: Config = {
47 compactWindow: 400_000,
48 // Late on purpose: a switch loses working detail, so the window is used nearly
49 // to its end. Urgent still leaves room for the ledger entries before the limit.
50 // Of a 400k limit these are 300k, 332k and 352k.
51 boundaryPct: 75,
52 softPct: 83,
53 urgentPct: 88,
54 coldMinTokens: 100_000,
55 cacheTtlSec: 3_600,
56 noContinueAtPercent: 90,
57 cooldownRequests: 30,
58 maxSwitchesPerHour: 4,
59 handoffTokens: 15_000,
60 snippetTokens: 6_000,
61 homeTokens: 1_500,
62 pageTokens: 4_000,
63 snippetLines: 30,
64 recentActions: 10,
65 recentRequests: 3,
66 wRead: 0.025,
67 wWrite: 1,
68 breakEvenSafety: 1.5,
69 maxTextChars: 1_500,
70 gateMinTokens: 120_000,
71 freshTailSteps: 5,
72 autoTailSteps: 25,
73}
74
75/**
76 * A rough token count with no tokenizer: an ASCII character is a quarter of a
77 * token, any other (CJK, mostly) a whole one.
78 */
79export const estimateTokens = (text: string): number => {
80 let ascii = 0
81 let other = 0
82 for (const char of text) {
83 if (char.charCodeAt(0) < 128) ascii += 1
84 else other += 1
85 }
86
87 return Math.ceil(ascii / 4) + other
88}
89
90export const toJsonl = (rows: readonly unknown[]): string =>
91 rows.length === 0 ? '' : `${rows.map(row => JSON.stringify(row)).join('\n')}\n`
92
93/** Reads JSON lines, leaving out any line that does not parse. */
94export const parseJsonl = <T>(text: string): T[] => {
95 const rows: T[] = []
96 for (const line of text.split('\n')) {
97 if (line.trim() === '') continue
98 try {
99 rows.push(JSON.parse(line) as T)
100 } catch {
101 // a damaged line is skipped, the rest still reads
102 }
103 }
104
105 return rows
106}
107
108export type Role = 'user' | 'assistant' | 'tool'
109
110/**
111 * Origins that hand a session work nobody typed into it: a coordinating
112 * session's hand-off or delivery, a scheduled prompt, a channel's message.
113 */
114const RELAY_ORIGINS: ReadonlySet<string> = new Set([
115 'coordinator',
116 'projects-relay',
117 'scheduled-trigger',
118 'channel',
119 'slack-ping',
120])
121
122/**
123 * How a coordinating session's hand-off opens, past any reminder the engine put
124 * before it. The attributes after `from` vary (`session`, `reason`, a time).
125 */
126const COORDINATOR_RELAY = /^(?:\s*<system-reminder>[\s\S]*?<\/system-reminder>)*\s*<relay\b[^>]*\bfrom="coordinator"/
127
128/**
129 * Whether a row is such a hand-off: where a task came from, though never a
130 * person's own request.
131 *
132 * The origin tells it when the engine names one. A coordinator's hand-off to a
133 * thread on the person's own machine arrives with none it can name
134 * (`unclassified`, seen on 2.1.296), and an imported row has no origin at all,
135 * so there the opening tag decides — but only on a prompt row no other origin
136 * vouches for, so a notification or a plugin quoting the tag is not taken for one.
137 */
138export const isRelay = (row: HistoryRecord): boolean => {
139 if (row.role !== 'user' || row.isPerson === true) return false
140 if (row.origin !== undefined && RELAY_ORIGINS.has(row.origin)) return true
141
142 return (
143 row.door === undefined &&
144 (row.origin === undefined || row.origin === 'unclassified') &&
145 COORDINATOR_RELAY.test(row.text)
146 )
147}
148
149/** One row of the conversation as ctx-mod keeps it on disk. */
150export type HistoryRecord = {
151 /** Counts up from 1 across the whole session, never reused. */
152 seq: number
153 /** The engine's id of the row. */
154 uuid: string
155 /** Milliseconds since the epoch. */
156 ts: number
157 /** The context window the row was made in, from 1. */
158 window: number
159 role: Role
160 /** True on a prompt a person typed (never a plugin's or a notification). */
161 isPerson?: true
162 /**
163 * The engine's origin kind of a prompt row (`composer`, `coordinator`,
164 * `task-notification`, ...). Rows written before this field have none.
165 */
166 origin?: string
167 /** The tool's name, on a tool call's result. */
168 tool?: string
169 /** The file a Read, Edit, Write or search was about. */
170 path?: string
171 /**
172 * A one-line summary of a tool call's input, at most 300 characters: the
173 * command behind a Bash result or the pattern behind a Grep, so the model can
174 * search for what it ran instead of running it again.
175 */
176 input?: string
177 isError?: true
178 /** The deferred tools a ToolSearch call loaded, by name; only that call's result carries them. */
179 loaded?: string[]
180 /**
181 * The door the row came in by, written only on rows outside the three original
182 * ones (prompt, response, tool-result): a row without it is read as before.
183 */
184 door?: string
185 /** The attachment type behind a `door` row, such as `queued_command`. */
186 name?: string
187 /**
188 * The tool call's arguments in full (see `formatArgs`), cut at `MAX_ARGS_CHARS`;
189 * `input` stays the short summary. Rows written before this field have none.
190 */
191 args?: string
192 /** The engine's `tool_use_id` of the call, so a conversation can be lined up with the rows later. */
193 callId?: string
194 /** A field cut at the row, whose whole original sits in `big-<seq>-<field>-<n>.txt` files. */
195 overflow?: { text?: Overflow; args?: Overflow }
196 text: string
197}
198
199/**
200 * How one over-long field was set aside: its length, how many part files hold it
201 * and, only when a part was cut short to keep a surrogate pair whole, where each
202 * part after the first starts.
203 */
204export type Overflow = { chars: number; parts: number; cuts?: number[] }
205
206/**
207 * The name prefix of every tool this mod registers. Its own calls are bookkeeping
208 * rather than the work, so the recap and the search leave them out by this prefix.
209 */
210export const OWN_TOOL_PREFIX = 'mcp__ctx-mod__'
211
212export type TaskStatus = 'in_progress' | 'done' | 'superseded' | 'blocked'
213
214/** Where the next edit of a task goes: a file and a short text found once in it. */
215export type Anchor = { path: string; match: string; note: string }
216
217/** What the model hands `ledger_append`. */
218export type LedgerInput = {
219 task_id: string
220 title: string
221 status: TaskStatus
222 text: string
223 next_step?: string
224 anchors?: Anchor[]
225 /** History seqs proving a `done`. */
226 evidence?: number[]
227 superseded_by?: string
228 /** The seq of the person's prompt the task answers; the latest when left out. */
229 request_ref?: number
230}
231
232/** A ledger entry as stored: the input plus what the code stamps. */
233export type LedgerEntry = Omit<LedgerInput, 'request_ref'> & {
234 seq: number
235 ts: number
236 window: number
237 request_ref: number
238 /** The seq of the last history row when this entry was written; older entries lack it. */
239 through?: number
240}
241
242export type FileOp = 'read' | 'edit' | 'write' | 'search'
243
244/** One touch of a file by the main conversation's tools. */
245export type FileActivity = {
246 path: string
247 op: FileOp
248 /** The history seq of the tool result. */
249 seq: number
250 window: number
251 isError: boolean
252 /** 1-based, inclusive, when the call named a range. */
253 startLine?: number
254 endLine?: number
255 /** The first line an Edit wrote, to find the place again later. */
256 hint?: string
257}
258
259/** A piece of a file read from disk at the switch, or why there is none. */
260export type Snippet =
261 | {
262 path: string
263 taskId: string
264 note: string
265 /** 1-based line the text starts at. */
266 startLine: number
267 /** The lines, each prefixed with its number and a tab. */
268 text: string
269 needsRead?: undefined
270 }
271 | {
272 path: string
273 taskId: string
274 note: string
275 /** Why no text could be taken: the model reads the file itself. */
276 needsRead: string
277 /** The last history seq that read the file, when there is one. */
278 lastSeq?: number
279 text?: undefined
280 }
281
282/** cache-keeper's published state, as `$.state` hands it over. */
283export type CacheState = {
284 lastActivityAt: number | null
285 ttl: '5m' | '1h' | null
286 expiresAt: number | null
287 cachedPrefixTokens: number
288 isEnabled: boolean
289 pausedReason: string | null
290 bridgePhase: 'idle' | 'wrapped'
291}
292hooks/lib/ledger.ts 735 lines1// The task ledger: append-only entries, checked by code, turned into the home
2// page a fresh window opens with and paged into the archive when it grows.
3// Pure: no disk, no clock, no engine calls — the caller supplies all of that.
4
5import type {
6 Anchor,
7 Config,
8 HistoryRecord,
9 LedgerEntry,
10 LedgerInput,
11 TaskStatus,
12} from './types.ts'
13import { estimateTokens, isRelay, toJsonl } from './types.ts'
14
15/** Why an anchor cannot be used: absent, not found, or not unique. */
16export type AnchorCheck = 'ok' | 'missing-file' | 'no-match' | 'ambiguous'
17
18export type ValidateContext = {
19 entries: readonly LedgerEntry[]
20 history: (seq: number) => HistoryRecord | undefined
21 latestPersonSeq: number | null
22 /** The latest hand-off row (see `isRelay`): what an entry answers when no person has asked for anything. */
23 latestRelaySeq?: number | null
24 checkAnchor: (anchor: Anchor) => AnchorCheck
25 config: Config
26}
27
28const TASK_ID = /^[\w.-]{1,64}$/
29const MAX_TITLE_CHARS = 200
30const MAX_ANCHORS = 8
31const MAX_MATCH_CHARS = 300
32const MAX_REQUEST_CHARS = 2_000
33const MAX_SUPERSEDED_SHOWN = 5
34/** An active task shows at most this many notes from its latest window, within a combined text budget. */
35const MAX_WINDOW_NOTES = 3
36const WINDOW_NOTES_CHARS = 3_000
37/** Open tasks beyond the newest this many are shown as a short summary, so the home page has an upper bound. */
38const MAX_FULL_ACTIVE = 5
39const SHORT_FIELD_CHARS = 200
40const STATUSES: readonly TaskStatus[] = ['in_progress', 'done', 'superseded', 'blocked']
41
42const STATUS_LABEL: Record<TaskStatus, string> = {
43 in_progress: '进行中',
44 done: '已完成',
45 superseded: '已被取代',
46 blocked: '受阻',
47}
48
49/** The only thing a tool input safely can be: a plain JSON object. */
50const isRecord = (value: unknown): value is Record<string, unknown> =>
51 typeof value === 'object' && value !== null && !Array.isArray(value)
52
53const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
54
55/** A seq or ts the rest of the code may index with: a finite, whole number. */
56const asInt = (value: unknown): number | undefined =>
57 typeof value === 'number' && Number.isInteger(value) ? value : undefined
58
59const asStatus = (value: unknown): TaskStatus =>
60 (STATUSES as readonly string[]).includes(value as string) ? (value as TaskStatus) : 'in_progress'
61
62/** Shows a bad value without ever throwing on it (an object's toString may be garbage). */
63const describe = (value: unknown): string => {
64 try {
65 if (typeof value === 'string') return value
66 const text = JSON.stringify(value)
67
68 return text === undefined ? typeof value : text
69 } catch {
70 return `(${typeof value})`
71 }
72}
73
74/**
75 * Every problem with one `ledger_append`, in Chinese, one sentence each, so the
76 * model can fix them all in one retry: the rules are independent, so nothing
77 * here returns after the first failure. The input is whatever the model sent,
78 * so every field is re-typed here before it is trusted.
79 */
80export const validate = (input: LedgerInput, context: ValidateContext): string[] => {
81 if (!isRecord(input)) {
82 return ['ledger_append 的输入必须是一个对象,至少包含 task_id、title、status、text 四个字段。']
83 }
84 const raw: Record<string, unknown> = input
85 const problems: string[] = []
86
87 const taskId = raw.task_id
88 if (typeof taskId !== 'string' || !TASK_ID.test(taskId)) {
89 problems.push(
90 'task_id 只能由字母、数字、下划线、点和短横线组成,长度 1-64 个字符,请改成一个这样的短标识。',
91 )
92 }
93
94 const title = asText(raw.title).trim()
95 if (title === '') {
96 problems.push('title 不能为空,请用一句话说明这个任务要做什么。')
97 } else if (title.length > MAX_TITLE_CHARS) {
98 problems.push(`title 有 ${title.length} 个字符,最多 ${MAX_TITLE_CHARS} 个,请压缩。`)
99 }
100
101 const status = raw.status as string
102 if (!(STATUSES as readonly string[]).includes(status)) {
103 problems.push(
104 `status 只能是 in_progress、done、superseded、blocked 之一,收到的是「${describe(raw.status)}」。`,
105 )
106 }
107
108 if (raw.text === undefined || typeof raw.text !== 'string') {
109 problems.push('text 必须是字符串,请用一句话说明这一步的要点。')
110 } else if (raw.text.trim() === '') {
111 // An empty entry would still count as booked progress while carrying none.
112 problems.push('text 不能为空,请用一句话说明这一步的要点。')
113 } else if (raw.text.length > context.config.maxTextChars) {
114 problems.push(
115 `text 有 ${raw.text.length} 个字符,超过上限 ${context.config.maxTextChars},请拆成多条 ledger_append,这一条只留当前这一步的要点。`,
116 )
117 }
118
119 if (raw.next_step !== undefined && typeof raw.next_step !== 'string') {
120 problems.push('next_step 必须是字符串,请用一句话写下一步做什么;不需要就整个省略。')
121 }
122
123 if (status === 'in_progress') {
124 const given = raw.next_step
125 // An omitted next_step carries over from the task's latest entry, so that one counts as given.
126 const earlier = tasksOf(context.entries).find(task => task.taskId === taskId)?.nextStep?.value
127 const hasStep = typeof given === 'string' ? given.trim() !== '' : given === undefined && (earlier ?? '').trim() !== ''
128 if (!hasStep && (given === undefined || typeof given === 'string')) {
129 problems.push('status 是 in_progress,必须写 next_step:换窗口后要接着做的下一步。任务做完了就标 done。')
130 }
131 }
132
133 if (raw.superseded_by !== undefined && typeof raw.superseded_by !== 'string') {
134 problems.push('superseded_by 必须是字符串:取代它的那个 task_id。')
135 }
136
137 const refSeq = asInt(raw.request_ref)
138 if (raw.request_ref !== undefined && refSeq === undefined) {
139 problems.push('request_ref 必须是历史编号(有限整数),请用 history 查一个真实的编号。')
140 } else if (refSeq !== undefined) {
141 const record = context.history(refSeq)
142 if (record === undefined) {
143 problems.push(`request_ref #${refSeq} 在历史里不存在,请用 history 查一个真实的历史编号。`)
144 } else if (record.isPerson !== true && !isRelay(record)) {
145 problems.push(
146 `request_ref #${refSeq} 的 role 是 ${record.role},不是真人请求,也不是派发任务的转交,请指向用户亲口提的那条消息或派发这个任务的那条转交;都没有就省略 request_ref。`,
147 )
148 }
149 }
150 // An omitted request_ref is never refused: a session a coordinator started has no person's
151 // request for a while, and that stretch must still be booked (see `defaultRequestSeq` for what it gets).
152
153 const evidenceGiven = raw.evidence !== undefined
154 const evidence = Array.isArray(raw.evidence) ? raw.evidence : []
155 if (evidenceGiven && !Array.isArray(raw.evidence)) {
156 problems.push('evidence 必须是历史编号的数组,例如 [12, 15],请把非编号的内容去掉。')
157 } else {
158 const junk = evidence.filter(item => asInt(item) === undefined)
159 if (junk.length > 0) {
160 problems.push(
161 `evidence 里只能放历史编号(有限整数),有 ${junk.length} 项不是,请把它们去掉。`,
162 )
163 }
164 }
165
166 if (status === 'done') {
167 const seqs = evidence.filter((item): item is number => asInt(item) !== undefined)
168 if (seqs.length === 0) {
169 problems.push('status 是 done,必须给出 evidence:至少一个能证明做完的历史编号。')
170 }
171 const start = taskStart(taskId, refSeq, context)
172 for (const seq of seqs) {
173 const record = context.history(seq)
174 if (record === undefined) {
175 problems.push(`evidence #${seq} 在历史里不存在,请换成真实的历史编号。`)
176 } else if (record.role !== 'tool') {
177 problems.push(`evidence #${seq} 的 role 是 ${record.role},不是 tool,完成的证据要是工具结果。`)
178 } else if (record.isError === true) {
179 problems.push(`evidence #${seq} 是一次报错的工具结果,不能当作完成的证据,请换成成功的工具结果。`)
180 } else if (start !== null && record.ts < start.ts) {
181 problems.push(
182 `evidence #${seq} 的时间早于这个任务的起点(${start.from}),请换成任务开始之后的工具结果。`,
183 )
184 }
185 }
186 }
187
188 if (status === 'superseded') {
189 const by = asText(raw.superseded_by).trim()
190 if (by === '') {
191 if (typeof raw.superseded_by === 'string' || raw.superseded_by === undefined) {
192 problems.push('status 是 superseded,必须写 superseded_by:取代它的那个 task_id。')
193 }
194 } else if (by === taskId) {
195 problems.push('superseded_by 不能是任务自己,请写真正取代它的另一个 task_id。')
196 }
197 }
198
199 const rawAnchors = raw.anchors
200 if (rawAnchors !== undefined && !Array.isArray(rawAnchors)) {
201 problems.push('anchors 必须是数组,每项是一个含 path、match、note 三个字符串字段的对象。')
202 } else {
203 const anchors: readonly unknown[] = Array.isArray(rawAnchors) ? rawAnchors : []
204 if (anchors.length > MAX_ANCHORS) {
205 problems.push(`anchors 最多 ${MAX_ANCHORS} 个,收到 ${anchors.length} 个,请删掉不重要的。`)
206 }
207 anchors.forEach((anchor, index) => {
208 const at = index + 1
209 if (!isRecord(anchor)) {
210 problems.push(`锚点 ${at} 必须是对象,含 path、match、note 三个字符串字段,请改写。`)
211 return
212 }
213 const wrong: string[] = []
214 if (typeof anchor.path !== 'string') wrong.push('path')
215 if (typeof anchor.match !== 'string') wrong.push('match')
216 if (anchor.note !== undefined && typeof anchor.note !== 'string') wrong.push('note')
217 if (wrong.length > 0) {
218 problems.push(`锚点 ${at} 的 ${wrong.join('、')} 必须是字符串(note 可以省略),请改正。`)
219 }
220
221 const path = asText(anchor.path).trim()
222 const match = asText(anchor.match).trim()
223 if (typeof anchor.path === 'string' && path === '') {
224 problems.push(`锚点 ${at} 的 path 不能为空,请给出文件路径。`)
225 }
226 if (typeof anchor.match === 'string') {
227 if (match === '') {
228 problems.push(`锚点 ${at} 的 match 不能为空,请给一段在文件里能找到的原文。`)
229 } else if (match.length > MAX_MATCH_CHARS) {
230 problems.push(`锚点 ${at} 的 match 有 ${match.length} 个字符,最多 ${MAX_MATCH_CHARS} 个,请截短。`)
231 }
232 }
233 if (path === '' || match === '') return
234
235 const check = context.checkAnchor({
236 path,
237 match,
238 note: typeof anchor.note === 'string' ? anchor.note : '',
239 })
240 if (check === 'missing-file') {
241 problems.push(`锚点 ${at} 的文件不存在:${path},请确认路径,或先用工具读一下再写。`)
242 } else if (check === 'no-match') {
243 problems.push(`锚点 ${at} 在 ${path} 里找不到 match 这段原文,请换一段确实存在于文件里的原文。`)
244 } else if (check === 'ambiguous') {
245 problems.push(`锚点 ${at} 的 match 在 ${path} 里出现多次,请换一段能唯一定位的原文。`)
246 }
247 })
248 }
249
250 return problems
251}
252
253/**
254 * The request an entry answers when it names none: whichever came last, the
255 * person's latest prompt or the latest hand-off. Neither outranks the other —
256 * a person may speak after a hand-off, and a coordinator may hand over new work
257 * after the person spoke.
258 */
259export const defaultRequestSeq = (
260 personSeq: number | null | undefined,
261 relaySeq: number | null | undefined,
262): number | null => {
263 const person = personSeq ?? null
264 const relay = relaySeq ?? null
265 if (person === null) return relay
266 if (relay === null) return person
267
268 return Math.max(person, relay)
269}
270
271/**
272 * The earliest moment a task could have started: its own first entry, else its
273 * request. `from` names that starting point for the model, so a refusal says
274 * which row the evidence was measured against.
275 */
276const taskStart = (
277 taskId: unknown,
278 refSeq: number | undefined,
279 context: ValidateContext,
280): { ts: number; from: string } | null => {
281 const first = context.entries.find(entry => entry.task_id === taskId)
282 if (first !== undefined) {
283 const ts = asInt(first.ts)
284
285 return ts === undefined ? null : { ts, from: `这个任务的第一条记录 #L${first.seq}` }
286 }
287
288 const seq = refSeq ?? defaultRequestSeq(context.latestPersonSeq, context.latestRelaySeq)
289 if (seq === null) return null
290 const record = context.history(seq)
291 if (record === undefined) return null
292
293 return {
294 ts: record.ts,
295 from:
296 refSeq === undefined
297 ? `省略 request_ref 时默认对应的请求 #${seq};任务其实来自更早的请求,就用 request_ref 指明`
298 : `request_ref 指向的 #${seq}`,
299 }
300}
301
302/** Keeps exactly the three string fields an anchor is, nothing else. */
303const anchorOf = (value: unknown): Anchor | undefined => {
304 if (!isRecord(value)) return undefined
305
306 return {
307 path: asText(value.path),
308 match: asText(value.match),
309 note: asText(value.note),
310 }
311}
312
313const anchorsOf = (value: unknown): Anchor[] | undefined => {
314 if (!Array.isArray(value)) return undefined
315 const anchors: Anchor[] = []
316 for (const item of value) {
317 const anchor = anchorOf(item)
318 if (anchor !== undefined) anchors.push(anchor)
319 }
320
321 return anchors
322}
323
324const intsOf = (value: unknown): number[] | undefined => {
325 if (!Array.isArray(value)) return undefined
326 const ints: number[] = []
327 for (const item of value) {
328 const int = asInt(item)
329 if (int !== undefined) ints.push(int)
330 }
331
332 return ints
333}
334
335/**
336 * Turns a checked input into the row that is stored: seq, time and window come
337 * from the caller, and an omitted request_ref falls back to the latest request
338 * (see `defaultRequestSeq`), else to 0: nothing asked for it that the history
339 * can name. Whatever the model sent, only
340 * values of the right type are written — a bad field is dropped, not copied on.
341 */
342export const stamp = (
343 input: LedgerInput,
344 meta: {
345 seq: number
346 ts: number
347 window: number
348 latestPersonSeq: number | null
349 latestRelaySeq?: number | null
350 through?: number
351 },
352): LedgerEntry => {
353 const raw: Record<string, unknown> = isRecord(input) ? input : {}
354 const entry: LedgerEntry = {
355 task_id: asText(raw.task_id),
356 title: asText(raw.title),
357 status: asStatus(raw.status),
358 text: asText(raw.text),
359 seq: meta.seq,
360 ts: meta.ts,
361 window: meta.window,
362 request_ref: asInt(raw.request_ref) ?? defaultRequestSeq(meta.latestPersonSeq, meta.latestRelaySeq) ?? 0,
363 }
364 if (typeof raw.next_step === 'string') entry.next_step = raw.next_step
365 const anchors = anchorsOf(raw.anchors)
366 if (anchors !== undefined) entry.anchors = anchors
367 const evidence = intsOf(raw.evidence)
368 if (evidence !== undefined) entry.evidence = evidence
369 if (typeof raw.superseded_by === 'string') entry.superseded_by = raw.superseded_by
370 // Lets a later check tell which history rows this entry did not cover.
371 if (meta.through !== undefined) entry.through = meta.through
372
373 return entry
374}
375
376/** A field's value as the task stands now, and which entry it was written in. */
377export type Resolved<T> = { value: T; seq: number; isInherited: boolean }
378
379export type TaskState = {
380 taskId: string
381 /** The latest entry's title. */
382 title: string
383 /** The latest entry's status. */
384 status: LedgerEntry['status']
385 first: LedgerEntry
386 latest: LedgerEntry
387 entries: LedgerEntry[]
388 /** The next step in force: the latest entry's own, else the newest earlier one; absent once cleared or closed. */
389 nextStep?: Resolved<string>
390 /** The anchors in force, resolved the same way as the next step. */
391 anchors?: Resolved<Anchor[]>
392}
393
394const isClosedStatus = (status: LedgerEntry['status']): boolean =>
395 status === 'done' || status === 'superseded'
396
397/**
398 * An omitted field means "unchanged", so it is read from the newest entry that
399 * gave one. An empty value is a given value that clears, and ends the search;
400 * so does a closed entry, because a finished task has no step left to carry.
401 */
402const resolveField = <T>(
403 entries: readonly LedgerEntry[],
404 pick: (entry: LedgerEntry) => T | undefined,
405 isEmpty: (value: T) => boolean,
406): Resolved<T> | undefined => {
407 const last = entries[entries.length - 1]
408 if (last === undefined || isClosedStatus(last.status)) return undefined
409 for (let at = entries.length - 1; at >= 0; at -= 1) {
410 const entry = entries[at]
411 if (entry === undefined || (at < entries.length - 1 && isClosedStatus(entry.status))) return undefined
412 const value = pick(entry)
413 if (value === undefined) continue
414
415 return isEmpty(value) ? undefined : { value, seq: entry.seq, isInherited: at < entries.length - 1 }
416 }
417
418 return undefined
419}
420
421/** Groups entries by task in first-seen order; later entries win as the latest. */
422export const tasksOf = (entries: readonly LedgerEntry[]): TaskState[] => {
423 const byId = new Map<string, TaskState>()
424 for (const entry of entries) {
425 const found = byId.get(entry.task_id)
426 if (found === undefined) {
427 byId.set(entry.task_id, {
428 taskId: entry.task_id,
429 title: entry.title,
430 status: entry.status,
431 first: entry,
432 latest: entry,
433 entries: [entry],
434 })
435 continue
436 }
437 found.entries.push(entry)
438 found.title = entry.title
439 found.status = entry.status
440 found.latest = entry
441 }
442
443 const tasks = [...byId.values()]
444 for (const task of tasks) {
445 const nextStep = resolveField(
446 task.entries,
447 entry => (typeof entry.next_step === 'string' ? entry.next_step : undefined),
448 value => value.trim() === '',
449 )
450 if (nextStep !== undefined) task.nextStep = nextStep
451 const anchors = resolveField(
452 task.entries,
453 entry => (Array.isArray(entry.anchors) ? entry.anchors : undefined),
454 value => value.length === 0,
455 )
456 if (anchors !== undefined) task.anchors = anchors
457 }
458
459 return tasks
460}
461
462/** Whether this window has booked anything since the latest person prompt. */
463export const hasFreshEntry = (
464 entries: readonly LedgerEntry[],
465 sinceTs: number,
466 window: number,
467): boolean => entries.some(entry => entry.window === window && entry.ts >= sinceTs)
468
469export type ArchivePage = {
470 page: number
471 titles: string[]
472 fromSeq: number
473 toSeq: number
474 fromTs: number
475 toTs: number
476}
477
478const dayOf = (ts: number): string => new Date(ts).toISOString().slice(0, 10)
479
480const clipRequest = (text: string, seq: number): string =>
481 text.length > MAX_REQUEST_CHARS
482 ? `${text.slice(0, MAX_REQUEST_CHARS)}……(原文更长,用 history 读 #${seq})`
483 : text
484
485/** A request this short ("可以", "重启了") is shown with the reply it answered. */
486const SHORT_REQUEST_CHARS = 30
487/** How much of that reply is shown: where it opened and where it ended, which is where the ask usually sits. */
488const ANSWERED_HEAD_CHARS = 100
489const ANSWERED_TAIL_CHARS = 160
490
491const clipAnswered = (text: string): string => {
492 const flat = text.replace(/\s+/g, ' ').trim()
493
494 return flat.length <= ANSWERED_HEAD_CHARS + ANSWERED_TAIL_CHARS
495 ? flat
496 : `${flat.slice(0, ANSWERED_HEAD_CHARS)} …… ${flat.slice(flat.length - ANSWERED_TAIL_CHARS)}`
497}
498
499const clipText = (text: string, limit: number): string =>
500 text.length > limit ? `${text.slice(0, limit)}……` : text
501
502const newestFirst = (tasks: readonly TaskState[]): TaskState[] =>
503 [...tasks].sort(
504 (left, right) => right.latest.ts - left.latest.ts || right.latest.seq - left.latest.seq,
505 )
506
507const anchorLine = (task: TaskState): string => {
508 const by = task.latest.superseded_by
509
510 return typeof by === 'string' && by.trim() !== ''
511 ? `- ${task.title}(已由 ${by} 取代)`
512 : `- ${task.title}`
513}
514
515/**
516 * The notes a task left, oldest first. A stage booked as its own entry must not
517 * vanish behind the last one, so newest notes are taken until the count or the
518 * text budget runs out; the latest window's come first and earlier windows fill
519 * what is left, so a window that booked only once still shows the work before it.
520 * The latest note always stays, even alone over budget: it is the one the model
521 * reads first.
522 */
523const windowNotesOf = (task: TaskState, entries: readonly LedgerEntry[]): LedgerEntry[] => {
524 const newest = entries
525 .filter(entry => entry.task_id === task.latest.task_id)
526 .sort((left, right) => right.window - left.window || right.seq - left.seq)
527 const picked: LedgerEntry[] = []
528 let chars = 0
529 for (const entry of newest) {
530 if (picked.length >= MAX_WINDOW_NOTES) break
531 if (picked.length > 0 && chars + entry.text.length > WINDOW_NOTES_CHARS) break
532 picked.push(entry)
533 chars += entry.text.length
534 }
535
536 return picked.reverse()
537}
538
539/**
540 * The home page a new window opens with: the person's own words first, then what
541 * is still open. Sections 1-3 are never dropped, so the request and the open
542 * tasks always survive the cap; the rest is trimmed oldest-first.
543 */
544export const renderHome = (
545 input: {
546 window: number
547 entries: readonly LedgerEntry[]
548 requests: readonly HistoryRecord[]
549 /** The hand-off the open work came from, when it was not a person's request (see `isRelay`). */
550 relay?: HistoryRecord
551 /** The model's reply each request came after, by the request's seq. */
552 answered?: ReadonlyMap<number, HistoryRecord>
553 archive: readonly ArchivePage[]
554 },
555 config: Config,
556): string => {
557 const tasks = tasksOf(input.entries)
558 const active = tasks.filter(task => task.status === 'in_progress' || task.status === 'blocked')
559 const doneLines = newestFirst(tasks.filter(task => task.status === 'done')).map(
560 task => `- ${task.title}`,
561 )
562 const archiveLines = input.archive.map(
563 page =>
564 `- 第 ${page.page} 页:${page.titles.join('、')}(#${page.fromSeq}-${page.toSeq},${dayOf(page.fromTs)} 至 ${dayOf(page.toTs)})`,
565 )
566 const superseded = newestFirst(tasks.filter(task => task.status === 'superseded'))
567 const pastCap = Math.max(0, superseded.length - MAX_SUPERSEDED_SHOWN)
568 const superLines = superseded.slice(0, MAX_SUPERSEDED_SHOWN).map(anchorLine)
569
570 const head: string[] = []
571 head.push(
572 input.window === 1
573 ? '当前是第 1 个窗口。'
574 : `当前是第 ${input.window} 个窗口(上一个是第 ${input.window - 1} 个)。`,
575 )
576 head.push('', '## 最近的用户请求')
577 if (input.requests.length === 0) head.push('无')
578 for (const record of input.requests) {
579 // Typed while a turn was running: it is about the work then under way, not a turn of its own.
580 const midTurn = record.name === 'queued_command' ? '(回合进行中插入)' : ''
581 head.push(`- [#${record.seq}] ${new Date(record.ts).toISOString()} ${midTurn}${clipRequest(record.text, record.seq)}`)
582 const reply = record.text.length <= SHORT_REQUEST_CHARS ? input.answered?.get(record.seq) : undefined
583 if (reply !== undefined) head.push(` - 回复的是助手 [#${reply.seq}]:${clipAnswered(reply.text)}`)
584 }
585 if (input.relay !== undefined) {
586 const relay = input.relay
587 // Kept apart from the requests: it says what the work is, but no person said it.
588 head.push('', '## 派发来的任务(协调者等转交的,不是用户亲口说的)')
589 head.push(`- [#${relay.seq}] ${new Date(relay.ts).toISOString()} ${clipRequest(relay.text, relay.seq)}`)
590 }
591 head.push('', '## 进行中 / 受阻的任务')
592 if (active.length === 0) {
593 head.push('无')
594 } else {
595 // Newest first, so what is dropped to a summary is what was touched longest ago.
596 const ordered = newestFirst(active)
597 const full = ordered.slice(0, MAX_FULL_ACTIVE)
598 const short = ordered.slice(MAX_FULL_ACTIVE)
599 for (const task of full) {
600 head.push(`- ${task.taskId}:${task.title}(${STATUS_LABEL[task.status]})`)
601 const notes = windowNotesOf(task, input.entries)
602 const hasOlderWindow = notes.some(note => note.window !== task.latest.window)
603 if (notes.length <= 1) {
604 head.push(` - 最新:${task.latest.text}`)
605 } else {
606 head.push(
607 hasOlderWindow
608 ? ` - 记录(最近 ${notes.length} 条,旧→新;标了窗口号的是更早的窗口写的):`
609 : ` - 记录(同一窗口写的 ${notes.length} 条,旧→新):`,
610 )
611 for (const note of notes) {
612 const from = note.window === task.latest.window ? '' : `(第 ${note.window} 个窗口)`
613 head.push(` - #L${note.seq}${from}:${note.text}`)
614 }
615 }
616 const next = task.nextStep
617 if (next !== undefined) {
618 head.push(
619 next.isInherited
620 ? ` - 下一步(沿用 #L${next.seq} 的记录,可能已过时):${next.value}`
621 : ` - 下一步:${next.value}`,
622 )
623 }
624 }
625 for (const task of short) {
626 head.push(`- ${task.taskId}:${task.title}(${STATUS_LABEL[task.status]})`)
627 head.push(` - 最新 #L${task.latest.seq}:${clipText(task.latest.text, SHORT_FIELD_CHARS)}`)
628 const next = task.nextStep
629 if (next !== undefined) {
630 const value = clipText(next.value, SHORT_FIELD_CHARS)
631 head.push(
632 next.isInherited
633 ? ` - 下一步(沿用 #L${next.seq} 的记录,可能已过时):${value}`
634 : ` - 下一步:${value}`,
635 )
636 }
637 }
638 if (short.length > 0) {
639 head.push(`(还有 ${short.length} 个较早更新的任务只显示了摘要,完整记录用 ledger_read 按 task_id 查)`)
640 }
641 }
642
643 let trimmed = 0
644 const build = (): string => {
645 const lines = [...head]
646 if (superLines.length > 0) lines.push('', '## 不要继续这些(已被取代)', ...superLines)
647 if (doneLines.length > 0) lines.push('', '## 已完成', ...doneLines)
648 if (archiveLines.length > 0) lines.push('', '## 归档目录', ...archiveLines)
649 const omitted = trimmed + pastCap
650 if (omitted > 0) lines.push('', `(已省略 ${omitted} 条,用 ledger_read 查看)`)
651
652 return lines.join('\n')
653 }
654
655 let text = build()
656 while (
657 estimateTokens(text) > config.homeTokens &&
658 doneLines.length + archiveLines.length + superLines.length > 0
659 ) {
660 if (doneLines.length > 0) doneLines.pop()
661 else if (archiveLines.length > 0) archiveLines.shift()
662 else superLines.pop()
663 trimmed += 1
664 text = build()
665 }
666
667 return text
668}
669
670/** A done task counts as closed only once its evidence was actually recorded. */
671const isClosed = (task: TaskState): boolean => {
672 if (task.status === 'superseded') return true
673 if (task.status !== 'done') return false
674 const evidence = task.latest.evidence
675
676 return Array.isArray(evidence) && evidence.length > 0
677}
678
679/**
680 * Pages the ledger over: when the current page would be too long to read back,
681 * every closed task's rows move to this page's archive untouched, unless the
682 * task is one the caller still wants in the window.
683 */
684export const turnPage = (
685 entries: readonly LedgerEntry[],
686 protectedTaskIds: ReadonlySet<string>,
687 config: Config,
688): { keep: LedgerEntry[]; moved: LedgerEntry[] } => {
689 if (estimateTokens(toJsonl(entries)) <= config.pageTokens) {
690 return { keep: [...entries], moved: [] }
691 }
692
693 const movable = new Set<string>()
694 for (const task of tasksOf(entries)) {
695 if (protectedTaskIds.has(task.taskId)) continue
696 if (isClosed(task)) movable.add(task.taskId)
697 }
698
699 const keep: LedgerEntry[] = []
700 const moved: LedgerEntry[] = []
701 for (const entry of entries) {
702 if (movable.has(entry.task_id)) moved.push(entry)
703 else keep.push(entry)
704 }
705
706 return { keep, moved }
707}
708
709/** The one-line index entry an archived page leaves behind. */
710export const pageOf = (page: number, moved: readonly LedgerEntry[]): ArchivePage => {
711 if (moved.length === 0) {
712 return { page, titles: [], fromSeq: 0, toSeq: 0, fromTs: 0, toTs: 0 }
713 }
714
715 const titles: string[] = []
716 for (const task of tasksOf(moved)) {
717 if (!titles.includes(task.title)) titles.push(task.title)
718 }
719
720 const first = moved[0] as LedgerEntry
721 const last = moved[moved.length - 1] as LedgerEntry
722 let fromTs = first.ts
723 let toTs = first.ts
724 for (const entry of moved) {
725 if (entry.ts < fromTs) fromTs = entry.ts
726 if (entry.ts > toTs) toTs = entry.ts
727 }
728
729 return { page, titles, fromSeq: first.seq, toSeq: last.seq, fromTs, toTs }
730}
731
732/** The archive file of one page: archive-001.jsonl, zero-padded to three. */
733export const archiveName = (page: number): string =>
734 `archive-${String(page).padStart(3, '0')}.jsonl`
735hooks/lib/workset.ts 482 lines1import type { Anchor, Config, FileActivity, Snippet } from './types.ts'
2import { estimateTokens } from './types.ts'
3
4/** Two spellings of one path must map to one file: Windows folds slashes and case. */
5export const pathKey = (path: string): string =>
6 path.replace(/\\/g, '/').toLowerCase().replace(/\/+$/, '')
7
8/** The same file spelled two ways is one file; read one field of a call the model wrote, never trusting the shape. */
9const field = (input: Readonly<Record<string, unknown>>, name: string): unknown =>
10 typeof input === 'object' && input !== null ? input[name] : undefined
11
12const asPath = (value: unknown): string | undefined =>
13 typeof value === 'string' && value.trim() !== '' ? value : undefined
14
15const asLine = (value: unknown): number | undefined =>
16 typeof value === 'number' && Number.isFinite(value) ? value : undefined
17
18/** The first non-blank line an Edit wrote: the text a later read of the file can be searched for. */
19const hintOf = (value: unknown): string | undefined => {
20 if (typeof value !== 'string') return undefined
21 for (const row of value.split('\n')) {
22 const line = row.trim()
23 if (line !== '') return line.slice(0, 200)
24 }
25
26 return undefined
27}
28
29export type FileState = {
30 /** The spelling of the newest activity that touched the file. */
31 path: string
32 lastOp: FileActivity['op']
33 lastSeq: number
34 reads: number
35 edits: number
36 lastReadSeq?: number
37 lastEditSeq?: number
38 isEditedSinceRead: boolean
39}
40
41/** Turns one tool call into the file activity the working set is built from; null when no file was touched. */
42export const activityOf = (
43 tool: string,
44 input: Readonly<Record<string, unknown>>,
45 meta: { seq: number; window: number; isError: boolean },
46): FileActivity | null => {
47 const base = (path: string, op: FileActivity['op']): FileActivity => ({
48 path,
49 op,
50 seq: meta.seq,
51 window: meta.window,
52 isError: meta.isError,
53 })
54
55 if (tool === 'Read') {
56 const path = asPath(field(input, 'file_path'))
57 if (path === undefined) return null
58 const activity = base(path, 'read')
59 const offset = asLine(field(input, 'offset'))
60 const limit = asLine(field(input, 'limit'))
61 if (offset !== undefined) {
62 activity.startLine = offset
63 if (limit !== undefined) activity.endLine = offset + limit - 1
64 }
65
66 return activity
67 }
68
69 if (tool === 'Edit') {
70 const path = asPath(field(input, 'file_path'))
71 if (path === undefined) return null
72 const activity = base(path, 'edit')
73 const hint = hintOf(field(input, 'new_string'))
74 if (hint !== undefined) activity.hint = hint
75
76 return activity
77 }
78
79 if (tool === 'Write') {
80 const path = asPath(field(input, 'file_path'))
81
82 return path === undefined ? null : base(path, 'write')
83 }
84
85 if (tool === 'NotebookEdit') {
86 const path = asPath(field(input, 'notebook_path'))
87
88 return path === undefined ? null : base(path, 'edit')
89 }
90
91 if (tool === 'Grep' || tool === 'Glob') {
92 const path = asPath(field(input, 'path'))
93
94 return path === undefined ? null : base(path, 'search')
95 }
96
97 return null
98}
99
100/** One row per file the window worked in, newest activity first; failures and searches say nothing about the file. */
101export const filesOf = (activities: readonly FileActivity[], window?: number): FileState[] => {
102 const rows = new Map<string, FileState>()
103
104 for (const activity of activities) {
105 if (activity.isError || activity.op === 'search') continue
106 if (window !== undefined && activity.window !== window) continue
107 const key = pathKey(activity.path)
108 let row = rows.get(key)
109 if (row === undefined) {
110 row = {
111 path: activity.path,
112 lastOp: activity.op,
113 lastSeq: activity.seq,
114 reads: 0,
115 edits: 0,
116 isEditedSinceRead: false,
117 }
118 rows.set(key, row)
119 }
120 if (activity.seq >= row.lastSeq) {
121 row.lastSeq = activity.seq
122 row.lastOp = activity.op
123 row.path = activity.path
124 }
125 if (activity.op === 'read') {
126 row.reads += 1
127 if (row.lastReadSeq === undefined || activity.seq > row.lastReadSeq) {
128 row.lastReadSeq = activity.seq
129 }
130 } else {
131 row.edits += 1
132 if (row.lastEditSeq === undefined || activity.seq > row.lastEditSeq) {
133 row.lastEditSeq = activity.seq
134 }
135 }
136 }
137
138 const out = [...rows.values()]
139 for (const row of out) {
140 row.isEditedSinceRead =
141 row.lastEditSeq !== undefined &&
142 (row.lastReadSeq === undefined || row.lastEditSeq > row.lastReadSeq)
143 }
144
145 return out.sort((a, b) => b.lastSeq - a.lastSeq)
146}
147
148export type Located =
149 | { kind: 'ok'; line: number }
150 | { kind: 'no-match' }
151 | { kind: 'ambiguous'; count: number }
152
153const lineAt = (text: string, at: number): number => {
154 let line = 1
155 for (let i = 0; i < at; i += 1) {
156 if (text.charCodeAt(i) === 10) line += 1
157 }
158
159 return line
160}
161
162/** Finds the anchor text as one exact substring; line endings are folded so a saved \r never hides it. */
163export const locate = (fileText: string, match: string): Located => {
164 if (match === '') return { kind: 'no-match' }
165 const text = fileText.replace(/\r\n/g, '\n')
166 const needle = match.replace(/\r\n/g, '\n')
167 let count = 0
168 let at = text.indexOf(needle)
169 if (at === -1) return { kind: 'no-match' }
170 const first = at
171 while (at !== -1) {
172 count += 1
173 at = text.indexOf(needle, at + 1)
174 }
175
176 return count === 1 ? { kind: 'ok', line: lineAt(text, first) } : { kind: 'ambiguous', count }
177}
178
179/** Takes the lines around `line`, numbered: enough for the model to edit straight away, cut at the file's edges. */
180export const cut = (
181 fileText: string,
182 line: number,
183 around: number,
184): { startLine: number; endLine: number; text: string } => {
185 const lines = fileText.replace(/\r\n/g, '\n').split('\n')
186 if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
187 const total = lines.length
188 // A line number from a damaged ledger must still give a usable slice: no empty range, no NaN in the text.
189 if (total === 0) return { startLine: 1, endLine: 0, text: '' }
190 const span = Number.isFinite(around) ? Math.max(0, Math.trunc(around)) : 0
191 const at = Number.isFinite(line) ? Math.min(Math.max(1, Math.trunc(line)), total) : 1
192 const startLine = Math.max(1, at - span)
193 const endLine = Math.min(total, at + span)
194 const rows: string[] = []
195 for (let n = startLine; n <= endLine; n += 1) {
196 rows.push(`${n}\t${lines[n - 1] ?? ''}`)
197 }
198
199 return { startLine, endLine, text: rows.join('\n') }
200}
201
202export type FileContent = string | { missing: true } | { tooLarge: true }
203export type OpenTask = { taskId: string; anchors: readonly Anchor[] }
204
205type HintEdit = { path: string; hint: string; seq: number }
206
207/**
208 * An anchor arrives from a ledger file written by an older version, so every field is checked and
209 * a broken one is dropped rather than thrown on: a task with no anchor left is simply anchor-less.
210 */
211const goodAnchors = (anchors: unknown): Anchor[] => {
212 if (!Array.isArray(anchors)) return []
213 const out: Anchor[] = []
214 for (const entry of anchors) {
215 if (typeof entry !== 'object' || entry === null) continue
216 const record = entry as Record<string, unknown>
217 const path = asPath(record['path'])
218 const match = record['match']
219 if (path === undefined || typeof match !== 'string' || match === '') continue
220 const note = record['note']
221 out.push({ path, match, note: typeof note === 'string' ? note : '' })
222 }
223
224 return out
225}
226
227/** Reads an anchor list off a task row that may itself be damaged; the text of a bad id is left empty. */
228const goodTask = (task: unknown): { taskId: string; anchors: readonly Anchor[] } => {
229 if (typeof task !== 'object' || task === null) return { taskId: '', anchors: [] }
230 const record = task as { taskId?: unknown; anchors?: unknown }
231
232 return {
233 taskId: typeof record.taskId === 'string' ? record.taskId : '',
234 anchors: goodAnchors(record.anchors),
235 }
236}
237
238const goodActivities = (activities: readonly FileActivity[]): readonly FileActivity[] =>
239 Array.isArray(activities) ? activities : []
240
241/** Where a task with no anchors left us: the newest edits, one per file, at most three. */
242const hintEdits = (activities: readonly FileActivity[], window?: number): HintEdit[] => {
243 const sorted = activities
244 .filter(
245 activity =>
246 !activity.isError &&
247 activity.op === 'edit' &&
248 (window === undefined || activity.window === window),
249 )
250 .sort((a, b) => b.seq - a.seq)
251 const picked: HintEdit[] = []
252 const seen = new Set<string>()
253 for (const activity of sorted) {
254 const hint = activity.hint
255 if (hint === undefined || hint === '') continue
256 const key = pathKey(activity.path)
257 if (seen.has(key)) continue
258 seen.add(key)
259 picked.push({ path: activity.path, hint, seq: activity.seq })
260 if (picked.length === 3) break
261 }
262
263 return picked
264}
265
266/** The new window's own reads see the old file, not what the file looks like now: the hint files come along. */
267const lastReadSeq = (activities: readonly FileActivity[], path: string): number | undefined => {
268 const key = pathKey(path)
269 let last: number | undefined
270 for (const activity of activities) {
271 if (activity.isError || activity.op !== 'read') continue
272 if (pathKey(activity.path) !== key) continue
273 if (last === undefined || activity.seq > last) last = activity.seq
274 }
275
276 return last
277}
278
279/** Files the caller must read before the switch: every anchor's file, plus a fallback for anchor-less tasks. */
280export const pathsToRead = (
281 tasks: readonly OpenTask[],
282 activities: readonly FileActivity[],
283 window?: number,
284): string[] => {
285 const out: string[] = []
286 const seen = new Set<string>()
287 const list: readonly unknown[] = Array.isArray(tasks) ? tasks : []
288 const acts = goodActivities(activities)
289 let wantsFallback = false
290 const add = (path: string): void => {
291 const key = pathKey(path)
292 if (seen.has(key)) return
293 seen.add(key)
294 out.push(path)
295 }
296
297 for (const raw of list) {
298 const task = goodTask(raw)
299 if (task.anchors.length === 0) wantsFallback = true
300 for (const anchor of task.anchors) add(anchor.path)
301 }
302 if (wantsFallback) {
303 for (const edit of hintEdits(acts, window)) add(edit.path)
304 }
305
306 return out
307}
308
309/**
310 * Turns the disk contents the caller read into the text around each anchor, within a token budget;
311 * an anchor that no longer resolves is reported as needing a re-read, never guessed at.
312 */
313export const snippetsOf = (
314 input: {
315 tasks: readonly OpenTask[]
316 files: ReadonlyMap<string, FileContent>
317 activities: readonly FileActivity[]
318 window?: number
319 },
320 config: Config,
321): Snippet[] => {
322 const out: Snippet[] = []
323 const seen = new Set<string>()
324 const list: readonly unknown[] = Array.isArray(input.tasks) ? input.tasks : []
325 const acts = goodActivities(input.activities)
326 let budget = 0
327 let count = 0
328
329 const needsRead = (path: string, taskId: string, note: string, reason: string): void => {
330 const key = `${taskId}\u0000${pathKey(path)}\u0000${reason}`
331 if (seen.has(key)) return
332 seen.add(key)
333 const seq = lastReadSeq(acts, path)
334 out.push(
335 seq === undefined
336 ? { path, taskId, note, needsRead: reason }
337 : { path, taskId, note, needsRead: reason, lastSeq: seq },
338 )
339 }
340
341 const snippet = (path: string, taskId: string, note: string, startLine: number, text: string): void => {
342 const key = `${taskId}\u0000${pathKey(path)}\u0000#${startLine}`
343 if (seen.has(key)) return
344 seen.add(key)
345 const tokens = estimateTokens(text)
346 if (count > 0 && budget + tokens > config.snippetTokens) {
347 needsRead(path, taskId, note, '超出片段预算')
348 return
349 }
350 budget += tokens
351 count += 1
352 out.push({ path, taskId, note, startLine, text })
353 }
354
355 const fromFile = (path: string, taskId: string, note: string, match: string): void => {
356 const content = input.files.get(pathKey(path))
357 if (content === undefined || typeof content !== 'string') {
358 const isTooLarge = typeof content === 'object' && content !== null && 'tooLarge' in content
359 needsRead(path, taskId, note, isTooLarge ? '文件太大,没有取片段' : '文件不存在或没有读到')
360 return
361 }
362 const at = locate(content, match)
363 if (at.kind === 'no-match') {
364 needsRead(path, taskId, note, '在文件里找不到锚点原文')
365 return
366 }
367 if (at.kind === 'ambiguous') {
368 needsRead(path, taskId, note, `锚点原文出现了 ${at.count} 次,无法唯一定位`)
369 return
370 }
371 const part = cut(content, at.line, config.snippetLines)
372 snippet(path, taskId, note, part.startLine, part.text)
373 }
374
375 let didFallback = false
376 for (const raw of list) {
377 const task = goodTask(raw)
378 if (task.anchors.length === 0) {
379 if (didFallback) continue
380 didFallback = true
381 for (const edit of hintEdits(acts, input.window)) {
382 const content = input.files.get(pathKey(edit.path))
383 if (typeof content !== 'string') continue
384 const at = locate(content, edit.hint)
385 if (at.kind !== 'ok') continue
386 const part = cut(content, at.line, config.snippetLines)
387 snippet(edit.path, task.taskId, '最近改动的位置', part.startLine, part.text)
388 }
389 continue
390 }
391 for (const anchor of task.anchors) {
392 fromFile(anchor.path, task.taskId, anchor.note, anchor.match)
393 }
394 }
395
396 return out
397}
398
399/**
400 * A path shown relative to the working directory, for text the model reads. A path outside
401 * the directory, one that is already relative, or no directory at all leaves it as it was;
402 * what is stored on disk is never passed through here.
403 */
404export const relativePath = (path: string, base: string | undefined): string => {
405 if (base === undefined) return path
406 const root = base.replace(/[\\/]+$/, '')
407 if (root === '') return path
408 // Windows folds case, and a drive-letter or backslash root marks it; a POSIX path keeps its case.
409 const isWindows = /^[A-Za-z]:/.test(root) || root.includes('\\')
410 const fold = (text: string): string => {
411 const slashed = text.replace(/\\/g, '/')
412
413 return isWindows ? slashed.toLowerCase() : slashed
414 }
415 const prefix = `${fold(root)}/`
416 const full = fold(path)
417 // Backslash and slash are the same length, so the cut in the folded text is the cut in the original.
418 if (!full.startsWith(prefix) || full.length === prefix.length) return path
419
420 return path.slice(prefix.length)
421}
422
423/** How many working-set rows the model is shown before the rest are only counted. */
424export const MAX_LISTED_FILES = 30
425
426/**
427 * The block a brand-new window reads first: what was worked in, and the exact places to carry on from.
428 * With a `base` (the working directory) the paths shown are relative to it where they lie inside it.
429 */
430export const renderWorkset = (
431 files: readonly FileState[],
432 snippets: readonly Snippet[],
433 /** Tasks whose anchors were carried over from an earlier entry, by the entry's seq. */
434 inherited: ReadonlyMap<string, number> = new Map(),
435 base?: string,
436): string => {
437 const lines: string[] = ['【文件工作集】']
438 const shown = (path: string): string => relativePath(path, base)
439 const isShortened =
440 base !== undefined &&
441 (files.slice(0, MAX_LISTED_FILES).some(file => shown(file.path) !== file.path) ||
442 snippets.some(snippet => shown(snippet.path) !== snippet.path))
443 if (isShortened) lines.push(`路径相对工作目录 ${base}`)
444 if (files.length === 0) {
445 lines.push('本窗口没有读写过文件')
446 } else {
447 for (const file of files.slice(0, MAX_LISTED_FILES)) {
448 const edited = file.isEditedSinceRead ? ' · 改后未重读' : ''
449 lines.push(
450 `${shown(file.path)} · 读 ${file.reads} 次/改 ${file.edits} 次 · 最后活动 #${file.lastSeq}${edited}`,
451 )
452 }
453 if (files.length > MAX_LISTED_FILES) lines.push(`另有 ${files.length - MAX_LISTED_FILES} 个`)
454 }
455
456 if (snippets.length > 0) {
457 lines.push('', '【待改位置】')
458 const marked = new Set<string>()
459 for (const snippet of snippets) {
460 // Marked once per task, on its first row, so the section is not repeated at every anchor.
461 const from = inherited.get(snippet.taskId)
462 const mark =
463 from === undefined || marked.has(snippet.taskId) ? '' : `(沿用 #L${from} 的记录,可能已过时)`
464 marked.add(snippet.taskId)
465 if (snippet.needsRead !== undefined) {
466 const seen =
467 snippet.lastSeq === undefined ? '' : `(上次读取见 #${snippet.lastSeq})`
468 lines.push(
469 `- 任务 ${snippet.taskId}${mark} · ${shown(snippet.path)} · ${snippet.note} · 需重读:${snippet.needsRead}${seen}`,
470 )
471 continue
472 }
473 lines.push(`- 任务 ${snippet.taskId}${mark} · ${shown(snippet.path)}:${snippet.startLine} · ${snippet.note}`)
474 lines.push('```')
475 lines.push(snippet.text)
476 lines.push('```')
477 }
478 }
479
480 return lines.join('\n')
481}
482hooks/lib/handoff.ts 203 lines1import { estimateTokens } from './types.ts'
2import type { Config } from './types.ts'
3import type { AgentNote } from './session.ts'
4
5/** One recent tool call, as the handoff lists it. */
6export type HandoffAction = {
7 seq: number
8 tool: string
9 path?: string
10 /** The call's input summary, when the row carries one. */
11 input?: string
12 isError: boolean
13 head: string
14}
15
16/** How many agents the handoff lists before it stops naming them. */
17const MAX_AGENTS = 12
18
19/**
20 * The state a noted subagent shows once the engine no longer lists it. It is a
21 * state of its own, not one the engine uses, so the handoff sorts it last.
22 */
23export const ENDED_STATE = '已结束'
24
25/** The closing line of the agent section: how to reach one again from a new window. */
26const AGENT_FOOTER =
27 '要继续某个子代理(已结束的也可以),用 SendMessage 发给它的 id;它的任务原文可用 history 搜索 Agent 或 SendMessage,再按编号读,read 时加 field: "input" 就是完整的任务原文。'
28
29/** Where an agent's state sorts: running first, the ones that already ended last, the rest between. */
30const rankOf = (state: string): number => {
31 if (state === 'running') return 0
32 if (state === ENDED_STATE) return 2
33
34 return 1
35}
36
37/**
38 * The subagents the handoff names, one line each. Running ones come first, so the
39 * ones that still need a decision survive the cap; ended ones come last, in the
40 * order the caller gave them. Past the cap a single line says how many were left out.
41 */
42export const renderAgents = (agents: readonly AgentNote[]): string => {
43 if (agents.length === 0) return ''
44 const ranked = [...agents].sort((left, right) => rankOf(left.state) - rankOf(right.state))
45 const lines = ranked
46 .slice(0, MAX_AGENTS)
47 .map(agent => `- ${agent.id}(${agent.type},${agent.state}):${agent.description}`)
48 const rest = ranked.length - lines.length
49 if (rest > 0) lines.push(`另有 ${rest} 个未列出,用 ListAgents 查看`)
50
51 return ['## 子代理', ...lines, AGENT_FOOTER].join('\n')
52}
53
54/** A deferred tool the window used whose first load rebuilt the cache, with the tokens that rebuild rewrote. */
55export type RebuiltTool = { name: string; calls: number; rebuilt: number }
56
57/**
58 * A rewrite count as the handoff spells it: a plain count under ten thousand, and from
59 * ten thousand on in tenths of 万. The rounding is done on whole tokens, so no float
60 * error can move a half up or down.
61 */
62export const rewriteOf = (tokens: number): string => {
63 if (tokens < 10_000) return `约 ${Math.round(tokens)}`
64 const tenths = Math.round(tokens / 1_000)
65
66 return `约 ${Math.floor(tenths / 10)}.${tenths % 10} 万`
67}
68
69/**
70 * The deferred tools whose first load rebuilt the cached prefix, one line each, with
71 * what the rebuild rewrote. Only these are advised: a load that rebuilt nothing costs
72 * little, so the handoff says nothing of it.
73 */
74export const renderTools = (tools: readonly RebuiltTool[]): string => {
75 if (tools.length === 0) return ''
76
77 return [
78 '## 加载时会重建缓存的延迟工具',
79 ...tools.map(tool => `- ${tool.name}(上个窗口调用 ${tool.calls} 次;上次加载后重写了${rewriteOf(tool.rebuilt)} token)`),
80 '这些工具组第一次加载时,整段对话的缓存会重建,上下文越大重写越多。账本的下一步明确要用到其中某组时,在第一步就用一次 ToolSearch 加载它;不确定会不会用到就先不加载,因为加载后每个请求都要多带它的定义。其他延迟工具随用随加载,不必提前。',
81 ].join('\n')
82}
83
84/**
85 * The section that says why this window was switched when the gate held a load back: the
86 * switch is for those groups, and the first step of the new window is to load them.
87 */
88export const renderLoadReason = (groups: readonly string[]): string =>
89 groups.length === 0
90 ? ''
91 : [
92 '## 这次换窗口的原因',
93 `换窗口是为了加载 ${groups.join('、')}:第一步先用一次 ToolSearch 加载${groups.length === 1 ? '它' : '它们'},再继续账本里的下一步。`,
94 ].join('\n')
95
96export type HandoffInput = {
97 window: number
98 /** The home page the ledger rendered: requests and open tasks. Never cut. */
99 home: string
100 /** Why the window was switched, when the gate held a load back, already rendered; never cut. */
101 loadReason?: string
102 /**
103 * What the model last answered, when no task is running: without it a new
104 * window sees the request and the actions but not the conclusion. Never cut.
105 */
106 lastReply?: string
107 /** The subagents of the session, already rendered; never cut. */
108 agents?: string
109 /** The deferred tools whose load rebuilt the cache, already rendered; never cut. */
110 tools?: string
111 /** What the ledger does not cover, already rendered; never cut, because the actions that would show it can be. */
112 coverage?: string
113 /** The file working set and the snippets taken from disk at the switch. */
114 workset: string
115 actions: readonly HandoffAction[]
116 /**
117 * The switch cut a running turn (an automatic compaction): the model must
118 * go on with what it was doing, not wait to be asked.
119 */
120 isMidTurn: boolean
121}
122
123/** What the model reads first in a new window. */
124export const openingOf = (window: number, isMidTurn: boolean): string =>
125 [
126 `这是第 ${window} 个窗口,上一个窗口的对话不在上下文里。下面是代码生成的交接信息。`,
127 '「待改位置」里的文件片段是刚刚从磁盘读出的最新内容,带行号,可以直接据此修改,不需要重新读取整个文件。标了“需重读”的位置,或片段不够用时,用带偏移和长度的读取,只读需要的部分。',
128 '不要重做账本里已完成的任务。需要之前的工具输出原文时用 history 工具按 #编号 读取。',
129 '之前跑过的命令和读过的文件,输出都在历史里:需要时先用 history 按命令或文件名搜索,再按 #编号 读取(命令的完整原文加 field: "input"),不要重跑同样的探测命令。',
130 isMidTurn
131 ? '上一个窗口是在任务进行中被切走的:不要等待新的指示,按「进行中的任务」里的下一步和「最近的动作」接着做。'
132 : '先用一句话复述当前请求和现在的进度,然后继续。',
133 ].join('\n')
134
135/** The stretch of work after the latest ledger entry, as the handoff owns up to it; empty when nothing is uncovered. */
136export const renderCoverage = (tail: { steps: number; from: number | null; to: number | null; entrySeq: number | null }): string => {
137 if (tail.steps <= 0) return ''
138 const range = tail.from === null || tail.to === null ? '' : `(#${tail.from}–#${tail.to})`
139 const body =
140 tail.entrySeq === null
141 ? `上个窗口里没有记过账,有 ${tail.steps} 个工具结果${range}只在历史里。开工前先用 history 读最近的用户请求和这些结果,不要凭印象继续。`
142 : `最近一次记账(#L${tail.entrySeq})之后还有 ${tail.steps} 个工具结果没有记进账本${range}。它们的结论账本里没有:最近的那些列在“最近的动作”里,需要细节时用 history 按编号读原文,不要凭印象继续。`
143
144 return ['## 账本没覆盖到的部分', body].join('\n')
145}
146
147/** The last tool calls, oldest first, one line each. */
148export const renderActions = (actions: readonly HandoffAction[]): string =>
149 actions.length === 0
150 ? ''
151 : [
152 '## 最近的动作',
153 ...actions.map(
154 action =>
155 `- #${action.seq} ${action.tool}${action.input === undefined ? '' : ` ${action.input}`}${action.path === undefined ? '' : ` ${action.path}`}${action.isError ? '(失败)' : ''}:${action.head}`,
156 ),
157 ].join('\n')
158
159/**
160 * The one message a new window opens with. `handoffTokens` is a target, not a
161 * ceiling: the recent actions go first, then the working set is cut from its end,
162 * but the opening, the home page, the last reply, the subagents and the loaded
163 * tools always stay, since they carry the request, the open tasks, the conclusion
164 * and how to pick them up — so the total can run past it. What the open tasks add
165 * is bounded by the home page (`MAX_FULL_ACTIVE` in ledger.ts), not by this cap.
166 */
167export const assemble = (input: HandoffInput, config: Config): string => {
168 const opening = openingOf(input.window, input.isMidTurn)
169 const reply =
170 input.lastReply === undefined || input.lastReply.trim() === ''
171 ? ''
172 : `## 上一次助手的最后回复\n${input.lastReply}`
173 // The agents list and the loaded tools ride beside the home page and the last
174 // reply: they are what the model needs to pick work back up, so like them they
175 // are never cut.
176 const agents = input.agents ?? ''
177 const tools = input.tools ?? ''
178 const loadReason = input.loadReason ?? ''
179 const coverage = input.coverage ?? ''
180 const join = (workset: string, actions: string): string =>
181 [opening, input.home, reply, agents, loadReason, tools, coverage, workset, actions].filter(part => part !== '').join('\n\n')
182 const actions = renderActions(input.actions)
183 const whole = join(input.workset, actions)
184 if (estimateTokens(whole) <= config.handoffTokens) return whole
185 const withoutActions = join(input.workset, '')
186 if (estimateTokens(withoutActions) <= config.handoffTokens) return withoutActions
187 const fixed = estimateTokens(join('', ''))
188 const note = '\n(交接信息超出上限,其余片段已省略:用带偏移的读取查看)'
189 let room = Math.max(0, config.handoffTokens - fixed - estimateTokens(note))
190 const kept: string[] = []
191 for (const line of input.workset.split('\n')) {
192 const cost = estimateTokens(line) + 1
193 if (cost > room) break
194 room -= cost
195 kept.push(line)
196 }
197 // A fence left open would swallow whatever the model reads next.
198 const open = kept.filter(line => line.startsWith('```')).length % 2 === 1
199 const cut = kept.length === 0 ? '' : `${kept.join('\n')}${open ? '\n```' : ''}${note}`
200
201 return join(cut, '')
202}
203