@modusensus/dsh-mneme 0.8.6 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +18 -148
  2. package/bin/cli.mjs +689 -603
  3. package/lib/api-standalone.js +57 -1
  4. package/lib/api.js +34 -11
  5. package/lib/client.js +289 -59
  6. package/lib/config.js +54 -6
  7. package/lib/content-hash.js +54 -0
  8. package/lib/document-index.js +94 -0
  9. package/lib/document.js +93 -11
  10. package/lib/index.js +50 -11
  11. package/lib/inject.js +6 -2
  12. package/lib/lang.js +15 -4
  13. package/lib/maintenance.js +142 -0
  14. package/lib/mirror.js +147 -15
  15. package/lib/recall-stats.js +52 -2
  16. package/lib/scope.js +34 -6
  17. package/lib/service.js +86 -7
  18. package/lib/settings.js +2 -1
  19. package/lib/store.js +231 -21
  20. package/lib/tools.js +27 -9
  21. package/lib/write-admission.js +259 -0
  22. package/package.json +4 -4
  23. package/src/api-standalone.js +57 -1
  24. package/src/api.js +34 -11
  25. package/src/config.js +54 -6
  26. package/src/content-hash.js +54 -0
  27. package/src/document-index.js +94 -0
  28. package/src/document.js +93 -11
  29. package/src/index.js +50 -11
  30. package/src/inject.js +6 -2
  31. package/src/lang.js +15 -4
  32. package/src/maintenance.js +142 -0
  33. package/src/mirror.js +147 -15
  34. package/src/recall-stats.js +52 -2
  35. package/src/scope.js +34 -6
  36. package/src/service.js +86 -7
  37. package/src/settings.js +2 -1
  38. package/src/store.js +231 -21
  39. package/src/tools.js +27 -9
  40. package/src/write-admission.js +259 -0
  41. package/test/api.test.js +12 -0
  42. package/test/client.test.js +90 -5
  43. package/test/content-hash.test.js +204 -0
  44. package/test/document-dir.test.js +233 -0
  45. package/test/document.test.js +151 -3
  46. package/test/inject-parent-gate.test.js +106 -0
  47. package/test/inject-pools.test.js +8 -3
  48. package/test/llm-audit.test.js +42 -0
  49. package/test/maintenance.test.js +391 -0
  50. package/test/mirror-types.test.js +208 -0
  51. package/test/recall-evals.test.js +18 -0
  52. package/test/recall-stats.test.js +66 -0
  53. package/test/write-admission.test.js +345 -0
@@ -0,0 +1,142 @@
1
+ // #275 存储生命周期第一批:无损回收的维护入口。
2
+ //
3
+ // 为什么是「手动入口 + dry-run + receipt」而不是后台任务:这两步都是不可逆的内容
4
+ // 丢弃(历史 run 的输入快照置空、归档行的向量置空),该由人在看得见数字的时候按下
5
+ // (维护者 2026-09-23 拍板:不挂启动自动跑,VACUUM 约 0.8s 说明它就该是个随手跑的
6
+ // 东西)。所以模块里没有任何定时器,挂点只有两个:standalone 数据面的
7
+ // `POST /maintenance/reclaim` 与它上面的 `dsh-mneme reclaim` 子命令。
8
+ //
9
+ // 两项都零价值判断、零条数变化:
10
+ // A `dream_runs.input` 置空。这一列的含义按 run_type 分叉:auto / sleep 的 run 存的
11
+ // 是当时的记忆库快照,可由记忆库重建;**organize 的 run 存的是 apply 的重放载荷**,
12
+ // 置空会让那次 apply 静默空转并把报告锁死,所以 organize 行不在可清范围内(谓词
13
+ // 在 store.dreamRunInputStats / clearDreamRunInputs 里)。run 的骨架、LLM 决策原文
14
+ // 与 receipt 一律保留,行永不删(拍板 3:裁列同意、删行不同意)。
15
+ // B 归档行向量置空:检索 SQL 恒带 `archived = 0`,这部分向量按定义不可达。它不是
16
+ // 单程票——取消归档时 service 会重新排队嵌入(archiveMemory 的还原分支)。
17
+ //
18
+ // 报告口径(拍板 2):收益按 VACUUM 前后体积量,不按列字节估——实际释放来自溢出页与
19
+ // 索引页回收,列文本大小只是上界,所以两个数分开报,不混成一个「省了多少」。
20
+ //
21
+ // 与 #254 的分界(拍板 4):本入口止体积、不止重复。归档区里同主题堆积是出口问题,
22
+ // 归 #254 的写入准入与后续的整理动作面,这里一条记忆都不碰。
23
+ export const RECLAIM_TRIGGER_SOURCE = "maintenance";
24
+ export const RECLAIM_OPERATION_TYPE = "storage_reclaim";
25
+ // 输入快照的默认保留窗口:7 天内仍可能被离线回放(近期 run 的决策上下文),更早的
26
+ // 重建成本低于留存成本。窗口由调用方显式给,不做成配置键——配置键意味着有个周期性
27
+ // 消费者,而本批次刻意不接周期动作。
28
+ export const DEFAULT_INPUT_RETENTION_DAYS = 7;
29
+
30
+ /**
31
+ * 无损回收(#275 第一批)。返回一个函数:同一条代码路径跑 dry-run 与执行,dry-run
32
+ * 只是不落 UPDATE/VACUUM,所以「报出来的数字」与「执行时的数字」出自同一处统计。
33
+ *
34
+ * @param {object} deps
35
+ * @param {object} deps.store 存储层(dreamRunInputStats / clearDreamRunInputs /
36
+ * archivedEmbeddingStats / clearArchivedEmbeddings / storageStats / vacuum / saveLlmAudit)
37
+ * @param {object} [deps.config] 已解析配置(读 llmAudit.enabled:关掉 audit 时不落 receipt)
38
+ * @param {object} [deps.logger]
39
+ * @param {() => number} [deps.now] 时钟注入(测试用)
40
+ */
41
+ export function createMaintenance({ store, config, logger, now = Date.now } = {}) {
42
+ const warn = (msg) => {
43
+ try {
44
+ logger?.warn?.(msg);
45
+ } catch { /* 日志故障不能影响回收本身 */ }
46
+ };
47
+
48
+ /** receipt 落到 llm_audit_logs(既有 bookkeeping 表,不触发写钩子)。 */
49
+ function writeReceipt(report) {
50
+ if (config?.llmAudit?.enabled === false) return null;
51
+ try {
52
+ const row = store.saveLlmAudit({
53
+ trigger_source: RECLAIM_TRIGGER_SOURCE,
54
+ operation_type: RECLAIM_OPERATION_TYPE,
55
+ // 不是模型调用,但该列 NOT NULL——用显式占位串,免得混进按路由分组的花费统计。
56
+ model_id: "-",
57
+ status: "success",
58
+ metadata: {
59
+ older_than_days: report.olderThanDays,
60
+ cleared: report.cleared,
61
+ size_before: report.size.before_bytes,
62
+ size_after: report.size.after_bytes,
63
+ vacuum: report.vacuum.ran,
64
+ duration_ms: report.duration_ms
65
+ }
66
+ });
67
+ return row?.id ?? null;
68
+ } catch (e) {
69
+ // 回执失败只 warn:清理已经发生了,不能因为记不上账就当作没发生(也不能反噬)。
70
+ warn(`[dsh-mneme] storage reclaim receipt failed: ${String(e)}`);
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * @param {object} [opts]
77
+ * @param {number} [opts.olderThanDays] 输入快照的保留窗口(天);0 = 清掉全部快照
78
+ * @param {boolean} [opts.vacuum] 是否在清理后 VACUUM(默认否:它要排他锁,由人决定)
79
+ * @param {boolean} [opts.dryRun] 默认 true——只报数字,不改一个字节
80
+ * @returns {object} 报告(dry-run 与执行同形状,执行时多 receipt 字段)
81
+ */
82
+ function reclaim({ olderThanDays = DEFAULT_INPUT_RETENTION_DAYS, vacuum = false, dryRun = true } = {}) {
83
+ const days = Number.isFinite(olderThanDays) && olderThanDays >= 0 ? olderThanDays : DEFAULT_INPUT_RETENTION_DAYS;
84
+ const cutoff = new Date(now() - days * 86400000).toISOString();
85
+ const started = now();
86
+ const before = store.storageStats();
87
+ const inputs = store.dreamRunInputStats(cutoff);
88
+ const embeddings = store.archivedEmbeddingStats();
89
+ const report = {
90
+ dryRun,
91
+ olderThanDays: days,
92
+ cutoff,
93
+ // planned = 这次会动几行;cleared = 实际动了几行(dry-run 恒 0)
94
+ planned: { dream_run_inputs: inputs.runs, archived_embeddings: embeddings.rows },
95
+ // 列文本大小:上界,不是回收量(真实释放看下面的 size 差值)
96
+ column_bytes: { dream_run_inputs: inputs.bytes, archived_embeddings: embeddings.bytes },
97
+ cleared: { dream_run_inputs: 0, archived_embeddings: 0 },
98
+ size: {
99
+ before_bytes: before.fileBytes,
100
+ after_bytes: before.fileBytes,
101
+ // 空闲页就是「现在 VACUUM 能立刻收回来的那部分」,带上它 dry-run 才有个能
102
+ // 参照的量(真实数字仍然只有做完才知道)
103
+ freelist_pages: before.freelistCount,
104
+ freelist_bytes: before.freelistCount * before.pageSize,
105
+ page_count: before.pageCount
106
+ },
107
+ vacuum: { requested: vacuum === true, ran: false, duration_ms: null, error: null },
108
+ receipt: null,
109
+ duration_ms: 0
110
+ };
111
+ if (dryRun) {
112
+ report.duration_ms = now() - started;
113
+ return report;
114
+ }
115
+ report.cleared.dream_run_inputs = store.clearDreamRunInputs(cutoff);
116
+ report.cleared.archived_embeddings = store.clearArchivedEmbeddings();
117
+ if (report.vacuum.requested) {
118
+ // VACUUM 失败不能把前面的清理一起吞掉:两个 UPDATE 已经各自提交了,此时抛出去
119
+ // 就是「数据已经不可逆地清了,但没有任何回执」。所以失败记进报告、回执照写,
120
+ // 让调用方看到「清了 N 行,VACUUM 没跑成」——锁被别的进程占着(SQLITE_BUSY)是
121
+ // 最常见的成因,重跑一次 --apply --vacuum 即可(清理是幂等的)。
122
+ try {
123
+ const result = store.vacuum();
124
+ report.vacuum.ran = true;
125
+ report.vacuum.duration_ms = result.duration_ms;
126
+ } catch (e) {
127
+ report.vacuum.error = String(e);
128
+ warn(`[dsh-mneme] storage reclaim vacuum failed: ${String(e)}`);
129
+ }
130
+ }
131
+ const after = store.storageStats();
132
+ report.size.after_bytes = after.fileBytes;
133
+ report.size.freelist_pages = after.freelistCount;
134
+ report.size.freelist_bytes = after.freelistCount * after.pageSize;
135
+ report.size.page_count = after.pageCount;
136
+ report.duration_ms = now() - started;
137
+ report.receipt = writeReceipt(report);
138
+ return report;
139
+ }
140
+
141
+ return { reclaim };
142
+ }
package/lib/mirror.js CHANGED
@@ -2,17 +2,37 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node
2
2
  import { createHash } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import { STR, langOf } from "./lang.js";
5
+ import { PACKAGE_VERSION } from "./version-check.js";
5
6
 
6
7
  export const TYPE_FILE = {
7
8
  preference: "preferences.md",
8
9
  project: "projects.md",
9
10
  decision: "decisions.md",
10
11
  history: "history.md",
11
- summary: "summary.md"
12
+ summary: "summary.md",
13
+ pitfall: "pitfalls.md",
14
+ constraint: "constraints.md",
15
+ rejected_solution: "rejected-solutions.md",
16
+ pattern: "patterns.md",
17
+ document: "documents.md"
12
18
  };
13
19
 
20
+ // 不落镜像的 type。现在是空集:document 由 #296 第二批收进来——第一版排除它的
21
+ // 理由是「镜像块渲染的字段里没有 doc_path,落一个 documents.md 会得到看着完整、
22
+ // 其实找不到文件的视图」,doc_path 进指针行以后这条理由不再成立。这个集合由
23
+ // test/mirror-types.test.js 钉死(TYPES \ TYPE_FILE 必须恰好等于本集合):将来
24
+ // 新增 type 时必须显式决定它落不落镜像,静默漏掉才是 #278 要修的那种盲区。
25
+ export const MIRROR_EXCLUDED_TYPES = new Set([]);
26
+
27
+ // 只读视图(#296):document 的镜像只有指针行(id + 标题 + 摘要首句 + doc_path),
28
+ // 没有可编辑的正文,所以它不参与 readHumanEdits 的人改合并,手工改动一律被下次
29
+ // 同步覆盖;导出/导入也不带它(那两个是 round-trip 通道,见 api.js)。
30
+ export const MIRROR_READONLY_TYPES = new Set(["document"]);
31
+
14
32
  const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
15
33
  const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
34
+ // 只读视图里摘要首句的字符上限(#296):够定位就行,长摘要不该把一行撑成一段。
35
+ const POINTER_MAX = 120;
16
36
 
17
37
  function esc(text) {
18
38
  return String(text).replace(ESCAPE, "\\$1");
@@ -22,6 +42,36 @@ function unescape(text) {
22
42
  return String(text).replace(UNESCAPE, "$1");
23
43
  }
24
44
 
45
+ // 元数据标签的唯一来源是 STR.mirrorLabel(lang.js):渲染按实例语言取,解析两套
46
+ // 都认。下面的正则从那张表现算——旧实现把手写正则与 lang.js 的两处文案分开维护,
47
+ // 加一个字段要改三处,漏改一处那一行就会随 /import 写进 entry 的 content。
48
+ const META_LABELS = [];
49
+ for (const language of Object.keys(STR.mirrorLabel)) META_LABELS.push(...Object.values(STR.mirrorLabel[language]));
50
+
51
+ function reEsc(text) {
52
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
53
+ }
54
+
55
+ // 元数据行的值必须单行:值里带换行会多出一行,轻则破坏「整段元数据行」的解析契约,
56
+ // 重则伪造出一个条目头(sensitivity 是调用方传入的任意字符串,属信任边界)。
57
+ function oneLine(text) {
58
+ return String(text).replace(/\s+/g, " ").trim();
59
+ }
60
+
61
+ // 结构头锚:ID 行紧跟类型行才算条目头(正文里机器格式的 ID 行不会切开块)。
62
+ const ANCHOR_RE = new RegExp(
63
+ "^- \\*\\*ID\\*\\*: `([^`]+)`\\n- \\*\\*(?:" +
64
+ [STR.mirrorLabel.zh.type, STR.mirrorLabel.en.type].map(reEsc).join("|") +
65
+ ")\\*\\*:", "gm"
66
+ );
67
+ // 渲染出的整段元数据行(加字段只需改 lang.js 的 mirrorLabel)。
68
+ const META_RUN_RE = new RegExp(`^(- \\*\\*(?:${META_LABELS.map(reEsc).join("|")})\\*\\*:.*\\n?)+`);
69
+ // 三方合并用的版本令牌(service.syncMirror 比对并发写)。
70
+ const UPDATED_RE = new RegExp(
71
+ "- \\*\\*(?:" + [STR.mirrorLabel.zh.updated, STR.mirrorLabel.en.updated].map(reEsc).join("|") +
72
+ ")\\*\\*: ([^\\n]+)"
73
+ );
74
+
25
75
  function renderMemory(m, language = "zh") {
26
76
  // last-rendered digest baseline: sha256(title \x00 content). service.js
27
77
  // compares the file hash against this to tell "untouched by a human" (machine
@@ -40,6 +90,12 @@ function renderMemory(m, language = "zh") {
40
90
  lines.push(`- **${ML.tags}**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
41
91
  lines.push(`- **${ML.updated}**: ${m.updated_at}`);
42
92
  if (m.source) lines.push(`- **${ML.source}**: ${esc(m.source)}`);
93
+ // scope / sensitivity 是只读展示字段(#278:sensitivity 的过滤语义另有归属,见
94
+ // 那条讨论;这里只把值摆出来)。只在真有值时渲染——本机 2843 条里只有 37 条带
95
+ // scope 标签,无条件渲染会给每条目多添两行空字段。
96
+ const scopeText = [m.agent_scope, m.workspace_scope].filter(Boolean).map(oneLine).join(" / ");
97
+ if (scopeText) lines.push(`- **${ML.scope}**: ${scopeText}`);
98
+ if (m.sensitivity) lines.push(`- **${ML.sensitivity}**: ${oneLine(m.sensitivity)}`);
43
99
  lines.push("");
44
100
  lines.push(`<!-- mirror-digest: ${digest} -->`);
45
101
  lines.push(m.content);
@@ -49,22 +105,76 @@ function renderMemory(m, language = "zh") {
49
105
  return lines.join("\n");
50
106
  }
51
107
 
108
+ /**
109
+ * 文件头 frontmatter(#278 第一批)。按 type 分文件时一个文件装 N 条,所以
110
+ * frontmatter 只能落在文件级;条目级元数据仍是块内的 `- **字段**:` 行(见
111
+ * renderMemory)。
112
+ *
113
+ * `covered` / `coverage` 是覆盖范围的自述,必须与这份文本**实际包含的行集**一致:
114
+ * 磁盘镜像写活跃集(active-only),/export 的文档写全表(all)。说一套写一套比
115
+ * 没有这个字段更糟——外部工具会照着它聚合条数与标签。
116
+ *
117
+ * 键名对齐 OKF v0.2:`type` 是 §4.1 唯一必填键,`tags` 是 §4.1 的推荐键,
118
+ * `generated.by` / `generated.at` 是 §5.2 的 trust 族。`generated.by` 按 §7 的
119
+ * actor 约定写 `<producer>/<version>`(所以带版本号);`generated.at` 按 §5.2 的
120
+ * 语义只记「内容上次真正变化」,sync 因此只在正文变化时才落盘。v0.2 已用
121
+ * `generated.at` 取代 v0.1 的 `timestamp`,没有 `updated` 这个顶层键。
122
+ * `covered` / `coverage` 是 §4.1 允许的 producer 自定义扩展。
123
+ */
124
+ export function renderFileHeader(type, items, coverage = "active-only") {
125
+ const tags = [...new Set(items.flatMap((m) => (m.tags ?? []).map(String)))].sort();
126
+ return [
127
+ "---",
128
+ `type: ${type}`,
129
+ `generated.by: dsh-mneme/${PACKAGE_VERSION}`,
130
+ `generated.at: ${new Date().toISOString()}`,
131
+ `covered: ${items.length}`,
132
+ `coverage: ${coverage}`,
133
+ `tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
134
+ "---",
135
+ ""
136
+ ].join("\n");
137
+ }
138
+
139
+ /**
140
+ * document 的只读视图一行(#296):一句话定位 + 文件指针,正文按 doc_path 去读。
141
+ * 摘要首句按句末标点切,切不出来就整段(超长再按字符数兜底),保证「一行一个
142
+ * 文档」的形态不被长摘要撑破。
143
+ */
144
+ function renderPointer(m) {
145
+ const flat = String(m.content ?? "").replace(/\s+/g, " ").trim();
146
+ // 句末标点切首句:中文的。!? 后面通常不跟空格,不能像英文那样要求一个空白
147
+ // (要求了就一句都切不出来,整段摘要被当成「首句」塞进视图)。英文的 . 仍要求
148
+ // 后接空白或结尾,免得把 e.g. / v1.2 这类点号当成句末。
149
+ const first = flat.match(/^.*?(?:[。!?]|[!?]|[.](?=\s|$))|^.*$/s)?.[0] ?? flat;
150
+ // 按码点切,不按 UTF-16 码元:后者会在 emoji / 代理对中间落刀,落盘成 U+FFFD。
151
+ const chars = [...first];
152
+ const summary = chars.length > POINTER_MAX ? `${chars.slice(0, POINTER_MAX).join("")}…` : first;
153
+ const where = m.doc_path ? ` · \`${oneLine(m.doc_path)}\`` : "";
154
+ return `- \`${m.id}\` **${oneLine(m.title)}**:${oneLine(summary)}${where}\n`;
155
+ }
156
+
52
157
  /**
53
158
  * Render one type's memories into exactly the mirror-file text (header +
54
159
  * per-memory blocks, updated_at DESC like sync). sync() writes this to disk;
55
- * the /export endpoint returns the same text, so an exported markdown is
56
- * byte-compatible with a mirror file and can be fed straight back through
57
- * parseHumanEdits → mergeHumanEdits. Unknown type → undefined.
160
+ * the /export endpoint reuses the same entry blocks for its per-type sections,
161
+ * so an exported markdown round-trips back through parseHumanEdits →
162
+ * mergeHumanEdits. Unknown type → undefined.
163
+ *
164
+ * `fileHeader: false` 供 /export 用:一个文档只能有一个 frontmatter 块(在文档
165
+ * 最前,见 api.js 的 /export),所以分节不再各自带文件头。只读 type(#296 的
166
+ * document)只走磁盘镜像这一条:渲染指针行 + 只读文件头,导出侧不取它。
58
167
  */
59
- export function renderMirrorText(type, memories, language = "zh") {
168
+ export function renderMirrorText(type, memories, language = "zh", { fileHeader = true } = {}) {
60
169
  const name = TYPE_FILE[type];
61
170
  if (!name) return undefined;
62
171
  const items = (memories ?? [])
63
172
  .slice()
64
173
  .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
65
- const header = STR.mirrorHeader[language](name);
66
- const body = items.map((m) => renderMemory(m, language)).join("\n");
67
- return header + body;
174
+ const readonly = MIRROR_READONLY_TYPES.has(type);
175
+ const header = readonly ? STR.mirrorReadonlyHeader[language](name) : STR.mirrorHeader[language](name);
176
+ const body = items.map((m) => (readonly ? renderPointer(m) : renderMemory(m, language))).join("\n");
177
+ return (fileHeader ? renderFileHeader(type, items) : "") + header + body;
68
178
  }
69
179
 
70
180
  /**
@@ -91,7 +201,7 @@ export function parseHumanEdits(text) {
91
201
  // A body line like "- **ID**: `x`" is not, so it never splits the block
92
202
  // or produces a ghost entry.
93
203
  // 解析同时接受两种语言的标签:切换语言前渲染的镜像文件仍能合并。
94
- const anchors = [...normalized.matchAll(/^- \*\*ID\*\*: `([^`]+)`\n- \*\*(?:类型|Type)\*\*:/gm)];
204
+ const anchors = [...normalized.matchAll(ANCHOR_RE)];
95
205
  let prevEnd = 0;
96
206
  for (let i = 0; i < anchors.length; i++) {
97
207
  const anchor = anchors[i];
@@ -109,7 +219,7 @@ export function parseHumanEdits(text) {
109
219
  let body = normalized
110
220
  .slice(blockStart, blockEnd)
111
221
  .replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
112
- .replace(/^(- \*\*(?:类型|重要性|标签|更新时间|来源|Type|Importance|Tags|Updated|Source)\*\*:.*\n?)+/, "")
222
+ .replace(META_RUN_RE, "")
113
223
  .replace(/^<!-- mirror-digest: [a-f0-9]+ -->\n?/m, "");
114
224
  const separators = [...body.matchAll(/^---\s*$/gm)];
115
225
  const lastSep = separators[separators.length - 1];
@@ -120,7 +230,7 @@ export function parseHumanEdits(text) {
120
230
  // render time — the version token for detecting a concurrent store write
121
231
  // during a three-way merge of human edits (see service.syncMirror).
122
232
  const block = normalized.slice(blockStart, blockEnd);
123
- const updatedMatch = block.match(/- \*\*(?:更新时间|Updated)\*\*: ([^\n]+)/);
233
+ const updatedMatch = block.match(UPDATED_RE);
124
234
  const digestMatch = block.match(/<!-- mirror-digest: ([a-f0-9]+) -->/);
125
235
  edits.push({
126
236
  id: anchor[1],
@@ -136,6 +246,15 @@ export function parseHumanEdits(text) {
136
246
  return edits;
137
247
  }
138
248
 
249
+ /**
250
+ * 判断「这份文本的内容有没有变」时忽略 generated.at:它每次渲染都是新时间戳,
251
+ * 带上它就永远判为变化。其余字节必须逐字相同才算没变——镜像的幂等性(同样的库
252
+ * 渲染两次得到同样的文件)才是可断言的。
253
+ */
254
+ function stripGeneratedAt(text) {
255
+ return String(text).replace(/^generated\.at: .*$/m, "");
256
+ }
257
+
139
258
  export function createMirror(dir, language = "zh") {
140
259
  mkdirSync(dir, { recursive: true });
141
260
 
@@ -150,7 +269,10 @@ export function createMirror(dir, language = "zh") {
150
269
  * this wrapper only owns the "read file → text" side.
151
270
  */
152
271
  function readHumanEdits(type = undefined) {
153
- const types = type ? [type] : Object.keys(TYPE_FILE);
272
+ // 只读 type 不参与人改合并(#296):它们的文件里没有可编辑的正文,解析出来的
273
+ // 指针行文本若被当成「人改」写回库,会污染摘要。整类跳过,包括 type 指定的
274
+ // 那条调用路径。
275
+ const types = (type ? [type] : Object.keys(TYPE_FILE)).filter((t) => !MIRROR_READONLY_TYPES.has(t));
154
276
  const edits = [];
155
277
  for (const t of types) {
156
278
  const file = filePath(t);
@@ -182,9 +304,19 @@ export function createMirror(dir, language = "zh") {
182
304
  // memories do not "resurrect" via readHumanEdits
183
305
  rmSync(file, { force: true });
184
306
  } else {
185
- // 渲染走 renderMirrorText(与 /export 共用同一条渲染路径),磁盘镜像
186
- // 与导出文本永远同构。
187
- writeFileSync(file, renderMirrorText(type, items, language), "utf8");
307
+ // 渲染走 renderMirrorText(与 /export 共用同一条渲染路径)。
308
+ const text = renderMirrorText(type, items, language);
309
+ // 内容没变就不落盘。generated.at 每次渲染都新,无脑写会让编辑器、同步盘
310
+ // 与 git 在每次业务写后看到全部镜像文件「被外部修改」,而内容其实一字未
311
+ // 变;按 OKF 的语义它只该记内容变化,未变就保留旧值。这也是同步读 9 个
312
+ // 小文件的代价,比无谓写 9 次盘便宜。
313
+ let prev = null;
314
+ try {
315
+ prev = readFileSync(file, "utf8");
316
+ } catch { /* 首次写:读不到就是没有旧文件 */ }
317
+ if (prev === null || stripGeneratedAt(prev) !== stripGeneratedAt(text)) {
318
+ writeFileSync(file, text, "utf8");
319
+ }
188
320
  }
189
321
  results[type] = { ok: true };
190
322
  } catch (error) {
@@ -5,6 +5,8 @@
5
5
  // 新内聚块从第一行就落在自己的文件里,service.js 保留 barrel 出口、调用方零改动。
6
6
  // 纯读不写,绝不触发任何 write hook。
7
7
 
8
+ import { scopeKeyOf } from "./scope.js";
9
+
8
10
  /**
9
11
  * 口径(issue #217 评论 2026-09-18,锚 5bd2dab):
10
12
  * - Top-N:窗口内 recall_runs.candidates(最终返回集)按 id 计数,join
@@ -20,6 +22,17 @@
20
22
  * - coverage:recallRecordDefault 开启前的窗口算不到,earliestRunAt 为
21
23
  * null(窗口内无回执)或早于窗口起点时,前端标注可信度;扫描行数有
22
24
  * 上限,超出标 truncated(degraded 口径),不静默少算。
25
+ * - archive(#275 拍板的第五指标,与上面几项同位):
26
+ * · total = 归档区现有行数;addedInWindow / perDay = 窗口内进入归档区的行数
27
+ * 与其日均(净增速率)。归档时刻只能取 updated_at(setArchived 翻标志位时刷
28
+ * 它)——这是代理口径:对已归档行做一次 memory_update,或对同一行重复调
29
+ * setArchived(…, true),都会被算成「本窗口新进归档」,perDay 因此偏高。精确
30
+ * 口径要一个 archived_at 列(schema 变更,属第二批题材),本版不加;真正的
31
+ * 「净」增本来也要跨快照比 total(物理删除落地后,差值才会由负向变化体现)。
32
+ * · compressible = 可压掉行数:同 type、同 scope 三维且内容哈希完全相同的归档
33
+ * 行里,多出来的那些(每组留一行)。用内容哈希而不是向量近重复,是因为回收
34
+ * 动作本身会清掉归档行向量(clearArchivedEmbeddings)——指标不能建在它自己
35
+ * 的输入会被回收掉的数据上;精确重复与 #254 的计量口径同一把尺。
23
36
  *
24
37
  * @param {object} store - createStore 产物(只调用 listRecallRunsSince / all)
25
38
  * @param {{ windowDays?: number, exemptDays?: number }} [options]
@@ -36,7 +49,8 @@ export function recallStats(store, options = {}) {
36
49
  const rawSlots = Number(options.maxInjectSlots ?? 5);
37
50
  const maxInjectSlots = Number.isInteger(rawSlots) && rawSlots >= 1 ? rawSlots : 5;
38
51
  const now = Date.now();
39
- const since = new Date(now - windowDays * 86400000).toISOString();
52
+ const sinceMs = now - windowDays * 86400000;
53
+ const since = new Date(sinceMs).toISOString();
40
54
 
41
55
  const { rows: runs, total } = store.listRecallRunsSince(since);
42
56
  const hits = new Map(); // id -> { count, title, source }
@@ -67,8 +81,29 @@ export function recallStats(store, options = {}) {
67
81
  let activeCount = 0;
68
82
  let zombieCount = 0;
69
83
  let exemptCount = 0;
84
+ // 第五指标(#275):归档区一侧单独累计,分组键与 #254 的去重候选集同构
85
+ // (type + 哈希 + scope 三维)——可压掉的必须是「本来就会被判成同一件事」的行。
86
+ let archivedTotal = 0;
87
+ let archivedInWindow = 0;
88
+ const hashGroups = new Map();
70
89
  for (const m of memories) {
71
- if (m.archived || m.forgotten) continue;
90
+ if (m.archived) {
91
+ archivedTotal += 1;
92
+ const archivedMs = Date.parse(m.updated_at ?? "");
93
+ if (Number.isFinite(archivedMs) && archivedMs >= sinceMs) archivedInWindow += 1;
94
+ if (m.content_hash) {
95
+ const key = [
96
+ m.type ?? "",
97
+ m.content_hash,
98
+ scopeKeyOf(m.agent_scope) ?? "",
99
+ scopeKeyOf(m.workspace_scope) ?? "",
100
+ scopeKeyOf(m.sensitivity) ?? ""
101
+ ].join("\u0000");
102
+ hashGroups.set(key, (hashGroups.get(key) ?? 0) + 1);
103
+ }
104
+ continue;
105
+ }
106
+ if (m.forgotten) continue;
72
107
  const createdMs = Date.parse(m.created_at ?? "");
73
108
  // created_at 解析不了时无法证明已过机会期 → 归入豁免,宁漏勿误伤
74
109
  if (!Number.isFinite(createdMs) || now - createdMs < EXEMPT_MS) {
@@ -98,6 +133,14 @@ export function recallStats(store, options = {}) {
98
133
  .sort((a, b) => b.count - a.count || a.id.localeCompare(b.id))
99
134
  .slice(0, 10);
100
135
 
136
+ let compressibleGroups = 0;
137
+ let compressibleRows = 0;
138
+ for (const n of hashGroups.values()) {
139
+ if (n < 2) continue;
140
+ compressibleGroups += 1;
141
+ compressibleRows += n - 1;
142
+ }
143
+
101
144
  return {
102
145
  windowDays,
103
146
  generatedAt: new Date(now).toISOString(),
@@ -113,6 +156,13 @@ export function recallStats(store, options = {}) {
113
156
  slotFillRate: injectRuns > 0 ? injectedCount / (injectRuns * maxInjectSlots) : null
114
157
  },
115
158
  topRecalled,
159
+ // #275 第五指标:归档净增速率 + 可压掉行数(口径见文件头)
160
+ archive: {
161
+ total: archivedTotal,
162
+ addedInWindow: archivedInWindow,
163
+ perDay: Math.round((archivedInWindow / windowDays) * 100) / 100,
164
+ compressible: { rows: compressibleRows, groups: compressibleGroups }
165
+ },
116
166
  zombie: {
117
167
  activeCount,
118
168
  zombieCount,
package/lib/scope.js CHANGED
@@ -34,12 +34,7 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
34
34
  };
35
35
 
36
36
  return function resolveWriteScope(exec) {
37
- let session = null;
38
- try {
39
- session = exec?.agent?.session ?? null;
40
- } catch {
41
- session = null;
42
- }
37
+ const session = sessionOf(exec);
43
38
 
44
39
  // 持久化 header(issue #17 的身份契约):session.header 是创建时冻结的
45
40
  // SessionHeader(含 agentPreset/cwd)。⚠️ 不能先读 requestHeader()——那返回
@@ -83,6 +78,39 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
83
78
  };
84
79
  }
85
80
 
81
+ /**
82
+ * 工具 exec 上的会话对象。宿主 ctx 是 Proxy(cordis 4),裸读未 inject 的属性会
83
+ * 抛错——所以取值恒包在 try 里,取不到返回 null。scope 解析与写入准入的会话键
84
+ * 共用这一处取值口径。
85
+ * @param {object} exec
86
+ * @returns {object|null}
87
+ */
88
+ export function sessionOf(exec) {
89
+ try {
90
+ return exec?.agent?.session ?? null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * 写入准入的会话键(#254 G1 会话写入预算的计数单位)。session_id 在这里只作
98
+ * 「同一段对话」的标识,不作为作用域键(见文件头:记忆挂在持久化身份标签上);
99
+ * 取不到会话身份(系统写入)返回 null = 不进预算。
100
+ * @param {object} exec
101
+ * @returns {string|null}
102
+ */
103
+ export function sessionKeyOf(exec) {
104
+ // 整个取值(含 .id)都在 try 里:宿主 session 可能是会抛错的 accessor,而这个
105
+ // 函数在 memory_save 的保存之前被调用——抛出去就是一次工具调用直接失败。
106
+ try {
107
+ const id = sessionOf(exec)?.id;
108
+ return id == null ? null : String(id);
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
86
114
  /**
87
115
  * v0.8.1 底座(issue #170):显式 scope 参数(memory_save / memory_update /
88
116
  * 面板编辑)的归一化。约定: