@jjh32456/dsh-longlive 1.6.8 → 1.8.0

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 (288) hide show
  1. package/CHANGELOG.md +69 -1539
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +33 -12
  4. package/README.en.md +17 -7
  5. package/README.md +29 -10
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +17 -12
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +3 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +143 -37
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +66 -29
  16. package/docs/MIGRATION-1.6.0.md +2 -2
  17. package/docs/OPERATIONS.md +6 -6
  18. package/docs/PANEL-INTEGRATION.md +6 -0
  19. package/docs/PRIVACY.md +88 -0
  20. package/docs/QUICKSTART.md +5 -5
  21. package/docs/README.en.md +3 -2
  22. package/docs/RESIDENT-WATCHER.md +1 -1
  23. package/docs/SESSION-WATCH.md +6 -6
  24. package/docs/TROUBLESHOOTING.md +4 -4
  25. package/package.json +42 -117
  26. package/runbook.md +2 -2
  27. package/scripts/backfill-auto-cards.mjs +98 -0
  28. package/scripts/install-surgery-task.ps1 +5 -2
  29. package/scripts/lib/session-watch-fixture.mjs +1 -1
  30. package/scripts/longlive-doctor.mjs +6 -0
  31. package/scripts/migrate-180.mjs +163 -0
  32. package/scripts/recall-index-build.mjs +128 -0
  33. package/scripts/session-watch-drill.mjs +1 -1
  34. package/scripts/surgery-run.cmd +1 -1
  35. package/scripts/unattended-surgery.ps1 +5 -2
  36. package/scripts/verify-card-anchors.mjs +138 -0
  37. package/src/actions/audit.js +125 -0
  38. package/src/actions/evidence.js +122 -0
  39. package/src/actions/fs.js +162 -0
  40. package/src/activity/index.js +129 -18
  41. package/src/activity/model.js +11 -1
  42. package/src/activity/store.js +22 -3
  43. package/src/activity/thinking.js +176 -0
  44. package/src/adapters/constants.js +11 -0
  45. package/src/adapters/engram/data/aliases.json +51 -0
  46. package/src/adapters/engram/data/policy.json +8 -0
  47. package/src/adapters/engram/data/routing-rules.json +546 -0
  48. package/src/adapters/engram/index.js +235 -0
  49. package/src/adapters/registry.js +315 -0
  50. package/src/answer/audit.js +193 -0
  51. package/src/answer/evidence.js +30 -0
  52. package/src/answer/guard.js +1370 -0
  53. package/src/answer/index.js +1067 -0
  54. package/src/answer/query.js +111 -0
  55. package/src/answer/second-hop.mjs +101 -0
  56. package/src/auto-truncate.js +1 -1
  57. package/src/config.js +145 -48
  58. package/src/deps.js +6 -4
  59. package/src/features.js +158 -8
  60. package/src/identity/index.js +13 -5
  61. package/src/identity/inject-position.js +2 -2
  62. package/src/index.js +97 -7
  63. package/src/lifecycle/gap-clocks.js +328 -0
  64. package/src/lifecycle/index.js +269 -0
  65. package/src/memory/activity-capture.js +229 -0
  66. package/src/memory/anchors.js +194 -0
  67. package/src/memory/audit-records.js +222 -0
  68. package/src/memory/auto-cards.js +401 -0
  69. package/src/memory/capture.js +442 -0
  70. package/src/memory/card-anchors.js +203 -0
  71. package/src/memory/conflicts.js +106 -0
  72. package/src/memory/decay.js +311 -0
  73. package/src/memory/distill.js +471 -0
  74. package/src/memory/event-index.js +917 -0
  75. package/src/memory/evidence-chain.js +295 -0
  76. package/src/memory/evidence.js +145 -0
  77. package/src/memory/filter-runner.js +1 -1
  78. package/src/memory/filter.js +5 -1
  79. package/src/memory/forget-v2.js +410 -0
  80. package/src/memory/forget.js +71 -0
  81. package/src/memory/frames-activation.js +247 -0
  82. package/src/memory/fts-query.js +174 -0
  83. package/src/memory/governance.js +604 -0
  84. package/src/memory/index.js +1 -0
  85. package/src/memory/l2-gate.js +169 -0
  86. package/src/memory/lock.js +121 -0
  87. package/src/memory/migrate.js +139 -0
  88. package/src/memory/recall-v2.js +223 -0
  89. package/src/memory/recall.js +65 -2
  90. package/src/memory/records.js +425 -0
  91. package/src/memory/retention.js +128 -0
  92. package/src/memory/review.js +124 -0
  93. package/src/memory/trust.js +88 -0
  94. package/src/memory/version-chain.js +264 -0
  95. package/src/memory/wal.js +137 -0
  96. package/src/memory/working-frame.js +516 -0
  97. package/src/metrics/classify.mjs +5 -3
  98. package/src/metrics/index.mjs +21 -1
  99. package/src/metrics/prestep-latency.mjs +300 -0
  100. package/src/metrics/store.mjs +25 -0
  101. package/src/policy-profile.js +18 -3
  102. package/src/recall/anaphora.mjs +87 -0
  103. package/src/recall/cards.mjs +7 -0
  104. package/src/recall/embedding.mjs +8 -8
  105. package/src/recall/entities.mjs +112 -0
  106. package/src/recall/index.js +235 -19
  107. package/src/recall/inject-order.mjs +10 -8
  108. package/src/recall/privacy.mjs +1 -1
  109. package/src/recall/recall-core.mjs +116 -10
  110. package/src/recall/shared-visibility.js +302 -0
  111. package/src/recall/time-normalizer.mjs +196 -0
  112. package/src/role/activation.js +191 -0
  113. package/src/role/attention.js +318 -0
  114. package/src/role/base.js +36 -0
  115. package/src/role/channels.js +423 -0
  116. package/src/role/index.js +425 -0
  117. package/src/role/interfaces.js +115 -0
  118. package/src/role/personality.js +370 -0
  119. package/src/role/readers.js +151 -0
  120. package/src/role/relationships.js +477 -0
  121. package/src/role/store.js +202 -0
  122. package/src/role/writeprotect.js +373 -0
  123. package/src/router/aliases.json +176 -25
  124. package/src/router/background/agent-runner.mjs +1 -1
  125. package/src/router/background/anchor.mjs +1 -1
  126. package/src/router/background/audit.mjs +2 -2
  127. package/src/router/background/budget-ledger.mjs +2 -2
  128. package/src/router/background/host-sim.mjs +1 -1
  129. package/src/router/background/memory.mjs +4 -4
  130. package/src/router/background/queue.mjs +1 -1
  131. package/src/router/background/retry.mjs +1 -1
  132. package/src/router/background/router.mjs +1 -1
  133. package/src/router/background/stream.mjs +1 -1
  134. package/src/router/background/tool-policy.mjs +1 -1
  135. package/src/router/design-headings.json +5 -5
  136. package/src/router/face/config.mjs +1 -1
  137. package/src/router/face/identity.mjs +3 -3
  138. package/src/router/judge.mjs +8 -7
  139. package/src/router/policies/generic.json +21 -4
  140. package/src/router/route.js +109 -22
  141. package/src/router/router.mjs +10 -6
  142. package/src/router/routing-rules.json +260 -445
  143. package/src/router/run-sim.mjs +8 -8
  144. package/src/router/runtime.js +23 -12
  145. package/src/router/service/config.mjs +5 -5
  146. package/src/router/service/context.mjs +1 -1
  147. package/src/router/service/queue.mjs +1 -1
  148. package/src/router/shadow.mjs +4 -3
  149. package/src/surgery-recovery.js +2 -2
  150. package/src/tool-output.js +2 -2
  151. package/src/tools.js +39 -7
  152. package/src/truncate-visible.js +2 -2
  153. package/src/ui/actions.js +1312 -0
  154. package/src/ui/panel-api.js +367 -0
  155. package/src/ui/panel-auth.js +251 -0
  156. package/src/ui/panel-integrity.js +243 -0
  157. package/src/ui/routes-readonly.js +695 -0
  158. package/src/ui/routes.js +3 -0
  159. package/docs/DEV.md +0 -279
  160. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  161. package/docs/PRESENCE-BUS.md +0 -694
  162. package/docs/SURGERY.md +0 -186
  163. package/scripts/mark-core-cards.mjs +0 -289
  164. package/scripts/pack.mjs +0 -392
  165. package/scripts/router-proposals.mjs +0 -386
  166. package/src/router/fixtures/M1.json +0 -16
  167. package/src/router/fixtures/M2.json +0 -16
  168. package/src/router/fixtures/M3.json +0 -20
  169. package/src/router/fixtures/M4.json +0 -20
  170. package/src/router/fixtures/M5.json +0 -45
  171. package/src/router/fixtures/M6.json +0 -53
  172. package/src/router/fixtures/M7.json +0 -46
  173. package/src/router/fixtures/S1.json +0 -20
  174. package/src/router/fixtures/S2.json +0 -23
  175. package/src/router/fixtures/S3.json +0 -20
  176. package/src/router/fixtures/S4.json +0 -34
  177. package/src/router/fixtures/S5.json +0 -43
  178. package/src/router/fixtures/S6.json +0 -47
  179. package/src/router/fixtures/T1.json +0 -23
  180. package/src/router/fixtures/T2.json +0 -16
  181. package/src/router/fixtures/T3.json +0 -19
  182. package/src/router/fixtures/T4.json +0 -16
  183. package/src/router/fixtures/X1.json +0 -17
  184. package/src/router/fixtures/X2.json +0 -20
  185. package/src/router/gen-design-headings.mjs +0 -107
  186. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  187. package/test/activity-realtime.test.mjs +0 -959
  188. package/test/activity-stream.test.mjs +0 -512
  189. package/test/auto-recall.test.mjs +0 -474
  190. package/test/auto-truncate.test.mjs +0 -931
  191. package/test/autobind.test.mjs +0 -399
  192. package/test/bus.test.mjs +0 -1572
  193. package/test/cli-guard.test.mjs +0 -198
  194. package/test/conclusion.test.mjs +0 -408
  195. package/test/config-schema.test.mjs +0 -210
  196. package/test/d6-output.test.mjs +0 -194
  197. package/test/d6.test.mjs +0 -395
  198. package/test/deps.test.mjs +0 -342
  199. package/test/doctor-setup.test.mjs +0 -297
  200. package/test/embedding-recall.test.mjs +0 -347
  201. package/test/feature-flags.test.mjs +0 -585
  202. package/test/filter-shadow.test.mjs +0 -170
  203. package/test/fixtures/activity-append-child.mjs +0 -29
  204. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  205. package/test/fixtures/d6-header-tools.json +0 -1
  206. package/test/fixtures/lock-child.mjs +0 -67
  207. package/test/fixtures/pre-step-container.mjs +0 -100
  208. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  209. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  210. package/test/fixtures/profile-session-longlive-config.json +0 -20
  211. package/test/fixtures/proposal-cas-child.mjs +0 -66
  212. package/test/host-engine-status.test.mjs +0 -307
  213. package/test/hot-truncate.test.mjs +0 -1349
  214. package/test/identity-card-marker.test.mjs +0 -307
  215. package/test/identity-router/run.mjs +0 -721
  216. package/test/integration-pre-step.test.mjs +0 -553
  217. package/test/live.test.mjs +0 -1023
  218. package/test/maintenance-schedule.test.mjs +0 -538
  219. package/test/memory.test.mjs +0 -2828
  220. package/test/metrics.test.mjs +0 -478
  221. package/test/orchestrator.test.mjs +0 -266
  222. package/test/p1_probe.mjs +0 -97
  223. package/test/packaging.test.mjs +0 -184
  224. package/test/portability.test.mjs +0 -343
  225. package/test/rc4-regressions.test.mjs +0 -562
  226. package/test/rc5-regressions.test.mjs +0 -197
  227. package/test/rc6-chinese.test.mjs +0 -394
  228. package/test/router-1100/apply-off.test.mjs +0 -175
  229. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  230. package/test/router-1100/config-context.test.mjs +0 -211
  231. package/test/router-1100/helpers.mjs +0 -129
  232. package/test/router-1100/hint-envelope.test.mjs +0 -150
  233. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  234. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  235. package/test/router-1100/router-apply.test.mjs +0 -372
  236. package/test/router-1100/router-fixb.test.mjs +0 -602
  237. package/test/router-1100/router-proposals.test.mjs +0 -405
  238. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  239. package/test/router-1100/router-security-cr.test.mjs +0 -333
  240. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  241. package/test/router-1100/tools-contract.test.mjs +0 -164
  242. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  243. package/test/router-1100/v6-contract.test.mjs +0 -158
  244. package/test/router-1100/v6b-contract.test.mjs +0 -293
  245. package/test/router-1100/v6c-contract.test.mjs +0 -249
  246. package/test/router-1100/wal-rotation.test.mjs +0 -375
  247. package/test/router-1100/wp1-config.test.mjs +0 -170
  248. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  249. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  250. package/test/router-1100/wp1-status.test.mjs +0 -220
  251. package/test/router-1100/wp1-tools.test.mjs +0 -146
  252. package/test/router-1100/wp5-cli.test.mjs +0 -240
  253. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  254. package/test/router-1100/wp6-merge.test.mjs +0 -386
  255. package/test/router-1100/wp6-wake.test.mjs +0 -502
  256. package/test/router-background/audit.test.mjs +0 -37
  257. package/test/router-background/budget-ledger.test.mjs +0 -330
  258. package/test/router-background/cross-process.test.mjs +0 -222
  259. package/test/router-background/fixtures/cross-child.mjs +0 -85
  260. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  261. package/test/router-background/router.test.mjs +0 -1076
  262. package/test/router-background/stream-retry.test.mjs +0 -358
  263. package/test/router-background/tool-agent.test.mjs +0 -384
  264. package/test/router-face/bridge.test.mjs +0 -169
  265. package/test/router-face/face.test.mjs +0 -316
  266. package/test/router-failclosed.test.mjs +0 -466
  267. package/test/router-identity.test.mjs +0 -384
  268. package/test/router-l1-l2.contract.test.mjs +0 -1075
  269. package/test/router-memory-tools.test.mjs +0 -262
  270. package/test/router-policy.test.mjs +0 -225
  271. package/test/router-runtime.test.mjs +0 -438
  272. package/test/router-shadow.test.mjs +0 -287
  273. package/test/router.test.mjs +0 -903
  274. package/test/run-rc.mjs +0 -315
  275. package/test/run.mjs +0 -2469
  276. package/test/safety-defaults.test.mjs +0 -181
  277. package/test/seeded-load.test.mjs +0 -436
  278. package/test/session-original.test.mjs +0 -338
  279. package/test/state-truth.test.mjs +0 -937
  280. package/test/surgery-recovery.test.mjs +0 -936
  281. package/test/t7_drive.mjs +0 -151
  282. package/test/tool-output.test.mjs +0 -393
  283. package/test/tool-schema.test.mjs +0 -177
  284. package/test/tools_contract.test.mjs +0 -716
  285. package/test/truncate-visible.test.mjs +0 -1150
  286. package/test/ui-flags.test.mjs +0 -477
  287. package/test/v4-hardening.test.mjs +0 -427
  288. package/test/version-consistency.test.mjs +0 -286
@@ -0,0 +1,917 @@
1
+ // src/memory/event-index.js — M2-A:统一 memory event 模型 + 事件索引(SQLite FTS5)
2
+ //
3
+ // 依据提案 §1.1 / §2.2 / §9.1(v2.6.3)与 FTS5-MEMGAP-EVIDENCE(任务):
4
+ // * L1 事件索引是 L0 会话日志的**派生**:可整体删除后仅凭 L0 重建(硬验收);
5
+ // * 主查询入口 = SQLite FTS5(node:sqlite 内置,本机 3.53.4 / FTS5=1);
6
+ // * 双表:`events_fts` = trigram(raw_text) 覆盖中文 ≥3 字子串/版本号/ID/日期;
7
+ // `events_terms` = unicode61(term_text 预分词 bigram + aliases) 补 2 字 CJK;
8
+ // * 单写管道:一条 EventRecord 在同一个事务里写 events + events_fts + events_terms,
9
+ // 并追加派生 JSONL 审计层 `events.audit.jsonl`(可导出/diff/迁移);
10
+ // * 幂等:同一 (id, digest) 重放不产生新行;会话副本按 visible-messages digest 去重。
11
+ //
12
+ // 本模块只读 L0(绝不写 .dsh/sessions),所有落盘都在 <memoryRoot>/.longlive/index/。
13
+
14
+ import { createRequire } from 'node:module';
15
+ import crypto from 'node:crypto';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ import { decompressFrame, deriveMessagesDigest, parseSession, splitFrames, stableStringify } from '../codec.js';
21
+ import { extractTerms, termTextOf, aliasesOf, buildFtsCandidates } from './fts-query.js';
22
+ import { makeSessionTurnAnchor, turnDigest } from './anchors.js';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ let sqliteModule = null;
26
+
27
+ /** Lazy node:sqlite loader: keeps the module loadable on hosts without it. */
28
+ function sqlite() {
29
+ if (sqliteModule === null) sqliteModule = require('node:sqlite');
30
+ return sqliteModule;
31
+ }
32
+
33
+ export const INDEX_DIRNAME = 'index';
34
+ export const EVENTS_DB = 'events.db';
35
+ export const EVENTS_AUDIT = 'events.audit.jsonl';
36
+ export const FRAMES_DIR = 'frames';
37
+ export const BUILD_STATE = 'build-state.json';
38
+ export const EVENT_SCHEMA = 'events/v1';
39
+ export const INDEX_SCHEMA_VERSION = 1;
40
+
41
+ const SCHEMA = `
42
+ CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
43
+ CREATE TABLE IF NOT EXISTS events (
44
+ id TEXT PRIMARY KEY,
45
+ session_id TEXT NOT NULL,
46
+ turn INTEGER NOT NULL,
47
+ seq_start INTEGER,
48
+ seq_end INTEGER,
49
+ time_start INTEGER,
50
+ time_end INTEGER,
51
+ project TEXT,
52
+ kind TEXT,
53
+ summary TEXT,
54
+ topics_json TEXT,
55
+ terms_json TEXT,
56
+ counts_json TEXT,
57
+ anchors_json TEXT,
58
+ digest TEXT NOT NULL,
59
+ built_at INTEGER,
60
+ source TEXT
61
+ );
62
+ CREATE INDEX IF NOT EXISTS idx_events_session_turn ON events(session_id, turn);
63
+ CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
64
+ CREATE INDEX IF NOT EXISTS idx_events_time ON events(time_end);
65
+ CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(id UNINDEXED, raw_text, tokenize='trigram');
66
+ CREATE VIRTUAL TABLE IF NOT EXISTS events_terms USING fts5(id UNINDEXED, term_text, aliases, tokenize='unicode61');
67
+ `;
68
+
69
+ export class IndexError extends Error {
70
+ constructor(code, message, detail = undefined) {
71
+ super(message);
72
+ this.name = 'IndexError';
73
+ this.code = code;
74
+ if (detail !== undefined) this.detail = detail;
75
+ }
76
+ }
77
+
78
+ export function indexPaths(memoryRoot) {
79
+ const root = path.resolve(String(memoryRoot ?? ''));
80
+ const dir = path.join(root, '.longlive', INDEX_DIRNAME);
81
+ return {
82
+ root,
83
+ dir,
84
+ db: path.join(dir, EVENTS_DB),
85
+ audit: path.join(dir, EVENTS_AUDIT),
86
+ framesDir: path.join(dir, FRAMES_DIR),
87
+ buildState: path.join(dir, BUILD_STATE),
88
+ };
89
+ }
90
+
91
+ function nowIso(now) {
92
+ const value = now();
93
+ return (value instanceof Date ? value : new Date(value)).toISOString();
94
+ }
95
+
96
+ function sha256Hex(text) {
97
+ return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
98
+ }
99
+
100
+ function textOf(event) {
101
+ const parts = event?.data?.content;
102
+ if (!Array.isArray(parts)) return '';
103
+ return parts
104
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
105
+ .map((block) => block.text)
106
+ .join('\n')
107
+ .trim();
108
+ }
109
+
110
+ function classifyKind(corpus, counts) {
111
+ const text = String(corpus ?? '');
112
+ if (/(决定|拍板|裁定|结论|选型|方案|定稿|通过)/.test(text)) return 'conclusion';
113
+ if (/(事故|故障|报错|失败|回归|broken|error|crash)/i.test(text)) return 'incident';
114
+ if (/(计划|下一步|待办|排期|里程碑)/.test(text)) return 'plan';
115
+ if (counts.toolCalls > 0) return 'ops';
116
+ return 'chat';
117
+ }
118
+
119
+ function rawTextFor(record) {
120
+ const parts = [
121
+ `[${record.kind}] ${record.summary}`,
122
+ Array.isArray(record.terms) ? record.terms.join(' ') : '',
123
+ Array.isArray(record.topics) ? record.topics.join(' ') : '',
124
+ record.project ?? '',
125
+ ];
126
+ return parts.filter((part) => typeof part === 'string' && part !== '').join('\n').slice(0, 20000);
127
+ }
128
+
129
+ function parseRow(row) {
130
+ if (!row) return null;
131
+ return {
132
+ id: row.id,
133
+ sessionId: row.session_id,
134
+ turn: row.turn,
135
+ seqStart: row.seq_start,
136
+ seqEnd: row.seq_end,
137
+ timeStart: row.time_start,
138
+ timeEnd: row.time_end,
139
+ project: row.project,
140
+ kind: row.kind,
141
+ summary: row.summary,
142
+ topics: safeJson(row.topics_json, []),
143
+ terms: safeJson(row.terms_json, []),
144
+ counts: safeJson(row.counts_json, {}),
145
+ anchors: safeJson(row.anchors_json, []),
146
+ digest: row.digest,
147
+ builtAt: row.built_at,
148
+ source: row.source,
149
+ ...(typeof row.rank === 'number' ? { rank: row.rank } : {}),
150
+ };
151
+ }
152
+
153
+ function safeJson(text, fallback) {
154
+ try {
155
+ return JSON.parse(text);
156
+ } catch {
157
+ return fallback;
158
+ }
159
+ }
160
+
161
+ function validateRecord(record) {
162
+ if (!record || typeof record !== 'object') throw new IndexError('E_EVENT_INVALID', 'event must be an object');
163
+ if (typeof record.id !== 'string' || record.id === '') throw new IndexError('E_EVENT_ID', 'event.id required');
164
+ if (typeof record.sessionId !== 'string' || record.sessionId === '') {
165
+ throw new IndexError('E_EVENT_SESSION', 'event.sessionId required');
166
+ }
167
+ if (!Number.isInteger(record.turn) || record.turn < 0) throw new IndexError('E_EVENT_TURN', 'event.turn required');
168
+ if (typeof record.digest !== 'string' || !/^[0-9a-f]{64}$/.test(record.digest)) {
169
+ throw new IndexError('E_EVENT_DIGEST', 'event.digest required (sha256 hex)');
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Open (and lazily create) the event index.
175
+ *
176
+ * @param {object} options
177
+ * @param {string} options.memoryRoot
178
+ * @param {() => Date} [options.now]
179
+ * @param {string} [options.dbPath] override for tests
180
+ */
181
+ export function openEventIndex(options = {}) {
182
+ const memoryRoot = path.resolve(String(options.memoryRoot ?? ''));
183
+ if (memoryRoot === '') throw new IndexError('E_MEMORY_ROOT', 'memoryRoot required');
184
+ const now = typeof options.now === 'function' ? options.now : () => new Date();
185
+ const paths = indexPaths(memoryRoot);
186
+ fs.mkdirSync(paths.dir, { recursive: true });
187
+ fs.mkdirSync(paths.framesDir, { recursive: true });
188
+ const dbPath = options.dbPath ? path.resolve(options.dbPath) : paths.db;
189
+ const { DatabaseSync } = sqlite();
190
+ const db = new DatabaseSync(dbPath);
191
+ db.exec('PRAGMA journal_mode = WAL');
192
+ db.exec('PRAGMA busy_timeout = 5000');
193
+ db.exec(SCHEMA);
194
+ let closed = false;
195
+
196
+ function getMeta(key) {
197
+ const row = db.prepare('SELECT v FROM meta WHERE k = ?').get(key);
198
+ return row ? row.v : null;
199
+ }
200
+
201
+ function setMeta(key, value) {
202
+ db.prepare('INSERT INTO meta(k, v) VALUES(?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v')
203
+ .run(key, String(value));
204
+ }
205
+
206
+ function appendAudit(record, action) {
207
+ const line = {
208
+ schema: 'events-audit/v1',
209
+ id: record.id,
210
+ sessionId: record.sessionId,
211
+ turn: record.turn,
212
+ digest: record.digest,
213
+ kind: record.kind ?? null,
214
+ anchors: record.anchors ?? [],
215
+ builtAt: record.builtAt ?? Date.parse(nowIso(now)),
216
+ source: record.source ?? 'rebuild',
217
+ action,
218
+ };
219
+ try {
220
+ fs.appendFileSync(paths.audit, `${JSON.stringify(line)}\n`, 'utf8');
221
+ } catch {
222
+ /* audit layer is derived; the DB row is the query source */
223
+ }
224
+ }
225
+
226
+ function upsertEvent(record, options2 = {}) {
227
+ validateRecord(record);
228
+ const existing = db.prepare('SELECT digest FROM events WHERE id = ?').get(record.id);
229
+ if (existing && existing.digest === record.digest) {
230
+ return { inserted: false, duplicated: true, id: record.id };
231
+ }
232
+ if (existing && existing.digest !== record.digest) {
233
+ // Same id, different content: never overwrite silently. The conflict is
234
+ // audited and the caller must treat it as a rejected write.
235
+ appendAudit({ ...record, source: record.source ?? options2.source ?? 'upsert', anchors: record.anchors ?? [] }, 'id-digest-conflict');
236
+ return { inserted: false, conflict: true, code: 'E_ID_DIGEST_CONFLICT', id: record.id };
237
+ }
238
+ const builtAt = Number.isInteger(record.builtAt) ? record.builtAt : Date.parse(nowIso(now));
239
+ const source = record.source ?? options2.source ?? 'rebuild';
240
+ const rawText = rawTextFor(record);
241
+ const termText = termTextOf([record.summary, ...(record.topics ?? []), ...(record.terms ?? [])].join('\n')).slice(0, 12000);
242
+ const aliases = [...new Set([...(aliasesOf(record.summary ?? '')), ...(record.aliases ?? [])])].join(' ').slice(0, 4000);
243
+ const anchors = record.anchors ?? [];
244
+ db.exec('BEGIN IMMEDIATE');
245
+ try {
246
+ db.prepare(`
247
+ INSERT INTO events
248
+ (id, session_id, turn, seq_start, seq_end, time_start, time_end, project, kind,
249
+ summary, topics_json, terms_json, counts_json, anchors_json, digest, built_at, source)
250
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
251
+ ON CONFLICT(id) DO UPDATE SET
252
+ session_id = excluded.session_id, turn = excluded.turn,
253
+ seq_start = excluded.seq_start, seq_end = excluded.seq_end,
254
+ time_start = excluded.time_start, time_end = excluded.time_end,
255
+ project = excluded.project, kind = excluded.kind, summary = excluded.summary,
256
+ topics_json = excluded.topics_json, terms_json = excluded.terms_json,
257
+ counts_json = excluded.counts_json, anchors_json = excluded.anchors_json,
258
+ digest = excluded.digest, built_at = excluded.built_at, source = excluded.source
259
+ `).run(
260
+ record.id, record.sessionId, record.turn,
261
+ record.seqStart ?? null, record.seqEnd ?? null,
262
+ record.timeStart ?? null, record.timeEnd ?? null,
263
+ record.project ?? null, record.kind ?? null,
264
+ record.summary ?? null,
265
+ JSON.stringify(record.topics ?? []),
266
+ JSON.stringify(record.terms ?? []),
267
+ JSON.stringify(record.counts ?? {}),
268
+ JSON.stringify(anchors),
269
+ record.digest, builtAt, source,
270
+ );
271
+ db.prepare('DELETE FROM events_fts WHERE id = ?').run(record.id);
272
+ db.prepare('DELETE FROM events_terms WHERE id = ?').run(record.id);
273
+ db.prepare('INSERT INTO events_fts(id, raw_text) VALUES(?, ?)').run(record.id, rawText);
274
+ db.prepare('INSERT INTO events_terms(id, term_text, aliases) VALUES(?, ?, ?)').run(record.id, termText, aliases);
275
+ db.exec('COMMIT');
276
+ } catch (error) {
277
+ db.exec('ROLLBACK');
278
+ throw new IndexError('E_INDEX_WRITE', `event index write failed: ${error.message}`, { id: record.id });
279
+ }
280
+ appendAudit({ ...record, builtAt, source, anchors }, existing ? 'update' : 'insert');
281
+ setMeta('last_write_at', nowIso(now));
282
+ setMeta('schema', `${EVENT_SCHEMA}/${INDEX_SCHEMA_VERSION}`);
283
+ return { inserted: true, id: record.id };
284
+ }
285
+
286
+ function runCandidates(table, candidates, limit, kind) {
287
+ for (const candidate of candidates) {
288
+ let rows;
289
+ try {
290
+ const sql = kind
291
+ ? `SELECT e.*, bm25(${table}) AS rank FROM ${table} JOIN events e ON e.id = ${table}.id WHERE ${table} MATCH ? AND e.kind = ? ORDER BY rank LIMIT ?`
292
+ : `SELECT e.*, bm25(${table}) AS rank FROM ${table} JOIN events e ON e.id = ${table}.id WHERE ${table} MATCH ? ORDER BY rank LIMIT ?`;
293
+ rows = kind
294
+ ? db.prepare(sql).all(candidate.match, kind, limit)
295
+ : db.prepare(sql).all(candidate.match, limit);
296
+ } catch {
297
+ continue; // FTS syntax errors are expected for hostile input: try next candidate
298
+ }
299
+ if (rows.length > 0) return { rows: rows.map(parseRow), strategy: `${table}:${candidate.label}` };
300
+ }
301
+ return null;
302
+ }
303
+
304
+ /**
305
+ * Query the index. Never throws for hostile queries: candidate chain + try/catch,
306
+ *全失败返回 { ok:true, rows:[], code:'E_FTS_NO_MATCH' }。
307
+ */
308
+ function search(query, opts = {}) {
309
+ const limit = Number.isInteger(opts.limit) ? Math.min(Math.max(opts.limit, 1), 100) : 10;
310
+ const kind = typeof opts.kind === 'string' && opts.kind !== '' ? opts.kind : undefined;
311
+ const candidates = buildFtsCandidates(query);
312
+ const tried = [];
313
+ if (candidates.normalized === '') {
314
+ return { ok: true, schema: EVENT_SCHEMA, query: '', normalized: '', rows: [], code: 'E_FTS_EMPTY_QUERY', strategy: null, tried: [] };
315
+ }
316
+ for (const candidate of candidates.raw) tried.push(`events_fts:${candidate.label}`);
317
+ const rawHit = runCandidates('events_fts', candidates.raw, limit, kind);
318
+ if (rawHit) {
319
+ return { ok: true, schema: EVENT_SCHEMA, query: String(query), normalized: candidates.normalized, rows: rawHit.rows, strategy: rawHit.strategy, tried };
320
+ }
321
+ for (const candidate of candidates.terms) tried.push(`events_terms:${candidate.label}`);
322
+ const termHit = runCandidates('events_terms', candidates.terms, limit, kind);
323
+ if (termHit) {
324
+ return { ok: true, schema: EVENT_SCHEMA, query: String(query), normalized: candidates.normalized, rows: termHit.rows, strategy: termHit.strategy, tried };
325
+ }
326
+ return { ok: true, schema: EVENT_SCHEMA, query: String(query), normalized: candidates.normalized, rows: [], code: 'E_FTS_NO_MATCH', strategy: null, tried };
327
+ }
328
+
329
+ function listEvents(opts = {}) {
330
+ const limit = Number.isInteger(opts.limit) ? Math.min(Math.max(opts.limit, 1), 500) : 20;
331
+ const offset = Number.isInteger(opts.offset) && opts.offset > 0 ? opts.offset : 0;
332
+ const kind = typeof opts.kind === 'string' && opts.kind !== '' ? opts.kind : null;
333
+ const rows = kind
334
+ ? db.prepare('SELECT * FROM events WHERE kind = ? ORDER BY time_end DESC, id ASC LIMIT ? OFFSET ?').all(kind, limit, offset)
335
+ : db.prepare('SELECT * FROM events ORDER BY time_end DESC, id ASC LIMIT ? OFFSET ?').all(limit, offset);
336
+ return rows.map(parseRow);
337
+ }
338
+
339
+ function count(opts = {}) {
340
+ const kind = typeof opts.kind === 'string' && opts.kind !== '' ? opts.kind : null;
341
+ const row = kind
342
+ ? db.prepare('SELECT COUNT(*) AS n FROM events WHERE kind = ?').get(kind)
343
+ : db.prepare('SELECT COUNT(*) AS n FROM events').get();
344
+ return row.n;
345
+ }
346
+
347
+ /** Read one EventRecord by id (AutoCard source-of-truth check). */
348
+ function getEvent(id) {
349
+ if (typeof id !== 'string' || id === '') return null;
350
+ const row = db.prepare('SELECT * FROM events WHERE id = ?').get(id);
351
+ return row ? parseRow(row) : null;
352
+ }
353
+
354
+ function deleteSession(sessionId) {
355
+ const rows = db.prepare('SELECT id FROM events WHERE session_id = ?').all(sessionId);
356
+ db.exec('BEGIN IMMEDIATE');
357
+ try {
358
+ for (const row of rows) {
359
+ db.prepare('DELETE FROM events_fts WHERE id = ?').run(row.id);
360
+ db.prepare('DELETE FROM events_terms WHERE id = ?').run(row.id);
361
+ }
362
+ db.prepare('DELETE FROM events WHERE session_id = ?').run(sessionId);
363
+ db.exec('COMMIT');
364
+ } catch (error) {
365
+ db.exec('ROLLBACK');
366
+ throw new IndexError('E_INDEX_DELETE', `session delete failed: ${error.message}`, { sessionId });
367
+ }
368
+ return { ok: true, removed: rows.length };
369
+ }
370
+
371
+ function stats() {
372
+ const sizeOf = (file) => {
373
+ try {
374
+ return fs.statSync(file).size;
375
+ } catch {
376
+ return 0;
377
+ }
378
+ };
379
+ return {
380
+ schema: EVENT_SCHEMA,
381
+ schemaVersion: INDEX_SCHEMA_VERSION,
382
+ dbPath,
383
+ // WAL means the main db file stays small until checkpoint: report the family.
384
+ dbBytes: sizeOf(dbPath) + sizeOf(`${dbPath}-wal`) + sizeOf(`${dbPath}-shm`),
385
+ events: count(),
386
+ ftsRows: db.prepare('SELECT COUNT(*) AS n FROM events_fts').get().n,
387
+ termRows: db.prepare('SELECT COUNT(*) AS n FROM events_terms').get().n,
388
+ sessions: (() => {
389
+ const row = db.prepare('SELECT COUNT(DISTINCT session_id) AS n FROM events').get();
390
+ return row.n;
391
+ })(),
392
+ kinds: Object.fromEntries(db.prepare('SELECT kind, COUNT(*) AS n FROM events GROUP BY kind').all().map((row) => [row.kind, row.n])),
393
+ lastWriteAt: getMeta('last_write_at'),
394
+ };
395
+ }
396
+
397
+ function close() {
398
+ if (closed) return;
399
+ closed = true;
400
+ db.close();
401
+ }
402
+
403
+ return { memoryRoot, paths, dbPath, upsertEvent, deleteSession, search, listEvents, count, getEvent, stats, getMeta, setMeta, close };
404
+ }
405
+
406
+ /** Extract per-turn EventRecords from a parsed session. */
407
+ export function extractTurns(header, events, frames = [], texts = []) {
408
+ const sessionId = typeof header?.id === 'string' && header.id !== ''
409
+ ? header.id
410
+ : (typeof header?.sessionId === 'string' && header.sessionId !== '' ? header.sessionId : null);
411
+ if (sessionId === null) throw new IndexError('E_SESSION_ID', 'session header carries no id');
412
+ const project = typeof header?.cwd === 'string' ? header.cwd : null;
413
+
414
+ // Map seq -> frame index (frame 0 carries the header line).
415
+ const seqToFrame = new Map();
416
+ let lineNo = 0;
417
+ (Array.isArray(texts) ? texts : []).forEach((text, frameIndex) => {
418
+ for (const line of String(text).split('\n')) {
419
+ if (line === '') continue;
420
+ if (lineNo === 0) {
421
+ lineNo = 1;
422
+ continue;
423
+ }
424
+ seqToFrame.set(lineNo - 1, frameIndex);
425
+ lineNo += 1;
426
+ }
427
+ });
428
+
429
+ let currentTurn = 0;
430
+ const groups = new Map();
431
+ for (const event of events) {
432
+ const turn = Number(event?.data?.turn);
433
+ if (Number.isInteger(turn) && turn >= 0) currentTurn = turn;
434
+ if (!groups.has(currentTurn)) groups.set(currentTurn, []);
435
+ groups.get(currentTurn).push(event);
436
+ }
437
+
438
+ const out = [];
439
+ for (const [turn, list] of [...groups.entries()].sort((a, b) => a[0] - b[0])) {
440
+ const seqStart = list[0].seq;
441
+ const seqEnd = list[list.length - 1].seq;
442
+ const times = list.map((event) => Number(event?.time)).filter((value) => Number.isFinite(value));
443
+ const userTexts = [];
444
+ const assistantTexts = [];
445
+ let toolCalls = 0;
446
+ for (const event of list) {
447
+ if (event.type === 'user/message' && event?.data?.source?.kind === 'user') userTexts.push(textOf(event));
448
+ else if (event.type === 'assistant/message') assistantTexts.push(textOf(event));
449
+ else if (event.type === 'tool/call') toolCalls += 1;
450
+ }
451
+ const corpus = [...userTexts, ...assistantTexts].filter((text) => text !== '').join('\n');
452
+ const counts = { userMsgs: userTexts.length, assistantMsgs: assistantTexts.length, toolCalls };
453
+ const kind = classifyKind(corpus, counts);
454
+ const head = userTexts[0] ?? assistantTexts[0] ?? '';
455
+ const tail = assistantTexts[assistantTexts.length - 1] ?? '';
456
+ const summary = [head.slice(0, 160), tail === '' ? '' : `→ ${tail.slice(0, 160)}`].filter(Boolean).join(' ').slice(0, 400);
457
+ const terms = extractTerms(corpus, [sessionId]);
458
+ const topics = [...new Set(terms.filter((term) => /[\u3400-\u4dbf\u4e00-\u9fff]/.test(term)))].slice(0, 5);
459
+ const digest = turnDigest(events, seqStart, seqEnd);
460
+ const anchor = makeSessionTurnAnchor({ sessionId, turn, seqStart, seqEnd, digest, status: 'live' });
461
+ const frameIndexes = [...new Set(list.map((event) => seqToFrame.get(event.seq)).filter((value) => Number.isInteger(value)))];
462
+ out.push({
463
+ id: `evt:${sessionId}:${turn}`,
464
+ sessionId,
465
+ turn,
466
+ seqStart,
467
+ seqEnd,
468
+ timeStart: times.length > 0 ? Math.min(...times) : null,
469
+ timeEnd: times.length > 0 ? Math.max(...times) : null,
470
+ project,
471
+ kind,
472
+ topics,
473
+ terms,
474
+ summary,
475
+ counts,
476
+ anchors: [anchor],
477
+ digest,
478
+ frameIndexes,
479
+ rawText: rawTextFor({ kind, summary, terms, topics, project }),
480
+ source: 'rebuild',
481
+ });
482
+ }
483
+ return out;
484
+ }
485
+
486
+ /** Recursively discover DSH session logs under the given roots. */
487
+ export function discoverSessionLogs(roots) {
488
+ const out = [];
489
+ const stack = (Array.isArray(roots) ? roots : [roots])
490
+ .filter((root) => typeof root === 'string' && root !== '')
491
+ .map((root) => path.resolve(root));
492
+ const seen = new Set();
493
+ while (stack.length > 0) {
494
+ const dir = stack.pop();
495
+ if (seen.has(dir)) continue;
496
+ seen.add(dir);
497
+ let entries = [];
498
+ try {
499
+ entries = fs.readdirSync(dir, { withFileTypes: true });
500
+ } catch {
501
+ continue;
502
+ }
503
+ for (const entry of entries) {
504
+ const full = path.join(dir, entry.name);
505
+ if (entry.isDirectory()) {
506
+ stack.push(full);
507
+ } else if (entry.isFile() && entry.name === 'session.v4.jsonl.zstd') {
508
+ out.push({ file: full, sessionId: path.basename(dir) });
509
+ }
510
+ }
511
+ }
512
+ return out.sort((a, b) => a.file.localeCompare(b.file));
513
+ }
514
+
515
+ function defaultSessionRoots() {
516
+ const dshHome = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
517
+ ? path.resolve(process.env.DSH_HOME.trim())
518
+ : path.join(os.homedir(), '.dsh');
519
+ return [path.join(dshHome, 'sessions')];
520
+ }
521
+
522
+ /** Remove every derived index artifact (used by the delete-rebuild acceptance). */
523
+ export function deleteIndex(memoryRoot) {
524
+ const paths = indexPaths(memoryRoot);
525
+ const removed = [];
526
+ for (const file of [paths.db, `${paths.db}-wal`, `${paths.db}-shm`, paths.audit, paths.buildState]) {
527
+ try {
528
+ fs.rmSync(file, { force: true });
529
+ removed.push(file);
530
+ } catch {
531
+ /* best effort */
532
+ }
533
+ }
534
+ try {
535
+ fs.rmSync(paths.framesDir, { recursive: true, force: true });
536
+ removed.push(paths.framesDir);
537
+ } catch {
538
+ /* best effort */
539
+ }
540
+ return { ok: true, removed };
541
+ }
542
+
543
+ /**
544
+ * Build (or incrementally extend) the event index from L0 session logs.
545
+ * L0 is opened read-only; every write stays under <memoryRoot>/.longlive/index/.
546
+ */
547
+ export function buildIndexFromSessions(options = {}) {
548
+ const memoryRoot = path.resolve(String(options.memoryRoot ?? ''));
549
+ if (memoryRoot === '') throw new IndexError('E_MEMORY_ROOT', 'memoryRoot required');
550
+ const now = typeof options.now === 'function' ? options.now : () => new Date();
551
+ const started = Date.now();
552
+ const sessionRoots = Array.isArray(options.sessionRoots) && options.sessionRoots.length > 0
553
+ ? options.sessionRoots
554
+ : defaultSessionRoots();
555
+ const limitSessions = Number.isInteger(options.limitSessions) && options.limitSessions > 0
556
+ ? options.limitSessions
557
+ : Number.POSITIVE_INFINITY;
558
+ if (options.rebuild === true) deleteIndex(memoryRoot);
559
+
560
+ const paths = indexPaths(memoryRoot);
561
+ const index = openEventIndex({ memoryRoot, now, ...(options.dbPath ? { dbPath: options.dbPath } : {}) });
562
+ const logs = discoverSessionLogs(sessionRoots).slice(0, limitSessions);
563
+ const built = [];
564
+ const duplicates = [];
565
+ const errors = [];
566
+ let inserted = 0;
567
+ let skipped = 0;
568
+ let eventsIndexed = 0;
569
+ const seenSessionDigests = new Map();
570
+
571
+ for (const log of logs) {
572
+ try {
573
+ const buf = fs.readFileSync(log.file);
574
+ const parsed = parseSession(buf);
575
+ const stats = fs.statSync(log.file);
576
+ const sessionId = typeof parsed.header?.id === 'string' && parsed.header.id !== ''
577
+ ? parsed.header.id
578
+ : log.sessionId;
579
+ const sessionDigest = deriveMessagesDigest(parsed.header, parsed.events).sha256;
580
+ const duplicateOf = seenSessionDigests.get(sessionDigest);
581
+ if (duplicateOf !== undefined) {
582
+ duplicates.push({ sessionId, duplicateOf, file: log.file });
583
+ continue;
584
+ }
585
+ seenSessionDigests.set(sessionDigest, sessionId);
586
+ const records = extractTurns(parsed.header, parsed.events, parsed.frames, parsed.texts);
587
+ let sessionInserted = 0;
588
+ for (const record of records) {
589
+ record.builtAt = now().getTime();
590
+ record.source = options.source ?? 'rebuild';
591
+ const result = index.upsertEvent(record, { source: record.source });
592
+ if (result.inserted) {
593
+ inserted += 1;
594
+ sessionInserted += 1;
595
+ } else {
596
+ skipped += 1;
597
+ }
598
+ eventsIndexed += 1;
599
+ }
600
+ fs.writeFileSync(path.join(paths.framesDir, `${sessionId}.json`), `${JSON.stringify({
601
+ schema: 'frames/v1',
602
+ sessionId,
603
+ sessionDigest,
604
+ logBytes: buf.length,
605
+ logMtimeMs: stats.mtimeMs,
606
+ logSha256: crypto.createHash('sha256').update(buf).digest('hex'),
607
+ frames: parsed.frames.length,
608
+ turns: records.map((record) => ({
609
+ turn: record.turn,
610
+ seqStart: record.seqStart,
611
+ seqEnd: record.seqEnd,
612
+ frames: record.frameIndexes,
613
+ })),
614
+ }, null, 2)}\n`, 'utf8');
615
+ built.push({
616
+ sessionId,
617
+ dirSessionId: log.sessionId,
618
+ headerId: sessionId,
619
+ turns: records.length,
620
+ inserted: sessionInserted,
621
+ bytes: buf.length,
622
+ logBytes: buf.length,
623
+ logMtimeMs: stats.mtimeMs,
624
+ lastSeq: parsed.events.length - 1,
625
+ lastTurn: records.length > 0 ? records[records.length - 1].turn : -1,
626
+ header: { id: parsed.header?.id ?? null, cwd: parsed.header?.cwd ?? null },
627
+ sessionDigest,
628
+ file: log.file,
629
+ });
630
+ } catch (error) {
631
+ errors.push({ file: log.file, code: error?.code ?? 'E_SESSION_PARSE', message: String(error?.message ?? error) });
632
+ }
633
+ }
634
+
635
+ const buildState = {
636
+ schema: 'events-build-state/v1',
637
+ builtAt: nowIso(now),
638
+ schemaVersion: INDEX_SCHEMA_VERSION,
639
+ memoryRoot,
640
+ sessions: built.map((entry) => ({
641
+ sessionId: entry.sessionId,
642
+ dirSessionId: entry.dirSessionId ?? entry.sessionId,
643
+ headerId: entry.headerId,
644
+ turns: entry.turns,
645
+ bytes: entry.bytes,
646
+ logBytes: entry.logBytes,
647
+ logMtimeMs: entry.logMtimeMs,
648
+ lastSeq: entry.lastSeq,
649
+ lastTurn: entry.lastTurn,
650
+ header: entry.header,
651
+ sessionDigest: entry.sessionDigest,
652
+ })),
653
+ duplicates,
654
+ errors,
655
+ stats: {
656
+ sessionsScanned: logs.length,
657
+ sessionsBuilt: built.length,
658
+ duplicateSessions: duplicates.length,
659
+ turns: eventsIndexed,
660
+ inserted,
661
+ skipped,
662
+ errors: errors.length,
663
+ durationMs: Date.now() - started,
664
+ },
665
+ generatedAt: nowIso(now),
666
+ };
667
+ fs.writeFileSync(paths.buildState, `${JSON.stringify(buildState, null, 2)}\n`, 'utf8');
668
+ index.setMeta('build_state_at', buildState.builtAt);
669
+ const finalStats = index.stats();
670
+ index.close();
671
+ return { ok: true, memoryRoot, paths, ...buildState.stats, duplicateSessions: duplicates.length, dbBytes: finalStats.dbBytes, built: built.length };
672
+ }
673
+
674
+ /** Panel read-only route #11 reader: list indexed events; undefined when no index. */
675
+ export function createEventsIndexReader(options = {}) {
676
+ const memoryRoot = path.resolve(String(options.memoryRoot ?? ''));
677
+ const now = typeof options.now === 'function' ? options.now : () => new Date();
678
+ const paths = indexPaths(memoryRoot);
679
+ if (!fs.existsSync(paths.db)) return undefined;
680
+ return function eventsReader(query = {}) {
681
+ const index = openEventIndex({ memoryRoot, now });
682
+ try {
683
+ const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 200) : 20;
684
+ const offset = Number.isInteger(query.offset) && query.offset > 0 ? query.offset : 0;
685
+ const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : undefined;
686
+ const rows = index.listEvents({ limit, offset, kind });
687
+ return {
688
+ schema: EVENT_SCHEMA,
689
+ events: rows,
690
+ total: index.count({ kind }),
691
+ limit,
692
+ offset,
693
+ generatedAt: now().toISOString(),
694
+ };
695
+ } finally {
696
+ index.close();
697
+ }
698
+ };
699
+ }
700
+
701
+
702
+ /**
703
+ * Parse appended tail frames (no session header line) starting at startSeq.
704
+ * Host appends whole zstd frames, so (previous logBytes → new EOF) is frame aligned.
705
+ */
706
+ export function parseSessionTail(buf, options = {}) {
707
+ const startSeq = Number.isInteger(options.startSeq) ? options.startSeq : 0;
708
+ const frames = splitFrames(buf);
709
+ const texts = frames.map((frame) => decompressFrame(buf.subarray(frame.start, frame.end)));
710
+ const events = [];
711
+ for (const text of texts) {
712
+ for (const line of String(text).split('\n')) {
713
+ if (line === '') continue;
714
+ let event = null;
715
+ try {
716
+ event = JSON.parse(line);
717
+ } catch (error) {
718
+ throw new IndexError('E_TAIL_PARSE', 'tail line is not JSON: ' + error.message);
719
+ }
720
+ events.push(event);
721
+ }
722
+ }
723
+ events.forEach((event, index) => {
724
+ if (event.seq !== startSeq + index) {
725
+ throw new IndexError('E_TAIL_SEQ', 'tail seq ' + event.seq + ' (expected ' + (startSeq + index) + '); log rewritten, not appended');
726
+ }
727
+ });
728
+ return { frames, texts, events };
729
+ }
730
+
731
+ function readFromOffset(file, offset) {
732
+ const fd = fs.openSync(file, 'r');
733
+ try {
734
+ const size = fs.fstatSync(fd).size;
735
+ if (offset >= size) return Buffer.alloc(0);
736
+ const buf = Buffer.alloc(size - offset);
737
+ fs.readSync(fd, buf, 0, size - offset, offset);
738
+ return buf;
739
+ } finally {
740
+ fs.closeSync(fd);
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Incremental build: unchanged logs are skipped; appended logs read ONLY the new
746
+ * tail frames (from build-state.logBytes) and index just the new turns. Any doubt
747
+ * (shrunk file, non-frame-aligned tail, tail starting mid-turn) falls back to a
748
+ * full re-parse of that session — digest skip in upsertEvent stays as the safety net.
749
+ */
750
+ export function buildIndexIncremental(options = {}) {
751
+ const memoryRoot = path.resolve(String(options.memoryRoot ?? ''));
752
+ if (memoryRoot === '') throw new IndexError('E_MEMORY_ROOT', 'memoryRoot required');
753
+ const now = typeof options.now === 'function' ? options.now : () => new Date();
754
+ const started = Date.now();
755
+ const sessionRoots = Array.isArray(options.sessionRoots) && options.sessionRoots.length > 0
756
+ ? options.sessionRoots
757
+ : defaultSessionRoots();
758
+ const paths = indexPaths(memoryRoot);
759
+ let previousState = null;
760
+ try {
761
+ previousState = JSON.parse(fs.readFileSync(paths.buildState, 'utf8'));
762
+ } catch {
763
+ previousState = null;
764
+ }
765
+ if (!previousState || !Array.isArray(previousState.sessions)) {
766
+ const full = buildIndexFromSessions({
767
+ memoryRoot,
768
+ sessionRoots,
769
+ now,
770
+ ...(options.limitSessions ? { limitSessions: options.limitSessions } : {}),
771
+ });
772
+ return { ok: true, mode: 'full-fallback', ...full };
773
+ }
774
+ const index = openEventIndex({ memoryRoot, now, ...(options.dbPath ? { dbPath: options.dbPath } : {}) });
775
+ const logs = discoverSessionLogs(sessionRoots).slice(0, Number.isInteger(options.limitSessions) ? options.limitSessions : Number.POSITIVE_INFINITY);
776
+ const stateBySession = new Map(previousState.sessions.map((entry) => [entry.dirSessionId ?? entry.sessionId, { ...entry }]));
777
+ const duplicates = Array.isArray(previousState.duplicates) ? [...previousState.duplicates] : [];
778
+ const errors = [];
779
+ const stats = {
780
+ sessionsScanned: logs.length,
781
+ sessionsUnchanged: 0,
782
+ sessionsAppended: 0,
783
+ sessionsRebuilt: 0,
784
+ sessionsNew: 0,
785
+ turns: 0,
786
+ inserted: 0,
787
+ skipped: 0,
788
+ errors: 0,
789
+ };
790
+
791
+ const rebuildOne = (log, stat) => {
792
+ const buf = fs.readFileSync(log.file);
793
+ const parsed = parseSession(buf);
794
+ const headerId = typeof parsed.header?.id === 'string' && parsed.header.id !== '' ? parsed.header.id : log.sessionId;
795
+ index.deleteSession(headerId);
796
+ const records = extractTurns(parsed.header, parsed.events, parsed.frames, parsed.texts);
797
+ let inserted = 0;
798
+ for (const record of records) {
799
+ record.builtAt = now().getTime();
800
+ record.source = 'incremental-rebuild';
801
+ const result = index.upsertEvent(record, { source: record.source });
802
+ if (result.inserted) inserted += 1;
803
+ }
804
+ stateBySession.set(log.sessionId, {
805
+ sessionId: headerId,
806
+ dirSessionId: log.sessionId,
807
+ headerId,
808
+ turns: records.length,
809
+ bytes: buf.length,
810
+ logBytes: buf.length,
811
+ logMtimeMs: stat.mtimeMs,
812
+ lastSeq: parsed.events.length - 1,
813
+ lastTurn: records.length > 0 ? records[records.length - 1].turn : -1,
814
+ header: { id: parsed.header?.id ?? null, cwd: parsed.header?.cwd ?? null },
815
+ sessionDigest: deriveMessagesDigest(parsed.header, parsed.events).sha256,
816
+ });
817
+ stats.turns += records.length;
818
+ stats.inserted += inserted;
819
+ stats.skipped += records.length - inserted;
820
+ return { records, inserted };
821
+ };
822
+
823
+ for (const log of logs) {
824
+ let stat = null;
825
+ try {
826
+ stat = fs.statSync(log.file);
827
+ } catch {
828
+ continue;
829
+ }
830
+ const stateEntry = stateBySession.get(log.sessionId);
831
+ if (stateEntry && stateEntry.logBytes === stat.size && stateEntry.logMtimeMs === stat.mtimeMs) {
832
+ stats.sessionsUnchanged += 1;
833
+ continue;
834
+ }
835
+ if (!stateEntry) {
836
+ try {
837
+ rebuildOne(log, stat);
838
+ stats.sessionsNew += 1;
839
+ } catch (error) {
840
+ errors.push({ file: log.file, code: error?.code ?? 'E_INCREMENTAL', message: String(error?.message ?? error) });
841
+ }
842
+ continue;
843
+ }
844
+ if (stat.size < stateEntry.logBytes) {
845
+ try {
846
+ rebuildOne(log, stat);
847
+ stats.sessionsRebuilt += 1;
848
+ } catch (error) {
849
+ errors.push({ file: log.file, code: error?.code ?? 'E_INCREMENTAL', message: String(error?.message ?? error) });
850
+ }
851
+ continue;
852
+ }
853
+ try {
854
+ const tailBuf = readFromOffset(log.file, stateEntry.logBytes);
855
+ if (tailBuf.length === 0) {
856
+ stats.sessionsUnchanged += 1;
857
+ continue;
858
+ }
859
+ const tail = parseSessionTail(tailBuf, { startSeq: stateEntry.lastSeq + 1 });
860
+ const firstTurn = Number(tail.events[0]?.data?.turn);
861
+ if (!Number.isInteger(firstTurn) || firstTurn <= (stateEntry.lastTurn ?? -1)) {
862
+ rebuildOne(log, stat);
863
+ stats.sessionsRebuilt += 1;
864
+ continue;
865
+ }
866
+ const header = { id: stateEntry.headerId ?? log.sessionId, cwd: stateEntry.header?.cwd ?? null };
867
+ const records = extractTurns(header, tail.events, tail.frames, tail.texts);
868
+ let inserted = 0;
869
+ for (const record of records) {
870
+ record.builtAt = now().getTime();
871
+ record.source = 'incremental-tail';
872
+ const result = index.upsertEvent(record, { source: record.source });
873
+ if (result.inserted) inserted += 1;
874
+ }
875
+ stateBySession.set(log.sessionId, {
876
+ ...stateEntry,
877
+ turns: (stateEntry.turns ?? 0) + records.length,
878
+ logBytes: stat.size,
879
+ logMtimeMs: stat.mtimeMs,
880
+ lastSeq: tail.events[tail.events.length - 1].seq,
881
+ lastTurn: records.length > 0 ? records[records.length - 1].turn : stateEntry.lastTurn,
882
+ });
883
+ stats.sessionsAppended += 1;
884
+ stats.turns += records.length;
885
+ stats.inserted += inserted;
886
+ stats.skipped += records.length - inserted;
887
+ } catch {
888
+ try {
889
+ rebuildOne(log, stat);
890
+ stats.sessionsRebuilt += 1;
891
+ } catch (error2) {
892
+ errors.push({ file: log.file, code: error2?.code ?? 'E_INCREMENTAL', message: String(error2?.message ?? error2) });
893
+ }
894
+ }
895
+ }
896
+
897
+ stats.errors = errors.length;
898
+ const buildState = {
899
+ schema: 'events-build-state/v1',
900
+ builtAt: nowIso(now),
901
+ schemaVersion: INDEX_SCHEMA_VERSION,
902
+ memoryRoot,
903
+ mode: 'incremental',
904
+ sessions: [...stateBySession.values()].sort((a, b) => a.sessionId.localeCompare(b.sessionId)),
905
+ duplicates,
906
+ errors,
907
+ stats,
908
+ durationMs: Date.now() - started,
909
+ };
910
+ fs.writeFileSync(paths.buildState, JSON.stringify(buildState, null, 2) + '\n', 'utf8');
911
+ index.setMeta('build_state_at', buildState.builtAt);
912
+ const finalStats = index.stats();
913
+ index.close();
914
+ return { ok: true, mode: 'incremental', memoryRoot, paths, ...stats, dbBytes: finalStats.dbBytes, durationMs: Date.now() - started };
915
+ }
916
+
917
+ export { stableStringify };