@thread-memory/core 0.1.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 (76) hide show
  1. package/dist/events.d.ts +18 -0
  2. package/dist/events.js +17 -0
  3. package/dist/events.js.map +1 -0
  4. package/dist/events.test.d.ts +1 -0
  5. package/dist/events.test.js +46 -0
  6. package/dist/events.test.js.map +1 -0
  7. package/dist/feedback-guard.d.ts +3 -0
  8. package/dist/feedback-guard.js +33 -0
  9. package/dist/feedback-guard.js.map +1 -0
  10. package/dist/feedback-guard.test.d.ts +1 -0
  11. package/dist/feedback-guard.test.js +67 -0
  12. package/dist/feedback-guard.test.js.map +1 -0
  13. package/dist/governor.d.ts +16 -0
  14. package/dist/governor.js +25 -0
  15. package/dist/governor.js.map +1 -0
  16. package/dist/index.d.ts +12 -0
  17. package/dist/index.js +13 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/index.test.d.ts +1 -0
  20. package/dist/index.test.js +8 -0
  21. package/dist/index.test.js.map +1 -0
  22. package/dist/isolation.test.d.ts +1 -0
  23. package/dist/isolation.test.js +102 -0
  24. package/dist/isolation.test.js.map +1 -0
  25. package/dist/light-confirm.d.ts +37 -0
  26. package/dist/light-confirm.js +149 -0
  27. package/dist/light-confirm.js.map +1 -0
  28. package/dist/light-confirm.test.d.ts +1 -0
  29. package/dist/light-confirm.test.js +154 -0
  30. package/dist/light-confirm.test.js.map +1 -0
  31. package/dist/lineage.test.d.ts +1 -0
  32. package/dist/lineage.test.js +58 -0
  33. package/dist/lineage.test.js.map +1 -0
  34. package/dist/migrate.d.ts +45 -0
  35. package/dist/migrate.js +349 -0
  36. package/dist/migrate.js.map +1 -0
  37. package/dist/migrate.test.d.ts +1 -0
  38. package/dist/migrate.test.js +111 -0
  39. package/dist/migrate.test.js.map +1 -0
  40. package/dist/paths.d.ts +7 -0
  41. package/dist/paths.js +16 -0
  42. package/dist/paths.js.map +1 -0
  43. package/dist/project-key.d.ts +3 -0
  44. package/dist/project-key.js +48 -0
  45. package/dist/project-key.js.map +1 -0
  46. package/dist/project-key.test.d.ts +1 -0
  47. package/dist/project-key.test.js +29 -0
  48. package/dist/project-key.test.js.map +1 -0
  49. package/dist/query.d.ts +49 -0
  50. package/dist/query.js +165 -0
  51. package/dist/query.js.map +1 -0
  52. package/dist/query.test.d.ts +1 -0
  53. package/dist/query.test.js +162 -0
  54. package/dist/query.test.js.map +1 -0
  55. package/dist/schema.d.ts +4 -0
  56. package/dist/schema.js +92 -0
  57. package/dist/schema.js.map +1 -0
  58. package/dist/schema.test.d.ts +1 -0
  59. package/dist/schema.test.js +80 -0
  60. package/dist/schema.test.js.map +1 -0
  61. package/dist/state.d.ts +4 -0
  62. package/dist/state.js +15 -0
  63. package/dist/state.js.map +1 -0
  64. package/dist/status-card.d.ts +10 -0
  65. package/dist/status-card.js +65 -0
  66. package/dist/status-card.js.map +1 -0
  67. package/dist/status-card.test.d.ts +1 -0
  68. package/dist/status-card.test.js +53 -0
  69. package/dist/status-card.test.js.map +1 -0
  70. package/dist/store.d.ts +170 -0
  71. package/dist/store.js +664 -0
  72. package/dist/store.js.map +1 -0
  73. package/dist/store.test.d.ts +1 -0
  74. package/dist/store.test.js +284 -0
  75. package/dist/store.test.js.map +1 -0
  76. package/package.json +29 -0
package/dist/store.js ADDED
@@ -0,0 +1,664 @@
1
+ import Database from "better-sqlite3";
2
+ import { createHash } from "node:crypto";
3
+ import { MAX_BODY_CHARS, truncateBody } from "./events.js";
4
+ import { assertTransition } from "./state.js";
5
+ import { ensureSchema } from "./schema.js";
6
+ import { SpillPolicy, isIndexable } from "./governor.js";
7
+ export const EVENTS_SCHEMA = `
8
+ CREATE TABLE IF NOT EXISTS events (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ session_id TEXT NOT NULL,
11
+ kind TEXT NOT NULL,
12
+ ts TEXT NOT NULL,
13
+ seq INTEGER NOT NULL,
14
+ body TEXT NOT NULL,
15
+ meta TEXT,
16
+ truncated INTEGER NOT NULL DEFAULT 0,
17
+ project_key TEXT,
18
+ scope TEXT NOT NULL DEFAULT 'project',
19
+ origin TEXT,
20
+ spilled INTEGER NOT NULL DEFAULT 0,
21
+ isolation INTEGER NOT NULL DEFAULT 0
22
+ );
23
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, seq);
24
+
25
+ CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
26
+ body,
27
+ content='events',
28
+ content_rowid='id',
29
+ tokenize='unicode61'
30
+ );
31
+
32
+ CREATE TABLE IF NOT EXISTS episodes (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ session_id TEXT NOT NULL,
35
+ seq_start INTEGER NOT NULL,
36
+ seq_end INTEGER,
37
+ status TEXT NOT NULL DEFAULT 'active',
38
+ summary TEXT,
39
+ created_at TEXT NOT NULL
40
+ );
41
+ CREATE INDEX IF NOT EXISTS idx_episodes_session ON episodes(session_id, id);
42
+
43
+ CREATE TABLE IF NOT EXISTS spills (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ event_id INTEGER NOT NULL REFERENCES events(id),
46
+ ref TEXT NOT NULL,
47
+ blob TEXT,
48
+ sha256 TEXT NOT NULL,
49
+ created_at TEXT NOT NULL
50
+ );
51
+
52
+ CREATE TABLE IF NOT EXISTS lineage_edges (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ session_id TEXT NOT NULL,
55
+ src_type TEXT NOT NULL,
56
+ src_id INTEGER,
57
+ dst_type TEXT NOT NULL,
58
+ dst_id INTEGER,
59
+ ref TEXT,
60
+ edge_type TEXT NOT NULL,
61
+ confidence REAL NOT NULL DEFAULT 1.0,
62
+ ts TEXT NOT NULL
63
+ );
64
+ CREATE INDEX IF NOT EXISTS idx_lineage_src ON lineage_edges(session_id, src_type, src_id);
65
+ CREATE INDEX IF NOT EXISTS idx_lineage_dst ON lineage_edges(session_id, dst_type, dst_id);
66
+
67
+ CREATE TABLE IF NOT EXISTS schema_version (
68
+ version INTEGER NOT NULL,
69
+ applied_at TEXT NOT NULL
70
+ );
71
+ `;
72
+ export const STRUCTURED_SCHEMA = `
73
+ CREATE TABLE IF NOT EXISTS goals (
74
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
75
+ session_id TEXT NOT NULL,
76
+ text TEXT NOT NULL,
77
+ status TEXT NOT NULL DEFAULT 'active',
78
+ source_event INTEGER,
79
+ created_at TEXT NOT NULL,
80
+ project_key TEXT,
81
+ scope TEXT NOT NULL DEFAULT 'project',
82
+ origin TEXT,
83
+ isolation INTEGER NOT NULL DEFAULT 0
84
+ );
85
+ CREATE INDEX IF NOT EXISTS idx_goals_session ON goals(session_id, id);
86
+
87
+ CREATE TABLE IF NOT EXISTS decisions (
88
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
89
+ session_id TEXT NOT NULL,
90
+ text TEXT NOT NULL,
91
+ status TEXT NOT NULL DEFAULT 'proposed',
92
+ superseded_by INTEGER,
93
+ source_event INTEGER,
94
+ created_at TEXT NOT NULL,
95
+ updated_at TEXT NOT NULL,
96
+ project_key TEXT,
97
+ scope TEXT NOT NULL DEFAULT 'project',
98
+ origin TEXT,
99
+ isolation INTEGER NOT NULL DEFAULT 0
100
+ );
101
+ CREATE INDEX IF NOT EXISTS idx_decisions_session ON decisions(session_id, status);
102
+
103
+ CREATE TABLE IF NOT EXISTS feedback (
104
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105
+ session_id TEXT NOT NULL,
106
+ text TEXT NOT NULL,
107
+ kind TEXT NOT NULL DEFAULT 'preference',
108
+ source_event INTEGER,
109
+ created_at TEXT NOT NULL,
110
+ project_key TEXT,
111
+ scope TEXT NOT NULL DEFAULT 'project',
112
+ origin TEXT,
113
+ isolation INTEGER NOT NULL DEFAULT 0
114
+ );
115
+ CREATE INDEX IF NOT EXISTS idx_feedback_session ON feedback(session_id, id);
116
+
117
+ CREATE TABLE IF NOT EXISTS session_isolation (
118
+ session_id TEXT PRIMARY KEY,
119
+ isolated INTEGER NOT NULL DEFAULT 0,
120
+ updated_at TEXT NOT NULL
121
+ );
122
+
123
+ CREATE TABLE IF NOT EXISTS lineage_edges (
124
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
125
+ session_id TEXT NOT NULL,
126
+ src_type TEXT NOT NULL,
127
+ src_id INTEGER,
128
+ dst_type TEXT NOT NULL,
129
+ dst_id INTEGER,
130
+ ref TEXT,
131
+ edge_type TEXT NOT NULL,
132
+ confidence REAL NOT NULL DEFAULT 1.0,
133
+ ts TEXT NOT NULL
134
+ );
135
+ CREATE INDEX IF NOT EXISTS idx_lineage_src ON lineage_edges(session_id, src_type, src_id);
136
+ CREATE INDEX IF NOT EXISTS idx_lineage_dst ON lineage_edges(session_id, dst_type, dst_id);
137
+
138
+ CREATE TABLE IF NOT EXISTS schema_version (
139
+ version INTEGER NOT NULL,
140
+ applied_at TEXT NOT NULL
141
+ );
142
+
143
+ CREATE TABLE IF NOT EXISTS entities (
144
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
145
+ name TEXT NOT NULL UNIQUE,
146
+ kind TEXT NOT NULL
147
+ );
148
+
149
+ CREATE TABLE IF NOT EXISTS decision_entities (
150
+ decision_id INTEGER NOT NULL REFERENCES decisions(id),
151
+ entity_id INTEGER NOT NULL REFERENCES entities(id),
152
+ edge TEXT NOT NULL DEFAULT 'references',
153
+ ts TEXT NOT NULL,
154
+ PRIMARY KEY (decision_id, entity_id)
155
+ );
156
+
157
+ CREATE TABLE IF NOT EXISTS metrics (
158
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
159
+ event_id INTEGER,
160
+ name TEXT NOT NULL,
161
+ value REAL NOT NULL,
162
+ ts TEXT NOT NULL
163
+ );
164
+ `;
165
+ export class ThreadStore {
166
+ eventsDb;
167
+ structuredDb;
168
+ projectKey;
169
+ spillPolicy;
170
+ constructor(opts, spillPolicy = new SpillPolicy()) {
171
+ this.eventsDb = openDb(opts.eventsPath, EVENTS_SCHEMA, "events");
172
+ this.structuredDb = openDb(opts.structuredPath, STRUCTURED_SCHEMA, "structured");
173
+ this.projectKey = opts.projectKey;
174
+ this.spillPolicy = spillPolicy;
175
+ }
176
+ getRecentSessionId() {
177
+ const row = this.eventsDb
178
+ .prepare(`SELECT session_id FROM events ORDER BY id DESC LIMIT 1`)
179
+ .get();
180
+ return row?.session_id;
181
+ }
182
+ // 引用回拉:spill.blob → spills 表;无 spill 返回事件 body;不可回拉返回 body + 缺失标记
183
+ // 隔离保护:隔离事件的原文仅其所属会话可回拉(sessionId 缺省时隔离内容不可见)
184
+ expand(eventId, opts = {}) {
185
+ const meta = this.eventsDb
186
+ .prepare(`SELECT session_id, isolation FROM events WHERE id = ?`)
187
+ .get(eventId);
188
+ if (!meta) {
189
+ return `[缺失: event ${eventId} 不存在]`;
190
+ }
191
+ if (meta.isolation === 1 && opts.sessionId !== meta.session_id) {
192
+ return `[隔离内容不可见: event ${eventId} 属于隔离会话]`;
193
+ }
194
+ const spill = this.eventsDb
195
+ .prepare(`SELECT blob, ref FROM spills WHERE event_id = ? LIMIT 1`)
196
+ .get(eventId);
197
+ if (spill?.blob != null) {
198
+ return spill.blob;
199
+ }
200
+ const event = this.eventsDb.prepare(`SELECT body FROM events WHERE id = ?`).get(eventId);
201
+ if (event) {
202
+ return spill
203
+ ? `${event.body}\n[原文不可回拉: ref=${spill.ref}]`
204
+ : event.body;
205
+ }
206
+ return `[缺失: event ${eventId} 不存在]`;
207
+ }
208
+ hasAssistantTurn(sessionId, uuid) {
209
+ return (this.eventsDb
210
+ .prepare(`SELECT 1 FROM events WHERE session_id = ? AND kind = 'assistant_message' AND json_extract(meta, '$.assistant_uuid') = ? LIMIT 1`)
211
+ .get(sessionId, uuid) !== undefined);
212
+ }
213
+ close() {
214
+ this.eventsDb.close();
215
+ this.structuredDb.close();
216
+ }
217
+ append(event, opts = {}) {
218
+ return this.eventsDb.transaction(() => {
219
+ if (opts.origin) {
220
+ const existing = this.eventsDb
221
+ .prepare(`SELECT id FROM events WHERE origin = ? LIMIT 1`)
222
+ .get(opts.origin);
223
+ if (existing) {
224
+ return this.eventsDb
225
+ .prepare(`SELECT * FROM events WHERE id = ?`)
226
+ .get(existing.id);
227
+ }
228
+ }
229
+ const { body, truncated } = truncateBody(event.body);
230
+ const metaJson = event.meta ? JSON.stringify(event.meta) : null;
231
+ const metaTruncated = metaJson ? metaJson.length > MAX_BODY_CHARS : false;
232
+ const storedMetaJson = metaTruncated && metaJson ? metaJson.slice(0, MAX_BODY_CHARS) : metaJson;
233
+ const spill = this.spillPolicy.evaluate(body, { ref: opts.spillRef });
234
+ const storedBody = spill.spill ? spill.kept : body;
235
+ const seq = this.nextSeq(event.session_id);
236
+ // 项目事实(tool 类事件)强制共享:隔离只作用于对话上下文,事实血缘不断链
237
+ const isolation = isToolKind(event.kind) ? 0 : opts.isolation ? 1 : 0;
238
+ this.eventsDb
239
+ .prepare(`INSERT INTO events (session_id, kind, ts, seq, body, meta, truncated, project_key, scope, origin, spilled, isolation)
240
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
241
+ .run(event.session_id, event.kind, event.ts, seq, storedBody, storedMetaJson, truncated || metaTruncated ? 1 : 0, opts.scope === "global" ? null : opts.projectKey ?? this.projectKey ?? null, opts.scope ?? "project", opts.origin ?? null, spill.spill ? 1 : 0, isolation);
242
+ const eventId = this.eventsDb.prepare(`SELECT last_insert_rowid() AS id`).get();
243
+ if (spill.spill) {
244
+ const sha256 = sha256Hex(body);
245
+ this.eventsDb
246
+ .prepare(`INSERT INTO spills (event_id, ref, blob, sha256, created_at) VALUES (?, ?, ?, ?, ?)`)
247
+ .run(eventId.id, spill.ref ?? `spill:${eventId.id}`, body, sha256, event.ts);
248
+ }
249
+ if (isIndexable(event.kind)) {
250
+ this.eventsDb.prepare(`INSERT INTO events_fts(rowid, body) VALUES (?, ?)`).run(eventId.id, cjkSpace(storedBody));
251
+ }
252
+ const eventMeta = event.meta;
253
+ if (typeof eventMeta?.file_path === "string") {
254
+ this.addLineageEdge(event.session_id, "event", eventId.id, "file", null, "touches_file", {
255
+ ref: eventMeta.file_path,
256
+ ts: event.ts,
257
+ });
258
+ }
259
+ if (typeof eventMeta?.tool_name === "string") {
260
+ this.addLineageEdge(event.session_id, "event", eventId.id, "tool", null, "uses_tool", {
261
+ ref: eventMeta.tool_name,
262
+ ts: event.ts,
263
+ });
264
+ }
265
+ this.updateEpisode(event.session_id, seq, event.kind, event.ts);
266
+ return {
267
+ ...event,
268
+ id: eventId.id,
269
+ seq,
270
+ body: storedBody,
271
+ meta: storedMetaJson ? safeParse(storedMetaJson) : undefined,
272
+ truncated: truncated || metaTruncated,
273
+ project_key: opts.projectKey ?? this.projectKey,
274
+ scope: opts.scope ?? "project",
275
+ origin: opts.origin,
276
+ spilled: spill.spill ? 1 : 0,
277
+ isolation,
278
+ };
279
+ })();
280
+ }
281
+ transact(fn) {
282
+ return this.structuredDb.transaction(fn)();
283
+ }
284
+ search(query, opts = {}) {
285
+ const ftsQuery = toFtsQuery(query);
286
+ if (!ftsQuery) {
287
+ return [];
288
+ }
289
+ let sql = `
290
+ SELECT e.id, e.session_id, e.kind, e.ts, e.seq, e.body, e.truncated, bm25(events_fts) AS score
291
+ FROM events_fts f JOIN events e ON e.id = f.rowid
292
+ WHERE events_fts MATCH ?`;
293
+ const params = [ftsQuery];
294
+ if (opts.sessionId) {
295
+ // 当前会话可见自己的全部内容(含隔离),其他会话只见未隔离行
296
+ sql += ` AND (e.session_id = ? OR e.isolation = 0)`;
297
+ params.push(opts.sessionId);
298
+ }
299
+ else {
300
+ sql += ` AND e.isolation = 0`;
301
+ }
302
+ sql += ` ORDER BY score LIMIT ?`;
303
+ params.push(opts.limit ?? 20);
304
+ return this.eventsDb.prepare(sql).all(...params);
305
+ }
306
+ getActiveEpisode(sessionId) {
307
+ return this.eventsDb
308
+ .prepare(`SELECT * FROM episodes WHERE session_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1`)
309
+ .get(sessionId);
310
+ }
311
+ getLatestEpisodeWithSummary(sessionId) {
312
+ return this.eventsDb
313
+ .prepare(`SELECT * FROM episodes WHERE session_id = ? AND summary IS NOT NULL ORDER BY id DESC LIMIT 1`)
314
+ .get(sessionId);
315
+ }
316
+ addGoal(sessionId, text, opts = {}) {
317
+ const existing = this.findByOrigin("goals", opts.origin);
318
+ if (existing) {
319
+ return existing;
320
+ }
321
+ const ts = opts.ts ?? new Date().toISOString();
322
+ const goal = this.structuredDb
323
+ .prepare(`INSERT INTO goals (session_id, text, status, source_event, created_at, project_key, scope, origin, isolation)
324
+ VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?) RETURNING *`)
325
+ .get(sessionId, text, opts.sourceEvent ?? null, ts, opts.scope === "global" ? null : opts.projectKey ?? this.projectKey ?? null, opts.scope ?? "project", opts.origin ?? null, opts.isolation ? 1 : 0);
326
+ if (opts.sourceEvent) {
327
+ this.addLineageEdge(sessionId, "goal", goal.id, "event", opts.sourceEvent, "derived_from", {
328
+ ts,
329
+ });
330
+ }
331
+ return goal;
332
+ }
333
+ getActiveGoals(sessionId) {
334
+ return this.structuredDb
335
+ .prepare(`SELECT * FROM goals WHERE session_id = ? AND status = 'active' ORDER BY id`)
336
+ .all(sessionId);
337
+ }
338
+ getFeedback(sessionId, limit = 5) {
339
+ return this.structuredDb
340
+ .prepare(`SELECT * FROM feedback WHERE session_id = ? ORDER BY id DESC LIMIT ?`)
341
+ .all(sessionId, limit);
342
+ }
343
+ getRecentEvents(sessionId, limit = 5) {
344
+ const rows = this.eventsDb
345
+ .prepare(`SELECT id, session_id, kind, ts, seq, body, meta, truncated
346
+ FROM events WHERE session_id = ? ORDER BY id DESC LIMIT ?`)
347
+ .all(sessionId, limit);
348
+ return rows.map((r) => ({
349
+ ...r,
350
+ meta: r.meta ? safeParse(r.meta) : undefined,
351
+ }));
352
+ }
353
+ updateGoalStatus(sessionId, goalId, status) {
354
+ return this.structuredDb
355
+ .prepare(`UPDATE goals SET status = ? WHERE id = ? AND session_id = ? RETURNING *`)
356
+ .get(status, goalId, sessionId);
357
+ }
358
+ addFeedback(sessionId, text, kind, opts = {}) {
359
+ const existing = this.findByOrigin("feedback", opts.origin);
360
+ if (existing) {
361
+ return existing;
362
+ }
363
+ const ts = opts.ts ?? new Date().toISOString();
364
+ return this.structuredDb
365
+ .prepare(`INSERT INTO feedback (session_id, text, kind, source_event, created_at, project_key, scope, origin, isolation)
366
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`)
367
+ .get(sessionId, text, kind, opts.sourceEvent ?? null, ts, opts.scope === "global" ? null : opts.projectKey ?? this.projectKey ?? null, opts.scope ?? "project", opts.origin ?? null, opts.isolation ? 1 : 0);
368
+ }
369
+ // 反馈治理(B⑥-② 恢复通道):按 id 删除反馈行——教训可删即恢复;事件流水保留(真相源不变)。
370
+ // 单用户语义:跨会话行亦可删(治理动作由用户/其代理发起)。
371
+ deleteFeedback(id) {
372
+ const res = this.structuredDb.prepare(`DELETE FROM feedback WHERE id = ?`).run(id);
373
+ return res.changes > 0;
374
+ }
375
+ proposeDecision(sessionId, text, opts = {}) {
376
+ const existing = this.findByOrigin("decisions", opts.origin);
377
+ if (existing) {
378
+ return existing;
379
+ }
380
+ const dup = this.structuredDb
381
+ .prepare(`SELECT * FROM decisions WHERE session_id = ? AND text = ? AND status NOT IN ('superseded', 'revoked') LIMIT 1`)
382
+ .get(sessionId, text);
383
+ if (dup) {
384
+ return dup;
385
+ }
386
+ const ts = opts.ts ?? new Date().toISOString();
387
+ const decision = this.structuredDb
388
+ .prepare(`INSERT INTO decisions (session_id, text, status, source_event, created_at, updated_at, project_key, scope, origin, isolation)
389
+ VALUES (?, ?, 'proposed', ?, ?, ?, ?, ?, ?, ?) RETURNING *`)
390
+ .get(sessionId, text, opts.sourceEvent ?? null, ts, ts, opts.scope === "global" ? null : opts.projectKey ?? this.projectKey ?? null, opts.scope ?? "project", opts.origin ?? null, opts.isolation ? 1 : 0);
391
+ if (opts.sourceEvent) {
392
+ this.addLineageEdge(sessionId, "decision", decision.id, "event", opts.sourceEvent, "derived_from", {
393
+ ts,
394
+ });
395
+ }
396
+ return decision;
397
+ }
398
+ // 结构化表幂等:origin 已存在(含跨会话)则返回既有行,不重复写
399
+ findByOrigin(table, origin) {
400
+ if (!origin) {
401
+ return undefined;
402
+ }
403
+ return this.structuredDb
404
+ .prepare(`SELECT * FROM ${table} WHERE origin = ? LIMIT 1`)
405
+ .get(origin);
406
+ }
407
+ confirmLatestProposed(sessionId, opts = {}) {
408
+ const target = this.structuredDb
409
+ .prepare(`SELECT * FROM decisions WHERE session_id = ? AND status = 'proposed' ORDER BY id DESC LIMIT 1`)
410
+ .get(sessionId);
411
+ if (!target) {
412
+ return undefined;
413
+ }
414
+ return this.transitionDecision(target, "active", opts.ts);
415
+ }
416
+ revokeLatestActive(sessionId, opts = {}) {
417
+ const target = this.structuredDb
418
+ .prepare(`SELECT * FROM decisions WHERE session_id = ? AND status IN ('proposed', 'active') ORDER BY id DESC LIMIT 1`)
419
+ .get(sessionId);
420
+ if (!target) {
421
+ return undefined;
422
+ }
423
+ return this.transitionDecision(target, "revoked", opts.ts);
424
+ }
425
+ supersedeLatestActive(sessionId, replacementText, opts = {}) {
426
+ const target = this.structuredDb
427
+ .prepare(`SELECT * FROM decisions WHERE session_id = ? AND status IN ('proposed', 'active') ORDER BY id DESC LIMIT 1`)
428
+ .get(sessionId);
429
+ const ts = opts.ts ?? new Date().toISOString();
430
+ if (!target) {
431
+ return undefined;
432
+ }
433
+ const replacement = this.structuredDb
434
+ .prepare(`INSERT INTO decisions (session_id, text, status, superseded_by, source_event, created_at, updated_at, isolation)
435
+ VALUES (?, ?, 'active', NULL, ?, ?, ?, ?) RETURNING *`)
436
+ .get(sessionId, replacementText, opts.sourceEvent ?? null, ts, ts, target.isolation ?? 0);
437
+ const superseded = this.transitionDecision(target, "superseded", ts, replacement.id);
438
+ this.addLineageEdge(sessionId, "decision", target.id, "decision", replacement.id, "supersedes", {
439
+ ts,
440
+ });
441
+ return { superseded, replacement };
442
+ }
443
+ getActiveDecisions(sessionId) {
444
+ return this.structuredDb
445
+ .prepare(`SELECT * FROM decisions WHERE session_id = ? AND status = 'active' ORDER BY id`)
446
+ .all(sessionId);
447
+ }
448
+ // 作用域合并(B②):当前会话全部 active + 同项目 project 级 active(其他会话建立)——非当前项目硬过滤
449
+ // 隔离过滤:其他会话的隔离行(isolation=1)不可见
450
+ getActiveDecisionsMerged(sessionId, projectKey) {
451
+ if (!projectKey) {
452
+ return this.getActiveDecisions(sessionId);
453
+ }
454
+ return this.structuredDb
455
+ .prepare(`SELECT * FROM decisions
456
+ WHERE status = 'active'
457
+ AND (session_id = ? OR (project_key = ? AND scope = 'project'))
458
+ AND (session_id = ? OR isolation = 0)
459
+ ORDER BY id`)
460
+ .all(sessionId, projectKey, sessionId);
461
+ }
462
+ getActiveGoalsMerged(sessionId, projectKey) {
463
+ if (!projectKey) {
464
+ return this.getActiveGoals(sessionId);
465
+ }
466
+ return this.structuredDb
467
+ .prepare(`SELECT * FROM goals
468
+ WHERE status = 'active'
469
+ AND (session_id = ? OR (project_key = ? AND scope = 'project'))
470
+ AND (session_id = ? OR isolation = 0)
471
+ ORDER BY id`)
472
+ .all(sessionId, projectKey, sessionId);
473
+ }
474
+ // 反馈合并:当前会话 + 同项目 project 级 + global 级(跨项目共享),去重按 text
475
+ // 隔离过滤:其他会话的隔离行不可见
476
+ getFeedbackMerged(sessionId, projectKey, limit = 5) {
477
+ const rows = this.structuredDb
478
+ .prepare(`SELECT * FROM feedback
479
+ WHERE (session_id = ? OR scope = 'global' OR (project_key = ? AND scope = 'project'))
480
+ AND (session_id = ? OR isolation = 0)
481
+ ORDER BY id DESC LIMIT ?`)
482
+ .all(sessionId, projectKey ?? "", sessionId, limit);
483
+ const seen = new Set();
484
+ const out = [];
485
+ for (const row of rows) {
486
+ if (seen.has(row.text)) {
487
+ continue;
488
+ }
489
+ seen.add(row.text);
490
+ out.push(row);
491
+ }
492
+ return out;
493
+ }
494
+ // 会话临时隔离(B⑧):会话级可变开关——隔离期间对话上下文仅自己可见,项目事实(tool)仍共享
495
+ setSessionIsolation(sessionId, isolated) {
496
+ this.structuredDb
497
+ .prepare(`INSERT INTO session_isolation (session_id, isolated, updated_at) VALUES (?, ?, ?)
498
+ ON CONFLICT(session_id) DO UPDATE SET isolated = excluded.isolated, updated_at = excluded.updated_at`)
499
+ .run(sessionId, isolated ? 1 : 0, new Date().toISOString());
500
+ }
501
+ getSessionIsolation(sessionId) {
502
+ const row = this.structuredDb
503
+ .prepare(`SELECT isolated FROM session_isolation WHERE session_id = ?`)
504
+ .get(sessionId);
505
+ return row?.isolated === 1;
506
+ }
507
+ // 沉淀:清除指定结构化行的隔离标记(隔离会话期间产生的决策按需转共享)
508
+ unisolateRow(sessionId, table, id) {
509
+ const row = this.structuredDb
510
+ .prepare(`UPDATE ${table} SET isolation = 0 WHERE id = ? AND session_id = ? RETURNING id`)
511
+ .get(id, sessionId);
512
+ return row !== undefined;
513
+ }
514
+ getLatestProposed(sessionId) {
515
+ return this.structuredDb
516
+ .prepare(`SELECT * FROM decisions WHERE session_id = ? AND status = 'proposed' ORDER BY id DESC LIMIT 1`)
517
+ .get(sessionId);
518
+ }
519
+ getDecisions(sessionId, status) {
520
+ if (status) {
521
+ return this.structuredDb
522
+ .prepare(`SELECT * FROM decisions WHERE session_id = ? AND status = ? ORDER BY id`)
523
+ .all(sessionId, status);
524
+ }
525
+ return this.structuredDb.prepare(`SELECT * FROM decisions WHERE session_id = ? ORDER BY id`).all(sessionId);
526
+ }
527
+ // 血缘分库(B④):任一端 ∈ {goal,decision,feedback} → 结构化库;两端均 ∈ {event,file,tool} → 事件库
528
+ lineageDb(srcType, dstType) {
529
+ const srcEvent = srcType === "event" || srcType === "file" || srcType === "tool";
530
+ const dstEvent = dstType === "event" || dstType === "file" || dstType === "tool";
531
+ return srcEvent && dstEvent ? this.eventsDb : this.structuredDb;
532
+ }
533
+ addLineageEdge(sessionId, srcType, srcId, dstType, dstId, edgeType, opts = {}) {
534
+ const db = this.lineageDb(srcType, dstType);
535
+ db
536
+ .prepare(`INSERT INTO lineage_edges (session_id, src_type, src_id, dst_type, dst_id, ref, edge_type, confidence, ts)
537
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
538
+ .run(sessionId, srcType, srcId, dstType, dstId, opts.ref ?? null, edgeType, opts.confidence ?? 1.0, opts.ts ?? new Date().toISOString());
539
+ }
540
+ getRelatedEvents(sessionId, eventId) {
541
+ return this.getRelatedEdges(sessionId, "event", eventId);
542
+ }
543
+ getRelatedEdges(sessionId, type, id) {
544
+ const db = type === "event" || type === "file" || type === "tool" ? this.eventsDb : this.structuredDb;
545
+ return db
546
+ .prepare(`SELECT * FROM lineage_edges
547
+ WHERE session_id = ? AND (src_type = ? AND src_id = ? OR dst_type = ? AND dst_id = ?)
548
+ ORDER BY id`)
549
+ .all(sessionId, type, id, type, id);
550
+ }
551
+ getEventsForFile(sessionId, filePath) {
552
+ return this.eventsDb
553
+ .prepare(`SELECT * FROM lineage_edges
554
+ WHERE session_id = ? AND dst_type = 'file' AND ref = ?
555
+ ORDER BY id`)
556
+ .all(sessionId, filePath);
557
+ }
558
+ transitionDecision(decision, to, ts, supersededBy) {
559
+ assertTransition(decision.status, to);
560
+ const updatedAt = ts ?? new Date().toISOString();
561
+ return this.structuredDb
562
+ .prepare(`UPDATE decisions SET status = ?, superseded_by = ?, updated_at = ? WHERE id = ? RETURNING *`)
563
+ .get(to, supersededBy ?? decision.superseded_by, updatedAt, decision.id);
564
+ }
565
+ nextSeq(sessionId) {
566
+ const row = this.eventsDb
567
+ .prepare(`SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM events WHERE session_id = ?`)
568
+ .get(sessionId);
569
+ return row.seq;
570
+ }
571
+ updateEpisode(sessionId, seq, kind, ts) {
572
+ if (kind === "user_message") {
573
+ this.closeActiveEpisode(sessionId, seq - 1);
574
+ this.eventsDb
575
+ .prepare(`INSERT INTO episodes (session_id, seq_start, status, created_at) VALUES (?, ?, 'active', ?)`)
576
+ .run(sessionId, seq, ts);
577
+ }
578
+ else {
579
+ this.eventsDb
580
+ .prepare(`UPDATE episodes SET seq_end = ? WHERE session_id = ? AND status = 'active'`)
581
+ .run(seq, sessionId);
582
+ }
583
+ }
584
+ closeActiveEpisode(sessionId, seqEnd) {
585
+ if (seqEnd <= 0) {
586
+ return;
587
+ }
588
+ const episode = this.getActiveEpisode(sessionId);
589
+ if (!episode) {
590
+ return;
591
+ }
592
+ const rows = this.eventsDb
593
+ .prepare(`SELECT body FROM events WHERE session_id = ? AND seq BETWEEN ? AND ? ORDER BY seq`)
594
+ .all(sessionId, episode.seq_start, seqEnd);
595
+ const summary = rows
596
+ .map((r) => r.body)
597
+ .join("\n")
598
+ .slice(0, EPISODE_SUMMARY_CHARS);
599
+ this.eventsDb
600
+ .prepare(`UPDATE episodes SET seq_end = ?, summary = COALESCE(summary, ?) WHERE id = ? AND session_id = ?`)
601
+ .run(seqEnd, summary, episode.id, sessionId);
602
+ }
603
+ }
604
+ function openDb(path, schema, kind) {
605
+ const db = new Database(path);
606
+ db.pragma("journal_mode = WAL");
607
+ db.pragma("busy_timeout = 5000");
608
+ db.exec(schema);
609
+ ensureSchema(db, kind);
610
+ return db;
611
+ }
612
+ function isToolKind(kind) {
613
+ return kind === "tool_call" || kind === "tool_result";
614
+ }
615
+ function toFtsQuery(query) {
616
+ const tokens = cjkSpace(query).split(/\s+/).filter(Boolean);
617
+ if (tokens.length === 0) {
618
+ return "";
619
+ }
620
+ return tokens.map((t) => `"${t.replaceAll('"', '""')}"`).join(" AND ");
621
+ }
622
+ const EPISODE_SUMMARY_CHARS = 4000;
623
+ function safeParse(text) {
624
+ try {
625
+ return JSON.parse(text);
626
+ }
627
+ catch {
628
+ return undefined;
629
+ }
630
+ }
631
+ function sha256Hex(text) {
632
+ return createHash("sha256").update(text).digest("hex");
633
+ }
634
+ function cjkSpace(text) {
635
+ return text
636
+ .replace(/([\u3400-\u9fff\uf900-\ufaff])/g, " $1 ")
637
+ .replace(/\s+/g, " ")
638
+ .trim();
639
+ }
640
+ // 分层优先级裁决(B③):同事实(归一化 text)跨层级出现时保留最高优先级 会话内>项目>用户>全局
641
+ const SCOPE_PRIORITY = { session: 3, project: 2, global: 1 };
642
+ export function applyScopePriority(rows) {
643
+ const best = new Map();
644
+ for (const row of rows) {
645
+ const key = normalizeText(row.text);
646
+ if (!key) {
647
+ continue;
648
+ }
649
+ const existing = best.get(key);
650
+ const prio = SCOPE_PRIORITY[row.scope ?? "project"] ?? 0;
651
+ const existingPrio = existing ? (SCOPE_PRIORITY[existing.scope ?? "project"] ?? 0) : -1;
652
+ if (!existing || prio > existingPrio) {
653
+ best.set(key, row);
654
+ }
655
+ }
656
+ return [...best.values()];
657
+ }
658
+ function normalizeText(text) {
659
+ return text
660
+ .replace(/\s+/g, "")
661
+ .replace(/[,。!!??、;;::,.·'"“”‘’()()[\]【】<>《》]/g, "")
662
+ .toLowerCase();
663
+ }
664
+ //# sourceMappingURL=store.js.map