@modusensus/dsh-mneme 0.8.15 → 0.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +8 -3
  2. package/bin/cli.mjs +4 -2
  3. package/docs/CONFIGURATION.md +14 -2
  4. package/docs/DISTILL.md +162 -0
  5. package/docs/STORAGE.md +11 -2
  6. package/lib/api-standalone.js +22 -39
  7. package/lib/api.js +171 -3
  8. package/lib/client.js +1065 -156
  9. package/lib/config.js +68 -1
  10. package/lib/dream/decisions.js +20 -0
  11. package/lib/dream.js +2 -0
  12. package/lib/hot-memory.js +62 -0
  13. package/lib/index.js +24 -7
  14. package/lib/inject.js +27 -63
  15. package/lib/maintenance.js +25 -5
  16. package/lib/recall-stats.js +22 -7
  17. package/lib/reclaim-http.js +150 -0
  18. package/lib/safe-slice.js +18 -0
  19. package/lib/service.js +27 -2
  20. package/lib/settings.js +13 -2
  21. package/lib/store.js +196 -7
  22. package/lib/summarize.js +208 -60
  23. package/package.json +1 -1
  24. package/src/api-standalone.js +22 -39
  25. package/src/api.js +171 -3
  26. package/src/config.js +68 -1
  27. package/src/dream/decisions.js +20 -0
  28. package/src/dream.js +2 -0
  29. package/src/hot-memory.js +62 -0
  30. package/src/index.js +24 -7
  31. package/src/inject.js +27 -63
  32. package/src/maintenance.js +25 -5
  33. package/src/recall-stats.js +22 -7
  34. package/src/reclaim-http.js +150 -0
  35. package/src/safe-slice.js +18 -0
  36. package/src/service.js +27 -2
  37. package/src/settings.js +13 -2
  38. package/src/store.js +196 -7
  39. package/src/summarize.js +208 -60
  40. package/test/api-routes-gaps.test.js +681 -0
  41. package/test/api.test.js +25 -2
  42. package/test/client.test.js +354 -2
  43. package/test/contrast.test.js +314 -0
  44. package/test/dream-merge-guardrail.test.js +219 -0
  45. package/test/hot-memory.test.js +67 -1
  46. package/test/inject-recall.test.js +39 -0
  47. package/test/inject.test.js +23 -0
  48. package/test/llm-audit.test.js +98 -0
  49. package/test/maintenance.test.js +112 -2
  50. package/test/pre-inject-gate.test.js +10 -0
  51. package/test/recall-stats.test.js +89 -4
  52. package/test/reclaim-http.test.js +209 -0
  53. package/test/service.test.js +7 -2
  54. package/test/settings.test.js +8 -0
  55. package/test/store.test.js +108 -0
  56. package/test/summarize.test.js +174 -0
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-1602%20passed-success)](https://github.com/slow-stack/mneme)
8
+ [![tests](https://img.shields.io/badge/tests-1695%20passed-success)](https://github.com/slow-stack/mneme)
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions)
10
10
  [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org)
11
11
  [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -87,6 +87,8 @@ dsh web
87
87
 
88
88
  默认 `131072`(即上限,#135)已为思考型模型预留推理+正文的双重预算(部分思考型模型仅推理就可能消耗 8k+ token),常规记忆库无需调整。若**记忆量大**(数万字符以上)且正文仍被截断,优先按下方「巩固模型分类声明」换非思考模型 / 配 `dreamReasoningEffort` 压低思考,再考虑调大输入侧上限(如 `distillMaxChars`)——`dreamMaxTokens` 本身已无上调空间。
89
89
 
90
+ > **不要为了省 token 把它调小**(#135 成本审计实测):这个键是**上限**而非计费口径,流式按实际用量计费,压上限只在真撞顶时生效;而撞顶的后果是**整轮失败**(截断 → 无完整 JSON → `no json array in llm output`),且调度器只有 `ok` 轮才刷新基线,下一次写入会用同一批输入重跑。按 133 个有真实用量的成功轮折算,cap=16384 撞顶 78.9%、净总量 **+54.1%**;cap=32768 +15.9%。真正的大头(`dream_consolidate` 输出 token 里约 88% 是推理与 JSON 骨架,只有约 11.8% 是决策正文)由 `dreamReasoningEffort` 与模型选择决定,与本键无关。相关的数量护栏见 `dreamMaxMergePerRun`。
91
+
90
92
  > 若使用**思考型模型**(如 deepseek-v4-flash / DeepSeek-R1 类),模型可能把全部预算花在 reasoning 上导致正文为空(日志出现 `no json array in llm output`)。#135 修复后:未配置 `dreamReasoningEffort` 时会**自动取模型支持的最低档**发流(不再省略字段让模型自带默认档——v4-flash 系默认 high——顶上烧光预算),默认配置即生效;显式 `none` = 不发送字段用服务商默认,`off`/`low`/`medium`/`high` 原样传递,模型不支持的档位自动换用(拒绝原因记入 llm_audit)。正文仍为空时配置 `dreamProvider`/`dreamModel` 指向非思考模型(`dreamMaxTokens` 默认已是上限 131072,无上调空间)。sleep 侧对应 `sleepReasoningEffort`。
91
93
 
92
94
  **巩固模型分类声明**(settings panel「巩固模型」= `dreamProvider`/`dreamModel`,睡眠侧对应 `sleepProvider`/`sleepModel`):
@@ -339,6 +341,7 @@ dsh web
339
341
  | `dreamCandidateMax` | `0` | hybrid 的候选总量上限;`0` = 复用 `dreamMaxSnapshotSize`。候选总量与库总量解耦,输入成本不随库增长 |
340
342
  | `dreamCandidateMinSim` | `0.85` | hybrid 判「高相似」的阈值(与 sleep 的 normal 档对齐,两个模块共用同一个「高相似」定义) |
341
343
  | `dreamMaxArchivePerRun` | `8` | 单轮 archive 决策上限(超限整单拒绝,防一次性大扫除;正常清理可调高) |
344
+ | `dreamMaxMergePerRun` | `40` | 单轮 merge 决策上限(超限整单拒绝)。merge 是巩固里唯一回吐正文的动作(每条要模型重写合并后的 content),所以这是「输出里正文占比」唯一可控的杠杆;默认 40 = 实测每轮 merge 数分布上沿(全库 p99 28 / max 39,历史 0 命中),与 archive 闸默认 8 同为尾险护栏而非成本削减工具。**注意**:`dream_consolidate` 的输出 token 里只有约 11.8% 是决策正文,其余约 88% 是推理与 JSON 骨架——想省 token 请调 `dreamReasoningEffort` 或换非思考模型,调小本键收益有限 |
342
345
  | `dreamSkipInvalid` | `true` | 单条非法决策跳过 + 合法子集应用 + run 记 degraded(#89);`false` 恢复严格模式整单拒绝 |
343
346
  | `allowCrossTypeMerge` | `false` | 显式放宽跨类型合并(默认关,类型边界由用户承担);跨类型意图的正确出口是 `sleepActionSet: full` |
344
347
  | `dreamImplicitKeep` | `true` | 显式决策覆盖率不足时的隐式 keep(未提及条目保持原样);`false` + `dreamMinExplicitCoverage: 0` 恢复旧严格行为 |
@@ -434,6 +437,7 @@ dsh web
434
437
  | `strictScope` | `false` | 作用域硬过滤(v0.8.0 引入,v0.8.1 起只认显式声明;依赖 `scopeEnabled`):检索 / 注入 / 列表 / 单取四路过滤,**显式声明**收窄到他者作用域的记忆完全不可见;载体自动标注只降权保留可见——真正的物理隔离请用 sensitivity。会话身份解析不到时 fail-closed 只挡显式行。关闭时全部为软隔离(降权保留可见)。也走 feature_flags 白名单 |
435
438
  | `writeAdmission` | `{enabled:false, enforce:false}` | 写入准入(issue #254,默认关):把「这条该不该进库」前移到 LLM 之前。第 1 级是零 LLM 的确定性判据——空白 / 纯噪声 + 密钥 / PII(判据来源是 #164 A2,经 `sensitiveScan` 注入,本批只定义接口)。`enabled` 跑判据并落审计行(`llm_audit_logs` 的 `metadata.deny`),`enforce` 才真的拒(`store.save` 之前返回,`memory_save` 返回 `action:"denied"` + `reason`);只开 `enabled` = 仅告警、写入不拦。去重键命中不进第 1 级(归 write-update 放行),G1/G2 阈值只计量。也走 feature_flags 白名单 |
436
439
  | `preInjectGate` | `{enabled:false, enforce:false}` | 注入前判定(issue #380,默认关):每帧注入候选出池后、进入 system prompt 前,一次池级 LLM 调用判出「会向本次请求注入意见/立场」的记忆(判定协议与 E12 实验同源;实测过滤把谄媚 26.9%→2.5%,且判定结果绝不进模型上下文——给模型看标记的线已被 E2/E3 关闭)。`enabled` 跑判定并落审计行(真实 token 用量 + 被标记记忆 id),`enforce` 才在缓存命中帧真滤除;冷缓存首帧降级为原样注入(宿主注入渲染是同步回调,判定走 prefetch+cache)。pin 池与检索线索行豁免;判定失败原样注入不阻塞;整池被标记时放弃本次滤除(fail-open);判定连续失败 3 次熔断一个冷却期;无默认路由时审计行 `model_id` 落 `unknown` 占位;`llmAudit` 关闭时本闸不运行;lightMode 强制关。也走 feature_flags 白名单 |
440
+ | `distillRecords` | `{enabled:false, retentionDays:90}` | 蒸馏原文留存 + 记忆级溯源(issue #399,默认关):把蒸馏从「替换」降级为「叠加索引」——蒸过的窗口原文旁路落 `distill_records`(一个事件一行,主键 `(session_id, seq)`,`INSERT OR IGNORE` 幂等),每条新记忆落 `memory_provenance` 回指来源窗口(`(memory_id, session_id, seq_start, seq_end)` 唯一)。**渲染与落盘共用同一隐私谓词**:私有推理链、插件注入的机器内容(AGENTS.md / skill 正文 / 文件变更通知)两条路都不进;工具调用入参与结果、code 输出**只进渲染、不落盘**(渲染是 `codingRetrospect` 的输入,落盘只留对话)。留存文本**不受** `distillMaxChars` / 工具裁剪约束(原文就是原文)。到期裁剪只删 `distill_records`,溯源链接保留指向空区间(面板显示「来源已过期」),**绝不级联删记忆**;删记忆时显式清链接。无历史回填、从未蒸馏的窗口不留记录。进 `dsh-mneme reclaim` 报告第三项;lightMode 强制关。也走 feature_flags 白名单 |
437
441
  | `conflictFreezeEnabled` | `false` | 冲突冻结(v0.4.4):dream 发现矛盾对不自动裁决,冻结进冲突队列;状态页「冲突队列」支持并排对比与人工确认(保留 A / 保留 B / 仅标记已处理,v0.8.0) |
438
442
  | `conflictFreezeMaxPending` | `100` | 冲突冻结队列最大挂起数(1-1000):超出后不再入队(防队列无限膨胀) |
439
443
  | `escapePromptVariables` | `true` | 注入文本花括号转义(v0.8.0 恢复,issue #162):注入边界把 `{{...}}` 转义,防止 hot memory / 记忆原文里的 Go template / Vue 语法触发宿主 interpolate 抛错卡死会话(v0.7.4 曾修复、v0.7.11 误删) |
@@ -613,7 +617,7 @@ src/
613
617
  ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
614
618
  └── index.js # 插件接线
615
619
  lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
616
- test/ # 1602 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
620
+ test/ # 1695 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
617
621
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单
618
622
  ```
619
623
 
@@ -622,7 +626,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
622
626
  ```bash
623
627
  cd dsh-mneme
624
628
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
625
- npm test # 运行 1602 个测试
629
+ npm test # 运行 1695 个测试
626
630
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
627
631
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
628
632
  ```
@@ -636,6 +640,7 @@ npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动
636
640
  > 设计文档位于仓库根 `docs/`,链接以 `../docs/` 相对路径指向(GitHub 上从本目录打开可正常跳转)。
637
641
 
638
642
  - [配置说明(全键参考)](docs/CONFIGURATION.md)
643
+ - [蒸馏原文留存与记忆级溯源](docs/DISTILL.md)
639
644
  - [实体结构化记忆设计](docs/ENTITIES.md)
640
645
  - [语义增强架构](docs/SEMANTIC.md)
641
646
  - [本地模型部署指南](docs/LOCAL_MODEL.md)
package/bin/cli.mjs CHANGED
@@ -579,10 +579,12 @@ function printReclaim(report, { applied }) {
579
579
  const size = report?.size ?? {};
580
580
  console.log(`存储无损回收(${applied ? '已执行' : 'dry-run,未改动任何数据'})`);
581
581
  console.log(` 输入快照保留窗口: ${report?.olderThanDays ?? '?'} 天(cutoff ${report?.cutoff ?? '?'})`);
582
- const rows = (o) => `dream_runs.input ${o.dream_run_inputs ?? 0} 行 / 归档行向量 ${o.archived_embeddings ?? 0} 行`;
582
+ // 第三项(#399):蒸馏原文的到期裁剪同样只删 distill_records,溯源链接刻意保留
583
+ const rows = (o) =>
584
+ `dream_runs.input ${o.dream_run_inputs ?? 0} 行 / 归档行向量 ${o.archived_embeddings ?? 0} 行 / 蒸馏原文 ${o.distill_records ?? 0} 行`;
583
585
  console.log(` ${applied ? '已清理' : '将清理'}: ${rows(applied ? cleared : planned)}`);
584
586
  console.log(
585
- ` 列文本大小(上界,非回收量): ${formatBytes(columnBytes.dream_run_inputs)} / ${formatBytes(columnBytes.archived_embeddings)}`
587
+ ` 列文本大小(上界,非回收量): ${formatBytes(columnBytes.dream_run_inputs)} / ${formatBytes(columnBytes.archived_embeddings)} / ${formatBytes(columnBytes.distill_records)}`
586
588
  );
587
589
  if (!applied) {
588
590
  console.log(
@@ -86,6 +86,17 @@
86
86
  | `memoryQualityFilter.minContentLength` | `10` | 最短内容长度(字符) | — |
87
87
  | `memoryQualityFilter.exemptImportance` | `4` | importance ≥ 此值豁免静默自动归档(#135) | 设 1 = 等效关自动归档 |
88
88
 
89
+ ## 蒸馏原文留存与溯源(#399)
90
+
91
+ 把蒸馏从「替换」降级为「叠加索引」:蒸过的窗口原文旁路落 `distill_records`,每条新记忆落 `memory_provenance` 回指来源窗口。**渲染与落盘共用同一隐私谓词**(`publicTranscriptParts`)——私有推理链与插件注入的机器内容两条路都不进;工具入参与结果只进渲染、不落盘。留存文本不受 `distillMaxChars` 与工具裁剪约束。详见 [蒸馏原文留存与记忆级溯源](DISTILL.md)。
92
+
93
+ | 键 | 默认 | 作用 | 开启后果 / 冲突 |
94
+ |---|---|---|---|
95
+ | `distillRecords.enabled` **light** | `false` | 蒸馏时顺带留存窗口原文 + 写记忆级溯源链接 | opt-in;关 = 一行不写、连收集都不做,行为与 #399 之前逐字节一致 |
96
+ | `distillRecords.retentionDays` | `90` | 原文保留天数(1–3650) | **只管启动期裁剪**(`src/index.js`,`enabled` 时动手)。`dsh-mneme reclaim` 不读这个键:它对三个表用同一条 `--older-than` 截止(默认 7 天),想对齐就显式传成与 `retentionDays` 相同的值(见 STORAGE.md)。两处都**只删 `distill_records`**,溯源链接保留指向空区间(显示「来源已过期」),绝不级联删记忆 |
97
+
98
+ 两个键都走 feature_flags 白名单(面板可启停);`enabled` 进 lightMode 预设关闭清单,`retentionDays` 不进(它是时长不是行为面,用户显式设过就照旧)。`dsh-mneme reclaim` 的报告从两项变三项(`planned` / `column_bytes` / `cleared` 各多一个 `distill_records`),入口语义(默认 dry-run、`--apply` 才动手)不变。已知边界:无历史回填、从未蒸馏过的窗口不留记录、不覆盖工具结果与 code 输出。
99
+
89
100
  ## 注入
90
101
 
91
102
  | 键 | 默认 | 作用 | 开启后果 / 冲突 |
@@ -116,7 +127,7 @@
116
127
  | `dreamPeakHours` | `""` | 巩固错峰,与 `summarizePeakHours` 同一份时段语法(#239) | 命中高峰不做梦、阈值继续累积、留 skip 审计;非法写法按未配置 |
117
128
  | `dreamPeakMaxDeferMinutes` | `120` | 巩固顺延上限(分钟) | 0 = 不设上限;到点仍处高峰照跑 |
118
129
  | `dreamProvider` / `dreamModel` | 空 | 巩固专用路由(settings「巩固模型」) | 建议非思考模型——思考模型易烧光预算返回空体(#135) |
119
- | `dreamMaxTokens` | `131072` | 巩固输出预算(256–131072) | 流式计费按实际用量,调大不增加成本 |
130
+ | `dreamMaxTokens` | `131072` | 巩固输出预算(256–131072) | 流式计费按实际用量,调大不增加成本;**调小则相反**——撞顶 → 无完整 JSON → 整轮失败 → baseline 不刷新 → 同输入重跑(#135 实测 cap=16384 净成本 +54%) |
120
131
  | `dreamReasoningEffort` | 未配置 | 巩固推理档位(同 `summarizeReasoningEffort` 的枚举) | 未配置 = 自动取模型支持的最低档(#135);显式 `none` = 不发字段 |
121
132
  | `dreamSummaryProvider` / `dreamSummaryModel` | 空 | 记忆总览(dream_summarize)专用路由(#258) | 空 = 回落巩固路由;总览输入为全库,建议大 ctx 非思考模型 |
122
133
  | `dreamSummaryMaxInputs` | `0` | 总览输入条数硬上限(按 updated_at 倒序取最新 N 条) | 0 = 不设上限(库增长可能撑爆小 ctx 模型) |
@@ -125,6 +136,7 @@
125
136
  | `dreamCandidateMax` | `0` | `hybrid` 候选总量上限(≤5000) | 0 = 复用 `dreamMaxSnapshotSize` |
126
137
  | `dreamCandidateMinSim` | `0.85` | `hybrid` 判「高相似」的阈值 | 与 sleep normal 档同一个定义 |
127
138
  | `dreamMaxArchivePerRun` | `8` | 单轮 archive 决策上限(≤200,#104) | 超限整单拒绝,`dreamSkipInvalid` 不豁免 |
139
+ | `dreamMaxMergePerRun` | `40` | 单轮 merge 决策上限(≤200,#135) | 超限整单拒绝;merge 是唯一回吐正文的动作,40 = 实测每轮 merge 数分布上沿(历史 0 命中) |
128
140
  | `dreamImplicitKeep` | `true` | LLM 未提及的快照记忆自动补 keep | `false` = 恢复严格校验(未覆盖即拒绝整单) |
129
141
  | `dreamMinExplicitCoverage` | `0.5` | 显式决策覆盖率下限(0–1) | 防截断输出被隐式 keep 洗白 |
130
142
  | `dreamSkipInvalid` | `true` | 跳过单条非法决策、应用合法子集、run 记 degraded(#89) | `false` = 恢复整单拒绝 |
@@ -257,7 +269,7 @@
257
269
  `autoReindexOnBoot` · `hybridInject` · `injectGuidanceEnabled` · `searchSemanticDedup` ·
258
270
  `selectiveInjectEnabled` · `bm25SearchEnabled` · `entityRecallEnabled` ·
259
271
  `dreamNarrativeEnabled` · `documentMemoryEnabled` · `heatEnabled` · `continuityRescueEnabled` ·
260
- `preInjectGate.enabled`(#380)
272
+ `preInjectGate.enabled`(#380) · `distillRecords.enabled`(#399)
261
273
 
262
274
  预设只是默认值而非强制:用户显式写进 feature_flags 的值在装配顺序上后展开、仍然生效
263
275
  (「用户开关 > 轻量预设 > bundle 配置」)。
@@ -0,0 +1,162 @@
1
+ # dsh-mneme 蒸馏原文留存与记忆级溯源(issue #399)
2
+
3
+ - **日期**:2026-10-12
4
+ - **状态**:✅ 已实现(opt-in,默认关)
5
+ - **范围**:把蒸馏从「替换」降级为「叠加索引」——窗口原文旁路留存 + 记忆回指来源窗口
6
+ - **相关文档**:[存储与回收](STORAGE.md)(#275 第一批,同一条 reclaim 入口) · [配置参考](CONFIGURATION.md) · [压缩边缘抢救](CONTINUITY.md)
7
+
8
+ ## 1. 问题
9
+
10
+ 蒸馏(`src/summarize.js`)是一条**有损**管道:它把一段会话窗口喂给 LLM,拿回几条结构化
11
+ 记忆,然后**消费掉游标**——窗口原文此后在库内没有任何痕迹。于是三件事同时成立:
12
+
13
+ 1. 记忆里没写进去的细节,事后**无法回查**(不是「没保存」,是「没地方找」);
14
+ 2. 一条记忆**说不清自己出自哪段对话**,人工复核只能靠标题猜;
15
+ 3. 蒸馏质量出问题时,**没有基线可比**(原文已经不在手上)。
16
+
17
+ 信息论的依据很直白(arXiv 2609.36059 §3 Eq.1):旁路索引不丢信息——
18
+ `I(Y;M,H,X) = I(Y;M,H)`,留存原文 `X` 不改变 `Y` 与已蒸馏产物 `(M,H)` 的互信息,
19
+ 但**取回** `X` 的能力被保住了。所以这不是「再存一份」的冗余,而是把单向替换变成
20
+ 可回指的叠加索引。
21
+
22
+ ## 2. 形态:两张表,只增不改
23
+
24
+ ```
25
+ distill_records(session_id TEXT, seq INTEGER, role TEXT, text TEXT,
26
+ window_id TEXT, distilled_at TEXT, PRIMARY KEY(session_id, seq))
27
+ idx_distill_records_window(window_id)
28
+ idx_distill_records_distilled_at(distilled_at)
29
+
30
+ memory_provenance(memory_id TEXT, session_id TEXT, seq_start INTEGER, seq_end INTEGER,
31
+ created_at TEXT)
32
+ UNIQUE idx_memory_provenance_link(memory_id, session_id, seq_start, seq_end)
33
+ ```
34
+
35
+ - `CREATE TABLE IF NOT EXISTS`:**无 ALTER、无回填**,存量库启动即建表,旧库零迁移成本。
36
+ - **不动 `memories`**:溯源是旁路,不往记忆表加列。删掉这两张表,插件行为逐字节回到 #399 之前。
37
+ - `memory_id` 用 **TEXT**(issue 草稿里写的是 INTEGER):`memories.id` 在本仓一直是 TEXT
38
+ (`src/store.js` 的 `save()` 铸 UUID 风格字符串),写成 INTEGER 只会让 SQLite 按
39
+ TEXT 亲和存进去、却在类型声明上误导后来人。
40
+ - 主键 `(session_id, seq)` 决定了**一个事件一行**:`seq` 是会话事件序号,一次事件可能携带
41
+ 多条公开文本(`agent-message` / `inbox` / `spliced` 可以一次投递多条子会话消息),它们
42
+ 按事件合并成一行(`role` 取首条、`text` 以换行连接)。各写各的行会被
43
+ `INSERT OR IGNORE` 静默丢掉后几条——那是**丢数据**,不是去重。
44
+ - 幂等靠 `INSERT OR IGNORE`(记录)+ 唯一索引 `ON CONFLICT DO NOTHING`(链接):
45
+ 同一窗口重蒸一次不翻倍,且**保留首次落盘时间**(`distilled_at` 是「原文何时被留下」的
46
+ 事实,重跑改写它才是撒谎)。
47
+
48
+ ## 3. 写入点:蒸馏时顺手落,不新建常驻管道
49
+
50
+ `summarize()` 触发时已经持有会话事件、已经算出窗口边界(`previousSeq` / `triggerSeq`),
51
+ 原文留存就在那里顺带落盘——**不新开监听器、不常驻订阅会话事件**。好处是留存范围与
52
+ 蒸馏范围**天然一致**:蒸过的窗口才留,没蒸过的窗口不留(见 §7 已知坑 2)。
53
+
54
+ 落盘与记忆写入同事务(`service.transaction`):两者要么一起进,要么一起退。游标消费也在
55
+ 同一个事务里——「记忆写了但原文没落」这种半截状态不存在。
56
+
57
+ ## 4. 隐私:渲染与落盘共用同一谓词
58
+
59
+ 这是本次改动**最要紧**的约束。原文留存一旦与喂给 LLM 的转录各写一份过滤逻辑,两条路径
60
+ 就会漂移,而漂移的方向必然是「**存下来的比 LLM 看过的更多**」——最糟的方向:模型没看到的
61
+ 私有内容反而落进了持久库。
62
+
63
+ 所以两边都从 `publicTranscriptParts(event)`(`src/summarize.js`)取数,同一个函数、同一份
64
+ 判定。口径:
65
+
66
+ | 内容 | 进 LLM 转录 | 落 distill_records |
67
+ |---|---|---|
68
+ | 真实 `user/message`(`kind` 缺失 / `user` / `agent-message` / `subagent-settled`) | ✅ | ✅ |
69
+ | 助手公开 `text` 块 | ✅ | ✅ |
70
+ | 助手 `reasoning` 块(私有推理链) | ❌ | ❌ |
71
+ | 插件注入机器内容(`kind: "plugin:dsh-mneme"` / skill 正文 / AGENTS.md / 文件变更通知) | ❌ | ❌ |
72
+ | 工具调用入参、工具结果、code 输出 | ✅(截断) | ❌ |
73
+
74
+ 最后一行是**刻意的路径差异**,不是漂移:`codingRetrospect` 靠工具输出沉淀踩坑根因,所以
75
+ 渲染路径必须带它;但它体积大、含路径与命令、且不属于「对话内容」,落盘只留对话。
76
+ 差异方向仍满足「落盘的 ⊆ 渲染的」。
77
+
78
+ ## 5. 留存不受喂模型的裁剪约束
79
+
80
+ 喂 LLM 的转录受三道裁剪:`distillMaxChars`(默认 24000,超长保**尾部**)、工具入参
81
+ `trim(args, 300)`、工具结果 `trim(out, 500)`。这三刀**不作用于落盘**——原文就是原文,
82
+ 截断过的「原文」没有回查价值。
83
+
84
+ 代价是库体积:一条超长提示会原样落库。所以留存默认**关**,并且有保留期(§6)。
85
+ `test/summarize.test.js` 有一条锁专门钉「两条路径的差异」:超长用户提示在记录里逐字全文,
86
+ 在喂模型的转录里找不到头部标记。
87
+
88
+ ## 6. 生命周期与开关
89
+
90
+ ```yaml
91
+ distillRecords:
92
+ enabled: false # 默认关(opt-in)
93
+ retentionDays: 90 # 保留期,1-3650
94
+ ```
95
+
96
+ | 层级 | 键 | 默认 |
97
+ |---|---|---|
98
+ | 父 | `autoSummarize` | 开(既有总闸) |
99
+ | 子 | `distillRecords.enabled` | **关**(引入新的持久化面,独立成键) |
100
+ | 子 | `distillRecords.retentionDays` | `90` |
101
+
102
+ 两个键都进 `settings.js` 白名单(面板可改);`enabled` 进 `config.js` 的 `LIGHT_MODE_OFF`
103
+ (轻量模式关掉留存这个成本/隐私面),`retentionDays` **不进**——它是时长而非行为面,用户
104
+ 显式设过就照旧。两个键都进 `api.js` 的 `NESTED_FLAG_PATHS`(漏了 effective 会静默缺键,
105
+ #254 踩过)。
106
+
107
+ **到期裁剪只删 `distill_records`**,`memory_provenance` 保留——链接从此指向空区间,面板
108
+ 显示「来源已过期」,而不是查无此记忆。**裁剪原文绝不级联删记忆**:原文是索引,记忆是资产。
109
+ 两处裁剪,**窗口各自独立**:启动期(`src/index.js`,只在 `enabled === true` 时动手——默认关时
110
+ 一张空表不该被每次启动扫一遍)按 `retentionDays` 裁;`dsh-mneme reclaim`(默认 dry-run)
111
+ **不读这个键**,跟同一条 `--older-than`(默认 7 天)走——两个窗口互不联动,想一次对齐就把
112
+ `--older-than` 显式传成与 `retentionDays` 相同的值。
113
+
114
+ 删记忆是另一条路:`store.remove(id)` 在事务内**显式**清掉它的溯源链接。之所以不靠
115
+ `ON DELETE CASCADE`,是因为 `src/store.js` 从不启用 `PRAGMA foreign_keys`(全库唯一的
116
+ `REFERENCES` 是 `recall_events → recall_runs`,翻这个 pragma 会顺带激活那条既有约束的
117
+ 行为,不是本次改动的范围)。声明式约束保留在 schema 里作为意图记录,实际清理是显式的。
118
+
119
+ `reclaim` 报告因此从两项变三项:
120
+
121
+ ```
122
+ planned: { dream_run_inputs, archived_embeddings, distill_records }
123
+ column_bytes: { ... }
124
+ cleared: { dream_run_inputs, archived_embeddings, distill_records }
125
+ ```
126
+
127
+ `reclaim` 的入口语义(默认 dry-run、`--apply` 才动手、`--vacuum` 单独指定)**一字未动**。
128
+
129
+ ## 7. 已知坑
130
+
131
+ 1. **无历史回填**:本批只对**此后**发生的蒸馏生效。升级前已蒸过的窗口原文已经丢了,
132
+ 没有补回来的路(也不该假装能——从记忆反推原文是编造)。
133
+ 2. **从未蒸馏过的窗口不留记录**:留存挂在蒸馏上,所以四类窗口天然无记录——① 窗口太小
134
+ (`window-too-small` 门,游标已被消费);② 插件当时是关的;③ 该窗口没触发过蒸馏
135
+ (`autoSummarize` 关 / 限流挡住 / 会话结束得太早);④ 蒸过了但那一轮 LLM 调用 abort。
136
+ 这些不是 bug,是「留存范围 = 蒸馏范围」的直接推论,但排查时容易误判成丢数据。
137
+ 3. **不覆盖工具结果与 code 输出**(§4 末行):想回查某次命令的真实输出,这两张表帮不上忙
138
+ ——那是渲染路径的事,且落盘会带来体积与敏感信息两个问题。
139
+
140
+ ## 8. 非目标(本批刻意不做)
141
+
142
+ 不嵌入、不进 `fuseRecall`、不进注入、不做「哪份更权威」的取代判定、不做 value histories
143
+ 查询接口、不动 `distillMaxChars` / `trim(args,300)` / `trim(out,500)` 三刀。这两张表是
144
+ **旁路索引**,不是第二条检索路——把它接进召回是另一个决定(成本与召回质量都要重新测),
145
+ 不该跟着留存一起顺手打开。
146
+
147
+ ## 9. 验收锚点
148
+
149
+ `test/summarize.test.js` 五条 + `test/maintenance.test.js` / `test/llm-audit.test.js` /
150
+ `test/settings.test.js` / `test/api.test.js` / `test/pre-inject-gate.test.js` 各一条:
151
+
152
+ - **幂等**:删掉持久化游标 + 换新 summarizer 实例重跑同一窗口 → 记录仍一行、链接无重复;
153
+ - **隐私**:reasoning / AGENTS.md / skill 正文 / 文件变更通知绝不落盘,工具输出不落盘而
154
+ 渲染路径仍带它;
155
+ - **未截断**:超长提示在记录里逐字全文,喂 LLM 的转录仍受 `distillMaxChars` 约束;
156
+ - **裁剪不毁资产**:到期裁剪后原文为 0、链接仍在、记忆数不变、`provenanceFor` 仍可查;
157
+ - **默认关零写入**:`enabled=false` 时连收集都不做(写入调用计数为 0),两表均 0 行,
158
+ 记忆写入照旧;
159
+ - **进 reclaim 报告**:dry-run 报出真实行数与列文本大小且一个字节不改,执行只删窗口外原文;
160
+ - **启动期裁剪**:读装配后的 cfg(面板改了保留期也认),且默认关时不动手;
161
+ - **白名单与往返**:两个键过 `settings` 校验(含范围外整数被拒),PUT 往返后 effective 读得回;
162
+ - **轻量模式**:预设关 `enabled`、保留用户显式设的 `retentionDays`、不污染用户对象。
package/docs/STORAGE.md CHANGED
@@ -9,7 +9,9 @@
9
9
 
10
10
  这个入口把这两样回收掉。一条记忆都不删、一次价值判断都不做。
11
11
 
12
- 它**不自动跑**:两项都是不可逆的内容丢弃,只在人看得见数字的时候按下(维护者 2026-09-23 拍板:不挂启动路径、不开 `auto_vacuum`)。
12
+ > #399 起多回收第三样:`distill_records`(蒸馏原文留存,默认关)。它同样是「留着只占体积」——原文的回查价值随保留期衰减,但**溯源链接刻意不删**(链接是记忆的元数据,原文才是可弃的缓存)。见 [蒸馏原文留存](DISTILL.md)。
13
+
14
+ 它**不自动跑**:几项都是不可逆的内容丢弃,只在人看得见数字的时候按下(维护者 2026-09-23 拍板:不挂启动路径、不开 `auto_vacuum`)。
13
15
 
14
16
  ## 用法
15
17
 
@@ -32,6 +34,9 @@ dsh-mneme reclaim --json # 原始 JSON,给脚本用
32
34
  | `memories.embedding`(`archived = 1`) | 置空 | 检索恒带 `archived = 0`,按定义不可达 |
33
35
  | `memories` 的行 | 一条不删 | 本入口止体积不止条数;条数与重复归 #254 与整理动作面 |
34
36
  | 活跃行的向量 | 不动 | 检索正在用 |
37
+ | `distill_records`(窗口外,#399) | 删行 | 蒸馏原文留存是 opt-in 旁路索引;到期即弃。**`memory_provenance` 链接保留**指向空区间(面板显示「来源已过期」),记忆与链接都不删——原文本就是「回查用」的,保留期到了就该走 |
38
+
39
+ `distill_records` 只在 `distillRecords.enabled=true` 时才有行(默认关,连收集都不做),所以对大多数库这一项恒为 0。
35
40
 
36
41
  取消归档时(`memory_archive` 还原)服务端会重新排队嵌入,所以被回收的向量能拿回来。
37
42
 
@@ -63,6 +68,7 @@ VACUUM 是 O(库大小),且需要排他写锁:库越大越慢,期间的写
63
68
  - 还原归档行后需要一次重嵌入才回到向量检索(排队的语义与写入路径一致:事务中或嵌入器未就绪会延后)。
64
69
  - `llmAudit.enabled=false` 时回执不落库(那个开关连审计行的启动期清理一起关掉),但清理照做。
65
70
  - 窗口内(默认 7 天)的输入快照不动:近期 run 仍可能在离线回放里被用到。
71
+ - `distill_records` 的窗口用同一个 `--older-than`(默认 7 天),但它的**保留期另有一处**:`distillRecords.retentionDays`(默认 90)在插件启动时裁剪。两处不冲突(启动裁剪是常态、reclaim 是手动瘦身),但别指望只调其中一个就能同时改两边——想一次对齐就把 `--older-than` 显式传成与 `retentionDays` 相同的值。裁剪只删原文,溯源链接留着(详见 [蒸馏原文留存](DISTILL.md))。
66
72
  - `run_type='organize'` 的行任何窗口都不清:它们的 `input` 存的是 apply 的重放载荷(`src/organize.js` 直接读它重建候选索引),不是可重建的快照。置空会让那次 apply 静默空转并把报告锁死。
67
73
 
68
74
  ## 升格吸收的 evidence(#230 × #275)
@@ -79,9 +85,12 @@ VACUUM 是 O(库大小),且需要排他写锁:库越大越慢,期间的写
79
85
 
80
86
  面板「记忆复用」卡与 `GET /api/dsh-mneme/recall-stats` 的返回里多了一个 `archive` 块(#275 拍板:与既有指标同位):
81
87
 
82
- - `total` / `addedInWindow` / `perDay`:归档区现有行数、窗口内新进的、以及日均净增速率。归档时刻取行的 `updated_at`(`setArchived` 会刷它)——代理口径:再改一次已归档行就会被算进本窗口,精确口径要一个 `archived_at` 列(属第二批题材);
88
+ - `total` / `addedInWindow` / `perDay`:归档区现有行数、窗口内新进的、以及日均净增速率。归档时刻取**精确列 `archived_at`**(#275 第二批落地):`setArchived` 只在「活跃 → 归档」那一次落值,取消归档清 `NULL`,重复归档不刷新(幂等)——重复归档不是新一次归档,刷新它就会让「对同一行反复调 `setArchived(true)`」又变成一次净增。本指标 v1 曾用行的 `updated_at` 代理(`setArchived` 翻标志位时刷它),偏差方向是**偏高**:对已归档行做一次普通更新,或重复归档,都会被算成「本窗口新进」。`archived_at` 就是为消灭这两个假阳性而加的,读侧**不要**再退回 `updated_at`,也不要写 `archived_at ?? updated_at` 兜底;
89
+ - `undated`:`archived_at` 为 `NULL` 的归档行数——本列落地前就已归档的**存量行**,时刻不可追溯。**刻意不计入 `addedInWindow`**:迁移当刻,窗口内的存量归档行同样是 `NULL`,既无法证明它在窗口内、也无法证明在窗口外。三种处置里选了这个:用 `updated_at` 顶替 = 走回被推翻的代理口径;按 0 充数 = 把「不知道」伪装成「没有」;单列计数让读侧自判可信度,宁少不脏。迁移**不回填**存量行,理由同上——回填等于把代理口径的偏差(偏高)固化成看不见的脏数据,留在 `NULL` 至少是「已知的不可知」;
83
90
  - `compressible.rows` / `.groups`:**可压掉行数**——同 type、同 scope、内容归一化后逐字节相同的归档行里,每组留下的那一行之外的那些。判据用内容哈希(与写入准入同一把尺),**不**建在向量近重复上:回收动作本身就会清掉归档行的向量,指标不能指望自己的输入还在。
84
91
 
92
+ `archived_at` 只在 `setArchived` 与「入库即归档」(`store.save({archived:true})`,时刻 = 入库时刻)两处落值;`update()` 的 patch **不能**写它(否则给精确口径开了绕过幂等/清空语义的后门)。`updated_at` 的既有语义不动——它仍是「最后一次写」,面板排序、快照哈希、CAS 都读它。
93
+
85
94
  这是观察口,不是动作触发:真删仍属第二批(未立项)。
86
95
 
87
96
  ## 与 #254 的分界
@@ -18,6 +18,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
18
18
  import { TYPES } from "./store.js";
19
19
  import { bootstrapFromDirectory } from "./bootstrap.js";
20
20
  import { PACKAGE_VERSION } from "./version-check.js";
21
+ import { parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
21
22
 
22
23
  const DEFAULT_PORT = 8790;
23
24
  const DEFAULT_HOST = "127.0.0.1";
@@ -482,54 +483,36 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
482
483
  // 默认 dry-run:只有显式 confirm:true 才真改数据。这两步都是不可逆的内容丢弃
483
484
  // (历史 run 的输入快照置空、归档行向量置空),所以不给「带 body 就执行」这种
484
485
  // 省事路径——确认必须建立在看得见的数字上。
486
+ //
487
+ // 口径(错误码字符串、默认值、报告形状)不在这里写:与宿主内的面板路由
488
+ // /api/dsh-mneme/maintenance/reclaim 共用 src/reclaim-http.js,两张皮只有鉴权
489
+ // 与路径不同。这条曾经是整段复制的,于是面板侧修好之后这边还停在旧形态(#411)。
485
490
  if (req.method === "POST" && pathname === "/maintenance/reclaim") {
486
491
  void readBody(req).then((text) => {
487
- let body;
488
- try {
489
- body = JSON.parse(text || "{}");
490
- } catch {
491
- sendJson(res, 400, { error: "invalid-json" });
492
- return;
493
- }
494
- if (body === null || typeof body !== "object" || Array.isArray(body)) {
495
- sendJson(res, 400, { error: "invalid-body" });
492
+ const parsed = parseReclaimJson(text);
493
+ if (!parsed.ok) {
494
+ sendJson(res, 400, { error: parsed.error });
496
495
  return;
497
496
  }
497
+ // 顺序敏感:端点整个没接线时报「窗口不合法」是误导,503 必须赢过字段校验。
498
498
  if (!maintenance) {
499
- sendJson(res, 503, { error: "maintenance-unavailable" });
499
+ sendJson(res, 503, { error: RECLAIM_UNAVAILABLE });
500
500
  return;
501
501
  }
502
- const dryRun = body.dryRun !== false;
503
- // 只收 JSON 数字:`Number()` 会把 ""/[]/false 都化成 0,而 0 在这里是
504
- // 「清掉全部快照」——最坏解释绝不能让一个畸形输入静默拿到。
505
- const olderThanDays = body.olderThanDays === undefined || body.olderThanDays === null
506
- ? undefined
507
- : body.olderThanDays;
508
- if (olderThanDays !== undefined && (!Number.isInteger(olderThanDays) || olderThanDays < 0)) {
509
- sendJson(res, 400, { error: "invalid-older-than-days" });
502
+ const fields = readReclaimFields(parsed.body);
503
+ if (!fields.ok) {
504
+ sendJson(res, 400, { error: fields.error });
510
505
  return;
511
506
  }
512
- if (body.vacuum !== undefined && typeof body.vacuum !== "boolean") {
513
- sendJson(res, 400, { error: "invalid-vacuum" });
514
- return;
515
- }
516
- if (!dryRun && body.confirm !== true) {
517
- // 拒执行,但把 dry-run 报告一并回给调用方:确认的依据就是这份数字。报告
518
- // 必须描述**将要执行的那个动作**,所以 vacuum 原样带上——否则调用方看到
519
- // 的是「不 VACUUM」的数字,却带着 vacuum 去执行。
520
- sendJson(res, 400, {
521
- error: "confirm-required",
522
- hint: "先看 dry-run 报告,确认后带 {\"dryRun\":false,\"confirm\":true} 再发",
523
- report: maintenance.reclaim({ olderThanDays, vacuum: body.vacuum === true, dryRun: true })
524
- });
525
- return;
526
- }
527
- try {
528
- sendJson(res, 200, maintenance.reclaim({ olderThanDays, vacuum: body.vacuum === true, dryRun }));
529
- } catch (error) {
530
- logger?.warn?.(`[dsh-mneme] storage reclaim failed: ${String(error)}`);
531
- sendJson(res, 500, { error: "internal" });
532
- }
507
+ const { status, body } = reclaimReply({
508
+ maintenance,
509
+ dryRun: fields.dryRun,
510
+ olderThanDays: fields.olderThanDays,
511
+ vacuum: fields.vacuum,
512
+ confirm: fields.confirm,
513
+ warn: (msg) => logger?.warn?.(msg)
514
+ });
515
+ sendJson(res, status, body);
533
516
  });
534
517
  return;
535
518
  }
package/lib/api.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { URL } from "node:url";
2
2
  import { timingSafeEqual, randomBytes } from "node:crypto";
3
3
  import { FEATURE_FLAG_SPEC } from "./settings.js";
4
+ import { TYPES } from "./store.js";
4
5
  import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
5
6
  import { langOf } from "./lang.js";
6
7
  import { computeHeat } from "./heat.js";
@@ -9,6 +10,7 @@ import { getInjectionSnapshot } from "./inject.js";
9
10
  import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
10
11
  import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
11
12
  import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
13
+ import { parseReclaimQuery, parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
12
14
 
13
15
  // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
14
16
  function sendJson(res, status, payload, headers = {}) {
@@ -92,7 +94,10 @@ function parseBody(text) {
92
94
  }
93
95
  }
94
96
 
95
- export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null) {
97
+ // maintenance 是第 9 个位置参数,默认 null:既有调用点(测试里的 14 处)全是位置传参、
98
+ // 不传它,必须继续工作——不传时 /maintenance/reclaim 回 503 maintenance-unavailable,
99
+ // 而不是把「没接线」伪装成崩溃(#386 第 4 条)。
100
+ export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null) {
96
101
  const disposers = [];
97
102
 
98
103
  // #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要
@@ -146,7 +151,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
146
151
  // Issue #380:注入前判定。漏登记的后果是 effective 静默缺键、面板盲——
147
152
  // 上面的 round-trip 测试专门锁这张表与白名单成对。
148
153
  "preInjectGate.enabled": ["preInjectGate", "enabled"],
149
- "preInjectGate.enforce": ["preInjectGate", "enforce"]
154
+ "preInjectGate.enforce": ["preInjectGate", "enforce"],
155
+ // Issue #399:蒸馏原文留存 + 溯源。两个键都是嵌套子字段(enabled 开关 +
156
+ // retentionDays 整数),漏登记的后果同上——effective 静默缺键。
157
+ "distillRecords.enabled": ["distillRecords", "enabled"],
158
+ "distillRecords.retentionDays": ["distillRecords", "retentionDays"]
150
159
  // sensitiveScanEnabled(#164 A2)是顶层扁平键,不需要进这张表——走 configFlagValue
151
160
  // 的默认分支即可。
152
161
  };
@@ -602,6 +611,165 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
602
611
  }
603
612
  });
604
613
 
614
+ // --- 新建一条记忆(面板写路径,与 /update、/delete 对称命名)---------------
615
+ // #386 第 5 条:CLI `add` 与 standalone `POST /memories` 都能建行,面板此前只
616
+ // 能看/改/删/导入。字段与 memory_save 工具对齐(type/title/content/importance/
617
+ // tags),走同一条 saveWithDedupe 路径——去重键仍是 (type, title, scope,
618
+ // sensitivity),所以「面板建」与「工具建」同标题会并入同一行,不会长出两份。
619
+ //
620
+ // 三处刻意的口径差异(都写在代码里,免得下一个人以为是抄漏):
621
+ // 1. content 要求 trim 后非空,与 /update 一致而非 standalone 的宽松版:
622
+ // 面板是人工输入,空正文只可能是误操作;standalone 面对的是程序化调用,
623
+ // 放宽是为了兼容「先建壳再补内容」的既有客户端。
624
+ // 2. document 直接 400:行铸造口唯一(registerDocument,#230),saveWithDedupe
625
+ // 会 throw,这里提前给干净错误码而不是让它落成 500。
626
+ // 3. 不接受 scope/sensitivity 字段——面板的 scope 编辑在详情抽屉里(带放宽
627
+ // 确认的安全阀,见 lib/client.js 的 MemoryDrawer),新建时就盖章 explicit
628
+ // 会绕过那道确认。传了也不报错,只是不生效(与 /update 的「字段出现即校验」
629
+ // 不同,因为这里根本没有该字段的语义)。
630
+ register({
631
+ kind: "exact",
632
+ path: "/api/dsh-mneme/create",
633
+ handler(req, res) {
634
+ try {
635
+ if (req.method !== "POST") {
636
+ sendJson(res, 404, { error: "not-found" });
637
+ return;
638
+ }
639
+ if (!requireAuth(req, res, apiToken)) return;
640
+ return readBody(req).then((text) => {
641
+ const body = parseBody(text);
642
+ if (!TYPES.has(body.type)) {
643
+ sendJson(res, 400, { error: "invalid-type" });
644
+ return;
645
+ }
646
+ if (body.type === "document") {
647
+ sendJson(res, 400, { error: "document-requires-register" });
648
+ return;
649
+ }
650
+ if (typeof body.title !== "string" || !body.title.trim()) {
651
+ sendJson(res, 400, { error: "invalid-title" });
652
+ return;
653
+ }
654
+ if (typeof body.content !== "string" || !body.content.trim()) {
655
+ sendJson(res, 400, { error: "invalid-content" });
656
+ return;
657
+ }
658
+ if (body.importance !== undefined
659
+ && (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5)) {
660
+ sendJson(res, 400, { error: "invalid-importance" });
661
+ return;
662
+ }
663
+ if (body.tags !== undefined && (!Array.isArray(body.tags) || !body.tags.every((tag) => typeof tag === "string"))) {
664
+ sendJson(res, 400, { error: "invalid-tags" });
665
+ return;
666
+ }
667
+ try {
668
+ const result = service.saveWithDedupe({
669
+ type: body.type,
670
+ title: body.title.trim(),
671
+ content: body.content.trim(),
672
+ importance: body.importance,
673
+ tags: body.tags ?? [],
674
+ // 面板不是工具也不是外部程序,来源要能在详情抽屉里一眼认出是谁写的。
675
+ source: "panel"
676
+ });
677
+ // #254 写入准入在无会话身份时放行(write-admission.js 首行早退),面板
678
+ // 路由恰好不带 _sessionKey,所以今天到不了这个分支。仍然显式写出来:
679
+ // 将来若把会话身份接进面板,落到 toApiList([null]) 上会变成 500,把
680
+ // 「被拒」伪装成「服务器出错」——两者的处置完全不同。
681
+ if (result.action === "denied") {
682
+ sendJson(res, 422, { error: "write-rejected", reason: result.reason });
683
+ return;
684
+ }
685
+ // action 随行透出(created/merged):面板据此提示「已并入同标题的既有条目」
686
+ // 还是「新建成功」——两者对用户是不同的事实,不能都报「已保存」。
687
+ sendJson(res, result.action === "created" ? 201 : 200, {
688
+ ...service.toApiList([result.memory])[0],
689
+ action: result.action
690
+ });
691
+ } catch (error) {
692
+ ctx.logger?.warn?.(`[dsh-mneme] panel create failed: ${String(error)}`);
693
+ sendJson(res, 500, { error: "internal" });
694
+ }
695
+ });
696
+ } catch {
697
+ sendJson(res, 500, { error: "internal" });
698
+ }
699
+ }
700
+ });
701
+
702
+ // --- maintenance/reclaim(#386 第 4 条:面板有了回收入口)------------------
703
+ // 与 standalone 数据面的 POST /maintenance/reclaim 是同一能力的两张皮:口径(错误码
704
+ // 字符串、默认值、报告形状)只写在 src/reclaim-http.js 里,这里与数据面都调它。
705
+ // 差别只有鉴权与路径——两张皮的其余部分必须没有第二份实现(#411 的教训:以前是整段
706
+ // 复制,于是 #410 修好这边之后那边还停在旧形态)。
707
+ //
708
+ // 为什么 GET 也能跑:dry-run 是只读的(src/maintenance.js 里 dry-run 与执行同一条
709
+ // 代码路径,只是不落 UPDATE/VACUUM),面板 mount 时拿它渲染「现在能回收多少」。
710
+ //
711
+ // 为什么两法都要 requireAuth:这是不可逆内容丢弃的入口,不能有例外。
712
+ register({
713
+ kind: "exact",
714
+ path: "/api/dsh-mneme/maintenance/reclaim",
715
+ handler(req, res) {
716
+ try {
717
+ if (req.method === "GET") {
718
+ if (!requireAuth(req, res, apiToken)) return;
719
+ if (!maintenance) {
720
+ sendJson(res, 503, { error: RECLAIM_UNAVAILABLE });
721
+ return;
722
+ }
723
+ const parsed = parseReclaimQuery(new URL(req.url, "http://localhost").searchParams.get("olderThanDays"));
724
+ if (!parsed.ok) {
725
+ sendJson(res, 400, { error: parsed.error });
726
+ return;
727
+ }
728
+ const { status, body } = reclaimReply({
729
+ maintenance,
730
+ dryRun: true,
731
+ olderThanDays: parsed.olderThanDays,
732
+ warn: (msg) => ctx.logger?.warn?.(msg)
733
+ });
734
+ sendJson(res, status, body);
735
+ return;
736
+ }
737
+ if (req.method !== "POST") {
738
+ sendJson(res, 404, { error: "not-found" });
739
+ return;
740
+ }
741
+ if (!requireAuth(req, res, apiToken)) return;
742
+ return readBody(req).then((text) => {
743
+ const parsed = parseReclaimJson(text);
744
+ if (!parsed.ok) {
745
+ sendJson(res, 400, { error: parsed.error });
746
+ return;
747
+ }
748
+ if (!maintenance) {
749
+ sendJson(res, 503, { error: RECLAIM_UNAVAILABLE });
750
+ return;
751
+ }
752
+ const fields = readReclaimFields(parsed.body);
753
+ if (!fields.ok) {
754
+ sendJson(res, 400, { error: fields.error });
755
+ return;
756
+ }
757
+ const { status, body } = reclaimReply({
758
+ maintenance,
759
+ dryRun: fields.dryRun,
760
+ olderThanDays: fields.olderThanDays,
761
+ vacuum: fields.vacuum,
762
+ confirm: fields.confirm,
763
+ warn: (msg) => ctx.logger?.warn?.(msg)
764
+ });
765
+ sendJson(res, status, body);
766
+ });
767
+ } catch {
768
+ sendJson(res, 500, { error: "internal" });
769
+ }
770
+ }
771
+ });
772
+
605
773
  // --- rules ---
606
774
  register({
607
775
  kind: "exact",
@@ -1429,7 +1597,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1429
1597
  });
1430
1598
 
1431
1599
  return {
1432
- routes: 32,
1600
+ routes: 34,
1433
1601
  dispose: () => {
1434
1602
  for (const dispose of disposers) dispose();
1435
1603
  }