@modusensus/dsh-mneme 0.8.3 → 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.
- package/LICENSE +21 -21
- package/README.md +727 -636
- package/bin/cli.mjs +0 -0
- package/bin/dsh-mneme-mcp.mjs +510 -0
- package/cordis.patch.yml +21 -21
- package/lib/api-standalone.js +506 -316
- package/lib/api.js +1403 -1316
- package/lib/bootstrap.js +211 -0
- package/lib/client.js +4657 -4366
- package/lib/commands.js +95 -95
- package/lib/config.js +615 -510
- package/lib/document.js +221 -0
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +658 -658
- package/lib/dream/narratives.js +44 -0
- package/lib/dream/sleep.js +702 -700
- package/lib/dream.js +147 -16
- package/lib/embedding.js +177 -177
- package/lib/entities/extractor.js +249 -249
- package/lib/guide.js +44 -0
- package/lib/heat.js +144 -136
- package/lib/hot-memory.js +46 -46
- package/lib/index.js +515 -515
- package/lib/inject.js +375 -295
- package/lib/lang.js +208 -179
- package/lib/local-embedder.js +320 -320
- package/lib/mirror.js +198 -198
- package/lib/organize.js +450 -0
- package/lib/quality-filter.js +126 -126
- package/lib/recall-stats.js +125 -0
- package/lib/reranker.js +269 -269
- package/lib/runtime/adopt.js +176 -176
- package/lib/runtime/closure.js +172 -172
- package/lib/runtime/download.js +247 -247
- package/lib/runtime/layout.js +265 -235
- package/lib/runtime/loader.js +252 -249
- package/lib/runtime/model-fetch.js +361 -361
- package/lib/runtime/provision.js +237 -237
- package/lib/runtime/tarball.js +130 -130
- package/lib/runtime/verify.js +204 -191
- package/lib/scope.js +118 -118
- package/lib/search/adaptive.js +60 -22
- package/lib/search/bm25.js +96 -96
- package/lib/service.js +2324 -2087
- package/lib/settings.js +459 -420
- package/lib/store.js +2549 -2367
- package/lib/summarize.js +820 -449
- package/lib/tools.js +702 -574
- package/lib/vector-index.js +110 -110
- package/lib/version-check.js +122 -122
- package/package.json +109 -108
- package/runtime-manifest.json +526 -596
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +199 -199
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/build-runtime-manifest.mjs +224 -217
- package/scripts/check-sync.js +42 -42
- package/scripts/e2e-dsh.js +220 -220
- package/scripts/mneme-runtime.mjs +277 -277
- package/scripts/stress-dsh.js +255 -255
- package/scripts/sync-lib.js +47 -47
- package/scripts/sync-test-badge.mjs +67 -61
- package/src/api-standalone.js +506 -316
- package/src/api.js +1403 -1316
- package/src/bootstrap.js +211 -0
- package/src/commands.js +95 -95
- package/src/config.js +615 -510
- package/src/document.js +221 -0
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +658 -658
- package/src/dream/narratives.js +44 -0
- package/src/dream/sleep.js +702 -700
- package/src/dream.js +147 -16
- package/src/embedding.js +177 -177
- package/src/entities/extractor.js +249 -249
- package/src/guide.js +44 -0
- package/src/heat.js +144 -136
- package/src/hot-memory.js +46 -46
- package/src/index.js +515 -515
- package/src/inject.js +375 -295
- package/src/lang.js +208 -179
- package/src/local-embedder.js +320 -320
- package/src/mirror.js +198 -198
- package/src/organize.js +450 -0
- package/src/quality-filter.js +126 -126
- package/src/recall-stats.js +125 -0
- package/src/reranker.js +269 -269
- package/src/runtime/adopt.js +176 -176
- package/src/runtime/closure.js +172 -172
- package/src/runtime/download.js +247 -247
- package/src/runtime/layout.js +265 -235
- package/src/runtime/loader.js +252 -249
- package/src/runtime/model-fetch.js +361 -361
- package/src/runtime/provision.js +237 -237
- package/src/runtime/tarball.js +130 -130
- package/src/runtime/verify.js +204 -191
- package/src/scope.js +118 -118
- package/src/search/adaptive.js +60 -22
- package/src/search/bm25.js +96 -96
- package/src/service.js +2324 -2087
- package/src/settings.js +459 -420
- package/src/store.js +2549 -2367
- package/src/summarize.js +820 -449
- package/src/tools.js +702 -574
- package/src/vector-index.js +110 -110
- package/src/version-check.js +122 -122
- package/test/adaptive-inject-budget.test.js +85 -0
- package/test/api-routes-gaps.test.js +218 -218
- package/test/api.test.js +1640 -1482
- package/test/audit.test.js +448 -448
- package/test/benchmark.test.js +94 -94
- package/test/bootstrap.test.js +220 -0
- package/test/client.test.js +860 -736
- package/test/clustering.test.js +100 -100
- package/test/commands.test.js +106 -106
- package/test/config.test.js +66 -66
- package/test/conflict-freeze.test.js +300 -300
- package/test/document.test.js +523 -0
- package/test/dream-archive-guardrail.test.js +116 -116
- package/test/dream-candidate-hybrid.test.js +281 -281
- package/test/dream-coverage-degrade.test.js +86 -86
- package/test/dream.test.js +1308 -1254
- package/test/embedding-ready.test.js +196 -196
- package/test/entities.test.js +547 -547
- package/test/entity-recall.test.js +179 -0
- package/test/epistemic.test.js +298 -298
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/graph-api.test.js +175 -175
- package/test/heat.test.js +175 -148
- package/test/helpers/dream-mock.js +82 -82
- package/test/helpers/peer-worker.mjs +25 -25
- package/test/helpers/runtime-source.js +60 -60
- package/test/helpers/tar-builder.js +80 -80
- package/test/hot-memory.test.js +145 -145
- package/test/inject-content-cap.test.js +91 -0
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +61 -0
- package/test/inject-pools.test.js +170 -0
- package/test/inject-recall.test.js +105 -0
- package/test/inject-rotation.test.js +161 -161
- package/test/inject.test.js +283 -249
- package/test/lang.test.js +87 -87
- package/test/lib-smoke.test.js +96 -96
- package/test/llm-audit.test.js +462 -309
- package/test/llm-message-source.test.js +177 -177
- package/test/local-embedder.test.js +249 -249
- package/test/mcp-stdio.test.js +385 -0
- package/test/mirror-dirty.test.js +424 -424
- package/test/mirror-edit-digest.test.js +187 -187
- package/test/mirror-generation.test.js +499 -499
- package/test/mirror.test.js +249 -249
- package/test/narrative-bars.test.js +162 -0
- package/test/organize.test.js +418 -0
- package/test/peer-blockers.test.js +193 -193
- package/test/policy-epoch.test.js +259 -259
- package/test/quality-filter.test.js +186 -186
- package/test/reasoning-effort.test.js +746 -746
- package/test/recall-evals.test.js +235 -235
- package/test/recall-layer.test.js +326 -326
- package/test/recall-stats.test.js +123 -0
- package/test/receipt-chain.test.js +451 -451
- package/test/reflection.test.js +226 -226
- package/test/reindex-backfill.test.js +62 -62
- package/test/reranker-single-logit.test.js +73 -73
- package/test/reranker.test.js +197 -197
- package/test/resident-status.test.js +83 -0
- package/test/runtime-adopt.test.js +206 -206
- package/test/runtime-cli.test.js +89 -89
- package/test/runtime-closure.test.js +193 -193
- package/test/runtime-download.test.js +302 -302
- package/test/runtime-layout.test.js +163 -163
- package/test/runtime-loader.test.js +255 -237
- package/test/runtime-manifest.test.js +159 -158
- package/test/runtime-model-fetch.test.js +348 -348
- package/test/runtime-provision.test.js +101 -101
- package/test/runtime-tarball.test.js +81 -81
- package/test/runtime-verify.test.js +279 -214
- package/test/scope-candidates.test.js +239 -239
- package/test/scope-explicit.test.js +452 -452
- package/test/scope-retrieval.test.js +197 -197
- package/test/scope-storage.test.js +255 -255
- package/test/scope-strict.test.js +231 -231
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- package/test/service-search.test.js +174 -174
- package/test/service.test.js +405 -405
- package/test/settings.test.js +261 -261
- package/test/skip-invalid-restore.test.js +262 -262
- package/test/sleep-action-set.test.js +422 -422
- package/test/sleep-heat.test.js +126 -125
- package/test/sleep-scheduler-rearm.test.js +89 -89
- package/test/sleep.test.js +410 -373
- package/test/standalone-api.test.js +544 -359
- package/test/store-vector-cache.test.js +98 -98
- package/test/store.test.js +396 -383
- package/test/stress.test.js +209 -209
- package/test/summarize.test.js +1288 -575
- package/test/tools-dto-schema-matrix.test.js +325 -0
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +374 -341
- package/test/updated-at-semantics.test.js +113 -113
- package/test/vector-index.test.js +221 -221
- package/test/version-check.test.js +152 -152
- package/test/webServer-optional.test.js +82 -82
package/src/organize.js
ADDED
|
@@ -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
|
+
}
|