@modusensus/dsh-mneme 0.8.5 → 0.8.7

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 (62) hide show
  1. package/README.md +45 -147
  2. package/bin/cli.mjs +689 -603
  3. package/lib/api-standalone.js +57 -1
  4. package/lib/api.js +34 -11
  5. package/lib/client.js +289 -59
  6. package/lib/config.js +78 -11
  7. package/lib/content-hash.js +54 -0
  8. package/lib/document-index.js +94 -0
  9. package/lib/document.js +93 -11
  10. package/lib/dream/sleep.js +122 -8
  11. package/lib/dream.js +1354 -1347
  12. package/lib/index.js +115 -14
  13. package/lib/inject.js +6 -2
  14. package/lib/lang.js +15 -4
  15. package/lib/local-embedder.js +44 -6
  16. package/lib/maintenance.js +142 -0
  17. package/lib/mirror.js +147 -15
  18. package/lib/recall-stats.js +52 -2
  19. package/lib/scope.js +34 -6
  20. package/lib/service.js +86 -7
  21. package/lib/settings.js +6 -3
  22. package/lib/store.js +248 -21
  23. package/lib/tools.js +27 -9
  24. package/lib/write-admission.js +259 -0
  25. package/package.json +4 -4
  26. package/src/api-standalone.js +57 -1
  27. package/src/api.js +34 -11
  28. package/src/config.js +78 -11
  29. package/src/content-hash.js +54 -0
  30. package/src/document-index.js +94 -0
  31. package/src/document.js +93 -11
  32. package/src/dream/sleep.js +122 -8
  33. package/src/dream.js +1354 -1347
  34. package/src/index.js +115 -14
  35. package/src/inject.js +6 -2
  36. package/src/lang.js +15 -4
  37. package/src/local-embedder.js +44 -6
  38. package/src/maintenance.js +142 -0
  39. package/src/mirror.js +147 -15
  40. package/src/recall-stats.js +52 -2
  41. package/src/scope.js +34 -6
  42. package/src/service.js +86 -7
  43. package/src/settings.js +6 -3
  44. package/src/store.js +248 -21
  45. package/src/tools.js +27 -9
  46. package/src/write-admission.js +259 -0
  47. package/test/api.test.js +15 -2
  48. package/test/client.test.js +90 -5
  49. package/test/content-hash.test.js +204 -0
  50. package/test/document-dir.test.js +233 -0
  51. package/test/document.test.js +151 -3
  52. package/test/inject-parent-gate.test.js +106 -0
  53. package/test/inject-pools.test.js +8 -3
  54. package/test/lastrunat-persist.test.js +101 -0
  55. package/test/llm-audit.test.js +136 -0
  56. package/test/local-embedder.test.js +45 -2
  57. package/test/maintenance.test.js +391 -0
  58. package/test/mirror-types.test.js +208 -0
  59. package/test/recall-evals.test.js +18 -0
  60. package/test/recall-stats.test.js +66 -0
  61. package/test/sleep.test.js +117 -0
  62. package/test/write-admission.test.js +345 -0
package/lib/config.js CHANGED
@@ -77,12 +77,16 @@ export const Config = z.object({
77
77
  // maxInjectedItems 上限——只做**单向收缩**,绝不越过用户配置的上限;判据只看
78
78
  // 查询本身,不做额外检索(先探针检索等于白付一次 fuseRecall)。
79
79
  injectUncertaintyAdaptive: z.boolean().default(false),
80
- // #249(第一批):能力说明——「怎么用记忆」的判断指引。落两个零注入成本的
81
- // 位:①`memory_search` / `memory_save` 的工具描述补一句判断指引(工具描述是
82
- // 常驻文本,不进每轮上下文);②一段 order 150 的系统提示段(一次性、同会话
83
- // 内不随轮次变化,因此不作废前缀缓存),只讲总则(优先序、何时查、何时写、
84
- // 何时 no-op)。默认关:关闭时工具描述与提示段与既有行为逐字节一致。
85
- injectGuidanceEnabled: z.boolean().default(false),
80
+ // #249(第一批;第二批归位为注入子开关):能力说明——「怎么用记忆」的判断
81
+ // 指引。落两个零注入成本的位:①`memory_search` / `memory_save` 的工具描述补
82
+ // 一句判断指引(工具描述是常驻文本,不进每轮上下文);②一段 order 150 的
83
+ // 系统提示段(一次性、同会话内不随轮次变化,因此不作废前缀缓存),只讲总则
84
+ // (优先序、何时查、何时写、何时 no-op)。默认开=基础档:#249 §10 的判据是
85
+ // 「只修正既有位、不引入新注入时机/新表面/额外 LLM 调用」的子项随父开关
86
+ // 生效——「agent 不知道何时该查、何时该写」是已实测的缺口,把它默认关着是反的。
87
+ // 父开关 `autoInject` 关闭时它不生效(闸门见 injectChildEnabled);用户显式写进
88
+ // feature_flags 的值永远优先于这里的默认值。
89
+ injectGuidanceEnabled: z.boolean().default(true),
86
90
  // #249(第一批):B1 pin 池预算——约束/偏好类注入条目的独立小上限。约束与
87
91
  // 偏好被静默降级是本议题的立项核心(同类知识与情景日志同池同速率摘要,实测
88
92
  // 一轮压缩后仅保 53%、五轮 10%),故这两类不进相关性竞争、不参与跨轮轮换、
@@ -208,6 +212,15 @@ export const Config = z.object({
208
212
  // document 摘要行的注入预算(#230 拍板):次优先档内最多注入的 document
209
213
  // 行数,超预算跳过由后续候选补位。只约束注入,不约束检索。
210
214
  documentInjectBudget: z.natural().min(1).max(5).default(2),
215
+ // document 的 managed 落盘目录(#296 第二批):空串 = 跟随 memoryDir 的
216
+ // `<memoryDir>/documents/`。`~` / `~/` / `~\` 展开到 home,绝对路径原样用,相对
217
+ // 路径落在 memoryDir 下(这是本键自己的规则:memoryDir 的相对路径是原样留着的
218
+ // cwd 语义,两者刻意不同;`~user` 形式两边都不展开)。目录由 mneme 建,里面的
219
+ // `index.md`
220
+ // 整文件机器所有。这个目录**之外**的文件只登记指针行,正文一个字节都不碰
221
+ // (= #230 的「管线对正文零读零写」);与 memoryDir 一样是路径配置,不是
222
+ // 行为开关,故不进 settings.js 的 feature flags 白名单、也不上面板。
223
+ documentDir: z.string().default(""),
211
224
  // 隐式 keep(v0.4.4):LLM 未提及的 snapshot 记忆自动补 {action:"keep"},
212
225
  // 避免"未覆盖即全拒"白白浪费整轮 run。设为 false 时保留旧的严格校验
213
226
  // (未覆盖即拒绝整单)。
@@ -248,6 +261,18 @@ export const Config = z.object({
248
261
  localEmbedDimension: z.natural().default(512),
249
262
  localEmbedDevice: z.union([z.const("cpu"), z.const("gpu")]).default("cpu"),
250
263
  localEmbedBatchSize: z.natural().min(1).max(64).default(8),
264
+ // 本地嵌入的池化方式(**bug 修复,不是新能力**):BGE 系是按 CLS 池化训练的——
265
+ // 模型自带的 `1_Pooling/config.json` 明确写着 `pooling_mode_cls_token: true` /
266
+ // `pooling_mode_mean_tokens: false`,官方 README 也是「select the last hidden state of
267
+ // the first token」+ L2 normalize;而 transformers.js 的 `feature-extraction` 默认 mean。
268
+ // ⇒ 此前本地嵌入对 BGE 系一直用错池化:**不报错、只是向量系统性偏差**,检索质量静默受损。
269
+ // 'auto' = 按模型族判定(BGE → cls,其余 → mean,未受影响的模型行为不变);也可显式钉住。
270
+ // ⚠️ 池化决定向量空间,改它会改变 modelHash(见 local-embedder.js),既有索引会被判失配并重建。
271
+ localEmbedPooling: z.union([
272
+ z.const("auto"),
273
+ z.const("cls"),
274
+ z.const("mean")
275
+ ]).default("auto"),
251
276
 
252
277
  // Ollama embedder.
253
278
  ollamaBaseUrl: z.string().default("http://localhost:11434"),
@@ -488,11 +513,18 @@ export const Config = z.object({
488
513
  }).default({}),
489
514
 
490
515
  // --- LLM audit trail (Bug8) ------------------------------------------------
491
- // Records every background LLM call (autoDream consolidation + summary,
492
- // autoSummarize compression) into llm_audit_logs: tokens, duration, status
493
- // and which trigger produced it. Failures are recorded as status=error and
494
- // never block the feature. retentionDays bounds the table: older rows are
495
- // purged on boot.
516
+ // Records every background LLM call into llm_audit_logs: tokens, duration,
517
+ // status and which trigger produced it. Failures are recorded as
518
+ // status='error' and never block the feature. retentionDays bounds the table:
519
+ // older rows are purged on boot.
520
+ //
521
+ // Covered trigger_source values: autoDream (consolidation + summary),
522
+ // autoSummarize (compression), sleep (conflict + pattern) and entityExtract
523
+ // (issue #250 — the last two were structurally missing the hook, not a
524
+ // deliberate narrowing). entityExtract runs on every memory write, so row
525
+ // growth scales with write volume; retentionDays caps the ceiling but not the
526
+ // rate. If that turns out too fast, sample by operation_type or give it a
527
+ // separate retention — do not add a second gate: this flag is the single one.
496
528
  llmAudit: z.object({
497
529
  enabled: z.boolean().default(true),
498
530
  retentionDays: z.natural().min(1).max(3650).default(90)
@@ -588,6 +620,9 @@ const LIGHT_MODE_OFF = [
588
620
  "rerankEnabled",
589
621
  "autoReindexOnBoot",
590
622
  "hybridInject",
623
+ // #249 第二批:能力说明在轻量档保持关闭——它是工具描述与提示段上的额外常驻
624
+ // 文本,轻量档(小模型 / 小上下文)不该因默认值翻转而多付这份提示成本。
625
+ "injectGuidanceEnabled",
591
626
  "searchSemanticDedup",
592
627
  "selectiveInjectEnabled",
593
628
  "bm25SearchEnabled",
@@ -613,3 +648,35 @@ export function applyLightModePreset(cfg) {
613
648
  for (const key of LIGHT_MODE_OFF) preset[key] = false;
614
649
  return preset;
615
650
  }
651
+
652
+ /**
653
+ * #249 第二批:注入形态的父/子开关。父 = `autoInject`(既有的挂载总闸,默认
654
+ * 开)。子项按 #249 §10 的判据分档——看它是否引入**新的注入时机/新的注入
655
+ * 表面/额外成本**:
656
+ *
657
+ * - 只修正既有每轮块内的位(能力说明)→ 随父开关生效,键的默认值给开;
658
+ * - 需要新时机或新表面(N2 回合结束提醒、N3 压缩边缘)→ 独立开关、默认关,
659
+ * 实现时挂到这张表下(今天还没有这两个键,故表里只有能力说明一项)。
660
+ *
661
+ * 语义:**父关 = 子项一律不生效,但子项的持久值原样保留**(父关是「不生效」,
662
+ * 不是「重置用户配置」)。用户显式写进 kv 的值也不因父关被改写。
663
+ *
664
+ * 闸门放在消费点而不是合并后的 cfg 上:压 cfg 会让 `/features` 的 effective
665
+ * 失去「子项自己勾着、父关时当前不生效」这个状态,面板就显示不出来。跨文件
666
+ * 契约见 test/inject-parent-gate.test.js(含面板侧关系的漂移检查)。
667
+ *
668
+ * 表里只放**顶层扁平键**:点号键(如 `memoryQualityFilter.enabled`)在合并后的
669
+ * cfg 里是嵌套对象,`cfg?.["a.b"]` 取不到,挂进来会恒判不生效且不报错。N2/N3
670
+ * 是扁平键,不受这条限制。
671
+ */
672
+ export const INJECT_CHILD_FLAGS = Object.freeze({
673
+ autoInject: Object.freeze(["injectGuidanceEnabled"])
674
+ });
675
+
676
+ /** 子开关的运行时生效值:父开关显式关(false)时恒不生效。 */
677
+ export function injectChildEnabled(cfg, key) {
678
+ for (const [parent, children] of Object.entries(INJECT_CHILD_FLAGS)) {
679
+ if (children.includes(key) && cfg?.[parent] === false) return false;
680
+ }
681
+ return cfg?.[key] === true;
682
+ }
@@ -0,0 +1,54 @@
1
+ // #254 写入准入的确定性计量锚:内容归一化哈希(exact duplicate 判定)。
2
+ //
3
+ // 为什么用哈希而不是相似度:计量阶段要的是一个零成本、可复算、不留争议的判据。
4
+ // 相似度阈值两边都是误判——同一件事换个说法就掉到线下,不同的事共享话题词又顶到
5
+ // 线上,阈值往哪边挪都只是换一种错法(#254 拍板:计量信号用内容哈希)。哈希只认
6
+ // 「归一化后逐字节相同」,算不出来就只有一种解释。
7
+ //
8
+ // 但它不等于「原文逐字节相同」:归一化会折掉格式,也会折掉标点,于是 `版本 3.5` 与
9
+ // `版本 35`、`a-b` 与 `ab` 落同一个键。折叠范围(大小写 / 空白 / 标点)是口径本身
10
+ // 定的,#254 已拍板——误报面在计量阶段可接受(只标记、不拦截),但「命中即精确重复」
11
+ // 这句话别升级成「命中即内容相同」。要更窄的判据就得改归一化口径,而那要带存量重算。
12
+ //
13
+ // 归一化口径一次定死(NFKC → 小写 → 去标点 → 空白折叠 → trim),动机是让「只差
14
+ // 格式」的两次写入落在同一个键上:全角/半角、大小写、行内换行与缩进、句末标点都
15
+ // 不该让同一件事变成两条。反过来它不折叠同义词、不排序词序——那些只能靠相似度,
16
+ // 而相似度已判定不可用于本信号。
17
+ //
18
+ // 改口径必须带存量重算策略:memories.content_hash 是派生列,口径一变,库里已有的
19
+ // 值全部按旧口径算——补 NULL 修不好,只能整列重算(UPDATE 全表 + 重建索引)。
20
+ // 所以口径只能在这里改,且改要单独成批,不要混进别的改动里。
21
+ //
22
+ // 空内容(标题与正文归一后都为空)返回 null:这类写入没有可比内容,落 NULL 让它们
23
+ // 不互相匹配成「全是重复」。
24
+ //
25
+ // 另有一处形状相近、口径不同的哈希:service.js 里镜像人工编辑的 digest 校验(两处)
26
+ // 用 sha256(title\0content),那里**不**归一化(要逐字节保真)。两把哈希用途不同,别互换。
27
+ import { createHash } from "node:crypto";
28
+
29
+ /**
30
+ * 归一化一段文本用于内容比对(口径见文件头)。
31
+ * @param {unknown} value
32
+ * @returns {string}
33
+ */
34
+ export function normalizeForHash(value) {
35
+ return String(value ?? "")
36
+ .normalize("NFKC")
37
+ .toLowerCase()
38
+ .replace(/\p{P}/gu, "")
39
+ .replace(/\s+/g, " ")
40
+ .trim();
41
+ }
42
+
43
+ /**
44
+ * 一行记忆的内容锚:标题与正文分别归一后用 NUL 拼接再取 sha256(十六进制全串)。
45
+ * NUL 分隔是防拼接歧义——('ab','c') 与 ('a','bc') 不能落到同一个键上。
46
+ * @param {{title?: string, content?: string}|null|undefined} memory
47
+ * @returns {string|null} 归一后无内容时返回 null
48
+ */
49
+ export function contentHashOf(memory) {
50
+ const title = normalizeForHash(memory?.title);
51
+ const content = normalizeForHash(memory?.content);
52
+ if (!title && !content) return null;
53
+ return createHash("sha256").update(`${title}\u0000${content}`).digest("hex");
54
+ }
@@ -0,0 +1,94 @@
1
+ // dsh-mneme/src/document-index.js
2
+ // documentDir 的索引(issue #296 第二批)。`<documentDir>/index.md` 整文件机器所有,
3
+ // 可从库随时重建:内容里没有任何「生成时间」,所以删掉再同步一次得到逐字节相同的
4
+ // 文件(test/document-dir.test.js 钉住这一条)。
5
+ //
6
+ // 与镜像侧 documents.md 的分工(#296 第 2 节表格):这里是 document 子系统自己的
7
+ // 索引(id + 标题 + 定位 + managed 标记),那份是带 frontmatter 的镜像只读视图
8
+ // (多一个摘要首句)。两份都只列指针行,都不含正文——正文永远在 doc_path 指向的
9
+ // 文件里,那是 agent 的东西。
10
+ import { readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { STR } from "./lang.js";
13
+ import { isManagedDocumentPath } from "./document.js";
14
+
15
+ function oneLine(text) {
16
+ return String(text ?? "").replace(/\s+/g, " ").trim();
17
+ }
18
+
19
+ /**
20
+ * @param {string} dir resolved documentDir (see resolveDocumentDir)
21
+ * @param {"zh"|"en"} language instance language, same as the mirror
22
+ */
23
+ export function createDocumentIndex(dir, language = "zh") {
24
+ const filePath = join(dir, "index.md");
25
+
26
+ /**
27
+ * Render the whole file from the given rows. Sorted updated_at DESC, id ASC:
28
+ * the same store state must always render the same bytes (rebuildable).
29
+ */
30
+ function render(memories) {
31
+ const items = (memories ?? []).slice().sort((a, b) => {
32
+ if (a.updated_at !== b.updated_at) return a.updated_at < b.updated_at ? 1 : -1;
33
+ return a.id < b.id ? -1 : 1;
34
+ });
35
+ const lines = [STR.documentIndexHeader[language]()];
36
+ for (const m of items) {
37
+ const where = isManagedDocumentPath(dir, m.doc_path) ? "managed" : "external";
38
+ // doc_path 理论上必带(store.saveDocument 是唯一铸造口),缺了就只写定位
39
+ // 标记,不编造路径。
40
+ const path = m.doc_path ? ` · \`${oneLine(m.doc_path)}\`` : "";
41
+ lines.push(`- \`${m.id}\` **${oneLine(m.title)}** · ${where}${path}`);
42
+ }
43
+ return `${lines.join("\n")}\n`;
44
+ }
45
+
46
+ /**
47
+ * Ensure the directory exists (recursive). Never throws: callers warn and move
48
+ * on — a managed directory that cannot be created must not fail plugin boot.
49
+ */
50
+ function ensure() {
51
+ try {
52
+ mkdirSync(dir, { recursive: true });
53
+ return { ok: true };
54
+ } catch (error) {
55
+ return { ok: false, error: error?.message ?? String(error) };
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Write the index if it changed. Never throws: a failed index write must not
61
+ * fail the business write that triggered it (same fail-safe shape as
62
+ * mirror.sync). Returns { ok, changed } or { ok: false, error }.
63
+ */
64
+ function sync(memories) {
65
+ const text = render(memories);
66
+ let prev = null;
67
+ try { prev = readFileSync(filePath, "utf8"); } catch { /* first write */ }
68
+ if (prev === text) return { ok: true, changed: false };
69
+ const ready = ensure();
70
+ if (!ready.ok) return { ok: false, error: ready.error };
71
+ try {
72
+ writeFileSync(filePath, text, "utf8");
73
+ return { ok: true, changed: true };
74
+ } catch (error) {
75
+ return { ok: false, error: error?.message ?? String(error) };
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Drop the index file (闸关时用)。闸关意味着 document 子系统整体退出,留一份
81
+ * 陈旧索引会列出已归档、已遗忘的指针行——正是 documents.md 在闸关时被镜像删掉
82
+ * 要避免的那种「看着还在、其实已关」的视图。删掉不从库取数,O(1),也不抛。
83
+ */
84
+ function remove() {
85
+ try {
86
+ rmSync(filePath, { force: true });
87
+ return { ok: true };
88
+ } catch (error) {
89
+ return { ok: false, error: error?.message ?? String(error) };
90
+ }
91
+ }
92
+
93
+ return { filePath, render, sync, ensure, remove };
94
+ }
package/lib/document.js CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { statSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
- import { isAbsolute, resolve } from "node:path";
16
+ import { isAbsolute, resolve, sep } from "node:path";
17
17
  import { scopeKeyOf } from "./scope.js";
18
18
  import { cosineSimilarity } from "./dream/clustering.js";
19
19
 
@@ -23,26 +23,71 @@ import { cosineSimilarity } from "./dream/clustering.js";
23
23
  // 新版判成「无近重复」,留下两行活跃摘要漂移——supersede 探测必须全时段。
24
24
  const DOCUMENT_MIN_SIM = 0.92;
25
25
  const CANDIDATE_LIMIT = 200;
26
+ const DOCUMENT_DIR_NAME = "documents";
27
+
28
+ /**
29
+ * 解析 managed 文档目录(#296 第二批)。`configured` 为空 = 跟随 memoryDir 的
30
+ * `<memoryDir>/documents/`;`~` / `~/` / `~\` 展开到用户 home;相对路径落在
31
+ * memoryDir 下(本键自己的规则:memoryDir 的相对路径原样留着走 cwd 语义);
32
+ * 绝对路径归一化后原样用。纯函数,路径解析集中在 index.js 调用一次。
33
+ */
34
+ export function resolveDocumentDir(memoryDir, configured = "") {
35
+ const raw = String(configured ?? "").trim();
36
+ if (!raw) return resolve(memoryDir, DOCUMENT_DIR_NAME);
37
+ if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
38
+ return resolve(homedir(), raw.slice(2));
39
+ }
40
+ return isAbsolute(raw) ? resolve(raw) : resolve(memoryDir, raw);
41
+ }
42
+
43
+ /**
44
+ * 这个路径是不是落在 managed 目录里(`documentDir` 内 = mneme 管的树,`index.md`
45
+ * 与镜像 md 同权;之外 = 只登记指针行、正文一个字节都不碰)。比较前两边都归一化,
46
+ * Windows 上大小写不敏感(文件系统就是),否则 `C:\a\documents\x.md` 与
47
+ * `c:\A\Documents\x.md` 会被判成两类。
48
+ */
49
+ export function isManagedDocumentPath(dir, path) {
50
+ if (!dir || !path) return false;
51
+ const norm = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p));
52
+ const base = norm(resolve(dir));
53
+ const target = norm(resolve(path));
54
+ const prefix = base.endsWith(sep) ? base : base + sep;
55
+ return target === base || target.startsWith(prefix);
56
+ }
26
57
 
27
58
  // 追加到被取代行正文的指针注记。刻意不带语言分支:id 是跨语言稳定键(用户
28
59
  // 对 tag/章节名建议用英文同理——指针性内容不翻译);可追溯链 = 注记里的新行
29
60
  // id + 旧行 content_history 里的旧摘要(source=superseded)。
30
61
  const supersededByPointer = (id) => `\n\n[superseded by ${id}]`;
31
62
 
63
+ // 升格吸收不碰的类型(#275 拍板 5 的边界):吸收的对象是「原子条」,而下面这两种
64
+ // 行各有自己的生命周期,被归档会各自留下第二行——
65
+ // document:本体是磁盘上的文件。归档它等于替用户撤下外档;而且 supersede 探测
66
+ // 只看活跃行(store.list 默认排除归档),同一 path 下次注册会在旧行还挂着
67
+ // 「新版本」语义时再铸一行,指针注记与 content_history 记账也走不到。
68
+ // summary:dream 总览与叙述按 source 身份去重、并按注入档位常驻。归档它会让下一
69
+ // 次做梦把它当不存在(候选集同样只取活跃行),于是库里多出一行同源总览。
70
+ // 口径是保守方向的:拿不准的行一律不吸收——少做一步没有代价,收错一步是内容事故。
71
+ const ABSORB_EXEMPT_TYPES = new Set(["document", "summary"]);
72
+
32
73
  /**
33
74
  * Build the document registrar. Injected deps are service.js closure members
34
75
  * (store / embedQuery / transaction / finalize) so this module stays free of
35
76
  * service-internal wiring; `finalize` is the single write epilogue (mirror
36
77
  * sync + notify + re-embed) so callers never duplicate it.
37
78
  *
38
- * @returns {(payload: object) => Promise<object>}
79
+ * `pinnedTypes` 是注入的 #249 逐字保真池(service.js 的 PINNED_MEMORY_TYPES):
80
+ * 这里不 import 它在 service.js 里,是因为 service.js 反向 import 本模块——
81
+ * 方向反了会成环。
82
+ *
83
+ * @returns {(payload: object, opts?: object) => Promise<object>}
39
84
  * `{ action: "created"|"superseded", memory, superseded?, evidence_kept,
40
- * evidence_dropped, degraded }`
85
+ * evidence_dropped, evidence_archived, degraded }`
41
86
  * @throws flag 关 / 路径非法或文件缺失 / title 或 summary 为空 / evidence 全部
42
87
  * 捏造 / 仅 vector 近重复(疑似重复外档,交 agent 裁决而不是静默二选一)。
43
88
  */
44
- export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize }) {
45
- return async function registerDocument(payload, { hiddenEvidenceIds = [] } = {}) {
89
+ export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize, pinnedTypes }) {
90
+ return async function registerDocument(payload, { hiddenEvidenceIds = [], archiveEvidence = true } = {}) {
46
91
  // opt-in 总闸(#230 对齐 #228 形态):关 = 注册入口整体不存在,错误信息
47
92
  // 指回配置键,agent 能准确转告用户去开。
48
93
  if (config?.documentMemoryEnabled !== true) {
@@ -90,15 +135,14 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
90
135
  const hidden = new Set((Array.isArray(hiddenEvidenceIds) ? hiddenEvidenceIds : []).map((id) => String(id)));
91
136
  const kept = [];
92
137
  const dropped = [];
138
+ // 存在但已归档的引用另记一份:它可能是被上一版文档升格吸收走的行(见下方
139
+ // supersede 目标探测后的「认回」),不能一律按「不可用」处理。
140
+ const archivedRefs = new Set();
93
141
  for (const id of wanted) {
94
142
  const row = hidden.has(id) ? null : store.getById(id);
143
+ if (row?.archived) archivedRefs.add(id);
95
144
  (row && !row.archived ? kept : dropped).push(id);
96
145
  }
97
- if (wanted.length > 0 && kept.length === 0) {
98
- throw new Error(
99
- `registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
100
- );
101
- }
102
146
 
103
147
  const title = String(payload?.title ?? "").trim();
104
148
  if (!title) throw new Error("registerDocument: title is required");
@@ -131,6 +175,25 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
131
175
  .filter((m) => scopeMatches(m));
132
176
  const explicitTarget = scopeMatched.find((m) => samePath(m.doc_path, expanded))
133
177
  ?? scopeMatched.find((m) => m.title.trim() === title);
178
+ // 升格吸收过的引用认回(#275 拍板 5 的配套):evidence 行在文档注册成功后就翻了
179
+ // archived,于是「同一份文档出新版、evidence 照旧」这一最常规的路径会整批落在
180
+ // dropped——那会被下面的捏造判据误报成「证据都是编的」。口径:已归档行仍可为
181
+ // **吸收它的那份文档**背书(loser.evidence 就是它吸收走的名单),不为别的文档
182
+ // 背书;捏造与跨 scope 照旧拒绝。
183
+ if (explicitTarget) {
184
+ const own = new Set(explicitTarget.evidence ?? []);
185
+ for (const id of archivedRefs) {
186
+ if (!own.has(id)) continue;
187
+ const at = dropped.indexOf(id);
188
+ if (at >= 0) dropped.splice(at, 1);
189
+ kept.push(id);
190
+ }
191
+ }
192
+ if (wanted.length > 0 && kept.length === 0) {
193
+ throw new Error(
194
+ `registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
195
+ );
196
+ }
134
197
  if (!explicitTarget) {
135
198
  // vector 档:embedder 不可用/向量缺失一律跳过——去重是增强不是写入依赖
136
199
  // (findSessionDuplicate 同原则)。probe 用 title+summary,与行向量
@@ -206,7 +269,25 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
206
269
  store.setArchived(loser.id, true);
207
270
  superseded = store.getById(loser.id);
208
271
  }
209
- return { created, superseded };
272
+ // #275 拍板 5(#230 验收口径的补丁):升格吸收的 evidence 行随之退出活跃面——
273
+ // 一次吸收 20 条,库里就是「21 行不是 1 行」。四条口径:①与本行同一事务(要么
274
+ // 都成、要么都不成);②pinned(constraint / preference,即 #249 的逐字保真池)
275
+ // 永不自动归档——升格不能绕过它;③自有生命周期的类型(document / summary,见
276
+ // ABSORB_EXEMPT_TYPES)不吸收,吸收的是原子条;④只翻标志位、内容与审计全留
277
+ // (可恢复),opt-out 走 archiveEvidence。
278
+ // pinnedTypes 拿不到就不做这一步:宁可少做,也不能把保真池当普通行收走。
279
+ let evidenceArchived = 0;
280
+ if (archiveEvidence && pinnedTypes && typeof pinnedTypes.has === "function") {
281
+ for (const id of kept) {
282
+ // 事务内重读:期间被别的进程归档/遗忘的行不重复计数,也不误伤 pinned。
283
+ const row = store.getById(id);
284
+ if (!row || row.archived || row.forgotten) continue;
285
+ if (pinnedTypes.has(row.type) || ABSORB_EXEMPT_TYPES.has(row.type)) continue;
286
+ store.setArchived(id, true);
287
+ evidenceArchived += 1;
288
+ }
289
+ }
290
+ return { created, superseded, evidenceArchived };
210
291
  });
211
292
  finalize(result.superseded ? [result.created, result.superseded] : [result.created]);
212
293
  return {
@@ -215,6 +296,7 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
215
296
  ...(result.superseded ? { superseded: result.superseded } : {}),
216
297
  evidence_kept: kept.length,
217
298
  evidence_dropped: dropped.length,
299
+ evidence_archived: result.evidenceArchived,
218
300
  degraded: dropped.length > 0
219
301
  };
220
302
  };
@@ -49,12 +49,22 @@ function parseJsonArray(text) {
49
49
  }
50
50
  }
51
51
 
52
- /** Same stream consumption contract as dream.js. */
53
- async function streamText(ctx, options, onStreamError) {
52
+ /**
53
+ * Same stream consumption contract as dream.js.
54
+ *
55
+ * `onUsage`(optional, issue #250)receives any usage chunk for token accounting.
56
+ * dream.js 那份一直有它;sleep 这份副本没有,导致 conflict / pattern 两条链路的
57
+ * token 从未进 llm_audit_logs(面板因此只看到 autoDream 与 autoSummarize)。
58
+ */
59
+ async function streamText(ctx, options, onUsage, onStreamError) {
54
60
  if (!ctx?.llm?.stream) return undefined;
55
61
  let text = "";
56
62
  for await (const chunk of ctx.llm.stream(options)) {
57
63
  if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
64
+ // DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens /
65
+ // outputTokens / totalTokens);兼容直接平铺在 chunk 上的旧形态。归一放在
66
+ // 这里,调用侧的 reporter 只需认平铺形状——与 dream.js:239 同一处理。
67
+ if (chunk.type === "usage" && typeof onUsage === "function") onUsage(chunk.usage ?? chunk);
58
68
  if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
59
69
  // Same rc.1 error-as-finish-chunk behavior as dream.js — surface the
60
70
  // cause instead of discarding it.
@@ -67,6 +77,79 @@ async function streamText(ctx, options, onStreamError) {
67
77
  return text;
68
78
  }
69
79
 
80
+ /**
81
+ * Issue #250:给 sleep 的一条 LLM 调用记一笔 llm_audit_logs(token / 时长 / 状态 /
82
+ * 触发源)。与 dream.js 的 runAuditedLlm 同口径但各自独立一份——dream 那两个
83
+ * checker 的调用形状绑定着它自己的排程细节,硬抽共享抽象只会收敛回 dream 的形态。
84
+ *
85
+ * 记账是 best-effort:写审计行失败只 warn,绝不反噬 sleep 本体(CONTRIBUTING 的
86
+ * fail-safe 硬约定)。`body(reportUsage)` 负责真实消费流,usage chunk 经
87
+ * reportUsage 上报。
88
+ *
89
+ * 审计诚实性:流被中止/报错时 body 返回 undefined,记 status='error';流式成功但
90
+ * 输出不可用时 `spec.auditError` 给出原因,同样记 error——否则同一轮调用会在
91
+ * 「phase 报 failed」与「audit 报 success」之间自相矛盾(CONTRIBUTING 的审计诚实性
92
+ * 硬约定)。`auditError` 必须是**纯判据函数**:调用方的控制流不能依赖它的副作用,
93
+ * 因为审计关闭时这里整段早退、钩子根本不会被调用(dream 正是那样踩的,见 PR 正文)。
94
+ */
95
+ async function auditedSleepLlm(ctx, service, config, logger, spec, body) {
96
+ if (config?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") {
97
+ return body(() => {});
98
+ }
99
+ const startedAt = Date.now();
100
+ const timestamp = new Date(startedAt).toISOString();
101
+ let inputTokens = 0;
102
+ let outputTokens = 0;
103
+ let status = "success";
104
+ let errorMessage = null;
105
+ try {
106
+ const result = await body((usage) => {
107
+ if (!usage) return;
108
+ const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
109
+ const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
110
+ if (Number.isFinite(i)) inputTokens = i;
111
+ if (Number.isFinite(o)) outputTokens = o;
112
+ });
113
+ if (result === undefined) {
114
+ status = "error";
115
+ const streamErr = typeof spec.streamError === "function" ? String(spec.streamError() ?? "") : "";
116
+ errorMessage = streamErr ? `llm stream aborted or errored (${streamErr})` : "llm stream aborted or errored";
117
+ } else if (typeof spec.auditError === "function") {
118
+ // 流式成功但输出不可用,仍然是一次失败的调用——记 error,不与调用方的
119
+ // failed 返回值打架。
120
+ const message = spec.auditError(result);
121
+ if (message) {
122
+ status = "error";
123
+ errorMessage = message;
124
+ }
125
+ }
126
+ return result;
127
+ } catch (error) {
128
+ status = "error";
129
+ errorMessage = String(error?.message ?? error);
130
+ throw error;
131
+ } finally {
132
+ try {
133
+ service.saveLlmAudit({
134
+ timestamp,
135
+ trigger_source: spec.triggerSource,
136
+ operation_type: spec.operationType,
137
+ model_id: spec.modelId,
138
+ input_tokens: inputTokens,
139
+ output_tokens: outputTokens,
140
+ total_tokens: inputTokens + outputTokens,
141
+ cost_usd: 0,
142
+ duration_ms: Date.now() - startedAt,
143
+ status,
144
+ error_message: errorMessage,
145
+ related_memory_ids: spec.relatedMemoryIds ?? []
146
+ });
147
+ } catch (auditError) {
148
+ logger?.warn?.(`dsh-mneme sleep: llm audit write failed: ${String(auditError)}`);
149
+ }
150
+ }
151
+ }
152
+
70
153
  /** LLM route (Issue #25): explicit sleepProvider/Model wins, then the dream
71
154
  * route as a shared explicit fallback, then the agent default model. Sleep
72
155
  * can pin a cheaper model for its bulk passes without disturbing the dream
@@ -232,7 +315,19 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
232
315
  const conflictPrompt = actionSet === "full" ? STR.prompts.conflictFull[language] : STR.prompts.conflict[language];
233
316
  const runConflict = (withEffort) => {
234
317
  conflictStreamFailure = "";
235
- return streamText(ctx, {
318
+ return auditedSleepLlm(ctx, service, config, logger, {
319
+ triggerSource: "sleep",
320
+ operationType: "sleep_conflict",
321
+ modelId: `${route.provider}:${route.model}`,
322
+ // 候选对的双方 id:面板的活动流用 related_memory_ids 解析「沉淀条数」。
323
+ relatedMemoryIds: samePairs.flatMap((p) => [p.a.id, p.b.id]),
324
+ streamError: () => conflictStreamFailure,
325
+ // 纯判据:输出里没有 JSON 数组 ⇒ 这轮是失败的调用,审计记 error。**不要**把
326
+ // 解析结果存进闭包给下面的控制流用——审计关闭时 auditedSleepLlm 整段早退、
327
+ // 这个钩子不会被调用,控制流会跟着失效(dream 的既有坑)。代价是审计开启时
328
+ // 多解析一次,换来两条路径彻底解耦。
329
+ auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output")
330
+ }, (reportUsage) => streamText(ctx, {
236
331
  provider: route.provider,
237
332
  model: route.model,
238
333
  purpose: "sleep-conflict",
@@ -242,7 +337,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
242
337
  { role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin", plugin: "dsh-mneme" } },
243
338
  { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
244
339
  ]
245
- }, (reason) => { conflictStreamFailure = describeStreamFailure(reason); });
340
+ }, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
246
341
  };
247
342
  const text = await withEffortFallback(ctx, sleepEffort, () => runConflict(true), () => runConflict(false), () => conflictStreamFailure);
248
343
  if (text === undefined) {
@@ -385,7 +480,16 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
385
480
  let patternStreamFailure = "";
386
481
  const runPattern = (withEffort) => {
387
482
  patternStreamFailure = "";
388
- return streamText(ctx, {
483
+ return auditedSleepLlm(ctx, service, config, logger, {
484
+ triggerSource: "sleep",
485
+ operationType: "sleep_pattern",
486
+ modelId: `${route.provider}:${route.model}`,
487
+ // 本轮扫描的记忆 id:与 dream 的 relatedMemoryIds 同义,供面板解析沉淀条数。
488
+ relatedMemoryIds: memories.map((m) => m.id),
489
+ streamError: () => patternStreamFailure,
490
+ // 纯判据,同 conflict 阶段:不把解析结果存进闭包供控制流使用。
491
+ auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output")
492
+ }, (reportUsage) => streamText(ctx, {
389
493
  provider: route.provider,
390
494
  model: route.model,
391
495
  purpose: "sleep-pattern",
@@ -395,7 +499,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
395
499
  { role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin", plugin: "dsh-mneme" } },
396
500
  { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
397
501
  ]
398
- }, (reason) => { patternStreamFailure = describeStreamFailure(reason); });
502
+ }, reportUsage, (reason) => { patternStreamFailure = describeStreamFailure(reason); }));
399
503
  };
400
504
  const text = await withEffortFallback(ctx, sleepEffort, () => runPattern(true), () => runPattern(false), () => patternStreamFailure);
401
505
  if (text === undefined) {
@@ -507,6 +611,9 @@ function deriveStatus(phases) {
507
611
  */
508
612
  export async function runSleep(ctx, service, config, logger, semantic = null, signal = null) {
509
613
  const runId = randomUUID();
614
+ // Issue #89:开跑时刻——审计行 created_at 的基准(= 冷却种子),run 耗时
615
+ // (LLM 各 phase)不应计入重启后的冷却窗口。
616
+ const sleepStartedAt = Date.now();
510
617
  const phases = {};
511
618
  const attempt = async (name, fn) => {
512
619
  if (signal?.aborted) return; // user resumed activity — stop before next phase
@@ -543,6 +650,9 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si
543
650
  try {
544
651
  service.saveDreamRun({
545
652
  id: runId,
653
+ // Issue #89:审计行记开跑时刻而非完成时刻——run 耗时(含 LLM 各 phase)
654
+ // 不该计入重启后的 sleep 冷却种子。
655
+ created_at: new Date(sleepStartedAt).toISOString(),
546
656
  status,
547
657
  provider: route?.provider,
548
658
  model: route?.model,
@@ -582,10 +692,14 @@ export function createSleepScheduler({
582
692
  onRun = null,
583
693
  now = () => Date.now(),
584
694
  setTimeoutFn = setTimeout,
585
- clearTimeoutFn = clearTimeout
695
+ clearTimeoutFn = clearTimeout,
696
+ // Issue #89 同族:冷却时刻由调用方从 dream_runs 审计表恢复(run_type='sleep')——
697
+ // 内存变量进程重启即归零,冷却闸对新实例放行 → 重启后立即进入 sleep 冷却盲区。
698
+ // 测试注入 0(默认)即保持旧行为。
699
+ lastRunAtSeed = 0
586
700
  }) {
587
701
  let lastWriteAt = now();
588
- let lastRunAt = 0;
702
+ let lastRunAt = lastRunAtSeed;
589
703
  let running = false;
590
704
  let disposed = false;
591
705
  let idleTimer = null;