@modusensus/dsh-mneme 0.7.31 → 0.8.0

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 (93) hide show
  1. package/README.md +65 -27
  2. package/lib/api.js +139 -6
  3. package/lib/client.js +369 -32
  4. package/lib/commands.js +36 -5
  5. package/lib/config.js +101 -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 +54 -13
  12. package/lib/lang.js +168 -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/scope.js +83 -0
  25. package/lib/service.js +374 -55
  26. package/lib/settings.js +43 -2
  27. package/lib/store.js +126 -13
  28. package/lib/summarize.js +108 -35
  29. package/lib/tools.js +205 -12
  30. package/package.json +7 -2
  31. package/runtime-manifest.json +596 -0
  32. package/scripts/build-runtime-manifest.mjs +217 -0
  33. package/scripts/mneme-runtime.mjs +277 -0
  34. package/src/api.js +139 -6
  35. package/src/commands.js +36 -5
  36. package/src/config.js +101 -14
  37. package/src/dream/decisions.js +147 -14
  38. package/src/dream/sleep.js +84 -34
  39. package/src/dream.js +250 -60
  40. package/src/embedding.js +22 -2
  41. package/src/index.js +68 -37
  42. package/src/inject.js +54 -13
  43. package/src/lang.js +168 -0
  44. package/src/local-embedder.js +23 -9
  45. package/src/mirror.js +17 -14
  46. package/src/reranker.js +23 -8
  47. package/src/runtime/adopt.js +176 -0
  48. package/src/runtime/closure.js +172 -0
  49. package/src/runtime/download.js +247 -0
  50. package/src/runtime/layout.js +235 -0
  51. package/src/runtime/loader.js +249 -0
  52. package/src/runtime/provision.js +237 -0
  53. package/src/runtime/tarball.js +130 -0
  54. package/src/runtime/verify.js +191 -0
  55. package/src/scope.js +83 -0
  56. package/src/service.js +374 -55
  57. package/src/settings.js +43 -2
  58. package/src/store.js +126 -13
  59. package/src/summarize.js +108 -35
  60. package/src/tools.js +205 -12
  61. package/test/api.test.js +175 -9
  62. package/test/client.test.js +152 -10
  63. package/test/commands.test.js +40 -3
  64. package/test/config.test.js +17 -1
  65. package/test/dream-candidate-hybrid.test.js +281 -0
  66. package/test/dream.test.js +172 -0
  67. package/test/embedding-ready.test.js +196 -0
  68. package/test/helpers/runtime-source.js +60 -0
  69. package/test/helpers/tar-builder.js +80 -0
  70. package/test/inject.test.js +130 -1
  71. package/test/lang.test.js +87 -0
  72. package/test/quality-filter.test.js +68 -0
  73. package/test/reasoning-effort.test.js +106 -15
  74. package/test/reindex-backfill.test.js +62 -0
  75. package/test/runtime-adopt.test.js +206 -0
  76. package/test/runtime-cli.test.js +89 -0
  77. package/test/runtime-closure.test.js +193 -0
  78. package/test/runtime-download.test.js +302 -0
  79. package/test/runtime-layout.test.js +163 -0
  80. package/test/runtime-loader.test.js +237 -0
  81. package/test/runtime-manifest.test.js +158 -0
  82. package/test/runtime-provision.test.js +101 -0
  83. package/test/runtime-tarball.test.js +82 -0
  84. package/test/runtime-verify.test.js +214 -0
  85. package/test/scope-retrieval.test.js +197 -0
  86. package/test/scope-storage.test.js +247 -0
  87. package/test/scope-strict.test.js +165 -0
  88. package/test/service.test.js +76 -0
  89. package/test/sleep-action-set.test.js +422 -0
  90. package/test/store.test.js +14 -0
  91. package/test/summarize.test.js +224 -0
  92. package/test/tools.test.js +30 -5
  93. package/test/webServer-optional.test.js +34 -1
package/src/store.js CHANGED
@@ -43,7 +43,8 @@ CREATE TABLE IF NOT EXISTS dream_runs (
43
43
  summary_stored INTEGER NOT NULL DEFAULT 0,
44
44
  receipt TEXT NOT NULL,
45
45
  policy_epoch INTEGER NOT NULL DEFAULT 0, -- 裁决规则版本:规则升级后旧裁决降级为历史证据
46
- run_type TEXT NOT NULL DEFAULT 'auto' -- auto | sleep:睡眠周期的审计区分
46
+ run_type TEXT NOT NULL DEFAULT 'auto', -- auto | sleep:睡眠周期的审计区分
47
+ skipped TEXT -- JSON: degraded 轮被跳过的逐条明细(index/action/ids/error)
47
48
  );
48
49
  CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
49
50
 
@@ -309,7 +310,9 @@ function escapeLike(q) {
309
310
  // 法值一律返回 undefined → 不进 WHERE(忽略而非报错:面板传坏参数时宁可放宽
310
311
  // 过滤也不要白屏)。updated_at 列是 toISOString 产生的 UTC "Z" 字符串,字典
311
312
  // 序与时间序一致,SQL 里可直接比较。
312
- function updatedAtBounds(updatedFrom, updatedTo) {
313
+ // v0.8.0 A2:导出复用——occurred_at 时间过滤(service 的搜索后置过滤)与
314
+ // updated_at 过滤共用同一套边界归一化,坏参数口径一致。
315
+ export function updatedAtBounds(updatedFrom, updatedTo) {
313
316
  const norm = (raw, endOfDay) => {
314
317
  if (typeof raw !== "string" || !raw.trim()) return undefined;
315
318
  const s = raw.trim();
@@ -331,6 +334,26 @@ function parseTags(raw) {
331
334
  }
332
335
  }
333
336
 
337
+ // scope 列(agent_scope/workspace_scope/sensitivity)写入归一化:非空 trim 字符串
338
+ // 原样收下,其余(undefined/null/空串/非字符串)一律 NULL。存储层不做枚举校验——
339
+ // scope 值是宿主身份标签(agentPreset id / 目录路径 / 自由 sensitivity 标签),
340
+ // 语义由调用方负责;这里只保证「脏输入落 NULL,绝不阻塞写入」。
341
+ function normalizeScopeText(raw) {
342
+ if (typeof raw !== "string") return null;
343
+ const s = raw.trim();
344
+ return s ? s : null;
345
+ }
346
+
347
+ // occurred_at:事件发生时间(区别于 created_at 的入库时间)。可解析的时间戳
348
+ // 统一归一到 UTC ISO(A2 的时间过滤要按字典序直接比较);解析失败落 NULL,
349
+ // 同样不阻塞写入。
350
+ function normalizeOccurredAt(raw) {
351
+ if (typeof raw !== "string" || !raw.trim()) return null;
352
+ const ms = Date.parse(raw.trim());
353
+ if (Number.isNaN(ms)) return null;
354
+ return new Date(ms).toISOString();
355
+ }
356
+
334
357
  function toRow(row) {
335
358
  if (!row) return undefined;
336
359
  return {
@@ -346,6 +369,10 @@ function toRow(row) {
346
369
  content_history: parseJsonArray(row.content_history),
347
370
  quality_score: row.quality_score !== null && row.quality_score !== undefined ? Number(row.quality_score) : undefined,
348
371
  epistemic_status: row.epistemic_status ?? "subjective",
372
+ agent_scope: row.agent_scope ?? undefined,
373
+ workspace_scope: row.workspace_scope ?? undefined,
374
+ sensitivity: row.sensitivity ?? undefined,
375
+ occurred_at: row.occurred_at ?? undefined,
349
376
  created_at: row.created_at,
350
377
  updated_at: row.updated_at,
351
378
  last_accessed_at: row.last_accessed_at ?? undefined,
@@ -371,7 +398,9 @@ function toDreamRun(row) {
371
398
  summary_stored: row.summary_stored === 1,
372
399
  receipt: row.receipt,
373
400
  policy_epoch: row.policy_epoch ?? 0,
374
- run_type: row.run_type ?? "auto"
401
+ run_type: row.run_type ?? "auto",
402
+ // Issue #104:degraded 轮的逐条跳过明细(JSON 列,NULL = 该轮无跳过项)。
403
+ skipped: row.skipped ? JSON.parse(row.skipped) : undefined
375
404
  };
376
405
  }
377
406
 
@@ -594,9 +623,20 @@ export function createStore(path) {
594
623
  addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
595
624
  addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
596
625
 
626
+ // v0.8.0 A1(issue #17)scope 隔离存储层:四列全部可空、不带 DEFAULT——存量行
627
+ // 零重写,读侧把 NULL 视为未标注(= 全局可见)。是否落值由 scopeEnabled 门控
628
+ // (service/tools 层),存储层只负责归一化(normalizeScopeText/normalizeOccurredAt)。
629
+ addColumn("memories", "agent_scope", "ALTER TABLE memories ADD COLUMN agent_scope TEXT");
630
+ addColumn("memories", "workspace_scope", "ALTER TABLE memories ADD COLUMN workspace_scope TEXT");
631
+ addColumn("memories", "sensitivity", "ALTER TABLE memories ADD COLUMN sensitivity TEXT");
632
+ addColumn("memories", "occurred_at", "ALTER TABLE memories ADD COLUMN occurred_at TEXT");
633
+
597
634
  // Legacy dream_runs without policy_epoch → backfill with the default epoch.
598
635
  addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
599
636
  addColumn("dream_runs", "run_type", "ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
637
+ // Issue #104:degraded(合法子集已应用)轮被跳过的决策明细。此前只进 logger.warn,
638
+ // 离线回放 dream_runs 无法定位 degraded 成因(跨类型 merge / update 保护期 / unknown id)。
639
+ addColumn("dream_runs", "skipped", "ALTER TABLE dream_runs ADD COLUMN skipped TEXT");
600
640
 
601
641
  // Legacy mirror_state without v0.3.6 generation columns → add each missing
602
642
  // column idempotently (old DBs open cleanly, no data loss).
@@ -637,7 +677,7 @@ export function createStore(path) {
637
677
  return ts;
638
678
  }
639
679
 
640
- function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null } = {}) {
680
+ function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
641
681
  const clauses = [];
642
682
  const params = [];
643
683
  if (type !== undefined) {
@@ -664,6 +704,33 @@ export function createStore(path) {
664
704
  clauses.push("updated_at <= ?");
665
705
  params.push(bounds.to);
666
706
  }
707
+ // occurred_at 闭区间:与 list() 同口径(COALESCE 回退 created_at),
708
+ // memory_list 的 total 才能和过滤后的行保持一致。
709
+ const occurred = updatedAtBounds(occurredFrom, occurredTo);
710
+ if (occurred.from) {
711
+ clauses.push("COALESCE(occurred_at, created_at) >= ?");
712
+ params.push(occurred.from);
713
+ }
714
+ if (occurred.to) {
715
+ clauses.push("COALESCE(occurred_at, created_at) <= ?");
716
+ params.push(occurred.to);
717
+ }
718
+ // occurred_at 闭区间:与 list() 同过滤(visibility 同口径),memory_list
719
+ // 的 total 才能和过滤后的行保持一致。
720
+ if (visibility) {
721
+ if (visibility.agentScope != null) {
722
+ clauses.push("(agent_scope IS NULL OR agent_scope = ?)");
723
+ params.push(visibility.agentScope);
724
+ } else {
725
+ clauses.push("agent_scope IS NULL");
726
+ }
727
+ if (visibility.workspaceScope != null) {
728
+ clauses.push("(workspace_scope IS NULL OR workspace_scope = ?)");
729
+ params.push(visibility.workspaceScope);
730
+ } else {
731
+ clauses.push("workspace_scope IS NULL");
732
+ }
733
+ }
667
734
  if (!includeForgotten) {
668
735
  clauses.push("forgotten = 0");
669
736
  }
@@ -708,8 +775,8 @@ export function createStore(path) {
708
775
  : inferEpistemicStatus(memory);
709
776
  runAtomically(() => {
710
777
  db.prepare(
711
- `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, created_at, updated_at)
712
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)`
778
+ `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, sensitivity, occurred_at, created_at, updated_at)
779
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
713
780
  ).run(
714
781
  id,
715
782
  type,
@@ -723,6 +790,10 @@ export function createStore(path) {
723
790
  Number.isFinite(memory.quality_score) ? memory.quality_score : null,
724
791
  embedding,
725
792
  epistemicStatus,
793
+ normalizeScopeText(memory.agent_scope),
794
+ normalizeScopeText(memory.workspace_scope),
795
+ normalizeScopeText(memory.sensitivity),
796
+ normalizeOccurredAt(memory.occurred_at),
726
797
  now,
727
798
  now
728
799
  );
@@ -930,7 +1001,7 @@ export function createStore(path) {
930
1001
  return rows.map(toRow);
931
1002
  }
932
1003
 
933
- function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null } = {}) {
1004
+ function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
934
1005
  const clauses = [];
935
1006
  const params = [];
936
1007
  if (type) {
@@ -958,6 +1029,36 @@ export function createStore(path) {
958
1029
  clauses.push("updated_at <= ?");
959
1030
  params.push(bounds.to);
960
1031
  }
1032
+ // v0.8.0 A2(issue #17):occurred_at 闭区间过滤——按「事件发生时间」检索。
1033
+ // 未标注 occurred_at 的行(含全部存量)回退 created_at 比较(COALESCE),
1034
+ // 否则过滤器对旧库近乎不可用;边界归一化与 updated_at 共用 updatedAtBounds。
1035
+ const occurred = updatedAtBounds(occurredFrom, occurredTo);
1036
+ if (occurred.from) {
1037
+ clauses.push("COALESCE(occurred_at, created_at) >= ?");
1038
+ params.push(occurred.from);
1039
+ }
1040
+ if (occurred.to) {
1041
+ clauses.push("COALESCE(occurred_at, created_at) <= ?");
1042
+ params.push(occurred.to);
1043
+ }
1044
+ // v0.8.0 A3(issue #17):strictScope 硬过滤(memory_list 分页路径)。
1045
+ // SQL 与 service.isVisibleInScope 同口径:未标注(NULL)恒可见;当前维度
1046
+ // 解析不到时该维度只放行未标注行(fail-closed)。只有显式传 visibility
1047
+ // 的用户面调用才过滤——dream/质量过滤等内部 store.list 调用不受影响。
1048
+ if (visibility) {
1049
+ if (visibility.agentScope != null) {
1050
+ clauses.push("(agent_scope IS NULL OR agent_scope = ?)");
1051
+ params.push(visibility.agentScope);
1052
+ } else {
1053
+ clauses.push("agent_scope IS NULL");
1054
+ }
1055
+ if (visibility.workspaceScope != null) {
1056
+ clauses.push("(workspace_scope IS NULL OR workspace_scope = ?)");
1057
+ params.push(visibility.workspaceScope);
1058
+ } else {
1059
+ clauses.push("workspace_scope IS NULL");
1060
+ }
1061
+ }
961
1062
  if (!includeForgotten) {
962
1063
  clauses.push("forgotten = 0");
963
1064
  }
@@ -1031,11 +1132,18 @@ export function createStore(path) {
1031
1132
  ).get().c;
1032
1133
  }
1033
1134
 
1034
- /** Candidate rows still missing an embedding, for incremental re-indexing. */
1135
+ /**
1136
+ * Candidate rows still missing an embedding, for incremental re-indexing.
1137
+ * Issue #128: active rows only — archived/forgotten rows never participate in
1138
+ * recall or dream clustering, so backfill quota must not be eaten by them
1139
+ * (report measured 25/50 reindex slots landing on archived rows while 88
1140
+ * active rows stayed un-embedded). Same 口径 as embeddedCount()/count().
1141
+ */
1035
1142
  function needsEmbedding(limit = 50) {
1036
1143
  return db.prepare(
1037
1144
  `SELECT id, title, content FROM memories
1038
- WHERE embedding IS NULL OR embedding = ''
1145
+ WHERE (embedding IS NULL OR embedding = '')
1146
+ AND forgotten = 0 AND archived = 0
1039
1147
  ORDER BY updated_at DESC LIMIT ?`
1040
1148
  ).all(limit);
1041
1149
  }
@@ -1119,16 +1227,20 @@ export function createStore(path) {
1119
1227
  const now = nowIso();
1120
1228
  const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
1121
1229
  const runType = run.run_type ?? "auto";
1230
+ // Issue #104:degraded 轮的跳过明细(JSON)。与 decisions/outcome 同为可选
1231
+ // 载荷,未提供时落 NULL(而不是 "[]"),审计行只记真实发生过的跳过。
1232
+ const skipped = run.skipped !== undefined ? JSON.stringify(run.skipped) : null;
1122
1233
  db.prepare(
1123
1234
  `INSERT INTO dream_runs (id, created_at, status, error, provider, model, snapshot_hash,
1124
- input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch, run_type)
1125
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1235
+ input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch, run_type, skipped)
1236
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1126
1237
  ON CONFLICT(id) DO UPDATE SET
1127
1238
  created_at=excluded.created_at, status=excluded.status, error=excluded.error,
1128
1239
  provider=excluded.provider, model=excluded.model, snapshot_hash=excluded.snapshot_hash,
1129
1240
  input_count=excluded.input_count, input=excluded.input, decisions=excluded.decisions,
1130
1241
  outcome=excluded.outcome, applied=excluded.applied, summary_stored=excluded.summary_stored,
1131
- receipt=excluded.receipt, policy_epoch=excluded.policy_epoch, run_type=excluded.run_type`
1242
+ receipt=excluded.receipt, policy_epoch=excluded.policy_epoch, run_type=excluded.run_type,
1243
+ skipped=excluded.skipped`
1132
1244
  ).run(
1133
1245
  id,
1134
1246
  run.created_at ?? now,
@@ -1145,7 +1257,8 @@ export function createStore(path) {
1145
1257
  run.summary_stored ? 1 : 0,
1146
1258
  run.receipt,
1147
1259
  policyEpoch,
1148
- runType
1260
+ runType,
1261
+ skipped
1149
1262
  );
1150
1263
  return getDreamRun(id);
1151
1264
  }
package/src/summarize.js CHANGED
@@ -1,22 +1,9 @@
1
1
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
2
-
3
- const SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容,提炼值得跨会话记住的原子记忆。
4
- 原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、名字、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。
5
- 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history","title":"简短标题","content":"保留原始细节的一句话","importance":1-5}。
6
- 不要输出任何其他文字。`;
2
+ import { STR, langOf } from "./lang.js";
7
3
 
8
4
  // 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
9
5
  // 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
10
6
  // title/content 单列结构(store 无结构化字段),信息浓缩进 content。
11
- const CODING_SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容(含用户输入、助手回答、工具调用与结果),提炼值得跨会话记住的原子记忆。
12
- 原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、报错信息、命令、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。
13
- 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history|rejected_solution|pitfall|constraint","title":"简短标题","content":"保留原始细节的一句话","importance":1-5}。
14
- 若对话涉及编码/调试,可额外提取编码类记忆:
15
- - rejected_solution:被否决/废弃的实现方案(content 含方案简述 + 被否决原因 + 最终采用方案)
16
- - pitfall:调试踩坑记录(content 含现象/报错 + 根因 + 解决/规避方法)
17
- - constraint:项目工程约束(content 含约束描述 + 来源)
18
- 普通闲聊、临时无关对话一律不提取编码类记忆。不要输出任何其他文字。`;
19
-
20
7
  /** Extract a JSON array from LLM output that may contain prose around it. */
21
8
  export function parseSummaryJson(raw) {
22
9
  const text = String(raw ?? "");
@@ -101,7 +88,7 @@ function toProtocolChunk(chunk) {
101
88
  // Privacy: assistant `reasoning` (private thought) blocks are deliberately NOT
102
89
  // collected — distilled memories must never sink private reasoning chains.
103
90
  // Only public text blocks (type "text") reach the summarizer.
104
- function collectMessages(session, maxChars = 8000) {
91
+ function collectMessages(session, maxChars = 8000, language = "zh") {
105
92
  // DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events
106
93
  const events = session.snapshotEvents?.() ?? session.events ?? [];
107
94
  const lines = [];
@@ -123,14 +110,14 @@ function collectMessages(session, maxChars = 8000) {
123
110
  case "user/message": {
124
111
  if (kind !== undefined && kind !== "user") break;
125
112
  const text = textOf(data?.content);
126
- if (text.trim()) lines.push(`用户:${text}`);
113
+ if (text.trim()) lines.push(STR.transcriptUser[language](text));
127
114
  break;
128
115
  }
129
116
  case "assistant/message": {
130
117
  const msg = data?.message;
131
118
  const blocks = Array.isArray(msg?.content) ? msg.content : [];
132
119
  const text = textOf(blocks);
133
- if (text.trim()) lines.push(`助手:${text}`);
120
+ if (text.trim()) lines.push(STR.transcriptAssistant[language](text));
134
121
  // 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。
135
122
  break;
136
123
  }
@@ -138,19 +125,19 @@ function collectMessages(session, maxChars = 8000) {
138
125
  const args = typeof data.arguments === "string"
139
126
  ? data.arguments
140
127
  : data.arguments ? JSON.stringify(data.arguments) : "";
141
- lines.push(`工具调用:${data.name ?? "?"}(${trim(args, 300)})`);
128
+ lines.push(STR.transcriptToolCall[language](data.name ?? "?", trim(args, 300)));
142
129
  break;
143
130
  }
144
131
  case "tool/result": {
145
132
  const out = typeof data.output === "string" ? data.output : data.output ? JSON.stringify(data.output) : "";
146
- const status = data.ok === false ? "失败" : "成功";
147
- lines.push(`工具结果(${status}):${trim(out, 500)}`);
133
+ const status = data.ok === false ? STR.statusFail[language] : STR.statusOk[language];
134
+ lines.push(STR.transcriptToolResult[language](status, trim(out, 500)));
148
135
  break;
149
136
  }
150
137
  case "tool/code-dispatch": {
151
138
  const out = typeof data.output === "string" ? data.output : data.output ? JSON.stringify(data.output) : "";
152
- const status = data.ok === false ? "失败" : "成功";
153
- lines.push(`代码执行(${status}):${trim(out, 500)}`);
139
+ const status = data.ok === false ? STR.statusFail[language] : STR.statusOk[language];
140
+ lines.push(STR.transcriptCode[language](status, trim(out, 500)));
154
141
  break;
155
142
  }
156
143
  default:
@@ -197,10 +184,54 @@ export function createSummarizer(ctx, service, config) {
197
184
  if (!config.autoSummarize) return { dispose: () => {} };
198
185
 
199
186
  const inFlight = new Map();
187
+ // Issue #127:per-session 上一次实际开跑时刻(最小间隔闸门用)。与 inFlight
188
+ // 同生命周期,dispose 时一并清空。
189
+ const lastRunAt = new Map();
200
190
  let disposed = false;
201
191
 
192
+ /** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */
193
+ function writeAudit(entry) {
194
+ if (config?.llmAudit?.enabled === false || typeof service.saveLlmAudit !== "function") return;
195
+ try {
196
+ service.saveLlmAudit({
197
+ trigger_source: "autoSummarize",
198
+ operation_type: "summarize_compress",
199
+ related_memory_ids: [],
200
+ ...entry
201
+ });
202
+ } catch (auditError) {
203
+ ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
204
+ }
205
+ }
206
+
202
207
  async function summarize(session) {
203
208
  if (disposed || inFlight.has(session.id)) return;
209
+
210
+ const header = session.requestHeader?.()?.config;
211
+ // Config override takes priority, then session header, then nothing.
212
+ const route = (config.summarizeProvider && config.summarizeModel)
213
+ ? { provider: config.summarizeProvider, model: config.summarizeModel }
214
+ : (header?.provider && header?.model)
215
+ ? { provider: header.provider, model: header.model }
216
+ : undefined;
217
+
218
+ // Issue #127 间隔门:同一会话两次蒸馏之间的最小间隔(0 = 现状,不限)。进入
219
+ // 即打点——失败/degraded 的 run 也占用间隔,与 dreamMinIntervalMinutes 语义
220
+ // 一致,防止失败连发。被挡下的 turn/end 静默跳过,但留一行 status='skipped'
221
+ // 审计,否则「这一轮为什么没蒸馏」对用户完全不可观测。
222
+ const gapMs = (config.summarizeMinIntervalMinutes ?? 0) * 60000;
223
+ const prevClaim = lastRunAt.get(session.id);
224
+ if (gapMs > 0 && Date.now() - (prevClaim ?? 0) < gapMs) {
225
+ writeAudit({
226
+ timestamp: new Date().toISOString(),
227
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
228
+ status: "skipped",
229
+ error_message: "min-interval"
230
+ });
231
+ return;
232
+ }
233
+ lastRunAt.set(session.id, Date.now());
234
+
204
235
  const controller = new AbortController();
205
236
  inFlight.set(session.id, controller);
206
237
  // audit state for the compression call. null = no audit for this run
@@ -209,18 +240,12 @@ export function createSummarizer(ctx, service, config) {
209
240
  // so a failed/aborted stream still leaves a status='error' trail without
210
241
  // ever blocking the summarization itself.
211
242
  let audit = null;
243
+ let abortedRun = false;
212
244
  try {
213
- const header = session.requestHeader?.()?.config;
214
- // Config override takes priority, then session header, then nothing.
215
- const route = (config.summarizeProvider && config.summarizeModel)
216
- ? { provider: config.summarizeProvider, model: config.summarizeModel }
217
- : (header?.provider && header?.model)
218
- ? { provider: header.provider, model: header.model }
219
- : undefined;
220
245
  if (!route) return;
221
246
  // 完整转录(Codex 式):蒸馏把整轮对话交给 LLM 提炼原子记忆,不再硬裁
222
247
  // 8000 字截断语义;上限由 distillMaxChars 控制(默认 24000,可调大)。
223
- const messages = collectMessages(session, config.distillMaxChars ?? 24000);
248
+ const messages = collectMessages(session, config.distillMaxChars ?? 24000, langOf(config));
224
249
  if (!messages.length) return;
225
250
 
226
251
  if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
@@ -240,7 +265,7 @@ export function createSummarizer(ctx, service, config) {
240
265
  model: route.model,
241
266
  purpose: "summarization",
242
267
  messages: [
243
- { role: "system", content: [{ type: "text", text: config.codingRetrospect ? CODING_SUMMARY_PROMPT : SUMMARY_PROMPT }] },
268
+ { role: "system", content: [{ type: "text", text: config.codingRetrospect ? STR.prompts.codingSummary[langOf(config)] : STR.prompts.summary[langOf(config)] }] },
244
269
  ...messages
245
270
  ],
246
271
  signal: controller.signal
@@ -314,8 +339,19 @@ export function createSummarizer(ctx, service, config) {
314
339
  }
315
340
  }
316
341
  }, intervalMs);
317
- if (aborted) return;
318
- const entries = parseSummaryJson(text || assembledText);
342
+ if (aborted) {
343
+ abortedRun = true;
344
+ return;
345
+ }
346
+ const parsed = parseSummaryJson(text || assembledText);
347
+ // Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进
348
+ // 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。
349
+ const cap = config.summarizeMaxEntriesPerRun ?? 0;
350
+ const entries = cap > 0 ? parsed.slice(0, cap) : parsed;
351
+ const capped = parsed.length - entries.length;
352
+ const dedupeMode = config.summarizeDedupeMode ?? "off";
353
+ let deduped = 0;
354
+ let dedupeMaxSim = 0;
319
355
  for (const entry of entries) {
320
356
  // Provenance: the summarizer runs on a real session (turn/end hook), so
321
357
  // session.id is always available here — it rides the human-readable
@@ -323,12 +359,45 @@ export function createSummarizer(ctx, service, config) {
323
359
  // 编码记忆类型(codingRetrospect)不带 tag:读取侧门控/加权靠 m.type
324
360
  // (rejected_solution/pitfall/constraint)区分即可,tag 体系
325
361
  // sanitizeTags 不认 `type:` 前缀反而会清空 tags 列(额外一次 UPDATE)。
362
+ const source = `session:${session.id}`;
363
+ // Issue #127 落库前去重(opt-in,默认 off = 现状):命中则并入既有条目
364
+ // (saveWithDedupe 的 _mergeInto 分支),不再新造一行。作用域 = 同会话 +
365
+ // 时间窗;跨会话的同类事实仍交给 autoDream / sleep 的批量裁决。
366
+ const dup = (dedupeMode !== "off" && typeof service.findSessionDuplicate === "function")
367
+ ? await service.findSessionDuplicate(
368
+ { ...entry, source },
369
+ {
370
+ mode: dedupeMode,
371
+ minSim: config.summarizeDedupeMinSim ?? 0.92,
372
+ windowHours: config.summarizeDedupeWindowHours ?? 24,
373
+ source
374
+ }
375
+ )
376
+ : undefined;
377
+ if (dup) {
378
+ deduped++;
379
+ dedupeMaxSim = Math.max(dedupeMaxSim, dup.sim ?? 0);
380
+ }
326
381
  service.saveWithDedupe({
327
382
  ...entry,
328
- source: `session:${session.id}`
383
+ source,
384
+ ...(dup ? { _mergeInto: dup.memory.id } : {})
329
385
  });
330
386
  }
387
+ if (audit && (capped > 0 || deduped > 0)) {
388
+ audit.metadata = {
389
+ parsed: parsed.length,
390
+ ...(capped > 0 ? { capped } : {}),
391
+ ...(deduped > 0 ? { deduped, mode: dedupeMode, maxSim: Number(dedupeMaxSim.toFixed(4)) } : {})
392
+ };
393
+ }
331
394
  } finally {
395
+ // Issue #127:aborted(会话关闭 / 插件 dispose)不占间隔,避免误伤该会话的
396
+ // 下一次蒸馏;其余情况(含失败)的打点保留,与 dreamMinIntervalMinutes 一致。
397
+ if (abortedRun || controller.signal.aborted) {
398
+ if (prevClaim === undefined) lastRunAt.delete(session.id);
399
+ else lastRunAt.set(session.id, prevClaim);
400
+ }
332
401
  if (audit) {
333
402
  try {
334
403
  service.saveLlmAudit({
@@ -343,7 +412,10 @@ export function createSummarizer(ctx, service, config) {
343
412
  duration_ms: Date.now() - audit.startedAt,
344
413
  status: audit.status,
345
414
  error_message: audit.errorMessage,
346
- related_memory_ids: []
415
+ related_memory_ids: [],
416
+ // Issue #127:parsed / capped / deduped 的观测数据。零 schema 变更
417
+ // (llm_audit_logs.metadata 已存在),一条 SQL 即可验证节流是否生效。
418
+ metadata: audit.metadata
347
419
  });
348
420
  } catch (auditError) {
349
421
  ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
@@ -371,6 +443,7 @@ export function createSummarizer(ctx, service, config) {
371
443
  unsubscribe?.();
372
444
  for (const controller of inFlight.values()) controller.abort();
373
445
  inFlight.clear();
446
+ lastRunAt.clear();
374
447
  }
375
448
  };
376
449
  }