@thincoder/core 0.9.2 → 0.9.4

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 (54) hide show
  1. package/CHANGELOG.md +76 -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-presets.mjs +2 -2
  28. package/config.mjs +1 -1
  29. package/context.mjs +66 -121
  30. package/fts-text.mjs +41 -0
  31. package/memory/core.mjs +4 -18
  32. package/memory/schema.mjs +4 -11
  33. package/model-specs.mjs +22 -7
  34. package/package.json +5 -1
  35. package/prompts/common.md +2 -2
  36. package/prompts/discipline-engineering.md +17 -2
  37. package/prompts/persona-engineering.md +1 -1
  38. package/session-gc.mjs +11 -0
  39. package/session-index-build.mjs +298 -0
  40. package/session-index-cmd.mjs +61 -0
  41. package/session-index-pass.mjs +95 -0
  42. package/session-index-query.mjs +102 -0
  43. package/session-index.mjs +285 -0
  44. package/session-slots-manifest.mjs +19 -0
  45. package/token-window.mjs +188 -0
  46. package/tools/bash.mjs +4 -15
  47. package/tools/execute.mjs +5 -13
  48. package/tools/git-checkpoint.mjs +1 -1
  49. package/tools/git-ext.mjs +23 -20
  50. package/tools/git-run.mjs +141 -0
  51. package/tools/git.mjs +42 -36
  52. package/tools/index.mjs +3 -1
  53. package/tools/process-tree.mjs +20 -0
  54. package/tools/shared.mjs +8 -4
@@ -0,0 +1,298 @@
1
+ /**
2
+ * session-index-build.mjs — 会话枚举 / 取源 / 水位增量 / 会话重建(SESSION.md §6.19 D-SE44/D-SE45;写面)。
3
+ *
4
+ * 机制契约全文 = `docs/core/design/SESSION.md` §6.19(D2 单一权威源)。本档只**读**主存(槽 JSON 整读 / 段偏移
5
+ * `readSync`),绝不写回 sessions 树(零权威);`session-index.mjs` 的 >300 软线二分产物(设计「跨档线
6
+ * 拆分预案」:枚举与水位增量 ∥ 重建 / 拍 / 清行——拍 / 清行 / 有界趟住 `session-index-pass.mjs`)。
7
+ *
8
+ * 取源**双路**:`<槽文件>.d/` 含 `seg-*.jsonl` ⇒ `src=sidecar`(增量面);否则 `src=json`(槽 JSON
9
+ * `history` 一次性整档读,mtime / size 键)。水位 = `(seg_n, seg_bytes, seg_mtime, tail_hash)` + 三规则(段号跳过 / 偏移追加(只落完整行)/ 重写检出);零源变 ⇒ 零读零写。
10
+ */
11
+ import { closeSync, openSync, readFileSync, readSync, readdirSync, statSync } from "node:fs"
12
+ import { createHash } from "node:crypto"
13
+ import { dirname, join } from "node:path"
14
+ import { sessionPath } from "./session-slots.mjs"
15
+ import { RECORD_SEG_MESSAGES, listSegments, recordDirOf, segName, tryParse } from "./session-segments.mjs"
16
+ import {
17
+ TAIL_HASH_BYTES, deleteSessionRows, dropSessionRows, findSessionByFile, insertMessages, insertSession, insertToolCalls,
18
+ messageText, toolCallParts, updateSession, writeMeta,
19
+ } from "./session-index.mjs"
20
+ /** 观测计数(测试缝:段读计数 / 读字节增量 / 已索引会话数)。 */
21
+ export const _indexStats = { segmentReads: 0, bytesRead: 0, sessionsIndexed: 0 }
22
+ export function _resetSessionIndexStats() { _indexStats.segmentReads = 0; _indexStats.bytesRead = 0; _indexStats.sessionsIndexed = 0 }
23
+
24
+ /** sessions 根(核 `sessionPath` 反推——`_setSessionsDirForTest` 沙箱缝自动随动;零副本)。 */
25
+ export function sessionsRoot() { return dirname(sessionPath(process.cwd())) }
26
+
27
+ /** 会话枚举:`^{40 位哈希}\.json\.\d+$` 文件名(不经 `.d` 枚举——孤儿 `.d` 实测 1,119 个);按源 mtime 降序(拍的选择序)。 */
28
+ export function listSessionFiles({ dir = sessionsRoot() } = {}) {
29
+ let names = []
30
+ try { names = readdirSync(dir) } catch { return [] }
31
+ const out = []
32
+ for (const name of names) {
33
+ const m = /^([0-9a-f]{40})\.json\.(\d+)$/.exec(name)
34
+ if (!m) continue
35
+ const file = join(dir, name)
36
+ try { out.push({ file, cwdKey: m[1], slot: Number(m[2]), mtimeMs: statSync(file).mtimeMs }) } catch { /* 竞态消失 */ }
37
+ }
38
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs)
39
+ }
40
+
41
+ /** 槽文件名 → `{cwdKey, slot}`(枚举面同一正则;入参 = 槽文件绝对路径)。 */
42
+ export function slotFileKey(file) {
43
+ const m = /^([0-9a-f]{40})\.json\.(\d+)$/.exec(file.replace(/\\/g, "/").split("/").pop() ?? "")
44
+ return m ? { cwdKey: m[1], slot: Number(m[2]) } : null
45
+ }
46
+
47
+ /** 取源选择:段数 > 0 ⇒ `src=sidecar`;否则 `src=json`(空 `.d` / 只有 `meta.json` 不算有内容)。 */
48
+ export function resolveSource(file) {
49
+ const dir = recordDirOf(file)
50
+ const segs = listSegments(dir)
51
+ return segs.length === 0 ? { src: "json", dir: null, segs: [] } : { src: "sidecar", dir, segs }
52
+ }
53
+
54
+ const segPath = (dir, n) => join(dir, segName(n))
55
+
56
+ /** 槽 JSON 头部 4KB 内的 `cwd`(有界读——不整体 parse;取不到 ⇒ null,行仍以 `file` 锚定)。 */
57
+ function readCwdHead(file) {
58
+ let fd = null
59
+ try {
60
+ fd = openSync(file, "r")
61
+ const buf = Buffer.alloc(4096)
62
+ _indexStats.bytesRead += readSync(fd, buf, 0, 4096, 0)
63
+ const m = /"cwd"\s*:\s*("(?:[^"\\]|\\.)*")/.exec(buf.toString("utf8"))
64
+ const v = m ? JSON.parse(m[1]) : null
65
+ return typeof v === "string" ? v : null
66
+ } catch { return null } finally { if (fd !== null) try { closeSync(fd) } catch { /* ignore */ } }
67
+ }
68
+
69
+ /** `<hash>.json.manifest` 槽摘要(每 cwd 一次读;缺失 ⇒ null,不落错值)。 */
70
+ export function readManifestDigest(file, cwdKey) {
71
+ try { const m = JSON.parse(readFileSync(join(dirname(file), `${cwdKey}.json.manifest`), "utf8")); return m?.slots && typeof m.slots === "object" ? m.slots : null } catch { return null }
72
+ }
73
+
74
+ /** sidecar 身份锚(`meta.json.identity`——槽号回收 / 轮转 ⇒ 会话级重建)。 */
75
+ function readSidecarIdentity(dir) {
76
+ try { const m = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8")); return typeof m?.identity === "string" && m.identity ? m.identity : null } catch { return null }
77
+ }
78
+
79
+ /** 一条消息 → 行(内容 / 名字 / 声明面同源;`ts` 仅取数值——legacy null)。 */
80
+ function messageRow(m, idx, seg) {
81
+ return {
82
+ idx, seg, ts: typeof m?.ts === "number" ? m.ts : null, role: typeof m?.role === "string" ? m.role : null,
83
+ name: typeof m?.name === "string" ? m.name : null, tool_call_id: typeof m?.tool_call_id === "string" ? m.tool_call_id : null,
84
+ content: messageText(m), calls: Array.isArray(m?.tool_calls) ? m.tool_calls.map(toolCallParts) : [],
85
+ }
86
+ }
87
+
88
+ /** 完整行切分:末尾无换行的半行丢弃(只落完整行——水位停在最后一个换行处)。 */
89
+ function completeLines(text) {
90
+ const cut = text.lastIndexOf("\n")
91
+ if (cut < 0) return { lines: [], bytes: 0 }
92
+ const lines = text.slice(0, cut + 1).split("\n")
93
+ lines.pop()
94
+ return { lines, bytes: Buffer.byteLength(text.slice(0, cut + 1), "utf8") }
95
+ }
96
+
97
+ /** 整段读(`mtime` = 读前 stat——并发写时下趟按 size 增量补齐)。 */
98
+ function readSegment(path) {
99
+ let st = null
100
+ try { st = statSync(path) } catch { return { lines: [], bytes: 0, mtime: 0 } }
101
+ _indexStats.segmentReads++
102
+ let text = ""
103
+ try { text = readFileSync(path, "utf8") } catch { return { lines: [], bytes: 0, mtime: st.mtimeMs } }
104
+ _indexStats.bytesRead += Buffer.byteLength(text, "utf8")
105
+ return { ...completeLines(text), mtime: st.mtimeMs }
106
+ }
107
+
108
+ /** 偏移追加读(自 `fromByte`;只落完整行——返回新水位字节位)。 */
109
+ function readIncrement(path, fromByte) {
110
+ let st = null
111
+ try { st = statSync(path) } catch { return { lines: [], bytes: fromByte, mtime: 0 } }
112
+ if (st.size <= fromByte) return { lines: [], bytes: fromByte, mtime: st.mtimeMs }
113
+ let text = ""
114
+ let fd = null
115
+ _indexStats.segmentReads++
116
+ try {
117
+ fd = openSync(path, "r")
118
+ const buf = Buffer.alloc(st.size - fromByte)
119
+ const n = readSync(fd, buf, 0, buf.length, fromByte)
120
+ text = buf.toString("utf8", 0, n)
121
+ _indexStats.bytesRead += n
122
+ } catch { return { lines: [], bytes: fromByte, mtime: st.mtimeMs } } finally { if (fd !== null) try { closeSync(fd) } catch { /* ignore */ } }
123
+ const c = completeLines(text)
124
+ return { lines: c.lines, bytes: fromByte + c.bytes, mtime: st.mtimeMs }
125
+ }
126
+
127
+ /** 尾哈希(文件末 4KB 的 sha1——等长改写检出,D-SE45 规则③;与比对侧同口径 = 现盘文件末尾窗口)。 */
128
+ function tailHash(path) {
129
+ let st = null
130
+ try { st = statSync(path) } catch { return "" }
131
+ const len = Math.min(TAIL_HASH_BYTES, st.size)
132
+ if (len <= 0) return ""
133
+ const buf = Buffer.alloc(len)
134
+ let fd = null
135
+ try {
136
+ fd = openSync(path, "r")
137
+ const n = readSync(fd, buf, 0, len, st.size - len)
138
+ _indexStats.bytesRead += n
139
+ return createHash("sha1").update(buf.subarray(0, n)).digest("hex")
140
+ } catch { return "" } finally { if (fd !== null) try { closeSync(fd) } catch { /* ignore */ } }
141
+ }
142
+
143
+ /** 行 → 消息行(半行 / 损坏行跳过;`idx` 只数可解析行——与槽 JSON 投影面跳过损坏行同口径;`k0` = 该段已落行数)。 */
144
+ function parsedRows(lines, seg, k0) {
145
+ const base = (seg - 1) * RECORD_SEG_MESSAGES
146
+ const rows = []
147
+ for (const line of lines) {
148
+ const m = line === "" ? undefined : tryParse(line)
149
+ if (m === undefined) continue
150
+ rows.push(messageRow(m, base + k0 + rows.length, seg))
151
+ }
152
+ return rows
153
+ }
154
+
155
+ /** 行集 → 表(消息 + FTS 同事务;声明 `ord` = 消息内序)。 */
156
+ function writeRows(db, sid, rows) {
157
+ insertMessages(db, sid, rows)
158
+ const calls = rows.flatMap((r) => r.calls.map((c, k) => ({ msg_idx: r.idx, ord: k, call_id: c.call_id, name: c.name, args: c.args })))
159
+ insertToolCalls(db, sid, calls)
160
+ }
161
+
162
+ /** `pass_at` = 最近一次实际写入(变更)时刻 + 计数快照(无源变的 pass 零写、不推进)。 */
163
+ export function bumpMeta(db, now) {
164
+ writeMeta(db, "pass_at", now)
165
+ for (const [k, sql] of [["snapshot_sessions", "SELECT COUNT(*) FROM sessions"], ["snapshot_messages", "SELECT COUNT(*) FROM messages"], ["snapshot_tool_calls", "SELECT COUNT(*) FROM tool_calls"]]) {
166
+ try { writeMeta(db, k, Object.values(db.prepare(sql).get())[0]) } catch { /* 计数快照尽力面 */ }
167
+ }
168
+ }
169
+
170
+ /** 会话行公共字段(`cwd` / `title` / `updated_at` 取源见 §6.19:头 4KB + manifest 槽摘要)。 */
171
+ function sessionFields(file, srcTag, meta) {
172
+ const d = meta.digest && meta.slot !== null ? meta.digest[String(meta.slot)] : null
173
+ return {
174
+ cwd_key: meta.cwdKey, slot: meta.slot, file, src: srcTag, identity: meta.identity, cwd: readCwdHead(file),
175
+ title: typeof d?.title === "string" ? d.title : null,
176
+ updated_at: Number.isFinite(d?.updatedAt) ? d.updatedAt : Number.isFinite(d?.ts) ? d.ts : null,
177
+ indexed_at: meta.now,
178
+ }
179
+ }
180
+
181
+ /** 单会话索引(水位增量;返回 `{changed, readBytes?}`——写失败 = 静默降级(主存零险))。 */
182
+ export function indexSession(db, file, { force = false, cwdKey = null, slot = null, digest = null, now = Date.now() } = {}) {
183
+ let st = null
184
+ try { st = statSync(file) } catch {
185
+ // 源消失(库内可能仍有行)⇒ 先清该会话行(调用侧据此走回落路径——既有文案)
186
+ const gone = findSessionByFile(db, file)
187
+ if (gone) dropSessionRows(db, gone.id, now)
188
+ return { changed: false, missing: true }
189
+ }
190
+ const src = resolveSource(file)
191
+ const row = findSessionByFile(db, file)
192
+ const lastSeg = src.segs.length ? src.segs[src.segs.length - 1] : 0
193
+ const identity = src.src === "sidecar" ? readSidecarIdentity(src.dir) : null
194
+ const shrank = !!row && row.src === "sidecar" && src.src === "sidecar" && lastSeg < row.seg_n
195
+ const identityChanged = !!(identity && row && row.identity && identity !== row.identity)
196
+ const full = force || !row || row.src !== src.src || shrank || identityChanged
197
+ const key = slotFileKey(file)
198
+ const meta = { cwdKey: cwdKey ?? row?.cwd_key ?? key?.cwdKey ?? null, slot: slot ?? row?.slot ?? key?.slot ?? null, identity, digest, now }
199
+
200
+ if (src.src === "json") {
201
+ if (!full && row.seg_bytes === st.size && row.seg_mtime === st.mtimeMs) return { changed: false, readBytes: 0 }
202
+ return ingestJson(db, file, st, row, meta)
203
+ }
204
+ const plan = planSidecar(row, src, full)
205
+ return plan.mode === "noop" ? { changed: false, readBytes: 0 } : ingestSidecar(db, file, src, plan, row, meta)
206
+ }
207
+
208
+ /** 水位三规则(D-SE45):`full` ⇒ 全量重扫;否则按水位段 size / mtime / tail_hash 判 零动作 / 偏移追加 / 该段起重建;段号 > `seg_n` ⇒ 整段建。 */
209
+ function planSidecar(row, src, full) {
210
+ if (full) return { mode: "reset", fromSeg: 1 }
211
+ let st = null
212
+ try { st = statSync(segPath(src.dir, row.seg_n)) } catch { return { mode: "reset", fromSeg: row.seg_n } }
213
+ let mode = "noop"
214
+ if (st.size < row.seg_bytes) mode = "reset" // ② 段被重写(_materialize / 隔离重建面)
215
+ else if (st.size === row.seg_bytes) {
216
+ // ② 零动作 / ③ 同尺寸异内容(等长重写)——只信 size / mtime 会漏检(尾哈希第三层)
217
+ mode = st.mtimeMs === row.seg_mtime || tailHash(segPath(src.dir, row.seg_n)) === row.tail_hash ? "noop" : "reset"
218
+ } else mode = "append" // ② 自 seg_bytes 偏移读增量
219
+ if (mode === "noop" && src.segs[src.segs.length - 1] > row.seg_n) mode = "append" // 段号 > seg_n(轮转)
220
+ return { mode, fromSeg: row.seg_n }
221
+ }
222
+
223
+ /** `src=json`:槽 JSON `history` 一次性整档读(mtime / size 键;无增量面 ⇒ 变即整会话重建)。 */
224
+ function ingestJson(db, file, st, row, meta) {
225
+ _indexStats.segmentReads++
226
+ let data = null
227
+ try {
228
+ const text = readFileSync(file, "utf8")
229
+ _indexStats.bytesRead += Buffer.byteLength(text, "utf8")
230
+ data = JSON.parse(text)
231
+ } catch { return { changed: false, skipped: "unreadable" } }
232
+ const history = Array.isArray(data?.history) ? data.history : null
233
+ if (!history) return { changed: false, skipped: "no-history" }
234
+ db.exec("BEGIN IMMEDIATE")
235
+ try {
236
+ if (row) deleteSessionRows(db, row.id, { fromIdx: null })
237
+ const sid = insertSession(db, { ...sessionFields(file, "json", meta), seg_n: 0, seg_bytes: st.size, seg_mtime: st.mtimeMs, tail_hash: "" })
238
+ const rows = []
239
+ history.forEach((m, i) => rows.push(messageRow(m, i, 1)))
240
+ writeRows(db, sid, rows)
241
+ bumpMeta(db, meta.now)
242
+ db.exec("COMMIT")
243
+ _indexStats.sessionsIndexed++
244
+ return { changed: true, messages: rows.length, readBytes: st.size }
245
+ } catch (e) {
246
+ try { db.exec("ROLLBACK") } catch { /* ignore */ }
247
+ return { changed: false, error: e.message }
248
+ }
249
+ }
250
+
251
+ /** `src=sidecar`:区间删行(reset 面)→ 增量 / 整段读 → 插行 → 水位推进(单事务)。 */
252
+ function ingestSidecar(db, file, src, plan, row, meta) {
253
+ const fromSeg = plan.fromSeg
254
+ db.exec("BEGIN IMMEDIATE")
255
+ try {
256
+ let sid = row?.id ?? null
257
+ if (plan.mode === "reset" && sid !== null) {
258
+ if (fromSeg <= 1) { deleteSessionRows(db, sid, { fromIdx: null }); sid = null }
259
+ else deleteSessionRows(db, sid, { fromIdx: (fromSeg - 1) * RECORD_SEG_MESSAGES })
260
+ }
261
+ if (sid === null) sid = insertSession(db, { ...sessionFields(file, "sidecar", meta), seg_n: 0, seg_bytes: 0, seg_mtime: 0, tail_hash: "" })
262
+ let last = null
263
+ if (plan.mode === "append") {
264
+ const have = Number(db.prepare("SELECT COUNT(*) AS n FROM messages WHERE sid = ? AND seg = ?").get(sid, row.seg_n)?.n ?? 0)
265
+ const inc = readIncrement(segPath(src.dir, row.seg_n), row.seg_bytes)
266
+ writeRows(db, sid, parsedRows(inc.lines, row.seg_n, have))
267
+ last = { seg: row.seg_n, bytes: inc.bytes, mtime: inc.mtime }
268
+ }
269
+ for (const n of src.segs) {
270
+ if (n < fromSeg) continue // ① 已写段不可变 ⇒ 跳过
271
+ if (n === fromSeg && plan.mode === "append") continue // 水位段已按偏移增量处理
272
+ const seg = readSegment(segPath(src.dir, n))
273
+ writeRows(db, sid, parsedRows(seg.lines, n, 0))
274
+ last = { seg: n, bytes: seg.bytes, mtime: seg.mtime }
275
+ }
276
+ const patch = last ? { seg_n: last.seg, seg_bytes: last.bytes, seg_mtime: last.mtime, tail_hash: tailHash(segPath(src.dir, last.seg)) } : {}
277
+ updateSession(db, sid, { identity: meta.identity, src: "sidecar", indexed_at: meta.now, ...patch })
278
+ bumpMeta(db, meta.now)
279
+ db.exec("COMMIT")
280
+ _indexStats.sessionsIndexed++
281
+ return { changed: true, lastSeg: last?.seg ?? null }
282
+ } catch (e) {
283
+ try { db.exec("ROLLBACK") } catch { /* ignore */ }
284
+ return { changed: false, error: e.message }
285
+ }
286
+ }
287
+
288
+ /** 懒保证(触发点①——**射程 = 有段档(`src=sidecar`)**):`src=json` 档免 ensure(整档 JSON 读与回落路径同成本、无收益 ⇒ 其索引化只归延迟拍 / `--rebuild` 两面)。 */
289
+ export function ensureSessionIndexed(db, file, opts = {}) {
290
+ try { statSync(file) } catch { return indexSession(db, file, opts) } // 源消失 ⇒ 清行后回落(见下)
291
+ const src = resolveSource(file)
292
+ if (src.src !== "sidecar") return { changed: false, skipped: "json-source" }
293
+ const row = findSessionByFile(db, file)
294
+ const key = slotFileKey(file)
295
+ const cwdKey = row?.cwd_key ?? key?.cwdKey ?? null
296
+ if (row?.src === "json") return indexSession(db, file, { ...opts, cwdKey, slot: row.slot, digest: readManifestDigest(file, cwdKey) })
297
+ return indexSession(db, file, { ...opts, cwdKey, slot: row?.slot ?? key?.slot ?? null, digest: row ? null : cwdKey ? readManifestDigest(file, cwdKey) : null })
298
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * session-index-cmd.mjs — 会话索引命令面(SESSION.md §6.19 D-SE46)。
3
+ *
4
+ * `thincoder session index [--status | --rebuild]`(双端对位:VSC 命令 `thincoder.sessionIndexRebuild`
5
+ * 走同一核数据面)——**核内零消费方结构机检**:console 形态属壳侧,本档只取 `out` / `err` 注入缝。
6
+ * --status 会话 / 消息 / 工具调用行数 + 库字节 + 水位覆盖(已索引 / 盘上会话档)+ 上次变更时刻
7
+ * --rebuild 清四表 → 全量重扫(存量首建 / 运维兜底;索引可丢弃 ⇒ 重建即迁移)
8
+ * 无参 同 --status
9
+ * 返回进程退出码(0 / 1)。
10
+ */
11
+ import { clearIndex, indexSummary, openSessionIndex } from "./session-index.mjs"
12
+ import { listSessionFiles, sessionsRoot } from "./session-index-build.mjs"
13
+ import { rebuildAllSessions } from "./session-index-pass.mjs"
14
+
15
+ /** 用法行(错用一律拒——零静默忽略旗标)。 */
16
+ export const SESSION_INDEX_USAGE = "Usage: thincoder session index [--status | --rebuild]"
17
+
18
+ const mb = (bytes) => `${(bytes / 1048576).toFixed(1)} MB`
19
+ const iso = (ms) => (ms > 0 ? new Date(ms).toISOString() : "(never)")
20
+
21
+ /** 状态摘要行(读数单源 = `indexSummary` + 盘上会话档计数)。 */
22
+ export function sessionIndexStatusLine(summary, onDisk) {
23
+ return `Session index: ${summary.sessions} sessions (sidecar ${summary.sidecar} / json ${summary.json})` +
24
+ ` · ${summary.messages} messages · ${summary.toolCalls} tool calls` +
25
+ ` · ${mb(summary.bytes)} on disk` +
26
+ ` · covered ${summary.sessions}/${onDisk} session file(s) on disk` +
27
+ ` · last change ${iso(summary.passAt)}`
28
+ }
29
+
30
+ /**
31
+ * 命令面执行体。`args` = 原始 argv 片段(`args[0]` 须为 `"index"`——壳侧分发同源);
32
+ * `dir` = sessions 根(缺省核侧派生);`dbPath` = 索引库路径(缺省 `~/.thincoder/session-index.db`);
33
+ * `out` / `err` = 输出缝(缺省 console);`now` = 时钟缝。
34
+ */
35
+ export async function runSessionIndex(args, { dir = sessionsRoot(), dbPath = null, out = console.log, err = console.error, now = Date.now() } = {}) {
36
+ const argv = Array.isArray(args) ? args : []
37
+ const flags = argv.slice(1)
38
+ const bad = flags.filter((f) => f !== "--status" && f !== "--rebuild")
39
+ if (argv[0] !== "index" || bad.length > 0 || (flags.includes("--status") && flags.includes("--rebuild"))) {
40
+ err(SESSION_INDEX_USAGE)
41
+ return 1
42
+ }
43
+ const db = openSessionIndex(dbPath ? { path: dbPath } : {})
44
+ if (!db) {
45
+ err("Session index unavailable — the derived index could not be opened (disk / permissions); queries fall back to the session files.")
46
+ return 1
47
+ }
48
+ try {
49
+ if (flags.includes("--rebuild")) {
50
+ clearIndex(db) // 清四表(FTS 同删义务面之一)
51
+ const r = rebuildAllSessions(db, { dir, now })
52
+ out(`Session index rebuilt: ${r.changed}/${r.sessions} session file(s) indexed`)
53
+ }
54
+ const onDisk = listSessionFiles({ dir }).length
55
+ out(sessionIndexStatusLine(indexSummary(db, dbPath ? { path: dbPath } : {}), onDisk))
56
+ return 0
57
+ } catch (e) {
58
+ err(`Session index error: ${e.message}`)
59
+ return 1
60
+ } finally { db.close() }
61
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * session-index-pass.mjs — 有界趟 / 全量重建 / 源消失清行 / 延迟拍(SESSION.md §6.19 D-SE45/D-SE46)。
3
+ *
4
+ * `session-index-build.mjs` 的 >300 软线二分产物(设计「跨档线拆分预案」:枚举与水位增量 ∥
5
+ * 重建 / 拍 / 清行)。触发三点(均不触主存档):
6
+ * ① 懒保证(`session-index-build.mjs` `ensureSessionIndexed`——射程 = 有段档)
7
+ * ② 启动窗外延迟拍(`scheduleSessionIndexPass`——端壳挂点,核会话档零改)
8
+ * ③ 显式命令(`runSessionIndex --rebuild`——住 `session-index-cmd.mjs`)
9
+ */
10
+ import { statSync } from "node:fs"
11
+ import { INDEX_PASS_BUDGET_MS, INDEX_PASS_MAX_SESSIONS, deleteSessionRows, openSessionIndex } from "./session-index.mjs"
12
+ import { bumpMeta, indexSession, listSessionFiles, readManifestDigest, sessionsRoot } from "./session-index-build.mjs"
13
+
14
+ /** 延迟拍延迟(`GC_PASS_DELAY_MS` 同款:自调度点起 3s——启动窗外)。 */
15
+ export const INDEX_PASS_DELAY_MS = 3000
16
+ let passDelayMs = INDEX_PASS_DELAY_MS
17
+ export function _setSessionIndexPassDelayForTest(ms) { passDelayMs = ms }
18
+
19
+ /** 源消失 ⇒ 级联清(`deleteSlot` / `session gc` 连带面;清运无锁、幂等)。 */
20
+ export function pruneMissingSessions(db, { now = Date.now() } = {}) {
21
+ let rows = []
22
+ try { rows = db.prepare("SELECT id, file FROM sessions").all() } catch { return { pruned: 0 } }
23
+ let pruned = 0
24
+ db.exec("BEGIN IMMEDIATE")
25
+ try {
26
+ for (const r of rows) {
27
+ let alive = true
28
+ try { statSync(r.file) } catch { alive = false }
29
+ if (alive) continue
30
+ deleteSessionRows(db, r.id, { fromIdx: null })
31
+ pruned++
32
+ }
33
+ if (pruned > 0) bumpMeta(db, now)
34
+ db.exec("COMMIT")
35
+ } catch (e) {
36
+ try { db.exec("ROLLBACK") } catch { /* ignore */ }
37
+ return { pruned: 0, error: e.message }
38
+ }
39
+ return { pruned }
40
+ }
41
+
42
+ /** 每 cwd manifest 一次读(拍 / 重建共用——覆盖该 cwd 全槽)。 */
43
+ function digestFor(cache, f) {
44
+ if (!cache.has(f.cwdKey)) cache.set(f.cwdKey, readManifestDigest(f.file, f.cwdKey))
45
+ return cache.get(f.cwdKey)
46
+ }
47
+
48
+ /** 有界趟(触发点②):按源 mtime 降序、≤ `limit` 会话 且 ≤ `budgetMs`;水位保证可续跑。 */
49
+ export function syncSessions(db, { dir = sessionsRoot(), limit = INDEX_PASS_MAX_SESSIONS, budgetMs = INDEX_PASS_BUDGET_MS, force = false, now = Date.now() } = {}) {
50
+ const t0 = Date.now()
51
+ const cache = new Map()
52
+ const result = { scanned: 0, changed: 0, skipped: 0 }
53
+ for (const f of listSessionFiles({ dir }).slice(0, limit)) {
54
+ if (Date.now() - t0 > budgetMs) break
55
+ const r = indexSession(db, f.file, { force, cwdKey: f.cwdKey, slot: f.slot, digest: digestFor(cache, f), now })
56
+ result.scanned++
57
+ if (r.changed) result.changed++
58
+ else if (r.skipped || r.error) result.skipped++
59
+ }
60
+ const p = pruneMissingSessions(db, { now })
61
+ return { ...result, pruned: p.pruned ?? 0, elapsedMs: Date.now() - t0 }
62
+ }
63
+
64
+ /** `--rebuild` 面(③ 显式命令——存量首建 / 运维兜底):逐会话全量重扫(水位忽略)。 */
65
+ export function rebuildAllSessions(db, { dir = sessionsRoot(), now = Date.now() } = {}) {
66
+ const files = listSessionFiles({ dir })
67
+ const cache = new Map()
68
+ let changed = 0
69
+ for (const f of files) {
70
+ if (indexSession(db, f.file, { force: true, cwdKey: f.cwdKey, slot: f.slot, digest: digestFor(cache, f), now }).changed) changed++
71
+ }
72
+ return { sessions: files.length, changed }
73
+ }
74
+
75
+ // ── 延迟拍(触发点②:端壳挂点——核会话档零改)────────────────────────────────
76
+
77
+ let passScheduled = false
78
+
79
+ /** 启动窗外延迟拍:`delayMs` 后点火一趟有界 pass(异步非阻塞 / 失败静默 / 不 unref;
80
+ * 每进程一次——`scheduleSessionGC` 同款)。 */
81
+ export function scheduleSessionIndexPass({ delayMs = passDelayMs, dir = sessionsRoot(), limit = INDEX_PASS_MAX_SESSIONS, budgetMs = INDEX_PASS_BUDGET_MS } = {}) {
82
+ if (passScheduled) return false
83
+ passScheduled = true
84
+ setTimeout(() => {
85
+ try {
86
+ const db = openSessionIndex()
87
+ if (!db) return
88
+ try { syncSessions(db, { dir, limit, budgetMs }) } finally { db.close() }
89
+ } catch { /* 拍失败静默——索引 = 派生品,主存零险 */ }
90
+ }, delayMs)
91
+ return true
92
+ }
93
+
94
+ /** 测试缝(延迟拍一次性旗标复位——用例可重复点火)。 */
95
+ export function _resetSessionIndexPassScheduleForTest() { passScheduled = false }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * session-index-query.mjs — 索引查询与 FTS 检索(SESSION.md §6.19 D-SE47;查询面)。
3
+ *
4
+ * `session-index.mjs` 的 >300 软线二分产物(设计「跨档线拆分预案」:DDL / 打开与自愈 ∥
5
+ * 查询与 FTS 检索)。路由判据 / 语义分面 = §6.19 查询面路由表:
6
+ * · 单会话(`path=<文件>` ∈ sessions 根 ∧ 库内有行)——`keyword` = 子串(`LIKE`,既有契约不变)
7
+ * · 跨会话(`path:"all"`)——`keyword` = FTS 词 / 短语(`buildFtsQuery` 单源);无词元 ⇒ 退化 `LIKE`
8
+ * 两者过滤面同义对齐 JSON 回落路径(role / tool / since-until / limit / direction);输出恒时间序。
9
+ * 本档不持 `node:sqlite`(句柄由调用侧传入)——消费侧仍一律动态 import(装配面纪律同族)。
10
+ */
11
+ import { buildFtsQuery } from "./fts-text.mjs"
12
+ import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, capArguments, findSessionByFile } from "./session-index.mjs"
13
+
14
+ /** `LIKE` 元字符按字面转义(`%` / `_` / `\` + `ESCAPE '\'`——keyword 恒字面匹配)。 */
15
+ export function likePattern(keyword) {
16
+ return `%${String(keyword).replace(/[\\%_]/g, (c) => "\\" + c)}%`
17
+ }
18
+
19
+ /** 恒时间序键(`all` 面:无 ts 行恒末尾;`rowid` 末端决胜);单会话面用会话内序 `idx`
20
+ * (= JSON 面 `history` 数组序 / 存储面 `iterate` 序——两路逐条相等的地基)。 */
21
+ const SESSION_CHRONO = ["m.idx"]
22
+ const ALL_CHRONO = ["(m.ts IS NULL)", "m.ts", "m.idx", "m.rowid"]
23
+ /** FTS 词元可用性:关键词须含至少一个字母 / 数字(= unicode61 的 token 主体)——否则 FTS 短语
24
+ * 无词元(零命中)而契约要求「按字面 LIKE」(§6.19 边界情形表退化路径)。 */
25
+ const FTS_USABLE = /[\p{L}\p{N}]/u
26
+
27
+ /** AND 过滤面(JSON 面 `matches` 的同义 SQL:role / keyword / tool(名字 ∪ 声明)/ since-until)。 */
28
+ function filterClause({ role, like, tool, since, until }) {
29
+ const where = []
30
+ const args = []
31
+ const s = since ?? null
32
+ const u = until ?? null
33
+ if (role !== undefined && role !== null) { where.push("m.role = ?"); args.push(role) }
34
+ if (like) { where.push("m.content LIKE ? ESCAPE '\\'"); args.push(like) }
35
+ if (tool) {
36
+ where.push("((m.role = 'tool' AND m.name = ?) OR (m.role = 'assistant' AND EXISTS (SELECT 1 FROM tool_calls tc WHERE tc.sid = m.sid AND tc.msg_idx = m.idx AND tc.name = ?)))")
37
+ args.push(tool, tool)
38
+ }
39
+ if (s !== null || u !== null) {
40
+ where.push("m.ts IS NOT NULL")
41
+ if (s !== null) { where.push("m.ts >= ?"); args.push(s) }
42
+ if (u !== null) { where.push("m.ts <= ?"); args.push(u) }
43
+ }
44
+ return { where, args }
45
+ }
46
+
47
+ /** 时间序 + direction 取端(newest = 最新端);返回恒时间序(`formatMatches` 语义零改)。
48
+ * 方向缀逐项展开(`ORDER BY a, b DESC` 只作用末项——按项渲染)。 */
49
+ function windowed(db, select, { where, args }, { direction, limit, order }) {
50
+ const cond = where.length ? ` WHERE ${where.join(" AND ")}` : ""
51
+ const desc = direction !== "oldest"
52
+ const dir = desc ? "DESC" : "ASC"
53
+ const n = Number.isFinite(limit) ? Math.min(Math.max(1, Math.floor(limit)), MAX_QUERY_LIMIT) : DEFAULT_QUERY_LIMIT
54
+ const rows = db.prepare(`${select}${cond} ORDER BY ${order.map((c) => `${c} ${dir}`).join(", ")} LIMIT ?`).all(...args, n)
55
+ return attachCalls(db, desc ? rows.reverse() : rows)
56
+ }
57
+
58
+ /** 附声明行(`[{name, arguments}]`——输出上限 300 字符 = 存储面同值;无声明不动形)。 */
59
+ function attachCalls(db, rows) {
60
+ for (const r of rows) {
61
+ const calls = toolCallsFor(db, r.sid, r.idx)
62
+ if (calls.length > 0) r.tool_calls = calls
63
+ }
64
+ return rows
65
+ }
66
+
67
+ /** 单会话索引检索(keyword = 子串 `LIKE`——既有契约不变)。返回行数组;**`null` = 库内无此会话**
68
+ * (调用侧据此回落主存 JSON 路径 + 既有护栏——逐字保留)。 */
69
+ export function querySessionRows(db, file, { role, keyword, tool, since, until, limit, direction = "newest" } = {}) {
70
+ const session = findSessionByFile(db, file)
71
+ if (!session) return null
72
+ const { where, args } = filterClause({ role, like: keyword ? likePattern(keyword) : null, tool, since, until })
73
+ return windowed(
74
+ db,
75
+ "SELECT m.rowid AS rowid, m.sid, m.idx, m.ts, m.role, m.name, m.tool_call_id, m.content FROM messages m",
76
+ { where: ["m.sid = ?", ...where], args: [session.id, ...args] },
77
+ { direction, limit, order: SESSION_CHRONO },
78
+ )
79
+ }
80
+
81
+ /** 跨会话检索(`path:"all"`):`keyword` = FTS 词 / 短语(`buildFtsQuery` 单源);无 FTS 词元
82
+ * (纯标点 / 空白)⇒ 退化 `LIKE`(同字面转义)。行携 `file` 回查锚(D-SE47)。 */
83
+ export function queryAllRows(db, { keyword, role, tool, since, until, limit, direction = "newest" } = {}) {
84
+ const { where, args } = filterClause({ role, like: null, tool, since, until })
85
+ const fts = keyword && FTS_USABLE.test(keyword) ? buildFtsQuery(keyword) : ""
86
+ let select = "SELECT m.rowid AS rowid, m.sid, m.idx, m.ts, m.role, m.name, m.tool_call_id, m.content, s.slot, s.cwd, s.title, s.file FROM messages m JOIN sessions s ON s.id = m.sid"
87
+ if (fts) {
88
+ select += " JOIN messages_fts f ON f.rowid = m.rowid"
89
+ where.unshift("messages_fts MATCH ?")
90
+ args.unshift(fts)
91
+ } else if (keyword) {
92
+ where.push("m.content LIKE ? ESCAPE '\\'")
93
+ args.push(likePattern(keyword))
94
+ }
95
+ return windowed(db, select, { where, args }, { direction, limit, order: ALL_CHRONO })
96
+ }
97
+
98
+ /** 声明行读面(`[{name, arguments}]`——`arguments` 上限 300 字符,与存储面同值)。 */
99
+ export function toolCallsFor(db, sid, msgIdx) {
100
+ const rows = db.prepare("SELECT name, args FROM tool_calls WHERE sid = ? AND msg_idx = ? ORDER BY ord").all(sid, msgIdx)
101
+ return rows.filter((r) => r.name).map((r) => (r.args === null ? { name: r.name } : { name: r.name, arguments: capArguments(r.args) }))
102
+ }