@nowcrew/daemon 0.6.93-beta.10 → 0.6.93-beta.12
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/dist/local-memory/inventory-cli.js +4 -1
- package/dist/local-memory/inventory-v2.js +84 -5
- package/dist/local-memory/migration-access.js +36 -1
- package/dist/local-memory/migration-plan.js +2 -2
- package/dist/local-memory/migration-store.js +22 -9
- package/dist/local-memory/read-cli.js +24 -1
- package/dist/local-memory/write.js +2 -1
- package/package.json +1 -1
|
@@ -46,7 +46,10 @@ export async function runMemoryInventoryCommand(args, io) {
|
|
|
46
46
|
: v2?.analysis ?? previewMemoryInventory(scan.sources, scanComplete);
|
|
47
47
|
const complete = scanComplete && !analysis.issues.some((issue) => issue.code === "missing_detail");
|
|
48
48
|
const issues = [...scan.issues, ...analysis.issues];
|
|
49
|
-
const referenced = new Set(
|
|
49
|
+
const referenced = new Set([
|
|
50
|
+
...analysis.entries.map((entry) => entry.sourceFields.file),
|
|
51
|
+
...(v2?.preservedSourcePaths ?? []),
|
|
52
|
+
]);
|
|
50
53
|
const reportSources = v2?.stateSource ? [...scan.sources, v2.stateSource] : scan.sources;
|
|
51
54
|
const files = reportSources.map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 }));
|
|
52
55
|
const orphanNotes = files.filter((file) => file.path.startsWith("notes/") && !referenced.has(file.path)).map((file) => file.path);
|
|
@@ -4,6 +4,7 @@ import { renderLocalMemoryIndexCard } from "./index-budget.js";
|
|
|
4
4
|
import { inventoryLimits, readMemoryInventorySource } from "./inventory-source.js";
|
|
5
5
|
import { MigrationStateSchema } from "./migration-schema.js";
|
|
6
6
|
import { assertMigrationStateIntegrity } from "./migration-state-invariants.js";
|
|
7
|
+
import { isHarmlessMigrationRemainder } from "./migration-plan.js";
|
|
7
8
|
import { parseFullMemoryIndex, selectLocalMemoryPreview } from "./storage-format.js";
|
|
8
9
|
const STATE_PATH = ".crew/memory/migration.v1.json";
|
|
9
10
|
const DETAIL_MARKER_PATTERN = /<!--\s*NOWWORK:MEMORY_ENTRY:(?:BEGIN\s+id=([A-Za-z0-9_-]+)\s+revision=([1-9][0-9]*)|END\s+id=([A-Za-z0-9_-]+))\s*-->/gu;
|
|
@@ -28,6 +29,15 @@ function sourceFields(entry) {
|
|
|
28
29
|
...(entry.reviewAfter === null ? {} : { review_after: entry.reviewAfter }),
|
|
29
30
|
};
|
|
30
31
|
}
|
|
32
|
+
function exactManagedDetailBodies(detail, targetId) {
|
|
33
|
+
const escapedId = targetId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
34
|
+
const pattern = new RegExp(`<!--\\s*NOWWORK:MEMORY_ENTRY:BEGIN\\s+id=${escapedId}\\s+revision=[1-9][0-9]*\\s*-->[\\s\\S]*?<!--\\s*NOWWORK:MEMORY_ENTRY:END\\s+id=${escapedId}\\s*-->`, "gu");
|
|
35
|
+
return [...detail.matchAll(pattern)].map(match => {
|
|
36
|
+
const beginEnd = match[0].indexOf("-->") + 3;
|
|
37
|
+
const endStart = match[0].lastIndexOf("<!--");
|
|
38
|
+
return match[0].slice(beginEnd, endStart);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
31
41
|
function harmlessDetailRemainder(text) {
|
|
32
42
|
let cursor = 0;
|
|
33
43
|
let lineStart = true;
|
|
@@ -60,6 +70,63 @@ function harmlessDetailRemainder(text) {
|
|
|
60
70
|
}
|
|
61
71
|
return true;
|
|
62
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* A migrated legacy note can remain on disk for external references. It is
|
|
75
|
+
* safe to exclude it from the V2 orphan scan only when the immutable
|
|
76
|
+
* migration mapping proves that the complete, unchanged source was copied to
|
|
77
|
+
* the indexed target entry.
|
|
78
|
+
*/
|
|
79
|
+
export function findControlledPreservedSourcePaths(entries, sources, mappings, sourceHashes = new Map()) {
|
|
80
|
+
const entriesById = new Map(entries.map(entry => [entry.id, entry]));
|
|
81
|
+
const sourcesByPath = new Map(sources.map(source => [source.path, source]));
|
|
82
|
+
const grouped = new Map();
|
|
83
|
+
for (const mapping of mappings) {
|
|
84
|
+
if (!mapping.sourceFile.startsWith("notes/"))
|
|
85
|
+
continue;
|
|
86
|
+
const current = grouped.get(mapping.sourceFile) ?? [];
|
|
87
|
+
grouped.set(mapping.sourceFile, [...current, mapping]);
|
|
88
|
+
}
|
|
89
|
+
const preserved = [];
|
|
90
|
+
for (const [path, values] of grouped) {
|
|
91
|
+
const source = sourcesByPath.get(path);
|
|
92
|
+
if (source === undefined || values.length === 0)
|
|
93
|
+
continue;
|
|
94
|
+
const ranges = [...values].sort((left, right) => left.start - right.start);
|
|
95
|
+
let cursor = 0;
|
|
96
|
+
let valid = true;
|
|
97
|
+
for (const mapping of ranges) {
|
|
98
|
+
const targetFile = `notes/${mapping.category}.md`;
|
|
99
|
+
const target = mapping.targetId === undefined ? undefined : entriesById.get(mapping.targetId);
|
|
100
|
+
const targetSource = target === undefined ? undefined : sourcesByPath.get(target.file);
|
|
101
|
+
const expectedSourceSha256 = mapping.sourceSha256 ?? sourceHashes.get(path);
|
|
102
|
+
const sourceBody = source.content.slice(mapping.start, mapping.end);
|
|
103
|
+
const renderedBody = mapping.sourceFile === targetFile
|
|
104
|
+
? sourceBody
|
|
105
|
+
: `\n## ${mapping.title}\n\nScope: ${mapping.scope}\n\n${sourceBody}\n`;
|
|
106
|
+
const sourceBlockBodies = mapping.targetId === undefined
|
|
107
|
+
? []
|
|
108
|
+
: exactManagedDetailBodies(sourceBody, mapping.targetId);
|
|
109
|
+
const expectedBody = mapping.sourceFile === targetFile && sourceBlockBodies.length === 1
|
|
110
|
+
? sourceBlockBodies[0]
|
|
111
|
+
: renderedBody;
|
|
112
|
+
const targetBodies = mapping.targetId === undefined || targetSource === undefined
|
|
113
|
+
? []
|
|
114
|
+
: exactManagedDetailBodies(targetSource.content, mapping.targetId);
|
|
115
|
+
if (expectedSourceSha256 === undefined || expectedSourceSha256 !== source.sha256
|
|
116
|
+
|| mapping.start < cursor || !isHarmlessMigrationRemainder(source.content.slice(cursor, mapping.start))
|
|
117
|
+
|| mapping.end > source.content.length
|
|
118
|
+
|| target === undefined || target.file !== targetFile || targetSource === undefined
|
|
119
|
+
|| targetBodies.length !== 1 || targetBodies[0] !== expectedBody) {
|
|
120
|
+
valid = false;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
cursor = mapping.end;
|
|
124
|
+
}
|
|
125
|
+
if (valid && isHarmlessMigrationRemainder(source.content.slice(cursor)))
|
|
126
|
+
preserved.push(path);
|
|
127
|
+
}
|
|
128
|
+
return preserved.sort((left, right) => left.localeCompare(right));
|
|
129
|
+
}
|
|
63
130
|
export function* scanManagedDetailMarkers(detail) {
|
|
64
131
|
for (const marker of detail.matchAll(DETAIL_MARKER_PATTERN)) {
|
|
65
132
|
const beginId = marker[1];
|
|
@@ -131,7 +198,12 @@ function inspectDetailEntry(entry, analysis) {
|
|
|
131
198
|
return revisions[0] > entry.revision ? "managed_revision_invalid" : null;
|
|
132
199
|
}
|
|
133
200
|
export function inspectManagedDetailFiles(entries, sources, analyze = analyzeManagedDetailFile) {
|
|
201
|
+
return inspectManagedDetailFilesWithMappings(entries, sources, [], new Map(), analyze);
|
|
202
|
+
}
|
|
203
|
+
export function inspectManagedDetailFilesWithMappings(entries, sources, mappings, sourceHashes = new Map(), analyze = analyzeManagedDetailFile) {
|
|
134
204
|
const sourceMap = new Map(sources.map(source => [source.path, source]));
|
|
205
|
+
const preservedSourcePaths = findControlledPreservedSourcePaths(entries, sources, mappings, sourceHashes);
|
|
206
|
+
const preserved = new Set(preservedSourcePaths);
|
|
135
207
|
const expectedByFile = new Map();
|
|
136
208
|
for (const entry of entries) {
|
|
137
209
|
const expected = expectedByFile.get(entry.file) ?? new Set();
|
|
@@ -150,8 +222,10 @@ export function inspectManagedDetailFiles(entries, sources, analyze = analyzeMan
|
|
|
150
222
|
continue;
|
|
151
223
|
const analysis = analyze(source.content, expectedByFile.get(path) ?? new Set());
|
|
152
224
|
analyses.set(path, analysis);
|
|
153
|
-
if (analysis.coverageIssue !== null
|
|
225
|
+
if (analysis.coverageIssue !== null
|
|
226
|
+
&& !(preserved.has(path) && analysis.coverageIssue === "unindexed_text_requires_mapping")) {
|
|
154
227
|
fileIssues.push({ code: analysis.coverageIssue, path });
|
|
228
|
+
}
|
|
155
229
|
}
|
|
156
230
|
const entryIssues = new Map();
|
|
157
231
|
for (const entry of entries) {
|
|
@@ -159,7 +233,7 @@ export function inspectManagedDetailFiles(entries, sources, analyze = analyzeMan
|
|
|
159
233
|
if (issue !== null)
|
|
160
234
|
entryIssues.set(entry.id, issue);
|
|
161
235
|
}
|
|
162
|
-
return { entryIssues, fileIssues };
|
|
236
|
+
return { entryIssues, fileIssues, preservedSourcePaths };
|
|
163
237
|
}
|
|
164
238
|
async function inspectCommittedState(root, budget) {
|
|
165
239
|
let source;
|
|
@@ -170,6 +244,7 @@ async function inspectCommittedState(root, budget) {
|
|
|
170
244
|
return {
|
|
171
245
|
valid: false,
|
|
172
246
|
source: null,
|
|
247
|
+
state: null,
|
|
173
248
|
issue: {
|
|
174
249
|
code: error.code === "ENOENT"
|
|
175
250
|
? "memory_migration_state_missing"
|
|
@@ -190,14 +265,15 @@ async function inspectCommittedState(root, budget) {
|
|
|
190
265
|
assertMigrationStateIntegrity(state, state.identity);
|
|
191
266
|
const confirmed = await readMemoryInventorySource(root, STATE_PATH, Math.min(inventoryLimits.fileBytes, budget));
|
|
192
267
|
if (confirmed.sha256 !== source.sha256) {
|
|
193
|
-
return { valid: false, source, issue: { code: "source_changed", path: STATE_PATH } };
|
|
268
|
+
return { valid: false, source, state: null, issue: { code: "source_changed", path: STATE_PATH } };
|
|
194
269
|
}
|
|
195
|
-
return { valid: true, source, issue: null };
|
|
270
|
+
return { valid: true, source, state, issue: null };
|
|
196
271
|
}
|
|
197
272
|
catch (error) {
|
|
198
273
|
return {
|
|
199
274
|
valid: false,
|
|
200
275
|
source,
|
|
276
|
+
state: null,
|
|
201
277
|
issue: {
|
|
202
278
|
code: "memory_migration_state_invalid",
|
|
203
279
|
path: STATE_PATH,
|
|
@@ -225,7 +301,9 @@ export async function inspectV2MemoryInventory(root, sources, readComplete) {
|
|
|
225
301
|
const state = await inspectCommittedState(root, Math.max(0, inventoryLimits.totalBytes - scannedBytes));
|
|
226
302
|
if (state.issue)
|
|
227
303
|
addIssue(state.issue.code, state.issue.path);
|
|
228
|
-
const
|
|
304
|
+
const sourceHashes = new Map((state.state?.manifest.files ?? [])
|
|
305
|
+
.flatMap(file => file.before.exists ? [[file.path, file.before.sha256]] : []));
|
|
306
|
+
const detailInspection = inspectManagedDetailFilesWithMappings(entries, sources, state.state?.manifest.recordMappings ?? [], sourceHashes);
|
|
229
307
|
for (const issue of detailInspection.fileIssues)
|
|
230
308
|
addIssue(issue.code, issue.path);
|
|
231
309
|
for (const entry of entries) {
|
|
@@ -260,6 +338,7 @@ export async function inspectV2MemoryInventory(root, sources, readComplete) {
|
|
|
260
338
|
stateValid: state.valid,
|
|
261
339
|
writeReady,
|
|
262
340
|
stateSource: state.source,
|
|
341
|
+
preservedSourcePaths: detailInspection.preservedSourcePaths,
|
|
263
342
|
analysis: {
|
|
264
343
|
entries: entries.map(entry => {
|
|
265
344
|
const detail = shownDetails.has(entry.file) ? null : sourceMap.get(entry.file)?.content ?? null;
|
|
@@ -6,7 +6,8 @@ import { defaultProcessController } from "../execution-journal.js";
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { MigrationStateSchema } from "./migration-schema.js";
|
|
8
8
|
import { detectLocalMemoryStorageVersion, parseFullMemoryIndex } from "./storage-format.js";
|
|
9
|
-
import { readMemoryInventorySource,
|
|
9
|
+
import { inventoryLimits, readMemoryInventorySource, scanMemorySources } from "./inventory-source.js";
|
|
10
|
+
import { inspectV2MemoryInventory } from "./inventory-v2.js";
|
|
10
11
|
import { MEMORY_STATE_GUARD_CAPABILITY, MEMORY_STORAGE_V2_CAPABILITY } from "../memory-migration-protocol.js";
|
|
11
12
|
import { isLocalMemoryInitializationIdentity, localMemoryInitializationIdentity } from "./initialization-identity.js";
|
|
12
13
|
import { assertMigrationStateIntegrity } from "./migration-state-invariants.js";
|
|
@@ -59,6 +60,40 @@ export async function assertMemoryWriterAllowed(agentDir, capabilities = []) {
|
|
|
59
60
|
throw new Error("memory_v2_writer_capability_missing");
|
|
60
61
|
}
|
|
61
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Check the committed V2 catalog immediately before a write is persisted.
|
|
65
|
+
* Keep this separate from the boot/state gate so legacy writer-specific
|
|
66
|
+
* validation (size limits, entry limits, note limits) retains its established
|
|
67
|
+
* error precedence.
|
|
68
|
+
*/
|
|
69
|
+
export async function assertMemoryInventoryHealth(agentDir) {
|
|
70
|
+
const root = await realpath(agentDir);
|
|
71
|
+
let state;
|
|
72
|
+
try {
|
|
73
|
+
const source = await readMemoryInventorySource(root, ".crew/memory/migration.v1.json", inventoryLimits.fileBytes);
|
|
74
|
+
state = StateGate.parse(JSON.parse(source.content));
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error.code === "ENOENT")
|
|
78
|
+
return;
|
|
79
|
+
if (error instanceof z.ZodError || error instanceof SyntaxError)
|
|
80
|
+
throw new Error("memory_migration_state_invalid");
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
// Legacy/schema1 writers do not need a V2 inventory scan. In particular,
|
|
84
|
+
// a large pending directory must keep the old writer's normal latency and
|
|
85
|
+
// budget behavior.
|
|
86
|
+
if (state.phase !== "committed" || state.storageVersion !== 2)
|
|
87
|
+
return;
|
|
88
|
+
const scan = await scanMemorySources(root, { includeOperationalFiles: false });
|
|
89
|
+
const health = await inspectV2MemoryInventory(root, scan.sources, scan.issues.length === 0);
|
|
90
|
+
const blockingNoteIssues = health.analysis.issues.filter(issue => issue.path.startsWith("notes/"));
|
|
91
|
+
if (scan.issues.length === 0
|
|
92
|
+
&& !health.analysis.issues.some(issue => issue.path === "MEMORY.md")
|
|
93
|
+
&& blockingNoteIssues.length > 0) {
|
|
94
|
+
throw new Error("memory_migration_inventory_invalid");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
62
97
|
const StateGate = z.object({
|
|
63
98
|
schemaVersion: z.literal(1), migrationId: z.string().min(1),
|
|
64
99
|
phase: z.enum(["prepared", "applying", "committed", "rolled_back", "repair_required"]),
|
|
@@ -25,7 +25,7 @@ export function initializeExactWorkspaceSeed(bytes) {
|
|
|
25
25
|
const categories = /^(?:preferences|project-rules|testing|debugging|git|incidents|lessons)$/u;
|
|
26
26
|
const blockPattern = /<!--\s*NOWWORK:MEMORY_ENTRY:BEGIN\s+id=([A-Za-z0-9_-]+)\s+revision=([1-9][0-9]*)\s*-->([\s\S]*?)<!--\s*NOWWORK:MEMORY_ENTRY:END\s+id=\1\s*-->/gu;
|
|
27
27
|
const controlMarker = /<!--\s*NOWWORK:MEMORY_/u;
|
|
28
|
-
const
|
|
28
|
+
export const isHarmlessMigrationRemainder = (text) => !text.replace(/<!--[\s\S]*?-->/gu, '').replace(/^#{1,2}[^\n]*$/gmu, '').trim();
|
|
29
29
|
function migrationSummary(body, title) {
|
|
30
30
|
const clean = (value) => value.replace(/<!--[\s\S]*?-->/gu, ' ')
|
|
31
31
|
.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, 180);
|
|
@@ -148,7 +148,7 @@ export function planMemoryMigration(identity, files, supplied = []) {
|
|
|
148
148
|
cursor = Math.max(cursor, range.end);
|
|
149
149
|
}
|
|
150
150
|
remainder += content.slice(cursor);
|
|
151
|
-
if (!
|
|
151
|
+
if (!isHarmlessMigrationRemainder(remainder))
|
|
152
152
|
issues.push({ path, reason: 'unindexed_text_requires_mapping' });
|
|
153
153
|
}
|
|
154
154
|
for (const mapping of additions) {
|
|
@@ -4,8 +4,9 @@ import { z } from 'zod';
|
|
|
4
4
|
import { atomicBytes, digest, inventory, readBytes, safePath, STATE_PATH, syncDirectory, validateMigrationRoot } from './migration-io.js';
|
|
5
5
|
import { initializeExactWorkspaceSeed, planMemoryMigration, validateMigrationTarget } from './migration-plan.js';
|
|
6
6
|
import { MigrationError, MigrationHistorySchema, MigrationProcessStoppedReceiptSchema, MigrationStateSchema } from './migration-schema.js';
|
|
7
|
+
import { inspectManagedDetailFilesWithMappings } from './inventory-v2.js';
|
|
7
8
|
import { assertMigrationStateIntegrity as assertIdentity, encodeMigrationData as encode, migrationOperationRoot as opRoot, migrationPlanDigest as planDigest, sameMigrationData as same, verifyMigrationIntent as verifyIntent, } from './migration-state-invariants.js';
|
|
8
|
-
import { detectLocalMemoryStorageVersion } from './storage-format.js';
|
|
9
|
+
import { detectLocalMemoryStorageVersion, parseFullMemoryIndex } from './storage-format.js';
|
|
9
10
|
import { isLocalMemoryInitializationIdentity, localMemoryInitializationIdentity } from './initialization-identity.js';
|
|
10
11
|
async function immutable(root, path, data) {
|
|
11
12
|
const previous = await readBytes(root, path, 64 * 1024 * 1024);
|
|
@@ -256,15 +257,27 @@ async function publish(root, state, side, hooks) {
|
|
|
256
257
|
await hooks.afterReplace?.(file.path, index++);
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
|
-
function validTarget(files) {
|
|
260
|
+
function validTarget(files, mappings = [], sourceHashes = new Map()) {
|
|
260
261
|
try {
|
|
261
262
|
validateMigrationTarget(files);
|
|
262
|
-
|
|
263
|
+
const memory = files.get('MEMORY.md');
|
|
264
|
+
if (memory === undefined)
|
|
265
|
+
return false;
|
|
266
|
+
const entries = [...parseFullMemoryIndex(memory.toString('utf8'))];
|
|
267
|
+
const sources = [...files]
|
|
268
|
+
.filter(([path]) => path === 'MEMORY.md' || path.startsWith('notes/'))
|
|
269
|
+
.map(([path, bytes]) => ({ path, bytes: bytes.length, sha256: digest(bytes), content: bytes.toString('utf8') }));
|
|
270
|
+
const inspection = inspectManagedDetailFilesWithMappings(entries, sources, mappings, sourceHashes);
|
|
271
|
+
return inspection.fileIssues.length === 0 && inspection.entryIssues.size === 0;
|
|
263
272
|
}
|
|
264
273
|
catch {
|
|
265
274
|
return false;
|
|
266
275
|
}
|
|
267
276
|
}
|
|
277
|
+
function manifestSourceHashes(manifest) {
|
|
278
|
+
return new Map(manifest.files
|
|
279
|
+
.flatMap(file => file.before.exists ? [[file.path, file.before.sha256]] : []));
|
|
280
|
+
}
|
|
268
281
|
async function finalState(input, state) {
|
|
269
282
|
const terminalPath = `${opRoot(state.identity)}/terminal.json`;
|
|
270
283
|
const bytes = await readBytes(input.agentDir, terminalPath, 32 * 1024 * 1024);
|
|
@@ -283,7 +296,7 @@ async function finalState(input, state) {
|
|
|
283
296
|
if (terminal?.phase === 'committed') {
|
|
284
297
|
let valid = false;
|
|
285
298
|
try {
|
|
286
|
-
valid = validTarget((await inventory(input.agentDir)).files);
|
|
299
|
+
valid = validTarget((await inventory(input.agentDir)).files, state.manifest.recordMappings, manifestSourceHashes(state.manifest));
|
|
287
300
|
}
|
|
288
301
|
catch { /* Protect damaged content without erasing commit history. */ }
|
|
289
302
|
if (!valid)
|
|
@@ -309,13 +322,13 @@ export async function applyMemoryMigration(input, hooks = {}) {
|
|
|
309
322
|
if (classification.unknown.length || classification.classes.some(value => value !== 'B' && value !== 'AB'))
|
|
310
323
|
return repair(input.agentDir, state, classification.unknown.length ? classification.unknown : ['MEMORY.md'], 'source_changed');
|
|
311
324
|
const backup = await copies(input.agentDir, state, 'before'), staged = await copies(input.agentDir, state, 'after');
|
|
312
|
-
if (!backup || !staged || !validTarget(staged))
|
|
325
|
+
if (!backup || !staged || !validTarget(staged, state.manifest.recordMappings, manifestSourceHashes(state.manifest)))
|
|
313
326
|
throw new MigrationError('migration_copy_invalid');
|
|
314
327
|
await immutable(input.agentDir, `${opRoot(state.identity)}/intent.json`, encode(receipt));
|
|
315
328
|
state = await save(input.agentDir, { ...state, phase: 'applying', intentReceipt: receipt });
|
|
316
329
|
await publish(input.agentDir, state, 'after', hooks);
|
|
317
330
|
const current = await classify(input.agentDir, state);
|
|
318
|
-
if (current.unknown.length || current.classes.some(value => value === 'X' || value === 'B') || !validTarget(current.files))
|
|
331
|
+
if (current.unknown.length || current.classes.some(value => value === 'X' || value === 'B') || !validTarget(current.files, state.manifest.recordMappings, manifestSourceHashes(state.manifest)))
|
|
319
332
|
return repair(input.agentDir, state, ['MEMORY.md'], 'target_validation_failed');
|
|
320
333
|
return save(input.agentDir, { ...state, phase: 'committed', storageVersion: 2 });
|
|
321
334
|
}
|
|
@@ -378,16 +391,16 @@ export async function recoverMemoryMigration(input, hooks = {}) {
|
|
|
378
391
|
if (current.unknown.length || current.classes.includes('X'))
|
|
379
392
|
return repair(input.agentDir, state, [...current.unknown, ...state.manifest.files.filter((_, index) => current.classes[index] === 'X').map(file => file.path)], 'unknown_change');
|
|
380
393
|
const allA = current.classes.every(value => value === 'A' || value === 'AB'), allB = current.classes.every(value => value === 'B' || value === 'AB');
|
|
381
|
-
if (allA && !cancelled && validTarget(current.files))
|
|
394
|
+
if (allA && !cancelled && validTarget(current.files, state.manifest.recordMappings, manifestSourceHashes(state.manifest)))
|
|
382
395
|
return save(input.agentDir, { ...state, phase: 'committed', storageVersion: 2, ...(receipt ? { intentReceipt: receipt } : {}) });
|
|
383
396
|
if (allB && (!receipt || cancelled))
|
|
384
397
|
return save(input.agentDir, { ...state, phase: 'rolled_back', storageVersion: state.identity.sourceStorageVersion });
|
|
385
398
|
const staged = await copies(input.agentDir, state, 'after');
|
|
386
|
-
if (receipt && !cancelled && staged && validTarget(staged)) {
|
|
399
|
+
if (receipt && !cancelled && staged && validTarget(staged, state.manifest.recordMappings, manifestSourceHashes(state.manifest))) {
|
|
387
400
|
state = await save(input.agentDir, { ...state, phase: 'applying', intentReceipt: receipt });
|
|
388
401
|
await publish(input.agentDir, state, 'after', hooks);
|
|
389
402
|
const target = await classify(input.agentDir, state);
|
|
390
|
-
if (!target.unknown.length && target.classes.every(value => value === 'A' || value === 'AB') && validTarget(target.files))
|
|
403
|
+
if (!target.unknown.length && target.classes.every(value => value === 'A' || value === 'AB') && validTarget(target.files, state.manifest.recordMappings, manifestSourceHashes(state.manifest)))
|
|
391
404
|
return save(input.agentDir, { ...state, phase: 'committed', storageVersion: 2 });
|
|
392
405
|
}
|
|
393
406
|
const backup = await copies(input.agentDir, state, 'before');
|
|
@@ -10,6 +10,9 @@ import { detectLocalMemoryStorageVersion, parseFullMemoryIndex } from "./storage
|
|
|
10
10
|
import { searchFullMemoryIndex } from "./full-index-search.js";
|
|
11
11
|
import { searchLegacyMemorySources } from "./legacy-adapter.js";
|
|
12
12
|
import { withMemoryAccess } from "./migration-access.js";
|
|
13
|
+
import { readMemoryInventorySource, scanMemorySources } from "./inventory-source.js";
|
|
14
|
+
import { MigrationStateSchema } from "./migration-schema.js";
|
|
15
|
+
import { findControlledPreservedSourcePaths } from "./inventory-v2.js";
|
|
13
16
|
const OUTPUT_BYTES = 12 * 1024;
|
|
14
17
|
// A V2 page may carry coverage metadata for legacy sources. Reserve room for
|
|
15
18
|
// that metadata so a mixed result never exceeds the command's output limit.
|
|
@@ -125,6 +128,22 @@ async function readSource(home, file) {
|
|
|
125
128
|
await handle.close();
|
|
126
129
|
}
|
|
127
130
|
}
|
|
131
|
+
async function readControlledPreservedSourcePaths(home, memory) {
|
|
132
|
+
try {
|
|
133
|
+
const root = await realpath(home);
|
|
134
|
+
const stateSource = await readMemoryInventorySource(root, ".crew/memory/migration.v1.json", FILE_BYTES);
|
|
135
|
+
const state = MigrationStateSchema.parse(JSON.parse(stateSource.content));
|
|
136
|
+
if (state.phase !== "committed" || state.storageVersion !== 2)
|
|
137
|
+
return new Set();
|
|
138
|
+
const scan = await scanMemorySources(root, { includeOperationalFiles: false });
|
|
139
|
+
const sourceHashes = new Map(state.manifest.files
|
|
140
|
+
.flatMap(file => file.before.exists ? [[file.path, file.before.sha256]] : []));
|
|
141
|
+
return new Set(findControlledPreservedSourcePaths(parseFullMemoryIndex(memory), scan.sources, state.manifest.recordMappings, sourceHashes));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return new Set();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
128
147
|
function normalizeLimit(requestedLimit, max, safe) {
|
|
129
148
|
if (requestedLimit <= max)
|
|
130
149
|
return { limit: requestedLimit };
|
|
@@ -256,10 +275,14 @@ async function runMemoryReadUnlocked(argv, io) {
|
|
|
256
275
|
...command, limit: normalized.limit, history: command.command === "search-history",
|
|
257
276
|
}, FULL_INDEX_RESULT_BYTES);
|
|
258
277
|
const indexedFiles = new Set(["MEMORY.md", ...parseFullMemoryIndex(source.text).map((match) => match.file)]);
|
|
278
|
+
const preservedSourceFiles = await readControlledPreservedSourcePaths(io.env.CREW_HOME, source.text);
|
|
279
|
+
const legacyExcludedFiles = fileWasExplicit
|
|
280
|
+
? indexedFiles
|
|
281
|
+
: new Set([...indexedFiles, ...preservedSourceFiles]);
|
|
259
282
|
const legacy = await searchLegacyMemorySources({ agentDir: io.env.CREW_HOME, query: command.query,
|
|
260
283
|
limit: normalized.limit, outputBytes: FULL_INDEX_LEGACY_SCAN_BYTES,
|
|
261
284
|
...(command.cursor === undefined ? {} : { cursor: command.cursor }),
|
|
262
|
-
...(fileWasExplicit ? { files: [command.file], excludeFiles:
|
|
285
|
+
...(fileWasExplicit ? { files: [command.file], excludeFiles: legacyExcludedFiles } : { excludeFiles: legacyExcludedFiles }) });
|
|
263
286
|
if (result.matches.length === 0 && (command.cursor === undefined || legacy.matchCount > 0)) {
|
|
264
287
|
if (legacy.unindexedFiles.length > 0 && (legacy.matchCount > 0 || result.matchCount === 0)) {
|
|
265
288
|
writeResult({ ...legacy, ...(normalized.warning ? { warning: normalized.warning,
|
|
@@ -7,7 +7,7 @@ import { LocalMemoryChangeSchema, LocalMemorySingleLineText, validateLocalMemory
|
|
|
7
7
|
import { LOCAL_MEMORY_INDEX_CODE_POINT_CAP, LOCAL_MEMORY_INDEX_ENTRY_CAP, LOCAL_MEMORY_TOTAL_CODE_POINT_CAP, renderGeneratedIndex, selectLocalMemoryIndexEntries, } from "./index-budget.js";
|
|
8
8
|
import { persistPendingMemoryCandidate, readPendingMemoryCandidates, removePendingMemoryCandidate, } from "./pending.js";
|
|
9
9
|
import { assertLocalMemoryStorageSize, detectLocalMemoryStorageVersion, LOCAL_MEMORY_GENERATED_INDEX_PATTERN, LOCAL_MEMORY_NOTE_BYTE_CAP, LOCAL_MEMORY_READ_BYTE_CAP, parseFullMemoryIndex, replaceFullMemoryIndex, selectLocalMemoryPreview, } from "./storage-format.js";
|
|
10
|
-
import { assertMemoryWriterAllowed, withMemoryAccess } from "./migration-access.js";
|
|
10
|
+
import { assertMemoryInventoryHealth, assertMemoryWriterAllowed, withMemoryAccess } from "./migration-access.js";
|
|
11
11
|
const StatusChangeSchema = z.object({
|
|
12
12
|
memoryId: LocalMemorySingleLineText,
|
|
13
13
|
expectedRevision: z.number().int().positive(),
|
|
@@ -686,6 +686,7 @@ export async function applyLocalMemoryResult(input) {
|
|
|
686
686
|
if (Buffer.byteLength(content, "utf8") > LOCAL_MEMORY_NOTE_BYTE_CAP)
|
|
687
687
|
throw new Error("local_memory_note_byte_limit");
|
|
688
688
|
}
|
|
689
|
+
await assertMemoryInventoryHealth(input.agentDir);
|
|
689
690
|
for (const candidate of pendingCandidates)
|
|
690
691
|
await persistPendingMemoryCandidate({ agentDir: input.agentDir, candidate });
|
|
691
692
|
for (const [path, content] of noteUpdates)
|