SLOPSHOPPER

ctx-mod

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

newguardcommandstatusprompttool
★ 3v0.1.0MITupdated 2026-10-15wdsewdser-bit/claude-code-mods/ctx-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ctx-mod
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ctx-mod ⎿ ctx-mod: ctx-mod 未启用:用环境变量 CTX_MOD=1 启动 Claude Code 后生效(例如 `CTX_MOD=1 claude`)。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ctx-mod

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

模型得到的工具

工具作用
contextstatus 报告上下文用量、距上次换窗的请求数、本小时换窗次数、进行中任务数;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 就按词搜索
historysearch 按词、角色、工具名、文件、窗口、入口(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 万)时分三级提醒模型先记账再换窗。故意放得晚:换窗会丢掉工作细节,所以尽量把一个窗口用完。

  • 模型自己申请换窗,要求距上次换窗至少 30 次模型请求(按真实模型请求计,不按工具调用计),每小时最多 4 次;账本要新,即最近一次用户请求之后本窗口有记账,且之后的工具结果不超过 5 个。否则申请被拒,并告诉模型原因。
  • 引擎自动压缩时由 mod 接管,用交接信息代替摘要。账本不新,或者之后有超过 25 个没记账的工具结果时,把摘要交还给引擎。
  • 缓存已经过期、上下文又超过 10 万 token 时,下一条用户消息会先写进会话目录的 held.json 扣下,换完窗口再提交,因为反正要整段重写缓存。换窗期间新来的消息排队在后面。held.json 写不下时消息照常提交,不丢。进程中断或重新打开会话后遗留的扣留消息不会自动重发,只告诉用户。
  • 上下文超过 12 万 token 时,加载会重建缓存的工具组(如 computer-use)之前先换窗;同一组在同一窗口里只拦一次,换窗后可以再拦。
  • 有文件没写进磁盘时,不做零摘要换窗,改由引擎摘要。
  • 5 小时额度用到 90% 以上时,换窗后不会自己接着干,等用户发话。

这些数字都在 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 不动。它不会自动运行,需要你手动执行,或自行配置计划任务。

已知限制

  • 助手在回合中间、工具调用之前说的话,有一部分在引擎里是思考块,不会进历史。每回合最后的正式回复不受影响。
  • 换窗只保留账本里记下的东西和磁盘上的文件。模型没记账的中间结论会丢,所以账本不新时 mod 会拒绝零摘要换窗。
  • 请求数、窗口大小等判断依赖引擎的事件,引擎版本变了可能要跟着改。
  • costly.json 跨会话表已加锁,但抢不到锁就放弃这次记录,仍是尽力而为;它影响加载前的拦截和交接提示。
  • 真实引擎中很多改动要重启已开会话才生效。有些行为只在单元测试或假引擎里验证过。桌面应用和终端交互会话还没测。

开发

claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .

.claude-plugin/types/ 是引擎加载 mod 时生成的类型定义,不入库。从磁盘加载开发中的 mod:claude --plugin-dir <这个目录>。

Source 8 files
hooks/register.ts 1272 lines
1// 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 lines
1// 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}
747
hooks/lib/session.ts 2104 lines
1// 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 lines
1// 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}
198
hooks/lib/types.ts 292 lines
1// 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}
292
hooks/lib/ledger.ts 735 lines
1// 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`
735
hooks/lib/workset.ts 482 lines
1import 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}
482
hooks/lib/handoff.ts 203 lines
1import { 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