dsh-prime-memory 0.24.0 → 0.24.2
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 +31 -0
- package/CHANGELOG.ja.md +26 -0
- package/CHANGELOG.ko.md +26 -0
- package/CHANGELOG.md +31 -0
- package/dist/client.js +126 -7
- package/dist/config.d.ts +4 -1
- package/dist/config.js +10 -1
- package/dist/conflict-service.d.ts +3 -1
- package/dist/conflict-service.js +1 -0
- package/dist/contract.d.ts +37 -0
- package/dist/index.js +22 -0
- package/dist/pipeline/ruminate.d.ts +4 -1
- package/dist/pipeline/ruminate.js +19 -10
- package/dist/pipeline/runner.js +6 -1
- package/dist/settings.js +4 -0
- package/dist/stats.d.ts +6 -0
- package/dist/stats.js +40 -7
- package/dist/store/l1.d.ts +19 -0
- package/dist/store/l1.js +46 -2
- package/dist/store/memory-md-disable.d.ts +51 -0
- package/dist/store/memory-md-disable.js +207 -11
- package/dist/store/memory-md-export.js +28 -1
- package/dist/store/memory-md-run.d.ts +48 -0
- package/dist/store/memory-md-run.js +151 -0
- package/dist/store/memory-md-sync.d.ts +59 -0
- package/dist/store/memory-md-sync.js +152 -0
- package/dist/store/memory-md-types.d.ts +71 -2
- package/dist/store/memory-md.d.ts +9 -0
- package/dist/store/memory-md.js +11 -0
- package/dist/store/sqlite.js +24 -6
- package/dist/tools/index.js +3 -1
- package/dist/tools/slots.d.ts +2 -1
- package/dist/tools/slots.js +2 -1
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
package/dist/settings.js
CHANGED
|
@@ -81,6 +81,8 @@ const ALWAYS_ON = {
|
|
|
81
81
|
memoryMutate: false,
|
|
82
82
|
conflictFreeze: false,
|
|
83
83
|
skillsEnabled: false,
|
|
84
|
+
// md 投射护栏运行时覆盖:0 = 跟随部署 cfg.memExport.maxRecords(面板上可改)
|
|
85
|
+
mdExportMaxRecords: 0,
|
|
84
86
|
toolRoom: true,
|
|
85
87
|
toolGraph: true,
|
|
86
88
|
toolRuminate: true,
|
|
@@ -315,6 +317,8 @@ function resolveSettings(value) {
|
|
|
315
317
|
conflictFreeze: v.conflictFreeze === true,
|
|
316
318
|
// 轨2 技能结晶:严格 === true(默认关,新功能默认关)
|
|
317
319
|
skillsEnabled: v.skillsEnabled === true,
|
|
320
|
+
// md 投射护栏钳 0~10000(与 live schema 同上限);0 = 跟随部署默认
|
|
321
|
+
mdExportMaxRecords: Math.min(10_000, Math.max(0, num(v.mdExportMaxRecords))),
|
|
318
322
|
// 工具分组封印:严格 !== false(缺省/异常值 = 未封印,工具照常注册)
|
|
319
323
|
toolRoom: v.toolRoom !== false,
|
|
320
324
|
toolGraph: v.toolGraph !== false,
|
package/dist/stats.d.ts
CHANGED
|
@@ -177,6 +177,12 @@ export declare function hitToUiRecord(r: {
|
|
|
177
177
|
metadata?: Record<string, unknown>;
|
|
178
178
|
score?: number;
|
|
179
179
|
family?: string;
|
|
180
|
+
/** 名称(md 投射相):投射文件名取它;无名字 = undefined。 */
|
|
181
|
+
name?: string;
|
|
182
|
+
/** 可见范围(§E):'workspace' 才带归属,其余一律归一 'global'。 */
|
|
183
|
+
scope?: string;
|
|
184
|
+
/** 归属工作区路径(仅 scope='workspace' 时非空)。 */
|
|
185
|
+
workspaceId?: string;
|
|
180
186
|
/** 退场判据:`valid_to` 闭合即已退场(软删)。由 `listL1`/`getByIds` 透传。 */
|
|
181
187
|
validTo?: number;
|
|
182
188
|
}, extra?: {
|
package/dist/stats.js
CHANGED
|
@@ -38,7 +38,8 @@ import { snapshotTokenCost } from './token-cost.js';
|
|
|
38
38
|
import { computeMaturity } from './util/maturity.js';
|
|
39
39
|
import { isFeedbackScore } from './store/recall-feedback.js';
|
|
40
40
|
import { relinkWorkspaces } from './pipeline/workspace-relink.js';
|
|
41
|
-
import {
|
|
41
|
+
import { reconcileLedger } from './store/memory-md-export.js';
|
|
42
|
+
import { mdRunStatus, runProjection } from './store/memory-md-run.js';
|
|
42
43
|
import { disableProjectionFile } from './store/memory-md-disable.js';
|
|
43
44
|
import { MEMORY_TYPE_DIRS, UNSORTED_DIR, resolveMemoriesRoot, targetFor } from './store/memory-md.js';
|
|
44
45
|
import { ensureTypeSkeleton } from './store/memory-md-skeleton.js';
|
|
@@ -114,6 +115,7 @@ export const MEMORY_ENDPOINTS = [
|
|
|
114
115
|
'dsh-memory/recall-feedback',
|
|
115
116
|
'dsh-memory/workspace-relink',
|
|
116
117
|
'dsh-memory/mem-export',
|
|
118
|
+
'dsh-memory/mem-run-status',
|
|
117
119
|
'dsh-memory/mem-reconcile',
|
|
118
120
|
'dsh-memory/mem-sync',
|
|
119
121
|
'dsh-memory/mem-orphans',
|
|
@@ -398,6 +400,8 @@ function sanitizeSettings(s) {
|
|
|
398
400
|
/** 端点分发表(导出供测试直调:可精确注入 rebuild/ruminate 等可选控制器,验证 deps 接线)。 */
|
|
399
401
|
export async function handleEndpoint(endpoint, payload, deps) {
|
|
400
402
|
const { cfg, stores, status, live, modes, dataDir, rebuild, ruminate, embedManager, sessionInfo, roomRegistry, memorySources, skillStore } = deps;
|
|
403
|
+
// 退场/恢复/新增的文件跟随已收口在 `L1Store` 的汇流口(`store/memory-md-sync.ts`),
|
|
404
|
+
// 本分发层不再自己建点火器——那 8 处事件挂钩已被真机证伪(会漏)。
|
|
401
405
|
switch (endpoint) {
|
|
402
406
|
case 'dsh-memory/stats':
|
|
403
407
|
return buildStats(cfg, stores, status);
|
|
@@ -554,7 +558,9 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
554
558
|
case 'dsh-memory/mem-export': {
|
|
555
559
|
// 单向导出:库 → md 文件 + 账本。**不写检索面**(用例钉住:导出前后 hashRecords 相同)。
|
|
556
560
|
// 先查配置门再查装配门:“整面默认关”时该听到的是“没开”,不是“没装配”。
|
|
557
|
-
|
|
561
|
+
// 护栏读**生效**配置(含面板上的 `mdExportMaxRecords` 覆盖):只看启动快照
|
|
562
|
+
// 就会“调了没反应”,而这条路径的护栏正是给人调的。
|
|
563
|
+
const exp = effectiveCfg(cfg, live).memExport;
|
|
558
564
|
if (exp?.enabled !== true)
|
|
559
565
|
throw new Error('md 投射未开启(cfg.memExport.enabled)');
|
|
560
566
|
const md = stores.mdLedger;
|
|
@@ -584,7 +590,10 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
584
590
|
skeletonRoots.add(t.root);
|
|
585
591
|
}
|
|
586
592
|
await ensureTypeSkeleton({ roots: [...skeletonRoots], countsByType, logger: deps.logger });
|
|
587
|
-
|
|
593
|
+
// 滚动执行到清完:先把导出游标推到表尾,再把后缀收敛游标推到表尾。
|
|
594
|
+
// 单轮护栏(`maxRecords`/`timeBudgetMs`)不变,跨轮另有轮数与总墙钟兜底;
|
|
595
|
+
// 进度经 `dsh-memory/mem-run-status` 读(面板画进度条)。
|
|
596
|
+
const run = await runProjection({
|
|
588
597
|
records: all,
|
|
589
598
|
cfgRoot: exp.root,
|
|
590
599
|
dataDir: deps.dataDir,
|
|
@@ -593,11 +602,17 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
593
602
|
timeBudgetMs: exp.timeBudgetMs,
|
|
594
603
|
logger: deps.logger,
|
|
595
604
|
});
|
|
596
|
-
return { ...
|
|
605
|
+
return { ...run, named: naming.named, nameFailed: naming.failed };
|
|
606
|
+
}
|
|
607
|
+
case 'dsh-memory/mem-run-status': {
|
|
608
|
+
// 只读进度快照。故意不查配置门:任务没在跑时也要能读到上一次的 `notice`
|
|
609
|
+
// (“预算用尽、还剩 N 条”这句话必须在人下次点击之前也看得见)。
|
|
610
|
+
return mdRunStatus();
|
|
597
611
|
}
|
|
598
612
|
case 'dsh-memory/mem-reconcile': {
|
|
599
613
|
// 只读对账:不写库、不写文件,只按账本基线出四态报告(所以不需高权限门)。
|
|
600
|
-
|
|
614
|
+
// 同样读生效配置:对账与导出共用护栏,两边半生效会让人看不出差异从哪来。
|
|
615
|
+
const exp = effectiveCfg(cfg, live).memExport;
|
|
601
616
|
if (exp?.enabled !== true)
|
|
602
617
|
throw new Error('md 投射未开启(cfg.memExport.enabled)');
|
|
603
618
|
const md = stores.mdLedger;
|
|
@@ -981,7 +996,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
981
996
|
const page = stores.l1.list({ tag: slug, retired: false, limit: 200, offset });
|
|
982
997
|
if (page.items.length === 0)
|
|
983
998
|
break;
|
|
984
|
-
const
|
|
999
|
+
const pageIds = page.items.map((r) => r.id);
|
|
1000
|
+
const n = stores.l1.retire(pageIds, { at: new Date().toISOString(), reason: 'manual' });
|
|
985
1001
|
retiredRecords += n;
|
|
986
1002
|
if (page.items.length < 200)
|
|
987
1003
|
break;
|
|
@@ -1233,6 +1249,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1233
1249
|
memoryMutate: false,
|
|
1234
1250
|
conflictFreeze: live?.get()?.conflictFreeze === true,
|
|
1235
1251
|
skillsEnabled: live?.get()?.skillsEnabled === true,
|
|
1252
|
+
mdExportMaxRecords: live?.get()?.mdExportMaxRecords ?? 0,
|
|
1236
1253
|
}),
|
|
1237
1254
|
// 静态部署上限(cordis.patch.yml):运行时开关与它取 AND
|
|
1238
1255
|
ceilings: { capture: cfg.capture.enabled, distill: cfg.extract.enabled, recall: cfg.recall.enabled },
|
|
@@ -1275,7 +1292,12 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1275
1292
|
if (typeof patch[key] === 'boolean')
|
|
1276
1293
|
clean[key] = patch[key];
|
|
1277
1294
|
}
|
|
1278
|
-
//
|
|
1295
|
+
// md 投射护栏运行时覆盖:数字才收,钳 0~10000(与 live schema 同上限);0 = 跟随部署默认。
|
|
1296
|
+
// 不加进白名单的话,面板上那个输入框会“写成功但被静默丢”——看得到数字、不生效。
|
|
1297
|
+
if (typeof patch.mdExportMaxRecords === 'number' && Number.isFinite(patch.mdExportMaxRecords)) {
|
|
1298
|
+
clean.mdExportMaxRecords = Math.min(10_000, Math.max(0, Math.floor(patch.mdExportMaxRecords)));
|
|
1299
|
+
}
|
|
1300
|
+
// 运行时统一路由链:结构校验后整体写入(空数组 = 回到跟随部署配置)
|
|
1279
1301
|
if (patch.distillChain !== undefined) {
|
|
1280
1302
|
const err = validateDistillChain(patch.distillChain);
|
|
1281
1303
|
if (err)
|
|
@@ -1648,6 +1670,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1648
1670
|
throw new Error('ids 缺失');
|
|
1649
1671
|
const r = await stores.l1.restore(ids);
|
|
1650
1672
|
deps.logger.info(`[memory] 恢复已退场记忆 ${r.restored} 条(补向量 ${r.vectorsWritten} 条)`);
|
|
1673
|
+
// 掉 `.disable` 后缀由 `l1.restore()` 里的钩子承接(与退场对称的那一半)。
|
|
1651
1674
|
const resp = r;
|
|
1652
1675
|
return resp;
|
|
1653
1676
|
}
|
|
@@ -2203,6 +2226,16 @@ export function hitToUiRecord(r, extra) {
|
|
|
2203
2226
|
}),
|
|
2204
2227
|
// G1 使用场景:从命名空间保留键读出(功能关/无键 → undefined)。
|
|
2205
2228
|
usageScenario: Array.isArray(r.metadata?.dsh_usage_scenario) ? r.metadata?.dsh_usage_scenario : undefined,
|
|
2229
|
+
// 投射身份三项(N1):名称 / 可见范围 / 归属工作区。面板靠它们核对“这条记忆
|
|
2230
|
+
// 对应哪个投射文件、对谁可见”——这三个值此前从不进 UiRecord,于是面板上
|
|
2231
|
+
// 无法把记忆与投射文件对上。纯投影,不从存储侧另算。
|
|
2232
|
+
name: r.name ?? null,
|
|
2233
|
+
scope: r.scope === 'workspace' ? 'workspace' : 'global',
|
|
2234
|
+
workspaceId: r.scope === 'workspace' ? (r.workspaceId ?? '') || null : null,
|
|
2235
|
+
// 关键词(保留字段,用于 BM25):与 usageScenario 同范式,从保留键读出(无键 → undefined)。
|
|
2236
|
+
keywords: Array.isArray(r.metadata?.dsh_keywords)
|
|
2237
|
+
? (r.metadata?.dsh_keywords).filter((k) => typeof k === 'string' && k !== '')
|
|
2238
|
+
: undefined,
|
|
2206
2239
|
// 人给相关度均值:读时从 sidecar 派生(不进存储/快照);未打分 → 字段缺席。
|
|
2207
2240
|
userRelevance: extra?.userRelevance ?? undefined,
|
|
2208
2241
|
};
|
package/dist/store/l1.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { type SupersedeInfo } from './supersede.js';
|
|
|
6
6
|
import { type ExportThenPurgeResult, type PurgeOptions, type RestoreResult, type SnapshotRestorePlan, type SnapshotSummary } from './l1-snapshot.js';
|
|
7
7
|
import { type EmbeddingService } from './embedding.js';
|
|
8
8
|
import { type L1MetaLite, type MemoryDb } from './sqlite.js';
|
|
9
|
+
import type { L1FileSyncHook } from './memory-md-sync.js';
|
|
9
10
|
export type RecallStrategy = 'keyword' | 'embedding' | 'hybrid';
|
|
10
11
|
/**
|
|
11
12
|
* 图谱路提供者(§D 第 3 路):按查询返回图谱命中(已按 score 降序)。
|
|
@@ -73,6 +74,15 @@ export declare class L1Store {
|
|
|
73
74
|
untaggedL1Count(): number;
|
|
74
75
|
/** Room 计数缓存(见 `listRooms()`:tags 仅随反刍变化,不必每次敲库)。 */
|
|
75
76
|
private roomCache;
|
|
77
|
+
/**
|
|
78
|
+
* 库→文件投射的跟随钩子(收口点;未注入 = 零文件动作)。
|
|
79
|
+
*
|
|
80
|
+
* 为什么不放在调用方:改变记录有效性/身份的写路径全部汇流在本类的 `appendNew` /
|
|
81
|
+
* `retire` / `restore` / `rename` 四个方法上,而上游有 8 处 `retire()` 调用点——事件挂钩
|
|
82
|
+
* 已被真机证伪(920 条退场只有 8 个 `.disable`)。放这里意味着新增调用点不会漏。
|
|
83
|
+
* 与构造函数里的 `autoName: () => cfg.memExport.enabled` 同一先例:只拿判据,不拿配置快照。
|
|
84
|
+
*/
|
|
85
|
+
private fileSync?;
|
|
76
86
|
constructor(dataDir: string, db: MemoryDb, embed?: EmbeddingService, strategy?: RecallStrategy, logger?: MemoryLogger,
|
|
77
87
|
/** 时效衰减半衰期(天;0=关)。缺省 30 与 config 默认一致。 */
|
|
78
88
|
decayHalfLifeDays?: number,
|
|
@@ -88,6 +98,15 @@ export declare class L1Store {
|
|
|
88
98
|
autoName?: (() => boolean) | undefined);
|
|
89
99
|
/** 读路径时效半衰期(天;0=关)。老化权重展示与召回衰减同一事实源。 */
|
|
90
100
|
get decayHalfLife(): number;
|
|
101
|
+
/**
|
|
102
|
+
* 装配层晚绑定投射跟随钩子。
|
|
103
|
+
*
|
|
104
|
+
* 必须晚于构造:`stores.l1` 先建,md 账本(`stores.mdLedger`)后装,构造期拿不到 ledger。
|
|
105
|
+
* 未注入时四个汇流口行为与注入前逐字一致(投射面关着也是这条)。
|
|
106
|
+
*/
|
|
107
|
+
setFileSyncHook(hook: L1FileSyncHook): void;
|
|
108
|
+
/** 有效性变更点火(增/退场/恢复共用一个入口:钩子内部自己跑「导出→后缀收敛」)。 */
|
|
109
|
+
private fireFileSync;
|
|
91
110
|
init(): Promise<void>;
|
|
92
111
|
/** 旧版单文件 records.jsonl 一次性导入检索库,成功后改名 .imported。 */
|
|
93
112
|
private importLegacy;
|
package/dist/store/l1.js
CHANGED
|
@@ -42,6 +42,15 @@ export class L1Store {
|
|
|
42
42
|
}
|
|
43
43
|
/** Room 计数缓存(见 `listRooms()`:tags 仅随反刍变化,不必每次敲库)。 */
|
|
44
44
|
roomCache = null;
|
|
45
|
+
/**
|
|
46
|
+
* 库→文件投射的跟随钩子(收口点;未注入 = 零文件动作)。
|
|
47
|
+
*
|
|
48
|
+
* 为什么不放在调用方:改变记录有效性/身份的写路径全部汇流在本类的 `appendNew` /
|
|
49
|
+
* `retire` / `restore` / `rename` 四个方法上,而上游有 8 处 `retire()` 调用点——事件挂钩
|
|
50
|
+
* 已被真机证伪(920 条退场只有 8 个 `.disable`)。放这里意味着新增调用点不会漏。
|
|
51
|
+
* 与构造函数里的 `autoName: () => cfg.memExport.enabled` 同一先例:只拿判据,不拿配置快照。
|
|
52
|
+
*/
|
|
53
|
+
fileSync;
|
|
45
54
|
constructor(dataDir, db, embed = new NoopEmbeddingService(), strategy = 'hybrid', logger,
|
|
46
55
|
/** 时效衰减半衰期(天;0=关)。缺省 30 与 config 默认一致。 */
|
|
47
56
|
decayHalfLifeDays,
|
|
@@ -71,6 +80,21 @@ export class L1Store {
|
|
|
71
80
|
get decayHalfLife() {
|
|
72
81
|
return this.decayHalfLifeDays;
|
|
73
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* 装配层晚绑定投射跟随钩子。
|
|
85
|
+
*
|
|
86
|
+
* 必须晚于构造:`stores.l1` 先建,md 账本(`stores.mdLedger`)后装,构造期拿不到 ledger。
|
|
87
|
+
* 未注入时四个汇流口行为与注入前逐字一致(投射面关着也是这条)。
|
|
88
|
+
*/
|
|
89
|
+
setFileSyncHook(hook) {
|
|
90
|
+
this.fileSync = hook;
|
|
91
|
+
}
|
|
92
|
+
/** 有效性变更点火(增/退场/恢复共用一个入口:钩子内部自己跑「导出→后缀收敛」)。 */
|
|
93
|
+
fireFileSync(ids, why) {
|
|
94
|
+
if (this.fileSync === undefined || ids.length === 0)
|
|
95
|
+
return;
|
|
96
|
+
this.fileSync.sync(ids, why);
|
|
97
|
+
}
|
|
74
98
|
async init() {
|
|
75
99
|
await ensureDir(this.recordsDir);
|
|
76
100
|
await this.importLegacy();
|
|
@@ -153,9 +177,16 @@ export class L1Store {
|
|
|
153
177
|
* 改名不触发场景重聚类,也不动 `updated_time`(约束在 `renameL1` 里)。
|
|
154
178
|
*/
|
|
155
179
|
rename(id, name) {
|
|
180
|
+
// 改名前的身份要先把住:投射文件名就是 `name`(`targetFor` 算路径),库里改了而盘上
|
|
181
|
+
// 没跟进就会长期对不上,后缀收敛器也修不了(它按**当前** name 算路径)。
|
|
182
|
+
const oldName = this.db.getL1ByIds([id])[0]?.name;
|
|
156
183
|
const r = this.db.renameL1(id, name);
|
|
157
|
-
if (r === 'ok')
|
|
184
|
+
if (r === 'ok') {
|
|
185
|
+
if (oldName !== undefined && oldName !== '' && oldName !== name && this.fileSync !== undefined) {
|
|
186
|
+
this.fileSync.renamed(new Map([[id, oldName]]), 'rename');
|
|
187
|
+
}
|
|
158
188
|
return { ok: true };
|
|
189
|
+
}
|
|
159
190
|
return { ok: false, reason: r };
|
|
160
191
|
}
|
|
161
192
|
/** 按名字反查 id(重名预检 / md 回灌);未命中 ⇒ undefined。 */
|
|
@@ -452,6 +483,8 @@ export class L1Store {
|
|
|
452
483
|
this.logger?.error(`[memory] L1 检索库批量写入失败(${records.length} 条,JSONL 事实源完好),` +
|
|
453
484
|
'这批记忆暂不可检索;可在设置页运行「重建记忆」修复');
|
|
454
485
|
}
|
|
486
|
+
// 写对称:新增也即时上盘(不阻塞、失败只 warn)——否则「文件树 = 库当前状态」只对删成立。
|
|
487
|
+
this.fireFileSync(records.map((r) => r.id), 'appendNew');
|
|
455
488
|
}
|
|
456
489
|
/** 去重 update/merge 产出的记录:只更新检索库(JSONL 事实源不改写,官方语义)。 */
|
|
457
490
|
async upsert(record) {
|
|
@@ -488,7 +521,11 @@ export class L1Store {
|
|
|
488
521
|
* 而那种不一致只有在误删发生时才暴露。
|
|
489
522
|
*/
|
|
490
523
|
retire(ids, info) {
|
|
491
|
-
|
|
524
|
+
const n = this.db.retireL1Batch(ids, info);
|
|
525
|
+
// 退场汇流口(人工软删 / 合并取代 / 冲突判负 都经这里)——所以机器侧自动退场也自动有文件跟随。
|
|
526
|
+
// 不 await:库侧结果先成立,投射是旁路(见 `setFileSyncHook` 的注释)。
|
|
527
|
+
this.fireFileSync(ids, 'retire');
|
|
528
|
+
return n;
|
|
492
529
|
}
|
|
493
530
|
/** 已退场(可恢复)记录列表(面板用)。 */
|
|
494
531
|
listRetired(opts) {
|
|
@@ -517,6 +554,8 @@ export class L1Store {
|
|
|
517
554
|
vectorsWritten++;
|
|
518
555
|
this.db.upsertL1(rec, vec);
|
|
519
556
|
}
|
|
557
|
+
// 恢复活跃 = 有效性变更,后缀该从 `.disable` 掉回去(与 `retire` 对称的那一半)。
|
|
558
|
+
this.fireFileSync(records.map((r) => r.id), 'restore');
|
|
520
559
|
return { restored: records.length, vectorsWritten };
|
|
521
560
|
}
|
|
522
561
|
/**
|
|
@@ -624,6 +663,11 @@ export class L1Store {
|
|
|
624
663
|
unretired = back.restored;
|
|
625
664
|
stillRetired = [];
|
|
626
665
|
}
|
|
666
|
+
// 回到主表就等于“库里多一份可投射的份”:无论带不带退场标记都要上盘
|
|
667
|
+
// (带标记的那批由钩子内部的收敛器接成 `.disable`)。走 `restore` 的那批已点火,不重发。
|
|
668
|
+
if (r.restored > 0 && unretired === 0) {
|
|
669
|
+
this.fireFileSync(targetIds, 'restoreFromSnapshot');
|
|
670
|
+
}
|
|
627
671
|
return { ...r, dir: resolved.dir, found: true, missing, unretired, stillRetired };
|
|
628
672
|
}
|
|
629
673
|
/** 这批 id 里当前**在库**的集合(分块查,避免一次 IN 太多参数)。 */
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import { isRetiredRecord } from './memory-md.js';
|
|
2
|
+
import type { SuffixReport } from './memory-md-types.js';
|
|
1
3
|
import type { MdLedgerStore } from './memory-md-ledger.js';
|
|
2
4
|
import type { MemoryLogger, MemoryRecord } from '../types.js';
|
|
3
5
|
/** 文件侧的结果,原样进端点响应(面板看得见"到底动没动文件")。 */
|
|
4
6
|
export type DisableOutcome = 'renamed' | 'created' | 'already' | 'no-workspace' | 'failed';
|
|
7
|
+
/**
|
|
8
|
+
* 退场判据的单一真源在 `memory-md.ts`(导出侧与这里必须共用一个),本模块只转发,
|
|
9
|
+
* 免得既有调用方拿不到。另起一套(比如再叠上 `persistence`)只会让"列表说它退了、
|
|
10
|
+
* 文件却没后缀"重新变成常态。
|
|
11
|
+
*/
|
|
12
|
+
export { isRetiredRecord };
|
|
5
13
|
export interface DisableDeps {
|
|
6
14
|
/**
|
|
7
15
|
* 刚被退场的那条记录。调用方应传**退场后**的快照(带 `validTo`),这样渲染出的
|
|
@@ -14,6 +22,24 @@ export interface DisableDeps {
|
|
|
14
22
|
ledger: MdLedgerStore;
|
|
15
23
|
logger?: MemoryLogger;
|
|
16
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* 账本跟着换键。
|
|
27
|
+
*
|
|
28
|
+
* 键是绝对路径(N10),所以改名必须"删旧键、记新键"——只改文件不改账本,下一轮对账
|
|
29
|
+
* 会同时报"旧键文件缺失"和"新文件是外人加的",两边都错。
|
|
30
|
+
* 没有旧条目(人从没导出过)时只记新键,不补造旧键。
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ `fileDigest` **取磁盘上的真实字节**,不取我们手上那份渲染结果:改名分支里
|
|
33
|
+
* 旧文件是“未退场时导出的那一版”(不带 `retired`),拿新渲染的摘要当基线,
|
|
34
|
+
* 下一轮对账就会把“我们自己改的文件”报成“人改过文件”。
|
|
35
|
+
*
|
|
36
|
+
* @param persist - 默认 true(单条失效联动:写完就该落盘)。**批量收敛必须传 false**:
|
|
37
|
+
* 账本是整本 JSON 的原子重写,每条都 `flush()` 就把「窗口内只记脏、收尾落一次」
|
|
38
|
+
* 的设计退化成 O(n²)(真机 912 条回填会被墙钟 `timeBudgetMs` 拦成好几次点击)。
|
|
39
|
+
*
|
|
40
|
+
* 导出给 `memory-md-sync.ts` 的改名跟随用——「取磁盘真实字节当基线」这条纪律只该有一份。
|
|
41
|
+
*/
|
|
42
|
+
export declare function moveLedger(ledger: MdLedgerStore, fromAbs: string, toAbs: string, r: MemoryRecord, fallbackText: string, persist?: boolean): Promise<void>;
|
|
17
43
|
/**
|
|
18
44
|
* 把一条已退场记忆的投射文件标成 `.disable`。
|
|
19
45
|
*
|
|
@@ -21,3 +47,28 @@ export interface DisableDeps {
|
|
|
21
47
|
* @returns 文件侧结果;**任何异常都收敛成 `'failed'`,不抛**。
|
|
22
48
|
*/
|
|
23
49
|
export declare function disableProjectionFile(deps: DisableDeps): Promise<DisableOutcome>;
|
|
50
|
+
export interface SuffixConvergeDeps {
|
|
51
|
+
/** 全量记录(含退场的)——收敛是库→盘的形状对齐,不是增量事件。 */
|
|
52
|
+
records: readonly MemoryRecord[];
|
|
53
|
+
cfgRoot: string | undefined;
|
|
54
|
+
dataDir: string;
|
|
55
|
+
ledger: MdLedgerStore;
|
|
56
|
+
/** 与 `exportRecords` 同口径:只约束**真碰了磁盘**的动作数,判为“形状已对”的不吃配额。 */
|
|
57
|
+
maxRecords: number;
|
|
58
|
+
timeBudgetMs: number;
|
|
59
|
+
logger?: MemoryLogger;
|
|
60
|
+
/** 注入时钟(测试用)。 */
|
|
61
|
+
now?: () => number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 后缀不变式收敛:把「库里的有/无效」与「文件的 `.memory.md` / `.memory.md.disable`」对齐。
|
|
65
|
+
*
|
|
66
|
+
* 为什么挂在导出后面而不是各个退场调用点:那 8 处 `.retire()` 分布在手动软删、批量退场、
|
|
67
|
+
* 合并取代、冲突判负、slot 连带等路径上,事件挂钩注定会漏(真机实测:920 条退场记录
|
|
68
|
+
* 只有 8 个 `.disable`,全部来自召回面板那一条路径)。收敛器只依赖三个可观测事实:
|
|
69
|
+
* 文件存在性、当前后缀、指纹一致性。
|
|
70
|
+
*
|
|
71
|
+
* 护栏与导出同一套(`maxRecords` 只数碰盘动作 + `timeBudgetMs` 墙钟),所以第一次点可能只
|
|
72
|
+
* 收敛一部分,`capped`/`remaining` 会告诉你还要点几次。
|
|
73
|
+
*/
|
|
74
|
+
export declare function convergeDisabledSuffix(deps: SuffixConvergeDeps): Promise<SuffixReport>;
|
|
@@ -5,11 +5,20 @@
|
|
|
5
5
|
* workspace,也不该拿到文件系统的写权限。**库是事实源,文件是投影**——所以退场先成立,
|
|
6
6
|
* 文件侧只是把"人已处置"这件事也写到磁盘上。
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* 后缀不变式(作者口径 2026-10-14):文件后缀**只跟「这条记录在库里是否有效」有关**,
|
|
9
|
+
* `name` 字段无感,「是谁把它退场的」也无感。所以不能只在一个退场按钮后面挂一刀——
|
|
10
|
+
* 全仓有 8 处 `.retire()` 调用点(手动判过时 / 批量软删 / 合并取代 / 冲突判负 / slot 连带…),
|
|
11
|
+
* 事件挂钩注定会漏。`convergeDisabledSuffix` 就是这个不变式的收敛器:每次导出后按
|
|
12
|
+
* 「文件存在性 + 当前后缀 + 指纹一致性」三项检查重算一遍,与退场路径无关。
|
|
13
|
+
*
|
|
14
|
+
* 点火已由 `L1Store` 的汇流口接管(`store/memory-md-sync.ts`),本模块只供收敛器与
|
|
15
|
+
* 单条凭证两个原件——曾经住在这里的旧「后缀点火器工厂」已退役,不留第二事实源。
|
|
16
|
+
*
|
|
17
|
+
* 单点失效联动(`disableProjectionFile`)的三种落点:
|
|
18
|
+
* - 盘上有 `.memory.md`(**不再要求有 workspace**:无归属那条也要改,它的文件本来就在单根)→ **改名**成 `.memory.md.disable`;
|
|
19
|
+
* - 盘上什么都没有、但**有** workspace → **创建** `.disable`(内容就是渲染结果,带 `retired: true`),
|
|
11
20
|
* 人不点导出也有一份磁盘凭证,P2 与人都看得见;
|
|
12
|
-
* -
|
|
21
|
+
* - 盘上什么都没有、且没有 workspace → **什么都不写**。宁缺勿猜:为一个归属不明的记忆在单根凭空造文件,
|
|
13
22
|
* 等于把"没路由上"伪装成"它属于默认根"。
|
|
14
23
|
*
|
|
15
24
|
* 三条硬约束:
|
|
@@ -18,9 +27,15 @@
|
|
|
18
27
|
* 3. **改名而不是删除**:`.disable` 是凭证,删了 P2 与人都无从对齐。
|
|
19
28
|
*/
|
|
20
29
|
import { dirname } from 'node:path';
|
|
21
|
-
import { readFile, rename, stat } from 'node:fs/promises';
|
|
30
|
+
import { readFile, rename, rm, stat } from 'node:fs/promises';
|
|
22
31
|
import { atomicWriteText, ensureDir } from '../util/io.js';
|
|
23
|
-
import { disablePathOf, digest, recordDigest, renderRecord, targetFor } from './memory-md.js';
|
|
32
|
+
import { disablePathOf, digest, isRetiredRecord, recordDigest, renderRecord, targetFor } from './memory-md.js';
|
|
33
|
+
/**
|
|
34
|
+
* 退场判据的单一真源在 `memory-md.ts`(导出侧与这里必须共用一个),本模块只转发,
|
|
35
|
+
* 免得既有调用方拿不到。另起一套(比如再叠上 `persistence`)只会让"列表说它退了、
|
|
36
|
+
* 文件却没后缀"重新变成常态。
|
|
37
|
+
*/
|
|
38
|
+
export { isRetiredRecord };
|
|
24
39
|
/** 存在性判定:一次 `stat`,任何错误都当"不存在"(权限/竞态都不该让失效动作失败)。 */
|
|
25
40
|
async function exists(p) {
|
|
26
41
|
try {
|
|
@@ -41,8 +56,14 @@ async function exists(p) {
|
|
|
41
56
|
* ⚠️ `fileDigest` **取磁盘上的真实字节**,不取我们手上那份渲染结果:改名分支里
|
|
42
57
|
* 旧文件是“未退场时导出的那一版”(不带 `retired`),拿新渲染的摘要当基线,
|
|
43
58
|
* 下一轮对账就会把“我们自己改的文件”报成“人改过文件”。
|
|
59
|
+
*
|
|
60
|
+
* @param persist - 默认 true(单条失效联动:写完就该落盘)。**批量收敛必须传 false**:
|
|
61
|
+
* 账本是整本 JSON 的原子重写,每条都 `flush()` 就把「窗口内只记脏、收尾落一次」
|
|
62
|
+
* 的设计退化成 O(n²)(真机 912 条回填会被墙钟 `timeBudgetMs` 拦成好几次点击)。
|
|
63
|
+
*
|
|
64
|
+
* 导出给 `memory-md-sync.ts` 的改名跟随用——「取磁盘真实字节当基线」这条纪律只该有一份。
|
|
44
65
|
*/
|
|
45
|
-
async function moveLedger(ledger, fromAbs, toAbs, r, fallbackText) {
|
|
66
|
+
export async function moveLedger(ledger, fromAbs, toAbs, r, fallbackText, persist = true) {
|
|
46
67
|
const prev = ledger.get(fromAbs);
|
|
47
68
|
const st = await stat(toAbs).catch(() => undefined);
|
|
48
69
|
const actual = await readFile(toAbs, 'utf8').catch(() => fallbackText);
|
|
@@ -59,7 +80,8 @@ async function moveLedger(ledger, fromAbs, toAbs, r, fallbackText) {
|
|
|
59
80
|
});
|
|
60
81
|
if (prev !== undefined)
|
|
61
82
|
ledger.delete(fromAbs);
|
|
62
|
-
|
|
83
|
+
if (persist)
|
|
84
|
+
await ledger.flush();
|
|
63
85
|
}
|
|
64
86
|
/**
|
|
65
87
|
* 把一条已退场记忆的投射文件标成 `.disable`。
|
|
@@ -70,8 +92,6 @@ async function moveLedger(ledger, fromAbs, toAbs, r, fallbackText) {
|
|
|
70
92
|
export async function disableProjectionFile(deps) {
|
|
71
93
|
const { record: r, ledger, logger } = deps;
|
|
72
94
|
const ws = (r.workspaceId ?? '').trim();
|
|
73
|
-
if (ws.length === 0)
|
|
74
|
-
return 'no-workspace';
|
|
75
95
|
try {
|
|
76
96
|
const target = targetFor(r, deps.cfgRoot, deps.dataDir, logger);
|
|
77
97
|
const abs = target.absPath;
|
|
@@ -88,11 +108,28 @@ export async function disableProjectionFile(deps) {
|
|
|
88
108
|
return 'already';
|
|
89
109
|
}
|
|
90
110
|
if (await exists(abs)) {
|
|
91
|
-
|
|
111
|
+
try {
|
|
112
|
+
await rename(abs, dis);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
// 竞态兜底:存在性检查和改名之间,收口挂钩可能已经把这个文件改走了(同一批次
|
|
116
|
+
// 双触发时两条路径都会走到这里)。改成功过的文件再改名报 ENOENT——盘上已经是
|
|
117
|
+
// 想要的那份,该报 `already`,不该刷 warn(真机日志里噪声会淹掉真失败)。
|
|
118
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT' && await exists(dis)) {
|
|
119
|
+
if (ledger.get(dis) === undefined)
|
|
120
|
+
await moveLedger(ledger, abs, dis, r, text);
|
|
121
|
+
return 'already';
|
|
122
|
+
}
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
92
125
|
await moveLedger(ledger, abs, dis, r, text);
|
|
93
126
|
logger?.info(`[memory] 声明过时 → 投射文件已改名: ${dis}`);
|
|
94
127
|
return 'renamed';
|
|
95
128
|
}
|
|
129
|
+
// 盘上什么都没有:无归属时不凭空在单根造文件(原「宁缺勿猜」红线只保留在这一分支)。
|
|
130
|
+
// 旧写法是在函数入头就早退,于是「导出能写进单根、退场却改不了名」——写得出收不回。
|
|
131
|
+
if (ws.length === 0)
|
|
132
|
+
return 'no-workspace';
|
|
96
133
|
await ensureDir(dirname(dis));
|
|
97
134
|
await atomicWriteText(dis, text);
|
|
98
135
|
await moveLedger(ledger, abs, dis, r, text);
|
|
@@ -104,3 +141,162 @@ export async function disableProjectionFile(deps) {
|
|
|
104
141
|
return 'failed';
|
|
105
142
|
}
|
|
106
143
|
}
|
|
144
|
+
/** 让出事件循环一次:收敛是长任务,不能独占 tick(面板会僵)。 */
|
|
145
|
+
function tick() {
|
|
146
|
+
return new Promise((resolveTick) => { setImmediate(resolveTick); });
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* 盘上这份是不是被人改过:拿真实字节与账本的 `fileDigest` 比。
|
|
150
|
+
*
|
|
151
|
+
* 没账(从没导出过 / 首轮基线未建)时返回 false——“没基线”不等于“被改过”。
|
|
152
|
+
* 注意这里只诊断不阻断:改名只动后缀、不动字节,所以人改的内容跟着文件一起走,
|
|
153
|
+
* 丢不了;计数与重新记账是为了让下一遍对账能把这件事说清。
|
|
154
|
+
*/
|
|
155
|
+
async function looksEdited(ledger, abs) {
|
|
156
|
+
const prev = ledger.get(abs);
|
|
157
|
+
if (prev === undefined)
|
|
158
|
+
return false;
|
|
159
|
+
const actual = await readFile(abs, 'utf8').catch(() => undefined);
|
|
160
|
+
return actual !== undefined && digest(actual) !== prev.fileDigest;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* 后缀不变式收敛:把「库里的有/无效」与「文件的 `.memory.md` / `.memory.md.disable`」对齐。
|
|
164
|
+
*
|
|
165
|
+
* 为什么挂在导出后面而不是各个退场调用点:那 8 处 `.retire()` 分布在手动软删、批量退场、
|
|
166
|
+
* 合并取代、冲突判负、slot 连带等路径上,事件挂钩注定会漏(真机实测:920 条退场记录
|
|
167
|
+
* 只有 8 个 `.disable`,全部来自召回面板那一条路径)。收敛器只依赖三个可观测事实:
|
|
168
|
+
* 文件存在性、当前后缀、指纹一致性。
|
|
169
|
+
*
|
|
170
|
+
* 护栏与导出同一套(`maxRecords` 只数碰盘动作 + `timeBudgetMs` 墙钟),所以第一次点可能只
|
|
171
|
+
* 收敛一部分,`capped`/`remaining` 会告诉你还要点几次。
|
|
172
|
+
*/
|
|
173
|
+
export async function convergeDisabledSuffix(deps) {
|
|
174
|
+
const { records, ledger, logger } = deps;
|
|
175
|
+
const startedAt = (deps.now ?? Date.now)();
|
|
176
|
+
const rep = {
|
|
177
|
+
scanned: 0, renamed: 0, revived: 0, edited: 0, already: 0, both: 0, absent: 0, skipped: 0, remaining: 0, capped: false,
|
|
178
|
+
};
|
|
179
|
+
let worked = 0;
|
|
180
|
+
for (let i = 0; i < records.length; i++) {
|
|
181
|
+
// 两条护栏都在循环顶部查:写在末尾会被 `continue` 跳过(exportRecords 自家测试抓过)。
|
|
182
|
+
if (worked >= deps.maxRecords || (deps.now ?? Date.now)() - startedAt >= deps.timeBudgetMs) {
|
|
183
|
+
rep.capped = true;
|
|
184
|
+
rep.remaining = records.length - i;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
const r = records[i];
|
|
188
|
+
if (r === undefined)
|
|
189
|
+
continue;
|
|
190
|
+
rep.scanned++;
|
|
191
|
+
const target = targetFor(r, deps.cfgRoot, deps.dataDir, logger);
|
|
192
|
+
const abs = target.absPath;
|
|
193
|
+
if (abs === undefined) {
|
|
194
|
+
rep.skipped++;
|
|
195
|
+
logger?.warn(`[memory] 后缀收敛跳过(路径越界): ${r.id} → ${target.relPath}`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const dis = disablePathOf(abs);
|
|
199
|
+
try {
|
|
200
|
+
const hasDis = await exists(dis);
|
|
201
|
+
const hasAbs = await exists(abs);
|
|
202
|
+
if (isRetiredRecord(r)) {
|
|
203
|
+
if (hasDis && hasAbs) {
|
|
204
|
+
// 两份都在:可能是上一遍改名中途崩了,也可能是人复制了一份。删哪份都是不可逆判断,
|
|
205
|
+
// 所以只报账,交孤儿归档/人裁决。
|
|
206
|
+
rep.both++;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (hasDis) {
|
|
210
|
+
// 后缀已对上;仍要保证账本认得它(改完文件就崩的残留态)。
|
|
211
|
+
if (ledger.get(dis) === undefined) {
|
|
212
|
+
await moveLedger(ledger, abs, dis, r, renderRecord(r).text, false);
|
|
213
|
+
worked++;
|
|
214
|
+
}
|
|
215
|
+
rep.already++;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!hasAbs) {
|
|
219
|
+
rep.absent++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (await looksEdited(ledger, abs))
|
|
223
|
+
rep.edited++;
|
|
224
|
+
try {
|
|
225
|
+
await rename(abs, dis);
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
// 与 `disableProjectionFile` 同一条竞态兜底:双触发时另一条路径可能已把这份改走了。
|
|
229
|
+
// 盘上已是想要的那份 → 算 `already`,不刷 warn(否则噪声会淹掉真失败)。
|
|
230
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT' && await exists(dis)) {
|
|
231
|
+
if (ledger.get(dis) === undefined)
|
|
232
|
+
await moveLedger(ledger, abs, dis, r, renderRecord(r).text, false);
|
|
233
|
+
rep.already++;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
await moveLedger(ledger, abs, dis, r, renderRecord(r).text, false);
|
|
239
|
+
rep.renamed++;
|
|
240
|
+
worked++;
|
|
241
|
+
await tick();
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// 有效记录:后缀应该是 `.memory.md`。
|
|
245
|
+
if (hasDis && !hasAbs) {
|
|
246
|
+
const entry = ledger.get(dis);
|
|
247
|
+
if (entry === undefined || entry.id !== r.id) {
|
|
248
|
+
// 人手造的 `.disable` 是文件侧退场凭证(recall-feedback 的文件侧对偶)。
|
|
249
|
+
// P2 关着时库里不知道这件事,自动改回去等于揉掉人的裁决 → 只报账不动盘。
|
|
250
|
+
rep.both++;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
await rename(dis, abs);
|
|
254
|
+
await moveLedger(ledger, dis, abs, r, renderRecord(r).text, false);
|
|
255
|
+
rep.revived++;
|
|
256
|
+
worked++;
|
|
257
|
+
await tick();
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (hasDis && hasAbs) {
|
|
261
|
+
// 有效记录两份都在。多数不是人手复制,而是「恢复后导出补写了非退场路径,
|
|
262
|
+
// `.disable` 那份还留着」——不收这一格,恢复一轮就会永久多出双份。
|
|
263
|
+
// 删得同时满足三个条件才算“机器自己造的陈年标记”,不丢任何信息:
|
|
264
|
+
// ① 凭证确实是我们给**这条记录**写的(账本同 id);② 它自我们写下后没被人改过
|
|
265
|
+
//(磁盘字节 == 账本 fileDigest);③ `.memory.md` 这份就是我们当前渲染的那份
|
|
266
|
+
//(人手放的一份不会正好等于渲染结果)。任一不成立 → 只报账,交人裁决。
|
|
267
|
+
const entry = ledger.get(dis);
|
|
268
|
+
const disText = await readFile(dis, 'utf8').catch(() => undefined);
|
|
269
|
+
const absText = await readFile(abs, 'utf8').catch(() => undefined);
|
|
270
|
+
if (entry !== undefined && entry.id === r.id
|
|
271
|
+
&& disText !== undefined && digest(disText) === entry.fileDigest
|
|
272
|
+
&& absText !== undefined && absText === renderRecord(r).text) {
|
|
273
|
+
await rm(dis);
|
|
274
|
+
ledger.delete(dis);
|
|
275
|
+
rep.revived++;
|
|
276
|
+
worked++;
|
|
277
|
+
await tick();
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
rep.both++;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (hasAbs) {
|
|
284
|
+
rep.already++;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
rep.absent++;
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
// 单条失败不阻断整批:下一遍还会重算(收敛是无状态的)。
|
|
291
|
+
rep.skipped++;
|
|
292
|
+
logger?.warn(`[memory] 后缀收敛失败(下轮重试): ${abs} - ${err instanceof Error ? err.message : String(err)}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// 收尾落一次账:循环里只记脏(`moveLedger` 的 `persist=false`)。中途崩了就丢本轮账,
|
|
296
|
+
// 但收敛器无状态且幂等——下一遍重算即可,所以不值得为它每条写一次整本 JSON。
|
|
297
|
+
await ledger.flush();
|
|
298
|
+
if (rep.renamed + rep.revived + rep.both + rep.edited > 0) {
|
|
299
|
+
logger?.info(`[memory] 后缀收敛:改名 ${rep.renamed}(其中人改过 ${rep.edited})· 改回 ${rep.revived} · 已对 ${rep.already} · 冲突 ${rep.both} · 无文件 ${rep.absent}${rep.capped ? ` · 剩余 ${rep.remaining}` : ''}`);
|
|
300
|
+
}
|
|
301
|
+
return rep;
|
|
302
|
+
}
|