wendkeep 0.58.3 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/README.en.md +45 -3
- package/README.md +45 -3
- package/bin/wendkeep.mjs +54 -6
- package/docs/en/commands/changes-and-verification.md +9 -3
- package/docs/en/commands/getting-started.md +7 -3
- package/docs/en/commands/memory.md +20 -2
- package/docs/en/commands/operating-profiles.md +173 -0
- package/docs/en/commands/sessions-and-import.md +8 -4
- package/docs/en/commands/verify.md +12 -6
- package/docs/pt-BR/commands/changes-and-verification.md +9 -4
- package/docs/pt-BR/commands/getting-started.md +7 -3
- package/docs/pt-BR/commands/memory.md +18 -2
- package/docs/pt-BR/commands/operating-profiles.md +171 -0
- package/docs/pt-BR/commands/sessions-and-import.md +7 -3
- package/docs/pt-BR/commands/verify.md +11 -5
- package/hooks/brain-core.mjs +159 -159
- package/hooks/brain-inject.mjs +83 -26
- package/hooks/brain-recall.mjs +32 -32
- package/hooks/brain-reindex.mjs +13 -13
- package/hooks/change-context.mjs +24 -10
- package/hooks/change-core.mjs +174 -37
- package/hooks/change-guard.mjs +115 -16
- package/hooks/change-nag.mjs +20 -5
- package/hooks/change-warn.mjs +27 -9
- package/hooks/decision-capture.mjs +1 -1
- package/hooks/derived-sections.mjs +1 -1
- package/hooks/flow-core.mjs +891 -0
- package/hooks/flow-protected-policy.mjs +218 -0
- package/hooks/frontmatter-repair.mjs +3 -1
- package/hooks/git-snapshot.mjs +722 -0
- package/hooks/import-sessions.mjs +10 -5
- package/hooks/memory-mode.mjs +63 -13
- package/hooks/memory-store.mjs +309 -69
- package/hooks/obsidian-common.mjs +39 -55
- package/hooks/operating-profile-runtime.mjs +157 -0
- package/hooks/plan-capture.mjs +14 -3
- package/hooks/sensors-core.mjs +15 -3
- package/hooks/session-backfill.mjs +7 -2
- package/hooks/session-ensure.mjs +6 -4
- package/hooks/session-iteration.mjs +65 -0
- package/hooks/session-memory-lifecycle.mjs +10 -5
- package/hooks/session-note-io.mjs +130 -15
- package/hooks/session-observability.mjs +4 -2
- package/hooks/session-stop.mjs +65 -19
- package/hooks/spec-core.mjs +91 -12
- package/hooks/subagent-stop.mjs +4 -1
- package/hooks/subagent-usage.mjs +2 -2
- package/hooks/task-log.mjs +3 -1
- package/hooks/token-usage.mjs +1 -1
- package/hooks/vault-health.mjs +183 -37
- package/hooks/vault-path-safety.mjs +2 -0
- package/hooks/vault-runtime-store.mjs +558 -0
- package/package.json +10 -3
- package/packages/cli/package.json +5 -0
- package/packages/harness/package.json +5 -0
- package/packages/integrations/package.json +5 -0
- package/packages/mcp/package.json +5 -0
- package/packages/pi/package.json +5 -0
- package/packages/vault/package.json +6 -0
- package/packages/vault/src/index.mjs +2 -0
- package/packages/vault/src/project-vault.mjs +327 -0
- package/packages/vault/src/vault-path-safety.mjs +558 -0
- package/src/change.mjs +2 -1
- package/src/flow.mjs +232 -0
- package/src/init.mjs +26 -3
- package/src/memory.mjs +785 -35
- package/src/operating-profile.mjs +133 -0
- package/src/profile.mjs +224 -0
- package/src/project-vault.mjs +2 -221
- package/src/rebuild-costs.mjs +11 -4
- package/src/skills-seed.mjs +38 -16
- package/src/sync-defs.mjs +16 -7
- package/src/sync.mjs +9 -1
- package/src/taxonomy.mjs +8 -0
- package/src/validate-memory.mjs +21 -8
- package/src/verify.mjs +12 -2
package/src/memory.mjs
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import {
|
|
3
|
-
copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync,
|
|
3
|
+
constants as fsConstants, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync,
|
|
4
|
+
rmSync, writeFileSync,
|
|
4
5
|
} from 'node:fs';
|
|
5
6
|
import { dirname, join } from 'node:path';
|
|
6
7
|
import { sanitizeMemoryText, renderSharedMemory, validateSharedMemory } from '../hooks/memory-schema.mjs';
|
|
7
8
|
import {
|
|
8
|
-
enqueueMemoryEvent, projectMemoryOutbox,
|
|
9
|
+
canonicalMemoryJson, deriveMemoryProjection, enqueueMemoryEvent, projectMemoryOutbox,
|
|
10
|
+
MEMORY_LOCK_BUSY, prepareMemoryProjection, publishMemoryProjection, readMemoryLedger,
|
|
11
|
+
repairMemoryLedger, withMemoryLock,
|
|
9
12
|
} from '../hooks/memory-store.mjs';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
13
|
+
import { mutateSessionRegistry, readSessionRegistry, registryPath } from '../hooks/obsidian-common.mjs';
|
|
14
|
+
import {
|
|
15
|
+
assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath, unlinkVaultFile,
|
|
16
|
+
writeVaultFileAtomic,
|
|
17
|
+
} from '../hooks/vault-path-safety.mjs';
|
|
18
|
+
import {
|
|
19
|
+
readLedgerForValidation, readProjectForValidation, validateMemoryBundle,
|
|
20
|
+
} from './validate-memory.mjs';
|
|
21
|
+
import { validateCore } from './validate-core.mjs';
|
|
12
22
|
import { checkMemoryBundle } from '../hooks/vault-health.mjs';
|
|
13
23
|
|
|
14
24
|
const BRAIN = '.brain';
|
|
@@ -18,9 +28,69 @@ const CANDIDATES = 'MEMORY_CANDIDATES.jsonl';
|
|
|
18
28
|
|
|
19
29
|
function brainPath(vault, name) { return join(vault, BRAIN, name); }
|
|
20
30
|
function hash(value) { return createHash('sha256').update(String(value)).digest('hex'); }
|
|
31
|
+
function byteHash(value) { return createHash('sha256').update(value).digest('hex'); }
|
|
32
|
+
|
|
33
|
+
function checkedVaultFile(vault, path, label, {
|
|
34
|
+
allowMissing = true, mustNotExist = false,
|
|
35
|
+
} = {}) {
|
|
36
|
+
return assertVaultPathSafe(vault, path, {
|
|
37
|
+
allowMissing, expectedType: 'file', mustNotExist, label,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readVaultFile(vault, path, encoding, label, { allowMissing = false } = {}) {
|
|
42
|
+
let checked = checkedVaultFile(vault, path, label, { allowMissing });
|
|
43
|
+
if (!checked.exists) return null;
|
|
44
|
+
checked = checkedVaultFile(vault, checked.target, label, { allowMissing: false });
|
|
45
|
+
return readFileSync(checked.target, encoding);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function snapshotVaultFile(vault, path, label, encoding = undefined) {
|
|
49
|
+
const checked = checkedVaultFile(vault, path, label);
|
|
50
|
+
return checked.exists
|
|
51
|
+
? { exists: true, bytes: readVaultFile(vault, checked.target, encoding, label) }
|
|
52
|
+
: { exists: false, bytes: null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function copyVaultFileToExternal(vault, sourcePath, destinationPath, label) {
|
|
56
|
+
let source = checkedVaultFile(vault, sourcePath, label, { allowMissing: false });
|
|
57
|
+
source = checkedVaultFile(vault, source.target, label, { allowMissing: false });
|
|
58
|
+
copyFileSync(source.target, destinationPath, fsConstants.COPYFILE_EXCL);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function copyVaultFileExclusive(vault, sourcePath, destinationPath, label) {
|
|
62
|
+
checkedVaultFile(vault, sourcePath, `origem de ${label}`, { allowMissing: false });
|
|
63
|
+
checkedVaultFile(vault, destinationPath, `destino de ${label}`, { mustNotExist: true });
|
|
64
|
+
const source = checkedVaultFile(vault, sourcePath, `origem de ${label}`, { allowMissing: false });
|
|
65
|
+
const destination = checkedVaultFile(vault, destinationPath, `destino de ${label}`, {
|
|
66
|
+
mustNotExist: true,
|
|
67
|
+
});
|
|
68
|
+
copyFileSync(source.target, destination.target, fsConstants.COPYFILE_EXCL);
|
|
69
|
+
checkedVaultFile(vault, destination.target, `destino de ${label}`, { allowMissing: false });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeVaultArtifact(vault, path, content, label, publisher = null) {
|
|
73
|
+
const checked = checkedVaultFile(vault, path, label);
|
|
74
|
+
if (!publisher) {
|
|
75
|
+
writeVaultFileAtomic(vault, checked.target, content, 'utf8', { label });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Test/fault-injection publishers keep their old (path, content) contract, but may
|
|
79
|
+
// only run after the real Vault target has been checked immediately before mutation.
|
|
80
|
+
checkedVaultFile(vault, checked.target, label);
|
|
81
|
+
publisher(checked.target, content);
|
|
82
|
+
checkedVaultFile(vault, checked.target, label, { allowMissing: false });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function restoreVaultFile(vault, path, snapshot, label) {
|
|
86
|
+
if (snapshot.exists) writeVaultFileAtomic(vault, path, snapshot.bytes, 'utf8', { label });
|
|
87
|
+
else unlinkVaultFile(vault, path, { label });
|
|
88
|
+
}
|
|
21
89
|
|
|
22
90
|
function projectId(vault) {
|
|
23
|
-
const marker = JSON.parse(
|
|
91
|
+
const marker = JSON.parse(readVaultFile(
|
|
92
|
+
vault, brainPath(vault, 'PROJECT.json'), 'utf8', 'autoridade PROJECT.json',
|
|
93
|
+
));
|
|
24
94
|
if (!marker?.projectId) throw new Error('PROJECT.json inválido: projectId ausente.');
|
|
25
95
|
return marker.projectId;
|
|
26
96
|
}
|
|
@@ -54,17 +124,21 @@ function candidateText(candidates) {
|
|
|
54
124
|
|
|
55
125
|
export function seedMemoryV2(vault) {
|
|
56
126
|
const brain = join(vault, BRAIN);
|
|
57
|
-
|
|
127
|
+
mkdirVaultPath(vault, brain, { label: 'raiz .brain da memória' });
|
|
58
128
|
const created = [];
|
|
59
129
|
const artifacts = [
|
|
60
130
|
[LEDGER, ''],
|
|
61
131
|
[CANDIDATES, ''],
|
|
62
132
|
[SHARED, renderSharedMemory()],
|
|
63
133
|
];
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
134
|
+
const checked = assertVaultPathsSafe(vault, artifacts.map(([name]) => ({
|
|
135
|
+
path: join(brain, name), expectedType: 'file', label: `artefato inicial ${name}`,
|
|
136
|
+
})));
|
|
137
|
+
for (let index = 0; index < artifacts.length; index += 1) {
|
|
138
|
+
const [name, content] = artifacts[index];
|
|
139
|
+
const path = checked[index].target;
|
|
140
|
+
if (!checked[index].exists) {
|
|
141
|
+
writeVaultFileAtomic(vault, path, content, 'utf8', { label: `artefato inicial ${name}` });
|
|
68
142
|
created.push(name);
|
|
69
143
|
}
|
|
70
144
|
}
|
|
@@ -78,12 +152,13 @@ export function memoryStatus(vault) {
|
|
|
78
152
|
export function migrateMemory(vault, {
|
|
79
153
|
apply = false,
|
|
80
154
|
validateBundle = validateMemoryBundle,
|
|
81
|
-
publishArtifact =
|
|
155
|
+
publishArtifact = null,
|
|
82
156
|
} = {}) {
|
|
83
157
|
projectId(vault);
|
|
84
158
|
const sharedPath = brainPath(vault, SHARED);
|
|
85
|
-
const
|
|
86
|
-
const
|
|
159
|
+
const shared = checkedVaultFile(vault, sharedPath, 'SHARED legado');
|
|
160
|
+
const hadShared = shared.exists;
|
|
161
|
+
const legacy = hadShared ? readVaultFile(vault, shared.target, 'utf8', 'SHARED legado') : '';
|
|
87
162
|
const parsed = validateSharedMemory(legacy);
|
|
88
163
|
const alreadyV2 = parsed.ok && parsed.metadata.schema_version === 2;
|
|
89
164
|
const candidates = alreadyV2 ? [] : legacyCandidates(legacy);
|
|
@@ -98,7 +173,10 @@ export function migrateMemory(vault, {
|
|
|
98
173
|
const emptyShared = renderSharedMemory();
|
|
99
174
|
const sharedValidation = validateSharedMemory(emptyShared, { eventIds: new Set() });
|
|
100
175
|
if (!sharedValidation.ok) throw new Error(`Migração inválida: ${sharedValidation.errors.join(' ')}`);
|
|
101
|
-
if (backupPath
|
|
176
|
+
if (backupPath) {
|
|
177
|
+
const backup = checkedVaultFile(vault, backupPath, 'backup do SHARED legado');
|
|
178
|
+
if (!backup.exists) copyVaultFileExclusive(vault, sharedPath, backupPath, 'backup do SHARED legado');
|
|
179
|
+
}
|
|
102
180
|
|
|
103
181
|
// Build and validate a complete candidate vault away from the live paths. This makes
|
|
104
182
|
// the validation callback incapable of observing a half-published live bundle.
|
|
@@ -107,8 +185,12 @@ export function migrateMemory(vault, {
|
|
|
107
185
|
let stagedValidation;
|
|
108
186
|
try {
|
|
109
187
|
mkdirSync(stagingBrain, { recursive: true });
|
|
110
|
-
|
|
111
|
-
|
|
188
|
+
copyVaultFileToExternal(
|
|
189
|
+
vault, brainPath(vault, 'CORE.md'), join(stagingBrain, 'CORE.md'), 'autoridade CORE.md',
|
|
190
|
+
);
|
|
191
|
+
copyVaultFileToExternal(
|
|
192
|
+
vault, brainPath(vault, 'PROJECT.json'), join(stagingBrain, 'PROJECT.json'), 'autoridade PROJECT.json',
|
|
193
|
+
);
|
|
112
194
|
writeFileSync(join(stagingBrain, LEDGER), '', 'utf8');
|
|
113
195
|
writeFileSync(join(stagingBrain, SHARED), emptyShared, 'utf8');
|
|
114
196
|
writeFileSync(join(stagingBrain, CANDIDATES), candidateText(candidates), 'utf8');
|
|
@@ -121,21 +203,27 @@ export function migrateMemory(vault, {
|
|
|
121
203
|
}
|
|
122
204
|
|
|
123
205
|
const targets = [LEDGER, SHARED, CANDIDATES].map((name) => brainPath(vault, name));
|
|
124
|
-
const before = new Map(targets.map((path) => [path,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
206
|
+
const before = new Map(targets.map((path) => [path, snapshotVaultFile(
|
|
207
|
+
vault, path, `snapshot de ${path}`, 'utf8',
|
|
208
|
+
)]));
|
|
209
|
+
assertVaultPathsSafe(vault, targets.map((path) => ({
|
|
210
|
+
path, expectedType: 'file', label: `target de migração ${path}`,
|
|
211
|
+
})));
|
|
128
212
|
try {
|
|
129
|
-
if (!
|
|
130
|
-
|
|
131
|
-
|
|
213
|
+
if (!before.get(brainPath(vault, LEDGER)).exists) {
|
|
214
|
+
writeVaultArtifact(vault, brainPath(vault, LEDGER), '', 'ledger migrado', publishArtifact);
|
|
215
|
+
}
|
|
216
|
+
writeVaultArtifact(vault, sharedPath, emptyShared, 'SHARED migrado', publishArtifact);
|
|
217
|
+
writeVaultArtifact(
|
|
218
|
+
vault, brainPath(vault, CANDIDATES), candidateText(candidates),
|
|
219
|
+
'candidates migrados', publishArtifact,
|
|
220
|
+
);
|
|
132
221
|
const validation = validateMemoryBundle(vault);
|
|
133
222
|
if (!validation.ok) throw new Error(`Bundle migrado inválido: ${validation.errors.join(' ')}`);
|
|
134
223
|
return { status: 'migrated', alreadyV2: false, candidates: candidates.length, backupPath, validation };
|
|
135
224
|
} catch (error) {
|
|
136
225
|
for (const [path, snapshot] of before) {
|
|
137
|
-
|
|
138
|
-
else if (existsSync(path)) unlinkSync(path);
|
|
226
|
+
restoreVaultFile(vault, path, snapshot, `rollback da migração ${path}`);
|
|
139
227
|
}
|
|
140
228
|
throw error;
|
|
141
229
|
}
|
|
@@ -143,8 +231,10 @@ export function migrateMemory(vault, {
|
|
|
143
231
|
|
|
144
232
|
function readCandidates(vault) {
|
|
145
233
|
const path = brainPath(vault, CANDIDATES);
|
|
146
|
-
|
|
147
|
-
|
|
234
|
+
const checked = checkedVaultFile(vault, path, 'MEMORY_CANDIDATES.jsonl');
|
|
235
|
+
if (!checked.exists) return [];
|
|
236
|
+
return readVaultFile(vault, checked.target, 'utf8', 'MEMORY_CANDIDATES.jsonl')
|
|
237
|
+
.split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
148
238
|
}
|
|
149
239
|
|
|
150
240
|
export function decideMemoryCandidate(vault, { action, candidateId, value } = {}) {
|
|
@@ -173,15 +263,595 @@ export function decideMemoryCandidate(vault, { action, candidateId, value } = {}
|
|
|
173
263
|
const remaining = candidates.filter((item) => item.candidate_id !== candidateId);
|
|
174
264
|
const projected = readCandidates(vault);
|
|
175
265
|
const merged = new Map([...remaining, ...projected].map((item) => [item.candidate_id, item]));
|
|
176
|
-
|
|
266
|
+
writeVaultFileAtomic(
|
|
267
|
+
vault, brainPath(vault, CANDIDATES), candidateText([...merged.values()]), 'utf8',
|
|
268
|
+
{ label: 'candidates após decisão humana' },
|
|
269
|
+
);
|
|
177
270
|
return { status: action === 'promote' ? 'promoted' : 'rejected', candidateId, eventId: event.event_id, projection };
|
|
178
271
|
}
|
|
179
272
|
|
|
180
|
-
|
|
273
|
+
function cloneJson(value) {
|
|
274
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sameCheckpoint(left, right) {
|
|
278
|
+
return Boolean(left && right) && canonicalMemoryJson(left) === canonicalMemoryJson(right);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function normalizeReconciliationRequest({ sessionId, bySessionId, reason } = {}) {
|
|
282
|
+
const normalizedSessionId = String(sessionId || '').trim();
|
|
283
|
+
const normalizedBySessionId = String(bySessionId || '').trim();
|
|
284
|
+
const normalizedReason = sanitizeMemoryText(reason || '').trim().slice(0, 500);
|
|
285
|
+
if (!normalizedSessionId) throw new TypeError('sessionId é obrigatório para reconciliar attempt ambíguo.');
|
|
286
|
+
if (!normalizedBySessionId) throw new TypeError('bySessionId é obrigatório para reconciliar attempt ambíguo.');
|
|
287
|
+
if (!normalizedReason) throw new TypeError('reason é obrigatório para reconciliar attempt ambíguo.');
|
|
288
|
+
if (normalizedSessionId === normalizedBySessionId) {
|
|
289
|
+
throw new TypeError('A sessão reconciliadora deve ser diferente da sessão superseded.');
|
|
290
|
+
}
|
|
291
|
+
return { sessionId: normalizedSessionId, bySessionId: normalizedBySessionId, reason: normalizedReason };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function attemptFingerprint(attempt) {
|
|
295
|
+
return hash(canonicalMemoryJson(attempt || null));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function matchingAppliedReconciliation(entry, request) {
|
|
299
|
+
const attempt = entry?.last_memory_attempt;
|
|
300
|
+
if (attempt?.memory_mode !== 'v2' || attempt?.state !== 'skipped' || attempt?.disposition !== 'superseded') return null;
|
|
301
|
+
if (attempt.reconciled_by_session_id !== request.bySessionId
|
|
302
|
+
|| attempt.reconciliation_reason !== request.reason) return null;
|
|
303
|
+
return (Array.isArray(entry.memory_reconciliations) ? entry.memory_reconciliations : [])
|
|
304
|
+
.find((item) => item?.type === 'ambiguous_attempt_superseded'
|
|
305
|
+
&& item.reconciled_by_session_id === request.bySessionId
|
|
306
|
+
&& item.reason === request.reason) || null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function validationError(label, result) {
|
|
310
|
+
const details = (result?.errors || []).join(' ');
|
|
311
|
+
return new Error(`${label} inválido para reconciliação${details ? `: ${details}` : '.'}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function readMemoryAuthority(vault) {
|
|
315
|
+
assertVaultPathsSafe(vault, [
|
|
316
|
+
['PROJECT.json', 'autoridade PROJECT.json'],
|
|
317
|
+
['CORE.md', 'autoridade CORE.md'],
|
|
318
|
+
[LEDGER, 'autoridade MEMORY_EVENTS.jsonl'],
|
|
319
|
+
].map(([name, label]) => ({
|
|
320
|
+
path: brainPath(vault, name), expectedType: 'file', label,
|
|
321
|
+
})));
|
|
322
|
+
const project = readProjectForValidation(vault);
|
|
323
|
+
if (!project.ok) throw validationError('PROJECT.json', project);
|
|
324
|
+
|
|
325
|
+
const projectBytes = readVaultFile(vault, project.path, undefined, 'autoridade PROJECT.json');
|
|
326
|
+
const corePath = brainPath(vault, 'CORE.md');
|
|
327
|
+
let coreBytes;
|
|
328
|
+
try {
|
|
329
|
+
coreBytes = readVaultFile(vault, corePath, undefined, 'autoridade CORE.md');
|
|
330
|
+
} catch (error) {
|
|
331
|
+
throw new Error(`CORE.md ausente ou ilegível para reconciliação: ${error?.message || error}`);
|
|
332
|
+
}
|
|
333
|
+
const core = validateCore(coreBytes.toString('utf8'));
|
|
334
|
+
if (!core.ok) throw validationError('CORE.md', core);
|
|
335
|
+
|
|
336
|
+
const ledgerPath = brainPath(vault, LEDGER);
|
|
337
|
+
let ledgerBefore;
|
|
338
|
+
try {
|
|
339
|
+
ledgerBefore = readVaultFile(vault, ledgerPath, undefined, 'autoridade MEMORY_EVENTS.jsonl');
|
|
340
|
+
} catch (error) {
|
|
341
|
+
throw new Error(`MEMORY_EVENTS.jsonl ausente ou ilegível para reconciliação: ${error?.message || error}`);
|
|
342
|
+
}
|
|
343
|
+
const ledger = readLedgerForValidation(vault, { projectId: project.projectId });
|
|
344
|
+
if (!ledger.ok) throw validationError('Ledger', ledger);
|
|
345
|
+
const ledgerAfter = readVaultFile(vault, ledgerPath, undefined, 'autoridade MEMORY_EVENTS.jsonl');
|
|
346
|
+
if (!ledgerBefore.equals(ledgerAfter)) {
|
|
347
|
+
throw new Error('Snapshot do ledger mudou durante o preflight de reconciliação.');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
projectId: project.projectId,
|
|
352
|
+
projectHash: byteHash(projectBytes),
|
|
353
|
+
coreHash: byteHash(coreBytes),
|
|
354
|
+
ledgerHash: byteHash(ledgerAfter),
|
|
355
|
+
ledgerEvents: ledger.events,
|
|
356
|
+
ledgerById: new Map(ledger.events.map((event) => [event.event_id, event])),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function assertAuthorityMatches(expected, actual) {
|
|
361
|
+
const changed = [
|
|
362
|
+
['PROJECT.json', 'projectHash'],
|
|
363
|
+
['CORE.md', 'coreHash'],
|
|
364
|
+
['ledger', 'ledgerHash'],
|
|
365
|
+
].find(([, key]) => expected?.[key] !== actual?.[key]);
|
|
366
|
+
if (expected?.projectId !== actual?.projectId || changed) {
|
|
367
|
+
const label = expected?.projectId !== actual?.projectId ? 'PROJECT.json' : changed[0];
|
|
368
|
+
throw new Error(`Snapshot de autoridade mudou após o preflight (${label}); reconciliação abortada.`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function proofValue(value) {
|
|
373
|
+
return typeof value === 'string' ? value.trim() : value;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function validateSuccessorProof(request, attempt, authority) {
|
|
377
|
+
if (attempt?.memory_mode !== 'v2' || attempt?.state !== 'projected'
|
|
378
|
+
|| attempt?.disposition !== 'applied') {
|
|
379
|
+
throw new Error(`Sessão reconciliadora ${request.bySessionId} não possui attempt projected/applied.`);
|
|
380
|
+
}
|
|
381
|
+
if (!Array.isArray(attempt.event_ids) || attempt.event_ids.length === 0) {
|
|
382
|
+
throw new Error(`Attempt da sessão reconciliadora ${request.bySessionId} não possui event_ids.`);
|
|
383
|
+
}
|
|
384
|
+
if (new Set(attempt.event_ids).size !== attempt.event_ids.length) {
|
|
385
|
+
throw new Error('Identidade causal inválida: successor attempt possui event_ids duplicados.');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const expected = {
|
|
389
|
+
canonical_session_id: request.bySessionId,
|
|
390
|
+
activation_id: proofValue(attempt.activation_id),
|
|
391
|
+
activation_epoch: attempt.activation_epoch,
|
|
392
|
+
source_turn_id: proofValue(attempt.turn_id),
|
|
393
|
+
turn_sequence: attempt.turn_sequence,
|
|
394
|
+
};
|
|
395
|
+
if (proofValue(attempt.canonical_session_id) !== request.bySessionId) {
|
|
396
|
+
throw new Error(`Identidade causal inválida: canonical_session_id do successor attempt deve ser ${request.bySessionId}.`);
|
|
397
|
+
}
|
|
398
|
+
if (!expected.activation_id) throw new Error('Identidade causal inválida: activation_id ausente no successor attempt.');
|
|
399
|
+
if (!Number.isInteger(expected.activation_epoch) || expected.activation_epoch < 0) {
|
|
400
|
+
throw new Error('Identidade causal inválida: activation_epoch do successor attempt deve ser inteiro não negativo.');
|
|
401
|
+
}
|
|
402
|
+
if (!expected.source_turn_id) throw new Error('Identidade causal inválida: turn_id ausente no successor attempt.');
|
|
403
|
+
if (!Number.isInteger(expected.turn_sequence) || expected.turn_sequence < 0) {
|
|
404
|
+
throw new Error('Identidade causal inválida: turn_sequence do successor attempt deve ser inteiro não negativo.');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const ledgerById = authority.ledgerById
|
|
408
|
+
|| new Map(authority.ledgerEvents.map((event) => [event.event_id, event]));
|
|
409
|
+
const missing = attempt.event_ids.filter((eventId) => !ledgerById.has(eventId));
|
|
410
|
+
if (missing.length) {
|
|
411
|
+
throw new Error('Attempt da sessão reconciliadora referencia event_ids ausentes do ledger.');
|
|
412
|
+
}
|
|
413
|
+
for (const eventId of attempt.event_ids) {
|
|
414
|
+
const event = ledgerById.get(eventId);
|
|
415
|
+
for (const [field, expectedValue] of Object.entries(expected)) {
|
|
416
|
+
if (event?.[field] !== expectedValue) {
|
|
417
|
+
throw new Error(`Identidade causal inválida no evento ${eventId}: ${field} não corresponde ao successor attempt.`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
v: 1,
|
|
424
|
+
project_id: authority.projectId,
|
|
425
|
+
registry_session_id: request.bySessionId,
|
|
426
|
+
canonical_session_id: request.bySessionId,
|
|
427
|
+
activation_id: expected.activation_id,
|
|
428
|
+
activation_epoch: expected.activation_epoch,
|
|
429
|
+
turn_id: expected.source_turn_id,
|
|
430
|
+
turn_sequence: expected.turn_sequence,
|
|
431
|
+
required_event_ids: [...attempt.event_ids],
|
|
432
|
+
expected_ledger_sha256: authority.ledgerHash,
|
|
433
|
+
expected_core_sha256: authority.coreHash,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function assertStoredProofMatches(stored, current) {
|
|
438
|
+
if (!stored || typeof stored !== 'object') {
|
|
439
|
+
throw new Error('Audit matching inválido: causal_proof ausente; retry idempotente recusado.');
|
|
440
|
+
}
|
|
441
|
+
const causalFields = [
|
|
442
|
+
'v', 'project_id', 'registry_session_id', 'canonical_session_id', 'activation_id',
|
|
443
|
+
'activation_epoch', 'turn_id', 'turn_sequence', 'required_event_ids',
|
|
444
|
+
];
|
|
445
|
+
const select = (proof) => Object.fromEntries(causalFields.map((field) => [field, proof?.[field]]));
|
|
446
|
+
const validHash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
447
|
+
if (!validHash(stored.expected_ledger_sha256) || !validHash(stored.expected_core_sha256)
|
|
448
|
+
|| canonicalMemoryJson(select(stored)) !== canonicalMemoryJson(select(current))) {
|
|
449
|
+
throw new Error('Prova causal armazenada diverge da identidade, dos event_ids ou dos hashes autorizados.');
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function assertHealthyReconciledBundle(vault, registry) {
|
|
454
|
+
const health = checkMemoryBundle(vault, { registry });
|
|
455
|
+
if (!health.ok || health.status === 'blocked') {
|
|
456
|
+
throw new Error(`Memory health bloqueado durante reconciliação: ${(health.failures || []).join(' ')}`);
|
|
457
|
+
}
|
|
458
|
+
return health;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function assertPublishedProjection(vault, projection) {
|
|
462
|
+
const shared = readVaultFile(
|
|
463
|
+
vault, brainPath(vault, SHARED), 'utf8', 'projeção SHARED_MEMORY.md publicada',
|
|
464
|
+
);
|
|
465
|
+
const candidates = readVaultFile(
|
|
466
|
+
vault, brainPath(vault, CANDIDATES), 'utf8', 'projeção MEMORY_CANDIDATES.jsonl publicada',
|
|
467
|
+
);
|
|
468
|
+
if (shared !== projection.sharedContent || candidates !== projection.candidatesContent) {
|
|
469
|
+
throw new Error('Publicação derivada divergiu dos bytes preparados sob MEMORY.lock.');
|
|
470
|
+
}
|
|
471
|
+
const bundle = validateMemoryBundle(vault);
|
|
472
|
+
if (!bundle.ok) throw new Error(`Bundle reprojetado inválido: ${bundle.errors.join(' ')}`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function prepareReconciliation(vault, rawRequest, { expectedAuthority } = {}) {
|
|
476
|
+
const request = normalizeReconciliationRequest(rawRequest);
|
|
477
|
+
const authority = readMemoryAuthority(vault);
|
|
478
|
+
if (expectedAuthority) assertAuthorityMatches(expectedAuthority, authority);
|
|
479
|
+
const registry = readSessionRegistry(vault);
|
|
480
|
+
const target = registry.sessions[request.sessionId];
|
|
481
|
+
const successor = registry.sessions[request.bySessionId];
|
|
482
|
+
if (!target) throw new Error(`Sessão não encontrada para reconciliação: ${request.sessionId}`);
|
|
483
|
+
if (!successor) throw new Error(`Sessão reconciliadora não encontrada: ${request.bySessionId}`);
|
|
484
|
+
|
|
485
|
+
const priorAudit = matchingAppliedReconciliation(target, request);
|
|
486
|
+
if (priorAudit) {
|
|
487
|
+
const proof = validateSuccessorProof(request, successor.last_memory_attempt, authority);
|
|
488
|
+
assertStoredProofMatches(priorAudit.causal_proof, proof);
|
|
489
|
+
const health = assertHealthyReconciledBundle(vault, registry);
|
|
490
|
+
return {
|
|
491
|
+
request,
|
|
492
|
+
authority,
|
|
493
|
+
alreadyApplied: true,
|
|
494
|
+
health,
|
|
495
|
+
reconciliationId: priorAudit.reconciliation_id || null,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const targetAttempt = target.last_memory_attempt;
|
|
500
|
+
if (targetAttempt?.memory_mode !== 'v2' || targetAttempt?.state !== 'skipped'
|
|
501
|
+
|| targetAttempt?.disposition !== 'ambiguous') {
|
|
502
|
+
throw new Error(`Attempt da sessão ${request.sessionId} não está ambiguous/skipped em memória v2.`);
|
|
503
|
+
}
|
|
504
|
+
if (!Array.isArray(targetAttempt.event_ids) || targetAttempt.event_ids.length !== 0) {
|
|
505
|
+
throw new Error(`Attempt ambíguo da sessão ${request.sessionId} deve ter event_ids vazio.`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const successorAttempt = successor.last_memory_attempt;
|
|
509
|
+
const proof = validateSuccessorProof(request, successorAttempt, authority);
|
|
510
|
+
const derived = deriveMemoryProjection(vault, authority.ledgerEvents);
|
|
511
|
+
return {
|
|
512
|
+
request,
|
|
513
|
+
authority,
|
|
514
|
+
proof,
|
|
515
|
+
alreadyApplied: false,
|
|
516
|
+
targetFingerprint: attemptFingerprint(targetAttempt),
|
|
517
|
+
successorFingerprint: attemptFingerprint(successorAttempt),
|
|
518
|
+
targetAttempt: cloneJson(targetAttempt),
|
|
519
|
+
successorCheckpoint: cloneJson(successorAttempt.checkpoint || successor.memory_checkpoint || null),
|
|
520
|
+
nextCheckpoint: cloneJson(derived.checkpoint),
|
|
521
|
+
ledgerEvents: authority.ledgerEvents.length,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function snapshotArtifact(vault, path, label) {
|
|
526
|
+
return snapshotVaultFile(vault, path, label);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function restoreArtifact(vault, path, snapshot, label) {
|
|
530
|
+
restoreVaultFile(vault, path, snapshot, label);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function reconcileMemory(vault, {
|
|
534
|
+
sessionId,
|
|
535
|
+
bySessionId,
|
|
536
|
+
reason,
|
|
537
|
+
apply = false,
|
|
538
|
+
now = new Date().toISOString(),
|
|
539
|
+
beforeRegistryMutation,
|
|
540
|
+
memoryLock = {},
|
|
541
|
+
} = {}) {
|
|
542
|
+
const prepared = prepareReconciliation(vault, { sessionId, bySessionId, reason });
|
|
543
|
+
if (prepared.alreadyApplied && !apply) {
|
|
544
|
+
return {
|
|
545
|
+
status: 'unchanged',
|
|
546
|
+
sessionId: prepared.request.sessionId,
|
|
547
|
+
bySessionId: prepared.request.bySessionId,
|
|
548
|
+
reconciliationId: prepared.reconciliationId,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
if (!apply) {
|
|
552
|
+
return {
|
|
553
|
+
status: 'dry-run',
|
|
554
|
+
sessionId: prepared.request.sessionId,
|
|
555
|
+
bySessionId: prepared.request.bySessionId,
|
|
556
|
+
reason: prepared.request.reason,
|
|
557
|
+
ledgerEvents: prepared.ledgerEvents,
|
|
558
|
+
checkpointBefore: prepared.successorCheckpoint,
|
|
559
|
+
checkpointAfter: prepared.nextCheckpoint,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const outcome = withMemoryLock(vault, () => {
|
|
564
|
+
const locked = prepareReconciliation(vault, prepared.request, { expectedAuthority: prepared.authority });
|
|
565
|
+
if (locked.alreadyApplied) {
|
|
566
|
+
return {
|
|
567
|
+
status: 'unchanged',
|
|
568
|
+
sessionId: locked.request.sessionId,
|
|
569
|
+
bySessionId: locked.request.bySessionId,
|
|
570
|
+
reconciliationId: locked.reconciliationId,
|
|
571
|
+
health: locked.health,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
if (locked.targetFingerprint !== prepared.targetFingerprint
|
|
575
|
+
|| locked.successorFingerprint !== prepared.successorFingerprint) {
|
|
576
|
+
throw new Error('CAS perdido: attempt mudou antes da região crítica de reconciliação.');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (typeof beforeRegistryMutation === 'function') beforeRegistryMutation();
|
|
580
|
+
const finalAuthority = readMemoryAuthority(vault);
|
|
581
|
+
assertAuthorityMatches(prepared.authority, finalAuthority);
|
|
582
|
+
const preparedProjection = prepareMemoryProjection(vault, finalAuthority.ledgerEvents);
|
|
583
|
+
assertAuthorityMatches(prepared.authority, readMemoryAuthority(vault));
|
|
584
|
+
|
|
585
|
+
const sharedPath = brainPath(vault, SHARED);
|
|
586
|
+
const candidatesPath = brainPath(vault, CANDIDATES);
|
|
587
|
+
const sharedBefore = snapshotArtifact(vault, sharedPath, 'snapshot SHARED de reconciliação');
|
|
588
|
+
const candidatesBefore = snapshotArtifact(
|
|
589
|
+
vault, candidatesPath, 'snapshot CANDIDATES de reconciliação',
|
|
590
|
+
);
|
|
591
|
+
let projectionTouched = false;
|
|
592
|
+
let backupCreated = false;
|
|
593
|
+
let backupPath = null;
|
|
594
|
+
let projection;
|
|
595
|
+
let committedHealth;
|
|
596
|
+
|
|
597
|
+
try {
|
|
598
|
+
// Intentional global order: MEMORY.lock -> SESSION_REGISTRY lock. The Stop
|
|
599
|
+
// lifecycle releases its registry lock before projection, so no inverse nested
|
|
600
|
+
// acquisition exists; keeping both here closes the CAS-to-sidecar race window.
|
|
601
|
+
const registryResult = mutateSessionRegistry(vault, (registry) => {
|
|
602
|
+
const target = registry.sessions?.[locked.request.sessionId];
|
|
603
|
+
const successor = registry.sessions?.[locked.request.bySessionId];
|
|
604
|
+
if (!target || !successor
|
|
605
|
+
|| attemptFingerprint(target.last_memory_attempt) !== locked.targetFingerprint
|
|
606
|
+
|| attemptFingerprint(successor.last_memory_attempt) !== locked.successorFingerprint) {
|
|
607
|
+
throw new Error('CAS perdido: attempt mudou durante a reconciliação; nenhuma reclassificação aplicada.');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const nextCheckpoint = cloneJson(preparedProjection.checkpoint);
|
|
611
|
+
const reconciliationId = `memrec-${hash(`${locked.request.sessionId}\0${locked.request.bySessionId}\0${locked.targetFingerprint}\0${locked.request.reason}`).slice(0, 20)}`;
|
|
612
|
+
const targetAudit = {
|
|
613
|
+
v: 1,
|
|
614
|
+
reconciliation_id: reconciliationId,
|
|
615
|
+
type: 'ambiguous_attempt_superseded',
|
|
616
|
+
reconciled_at: now,
|
|
617
|
+
reconciled_by_session_id: locked.request.bySessionId,
|
|
618
|
+
reason: locked.request.reason,
|
|
619
|
+
causal_proof: cloneJson(locked.proof),
|
|
620
|
+
original_attempt: cloneJson(locked.targetAttempt),
|
|
621
|
+
};
|
|
622
|
+
target.memory_reconciliations = [
|
|
623
|
+
...(Array.isArray(target.memory_reconciliations) ? target.memory_reconciliations : []),
|
|
624
|
+
targetAudit,
|
|
625
|
+
];
|
|
626
|
+
target.last_memory_attempt = {
|
|
627
|
+
...cloneJson(locked.targetAttempt),
|
|
628
|
+
disposition: 'superseded',
|
|
629
|
+
reconciled_at: now,
|
|
630
|
+
reconciled_by_session_id: locked.request.bySessionId,
|
|
631
|
+
reconciliation_reason: locked.request.reason,
|
|
632
|
+
};
|
|
633
|
+
target.memory_status = 'skipped';
|
|
634
|
+
|
|
635
|
+
const checkpointBefore = cloneJson(successor.last_memory_attempt.checkpoint || successor.memory_checkpoint || null);
|
|
636
|
+
const refreshedCheckpoint = !sameCheckpoint(checkpointBefore, nextCheckpoint);
|
|
637
|
+
if (refreshedCheckpoint) {
|
|
638
|
+
successor.memory_reconciliations = [
|
|
639
|
+
...(Array.isArray(successor.memory_reconciliations) ? successor.memory_reconciliations : []),
|
|
640
|
+
{
|
|
641
|
+
v: 1,
|
|
642
|
+
reconciliation_id: reconciliationId,
|
|
643
|
+
type: 'checkpoint_refreshed',
|
|
644
|
+
reconciled_at: now,
|
|
645
|
+
event_ids: [...successor.last_memory_attempt.event_ids],
|
|
646
|
+
causal_proof: cloneJson(locked.proof),
|
|
647
|
+
original_checkpoint: checkpointBefore,
|
|
648
|
+
checkpoint: cloneJson(nextCheckpoint),
|
|
649
|
+
},
|
|
650
|
+
];
|
|
651
|
+
}
|
|
652
|
+
successor.last_memory_attempt = { ...successor.last_memory_attempt, checkpoint: cloneJson(nextCheckpoint) };
|
|
653
|
+
successor.memory_checkpoint = cloneJson(nextCheckpoint);
|
|
654
|
+
|
|
655
|
+
projectionTouched = true;
|
|
656
|
+
projection = publishMemoryProjection(vault, preparedProjection);
|
|
657
|
+
assertPublishedProjection(vault, preparedProjection);
|
|
658
|
+
assertAuthorityMatches(prepared.authority, readMemoryAuthority(vault));
|
|
659
|
+
// Policy blockers unrelated to this target (for example another ambiguous Stop
|
|
660
|
+
// or a candidate conflict) remain visible, but do not roll back a valid scoped CAS.
|
|
661
|
+
committedHealth = checkMemoryBundle(vault, { registry });
|
|
662
|
+
|
|
663
|
+
const path = registryPath(vault);
|
|
664
|
+
const registryArtifact = checkedVaultFile(vault, path, 'SESSION_REGISTRY da reconciliação');
|
|
665
|
+
if (registryArtifact.exists) {
|
|
666
|
+
const exact = readVaultFile(
|
|
667
|
+
vault, registryArtifact.target, undefined, 'SESSION_REGISTRY da reconciliação',
|
|
668
|
+
);
|
|
669
|
+
backupPath = `${path}.reconcile-${hash(exact).slice(0, 12)}.bak`;
|
|
670
|
+
const backup = checkedVaultFile(vault, backupPath, 'backup da reconciliação');
|
|
671
|
+
if (!backup.exists) {
|
|
672
|
+
copyVaultFileExclusive(vault, path, backupPath, 'backup da reconciliação');
|
|
673
|
+
backupCreated = true;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return {
|
|
677
|
+
status: 'reconciled',
|
|
678
|
+
reconciliationId,
|
|
679
|
+
refreshedCheckpoint,
|
|
680
|
+
supersededSession: locked.request.sessionId,
|
|
681
|
+
backupPath,
|
|
682
|
+
};
|
|
683
|
+
});
|
|
684
|
+
return {
|
|
685
|
+
status: 'reconciled', projection, registry: registryResult, health: committedHealth,
|
|
686
|
+
};
|
|
687
|
+
} catch (error) {
|
|
688
|
+
const rollbackErrors = [];
|
|
689
|
+
if (projectionTouched) {
|
|
690
|
+
try {
|
|
691
|
+
restoreArtifact(vault, sharedPath, sharedBefore, 'rollback SHARED da reconciliação');
|
|
692
|
+
} catch (rollbackError) { rollbackErrors.push(rollbackError); }
|
|
693
|
+
try {
|
|
694
|
+
restoreArtifact(vault, candidatesPath, candidatesBefore, 'rollback CANDIDATES da reconciliação');
|
|
695
|
+
} catch (rollbackError) { rollbackErrors.push(rollbackError); }
|
|
696
|
+
}
|
|
697
|
+
if (backupCreated && backupPath) {
|
|
698
|
+
try {
|
|
699
|
+
unlinkVaultFile(vault, backupPath, {
|
|
700
|
+
missingOk: false, label: 'backup revertido da reconciliação',
|
|
701
|
+
});
|
|
702
|
+
} catch (rollbackError) { rollbackErrors.push(rollbackError); }
|
|
703
|
+
}
|
|
704
|
+
if (rollbackErrors.length) {
|
|
705
|
+
throw new AggregateError([error, ...rollbackErrors], `Reconciliação falhou e o rollback dos sidecars também falhou: ${error.message}`);
|
|
706
|
+
}
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
}, memoryLock);
|
|
710
|
+
|
|
711
|
+
if (outcome === MEMORY_LOCK_BUSY) {
|
|
712
|
+
const error = new Error('MEMORY.lock indisponível; reconciliação não aplicada e ambiguidade preservada.');
|
|
713
|
+
error.code = 'WENDKEEP_MEMORY_LOCK_BUSY';
|
|
714
|
+
throw error;
|
|
715
|
+
}
|
|
716
|
+
return outcome;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function checkpointShape(checkpoint) {
|
|
720
|
+
return checkpoint && typeof checkpoint === 'object'
|
|
721
|
+
&& Number.isInteger(checkpoint.revision) && checkpoint.revision >= 0
|
|
722
|
+
&& typeof checkpoint.event_cursor === 'string' && checkpoint.event_cursor
|
|
723
|
+
&& typeof checkpoint.state_hash === 'string' && checkpoint.state_hash;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function legacyCheckpointMigration(sessionId, attempt, authority, fullReplay) {
|
|
727
|
+
const checkpoint = attempt?.checkpoint;
|
|
728
|
+
const requiredEventIds = Array.isArray(attempt?.event_ids) ? [...attempt.event_ids] : [];
|
|
729
|
+
if (!checkpointShape(checkpoint) || !requiredEventIds.length) return null;
|
|
730
|
+
// The pre-physical format had no explicit causal_event_cursor: event_cursor itself
|
|
731
|
+
// named the reducer's causal tail while revision/hash described the full authority
|
|
732
|
+
// snapshot. Restricting automatic migration to that exact snapshot is both O(n) and
|
|
733
|
+
// fail-closed: historical/ambiguous prefixes require explicit human reconciliation.
|
|
734
|
+
if (checkpoint.causal_event_cursor !== undefined) return null;
|
|
735
|
+
if (requiredEventIds.some((eventId) => !authority.ledgerById.has(eventId))) return null;
|
|
736
|
+
if (checkpoint.revision !== fullReplay.revision
|
|
737
|
+
|| checkpoint.state_hash !== fullReplay.stateHash
|
|
738
|
+
|| checkpoint.event_cursor !== fullReplay.eventCursor) return null;
|
|
739
|
+
if (sameCheckpoint(checkpoint, fullReplay.checkpoint)) return null;
|
|
740
|
+
|
|
741
|
+
const proof = validateSuccessorProof({ bySessionId: sessionId }, attempt, authority);
|
|
742
|
+
return {
|
|
743
|
+
sessionId,
|
|
744
|
+
expectedFingerprint: attemptFingerprint(attempt),
|
|
745
|
+
originalCheckpoint: cloneJson(checkpoint),
|
|
746
|
+
checkpoint: cloneJson(fullReplay.checkpoint),
|
|
747
|
+
proof,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export function migrateLegacyMemoryCheckpoints(vault, {
|
|
752
|
+
now = new Date().toISOString(), memoryLock = {},
|
|
753
|
+
} = {}) {
|
|
754
|
+
const expectedAuthority = readMemoryAuthority(vault);
|
|
755
|
+
const outcome = withMemoryLock(vault, () => {
|
|
756
|
+
const authority = readMemoryAuthority(vault);
|
|
757
|
+
assertAuthorityMatches(expectedAuthority, authority);
|
|
758
|
+
const inspected = readSessionRegistry(vault);
|
|
759
|
+
const fullReplay = deriveMemoryProjection(vault, authority.ledgerEvents);
|
|
760
|
+
const plans = Object.entries(inspected.sessions || {})
|
|
761
|
+
.map(([sessionId, entry]) => legacyCheckpointMigration(
|
|
762
|
+
sessionId, entry?.last_memory_attempt, authority, fullReplay,
|
|
763
|
+
))
|
|
764
|
+
.filter(Boolean);
|
|
765
|
+
assertAuthorityMatches(expectedAuthority, readMemoryAuthority(vault));
|
|
766
|
+
if (!plans.length) return {
|
|
767
|
+
status: 'unchanged', migrated: 0, sessions: [], backupPath: null,
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
let backupPath = null;
|
|
771
|
+
let backupCreated = false;
|
|
772
|
+
try {
|
|
773
|
+
return mutateSessionRegistry(vault, (registry) => {
|
|
774
|
+
// Validate every CAS before changing the first entry, making the batch atomic.
|
|
775
|
+
for (const plan of plans) {
|
|
776
|
+
const attempt = registry.sessions?.[plan.sessionId]?.last_memory_attempt;
|
|
777
|
+
if (attemptFingerprint(attempt) !== plan.expectedFingerprint) {
|
|
778
|
+
throw new Error(`CAS perdido: checkpoint da sessão ${plan.sessionId} mudou durante a migração estrutural.`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const path = registryPath(vault);
|
|
783
|
+
const registryArtifact = checkedVaultFile(vault, path, 'SESSION_REGISTRY da migração');
|
|
784
|
+
if (registryArtifact.exists) {
|
|
785
|
+
const exact = readVaultFile(
|
|
786
|
+
vault, registryArtifact.target, undefined, 'SESSION_REGISTRY da migração',
|
|
787
|
+
);
|
|
788
|
+
backupPath = `${path}.checkpoint-migrate-${hash(exact).slice(0, 12)}.bak`;
|
|
789
|
+
const backup = checkedVaultFile(vault, backupPath, 'backup da migração de checkpoint');
|
|
790
|
+
if (!backup.exists) {
|
|
791
|
+
copyVaultFileExclusive(vault, path, backupPath, 'backup da migração de checkpoint');
|
|
792
|
+
backupCreated = true;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
for (const plan of plans) {
|
|
797
|
+
const entry = registry.sessions[plan.sessionId];
|
|
798
|
+
const reconciliationId = `memcp-${hash(`${plan.sessionId}\0${plan.expectedFingerprint}\0${canonicalMemoryJson(plan.checkpoint)}`).slice(0, 20)}`;
|
|
799
|
+
entry.memory_reconciliations = [
|
|
800
|
+
...(Array.isArray(entry.memory_reconciliations) ? entry.memory_reconciliations : []),
|
|
801
|
+
{
|
|
802
|
+
v: 1,
|
|
803
|
+
reconciliation_id: reconciliationId,
|
|
804
|
+
type: 'legacy_causal_checkpoint_migrated',
|
|
805
|
+
reconciled_at: now,
|
|
806
|
+
causal_proof: cloneJson(plan.proof),
|
|
807
|
+
original_checkpoint: cloneJson(plan.originalCheckpoint),
|
|
808
|
+
checkpoint: cloneJson(plan.checkpoint),
|
|
809
|
+
},
|
|
810
|
+
];
|
|
811
|
+
entry.last_memory_attempt = {
|
|
812
|
+
...entry.last_memory_attempt,
|
|
813
|
+
checkpoint: cloneJson(plan.checkpoint),
|
|
814
|
+
};
|
|
815
|
+
entry.memory_checkpoint = cloneJson(plan.checkpoint);
|
|
816
|
+
}
|
|
817
|
+
return {
|
|
818
|
+
status: 'migrated',
|
|
819
|
+
migrated: plans.length,
|
|
820
|
+
sessions: plans.map((plan) => plan.sessionId),
|
|
821
|
+
backupPath,
|
|
822
|
+
};
|
|
823
|
+
});
|
|
824
|
+
} catch (error) {
|
|
825
|
+
if (backupCreated && backupPath) {
|
|
826
|
+
try {
|
|
827
|
+
unlinkVaultFile(vault, backupPath, {
|
|
828
|
+
missingOk: false, label: 'backup revertido da migração de checkpoint',
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
catch (cleanupError) {
|
|
832
|
+
throw new AggregateError([error, cleanupError], `Migração de checkpoint falhou e o backup novo não pôde ser removido: ${error.message}`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
throw error;
|
|
836
|
+
}
|
|
837
|
+
}, memoryLock);
|
|
838
|
+
if (outcome === MEMORY_LOCK_BUSY) {
|
|
839
|
+
const error = new Error('MEMORY.lock indisponível; checkpoints legados não foram migrados.');
|
|
840
|
+
error.code = 'WENDKEEP_MEMORY_LOCK_BUSY';
|
|
841
|
+
throw error;
|
|
842
|
+
}
|
|
843
|
+
return outcome;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export function repairMemory(vault, options = {}) {
|
|
181
847
|
const repaired = repairMemoryLedger(vault);
|
|
182
848
|
if (repaired.status === 'busy') return repaired;
|
|
183
849
|
const projection = projectMemoryOutbox(vault);
|
|
184
|
-
|
|
850
|
+
if (projection.status === 'busy') return { status: 'busy', repaired, projection };
|
|
851
|
+
const checkpointMigration = migrateLegacyMemoryCheckpoints(vault, options);
|
|
852
|
+
return {
|
|
853
|
+
status: 'repaired', repaired, projection, checkpointMigration,
|
|
854
|
+
};
|
|
185
855
|
}
|
|
186
856
|
|
|
187
857
|
export function runValidateMemoryBundle(argv) {
|
|
@@ -213,21 +883,101 @@ function option(argv, name) {
|
|
|
213
883
|
return argv.find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1) || '';
|
|
214
884
|
}
|
|
215
885
|
|
|
886
|
+
function memoryUsageError(message) {
|
|
887
|
+
const error = new TypeError(message);
|
|
888
|
+
error.code = 'WENDKEEP_MEMORY_USAGE';
|
|
889
|
+
return error;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function parseReconcileArgs(argv) {
|
|
893
|
+
const sessionId = argv[1];
|
|
894
|
+
if (!sessionId || sessionId.startsWith('--')) {
|
|
895
|
+
throw memoryUsageError('memory reconcile requer exatamente um <session> posicional.');
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const values = new Map();
|
|
899
|
+
const seen = new Set();
|
|
900
|
+
let apply = false;
|
|
901
|
+
const valueOptions = new Set(['--by-session', '--reason', '--vault']);
|
|
902
|
+
|
|
903
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
904
|
+
const token = argv[index];
|
|
905
|
+
if (!token.startsWith('--')) {
|
|
906
|
+
throw memoryUsageError(`memory reconcile recebeu argumento posicional extra: ${token}.`);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const equalAt = token.indexOf('=');
|
|
910
|
+
const name = equalAt >= 0 ? token.slice(0, equalAt) : token;
|
|
911
|
+
if (name !== '--apply' && !valueOptions.has(name)) {
|
|
912
|
+
throw memoryUsageError(`memory reconcile recebeu opção desconhecida: ${name}.`);
|
|
913
|
+
}
|
|
914
|
+
if (seen.has(name)) throw memoryUsageError(`memory reconcile recebeu opção duplicada: ${name}.`);
|
|
915
|
+
seen.add(name);
|
|
916
|
+
|
|
917
|
+
if (name === '--apply') {
|
|
918
|
+
if (equalAt >= 0) throw memoryUsageError('--apply não aceita valor.');
|
|
919
|
+
apply = true;
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const value = equalAt >= 0 ? token.slice(equalAt + 1) : argv[index + 1];
|
|
924
|
+
if (!value || value.startsWith('--')) {
|
|
925
|
+
throw memoryUsageError(`${name} requer valor não vazio que não comece com --.`);
|
|
926
|
+
}
|
|
927
|
+
values.set(name, value);
|
|
928
|
+
if (equalAt < 0) index += 1;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (!values.get('--by-session')?.trim()) {
|
|
932
|
+
throw memoryUsageError('memory reconcile requer --by-session <session>.');
|
|
933
|
+
}
|
|
934
|
+
if (!values.get('--reason')?.trim()) {
|
|
935
|
+
throw memoryUsageError('memory reconcile requer --reason <text>.');
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return {
|
|
939
|
+
sessionId,
|
|
940
|
+
bySessionId: values.get('--by-session'),
|
|
941
|
+
reason: values.get('--reason'),
|
|
942
|
+
vault: values.get('--vault') || '',
|
|
943
|
+
apply,
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
216
947
|
export function runMemory(argv) {
|
|
217
948
|
const [sub, positional] = argv;
|
|
218
|
-
|
|
949
|
+
let reconcileArgs = null;
|
|
950
|
+
if (sub === 'reconcile') {
|
|
951
|
+
try {
|
|
952
|
+
reconcileArgs = parseReconcileArgs(argv);
|
|
953
|
+
} catch (error) {
|
|
954
|
+
process.stderr.write(`wendkeep memory: ${error.message}\n`);
|
|
955
|
+
process.exitCode = error.code === 'WENDKEEP_MEMORY_USAGE' ? 2 : 1;
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const vault = (reconcileArgs?.vault || option(argv, '--vault')) || process.env.OBSIDIAN_VAULT_PATH;
|
|
219
960
|
if (!vault) { process.stderr.write('wendkeep memory: passe --vault <path>.\n'); process.exitCode = 2; return; }
|
|
961
|
+
if (!existsSync(vault)) { process.stderr.write(`wendkeep memory: not found: ${vault}\n`); process.exitCode = 2; return; }
|
|
220
962
|
try {
|
|
221
963
|
let result;
|
|
222
964
|
if (sub === 'status') result = memoryStatus(vault);
|
|
223
965
|
else if (sub === 'migrate') result = migrateMemory(vault, { apply: argv.includes('--apply') });
|
|
224
966
|
else if (sub === 'repair') result = repairMemory(vault);
|
|
967
|
+
else if (sub === 'reconcile') {
|
|
968
|
+
result = reconcileMemory(vault, {
|
|
969
|
+
sessionId: reconcileArgs.sessionId,
|
|
970
|
+
bySessionId: reconcileArgs.bySessionId,
|
|
971
|
+
reason: reconcileArgs.reason,
|
|
972
|
+
apply: reconcileArgs.apply,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
225
975
|
else if (sub === 'promote' || sub === 'reject') result = decideMemoryCandidate(vault, { action: sub, candidateId: positional });
|
|
226
|
-
else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | promote <candidate> | reject <candidate>.\n'); process.exitCode = 2; return; }
|
|
976
|
+
else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> | reject <candidate>.\n'); process.exitCode = 2; return; }
|
|
227
977
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
978
|
+
if (sub === 'status' && argv.includes('--gate')) process.exitCode = result.status === 'blocked' ? 1 : 0;
|
|
979
|
+
else if (sub === 'reconcile' && reconcileArgs.apply) process.exitCode = result.health?.status === 'blocked' ? 1 : 0;
|
|
980
|
+
else process.exitCode = result.ok === false ? 1 : 0;
|
|
231
981
|
} catch (error) {
|
|
232
982
|
process.stderr.write(`wendkeep memory: ${error.message}\n`);
|
|
233
983
|
process.exitCode = 1;
|