@modusensus/dsh-mneme 0.8.4 → 0.8.5

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 (69) hide show
  1. package/README.md +16 -7
  2. package/bin/dsh-mneme-mcp.mjs +11 -3
  3. package/lib/api-standalone.js +11 -0
  4. package/lib/api.js +21 -0
  5. package/lib/client.js +213 -31
  6. package/lib/config.js +82 -0
  7. package/lib/document.js +221 -0
  8. package/lib/dream/sleep.js +6 -4
  9. package/lib/dream.js +1347 -1319
  10. package/lib/guide.js +44 -0
  11. package/lib/heat.js +2 -0
  12. package/lib/inject.js +91 -15
  13. package/lib/lang.js +6 -0
  14. package/lib/organize.js +450 -0
  15. package/lib/recall-stats.js +23 -1
  16. package/lib/runtime/layout.js +30 -0
  17. package/lib/runtime/loader.js +4 -1
  18. package/lib/runtime/verify.js +19 -6
  19. package/lib/search/adaptive.js +38 -0
  20. package/lib/service.js +165 -12
  21. package/lib/settings.js +31 -2
  22. package/lib/store.js +109 -8
  23. package/lib/summarize.js +253 -8
  24. package/lib/tools.js +136 -8
  25. package/package.json +6 -6
  26. package/runtime-manifest.json +104 -174
  27. package/scripts/build-runtime-manifest.mjs +8 -1
  28. package/scripts/sync-test-badge.mjs +18 -12
  29. package/src/api-standalone.js +11 -0
  30. package/src/api.js +21 -0
  31. package/src/config.js +82 -0
  32. package/src/document.js +221 -0
  33. package/src/dream/sleep.js +6 -4
  34. package/src/dream.js +1347 -1319
  35. package/src/guide.js +44 -0
  36. package/src/heat.js +2 -0
  37. package/src/inject.js +91 -15
  38. package/src/lang.js +6 -0
  39. package/src/organize.js +450 -0
  40. package/src/recall-stats.js +23 -1
  41. package/src/runtime/layout.js +30 -0
  42. package/src/runtime/loader.js +4 -1
  43. package/src/runtime/verify.js +19 -6
  44. package/src/search/adaptive.js +38 -0
  45. package/src/service.js +165 -12
  46. package/src/settings.js +31 -2
  47. package/src/store.js +109 -8
  48. package/src/summarize.js +253 -8
  49. package/src/tools.js +136 -8
  50. package/test/adaptive-inject-budget.test.js +85 -0
  51. package/test/api.test.js +65 -3
  52. package/test/client.test.js +63 -2
  53. package/test/document.test.js +523 -0
  54. package/test/dream.test.js +51 -0
  55. package/test/inject-content-cap.test.js +5 -3
  56. package/test/inject-pools.test.js +170 -0
  57. package/test/inject-recall.test.js +105 -0
  58. package/test/inject.test.js +35 -1
  59. package/test/llm-audit.test.js +153 -0
  60. package/test/organize.test.js +418 -0
  61. package/test/recall-stats.test.js +7 -1
  62. package/test/runtime-loader.test.js +18 -0
  63. package/test/runtime-manifest.test.js +2 -1
  64. package/test/runtime-verify.test.js +65 -0
  65. package/test/sleep.test.js +37 -0
  66. package/test/store.test.js +13 -0
  67. package/test/summarize.test.js +407 -3
  68. package/test/tools-dto-schema-matrix.test.js +325 -0
  69. package/test/tools.test.js +36 -3
@@ -0,0 +1,450 @@
1
+ // dsh-mneme/src/organize.js
2
+ // agent 主动整理接口(issue #231,口径基线 = #164 的「第三腿触发」):dryRun
3
+ // 出比对报告 → agent 判断 → apply 落库;筛除项进归档不删;调用全程留审计回执。
4
+ //
5
+ // 只做功能本体(维护者口径,issue #231):落在 service 层并带测试,**不进工具
6
+ // 列表、不加独立 opt-in 开关**——#249 到位时只差「注册工具 + 注入指引」一步。
7
+ //
8
+ // 独立成模块(AGENTS.md 尺寸约定,同 document.js / recall-stats.js 先例):
9
+ // service.js 只做依赖注入 + barrel 出口,调用方零改动。
10
+ //
11
+ // 四条硬规则,都是「宁可什么都不做」的形态:
12
+ // 1. dryRun 不写记忆表:它只读 + 写一行审计(dream_runs)。比对是报告,不是动作。
13
+ // 2. apply 必须引用一次真实 dryRun——run_id 且 run_type='organize',且不是 apply
14
+ // 自己的回执(靠 outcome.dry_run_id 区分)、也不是 failed 的报告,且**还没落地过**
15
+ // (报告行上的 outcome.applied_at,同一份报告只认一次,挡掉重放)。没有比对过的
16
+ // 候选一律不落库,堵死「跳过报告直接写」这条绕过路径。
17
+ // 3. apply 只重放 dryRun 校验过的那份规范化快照,且只认快照里的索引:被 dryRun
18
+ // 跳过的候选没有资格落库;scope 比对按**候选自己的** scope(candidate ?? payload),
19
+ // 不会出现「报告按 scope A 比对、落地却写成 scope B」的错位。
20
+ // 3b. archive 只认报告里命中过的 id(item.match.id,落在报告的 decisions 列):报告
21
+ // 是唯一的判断依据,整理接口不能变成第二条「按 id 改库」的通道。
22
+ // 4. 筛除 = 归档(store.setArchived),绝不物理删除:归档可恢复,删除不可逆。
23
+ //
24
+ // 宽容形态(仓库红线 4,同 issue #89)只管**输入**:单条非法候选/决策跳过 + 应用
25
+ // 合法子集 + run 记 degraded,不整单拒绝。基础设施错误(嵌入器抛错、存储异常)
26
+ // 相反——记 failed 并原样上抛,绝不静默降级成「没找到重复」。审计口径复用
27
+ // dream_runs 的 receipt 语义(buildReceipt / parseReceipt 同一格式),不新建审计面。
28
+
29
+ import { createHash, randomUUID } from "node:crypto";
30
+ import { scopeKeyOf } from "./scope.js";
31
+ import { cosineSimilarity } from "./dream/clustering.js";
32
+ import { buildReceipt } from "./dream.js";
33
+
34
+ // 与 document 的 C2 档(#230)和 findSessionDuplicate 的 vector 档同源:同一份
35
+ // 「近重复」语义只有一个阈值,不在第三个地方另发明一个。
36
+ const MIN_SIM = 0.92;
37
+ // 向量比对对象的上限(document.js 同口径:精确层全量、向量层有界)。精确层
38
+ // 不能截断——同标题漏检会让报告给出错误的 verdict。
39
+ const CANDIDATE_LIMIT = 200;
40
+ // 单次 dryRun 的候选硬上限:主场景是「长段工作收尾整理」,不是批量导入;不设界
41
+ // 的话一次调用会把整库拉进比对。
42
+ const MAX_ITEMS = 50;
43
+ // scope 标注只做搬运(判定在 store / service 侧),省得整理接口成为第二条绕过
44
+ // scope 治理的写入通道。载荷级给的值是候选级的缺省——比对按它判 scope,落地就必须
45
+ // 带上它,否则报告与写入落在两个 scope 里。
46
+ const SCOPE_KEYS = ["sensitivity", "agent_scope", "workspace_scope", "agent_scope_source", "workspace_scope_source"];
47
+ // 比对键必须与 saveWithDedupe 的去重键**同口径**:`src/service.js` 的 scopeMatches 只比
48
+ // sensitivity / agent_scope / workspace_scope 三列,来源两列不进键(它们是元数据,合并时
49
+ // 还会把被并入行的 auto 来源升级成 explicit)。比对键里多带来源,就会出现「dryRun 报 new、
50
+ // apply 却把它并进既有行」——报告与写入错位,正是本模块硬规则 3 要挡的那类不一致。
51
+ const MATCH_SCOPE_KEYS = ["sensitivity", "agent_scope", "workspace_scope"];
52
+
53
+ /**
54
+ * Build the organizer.
55
+ *
56
+ * Injected deps are service.js closure members (store / embedQuery / saveWithDedupe
57
+ * / transaction / finalize) so this module stays free of service-internal wiring;
58
+ * `saveWithDedupe` is the normal write path, so its own epilogue (mirror sync and
59
+ * notify) is never duplicated here. `finalize` is the post-commit re-embed hook
60
+ * (document.js 同款): transaction 提交后补重嵌入,见 service.js › transaction 注释。
61
+ *
62
+ * @returns {{ organize: (payload: object, opts?: object) => Promise<object> }}
63
+ * `organize({ mode: "dryRun"|"apply", ... }, opts)` — 一个入口带 mode 参数
64
+ * (维护者倾向的形态),内部两个具名函数各自可测。
65
+ */
66
+ export function createOrganizer({ store, embedQuery, saveWithDedupe, transaction, finalize }) {
67
+ const hashOf = (value) => createHash("sha256").update(JSON.stringify(value ?? null)).digest("hex");
68
+
69
+ /**
70
+ * 候选规范化。返回 `{ candidate }` 或 `{ error }`——错误在 dryRun 里进 skipped,
71
+ * 不抛整单(红线 4)。document 刻意在此拦下:它的唯一铸造口是 registerDocument
72
+ * (#230),service 的通用 save 路径也会拒绝,这里提前拦截只是为了报告不撒谎。
73
+ */
74
+ function normalizeCandidate(raw, defaults = {}) {
75
+ const type = String(raw?.type ?? "").trim();
76
+ const title = String(raw?.title ?? "").trim();
77
+ const content = String(raw?.content ?? "").trim();
78
+ if (!type || !title || !content) return { error: "type/title/content are required" };
79
+ if (type === "document") return { error: "document rows are minted only via registerDocument (#230)" };
80
+ const candidate = {
81
+ type,
82
+ title,
83
+ content,
84
+ importance: Number.isInteger(raw?.importance) ? Math.min(5, Math.max(1, raw.importance)) : 3,
85
+ tags: [...new Set((Array.isArray(raw?.tags) ? raw.tags : []).map((t) => String(t ?? "").trim()).filter(Boolean))],
86
+ source: String(raw?.source ?? "organize")
87
+ };
88
+ for (const key of SCOPE_KEYS) {
89
+ const value = raw?.[key] ?? defaults[key];
90
+ if (value !== undefined) candidate[key] = value;
91
+ }
92
+ return { candidate };
93
+ }
94
+
95
+ /** 标题归一(同 findSessionDuplicate 的 title 档):大小写与标点不构成「不同标题」。 */
96
+ const normalizeTitle = (s) => String(s ?? "").toLowerCase().replace(/[\s\p{P}]+/gu, "");
97
+
98
+ /**
99
+ * 向量档最佳命中。拿不到向量/嵌入器时返回 null(无信号 = 不判定,不是报错);
100
+ * 但**抛出的**错误不再吞掉——嵌入器故障下的空信号会被读成「全是新条目」,让
101
+ * agent 把重复条写进库,比整单失败更糟,交 dryRun 记 failed 后上抛。
102
+ */
103
+ async function bestNear(candidate, rows, vecs) {
104
+ if (!vecs?.size) return null;
105
+ const probe = await embedQuery([candidate.title, candidate.content].filter(Boolean).join("\n"));
106
+ if (!probe) return null;
107
+ let best = null;
108
+ for (const m of rows) {
109
+ const v = vecs.get(m.id);
110
+ if (!v) continue; // 无向量的既有行不参与比对
111
+ const sim = cosineSimilarity(probe, v);
112
+ if (sim >= MIN_SIM && (!best || sim > best.sim)) best = { memory: m, sim };
113
+ }
114
+ return best;
115
+ }
116
+
117
+ /**
118
+ * 比对报告。**不写记忆表**;只写一行 dream_runs 审计(run_type='organize')。
119
+ *
120
+ * @param {object} payload
121
+ * `{ candidates: Array<{type,title,content,importance?,tags?}>, agent_scope?,
122
+ * workspace_scope?, sensitivity? }`
123
+ * @returns {Promise<{run_id, created_at, status, counts, items, skipped}>}
124
+ * items[i] = `{ index, type, title, verdict: "exact"|"near"|"new", match }`
125
+ */
126
+ async function dryRun(payload = {}) {
127
+ const raws = Array.isArray(payload.candidates) ? payload.candidates : null;
128
+ if (!raws) throw new Error("organize.dryRun: candidates must be an array");
129
+ const bounded = raws.slice(0, MAX_ITEMS);
130
+ const defaults = Object.fromEntries(SCOPE_KEYS.filter((k) => payload?.[k] !== undefined).map((k) => [k, payload[k]]));
131
+
132
+ const skipped = [];
133
+ const accepted = [];
134
+ bounded.forEach((raw, index) => {
135
+ const { candidate, error } = normalizeCandidate(raw, defaults);
136
+ if (error) skipped.push({ index, error });
137
+ else accepted.push({ index, candidate });
138
+ });
139
+ // 审计存规范化快照(带原始索引),不存调用方原始输入:apply 要重放的就是这份
140
+ // ——原始输入里混着被跳过的候选,也没有随行的 payload 级 scope。
141
+ const snapshot = accepted.map(({ index, candidate }) => ({ index, ...candidate }));
142
+
143
+ // scope 同等才比对(跨 scope 永不互判——防泄漏,与 saveWithDedupe 的去重键
144
+ // 「type+title+scope」同口径)。**按候选自己的 scope 比,不按载荷级**:
145
+ // normalizeCandidate 允许逐条给 scope(raw ?? payload),若这里只看 payload,
146
+ // 一条 agent_scope:"agent-b" 的候选就会拿默认 scope 的行去比——同一个 scope 里
147
+ // 明明已有同标题行也报 "new",报告 scope 与写入 scope 就此错位(硬规则 3)。
148
+ // 行扫描与向量读取都按 (type, scope) 缓存:一批候选通常同类型同 scope,避免逐条全表扫。
149
+ // 比对键用 MATCH_SCOPE_KEYS(三列值),搬运用 SCOPE_KEYS(五列)——两件事,别混:
150
+ // 「怎么判同一行」跟 write 路径一致,「怎么把 scope 搬到候选上」才需要连来源一起带。
151
+ const scopeOf = (source) => MATCH_SCOPE_KEYS.map((k) => scopeKeyOf(source?.[k])).join("\u0000");
152
+ const scopeMatches = (m, source) => MATCH_SCOPE_KEYS.every((k) => scopeKeyOf(m[k]) === scopeKeyOf(source?.[k]));
153
+ const cacheKey = (candidate) => `${candidate.type}\u0000${scopeOf(candidate)}`;
154
+ const rowsByScope = new Map();
155
+ const rowsFor = (candidate) => {
156
+ const key = cacheKey(candidate);
157
+ if (!rowsByScope.has(key)) {
158
+ rowsByScope.set(
159
+ key,
160
+ store.list({ type: candidate.type, limit: null }).filter((m) => !m.archived && scopeMatches(m, candidate))
161
+ );
162
+ }
163
+ return rowsByScope.get(key);
164
+ };
165
+ const vecsByScope = new Map();
166
+ const vecsFor = (candidate, rows) => {
167
+ const key = cacheKey(candidate);
168
+ if (!vecsByScope.has(key)) {
169
+ vecsByScope.set(key, store.getEmbeddings(rows.slice(0, CANDIDATE_LIMIT).map((m) => m.id)));
170
+ }
171
+ return vecsByScope.get(key);
172
+ };
173
+
174
+ const runId = randomUUID();
175
+ const createdAt = new Date().toISOString();
176
+ // 哈希与 input 必须指向同一份东西:审计存的是规范化快照(apply 要重放它),
177
+ // 所以哈希也算快照——否则回执里的 snapshot_hash 描述的是调用方原始输入,
178
+ // 谁也校验不了。
179
+ const snapshotHash = hashOf(snapshot);
180
+ const record = (status, { error = null, decisions = items, skipped: skipList = skipped, applied = 0 } = {}) =>
181
+ store.saveDreamRun({
182
+ id: runId,
183
+ created_at: createdAt,
184
+ status,
185
+ error,
186
+ provider: null,
187
+ model: null,
188
+ snapshot_hash: snapshotHash,
189
+ input_count: snapshot.length,
190
+ input: snapshot,
191
+ decisions,
192
+ applied,
193
+ summary_stored: 0,
194
+ receipt: buildReceipt({ runId, status, snapshotHash, inputCount: snapshot.length, applied, summaryStored: false }),
195
+ policy_epoch: 0,
196
+ run_type: "organize",
197
+ ...(skipList.length ? { skipped: skipList } : {})
198
+ });
199
+
200
+ const items = [];
201
+ try {
202
+ for (const { index, candidate } of accepted) {
203
+ const rows = rowsFor(candidate);
204
+ let item = { index, type: candidate.type, title: candidate.title, verdict: "new", match: null };
205
+ const exact = rows.find((m) => normalizeTitle(m.title) === normalizeTitle(candidate.title));
206
+ if (exact) {
207
+ item = { ...item, verdict: "exact", match: { id: exact.id, title: exact.title, sim: null } };
208
+ } else {
209
+ const near = await bestNear(candidate, rows, vecsFor(candidate, rows));
210
+ if (near) {
211
+ item = { ...item, verdict: "near", match: { id: near.memory.id, title: near.memory.title, sim: near.sim } };
212
+ }
213
+ }
214
+ items.push(item);
215
+ }
216
+ } catch (e) {
217
+ // 审计先留痕、再上抛:一份没有回执的失败报告,事后无法与「没调用过」区分。
218
+ record("failed", { error: String(e?.message ?? e) });
219
+ throw e;
220
+ }
221
+
222
+ const counts = {
223
+ total: items.length,
224
+ exact: items.filter((i) => i.verdict === "exact").length,
225
+ near: items.filter((i) => i.verdict === "near").length,
226
+ new: items.filter((i) => i.verdict === "new").length,
227
+ skipped: skipped.length,
228
+ truncated: raws.length - bounded.length
229
+ };
230
+ // 有被拒的候选 → degraded(如实标记),而不是 ok 掩盖过去;一条都没报 → noop。
231
+ const status = skipped.length ? "degraded" : items.length ? "ok" : "noop";
232
+ record(status);
233
+ return { run_id: runId, created_at: createdAt, status, counts, items, skipped };
234
+ }
235
+
236
+ /**
237
+ * 按 agent 的判断落库。decisions 逐条:
238
+ * `{ action: "save", index }` — 写候选(走 saveWithDedupe:同键自动合并)
239
+ * `{ action: "discard", index }` — 不写(只进回执,不删任何东西)
240
+ * `{ action: "archive", id }` — 库内被取代的行 → 归档(可恢复,不删)
241
+ *
242
+ * @param {object} payload `{ run_id, decisions }`
243
+ * @param {object} [opts] `{ hiddenIds }` — 调用者 scope 看不见的行按「不存在」
244
+ * 处理(#170 复核项 4:无存在性泄漏),与 document 的 hiddenEvidenceIds 同形:
245
+ * **可见性由可信调用方算好传入**(tools 层先例:register_document),不取 payload
246
+ * 里的字段——那是调用方可自选的内容,不是授权。
247
+ * @returns {{run_id, dry_run_id, status, saved, merged, discarded, archived, memory_ids, skipped, degraded}}
248
+ * `saved` 只数**新建**的行,合并进既有行的算 `merged`(两者之和 = 落地生效的条数);
249
+ * `memory_ids` 含合并命中的既有行 id(它们同样需要重嵌入)。
250
+ */
251
+ function apply(payload = {}, { hiddenIds = [] } = {}) {
252
+ const dryRunId = String(payload?.run_id ?? "").trim();
253
+ if (!dryRunId) {
254
+ throw new Error("organize.apply: run_id is required (apply must reference a dryRun report)");
255
+ }
256
+ const report = store.getDreamRun(dryRunId);
257
+ if (report?.run_type !== "organize") {
258
+ throw new Error(`organize.apply: unknown organize run "${dryRunId}"`);
259
+ }
260
+ // apply 回执与 dryRun 报告共用 run_type='organize':只有 outcome.dry_run_id 能
261
+ // 把它们分开。不查这一条,apply 的 run_id 可以被当成 dry_run_id,串成
262
+ // apply → apply 的审计链,报告那一环就此消失。
263
+ if (report.outcome?.dry_run_id) {
264
+ throw new Error(`organize.apply: "${dryRunId}" is an apply receipt, not a dryRun report`);
265
+ }
266
+ if (report.status === "failed") {
267
+ throw new Error(`organize.apply: dryRun "${dryRunId}" failed — re-run dryRun before applying`);
268
+ }
269
+ // 同一份报告只能落地一次。凭证写在**报告行**上(下面的 outcome.applied_at),
270
+ // 与数据同事务;只看 apply 回执挡不住「重放同一 run_id」——那会各记一份收据,
271
+ // 审计计数虚高,破坏「全留审计」的承诺。
272
+ if (report.outcome?.applied_at) {
273
+ throw new Error(`organize.apply: dryRun "${dryRunId}" was already applied at ${report.outcome.applied_at}`);
274
+ }
275
+ const decisions = Array.isArray(payload?.decisions) ? payload.decisions : null;
276
+ if (!decisions) throw new Error("organize.apply: decisions must be an array");
277
+
278
+ const snapshot = Array.isArray(report.input) ? report.input : [];
279
+ const byIndex = new Map(snapshot.map((entry) => [Number(entry?.index), entry]));
280
+ const hidden = new Set((Array.isArray(hiddenIds) ? hiddenIds : []).map((id) => String(id)));
281
+ // 报告认定过的「库内被取代行」:dryRun 的 items 落在报告的 decisions 列里,
282
+ // 只有那里命中过的 id 才有资格归档。不查这一条,apply 可以归档报告里根本
283
+ // 没出现过的行(评审实测:preference-only 的报告里归档了 project 行)——
284
+ // 整理接口就成了绕过报告的第二条改库通道。
285
+ const matchedIds = new Set(
286
+ (Array.isArray(report.decisions) ? report.decisions : [])
287
+ .map((d) => (d?.match?.id != null ? String(d.match.id) : null))
288
+ .filter(Boolean)
289
+ );
290
+ const skipped = [];
291
+ const byId = {};
292
+ const memoryIds = [];
293
+ const savedRows = [];
294
+ let saved = 0; // 真正新建的行
295
+ let merged = 0; // 合并进既有行(同键命中)——不是新建,不能算进 saved
296
+ let discarded = 0;
297
+ let archived = 0;
298
+
299
+ const runDecisions = () => {
300
+ decisions.forEach((decision, i) => {
301
+ const action = String(decision?.action ?? "").trim();
302
+ if (action === "save") {
303
+ const index = Number(decision?.index);
304
+ const entry = Number.isInteger(index) ? byIndex.get(index) : undefined;
305
+ if (!entry) {
306
+ // 索引不在快照里 = dryRun 从没校验过它(越界,或当时就被 normalize 拒了)。
307
+ skipped.push({ index: i, action, error: "candidate index was not accepted by this dryRun" });
308
+ return;
309
+ }
310
+ const { index: _originalIndex, ...candidate } = entry;
311
+ // 这里不吞异常:能走到这一步的候选是 dryRun 已经规范化过的合法输入,抛出
312
+ // 来的只能是基础设施错误 → 让它冒到事务外,整批回滚 + failed(红线 4 的
313
+ // 「单条跳过」只覆盖输入级错误,那一步在 normalizeCandidate)。
314
+ const result = saveWithDedupe(candidate);
315
+ if (result?.action === "created") saved++;
316
+ else merged++;
317
+ savedRows.push(result.memory);
318
+ memoryIds.push(result.memory.id);
319
+ byId[result.memory.id] = action;
320
+ return;
321
+ }
322
+ if (action === "discard") {
323
+ // 与 save 同一把尺:index 必须指向 dryRun 真的收下的候选。否则这是一条针对
324
+ // 不存在的候选的「裁决」,记进回执就是虚账(skipped 为空、可能还报 noop)。
325
+ const index = Number(decision?.index);
326
+ const entry = Number.isInteger(index) ? byIndex.get(index) : undefined;
327
+ if (!entry) {
328
+ skipped.push({ index: i, action, error: "candidate index was not accepted by this dryRun" });
329
+ return;
330
+ }
331
+ discarded++;
332
+ return;
333
+ }
334
+ if (action === "archive") {
335
+ const id = String(decision?.id ?? "").trim();
336
+ // 可见性先于「是否被报告命中」:调用方看不见的行一律按不存在处理(不泄漏
337
+ // 存在性,错误文案也维持「unknown, archived or out of scope」这一句)。
338
+ const row = !id || hidden.has(id) ? null : store.getById(id);
339
+ if (!row) {
340
+ skipped.push({ index: i, action, ids: id ? [id] : [], error: "unknown, archived or out of scope" });
341
+ return;
342
+ }
343
+ // 看得见、也存在,但报告从没把它标成命中行 → 拒绝:报告是唯一的判断依据。
344
+ if (!matchedIds.has(id)) {
345
+ skipped.push({ index: i, action, ids: [id], error: "id was not flagged as a match by this dryRun" });
346
+ return;
347
+ }
348
+ if (row.archived) {
349
+ byId[id] = "keep"; // 已归档 = 目标状态已达,不重复计数
350
+ return;
351
+ }
352
+ store.setArchived(id, true);
353
+ archived++;
354
+ byId[id] = action;
355
+ return;
356
+ }
357
+ skipped.push({ index: i, action, error: `unknown action "${action}"` });
358
+ });
359
+ };
360
+
361
+ const applyRunId = randomUUID();
362
+ const createdAt = new Date().toISOString();
363
+ const snapshotHash = report.snapshot_hash;
364
+ // 回执的 applied/byId 由调用方传入:失败路径必须报 0(事务已回滚,累积的
365
+ // saved/archived 是尝试值,写进回执就是撒谎)。
366
+ const record = (status, error, { byId: outcomeById = byId, applied = saved + merged + archived } = {}) => store.saveDreamRun({
367
+ id: applyRunId,
368
+ created_at: createdAt,
369
+ status,
370
+ error,
371
+ provider: null,
372
+ model: null,
373
+ snapshot_hash: snapshotHash,
374
+ input_count: snapshot.length,
375
+ input: snapshot,
376
+ decisions,
377
+ // dry_run_id 是这条回执与它所指报告的连接键:审计能还原「报告 → 判断 → 落地」
378
+ // 三步,而不用新建审计面。
379
+ outcome: { dry_run_id: dryRunId, byId: outcomeById },
380
+ applied,
381
+ summary_stored: 0,
382
+ receipt: buildReceipt({
383
+ runId: applyRunId,
384
+ status,
385
+ snapshotHash,
386
+ inputCount: snapshot.length,
387
+ applied,
388
+ summaryStored: false
389
+ }),
390
+ policy_epoch: 0,
391
+ run_type: "organize",
392
+ ...(skipped.length ? { skipped } : {})
393
+ });
394
+
395
+ let status;
396
+ try {
397
+ transaction(() => {
398
+ runDecisions();
399
+ // 给报告打「已落地」标记,与数据同事务:标记写不进去就整批回滚,不会出现
400
+ // 「库改了、报告却还能再落地一次」的窗口。
401
+ store.saveDreamRun({
402
+ ...report,
403
+ outcome: { ...(report.outcome ?? {}), applied_at: createdAt, applied_by: applyRunId }
404
+ });
405
+ // 只有 discard 的批次什么都没改 → noop(绝不虚报 ok)。
406
+ status = skipped.length ? "degraded" : saved + merged + archived > 0 ? "ok" : "noop";
407
+ // 成功回执与数据同事务:审计写不进去就一起回滚,不留「库改了却没有回执」
408
+ // 的窗口(回执是这条链上唯一的证据)。
409
+ record(status, null);
410
+ });
411
+ } catch (e) {
412
+ // 事务已回滚 → 失败回执单独写。审计本身失败不能顶掉原始错误(调用方要处理的
413
+ // 是原始错误那一件),但也别静默:挂到它身上,两件事都看得见。
414
+ try {
415
+ record("failed", String(e?.message ?? e), { byId: {}, applied: 0 });
416
+ } catch (auditError) {
417
+ e.audit_error = String(auditError?.message ?? auditError);
418
+ }
419
+ throw e;
420
+ }
421
+
422
+ // transaction 只负责镜像与通知,重嵌入留给调用方(service.js › transaction
423
+ // 注释);document.js 用同一个 finalize 先例。放在提交之后,事务里 scheduleEmbed
424
+ // 会被 txDepth 挡掉,不补这一步新写的记忆就没有向量。
425
+ if (savedRows.length && typeof finalize === "function") finalize(savedRows);
426
+
427
+ return {
428
+ run_id: applyRunId,
429
+ dry_run_id: dryRunId,
430
+ status,
431
+ saved,
432
+ merged,
433
+ discarded,
434
+ archived,
435
+ memory_ids: memoryIds,
436
+ skipped,
437
+ degraded: skipped.length > 0
438
+ };
439
+ }
440
+
441
+ /** 一个入口带 mode 参数(维护者口径);opts 是可信调用上下文,不取 payload 里的字段。 */
442
+ async function organize(payload = {}, opts = {}) {
443
+ const mode = String(payload?.mode ?? "dryRun").trim();
444
+ if (mode === "dryRun") return dryRun(payload);
445
+ if (mode === "apply") return apply(payload, opts);
446
+ throw new Error(`organize: unknown mode "${mode}" (dryRun | apply)`);
447
+ }
448
+
449
+ return { organize };
450
+ }
@@ -14,6 +14,9 @@
14
14
  * - 僵尸率:活跃(未归档未遗忘)且窗口内零候选出现;分母剔除写入不足
15
15
  * 豁免期的记忆(按当前时刻计,exemptCount 单独报数)。
16
16
  * - 注入命中率:注入侧无留痕,待 #217 mode='inject' 口径拍板后接入。
17
+ * - 注入命中率:mode='inject' 行单独计数(2026-09-19 拍板),slotFillRate =
18
+ * 注入条数 / (轮数 × maxInjectSlots,默认 5);注入候选同时进 hits——注入是
19
+ * 曝光型访问事件,计入 Top-N 与僵尸零曝光判定。
17
20
  * - coverage:recallRecordDefault 开启前的窗口算不到,earliestRunAt 为
18
21
  * null(窗口内无回执)或早于窗口起点时,前端标注可信度;扫描行数有
19
22
  * 上限,超出标 truncated(degraded 口径),不静默少算。
@@ -28,13 +31,27 @@ export function recallStats(store, options = {}) {
28
31
  // 调口径不留硬编码;越界值静默回默认(与 windowDays 同款宽容)。
29
32
  const rawExempt = Number(options.exemptDays ?? 7);
30
33
  const exemptDays = Number.isInteger(rawExempt) && rawExempt >= 0 && rawExempt <= 365 ? rawExempt : 7;
34
+ // 注入槽位数(#217 增量):slotFillRate 分母用;默认 5 与 injectCandidates
35
+ // 的 maxItems 默认一致,option 化防调用方自定义槽位时算错填充率。
36
+ const rawSlots = Number(options.maxInjectSlots ?? 5);
37
+ const maxInjectSlots = Number.isInteger(rawSlots) && rawSlots >= 1 ? rawSlots : 5;
31
38
  const now = Date.now();
32
39
  const since = new Date(now - windowDays * 86400000).toISOString();
33
40
 
34
41
  const { rows: runs, total } = store.listRecallRunsSince(since);
35
42
  const hits = new Map(); // id -> { count, title, source }
43
+ // 注入命中率口径(#217 增量,2026-09-19 拍板):mode='inject' 行单独计数,
44
+ // slotFillRate = 注入条数 / (轮数 × 槽位数);注入候选同时进 hits——注入是
45
+ // 曝光型访问事件,计入 Top-N 与僵尸零曝光判定。
46
+ let injectRuns = 0;
47
+ let injectedCount = 0;
36
48
  for (const run of runs) {
37
- for (const cand of run.candidates ?? []) {
49
+ const cands = run.candidates ?? [];
50
+ if (run.mode === "inject") {
51
+ injectRuns += 1;
52
+ injectedCount += cands.length;
53
+ }
54
+ for (const cand of cands) {
38
55
  if (!cand?.id) continue;
39
56
  const cur = hits.get(cand.id) ?? { count: 0, title: cand.title ?? null, source: cand.source ?? null };
40
57
  cur.count += 1;
@@ -90,6 +107,11 @@ export function recallStats(store, options = {}) {
90
107
  truncated: total > runs.length,
91
108
  earliestRunAt: runs.length ? runs[0].created_at : null
92
109
  },
110
+ injection: {
111
+ runs: injectRuns,
112
+ injectedCount,
113
+ slotFillRate: injectRuns > 0 ? injectedCount / (injectRuns * maxInjectSlots) : null
114
+ },
93
115
  topRecalled,
94
116
  zombie: {
95
117
  activeCount,
@@ -159,6 +159,36 @@ export function readJsonSafe(file) {
159
159
  }
160
160
  }
161
161
 
162
+ /**
163
+ * 归一清单里记下的完整性结论。
164
+ *
165
+ * 为什么要有这一处:这个字段有两种形态 —— 下载通道写的是 `{status, checked, detail}` 对象,
166
+ * 而收编来的历史清单根本没有它。判据(verify)与投影(loader)各自猜形状就出过真事故
167
+ * (issue #268:判据只认 `"sha512-matched"`,下载档写的 `"verified"` 进去恒判失败,
168
+ * 而 loader 又把那个对象直接当字符串外发)。形状归一收在这里,两边都只调它。
169
+ *
170
+ * 它只回答「清单里声称什么」,不重新验算 —— 原始 tarball 落盘后就没了,重算无从谈起。
171
+ *
172
+ * 形状非法(`false` / `0` / `[]` / `{}` / `{status: 5}` 这类)给 `"malformed"`:判据不认
173
+ * 它,于是 fail-closed。这类值既不能证明一致、也不是「没记录过」,按 ok:true 放行等于把
174
+ * 形状错误读成「已经验过了」(#269 评审)。
175
+ *
176
+ * @param {unknown} recorded - 清单里的 integrity 字段。
177
+ * @returns {{status: string, detail: string|null}} 状态词;缺失(null/undefined)为 "unverified"。
178
+ */
179
+ export function recordedIntegrity(recorded) {
180
+ const asStatus = (value) => (typeof value === "string" && value.trim() !== "" ? value.trim() : null);
181
+ if (recorded === undefined || recorded === null) return { status: "unverified", detail: null };
182
+ if (typeof recorded === "string") return { status: asStatus(recorded) ?? "unverified", detail: null };
183
+ if (typeof recorded !== "object" || Array.isArray(recorded)) {
184
+ const shape = Array.isArray(recorded) ? "array" : typeof recorded;
185
+ return { status: "malformed", detail: `integrity 字段形状非法(${shape}),无法据此判定` };
186
+ }
187
+ const status = asStatus(recorded.status);
188
+ if (!status) return { status: "malformed", detail: "integrity 对象里没有可用的 status" };
189
+ return { status, detail: typeof recorded.detail === "string" ? recorded.detail : null };
190
+ }
191
+
162
192
  /**
163
193
  * 列出运行时根目录下所有 payload 目录(名称排序)。目录不存在返回空数组。
164
194
  * 加载器用它挑候选,再逐个 describePayload 判断可用性。
@@ -27,6 +27,7 @@ import {
27
27
  describePayload,
28
28
  listPayloadDirs,
29
29
  payloadDir,
30
+ recordedIntegrity,
30
31
  transformersVersionOk
31
32
  } from "./layout.js";
32
33
 
@@ -129,7 +130,9 @@ export function describeLocalRuntime({
129
130
  materialize: candidate.manifest?.materialize?.mode ?? null,
130
131
  // 历史 payload(收编来的)清单里没有 integrity 字段,如实报 unverified;
131
132
  // 下载通道会把校验结果写进清单,这里不改代码就能读到。
132
- integrity: candidate.manifest?.integrity ?? "unverified",
133
+ // 必须投影成**状态字符串**:这个字段经免鉴权的 /semantic 出网,形状按 DTO 声明来
134
+ // —— 原来直接把清单里的对象塞进去,是形状违约(issue #268)。
135
+ integrity: recordedIntegrity(candidate.manifest?.integrity).status,
133
136
  functional: "unknown",
134
137
  hint: RUNTIME_HINT
135
138
  };
@@ -20,7 +20,16 @@
20
20
  // @module dsh-mneme/runtime/verify
21
21
  import { pathToFileURL } from "node:url";
22
22
  import { join } from "node:path";
23
- import { TRANSFORMERS_ENTRY, describePayload } from "./layout.js";
23
+ import { TRANSFORMERS_ENTRY, describePayload, recordedIntegrity } from "./layout.js";
24
+
25
+ /**
26
+ * 能证明「与清单里的 sha512 一致」的状态词。下载通道(`download.js`)记的是 `"verified"`,
27
+ * 本模块的强校验路径记的是 `"sha512-matched"` —— 同一件事的两种记法,判据必须都认,
28
+ * 否则清单里那份结论喂进来就是系统性判失败(issue #268)。
29
+ */
30
+ const MATCHED_INTEGRITY = new Set(["sha512-matched", "verified"]);
31
+ /** 没有可比对原始产物时的说明文案(收编路径的默认值)。 */
32
+ const UNVERIFIED_DETAIL = "该来源没有可比对的原始产物(收编);如需强校验请用 --strict 走 tarball 通道";
24
33
 
25
34
  /** 默认探针文本:中英各一条,避免只覆盖单字节语种。 */
26
35
  export const DEFAULT_PROBE_TEXTS = ["猫咪喜欢晒太阳", "the quick brown fox"];
@@ -157,8 +166,8 @@ export async function verifyFunctional(dir, {
157
166
  * @param {string} dir - payload 目录。
158
167
  * @param {object} [opts] - 选项。
159
168
  * @param {{status: string, detail?: string}} [opts.integrity] - 调用方算好的完整性结论
160
- * (下载/本地 tarball 路径在解包前比对过 sha512,把结果传进来;收编路径没有可比对
161
- * 的原始产物,默认 unverified)。
169
+ * (下载/本地 tarball 路径在解包前比对过 sha512,把结果传进来)。省略时读 payload
170
+ * 清单里记下的那份(下载通道会把结论写进去);两边都没有才如实标 unverified。
162
171
  * @returns {Promise<object>} 三件套结果 + 总判定。
163
172
  */
164
173
  export async function verifyPayload(dir, {
@@ -172,9 +181,13 @@ export async function verifyPayload(dir, {
172
181
  integrity
173
182
  } = {}) {
174
183
  const structural = describePayload(dir, { platform });
175
- const integrityResult = integrity
176
- ? { ok: integrity.status === "sha512-matched", status: integrity.status, detail: integrity.detail ?? null }
177
- : { ok: true, status: "unverified", detail: "该来源没有可比对的原始产物(收编);如需强校验请用 --strict 走 tarball 通道" };
184
+ // 调用方没显式给结论时,读清单里记下的那份:下载档在落盘前逐个 tarball 比对过 sha512,
185
+ // 结论就落在 <dir>/mneme-runtime.json 里。不读它,这一层在生产路径上永远不生效(issue #268)。
186
+ const claimed = recordedIntegrity(integrity ?? structural.manifest?.integrity);
187
+ const integrityResult =
188
+ claimed.status === "unverified"
189
+ ? { ok: true, status: "unverified", detail: claimed.detail ?? UNVERIFIED_DETAIL }
190
+ : { ok: MATCHED_INTEGRITY.has(claimed.status), status: claimed.status, detail: claimed.detail };
178
191
 
179
192
  const functional = structural.ok
180
193
  ? await verifyFunctional(dir, { engine, probeTexts, cacheDir, model, dtype, device })
@@ -20,3 +20,41 @@ export function adaptiveThreshold(query, candidates = []) {
20
20
  if (scores.length >= 5 && scores[0] - scores[4] > 0.3) return 0.5;
21
21
  return 0.65;
22
22
  }
23
+
24
+ // Issue #239(第 5 项):注入条数的查询自适应(去 always-on 化的一半)。
25
+ // 依据:不确定性强的话题多召回、确定性的少召回(Oblivion 的不确定性驱动读路径);
26
+ // 但**注入相关却带偏生成**的内容可能比不注入更糟(2607.24010 与 2604.02280 两处
27
+ // 独立出处),所以这里只做**单向收缩**:模糊 → 维持用户配置的上限,确定 → 收缩,
28
+ // 绝不越过上限。判据只看查询本身——不做额外检索(2607.24010:触发侧成本别忽略,
29
+ // 先探针检索等于白付一次 fuseRecall)。
30
+ const RECALL_CUES = [
31
+ // 中文回指 / 时间线索:出现即说明用户要的是「记忆里的东西」,当场算不出来
32
+ "上次", "之前", "以前", "那天", "那次", "刚才", "刚刚", "后来", "很早",
33
+ "还记得", "记不记得", "我们讨论", "我们聊", "我说过", "你提过", "提到过",
34
+ "最近", "上次说", "之前说",
35
+ // 英文同类线索
36
+ "last time", "previously", "earlier", "remember", "we discussed",
37
+ "i mentioned", "you said", "that thing", "remind me"
38
+ ];
39
+
40
+ /**
41
+ * 这段查询是否需要「宽召回」。命中回指/时间线索,或短到几乎匹配任何东西
42
+ * (与 adaptiveThreshold 对 <5 字符的判断同源)。纯函数、零成本。
43
+ * 拿不到查询时不猜——返回 true 维持现状。
44
+ */
45
+ export function needsBroadRecall(query) {
46
+ const q = String(query ?? "").trim().toLowerCase();
47
+ if (q === "") return true;
48
+ if (q.length < 5) return true;
49
+ return RECALL_CUES.some((cue) => q.includes(cue));
50
+ }
51
+
52
+ /**
53
+ * 注入条数的自适应预算:模糊 → 维持配置上限;确定 → 收缩到一半(下限 1)。
54
+ * `maxItems <= 1` 时原样返回(含 0 = 不注入,flag 打开也不该把 0 变成 1)。
55
+ */
56
+ export function adaptiveInjectBudget(query, maxItems) {
57
+ const cap = Number.isFinite(maxItems) && maxItems > 0 ? Math.floor(maxItems) : maxItems;
58
+ if (!Number.isFinite(cap) || cap <= 1) return cap;
59
+ return needsBroadRecall(query) ? cap : Math.max(1, Math.ceil(cap / 2));
60
+ }