@modusensus/dsh-mneme 0.8.14 → 0.8.16
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 +660 -654
- package/assets/icon.svg +3 -3
- package/bin/cli.mjs +691 -689
- package/bin/dsh-mneme-mcp.mjs +510 -510
- package/bin/dsh-mneme-serve.mjs +151 -151
- package/cordis.patch.yml +21 -21
- package/docs/CONFIGURATION.md +275 -0
- package/docs/CONTINUITY.md +124 -0
- package/docs/DAEMON.md +46 -0
- package/docs/DISTILL.md +162 -0
- package/docs/ENTITIES.md +245 -0
- package/docs/LOCAL_MODEL.md +222 -0
- package/docs/SEMANTIC.md +256 -0
- package/docs/SLEEP.md +164 -0
- package/docs/STORAGE.md +98 -0
- package/lib/api-standalone.js +656 -673
- package/lib/api.js +1605 -1433
- package/lib/bootstrap.js +211 -211
- package/lib/client.js +6354 -5296
- package/lib/commands.js +95 -95
- package/lib/config.js +947 -843
- package/lib/content-hash.js +54 -54
- package/lib/continuity.js +220 -220
- package/lib/document-index.js +94 -94
- package/lib/document.js +303 -303
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +694 -674
- package/lib/dream/narratives.js +44 -44
- package/lib/dream/sleep.js +819 -819
- package/lib/dream.js +1503 -1501
- package/lib/embedding.js +177 -177
- package/lib/entities/extractor.js +252 -252
- package/lib/graph/anchoring.js +179 -179
- package/lib/guide.js +74 -74
- package/lib/heat.js +144 -144
- package/lib/hot-memory.js +108 -46
- package/lib/index.js +552 -503
- package/lib/inject.js +428 -450
- package/lib/lang.js +248 -229
- package/lib/local-embedder.js +358 -358
- package/lib/maintenance.js +162 -142
- package/lib/mirror.js +330 -330
- package/lib/organize.js +450 -450
- package/lib/peak-hours.js +117 -117
- package/lib/placement.js +13 -0
- package/lib/pre-inject-gate.js +340 -0
- package/lib/quality-filter.js +126 -126
- package/lib/recall-stats.js +190 -175
- package/lib/reclaim-http.js +150 -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 -265
- package/lib/runtime/loader.js +252 -252
- 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 +223 -204
- package/lib/safe-slice.js +18 -0
- package/lib/scope.js +146 -146
- package/lib/search/adaptive.js +60 -60
- package/lib/search/bm25.js +96 -96
- package/lib/semantic.js +229 -229
- package/lib/sensitive-scan.js +209 -209
- package/lib/serve.js +215 -215
- package/lib/service.js +2688 -2605
- package/lib/settings.js +528 -511
- package/lib/store.js +3196 -3007
- package/lib/summarize.js +1039 -856
- package/lib/tools.js +743 -737
- package/lib/vector-index.js +110 -110
- package/lib/version-check.js +122 -122
- package/lib/write-admission.js +402 -402
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +118 -118
- package/runtime-manifest.json +526 -526
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +249 -249
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/build-runtime-manifest.mjs +224 -224
- package/scripts/changelog-prep.mjs +36 -36
- 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 +69 -69
- package/scripts/test-count-sync.mjs +44 -44
- package/src/api-standalone.js +656 -673
- package/src/api.js +1605 -1433
- package/src/bootstrap.js +211 -211
- package/src/commands.js +95 -95
- package/src/config.js +947 -843
- package/src/content-hash.js +54 -54
- package/src/continuity.js +220 -220
- package/src/document-index.js +94 -94
- package/src/document.js +303 -303
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +694 -674
- package/src/dream/narratives.js +44 -44
- package/src/dream/sleep.js +819 -819
- package/src/dream.js +1503 -1501
- package/src/embedding.js +177 -177
- package/src/entities/extractor.js +252 -252
- package/src/graph/anchoring.js +179 -179
- package/src/guide.js +74 -74
- package/src/heat.js +144 -144
- package/src/hot-memory.js +108 -46
- package/src/index.js +552 -503
- package/src/inject.js +428 -450
- package/src/lang.js +248 -229
- package/src/local-embedder.js +358 -358
- package/src/maintenance.js +162 -142
- package/src/mirror.js +330 -330
- package/src/organize.js +450 -450
- package/src/peak-hours.js +117 -117
- package/src/placement.js +13 -0
- package/src/pre-inject-gate.js +340 -0
- package/src/quality-filter.js +126 -126
- package/src/recall-stats.js +190 -175
- package/src/reclaim-http.js +150 -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 -265
- package/src/runtime/loader.js +252 -252
- 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 +223 -204
- package/src/safe-slice.js +18 -0
- package/src/scope.js +146 -146
- package/src/search/adaptive.js +60 -60
- package/src/search/bm25.js +96 -96
- package/src/semantic.js +229 -229
- package/src/sensitive-scan.js +209 -209
- package/src/serve.js +215 -215
- package/src/service.js +2688 -2605
- package/src/settings.js +528 -511
- package/src/store.js +3196 -3007
- package/src/summarize.js +1039 -856
- package/src/tools.js +743 -737
- package/src/vector-index.js +110 -110
- package/src/version-check.js +122 -122
- package/src/write-admission.js +402 -402
- package/test/adaptive-inject-budget.test.js +85 -85
- package/test/api-routes-gaps.test.js +899 -218
- package/test/api.test.js +1725 -1690
- package/test/audit.test.js +448 -448
- package/test/baseline-persist.test.js +248 -248
- package/test/benchmark.test.js +94 -94
- package/test/bootstrap.test.js +220 -220
- package/test/changelog-prep.test.js +60 -60
- package/test/client.test.js +1582 -1169
- 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/content-hash.test.js +204 -204
- package/test/continuity.test.js +333 -333
- package/test/contrast.test.js +314 -0
- package/test/document-dir.test.js +233 -233
- package/test/document.test.js +671 -671
- 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-failure-backoff.test.js +173 -173
- package/test/dream-merge-guardrail.test.js +219 -0
- package/test/dream-peak-hours.test.js +194 -194
- package/test/dream.test.js +1385 -1383
- package/test/embedding-ready.test.js +196 -196
- package/test/entities.test.js +627 -547
- package/test/entity-recall.test.js +179 -179
- package/test/epistemic.test.js +325 -325
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/graph-anchoring.test.js +184 -184
- package/test/graph-api.test.js +175 -175
- package/test/graph-inject-budget.test.js +103 -103
- package/test/graph-passive-confirm.test.js +91 -91
- package/test/graph-weight-evolution.test.js +104 -104
- package/test/heat.test.js +175 -175
- 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/helpers/write-admission-samples.js +308 -308
- package/test/hot-memory.test.js +211 -145
- package/test/inject-content-cap.test.js +91 -91
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +77 -77
- package/test/inject-parent-gate.test.js +106 -106
- package/test/inject-pools.test.js +194 -194
- package/test/inject-recall.test.js +144 -105
- package/test/inject-rotation.test.js +161 -161
- package/test/inject.test.js +443 -420
- package/test/lang.test.js +87 -87
- package/test/lastrunat-persist.test.js +101 -101
- package/test/lib-smoke.test.js +96 -96
- package/test/llm-audit.test.js +696 -598
- package/test/llm-message-source.test.js +207 -207
- package/test/local-embedder.test.js +292 -292
- package/test/maintenance.test.js +501 -391
- package/test/mcp-stdio.test.js +385 -385
- 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-types.test.js +208 -208
- package/test/mirror.test.js +249 -249
- package/test/narrative-bars.test.js +162 -162
- package/test/organize.test.js +418 -418
- package/test/peer-blockers.test.js +193 -193
- package/test/placement.test.js +36 -0
- package/test/policy-epoch.test.js +259 -259
- package/test/pre-inject-gate.test.js +444 -0
- package/test/quality-filter.test.js +186 -186
- package/test/reasoning-effort.test.js +752 -752
- package/test/recall-anti-update.test.js +82 -82
- package/test/recall-evals.test.js +253 -253
- package/test/recall-layer.test.js +326 -326
- package/test/recall-stats.test.js +274 -189
- package/test/receipt-chain.test.js +451 -451
- package/test/reclaim-http.test.js +209 -0
- 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 -83
- 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 -255
- package/test/runtime-manifest.test.js +159 -159
- 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 +319 -279
- package/test/scope-candidates.test.js +239 -239
- package/test/scope-explicit.test.js +452 -452
- package/test/scope-inject-soft.test.js +106 -106
- package/test/scope-retrieval.test.js +197 -197
- package/test/scope-storage.test.js +255 -255
- package/test/scope-strict.test.js +367 -367
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- package/test/sensitive-scan.test.js +207 -207
- package/test/serve-bin.test.js +121 -121
- package/test/serve.test.js +196 -196
- package/test/service-search.test.js +174 -174
- package/test/service.test.js +410 -405
- package/test/settings.test.js +269 -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 -126
- package/test/sleep-scheduler-rearm.test.js +89 -89
- package/test/sleep.test.js +527 -527
- package/test/standalone-api.test.js +751 -751
- package/test/store-vector-cache.test.js +98 -98
- package/test/store.test.js +504 -396
- package/test/stress.test.js +209 -209
- package/test/summarize-occurred-at.test.js +127 -0
- package/test/summarize-reasoning-effort.test.js +189 -189
- package/test/summarize.test.js +1639 -1465
- package/test/supersede-state-tracking.test.js +394 -0
- package/test/test-count-sync.test.js +60 -60
- package/test/tools-dto-schema-matrix.test.js +325 -325
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +428 -374
- 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/test/write-admission.test.js +668 -668
package/lib/document.js
CHANGED
|
@@ -1,303 +1,303 @@
|
|
|
1
|
-
// dsh-mneme/src/document.js
|
|
2
|
-
// document 型记忆注册(issue #230,口径基线 = #164 设计稿评审线):agent 产
|
|
3
|
-
// 长文档的指针行铸造口。写入权分离——全文归 agent(管线对文件零读零写零改),
|
|
4
|
-
// 管线只做四件事:注册校验(文件存在 + 路径合法 + evidence 求交)、摘要 +
|
|
5
|
-
// doc_path 落库(库里只存这三样)、C2 vector 档比对去重、supersede 记账
|
|
6
|
-
// (出新版 = 新摘要行 supersede 旧行,旧文件不删,content_history 可追溯)。
|
|
7
|
-
//
|
|
8
|
-
// 独立成模块(AGENTS.md 尺寸约定,同 recallStats 先例):service.js 已过
|
|
9
|
-
// 2000 行参考线,这里只依赖注入 service 内部件,barrel 出口在 service.js,
|
|
10
|
-
// 调用方零改动。防绕过三层兜底:service 两条业务守卫(saveWithDedupe /
|
|
11
|
-
// updateMemory)+ 存储层唯一铸造口(store.saveDocument,通用 save/update/
|
|
12
|
-
// CAS 整类拒绝 document)——注册是唯一铸造口。
|
|
13
|
-
|
|
14
|
-
import { statSync } from "node:fs";
|
|
15
|
-
import { homedir } from "node:os";
|
|
16
|
-
import { isAbsolute, resolve, sep } from "node:path";
|
|
17
|
-
import { scopeKeyOf } from "./scope.js";
|
|
18
|
-
import { cosineSimilarity } from "./dream/clustering.js";
|
|
19
|
-
|
|
20
|
-
// C2 vector 档阈值(#164 拍板 0.92,与 findSessionDuplicate 的 vector 档同源)。
|
|
21
|
-
// 刻意不做 24h 时间窗:那个窗是会话去重的「近邻界」(findSessionDuplicate 注释),
|
|
22
|
-
// 不是相似度语义本身;document 版本更新天然跨天/跨周,套窗会把一周后重注册的
|
|
23
|
-
// 新版判成「无近重复」,留下两行活跃摘要漂移——supersede 探测必须全时段。
|
|
24
|
-
const DOCUMENT_MIN_SIM = 0.92;
|
|
25
|
-
const CANDIDATE_LIMIT = 200;
|
|
26
|
-
const DOCUMENT_DIR_NAME = "documents";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* 解析 managed 文档目录(#296 第二批)。`configured` 为空 = 跟随 memoryDir 的
|
|
30
|
-
* `<memoryDir>/documents/`;`~` / `~/` / `~\` 展开到用户 home;相对路径落在
|
|
31
|
-
* memoryDir 下(本键自己的规则:memoryDir 的相对路径原样留着走 cwd 语义);
|
|
32
|
-
* 绝对路径归一化后原样用。纯函数,路径解析集中在 index.js 调用一次。
|
|
33
|
-
*/
|
|
34
|
-
export function resolveDocumentDir(memoryDir, configured = "") {
|
|
35
|
-
const raw = String(configured ?? "").trim();
|
|
36
|
-
if (!raw) return resolve(memoryDir, DOCUMENT_DIR_NAME);
|
|
37
|
-
if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
38
|
-
return resolve(homedir(), raw.slice(2));
|
|
39
|
-
}
|
|
40
|
-
return isAbsolute(raw) ? resolve(raw) : resolve(memoryDir, raw);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* 这个路径是不是落在 managed 目录里(`documentDir` 内 = mneme 管的树,`index.md`
|
|
45
|
-
* 与镜像 md 同权;之外 = 只登记指针行、正文一个字节都不碰)。比较前两边都归一化,
|
|
46
|
-
* Windows 上大小写不敏感(文件系统就是),否则 `C:\a\documents\x.md` 与
|
|
47
|
-
* `c:\A\Documents\x.md` 会被判成两类。
|
|
48
|
-
*/
|
|
49
|
-
export function isManagedDocumentPath(dir, path) {
|
|
50
|
-
if (!dir || !path) return false;
|
|
51
|
-
const norm = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p));
|
|
52
|
-
const base = norm(resolve(dir));
|
|
53
|
-
const target = norm(resolve(path));
|
|
54
|
-
const prefix = base.endsWith(sep) ? base : base + sep;
|
|
55
|
-
return target === base || target.startsWith(prefix);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// 追加到被取代行正文的指针注记。刻意不带语言分支:id 是跨语言稳定键(用户
|
|
59
|
-
// 对 tag/章节名建议用英文同理——指针性内容不翻译);可追溯链 = 注记里的新行
|
|
60
|
-
// id + 旧行 content_history 里的旧摘要(source=superseded)。
|
|
61
|
-
const supersededByPointer = (id) => `\n\n[superseded by ${id}]`;
|
|
62
|
-
|
|
63
|
-
// 升格吸收不碰的类型(#275 拍板 5 的边界):吸收的对象是「原子条」,而下面这两种
|
|
64
|
-
// 行各有自己的生命周期,被归档会各自留下第二行——
|
|
65
|
-
// document:本体是磁盘上的文件。归档它等于替用户撤下外档;而且 supersede 探测
|
|
66
|
-
// 只看活跃行(store.list 默认排除归档),同一 path 下次注册会在旧行还挂着
|
|
67
|
-
// 「新版本」语义时再铸一行,指针注记与 content_history 记账也走不到。
|
|
68
|
-
// summary:dream 总览与叙述按 source 身份去重、并按注入档位常驻。归档它会让下一
|
|
69
|
-
// 次做梦把它当不存在(候选集同样只取活跃行),于是库里多出一行同源总览。
|
|
70
|
-
// 口径是保守方向的:拿不准的行一律不吸收——少做一步没有代价,收错一步是内容事故。
|
|
71
|
-
const ABSORB_EXEMPT_TYPES = new Set(["document", "summary"]);
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Build the document registrar. Injected deps are service.js closure members
|
|
75
|
-
* (store / embedQuery / transaction / finalize) so this module stays free of
|
|
76
|
-
* service-internal wiring; `finalize` is the single write epilogue (mirror
|
|
77
|
-
* sync + notify + re-embed) so callers never duplicate it.
|
|
78
|
-
*
|
|
79
|
-
* `pinnedTypes` 是注入的 #249 逐字保真池(service.js 的 PINNED_MEMORY_TYPES):
|
|
80
|
-
* 这里不 import 它在 service.js 里,是因为 service.js 反向 import 本模块——
|
|
81
|
-
* 方向反了会成环。
|
|
82
|
-
*
|
|
83
|
-
* @returns {(payload: object, opts?: object) => Promise<object>}
|
|
84
|
-
* `{ action: "created"|"superseded", memory, superseded?, evidence_kept,
|
|
85
|
-
* evidence_dropped, evidence_archived, degraded }`
|
|
86
|
-
* @throws flag 关 / 路径非法或文件缺失 / title 或 summary 为空 / evidence 全部
|
|
87
|
-
* 捏造 / 仅 vector 近重复(疑似重复外档,交 agent 裁决而不是静默二选一)。
|
|
88
|
-
*/
|
|
89
|
-
export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize, pinnedTypes }) {
|
|
90
|
-
return async function registerDocument(payload, { hiddenEvidenceIds = [], archiveEvidence = true } = {}) {
|
|
91
|
-
// opt-in 总闸(#230 对齐 #228 形态):关 = 注册入口整体不存在,错误信息
|
|
92
|
-
// 指回配置键,agent 能准确转告用户去开。
|
|
93
|
-
if (config?.documentMemoryEnabled !== true) {
|
|
94
|
-
throw new Error("document memory is disabled (documentMemoryEnabled)");
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// --- 1. 路径合法 + 文件存在 --------------------------------------------
|
|
98
|
-
// 只认展开 ~ 后的绝对路径:相对路径依赖进程 cwd,注册时的 cwd 与读取时
|
|
99
|
-
// 的 cwd 不保证一致,指针必须自带完整定位。校验存在 + 常规文件 + 非空——
|
|
100
|
-
// 空文件/被截断成 0 字节是「文字结构意外破坏」的最早信号,注册时拦住
|
|
101
|
-
// 而不是落一条死指针(回退路径:agent 修好文件后重注册,同 path/title
|
|
102
|
-
// 自动 supersede 旧行)。
|
|
103
|
-
const rawPath = String(payload?.path ?? "").trim();
|
|
104
|
-
if (!rawPath) throw new Error("registerDocument: path is required");
|
|
105
|
-
// 只展开 ~ / ~/(~user 形式拒收为普通相对路径走 isAbsolute 拦截——不猜测
|
|
106
|
-
// 其他用户的 home,错展开比报错更糟)。
|
|
107
|
-
const isTilde = rawPath === "~" || rawPath.startsWith("~/") || rawPath.startsWith("~\\");
|
|
108
|
-
const absolute = isTilde ? resolve(homedir(), rawPath.slice(2)) : rawPath;
|
|
109
|
-
if (!isAbsolute(absolute)) {
|
|
110
|
-
throw new Error(`registerDocument: path must be absolute (got "${rawPath}")`);
|
|
111
|
-
}
|
|
112
|
-
// 所有绝对路径统一 resolve 归一化(. / .. / 重复分隔符)——归一化不做的话,
|
|
113
|
-
// D:\a\.\b.md 与 D:\a\b.md 会被判成两个路径,同文件绕过 same-path
|
|
114
|
-
// supersede,留下双活跃指针。realpathSync 刻意不用:符号链接合并会改写
|
|
115
|
-
// agent 提交的路径字面量,指针的可读性比追符号链更重要。
|
|
116
|
-
const expanded = resolve(absolute);
|
|
117
|
-
let stat = null;
|
|
118
|
-
try { stat = statSync(expanded); } catch { /* missing/unreadable → null */ }
|
|
119
|
-
if (!stat?.isFile() || stat.size <= 0) {
|
|
120
|
-
throw new Error(`registerDocument: document file not found or empty: ${expanded}`);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// --- 2. evidence 求交(sleep.js 扫描集求交同款语义)---------------------
|
|
124
|
-
// 引用的原子条必须真实存在且未归档(归档行已被降级,不再为新鲜 document
|
|
125
|
-
// 背书)。红线 4 宽容形态:合法子集落库 + evidence_degraded 系统标记;
|
|
126
|
-
// 提供了 evidence 但全军覆没 = 捏造信号,整单拒绝,agent 修正后重试。
|
|
127
|
-
const wanted = [...new Set(
|
|
128
|
-
(Array.isArray(payload?.evidence) ? payload.evidence : [])
|
|
129
|
-
.map((id) => String(id ?? "").trim())
|
|
130
|
-
.filter(Boolean)
|
|
131
|
-
)];
|
|
132
|
-
// strictScope(tools 层传入,#170 复核项 4 同款):调用者 scope 看不见的
|
|
133
|
-
// 行按「不存在」处理——不得为跨 scope id 建立引用(存在性泄漏),与
|
|
134
|
-
// unknown/archived 同落 dropped,evidence_kept/dropped 计数保持诚实。
|
|
135
|
-
const hidden = new Set((Array.isArray(hiddenEvidenceIds) ? hiddenEvidenceIds : []).map((id) => String(id)));
|
|
136
|
-
const kept = [];
|
|
137
|
-
const dropped = [];
|
|
138
|
-
// 存在但已归档的引用另记一份:它可能是被上一版文档升格吸收走的行(见下方
|
|
139
|
-
// supersede 目标探测后的「认回」),不能一律按「不可用」处理。
|
|
140
|
-
const archivedRefs = new Set();
|
|
141
|
-
for (const id of wanted) {
|
|
142
|
-
const row = hidden.has(id) ? null : store.getById(id);
|
|
143
|
-
if (row?.archived) archivedRefs.add(id);
|
|
144
|
-
(row && !row.archived ? kept : dropped).push(id);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const title = String(payload?.title ?? "").trim();
|
|
148
|
-
if (!title) throw new Error("registerDocument: title is required");
|
|
149
|
-
const summary = String(payload?.summary ?? "").trim();
|
|
150
|
-
if (!summary) throw new Error("registerDocument: summary is required");
|
|
151
|
-
|
|
152
|
-
// --- 3. supersede 目标探测(三层)---------------------------------------
|
|
153
|
-
// 同 doc_path / 同标题 = agent 显式的「出新版」意图 → supersede;仅 vector
|
|
154
|
-
// 近重复而路径标题都不同 = 疑似重复外档 → 拒绝并指路。C2×C1 硬线:比对
|
|
155
|
-
// 不得越过矛盾裁决替 agent 二选一——改既有行还是归档后重注册,判断权留在
|
|
156
|
-
// agent 手里。去重键带 scope(scopeKeyOf):跨作用域永不互判(防泄漏,与
|
|
157
|
-
// saveWithDedupe 的 scopeMatches 同口径)。
|
|
158
|
-
const scopeMatches = (m) =>
|
|
159
|
-
scopeKeyOf(m.agent_scope) === scopeKeyOf(payload?.agent_scope) &&
|
|
160
|
-
scopeKeyOf(m.workspace_scope) === scopeKeyOf(payload?.workspace_scope) &&
|
|
161
|
-
scopeKeyOf(m.sensitivity) === scopeKeyOf(payload?.sensitivity);
|
|
162
|
-
const samePath = (a, b) => {
|
|
163
|
-
if (!a || !b) return false;
|
|
164
|
-
// Windows 文件系统大小写不敏感:比较前归一,否则 C:\a.md 与 c:\A.md
|
|
165
|
-
// 会被判成两个版本。
|
|
166
|
-
const norm = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p));
|
|
167
|
-
return norm(a) === norm(b);
|
|
168
|
-
};
|
|
169
|
-
// 精确层(同路径/同标题)必须全量扫描、不允许窗口截断:>200 行活跃
|
|
170
|
-
// document 时,窗口外的同路径旧版漏检会留下双活跃版本——supersede 判定
|
|
171
|
-
// 是正确性要求,不是性能优化对象。vector 档比对保留 CANDIDATE_LIMIT
|
|
172
|
-
// (getEmbeddings + 两两 cosine 才是有界对象)。
|
|
173
|
-
const scopeMatched = store
|
|
174
|
-
.list({ type: "document", limit: null })
|
|
175
|
-
.filter((m) => scopeMatches(m));
|
|
176
|
-
const explicitTarget = scopeMatched.find((m) => samePath(m.doc_path, expanded))
|
|
177
|
-
?? scopeMatched.find((m) => m.title.trim() === title);
|
|
178
|
-
// 升格吸收过的引用认回(#275 拍板 5 的配套):evidence 行在文档注册成功后就翻了
|
|
179
|
-
// archived,于是「同一份文档出新版、evidence 照旧」这一最常规的路径会整批落在
|
|
180
|
-
// dropped——那会被下面的捏造判据误报成「证据都是编的」。口径:已归档行仍可为
|
|
181
|
-
// **吸收它的那份文档**背书(loser.evidence 就是它吸收走的名单),不为别的文档
|
|
182
|
-
// 背书;捏造与跨 scope 照旧拒绝。
|
|
183
|
-
if (explicitTarget) {
|
|
184
|
-
const own = new Set(explicitTarget.evidence ?? []);
|
|
185
|
-
for (const id of archivedRefs) {
|
|
186
|
-
if (!own.has(id)) continue;
|
|
187
|
-
const at = dropped.indexOf(id);
|
|
188
|
-
if (at >= 0) dropped.splice(at, 1);
|
|
189
|
-
kept.push(id);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
if (wanted.length > 0 && kept.length === 0) {
|
|
193
|
-
throw new Error(
|
|
194
|
-
`registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
if (!explicitTarget) {
|
|
198
|
-
// vector 档:embedder 不可用/向量缺失一律跳过——去重是增强不是写入依赖
|
|
199
|
-
// (findSessionDuplicate 同原则)。probe 用 title+summary,与行向量
|
|
200
|
-
// (同两段拼接 embed)同构。
|
|
201
|
-
const candidates = scopeMatched.slice(0, CANDIDATE_LIMIT);
|
|
202
|
-
try {
|
|
203
|
-
const vecs = store.getEmbeddings(candidates.map((m) => m.id));
|
|
204
|
-
if (vecs.size) {
|
|
205
|
-
const probe = await embedQuery([title, summary].filter(Boolean).join("\n"));
|
|
206
|
-
if (probe) {
|
|
207
|
-
let best = null;
|
|
208
|
-
for (const m of candidates) {
|
|
209
|
-
const v = vecs.get(m.id);
|
|
210
|
-
if (!v) continue; // 无向量的既有行不参与比对(无信号 = 不判定)
|
|
211
|
-
const sim = cosineSimilarity(probe, v);
|
|
212
|
-
if (sim >= DOCUMENT_MIN_SIM && (!best || sim > best.sim)) best = { m, sim };
|
|
213
|
-
}
|
|
214
|
-
if (best) {
|
|
215
|
-
throw new Error(
|
|
216
|
-
`registerDocument: summary is a near duplicate (C2 >= ${DOCUMENT_MIN_SIM}, sim ${best.sim.toFixed(3)}) ` +
|
|
217
|
-
`of document row ${best.m.id} "${best.m.title}" — update that row via memory_update, or archive it ` +
|
|
218
|
-
`before registering a separate document`
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
} catch (e) {
|
|
224
|
-
// 近重复拒绝是业务结果,原样上抛;embedder 故障吞掉回落(只做路径/标题层)。
|
|
225
|
-
if (e instanceof Error && e.message.startsWith("registerDocument:")) throw e;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// --- 4. 落库 + supersede 记账(同事务)----------------------------------
|
|
230
|
-
// 新摘要行:库里只存 summary + doc_path + evidence(+ scope 标注),旧文件
|
|
231
|
-
// 零触碰。supersede:旧行 content_history 存旧摘要(source=superseded)、
|
|
232
|
-
// 正文追加指向新行的注记、归档(降可及性可复活,无物理删除)。
|
|
233
|
-
const userTags = (Array.isArray(payload?.tags) ? payload.tags : [])
|
|
234
|
-
.map((t) => String(t ?? "").trim())
|
|
235
|
-
.filter(Boolean);
|
|
236
|
-
const tags = [...new Set([...userTags, ...(dropped.length ? ["evidence_degraded"] : [])])];
|
|
237
|
-
const result = transaction(() => {
|
|
238
|
-
// saveDocument 是存储层唯一 document 铸造口(通用 save/update/CAS 拒绝
|
|
239
|
-
// document——CodeRabbit 复核 #8/#882):registerDocument 传入的 doc_path
|
|
240
|
-
// 已过归一化与文件校验。
|
|
241
|
-
const created = store.saveDocument({
|
|
242
|
-
type: "document",
|
|
243
|
-
title,
|
|
244
|
-
content: summary,
|
|
245
|
-
tags,
|
|
246
|
-
importance: Number.isInteger(payload?.importance)
|
|
247
|
-
? Math.min(5, Math.max(1, payload.importance))
|
|
248
|
-
: 3,
|
|
249
|
-
source: payload?.source ?? "tool",
|
|
250
|
-
evidence: kept,
|
|
251
|
-
doc_path: expanded,
|
|
252
|
-
// sensitivity 进 supersede 匹配键(scopeKeyOf 第三维):不落库的话,
|
|
253
|
-
// 首注册带 sensitivity、重注册同键会在匹配时判成两 scope,旧版漏检。
|
|
254
|
-
...(payload?.sensitivity !== undefined ? { sensitivity: payload.sensitivity } : {}),
|
|
255
|
-
...(payload?.agent_scope !== undefined
|
|
256
|
-
? { agent_scope: payload.agent_scope, agent_scope_source: payload.agent_scope_source }
|
|
257
|
-
: {}),
|
|
258
|
-
...(payload?.workspace_scope !== undefined
|
|
259
|
-
? { workspace_scope: payload.workspace_scope, workspace_scope_source: payload.workspace_scope_source }
|
|
260
|
-
: {})
|
|
261
|
-
});
|
|
262
|
-
let superseded;
|
|
263
|
-
const loser = explicitTarget ? store.getById(explicitTarget.id) : null;
|
|
264
|
-
if (loser && !loser.archived) {
|
|
265
|
-
store.update(loser.id, {
|
|
266
|
-
content: `${loser.content}${supersededByPointer(created.id)}`,
|
|
267
|
-
content_history: pushContentHistory(loser, "superseded")
|
|
268
|
-
});
|
|
269
|
-
store.setArchived(loser.id, true);
|
|
270
|
-
superseded = store.getById(loser.id);
|
|
271
|
-
}
|
|
272
|
-
// #275 拍板 5(#230 验收口径的补丁):升格吸收的 evidence 行随之退出活跃面——
|
|
273
|
-
// 一次吸收 20 条,库里就是「21 行不是 1 行」。四条口径:①与本行同一事务(要么
|
|
274
|
-
// 都成、要么都不成);②pinned(constraint / preference,即 #249 的逐字保真池)
|
|
275
|
-
// 永不自动归档——升格不能绕过它;③自有生命周期的类型(document / summary,见
|
|
276
|
-
// ABSORB_EXEMPT_TYPES)不吸收,吸收的是原子条;④只翻标志位、内容与审计全留
|
|
277
|
-
// (可恢复),opt-out 走 archiveEvidence。
|
|
278
|
-
// pinnedTypes 拿不到就不做这一步:宁可少做,也不能把保真池当普通行收走。
|
|
279
|
-
let evidenceArchived = 0;
|
|
280
|
-
if (archiveEvidence && pinnedTypes && typeof pinnedTypes.has === "function") {
|
|
281
|
-
for (const id of kept) {
|
|
282
|
-
// 事务内重读:期间被别的进程归档/遗忘的行不重复计数,也不误伤 pinned。
|
|
283
|
-
const row = store.getById(id);
|
|
284
|
-
if (!row || row.archived || row.forgotten) continue;
|
|
285
|
-
if (pinnedTypes.has(row.type) || ABSORB_EXEMPT_TYPES.has(row.type)) continue;
|
|
286
|
-
store.setArchived(id, true);
|
|
287
|
-
evidenceArchived += 1;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return { created, superseded, evidenceArchived };
|
|
291
|
-
});
|
|
292
|
-
finalize(result.superseded ? [result.created, result.superseded] : [result.created]);
|
|
293
|
-
return {
|
|
294
|
-
action: result.superseded ? "superseded" : "created",
|
|
295
|
-
memory: result.created,
|
|
296
|
-
...(result.superseded ? { superseded: result.superseded } : {}),
|
|
297
|
-
evidence_kept: kept.length,
|
|
298
|
-
evidence_dropped: dropped.length,
|
|
299
|
-
evidence_archived: result.evidenceArchived,
|
|
300
|
-
degraded: dropped.length > 0
|
|
301
|
-
};
|
|
302
|
-
};
|
|
303
|
-
}
|
|
1
|
+
// dsh-mneme/src/document.js
|
|
2
|
+
// document 型记忆注册(issue #230,口径基线 = #164 设计稿评审线):agent 产
|
|
3
|
+
// 长文档的指针行铸造口。写入权分离——全文归 agent(管线对文件零读零写零改),
|
|
4
|
+
// 管线只做四件事:注册校验(文件存在 + 路径合法 + evidence 求交)、摘要 +
|
|
5
|
+
// doc_path 落库(库里只存这三样)、C2 vector 档比对去重、supersede 记账
|
|
6
|
+
// (出新版 = 新摘要行 supersede 旧行,旧文件不删,content_history 可追溯)。
|
|
7
|
+
//
|
|
8
|
+
// 独立成模块(AGENTS.md 尺寸约定,同 recallStats 先例):service.js 已过
|
|
9
|
+
// 2000 行参考线,这里只依赖注入 service 内部件,barrel 出口在 service.js,
|
|
10
|
+
// 调用方零改动。防绕过三层兜底:service 两条业务守卫(saveWithDedupe /
|
|
11
|
+
// updateMemory)+ 存储层唯一铸造口(store.saveDocument,通用 save/update/
|
|
12
|
+
// CAS 整类拒绝 document)——注册是唯一铸造口。
|
|
13
|
+
|
|
14
|
+
import { statSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
17
|
+
import { scopeKeyOf } from "./scope.js";
|
|
18
|
+
import { cosineSimilarity } from "./dream/clustering.js";
|
|
19
|
+
|
|
20
|
+
// C2 vector 档阈值(#164 拍板 0.92,与 findSessionDuplicate 的 vector 档同源)。
|
|
21
|
+
// 刻意不做 24h 时间窗:那个窗是会话去重的「近邻界」(findSessionDuplicate 注释),
|
|
22
|
+
// 不是相似度语义本身;document 版本更新天然跨天/跨周,套窗会把一周后重注册的
|
|
23
|
+
// 新版判成「无近重复」,留下两行活跃摘要漂移——supersede 探测必须全时段。
|
|
24
|
+
const DOCUMENT_MIN_SIM = 0.92;
|
|
25
|
+
const CANDIDATE_LIMIT = 200;
|
|
26
|
+
const DOCUMENT_DIR_NAME = "documents";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 解析 managed 文档目录(#296 第二批)。`configured` 为空 = 跟随 memoryDir 的
|
|
30
|
+
* `<memoryDir>/documents/`;`~` / `~/` / `~\` 展开到用户 home;相对路径落在
|
|
31
|
+
* memoryDir 下(本键自己的规则:memoryDir 的相对路径原样留着走 cwd 语义);
|
|
32
|
+
* 绝对路径归一化后原样用。纯函数,路径解析集中在 index.js 调用一次。
|
|
33
|
+
*/
|
|
34
|
+
export function resolveDocumentDir(memoryDir, configured = "") {
|
|
35
|
+
const raw = String(configured ?? "").trim();
|
|
36
|
+
if (!raw) return resolve(memoryDir, DOCUMENT_DIR_NAME);
|
|
37
|
+
if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
38
|
+
return resolve(homedir(), raw.slice(2));
|
|
39
|
+
}
|
|
40
|
+
return isAbsolute(raw) ? resolve(raw) : resolve(memoryDir, raw);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 这个路径是不是落在 managed 目录里(`documentDir` 内 = mneme 管的树,`index.md`
|
|
45
|
+
* 与镜像 md 同权;之外 = 只登记指针行、正文一个字节都不碰)。比较前两边都归一化,
|
|
46
|
+
* Windows 上大小写不敏感(文件系统就是),否则 `C:\a\documents\x.md` 与
|
|
47
|
+
* `c:\A\Documents\x.md` 会被判成两类。
|
|
48
|
+
*/
|
|
49
|
+
export function isManagedDocumentPath(dir, path) {
|
|
50
|
+
if (!dir || !path) return false;
|
|
51
|
+
const norm = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p));
|
|
52
|
+
const base = norm(resolve(dir));
|
|
53
|
+
const target = norm(resolve(path));
|
|
54
|
+
const prefix = base.endsWith(sep) ? base : base + sep;
|
|
55
|
+
return target === base || target.startsWith(prefix);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 追加到被取代行正文的指针注记。刻意不带语言分支:id 是跨语言稳定键(用户
|
|
59
|
+
// 对 tag/章节名建议用英文同理——指针性内容不翻译);可追溯链 = 注记里的新行
|
|
60
|
+
// id + 旧行 content_history 里的旧摘要(source=superseded)。
|
|
61
|
+
const supersededByPointer = (id) => `\n\n[superseded by ${id}]`;
|
|
62
|
+
|
|
63
|
+
// 升格吸收不碰的类型(#275 拍板 5 的边界):吸收的对象是「原子条」,而下面这两种
|
|
64
|
+
// 行各有自己的生命周期,被归档会各自留下第二行——
|
|
65
|
+
// document:本体是磁盘上的文件。归档它等于替用户撤下外档;而且 supersede 探测
|
|
66
|
+
// 只看活跃行(store.list 默认排除归档),同一 path 下次注册会在旧行还挂着
|
|
67
|
+
// 「新版本」语义时再铸一行,指针注记与 content_history 记账也走不到。
|
|
68
|
+
// summary:dream 总览与叙述按 source 身份去重、并按注入档位常驻。归档它会让下一
|
|
69
|
+
// 次做梦把它当不存在(候选集同样只取活跃行),于是库里多出一行同源总览。
|
|
70
|
+
// 口径是保守方向的:拿不准的行一律不吸收——少做一步没有代价,收错一步是内容事故。
|
|
71
|
+
const ABSORB_EXEMPT_TYPES = new Set(["document", "summary"]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the document registrar. Injected deps are service.js closure members
|
|
75
|
+
* (store / embedQuery / transaction / finalize) so this module stays free of
|
|
76
|
+
* service-internal wiring; `finalize` is the single write epilogue (mirror
|
|
77
|
+
* sync + notify + re-embed) so callers never duplicate it.
|
|
78
|
+
*
|
|
79
|
+
* `pinnedTypes` 是注入的 #249 逐字保真池(service.js 的 PINNED_MEMORY_TYPES):
|
|
80
|
+
* 这里不 import 它在 service.js 里,是因为 service.js 反向 import 本模块——
|
|
81
|
+
* 方向反了会成环。
|
|
82
|
+
*
|
|
83
|
+
* @returns {(payload: object, opts?: object) => Promise<object>}
|
|
84
|
+
* `{ action: "created"|"superseded", memory, superseded?, evidence_kept,
|
|
85
|
+
* evidence_dropped, evidence_archived, degraded }`
|
|
86
|
+
* @throws flag 关 / 路径非法或文件缺失 / title 或 summary 为空 / evidence 全部
|
|
87
|
+
* 捏造 / 仅 vector 近重复(疑似重复外档,交 agent 裁决而不是静默二选一)。
|
|
88
|
+
*/
|
|
89
|
+
export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize, pinnedTypes }) {
|
|
90
|
+
return async function registerDocument(payload, { hiddenEvidenceIds = [], archiveEvidence = true } = {}) {
|
|
91
|
+
// opt-in 总闸(#230 对齐 #228 形态):关 = 注册入口整体不存在,错误信息
|
|
92
|
+
// 指回配置键,agent 能准确转告用户去开。
|
|
93
|
+
if (config?.documentMemoryEnabled !== true) {
|
|
94
|
+
throw new Error("document memory is disabled (documentMemoryEnabled)");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- 1. 路径合法 + 文件存在 --------------------------------------------
|
|
98
|
+
// 只认展开 ~ 后的绝对路径:相对路径依赖进程 cwd,注册时的 cwd 与读取时
|
|
99
|
+
// 的 cwd 不保证一致,指针必须自带完整定位。校验存在 + 常规文件 + 非空——
|
|
100
|
+
// 空文件/被截断成 0 字节是「文字结构意外破坏」的最早信号,注册时拦住
|
|
101
|
+
// 而不是落一条死指针(回退路径:agent 修好文件后重注册,同 path/title
|
|
102
|
+
// 自动 supersede 旧行)。
|
|
103
|
+
const rawPath = String(payload?.path ?? "").trim();
|
|
104
|
+
if (!rawPath) throw new Error("registerDocument: path is required");
|
|
105
|
+
// 只展开 ~ / ~/(~user 形式拒收为普通相对路径走 isAbsolute 拦截——不猜测
|
|
106
|
+
// 其他用户的 home,错展开比报错更糟)。
|
|
107
|
+
const isTilde = rawPath === "~" || rawPath.startsWith("~/") || rawPath.startsWith("~\\");
|
|
108
|
+
const absolute = isTilde ? resolve(homedir(), rawPath.slice(2)) : rawPath;
|
|
109
|
+
if (!isAbsolute(absolute)) {
|
|
110
|
+
throw new Error(`registerDocument: path must be absolute (got "${rawPath}")`);
|
|
111
|
+
}
|
|
112
|
+
// 所有绝对路径统一 resolve 归一化(. / .. / 重复分隔符)——归一化不做的话,
|
|
113
|
+
// D:\a\.\b.md 与 D:\a\b.md 会被判成两个路径,同文件绕过 same-path
|
|
114
|
+
// supersede,留下双活跃指针。realpathSync 刻意不用:符号链接合并会改写
|
|
115
|
+
// agent 提交的路径字面量,指针的可读性比追符号链更重要。
|
|
116
|
+
const expanded = resolve(absolute);
|
|
117
|
+
let stat = null;
|
|
118
|
+
try { stat = statSync(expanded); } catch { /* missing/unreadable → null */ }
|
|
119
|
+
if (!stat?.isFile() || stat.size <= 0) {
|
|
120
|
+
throw new Error(`registerDocument: document file not found or empty: ${expanded}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- 2. evidence 求交(sleep.js 扫描集求交同款语义)---------------------
|
|
124
|
+
// 引用的原子条必须真实存在且未归档(归档行已被降级,不再为新鲜 document
|
|
125
|
+
// 背书)。红线 4 宽容形态:合法子集落库 + evidence_degraded 系统标记;
|
|
126
|
+
// 提供了 evidence 但全军覆没 = 捏造信号,整单拒绝,agent 修正后重试。
|
|
127
|
+
const wanted = [...new Set(
|
|
128
|
+
(Array.isArray(payload?.evidence) ? payload.evidence : [])
|
|
129
|
+
.map((id) => String(id ?? "").trim())
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
)];
|
|
132
|
+
// strictScope(tools 层传入,#170 复核项 4 同款):调用者 scope 看不见的
|
|
133
|
+
// 行按「不存在」处理——不得为跨 scope id 建立引用(存在性泄漏),与
|
|
134
|
+
// unknown/archived 同落 dropped,evidence_kept/dropped 计数保持诚实。
|
|
135
|
+
const hidden = new Set((Array.isArray(hiddenEvidenceIds) ? hiddenEvidenceIds : []).map((id) => String(id)));
|
|
136
|
+
const kept = [];
|
|
137
|
+
const dropped = [];
|
|
138
|
+
// 存在但已归档的引用另记一份:它可能是被上一版文档升格吸收走的行(见下方
|
|
139
|
+
// supersede 目标探测后的「认回」),不能一律按「不可用」处理。
|
|
140
|
+
const archivedRefs = new Set();
|
|
141
|
+
for (const id of wanted) {
|
|
142
|
+
const row = hidden.has(id) ? null : store.getById(id);
|
|
143
|
+
if (row?.archived) archivedRefs.add(id);
|
|
144
|
+
(row && !row.archived ? kept : dropped).push(id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const title = String(payload?.title ?? "").trim();
|
|
148
|
+
if (!title) throw new Error("registerDocument: title is required");
|
|
149
|
+
const summary = String(payload?.summary ?? "").trim();
|
|
150
|
+
if (!summary) throw new Error("registerDocument: summary is required");
|
|
151
|
+
|
|
152
|
+
// --- 3. supersede 目标探测(三层)---------------------------------------
|
|
153
|
+
// 同 doc_path / 同标题 = agent 显式的「出新版」意图 → supersede;仅 vector
|
|
154
|
+
// 近重复而路径标题都不同 = 疑似重复外档 → 拒绝并指路。C2×C1 硬线:比对
|
|
155
|
+
// 不得越过矛盾裁决替 agent 二选一——改既有行还是归档后重注册,判断权留在
|
|
156
|
+
// agent 手里。去重键带 scope(scopeKeyOf):跨作用域永不互判(防泄漏,与
|
|
157
|
+
// saveWithDedupe 的 scopeMatches 同口径)。
|
|
158
|
+
const scopeMatches = (m) =>
|
|
159
|
+
scopeKeyOf(m.agent_scope) === scopeKeyOf(payload?.agent_scope) &&
|
|
160
|
+
scopeKeyOf(m.workspace_scope) === scopeKeyOf(payload?.workspace_scope) &&
|
|
161
|
+
scopeKeyOf(m.sensitivity) === scopeKeyOf(payload?.sensitivity);
|
|
162
|
+
const samePath = (a, b) => {
|
|
163
|
+
if (!a || !b) return false;
|
|
164
|
+
// Windows 文件系统大小写不敏感:比较前归一,否则 C:\a.md 与 c:\A.md
|
|
165
|
+
// 会被判成两个版本。
|
|
166
|
+
const norm = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p));
|
|
167
|
+
return norm(a) === norm(b);
|
|
168
|
+
};
|
|
169
|
+
// 精确层(同路径/同标题)必须全量扫描、不允许窗口截断:>200 行活跃
|
|
170
|
+
// document 时,窗口外的同路径旧版漏检会留下双活跃版本——supersede 判定
|
|
171
|
+
// 是正确性要求,不是性能优化对象。vector 档比对保留 CANDIDATE_LIMIT
|
|
172
|
+
// (getEmbeddings + 两两 cosine 才是有界对象)。
|
|
173
|
+
const scopeMatched = store
|
|
174
|
+
.list({ type: "document", limit: null })
|
|
175
|
+
.filter((m) => scopeMatches(m));
|
|
176
|
+
const explicitTarget = scopeMatched.find((m) => samePath(m.doc_path, expanded))
|
|
177
|
+
?? scopeMatched.find((m) => m.title.trim() === title);
|
|
178
|
+
// 升格吸收过的引用认回(#275 拍板 5 的配套):evidence 行在文档注册成功后就翻了
|
|
179
|
+
// archived,于是「同一份文档出新版、evidence 照旧」这一最常规的路径会整批落在
|
|
180
|
+
// dropped——那会被下面的捏造判据误报成「证据都是编的」。口径:已归档行仍可为
|
|
181
|
+
// **吸收它的那份文档**背书(loser.evidence 就是它吸收走的名单),不为别的文档
|
|
182
|
+
// 背书;捏造与跨 scope 照旧拒绝。
|
|
183
|
+
if (explicitTarget) {
|
|
184
|
+
const own = new Set(explicitTarget.evidence ?? []);
|
|
185
|
+
for (const id of archivedRefs) {
|
|
186
|
+
if (!own.has(id)) continue;
|
|
187
|
+
const at = dropped.indexOf(id);
|
|
188
|
+
if (at >= 0) dropped.splice(at, 1);
|
|
189
|
+
kept.push(id);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (wanted.length > 0 && kept.length === 0) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (!explicitTarget) {
|
|
198
|
+
// vector 档:embedder 不可用/向量缺失一律跳过——去重是增强不是写入依赖
|
|
199
|
+
// (findSessionDuplicate 同原则)。probe 用 title+summary,与行向量
|
|
200
|
+
// (同两段拼接 embed)同构。
|
|
201
|
+
const candidates = scopeMatched.slice(0, CANDIDATE_LIMIT);
|
|
202
|
+
try {
|
|
203
|
+
const vecs = store.getEmbeddings(candidates.map((m) => m.id));
|
|
204
|
+
if (vecs.size) {
|
|
205
|
+
const probe = await embedQuery([title, summary].filter(Boolean).join("\n"));
|
|
206
|
+
if (probe) {
|
|
207
|
+
let best = null;
|
|
208
|
+
for (const m of candidates) {
|
|
209
|
+
const v = vecs.get(m.id);
|
|
210
|
+
if (!v) continue; // 无向量的既有行不参与比对(无信号 = 不判定)
|
|
211
|
+
const sim = cosineSimilarity(probe, v);
|
|
212
|
+
if (sim >= DOCUMENT_MIN_SIM && (!best || sim > best.sim)) best = { m, sim };
|
|
213
|
+
}
|
|
214
|
+
if (best) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`registerDocument: summary is a near duplicate (C2 >= ${DOCUMENT_MIN_SIM}, sim ${best.sim.toFixed(3)}) ` +
|
|
217
|
+
`of document row ${best.m.id} "${best.m.title}" — update that row via memory_update, or archive it ` +
|
|
218
|
+
`before registering a separate document`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (e) {
|
|
224
|
+
// 近重复拒绝是业务结果,原样上抛;embedder 故障吞掉回落(只做路径/标题层)。
|
|
225
|
+
if (e instanceof Error && e.message.startsWith("registerDocument:")) throw e;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- 4. 落库 + supersede 记账(同事务)----------------------------------
|
|
230
|
+
// 新摘要行:库里只存 summary + doc_path + evidence(+ scope 标注),旧文件
|
|
231
|
+
// 零触碰。supersede:旧行 content_history 存旧摘要(source=superseded)、
|
|
232
|
+
// 正文追加指向新行的注记、归档(降可及性可复活,无物理删除)。
|
|
233
|
+
const userTags = (Array.isArray(payload?.tags) ? payload.tags : [])
|
|
234
|
+
.map((t) => String(t ?? "").trim())
|
|
235
|
+
.filter(Boolean);
|
|
236
|
+
const tags = [...new Set([...userTags, ...(dropped.length ? ["evidence_degraded"] : [])])];
|
|
237
|
+
const result = transaction(() => {
|
|
238
|
+
// saveDocument 是存储层唯一 document 铸造口(通用 save/update/CAS 拒绝
|
|
239
|
+
// document——CodeRabbit 复核 #8/#882):registerDocument 传入的 doc_path
|
|
240
|
+
// 已过归一化与文件校验。
|
|
241
|
+
const created = store.saveDocument({
|
|
242
|
+
type: "document",
|
|
243
|
+
title,
|
|
244
|
+
content: summary,
|
|
245
|
+
tags,
|
|
246
|
+
importance: Number.isInteger(payload?.importance)
|
|
247
|
+
? Math.min(5, Math.max(1, payload.importance))
|
|
248
|
+
: 3,
|
|
249
|
+
source: payload?.source ?? "tool",
|
|
250
|
+
evidence: kept,
|
|
251
|
+
doc_path: expanded,
|
|
252
|
+
// sensitivity 进 supersede 匹配键(scopeKeyOf 第三维):不落库的话,
|
|
253
|
+
// 首注册带 sensitivity、重注册同键会在匹配时判成两 scope,旧版漏检。
|
|
254
|
+
...(payload?.sensitivity !== undefined ? { sensitivity: payload.sensitivity } : {}),
|
|
255
|
+
...(payload?.agent_scope !== undefined
|
|
256
|
+
? { agent_scope: payload.agent_scope, agent_scope_source: payload.agent_scope_source }
|
|
257
|
+
: {}),
|
|
258
|
+
...(payload?.workspace_scope !== undefined
|
|
259
|
+
? { workspace_scope: payload.workspace_scope, workspace_scope_source: payload.workspace_scope_source }
|
|
260
|
+
: {})
|
|
261
|
+
});
|
|
262
|
+
let superseded;
|
|
263
|
+
const loser = explicitTarget ? store.getById(explicitTarget.id) : null;
|
|
264
|
+
if (loser && !loser.archived) {
|
|
265
|
+
store.update(loser.id, {
|
|
266
|
+
content: `${loser.content}${supersededByPointer(created.id)}`,
|
|
267
|
+
content_history: pushContentHistory(loser, "superseded")
|
|
268
|
+
});
|
|
269
|
+
store.setArchived(loser.id, true);
|
|
270
|
+
superseded = store.getById(loser.id);
|
|
271
|
+
}
|
|
272
|
+
// #275 拍板 5(#230 验收口径的补丁):升格吸收的 evidence 行随之退出活跃面——
|
|
273
|
+
// 一次吸收 20 条,库里就是「21 行不是 1 行」。四条口径:①与本行同一事务(要么
|
|
274
|
+
// 都成、要么都不成);②pinned(constraint / preference,即 #249 的逐字保真池)
|
|
275
|
+
// 永不自动归档——升格不能绕过它;③自有生命周期的类型(document / summary,见
|
|
276
|
+
// ABSORB_EXEMPT_TYPES)不吸收,吸收的是原子条;④只翻标志位、内容与审计全留
|
|
277
|
+
// (可恢复),opt-out 走 archiveEvidence。
|
|
278
|
+
// pinnedTypes 拿不到就不做这一步:宁可少做,也不能把保真池当普通行收走。
|
|
279
|
+
let evidenceArchived = 0;
|
|
280
|
+
if (archiveEvidence && pinnedTypes && typeof pinnedTypes.has === "function") {
|
|
281
|
+
for (const id of kept) {
|
|
282
|
+
// 事务内重读:期间被别的进程归档/遗忘的行不重复计数,也不误伤 pinned。
|
|
283
|
+
const row = store.getById(id);
|
|
284
|
+
if (!row || row.archived || row.forgotten) continue;
|
|
285
|
+
if (pinnedTypes.has(row.type) || ABSORB_EXEMPT_TYPES.has(row.type)) continue;
|
|
286
|
+
store.setArchived(id, true);
|
|
287
|
+
evidenceArchived += 1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { created, superseded, evidenceArchived };
|
|
291
|
+
});
|
|
292
|
+
finalize(result.superseded ? [result.created, result.superseded] : [result.created]);
|
|
293
|
+
return {
|
|
294
|
+
action: result.superseded ? "superseded" : "created",
|
|
295
|
+
memory: result.created,
|
|
296
|
+
...(result.superseded ? { superseded: result.superseded } : {}),
|
|
297
|
+
evidence_kept: kept.length,
|
|
298
|
+
evidence_dropped: dropped.length,
|
|
299
|
+
evidence_archived: result.evidenceArchived,
|
|
300
|
+
degraded: dropped.length > 0
|
|
301
|
+
};
|
|
302
|
+
};
|
|
303
|
+
}
|