@thincoder/core 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +1 -0
  3. package/agent/completion.mjs +3 -1
  4. package/agent/family-tools.mjs +12 -9
  5. package/agent/helpers.mjs +11 -2
  6. package/agent/run-stages.mjs +27 -5
  7. package/agent/setup.mjs +6 -0
  8. package/agent/write-gate.mjs +5 -5
  9. package/agent-tools/advisor-async.mjs +4 -4
  10. package/agent-tools/advisor.mjs +1 -1
  11. package/agent-tools/async-discard.mjs +1 -1
  12. package/agent-tools/audit-block.mjs +106 -0
  13. package/agent-tools/batch-lifecycle.mjs +72 -16
  14. package/agent-tools/batch-skeleton.mjs +69 -7
  15. package/agent-tools/batch.mjs +19 -6
  16. package/agent-tools/context.mjs +174 -0
  17. package/agent-tools/goal.mjs +7 -0
  18. package/agent-tools/parent-channel.mjs +2 -2
  19. package/agent-tools/plan.mjs +6 -6
  20. package/agent-tools/read-history.mjs +122 -24
  21. package/agent-tools/settings.mjs +4 -2
  22. package/agent-tools/subagent-async.mjs +3 -3
  23. package/agent-tools/subagent-spawn.mjs +29 -101
  24. package/agent-tools/task.mjs +11 -0
  25. package/agent-tools.mjs +4 -1
  26. package/agent.mjs +10 -4
  27. package/config.mjs +1 -1
  28. package/context.mjs +66 -121
  29. package/fts-text.mjs +41 -0
  30. package/memory/core.mjs +4 -18
  31. package/memory/schema.mjs +4 -11
  32. package/package.json +5 -1
  33. package/prompts/common.md +2 -2
  34. package/prompts/discipline-engineering.md +17 -2
  35. package/prompts/persona-engineering.md +1 -1
  36. package/session-gc.mjs +11 -0
  37. package/session-index-build.mjs +298 -0
  38. package/session-index-cmd.mjs +61 -0
  39. package/session-index-pass.mjs +95 -0
  40. package/session-index-query.mjs +102 -0
  41. package/session-index.mjs +285 -0
  42. package/session-slots-manifest.mjs +19 -0
  43. package/token-window.mjs +188 -0
  44. package/tools/bash.mjs +4 -15
  45. package/tools/execute.mjs +5 -13
  46. package/tools/git-checkpoint.mjs +1 -1
  47. package/tools/git-ext.mjs +23 -20
  48. package/tools/git-run.mjs +141 -0
  49. package/tools/git.mjs +42 -36
  50. package/tools/index.mjs +3 -1
  51. package/tools/process-tree.mjs +20 -0
  52. package/tools/shared.mjs +8 -4
@@ -0,0 +1,285 @@
1
+ /**
2
+ * session-index.mjs — 会话派生索引库:打开 / DDL / schema / 行写入原语 / 测试缝(SESSION.md §6.19)。
3
+ *
4
+ * 机制契约全文 = `docs/core/design/SESSION.md` §6.19(D-SE43–D-SE47;D2 单一权威源)。
5
+ * 本档 = 「DB 打开 / DDL / schema / 测试缝」面;查询与 FTS 检索 = `session-index-query.mjs`;
6
+ * 写面(枚举 / 水位增量 / 会话重建 / 延迟拍 / 清行)= `session-index-build.mjs` +
7
+ * `session-index-pass.mjs`(>300 软线按设计「跨档线拆分预案」二分);命令面 = `session-index-cmd.mjs`。
8
+ *
9
+ * **零权威**:主存(槽 JSON + `{槽}.d/` sidecar + manifest)= 真源;本库只读主存(整读 / 偏移
10
+ * `readSync`),**绝不写回** sessions 树;库丢 / 坏 = 重生成(D-SE46 打开即验 + 改名保留自愈)。
11
+ * **零第三方依赖**:仅 `node:` + 仓内相对 import(FTS5 同装配先例 = `memory/schema.mjs`)。
12
+ * 本档静态 import `node:sqlite` ⇒ **消费侧一律动态 import**(W8 契约②——`ledger-db.mjs` 同款:
13
+ * 静态 import 会把 node:sqlite 拉进 `read_history` 的装配期静态闭包)。
14
+ */
15
+ import { DatabaseSync } from "node:sqlite"
16
+ import { existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs"
17
+ import { dirname, join } from "node:path"
18
+ import { configDir } from "./config-io.mjs"
19
+ import { segmentCJK } from "./fts-text.mjs"
20
+
21
+ /** 库名(单库——与 `memory.db` 同区 `~/.thincoder/`;D-SE43)。 */
22
+ export const SESSION_INDEX_DB_NAME = "session-index.db"
23
+ /** schema 版本(`index_meta.v` 不符 ⇒ 重建——不做迁移;D-SE46)。 */
24
+ export const SESSION_INDEX_SCHEMA_VERSION = "1"
25
+ /** 多进程(CLI + VSC 同启)竞争:WAL + busy_timeout(`memory/schema.mjs` 同值)。 */
26
+ export const SESSION_INDEX_BUSY_TIMEOUT = 3000
27
+ /** 水位 `tail_hash` 窗口 = 末段尾 4KB(D-SE45——等长改写检出)。 */
28
+ export const TAIL_HASH_BYTES = 4096
29
+ /** `tool_calls` 输出上限(= 存储面同值——`slimForDisplay` 截断面即精度上限;D-SE47)。 */
30
+ export const ARG_MAX_CHARS = 300
31
+ /** 延迟拍预算(D-SE45 触发点②:单趟 ≤ 2000ms 且 ≤ 40 会话)。 */
32
+ export const INDEX_PASS_BUDGET_MS = 2000
33
+ export const INDEX_PASS_MAX_SESSIONS = 40
34
+ /** 查询窗口缺省 / 上限(与 read_history 参数面同值:缺省 50 / 上限 200)。 */
35
+ export const DEFAULT_QUERY_LIMIT = 50
36
+ export const MAX_QUERY_LIMIT = 200
37
+
38
+ // ── 测试缝(`_setLedgerDirForTest` 同款:测试不碰真实用户目录)─────────────────
39
+ let indexDirOverride = null
40
+ export function _setSessionIndexDirForTest(dir) { indexDirOverride = dir }
41
+ export function _resetSessionIndexDirForTest() { indexDirOverride = null }
42
+ export function sessionIndexDir() { return indexDirOverride ?? configDir }
43
+ export function sessionIndexDbPath() { return join(sessionIndexDir(), SESSION_INDEX_DB_NAME) }
44
+
45
+ /** DDL(四表 + FTS + 元表;`UNIQUE(sid, idx)` 兼作查询主索引——同列序不重复建;D-SE44)。 */
46
+ export const SESSION_INDEX_DDL = `
47
+ CREATE TABLE IF NOT EXISTS sessions (
48
+ id INTEGER PRIMARY KEY,
49
+ cwd_key TEXT NOT NULL,
50
+ slot INTEGER NOT NULL,
51
+ file TEXT NOT NULL,
52
+ cwd TEXT,
53
+ title TEXT,
54
+ identity TEXT,
55
+ src TEXT NOT NULL,
56
+ updated_at INTEGER,
57
+ indexed_at INTEGER,
58
+ seg_n INTEGER NOT NULL DEFAULT 0,
59
+ seg_bytes INTEGER NOT NULL DEFAULT 0,
60
+ seg_mtime REAL NOT NULL DEFAULT 0,
61
+ tail_hash TEXT NOT NULL DEFAULT '',
62
+ UNIQUE(cwd_key, slot)
63
+ );
64
+ CREATE TABLE IF NOT EXISTS messages (
65
+ rowid INTEGER PRIMARY KEY,
66
+ sid INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
67
+ idx INTEGER NOT NULL,
68
+ seg INTEGER NOT NULL,
69
+ ts INTEGER,
70
+ role TEXT,
71
+ name TEXT,
72
+ tool_call_id TEXT,
73
+ content TEXT NOT NULL DEFAULT '',
74
+ UNIQUE(sid, idx)
75
+ );
76
+ CREATE INDEX IF NOT EXISTS messages_ts ON messages(ts);
77
+ CREATE INDEX IF NOT EXISTS messages_name ON messages(name);
78
+ CREATE TABLE IF NOT EXISTS tool_calls (
79
+ id INTEGER PRIMARY KEY,
80
+ sid INTEGER NOT NULL,
81
+ msg_idx INTEGER NOT NULL,
82
+ ord INTEGER NOT NULL,
83
+ call_id TEXT,
84
+ name TEXT,
85
+ args TEXT,
86
+ UNIQUE(sid, msg_idx, ord)
87
+ );
88
+ CREATE INDEX IF NOT EXISTS tool_calls_name ON tool_calls(name);
89
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(seg_content, seg_name, tokenize='unicode61');
90
+ CREATE TABLE IF NOT EXISTS index_meta (k TEXT PRIMARY KEY, v TEXT);
91
+ `
92
+
93
+ function openRaw(path) {
94
+ const db = new DatabaseSync(path)
95
+ try {
96
+ db.exec(`PRAGMA journal_mode = WAL`)
97
+ db.exec(`PRAGMA busy_timeout = ${SESSION_INDEX_BUSY_TIMEOUT}`)
98
+ db.exec(`PRAGMA foreign_keys = ON`)
99
+ db.exec(SESSION_INDEX_DDL)
100
+ const v = readMeta(db, "v")
101
+ if (v !== null && v !== SESSION_INDEX_SCHEMA_VERSION) throw new Error(`session index schema ${v} ≠ ${SESSION_INDEX_SCHEMA_VERSION}`)
102
+ if (v === null) {
103
+ writeMeta(db, "v", SESSION_INDEX_SCHEMA_VERSION)
104
+ writeMeta(db, "built_at", Date.now())
105
+ }
106
+ } catch (e) {
107
+ // 失败句柄必须关闭:Windows 上未关闭的句柄会让改名失败(现场保留 + 新建两步都落空)
108
+ try { db.close() } catch { /* ignore */ }
109
+ throw e
110
+ }
111
+ return db
112
+ }
113
+
114
+ /** 打开即验 + 开箱自愈(D-SE46):缺失 ⇒ 建;打开 / DDL / 版本失败 ⇒ 现场改名
115
+ * `session-index.db.corrupt-<epochms>`(连同 `-wal` / `-shm`——否则残 WAL 会挂到新库上)+ 新建空库。
116
+ * 仍失败(磁盘满 / 目录不可写 / 无 node:sqlite)⇒ **null = 静默降级**(查询回落主存路径,主存零险)。 */
117
+ export function openSessionIndex({ path = sessionIndexDbPath() } = {}) {
118
+ try { mkdirSync(dirname(path), { recursive: true }) } catch { /* 已存在 / 并发建目录竞争 */ }
119
+ try { return openRaw(path) } catch { /* 缺失 / 坏库 / 版本不符 ⇒ 走自愈 */ }
120
+ const stamp = `${path}.corrupt-${Date.now()}`
121
+ for (const suffix of ["", "-wal", "-shm"]) {
122
+ try { if (existsSync(path + suffix)) renameSync(path + suffix, stamp + suffix) } catch { /* 现场保留尽力面 */ }
123
+ }
124
+ try { return openRaw(path) } catch { return null }
125
+ }
126
+
127
+ /** 元表读 / 写(`v` = schema 版本 · `built_at` = 首建 / 全量重建时刻 · `pass_at` = **最近一次
128
+ * 实际写入(变更)时刻**(无源变的 pass 零写、不推进)· 计数快照)。 */
129
+ export function readMeta(db, k) {
130
+ try {
131
+ const row = db.prepare("SELECT v FROM index_meta WHERE k = ?").get(k)
132
+ return row ? row.v : null
133
+ } catch { return null }
134
+ }
135
+
136
+ export function writeMeta(db, k, v) {
137
+ db.prepare("INSERT INTO index_meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v").run(k, String(v))
138
+ }
139
+
140
+ // ── 行形态(写入面与读出面共用同一口径)──────────────────────────────────────
141
+
142
+ /** 消息文本(多模态数组取 text 部件拼接)。**与 `agent-tools/read-history.mjs` 的 JSON 面
143
+ * `messageText` 同规则**——两面各自持有(索引面不得静态引 read-history 的装配面,反之亦然),
144
+ * 等价性由过滤矩阵对拍用例覆盖。 */
145
+ export function messageText(m) {
146
+ if (typeof m?.content === "string") return m.content
147
+ if (Array.isArray(m?.content)) {
148
+ return m.content
149
+ .map((p) => (p && typeof p === "object" && p.type === "text" ? p.text ?? "" : ""))
150
+ .filter((t) => t.length > 0)
151
+ .join(" ")
152
+ }
153
+ return ""
154
+ }
155
+
156
+ /** 一次工具声明 → `{ name, args, call_id }`(`{function:{…}}` 与扁平两形兼容;`args` 逐字取存储串)。 */
157
+ export function toolCallParts(tc) {
158
+ return {
159
+ call_id: tc?.id ?? null,
160
+ name: tc?.function?.name ?? tc?.name ?? "",
161
+ args: typeof tc?.function?.arguments === "string" ? tc.function.arguments
162
+ : typeof tc?.arguments === "string" ? tc.arguments : null,
163
+ }
164
+ }
165
+
166
+ /** 输出上限(= 存储面同值:≤300 原样,超限截 300 + `…`)。 */
167
+ export function capArguments(s) {
168
+ const t = String(s ?? "")
169
+ return t.length <= ARG_MAX_CHARS ? t : t.slice(0, ARG_MAX_CHARS) + "…"
170
+ }
171
+
172
+ // ── 删除面单点(FTS 同删义务——无触发器:四条删除路径全部经此函数,同事务内同步删;§6.19)──
173
+
174
+ /** 删行单点:`fromIdx = null` ⇒ 整会话(消息 + FTS + 工具声明 + 会话行);给定 `fromIdx` ⇒ 区间
175
+ * (`idx >= fromIdx` 的消息 / FTS 与 `msg_idx >= fromIdx` 的声明——段重写 / 重扫面)。 */
176
+ export function deleteSessionRows(db, sid, { fromIdx = null } = {}) {
177
+ const range = fromIdx === null ? "" : " AND idx >= ?"
178
+ const mArgs = fromIdx === null ? [sid] : [sid, fromIdx]
179
+ const tRange = fromIdx === null ? "" : " AND msg_idx >= ?"
180
+ db.prepare(`DELETE FROM messages_fts WHERE rowid IN (SELECT rowid FROM messages WHERE sid = ?${range})`).run(...mArgs)
181
+ db.prepare(`DELETE FROM tool_calls WHERE sid = ?${tRange}`).run(...mArgs)
182
+ db.prepare(`DELETE FROM messages WHERE sid = ?${range}`).run(...mArgs)
183
+ if (fromIdx === null) db.prepare(`DELETE FROM sessions WHERE id = ?`).run(sid)
184
+ }
185
+
186
+ /** 单会话清行(源消失面——单事务;幂等):删四表该会话行 + 推进 `pass_at`
187
+ * (查询侧据此走回落路径——既有 `session file not found` 文案逐字保留)。 */
188
+ export function dropSessionRows(db, sid, now = Date.now()) {
189
+ db.exec("BEGIN IMMEDIATE")
190
+ try {
191
+ deleteSessionRows(db, sid, { fromIdx: null })
192
+ writeMeta(db, "pass_at", now)
193
+ db.exec("COMMIT")
194
+ } catch (e) {
195
+ try { db.exec("ROLLBACK") } catch { /* ignore */ }
196
+ throw e
197
+ }
198
+ }
199
+
200
+ /** `--rebuild` 面:清四表(单事务)+ 刷新 `built_at`。 */
201
+ export function clearIndex(db) {
202
+ db.exec("BEGIN IMMEDIATE")
203
+ try {
204
+ db.exec("DELETE FROM messages_fts")
205
+ db.exec("DELETE FROM tool_calls")
206
+ db.exec("DELETE FROM messages")
207
+ db.exec("DELETE FROM sessions")
208
+ writeMeta(db, "built_at", Date.now())
209
+ db.exec("COMMIT")
210
+ } catch (e) {
211
+ try { db.exec("ROLLBACK") } catch { /* 回滚尽力面 */ }
212
+ throw e
213
+ }
214
+ }
215
+
216
+ // ── 会话行 / 消息行 / 声明行写入 ─────────────────────────────────────────────
217
+
218
+ export function findSessionByFile(db, file) {
219
+ try { return db.prepare("SELECT * FROM sessions WHERE file = ?").get(file) ?? null } catch { return null }
220
+ }
221
+
222
+ export function findSessionByCwdSlot(db, cwdKey, slot) {
223
+ try { return db.prepare("SELECT * FROM sessions WHERE cwd_key = ? AND slot = ?").get(cwdKey, slot) ?? null } catch { return null }
224
+ }
225
+
226
+ /** 建会话行(返回 sid);同 `(cwd_key, slot)` 已占 ⇒ 先摘旧行(槽号回收 / 轮转 / 本行重建面——
227
+ * 新旧行不混,`UNIQUE(cwd_key, slot)` 零冲突)。 */
228
+ export function insertSession(db, row) {
229
+ const old = findSessionByCwdSlot(db, row.cwd_key, row.slot)
230
+ if (old) deleteSessionRows(db, old.id, { fromIdx: null })
231
+ const r = db.prepare(
232
+ `INSERT INTO sessions (cwd_key, slot, file, cwd, title, identity, src, updated_at, indexed_at, seg_n, seg_bytes, seg_mtime, tail_hash)
233
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
234
+ ).run(row.cwd_key, row.slot, row.file, row.cwd ?? null, row.title ?? null, row.identity ?? null, row.src,
235
+ row.updated_at ?? null, row.indexed_at ?? null, row.seg_n ?? 0, row.seg_bytes ?? 0, row.seg_mtime ?? 0, row.tail_hash ?? "")
236
+ return Number(r.lastInsertRowid)
237
+ }
238
+
239
+ /** 会话级元数据 / 水位推进(单语句——写面在同一事务内调用)。 */
240
+ export function updateSession(db, sid, patch) {
241
+ const cols = ["cwd", "title", "identity", "src", "updated_at", "indexed_at", "seg_n", "seg_bytes", "seg_mtime", "tail_hash"]
242
+ const set = cols.filter((c) => patch[c] !== undefined)
243
+ if (set.length === 0) return
244
+ db.prepare(`UPDATE sessions SET ${set.map((c) => `${c} = ?`).join(", ")} WHERE id = ?`).run(...set.map((c) => patch[c]), sid)
245
+ }
246
+
247
+ /** 插消息行(连同 FTS 行——同事务)。`rows` = `{ idx, seg, ts, role, name, tool_call_id, content }`。 */
248
+ export function insertMessages(db, sid, rows) {
249
+ const msg = db.prepare(`INSERT INTO messages (sid, idx, seg, ts, role, name, tool_call_id, content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
250
+ const fts = db.prepare(`INSERT INTO messages_fts (rowid, seg_content, seg_name) VALUES (?, ?, ?)`)
251
+ for (const r of rows) {
252
+ const info = msg.run(sid, r.idx, r.seg, r.ts ?? null, r.role ?? null, r.name ?? null, r.tool_call_id ?? null, r.content ?? "")
253
+ fts.run(Number(info.lastInsertRowid), segmentCJK(r.content ?? ""), segmentCJK(r.name ?? ""))
254
+ }
255
+ }
256
+
257
+ /** 插声明行(`ord` = 该消息内声明序;`args` = 存储串逐字)。 */
258
+ export function insertToolCalls(db, sid, rows) {
259
+ const stmt = db.prepare(`INSERT INTO tool_calls (sid, msg_idx, ord, call_id, name, args) VALUES (?, ?, ?, ?, ?, ?)`)
260
+ for (const r of rows) stmt.run(sid, r.msg_idx, r.ord, r.call_id ?? null, r.name ?? "", r.args ?? null)
261
+ }
262
+
263
+ /** `--status` 读数(行数 / 取源分布 / 库字节 / 水位覆盖 / 上次变更时刻)。 */
264
+ export function indexSummary(db, { path = sessionIndexDbPath() } = {}) {
265
+ const one = (sql) => { try { return Number(Object.values(db.prepare(sql).get())[0] ?? 0) } catch { return 0 } }
266
+ let bytes = 0
267
+ try { bytes = statSync(path).size } catch { /* 库不在 / 不可读 ⇒ 0 */ }
268
+ return {
269
+ sessions: one("SELECT COUNT(*) FROM sessions"),
270
+ sidecar: one("SELECT COUNT(*) FROM sessions WHERE src = 'sidecar'"),
271
+ json: one("SELECT COUNT(*) FROM sessions WHERE src = 'json'"),
272
+ messages: one("SELECT COUNT(*) FROM messages"),
273
+ toolCalls: one("SELECT COUNT(*) FROM tool_calls"),
274
+ bytes,
275
+ builtAt: Number(readMeta(db, "built_at") ?? 0),
276
+ passAt: Number(readMeta(db, "pass_at") ?? 0),
277
+ }
278
+ }
279
+
280
+ /** 库侧清理(测试收尾 / 现场重置):删库文件三件套(含 WAL / SHM)。 */
281
+ export function removeIndexFiles(path = sessionIndexDbPath()) {
282
+ for (const suffix of ["", "-wal", "-shm"]) {
283
+ try { rmSync(path + suffix, { force: true }) } catch { /* 尽力面 */ }
284
+ }
285
+ }
@@ -289,6 +289,25 @@ export function claimSlot(cwd, slot, m = loadManifest(cwd), deadParam = null) {
289
289
  saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true, release: [slot] })
290
290
  }
291
291
 
292
+ /** 退出全释放(EXIT-CLAIM-RELEASE 批 · F-XR1 · SESSION.md §6.18 D-SE41):优雅退出前释放
293
+ * 本进程**全部**槽认领(保留集空——`staleClaims` 释放谓词单源);落盘判据 = `saveManifest`
294
+ * `opts.release` 三条自动继承(fresh 同次快照取释放集 / 值条件删除——他人窗口内新认领不误删 /
295
+ * 内存认领表同步移除)。**零触碰 marker 面**(F-XR2 路标保留——下次启动经 `usableSlot`
296
+ * 无属主短路零探测直达恢复);崩溃路径不经此(信号无 JS 钩子——F-XR3)。
297
+ * 早退面:磁盘无 manifest ⇒ `existsSync` 单条目早退**零写**(不造盘面);本进程无认领 ⇒
298
+ * 零写返回 false。失败容忍(F-XR1「退出恒达」):整体 try/catch **永不抛出**,返回 boolean
299
+ * (true = 有释放且落盘成功;false = 早退 / 失败——断言面);写失败盘面 = 认领残留 →
300
+ * 恢复走既有探测面(现状形态,数据零险)。 */
301
+ export function releaseClaimsAll(cwd) {
302
+ try {
303
+ if (!existsSync(manifestPath(cwd))) return false // 无 manifest ⇒ 零写(不造盘面)
304
+ const m = loadManifest(cwd)
305
+ if (staleClaims(m, []).length === 0) return false // 无本进程认领 ⇒ 零写(免白刷共享 sessionId)
306
+ saveManifest(cwd, m, null, { release: [] }) // 保留集空 = 全释放;不 setActive(active 共享指针不动)
307
+ return true
308
+ } catch { return false } // F-XR1:退出恒达(写失败 = 认领残留 → 恢复走探测面)
309
+ }
310
+
292
311
  /** Return the active slot number for this process, claiming one if necessary */
293
312
  export function activeSlot(cwd) {
294
313
  const m = loadManifest(cwd)
@@ -0,0 +1,188 @@
1
+ /**
2
+ * token-window.mjs — token 计量 / 窗口 / 切割族(自 `context.mjs` **逐字迁出**)+ 上下文用量单源
3
+ * (CONTEXT-COMPACTION.md §6.16.3 / §6.16.4 / §6.16.8 · context-tool 批 2026-09-21)。
4
+ * 迁出三族(语义零改——原档越限在即,本批按 §6.16.8 拆出):① 计量面(IMAGE_TOKEN_ESTIMATE + `estimateTokens`);
5
+ * ② 尾族(KEEP_HEAD 头注 + TAIL_BUDGET_FRACTION / SUMMARY_TOKEN_ESTIMATE / TAIL_FLOOR_MESSAGES + `keepTailSize` + `tailBudgetTokens`);
6
+ * ③ 切分与配对修复族(`splitHistory` + `repairedTailStart` + `tightenTailByBudget`)。
7
+ * 新增族:`contextUsage`(与 `compressIfNeeded` 的 token 判定**同一函数**)· `historyPercent`(与两端状态行**同式**)·
8
+ * `collectStaleToolOutputs`(陈旧工具输出合格集)+ 门槛常量 `PRUNE_MIN_TOKENS`(**与合格集同住本档** ⇒ 消费方单向取用、零回指)。
9
+ * 依赖面 = `provider/rate.mjs`(`estimateText`)+ `config.mjs`(`providerSpec` / `resolveCompactThreshold`)——**零 import 环**。
10
+ */
11
+ import { estimateText } from "./provider/rate.mjs"
12
+ import { providerSpec, resolveCompactThreshold } from "./config.mjs"
13
+
14
+ const IMAGE_TOKEN_ESTIMATE = 2000 // rough estimate for image content tokens (CLI legacy 256 underestimated real image costs, delaying compaction)
15
+
16
+ /** Rough token count for a list of messages (body + reasoning + tool_calls params) */
17
+ export function estimateTokens(messages) {
18
+ let tokens = 0
19
+ for (const m of messages) {
20
+ if (typeof m.content === "string") tokens += estimateText(m.content)
21
+ else if (Array.isArray(m.content)) {
22
+ for (const part of m.content) {
23
+ if (part.type === "text") tokens += estimateText(part.text)
24
+ else if (part.type === "image_url") tokens += IMAGE_TOKEN_ESTIMATE
25
+ }
26
+ }
27
+ if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
28
+ for (const tc of m.tool_calls ?? []) {
29
+ tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
30
+ }
31
+ }
32
+ return tokens
33
+ }
34
+
35
+ const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED earlier task in multi-task
36
+ // sessions — keeping them verbatim anchored attention on stale work. Everything before the tail is
37
+ // summarized (the summary itself distinguishes completed vs in-progress work; see SUMMARIZE_PROMPT).
38
+ // Tail count formula (D4): window-adaptive (~30 msgs per 100K — old fixed 10 too thin on 1M), capped
39
+ // at 40% of history; §6.4④ D-T1/D-T2 make the count only a CANDIDATE — a token budget (TAIL_BUDGET_FRACTION
40
+ // × window − SUMMARY_TOKEN_ESTIMATE ≈1K, §6.9) tightens it over pair-safe boundaries when compaction runs,
41
+ // never below TAIL_FLOOR_MESSAGES; ordinary sessions never reach it (D-T4: trigger 0.6 untouched).
42
+ const TAIL_BUDGET_FRACTION = 0.15
43
+ const SUMMARY_TOKEN_ESTIMATE = 1000 // §6.9: summary output target ~1K tokens — reserved from the 15%
44
+ const TAIL_FLOOR_MESSAGES = 10 // §6.4④ D-T2: the tail keeps ≥10 verbatim messages — floor beats budget
45
+ export function keepTailSize(provider, historyLen) {
46
+ // provider is guaranteed at every call site (runAgent always builds one); providerSpec
47
+ // degrades to DEFAULT_SPEC (128K) only if provider is somehow absent — acceptable
48
+ // because the 40% history cap still bounds the tail. providers[].context override
49
+ // (K units) is honored here (PROVIDER.md §6.15 T-C2: tail formula follows the window).
50
+ const ctxWindow = providerSpec(provider).context
51
+ return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
52
+ }
53
+ // §6.4④ D-T1 tail token budget: window×15% − summary ~1K — the compressed history segment (summary + placeholder + tail) lands ≈ 15% (B 口径 §6.4④).
54
+ export function tailBudgetTokens(provider) {
55
+ return Math.max(0, Math.floor(providerSpec(provider).context * TAIL_BUDGET_FRACTION) - SUMMARY_TOKEN_ESTIMATE)
56
+ }
57
+
58
+ /**
59
+ * Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
60
+ * head is normally empty (KEEP_HEAD = 0 — earliest messages go into the summary); the tool_calls-extension logic below is defensive for future KEEP_HEAD > 0.
61
+ * The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle, the summary swallows it, leaving orphan tool results → protocol 400.
62
+ * `budgetTokens` (optional, §6.4④ D-T1): when the candidate's estimate exceeds it, the boundary moves
63
+ * forward until the tail fits — never below the D-T2 floor (10 msgs, or the candidate itself when
64
+ * the 40% cap made it < 10 — short history).
65
+ */
66
+ export function splitHistory(history, keepTail, budgetTokens = null) {
67
+ if (history.length <= KEEP_HEAD + keepTail + 1) return null
68
+ let headEnd = KEEP_HEAD
69
+ // head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
70
+ // Parallel calls: one assistant followed by multiple tool messages — accepting only one still causes 400, must collect all
71
+ if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
72
+ while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
73
+ }
74
+ const candidate = repairedTailStart(history, headEnd, history.length - keepTail)
75
+ if (candidate <= headEnd) return null
76
+ let tailStart = candidate
77
+ // §6.4④ D-T1: tighten only above the floor — a candidate ≤ 10 IS the floor (short history under the 40% cap must not tighten further, review #5); the floor is D5-repaired too.
78
+ if (budgetTokens > 0 && keepTail > TAIL_FLOOR_MESSAGES) {
79
+ const floor = repairedTailStart(history, headEnd, history.length - TAIL_FLOOR_MESSAGES)
80
+ if (floor > candidate) tailStart = tightenTailByBudget(history, candidate, floor, budgetTokens)
81
+ }
82
+ return { headEnd, tailStart }
83
+ }
84
+
85
+ /**
86
+ * D5 tail-side pairing repair for a raw cut at history.length − tailCount: pull into the tail any
87
+ * assistant whose tool results are in the tail (the summary swallowing the owner leaves orphan tool
88
+ * results → protocol 400), then skip orphan tool messages at the new boundary. Single-assistant
89
+ * assumption (nearest owner only — a tail spans at most one assistant→tools cycle); bounds-guarded.
90
+ */
91
+ function repairedTailStart(history, headEnd, tailStart) {
92
+ const tailToolIds = new Set()
93
+ for (let i = tailStart; i < history.length; i++) {
94
+ if (history[i].role === "tool") tailToolIds.add(history[i].tool_call_id)
95
+ }
96
+ for (let i = tailStart - 1; i > headEnd; i--) {
97
+ const m = history[i]
98
+ if (m.role === "assistant" && m.tool_calls?.some((tc) => tailToolIds.has(tc.id))) {
99
+ tailStart = i
100
+ break
101
+ }
102
+ }
103
+ while (tailStart < history.length && tailStart > headEnd && history[tailStart].role === "tool") {
104
+ tailStart++
105
+ }
106
+ return tailStart
107
+ }
108
+
109
+ /**
110
+ * §6.4④ D-T1 budget tightening (pair-safe, review #2): walk the boundary FORWARD (fewer tail messages —
111
+ * the rest joins the summary) while the tail's estimated tokens exceed the budget. Only pair-safe
112
+ * positions may stop the walk: a boundary ON a tool message would orphan its owner assistant into the
113
+ * middle (D5); pairing is contiguous in the machine line (§6.4③) — every non-tool boundary is safe.
114
+ * No fit before the floor → keep the floor, accept the overrun.
115
+ */
116
+ function tightenTailByBudget(history, start, floorStart, budgetTokens) {
117
+ const suffixTokens = new Array(history.length + 1)
118
+ suffixTokens[history.length] = 0
119
+ for (let i = history.length - 1; i >= 0; i--) suffixTokens[i] = suffixTokens[i + 1] + estimateTokens([history[i]])
120
+ if (suffixTokens[start] <= budgetTokens) return start // already fits — ordinary sessions stay untouched (D-T2)
121
+ for (let p = start + 1; p <= floorStart; p++) { // first fit keeps the most recent verbatim context
122
+ if (history[p].role !== "tool" && suffixTokens[p] <= budgetTokens) return p
123
+ }
124
+ return floorStart
125
+ }
126
+
127
+ /**
128
+ * 上下文用量单源(§6.16.4「取自哪里」)——与 `compressIfNeeded` 的 token 判定**同一函数**:实测优先 =
129
+ * `_lastPromptTokens + 增量(history.slice(_usageAtLen))`;无实测 = `estimateTokens(history) + system + tools`(固定开销面经 extras 随带)。
130
+ * `threshold` = 该回合检查所用值(`agent._ctxBasis.threshold`——核 `agent.mjs` / VSC `checkAndCompact` 各一处暂存);缺省回退 `resolveCompactThreshold`(退化面①)。
131
+ * `window` = `providerSpec(agent.provider).context`。
132
+ * @returns {{basis:"measured"|"estimated", total, history, system, tools, overhead, threshold, window}}
133
+ */
134
+ export function contextUsage(agent, extras = {}) {
135
+ const history = Array.isArray(agent?.history) ? agent.history : []
136
+ const system = extras.systemPrompt ? estimateText(extras.systemPrompt) : 0
137
+ const tools = extras.tools ? estimateText(JSON.stringify(extras.tools)) : 0
138
+ const overhead = system + tools
139
+ const historyTokens = estimateTokens(history)
140
+ const measured = agent?._lastPromptTokens != null
141
+ return {
142
+ basis: measured ? "measured" : "estimated",
143
+ total: measured
144
+ ? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
145
+ : historyTokens + overhead,
146
+ history: historyTokens,
147
+ system,
148
+ tools,
149
+ overhead,
150
+ threshold: agent?._ctxBasis?.threshold
151
+ ?? resolveCompactThreshold(agent?.config?.agent?.compactThreshold, agent?.provider).value,
152
+ window: providerSpec(agent?.provider).context,
153
+ }
154
+ }
155
+
156
+ /**
157
+ * 状态行百分比(§6.16.4「与状态行 ctx% 的单源关系」)——与 CLI 状态行(`render-frame.mjs` 式)与 VSC `ctxPercentForHistory` **同式**;
158
+ * stats 报出的就是用户看到的那个百分比(不新增第三口径);空历史 ⇒ 0(状态行的 `ctxPct > 0` 显示门在端侧)。
159
+ */
160
+ export function historyPercent(history, provider) {
161
+ return Math.round((estimateTokens(history ?? []) / providerSpec(provider).context) * 100)
162
+ }
163
+
164
+ /** 陈旧工具输出门槛(§6.16.3 判据③):低于门槛的替换是净增 token(stub 本身有长度)——常量与合格集同住本档。 */
165
+ export const PRUNE_MIN_TOKENS = 200
166
+
167
+ /**
168
+ * 陈旧工具输出合格集(§6.16.3——三条件合取,单源实现):① `role === "tool"`;② **保护尾之外**(复用 `splitHistory` 的同一 `tailStart`——不新增第二处切割判据);③ 估算 ≥ `PRUNE_MIN_TOKENS`。
169
+ * 返回逐条计数面(prune 回执 + stats 两处消费):`indexes` 合格索引(升序)· `tokens` 合格集估算和 · `candidates` 全史 tool 结果数 · `tailKept` 保护尾内保留数 · `belowMin` 门槛下跳过数 · `tailCount` 保护尾消息数。
170
+ * **短历史(无中段)⇒ 合格集为空**(no-op——§6.16.3 边界)。
171
+ */
172
+ export function collectStaleToolOutputs(history, provider) {
173
+ const h = Array.isArray(history) ? history : []
174
+ const split = splitHistory(h, keepTailSize(provider, h.length), tailBudgetTokens(provider))
175
+ // 无中段(短历史)⇒ 可摘要面为空 ⇒ **全史即保护尾**(tailStart = 0——合格集必为空,同压缩面 no-op)
176
+ const tailStart = split ? split.tailStart : 0
177
+ const r = { indexes: [], tokens: 0, candidates: 0, tailKept: 0, belowMin: 0, tailCount: h.length - tailStart }
178
+ for (let i = 0; i < h.length; i++) {
179
+ if (h[i]?.role !== "tool") continue
180
+ r.candidates++
181
+ if (i >= tailStart) { r.tailKept++; continue }
182
+ const est = estimateTokens([h[i]])
183
+ if (est < PRUNE_MIN_TOKENS) { r.belowMin++; continue }
184
+ r.indexes.push(i)
185
+ r.tokens += est
186
+ }
187
+ return r
188
+ }
package/tools/bash.mjs CHANGED
@@ -5,7 +5,10 @@ import {
5
5
  makeDecoder,
6
6
  BASH_TIMEOUT_MS,
7
7
  } from "./shared.mjs";
8
- import { spawn, execFileSync } from "node:child_process";
8
+ import { spawn } from "node:child_process";
9
+ // 树杀单源(台账 #208② · TOOLS.md §6.14 落位表行 2):本地副本删除——改用 `./process-tree.mjs`
10
+ // (行为逐字同:win32 `taskkill /T /F` · POSIX 组杀 + 直杀兜底)。
11
+ import { killProcessTree } from "./process-tree.mjs";
9
12
 
10
13
  /** Maximum buffer size per stream (stdout / stderr) before truncation */
11
14
  const MAX_STREAM_BUF = 2_000_000
@@ -58,20 +61,6 @@ function buildBashEnv() {
58
61
  }
59
62
  }
60
63
 
61
- /**
62
- * Platform-aware process tree kill.
63
- * POSIX: kill process group (spawned with detached=true).
64
- * Windows: taskkill /T to reach grandchildren (npm test's subprocesses, etc.).
65
- */
66
- function killProcessTree(child) {
67
- if (process.platform === "win32") {
68
- try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
69
- } else {
70
- try { process.kill(-child.pid, "SIGKILL") } catch {}
71
- try { child.kill("SIGKILL") } catch {} // fallback: kill directly if group kill fails
72
- }
73
- }
74
-
75
64
  /**
76
65
  * Detect destructive git commands and auto-snapshot BEFORE execution.
77
66
  * The scenario: the model writes uncommitted code, then (after breaking things)
package/tools/execute.mjs CHANGED
@@ -24,9 +24,10 @@
24
24
  * filter — return only output lines matching this regex (case-insensitive)
25
25
  * timeoutMs — timeout (default 30s, max 600000ms)
26
26
  */
27
- import { spawn, execFileSync } from "node:child_process"
27
+ import { spawn } from "node:child_process"
28
28
  import { resolve } from "node:path"
29
29
  import { DESC } from "./shared.mjs"
30
+ import { killProcessTree } from "./process-tree.mjs"
30
31
 
31
32
  const MAX_SCRIPT = 50_000
32
33
  const MAX_OUTPUT = 50_000
@@ -58,18 +59,9 @@ function applyFilter(output, filter) {
58
59
  }
59
60
  }
60
61
 
61
- /** Platform-aware process tree kill — mirror of system.mjs/verify.mjs killProcessTree.
62
- * Timeout/abort must reach grandchildren: a script that spawned children keeps the
63
- * pipes open otherwise — "close" never fires and the tool stalls until the 3s kick
64
- * while the orphan keeps running (2026-09-05 advisor 🟡#4). */
65
- function killProcessTree(child) {
66
- if (process.platform === "win32") {
67
- try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
68
- } else {
69
- try { process.kill(-child.pid, "SIGKILL") } catch {}
70
- try { child.kill("SIGKILL") } catch {}
71
- }
72
- }
62
+ /** 树杀单源 = `tools/process-tree.mjs`(TOOLS.md §6.14 落位表行 2:自本档抽出以断
63
+ * `shared git-run execute shared` 环);**再导出面保留**(`test/tool-seams.test.mjs:26` 消费,
64
+ * `configureProcessTreeKill` 缺省径零改)。 */
73
65
  export { killProcessTree }
74
66
 
75
67
  // ─── 树杀注入缝(#57——「树杀实现按端注入」,形态参 §2.13.5 注入缝)───────────────
@@ -25,7 +25,7 @@ export async function lazyClearIfCommitted(cwd) {
25
25
  try {
26
26
  const cps = await listCheckpoints(cwd)
27
27
  if (cps.length === 0) return
28
- const headSec = runGit(cwd, ["log", "-1", "--format=%ct"])
28
+ const headSec = await runGit(cwd, ["log", "-1", "--format=%ct"])
29
29
  const headMs = Number.parseInt(headSec, 10) * 1000
30
30
  if (!Number.isFinite(headMs) || headMs <= 0) return
31
31
  const newest = cps[0] // listCheckpoints returns newest → oldest