@modusensus/dsh-mneme 0.7.30 → 0.7.32

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 (86) hide show
  1. package/README.md +25 -7
  2. package/lib/api.js +79 -6
  3. package/lib/client.js +217 -30
  4. package/lib/commands.js +6 -2
  5. package/lib/config.js +80 -14
  6. package/lib/dream/decisions.js +147 -14
  7. package/lib/dream/sleep.js +84 -34
  8. package/lib/dream.js +250 -60
  9. package/lib/embedding.js +22 -2
  10. package/lib/index.js +68 -37
  11. package/lib/inject.js +16 -9
  12. package/lib/lang.js +156 -0
  13. package/lib/local-embedder.js +23 -9
  14. package/lib/mirror.js +17 -14
  15. package/lib/reranker.js +23 -8
  16. package/lib/runtime/adopt.js +176 -0
  17. package/lib/runtime/closure.js +172 -0
  18. package/lib/runtime/download.js +247 -0
  19. package/lib/runtime/layout.js +235 -0
  20. package/lib/runtime/loader.js +249 -0
  21. package/lib/runtime/provision.js +237 -0
  22. package/lib/runtime/tarball.js +130 -0
  23. package/lib/runtime/verify.js +191 -0
  24. package/lib/service.js +102 -8
  25. package/lib/settings.js +36 -2
  26. package/lib/store.js +26 -8
  27. package/lib/summarize.js +108 -35
  28. package/lib/tools.js +119 -0
  29. package/package.json +11 -6
  30. package/runtime-manifest.json +596 -0
  31. package/scripts/build-runtime-manifest.mjs +217 -0
  32. package/scripts/mneme-runtime.mjs +277 -0
  33. package/src/api.js +79 -6
  34. package/src/commands.js +6 -2
  35. package/src/config.js +80 -14
  36. package/src/dream/decisions.js +147 -14
  37. package/src/dream/sleep.js +84 -34
  38. package/src/dream.js +250 -60
  39. package/src/embedding.js +22 -2
  40. package/src/index.js +68 -37
  41. package/src/inject.js +16 -9
  42. package/src/lang.js +156 -0
  43. package/src/local-embedder.js +23 -9
  44. package/src/mirror.js +17 -14
  45. package/src/reranker.js +23 -8
  46. package/src/runtime/adopt.js +176 -0
  47. package/src/runtime/closure.js +172 -0
  48. package/src/runtime/download.js +247 -0
  49. package/src/runtime/layout.js +235 -0
  50. package/src/runtime/loader.js +249 -0
  51. package/src/runtime/provision.js +237 -0
  52. package/src/runtime/tarball.js +130 -0
  53. package/src/runtime/verify.js +191 -0
  54. package/src/service.js +102 -8
  55. package/src/settings.js +36 -2
  56. package/src/store.js +26 -8
  57. package/src/summarize.js +108 -35
  58. package/src/tools.js +119 -0
  59. package/test/api.test.js +89 -9
  60. package/test/client.test.js +94 -10
  61. package/test/config.test.js +17 -1
  62. package/test/dream-candidate-hybrid.test.js +281 -0
  63. package/test/dream.test.js +172 -0
  64. package/test/embedding-ready.test.js +196 -0
  65. package/test/helpers/runtime-source.js +60 -0
  66. package/test/helpers/tar-builder.js +80 -0
  67. package/test/inject.test.js +37 -1
  68. package/test/lang.test.js +87 -0
  69. package/test/quality-filter.test.js +68 -0
  70. package/test/reasoning-effort.test.js +106 -15
  71. package/test/reindex-backfill.test.js +62 -0
  72. package/test/runtime-adopt.test.js +206 -0
  73. package/test/runtime-cli.test.js +89 -0
  74. package/test/runtime-closure.test.js +193 -0
  75. package/test/runtime-download.test.js +302 -0
  76. package/test/runtime-layout.test.js +163 -0
  77. package/test/runtime-loader.test.js +237 -0
  78. package/test/runtime-manifest.test.js +158 -0
  79. package/test/runtime-provision.test.js +101 -0
  80. package/test/runtime-tarball.test.js +82 -0
  81. package/test/runtime-verify.test.js +214 -0
  82. package/test/sleep-action-set.test.js +422 -0
  83. package/test/store.test.js +14 -0
  84. package/test/summarize.test.js +224 -0
  85. package/test/tools.test.js +30 -5
  86. package/test/webServer-optional.test.js +34 -1
package/src/dream.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { validateDecisions, applyDecisions } from "./dream/decisions.js";
2
- import { clusterMemories, findPotentialConflicts } from "./dream/clustering.js";
2
+ import { clusterMemories, findPotentialConflicts, cosineSimilarity } from "./dream/clustering.js";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
+ import { STR, langOf } from "./lang.js";
4
5
  export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute };
5
6
 
6
7
 
@@ -40,52 +41,6 @@ function extractJsonArray(text) {
40
41
  }
41
42
  return null;
42
43
  }
43
- const SUMMARY_PROMPT = `你是记忆库摘要助手。根据整理后的记忆,生成一段 150-200 字的记忆库总览,覆盖:用户偏好、活跃项目、关键决策。之后作为会话上下文注入。只输出摘要文本,不要其他内容。`;
44
-
45
- const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记忆条目(id、类型、标题、内容、重要性、更新时间)。
46
- 请执行记忆巩固(consolidation),输出一个决策 JSON 数组。
47
-
48
- 【决策格式(必须严格遵守)】
49
- 每个决策必须是对象,字段固定:
50
- - "action":必填。取值只能是 "keep" / "merge" / "archive" / "update" / "conflict" 之一(字段名必须是 action,严禁写成 type)
51
- - "ids":必填,数组,本决策涉及的记忆 id 列表
52
- - "reason":可选,字符串,决策理由
53
- - "importance":可选,整数 1-5
54
- - merge 额外字段:"keepSource"(单个 id 字符串,必须是 ids 之一)+ 合并后的 "title"、"content"
55
- - conflict 额外字段:"winner" 与 "loser",都是【单个 id 字符串,不是数组】
56
- - update 额外字段:修正后的 "title" 和/或 "content";"ids" 只能包含一个 id
57
-
58
- 【决策 JSON 示例】
59
- [
60
- { "action": "merge", "ids": ["m1", "m2"], "keepSource": "m1", "title": "合并标题", "content": "合并后的摘要内容", "importance": 4, "reason": "主题相近" },
61
- { "action": "conflict", "winner": "m3", "loser": "m4", "reason": "内容矛盾,保留更新的信息" },
62
- { "action": "update", "ids": ["m5"], "content": "修正后的内容", "reason": "信息过时" },
63
- { "action": "archive", "ids": ["m6"], "reason": "重复或过时" }
64
- ]
65
-
66
- 【任务】
67
- 1. 识别主题相近的条目 → merge(合并为更精炼的摘要,保留信息最完整的 id 作为 keepSource)
68
- 2. 识别重复/过时信息 → archive
69
- 3. 识别内容矛盾的条目 → conflict(按时间新旧、来源完整性、信息具体程度判断 winner/loser)
70
- 4. 发现单条记忆中的信息过时、错误或遗漏 → update(直接修正内容)
71
- - update 的 ids 只能包含一个 id
72
- - 必须提供修正后的 title 和/或 content
73
- - 仅当内容确实需要修正时才使用,不要滥用
74
- - 每次整理最多输出 2 个 update
75
- - 24 小时内新建的记忆不可 update
76
- 5. 无问题的条目无需输出(未提及的条目将自动保留 keep)
77
-
78
- 【硬性规则】
79
- - 字段名必须精确为 "action",严禁写成 "type";字段名统一用双引号
80
- - conflict 的 winner/loser、merge 的 keepSource 都是【单个 id 字符串,绝不是数组】
81
- - 每条记忆最多被 claim 一次:同一个 id 不能出现在多个决策中(同一 id 不能被 merge 和 conflict/archive 等重复占用)
82
- - 未在决策中提及的记忆将自动保留(keep),无需为每条记忆输出 keep
83
- - merge 的 keepSource 必须是 ids 之一
84
- - 仅合并同类型条目(type 相同)
85
- - 不要编造 ids;只使用提供的 id
86
- - 重要性 1-5,合并后取最高
87
- - 只输出 JSON 数组,不要其他文字`;
88
-
89
44
  function totalChars(memories) {
90
45
  return memories.reduce((sum, m) => sum + (m.title?.length ?? 0) + (m.content?.length ?? 0), 0);
91
46
  }
@@ -154,6 +109,13 @@ export function buildOutcome(decisions) {
154
109
  byId[d.loser] = "conflict-archived";
155
110
  } else if (d.action === "update") {
156
111
  for (const id of d.ids) byId[id] = "updated";
112
+ } else if (d.action === "supersede") {
113
+ // Issue #126 review(Copilot):新动作此前在 outcome.byId 里没有 disposition,
114
+ // 审计行产了 receipt 却看不出这些目标被如何处置。
115
+ byId[d.winner] = "supersede-winner";
116
+ byId[d.loser] = "superseded-archived";
117
+ } else if (d.action === "differentiate") {
118
+ for (const id of d.ids ?? []) byId[id] = "differentiated";
157
119
  }
158
120
  }
159
121
  return { byId };
@@ -226,6 +188,32 @@ function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) {
226
188
  kind: "update",
227
189
  input_digest: hashDecisionInput([at(c.ids[0])].filter(Boolean))
228
190
  });
191
+ } else if (c.action === "supersede") {
192
+ // Issue #126:演进型裁决——record 记被取代的一方。winner 正文未被改动,
193
+ // 不需要自己的 receipt;loser 归档并追加"已被取代"注记。
194
+ receipts.push({
195
+ ...base,
196
+ receipt_id: randomUUID(),
197
+ record_id: c.loser,
198
+ kind: "supersede",
199
+ input_digest: hashDecisionInput([at(c.winner), at(c.loser)].filter(Boolean)),
200
+ winner_id: c.winner,
201
+ loser_id: c.loser
202
+ });
203
+ } else if (c.action === "differentiate") {
204
+ // Issue #126:互补型裁决——两条都保留,各记一条 receipt。数量没有变化,
205
+ // count_before/after 固定 1/1(该记录只是被追加了差异注记)。
206
+ for (const id of c.ids ?? []) {
207
+ receipts.push({
208
+ ...base,
209
+ receipt_id: randomUUID(),
210
+ record_id: id,
211
+ kind: "differentiate",
212
+ input_digest: hashDecisionInput([at(id)].filter(Boolean)),
213
+ count_before: 1,
214
+ count_after: 1
215
+ });
216
+ }
229
217
  }
230
218
  }
231
219
  return receipts;
@@ -385,11 +373,48 @@ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError
385
373
  * actually accepted — or omit the field entirely when the model declares no
386
374
  * reasoning capability at all.
387
375
  *
376
+ * Unset vs explicit 'none' (Issue #135 建议 4/5): these are different intents.
377
+ * Explicit 'none' = the user asked to omit the field (provider default
378
+ * applies). Unset is the out-of-the-box state — dream/sleep calls must produce
379
+ * JSON, and omission lets the harness substitute the model's defaultEffort
380
+ * (thinking-type models often default to high), which drains the token budget
381
+ * and returns an empty body. So unset resolves to the LOWEST effort the model
382
+ * declares instead of omitting; when the capability query is unavailable the
383
+ * field stays omitted (fail-safe, retry guard unchanged).
384
+ *
388
385
  * @returns a supported effort id, or null when no effort should be sent, or the
389
386
  * configured value unchanged when the capability query is unavailable.
390
387
  */
388
+
389
+ // 常识档位排序(越靠前推理开销越低);未知档位排最后(宁可交给显式配置)。
390
+ const EFFORT_RANK = { minimal: 0, off: 0, low: 1, medium: 2, high: 3, max: 4 };
391
+
392
+ function lowestSupportedEffort(reasoning) {
393
+ const ids = (reasoning?.efforts ?? []).map((e) => e?.id).filter(Boolean);
394
+ if (ids.length === 0) return null;
395
+ return [...ids].sort((a, b) => (EFFORT_RANK[a] ?? 99) - (EFFORT_RANK[b] ?? 99))[0];
396
+ }
397
+
391
398
  async function resolveDreamEffort(ctx, route, configuredEffort, logger) {
392
- if (!configuredEffort || configuredEffort === "none") return null;
399
+ // 显式 'none':用户要求省略字段(服务商自带默认生效),照旧。
400
+ if (configuredEffort === "none") return null;
401
+ if (!configuredEffort) {
402
+ // 未配置(开箱默认):取模型声明的最低档,避免 defaultEffort 顶上(建议 4/5)。
403
+ if (typeof ctx?.llm?.resolveModelInfo !== "function") return null;
404
+ try {
405
+ const info = await ctx.llm.resolveModelInfo(route.provider, route.model);
406
+ const reasoning = info?.reasoning;
407
+ if (!reasoning) return null; // 无推理声明:省略即安全(无 defaultEffort 可顶上)
408
+ const lowest = lowestSupportedEffort(reasoning);
409
+ if (!lowest) return null;
410
+ logger?.info?.(`dsh-mneme dream: no reasoningEffort configured for ${route.provider}:${route.model}; using lowest supported "${lowest}" instead of the model default "${reasoning.defaultEffort ?? "n/a"}"`);
411
+ return lowest;
412
+ } catch (error) {
413
+ // 能力查询失败:省略字段(fail-safe),withEffortFallback 照旧兜底。
414
+ logger?.warn?.(`dsh-mneme dream: resolveModelInfo failed (${String(error?.message ?? error)}); omitting reasoningEffort`);
415
+ return null;
416
+ }
417
+ }
393
418
  // Capability query unavailable (older harness / minimal mocks): forward the
394
419
  // configured value as before — absence of the API proves nothing about the
395
420
  // model, and withEffortFallback still guards against rejection.
@@ -480,12 +505,112 @@ async function collectVectors(memories, semantic) {
480
505
  return vectors.some((v) => !v) ? null : vectors;
481
506
  }
482
507
 
508
+ /**
509
+ * Issue #125:把高相似对聚成**连通分量组**——一个簇 = 一个候选单元。直接按对展开
510
+ * 会把同一簇拆成互相抢占的多个决策(校验器有 one-claim 规则),成组才是正确粒度
511
+ * (与 sleep 冲突阶段的贪心"每记忆每轮最多进一对"同理)。
512
+ * 组内最小相似度用于组间排序:最该处理的簇先入选。
513
+ */
514
+ function similarityGroups(pairs) {
515
+ const parent = new Map();
516
+ const find = (x) => {
517
+ let root = x;
518
+ while (parent.get(root) !== root) root = parent.get(root);
519
+ return root;
520
+ };
521
+ for (const p of pairs) {
522
+ if (!parent.has(p.a.id)) parent.set(p.a.id, p.a.id);
523
+ if (!parent.has(p.b.id)) parent.set(p.b.id, p.b.id);
524
+ const ra = find(p.a.id);
525
+ const rb = find(p.b.id);
526
+ if (ra !== rb) parent.set(ra, rb);
527
+ }
528
+ const byRoot = new Map();
529
+ for (const p of pairs) {
530
+ for (const m of [p.a, p.b]) {
531
+ const root = find(m.id);
532
+ if (!byRoot.has(root)) byRoot.set(root, new Map());
533
+ byRoot.get(root).set(m.id, m);
534
+ }
535
+ }
536
+ return [...byRoot.values()].map((members) => {
537
+ const list = [...members.values()];
538
+ const ids = new Set(list.map((m) => m.id));
539
+ let minSim = 1;
540
+ for (const p of pairs) {
541
+ if (ids.has(p.a.id) && ids.has(p.b.id)) minSim = Math.min(minSim, p.similarity);
542
+ }
543
+ return { members: list, minSim };
544
+ });
545
+ }
546
+
547
+ /**
548
+ * Issue #125:为候选构造收集向量。与 collectVectors 的差别是**允许部分缺失**——
549
+ * 全量库只要有几条嵌入失败就整场返回 null,会让 hybrid 在真实库上几乎永远回落;
550
+ * 拿不到向量的条目只是不参与比较(cosineSimilarity 对非数组返回 0,不会误判)。
551
+ */
552
+ async function collectCandidateVectors(memories, semantic, logger) {
553
+ const { embedder, vectorIndex } = semantic;
554
+ const vectors = new Array(memories.length);
555
+ const missing = [];
556
+ for (let i = 0; i < memories.length; i++) {
557
+ const cached = vectorIndex.getEmbedding?.(memories[i].id);
558
+ if (cached) vectors[i] = cached;
559
+ else missing.push(i);
560
+ }
561
+ if (missing.length) {
562
+ try {
563
+ const texts = missing.map((i) => [memories[i].title, memories[i].content].filter(Boolean).join("\n"));
564
+ const rows = await embedder.embed(texts);
565
+ missing.forEach((mi, j) => {
566
+ if (rows[j]?.length) {
567
+ vectors[mi] = rows[j];
568
+ vectorIndex.saveEmbedding?.(memories[mi].id, rows[j]);
569
+ }
570
+ });
571
+ } catch (error) {
572
+ logger?.warn?.(`[dsh-mneme] dream hybrid vector backfill failed: ${String(error)}`);
573
+ }
574
+ }
575
+ return vectors;
576
+ }
577
+
578
+ /**
579
+ * Issue #125:hybrid 候选成员,按"组内最小相似度"降序展平(高价值簇排前面,
580
+ * 受上限截断时先入选)。同类型比较沿用 findPotentialConflicts;仅当
581
+ * allowCrossTypeMerge 显式开启时再补跨类型高相似对。
582
+ * 任何失败都返回空数组——调用方回落纯窗口,dream 路径绝不因向量层而失败。
583
+ */
584
+ async function hybridCandidateMembers(allMemories, config, semantic, logger) {
585
+ try {
586
+ const vectors = await collectCandidateVectors(allMemories, semantic, logger);
587
+ const minSim = config.dreamCandidateMinSim ?? 0.85;
588
+ const pairs = findPotentialConflicts(allMemories, vectors, minSim);
589
+ if (config.allowCrossTypeMerge === true) {
590
+ for (let i = 0; i < allMemories.length; i++) {
591
+ for (let j = i + 1; j < allMemories.length; j++) {
592
+ if (allMemories[i].type === allMemories[j].type) continue; // 同类型已由上面覆盖
593
+ const sim = cosineSimilarity(vectors[i], vectors[j]);
594
+ if (sim > minSim) pairs.push({ a: allMemories[i], b: allMemories[j], similarity: sim });
595
+ }
596
+ }
597
+ }
598
+ if (pairs.length === 0) return [];
599
+ return similarityGroups(pairs)
600
+ .sort((g1, g2) => g2.minSim - g1.minSim)
601
+ .flatMap((g) => g.members);
602
+ } catch (error) {
603
+ logger?.warn?.(`[dsh-mneme] dream hybrid candidate expansion failed (falling back to window): ${String(error)}`);
604
+ return [];
605
+ }
606
+ }
607
+
483
608
  /**
484
609
  * Rebuild the vector index after dream decisions so the store and the index
485
610
  * stay in sync: merged-away/archived/conflict-loser rows lose their vectors,
486
611
  * the merge keeper gets a fresh one.
487
612
  */
488
- async function maintainIndexAfterDream(decisions, service, semantic) {
613
+ export async function maintainIndexAfterDream(decisions, service, semantic) {
489
614
  const { embedder, vectorIndex } = semantic;
490
615
  if (!embedder || !vectorIndex || typeof embedder.embedSingle !== "function") return;
491
616
  const rebuild = new Map();
@@ -500,6 +625,18 @@ async function maintainIndexAfterDream(decisions, service, semantic) {
500
625
  }
501
626
  } else if (d.action === "archive" || d.action === "conflict") {
502
627
  for (const id of d.ids ?? [d.loser]) vectorIndex.deleteEmbedding(id);
628
+ } else if (d.action === "supersede") {
629
+ // Issue #126 review(Copilot):被取代的一方已归档 → 移除其向量(与 conflict 的
630
+ // loser 同等处置)。winner 正文未变化,向量保持有效。
631
+ vectorIndex.deleteEmbedding(d.loser);
632
+ } else if (d.action === "differentiate") {
633
+ // Issue #126 review(Copilot):双方正文都被追加了差异注记,而 update 在事务内
634
+ // 会因 txDepth>0 跳过 scheduleEmbed —— 不在这里重嵌,注记就进不了 embedding,
635
+ // 注释里承诺的"下一轮不再判成重复"就不成立。
636
+ for (const id of d.ids ?? []) {
637
+ const mem = service.getById(id);
638
+ if (mem) rebuild.set(id, [mem.title, mem.content].filter(Boolean).join("\n"));
639
+ }
503
640
  } else if (d.action === "update") {
504
641
  const id = d.ids[0];
505
642
  const mem = service.getById(id);
@@ -594,6 +731,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
594
731
  }
595
732
 
596
733
  async function runDream(ctx, service, config) {
734
+ const language = langOf(config);
597
735
  const logger = ctx.logger;
598
736
  let memories = service.all().filter((m) => !m.archived && m.type !== "summary");
599
737
  if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
@@ -601,7 +739,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
601
739
  // 窗口外的旧记忆不进 snapshot(大记忆量下全量快照会撑爆 LLM 输入,配合
602
740
  // 隐式 keep 让 run 始终可收敛)。按 updated_at 倒序取前 maxSize 条。
603
741
  const maxSize = Number.isInteger(config.dreamMaxSnapshotSize) ? config.dreamMaxSnapshotSize : 200;
604
- memories = [...memories]
742
+ const windowMemories = [...memories]
605
743
  .sort((a, b) => {
606
744
  const ta = String(a.updated_at ?? "");
607
745
  const tb = String(b.updated_at ?? "");
@@ -610,6 +748,31 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
610
748
  return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
611
749
  })
612
750
  .slice(0, Math.max(1, maxSize));
751
+ // Issue #125:hybrid 档在窗口之外并入向量翻出的高相似组。候选**总量**仍由
752
+ // maxSize(或显式 dreamCandidateMax)封顶、与库总量解耦——用"按相似度取候选"
753
+ // 替代"调大窗口",输入成本才不随库增长。封顶截断时向量组优先于窗口尾部,
754
+ // 否则"该合并的一对"仍会被时间窗挤掉。
755
+ let selected = windowMemories;
756
+ if ((config.dreamCandidateMode ?? "window") === "hybrid" && semantic?.embedder && semantic?.vectorIndex) {
757
+ const extra = await hybridCandidateMembers(memories, config, semantic, logger);
758
+ if (extra.length > 0) {
759
+ const cap = Math.max(
760
+ Math.max(1, maxSize),
761
+ Number.isInteger(config.dreamCandidateMax) && config.dreamCandidateMax > 0 ? config.dreamCandidateMax : 0
762
+ );
763
+ const merged = [];
764
+ const seen = new Set();
765
+ for (const m of [...extra, ...windowMemories]) {
766
+ if (seen.has(m.id)) continue;
767
+ seen.add(m.id);
768
+ merged.push(m);
769
+ if (merged.length >= cap) break;
770
+ }
771
+ selected = merged;
772
+ logger?.info?.(`[dsh-mneme] dream hybrid candidates: ${merged.length} (window ${windowMemories.length}, vector ${extra.length}, cap ${cap})`);
773
+ }
774
+ }
775
+ memories = selected;
613
776
  const snapshot = new Map(memories.map((m) => [m.id, m]));
614
777
  const route = resolveRoute(ctx, config, logger);
615
778
  const runId = randomUUID();
@@ -664,7 +827,12 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
664
827
  outcome: result.outcome,
665
828
  applied,
666
829
  summary_stored: summaryStored,
667
- receipt
830
+ receipt,
831
+ // Issue #104:degraded(合法子集已应用)轮被跳过的决策明细。写成独立字段
832
+ // 而非内联进 decisions——degraded 的 decisions 是合法子集,下游按「决策
833
+ // 数组」消费,内联标记会污染其它读者(失败轮的 _validationFailed 是整单
834
+ // 拒绝哨兵,语义与「部分应用」不同,也不复用)。
835
+ skipped: result.skipped
668
836
  });
669
837
  } catch (error) {
670
838
  logger?.warn?.(`dsh-mneme dream: failed to record audit run: ${String(error)}`);
@@ -687,11 +855,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
687
855
  const conflictIds = new Set(conflicts.flatMap((c) => [c.a.id, c.b.id]));
688
856
  const parts = [];
689
857
  clusters.forEach((cluster, ci) => {
690
- parts.push(`# 聚类 ${ci + 1}`);
858
+ parts.push(STR.clusterHeader[language](ci + 1));
691
859
  for (const m of cluster) {
692
860
  parts.push(
693
861
  `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}` +
694
- (conflictIds.has(m.id) ? " | [潜在冲突]" : "")
862
+ (conflictIds.has(m.id) ? STR.conflictMark[language] : "")
695
863
  );
696
864
  }
697
865
  });
@@ -712,8 +880,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
712
880
  // winner/loser (validation requires them) but they are treated as tentative
713
881
  // candidates — the human makes the final call, not the model.
714
882
  const consolidationPrompt = freezeEnabled
715
- ? CONSOLIDATION_PROMPT + `\n\n当前为「冲突冻结」模式:检测到内容矛盾的条目时,仍请输出 conflict,并以 winner/loser 作为候选、reason 说明理由;冲突不会被自动裁决,而会冻结待人工确认。`
716
- : CONSOLIDATION_PROMPT;
883
+ ? STR.prompts.consolidation[language] + STR.prompts.freezeSuffix[language]
884
+ : STR.prompts.consolidation[language];
717
885
  let decisionText;
718
886
  // 加固(v0.8.1):配置的 reasoningEffort 被 provider 拒收时回退重试一次
719
887
  // (不带该字段),避免 thinking 模型配置 low/medium 直接整单失败。解析放
@@ -764,7 +932,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
764
932
  logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0}; head: ${head})`);
765
933
  return finish({ ok: false, error: "no json array in llm output", summary: false });
766
934
  }
767
- const { ok, errors, skipped } = validateDecisions(decisions, snapshot, {
935
+ const { ok, errors, skipped, resolvedShortIds } = validateDecisions(decisions, snapshot, {
768
936
  maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
769
937
  minAgeHours: config.reflectionUpdateMinAgeHours,
770
938
  // v0.4.4 fix:显式透传,用户配 dreamImplicitKeep:false 时严格模式必须
@@ -776,9 +944,28 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
776
944
  skipInvalid: config.dreamSkipInvalid !== false,
777
945
  allowCrossTypeMerge: config.allowCrossTypeMerge === true
778
946
  });
947
+ // Issue #135:模型把 UUID 缩写成前缀时,唯一前缀已在校验前被解析回完整 id。
948
+ // 解析量是模型输出质量的一个直接信号(>0 意味着模型在缩写 id),记一条 info
949
+ // 便于事后从日志侧观察该行为的分布。
950
+ if (resolvedShortIds > 0) {
951
+ logger?.info?.(`[dsh-mneme] dream: resolved ${resolvedShortIds} short id prefix(es) to full ids`);
952
+ }
779
953
  if (!ok) {
954
+ // Issue #135(观测缺口):失败轮的逐条校验明细此前既不落库也基本不可见——
955
+ // skipped 的 warn 写在 return 之后(失败路径到不了),dream_runs.decisions
956
+ // 落 NULL,上百轮失败零现场。这里把明细前置到日志,并把
957
+ // { _validationFailed, errors, skipped } 随失败行持久化,事后可从审计行
958
+ // 直接定位「模型输出了什么、为何逐条非法」。
780
959
  logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
781
- return finish({ ok: false, error: `invalid decisions: ${errors.length} errors`, summary: false });
960
+ if (skipped.length > 0) {
961
+ logger?.warn?.(`dsh-mneme dream: ${skipped.length} invalid decision(s) skipped: ${skipped.map((s) => `decision[${s.index}]: ${s.error}`).join("; ")}`);
962
+ }
963
+ return finish({
964
+ ok: false,
965
+ error: `invalid decisions: ${errors.length} errors`,
966
+ summary: false,
967
+ decisions: [{ _validationFailed: true, errors, skipped }]
968
+ });
782
969
  }
783
970
  const skippedInvalid = skipped.length > 0;
784
971
  if (skippedInvalid) {
@@ -902,7 +1089,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
902
1089
  maxTokens: config.dreamMaxTokens ?? 2048,
903
1090
  ...(withEffort && effort ? { reasoningEffort: effort } : {}),
904
1091
  messages: [
905
- { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
1092
+ { role: "system", content: [{ type: "text", text: STR.prompts.dreamSummary[language] }] },
906
1093
  { role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
907
1094
  ]
908
1095
  }, reportUsage, (reason) => { summaryStreamFailure = describeStreamFailure(reason); }));
@@ -920,7 +1107,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
920
1107
  // must REPLACE the previous overview (not append — that would grow the
921
1108
  // summary unboundedly). `_overwrite` still archives the old overview into
922
1109
  // content_history before replacing it.
923
- service.saveWithDedupe({ type: "summary", title: "记忆库总览", content: summaryText.trim(), importance: 5, source: "dream", _overwrite: true });
1110
+ service.saveWithDedupe({ type: "summary", title: STR.summaryTitle[language], content: summaryText.trim(), importance: 5, source: "dream", _overwrite: true });
924
1111
  summaryStored = true;
925
1112
  // Re-embed the fresh summary so the index stays in sync with the store.
926
1113
  if (semantic?.embedder && semantic?.vectorIndex) {
@@ -966,7 +1153,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
966
1153
  conflicts,
967
1154
  failures,
968
1155
  frozen: frozenCount,
969
- summary: summaryStored
1156
+ summary: summaryStored,
1157
+ // Issue #104:只有真发生跳过时才落库(degraded 也可能仅因 summary 为空),
1158
+ // ok 轮留 NULL,避免「空数组」与「无此字段」两种假明细占据审计行。
1159
+ skipped: skippedInvalid ? skipped : undefined
970
1160
  });
971
1161
  }
972
1162
 
package/src/embedding.js CHANGED
@@ -81,9 +81,13 @@ export function createEmbedder({ store, settings, logger, vectorIndex }) {
81
81
  } catch { /* metadata write is best-effort */ }
82
82
  }
83
83
 
84
+ // issue #135: 「配置是否齐备」的唯一判据 —— embed()/embedFor() 的守卫与下面
85
+ // ready/configured 两个 getter 共用,避免以后改一处漏一处。
86
+ const configComplete = (cfg) => !!(cfg?.enabled && cfg.baseUrl && cfg.apiKey && cfg.model);
87
+
84
88
  async function embedFor(id, title, content) {
85
89
  const cfg = settings.getVectorConfig();
86
- if (!cfg?.enabled || !cfg.baseUrl || !cfg.apiKey || !cfg.model) return;
90
+ if (!configComplete(cfg)) return;
87
91
  const text = [title, content].filter(Boolean).join("\n");
88
92
  const vector = await embedText(cfg, text);
89
93
  if (vector) {
@@ -100,6 +104,22 @@ export function createEmbedder({ store, settings, logger, vectorIndex }) {
100
104
  // Display name for /semantic: a literal's constructor.name is "Object",
101
105
  // which the status card would render verbatim.
102
106
  name: "OpenAI",
107
+ /**
108
+ * issue #135: 这个 embedder 没有异步初始化,`"ready" in embedder` 的通用
109
+ * 分支会把它恒判为可用 —— 而 vector-config 四项有缺时它一条都嵌不出来。
110
+ * 对外报真实可用性,让 /semantic 的 ready 不再是「绿色假阳性」。
111
+ * 用 getter 而非快照字段:设置面板改完配置无需重启即生效(与 embed() 同源读取)。
112
+ */
113
+ get ready() {
114
+ return configComplete(settings.getVectorConfig());
115
+ },
116
+ /**
117
+ * 与 ready 同源,分开两个名字是给 /semantic 用:local/ollama 的
118
+ * ready=false 表示「还在初始化」,而这里的 false 表示「根本没配」。
119
+ */
120
+ get configured() {
121
+ return configComplete(settings.getVectorConfig());
122
+ },
103
123
  /** Fire-and-forget re-embed of a memory after any write. */
104
124
  schedule(memory) {
105
125
  if (!memory?.id) return;
@@ -109,7 +129,7 @@ export function createEmbedder({ store, settings, logger, vectorIndex }) {
109
129
  /** Embed one text and return its vector (null on failure/disabled). */
110
130
  async embed(query) {
111
131
  const cfg = settings.getVectorConfig();
112
- if (!cfg?.enabled || !cfg.baseUrl || !cfg.apiKey || !cfg.model) return null;
132
+ if (!configComplete(cfg)) return null;
113
133
  const vector = await embedText(cfg, query);
114
134
  if (vector) _dimension = vector.length;
115
135
  return vector;
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ import { createEmbedderByProvider } from "./local-embedder.js";
15
15
  import { LocalReranker } from "./reranker.js";
16
16
  import { createVectorIndex } from "./vector-index.js";
17
17
  import { Config, applyLightModePreset } from "./config.js";
18
+ import { langOf } from "./lang.js";
18
19
  import { extractEntities } from "./entities/extractor.js";
19
20
  import { mkdirSync } from "node:fs";
20
21
  import { join } from "node:path";
@@ -86,6 +87,44 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger }) {
86
87
  };
87
88
  }
88
89
 
90
+ /**
91
+ * Issue #128: bounded backfill of rows still missing an embedding (active rows
92
+ * only — needsEmbedding filters archived/forgotten). Exported for tests.
93
+ *
94
+ * Runs regardless of the model fingerprint: the old call-site gate returned
95
+ * early when vector_meta already held the embedder's hash, permanently
96
+ * orphaning rows whose embed failed at write time (embedder not ready /
97
+ * provider rate limit) — one successful embed was enough to never backfill
98
+ * again. markModel is idempotent when the fingerprint already matches, so
99
+ * re-running costs nothing beyond the actually-missing rows.
100
+ */
101
+ export async function backfillMissingEmbeddings({
102
+ store, embedder, vectorIndex, logger,
103
+ maxTotal = 500, batchSize = 10, rateLimitMs = 200
104
+ }) {
105
+ let indexed = 0;
106
+ for (let done = 0; done < maxTotal;) {
107
+ const rows = store.needsEmbedding(batchSize);
108
+ if (!rows.length) break;
109
+ for (const row of rows) {
110
+ try {
111
+ const text = [row.title, row.content].filter(Boolean).join("\n");
112
+ const vector = await embedder.embedSingle(text);
113
+ if (vector?.length) {
114
+ store.setEmbedding(row.id, vector);
115
+ indexed++;
116
+ }
117
+ } catch { /* skip the bad row */ }
118
+ }
119
+ done += rows.length;
120
+ // Rate limit: space out batches so the provider is not hammered.
121
+ if (store.needsEmbedding(1).length) await new Promise((r) => setTimeout(r, rateLimitMs));
122
+ }
123
+ if (indexed > 0 && embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
124
+ logger?.info?.(`[dsh-mneme] auto-reindex backfilled ${indexed} embeddings on boot`);
125
+ return indexed;
126
+ }
127
+
89
128
  export const apply = (ctx, config) => {
90
129
  const rawCfg = Config(config);
91
130
 
@@ -148,7 +187,9 @@ export const apply = (ctx, config) => {
148
187
  cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
149
188
  }
150
189
 
151
- const mirror = createMirror(memoryDir);
190
+ // 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream /
191
+ // sleep / mirror,多实例(如 agent preset 内挂载)互不影响。
192
+ const mirror = createMirror(memoryDir, langOf(cfg));
152
193
  const service = createService({ store, mirror, config: cfg, logger: ctx.logger });
153
194
 
154
195
  // F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry
@@ -212,7 +253,18 @@ export const apply = (ctx, config) => {
212
253
  // model fingerprint after each successful embed (Bug3).
213
254
  embedder = createEmbedder({ store, settings, logger: ctx.logger, vectorIndex });
214
255
  service.setEmbedder(embedder);
215
- // legacy OpenAI embedder is immediately usable
256
+ // issue #135: 未配置时明确告警一次。此前 legacy OpenAI embedder 恒报
257
+ // ready=true,向量层「绿的但全哑」可以静默存在很久(本机持续了数周)。
258
+ // 只记日志、不阻断启动:轻量模式与「先跑起来再补配置」都是正当用法。
259
+ if (embedder.configured === false) {
260
+ ctx.logger?.warn?.(
261
+ "[dsh-mneme] 向量层未配置(vector-config 的 enabled/baseUrl/apiKey/model 有缺):"
262
+ + "语义召回、语义去重、rerank、sleep 冲突检测将静默失效,"
263
+ + "dream 的语义聚类会退化为全量窗口兜底。"
264
+ + "请在设置面板补全 embedding 端点与模型,或把 embedProvider 改为 local/ollama。"
265
+ );
266
+ }
267
+ // legacy OpenAI embedder needs no async init → human edits apply right away
216
268
  applyHumanEdits();
217
269
  } else {
218
270
  try {
@@ -222,6 +274,7 @@ export const apply = (ctx, config) => {
222
274
  device: cfg.localEmbedDevice,
223
275
  batchSize: cfg.localEmbedBatchSize,
224
276
  cacheDir: cfg.embedModelCacheDir,
277
+ runtimeDir: cfg.runtimeDir,
225
278
  baseUrl: cfg.ollamaBaseUrl,
226
279
  logger: ctx.logger
227
280
  });
@@ -273,6 +326,7 @@ export const apply = (ctx, config) => {
273
326
  scoreThreshold: cfg.rerankScoreThreshold,
274
327
  device: cfg.localEmbedDevice,
275
328
  cacheDir: cfg.embedModelCacheDir,
329
+ runtimeDir: cfg.runtimeDir,
276
330
  logger: ctx.logger
277
331
  });
278
332
  service.setReranker(reranker);
@@ -287,10 +341,10 @@ export const apply = (ctx, config) => {
287
341
 
288
342
  // Bug2: lazy auto-backfill of missing embeddings on boot. When the vector API
289
343
  // is configured and rows still lack an embedding (e.g. written before vector
290
- // search was enabled) AND the vector_meta fingerprint is absent or stale, the
291
- // index is rebuilt in the background after a short delay. Gated on
292
- // cfg.autoReindexOnBoot; rate-limited in small batches so a large backlog
293
- // never floods the provider. Failures degrade silently — search stays keyword.
344
+ // search was enabled), the backfill runs in the background after a short
345
+ // delay. Gated on cfg.autoReindexOnBoot; rate-limited in small batches so a
346
+ // large backlog never floods the provider. Failures degrade silently —
347
+ // search stays keyword.
294
348
  function scheduleAutoReindex() {
295
349
  if (cfg.autoReindexOnBoot === false) return;
296
350
  const attempt = (tries) => {
@@ -303,36 +357,13 @@ export const apply = (ctx, config) => {
303
357
  return;
304
358
  }
305
359
  if (!store.needsEmbedding(1).length) return; // nothing to backfill
306
- // Model fingerprint gate: vectors already produced by the same model
307
- // mean there is no drift and no rebuild needed.
308
- const current = embedder.modelHash;
309
- if (current && vectorIndex.modelHash?.() === current) return;
310
- const BATCH = 10;
311
- const MAX_TOTAL = 500; // bound boot-time work
312
- (async () => {
313
- let indexed = 0;
314
- for (let done = 0; done < MAX_TOTAL;) {
315
- const rows = store.needsEmbedding(BATCH);
316
- if (!rows.length) break;
317
- for (const row of rows) {
318
- try {
319
- const text = [row.title, row.content].filter(Boolean).join("\n");
320
- const vector = await embedder.embedSingle(text);
321
- if (vector?.length) {
322
- store.setEmbedding(row.id, vector);
323
- indexed++;
324
- }
325
- } catch { /* skip the bad row */ }
326
- }
327
- done += rows.length;
328
- // Rate limit: space out batches so the provider is not hammered.
329
- if (store.needsEmbedding(1).length) await new Promise((r) => setTimeout(r, 200));
330
- }
331
- if (indexed > 0 && current) vectorIndex.markModel?.(current, embedder.dimension);
332
- ctx.logger?.info?.(`[dsh-mneme] auto-reindex backfilled ${indexed} embeddings on boot`);
333
- })().catch((error) => {
334
- ctx.logger?.warn?.(`[dsh-mneme] auto-reindex failed: ${String(error)}`);
335
- });
360
+ // Issue #128: no fingerprint gate here anymore — a matching fingerprint
361
+ // used to return early and permanently orphan rows whose embed failed
362
+ // at write time. See backfillMissingEmbeddings().
363
+ backfillMissingEmbeddings({ store, embedder, vectorIndex, logger: ctx.logger })
364
+ .catch((error) => {
365
+ ctx.logger?.warn?.(`[dsh-mneme] auto-reindex failed: ${String(error)}`);
366
+ });
336
367
  } catch (error) {
337
368
  ctx.logger?.warn?.(`[dsh-mneme] auto-reindex failed: ${String(error)}`);
338
369
  }
@@ -345,7 +376,7 @@ export const apply = (ctx, config) => {
345
376
  // on boot; add/remove re-register live through the API.
346
377
  let commands = null;
347
378
  if (ctx.commands) {
348
- commands = createCommandManager({ ctx, settings, logger: ctx.logger });
379
+ commands = createCommandManager({ ctx, settings, logger: ctx.logger, language: langOf(cfg) });
349
380
  commands.sync();
350
381
  }
351
382