dsh-prime-memory 0.22.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.md +401 -0
- package/CHANGELOG.ja.md +401 -0
- package/CHANGELOG.ko.md +401 -0
- package/CHANGELOG.md +100 -0
- package/dist/client.js +1951 -1188
- package/dist/config.d.ts +176 -1240
- package/dist/config.js +113 -2
- package/dist/conflict-service.d.ts +12 -0
- package/dist/conflict-service.js +24 -0
- package/dist/contract.d.ts +327 -0
- package/dist/domain-gate.d.ts +19 -0
- package/dist/domain-gate.js +45 -0
- package/dist/graph/constraints.d.ts +4 -2
- package/dist/graph/constraints.js +4 -2
- package/dist/hooks/obsolete-notice.d.ts +26 -0
- package/dist/hooks/obsolete-notice.js +64 -0
- package/dist/hooks/recall.d.ts +41 -0
- package/dist/hooks/recall.js +127 -28
- package/dist/hooks/skill-recall.d.ts +25 -0
- package/dist/hooks/skill-recall.js +72 -0
- package/dist/index.d.ts +1 -1029
- package/dist/index.js +55 -2
- package/dist/memory-types.d.ts +69 -0
- package/dist/memory-types.js +165 -0
- package/dist/memport/format.d.ts +75 -0
- package/dist/memport/format.js +345 -0
- package/dist/memport/prefilter.d.ts +62 -0
- package/dist/memport/prefilter.js +132 -0
- package/dist/memport/scan.d.ts +13 -0
- package/dist/memport/scan.js +100 -0
- package/dist/metadata-validators.d.ts +22 -0
- package/dist/metadata-validators.js +45 -0
- package/dist/pipeline/l1.d.ts +44 -2
- package/dist/pipeline/l1.js +177 -9
- package/dist/pipeline/md-sync.d.ts +65 -0
- package/dist/pipeline/md-sync.js +456 -0
- package/dist/pipeline/ruminate.d.ts +46 -1
- package/dist/pipeline/ruminate.js +150 -1
- package/dist/pipeline/runner.d.ts +10 -0
- package/dist/pipeline/runner.js +7 -1
- package/dist/pipeline/slot-migrate.d.ts +45 -0
- package/dist/pipeline/slot-migrate.js +45 -0
- package/dist/pipeline/workspace-relink.d.ts +60 -0
- package/dist/pipeline/workspace-relink.js +73 -0
- package/dist/projection/recall.d.ts +112 -0
- package/dist/projection/recall.js +183 -0
- package/dist/projection/slots.d.ts +5 -0
- package/dist/prompts/examples.d.ts +73 -0
- package/dist/prompts/examples.js +178 -0
- package/dist/prompts/graph-projection.js +3 -0
- package/dist/prompts/l1-dedup.d.ts +9 -3
- package/dist/prompts/l1-dedup.js +34 -13
- package/dist/prompts/l1-extraction.d.ts +9 -4
- package/dist/prompts/l1-extraction.js +76 -8
- package/dist/settings.d.ts +8 -0
- package/dist/settings.js +15 -1
- package/dist/skills/crystallize.d.ts +41 -0
- package/dist/skills/crystallize.js +75 -0
- package/dist/stats.d.ts +30 -1
- package/dist/stats.js +504 -13
- package/dist/store/embedding-source.d.ts +8 -0
- package/dist/store/embedding-source.js +39 -2
- package/dist/store/file-versions.d.ts +4 -0
- package/dist/store/file-versions.js +4 -0
- package/dist/store/graph-store.d.ts +14 -0
- package/dist/store/graph-store.js +35 -0
- package/dist/store/l1-snapshot.d.ts +26 -1
- package/dist/store/l1-snapshot.js +53 -8
- package/dist/store/l1.d.ts +77 -3
- package/dist/store/l1.js +128 -3
- package/dist/store/memory-md-archive.d.ts +84 -0
- package/dist/store/memory-md-archive.js +155 -0
- package/dist/store/memory-md-disable.d.ts +23 -0
- package/dist/store/memory-md-disable.js +106 -0
- package/dist/store/memory-md-export.d.ts +50 -0
- package/dist/store/memory-md-export.js +200 -0
- package/dist/store/memory-md-ledger.d.ts +94 -0
- package/dist/store/memory-md-ledger.js +243 -0
- package/dist/store/memory-md-orphan.d.ts +27 -0
- package/dist/store/memory-md-orphan.js +153 -0
- package/dist/store/memory-md-parse.d.ts +35 -0
- package/dist/store/memory-md-parse.js +85 -0
- package/dist/store/memory-md-skeleton.d.ts +18 -0
- package/dist/store/memory-md-skeleton.js +61 -0
- package/dist/store/memory-md-types.d.ts +174 -0
- package/dist/store/memory-md-types.js +10 -0
- package/dist/store/memory-md.d.ts +108 -0
- package/dist/store/memory-md.js +219 -0
- package/dist/store/memory-sources.d.ts +59 -0
- package/dist/store/memory-sources.js +214 -0
- package/dist/store/obsolete-notices.d.ts +39 -0
- package/dist/store/obsolete-notices.js +153 -0
- package/dist/store/recall-feedback.d.ts +75 -0
- package/dist/store/recall-feedback.js +213 -0
- package/dist/store/recall-usage.d.ts +11 -0
- package/dist/store/recall-usage.js +29 -0
- package/dist/store/session-recalled.d.ts +60 -0
- package/dist/store/session-recalled.js +310 -0
- package/dist/store/skill-store.d.ts +99 -0
- package/dist/store/skill-store.js +330 -0
- package/dist/store/sqlite.d.ts +71 -0
- package/dist/store/sqlite.js +259 -15
- package/dist/store/supersede.d.ts +1 -1
- package/dist/store/supersede.js +1 -1
- package/dist/tools/index.d.ts +4 -13
- package/dist/tools/index.js +80 -12
- package/dist/tools/skills.d.ts +13 -0
- package/dist/tools/skills.js +130 -0
- package/dist/types.d.ts +30 -7
- package/dist/types.js +10 -2
- package/dist/util/maturity.d.ts +44 -0
- package/dist/util/maturity.js +43 -0
- package/dist/util/md-name.d.ts +129 -0
- package/dist/util/md-name.js +366 -0
- package/dist/util/md-normalize.d.ts +23 -0
- package/dist/util/md-normalize.js +131 -0
- package/dist/wing-backfill.js +7 -4
- package/dist/workspace-route.d.ts +22 -0
- package/dist/workspace-route.js +66 -0
- package/dsh.plugin.json +1 -1
- package/package.json +6 -5
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2 反向同步:md 投射文件 → 索引(文件是人编辑的那一侧)。
|
|
3
|
+
*
|
|
4
|
+
* 与导出侧(`memory-md-export.ts`)共用同一份落点计算(`targetFor`)、同一个库侧指纹
|
|
5
|
+
* 算法(`recordDigest`)和同一本账本,所以两侧看到的世界一致;写文件这条路**直接复用**
|
|
6
|
+
* `exportRecords`(传单条记录),不另写一套落盘逻辑——两套写盘迟早漂移。
|
|
7
|
+
*
|
|
8
|
+
* 判定一律走**三方**:账本里存着上次投射时的库侧指纹与文件侧指纹,"谁变了"才是可判定的。
|
|
9
|
+
* 四象限(spec P2 裁决表):
|
|
10
|
+
* - 只有文件变 → 回灌入库,然后把文件重写成规范渲染(正文仍是人写的那段,frontmatter 归一)。
|
|
11
|
+
* 这一步同时消掉写回环:库和文件都回到账本记着的那个状态,下一轮判 `same`。
|
|
12
|
+
* - 只有库变 → 重写文件。
|
|
13
|
+
* - **两边都变 → 什么都不做**,只在报告里挂账。人不裁决机器不许动;两侧保持原样、账本也不动,
|
|
14
|
+
* 所以下次对账还会报同样的 `both`(挂账是持久的,不会悄悄过期)。
|
|
15
|
+
* - 都没变 → 跳过,不刷 mtime。
|
|
16
|
+
*
|
|
17
|
+
* 另有一条独立通道:`.memory.md.disable` 后缀 = 人在文件侧手工判过时 → 退场对应记录
|
|
18
|
+
* (写 valid_to,不进任何检索通路)。这是 `recall-feedback -1` 的文件侧对偶。
|
|
19
|
+
*
|
|
20
|
+
* 两条边界:
|
|
21
|
+
* - 没有账本、也认不出记录的文件 = 人新建的,**不入库**。导入一条新记忆必须过写入内核
|
|
22
|
+
* 的判据面(归一/长度/预算/去重),在这里手搓一条等于绕过那些不变量。只计数。
|
|
23
|
+
* - 遍历范围 = 从记录算出的落点根 ∪ 账本里记过的根。所以"没有任何记录指向的全新 workspace"
|
|
24
|
+
* 不在本轮范围内——它还没有投射根,等它的第一条记忆落进去才可见。
|
|
25
|
+
*
|
|
26
|
+
* 另有一条遍历防护(P2 缺口 task_3.7):投射根下的"链接"在 win32 上不止 symlink 一种形状,
|
|
27
|
+
* junction(目录联接,reparse point)也算,而 `Dirent.isSymbolicLink()` 对它的判定随 Node 版本
|
|
28
|
+
* 与文件系统漂移。所以进任何子目录前再加一道 `realpath` 判据:解析出的真实路径必须仍在该投射根
|
|
29
|
+
* 内,界外的一律不进并计入 `skippedLinks`。⚠️ 这个 realpath **只服务划界**——不参与账本键,也不
|
|
30
|
+
* 参与落点计算(那边刻意不做 realpath,理由见 `store/memory-md.ts::targetFor` 的注释)。两处不一致
|
|
31
|
+
* 是有意的,不是 bug。
|
|
32
|
+
*/
|
|
33
|
+
import { opendir, readFile, realpath, stat } from 'node:fs/promises';
|
|
34
|
+
import { join, resolve, sep } from 'node:path';
|
|
35
|
+
import { DISABLE_SUFFIX, digest, recordDigest, targetFor } from '../store/memory-md.js';
|
|
36
|
+
import { exportRecords } from '../store/memory-md-export.js';
|
|
37
|
+
import { parseMdFile } from '../store/memory-md-parse.js';
|
|
38
|
+
import { normalizeContentMd } from '../util/md-normalize.js';
|
|
39
|
+
/** 投射结构是 `<type>/<stem>.memory.md` 两层,3 层已足够宽容人的整理习惯,再深就是别人的目录。 */
|
|
40
|
+
const MAX_DEPTH = 3;
|
|
41
|
+
/** 样本上限:报告要进面板,全量路径会把面板撑爆;计数才是判据。 */
|
|
42
|
+
const SAMPLE_CAP = 12;
|
|
43
|
+
/**
|
|
44
|
+
* 越界判据:`resolved` 必须就是根,或者是根的子目录。
|
|
45
|
+
*
|
|
46
|
+
* 前缀比较带 `sep`:少了那个分隔符,兄弟目录 `<root>2` 会被判成界内(经典越界陷阱)。
|
|
47
|
+
* 两侧都出自 `realpath`,同一卷上大小写与 8.3 短名都已归一,所以这里做**严格字符串**比较;
|
|
48
|
+
* win32 上再折叠大小写只会放松判据(把真越界说成合法),不做。
|
|
49
|
+
* 导出只为把判据本身钉成用例:造不出链接的机器上,判据仍要有测试可跑。
|
|
50
|
+
*/
|
|
51
|
+
export function isWithinRoot(rootReal, resolved) {
|
|
52
|
+
return resolved === rootReal || resolved.startsWith(rootReal + sep);
|
|
53
|
+
}
|
|
54
|
+
/** 记一笔拦截:计数、样本、日志三样都要有——只给数字,人分不清是"外面真有东西"还是"边界划错了"。 */
|
|
55
|
+
function noteLink(guard, path, reason) {
|
|
56
|
+
guard.links += 1;
|
|
57
|
+
if (guard.linkSamples.length < SAMPLE_CAP)
|
|
58
|
+
guard.linkSamples.push({ path, action: `link: ${reason}` });
|
|
59
|
+
guard.logger?.warn(`[memory] md 同步遍历防护拦下: ${path} - ${reason}`);
|
|
60
|
+
}
|
|
61
|
+
/** errno 取码(Node 把系统错误码挂在 `code` 上)。 */
|
|
62
|
+
function errCode(err) {
|
|
63
|
+
return err?.code;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 文件基名(Windows 与 POSIX 分隔符都吃)。
|
|
67
|
+
*
|
|
68
|
+
* 预扫分组与主循环都靠它把路径映到记录(`pickRecord` 按 `name` 认人),
|
|
69
|
+
* 两处各写一份切法的话,一边改了另一边没改就会让同一文件在两处映射到不同记录。
|
|
70
|
+
*/
|
|
71
|
+
function baseName(abs) {
|
|
72
|
+
return abs.slice(Math.max(abs.lastIndexOf('/'), abs.lastIndexOf('\\')) + 1);
|
|
73
|
+
}
|
|
74
|
+
/** 列出一个根下的投射文件(含 `.disable`)。根不存在/没权限 → 空数组,不抛。 */
|
|
75
|
+
async function listProjectionFiles(root, guard) {
|
|
76
|
+
const out = [];
|
|
77
|
+
// 边界基线 = 投射根自己的真实路径,一轮只解析一次。
|
|
78
|
+
// ⚠️ 与 `targetFor` 的刻意不对称:落点/账本键那边**不**做 realpath(目录创建前后结果会变 →
|
|
79
|
+
// 键漂移 → 对账永远报"文件缺失",见 memory-md.ts::targetFor 注释);这里的 realpath 只用来
|
|
80
|
+
// 判断"还在不在根内",往下记进 `out` 和账本的路径仍然是拼出来的 `abs`。别把它当成同一件事。
|
|
81
|
+
// '' = 根本来不存在(走下面 opendir 同语义的正常缺席);undefined = 解析失败但根并非缺席。
|
|
82
|
+
const resolvedRoot = await realpath(root).catch((err) => (errCode(err) === 'ENOENT' ? '' : undefined));
|
|
83
|
+
if (resolvedRoot === undefined) {
|
|
84
|
+
// 划不出边界 = 无法判断界内界外 = 整根不进(安全方向),并计数留痕——静默少扫一整个根等于谎报。
|
|
85
|
+
noteLink(guard, root, '根的真实路径解析失败,无法划界,整根未扫');
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
if (resolvedRoot.length === 0)
|
|
89
|
+
return out;
|
|
90
|
+
const rootReal = resolvedRoot; // 显式落成 string,闭包里取值不靠推断运气
|
|
91
|
+
const walk = async (dir, depth) => {
|
|
92
|
+
// 深度护栏是**结构边界**(投射只有 `<type>/<stem>.memory.md` 两层),不是资源截断,
|
|
93
|
+
// 所以它不动 `capped`/`remaining`——那两个只归 maxFiles 与墙钟算账,两条护栏各记各的。
|
|
94
|
+
if (depth > MAX_DEPTH)
|
|
95
|
+
return;
|
|
96
|
+
let dh;
|
|
97
|
+
try {
|
|
98
|
+
dh = await opendir(dir);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return; // 根不存在或不可读:范围本来就按已知根枚举,缺一个根不是错误
|
|
102
|
+
}
|
|
103
|
+
for await (const de of dh) {
|
|
104
|
+
const abs = join(dir, de.name);
|
|
105
|
+
// 第一道(零额外 syscall):dirent 报链接就不进。
|
|
106
|
+
if (de.isSymbolicLink()) {
|
|
107
|
+
noteLink(guard, abs, 'dirent 报链接,未进入');
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (de.isDirectory()) {
|
|
111
|
+
// 第二道:win32 的链接形状不止 symlink,junction/目录联接是 reparse point,而它的 dirent
|
|
112
|
+
// 判定随 Node 版本与文件系统漂移(本机 Node 22.22.1 实测报 true,但不能把边界押在一个
|
|
113
|
+
// 会漂移的布尔上)。一次漏掉的目录链接就能把整棵外部树吞进扫描,或让遍历顺着链接绕出根。
|
|
114
|
+
// 拦下来的那个目录里的文件**不**计入 `remaining`:那是"本来就不在范围内",不是护栏截断。
|
|
115
|
+
const resolved = await realpath(abs).catch(() => undefined);
|
|
116
|
+
if (resolved === undefined) {
|
|
117
|
+
noteLink(guard, abs, '真实路径解析失败,无法划界,未进入');
|
|
118
|
+
}
|
|
119
|
+
else if (!isWithinRoot(rootReal, resolved)) {
|
|
120
|
+
noteLink(guard, abs, `真实路径在投射根外,未进入: ${resolved}`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// 护栏耗完也要继续走目录:否则"还剩多少个"就没人知道,静默截断等于谎报。
|
|
124
|
+
await walk(abs, depth + 1);
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!de.isFile())
|
|
129
|
+
continue;
|
|
130
|
+
if (!de.name.endsWith('.memory.md') && !de.name.endsWith(DISABLE_SUFFIX))
|
|
131
|
+
continue;
|
|
132
|
+
if (guard.files <= 0) {
|
|
133
|
+
guard.dropped += 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
guard.files -= 1;
|
|
137
|
+
out.push(abs);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
await walk(root, 0);
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* 这条文件对哪条记录。
|
|
145
|
+
*
|
|
146
|
+
* 先按账本记的 id;id 找不到(merge 会重铸 id,F1/F21)就按文件名干找 `name`。
|
|
147
|
+
* 两把钥匙都开不了才算找不到。
|
|
148
|
+
*/
|
|
149
|
+
function pickRecord(store, id, fileName) {
|
|
150
|
+
const byId = id.length > 0 ? store.getByIds([id])[0] : undefined;
|
|
151
|
+
if (byId !== undefined)
|
|
152
|
+
return byId;
|
|
153
|
+
const stem = fileName.replace(DISABLE_SUFFIX, '').replace(/\.memory\.md$/, '');
|
|
154
|
+
if (stem.length === 0)
|
|
155
|
+
return undefined;
|
|
156
|
+
const byName = store.findIdByName(stem);
|
|
157
|
+
return byName === undefined ? undefined : store.getByIds([byName])[0];
|
|
158
|
+
}
|
|
159
|
+
/** 写账(`.disable` 专用:那条路径不是 `targetFor` 会算出来的路径,所以只能手写字段)。 */
|
|
160
|
+
async function stampDisabled(ledger, rec, abs, text) {
|
|
161
|
+
const st = await stat(abs).catch(() => undefined);
|
|
162
|
+
const cut = abs.lastIndexOf('/');
|
|
163
|
+
const back = abs.lastIndexOf('\\');
|
|
164
|
+
const sepAt = Math.max(cut, back);
|
|
165
|
+
const entry = {
|
|
166
|
+
id: rec.id,
|
|
167
|
+
extKey: rec.extKey ?? '',
|
|
168
|
+
root: sepAt > 0 ? abs.slice(0, sepAt) : '',
|
|
169
|
+
relPath: sepAt > 0 ? abs.slice(sepAt + 1) : abs,
|
|
170
|
+
dbDigest: recordDigest(rec),
|
|
171
|
+
fileDigest: digest(text),
|
|
172
|
+
fileMtimeMs: st?.mtimeMs ?? 0,
|
|
173
|
+
fileSize: st?.size ?? 0,
|
|
174
|
+
syncedAt: new Date().toISOString(),
|
|
175
|
+
};
|
|
176
|
+
ledger.set(abs, entry);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* 反向同步一轮。
|
|
180
|
+
*
|
|
181
|
+
* @param deps - 见 `MdSyncDeps`。
|
|
182
|
+
* @returns 同步报告(含挂账样本、剩余量、以及遍历防护拦下的条目数 `skippedLinks`)。
|
|
183
|
+
* 任何单条失败只计数并留痕,绝不抛挂整轮。
|
|
184
|
+
*/
|
|
185
|
+
export async function syncFromFiles(deps) {
|
|
186
|
+
const { store, ledger, cfgRoot, dataDir, logger } = deps;
|
|
187
|
+
const now = deps.now ?? Date.now;
|
|
188
|
+
const startedAt = now();
|
|
189
|
+
const report = {
|
|
190
|
+
roots: 0,
|
|
191
|
+
scanned: 0,
|
|
192
|
+
imported: 0,
|
|
193
|
+
rendered: 0,
|
|
194
|
+
same: 0,
|
|
195
|
+
skipped: 0,
|
|
196
|
+
pending: 0,
|
|
197
|
+
retired: 0,
|
|
198
|
+
newFiles: 0,
|
|
199
|
+
orphan: 0,
|
|
200
|
+
remaining: 0,
|
|
201
|
+
capped: false,
|
|
202
|
+
skippedLinks: 0,
|
|
203
|
+
staleRootFiles: 0,
|
|
204
|
+
multiPath: 0,
|
|
205
|
+
hashMismatch: 0,
|
|
206
|
+
unsigned: 0,
|
|
207
|
+
samples: [],
|
|
208
|
+
};
|
|
209
|
+
const sample = (path, action) => {
|
|
210
|
+
if (report.samples.length < SAMPLE_CAP)
|
|
211
|
+
report.samples.push({ path, action });
|
|
212
|
+
};
|
|
213
|
+
// 自校验留痕(N13):声明 `hash` 与正文实算不符 → 记一笔并留痕;没声明(老文件) → 记
|
|
214
|
+
// `unsigned`。**两者都不阻断流程**:不符=正文被人改过(正是 P2 要回灌的输入),无声明=升级前
|
|
215
|
+
// 的产物(下轮导出会补盖)。只把「静默按既有口径处理」变成「看得见地处理」。
|
|
216
|
+
const noteHash = (parsed, abs) => {
|
|
217
|
+
if (parsed === undefined)
|
|
218
|
+
return; // 形状不符的文件:主循环各有各的处置,不自校验
|
|
219
|
+
if (parsed.hashValid === false) {
|
|
220
|
+
report.hashMismatch += 1;
|
|
221
|
+
sample(abs, 'hash: 声明与正文不符(文件被外部改动/损坏;仍按既有象限处理)');
|
|
222
|
+
logger?.warn(`[memory] md 自校验不符(声明 hash ≠ 正文实算): ${abs}`);
|
|
223
|
+
}
|
|
224
|
+
else if (parsed.hashValid === undefined) {
|
|
225
|
+
report.unsigned += 1;
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
// 根集合:记录算出的落点根 ∪ 账本记过的根。后者让人改过 workspaceId、或记录已被
|
|
229
|
+
// 裁撤之后,残留文件仍能被看见并挂账,而不是凭空蒸发。
|
|
230
|
+
const roots = new Set();
|
|
231
|
+
for (const r of store.all())
|
|
232
|
+
roots.add(targetFor(r, cfgRoot, dataDir, logger).root);
|
|
233
|
+
for (const e of ledger.all())
|
|
234
|
+
if (e.root.length > 0)
|
|
235
|
+
roots.add(e.root);
|
|
236
|
+
report.roots = roots.size;
|
|
237
|
+
const guard = {
|
|
238
|
+
files: Math.max(0, Math.trunc(deps.maxFiles)),
|
|
239
|
+
dropped: 0,
|
|
240
|
+
links: 0,
|
|
241
|
+
linkSamples: [],
|
|
242
|
+
logger,
|
|
243
|
+
};
|
|
244
|
+
const files = [];
|
|
245
|
+
for (const root of roots) {
|
|
246
|
+
for (const f of await listProjectionFiles(root, guard))
|
|
247
|
+
files.push({ root, abs: f });
|
|
248
|
+
}
|
|
249
|
+
report.skippedLinks = guard.links;
|
|
250
|
+
// 护栏掉过文件 = 本轮结论只覆盖一部分,必须说出去(和墙钟截断同一个口径)。
|
|
251
|
+
// 链接拦截不算在这里:那些文件本就在投射根外,不是"被护栏截断的范围内文件"。
|
|
252
|
+
if (guard.dropped > 0) {
|
|
253
|
+
report.capped = true;
|
|
254
|
+
report.remaining = guard.dropped;
|
|
255
|
+
}
|
|
256
|
+
// ---- 多路径预扫:「两边都变就不动」得能看见对面那一份 ----
|
|
257
|
+
// 改名后旧路径文件与旧账本条目都还在(搬走它们的是 `mem-orphans`,不是同步),于是同一
|
|
258
|
+
// 条记录在一轮里被两个条目独立判象限:两份都被改过 = 两次「仅文件变」回灌,后跑的赢,
|
|
259
|
+
// 前一份人写的编辑静默丢失。真机沙箱实测就是这个形状(旧路径被改 → `imported 1`,
|
|
260
|
+
// 库里正文来自已被改名那一侧,而权威路径那份还是旧内容)。
|
|
261
|
+
// 判据:按记录分组,组内**多于一份**与账本基线不一致 → 整组不动,计入 `multiPath` 挂账。
|
|
262
|
+
// 只有一份被改时照旧回灌(那是人真的在编辑这条记忆,只是编辑了旧路径——回灌后下一轮
|
|
263
|
+
// 由导出把内容写进权威路径,旧残片交给孤儿归档)。
|
|
264
|
+
// `.disable` 不参与分组:它只表达退场、不回灌内容,混进来会把 N11 的失效凭证
|
|
265
|
+
// 当成「另一份编辑」,把退场机制锁死。
|
|
266
|
+
const textCache = new Map();
|
|
267
|
+
const blocked = new Set();
|
|
268
|
+
const groups = new Map();
|
|
269
|
+
for (const { abs } of files) {
|
|
270
|
+
if (abs.endsWith(DISABLE_SUFFIX))
|
|
271
|
+
continue;
|
|
272
|
+
const rec = pickRecord(store, ledger.get(abs)?.id ?? '', baseName(abs));
|
|
273
|
+
if (rec === undefined)
|
|
274
|
+
continue;
|
|
275
|
+
const list = groups.get(rec.id);
|
|
276
|
+
if (list)
|
|
277
|
+
list.push(abs);
|
|
278
|
+
else
|
|
279
|
+
groups.set(rec.id, [abs]);
|
|
280
|
+
}
|
|
281
|
+
for (const [recId, list] of groups) {
|
|
282
|
+
if (list.length < 2)
|
|
283
|
+
continue;
|
|
284
|
+
let changed = 0;
|
|
285
|
+
const texts = [];
|
|
286
|
+
for (const abs of list) {
|
|
287
|
+
try {
|
|
288
|
+
const text = await readFile(abs, 'utf8');
|
|
289
|
+
texts.push([abs, text]);
|
|
290
|
+
const e = ledger.get(abs);
|
|
291
|
+
if (e === undefined || e.fileDigest !== digest(text))
|
|
292
|
+
changed++;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
// 读不出就不参评(主循环会按既有口径计入 skipped)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (changed < 2)
|
|
299
|
+
continue;
|
|
300
|
+
report.multiPath += list.length;
|
|
301
|
+
for (const [abs, text] of texts) {
|
|
302
|
+
blocked.add(abs);
|
|
303
|
+
textCache.set(abs, text);
|
|
304
|
+
}
|
|
305
|
+
logger?.warn(`[memory] md 同步挂账:记录 ${recId} 有 ${list.length} 份投射文件、其中 ${changed} 份被改,` +
|
|
306
|
+
`两侧都不动(先跑孤儿归档抹掉旧残片,下一轮就只剩一份)`);
|
|
307
|
+
for (const abs of list)
|
|
308
|
+
sample(abs, `同记录多路径(${recId}) ${list.length} 份里 ${changed} 份被改 → 不动,等人裁决`);
|
|
309
|
+
}
|
|
310
|
+
for (const item of files) {
|
|
311
|
+
const abs = item.abs;
|
|
312
|
+
// 两条护栏都在循环顶部查:写在末尾会被 continue 跳过(导出侧的测试抓过这个 bug)。
|
|
313
|
+
if (now() - startedAt > deps.timeBudgetMs) {
|
|
314
|
+
report.capped = true;
|
|
315
|
+
report.remaining = files.length - report.scanned;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
report.scanned += 1;
|
|
319
|
+
if (blocked.has(abs)) {
|
|
320
|
+
// 已在预扫里计入 multiPath 并留了样本:本轮什么都不动,也不能再当成 skipped。
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const cached = textCache.get(abs);
|
|
324
|
+
let text = '';
|
|
325
|
+
try {
|
|
326
|
+
text = cached ?? await readFile(abs, 'utf8');
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
report.skipped += 1;
|
|
330
|
+
logger?.warn(`[memory] md 同步跳过(读不出): ${abs} - ${err instanceof Error ? err.message : String(err)}`);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const fileName = baseName(abs);
|
|
334
|
+
const entry = ledger.get(abs);
|
|
335
|
+
const rec = pickRecord(store, entry?.id ?? '', fileName);
|
|
336
|
+
// ---- 只挡"跳根重复":文件所在根不是这条记录当前的落点根 ----
|
|
337
|
+
// 根集合是「记录算出的落点根 ∪ 账本记过的根」,所以换过 `memExport.root`、迁库、
|
|
338
|
+
// workspace 根与单根并存留残片时,同一条记录会在**两个根**下各有一份文件。旧那份不是
|
|
339
|
+
// 无足轻重的:它带着自己的账本条目进循环,而象限判定拿它跟库比,**写盘却走
|
|
340
|
+
// `targetFor` 算出的新根那份**(`exportRecords` 不读扫描到的路径)。结果是旧根里人写的
|
|
341
|
+
// 编辑能绕过“两边都变就不动”把新根那份覆盖掉——而报告里看起来一切合规。
|
|
342
|
+
// 刻意只比**根**不比全路径:人手在同一个根里的嵌套目录放文件(或放 `.disable`)仍是
|
|
343
|
+
// 既有语义,不能在本修复里顺手收窄掉。非当前根的那份本质是孤儿,交给 `mem-orphans` 归档。
|
|
344
|
+
// 比较用 `resolve` 不折叠大小写:判错时只会多拦不拦(stale = 什么都不做),方向安全。
|
|
345
|
+
if (rec !== undefined) {
|
|
346
|
+
const authRoot = targetFor(rec, cfgRoot, dataDir, logger).root;
|
|
347
|
+
if (resolve(authRoot) !== resolve(item.root)) {
|
|
348
|
+
report.staleRootFiles += 1;
|
|
349
|
+
sample(abs, 'stale: 所在根不是该记录当前的落点根(两侧都不动,交给孤儿归档)');
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// ---- `.disable` → 退场(后缀本身就是裁决,不参与指纹比较) ----
|
|
354
|
+
if (fileName.endsWith(DISABLE_SUFFIX)) {
|
|
355
|
+
if (rec === undefined) {
|
|
356
|
+
report.orphan += 1;
|
|
357
|
+
sample(abs, 'disable: 找不到对应记录');
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
// 自校验(N13):`.disable` 也带 `hash`。**只留痕、不改裁决**——后缀即“人已判过时”,
|
|
361
|
+
// 若因凭证正文被手改一字节就把退场锁死,等于让人改坏凭证就丢掉处置意图。
|
|
362
|
+
noteHash(parseMdFile(text), abs);
|
|
363
|
+
if (rec.validTo !== undefined) {
|
|
364
|
+
report.same += 1;
|
|
365
|
+
if (entry === undefined)
|
|
366
|
+
await stampDisabled(ledger, rec, abs, text);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
store.retire([rec.id], { at: new Date().toISOString(), reason: 'obsolete' });
|
|
370
|
+
const fresh = store.getByIds([rec.id])[0] ?? rec;
|
|
371
|
+
await stampDisabled(ledger, fresh, abs, text);
|
|
372
|
+
report.retired += 1;
|
|
373
|
+
sample(abs, 'disable: 已退场');
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const parsed = parseMdFile(text);
|
|
377
|
+
if (parsed === undefined) {
|
|
378
|
+
report.skipped += 1;
|
|
379
|
+
logger?.warn(`[memory] md 同步跳过(形状不符,不接受非投射格式): ${abs}`);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (rec === undefined) {
|
|
383
|
+
report.newFiles += 1;
|
|
384
|
+
sample(abs, '新文件: 未入库(导入须过写入内核)');
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
// 只对已入库记录的文件做自校验(上面新文件/下面孤儿不算:那不是我们管理的投射)。
|
|
388
|
+
noteHash(parsed, abs);
|
|
389
|
+
const dbChanged = entry === undefined || entry.dbDigest !== recordDigest(rec);
|
|
390
|
+
// 自校验:声明 `hash` 与正文不符 = 文件侧确实被改过。把它并进 `fileChanged`(只可能“打开”、
|
|
391
|
+
// 不会“关掉”判据)——这样即使账本基线因故与文件碰巧相等,正文被改仍不会被漏判成 `same`。
|
|
392
|
+
const fileChanged = entry === undefined || entry.fileDigest !== digest(text) || parsed.hashValid === false;
|
|
393
|
+
if (dbChanged && fileChanged) {
|
|
394
|
+
report.pending += 1;
|
|
395
|
+
sample(abs, 'both: 挂待裁决(两侧都不动)');
|
|
396
|
+
logger?.warn(`[memory] md 同步挂账(两边都变,等人裁决): ${abs} record=${rec.id}`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (!dbChanged && !fileChanged) {
|
|
400
|
+
report.same += 1;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const one = (records) => exportRecords({
|
|
404
|
+
records,
|
|
405
|
+
cfgRoot,
|
|
406
|
+
dataDir,
|
|
407
|
+
ledger,
|
|
408
|
+
maxRecords: 1,
|
|
409
|
+
timeBudgetMs: Math.max(1, deps.timeBudgetMs),
|
|
410
|
+
logger,
|
|
411
|
+
now: deps.now,
|
|
412
|
+
});
|
|
413
|
+
if (dbChanged) {
|
|
414
|
+
// 只有库变 → 重写文件(走导出侧同一条写盘路)。
|
|
415
|
+
const sub = await one([rec]);
|
|
416
|
+
if (sub.skipped > 0) {
|
|
417
|
+
report.skipped += 1;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
report.rendered += 1;
|
|
421
|
+
sample(abs, sub.written > 0 ? '库侧更新 → 重写文件' : '库侧更新 → 文件已一致');
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
// 只有文件变 → 回灌入库,再把文件重写成规范渲染(消写回环)。
|
|
425
|
+
// 入库前同样过一道版式归一:两头用同一个函数,才能保证“库里的内容”与“渲染出的正文”
|
|
426
|
+
// 是同一个形状,下一轮才判得出 `same`(P4.2)。
|
|
427
|
+
const updated = {
|
|
428
|
+
...rec,
|
|
429
|
+
content: normalizeContentMd(parsed.content),
|
|
430
|
+
updatedAt: now(),
|
|
431
|
+
version: (rec.version ?? 0) + 1,
|
|
432
|
+
};
|
|
433
|
+
try {
|
|
434
|
+
await store.appendNew([updated]);
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
report.skipped += 1;
|
|
438
|
+
logger?.warn(`[memory] md 回灌失败(跳过,不动文件): ${abs} - ${err instanceof Error ? err.message : String(err)}`);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const sub = await one([updated]);
|
|
442
|
+
if (sub.skipped > 0) {
|
|
443
|
+
// 入库成功但文件没回去:账本停在旧基线,下一轮会再报 fileOnly(文件其实已被我们
|
|
444
|
+
// 的入库改成了新内容,但渲染落盘失败)。留痕,不静默。
|
|
445
|
+
logger?.warn(`[memory] md 回灌后重渲染失败(下一轮仍会挂同一象限): ${abs}`);
|
|
446
|
+
}
|
|
447
|
+
report.imported += 1;
|
|
448
|
+
sample(abs, '文件侧更新 → 回灌入库');
|
|
449
|
+
}
|
|
450
|
+
// 链接拦截样本压到最后挂:`pending`/`orphan` 那批是等人裁决的,优先占面板那 12 个样本位;
|
|
451
|
+
// 但它们仍要出现——只有计数没有路径,人无法判断该去查哪个目录。
|
|
452
|
+
for (const s of guard.linkSamples)
|
|
453
|
+
sample(s.path, s.action);
|
|
454
|
+
await ledger.flush();
|
|
455
|
+
return report;
|
|
456
|
+
}
|
|
@@ -7,20 +7,24 @@
|
|
|
7
7
|
* - 蒸馏完成后按族强制 L2(把未整合的残余记录全部落进场景);
|
|
8
8
|
* - L3 画像冷启动更新(hasPersona=false 时自动触发,已有人格则轻量刷新);
|
|
9
9
|
* - 若有图谱投影,新记录自动入队(由 runner 的图谱泵接管)。
|
|
10
|
+
* - 收尾再跑一次 md 投射的**增量导出**(task_2.8):只导本轮新增/变更的记录,
|
|
11
|
+
* 门是既有的 `cfg.memExport.enabled`;它是旁路,抛错只 warn + 计数,绝不让反刍失败。
|
|
10
12
|
* - 支持中途取消:已蒸馏部分保留,pending 切片中未被消费的维持原状。
|
|
11
13
|
*
|
|
12
14
|
* 调度:任务入队走 runner 的优先级队列(live 任务优先于 ruminate 任务),
|
|
13
15
|
* 不阻塞宿主正常对话。
|
|
14
16
|
*/
|
|
15
17
|
import type { Context } from '@deepseek-ai/cordis';
|
|
16
|
-
import type
|
|
18
|
+
import { type MemoryConfig } from '../config.js';
|
|
17
19
|
import type { L1Store } from '../store/l1.js';
|
|
18
20
|
import { type MemoryBackend } from '../store/memory-backend.js';
|
|
21
|
+
import type { MdLedgerStore } from '../store/memory-md-ledger.js';
|
|
19
22
|
import type { PersonaStore } from '../store/persona.js';
|
|
20
23
|
import type { SceneStore } from '../store/scenes.js';
|
|
21
24
|
import type { StateStore } from '../store/state.js';
|
|
22
25
|
import type { LiveSettingsHandle } from '../settings.js';
|
|
23
26
|
import type { MemoryFamily, MemoryLogger } from '../types.js';
|
|
27
|
+
import { type SlotMigrateStoreFace } from './slot-migrate.js';
|
|
24
28
|
import { type RelabelStats } from './relabel.js';
|
|
25
29
|
import type { MemoryRunner } from './runner.js';
|
|
26
30
|
export interface RuminateStatus {
|
|
@@ -68,6 +72,13 @@ export declare class RuminateController {
|
|
|
68
72
|
private pendingFile;
|
|
69
73
|
/** Room 注册表(可选;孤儿候选预标记的合并词表来源)。 */
|
|
70
74
|
private readonly roomRegistry;
|
|
75
|
+
/**
|
|
76
|
+
* 本轮起始时的库快照(id → updatedAt),供收尾算「本轮新增/变更」增量集(task_2.8)。
|
|
77
|
+
* `undefined` = 本轮没拍(投射门未开或拍失败)→ 收尾一律不做全库重导。
|
|
78
|
+
*/
|
|
79
|
+
private mdBaseline;
|
|
80
|
+
/** 投射旁路失败累计次数(只 warn 不抛,计数供诊断/测试读取)。 */
|
|
81
|
+
private mdFailures;
|
|
71
82
|
/** 记忆后端:未注入时包 l1(进程内,行为与改造前等价)。 */
|
|
72
83
|
private backend;
|
|
73
84
|
constructor(ctx: Context, cfg: MemoryConfig, runner: Pick<MemoryRunner, 'enqueue' | 'states'>, stores: {
|
|
@@ -77,6 +88,14 @@ export declare class RuminateController {
|
|
|
77
88
|
scenes: Record<MemoryFamily, SceneStore>;
|
|
78
89
|
persona: Record<MemoryFamily, PersonaStore>;
|
|
79
90
|
state: StateStore;
|
|
91
|
+
/** 常驻槽位面(仅 cfg.slots.migrateLegacy 开启时读/写,缺省不碰)。 */
|
|
92
|
+
slots?: SlotMigrateStoreFace;
|
|
93
|
+
/**
|
|
94
|
+
* md 投射指纹账本(task_2.8)。装配层与 `stats.ts` 的 mem-export 端点共用同一个
|
|
95
|
+
* `stores` 对象,`index.ts` 在构造本控制器之后才把 `mdLedger` 挂上去——引用共享,
|
|
96
|
+
* 运行时读得到;缺席 = 投射面没装配 → 收尾零动作。
|
|
97
|
+
*/
|
|
98
|
+
mdLedger?: MdLedgerStore;
|
|
80
99
|
}, logger: MemoryLogger, live: LiveSettingsHandle, pendingFile: string,
|
|
81
100
|
/** Room 注册表(可选;孤儿候选预标记的合并词表来源)。 */
|
|
82
101
|
roomRegistry?: {
|
|
@@ -109,5 +128,31 @@ export declare class RuminateController {
|
|
|
109
128
|
* 现在:先把步骤数登记为 total、每完成一步自增 done,并给出当前动作描述(detail)。
|
|
110
129
|
*/
|
|
111
130
|
private doLightRefresh;
|
|
131
|
+
/**
|
|
132
|
+
* 投射侧诊断计数(只增不减;不进任何端点,避开契约五处齐改的面)。
|
|
133
|
+
*/
|
|
134
|
+
getMdFailures(): number;
|
|
135
|
+
/**
|
|
136
|
+
* 拍本轮起始基线(id → updatedAt)。三道门任一不满足就**一个动作都不做**:
|
|
137
|
+
* 连 `l1.all()` 都不调,这样“投射关着”时的产物与改动前逐字一致(本仓红线)。
|
|
138
|
+
*
|
|
139
|
+
* 为什么用基线 diff 而不是 `updatedAt >= startedAt`:relabel / 退场等改写走的是
|
|
140
|
+
* `patchMetadata` 那类最小写回,**不碰 `updated_time`**(它在快照哈希字段表里),
|
|
141
|
+
* 按时间戳筛会把这批真变了的记录漏掉。
|
|
142
|
+
*/
|
|
143
|
+
private snapshotMdBaseline;
|
|
144
|
+
/**
|
|
145
|
+
* 反刍收尾的增量 md 投射(task_2.8):库 → 文件 + 账本,**只碰本轮新增/变更的那批**。
|
|
146
|
+
*
|
|
147
|
+
* 与手动的 `dsh-memory/mem-export` 端点的区别:
|
|
148
|
+
* - 不跑命名回填(`assignMissingNames` 会写库,反刍是只读旁路,不能在那里改库);
|
|
149
|
+
* 本轮新记录的 `name` 已在写入前由管线单一真源分配,未命名的按 `fileStemFor` 回落。
|
|
150
|
+
* - 不建类型目录骨架(`ensureTypeSkeleton`),那是面板显式触发的动作。
|
|
151
|
+
* 护栏全部复用 `cfg.memExport` 既有值(`maxRecords`/`timeBudgetMs`),**不新增配置项**。
|
|
152
|
+
*
|
|
153
|
+
* 失败面:整个方法自吞异常,只 warn + 计数,绝不抛给调用方、绝不改变反刍结果。
|
|
154
|
+
* @param stage - 日志里的路径名(区分 finalize 与轻量刷新两条挂载点)。
|
|
155
|
+
*/
|
|
156
|
+
private projectMd;
|
|
112
157
|
private finish;
|
|
113
158
|
}
|