@modusensus/dsh-mneme 0.8.16 → 0.9.1

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 (135) hide show
  1. package/README.md +91 -16
  2. package/assets/icon.svg +2 -2
  3. package/bin/cli.mjs +8 -2
  4. package/docs/CONFIGURATION.md +27 -1
  5. package/docs/CONTINUITY.md +18 -3
  6. package/docs/SEMANTIC.md +3 -3
  7. package/docs/WORKFLOWS.md +299 -0
  8. package/lib/api-standalone.js +78 -12
  9. package/lib/api.js +403 -62
  10. package/lib/candidates.js +454 -0
  11. package/lib/capabilities.js +819 -0
  12. package/lib/client.js +2294 -423
  13. package/lib/config.js +63 -6
  14. package/lib/continuity.js +35 -6
  15. package/lib/dream/decisions.js +17 -1
  16. package/lib/dream/sleep.js +10 -2
  17. package/lib/dream.js +5 -1
  18. package/lib/embedding.js +116 -15
  19. package/lib/entities/extractor.js +4 -0
  20. package/lib/index.js +81 -12
  21. package/lib/inject.js +40 -4
  22. package/lib/lang.js +124 -0
  23. package/lib/pipeline-activities.js +178 -0
  24. package/lib/pipeline-attribution.js +165 -0
  25. package/lib/pipeline-command.js +279 -0
  26. package/lib/pipeline-confirm.js +142 -0
  27. package/lib/pipeline-exec.js +174 -0
  28. package/lib/pipeline-nudge.js +1281 -0
  29. package/lib/pipeline-observations.js +181 -0
  30. package/lib/pipeline-observe.js +133 -0
  31. package/lib/pipeline-runner.js +346 -0
  32. package/lib/pipeline-runs.js +442 -0
  33. package/lib/pipeline.js +655 -0
  34. package/lib/scope.js +16 -4
  35. package/lib/semantic.js +66 -10
  36. package/lib/serve.js +1 -1
  37. package/lib/service.js +59 -18
  38. package/lib/session-usage.js +350 -0
  39. package/lib/settings.js +138 -1
  40. package/lib/store.js +132 -6
  41. package/lib/summarize.js +30 -0
  42. package/lib/tools.js +11 -2
  43. package/package.json +3 -3
  44. package/scripts/changelog-prep.mjs +62 -7
  45. package/scripts/check-sync.js +8 -6
  46. package/scripts/publish-gate.mjs +197 -0
  47. package/scripts/release-docs.mjs +204 -0
  48. package/scripts/release-notes.mjs +162 -0
  49. package/src/api-standalone.js +78 -12
  50. package/src/api.js +403 -62
  51. package/src/candidates.js +454 -0
  52. package/src/capabilities.js +819 -0
  53. package/src/config.js +63 -6
  54. package/src/continuity.js +35 -6
  55. package/src/dream/decisions.js +17 -1
  56. package/src/dream/sleep.js +10 -2
  57. package/src/dream.js +5 -1
  58. package/src/embedding.js +116 -15
  59. package/src/entities/extractor.js +4 -0
  60. package/src/index.js +81 -12
  61. package/src/inject.js +40 -4
  62. package/src/lang.js +124 -0
  63. package/src/pipeline-activities.js +178 -0
  64. package/src/pipeline-attribution.js +165 -0
  65. package/src/pipeline-command.js +279 -0
  66. package/src/pipeline-confirm.js +142 -0
  67. package/src/pipeline-exec.js +174 -0
  68. package/src/pipeline-nudge.js +1281 -0
  69. package/src/pipeline-observations.js +181 -0
  70. package/src/pipeline-observe.js +133 -0
  71. package/src/pipeline-runner.js +346 -0
  72. package/src/pipeline-runs.js +442 -0
  73. package/src/pipeline.js +655 -0
  74. package/src/scope.js +16 -4
  75. package/src/semantic.js +66 -10
  76. package/src/serve.js +1 -1
  77. package/src/service.js +59 -18
  78. package/src/session-usage.js +350 -0
  79. package/src/settings.js +138 -1
  80. package/src/store.js +132 -6
  81. package/src/summarize.js +30 -0
  82. package/src/tools.js +11 -2
  83. package/test/api-hardening.test.js +173 -0
  84. package/test/api.test.js +244 -7
  85. package/test/candidates.test.js +345 -0
  86. package/test/capabilities.test.js +577 -0
  87. package/test/changelog-prep.test.js +74 -8
  88. package/test/cli-enums.test.js +37 -0
  89. package/test/client.test.js +510 -2
  90. package/test/continuity.test.js +84 -0
  91. package/test/docs-config-keys.test.js +65 -0
  92. package/test/embed-ready-poll.test.js +94 -0
  93. package/test/entities.test.js +13 -2
  94. package/test/failure-query.test.js +147 -0
  95. package/test/failure-retention.test.js +105 -0
  96. package/test/graph-api.test.js +36 -2
  97. package/test/helpers/write-admission-samples.js +6 -2
  98. package/test/inject-recall.test.js +32 -4
  99. package/test/lib-smoke.test.js +20 -0
  100. package/test/llm-audit.test.js +92 -1
  101. package/test/mint-source.test.js +202 -0
  102. package/test/persona-api.test.js +172 -0
  103. package/test/persona-flag.test.js +36 -0
  104. package/test/persona-inject.test.js +145 -0
  105. package/test/persona-settings.test.js +145 -0
  106. package/test/pipeline-activities.test.js +155 -0
  107. package/test/pipeline-attribution.test.js +175 -0
  108. package/test/pipeline-command.test.js +243 -0
  109. package/test/pipeline-confirm.test.js +148 -0
  110. package/test/pipeline-exec.test.js +172 -0
  111. package/test/pipeline-nudge.test.js +1036 -0
  112. package/test/pipeline-observations.test.js +157 -0
  113. package/test/pipeline-observe.test.js +127 -0
  114. package/test/pipeline-r1r2.test.js +149 -0
  115. package/test/pipeline-runner.test.js +442 -0
  116. package/test/pipeline-runs.test.js +767 -0
  117. package/test/pipeline.test.js +636 -0
  118. package/test/placement.test.js +1 -1
  119. package/test/publish-gate.test.js +272 -0
  120. package/test/recall-retention.test.js +128 -0
  121. package/test/reindex-backfill.test.js +83 -4
  122. package/test/release-docs.test.js +130 -0
  123. package/test/release-notes.test.js +240 -0
  124. package/test/release-workflow.test.js +93 -0
  125. package/test/route-policy.test.js +113 -0
  126. package/test/scope-distill-stamp.test.js +135 -0
  127. package/test/security-doc.test.js +139 -0
  128. package/test/sensitive-scan.test.js +44 -0
  129. package/test/serve.test.js +1 -1
  130. package/test/session-usage.test.js +308 -0
  131. package/test/store-write-path-columns.test.js +191 -0
  132. package/test/summarize.test.js +176 -0
  133. package/test/supersede-state-tracking.test.js +4 -4
  134. package/test/tools.test.js +16 -1
  135. package/test/workflow-guards.test.js +156 -0
package/src/scope.js CHANGED
@@ -48,9 +48,17 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
48
48
  header = null;
49
49
  }
50
50
 
51
+ // header 字段的读取要各自护住:session.header 本身取得回来,不代表它的字段
52
+ // 取得回来——宿主可以给出会抛错的 accessor(sessionOf / sessionKeyOf 就是为
53
+ // 同一类宿主写的)。漏在这里的异常会穿出解析器,把调用方的整段写入拖挂:
54
+ // 蒸馏路径上是一个窗口的记忆与游标一起丢,工具路径上是一次 memory_save 直接失败。
51
55
  let agentScope = null;
52
- const preset = header?.agentPreset;
53
- if (typeof preset === "string" && preset.trim()) agentScope = preset.trim();
56
+ try {
57
+ const preset = header?.agentPreset;
58
+ if (typeof preset === "string" && preset.trim()) agentScope = preset.trim();
59
+ } catch (e) {
60
+ warnOnce(`[dsh-mneme] session header agentPreset lookup failed: ${String(e)}`);
61
+ }
54
62
 
55
63
  // workspace:registry 反查优先(成员资格 = id 命中 + header canonical cwd
56
64
  // 校验过,path 已 realpath 归一),失败回退 header.cwd,再失败留 NULL。
@@ -70,8 +78,12 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
70
78
  );
71
79
  }
72
80
  if (!workspaceScope) {
73
- const cwd = header?.cwd;
74
- if (typeof cwd === "string" && cwd.trim()) workspaceScope = cwd.trim();
81
+ try {
82
+ const cwd = header?.cwd;
83
+ if (typeof cwd === "string" && cwd.trim()) workspaceScope = cwd.trim();
84
+ } catch (e) {
85
+ warnOnce(`[dsh-mneme] session header cwd lookup failed: ${String(e)}`);
86
+ }
75
87
  }
76
88
 
77
89
  return { agent_scope: agentScope, workspace_scope: workspaceScope };
package/src/semantic.js CHANGED
@@ -28,29 +28,59 @@ import { LocalReranker } from "./reranker.js";
28
28
  */
29
29
  export async function backfillMissingEmbeddings({
30
30
  store, embedder, vectorIndex, logger,
31
+ // maxTotal: 0 = no cap (drain the whole backlog this run). The default stays
32
+ // modest so a boot never floods a provider, but the cap is a caller choice
33
+ // now instead of a hardcoded one — the fixed 500 meant a 4k-row backlog
34
+ // needed nine restarts to clear, and nothing reported that it had been cut.
31
35
  maxTotal = 500, batchSize = 10, rateLimitMs = 200
32
36
  }) {
37
+ const startedAt = new Date().toISOString();
38
+ // Model-aware selector: rows whose vector came from another model (or
39
+ // predate per-row provenance) must be re-embedded too, otherwise a model
40
+ // switch leaves uncomparable vectors in the index forever.
41
+ const model = embedder.modelHash;
42
+ const pick = (n) => (typeof store.needsEmbeddingFor === "function"
43
+ ? store.needsEmbeddingFor(model, n)
44
+ : store.needsEmbedding(n));
45
+ const unlimited = !(maxTotal > 0);
33
46
  let indexed = 0;
34
- for (let done = 0; done < maxTotal;) {
35
- const rows = store.needsEmbedding(batchSize);
47
+ let processed = 0;
48
+ let failed = 0;
49
+ for (let done = 0; unlimited || done < maxTotal;) {
50
+ const rows = pick(batchSize);
36
51
  if (!rows.length) break;
37
52
  for (const row of rows) {
38
53
  try {
39
54
  const text = [row.title, row.content].filter(Boolean).join("\n");
40
55
  const vector = await embedder.embedSingle(text);
41
56
  if (vector?.length) {
42
- store.setEmbedding(row.id, vector);
57
+ store.setEmbedding(row.id, vector, model);
43
58
  indexed++;
59
+ } else {
60
+ failed++;
44
61
  }
45
- } catch { /* skip the bad row */ }
62
+ } catch { failed++; /* skip the bad row, but count it */ }
46
63
  }
47
64
  done += rows.length;
65
+ processed += rows.length;
48
66
  // Rate limit: space out batches so the provider is not hammered.
49
- if (store.needsEmbedding(1).length) await new Promise((r) => setTimeout(r, rateLimitMs));
67
+ if (pick(1).length) await new Promise((r) => setTimeout(r, rateLimitMs));
50
68
  }
51
- if (indexed > 0 && embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
52
- logger?.info?.(`[dsh-mneme] auto-reindex backfilled ${indexed} embeddings on boot`);
53
- return indexed;
69
+ if (indexed > 0 && model) vectorIndex.markModel?.(model, embedder.dimension);
70
+ // stillStale answers "did it actually finish?". The old return value was a
71
+ // bare indexed count, so a run that hit the cap looked exactly like a run
72
+ // that drained the backlog.
73
+ const stillStale = pick(1).length > 0;
74
+ const finishReason = stillStale ? "capped" : (failed > 0 && indexed === 0 ? "all-failed" : "drained");
75
+ logger?.info?.(
76
+ `[dsh-mneme] auto-reindex backfilled ${indexed} embeddings (processed=${processed}, failed=${failed}, finish=${finishReason})`
77
+ );
78
+ return {
79
+ indexed, processed, failed, finishReason, stillStale,
80
+ startedAt,
81
+ finishedAt: new Date().toISOString(),
82
+ model: model ?? null
83
+ };
54
84
  }
55
85
 
56
86
  /**
@@ -74,6 +104,9 @@ export function createSemantic({ store, service, settings, cfg, logger, vectorIn
74
104
  // boot 回填首查计时器。搬移前在 index.js 是裸 setTimeout(不参与卸载清理);
75
105
  // 纳入 dispose 是修悬挂,不改变启动行为。
76
106
  let reindexTimer = null;
107
+ // Last boot-backfill summary, exposed via backfillStatus() so "did it
108
+ // finish?" has an answer other than a log line.
109
+ let lastBackfill = null;
77
110
 
78
111
  if (lightMode) {
79
112
  // Light mode: the whole vector pipeline stays off — no embedder (nothing
@@ -202,12 +235,31 @@ export function createSemantic({ store, service, settings, cfg, logger, vectorIn
202
235
  if (tries > 0) reindexTimer = setTimeout(() => attempt(tries - 1), 2000);
203
236
  return;
204
237
  }
205
- if (!store.needsEmbedding(1).length) return; // nothing to backfill
238
+ const model = embedder.modelHash;
239
+ const stale = typeof store.hasStaleEmbedding === "function"
240
+ ? store.hasStaleEmbedding(model)
241
+ : store.needsEmbedding(1).length > 0;
242
+ if (!stale) return; // nothing missing and nothing from another model
206
243
  // Issue #128: no fingerprint gate here anymore — a matching fingerprint
207
244
  // used to return early and permanently orphan rows whose embed failed
208
245
  // at write time. See backfillMissingEmbeddings().
209
- backfillMissingEmbeddings({ store, embedder, vectorIndex, logger })
246
+ // Cap and pacing are configuration now (reindexMaxPerBoot /
247
+ // reindexBatchSize / reindexRateLimitMs) instead of hardcoded 500/10/200.
248
+ lastBackfill = {
249
+ state: "running", startedAt: new Date().toISOString(),
250
+ maxTotal: cfg.reindexMaxPerBoot, batchSize: cfg.reindexBatchSize
251
+ };
252
+ backfillMissingEmbeddings({
253
+ store, embedder, vectorIndex, logger,
254
+ maxTotal: cfg.reindexMaxPerBoot,
255
+ batchSize: cfg.reindexBatchSize,
256
+ rateLimitMs: cfg.reindexRateLimitMs
257
+ })
258
+ .then((summary) => {
259
+ lastBackfill = { ...summary, state: "finished" };
260
+ })
210
261
  .catch((error) => {
262
+ lastBackfill = { state: "error", error: String(error), startedAt: lastBackfill?.startedAt };
211
263
  logger?.warn?.(`[dsh-mneme] auto-reindex failed: ${String(error)}`);
212
264
  });
213
265
  } catch (error) {
@@ -221,6 +273,10 @@ export function createSemantic({ store, service, settings, cfg, logger, vectorIn
221
273
  return {
222
274
  embedder,
223
275
  reranker,
276
+ /** Last backfill run: { state, indexed, failed, finishReason, stillStale, ... }. */
277
+ backfillStatus() {
278
+ return lastBackfill;
279
+ },
224
280
  dispose() {
225
281
  if (embedRetryTimer !== null) { clearTimeout(embedRetryTimer); embedRetryTimer = null; }
226
282
  if (reindexTimer !== null) { clearTimeout(reindexTimer); reindexTimer = null; }
package/src/serve.js CHANGED
@@ -112,7 +112,7 @@ export async function createServeRuntime({
112
112
  }
113
113
  }
114
114
 
115
- // index.js:294-309:检索回执落 recall_runs(searchMemories 的 recordRecall 默认开,
115
+ // index.js 的 setRecallRecorder(2026-10-14 在 :339-350):检索回执落 recall_runs(searchMemories 的 recordRecall 默认开,
116
116
  // 第三方检索统计因此不缺数)。best-effort:回执写失败绝不影响检索本身。
117
117
  service.setRecallRecorder((recall) => {
118
118
  try {
package/src/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
+ }