@modusensus/dsh-mneme 0.8.16 → 0.9.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 (73) hide show
  1. package/README.md +16 -3
  2. package/assets/icon.svg +2 -2
  3. package/docs/CONFIGURATION.md +2 -1
  4. package/docs/CONTINUITY.md +18 -3
  5. package/docs/WORKFLOWS.md +299 -0
  6. package/lib/api.js +124 -8
  7. package/lib/candidates.js +454 -0
  8. package/lib/capabilities.js +819 -0
  9. package/lib/client.js +1703 -182
  10. package/lib/config.js +31 -5
  11. package/lib/continuity.js +35 -6
  12. package/lib/dream/decisions.js +17 -1
  13. package/lib/dream/sleep.js +10 -2
  14. package/lib/dream.js +5 -1
  15. package/lib/entities/extractor.js +4 -0
  16. package/lib/index.js +49 -12
  17. package/lib/inject.js +6 -1
  18. package/lib/pipeline.js +534 -0
  19. package/lib/scope.js +16 -4
  20. package/lib/serve.js +1 -1
  21. package/lib/service.js +59 -18
  22. package/lib/session-usage.js +350 -0
  23. package/lib/settings.js +7 -1
  24. package/lib/store.js +55 -3
  25. package/lib/summarize.js +31 -0
  26. package/lib/tools.js +11 -2
  27. package/package.json +1 -1
  28. package/scripts/publish-gate.mjs +197 -0
  29. package/src/api.js +124 -8
  30. package/src/candidates.js +454 -0
  31. package/src/capabilities.js +819 -0
  32. package/src/config.js +31 -5
  33. package/src/continuity.js +35 -6
  34. package/src/dream/decisions.js +17 -1
  35. package/src/dream/sleep.js +10 -2
  36. package/src/dream.js +5 -1
  37. package/src/entities/extractor.js +4 -0
  38. package/src/index.js +49 -12
  39. package/src/inject.js +6 -1
  40. package/src/pipeline.js +534 -0
  41. package/src/scope.js +16 -4
  42. package/src/serve.js +1 -1
  43. package/src/service.js +59 -18
  44. package/src/session-usage.js +350 -0
  45. package/src/settings.js +7 -1
  46. package/src/store.js +55 -3
  47. package/src/summarize.js +31 -0
  48. package/src/tools.js +11 -2
  49. package/test/api.test.js +147 -7
  50. package/test/candidates.test.js +345 -0
  51. package/test/capabilities.test.js +577 -0
  52. package/test/client.test.js +361 -2
  53. package/test/continuity.test.js +84 -0
  54. package/test/embed-ready-poll.test.js +94 -0
  55. package/test/entities.test.js +13 -2
  56. package/test/failure-query.test.js +147 -0
  57. package/test/failure-retention.test.js +105 -0
  58. package/test/graph-api.test.js +36 -2
  59. package/test/inject-recall.test.js +32 -4
  60. package/test/llm-audit.test.js +92 -1
  61. package/test/mint-source.test.js +202 -0
  62. package/test/pipeline.test.js +505 -0
  63. package/test/placement.test.js +1 -1
  64. package/test/publish-gate.test.js +272 -0
  65. package/test/recall-retention.test.js +128 -0
  66. package/test/release-workflow.test.js +77 -0
  67. package/test/scope-distill-stamp.test.js +135 -0
  68. package/test/serve.test.js +1 -1
  69. package/test/session-usage.test.js +308 -0
  70. package/test/store-write-path-columns.test.js +191 -0
  71. package/test/summarize.test.js +176 -0
  72. package/test/supersede-state-tracking.test.js +4 -4
  73. package/test/tools.test.js +16 -1
package/lib/config.js CHANGED
@@ -91,7 +91,8 @@ export const Config = z.object({
91
91
  // feature_flags 的值永远优先于这里的默认值。
92
92
  injectGuidanceEnabled: z.boolean().default(true),
93
93
  // #249 N3(压缩边缘双落点):上下文即将大幅精简前抢救「正在做什么」。默认关。
94
- // 触发靠宿主自己的压缩事件(`compaction/start|summary|end`,都在事件白名单里),
94
+ // 触发只认「历史真的被折叠了」的压缩事件(summary / prune / 不带 error 的 end;
95
+ // 判定见 continuity.js 的 isCompactionEdge —— start 只是「试过」,带 error 的 end 是失败收尾),
95
96
  // 不自定一套阈值参数——「压缩边缘」由宿主定义,我们再校准一份只会与之漂移。
96
97
  // 为什么是「新时机 + 新表面」因而默认关:它往对话里**追加消息**(新的注入表面,
97
98
  // 参照实现里最容易累积成一堆历史的那类),并多写一张提案表。按 §10 判据,引入
@@ -481,6 +482,13 @@ export const Config = z.object({
481
482
  reflectionFailureTracking: z.boolean().default(true),
482
483
  reflectionUpdateMaxPerRun: z.natural().min(0).max(5).default(2),
483
484
  reflectionUpdateMinAgeHours: z.natural().min(0).max(168).default(24),
485
+ // failure_memories 的启动期保留期(天)。此前在 index.js 里写死 90,是这一族
486
+ // 四张启动期清理表里唯一没有键可陈述的一条(#275 伞下的欠账)。形态对齐
487
+ // recallRetentionDays:顶层扁平键、只从配置文件改;暂不进 settings.js 白名单,
488
+ // 面板可调是后续的事(那要同时补 api.js 的 NESTED 表与 client.js 的面板行)。
489
+ // 下限取 1 而非 0:这张表的用途是「挖掘重复出现的纠正模式」,窗口太短就只剩
490
+ // 最近一次;要彻底停掉清理应当显式关掉 reflectionFailureTracking。
491
+ reflectionFailureRetentionDays: z.natural().min(1).max(3650).default(90),
484
492
 
485
493
  // --- conflict freeze: manual review for conflicting memories (v0.2.1) ---
486
494
  // Opt-in by default: when true, conflicting memories are not auto-merged
@@ -828,10 +836,15 @@ export const Config = z.object({
828
836
 
829
837
  // --- scope: v0.8.0 A1 存储层(issue #17)--------------------------------
830
838
  // 总开关默认关:关闭时写入不标注 scope、去重维持 (type, title) 现状,行为
831
- // 逐字节不变。开启后 memory_save 写入 agent_scope(session header 的
832
- // agentPreset)与 workspace_scope(registry 反查 canonical path,header.cwd
833
- // 兜底,取不到 NULL——解析绝不阻塞写入),去重键扩展为 (type, title,
834
- // agent_scope, workspace_scope, sensitivity)。检索侧加权/过滤在 A2/A3 落地。
839
+ // 逐字节不变。开启后**两条写入路径都自动盖章**(*_scope_source='auto'):
840
+ // ① 工具调用(tools.js:memory_save / memory_update,显式参数改标 'explicit')
841
+ // ② 机器提炼(summarize.js:会话蒸馏;合并进既有行时不盖章,作用域属于那一行
842
+ // 原来被提炼出来的语境——v0.9.0 补,此前只有工具路径盖章,于是「项目记忆」
843
+ // 在库里几乎不存在)。
844
+ // 两路的值同源:agent 维取 session header 的 agentPreset,workspace 维取
845
+ // registry 反查的 canonical path(header.cwd 兜底,取不到 NULL——解析绝不阻塞
846
+ // 写入),去重键扩展为 (type, title, agent_scope, workspace_scope, sensitivity)。
847
+ // 检索侧加权/过滤在 A2/A3 落地。
835
848
  // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
836
849
  scopeEnabled: z.boolean().default(false),
837
850
  // strictScope(A3):硬过滤模式。关闭=A2 软隔离(他 scope 降权保留可见,
@@ -842,6 +855,19 @@ export const Config = z.object({
842
855
  // 打开才有意义(没有写入标注就无可过滤维度),但独立成键:可先开标注积累
843
856
  // 数据、观察 A2 加权质量后再切硬过滤。
844
857
  strictScope: z.boolean().default(false),
858
+
859
+ // --- v0.9.0 M.O.(Modus Operandi · 手法,beta)--------------------------
860
+ // 命名:M.O. = Modus Operandi,拉丁语「一个人一贯的做事方式」,缩写 M.O.;
861
+ // 也正是 org 词根 modus(modusensus)。界面主显示 M.O.,设置项 hint 第一句
862
+ // 给全名——缩写不靠注释解释,靠用户第一眼能看见全名。
863
+ // 这是客户端只读视图(做法图谱 + 对话行结算卡)的门控。API 的 effective 取值只认
864
+ // config 顶层/嵌套路径,schema 里没有这个键 → effective 静默少键 → 客户端读
865
+ // 不到就一个座位都不注册(#254 漏登记 NESTED_FLAG_PATHS 是同一个坑)。
866
+ // 默认关:beta 功能必须 opt-in,老用户行为逐字节不变。不进 LIGHT_MODE_OFF——
867
+ // 它只是只读视图、不引入任何重型后台路径,轻量档用户显式勾选仍然赢(预设
868
+ // 该关的是重型能力,不该替用户关掉一个纯 UI 入口)。
869
+ // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
870
+ actionPipelineEnabled: z.boolean().default(false)
845
871
  });
846
872
 
847
873
  // Fields forced to false by the light-mode preset. Everything not listed here
package/lib/continuity.js CHANGED
@@ -15,9 +15,11 @@ import { createUserMessage } from "@deepseek-ai/dsh-llm";
15
15
  // 也只用本地字符串比较;门控成本为零。
16
16
  // 4. **触发器就是宿主真的压缩**,不自定一套阈值。宿主在 `agent/pre-step`(压力)与
17
17
  // `agent/request-error`(溢出)两处触发压缩,我们只订阅压缩事件
18
- // (`compaction/start|summary|end`,都在 @deepseek-ai/dsh-session 的事件白名单里
18
+ // (`compaction/*` 一族,都在 @deepseek-ai/dsh-session 的事件白名单里
19
19
  // `lib/types/known-event-types.js:31-34`)。自定阈值会与宿主口径漂移,还多一份没
20
20
  // 人校准的参数;「压缩边缘」本来就有宿主自己下的定义。
21
+ // **但发过压缩事件不等于这段历史真的被折叠了** —— 压缩插件在每一步都会试一次,
22
+ // 失败时没有任何东西需要抢救。记账口径见 isCompactionEdge。
21
23
  //
22
24
  // 落点顺序(为什么 pre-step 里挂得住):宿主的压缩插件在它自己的 pre-step 监听器里先
23
25
  // `await compactIfNeeded(...)` 再 `return next()`,所以我们的监听器拿到 `next()` 结果
@@ -38,7 +40,34 @@ export const CONTINUITY_NOTICE_MAX = 900;
38
40
  /** 边缘表的兜底上限:只留序号、且有界——压缩完就没有下一次 pre-step 的会话不走消费路径。 */
39
41
  const MAX_TRACKED_EDGES = 50;
40
42
 
41
- const EDGE_EVENT_TYPES = new Set(["compaction/start", "compaction/summary", "compaction/end"]);
43
+ /**
44
+ * 什么算「一个真的边缘」= 这段历史**真的被折叠了**。
45
+ *
46
+ * 这跟「有人发过压缩事件」不是同一个问题。压缩插件在**每一步**都会试一次压缩
47
+ * (`agent/pre-step` 里的 `compactIfNeeded`),所以 `compaction/start` 只在说
48
+ * 「有人试过」;尝试失败时没有任何东西需要抢救。按「试过就救」记账会得到一个
49
+ * **每步都触发**的触发器 —— 实测(2026-10-14,本机两个长会话、12,000+ 事件,
50
+ * 代理层把 dsh 原生压缩 100% 拒掉):
51
+ *
52
+ * · 按 `start|summary|end` 记账:1,287 次置位 / 359 次注入(4c28eaaa:2,895 / 625)
53
+ * · 按「真落地」记账: 39 次置位 / 32 次注入(4c28eaaa: 161 / 74)→ 降 91% / 88%
54
+ *
55
+ * 三种「真落地」:
56
+ * - `compaction/summary`:宿主压缩成功才落(dsh-compaction-basic 的
57
+ * `commitCompactionBody`,与成功路同步提交;失败路不落)。
58
+ * - `compaction/prune`:宿主把旧事件折叠出表面(dsh-compaction-tool-result-pruner),
59
+ * 是「表面真的变短了」最直接的证据。
60
+ * - `compaction/end`:**成败两条路都发**(同一份源码:失败路
61
+ * `append("compaction/end", { ...lifecycle, error: errorChain(error) })`),
62
+ * 所以只有**不带 error** 的那条才算落地。
63
+ */
64
+ function isCompactionEdge(event) {
65
+ const type = event?.type;
66
+ if (type === "compaction/summary" || type === "compaction/prune") return true;
67
+ if (type !== "compaction/end") return false;
68
+ const error = event?.data?.error;
69
+ return error === undefined || error === null || error === "";
70
+ }
42
71
 
43
72
  // 与 summarize.js 的 collectMessages 同一个口径:只取公开文本块,reasoning 私有推理
44
73
  // 不进快照(快照可能被宿主重建进摘要)。刻意副本而不是导出——那边是会话蒸馏的内部
@@ -148,21 +177,21 @@ function noticeAlreadyInSurface(session, text) {
148
177
  * 保持"挂上就是开"的单一口径。
149
178
  */
150
179
  export function createContinuityRescue(ctx, store) {
151
- // 待处理的边缘:sessionId → 压缩事件序号。压缩事件到达时置位,下一次 pre-step 消费后
180
+ // 待处理的边缘:sessionId → 压缩事件序号。**真落地**的压缩事件到达时置位,下一次 pre-step 消费后
152
181
  // 删除(同一个边缘绝不注入第二次)。只留序号、不留 Session:插件在宿主进程里只 apply
153
182
  // 一次、服务所有会话,握着 Session 就是握着它的全量日志不放——压缩完就收工、不再有
154
183
  // pre-step 是常见路径,这些条目永远走不到消费路径上。
155
184
  const edge = new Map();
156
185
 
157
186
  const unsubscribe = ctx.on("session/event", (session, event) => {
158
- if (!EDGE_EVENT_TYPES.has(event?.type)) return;
187
+ if (!isCompactionEdge(event)) return;
159
188
  if (!session?.id) return;
160
189
  if (!edge.has(session.id) && edge.size >= MAX_TRACKED_EDGES) {
161
190
  // 兜底淘汰最旧的(Map 是插入序):这里漏掉最坏也只少一次注入,不会无界增长。
162
191
  edge.delete(edge.keys().next().value);
163
192
  }
164
- // 同一轮压缩会依次落 start/summary/end,后到的覆盖前一个:留着最后一个(更有
165
- // 信息——summary 带摘要事件 seq),序号即触发证据。
193
+ // 同一轮可能出现多条落地事件(prune 与 end 并存),后到的覆盖前一个:留着最后一个,
194
+ // 序号即触发证据。
166
195
  edge.set(session.id, typeof event.seq === "number" ? event.seq : null);
167
196
  });
168
197
 
@@ -395,6 +395,12 @@ function casGuard(service, snapshot, ids) {
395
395
  * provided, every decision target is CAS-checked against it and a decision
396
396
  * computed from stale state is skipped and reported as a conflict instead of
397
397
  * overwriting concurrent writes (item ①).
398
+ * @param config - pipeline config; `config.mintSource` (optional string) is the
399
+ * provenance stamped on memories minted by `create` decisions. It is
400
+ * read from the CALLER, never from the decision object: the LLM authors
401
+ * `create` payloads and validateDecisions does not strip unknown fields, so
402
+ * trusting `d.source` would let a model label its own output as human-written.
403
+ * Absent/blank → saveWithDedupe's existing "manual" fallback (unchanged).
398
404
  * @returns {{ applied: number, conflicts: Array, failures: Array, committed: Array }}
399
405
  * applied - number of decisions/memories actually committed (archive counts
400
406
  * each archived memory as one, merge/conflict/update count one).
@@ -484,7 +490,17 @@ function applyCreate(d, service, config = {}) {
484
490
  const body = evidence.length > 0
485
491
  ? STR.evidenceSuffix[language](content, evidence)
486
492
  : content;
487
- const created = service.saveWithDedupe({ type, title, content: body, importance });
493
+ // mint 来源由调用方(管道)给,不从决策对象取——LLM 能自己写 d.source,
494
+ // 信它就等于让模型给自造条目盖「人写的」戳(见 applyDecisions 的 @param config)。
495
+ // 取不到就一个字段都不传,落回 saveWithDedupe 原有的兜底,行为与改前一致。
496
+ const mintSource = typeof config?.mintSource === "string" ? config.mintSource.trim() : "";
497
+ const created = service.saveWithDedupe({
498
+ type,
499
+ title,
500
+ content: body,
501
+ importance,
502
+ ...(mintSource ? { source: mintSource } : {})
503
+ });
488
504
  const memory = created?.memory;
489
505
  if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
490
506
  return { applied: 1, committed: { action: "create", id: memory.id, type } };
@@ -381,7 +381,11 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
381
381
  allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
382
382
  });
383
383
  if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}`, skipped };
384
- const { applied, failures, conflicts } = applyDecisions(decisions, service, logger, snapshot, config);
384
+ // 同 phasePatterns——本相位的 create 也落 source=sleep(默认档的
385
+ // LEGACY_ACTIONS 含 create,理论上可 mint)。
386
+ const { applied, failures, conflicts } = applyDecisions(
387
+ decisions, service, logger, snapshot, { ...config, mintSource: "sleep" }
388
+ );
385
389
  // Issue #126 review(Copilot):事务内 service.update 会跳过 scheduleEmbed(txDepth>0),
386
390
  // 而 sleep 路径此前没有 dream 那样的 post-apply 索引维护——differentiate 的差异注记与
387
391
  // supersede 的归档都反映不到缓存向量,"注记进 embedding 防再判重复"就是空承诺。
@@ -525,7 +529,11 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
525
529
  maxCreatePerRun: maxPatterns
526
530
  });
527
531
  if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}` };
528
- const { applied, failures, conflicts } = applyDecisions(decisions, service, logger, snapshot, config);
532
+ // 本相位的 create 落 source=sleep。来源从管道给而非从决策对象取——见
533
+ // applyDecisions 的 @param config。
534
+ const { applied, failures, conflicts } = applyDecisions(
535
+ decisions, service, logger, snapshot, { ...config, mintSource: "sleep" }
536
+ );
529
537
  return {
530
538
  status: applied > 0 ? "ok" : "noop",
531
539
  scanned: memories.length,
package/lib/dream.js CHANGED
@@ -1309,7 +1309,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1309
1309
  // a target changed during the LLM call is skipped and reported as a
1310
1310
  // conflict instead of being overwritten (item ①). Frozen conflicts are
1311
1311
  // excluded from this list (they are parked, not applied).
1312
- const { applied, conflicts, failures, committed } = applyDecisions(applyList, service, logger, snapshot, config);
1312
+ // mint 来源:主链路的 create 落 source=dream。来源从管道给而非从决策对象取——
1313
+ // 见 applyDecisions 的 @param config。
1314
+ const { applied, conflicts, failures, committed } = applyDecisions(
1315
+ applyList, service, logger, snapshot, { ...config, mintSource: "dream" }
1316
+ );
1313
1317
  // Per-record receipt chain: one row per actually-committed merge/conflict/
1314
1318
  // update verdict, stamped with the decision-basis digest + idempotency
1315
1319
  // counters (count_before → count_after). Written here, before the run audit
@@ -163,6 +163,10 @@ export async function extractEntities(memory, { store, config, callLLM, logger }
163
163
  if (config.entityExtractionModel) options.model = config.entityExtractionModel;
164
164
  const reasoning = config.entityExtractionReasoning;
165
165
  if (reasoning && reasoning !== "none") options.reasoningEffort = reasoning;
166
+ // #405: 审计行要能归因到具体记忆。extractor 只把 content 写进 prompt,id 这里顺
167
+ // options 交给 index.js 的 adapter(route 映射只认 provider/model,多余键不会进
168
+ // llm.stream)。拿不到 id 就不写这个键,adapter 侧照旧留空数组,不造虚账。
169
+ if (memory.id) options.memoryId = memory.id;
166
170
  const llmResponse = await callLLM(messages, options);
167
171
 
168
172
  if (!llmResponse) {
package/lib/index.js CHANGED
@@ -14,7 +14,7 @@ import { createInjector } from "./inject.js";
14
14
  import { createPreInjectGate } from "./pre-inject-gate.js";
15
15
  import { createContinuityRescue } from "./continuity.js";
16
16
  import { createSummarizer } from "./summarize.js";
17
- import { createDreamScheduler } from "./dream.js";
17
+ import { createDreamScheduler, describeStreamFailure } from "./dream.js";
18
18
  import { createSleepScheduler, runSleep } from "./dream/sleep.js";
19
19
  import { createApi } from "./api.js";
20
20
  import { createStandaloneApi } from "./api-standalone.js";
@@ -78,6 +78,11 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
78
78
  } catch { /* fall through to whatever route we already have */ }
79
79
  }
80
80
  const effort = options.reasoningEffort;
81
+ // #405: 审计行的 related_memory_ids 此前恒空、error_message 又被压成一句常量,
82
+ // 于是「哪条记忆没抽到实体、为什么」在库里查不出来(主库 735 条 error 行全是
83
+ // 同一句话)。id 由 extractor 顺 options 传下来——它只认 memory.content,不认
84
+ // id。口径同 write-admission:350:拿不到 id 就留空,绝不挂不存在的 id 造虚账。
85
+ const relatedMemoryIds = options.memoryId ? [options.memoryId] : [];
81
86
  // 没有解析出 provider/model 就没有可归属的模型——与 dream 的 resolveRoute
82
87
  // 无路由早退同口径,那种情况不写审计行。
83
88
  const modelId = route.provider && route.model ? `${route.provider}:${route.model}` : "";
@@ -89,7 +94,7 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
89
94
  const timestamp = new Date(startedAt).toISOString();
90
95
  // 记账是 best-effort:写审计行失败只 warn,绝不反噬抽取本身(CONTRIBUTING
91
96
  // 的 fail-safe 硬约定)。
92
- const writeAudit = (status, errorMessage) => {
97
+ const writeAudit = (status, errorMessage, relatedMemoryIds = []) => {
93
98
  if (config?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") return;
94
99
  // #372: modelId 为空 = 路由解析失败——此前整个 writeAudit 静默 return,
95
100
  // llm.stream 抛错也留不下任何行,正是 #108 要消灭的「零实体零日志」同款盲区。
@@ -111,7 +116,7 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
111
116
  duration_ms: Date.now() - startedAt,
112
117
  status,
113
118
  error_message: errorMessage,
114
- related_memory_ids: []
119
+ related_memory_ids: relatedMemoryIds
115
120
  });
116
121
  } catch (auditError) {
117
122
  logger?.warn?.(`dsh-mneme: entity extraction llm audit write failed: ${String(auditError)}`);
@@ -136,7 +141,7 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
136
141
  if (Number.isFinite(o)) outputTokens = o;
137
142
  }
138
143
  if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
139
- writeAudit("error", "llm stream aborted or errored");
144
+ writeAudit("error", describeStreamFailure(chunk.reason) || "llm stream aborted or errored", relatedMemoryIds);
140
145
  return undefined;
141
146
  }
142
147
  }
@@ -144,7 +149,7 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
144
149
  return text;
145
150
  })().catch((err) => {
146
151
  logger?.warn?.(`[dsh-mneme] entity extraction llm stream failed: ${String(err)}`);
147
- writeAudit("error", String(err?.message ?? err));
152
+ writeAudit("error", String(err?.message ?? err), relatedMemoryIds);
148
153
  return undefined;
149
154
  });
150
155
  };
@@ -183,11 +188,6 @@ export const apply = (ctx, config) => {
183
188
  }
184
189
 
185
190
  const store = createStore(join(memoryDir, "memory.db"));
186
- // Prune reflection failure rows older than 90 days on boot (best-effort, so
187
- // the failure table never grows unbounded).
188
- try {
189
- store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
190
- } catch { /* non-fatal */ }
191
191
 
192
192
  // User-configurable settings (profile, rules, panel mode, standalone API
193
193
  // token) share the same SQLite file in dedicated tables, isolated from
@@ -226,6 +226,21 @@ export const apply = (ctx, config) => {
226
226
  cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
227
227
  }
228
228
 
229
+ // 失败表的启动期清理也放在装配之后(此前它在 createStore 之后、装配之前,写死
230
+ // 90 天)。保留期现在是显式配置键 reflectionFailureRetentionDays;等它进面板
231
+ // 白名单的那天,这里读的就是装配后的值,不用再改第二处。best-effort 语义不变:
232
+ // 清理失败不挡启动,否则一次锁表就能让插件起不来——但「不挡启动」不等于「可以
233
+ // 悄悄失败」:这一族的 recall_runs 清理当年就是静默蒸发,表涨到两万余行无人
234
+ // 察觉,所以本处与 llm_audit / distill_records / recall_runs 三处一样出声。
235
+ try {
236
+ const failureRetentionDays = Number.isInteger(cfg.reflectionFailureRetentionDays)
237
+ ? cfg.reflectionFailureRetentionDays
238
+ : 90;
239
+ store.deleteOldFailures(new Date(Date.now() - failureRetentionDays * 86400000).toISOString());
240
+ } catch (err) {
241
+ ctx.logger?.warn?.(`[dsh-mneme] failure-table retention purge failed: ${String(err)}`);
242
+ }
243
+
229
244
  // Bug8 的启动期清理放在装配之后:面板把 llmAudit.* 写进 kv、经 nestedFlags 合进
230
245
  // cfg,而写入侧(dream / summarize / 写入准入)读的都是装配后的 cfg——清理若读
231
246
  // rawCfg,面板改了保留期它不认,更糟的是「开不开审计」与写入侧可能取到不同的值
@@ -236,7 +251,9 @@ export const apply = (ctx, config) => {
236
251
  const retentionMs = Number.isInteger(cfg.llmAudit?.retentionDays) ? cfg.llmAudit.retentionDays : 90;
237
252
  store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
238
253
  }
239
- } catch { /* non-fatal */ }
254
+ } catch (err) {
255
+ ctx.logger?.warn?.(`[dsh-mneme] llm_audit retention purge failed: ${String(err)}`);
256
+ }
240
257
 
241
258
  // #399 的启动期裁剪:与上面同款(读装配后的 cfg,理由同上——面板写的保留期必须
242
259
  // 与写入侧取到同一个值)。不同的一点:蒸馏原文留存默认关,而「默认关」在写入侧
@@ -250,7 +267,27 @@ export const apply = (ctx, config) => {
250
267
  : 90;
251
268
  store.clearDistillRecords(new Date(Date.now() - retentionMs * 86400000).toISOString());
252
269
  }
253
- } catch { /* non-fatal */ }
270
+ } catch (err) {
271
+ ctx.logger?.warn?.(`[dsh-mneme] distill_records retention purge failed: ${String(err)}`);
272
+ }
273
+
274
+ // recall_runs 的滚动清理(v0.7.0 原设计,2026-10-14 找回):与 llm_audit 同策略,
275
+ // 按 cfg.recallRetentionDays(默认 90 天)启动时清一次。读装配后的 cfg,理由与上面
276
+ // 两处一致 —— 写入侧取的是 cfg,清理侧若取 rawCfg,面板改的保留期就不生效。
277
+ // 这一对曾在 dd0ed78(v0.7.12 发布提交)里被整块弄丢,配置键与文档却一直留着,
278
+ // 实测 2026-10-13 那天从 16,041 涨到 20,510 行(一天四千余行,只增不减;
279
+ // 单给一个静态行数会随表腐坏,故带日期的区间才是可核对的说法)。注入行开始写真实 query 之后,这条从「表膨胀」
280
+ // 升级为「原话无限期留存」,所以必须与那次改动同批交付。
281
+ // 保留期语义是「天」(函数内部乘 86400000),清理失败只 warn,绝不挡插件启动。
282
+ try {
283
+ const recallRetentionDays = Number.isInteger(cfg.recallRetentionDays) ? cfg.recallRetentionDays : 90;
284
+ store.purgeRecallRunsOlderThan(recallRetentionDays);
285
+ } catch (err) {
286
+ // 这里必须真的出声。这一对当初是**静默**蒸发的:函数与调用一起没了、没人发现,
287
+ // 表就这么涨到两万余行。清理再失败一次而仍然不出声,同样的回归照样看不见——
288
+ // 「只 warn」不是修辞,是这族清理唯一的事后线索。
289
+ ctx.logger?.warn?.(`[dsh-mneme] recall_runs retention purge failed: ${String(err)}`);
290
+ }
254
291
 
255
292
  // 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream /
256
293
  // sleep / mirror,多实例(如 agent preset 内挂载)互不影响。
package/lib/inject.js CHANGED
@@ -38,7 +38,12 @@ function visibleSeqsOf(session) {
38
38
  // session event log (same event shape summarize.js consumes) and returns the
39
39
  // most recent human message. Any failure degrades to "" — the injector then
40
40
  // falls back to the legacy rule-based pick, never breaking the render.
41
- function lastUserQuery(ctx) {
41
+ //
42
+ // v0.9.0:导出给 tools.js 的 memory_update 用。失败记录表的 query 与注入
43
+ // 候选问的是同一件事——「当时到底想干什么」;这条取原话的路只该有一份实现。
44
+ // 复制一份的代价本仓库付过几次(src/lib 平价、snapshotEvents 兼容层),所以
45
+ // 这里只加 export,不改行为。
46
+ export function lastUserQuery(ctx) {
42
47
  try {
43
48
  const session = ctx?.agent?.session;
44
49
  // DSH ≥0.1.2-rc only exposes events via snapshotEvents(); older builds