@thread-memory/core 0.1.1 → 1.0.1

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 (69) hide show
  1. package/dist/assets.d.ts +14 -0
  2. package/dist/assets.js +104 -0
  3. package/dist/assets.js.map +1 -0
  4. package/dist/assets.test.d.ts +1 -0
  5. package/dist/assets.test.js +88 -0
  6. package/dist/assets.test.js.map +1 -0
  7. package/dist/behavior-contract.d.ts +2 -0
  8. package/dist/behavior-contract.js +9 -0
  9. package/dist/behavior-contract.js.map +1 -0
  10. package/dist/closing.d.ts +11 -0
  11. package/dist/closing.js +37 -0
  12. package/dist/closing.js.map +1 -0
  13. package/dist/delta.d.ts +24 -0
  14. package/dist/delta.js +55 -0
  15. package/dist/delta.js.map +1 -0
  16. package/dist/index.d.ts +6 -0
  17. package/dist/index.js +6 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/isolation.test.js +3 -5
  20. package/dist/isolation.test.js.map +1 -1
  21. package/dist/light-confirm.d.ts +3 -21
  22. package/dist/light-confirm.js +80 -97
  23. package/dist/light-confirm.js.map +1 -1
  24. package/dist/light-confirm.test.js +185 -103
  25. package/dist/light-confirm.test.js.map +1 -1
  26. package/dist/lineage.test.js +10 -12
  27. package/dist/lineage.test.js.map +1 -1
  28. package/dist/nav.d.ts +26 -0
  29. package/dist/nav.js +139 -0
  30. package/dist/nav.js.map +1 -0
  31. package/dist/nav.test.d.ts +1 -0
  32. package/dist/nav.test.js +100 -0
  33. package/dist/nav.test.js.map +1 -0
  34. package/dist/query-tool.d.ts +20 -0
  35. package/dist/query-tool.js +61 -0
  36. package/dist/query-tool.js.map +1 -0
  37. package/dist/query.test.js +8 -5
  38. package/dist/query.test.js.map +1 -1
  39. package/dist/schema.d.ts +3 -1
  40. package/dist/schema.js +104 -1
  41. package/dist/schema.js.map +1 -1
  42. package/dist/schema.test.js +90 -3
  43. package/dist/schema.test.js.map +1 -1
  44. package/dist/sediment.test.d.ts +1 -0
  45. package/dist/sediment.test.js +89 -0
  46. package/dist/sediment.test.js.map +1 -0
  47. package/dist/segment.d.ts +3 -0
  48. package/dist/segment.js +84 -0
  49. package/dist/segment.js.map +1 -0
  50. package/dist/segment.test.d.ts +1 -0
  51. package/dist/segment.test.js +37 -0
  52. package/dist/segment.test.js.map +1 -0
  53. package/dist/state-delta.test.d.ts +1 -0
  54. package/dist/state-delta.test.js +90 -0
  55. package/dist/state-delta.test.js.map +1 -0
  56. package/dist/status-card.d.ts +7 -0
  57. package/dist/status-card.js +108 -3
  58. package/dist/status-card.js.map +1 -1
  59. package/dist/status-card.test.js +76 -3
  60. package/dist/status-card.test.js.map +1 -1
  61. package/dist/store.d.ts +121 -11
  62. package/dist/store.js +425 -61
  63. package/dist/store.js.map +1 -1
  64. package/dist/store.test.js +120 -10
  65. package/dist/store.test.js.map +1 -1
  66. package/dist/v7.test.d.ts +1 -0
  67. package/dist/v7.test.js +106 -0
  68. package/dist/v7.test.js.map +1 -0
  69. package/package.json +2 -1
package/dist/store.js CHANGED
@@ -2,7 +2,8 @@ import Database from "better-sqlite3";
2
2
  import { createHash } from "node:crypto";
3
3
  import { MAX_BODY_CHARS, truncateBody } from "./events.js";
4
4
  import { assertTransition } from "./state.js";
5
- import { ensureSchema } from "./schema.js";
5
+ import { ensureSchema, EVENTS_FTS_DDL, EVENTS_FTS_TRI_DDL } from "./schema.js";
6
+ import { segment, segmentQuery } from "./segment.js";
6
7
  import { SpillPolicy, isIndexable } from "./governor.js";
7
8
  export const EVENTS_SCHEMA = `
8
9
  CREATE TABLE IF NOT EXISTS events (
@@ -22,12 +23,9 @@ CREATE TABLE IF NOT EXISTS events (
22
23
  );
23
24
  CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, seq);
24
25
 
25
- CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
26
- body,
27
- content='events',
28
- content_rowid='id',
29
- tokenize='unicode61'
30
- );
26
+ ${EVENTS_FTS_DDL}
27
+
28
+ ${EVENTS_FTS_TRI_DDL}
31
29
 
32
30
  CREATE TABLE IF NOT EXISTS episodes (
33
31
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -77,6 +75,7 @@ CREATE TABLE IF NOT EXISTS goals (
77
75
  status TEXT NOT NULL DEFAULT 'active',
78
76
  source_event INTEGER,
79
77
  created_at TEXT NOT NULL,
78
+ updated_at TEXT,
80
79
  project_key TEXT,
81
80
  scope TEXT NOT NULL DEFAULT 'project',
82
81
  origin TEXT,
@@ -107,6 +106,7 @@ CREATE TABLE IF NOT EXISTS feedback (
107
106
  kind TEXT NOT NULL DEFAULT 'preference',
108
107
  source_event INTEGER,
109
108
  created_at TEXT NOT NULL,
109
+ updated_at TEXT,
110
110
  project_key TEXT,
111
111
  scope TEXT NOT NULL DEFAULT 'project',
112
112
  origin TEXT,
@@ -161,6 +161,56 @@ CREATE TABLE IF NOT EXISTS metrics (
161
161
  value REAL NOT NULL,
162
162
  ts TEXT NOT NULL
163
163
  );
164
+
165
+ CREATE TABLE IF NOT EXISTS pending_candidates (
166
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
167
+ session_id TEXT NOT NULL,
168
+ text TEXT NOT NULL,
169
+ kind TEXT NOT NULL DEFAULT 'preference',
170
+ status TEXT NOT NULL DEFAULT 'pending',
171
+ source_event INTEGER,
172
+ created_at TEXT NOT NULL,
173
+ updated_at TEXT,
174
+ prompt_count INTEGER NOT NULL DEFAULT 0,
175
+ last_prompt_ts TEXT,
176
+ project_key TEXT,
177
+ isolation INTEGER NOT NULL DEFAULT 0
178
+ );
179
+ CREATE INDEX IF NOT EXISTS idx_pending_project ON pending_candidates(project_key, status);
180
+ CREATE INDEX IF NOT EXISTS idx_pending_session ON pending_candidates(session_id, status);
181
+
182
+ CREATE TABLE IF NOT EXISTS knowledge_assets (
183
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
184
+ path TEXT NOT NULL,
185
+ title TEXT NOT NULL,
186
+ topic TEXT,
187
+ scenario TEXT,
188
+ session_id TEXT NOT NULL,
189
+ source_event INTEGER,
190
+ created_at TEXT NOT NULL,
191
+ isolation INTEGER NOT NULL DEFAULT 0
192
+ );
193
+ CREATE INDEX IF NOT EXISTS idx_assets_session ON knowledge_assets(session_id, created_at);
194
+ CREATE INDEX IF NOT EXISTS idx_assets_visible ON knowledge_assets(isolation, created_at);
195
+
196
+ CREATE TABLE IF NOT EXISTS todos (
197
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
198
+ session_id TEXT NOT NULL,
199
+ text TEXT NOT NULL,
200
+ basis TEXT,
201
+ status TEXT NOT NULL DEFAULT 'pending',
202
+ created_at TEXT NOT NULL,
203
+ project_key TEXT,
204
+ isolation INTEGER NOT NULL DEFAULT 0
205
+ );
206
+ CREATE INDEX IF NOT EXISTS idx_todos_project ON todos(project_key, status);
207
+ CREATE INDEX IF NOT EXISTS idx_todos_session ON todos(session_id, status);
208
+
209
+ CREATE TABLE IF NOT EXISTS thread_meta (
210
+ key TEXT PRIMARY KEY,
211
+ value TEXT NOT NULL,
212
+ updated_at TEXT NOT NULL
213
+ );
164
214
  `;
165
215
  export class ThreadStore {
166
216
  eventsDb;
@@ -214,6 +264,219 @@ export class ThreadStore {
214
264
  this.eventsDb.close();
215
265
  this.structuredDb.close();
216
266
  }
267
+ // ─── 轻确认候选(§1.5.3d):规则粗筛暂存,未确认绝不进正式表 ───
268
+ addPendingCandidate(input) {
269
+ const now = new Date().toISOString();
270
+ const row = this.structuredDb
271
+ .prepare(`INSERT INTO pending_candidates (session_id, text, kind, status, source_event, created_at, updated_at, project_key, isolation)
272
+ VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?) RETURNING *`)
273
+ .get(input.sessionId, input.text, input.kind, input.sourceEvent ?? null, now, now, input.projectKey ?? null, input.isolation ? 1 : 0);
274
+ return row;
275
+ }
276
+ listPendingCandidates(opts = {}) {
277
+ const where = ["status = 'pending'"];
278
+ const params = [];
279
+ if (opts.projectKey) {
280
+ where.push("project_key = ?");
281
+ params.push(opts.projectKey);
282
+ }
283
+ else if (opts.sessionId) {
284
+ where.push("session_id = ?");
285
+ params.push(opts.sessionId);
286
+ }
287
+ return this.structuredDb
288
+ .prepare(`SELECT * FROM pending_candidates WHERE ${where.join(" AND ")} ORDER BY id DESC LIMIT 20`)
289
+ .all(...params);
290
+ }
291
+ confirmCandidate(id) {
292
+ return this.structuredDb
293
+ .prepare(`UPDATE pending_candidates SET status = 'confirmed', updated_at = ? WHERE id = ? AND status = 'pending' RETURNING *`)
294
+ .get(new Date().toISOString(), id);
295
+ }
296
+ ignoreCandidate(id) {
297
+ return this.structuredDb
298
+ .prepare(`UPDATE pending_candidates SET status = 'ignored', updated_at = ? WHERE id = ? AND status = 'pending' RETURNING *`)
299
+ .get(new Date().toISOString(), id);
300
+ }
301
+ // /thread-pending cancel-all(1.3):批量丢弃 pending 候选
302
+ ignoreAllPendingCandidates(opts = {}) {
303
+ const now = new Date().toISOString();
304
+ if (opts.projectKey) {
305
+ const info = this.structuredDb
306
+ .prepare(`UPDATE pending_candidates SET status = 'ignored', updated_at = ? WHERE status = 'pending' AND project_key = ?`)
307
+ .run(now, opts.projectKey);
308
+ return info.changes;
309
+ }
310
+ if (opts.sessionId) {
311
+ const info = this.structuredDb
312
+ .prepare(`UPDATE pending_candidates SET status = 'ignored', updated_at = ? WHERE status = 'pending' AND session_id = ?`)
313
+ .run(now, opts.sessionId);
314
+ return info.changes;
315
+ }
316
+ const info = this.structuredDb.prepare(`UPDATE pending_candidates SET status = 'ignored', updated_at = ? WHERE status = 'pending'`).run(now);
317
+ return info.changes;
318
+ }
319
+ markCandidatePrompted(id) {
320
+ this.structuredDb
321
+ .prepare(`UPDATE pending_candidates SET prompt_count = prompt_count + 1, last_prompt_ts = ?, updated_at = ? WHERE id = ?`)
322
+ .run(new Date().toISOString(), new Date().toISOString(), id);
323
+ }
324
+ // 候选超时(2026-08-20 收口修订):pending 候选超龄 → ignored(原文仍在事件流水 + 行保留,决策不丢)。
325
+ // 覆盖两类:被提示过(last_prompt_ts 超龄)与从未被提示(created_at 超龄,headless 无弹窗堆积场景)
326
+ expireCandidates(opts) {
327
+ const where = `status = 'pending' AND COALESCE(last_prompt_ts, created_at) < ?`;
328
+ if (opts.projectKey) {
329
+ const info = this.structuredDb
330
+ .prepare(`UPDATE pending_candidates SET status = 'ignored', updated_at = ? WHERE ${where} AND project_key = ?`)
331
+ .run(new Date().toISOString(), opts.before, opts.projectKey);
332
+ return info.changes;
333
+ }
334
+ const info = this.structuredDb
335
+ .prepare(`UPDATE pending_candidates SET status = 'ignored', updated_at = ? WHERE ${where}`)
336
+ .run(new Date().toISOString(), opts.before);
337
+ return info.changes;
338
+ }
339
+ pendingCount(opts = {}) {
340
+ const where = ["status = 'pending'"];
341
+ const params = [];
342
+ if (opts.projectKey) {
343
+ where.push("project_key = ?");
344
+ params.push(opts.projectKey);
345
+ }
346
+ else if (opts.sessionId) {
347
+ where.push("session_id = ?");
348
+ params.push(opts.sessionId);
349
+ }
350
+ const row = this.structuredDb
351
+ .prepare(`SELECT COUNT(*) AS c FROM pending_candidates WHERE ${where.join(" AND ")}`)
352
+ .get(...params);
353
+ return row.c;
354
+ }
355
+ // ─── 产出/待办/水位(v7,MAX 批 1 地基)───
356
+ // 产出登记 + 写时建边(单事务):produces(session→asset) + references(asset→source_event)。
357
+ // 与事件 append 跨库(events/structured 双库),无法同 SQLite 事务——事件已由 origin 幂等,
358
+ // 资产登记为结构化库内原子事务;调用方失败重试同事件采集(旁路可失败不阻塞)。
359
+ registerAsset(input) {
360
+ const ts = input.ts ?? new Date().toISOString();
361
+ const tx = this.structuredDb.transaction(() => {
362
+ // 路径幂等(2026-08-21 狗粮实证):同一文件每次 write/edit 都会触发登记 → 同 path 重复堆行
363
+ //(生产 README 同 path 12 行)。可见范围(isolation=0 或本会话)内已有同 path 行 →
364
+ // 只刷新标题并返回既有行,不再插入;血缘边在首次登记时已建立。
365
+ const existing = this.structuredDb
366
+ .prepare(`SELECT * FROM knowledge_assets WHERE path = ? AND (isolation = 0 OR session_id = ?) ORDER BY id LIMIT 1`)
367
+ .get(input.path, input.sessionId);
368
+ if (existing) {
369
+ if (existing.title !== input.title) {
370
+ this.structuredDb.prepare(`UPDATE knowledge_assets SET title = ? WHERE id = ?`).run(input.title, existing.id);
371
+ existing.title = input.title;
372
+ }
373
+ return existing;
374
+ }
375
+ const row = this.structuredDb
376
+ .prepare(`INSERT INTO knowledge_assets (path, title, topic, scenario, session_id, source_event, created_at, isolation)
377
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`)
378
+ .get(input.path, input.title, input.topic ?? null, input.scenario ?? null, input.sessionId, input.sourceEvent ?? null, ts, input.isolation ? 1 : 0);
379
+ this.addLineageEdge(input.sessionId, "session", null, "asset", row.id, "produces", {
380
+ ref: input.sessionId,
381
+ ts,
382
+ });
383
+ if (input.sourceEvent) {
384
+ this.addLineageEdge(input.sessionId, "asset", row.id, "event", input.sourceEvent, "references", { ts });
385
+ }
386
+ return row;
387
+ });
388
+ return tx();
389
+ }
390
+ listAssets(opts = {}) {
391
+ const where = [];
392
+ const params = [];
393
+ if (opts.sessionId) {
394
+ where.push("session_id = ?");
395
+ params.push(opts.sessionId);
396
+ }
397
+ if (opts.visibleToSession) {
398
+ where.push("(isolation = 0 OR session_id = ?)");
399
+ params.push(opts.visibleToSession);
400
+ }
401
+ const sql = `SELECT * FROM knowledge_assets${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY id DESC LIMIT ${opts.limit ?? 50}`;
402
+ return this.structuredDb.prepare(sql).all(...params);
403
+ }
404
+ getAsset(id) {
405
+ return this.structuredDb.prepare(`SELECT * FROM knowledge_assets WHERE id = ?`).get(id);
406
+ }
407
+ // 产出解除登记(2026-08-21 /thread-rev ast):硬删除 + 血缘边清理(双库:session→asset produces 在结构化库,
408
+ // asset→event references 在事件库)——事件流水保留原文,只解除结构化登记。
409
+ deleteAsset(id) {
410
+ const exists = this.structuredDb.prepare(`SELECT id FROM knowledge_assets WHERE id = ?`).get(id);
411
+ if (!exists) {
412
+ return false;
413
+ }
414
+ this.structuredDb.prepare(`DELETE FROM knowledge_assets WHERE id = ?`).run(id);
415
+ for (const db of [this.structuredDb, this.eventsDb]) {
416
+ db
417
+ .prepare(`DELETE FROM lineage_edges WHERE (src_type = 'asset' AND src_id = ?) OR (dst_type = 'asset' AND dst_id = ?)`)
418
+ .run(id, id);
419
+ }
420
+ return true;
421
+ }
422
+ // 发现层(2.4):最近 N 个非隔离、有产出的会话(各带最新产出标题/时间)
423
+ listActiveSessionsWithAssets(limit) {
424
+ return this.structuredDb
425
+ .prepare(`SELECT a.session_id, a.title AS latest_title, a.created_at AS latest_ts
426
+ FROM knowledge_assets a
427
+ JOIN (
428
+ SELECT session_id, MAX(created_at) AS m FROM knowledge_assets WHERE isolation = 0 GROUP BY session_id
429
+ ) b ON a.session_id = b.session_id AND a.created_at = b.m AND a.isolation = 0
430
+ ORDER BY a.created_at DESC
431
+ LIMIT ?`)
432
+ .all(limit);
433
+ }
434
+ addTodo(input) {
435
+ const row = this.structuredDb
436
+ .prepare(`INSERT INTO todos (session_id, text, basis, status, created_at, project_key, isolation)
437
+ VALUES (?, ?, ?, 'pending', ?, ?, ?) RETURNING *`)
438
+ .get(input.sessionId, input.text, input.basis ?? null, input.ts ?? new Date().toISOString(), input.projectKey ?? null, input.isolation ? 1 : 0);
439
+ return row;
440
+ }
441
+ listTodos(opts = {}) {
442
+ const where = [];
443
+ const params = [];
444
+ if (opts.sessionId) {
445
+ where.push("session_id = ?");
446
+ params.push(opts.sessionId);
447
+ }
448
+ if (opts.projectKey) {
449
+ where.push("project_key = ?");
450
+ params.push(opts.projectKey);
451
+ }
452
+ if (opts.status) {
453
+ where.push("status = ?");
454
+ params.push(opts.status);
455
+ }
456
+ if (opts.basis !== undefined) {
457
+ where.push("basis = ?");
458
+ params.push(opts.basis);
459
+ }
460
+ if (opts.visibleToSession) {
461
+ where.push("(isolation = 0 OR session_id = ?)");
462
+ params.push(opts.visibleToSession);
463
+ }
464
+ const sql = `SELECT * FROM todos${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY id DESC LIMIT ${opts.limit ?? 100}`;
465
+ return this.structuredDb.prepare(sql).all(...params);
466
+ }
467
+ updateTodoStatus(id, status) {
468
+ const info = this.structuredDb.prepare(`UPDATE todos SET status = ? WHERE id = ?`).run(status, id);
469
+ return info.changes > 0;
470
+ }
471
+ getMeta(key) {
472
+ const row = this.structuredDb.prepare(`SELECT value FROM thread_meta WHERE key = ?`).get(key);
473
+ return row?.value;
474
+ }
475
+ setMeta(key, value) {
476
+ this.structuredDb
477
+ .prepare(`INSERT INTO thread_meta (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
478
+ .run(key, value, new Date().toISOString());
479
+ }
217
480
  append(event, opts = {}) {
218
481
  return this.eventsDb.transaction(() => {
219
482
  if (opts.origin) {
@@ -247,7 +510,8 @@ export class ThreadStore {
247
510
  .run(eventId.id, spill.ref ?? `spill:${eventId.id}`, body, sha256, event.ts);
248
511
  }
249
512
  if (isIndexable(event.kind)) {
250
- this.eventsDb.prepare(`INSERT INTO events_fts(rowid, body) VALUES (?, ?)`).run(eventId.id, cjkSpace(storedBody));
513
+ this.eventsDb.prepare(`INSERT INTO events_fts(rowid, body_seg) VALUES (?, ?)`).run(eventId.id, segment(storedBody));
514
+ this.eventsDb.prepare(`INSERT INTO events_fts_tri(rowid, body) VALUES (?, ?)`).run(eventId.id, storedBody);
251
515
  }
252
516
  const eventMeta = event.meta;
253
517
  if (typeof eventMeta?.file_path === "string") {
@@ -282,15 +546,26 @@ export class ThreadStore {
282
546
  return this.structuredDb.transaction(fn)();
283
547
  }
284
548
  search(query, opts = {}) {
285
- const ftsQuery = toFtsQuery(query);
286
- if (!ftsQuery) {
549
+ const hits = this.runFtsSearch(query, opts, "seg");
550
+ if (hits.length > 0) {
551
+ return hits;
552
+ }
553
+ // 0-e 定案第 5 条(trigram 子串召回兜底):主路径 0 命中(jieba 分词失败 /
554
+ // "用户只记得半句"——查询是正文 token 的连续子串)时,用 trigram 表
555
+ // 短语匹配召回连续字符序列;隔离/会话过滤语义与主路径一致。
556
+ return this.runFtsSearch(query, opts, "tri");
557
+ }
558
+ runFtsSearch(query, opts, kind) {
559
+ const match = kind === "tri" ? toTrigramMatch(query) : toFtsMatch(query);
560
+ if (!match) {
287
561
  return [];
288
562
  }
563
+ const ftsTable = kind === "tri" ? "events_fts_tri" : "events_fts";
289
564
  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];
565
+ SELECT e.id, e.session_id, e.kind, e.ts, e.seq, e.body, e.truncated, bm25(${ftsTable}) AS score
566
+ FROM ${ftsTable} f JOIN events e ON e.id = f.rowid
567
+ WHERE ${ftsTable} MATCH ?`;
568
+ const params = [match];
294
569
  if (opts.sessionId) {
295
570
  // 当前会话可见自己的全部内容(含隔离),其他会话只见未隔离行
296
571
  sql += ` AND (e.session_id = ? OR e.isolation = 0)`;
@@ -299,7 +574,14 @@ export class ThreadStore {
299
574
  else {
300
575
  sql += ` AND e.isolation = 0`;
301
576
  }
302
- sql += ` ORDER BY score LIMIT ?`;
577
+ if (kind === "tri") {
578
+ // 兜底路径:trigram 打分区分度差,纯时间衰减排序(近期优先)
579
+ sql += ` ORDER BY e.ts DESC LIMIT ?`;
580
+ }
581
+ else {
582
+ // bm25 负值越小越相关(ASC);时间衰减 = 同分近期优先(ts DESC 二级排序,零成本先上,深调留 B⑤ 度量)
583
+ sql += ` ORDER BY score ASC, e.ts DESC LIMIT ?`;
584
+ }
303
585
  params.push(opts.limit ?? 20);
304
586
  return this.eventsDb.prepare(sql).all(...params);
305
587
  }
@@ -320,9 +602,9 @@ export class ThreadStore {
320
602
  }
321
603
  const ts = opts.ts ?? new Date().toISOString();
322
604
  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);
605
+ .prepare(`INSERT INTO goals (session_id, text, status, source_event, created_at, updated_at, project_key, scope, origin, isolation)
606
+ VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?) RETURNING *`)
607
+ .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);
326
608
  if (opts.sourceEvent) {
327
609
  this.addLineageEdge(sessionId, "goal", goal.id, "event", opts.sourceEvent, "derived_from", {
328
610
  ts,
@@ -335,6 +617,12 @@ export class ThreadStore {
335
617
  .prepare(`SELECT * FROM goals WHERE session_id = ? AND status = 'active' ORDER BY id`)
336
618
  .all(sessionId);
337
619
  }
620
+ // 目标全量(2026-08-21 /thread-reg gol 无参列表的 id 可见来源):含 active/completed/abandoned
621
+ getGoals(sessionId) {
622
+ return this.structuredDb
623
+ .prepare(`SELECT * FROM goals WHERE session_id = ? ORDER BY id DESC`)
624
+ .all(sessionId);
625
+ }
338
626
  getFeedback(sessionId, limit = 5) {
339
627
  return this.structuredDb
340
628
  .prepare(`SELECT * FROM feedback WHERE session_id = ? ORDER BY id DESC LIMIT ?`)
@@ -351,9 +639,17 @@ export class ThreadStore {
351
639
  }));
352
640
  }
353
641
  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);
642
+ const goal = this.structuredDb
643
+ .prepare(`UPDATE goals SET status = ?, updated_at = ? WHERE id = ? AND session_id = ? RETURNING *`)
644
+ .get(status, new Date().toISOString(), goalId, sessionId);
645
+ // 目标 → 待办生命周期同步(2026-08-20 关闭即沉淀):收尾沉淀产生的 todo 随目标状态自愈
646
+ if (goal) {
647
+ const todoStatus = status === "completed" ? "done" : status === "abandoned" ? "dropped" : "pending";
648
+ this.structuredDb
649
+ .prepare(`UPDATE todos SET status = ? WHERE session_id = ? AND basis = ? AND status = 'pending'`)
650
+ .run(todoStatus, sessionId, `goal:${goalId}`);
651
+ }
652
+ return goal;
357
653
  }
358
654
  addFeedback(sessionId, text, kind, opts = {}) {
359
655
  const existing = this.findByOrigin("feedback", opts.origin);
@@ -362,9 +658,9 @@ export class ThreadStore {
362
658
  }
363
659
  const ts = opts.ts ?? new Date().toISOString();
364
660
  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);
661
+ .prepare(`INSERT INTO feedback (session_id, text, kind, source_event, created_at, updated_at, project_key, scope, origin, isolation)
662
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`)
663
+ .get(sessionId, text, kind, opts.sourceEvent ?? null, ts, ts, opts.scope === "global" ? null : opts.projectKey ?? this.projectKey ?? null, opts.scope ?? "project", opts.origin ?? null, opts.isolation ? 1 : 0);
368
664
  }
369
665
  // 反馈治理(B⑥-② 恢复通道):按 id 删除反馈行——教训可删即恢复;事件流水保留(真相源不变)。
370
666
  // 单用户语义:跨会话行亦可删(治理动作由用户/其代理发起)。
@@ -404,40 +700,67 @@ export class ThreadStore {
404
700
  .prepare(`SELECT * FROM ${table} WHERE origin = ? LIMIT 1`)
405
701
  .get(origin);
406
702
  }
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) {
703
+ // ─── 显式决策通道(2026-08-21 结构通道化)───
704
+ // NL 判定停用后,决策只经三条显式路径:/thread-decision(用户命令)、record_decision(模型工具)、
705
+ // /thread-pending update(候选转正)。全部直接落 active——显式意图不再走 propose→confirm 两段。
706
+ // 命令/工具创建:origin/文本双幂等沿用 proposeDecision,插入即转 active
707
+ addDecision(sessionId, text, opts = {}) {
708
+ const d = this.proposeDecision(sessionId, text, opts);
709
+ return d.status === "active" ? d : this.transitionDecision(d, "active", opts.ts);
710
+ }
711
+ // 候选转正(修 1.3 语义缺口):旧 confirm 只翻候选状态、从不落 decisions 行 = 死路。
712
+ // update <id> [新文本] → 候选 → active 决策;候选 session/项目/隔离/血缘随行,文本可先修正。
713
+ promoteCandidate(id, newText) {
714
+ const candidate = this.structuredDb
715
+ .prepare(`SELECT * FROM pending_candidates WHERE id = ? AND status = 'pending'`)
716
+ .get(id);
717
+ if (!candidate) {
412
718
  return undefined;
413
719
  }
414
- return this.transitionDecision(target, "active", opts.ts);
720
+ const text = (newText ?? "").trim() || candidate.text;
721
+ const decision = this.addDecision(candidate.session_id, text, {
722
+ projectKey: candidate.project_key ?? undefined,
723
+ isolation: candidate.isolation === 1,
724
+ sourceEvent: candidate.source_event ?? undefined,
725
+ });
726
+ this.structuredDb
727
+ .prepare(`UPDATE pending_candidates SET status = 'confirmed', updated_at = ? WHERE id = ?`)
728
+ .run(new Date().toISOString(), id);
729
+ return decision;
415
730
  }
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;
731
+ // 决策删除(feedback-del 同风格):硬删除 + 血缘边(双库)/决策-实体边/superseded_by 引用连带清理。
732
+ // 事件流水保留原文 = 真相源不变,删除只影响结构化表视图。
733
+ deleteDecision(id) {
734
+ const exists = this.structuredDb.prepare(`SELECT id FROM decisions WHERE id = ?`).get(id);
735
+ if (!exists) {
736
+ return false;
422
737
  }
423
- return this.transitionDecision(target, "revoked", opts.ts);
738
+ this.structuredDb.prepare(`DELETE FROM decisions WHERE id = ?`).run(id);
739
+ this.structuredDb.prepare(`DELETE FROM decision_entities WHERE decision_id = ?`).run(id);
740
+ for (const db of [this.structuredDb, this.eventsDb]) {
741
+ db
742
+ .prepare(`DELETE FROM lineage_edges WHERE (src_type = 'decision' AND src_id = ?) OR (dst_type = 'decision' AND dst_id = ?)`)
743
+ .run(id, id);
744
+ }
745
+ this.structuredDb.prepare(`UPDATE decisions SET superseded_by = NULL WHERE superseded_by = ?`).run(id);
746
+ return true;
424
747
  }
425
- supersedeLatestActive(sessionId, replacementText, opts = {}) {
748
+ // 按 id 取代(/thread-decision <text> --supersedes <id>):旧决策转 superseded + supersedes 血缘边;
749
+ // replacement 继承目标行的 project_key/scope/isolation(修正旧 latest 版 replacement 丢项目字段的缺陷)。
750
+ supersedeDecisionById(sessionId, id, replacementText, opts = {}) {
426
751
  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();
752
+ .prepare(`SELECT * FROM decisions WHERE id = ? AND session_id = ? AND status IN ('proposed', 'active')`)
753
+ .get(id, sessionId);
430
754
  if (!target) {
431
755
  return undefined;
432
756
  }
757
+ const ts = opts.ts ?? new Date().toISOString();
433
758
  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);
759
+ .prepare(`INSERT INTO decisions (session_id, text, status, superseded_by, source_event, created_at, updated_at, project_key, scope, origin, isolation)
760
+ VALUES (?, ?, 'active', NULL, ?, ?, ?, ?, ?, NULL, ?) RETURNING *`)
761
+ .get(sessionId, replacementText, opts.sourceEvent ?? null, ts, ts, target.project_key ?? null, target.scope ?? "project", target.isolation ?? 0);
437
762
  const superseded = this.transitionDecision(target, "superseded", ts, replacement.id);
438
- this.addLineageEdge(sessionId, "decision", target.id, "decision", replacement.id, "supersedes", {
439
- ts,
440
- });
763
+ this.addLineageEdge(sessionId, "decision", target.id, "decision", replacement.id, "supersedes", { ts });
441
764
  return { superseded, replacement };
442
765
  }
443
766
  getActiveDecisions(sessionId) {
@@ -459,6 +782,25 @@ export class ThreadStore {
459
782
  ORDER BY id`)
460
783
  .all(sessionId, projectKey, sessionId);
461
784
  }
785
+ // 决策变更情境(§1.5.3c 机制 3):项目级最近变更的决策(含 proposed/active,按 updated_at 倒序),
786
+ // 供状态卡"最近决策"块——模型打开会话即见项目刚定的决策,不靠自觉查询。
787
+ getRecentDecisionsMerged(sessionId, projectKey, limit = 3) {
788
+ if (!projectKey) {
789
+ return this.structuredDb
790
+ .prepare(`SELECT * FROM decisions
791
+ WHERE session_id = ? OR isolation = 0
792
+ ORDER BY updated_at DESC, id DESC
793
+ LIMIT ?`)
794
+ .all(sessionId, limit);
795
+ }
796
+ return this.structuredDb
797
+ .prepare(`SELECT * FROM decisions
798
+ WHERE (session_id = ? OR (project_key = ? AND scope = 'project'))
799
+ AND (session_id = ? OR isolation = 0)
800
+ ORDER BY updated_at DESC, id DESC
801
+ LIMIT ?`)
802
+ .all(sessionId, projectKey, sessionId, limit);
803
+ }
462
804
  getActiveGoalsMerged(sessionId, projectKey) {
463
805
  if (!projectKey) {
464
806
  return this.getActiveGoals(sessionId);
@@ -504,17 +846,33 @@ export class ThreadStore {
504
846
  .get(sessionId);
505
847
  return row?.isolated === 1;
506
848
  }
507
- // 沉淀:清除指定结构化行的隔离标记(隔离会话期间产生的决策按需转共享)
849
+ // 沉淀:清除指定结构化行的隔离标记(隔离会话期间产生的行按需转共享;2026-08-21 覆盖 ast 产出)
508
850
  unisolateRow(sessionId, table, id) {
509
851
  const row = this.structuredDb
510
852
  .prepare(`UPDATE ${table} SET isolation = 0 WHERE id = ? AND session_id = ? RETURNING id`)
511
853
  .get(id, sessionId);
512
854
  return row !== undefined;
513
855
  }
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);
856
+ // 隔离行清单(2026-08-21:/thread-pub 无参列表——pub 只对 isolation=1 行生效,此清单即用户可见 id 来源;
857
+ // 2026-08-21 扩展 ast 资源:隔离期登记的产出同样可转共享)
858
+ listIsolatedRows(sessionId) {
859
+ const rows = [];
860
+ const assets = this.structuredDb
861
+ .prepare(`SELECT id, path, title FROM knowledge_assets WHERE session_id = ? AND isolation = 1 ORDER BY id`)
862
+ .all(sessionId);
863
+ for (const r of assets) {
864
+ rows.push({ kind: "ast", id: r.id, text: r.title || r.path });
865
+ }
866
+ for (const table of ["goals", "decisions", "feedback"]) {
867
+ const found = this.structuredDb
868
+ .prepare(`SELECT id, text FROM ${table} WHERE session_id = ? AND isolation = 1 ORDER BY id`)
869
+ .all(sessionId);
870
+ const kind = table === "goals" ? "goal" : table === "decisions" ? "decision" : "feedback";
871
+ for (const r of found) {
872
+ rows.push({ kind, id: r.id, text: r.text });
873
+ }
874
+ }
875
+ return rows;
518
876
  }
519
877
  getDecisions(sessionId, status) {
520
878
  if (status) {
@@ -612,12 +970,24 @@ function openDb(path, schema, kind) {
612
970
  function isToolKind(kind) {
613
971
  return kind === "tool_call" || kind === "tool_result";
614
972
  }
615
- function toFtsQuery(query) {
616
- const tokens = cjkSpace(query).split(/\s+/).filter(Boolean);
973
+ // 主路径 MATCH 表达式:全 OR + BM25 排序(召回优先,精度交给 bm25 打分与时间衰减)。
974
+ // 主表 events_fts 只有 body_seg 一列,无需列限定。
975
+ function toFtsMatch(query) {
976
+ const tokens = segmentQuery(query);
617
977
  if (tokens.length === 0) {
618
978
  return "";
619
979
  }
620
- return tokens.map((t) => `"${t.replaceAll('"', '""')}"`).join(" AND ");
980
+ return tokens.map((t) => `"${t.replaceAll('"', '""')}"`).join(" OR ");
981
+ }
982
+ // trigram 兜底 MATCH 表达式(0-e 定案第 5 条):查询折叠为连续字符序列(去空白/标点),
983
+ // trigram 对 ≥3 字符短语做子串匹配——"用户只记得半句"时主路径 0 命中由它召回。
984
+ // 兜底表 events_fts_tri 的列名是 body,MATCH 直接给短语即可(表内唯一列)。
985
+ function toTrigramMatch(query) {
986
+ const collapsed = query.replace(/\s+/g, "").replace(/[^\p{L}\p{N}]+/gu, "");
987
+ if (collapsed.length < 3) {
988
+ return "";
989
+ }
990
+ return `"${collapsed.replaceAll('"', '""')}"`;
621
991
  }
622
992
  const EPISODE_SUMMARY_CHARS = 4000;
623
993
  function safeParse(text) {
@@ -631,12 +1001,6 @@ function safeParse(text) {
631
1001
  function sha256Hex(text) {
632
1002
  return createHash("sha256").update(text).digest("hex");
633
1003
  }
634
- function cjkSpace(text) {
635
- return text
636
- .replace(/([\u3400-\u9fff\uf900-\ufaff])/g, " $1 ")
637
- .replace(/\s+/g, " ")
638
- .trim();
639
- }
640
1004
  // 分层优先级裁决(B③):同事实(归一化 text)跨层级出现时保留最高优先级 会话内>项目>用户>全局
641
1005
  const SCOPE_PRIORITY = { session: 3, project: 2, global: 1 };
642
1006
  export function applyScopePriority(rows) {