@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.
- package/dist/assets.d.ts +14 -0
- package/dist/assets.js +104 -0
- package/dist/assets.js.map +1 -0
- package/dist/assets.test.d.ts +1 -0
- package/dist/assets.test.js +88 -0
- package/dist/assets.test.js.map +1 -0
- package/dist/behavior-contract.d.ts +2 -0
- package/dist/behavior-contract.js +9 -0
- package/dist/behavior-contract.js.map +1 -0
- package/dist/closing.d.ts +11 -0
- package/dist/closing.js +37 -0
- package/dist/closing.js.map +1 -0
- package/dist/delta.d.ts +24 -0
- package/dist/delta.js +55 -0
- package/dist/delta.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/isolation.test.js +3 -5
- package/dist/isolation.test.js.map +1 -1
- package/dist/light-confirm.d.ts +3 -21
- package/dist/light-confirm.js +80 -97
- package/dist/light-confirm.js.map +1 -1
- package/dist/light-confirm.test.js +185 -103
- package/dist/light-confirm.test.js.map +1 -1
- package/dist/lineage.test.js +10 -12
- package/dist/lineage.test.js.map +1 -1
- package/dist/nav.d.ts +26 -0
- package/dist/nav.js +139 -0
- package/dist/nav.js.map +1 -0
- package/dist/nav.test.d.ts +1 -0
- package/dist/nav.test.js +100 -0
- package/dist/nav.test.js.map +1 -0
- package/dist/query-tool.d.ts +20 -0
- package/dist/query-tool.js +61 -0
- package/dist/query-tool.js.map +1 -0
- package/dist/query.test.js +8 -5
- package/dist/query.test.js.map +1 -1
- package/dist/schema.d.ts +3 -1
- package/dist/schema.js +104 -1
- package/dist/schema.js.map +1 -1
- package/dist/schema.test.js +90 -3
- package/dist/schema.test.js.map +1 -1
- package/dist/sediment.test.d.ts +1 -0
- package/dist/sediment.test.js +89 -0
- package/dist/sediment.test.js.map +1 -0
- package/dist/segment.d.ts +3 -0
- package/dist/segment.js +84 -0
- package/dist/segment.js.map +1 -0
- package/dist/segment.test.d.ts +1 -0
- package/dist/segment.test.js +37 -0
- package/dist/segment.test.js.map +1 -0
- package/dist/state-delta.test.d.ts +1 -0
- package/dist/state-delta.test.js +90 -0
- package/dist/state-delta.test.js.map +1 -0
- package/dist/status-card.d.ts +7 -0
- package/dist/status-card.js +108 -3
- package/dist/status-card.js.map +1 -1
- package/dist/status-card.test.js +76 -3
- package/dist/status-card.test.js.map +1 -1
- package/dist/store.d.ts +121 -11
- package/dist/store.js +425 -61
- package/dist/store.js.map +1 -1
- package/dist/store.test.js +120 -10
- package/dist/store.test.js.map +1 -1
- package/dist/v7.test.d.ts +1 -0
- package/dist/v7.test.js +106 -0
- package/dist/v7.test.js.map +1 -0
- 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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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,
|
|
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
|
|
286
|
-
if (
|
|
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(
|
|
291
|
-
FROM
|
|
292
|
-
WHERE
|
|
293
|
-
const params = [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if (!
|
|
421
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
616
|
-
|
|
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("
|
|
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) {
|