@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/service.js CHANGED
@@ -18,9 +18,14 @@ import { anchorSeeds, normalizePath, cascadeDepths, HOP_QUOTA } from "./graph/an
18
18
  // 的 recall_runs `auto` 模式里被按需检索过 328 次:不是垃圾数据,只是进不了注入池。
19
19
  // 收编后候选池 1362 → 1713(+25.8%),按 top_k=5 粗算约多占 1 席;重要度闸门对
20
20
  // history 一视同仁(见 injectCandidates 的 importance >= threshold),低于闸门照旧出局。
21
- // pattern 一并收编:它只由手工写入(实测 40 条全 source=manual),却已被 dreamMergeGuard
22
- // 列为长保留受保护类型(docs/CONFIGURATION.md)——巩固层当资产护住、注入面一次不给看,
23
- // 是同一条口径的两头不一致。
21
+ // pattern 一并收编:它一度被当成「只由手工写入(实测 40 条全 source=manual)」——那个
22
+ // 判据是错的。机器一直在 mint:2026-10-14 按 dream_runs 的 patterns.applied 逐轮求和,
23
+ // 得 144,与表内 pattern 行数 144 相等(72 轮 sleep 里 48 轮 ok,auto 轮 0),来源是
24
+ // src/dream/sleep.js 的 phasePatterns;当时的 source=manual 只是 saveWithDedupe 没拿到
25
+ // source 时的兜底值,不是「人写的」的意思(现在 mint 会带 source=sleep/dream)。
26
+ // 但它确实已被 dreamMergeGuard 列为长保留受保护类型(docs/CONFIGURATION.md)——巩固层
27
+ // 当资产护住、注入面一次不给看,是同一条口径的两头不一致。这一点与作者是谁无关,
28
+ // 收编的结论不变,错的只是当初用来支持它的那句前提。
24
29
  const INJECT_TYPES = new Set(["preference", "project", "decision", "summary", "rejected_solution", "pitfall", "constraint", "history", "pattern"]);
25
30
 
26
31
  // document 摘要行(#230):仅 documentMemoryEnabled 开启时进注入候选。独立
@@ -1293,7 +1298,10 @@ export function createService({ store, mirror, config, onWrite, logger, document
1293
1298
  let quality = null;
1294
1299
  if (qf?.enabled === true) {
1295
1300
  try {
1296
- const recentContents = store.all().slice(0, 20).map((m) => m.content ?? "");
1301
+ // #430:近重复判定只要最近 20 条正文,别再为了 slice(0,20) 先物化整库
1302
+ // (all() + 逐行 toRow,5k 行实测 100ms 级;本查询 0.06ms 级)。口径与
1303
+ // all().slice(0, 20) 一致:不加 WHERE、updated_at DESC、同刻顺序未定义。
1304
+ const recentContents = store.recentContents(20);
1297
1305
  quality = evaluateMemoryQuality(memory, {
1298
1306
  minContentLength: qf.minContentLength ?? 10,
1299
1307
  recentContents
@@ -1837,9 +1845,15 @@ export function createService({ store, mirror, config, onWrite, logger, document
1837
1845
  if ((config?.recallRecordDefault ?? true) && recallRecorder && injected.length > 0) {
1838
1846
  try {
1839
1847
  recallRecorder({
1840
- // recall_runs.query 是 NOT NULL(store.js:61):注入是主动曝光、
1841
- // 没有查询词,用空串而非 null——SQLite 改列约束需重建表,不值得。
1842
- query: "",
1848
+ // recall_runs.query 是 NOT NULL(store.js 的 recall_runs 建表语句,
1849
+ // 2026-10-14 在 :75)—— 注入是主动曝光,
1850
+ // 没有查询词时用空串而非 null——SQLite 改列约束需重建表,不值得。
1851
+ // v0.9.0:调用方(inject.js 的 lastUserQuery,2026-10-14 在 :343)本来就带着本轮的
1852
+ // 用户原话传进来,这里丢掉等于把「当时想干什么」从审计里抹掉
1853
+ // (2026-10-13 快照:18,231/18,231 注入行 query 全空,全空比例
1854
+ // 100%——这个比率才是要记的,绝对行数随表长,引用要带时刻)。
1855
+ // 有就记,真没有才空串。
1856
+ query: q,
1843
1857
  mode: "inject",
1844
1858
  topK: maxItems,
1845
1859
  threshold: null,
@@ -1944,6 +1958,9 @@ export function createService({ store, mirror, config, onWrite, logger, document
1944
1958
  setArchived: (mid, v) => archiveMemory(mid, v),
1945
1959
  transaction: (fn) => transaction(fn)
1946
1960
  };
1961
+ // 不传 mintSource——本路径由人在面板上点确认,即便日后有人在这里加
1962
+ // create,落 saveWithDedupe 的 manual 兜底恰好就是对的语义(人触发的写入)。
1963
+ // 这是刻意的,不是漏传。
1947
1964
  const { committed, failures } = applyDecisions(
1948
1965
  [{ action: "conflict", winner: winnerId, loser: loserId, reason: row.reason ?? "" }],
1949
1966
  applyService,
@@ -2150,18 +2167,22 @@ export function createService({ store, mirror, config, onWrite, logger, document
2150
2167
  try {
2151
2168
  // 预先取本次要覆盖的全部活跃行(只读一次)。这里曾经是
2152
2169
  // store.list({ limit: 500 })——活跃集超过 500 时镜像会静默少掉尾部记忆,
2153
- // 而文件本身没有任何提示(#278 第一批)。store.list 的 limit 默认值只有
2154
- // 50,比原来显式传的 500 更小,所以改用 all() 并在这里做同一套过滤
2155
- // (forgotten/archived 都不进镜像,与 includeForgotten:false + 默认
2156
- // includeArchived:false 等价)。
2157
- // 代价是全表读,且落在每次业务写后的最热路径上(#202 自记 all() 5k 行
2158
- // 231ms → ~135ms)。要压这一层得换按 type 分页取,属另一批的事;
2159
- // 在这里退回任何截断都不行——「宣称覆盖活跃集」与静默截断不能共存。
2160
- const list = store.all().filter((m) => !m.forgotten && !m.archived
2170
+ // 而文件本身没有任何提示(#278 第一批)。在这里退回任何截断都不行——
2171
+ // 「宣称覆盖活跃集」与静默截断不能共存(这一条不变)。
2172
+ // #430:改成 list 的无界档(limit: null)+ SQL 侧过滤,替掉 all() + 逐行
2173
+ // filter——list 的默认 includeForgotten:false / includeArchived:false 与旧
2174
+ // filter 的 !forgotten && !archived 完全同口径(store.js 里就是这两条
2175
+ // clause),document 闸改由 excludeTypes 在 LIMIT 之前排除。旧写法先物化
2176
+ // 整库(embedding 列 + 逐行 27 字段 toRow)再逐行丢弃:被丢掉的行连带它们
2177
+ // 的物化一起省下了。要再压一层得换按 type 分页取,属另一批的事。
2178
+ const list = store.list({
2179
+ limit: null,
2180
+ order: "chrono",
2161
2181
  // #296 第二批:镜像里的 documents.md 与 index.md 共用 documentMemoryEnabled
2162
2182
  // 闸。关掉时把 document 行也从渲染集里去掉——sync 对「空 type」的既有处理
2163
2183
  // 会把陈旧的 documents.md 删掉,不留一个「看着还在、其实已关」的视图。
2164
- && (m.type !== "document" || config?.documentMemoryEnabled === true));
2184
+ excludeTypes: config?.documentMemoryEnabled === true ? null : ["document"]
2185
+ });
2165
2186
  for (const memory of list) {
2166
2187
  if (memory?.type && TYPE_FILE[memory.type]) {
2167
2188
  coveredTypes.add(memory.type);
@@ -2524,11 +2545,31 @@ export function createService({ store, mirror, config, onWrite, logger, document
2524
2545
  // Async-initializing embedder: poll `ready` until it flips, then flush.
2525
2546
  if ("ready" in emb && embedReadyTimer === null) {
2526
2547
  let attempts = 0;
2548
+ let readyReadFailed = false;
2549
+ // 读 `ready` 必须先兜住异常再算天花板,否则天花板形同不存在:`ready` 是
2550
+ // getter(embedding.js 的 OpenAI embedder 直接读库),getter 一抛错,整条
2551
+ // `||` 的求值当场中断,右侧的 `attempts >= EMBED_READY_POLL_LIMIT` 永远轮
2552
+ // 不到 —— stopEmbedReadyPolling() 不执行,定时器就活过 3s 上限,并以未捕获
2553
+ // 异常的形式每 100ms 抛一次。实测(2026-10-14):卸载路径 store.close() 之后
2554
+ // 每次读 ready 都抛 "database is not open",5s 内读 46 次,天花板 31 被直接
2555
+ // 打穿,宿主事件循环因此永不排空。读不出来按「没就绪」处理,但上限照算。
2556
+ const readReady = () => {
2557
+ try {
2558
+ return emb.ready === true;
2559
+ } catch (err) {
2560
+ if (!readyReadFailed) {
2561
+ readyReadFailed = true; // 只报一次,别让 3s 上限变成 30 条刷屏
2562
+ logger?.warn?.("embedder ready probe failed, treating it as not-ready:", err);
2563
+ }
2564
+ return false;
2565
+ }
2566
+ };
2527
2567
  embedReadyTimer = setInterval(() => {
2528
2568
  attempts++;
2529
- if (emb.ready === true || attempts >= EMBED_READY_POLL_LIMIT) {
2569
+ const ready = readReady();
2570
+ if (ready || attempts >= EMBED_READY_POLL_LIMIT) {
2530
2571
  stopEmbedReadyPolling();
2531
- if (emb.ready === true) flushEmbedPending();
2572
+ if (ready) flushEmbedPending();
2532
2573
  else embedPending = []; // init never landed: drop the queue
2533
2574
  }
2534
2575
  }, EMBED_READY_POLL_MS);
@@ -0,0 +1,350 @@
1
+ /**
2
+ * 能力使用证据的**读侧**:从 DSH 会话日志里解析「哪个能力在什么时候被调用过」。
3
+ *
4
+ * 为什么单独一个文件:这份解析有两个已经踩过的坑,都属「静默少一大截」型——
5
+ * · **zstd 是多帧的**:`zlib.zstdDecompressSync(buf)` 只返回**第一帧**,不报错、不警告。
6
+ * 实测一个 1.5 MB 的会话文件只解出 291 字节,99% 的内容没被扫到,于是得出
7
+ * 「零命中」的假结论。必须自己按 magic `28 b5 2f fd` 切帧逐帧解。
8
+ * · **tool-call 的参数在 `arguments`,值是 JSON 字符串**(不是对象,也不是
9
+ * `parameters`):形如 `{"type":"tool-call","name":"skill","arguments":"{\"name\":\"agent-reach\"}"}`。
10
+ * 取错字段会得到 null,看着像「调用了但没记参数」。
11
+ * 把它们关在一个模块里,是为了让 `capabilities.js` 只负责「调用 + 投影」,不碰这些细节。
12
+ *
13
+ * **只读**:不解压到磁盘、不写任何文件、不改库。
14
+ *
15
+ * 与 `src/pipeline.js` 的 `deriveState` 同一套语汇:使用状态由**运行记录**推出来,
16
+ * 不由人写死——没记录就是 `candidate`(实话:不知道用过没有),有记录是 `trusted`,
17
+ * 久未用是 `stale`。
18
+ */
19
+ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { homedir } from "node:os";
22
+ import zlib from "node:zlib";
23
+
24
+ /** zstd 帧头 magic。多帧文件里每帧都以此开头,切帧就靠它。 */
25
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
26
+
27
+ /**
28
+ * 会话日志文件名前缀。
29
+ *
30
+ * **不是只认 `session.v4`** —— 本机实测同一会话树下并存三种格式:
31
+ * · `session.v4.jsonl.zstd`(219 个)
32
+ * · `session.v3.jsonl.zstd`(218 个)
33
+ * · `session.jsonl.zstd`(72 个,无版本号的最早期形态)
34
+ * 只认 v4 会**静默漏掉一半以上的历史**(实测漏掉 50 次技能调用,其中 `euthyna` 44 次;
35
+ * `brainstorming` / `writing-plans` / `subagent-driven-development` 三个技能明明用过
36
+ * 却被报成 `candidate`)。所以这里只要求「文件名以 `session` 开头」——宁可多读几个
37
+ * 非会话文件(解析时自然会被 `tool-call` 过滤掉),也不要漏掉真实记录。
38
+ */
39
+ const SESSION_FILE_PREFIX = "session";
40
+
41
+ /** 单次扫描最多读多少个文件——目录膨胀时宁可报错,也不要把一次面板刷新变成全盘扫描。 */
42
+ const MAX_SESSION_FILES = 2000;
43
+
44
+ /** 判定 `stale` 的默认天数。与 pipeline.js 的 90 天同源:都是「久未使用」的经验阈值。 */
45
+ export const STALE_AFTER_DAYS = 90;
46
+
47
+ /**
48
+ * 使用状态**三值**。
49
+ *
50
+ * **这是对 `pipeline.js` 四态(`candidate`/`trusted`/`stale`/`refuted`)的有意收敛,
51
+ * 不是遗漏**:`refuted` 在工作流里的含义是「这一步被运行记录判定为不成立」,而能力
52
+ * 的「被使用」记录**推不出**任何等价结论——一个能力被调用过,只能说「用过」,不能
53
+ * 因此说它「已被推翻」。能力的弃用要另立判据(比如「用它之后任务失败了」),那是
54
+ * 另一件事,**不能从调用次数硬凑**。所以这里只保留能从运行记录直接推出的三态;
55
+ * 少了的那一态宁可不做,也不拿 `candidate` 或 `stale` 冒充。
56
+ */
57
+ export const USAGE_STATES = ["candidate", "trusted", "stale"];
58
+
59
+ /**
60
+ * 会话根目录。默认由 `DSH_HOME` 推导,**不写死本机路径**——仓库里出现盘符是红线。
61
+ * 传 `dshHome` 可覆盖(测试用临时目录,生产用环境变量)。
62
+ */
63
+ export function resolveSessionDir({ dshHome = process.env.DSH_HOME || join(homedir(), ".dsh") } = {}) {
64
+ return join(String(dshHome), "sessions");
65
+ }
66
+
67
+ /**
68
+ * 逐帧解一个多帧 zstd buffer,**边解边回调**,不把全文拼成一个字符串。
69
+ *
70
+ * 用回调而不是返回字符串:实测一次读上百个会话文件会 OOM(`FATAL ERROR: Reached
71
+ * heap limit`)。逐帧交给回调,内存只保留一帧。
72
+ */
73
+ /**
74
+ * 多帧 zstd 逐帧解压,**边解边回调**。
75
+ *
76
+ * 返回 `{ frames, failed, bytes }`——`failed` 是**解不出来**的帧数。
77
+ *
78
+ * **关于「残缺帧」的一个实测结论**:zstd 是流式格式,**尾部被截断的帧会解出「部分
79
+ * 文本」而不抛错**(实测截断到一半的帧解出 `"这一帧会被截"`)。我试过两种完整性判据
80
+ * 都不可靠:①「截掉末字节再解、比对是否相同」——对短截断失效;②流式 API 的
81
+ * `end` 事件——截断帧照样正常结束、不报错。Node 的 zstd 绑定没有暴露「还有多少输入
82
+ * 未被消费」。
83
+ *
84
+ * 所以**不猜帧的完整性**,改在**行这一层**兜底:残缺帧解出来的尾部一定是半行,而
85
+ * jsonl 的解析器本来就要求整行是合法 JSON —— 半行会 `JSON.parse` 失败被丢掉。
86
+ * 代价是:**一条调用记录若正好被截在中间,会被丢弃**(而不是被当成完整记录误用)。
87
+ * 这个取舍是明确的:**宁可少一条,不要错一条**。
88
+ *
89
+ * 另外,**只有最后一帧可能被截断**(中间帧的边界由下一个 magic 保证),所以
90
+ * `failed` 只在真的解不出来时增长,不把「末帧可能是半行」计进去——那样会天天报警。
91
+ */
92
+ export function forEachFrame(buf, onText) {
93
+ let offset = 0;
94
+ let frames = 0;
95
+ let failed = 0;
96
+ while (offset < buf.length) {
97
+ const start = buf.indexOf(ZSTD_MAGIC, offset);
98
+ if (start === -1) break;
99
+ let end = buf.indexOf(ZSTD_MAGIC, start + 4);
100
+ if (end === -1) end = buf.length;
101
+ try {
102
+ onText(zlib.zstdDecompressSync(buf.subarray(start, end)).toString("utf8"));
103
+ frames += 1;
104
+ } catch {
105
+ // 帧头都坏了:计入 failed,继续后面的帧。
106
+ failed += 1;
107
+ }
108
+ offset = end;
109
+ }
110
+ return { frames, failed };
111
+ }
112
+
113
+ /**
114
+ * 递归找出所有会话日志文件。
115
+ *
116
+ * 返回 `{ files, unreadable, truncated }`——**目录读不动时必须留痕**。
117
+ *
118
+ * 为什么这是关键:早期版本用 `existsSync` 定 `present`、`readdirSync` 失败直接 `return`,
119
+ * 于是「目录存在但不可读(ACL 拒绝、权限不足)」与「目录存在且为空」产出**逐字段相同**
120
+ * 的读数(`present=true, scannedFiles=0, unreadable=[]`),清单上 175 条技能全 `candidate`。
121
+ * 那正是本仓库反复防的那类错:**读不到被显示成「读到但没有」**。所以 readdir 失败要
122
+ * 记进 `unreadable`,让上层能把它与「空目录」分辨开。
123
+ *
124
+ * `limit` 可注入(默认 `MAX_SESSION_FILES`)。**早期版本把上限写死在遍历里**,于是
125
+ * 调用方传 `maxFiles: 5000` 也只拿到 2000 个——上限在内层生效、外层无从知道,
126
+ * 又是「静默截断」。现在上限只在这里生效,且**超限时 `truncated` 为 true**。
127
+ */
128
+ export function findSessionFiles(dir, { limit = MAX_SESSION_FILES } = {}) {
129
+ const cap = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : MAX_SESSION_FILES;
130
+ const result = { files: [], unreadable: [], present: false, truncated: false };
131
+ if (!existsSync(dir)) return result;
132
+ result.present = true;
133
+ const walk = (current) => {
134
+ if (result.files.length >= cap) {
135
+ result.truncated = true;
136
+ return;
137
+ }
138
+ let entries;
139
+ try {
140
+ entries = readdirSync(current, { withFileTypes: true });
141
+ } catch (error) {
142
+ // 读不动就说出来——不静默 return(那会与「空目录」同形)。
143
+ result.unreadable.push({ dir: current, detail: `目录读失败:${error.message}` });
144
+ return;
145
+ }
146
+ for (const entry of entries) {
147
+ if (result.files.length >= cap) {
148
+ result.truncated = true;
149
+ return;
150
+ }
151
+ const full = join(current, entry.name);
152
+ // 符号链接要单独认:Dirent 对 symlink 的 isDirectory() 返回 false(踩过一次)。
153
+ let isDir = entry.isDirectory();
154
+ if (entry.isSymbolicLink()) {
155
+ try {
156
+ isDir = statSync(full).isDirectory();
157
+ } catch {
158
+ // 悬空链接:计入 unreadable(目标不可达也是「读不到」的一种)。
159
+ result.unreadable.push({ dir: full, detail: "符号链接目标不可达" });
160
+ continue;
161
+ }
162
+ }
163
+ if (isDir) walk(full);
164
+ else if (entry.name.startsWith(SESSION_FILE_PREFIX)) result.files.push(full);
165
+ }
166
+ };
167
+ walk(dir);
168
+ result.files.sort();
169
+ return result;
170
+ }
171
+
172
+ /**
173
+ * 从一行 jsonl 里抽出所有 tool-call。
174
+ *
175
+ * 返回 `{ name, args, turn, time }`——`args` 是**二次解析后的对象**,取不到就是 null
176
+ * (调用记录本身仍保留,只是参数缺失,这种要能看出来而不是丢掉整条)。
177
+ */
178
+ export function toolCallsInLine(line) {
179
+ if (typeof line !== "string" || !line.includes('"tool-call"')) return [];
180
+ let obj;
181
+ try {
182
+ obj = JSON.parse(line);
183
+ } catch {
184
+ return [];
185
+ }
186
+ const content = obj?.data?.message?.content;
187
+ if (!Array.isArray(content)) return [];
188
+ const out = [];
189
+ for (const item of content) {
190
+ if (item?.type !== "tool-call" || typeof item.name !== "string") continue;
191
+ let args = null;
192
+ if (typeof item.arguments === "string") {
193
+ try {
194
+ args = JSON.parse(item.arguments);
195
+ } catch {
196
+ args = null;
197
+ }
198
+ } else if (item.arguments && typeof item.arguments === "object") {
199
+ args = item.arguments;
200
+ }
201
+ out.push({
202
+ name: item.name,
203
+ args,
204
+ turn: obj?.data?.turn ?? null,
205
+ time: Number.isFinite(obj?.time) ? obj.time : null
206
+ });
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /**
212
+ * 一次调用对应哪个能力。三类能力的判定方式**故意不同**:
213
+ * · `skill` 工具 → 参数里的 `name` 就是技能名(照宿主 `skill` 工具的契约);
214
+ * · MCP 工具 → 公共名形如 `mcp__<serverName>__<rawName>`,取中间那段为 server;
215
+ * · 其余内置工具(read / pwsh 等)**不是能力清单里的东西**,返回 null 丢掉。
216
+ *
217
+ * 拿不到名字时返回 `{ kind, name: null }`,让上层能统计「对不上的条数」——
218
+ * 对不上是要报出来的事实,不是可以静默丢弃的噪音。
219
+ */
220
+ export function identifyCapability(call) {
221
+ if (!call || typeof call.name !== "string") return null;
222
+ if (call.name === "skill") {
223
+ const name = call.args && typeof call.args.name === "string" ? call.args.name.trim() : "";
224
+ return { kind: "skill", name: name === "" ? null : name };
225
+ }
226
+ if (call.name.startsWith("mcp__")) {
227
+ const parts = call.name.split("__");
228
+ // mcp__<server>__<tool>:server 为空也算 MCP 调用,只是名字不可用。
229
+ const server = typeof parts[1] === "string" ? parts[1].trim() : "";
230
+ return { kind: "mcp", name: server === "" ? null : server, tool: parts.slice(2).join("__") || null };
231
+ }
232
+ return null;
233
+ }
234
+
235
+ /**
236
+ * 扫全部会话日志,产出「按能力聚合的使用记录」。
237
+ *
238
+ * 返回结构里的 `skills` / `mcp` 是 Map(能力名 → { uses, lastUsedAt, sessions }),
239
+ * 由上层投影成清单字段。`unmatched` 记对不上名字的调用数——如实报,不硬凑。
240
+ *
241
+ * `maxFiles` 现在**真的管用**(透传给 `findSessionFiles` 的 `limit`):早期版本把上限
242
+ * 写死在遍历里,传 5000 也只拿到 2000 个且不报截断——调用方以为扫全了。
243
+ */
244
+ export function collectUsage({ sessionDir = resolveSessionDir(), maxFiles = MAX_SESSION_FILES } = {}) {
245
+ const found = findSessionFiles(sessionDir, { limit: maxFiles });
246
+ // 透传后 found.files 已按上限截好,slice 只是防御(limit 非法时)。
247
+ const files = found.files.slice(0, maxFiles);
248
+ const result = {
249
+ dir: sessionDir,
250
+ // `present` = 目录在;`readable` = 目录真能列出来。**两者必须分开报**,
251
+ // 否则「不可读」与「空目录」在读数上同形(那正是本仓库反复防的那类错)。
252
+ present: found.present,
253
+ readable: found.present && found.unreadable.length === 0,
254
+ // 文件数被上限截断时要说出来,否则「只扫了一部分」看不出来。
255
+ // 两个来源:内层遍历到上限(found.truncated),或调用方给的 maxFiles 更小。
256
+ truncated: found.truncated === true || found.files.length > files.length,
257
+ totalFiles: found.files.length,
258
+ scannedFiles: 0,
259
+ frames: 0,
260
+ failedFrames: 0,
261
+ skillCalls: 0,
262
+ mcpCalls: 0,
263
+ unmatched: 0,
264
+ skills: new Map(),
265
+ mcp: new Map(),
266
+ // 目录读不动、悬空链接、单文件读失败都记在这里——**读不到要有痕**。
267
+ unreadable: [...found.unreadable]
268
+ };
269
+
270
+ const record = (map, name, time, sessionId) => {
271
+ const entry = map.get(name) || { uses: 0, lastUsedAt: null, sessions: new Set() };
272
+ entry.uses += 1;
273
+ if (Number.isFinite(time) && (entry.lastUsedAt === null || time > entry.lastUsedAt)) entry.lastUsedAt = time;
274
+ if (sessionId) entry.sessions.add(sessionId);
275
+ map.set(name, entry);
276
+ };
277
+
278
+ for (const file of files) {
279
+ let buf;
280
+ try {
281
+ buf = readFileSync(file);
282
+ } catch (error) {
283
+ result.unreadable.push({ file, detail: `读失败:${error.message}` });
284
+ continue;
285
+ }
286
+ result.scannedFiles += 1;
287
+ const sessionId = file.split(/[\\/]/).slice(-2, -1)[0] || null;
288
+ const stat = forEachFrame(buf, (text) => {
289
+ for (const line of text.split("\n")) {
290
+ if (!line.trim() || !line.includes('"tool-call"')) continue;
291
+ // time 在行的外层,toolCallsInLine 已带上;这里从整行再取一次 session 级信息。
292
+ for (const call of toolCallsInLine(line)) {
293
+ const identified = identifyCapability(call);
294
+ if (!identified) continue;
295
+ if (identified.name === null) {
296
+ result.unmatched += 1;
297
+ continue;
298
+ }
299
+ if (identified.kind === "skill") {
300
+ result.skillCalls += 1;
301
+ record(result.skills, identified.name, call.time, sessionId);
302
+ } else {
303
+ result.mcpCalls += 1;
304
+ // MCP 按 **server** 聚合(清单里的 MCP 条目是 server 粒度),不是按工具。
305
+ record(result.mcp, identified.name, call.time, sessionId);
306
+ }
307
+ }
308
+ }
309
+ });
310
+ result.frames += stat.frames;
311
+ result.failedFrames += stat.failed;
312
+ buf.fill(0);
313
+ }
314
+
315
+ return result;
316
+ }
317
+
318
+ /**
319
+ * 由使用记录推使用状态。**照 `pipeline.js` 的 `deriveState` 写**:
320
+ * · 没有记录 → `candidate`(不知道用过没有,这是实话);
321
+ * · 有记录、最近一次在 `staleAfterDays` 天内 → `trusted`;
322
+ * · 有记录但最近一次早于阈值 → `stale`。
323
+ *
324
+ * 保留期会限制这个判定的可靠度:会话日志被清理后,久未用的能力会**退化成 candidate**
325
+ * (看起来像从没用过)。所以「candidate」的含义是「本机现存日志里没有记录」,
326
+ * 而不是「从未使用」——这句话必须由上层写进输出,否则会被读错。
327
+ */
328
+ export function deriveUsageState(entry, { now = Date.now(), staleAfterDays = STALE_AFTER_DAYS } = {}) {
329
+ if (!entry || !Number.isFinite(entry.uses) || entry.uses === 0) return "candidate";
330
+ if (Number.isFinite(entry.lastUsedAt)) {
331
+ const ageDays = (now - entry.lastUsedAt) / 86400000;
332
+ if (Number.isFinite(ageDays) && ageDays > staleAfterDays) return "stale";
333
+ }
334
+ return "trusted";
335
+ }
336
+
337
+ /** 把 Map 里的聚合记录投影成清单友好的纯数组(含状态),按使用次数降序。 */
338
+ export function summarizeUsage(collection, { now = Date.now(), staleAfterDays = STALE_AFTER_DAYS } = {}) {
339
+ const toRows = (map) =>
340
+ [...map.entries()]
341
+ .map(([name, entry]) => ({
342
+ name,
343
+ uses: entry.uses,
344
+ lastUsedAt: Number.isFinite(entry.lastUsedAt) ? new Date(entry.lastUsedAt).toISOString() : null,
345
+ sessions: entry.sessions.size,
346
+ state: deriveUsageState(entry, { now, staleAfterDays })
347
+ }))
348
+ .sort((a, b) => b.uses - a.uses || a.name.localeCompare(b.name));
349
+ return { skills: toRows(collection.skills), mcp: toRows(collection.mcp) };
350
+ }
package/lib/settings.js CHANGED
@@ -140,7 +140,13 @@ const FEATURE_FLAG_BOOLEANS = [
140
140
  // 落盘(distill_records)并给记忆挂窗口链接(memory_provenance);默认关。
141
141
  // 点号键平铺存、合并时展开回 distillRecords 对象(同 llmAudit.enabled)。
142
142
  // retentionDays 是 int,走下面的 INT_RANGES 表,不进开关白名单。
143
- "distillRecords.enabled"
143
+ "distillRecords.enabled",
144
+ // v0.9.0:M.O.(Modus Operandi · 手法,beta)客户端视图开关。客户端读 /features
145
+ // 的 effective
146
+ // 决定是否注册座位(会话头图标 / 浮层 / 右栏 / 对话行卡片),服务端只负责把
147
+ // 这个键稳定暴露出来——键若不在 effective 里,客户端静默少键、一个座位都不注册。
148
+ // 默认关(beta、opt-in);顶层扁平键,因此不需要 NESTED_FLAG_PATHS 条目。
149
+ "actionPipelineEnabled"
144
150
  ];
145
151
  // 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
146
152
  const FEATURE_FLAG_INT_RANGES = {
package/lib/store.js CHANGED
@@ -1565,11 +1565,24 @@ export function createStore(path) {
1565
1565
  // "chrono" is pure newest-first — the stable order paged browsing (month
1566
1566
  // tree, infinite scroll) needs; importance ordering would interleave
1567
1567
  // months across pages.
1568
- const orderBy = order === "chrono" ? "updated_at DESC, id DESC" : "importance DESC, updated_at DESC, id";
1568
+ // #430:chrono 的 **无界档** 不再追加 id 平局键。updated_at 上有 #202 建的
1569
+ // 索引,而带 id 的第二项排序键用不上它——5k 行实测同一份 SQL 只差这一项:
1570
+ // 100.6ms(带 id,EXPLAIN 多一个 USE TEMP B-TREE)对 23.4ms(纯 updated_at,
1571
+ // 直接走 idx_memories_updated)。分页档位必须留平局键(跨页拼接要求稳定,
1572
+ // 同刻两行在新旧页之间可能漏一条或重复一条);无界档没有跨页拼接,今天唯一
1573
+ // 的调用方 syncMirror 只用它算覆盖集,与顺序无关。
1574
+ const orderBy = order === "chrono"
1575
+ ? (unbounded ? "updated_at DESC" : "updated_at DESC, id DESC")
1576
+ : "importance DESC, updated_at DESC, id";
1577
+ // Issue #430:与 all() 共用同一份显式列清单(memoryColumns 由 PRAGMA 派生,
1578
+ // 排除 embedding)。toRow 本就不输出向量,SELECT * 只是把全表最重的列拖进
1579
+ // 内存再丢弃——无界档实测 5k 行 263ms → 117ms,窗口档 50ms → 41ms,本函数
1580
+ // 的全部调用方(分页浏览、document 索引、sleep 池、注册器全量扫描)一起受益。
1581
+ const projected = `SELECT ${memoryColumnList} FROM memories`;
1569
1582
  const rows = unbounded
1570
- ? db.prepare(`SELECT * FROM memories ${where} ORDER BY ${orderBy}`).all(...params)
1583
+ ? db.prepare(`${projected} ${where} ORDER BY ${orderBy}`).all(...params)
1571
1584
  : db.prepare(
1572
- `SELECT * FROM memories ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`
1585
+ `${projected} ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`
1573
1586
  ).all(...params, lim, off);
1574
1587
  return rows.map(toRow);
1575
1588
  }
@@ -1581,6 +1594,18 @@ export function createStore(path) {
1581
1594
  return rows.map(toRow);
1582
1595
  }
1583
1596
 
1597
+ // Issue #430:写路径要的「最近若干条正文」。质量过滤的近重复判定只读 content
1598
+ // 这一列 + 前 20 行,此前写成 all().slice(0, 20) —— 全表逐行 toRow(27 字段、
1599
+ // 三次 JSON 解析)之后只截前 20 条正文;5k 行实测 100ms 级 → 0.06ms 级。
1600
+ // 口径与 all() 的前 N 条严格一致:不加 WHERE(archived/forgotten 仍参与近重复
1601
+ // 判定)、updated_at DESC、同刻顺序未定义。limit 走 sanitizePage,非正整数
1602
+ // 落回 20,调用方不必自己防 0/undefined。
1603
+ function recentContents(limit = 20) {
1604
+ const { limit: lim } = sanitizePage(limit, 0, 20);
1605
+ const rows = db.prepare("SELECT content FROM memories ORDER BY updated_at DESC LIMIT ?").all(lim);
1606
+ return rows.map((row) => row.content ?? "");
1607
+ }
1608
+
1584
1609
  /** Set (or clear with null) the embedding vector of a memory. */
1585
1610
  function setEmbedding(id, vector) {
1586
1611
  const json = Array.isArray(vector) && vector.length ? JSON.stringify(vector) : null;
@@ -1968,6 +1993,30 @@ export function createStore(path) {
1968
1993
 
1969
1994
  // --- recall evaluation trail (方案 B: separate from the production audit) -
1970
1995
 
1996
+ /**
1997
+ * Rolling purge of the recall_runs production audit (v0.7.0): with recordRecall
1998
+ * defaulting ON, the table would otherwise grow unboundedly on a busy store.
1999
+ * Drop rows older than `days` (config.recallRetentionDays, default 90).
2000
+ * created_at is an ISO TEXT string, so the ISO bound compares lexicographically.
2001
+ * Returns the number of rows deleted (bookkeeping — never throws on empty).
2002
+ *
2003
+ * 2026-10-14 找回:这一对(本函数 + index.js 的启动调用)在 dd0ed78「release:
2004
+ * v0.7.12 + 编码记忆(codingRetrospect)蒸馏/…(合并 #73 与 #74)」里被整块弄丢。
2005
+ * 该提交不是 merge(单亲 cb5ec63),store.js 是从一个没有这段的版本重建的,
2006
+ * 于是函数与调用一起蒸发,而配置键 recallRetentionDays、README.md 与
2007
+ * docs/CONFIGURATION.md 的「recall_runs 滚动清理保留天数」一直留到今天。
2008
+ * 实测后果(2026-10-14 快照):本机 recall_runs 18,442 行、跨度 2026-09-08 →
2009
+ * 2026-10-13,即从那一天起只增不减。注意这张表是**活的、只增的**:同一个 10-13
2010
+ * 当天 14:43Z 是 18,442 行、20:47Z 已是 20,029 行,几小时就差一千五——所以任何
2011
+ * 行数只在测它的那一刻成立,引用时必须带时刻,否则日后对不上会被误读成出了错。
2012
+ * 这份审计里现在装的是用户原话,不是无信息的空串。
2013
+ */
2014
+ function purgeRecallRunsOlderThan(days) {
2015
+ const cutoff = new Date(Date.now() - days * 86400000).toISOString();
2016
+ const result = db.prepare("DELETE FROM recall_runs WHERE created_at < ?").run(cutoff);
2017
+ return result.changes;
2018
+ }
2019
+
1971
2020
  /**
1972
2021
  * Persist one retrieval-evaluation snapshot into recall_evals — the test/eval
1973
2022
  * sibling of recall_runs, deliberately stored apart so eval snapshots never
@@ -3102,6 +3151,8 @@ export function createStore(path) {
3102
3151
  getUnrecalledSince,
3103
3152
  list,
3104
3153
  all,
3154
+ // #430:有界单列读,写路径(质量过滤)用。
3155
+ recentContents,
3105
3156
  search,
3106
3157
  setEmbedding,
3107
3158
  getEmbeddings,
@@ -3122,6 +3173,7 @@ export function createStore(path) {
3122
3173
  getRecallRun,
3123
3174
  listRecallRuns,
3124
3175
  listRecallRunsSince,
3176
+ purgeRecallRunsOlderThan,
3125
3177
  saveRecallEval,
3126
3178
  getRecallEval,
3127
3179
  listRecallEvals,
package/lib/summarize.js CHANGED
@@ -4,6 +4,8 @@ import { STR, langOf } from "./lang.js";
4
4
  // 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)——
5
5
  // 三条链路同一降级语义,不另造第二份实现。
6
6
  import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
7
+ // v0.9.0(M.O. 项目记忆):蒸馏写入复用工具路径那把作用域解析器,见 createSummarizer。
8
+ import { createScopeResolver } from "./scope.js";
7
9
 
8
10
  // 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
9
11
  // 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
@@ -511,6 +513,16 @@ export function createSummarizer(ctx, service, config, deps = {}) {
511
513
  const setTimer = typeof deps.setTimeoutFn === "function" ? deps.setTimeoutFn : setTimeout;
512
514
  const clearTimer = typeof deps.clearTimeoutFn === "function" ? deps.clearTimeoutFn : clearTimeout;
513
515
 
516
+ // v0.9.0(M.O. 项目记忆):蒸馏写入也要盖作用域章。此前只有 memory_save 那条工具
517
+ // 路径盖章(tools.js 的 memory_save 里 resolveSessionScope 到 workspaceLabel 那一段,
518
+ // 2026-10-13 在 :173-193),而机器提炼是写入量的绝大多数——于是「项目记忆」
519
+ // 在库里根本不存在(全库带 scope 的行不足 3%,且带 scope 的全部来自工具路径)。
520
+ // 这里不另造口径:同一把解析器(scope.js 的 createScopeResolver,2026-10-13 在 :24)、同一把闸门
521
+ // (config.scopeEnabled === true)、同一对列(*_scope + *_scope_source='auto'),
522
+ // 与工具路径逐字对齐。解析器的入参形状是「工具 exec」(内部读 exec.agent.session),
523
+ // 蒸馏路径手里直接有会话对象,所以包一层壳喂进去——不改解析器的公共契约。
524
+ const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
525
+
514
526
  const inFlight = new Map();
515
527
  // Issue #127:per-session 上一次实际开跑时刻(最小间隔闸门用)。与 inFlight
516
528
  // 同生命周期,dispose 时一并清空。
@@ -873,6 +885,13 @@ export function createSummarizer(ctx, service, config, deps = {}) {
873
885
  const dedupeMode = config.summarizeDedupeMode ?? "off";
874
886
  let deduped = 0;
875
887
  let dedupeMaxSim = 0;
888
+ // v0.9.0(M.O. 项目记忆):一次窗口解析一次——同一蒸馏窗口里所有条目同属一个
889
+ // 工作区、一个 agent 身份,逐条解析是白花钱。解析不到就落 NULL(NULL = 未标注 =
890
+ // 全局可见),且解析器绝不抛错(scope.js 的硬约束:记忆写入不该被作用域拖挂)。
891
+ // 闸门与 tools.js 的 autoStamping(2026-10-13 在 :180)同一把:关着就一个字段都不加,老行为不变。
892
+ const writeScope = config?.scopeEnabled === true
893
+ ? resolveSessionScope({ agent: { session } })
894
+ : null;
876
895
  const writes = [];
877
896
  for (const entry of entries) {
878
897
  // Provenance: the summarizer runs on a real session (turn/end hook), so
@@ -917,6 +936,18 @@ export function createSummarizer(ctx, service, config, deps = {}) {
917
936
  const outcome = service.saveWithDedupe({
918
937
  ...entry,
919
938
  source,
939
+ // 合并进既有行时不盖章,但这件事由 saveWithDedupe 的合并分支自己保证
940
+ // (它只认 explicit 来源升级,从不采纳这里传的 auto 字段)——调用方不能
941
+ // 靠「省略字段」来表达这个意图,因为省略会反向把合并也挡掉:
942
+ // findSessionDuplicate 只按 type+source+时间窗找候选,不看 scope;找到
943
+ // 候选后由 saveWithDedupe 的 scopeMatches 门决定并或不并。这里一省略,
944
+ // 新条目的 scope 就是 null,与已盖过章的候选不等,门必拒 → 回落新建,
945
+ // 而那一行同样是新行、同样该带章,却没有章可带。实测两轮蒸馏正是如此:
946
+ // 第二轮命中第一轮、门拒后新建,留下同题两行一行 auto 一行 NULL——
947
+ // 作用域隔离没做成,重复倒留下了。传字段、让合并分支按自己的口径处理,
948
+ // 才是「不重贴」与「新行必贴」两个要求的唯一相容解。
949
+ ...(writeScope ? { agent_scope: writeScope.agent_scope, agent_scope_source: "auto" } : {}),
950
+ ...(writeScope ? { workspace_scope: writeScope.workspace_scope, workspace_scope_source: "auto" } : {}),
920
951
  ...(dup ? { _mergeInto: dup.memory.id } : {})
921
952
  });
922
953
  // created 与 merged 两条成功分支都返回 memory(含 id);merged 时 id 是