@modusensus/dsh-mneme 0.8.15 → 0.8.16

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 (56) hide show
  1. package/README.md +8 -3
  2. package/bin/cli.mjs +4 -2
  3. package/docs/CONFIGURATION.md +14 -2
  4. package/docs/DISTILL.md +162 -0
  5. package/docs/STORAGE.md +11 -2
  6. package/lib/api-standalone.js +22 -39
  7. package/lib/api.js +171 -3
  8. package/lib/client.js +1065 -156
  9. package/lib/config.js +68 -1
  10. package/lib/dream/decisions.js +20 -0
  11. package/lib/dream.js +2 -0
  12. package/lib/hot-memory.js +62 -0
  13. package/lib/index.js +24 -7
  14. package/lib/inject.js +27 -63
  15. package/lib/maintenance.js +25 -5
  16. package/lib/recall-stats.js +22 -7
  17. package/lib/reclaim-http.js +150 -0
  18. package/lib/safe-slice.js +18 -0
  19. package/lib/service.js +27 -2
  20. package/lib/settings.js +13 -2
  21. package/lib/store.js +196 -7
  22. package/lib/summarize.js +208 -60
  23. package/package.json +1 -1
  24. package/src/api-standalone.js +22 -39
  25. package/src/api.js +171 -3
  26. package/src/config.js +68 -1
  27. package/src/dream/decisions.js +20 -0
  28. package/src/dream.js +2 -0
  29. package/src/hot-memory.js +62 -0
  30. package/src/index.js +24 -7
  31. package/src/inject.js +27 -63
  32. package/src/maintenance.js +25 -5
  33. package/src/recall-stats.js +22 -7
  34. package/src/reclaim-http.js +150 -0
  35. package/src/safe-slice.js +18 -0
  36. package/src/service.js +27 -2
  37. package/src/settings.js +13 -2
  38. package/src/store.js +196 -7
  39. package/src/summarize.js +208 -60
  40. package/test/api-routes-gaps.test.js +681 -0
  41. package/test/api.test.js +25 -2
  42. package/test/client.test.js +354 -2
  43. package/test/contrast.test.js +314 -0
  44. package/test/dream-merge-guardrail.test.js +219 -0
  45. package/test/hot-memory.test.js +67 -1
  46. package/test/inject-recall.test.js +39 -0
  47. package/test/inject.test.js +23 -0
  48. package/test/llm-audit.test.js +98 -0
  49. package/test/maintenance.test.js +112 -2
  50. package/test/pre-inject-gate.test.js +10 -0
  51. package/test/recall-stats.test.js +89 -4
  52. package/test/reclaim-http.test.js +209 -0
  53. package/test/service.test.js +7 -2
  54. package/test/settings.test.js +8 -0
  55. package/test/store.test.js +108 -0
  56. package/test/summarize.test.js +174 -0
@@ -0,0 +1,150 @@
1
+ // 存储回收(maintenance/reclaim)的 HTTP 口径 —— 唯一实现。
2
+ //
3
+ // 为什么要单独一个模块:这张脸有两个入口——宿主内的面板路由(src/api.js 的
4
+ // /api/dsh-mneme/maintenance/reclaim)与 Bearer 数据面(src/api-standalone.js 的
5
+ // /maintenance/reclaim)。两者底下是**同一份** src/maintenance.js,差别只在鉴权与
6
+ // 路径。历史上它们是整段复制的,于是 #410 修好面板侧的上界与兜错之后,数据面还停在
7
+ // 旧形态(#411)——复制粘贴漂移不是设计,是缺陷。
8
+ //
9
+ // 口径(错误码字符串、默认值、报告形状)现在只写在这里,两个入口都调它。这样
10
+ // 「改一边忘一边」在结构上就不可能发生;真要再写第二份,test/reclaim-http.test.js
11
+ // 的静态锁会直接转红。
12
+ //
13
+ // 这里只做协议层的事:解析、校验、把 maintenance.reclaim 的结果包成答复。一行数据
14
+ // 都不改——真正的丢弃逻辑在 src/maintenance.js。
15
+
16
+ /**
17
+ * 回收窗口上界。整数校验挡不住量级:1e20 也是整数,而 maintenance 会拿它算
18
+ * `now() - days * 86400000` 再 toISOString(),抛 RangeError;GET 侧 400 位十进制
19
+ * 字面量还会化成 Infinity,被当成畸形输入静默落回默认窗口 7——调用方要一个巨大
20
+ * 窗口,实际只清了 7 天外的东西,而报告上写着 7 看不出异样。
21
+ *
22
+ * 取值与 config.js 的 llmAudit.retentionDays / recallRetentionDays 同一条线
23
+ * (3650 天 = 10 年):回收窗口比审计保留期还长没有意义,那之后的东西本来也不在。
24
+ */
25
+ export const MAX_RECLAIM_WINDOW_DAYS = 3650;
26
+
27
+ /** 拒执行时的提示。两个入口必须逐字一致——它是给调用方看的下一步动作。 */
28
+ export const RECLAIM_CONFIRM_HINT = "先看 dry-run 报告,确认后带 {\"dryRun\":false,\"confirm\":true} 再发";
29
+
30
+ /**
31
+ * 「维护能力没接线」的错误码。两个入口各有一次**顺序敏感**的显式检查(503 要赢过
32
+ * 400:端点整个没接线时,报「窗口不合法」是误导),所以这条不能只藏在 reclaimReply
33
+ * 里;但也必须是同一份字符串,否则两张皮又能各写各的。
34
+ */
35
+ export const RECLAIM_UNAVAILABLE = "maintenance-unavailable";
36
+
37
+ /** 回收失败的 warn 文案。两条路径共用,日志里不会出现两种拼法。 */
38
+ export function reclaimWarnMessage(error) {
39
+ return `[dsh-mneme] storage reclaim failed: ${String(error)}`;
40
+ }
41
+
42
+ /**
43
+ * 解析 GET 的 ?olderThanDays(searchParams.get 的原始字符串)。
44
+ *
45
+ * query 是字符串域:只收十进制整数字面量,缺省才落默认窗口。**不能用 Number()**
46
+ * ——它把 ""/[]/" " 都化成 0,而 0 在这里是「清掉全部快照」,最坏解释绝不能从一个
47
+ * 畸形输入静默拿到(POST 侧收 JSON 数字,口径不同,见 readReclaimFields)。
48
+ *
49
+ * @returns {{ok: true, olderThanDays: number|undefined} | {ok: false, error: string}}
50
+ */
51
+ export function parseReclaimQuery(raw) {
52
+ if (raw === null) return { ok: true, olderThanDays: undefined };
53
+ if (!/^\d+$/.test(raw)) return { ok: false, error: "invalid-older-than-days" };
54
+ const olderThanDays = Number(raw);
55
+ // 十进制字面量不保证是个能算日期的数:400 位数字会化成 Infinity。
56
+ if (!Number.isFinite(olderThanDays) || olderThanDays > MAX_RECLAIM_WINDOW_DAYS) {
57
+ return { ok: false, error: "invalid-older-than-days" };
58
+ }
59
+ return { ok: true, olderThanDays };
60
+ }
61
+
62
+ /**
63
+ * 解析 POST body(已读成文本)的结构。只判 JSON 与顶层形状,字段在
64
+ * readReclaimFields 里读——两步分开是为了让调用方保住「invalid-json → invalid-body
65
+ * → maintenance 是否接线 → 字段」这个顺序,这个顺序是既有的对外行为。
66
+ *
67
+ * 不用 parseBody:它把坏 JSON 吞成 {},而 invalid-json 与 invalid-body 是两个不同
68
+ * 的错(前者是调用方发坏了,后者是发成了数组/null)。
69
+ *
70
+ * @returns {{ok: true, body: object} | {ok: false, error: string}}
71
+ */
72
+ export function parseReclaimJson(text) {
73
+ let body;
74
+ try {
75
+ body = JSON.parse(text || "{}");
76
+ } catch {
77
+ return { ok: false, error: "invalid-json" };
78
+ }
79
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
80
+ return { ok: false, error: "invalid-body" };
81
+ }
82
+ return { ok: true, body };
83
+ }
84
+
85
+ /**
86
+ * 读 POST 的字段并归一化。
87
+ *
88
+ * @returns {{ok: true, dryRun: boolean, olderThanDays: number|undefined, vacuum: boolean,
89
+ * confirm: boolean} | {ok: false, error: string}}
90
+ */
91
+ export function readReclaimFields(body) {
92
+ const dryRun = body.dryRun !== false;
93
+ // 只收 JSON 数字:Number() 会把 ""/[]/false 都化成 0,而 0 在这里是「清掉全部
94
+ // 快照」——最坏解释绝不能让一个畸形输入静默拿到。
95
+ const olderThanDays = body.olderThanDays === undefined || body.olderThanDays === null
96
+ ? undefined
97
+ : body.olderThanDays;
98
+ if (olderThanDays !== undefined
99
+ && (!Number.isInteger(olderThanDays) || olderThanDays < 0 || olderThanDays > MAX_RECLAIM_WINDOW_DAYS)) {
100
+ return { ok: false, error: "invalid-older-than-days" };
101
+ }
102
+ if (body.vacuum !== undefined && typeof body.vacuum !== "boolean") {
103
+ return { ok: false, error: "invalid-vacuum" };
104
+ }
105
+ return {
106
+ ok: true,
107
+ dryRun,
108
+ olderThanDays,
109
+ vacuum: body.vacuum === true,
110
+ confirm: body.confirm === true
111
+ };
112
+ }
113
+
114
+ /**
115
+ * 把一次回收请求变成 { status, body }。调用方只管 sendJson,不自己拼答复。
116
+ *
117
+ * 报告一律不加包装层(就是 maintenance.reclaim 的原样返回):面板与数据面都直接读
118
+ * planned/cleared/vacuum 这几格。
119
+ *
120
+ * confirm-required 分支里那份 dry-run 报告是**在这里**算的。它曾经写在 readBody 的
121
+ * 回调里,而外层 try 那时已经退栈——抛出去就是一个没人接的 rejection,客户端拿到的
122
+ * 不是 500 而是「一个字节都没回」(#410 实测)。所以本地兜住,与执行路径给出同一个
123
+ * 500 internal。
124
+ *
125
+ * @param {{maintenance: object|null, dryRun: boolean, olderThanDays?: number,
126
+ * vacuum?: boolean, confirm?: boolean, warn?: ((msg: string) => void)|null}} input
127
+ * @returns {{status: number, body: object}}
128
+ */
129
+ export function reclaimReply({ maintenance, dryRun, olderThanDays, vacuum = false, confirm = false, warn = null }) {
130
+ if (!maintenance) return { status: 503, body: { error: RECLAIM_UNAVAILABLE } };
131
+ if (!dryRun && confirm !== true) {
132
+ // 拒执行,但把 dry-run 报告一并回给调用方:确认的依据就是这份数字。报告必须描述
133
+ // **将要执行的那个动作**,所以 vacuum 原样带上——否则调用方看到的是「不 VACUUM」
134
+ // 的数字,却带着 vacuum 去执行。
135
+ let report;
136
+ try {
137
+ report = maintenance.reclaim({ olderThanDays, vacuum, dryRun: true });
138
+ } catch (error) {
139
+ warn?.(reclaimWarnMessage(error));
140
+ return { status: 500, body: { error: "internal" } };
141
+ }
142
+ return { status: 400, body: { error: "confirm-required", hint: RECLAIM_CONFIRM_HINT, report } };
143
+ }
144
+ try {
145
+ return { status: 200, body: maintenance.reclaim({ olderThanDays, vacuum, dryRun }) };
146
+ } catch (error) {
147
+ warn?.(reclaimWarnMessage(error));
148
+ return { status: 500, body: { error: "internal" } };
149
+ }
150
+ }
@@ -0,0 +1,18 @@
1
+ // Issue #334:UTF-16 截断切开 emoji 代理对会留下孤立代理项(如 🔴 = D83D DD34
2
+ // 在 800 处切,D83D 留下、DD34 被切走)。DSH 把它序列化成非法 UTF-8,DeepSeek
3
+ // API 对每个请求回 400 INVALID_REQUEST——而且畸形文本随注入消息被永久写进会话
4
+ // 历史,此后**每一轮**都带着它,表现为会话从某一轮起连续 400、换话题/重启都不
5
+ // 恢复(会话级不可逆损坏)。防法是「少一个字符都行,绝不留半个」:尾字符是
6
+ // 高位代理(0xD800–0xDBFF)说明它的低位搭档被切走了,把这个高位代理整个丢掉。
7
+ // (反过来尾字符是低位代理不可能孤立——它的搭档必然紧邻在前、同在切点之内。)
8
+ // ASCII-only 的文本零开销(length 相等直接短路)。
9
+ // (原为 inject.js 模块私有;热记忆的轮次抽取同样要做正文截断,收拢成共享模块
10
+ // 以免两份实现漂移。)
11
+ export function safeSlice(text, limit) {
12
+ const s = String(text);
13
+ if (s.length <= limit) return s;
14
+ let cut = s.slice(0, limit);
15
+ const tail = cut.charCodeAt(cut.length - 1);
16
+ if (tail >= 0xd800 && tail <= 0xdbff) cut = cut.slice(0, -1);
17
+ return cut;
18
+ }
package/src/service.js CHANGED
@@ -13,7 +13,15 @@ import { createBM25Index } from "./search/bm25.js";
13
13
  import { adaptiveThreshold } from "./search/adaptive.js";
14
14
  import { anchorSeeds, normalizePath, cascadeDepths, HOP_QUOTA } from "./graph/anchoring.js";
15
15
 
16
- const INJECT_TYPES = new Set(["preference", "project", "decision", "summary", "rejected_solution", "pitfall", "constraint"]);
16
+ // Issue #369:蒸馏白名单(src/summarize.js 的 VALID)产得出 history,注入白名单
17
+ // 原先不收——本机活跃库实测 378/1820(20.8%)因此永不可自动注入,而它们在同一库
18
+ // 的 recall_runs `auto` 模式里被按需检索过 328 次:不是垃圾数据,只是进不了注入池。
19
+ // 收编后候选池 1362 → 1713(+25.8%),按 top_k=5 粗算约多占 1 席;重要度闸门对
20
+ // history 一视同仁(见 injectCandidates 的 importance >= threshold),低于闸门照旧出局。
21
+ // pattern 一并收编:它只由手工写入(实测 40 条全 source=manual),却已被 dreamMergeGuard
22
+ // 列为长保留受保护类型(docs/CONFIGURATION.md)——巩固层当资产护住、注入面一次不给看,
23
+ // 是同一条口径的两头不一致。
24
+ const INJECT_TYPES = new Set(["preference", "project", "decision", "summary", "rejected_solution", "pitfall", "constraint", "history", "pattern"]);
17
25
 
18
26
  // document 摘要行(#230):仅 documentMemoryEnabled 开启时进注入候选。独立
19
27
  // 集合而非常改共享 Set——INJECT_TYPES 本身是「类型可注入」的静态事实,flag
@@ -1634,9 +1642,14 @@ export function createService({ store, mirror, config, onWrite, logger, document
1634
1642
  } catch { /* vector unavailable: fall through to the recall cache */ }
1635
1643
  }
1636
1644
  if (!semanticItems.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
1645
+ // CodeRabbit on #404:这条回填分支此前独漏重要度门槛——向量路(上方)与
1646
+ // BM25 路(下方 :1655 注释「门控与向量路径一致(含 importance 阈值)」)
1647
+ // 都带着它。#369 收编 history 后缺口会被放大:库里低于闸门的行(实测
1648
+ // history 378 条中有 27 条 i<=2)走一次检索进缓存就能绕开门槛占注入位。
1637
1649
  for (const m of lastSemanticRecall.items) {
1638
1650
  if (m && !m.archived && injectTypes.has(m.type) && !m.forgotten &&
1639
- (m.source !== "narrative" || allowNarrative) && codingGate(m)) semanticItems.push(m);
1651
+ (m.source !== "narrative" || allowNarrative) && codingGate(m) &&
1652
+ (m.type === "summary" || m.type === "preference" || m.importance >= threshold)) semanticItems.push(m);
1640
1653
  }
1641
1654
  }
1642
1655
  // Issue #198:首轮(无向量、无缓存召回)的同步兜底——BM25 词法召回领位。
@@ -1970,6 +1983,11 @@ export function createService({ store, mirror, config, onWrite, logger, document
1970
1983
  type: m.type,
1971
1984
  importance: m.importance,
1972
1985
  content: m.content,
1986
+ // 裁决依据:两侧的新旧与重要度是「该留哪条」的客观信号。此前 side() 不带
1987
+ // 时间戳,面板只能显示 A 方/B 方两个标签,用户没有依据就整对删掉或乱选
1988
+ // (#400)。
1989
+ created_at: m.created_at ?? null,
1990
+ updated_at: m.updated_at ?? null,
1973
1991
  archived: m.archived === true,
1974
1992
  forgotten: m.forgotten === true
1975
1993
  };
@@ -2480,6 +2498,13 @@ export function createService({ store, mirror, config, onWrite, logger, document
2480
2498
  transaction,
2481
2499
  getDistillCursor: (sessionId) => store.getDistillCursor(sessionId),
2482
2500
  setDistillCursor: (sessionId, lastSeq) => store.setDistillCursor(sessionId, lastSeq),
2501
+ // #399 蒸馏原始记录与溯源:与上面两条游标一样是纯 passthrough——它们不是
2502
+ // 记忆写入,不该触发 syncMirror / notifyWrite / 实体抽取(那些钩子挂在
2503
+ // saveWithDedupe 上)。distill_records 的落盘由 summarize.js 在蒸馏窗口内
2504
+ // 一次性批写,读回只给面板/CLI 的溯源视图用。
2505
+ saveDistillRecords: (records) => store.saveDistillRecords(records),
2506
+ saveMemoryProvenance: (link) => store.saveMemoryProvenance(link),
2507
+ provenanceFor: (memoryId) => store.provenanceFor(memoryId),
2483
2508
  enqueue,
2484
2509
  setDreamHook(fn) { dreamHook = fn; },
2485
2510
  setSleepHook(fn) { sleepHook = fn; },
package/src/settings.js CHANGED
@@ -135,7 +135,12 @@ const FEATURE_FLAG_BOOLEANS = [
135
135
  // Issue #164 A2:写入边界的密钥 / PII 判据(src/sensitive-scan.js)。与上面两个
136
136
  // 键分层——本键决定「这类判据参不参与」(默认关),命中之后是仅告警还是真拦仍由
137
137
  // writeAdmission.enforce 决定(#164 口径:默认仅告警、拦截 opt-in)。
138
- "sensitiveScanEnabled"
138
+ "sensitiveScanEnabled",
139
+ // Issue #399:蒸馏原文留存 + 记忆级溯源。enabled 决定蒸馏时是否把公开正文原样
140
+ // 落盘(distill_records)并给记忆挂窗口链接(memory_provenance);默认关。
141
+ // 点号键平铺存、合并时展开回 distillRecords 对象(同 llmAudit.enabled)。
142
+ // retentionDays 是 int,走下面的 INT_RANGES 表,不进开关白名单。
143
+ "distillRecords.enabled"
139
144
  ];
140
145
  // 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
141
146
  const FEATURE_FLAG_INT_RANGES = {
@@ -159,6 +164,9 @@ const FEATURE_FLAG_INT_RANGES = {
159
164
  dreamPeakMaxDeferMinutes: [0, 1440],
160
165
  // Issue #125:hybrid 候选量上限(0 = 复用 dreamMaxSnapshotSize)。
161
166
  dreamCandidateMax: [0, 5000],
167
+ // #135 成本审计派生:单轮 merge 决策上限(merge 是唯一回吐正文的动作,默认 40
168
+ // = 实测分布上沿;与 config.js 的 z.natural().min(1).max(200) 对齐)。
169
+ dreamMaxMergePerRun: [1, 200],
162
170
  // Issue #164①:注入单条正文截断上限(默认 300 = 既有行为)。
163
171
  injectContentMaxChars: [60, 4000],
164
172
  // Issue #164:叙述条成簇门槛(共享同一 tag 的记忆数下限)。
@@ -175,7 +183,10 @@ const FEATURE_FLAG_INT_RANGES = {
175
183
  graphSeedCap: [1, 30],
176
184
  graphCascadeDepth: [1, 3],
177
185
  // Issue #24 块3:图线索行的独立注入预算(0 = 线索不进块)。
178
- graphInjectBudget: [0, 5]
186
+ graphInjectBudget: [0, 5],
187
+ // Issue #399:蒸馏原文记录的保留天数(与 config.js 的 z.natural().min(1).max(3650)
188
+ // 对齐;同 llmAudit.retentionDays,是 int 而非开关)。
189
+ "distillRecords.retentionDays": [1, 3650]
179
190
  };
180
191
  // 浮点开关的闭区间(与 config.js 的 z.number().min().max() 对齐)。与整数开关
181
192
  // 分开:面板的整数控件要求 Number.isInteger,而余弦相似度阈值必须允许小数。
package/src/store.js CHANGED
@@ -13,6 +13,10 @@ CREATE TABLE IF NOT EXISTS memories (
13
13
  importance INTEGER NOT NULL DEFAULT 3,
14
14
  forgotten INTEGER NOT NULL DEFAULT 0,
15
15
  archived INTEGER NOT NULL DEFAULT 0,
16
+ -- Issue #275 第二批:归档时刻的精确列。archived 是标志位,updated_at 只是
17
+ -- 「最后一次写」——对已归档行做一次 update 就会刷它,代理口径因此高估归档
18
+ -- 净增速率。本列只在「从活跃面进入归档面」那一次落值,取消归档置 NULL。
19
+ archived_at TEXT,
16
20
  source TEXT,
17
21
  content_history TEXT,
18
22
  content_hash TEXT,
@@ -215,6 +219,52 @@ CREATE TABLE IF NOT EXISTS distill_cursors (
215
219
  updated_at TEXT NOT NULL
216
220
  );
217
221
 
222
+ -- #399 蒸馏窗口的原始公开记录(凭证)。蒸馏此前只把会话压成记忆、原文随即消失,
223
+ -- 于是「这句原文没进任何记忆」永远不可测——留存一份原始副本,蒸馏就从「替换」
224
+ -- 降级成「叠加索引」(arXiv 2609.36059 §3 Eq.1:与原文并存的旁路索引不丢信息)。
225
+ -- 主键 (session_id, seq) 承担幂等:额度耗尽 / 宿主重启后会重跑同一 seq 窗口
226
+ -- (summarize.js 的 max-runs 门刻意不消费游标),重跑必须不产生重复记录。
227
+ -- 落盘文本不受 distillMaxChars / trim() 三刀约束——留存的意义就是整段原文。
228
+ -- 刻意不加 sensitivity 列:机密级是**记忆级**判断,写记录那一刻它还不存在
229
+ -- (一个窗口里什么都有),加了就是往 schema 里写一个做不到的承诺。
230
+ CREATE TABLE IF NOT EXISTS distill_records (
231
+ session_id TEXT NOT NULL,
232
+ seq INTEGER NOT NULL,
233
+ role TEXT NOT NULL, -- user | assistant | agent(lang.js 的三种公开形态)
234
+ text TEXT NOT NULL, -- 未截断的公开正文
235
+ window_id TEXT,
236
+ distilled_at TEXT NOT NULL,
237
+ PRIMARY KEY (session_id, seq)
238
+ );
239
+ CREATE INDEX IF NOT EXISTS idx_distill_records_window ON distill_records(window_id);
240
+ -- 到期裁剪按 distilled_at 扫(与 llm_audit_logs.timestamp 同一口径)。
241
+ CREATE INDEX IF NOT EXISTS idx_distill_records_distilled_at ON distill_records(distilled_at);
242
+
243
+ -- #399 记忆级溯源:一条记忆由哪个蒸馏窗口的哪个 seq 区间派生。归因用**区间**而非
244
+ -- 逐事件外键——蒸馏窗口本身就是连续 seq 段,窗口级归因第一步够用。
245
+ -- 删除语义:到期裁剪只删 distill_records,本表保留并指向空区间(面板/CLI 显示
246
+ -- 「来源已过期」),绝不级联删记忆;删记忆只清链接、记录保留——一个窗口的凭证
247
+ -- 不该因删掉其中一条派生记忆而消失。
248
+ -- memory_id 是 TEXT 而非 issue 草稿里的 INTEGER:memories.id 是 TEXT 主键,
249
+ -- 类型必须与父表一致(否则 join 恒空)。
250
+ -- ON DELETE CASCADE 在本库**不生效**——store.js 从不打开 PRAGMA foreign_keys,
251
+ -- 全库唯一那条 REFERENCES 也一直是惰性的。与其为一个新表翻转全局 pragma
252
+ -- (会同时改变既有 recall_events 的写入行为),不如在 remove() 里显式清链接:
253
+ -- 语义相同、影响面只在本表。见 remove() 的注释。
254
+ CREATE TABLE IF NOT EXISTS memory_provenance (
255
+ memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
256
+ session_id TEXT NOT NULL,
257
+ seq_start INTEGER NOT NULL,
258
+ seq_end INTEGER NOT NULL,
259
+ created_at TEXT NOT NULL
260
+ );
261
+ CREATE INDEX IF NOT EXISTS idx_memory_provenance_memory ON memory_provenance(memory_id);
262
+ CREATE INDEX IF NOT EXISTS idx_memory_provenance_session ON memory_provenance(session_id);
263
+ -- issue 草稿没有唯一约束,但重跑同一窗口会重复插同一链接(幂等测试锁 1 会挂)。
264
+ -- 同一 (记忆, 会话, 区间) 只有一条事实,用唯一索引 + ON CONFLICT DO NOTHING 表达。
265
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_provenance_link
266
+ ON memory_provenance(memory_id, session_id, seq_start, seq_end);
267
+
218
268
  -- entity gene (v0.3.0): named entities mentioned across memories, with
219
269
  -- time-boxed attributes (valid_from → valid_until) and typed relations.
220
270
  -- Attributes follow the snapshot style: saveAttr invalidates the previous
@@ -453,6 +503,10 @@ function toRow(row) {
453
503
  importance: row.importance,
454
504
  forgotten: row.forgotten === 1,
455
505
  archived: row.archived === 1,
506
+ // Issue #275 第二批:NULL(存量归档行、或落地前的历史数据)读作 undefined,
507
+ // 与 sensitivity/occurred_at 同款——调用方必须显式处理「时刻不可知」,不能
508
+ // 顺手 `?? updated_at` 把代理口径捡回来。
509
+ archived_at: row.archived_at ?? undefined,
456
510
  source: row.source ?? undefined,
457
511
  content_history: parseJsonArray(row.content_history),
458
512
  evidence: parseJsonArray(row.evidence),
@@ -741,6 +795,11 @@ export function createStore(path) {
741
795
  };
742
796
 
743
797
  addColumn("memories", "archived", "ALTER TABLE memories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
798
+ // Issue #275 第二批:归档时刻精确列。可空不带 DEFAULT——存量库的已归档行
799
+ // 零重写,读侧 NULL = 「本列落地前归档,时刻不可追溯」。刻意不回填:用
800
+ // updated_at 回填等于把被推翻的代理口径重新固化进列里,偏差(偏高)会从
801
+ // 「已知的口径差」变成「看不见的脏数据」。
802
+ addColumn("memories", "archived_at", "ALTER TABLE memories ADD COLUMN archived_at TEXT");
744
803
  addColumn("memories", "embedding", "ALTER TABLE memories ADD COLUMN embedding TEXT");
745
804
  addColumn("memories", "last_accessed_at", "ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
746
805
  addColumn("memories", "_full_content", "ALTER TABLE memories ADD COLUMN _full_content TEXT");
@@ -1058,8 +1117,8 @@ export function createStore(path) {
1058
1117
  : inferEpistemicStatus(memory);
1059
1118
  runAtomically(() => {
1060
1119
  db.prepare(
1061
- `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, agent_scope_source, workspace_scope_source, scope_decided_at, sensitivity, occurred_at, evidence, doc_path, content_hash, created_at, updated_at)
1062
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1120
+ `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, archived_at, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, agent_scope_source, workspace_scope_source, scope_decided_at, sensitivity, occurred_at, evidence, doc_path, content_hash, created_at, updated_at)
1121
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1063
1122
  ).run(
1064
1123
  id,
1065
1124
  type,
@@ -1068,6 +1127,10 @@ export function createStore(path) {
1068
1127
  tags,
1069
1128
  importance,
1070
1129
  memory.archived ? 1 : 0,
1130
+ // 铸造即归档的行(store.save({archived:true})):归档时刻就是入库时刻。
1131
+ // 不写这一格的话,archived=1 而 archived_at=NULL 会落在「存量行」语义里,
1132
+ // 精确口径读侧只能把它当不可追溯——明明是当下发生的归档,白丢一条记录。
1133
+ memory.archived ? now : null,
1071
1134
  memory.source ?? null,
1072
1135
  JSON.stringify(memory.content_history ?? []),
1073
1136
  Number.isFinite(memory.quality_score) ? memory.quality_score : null,
@@ -1218,6 +1281,12 @@ export function createStore(path) {
1218
1281
 
1219
1282
  function remove(id) {
1220
1283
  runAtomically(() => {
1284
+ // #399:memory_provenance 的 ON DELETE CASCADE 在本库是惰性的(createStore
1285
+ // 从不打开 PRAGMA foreign_keys),所以链接在这里显式清。不用翻转全局 pragma
1286
+ // 换这一条级联:那会同时激活 recall_events 那条既有的 REFERENCES,改变一个
1287
+ // 与 #399 无关的写入路径。记录本身(distill_records)不删——删掉一条派生记忆
1288
+ // 不该让整个窗口的原文凭证消失。
1289
+ db.prepare("DELETE FROM memory_provenance WHERE memory_id = ?").run(id);
1221
1290
  db.prepare("DELETE FROM memories WHERE id = ?").run(id);
1222
1291
  // Mirror sync must reflect the deletion; bump desired generation so a
1223
1292
  // crash between the delete and syncMirror leaves a recoverable debt.
@@ -1308,10 +1377,27 @@ export function createStore(path) {
1308
1377
  return getById(id);
1309
1378
  }
1310
1379
 
1380
+ // Issue #275 第二批:archived_at 是「从活跃面进入归档面」那一刻的精确记录。
1381
+ // updated_at 照旧刷新(本函数自 v0.2 起的既有语义,其他读侧依赖它——面板排序、
1382
+ // 快照哈希、CAS),两列职责分开:updated_at = 最后一次写,archived_at = 归档时刻。
1383
+ // 幂等:已是归档态再调 archived=true,不刷新 archived_at(重复归档不是新一次
1384
+ // 归档,否则「重复调 setArchived」又变成一次净增——那正是代理口径被推翻的原因)。
1311
1385
  function setArchived(id, archived) {
1386
+ const next = archived ? 1 : 0;
1312
1387
  runAtomically(() => {
1313
- db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
1314
- .run(archived ? 1 : 0, nowIso(), id);
1388
+ // nowIso 在事务内取(与改动前一致):单调顶推的时间戳不该被一次回滚白白消耗。
1389
+ const now = nowIso();
1390
+ // CASE 里的 archived 是**更新前**的值(SQLite UPDATE 表达式语义):
1391
+ // 活跃 → 归档:落当前时刻;已归档 → 归档:保留原值(含存量 NULL,不回填);
1392
+ // 任意 → 取消归档:清 NULL。
1393
+ // 幂等刻意选「重复归档不刷新时刻」:否则「对同一行重复调 setArchived(true)」
1394
+ // 会再被算成本窗口新进——那正是本批要消灭的代理口径偏高,换个入口复活而已。
1395
+ db.prepare(
1396
+ `UPDATE memories SET archived = ?,
1397
+ archived_at = CASE WHEN ? = 1 AND archived = 0 THEN ? WHEN ? = 0 THEN NULL ELSE archived_at END,
1398
+ updated_at = ?
1399
+ WHERE id = ?`
1400
+ ).run(next, next, now, next, now, id);
1315
1401
  incrementGeneration();
1316
1402
  });
1317
1403
  return getById(id);
@@ -2011,8 +2097,16 @@ export function createStore(path) {
2011
2097
 
2012
2098
  /**
2013
2099
  * Aggregate LLM spend over the last `days`: total calls/tokens/duration/cost,
2014
- * broken down by trigger_source and by status. Used by the API's
2015
- * /llm-audit/stats endpoint so the Web panel can show where budget goes.
2100
+ * broken down by trigger_source, by status, by operation_type and by day.
2101
+ * Used by the API's /llm-audit/stats endpoint so the Web panel can show
2102
+ * where budget goes.
2103
+ *
2104
+ * by_operation / by_day exist because by_source is too coarse for the
2105
+ * "where did the tokens go" panel: trigger_source merges dream_consolidate
2106
+ * and dream_summarize into one autoDream bucket, hiding the single largest
2107
+ * cost item (37% of 7-day tokens in the 2026-10 audit). by_day feeds the
2108
+ * 7-day trend bars. Both are additive — by_source/by_status keep their
2109
+ * shape (two tests assert on them).
2016
2110
  */
2017
2111
  function getLlmAuditStats({ days = 7 } = {}) {
2018
2112
  const since = new Date(Date.now() - days * 86400000).toISOString();
@@ -2034,6 +2128,22 @@ export function createStore(path) {
2034
2128
  const byStatus = db.prepare(
2035
2129
  "SELECT status, count(*) AS c FROM llm_audit_logs WHERE timestamp >= ? GROUP BY status"
2036
2130
  ).all(since);
2131
+ const byOperation = db.prepare(
2132
+ `SELECT operation_type AS operation, count(*) AS c,
2133
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
2134
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
2135
+ COALESCE(SUM(total_tokens), 0) AS total_tokens
2136
+ FROM llm_audit_logs WHERE timestamp >= ?
2137
+ GROUP BY operation_type ORDER BY total_tokens DESC`
2138
+ ).all(since);
2139
+ const byDay = db.prepare(
2140
+ `SELECT date(timestamp) AS day, count(*) AS c,
2141
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
2142
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
2143
+ COALESCE(SUM(total_tokens), 0) AS total_tokens
2144
+ FROM llm_audit_logs WHERE timestamp >= ?
2145
+ GROUP BY date(timestamp) ORDER BY day ASC`
2146
+ ).all(since);
2037
2147
  return {
2038
2148
  days,
2039
2149
  since,
@@ -2044,7 +2154,9 @@ export function createStore(path) {
2044
2154
  total_duration_ms: total.d,
2045
2155
  total_cost_usd: Number(total.cst),
2046
2156
  by_source: bySource,
2047
- by_status: byStatus
2157
+ by_status: byStatus,
2158
+ by_operation: byOperation,
2159
+ by_day: byDay
2048
2160
  };
2049
2161
  }
2050
2162
 
@@ -2053,6 +2165,77 @@ export function createStore(path) {
2053
2165
  return db.prepare("DELETE FROM llm_audit_logs WHERE timestamp < ?").run(before).changes;
2054
2166
  }
2055
2167
 
2168
+ // --- 蒸馏原始记录与记忆级溯源(#399)-------------------------------------
2169
+
2170
+ /**
2171
+ * 落盘一个蒸馏窗口的公开记录(#399)。
2172
+ *
2173
+ * `INSERT OR IGNORE` 而非 OR REPLACE:主键 (session_id, seq) 的冲突只可能来自
2174
+ * **同一窗口的重跑**(max-runs 门耗尽额度、宿主重启都会重跑同一 seq 段,见
2175
+ * summarize.js 里「max-runs 刻意不消费游标」的注释),而重跑必须幂等——同一
2176
+ * seq 的原文没有第二次「更真」的版本,保留首次落盘时间才诚实。
2177
+ * @param {Array<{seq:number, role:string, text:string, windowId?:string, distilledAt:string}>} records
2178
+ * @returns {number} 实际插入行数(被忽略的冲突不计)
2179
+ */
2180
+ function saveDistillRecords(records) {
2181
+ if (!Array.isArray(records) || records.length === 0) return 0;
2182
+ const stmt = db.prepare(
2183
+ `INSERT OR IGNORE INTO distill_records (session_id, seq, role, text, window_id, distilled_at)
2184
+ VALUES (?, ?, ?, ?, ?, ?)`
2185
+ );
2186
+ let inserted = 0;
2187
+ for (const r of records) {
2188
+ inserted += stmt.run(r.sessionId, r.seq, r.role, r.text, r.windowId ?? null, r.distilledAt).changes;
2189
+ }
2190
+ return inserted;
2191
+ }
2192
+
2193
+ /**
2194
+ * 到期记录统计(#275 回收报告口径,dry-run 用)。`bytes` 是列文本大小上界。
2195
+ * @returns {{rows: number, bytes: number}}
2196
+ */
2197
+ function distillRecordStats(before) {
2198
+ const row = db.prepare(
2199
+ `SELECT count(*) AS c, COALESCE(SUM(LENGTH(text)), 0) AS b
2200
+ FROM distill_records WHERE distilled_at < ?`
2201
+ ).get(before);
2202
+ return { rows: row.c, bytes: row.b };
2203
+ }
2204
+
2205
+ /**
2206
+ * 删除到期原始记录。返回实际删除行数。
2207
+ *
2208
+ * 只删 distill_records——memory_provenance 的链接**刻意保留**并从此指向空区间,
2209
+ * 面板/CLI 显示「来源已过期」。级联删链接会把「这条记忆有来源、只是来源到期」
2210
+ * 降级成「这条记忆无来源」,把可观测性削成不可观测(#399 删除语义)。
2211
+ */
2212
+ function clearDistillRecords(before) {
2213
+ return db.prepare("DELETE FROM distill_records WHERE distilled_at < ?").run(before).changes;
2214
+ }
2215
+
2216
+ /**
2217
+ * 记一条记忆级溯源链接(#399):某条记忆派生自某会话的 seq 区间。
2218
+ *
2219
+ * `ON CONFLICT DO NOTHING` 配合 idx_memory_provenance_link 唯一索引吸收同窗口
2220
+ * 重跑的重复链接(否则幂等测试锁会挂)。
2221
+ * @returns {number} 实际插入行数
2222
+ */
2223
+ function saveMemoryProvenance({ memoryId, sessionId, seqStart, seqEnd, createdAt }) {
2224
+ return db.prepare(
2225
+ `INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at)
2226
+ VALUES (?, ?, ?, ?, ?)
2227
+ ON CONFLICT DO NOTHING`
2228
+ ).run(memoryId, sessionId, seqStart, seqEnd, createdAt).changes;
2229
+ }
2230
+
2231
+ /** 一条记忆的全部溯源链接(#399 面板/CLI 读取面)。 */
2232
+ function provenanceFor(memoryId) {
2233
+ return db.prepare(
2234
+ `SELECT memory_id, session_id, seq_start, seq_end, created_at
2235
+ FROM memory_provenance WHERE memory_id = ? ORDER BY created_at, session_id, seq_start`
2236
+ ).all(memoryId);
2237
+ }
2238
+
2056
2239
  // --- 存储生命周期:无损回收(#275 第一批)--------------------------------
2057
2240
  //
2058
2241
  // 两项都零价值判断、零条数变化:行数不变,只把不可重建/不可达的内容丢掉。
@@ -2949,6 +3132,12 @@ export function createStore(path) {
2949
3132
  countLlmAudits,
2950
3133
  getLlmAuditStats,
2951
3134
  deleteOldLlmAudits,
3135
+ // 蒸馏原始记录与记忆级溯源(#399):窗口凭证的写入/统计/裁剪,链接的读写。
3136
+ saveDistillRecords,
3137
+ distillRecordStats,
3138
+ clearDistillRecords,
3139
+ saveMemoryProvenance,
3140
+ provenanceFor,
2952
3141
  // 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。
2953
3142
  dreamRunInputStats,
2954
3143
  clearDreamRunInputs,