dsh-lite-memory 0.3.0 → 0.15.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/README.md +73 -17
- package/TRIGGERS.md +18 -14
- package/anchor.js +518 -0
- package/bench/bench.mjs +145 -0
- package/bench/evolution.mjs +123 -0
- package/client.js +1519 -33
- package/docs/BENCH.md +74 -0
- package/docs/RELEASE-0.11.0.md +27 -0
- package/docs/RELEASE-0.11.1.md +11 -0
- package/docs/RELEASE-0.12.0.md +15 -0
- package/docs/RELEASE-0.12.1.md +17 -0
- package/docs/RELEASE-0.12.2.md +17 -0
- package/docs/RELEASE-0.13.0.md +7 -0
- package/docs/RELEASE-0.14.0.md +7 -0
- package/docs/RELEASE-0.15.0.md +8 -0
- package/index.js +2080 -76
- package/learning.js +585 -0
- package/package.json +10 -2
- package/state.js +1476 -0
- package/store.js +2967 -244
- package/test/anchor.test.mjs +436 -0
- package/test/client.test.mjs +114 -0
- package/test/contract.test.mjs +880 -0
- package/test/control.test.mjs +278 -0
- package/test/crossing.test.mjs +372 -0
- package/test/evolution-read.test.mjs +448 -0
- package/test/fixtures/legacy-v1/archive/mem_444444444444.json +18 -0
- package/test/fixtures/legacy-v1/entries/mem_111111111111.md +2 -0
- package/test/fixtures/legacy-v1/entries/mem_222222222222.md +1 -0
- package/test/fixtures/legacy-v1/entries/mem_333333333333.md +1 -0
- package/test/fixtures/legacy-v1/index.json +63 -0
- package/test/learning.test.mjs +772 -0
- package/test/lexical.test.mjs +33 -0
- package/test/recall.test.mjs +449 -0
- package/test/scenario.test.mjs +184 -0
- package/test/state.test.mjs +485 -0
- package/test/store.test.mjs +816 -115
- package/test/view.test.mjs +257 -0
- package/view.js +616 -0
package/state.js
ADDED
|
@@ -0,0 +1,1476 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
export const STATE_VERSION = 4;
|
|
8
|
+
export const STATE_FORMAT = 'dsh-lite-memory/state@4';
|
|
9
|
+
/** 3 版与 2 版照常读,下一次写入升到 4 版;旧程序看到 4 版会拒绝写,不会丢掉实验标记。 */
|
|
10
|
+
export const LEGACY_STATE_FORMATS = ['dsh-lite-memory/state@3', 'dsh-lite-memory/state@2'];
|
|
11
|
+
export const LEGACY_STATE_VERSIONS = [3, 2];
|
|
12
|
+
|
|
13
|
+
/** 一期只有一个实验开关,策略版本不认时停止实验而不是猜测兼容。 */
|
|
14
|
+
export const EVOLUTION_POLICY_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
function emptyTasks() {
|
|
17
|
+
return { revision: 0, defaults: null, sessions: {}, episodes: {} };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 学习账本只留有限次数、近期用量与回执;不是事件日志,也不无限增长。 */
|
|
21
|
+
export function emptyLearning() {
|
|
22
|
+
return { attempts: {}, recent: [], receipts: [] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 坏数据按空账本处理;学习账本坏了只停整理,不挡住读取已学规则。 */
|
|
26
|
+
export function normalizeLearning(raw) {
|
|
27
|
+
const out = emptyLearning();
|
|
28
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
|
29
|
+
if (raw.attempts && typeof raw.attempts === 'object' && !Array.isArray(raw.attempts)) {
|
|
30
|
+
const rows = Object.entries(raw.attempts)
|
|
31
|
+
.filter(([id, row]) => id && row && typeof row === 'object' && Number.isInteger(row.count) && row.count > 0)
|
|
32
|
+
.sort((a, b) => Date.parse(b[1].lastAt || 0) - Date.parse(a[1].lastAt || 0))
|
|
33
|
+
.slice(0, 32);
|
|
34
|
+
for (const [id, row] of rows) {
|
|
35
|
+
out.attempts[id] = {
|
|
36
|
+
count: Math.min(2, row.count),
|
|
37
|
+
lastAt: typeof row.lastAt === 'string' ? row.lastAt : '',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (Array.isArray(raw.recent)) {
|
|
42
|
+
out.recent = raw.recent.filter((value) => typeof value === 'string' && value).slice(-32);
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(raw.receipts)) {
|
|
45
|
+
out.receipts = raw.receipts
|
|
46
|
+
.filter((row) => row && typeof row === 'object' && typeof row.operationId === 'string')
|
|
47
|
+
.slice(-64)
|
|
48
|
+
.map((row) => JSON.parse(JSON.stringify(row)));
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function newEvolution() {
|
|
54
|
+
return {
|
|
55
|
+
enabled: false,
|
|
56
|
+
epoch: '',
|
|
57
|
+
policyVersion: EVOLUTION_POLICY_VERSION,
|
|
58
|
+
feedbackGeneration: 0,
|
|
59
|
+
tasks: emptyTasks(),
|
|
60
|
+
learning: emptyLearning(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 任务锚点只是实验内部状态;坏数据按空表处理,不让它挡住整层记忆读取。 */
|
|
65
|
+
function normalizeTasks(raw) {
|
|
66
|
+
const out = emptyTasks();
|
|
67
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
|
68
|
+
out.revision = Number.isInteger(raw.revision) && raw.revision >= 0 ? raw.revision : 0;
|
|
69
|
+
if (raw.defaults && typeof raw.defaults === 'object' && !Array.isArray(raw.defaults)) {
|
|
70
|
+
out.defaults = JSON.parse(JSON.stringify(raw.defaults));
|
|
71
|
+
}
|
|
72
|
+
for (const key of ['sessions', 'episodes']) {
|
|
73
|
+
if (raw[key] && typeof raw[key] === 'object' && !Array.isArray(raw[key])) {
|
|
74
|
+
out[key] = JSON.parse(JSON.stringify(raw[key]));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 缺字段即关闭;未知或不兼容版本一律按关闭处理,也不改写现场。 */
|
|
81
|
+
export function evolutionOf(state) {
|
|
82
|
+
const raw = state && state.experimental && state.experimental.projectEvolution;
|
|
83
|
+
if (raw === undefined) {
|
|
84
|
+
return { enabled: false, epoch: '', policyVersion: EVOLUTION_POLICY_VERSION, feedbackGeneration: 0, tasks: emptyTasks(), compatible: true };
|
|
85
|
+
}
|
|
86
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
87
|
+
return { enabled: false, epoch: '', policyVersion: 0, feedbackGeneration: 0, tasks: emptyTasks(), learning: emptyLearning(), compatible: false };
|
|
88
|
+
}
|
|
89
|
+
const policyVersion = Number.isInteger(raw.policyVersion) ? raw.policyVersion : 0;
|
|
90
|
+
const compatible = policyVersion === EVOLUTION_POLICY_VERSION;
|
|
91
|
+
return {
|
|
92
|
+
enabled: compatible && raw.enabled === true,
|
|
93
|
+
epoch: typeof raw.epoch === 'string' ? raw.epoch : '',
|
|
94
|
+
policyVersion,
|
|
95
|
+
feedbackGeneration: Number.isInteger(raw.feedbackGeneration) ? raw.feedbackGeneration : 0,
|
|
96
|
+
tasks: normalizeTasks(raw.tasks),
|
|
97
|
+
learning: normalizeLearning(raw.learning),
|
|
98
|
+
compatible,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 项目记忆总开关:缺字段按开启处理,老状态不用迁移。
|
|
104
|
+
* 它管这一层的所有读取、写入、召回和学习;全局层仍按发起项目判断,不跟着关。
|
|
105
|
+
*/
|
|
106
|
+
export function controlOf(state) {
|
|
107
|
+
const raw = state && state.control;
|
|
108
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
109
|
+
return { enabled: true, epoch: '', changedAt: '', compatible: true };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
enabled: raw.enabled !== false,
|
|
113
|
+
epoch: typeof raw.epoch === 'string' ? raw.epoch : '',
|
|
114
|
+
changedAt: typeof raw.changedAt === 'string' ? raw.changedAt : '',
|
|
115
|
+
compatible: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 关总开关只是收回授权:候选、试用、生效规则、证据、默认绑定和已用额度都保留,
|
|
121
|
+
* 只清运行层(在途整理、临时锚点、旧召回快照)。重新打开也换代次,关闭前排队的任务不能补交。
|
|
122
|
+
* 自进化跟随总开关一起停:重新打开记忆后,自进化仍是关闭的,等用户单独开启。
|
|
123
|
+
*/
|
|
124
|
+
export function applyControl(draft, enabled) {
|
|
125
|
+
const now = new Date().toISOString();
|
|
126
|
+
const current = controlOf(draft);
|
|
127
|
+
const next = Boolean(enabled);
|
|
128
|
+
if (current.enabled === next) return { changed: false, enabled: next };
|
|
129
|
+
const config = { enabled: next, epoch: crypto.randomUUID(), changedAt: now };
|
|
130
|
+
draft.control = config;
|
|
131
|
+
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
132
|
+
if (evolution && typeof evolution === 'object' && !Array.isArray(evolution)) {
|
|
133
|
+
const ledger = normalizeLearning(evolution.learning);
|
|
134
|
+
evolution.enabled = false;
|
|
135
|
+
evolution.epoch = crypto.randomUUID();
|
|
136
|
+
evolution.feedbackGeneration = Number.isInteger(evolution.feedbackGeneration) ? evolution.feedbackGeneration + 1 : 1;
|
|
137
|
+
evolution.changedAt = now;
|
|
138
|
+
/** 默认绑定属于已确认结果;临时锚点和在途观察不属于。 */
|
|
139
|
+
evolution.tasks = { ...emptyTasks(), ...(evolution.tasks && evolution.tasks.defaults ? { defaults: evolution.tasks.defaults } : {}) };
|
|
140
|
+
/** 额度只增不减:次数与回执留着,最近观察清掉。 */
|
|
141
|
+
evolution.learning = { ...ledger, recent: [] };
|
|
142
|
+
}
|
|
143
|
+
return { changed: true, enabled: next, epoch: config.epoch };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 自进化开关只停运行层:规则、候选、试用、证据和额度都保留,恢复时接着用。 */
|
|
147
|
+
export function applyEvolution(draft, enabled) {
|
|
148
|
+
const now = new Date().toISOString();
|
|
149
|
+
const current = evolutionOf(draft);
|
|
150
|
+
if (!current.compatible) throw new StateError('invalid', '实验配置版本不认,先不要改动开关');
|
|
151
|
+
const next = Boolean(enabled);
|
|
152
|
+
if (current.enabled === next) return { changed: false, enabled: next };
|
|
153
|
+
const config = {
|
|
154
|
+
enabled: next,
|
|
155
|
+
epoch: crypto.randomUUID(),
|
|
156
|
+
policyVersion: EVOLUTION_POLICY_VERSION,
|
|
157
|
+
feedbackGeneration: current.feedbackGeneration + 1,
|
|
158
|
+
changedAt: now,
|
|
159
|
+
/** 开与关都让上一轮锚点失效;已经确认的项目默认保留,它不属于临时作品状态。 */
|
|
160
|
+
tasks: { ...emptyTasks(), ...(current.tasks && current.tasks.defaults ? { defaults: current.tasks.defaults } : {}) },
|
|
161
|
+
/** 只清在途观察;已学规则的来源、版本和候选都在条目里,关开不动它们。 */
|
|
162
|
+
learning: { ...normalizeLearning(current.learning), recent: [] },
|
|
163
|
+
};
|
|
164
|
+
draft.experimental = { ...(draft.experimental || {}), projectEvolution: config };
|
|
165
|
+
/** 显式关闭自进化:试用转暂停待复核,不删除候选与证据;重新打开后可逐条恢复。 */
|
|
166
|
+
if (!next) {
|
|
167
|
+
for (const entry of draft.entries) {
|
|
168
|
+
const learning = entry && entry.maintenance && entry.maintenance.learning;
|
|
169
|
+
if (learning && learning.state === 'trial') {
|
|
170
|
+
learning.state = 'paused';
|
|
171
|
+
learning.blockedReason = 'needs_revalidation';
|
|
172
|
+
learning.pausedAt = now;
|
|
173
|
+
learning.pausedBy = 'experiment_off';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { changed: true, enabled: next, epoch: config.epoch };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 容量与预算都归这里管:写入路径只报「要写什么」,能不能落地由 state 决定。 */
|
|
181
|
+
export const LIMITS = {
|
|
182
|
+
entries: 200,
|
|
183
|
+
globalEntries: 200,
|
|
184
|
+
hardEntries: 1000,
|
|
185
|
+
entryBytes: 1200,
|
|
186
|
+
recordBytes: 10 * 1024,
|
|
187
|
+
totalBytes: 512 * 1024,
|
|
188
|
+
historyBytes: 512 * 1024,
|
|
189
|
+
stateBytes: 2 * 1024 * 1024,
|
|
190
|
+
titleChars: 60,
|
|
191
|
+
whenChars: 60,
|
|
192
|
+
summaryChars: 60,
|
|
193
|
+
tagChars: 20,
|
|
194
|
+
tags: 5,
|
|
195
|
+
lockWaitMs: 500,
|
|
196
|
+
suggestionWindowMs: 48 * 60 * 60 * 1000,
|
|
197
|
+
suggestionTtlMs: 48 * 60 * 60 * 1000,
|
|
198
|
+
pending: 20,
|
|
199
|
+
pendingBytes: 64 * 1024,
|
|
200
|
+
receipts: 256,
|
|
201
|
+
receiptTtlMs: 7 * 24 * 60 * 60 * 1000,
|
|
202
|
+
suppressions: 64,
|
|
203
|
+
suppressionTtlMs: 7 * 24 * 60 * 60 * 1000,
|
|
204
|
+
citations: 20,
|
|
205
|
+
citationSessions: 20,
|
|
206
|
+
citationTtlMs: 30 * 24 * 60 * 60 * 1000,
|
|
207
|
+
citationBytes: 16 * 1024,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const LEGACY_MARKERS = ['index.json', 'entries', 'archive'];
|
|
211
|
+
|
|
212
|
+
/** 失败带上可判定的 code,宿主据此给出状态,不靠匹配文案。 */
|
|
213
|
+
export class StateError extends Error {
|
|
214
|
+
constructor(code, message) {
|
|
215
|
+
super(message);
|
|
216
|
+
this.name = 'StateError';
|
|
217
|
+
this.code = code;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function memoryRoot() {
|
|
222
|
+
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
223
|
+
return path.join(home, 'lite-memory');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function realpathOf(target) {
|
|
227
|
+
try {
|
|
228
|
+
return fs.realpathSync.native ? fs.realpathSync.native(target) : fs.realpathSync(target);
|
|
229
|
+
} catch {
|
|
230
|
+
return path.resolve(target);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 用户显式配置的项目根:只认这一个键,不猜宿主内部字段。 */
|
|
235
|
+
function configuredRoot() {
|
|
236
|
+
const file = path.join(memoryRoot(), 'config.json');
|
|
237
|
+
let data;
|
|
238
|
+
try {
|
|
239
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
240
|
+
if (Buffer.byteLength(raw) > 8 * 1024) return '';
|
|
241
|
+
data = JSON.parse(raw);
|
|
242
|
+
} catch {
|
|
243
|
+
return '';
|
|
244
|
+
}
|
|
245
|
+
const value = data && typeof data.projectRoot === 'string' ? data.projectRoot.trim() : '';
|
|
246
|
+
return value ? realpathOf(path.resolve(value)) : '';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function gitRoot(cwd) {
|
|
250
|
+
try {
|
|
251
|
+
const out = execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
|
|
252
|
+
encoding: 'utf8',
|
|
253
|
+
timeout: 2000,
|
|
254
|
+
maxBuffer: 64 * 1024,
|
|
255
|
+
/* stderr 要留着:git 明说「不在仓库里」是可信结果,不能和超时/失败混成未确认。 */
|
|
256
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
257
|
+
});
|
|
258
|
+
const line = String(out || '').trim().split('\n')[0];
|
|
259
|
+
if (line) return { root: realpathOf(path.resolve(line)) };
|
|
260
|
+
return { root: '', reason: 'failed' };
|
|
261
|
+
} catch (error) {
|
|
262
|
+
const text = `${error && error.stderr ? error.stderr : ''}${error && error.message ? error.message : ''}`;
|
|
263
|
+
if (/not a git repository|不是.*git/i.test(text)) return { root: '', reason: 'not_git' };
|
|
264
|
+
return { root: '', reason: 'failed' };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const rootCache = new Map();
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 一个仓库根一份记忆:子目录、符号链接、worktree 都归到同一个根。
|
|
272
|
+
* 拿不准时(git 超时/不可信 cwd)不新建命名空间,交给调用方决定要不要写。
|
|
273
|
+
*/
|
|
274
|
+
export function resolveRoot(cwd, options = {}) {
|
|
275
|
+
const requested = realpathOf(path.resolve(cwd || process.cwd()));
|
|
276
|
+
const explicit = options.projectRoot ? realpathOf(path.resolve(options.projectRoot)) : '';
|
|
277
|
+
if (explicit) return { root: explicit, source: 'configured', confirmed: true };
|
|
278
|
+
const configured = configuredRoot();
|
|
279
|
+
/** 覆盖根只对它所在的子树生效:别的目录不能被一个全局配置并成同一个项目。 */
|
|
280
|
+
if (configured && (requested === configured || requested.startsWith(`${configured}${path.sep}`))) {
|
|
281
|
+
return { root: configured, source: 'configured', confirmed: true };
|
|
282
|
+
}
|
|
283
|
+
const cacheKey = `${options.workspaceDir || ''}\n${requested}`;
|
|
284
|
+
if (rootCache.has(cacheKey)) return rootCache.get(cacheKey);
|
|
285
|
+
let resolved;
|
|
286
|
+
if (options.workspaceDir) {
|
|
287
|
+
resolved = { root: realpathOf(path.resolve(options.workspaceDir)), source: 'workspace', confirmed: true };
|
|
288
|
+
} else {
|
|
289
|
+
const git = gitRoot(requested);
|
|
290
|
+
if (git.root) resolved = { root: git.root, source: 'git', confirmed: true };
|
|
291
|
+
else if (git.reason === 'not_git') resolved = { root: requested, source: 'cwd', confirmed: true };
|
|
292
|
+
else resolved = { root: requested, source: 'cwd', confirmed: false };
|
|
293
|
+
}
|
|
294
|
+
if (rootCache.size > 200) rootCache.clear();
|
|
295
|
+
rootCache.set(cacheKey, resolved);
|
|
296
|
+
return resolved;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function scopeOf(cwd, options = {}) {
|
|
300
|
+
const requested = path.resolve(cwd || process.cwd());
|
|
301
|
+
const resolved = resolveRoot(requested, options);
|
|
302
|
+
const root = resolved.root;
|
|
303
|
+
const hash = crypto.createHash('sha1').update(root).digest('hex').slice(0, 8);
|
|
304
|
+
const label = path.basename(root).replace(/[^\w.-]+/g, '-').slice(0, 32) || 'root';
|
|
305
|
+
return {
|
|
306
|
+
key: `${label}-${hash}`,
|
|
307
|
+
dir: path.join(memoryRoot(), `${label}-${hash}`),
|
|
308
|
+
cwd: root,
|
|
309
|
+
requestedCwd: requested,
|
|
310
|
+
rootSource: resolved.source,
|
|
311
|
+
confirmed: resolved.confirmed !== false,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** 跨项目通用的那层:用户说「以后所有项目都这样」才写这里。 */
|
|
316
|
+
export function globalScope() {
|
|
317
|
+
return { key: 'global', dir: path.join(memoryRoot(), 'global'), cwd: '', rootSource: 'global', confirmed: true };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 管理面枚举:只读已有 state.json 的项目层,不迁移旧格式、不创建目录。
|
|
322
|
+
* 损坏或读不出的层跳过;列表按更新时间倒序,limit 防一次扫太多。
|
|
323
|
+
*/
|
|
324
|
+
export function knownProjectScopes(limit = 40) {
|
|
325
|
+
const root = memoryRoot();
|
|
326
|
+
let names;
|
|
327
|
+
try {
|
|
328
|
+
names = fs.readdirSync(root, { withFileTypes: true });
|
|
329
|
+
} catch {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
const rows = [];
|
|
333
|
+
for (const item of names) {
|
|
334
|
+
if (!item.isDirectory() || item.name === 'global' || item.name === 'sessions' || item.name.startsWith('.')) continue;
|
|
335
|
+
const dir = path.join(root, item.name);
|
|
336
|
+
const file = path.join(dir, 'state.json');
|
|
337
|
+
try {
|
|
338
|
+
if (!fs.statSync(file).isFile()) continue;
|
|
339
|
+
} catch {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
let state;
|
|
343
|
+
try {
|
|
344
|
+
state = readEnvelope(file);
|
|
345
|
+
} catch {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (!state) continue;
|
|
349
|
+
const entries = Array.isArray(state.entries) ? state.entries : [];
|
|
350
|
+
const active = entries.filter((entry) => entry.status !== 'archived');
|
|
351
|
+
const evolution = state.experimental && state.experimental.projectEvolution ? state.experimental.projectEvolution : {};
|
|
352
|
+
rows.push({
|
|
353
|
+
key: item.name,
|
|
354
|
+
dir,
|
|
355
|
+
cwd: typeof state.cwd === 'string' ? state.cwd : '',
|
|
356
|
+
updatedAt: state.updatedAt || '',
|
|
357
|
+
entries: active.length,
|
|
358
|
+
archived: entries.length - active.length,
|
|
359
|
+
experimental: active.filter((entry) => entry.origin === 'project_evolution' || Boolean(entry.learning)).length,
|
|
360
|
+
enabled: evolution.enabled === true,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
rows.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
364
|
+
return rows.slice(0, Math.max(1, limit));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const stateFile = (scope) => path.join(scope.dir, 'state.json');
|
|
368
|
+
const prevFile = (scope) => path.join(scope.dir, 'state.prev.json');
|
|
369
|
+
const lockFile = (scope) => path.join(scope.dir, '.lock');
|
|
370
|
+
const migrationIntentFile = (scope) => path.join(scope.dir, 'migration.intent.json');
|
|
371
|
+
const purgeIntentFile = (scope) => path.join(scope.dir, 'purge.intent.json');
|
|
372
|
+
|
|
373
|
+
/** 搬移的协调文件放在根目录:它跨两层,不属于任何一层的状态包。 */
|
|
374
|
+
export const MOVE_FORMAT = 'dsh-lite-memory/move@1';
|
|
375
|
+
const moveIntentFile = () => path.join(memoryRoot(), 'move.intent.json');
|
|
376
|
+
|
|
377
|
+
/** 恢复流程要直接比对 current/prev:只暴露这两个固定文件名。 */
|
|
378
|
+
export function stateFiles(scope) {
|
|
379
|
+
return { current: stateFile(scope), prev: prevFile(scope) };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function sleepSync(ms) {
|
|
383
|
+
try {
|
|
384
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
385
|
+
} catch {
|
|
386
|
+
/* no Atomics: the loop below still backs off via the deadline */
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const lockDepth = new Map();
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 跨进程一把锁:.lock 用 wx 创建,写 owner token,只释放自己那把。
|
|
394
|
+
* 同进程内允许重入(读—改—写在同一个锁窗口里连续调用)。
|
|
395
|
+
*/
|
|
396
|
+
export function withScopeLock(scope, fn) {
|
|
397
|
+
const dir = scope.dir;
|
|
398
|
+
const depth = lockDepth.get(dir) || 0;
|
|
399
|
+
if (depth > 0) {
|
|
400
|
+
lockDepth.set(dir, depth + 1);
|
|
401
|
+
try {
|
|
402
|
+
return fn();
|
|
403
|
+
} finally {
|
|
404
|
+
lockDepth.set(dir, depth);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
408
|
+
const file = lockFile(scope);
|
|
409
|
+
const token = `${process.pid}:${crypto.randomBytes(4).toString('hex')}`;
|
|
410
|
+
const deadline = Date.now() + LIMITS.lockWaitMs;
|
|
411
|
+
let fd = null;
|
|
412
|
+
while (fd === null) {
|
|
413
|
+
try {
|
|
414
|
+
fd = fs.openSync(file, 'wx');
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (error.code !== 'EEXIST') throw new StateError('unavailable', `记忆库加锁失败(${error.code || 'IO'}):${file}`);
|
|
417
|
+
if (Date.now() > deadline) {
|
|
418
|
+
throw new StateError('busy', `记忆库正忙(另一个进程在写),稍后再试;若确认没有写入进程,删掉 ${file}`);
|
|
419
|
+
}
|
|
420
|
+
sleepSync(10);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
lockDepth.set(dir, 1);
|
|
424
|
+
try {
|
|
425
|
+
try {
|
|
426
|
+
fs.writeSync(fd, token);
|
|
427
|
+
} catch {
|
|
428
|
+
/* token is advisory; the lock file itself is the mutex */
|
|
429
|
+
}
|
|
430
|
+
return fn();
|
|
431
|
+
} finally {
|
|
432
|
+
lockDepth.delete(dir);
|
|
433
|
+
try {
|
|
434
|
+
fs.closeSync(fd);
|
|
435
|
+
} catch {
|
|
436
|
+
/* already closed */
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
if (fs.readFileSync(file, 'utf8').trim() === token) fs.rmSync(file, { force: true });
|
|
440
|
+
} catch {
|
|
441
|
+
/* someone else already removed it */
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function checksumOf(value) {
|
|
447
|
+
return `sha256:${crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')}`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function emptyState(scope) {
|
|
451
|
+
const now = new Date().toISOString();
|
|
452
|
+
return {
|
|
453
|
+
schemaVersion: STATE_VERSION,
|
|
454
|
+
storeId: crypto.randomUUID(),
|
|
455
|
+
scopeKey: scope.key,
|
|
456
|
+
cwd: scope.cwd || '',
|
|
457
|
+
generation: 0,
|
|
458
|
+
epoch: crypto.randomUUID(),
|
|
459
|
+
createdAt: now,
|
|
460
|
+
updatedAt: now,
|
|
461
|
+
entries: [],
|
|
462
|
+
candidates: [],
|
|
463
|
+
receipts: [],
|
|
464
|
+
suppressions: [],
|
|
465
|
+
control: { enabled: true, epoch: crypto.randomUUID(), changedAt: now },
|
|
466
|
+
experimental: { projectEvolution: newEvolution() },
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function fail(file, why, code = 'corrupt') {
|
|
471
|
+
return new StateError(code, `记忆状态${why},已停止读写:${file}`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function validEntry(entry) {
|
|
475
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
476
|
+
if (typeof entry.id !== 'string' || !/^mem_[a-z0-9]{6,}$/.test(entry.id)) return false;
|
|
477
|
+
if (!Number.isInteger(entry.revision) || entry.revision < 1) return false;
|
|
478
|
+
if (entry.status !== 'active' && entry.status !== 'archived') return false;
|
|
479
|
+
const current = entry.current;
|
|
480
|
+
if (!current || typeof current !== 'object' || typeof current.title !== 'string') return false;
|
|
481
|
+
if (typeof current.body !== 'string') return false;
|
|
482
|
+
if (!entry.maintenance || typeof entry.maintenance !== 'object') return false;
|
|
483
|
+
if (!Array.isArray(entry.maintenance.versions)) return false;
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function validateState(data) {
|
|
488
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new StateError('invalid', '记忆状态结构不对');
|
|
489
|
+
if (data.schemaVersion !== STATE_VERSION) {
|
|
490
|
+
throw new StateError('invalid', `记忆状态版本不认(${String(data.schemaVersion)}),没有改动任何文件`);
|
|
491
|
+
}
|
|
492
|
+
for (const key of ['storeId', 'scopeKey', 'epoch']) {
|
|
493
|
+
if (typeof data[key] !== 'string' || !data[key]) throw new StateError('invalid', `记忆状态缺少 ${key}`);
|
|
494
|
+
}
|
|
495
|
+
if (!Number.isInteger(data.generation) || data.generation < 0) throw new StateError('invalid', '记忆状态 generation 不对');
|
|
496
|
+
for (const key of ['entries', 'candidates', 'receipts', 'suppressions']) {
|
|
497
|
+
if (!Array.isArray(data[key])) throw new StateError('invalid', `记忆状态 ${key} 不是数组`);
|
|
498
|
+
}
|
|
499
|
+
const ids = new Set();
|
|
500
|
+
for (const entry of data.entries) {
|
|
501
|
+
if (!validEntry(entry)) throw new StateError('invalid', '记忆状态里有不合规的条目');
|
|
502
|
+
if (ids.has(entry.id)) throw new StateError('invalid', `记忆状态里条目 id 重复:${entry.id}`);
|
|
503
|
+
ids.add(entry.id);
|
|
504
|
+
}
|
|
505
|
+
for (const row of data.candidates) {
|
|
506
|
+
if (!row || typeof row.id !== 'string' || !row.id.startsWith('sug_')) throw new StateError('invalid', '记忆候选缺少 id');
|
|
507
|
+
}
|
|
508
|
+
for (const row of data.receipts) {
|
|
509
|
+
if (!row || typeof row.operationId !== 'string' || !row.operationId) throw new StateError('invalid', '记忆回执缺少 operationId');
|
|
510
|
+
}
|
|
511
|
+
for (const row of data.suppressions) {
|
|
512
|
+
if (!row || typeof row !== 'object' || typeof row.reason !== 'string' || !row.reason) {
|
|
513
|
+
throw new StateError('invalid', '记忆抑制规则缺少 reason');
|
|
514
|
+
}
|
|
515
|
+
if (row.expiresAt !== null && row.expiresAt !== undefined && typeof row.expiresAt !== 'string') {
|
|
516
|
+
throw new StateError('invalid', '记忆抑制规则的 expiresAt 不合法');
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (data.experimental !== undefined) {
|
|
520
|
+
const box = data.experimental;
|
|
521
|
+
if (!box || typeof box !== 'object' || Array.isArray(box)) throw new StateError('invalid', '记忆状态 experimental 不是对象');
|
|
522
|
+
const evolution = box.projectEvolution;
|
|
523
|
+
if (evolution !== undefined) {
|
|
524
|
+
if (!evolution || typeof evolution !== 'object' || Array.isArray(evolution)) {
|
|
525
|
+
throw new StateError('invalid', '记忆状态实验配置不是对象');
|
|
526
|
+
}
|
|
527
|
+
if (typeof evolution.enabled !== 'boolean') throw new StateError('invalid', '记忆状态实验开关不是布尔值');
|
|
528
|
+
if (typeof evolution.epoch !== 'string' || (evolution.enabled && !evolution.epoch)) {
|
|
529
|
+
throw new StateError('invalid', '记忆状态实验配置缺少 epoch');
|
|
530
|
+
}
|
|
531
|
+
if (!Number.isInteger(evolution.policyVersion)) throw new StateError('invalid', '记忆状态实验策略版本不对');
|
|
532
|
+
if (evolution.feedbackGeneration !== undefined
|
|
533
|
+
&& (!Number.isInteger(evolution.feedbackGeneration) || evolution.feedbackGeneration < 0)) {
|
|
534
|
+
throw new StateError('invalid', '记忆状态反馈代次不对');
|
|
535
|
+
}
|
|
536
|
+
if (evolution.learning !== undefined) {
|
|
537
|
+
const learning = evolution.learning;
|
|
538
|
+
if (!learning || typeof learning !== 'object' || Array.isArray(learning)) throw new StateError('invalid', '记忆状态学习账本不是对象');
|
|
539
|
+
if (learning.attempts !== undefined && (!learning.attempts || typeof learning.attempts !== 'object' || Array.isArray(learning.attempts))) {
|
|
540
|
+
throw new StateError('invalid', '记忆状态学习账本 attempts 不是对象');
|
|
541
|
+
}
|
|
542
|
+
for (const list of ['recent', 'receipts']) {
|
|
543
|
+
if (learning[list] !== undefined && !Array.isArray(learning[list])) throw new StateError('invalid', `记忆状态学习账本 ${list} 不是数组`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (evolution.tasks !== undefined) {
|
|
547
|
+
const tasks = evolution.tasks;
|
|
548
|
+
if (!tasks || typeof tasks !== 'object' || Array.isArray(tasks)) throw new StateError('invalid', '记忆状态任务锚点不是对象');
|
|
549
|
+
if (tasks.revision !== undefined && (!Number.isInteger(tasks.revision) || tasks.revision < 0)) {
|
|
550
|
+
throw new StateError('invalid', '记忆状态任务锚点版本不对');
|
|
551
|
+
}
|
|
552
|
+
for (const key of ['sessions', 'episodes']) {
|
|
553
|
+
if (tasks[key] !== undefined && (!tasks[key] || typeof tasks[key] !== 'object' || Array.isArray(tasks[key]))) {
|
|
554
|
+
throw new StateError('invalid', '记忆状态任务锚点表不是对象');
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (tasks.defaults !== undefined && tasks.defaults !== null && (typeof tasks.defaults !== 'object' || Array.isArray(tasks.defaults))) {
|
|
558
|
+
throw new StateError('invalid', '记忆状态栏目默认不是对象');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return data;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function readEnvelope(file) {
|
|
567
|
+
let raw;
|
|
568
|
+
try {
|
|
569
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (error.code === 'ENOENT') return null;
|
|
572
|
+
throw new StateError('unavailable', `记忆状态读不出来(${error.code || 'IO'}):${file}`);
|
|
573
|
+
}
|
|
574
|
+
if (Buffer.byteLength(raw) > LIMITS.stateBytes) throw fail(file, '超过大小上限');
|
|
575
|
+
let data;
|
|
576
|
+
try {
|
|
577
|
+
data = JSON.parse(raw);
|
|
578
|
+
} catch {
|
|
579
|
+
throw fail(file, '已损坏');
|
|
580
|
+
}
|
|
581
|
+
if (!data || (data.format !== STATE_FORMAT && !LEGACY_STATE_FORMATS.includes(data.format))
|
|
582
|
+
|| !data.payload || typeof data.payload !== 'object') {
|
|
583
|
+
throw fail(file, '格式不认');
|
|
584
|
+
}
|
|
585
|
+
if (data.checksum !== checksumOf(data.payload)) throw fail(file, '校验和不一致');
|
|
586
|
+
if (data.format !== STATE_FORMAT) {
|
|
587
|
+
if (!LEGACY_STATE_VERSIONS.includes(data.payload.schemaVersion)) throw fail(file, '版本对不上');
|
|
588
|
+
const upgraded = { ...data.payload, schemaVersion: STATE_VERSION };
|
|
589
|
+
if (upgraded.experimental === undefined) {
|
|
590
|
+
upgraded.experimental = { projectEvolution: { enabled: false, epoch: '', policyVersion: EVOLUTION_POLICY_VERSION, feedbackGeneration: 0, learning: emptyLearning() } };
|
|
591
|
+
}
|
|
592
|
+
validateState(upgraded);
|
|
593
|
+
return upgraded;
|
|
594
|
+
}
|
|
595
|
+
validateState(data.payload);
|
|
596
|
+
return data.payload;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export function encodeState(state) {
|
|
600
|
+
const payload = validateState(state);
|
|
601
|
+
return `${JSON.stringify({ format: STATE_FORMAT, checksum: checksumOf(payload), payload }, null, 2)}\n`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function fsyncDir(dir) {
|
|
605
|
+
try {
|
|
606
|
+
const fd = fs.openSync(dir, 'r');
|
|
607
|
+
try {
|
|
608
|
+
fs.fsyncSync(fd);
|
|
609
|
+
} finally {
|
|
610
|
+
fs.closeSync(fd);
|
|
611
|
+
}
|
|
612
|
+
} catch {
|
|
613
|
+
/* best effort: rename already landed */
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** 固定临时名:失败重试不会在目录里堆一串随机副本;恢复路径只认这几个已知名字。 */
|
|
618
|
+
function tempOf(file) {
|
|
619
|
+
return path.join(path.dirname(file), `.${path.basename(file)}.tmp`);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function discardTemp(file) {
|
|
623
|
+
try {
|
|
624
|
+
fs.rmSync(tempOf(file), { force: true });
|
|
625
|
+
} catch {
|
|
626
|
+
/* 临时文件不权威,删不掉也不能挡住真正的写入 */
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** 清理上次中断留下的临时副本:只碰已知的 .state*.tmp,不碰状态文件本身。 */
|
|
631
|
+
export function cleanStateResidue(scope) {
|
|
632
|
+
let names;
|
|
633
|
+
try {
|
|
634
|
+
names = fs.readdirSync(scope.dir);
|
|
635
|
+
} catch {
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
for (const name of names) {
|
|
639
|
+
if (!name.startsWith('.state') || !name.endsWith('.tmp')) continue;
|
|
640
|
+
try {
|
|
641
|
+
fs.rmSync(path.join(scope.dir, name), { force: true });
|
|
642
|
+
} catch {
|
|
643
|
+
/* 同一目录的临时副本,删不掉下次写前还会再清一次 */
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function writeDurable(file, text) {
|
|
649
|
+
const dir = path.dirname(file);
|
|
650
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
651
|
+
const tmp = tempOf(file);
|
|
652
|
+
discardTemp(file);
|
|
653
|
+
let fd = null;
|
|
654
|
+
try {
|
|
655
|
+
fd = fs.openSync(tmp, 'wx');
|
|
656
|
+
fs.writeFileSync(fd, text);
|
|
657
|
+
fs.fsyncSync(fd);
|
|
658
|
+
fs.closeSync(fd);
|
|
659
|
+
fd = null;
|
|
660
|
+
fs.renameSync(tmp, file);
|
|
661
|
+
} catch (error) {
|
|
662
|
+
try {
|
|
663
|
+
if (fd !== null) fs.closeSync(fd);
|
|
664
|
+
} catch {
|
|
665
|
+
/* already closed */
|
|
666
|
+
}
|
|
667
|
+
discardTemp(file);
|
|
668
|
+
throw new StateError('unavailable', `${path.basename(file)} 写不进去(${error.code || 'IO'})`);
|
|
669
|
+
}
|
|
670
|
+
fsyncDir(dir);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** 先发布旧状态到 prev,再换 current:任何一步中断都能看出「旧/新」而不是空库。 */
|
|
674
|
+
function publish(scope, next, hadCurrent) {
|
|
675
|
+
const dir = scope.dir;
|
|
676
|
+
let durability = 'confirmed';
|
|
677
|
+
const current = stateFile(scope);
|
|
678
|
+
if (hadCurrent) {
|
|
679
|
+
try {
|
|
680
|
+
const raw = fs.readFileSync(current, 'utf8');
|
|
681
|
+
writeDurable(prevFile(scope), raw);
|
|
682
|
+
} catch {
|
|
683
|
+
/* prev is a recovery aid; a missing one must not block a real write */
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const text = encodeState(next);
|
|
687
|
+
if (Buffer.byteLength(text) > LIMITS.stateBytes) {
|
|
688
|
+
throw new StateError('budget_exhausted', `记忆状态超过 ${LIMITS.stateBytes} 字节,先归档旧的再写`);
|
|
689
|
+
}
|
|
690
|
+
writeDurable(current, text);
|
|
691
|
+
return { durability };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function countActive(state) {
|
|
695
|
+
return state.entries.filter((entry) => entry.status === 'active').length;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* 一条占多少字节:正文 + 标题/摘要/标签(和展示与检索用到的字段一致)。
|
|
700
|
+
* 预算按这个算,capacityOf 报出来的数字才对得上。
|
|
701
|
+
*/
|
|
702
|
+
export function entryBytesOf(entry) {
|
|
703
|
+
const current = entry.current || {};
|
|
704
|
+
const body = typeof current.body === 'string' ? current.body : '';
|
|
705
|
+
const meta = [current.title || '', current.summary || '', current.when || '',
|
|
706
|
+
Array.isArray(current.keywords) ? current.keywords.join(',') : ''].join('\n');
|
|
707
|
+
return Buffer.byteLength(body) + Buffer.byteLength(meta) + Buffer.byteLength(fieldsBytesOf(current));
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* 结构化字段也要计入预算:条件、清单、关联、触发词都占模型可见额度。
|
|
712
|
+
* 键顺序固定,state 与 store 两边算出来的数字必须一致。
|
|
713
|
+
*/
|
|
714
|
+
export function fieldsBytesOf(current) {
|
|
715
|
+
return JSON.stringify({
|
|
716
|
+
topicKey: current.topicKey || '',
|
|
717
|
+
triggers: Array.isArray(current.triggers) ? current.triggers : [],
|
|
718
|
+
applicability: current.applicability || 'all_in_scope',
|
|
719
|
+
conditions: Array.isArray(current.conditions) ? current.conditions : [],
|
|
720
|
+
selection: current.selection || null,
|
|
721
|
+
links: Array.isArray(current.links) ? current.links : [],
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function activeBytesOf(state) {
|
|
726
|
+
return state.entries.reduce((sum, entry) => (entry.status === 'active' ? sum + entryBytesOf(entry) : sum), 0);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function entryLastTouch(entry) {
|
|
730
|
+
const maintenance = entry.maintenance || {};
|
|
731
|
+
return Date.parse(maintenance.lastUsed || entry.updatedAt || entry.createdAt || '') || 0;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const IDLE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* 到硬上限不拒绝新记忆:把同层最久没被读/更新的一条标成归档(正文保留),
|
|
738
|
+
* 返回动了哪些,让工具结果里说清楚。真腾不出位置才报 budget_exhausted。
|
|
739
|
+
*/
|
|
740
|
+
export function enforceBudget(state, scope) {
|
|
741
|
+
if (scope && scope.key === 'global') {
|
|
742
|
+
// 全局层与项目层共用同一套条数与字节预算,只是各自独立计算。
|
|
743
|
+
}
|
|
744
|
+
const evicted = [];
|
|
745
|
+
const now = Date.now();
|
|
746
|
+
for (;;) {
|
|
747
|
+
const active = state.entries.filter((entry) => entry.status === 'active');
|
|
748
|
+
const bytes = active.reduce((sum, entry) => sum + entryBytesOf(entry), 0);
|
|
749
|
+
if (active.length <= LIMITS.hardEntries && bytes <= LIMITS.totalBytes) break;
|
|
750
|
+
const idle = active.filter((entry) => now - entryLastTouch(entry) >= IDLE_MS);
|
|
751
|
+
const pool = idle.length ? idle : active;
|
|
752
|
+
const victim = pool.reduce((oldest, entry) => (entryLastTouch(entry) < entryLastTouch(oldest) ? entry : oldest));
|
|
753
|
+
if (!victim) throw new StateError('budget_exhausted', `记忆库已满:这一条自己就超过 ${LIMITS.totalBytes} 字节正文预算,先精简正文`);
|
|
754
|
+
victim.status = 'archived';
|
|
755
|
+
victim.updatedAt = new Date().toISOString();
|
|
756
|
+
evicted.push({ id: victim.id, title: victim.current.title });
|
|
757
|
+
}
|
|
758
|
+
const pendingBytes = state.candidates.reduce((sum, row) => sum + Buffer.byteLength(JSON.stringify(row.draft || {})), 0);
|
|
759
|
+
if (state.candidates.length > LIMITS.pending || pendingBytes > LIMITS.pendingBytes) {
|
|
760
|
+
state.candidates = state.candidates.slice(-LIMITS.pending);
|
|
761
|
+
}
|
|
762
|
+
const cutoff = now - LIMITS.receiptTtlMs;
|
|
763
|
+
state.receipts = state.receipts.filter((row) => {
|
|
764
|
+
const at = Date.parse(row.at || '');
|
|
765
|
+
return Number.isFinite(at) && at >= cutoff;
|
|
766
|
+
}).slice(-LIMITS.receipts);
|
|
767
|
+
return evicted;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function legacyEntries(scope) {
|
|
771
|
+
const index = path.join(scope.dir, 'index.json');
|
|
772
|
+
let raw;
|
|
773
|
+
try {
|
|
774
|
+
raw = fs.readFileSync(index, 'utf8');
|
|
775
|
+
} catch (error) {
|
|
776
|
+
if (error.code === 'ENOENT') return null;
|
|
777
|
+
throw new StateError('unavailable', `旧记忆索引读不出来(${error.code || 'IO'}):${index}`);
|
|
778
|
+
}
|
|
779
|
+
let data;
|
|
780
|
+
try {
|
|
781
|
+
data = JSON.parse(raw);
|
|
782
|
+
} catch {
|
|
783
|
+
throw new StateError('corrupt', `记忆索引已损坏,已停止读写:${index}`);
|
|
784
|
+
}
|
|
785
|
+
if (!data || !Array.isArray(data.entries)) throw new StateError('corrupt', `记忆索引结构不对,已停止读写:${index}`);
|
|
786
|
+
return data;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function legacyBody(scope, id) {
|
|
790
|
+
const file = path.join(scope.dir, 'entries', `${id}.md`);
|
|
791
|
+
try {
|
|
792
|
+
return fs.readFileSync(file, 'utf8');
|
|
793
|
+
} catch (error) {
|
|
794
|
+
if (error.code === 'ENOENT') return null;
|
|
795
|
+
throw new StateError('unavailable', `旧记忆正文读不出来(${error.code || 'IO'}):${id}`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function toStateEntry(legacy, body, status) {
|
|
800
|
+
const at = legacy.updated || legacy.created || new Date().toISOString();
|
|
801
|
+
return {
|
|
802
|
+
id: legacy.id,
|
|
803
|
+
revision: Number.isInteger(legacy.revision) && legacy.revision > 0 ? legacy.revision : 1,
|
|
804
|
+
status,
|
|
805
|
+
origin: legacy.source === 'observed' ? 'observed' : 'user_explicit',
|
|
806
|
+
createdAt: legacy.created || at,
|
|
807
|
+
updatedAt: at,
|
|
808
|
+
current: {
|
|
809
|
+
title: legacy.title || '',
|
|
810
|
+
summary: legacy.summary || '',
|
|
811
|
+
body: typeof body === 'string' ? body : '',
|
|
812
|
+
kind: legacy.kind || 'note',
|
|
813
|
+
when: legacy.when || '',
|
|
814
|
+
keywords: Array.isArray(legacy.tags) ? legacy.tags : [],
|
|
815
|
+
topicKey: legacy.topicKey || '',
|
|
816
|
+
triggers: Array.isArray(legacy.triggers) ? legacy.triggers : [],
|
|
817
|
+
/** 旧库只有一句 when 的条目不猜条件:先标 unresolved,第一次读取时让人确认。 */
|
|
818
|
+
applicability: legacy.applicability || (legacy.when ? 'unresolved' : 'all_in_scope'),
|
|
819
|
+
conditions: Array.isArray(legacy.conditions) ? legacy.conditions : [],
|
|
820
|
+
selection: legacy.selection || null,
|
|
821
|
+
links: Array.isArray(legacy.links) ? legacy.links : [],
|
|
822
|
+
aliases: Array.isArray(legacy.aliases) ? legacy.aliases : [],
|
|
823
|
+
},
|
|
824
|
+
maintenance: {
|
|
825
|
+
reviewState: 'legacy_unreviewed',
|
|
826
|
+
sourceRefs: [],
|
|
827
|
+
retiredNames: Array.isArray(legacy.aliases) ? legacy.aliases : [],
|
|
828
|
+
versions: Array.isArray(legacy.versions) ? legacy.versions : [],
|
|
829
|
+
checks: [],
|
|
830
|
+
hits: Number(legacy.hits) || 0,
|
|
831
|
+
merges: Number(legacy.merges) || 0,
|
|
832
|
+
lastUsed: legacy.lastUsed || at,
|
|
833
|
+
},
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** 旧版按 cwd 建库,根目录改到仓库根后,子目录的旧库必须收编到同一层。 */
|
|
838
|
+
function adoptableLegacy(scope) {
|
|
839
|
+
let names;
|
|
840
|
+
try {
|
|
841
|
+
names = fs.readdirSync(memoryRoot(), { withFileTypes: true });
|
|
842
|
+
} catch {
|
|
843
|
+
return [];
|
|
844
|
+
}
|
|
845
|
+
const found = [];
|
|
846
|
+
for (const dirent of names) {
|
|
847
|
+
if (!dirent.isDirectory() || dirent.name === scope.key || dirent.name === 'sessions') continue;
|
|
848
|
+
const dir = path.join(memoryRoot(), dirent.name);
|
|
849
|
+
const index = path.join(dir, 'index.json');
|
|
850
|
+
let data;
|
|
851
|
+
try {
|
|
852
|
+
const raw = fs.readFileSync(index, 'utf8');
|
|
853
|
+
if (Buffer.byteLength(raw) > LIMITS.stateBytes) continue;
|
|
854
|
+
data = JSON.parse(raw);
|
|
855
|
+
} catch {
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
const cwd = typeof data.cwd === 'string' ? realpathOf(path.resolve(data.cwd || '.')) : '';
|
|
859
|
+
const root = realpathOf(scope.cwd);
|
|
860
|
+
if (!cwd || !(cwd === root || cwd.startsWith(root + path.sep))) continue;
|
|
861
|
+
if (fs.existsSync(path.join(dir, 'state.json'))) continue;
|
|
862
|
+
found.push({ key: dirent.name, dir, cwd, entries: Array.isArray(data.entries) ? data.entries.length : 0 });
|
|
863
|
+
}
|
|
864
|
+
return found;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** 被吸收的旧库:当前根目录的旧索引 + 同一仓库下更早按子目录建的那些。 */
|
|
868
|
+
function legacySources(scope) {
|
|
869
|
+
const sources = [];
|
|
870
|
+
const own = legacyEntries(scope);
|
|
871
|
+
if (own) sources.push({ scope, legacy: own });
|
|
872
|
+
let adoptable = [];
|
|
873
|
+
if (scope.cwd) adoptable = adoptableLegacy(scope);
|
|
874
|
+
for (const item of adoptable) {
|
|
875
|
+
const child = { ...scope, key: item.key, dir: item.dir, cwd: item.cwd };
|
|
876
|
+
const legacy = legacyEntries(child);
|
|
877
|
+
if (legacy) sources.push({ scope: child, legacy });
|
|
878
|
+
}
|
|
879
|
+
return { sources, adoptable };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** 归档文件单独放:预检阶段只读,不写盘。 */
|
|
883
|
+
function legacyArchiveRows(scope) {
|
|
884
|
+
const dir = path.join(scope.dir, 'archive');
|
|
885
|
+
let names = [];
|
|
886
|
+
try {
|
|
887
|
+
names = fs.readdirSync(dir).filter((name) => name.endsWith('.json'));
|
|
888
|
+
} catch {
|
|
889
|
+
return [];
|
|
890
|
+
}
|
|
891
|
+
const rows = [];
|
|
892
|
+
for (const name of names) {
|
|
893
|
+
try {
|
|
894
|
+
const row = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
895
|
+
if (row && typeof row.id === 'string') rows.push(row);
|
|
896
|
+
} catch {
|
|
897
|
+
/* 坏归档文件不参与迁移,也不阻断当前条目 */
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return rows;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* 旧库只读预检:缺正文、同 id 冲突、同 id 不同正文、索引与正文不一致都在这里显形,
|
|
905
|
+
* 一次扫完所有会被吸收的旧库;有任何未解决项就一条都不发布。
|
|
906
|
+
*/
|
|
907
|
+
export function migrationReport(scope) {
|
|
908
|
+
const { sources, adoptable } = legacySources(scope);
|
|
909
|
+
const report = {
|
|
910
|
+
required: sources.some((source) => source.scope.dir === scope.dir),
|
|
911
|
+
entries: 0,
|
|
912
|
+
archived: 0,
|
|
913
|
+
missingBodies: [],
|
|
914
|
+
conflicts: [],
|
|
915
|
+
adoptable,
|
|
916
|
+
sources: sources.length,
|
|
917
|
+
};
|
|
918
|
+
const bodies = new Map();
|
|
919
|
+
for (const source of sources) {
|
|
920
|
+
const local = new Set();
|
|
921
|
+
const storeKey = source.scope.key || 'root';
|
|
922
|
+
const noteBody = (id, body, where) => {
|
|
923
|
+
const seen = bodies.get(id);
|
|
924
|
+
if (!seen) {
|
|
925
|
+
bodies.set(id, { body, store: storeKey });
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (seen.body !== body) report.conflicts.push({ id, store: storeKey, reason: `同 id 不同正文:${seen.store} 与 ${storeKey}` });
|
|
929
|
+
if (where === 'archive' && !body && seen.body) report.conflicts.push({ id, store: storeKey, reason: '归档缺少正文' });
|
|
930
|
+
};
|
|
931
|
+
for (const row of source.legacy.entries) {
|
|
932
|
+
if (!row || typeof row.id !== 'string' || !/^mem_[a-z0-9]{6,}$/.test(row.id)) {
|
|
933
|
+
report.conflicts.push({ id: String(row && row.id), store: storeKey, reason: 'id 不合法' });
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
if (local.has(row.id)) report.conflicts.push({ id: row.id, store: storeKey, reason: 'id 重复' });
|
|
937
|
+
local.add(row.id);
|
|
938
|
+
report.entries += 1;
|
|
939
|
+
const fileBody = legacyBody(source.scope, row.id);
|
|
940
|
+
if (fileBody === null) report.missingBodies.push(row.id);
|
|
941
|
+
/** 旧索引若自带正文,和正文文件必须一致,否则不知道该信哪份。 */
|
|
942
|
+
if (typeof row.body === 'string' && fileBody !== null && row.body !== fileBody) {
|
|
943
|
+
report.conflicts.push({ id: row.id, store: storeKey, reason: '索引与正文不一致' });
|
|
944
|
+
}
|
|
945
|
+
noteBody(row.id, fileBody === null ? '' : fileBody, 'active');
|
|
946
|
+
}
|
|
947
|
+
const archiveRows = legacyArchiveRows(source.scope);
|
|
948
|
+
report.archived += archiveRows.length;
|
|
949
|
+
for (const row of archiveRows) {
|
|
950
|
+
if (local.has(row.id)) {
|
|
951
|
+
report.conflicts.push({ id: row.id, store: storeKey, reason: '归档与索引 id 重复' });
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
local.add(row.id);
|
|
955
|
+
const fileBody = legacyBody(source.scope, row.id);
|
|
956
|
+
const text = typeof row.body === 'string' ? row.body : (fileBody === null ? '' : fileBody);
|
|
957
|
+
if (fileBody !== null && typeof row.body === 'string' && row.body !== fileBody) {
|
|
958
|
+
report.conflicts.push({ id: row.id, store: storeKey, reason: '归档索引与正文不一致' });
|
|
959
|
+
}
|
|
960
|
+
noteBody(row.id, text, 'archive');
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return report;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* 迁移是显式事务:先写 migration.intent.json,再发布 state,最后收走旧文件。
|
|
968
|
+
* 缺正文/同 id 冲突默认不动盘,把报告抛给调用方决定。
|
|
969
|
+
*/
|
|
970
|
+
export function migrateLegacy(scope, options = {}) {
|
|
971
|
+
return withScopeLock(scope, () => {
|
|
972
|
+
if (purgeIntent(scope)) throw new StateError('purge_pending', '这个记忆库正在彻底删除,迁移暂停;先调用 resumePurge 收尾');
|
|
973
|
+
const report = migrationReport(scope);
|
|
974
|
+
if (!report.required && !report.adoptable.length) return { migrated: false, report };
|
|
975
|
+
const decisions = options.decisions || {};
|
|
976
|
+
if (report.conflicts.length || report.missingBodies.length) {
|
|
977
|
+
const code = report.conflicts.length ? 'legacy_conflict' : 'legacy_missing_body';
|
|
978
|
+
const detail = report.conflicts.length
|
|
979
|
+
? `同 id 冲突:${report.conflicts.map((row) => row.id).join('、')}`
|
|
980
|
+
: `缺正文:${report.missingBodies.join('、')}`;
|
|
981
|
+
const error = new StateError(code, `旧记忆库迁移需要确认(${detail}),没有改动任何文件;确认后带 decisions 重试`);
|
|
982
|
+
error.report = report;
|
|
983
|
+
throw error;
|
|
984
|
+
}
|
|
985
|
+
const sources = legacySources(scope).sources;
|
|
986
|
+
const state = emptyState(scope);
|
|
987
|
+
const seen = new Set();
|
|
988
|
+
let missing = 0;
|
|
989
|
+
for (const source of sources) {
|
|
990
|
+
const legacy = source.legacy;
|
|
991
|
+
const sourceScope = source.scope;
|
|
992
|
+
for (const row of legacy.entries) {
|
|
993
|
+
if (seen.has(row.id)) continue;
|
|
994
|
+
seen.add(row.id);
|
|
995
|
+
const body = legacyBody(sourceScope, row.id);
|
|
996
|
+
if (body === null) missing += 1;
|
|
997
|
+
state.entries.push(toStateEntry(row, body === null ? '' : body, 'active'));
|
|
998
|
+
}
|
|
999
|
+
const archiveDir = path.join(sourceScope.dir, 'archive');
|
|
1000
|
+
let names = [];
|
|
1001
|
+
try {
|
|
1002
|
+
names = fs.readdirSync(archiveDir).filter((name) => name.endsWith('.json'));
|
|
1003
|
+
} catch {
|
|
1004
|
+
names = [];
|
|
1005
|
+
}
|
|
1006
|
+
for (const name of names) {
|
|
1007
|
+
if (seen.has(name.replace(/\.json$/, ''))) continue;
|
|
1008
|
+
let row;
|
|
1009
|
+
try {
|
|
1010
|
+
row = JSON.parse(fs.readFileSync(path.join(archiveDir, name), 'utf8'));
|
|
1011
|
+
} catch {
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
if (!row || typeof row.id !== 'string' || seen.has(row.id)) continue;
|
|
1015
|
+
seen.add(row.id);
|
|
1016
|
+
const body = legacyBody(sourceScope, row.id);
|
|
1017
|
+
state.entries.push(toStateEntry(row, body === null ? '' : body, 'archived'));
|
|
1018
|
+
state.entries[state.entries.length - 1].current.body = typeof row.body === 'string' ? row.body : (body || '');
|
|
1019
|
+
}
|
|
1020
|
+
if (legacy.lastSuggestedAt) state.lastSuggestedAt = legacy.lastSuggestedAt;
|
|
1021
|
+
}
|
|
1022
|
+
state.migration = {
|
|
1023
|
+
at: new Date().toISOString(),
|
|
1024
|
+
fromVersion: 1,
|
|
1025
|
+
entries: state.entries.length,
|
|
1026
|
+
sources: sources.map((source) => source.scope.key),
|
|
1027
|
+
missingBodies: missing,
|
|
1028
|
+
decisions,
|
|
1029
|
+
};
|
|
1030
|
+
fs.mkdirSync(scope.dir, { recursive: true });
|
|
1031
|
+
writeDurable(migrationIntentFile(scope), `${JSON.stringify({ at: state.migration.at, sources: state.migration.sources, phase: 'publishing' }, null, 2)}\n`);
|
|
1032
|
+
publish(scope, state, fs.existsSync(stateFile(scope)));
|
|
1033
|
+
const legacyRoot = path.join(scope.dir, 'legacy');
|
|
1034
|
+
fs.mkdirSync(legacyRoot, { recursive: true });
|
|
1035
|
+
for (const source of sources) {
|
|
1036
|
+
if (source.scope.dir === scope.dir) {
|
|
1037
|
+
const target = path.join(legacyRoot, 'v1');
|
|
1038
|
+
fs.mkdirSync(target, { recursive: true });
|
|
1039
|
+
for (const name of LEGACY_MARKERS) {
|
|
1040
|
+
const from = path.join(scope.dir, name);
|
|
1041
|
+
if (!fs.existsSync(from)) continue;
|
|
1042
|
+
try {
|
|
1043
|
+
fs.renameSync(from, path.join(target, name));
|
|
1044
|
+
} catch {
|
|
1045
|
+
/* leftover legacy file is inert once state.json exists */
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
} else {
|
|
1049
|
+
const target = path.join(legacyRoot, `scope-${source.scope.key}`);
|
|
1050
|
+
try {
|
|
1051
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
1052
|
+
fs.renameSync(source.scope.dir, target);
|
|
1053
|
+
} catch {
|
|
1054
|
+
/* best effort: leaving the old dir is inert without a root index */
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
fs.rmSync(migrationIntentFile(scope), { force: true });
|
|
1059
|
+
return { migrated: true, report, state };
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* 读当前状态:只有「完全没有」才算新库;坏文件、未知版本、迁移痕迹都报错,
|
|
1065
|
+
* 绝不用空库覆盖。
|
|
1066
|
+
*/
|
|
1067
|
+
export function load(scope, options = {}) {
|
|
1068
|
+
moveBarrier(scope, options);
|
|
1069
|
+
if (options.cleanup) cleanStateResidue(scope);
|
|
1070
|
+
const intent = purgeIntent(scope);
|
|
1071
|
+
if (intent && !options.allowPurge) {
|
|
1072
|
+
throw new StateError('purge_pending', `这个记忆库正在彻底删除(${intent.operationId}),先调用 resumePurge 收尾;期间不提供内容读取`);
|
|
1073
|
+
}
|
|
1074
|
+
const current = stateFile(scope);
|
|
1075
|
+
let data = null;
|
|
1076
|
+
try {
|
|
1077
|
+
data = readEnvelope(current);
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
// 校验和/结构坏了时有 prev 也不自动回滚:回滚会掩盖一次真实损坏。
|
|
1080
|
+
throw error;
|
|
1081
|
+
}
|
|
1082
|
+
if (!data) {
|
|
1083
|
+
const hasLegacy = LEGACY_MARKERS.some((name) => fs.existsSync(path.join(scope.dir, name)));
|
|
1084
|
+
const intent = fs.existsSync(migrationIntentFile(scope));
|
|
1085
|
+
if (hasLegacy || intent || (scope.cwd && adoptableLegacy(scope).length)) {
|
|
1086
|
+
const report = migrationReport(scope);
|
|
1087
|
+
const error = new StateError('migration_required', `旧记忆库需要迁移,先调用 migrateLegacy;报告:${JSON.stringify(report)}`);
|
|
1088
|
+
error.report = report;
|
|
1089
|
+
throw error;
|
|
1090
|
+
}
|
|
1091
|
+
const prev = readEnvelope(prevFile(scope));
|
|
1092
|
+
if (prev) {
|
|
1093
|
+
// current 丢了但 prev 完好:恢复 prev,并标记需要重写 current。
|
|
1094
|
+
return { state: prev, exists: false, recovered: true };
|
|
1095
|
+
}
|
|
1096
|
+
return { state: emptyState(scope), exists: false, recovered: false };
|
|
1097
|
+
}
|
|
1098
|
+
return { state: data, exists: true, recovered: false };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* 一次写入 = 一个事务:锁内重读 → 回执/摘要 → epoch/generation CAS → 校验 → 预算 → 原子发布。
|
|
1103
|
+
*/
|
|
1104
|
+
export function transact(scope, options, work) {
|
|
1105
|
+
return withScopeLock(scope, () => {
|
|
1106
|
+
const loaded = load(scope, {
|
|
1107
|
+
allowMove: Boolean(options && options.allowMove),
|
|
1108
|
+
allowPurge: Boolean(options && options.allowPurge),
|
|
1109
|
+
cleanup: true,
|
|
1110
|
+
});
|
|
1111
|
+
const { operationId = '', payloadDigest = '', expected = null, expectCreate = false, nextEpoch = '' } = options || {};
|
|
1112
|
+
if (operationId) {
|
|
1113
|
+
const replay = loaded.state.receipts.find((row) => row.operationId === operationId);
|
|
1114
|
+
if (replay) {
|
|
1115
|
+
if (payloadDigest && replay.payloadDigest && replay.payloadDigest !== payloadDigest) {
|
|
1116
|
+
throw new StateError('conflict', '同一个 operationId 带了不同内容,这次没有写入');
|
|
1117
|
+
}
|
|
1118
|
+
return { replayed: true, changed: Boolean(replay.changed), state: loaded.state, receipt: replay, archived: [] };
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (expectCreate && loaded.exists) {
|
|
1122
|
+
throw new StateError('conflict', '记忆库已经被另一个进程创建,重读后再写');
|
|
1123
|
+
}
|
|
1124
|
+
if (expected) {
|
|
1125
|
+
if (expected.epoch && expected.epoch !== loaded.state.epoch) {
|
|
1126
|
+
throw new StateError('conflict', '记忆库已经换过一代(epoch 变了),重读后再写');
|
|
1127
|
+
}
|
|
1128
|
+
if (Number.isInteger(expected.generation) && expected.generation !== loaded.state.generation) {
|
|
1129
|
+
throw new StateError('conflict', '记忆库刚被改过,重读后再写');
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
const draft = JSON.parse(JSON.stringify(loaded.state));
|
|
1133
|
+
const outcome = work(draft) || {};
|
|
1134
|
+
if (!outcome.changed) {
|
|
1135
|
+
return { changed: false, state: loaded.state, result: outcome.result, archived: [] };
|
|
1136
|
+
}
|
|
1137
|
+
const evicted = enforceBudget(draft, scope);
|
|
1138
|
+
if (nextEpoch) draft.epoch = nextEpoch;
|
|
1139
|
+
draft.generation = loaded.state.generation + 1;
|
|
1140
|
+
draft.updatedAt = new Date().toISOString();
|
|
1141
|
+
if (!draft.storeId) draft.storeId = loaded.state.storeId;
|
|
1142
|
+
if (operationId) {
|
|
1143
|
+
draft.receipts = [...draft.receipts, {
|
|
1144
|
+
operationId,
|
|
1145
|
+
payloadDigest,
|
|
1146
|
+
changed: true,
|
|
1147
|
+
revisionAfter: draft.generation,
|
|
1148
|
+
kind: outcome.kind || '',
|
|
1149
|
+
at: draft.updatedAt,
|
|
1150
|
+
...(outcome.receipt || {}),
|
|
1151
|
+
}].slice(-LIMITS.receipts);
|
|
1152
|
+
}
|
|
1153
|
+
validateState(draft);
|
|
1154
|
+
const published = publish(scope, draft, loaded.exists);
|
|
1155
|
+
return { changed: true, state: draft, result: outcome.result, archived: evicted, durability: published.durability };
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** 只读正文:缺失就报「正文缺失」,不返回空串冒充内容。 */
|
|
1160
|
+
export function readEntryBody(scope, id) {
|
|
1161
|
+
const loaded = load(scope);
|
|
1162
|
+
const entry = loaded.state.entries.find((row) => row.id === id);
|
|
1163
|
+
if (!entry) return null;
|
|
1164
|
+
return { body: entry.current.body, entry };
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
/** 根目录的协调锁:移动要同时持有两层,先拿这一把,固定顺序就不会互相等死。 */
|
|
1170
|
+
export function withRootLock(fn) {
|
|
1171
|
+
return withScopeLock({ dir: memoryRoot(), key: 'root' }, fn);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** 损坏的 intent 不当成不存在:宁可报错,也不让两层的半截状态被继续使用。 */
|
|
1175
|
+
export function readMoveIntent() {
|
|
1176
|
+
const file = moveIntentFile();
|
|
1177
|
+
let raw;
|
|
1178
|
+
try {
|
|
1179
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
if (error.code === 'ENOENT') return null;
|
|
1182
|
+
throw new StateError('unavailable', `移动协调文件读不出来(${error.code || 'IO'}):${file}`);
|
|
1183
|
+
}
|
|
1184
|
+
let intent;
|
|
1185
|
+
try {
|
|
1186
|
+
intent = JSON.parse(raw);
|
|
1187
|
+
} catch {
|
|
1188
|
+
throw new StateError('conflict', `移动中断,协调文件已损坏:${file};先人工确认两层状态再删除它`);
|
|
1189
|
+
}
|
|
1190
|
+
const stringField = (value) => value === undefined || typeof value === 'string';
|
|
1191
|
+
const ok = intent && intent.format === MOVE_FORMAT
|
|
1192
|
+
&& typeof intent.operationId === 'string' && intent.operationId
|
|
1193
|
+
&& intent.source && typeof intent.source.scopeKey === 'string' && typeof intent.source.id === 'string'
|
|
1194
|
+
&& stringField(intent.source.cwd) && stringField(intent.source.storeId)
|
|
1195
|
+
&& stringField(intent.source.epoch) && stringField(intent.source.toEpoch)
|
|
1196
|
+
&& intent.target && typeof intent.target.scopeKey === 'string' && typeof intent.target.newId === 'string'
|
|
1197
|
+
&& stringField(intent.target.cwd) && stringField(intent.target.storeId)
|
|
1198
|
+
&& stringField(intent.target.epoch) && stringField(intent.target.toEpoch);
|
|
1199
|
+
if (!ok) throw new StateError('conflict', `移动中断,协调文件内容不认:${file}`);
|
|
1200
|
+
return intent;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
export function writeMoveIntent(intent) {
|
|
1204
|
+
const payload = {
|
|
1205
|
+
format: MOVE_FORMAT,
|
|
1206
|
+
operationId: intent.operationId,
|
|
1207
|
+
at: intent.at || new Date().toISOString(),
|
|
1208
|
+
source: { ...intent.source },
|
|
1209
|
+
target: { ...intent.target },
|
|
1210
|
+
topicKey: typeof intent.topicKey === 'string' ? intent.topicKey : '',
|
|
1211
|
+
};
|
|
1212
|
+
writeDurable(moveIntentFile(), `${JSON.stringify(payload, null, 2)}\n`);
|
|
1213
|
+
return payload;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
export function clearMoveIntent() {
|
|
1217
|
+
fs.rmSync(moveIntentFile(), { force: true });
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* 双重屏障:purge 和 move 都先落 intent 再改状态。涉及的两个 scope 读写在
|
|
1222
|
+
* intent 期间一律拒绝,直到恢复流程(resume)走完;allowMove 只给恢复路径自己用。
|
|
1223
|
+
*/
|
|
1224
|
+
export function moveBarrier(scope, options = {}) {
|
|
1225
|
+
const intent = readMoveIntent();
|
|
1226
|
+
if (!intent || options.allowMove) return intent;
|
|
1227
|
+
if (intent.source.scopeKey === scope.key || intent.target.scopeKey === scope.key) {
|
|
1228
|
+
throw new StateError('move_pending', `这条记忆正在跨层搬移(${intent.operationId}),先调用 resumeMove 收尾;期间不提供这两层的内容读取`);
|
|
1229
|
+
}
|
|
1230
|
+
return intent;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** 把 prev 归一成刚发布的状态:搬移协议要求 prev 不保留搬动前的对立状态。 */
|
|
1234
|
+
export function sealPrev(scope) {
|
|
1235
|
+
const current = stateFile(scope);
|
|
1236
|
+
if (!fs.existsSync(current)) throw new StateError('unavailable', '状态还没发布,不能封存 prev');
|
|
1237
|
+
writeDurable(prevFile(scope), fs.readFileSync(current, 'utf8'));
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
export function removePrev(scope) {
|
|
1241
|
+
fs.rmSync(prevFile(scope), { force: true });
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/** 屏障只在 intent 存在期间生效:它不含标题与正文,只记身份与两个固定 epoch。 */
|
|
1245
|
+
export function purgeIntent(scope) {
|
|
1246
|
+
const file = purgeIntentFile(scope);
|
|
1247
|
+
let raw;
|
|
1248
|
+
try {
|
|
1249
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
if (error.code === 'ENOENT') return null;
|
|
1252
|
+
throw new StateError('unavailable', `彻底删除的意图文件读不出来(${error.code || 'IO'}):${file}`);
|
|
1253
|
+
}
|
|
1254
|
+
let row;
|
|
1255
|
+
try {
|
|
1256
|
+
row = JSON.parse(raw);
|
|
1257
|
+
} catch {
|
|
1258
|
+
throw new StateError('corrupt', `彻底删除的意图文件坏了,已停止读写:${file}`);
|
|
1259
|
+
}
|
|
1260
|
+
const ok = row && typeof row.operationId === 'string' && row.operationId
|
|
1261
|
+
&& typeof row.toEpoch === 'string' && row.toEpoch
|
|
1262
|
+
&& Array.isArray(row.ids) && row.ids.length > 0;
|
|
1263
|
+
if (!ok) throw new StateError('corrupt', `彻底删除的意图文件缺字段,已停止读写:${file}`);
|
|
1264
|
+
return row;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/** 精确指纹:标题 + 前置条件,不存原文,只用来挡住换个说法的重学。 */
|
|
1268
|
+
export function fingerprintOf(current) {
|
|
1269
|
+
const text = [current && current.title, current && current.when]
|
|
1270
|
+
.map((part) => String(part || '').replace(/\s+/g, ' ').trim().toLowerCase())
|
|
1271
|
+
.join('\n');
|
|
1272
|
+
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** 有限期抑制可以过期清掉;forgotten_source 永不过期,也不静默淘汰。 */
|
|
1276
|
+
function pruneSuppressions(rows) {
|
|
1277
|
+
const now = Date.now();
|
|
1278
|
+
const alive = [];
|
|
1279
|
+
const shortLived = [];
|
|
1280
|
+
for (const row of rows || []) {
|
|
1281
|
+
if (!row || typeof row.reason !== 'string') continue;
|
|
1282
|
+
if (row.reason === 'forgotten_source') {
|
|
1283
|
+
alive.push(row);
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
const expires = Date.parse(row.expiresAt || '');
|
|
1287
|
+
if (Number.isFinite(expires) && expires > now) shortLived.push(row);
|
|
1288
|
+
}
|
|
1289
|
+
const room = Math.max(0, LIMITS.suppressions - alive.length);
|
|
1290
|
+
return [...alive, ...shortLived.slice(-room)];
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function sentinelsOf(entry) {
|
|
1294
|
+
const current = entry.current || {};
|
|
1295
|
+
const out = new Set();
|
|
1296
|
+
const push = (value) => {
|
|
1297
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
1298
|
+
if (text.length >= 4) out.add(text);
|
|
1299
|
+
};
|
|
1300
|
+
push(current.title);
|
|
1301
|
+
push(current.summary);
|
|
1302
|
+
push(current.body);
|
|
1303
|
+
push(current.when);
|
|
1304
|
+
for (const name of current.aliases || []) push(name);
|
|
1305
|
+
for (const version of (entry.maintenance && entry.maintenance.versions) || []) {
|
|
1306
|
+
push(version.title);
|
|
1307
|
+
push(version.summary);
|
|
1308
|
+
push(version.body);
|
|
1309
|
+
push(version.when);
|
|
1310
|
+
}
|
|
1311
|
+
return [...out];
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const PURGE_KEEP = new Set(['state.json', 'state.prev.json', '.lock', 'purge.intent.json']);
|
|
1315
|
+
|
|
1316
|
+
/** 受管副本一次性清掉:旧版目录、md 正文、旧归档、失败提交残留、缓存文件。 */
|
|
1317
|
+
function cleanupManaged(scope) {
|
|
1318
|
+
for (const name of ['entries', 'archive', 'legacy']) {
|
|
1319
|
+
fs.rmSync(path.join(scope.dir, name), { recursive: true, force: true });
|
|
1320
|
+
}
|
|
1321
|
+
for (const name of ['index.json', 'migration.intent.json']) {
|
|
1322
|
+
fs.rmSync(path.join(scope.dir, name), { force: true });
|
|
1323
|
+
}
|
|
1324
|
+
let names = [];
|
|
1325
|
+
try {
|
|
1326
|
+
names = fs.readdirSync(scope.dir);
|
|
1327
|
+
} catch {
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
for (const name of names) {
|
|
1331
|
+
if (PURGE_KEEP.has(name)) continue;
|
|
1332
|
+
if (name.includes('.tmp')) fs.rmSync(path.join(scope.dir, name), { force: true });
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function fileContains(file, sentinels) {
|
|
1337
|
+
let raw;
|
|
1338
|
+
try {
|
|
1339
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
1340
|
+
} catch {
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
for (const sentinel of sentinels) {
|
|
1344
|
+
if (raw.includes(sentinel)) return true;
|
|
1345
|
+
const escaped = JSON.stringify(sentinel).slice(1, -1);
|
|
1346
|
+
if (escaped !== sentinel && raw.includes(escaped)) return true;
|
|
1347
|
+
const lines = sentinel.split('\n').map((line) => line.trim()).filter((line) => line.length >= 4);
|
|
1348
|
+
for (const line of lines) {
|
|
1349
|
+
if (raw.includes(line) || raw.includes(JSON.stringify(line).slice(1, -1))) return true;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
return false;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/** 清理后自检:还有受管副本就留在 purge_pending,不提前报成功。 */
|
|
1356
|
+
function leftoversIn(scope, sentinels) {
|
|
1357
|
+
if (!sentinels.length) return [];
|
|
1358
|
+
const hits = [];
|
|
1359
|
+
const walk = (dir) => {
|
|
1360
|
+
let names = [];
|
|
1361
|
+
try {
|
|
1362
|
+
names = fs.readdirSync(dir, { withFileTypes: true });
|
|
1363
|
+
} catch {
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
for (const dirent of names) {
|
|
1367
|
+
const file = path.join(dir, dirent.name);
|
|
1368
|
+
if (dirent.isDirectory()) walk(file);
|
|
1369
|
+
else if (dirent.name !== '.lock' && fileContains(file, sentinels)) hits.push(dirent.name);
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
walk(scope.dir);
|
|
1373
|
+
return hits;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* 彻底删除:锁内先落 intent 当屏障,再整份发布删除后的状态并旋转 epoch,
|
|
1378
|
+
* 然后把 prev 覆盖成删除后的状态、清掉受管旧副本,自检通过才移除 intent。
|
|
1379
|
+
* 任何一步中断都停在 purge_pending,重启后由 resumePurge 续做。
|
|
1380
|
+
*/
|
|
1381
|
+
export function purge(scope, options = {}) {
|
|
1382
|
+
return withScopeLock(scope, () => {
|
|
1383
|
+
const existing = purgeIntent(scope);
|
|
1384
|
+
if (existing && options.resume !== true) {
|
|
1385
|
+
throw new StateError('purge_pending', `上一次彻底删除没做完(${existing.operationId}),先调用 resumePurge 收尾`);
|
|
1386
|
+
}
|
|
1387
|
+
let loaded = load(scope, { allowPurge: true });
|
|
1388
|
+
let intent = existing;
|
|
1389
|
+
if (!intent) {
|
|
1390
|
+
const ids = [...new Set((Array.isArray(options.ids) ? options.ids : [])
|
|
1391
|
+
.filter((id) => typeof id === 'string' && /^mem_[a-z0-9]{6,}$/.test(id)))];
|
|
1392
|
+
if (!ids.length) throw new StateError('invalid', '没有要彻底删除的条目');
|
|
1393
|
+
intent = {
|
|
1394
|
+
operationId: typeof options.operationId === 'string' && options.operationId
|
|
1395
|
+
? options.operationId
|
|
1396
|
+
: `purge_${crypto.randomBytes(6).toString('hex')}`,
|
|
1397
|
+
scopeKey: scope.key,
|
|
1398
|
+
storeId: loaded.state.storeId,
|
|
1399
|
+
fromEpoch: loaded.state.epoch,
|
|
1400
|
+
toEpoch: crypto.randomUUID(),
|
|
1401
|
+
ids,
|
|
1402
|
+
phase: 'planned',
|
|
1403
|
+
at: new Date().toISOString(),
|
|
1404
|
+
};
|
|
1405
|
+
writeDurable(purgeIntentFile(scope), `${JSON.stringify(intent, null, 2)}\n`);
|
|
1406
|
+
loaded = load(scope, { allowPurge: true });
|
|
1407
|
+
}
|
|
1408
|
+
const ids = new Set(intent.ids);
|
|
1409
|
+
const removed = loaded.state.entries.filter((entry) => ids.has(entry.id));
|
|
1410
|
+
const alreadyPublished = loaded.state.epoch === intent.toEpoch;
|
|
1411
|
+
if (alreadyPublished) {
|
|
1412
|
+
writeDurable(prevFile(scope), encodeState(loaded.state));
|
|
1413
|
+
cleanupManaged(scope);
|
|
1414
|
+
fs.rmSync(purgeIntentFile(scope), { force: true });
|
|
1415
|
+
return { ok: true, replay: true, ids: [...ids], operationId: intent.operationId, epoch: loaded.state.epoch, generation: loaded.state.generation, scopeKey: scope.key };
|
|
1416
|
+
}
|
|
1417
|
+
if (!removed.length) {
|
|
1418
|
+
fs.rmSync(purgeIntentFile(scope), { force: true });
|
|
1419
|
+
return { ok: true, missing: true, ids: [...ids], operationId: intent.operationId, epoch: loaded.state.epoch, generation: loaded.state.generation, scopeKey: scope.key };
|
|
1420
|
+
}
|
|
1421
|
+
const draft = JSON.parse(JSON.stringify(loaded.state));
|
|
1422
|
+
const sentinels = [];
|
|
1423
|
+
for (const entry of removed) sentinels.push(...sentinelsOf(entry));
|
|
1424
|
+
draft.entries = draft.entries.filter((entry) => !ids.has(entry.id));
|
|
1425
|
+
draft.candidates = (draft.candidates || []).filter((row) => !ids.has(row.targetId)
|
|
1426
|
+
&& !ids.has(row.draft && row.draft.targetId));
|
|
1427
|
+
const now = new Date().toISOString();
|
|
1428
|
+
for (const entry of removed) {
|
|
1429
|
+
draft.suppressions.push({
|
|
1430
|
+
fingerprint: fingerprintOf(entry.current),
|
|
1431
|
+
sourceIds: Array.isArray(entry.maintenance && entry.maintenance.sourceRefs) ? [...entry.maintenance.sourceRefs] : [],
|
|
1432
|
+
reason: 'forgotten_source',
|
|
1433
|
+
at: now,
|
|
1434
|
+
expiresAt: null,
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
draft.suppressions = pruneSuppressions(draft.suppressions);
|
|
1438
|
+
draft.receipts = [{
|
|
1439
|
+
operationId: intent.operationId,
|
|
1440
|
+
changed: true,
|
|
1441
|
+
revisionAfter: loaded.state.generation + 1,
|
|
1442
|
+
kind: 'purge',
|
|
1443
|
+
at: now,
|
|
1444
|
+
}];
|
|
1445
|
+
draft.epoch = intent.toEpoch;
|
|
1446
|
+
/** 彻底删除也要作废本轮实验凭证:旧 contextId / 在途整理不能把刚删的规则带回来。 */
|
|
1447
|
+
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
1448
|
+
if (evolution && evolution.enabled === true) {
|
|
1449
|
+
evolution.epoch = crypto.randomUUID();
|
|
1450
|
+
evolution.feedbackGeneration = (Number(evolution.feedbackGeneration) || 0) + 1;
|
|
1451
|
+
}
|
|
1452
|
+
draft.generation = loaded.state.generation + 1;
|
|
1453
|
+
draft.updatedAt = now;
|
|
1454
|
+
validateState(draft);
|
|
1455
|
+
publish(scope, draft, loaded.exists);
|
|
1456
|
+
writeDurable(prevFile(scope), encodeState(draft));
|
|
1457
|
+
cleanupManaged(scope);
|
|
1458
|
+
const leftovers = leftoversIn(scope, sentinels);
|
|
1459
|
+
if (leftovers.length) {
|
|
1460
|
+
throw new StateError('purge_pending', `还有受管副本没清掉(${leftovers.join('、')}),重试继续清理`);
|
|
1461
|
+
}
|
|
1462
|
+
fs.rmSync(purgeIntentFile(scope), { force: true });
|
|
1463
|
+
return {
|
|
1464
|
+
ok: true,
|
|
1465
|
+
ids: [...ids],
|
|
1466
|
+
operationId: intent.operationId,
|
|
1467
|
+
epoch: draft.epoch,
|
|
1468
|
+
generation: draft.generation,
|
|
1469
|
+
scopeKey: scope.key,
|
|
1470
|
+
};
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
export function resumePurge(scope) {
|
|
1475
|
+
return purge(scope, { resume: true });
|
|
1476
|
+
}
|