@nowcrew/daemon 0.6.97-beta.1 → 0.6.97-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloud-report-upload.js +1 -10
- package/dist/execution-journal.js +3 -1
- package/dist/execution-protocol.js +1 -1
- package/dist/execution-runner.js +4 -1
- package/dist/local-execution-prompt.js +8 -3
- package/dist/local-executor.js +63 -39
- package/dist/local-memory/commit-telemetry.js +30 -0
- package/dist/local-memory/execution-workspace.js +62 -0
- package/dist/local-memory/full-index-search.js +47 -0
- package/dist/local-memory/index-budget.js +9 -3
- package/dist/local-memory/initialization-identity.js +36 -0
- package/dist/local-memory/inventory-cli.js +49 -8
- package/dist/local-memory/inventory-source.js +15 -8
- package/dist/local-memory/inventory-v2.js +361 -0
- package/dist/local-memory/legacy-adapter.js +233 -0
- package/dist/local-memory/migration-access.js +242 -0
- package/dist/local-memory/migration-cli.js +54 -0
- package/dist/local-memory/migration-io.js +199 -0
- package/dist/local-memory/migration-pending.js +9 -0
- package/dist/local-memory/migration-plan.js +317 -0
- package/dist/local-memory/migration-schema.js +55 -0
- package/dist/local-memory/migration-state-invariants.js +65 -0
- package/dist/local-memory/migration-store.js +414 -0
- package/dist/local-memory/migration-tool-entry.js +21 -0
- package/dist/local-memory/pending-query.js +5 -2
- package/dist/local-memory/pending-schema.js +1 -1
- package/dist/local-memory/pending.js +16 -2
- package/dist/local-memory/prompt.js +21 -5
- package/dist/local-memory/read-cli.js +98 -76
- package/dist/local-memory/rollout.js +79 -11
- package/dist/local-memory/runtime.js +181 -13
- package/dist/local-memory/storage-format.js +152 -0
- package/dist/local-memory/write.js +189 -21
- package/dist/local-memory/writer-mode.js +13 -0
- package/dist/local-memory-diagnostics.js +94 -13
- package/dist/local-memory-telemetry.js +19 -2
- package/dist/machine-info.js +3 -0
- package/dist/marketplace-protocol.js +1 -1
- package/dist/marketplace-remote.js +1 -1
- package/dist/marketplace-runner.js +1 -1
- package/dist/memory-migration-controller.js +86 -0
- package/dist/memory-migration-inspect.js +108 -0
- package/dist/memory-migration-journal.js +14 -0
- package/dist/memory-migration-protocol.js +14 -0
- package/dist/memory-migration-runtime.js +305 -0
- package/dist/memory-prune-diagnostics.js +60 -4
- package/dist/message-file-preview.js +11 -1
- package/dist/prompt.js +6 -2
- package/dist/runner.js +1 -1
- package/dist/runtimes/claude-thread-runner.js +25 -4
- package/dist/serve.js +38 -3
- package/dist/token.js +1 -0
- package/dist/workspace-control-handler.js +16 -3
- package/dist/workspace.js +13 -2
- package/package.json +1 -1
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { statfs, rm, readdir, opendir } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { atomicBytes, digest, inventory, readBytes, safePath, STATE_PATH, syncDirectory, validateMigrationRoot } from './migration-io.js';
|
|
5
|
+
import { initializeExactWorkspaceSeed, planMemoryMigration, validateMigrationTarget } from './migration-plan.js';
|
|
6
|
+
import { MigrationError, MigrationHistorySchema, MigrationProcessStoppedReceiptSchema, MigrationStateSchema } from './migration-schema.js';
|
|
7
|
+
import { inspectManagedDetailFilesWithMappings } from './inventory-v2.js';
|
|
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';
|
|
9
|
+
import { detectLocalMemoryStorageVersion, parseFullMemoryIndex } from './storage-format.js';
|
|
10
|
+
import { isLocalMemoryInitializationIdentity, localMemoryInitializationIdentity } from './initialization-identity.js';
|
|
11
|
+
async function immutable(root, path, data) {
|
|
12
|
+
const previous = await readBytes(root, path, 64 * 1024 * 1024);
|
|
13
|
+
if (previous) {
|
|
14
|
+
if (digest(previous) !== digest(data))
|
|
15
|
+
throw new MigrationError('migration_immutable_hash_mismatch', [{ path, reason: 'hash_mismatch' }]);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
await atomicBytes(root, path, data);
|
|
19
|
+
}
|
|
20
|
+
async function save(root, state) {
|
|
21
|
+
const parsed = MigrationStateSchema.parse(state);
|
|
22
|
+
assertIdentity(parsed, parsed.identity);
|
|
23
|
+
if (parsed.phase === 'committed' || parsed.phase === 'rolled_back') {
|
|
24
|
+
const previous = await readBytes(root, `${opRoot(parsed.identity)}/terminal.json`, 32 * 1024 * 1024);
|
|
25
|
+
if (previous && !same(JSON.parse(previous.toString('utf8')), parsed)) {
|
|
26
|
+
throw new MigrationError('migration_terminal_conflict');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
await atomicBytes(root, STATE_PATH, encode(parsed));
|
|
30
|
+
// State is the commit point. Retain the same terminal fact separately so accidental state loss cannot replay a completed plan.
|
|
31
|
+
if (parsed.phase === 'committed' || parsed.phase === 'rolled_back')
|
|
32
|
+
await immutable(root, `${opRoot(parsed.identity)}/terminal.json`, encode(parsed));
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
async function readState(root) {
|
|
36
|
+
const bytes = await readBytes(root, STATE_PATH, 32 * 1024 * 1024);
|
|
37
|
+
if (!bytes)
|
|
38
|
+
return null;
|
|
39
|
+
let state;
|
|
40
|
+
try {
|
|
41
|
+
state = MigrationStateSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new MigrationError('migration_state_invalid');
|
|
45
|
+
}
|
|
46
|
+
return state;
|
|
47
|
+
}
|
|
48
|
+
async function load(input) {
|
|
49
|
+
const state = await readState(input.agentDir);
|
|
50
|
+
if (state)
|
|
51
|
+
assertIdentity(state, input.identity);
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
export async function statusMemoryMigration(input) {
|
|
55
|
+
const identity = await validateMigrationRoot(input.agentDir, input.identity);
|
|
56
|
+
return load({ ...input, identity });
|
|
57
|
+
}
|
|
58
|
+
export async function prepareMemoryMigration(input, hooks = {}) {
|
|
59
|
+
const identity = await validateMigrationRoot(input.agentDir, input.identity);
|
|
60
|
+
const history = MigrationHistorySchema.parse(input.serverHistory);
|
|
61
|
+
const existing = await readState(input.agentDir);
|
|
62
|
+
if (existing?.migrationId === identity.migrationId) {
|
|
63
|
+
assertIdentity(existing, identity);
|
|
64
|
+
return existing;
|
|
65
|
+
}
|
|
66
|
+
if (history.kind !== 'none' || history.writeIntentConfirmed === true)
|
|
67
|
+
throw new MigrationError(history.kind === 'existing' ? 'migration_recovery_required' : 'migration_server_history_unavailable');
|
|
68
|
+
const operations = await readdir(await safePath(input.agentDir, '.crew/memory/migrations')).catch((error) => {
|
|
69
|
+
if (error.code === 'ENOENT')
|
|
70
|
+
return [];
|
|
71
|
+
throw error;
|
|
72
|
+
});
|
|
73
|
+
await verifyRolledBackPredecessors(input.agentDir, identity, history, operations, existing);
|
|
74
|
+
const leftovers = await readBytes(input.agentDir, `${opRoot(identity)}/manifest.json`, 32 * 1024 * 1024);
|
|
75
|
+
if (leftovers) {
|
|
76
|
+
const prepared = MigrationStateSchema.parse(JSON.parse(leftovers.toString('utf8')));
|
|
77
|
+
assertIdentity(prepared, identity);
|
|
78
|
+
const intent = await readBytes(input.agentDir, `${opRoot(identity)}/intent.json`);
|
|
79
|
+
const current = await classify(input.agentDir, prepared);
|
|
80
|
+
if (intent || current.unknown.length || current.classes.some(value => value !== 'B' && value !== 'AB'))
|
|
81
|
+
throw new MigrationError('migration_recovery_required');
|
|
82
|
+
if (!await copies(input.agentDir, prepared, 'before') || !await copies(input.agentDir, prepared, 'after'))
|
|
83
|
+
throw new MigrationError('migration_copy_invalid');
|
|
84
|
+
return save(input.agentDir, prepared);
|
|
85
|
+
}
|
|
86
|
+
const before = await inventory(input.agentDir);
|
|
87
|
+
const sourceMemory = before.files.get('MEMORY.md');
|
|
88
|
+
if (history.isNewAgent === true && identity.sourceStorageVersion === 0 && before.files.size === 0 && before.directories.length === 0)
|
|
89
|
+
throw new MigrationError('migration_workspace_seed_required');
|
|
90
|
+
const previousSeed = existing?.origin === 'initialized' && existing.phase === 'rolled_back'
|
|
91
|
+
? existing.manifest.files.find(file => file.path === 'MEMORY.md')?.before : undefined;
|
|
92
|
+
const retryingOriginalSeed = previousSeed?.exists === true && sourceMemory !== undefined && digest(sourceMemory) === previousSeed.sha256;
|
|
93
|
+
const expectedInitializationIdentity = await localMemoryInitializationIdentity(input.agentDir, identity.agentId, {
|
|
94
|
+
workspaceId: identity.workspaceId,
|
|
95
|
+
machineId: identity.machineId,
|
|
96
|
+
});
|
|
97
|
+
const onlyCurrentOperationResidue = isLocalMemoryInitializationIdentity(identity, expectedInitializationIdentity)
|
|
98
|
+
&& existing === null && operations.length === 1
|
|
99
|
+
&& operations[0] === identity.migrationId;
|
|
100
|
+
const pristineSeed = (history.isNewAgent === true && (operations.length === 0 || onlyCurrentOperationResidue) || retryingOriginalSeed) && identity.sourceStorageVersion === 0
|
|
101
|
+
&& before.files.size === 1 && sourceMemory !== undefined && before.directories.every(path => path === 'notes' || path === '.crew/memory/pending')
|
|
102
|
+
? initializeExactWorkspaceSeed(sourceMemory) : null;
|
|
103
|
+
const initialized = history.isNewAgent === true && identity.sourceStorageVersion === 0
|
|
104
|
+
&& pristineSeed !== null;
|
|
105
|
+
const after = pristineSeed !== null ? { files: new Map([['MEMORY.md', pristineSeed]]), recordMappings: [] } : planMemoryMigration(identity, before.files, input.recordMappings);
|
|
106
|
+
if (pristineSeed !== null)
|
|
107
|
+
validateMigrationTarget(after.files);
|
|
108
|
+
const paths = [...new Set([...before.files.keys(), ...after.files.keys()])].sort();
|
|
109
|
+
const manifest = { directories: before.directories, recordMappings: after.recordMappings, files: paths.map(path => {
|
|
110
|
+
const b = before.files.get(path);
|
|
111
|
+
const a = after.files.get(path);
|
|
112
|
+
return { path, before: b ? { exists: true, sha256: digest(b), ref: `${opRoot(identity)}/backup/${digest(b)}`, bytes: b.length } : { exists: false },
|
|
113
|
+
after: a ? { exists: true, sha256: digest(a), ref: `${opRoot(identity)}/staged/${digest(a)}`, bytes: a.length } : { exists: false } };
|
|
114
|
+
}) };
|
|
115
|
+
const sourceDigest = digest(encode({ directories: before.directories, files: manifest.files.map(file => ({ path: file.path, before: file.before.exists ? { exists: true, sha256: file.before.sha256 } : { exists: false } })) }));
|
|
116
|
+
const state = { schemaVersion: 1, identity, storageVersion: identity.sourceStorageVersion, targetStorageVersion: 2, origin: initialized ? 'initialized' : 'migrated', migrationId: identity.migrationId, phase: 'prepared', sourceDigest,
|
|
117
|
+
planDigest: planDigest(identity, sourceDigest, manifest), manifest, issues: [] };
|
|
118
|
+
MigrationStateSchema.parse(state);
|
|
119
|
+
const beforeBytes = [...before.files.values()].reduce((sum, value) => sum + value.length, 0);
|
|
120
|
+
const afterBytes = [...after.files.values()].reduce((sum, value) => sum + value.length, 0);
|
|
121
|
+
const max = Math.max(0, ...[...before.files.values(), ...after.files.values()].map(value => value.length));
|
|
122
|
+
const available = hooks.availableBytes ? await hooks.availableBytes() : await statfs(input.agentDir).then(value => value.bavail * value.bsize);
|
|
123
|
+
if (available < beforeBytes + afterBytes + max + encode(state).length * 3)
|
|
124
|
+
throw new MigrationError('migration_insufficient_disk_space');
|
|
125
|
+
for (const file of manifest.files) {
|
|
126
|
+
if (file.before.exists)
|
|
127
|
+
await immutable(input.agentDir, file.before.ref, before.files.get(file.path));
|
|
128
|
+
if (file.after.exists)
|
|
129
|
+
await immutable(input.agentDir, file.after.ref, after.files.get(file.path));
|
|
130
|
+
}
|
|
131
|
+
const current = await inventory(input.agentDir);
|
|
132
|
+
if (!same([...current.files].map(([path, bytes]) => [path, digest(bytes)]), [...before.files].map(([path, bytes]) => [path, digest(bytes)])) || !same(current.directories, before.directories))
|
|
133
|
+
throw new MigrationError('migration_source_changed');
|
|
134
|
+
await immutable(input.agentDir, `${opRoot(identity)}/manifest.json`, encode(state));
|
|
135
|
+
return save(input.agentDir, state);
|
|
136
|
+
}
|
|
137
|
+
/** 新操作只接纳服务端明确授权、摘要相符的回退终态;旧备份和历史保持不可变。 */
|
|
138
|
+
async function verifyRolledBackPredecessors(root, identity, history, operations, existing) {
|
|
139
|
+
const authorized = history.rolledBackOperations ?? [];
|
|
140
|
+
const allowed = new Map(authorized.map(operation => [operation.migrationId, operation]));
|
|
141
|
+
if (allowed.size !== authorized.length || allowed.has(identity.migrationId)
|
|
142
|
+
|| authorized.length > 0 && history.migrationId !== identity.migrationId
|
|
143
|
+
|| operations.some(name => name !== identity.migrationId && !allowed.has(name))
|
|
144
|
+
|| authorized.some(operation => !operations.includes(operation.migrationId))
|
|
145
|
+
|| existing && !allowed.has(existing.migrationId))
|
|
146
|
+
throw new MigrationError('migration_operation_residue');
|
|
147
|
+
for (const previous of authorized) {
|
|
148
|
+
const bytes = await readBytes(root, `.crew/memory/migrations/${previous.migrationId}/terminal.json`, 32 * 1024 * 1024);
|
|
149
|
+
if (!bytes)
|
|
150
|
+
throw new MigrationError('migration_rollback_history_invalid');
|
|
151
|
+
const terminal = MigrationStateSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
152
|
+
assertIdentity(terminal, terminal.identity);
|
|
153
|
+
if (terminal.phase !== 'rolled_back' || terminal.migrationId !== previous.migrationId
|
|
154
|
+
|| terminal.sourceDigest !== previous.sourceDigest || terminal.planDigest !== previous.planDigest
|
|
155
|
+
|| terminal.storageVersion !== terminal.identity.sourceStorageVersion
|
|
156
|
+
|| ['workspaceId', 'agentId', 'rootFingerprint', 'ownerIdentity', 'machineId'].some(key => terminal.identity[key] !== identity[key])
|
|
157
|
+
|| existing?.migrationId === terminal.migrationId && !same(existing, terminal))
|
|
158
|
+
throw new MigrationError('migration_rollback_history_invalid');
|
|
159
|
+
const manifest = await readBytes(root, `${opRoot(terminal.identity)}/manifest.json`, 32 * 1024 * 1024);
|
|
160
|
+
if (!manifest)
|
|
161
|
+
throw new MigrationError('migration_rollback_history_invalid');
|
|
162
|
+
const prepared = MigrationStateSchema.parse(JSON.parse(manifest.toString('utf8')));
|
|
163
|
+
assertIdentity(prepared, terminal.identity);
|
|
164
|
+
if (prepared.planDigest !== terminal.planDigest || !await copies(root, terminal, 'before'))
|
|
165
|
+
throw new MigrationError('migration_rollback_history_invalid');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function matches(bytes, image) { return image.exists ? bytes !== undefined && digest(bytes) === image.sha256 : bytes === undefined; }
|
|
169
|
+
function publicationPath(state, path, sha256) {
|
|
170
|
+
return `${opRoot(state.identity)}/publish/${digest(path)}-${sha256}.tmp`;
|
|
171
|
+
}
|
|
172
|
+
async function validatePublicationCopies(root, state) {
|
|
173
|
+
const directory = `${opRoot(state.identity)}/publish`;
|
|
174
|
+
const allowed = new Map();
|
|
175
|
+
for (const file of state.manifest.files)
|
|
176
|
+
for (const image of [file.before, file.after]) {
|
|
177
|
+
if (image.exists)
|
|
178
|
+
allowed.set(publicationPath(state, file.path, image.sha256), image.sha256);
|
|
179
|
+
}
|
|
180
|
+
const stream = await opendir(await safePath(root, directory)).catch((error) => {
|
|
181
|
+
if (error.code === 'ENOENT')
|
|
182
|
+
return null;
|
|
183
|
+
throw error;
|
|
184
|
+
});
|
|
185
|
+
if (!stream)
|
|
186
|
+
return;
|
|
187
|
+
for await (const entry of stream) {
|
|
188
|
+
const path = `${directory}/${entry.name}`;
|
|
189
|
+
const expected = allowed.get(path);
|
|
190
|
+
if (!expected || !entry.isFile())
|
|
191
|
+
throw new MigrationError('migration_publication_copy_unknown', [{ path, reason: 'unrecognized_publication_copy' }]);
|
|
192
|
+
const content = await readBytes(root, path);
|
|
193
|
+
if (!content || digest(content) !== expected)
|
|
194
|
+
throw new MigrationError('migration_publication_copy_unknown', [{ path, reason: 'publication_hash_mismatch' }]);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function classify(root, state) {
|
|
198
|
+
await validatePublicationCopies(root, state);
|
|
199
|
+
const current = await inventory(root);
|
|
200
|
+
const known = new Set(state.manifest.files.map(file => file.path));
|
|
201
|
+
const allowedDirs = new Set(state.manifest.directories);
|
|
202
|
+
for (const file of state.manifest.files)
|
|
203
|
+
if (file.after.exists) {
|
|
204
|
+
let dir = dirname(file.path);
|
|
205
|
+
while (dir !== '.' && dir !== '.crew' && dir !== '.crew/memory') {
|
|
206
|
+
allowedDirs.add(dir);
|
|
207
|
+
dir = dirname(dir);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const unknown = [...current.files.keys()].filter(path => !known.has(path));
|
|
211
|
+
unknown.push(...current.directories.filter(path => !allowedDirs.has(path)), ...state.manifest.directories.filter(path => !current.directories.includes(path)));
|
|
212
|
+
return { files: current.files, unknown, classes: state.manifest.files.map(file => { const b = matches(current.files.get(file.path), file.before), a = matches(current.files.get(file.path), file.after); return a && b ? 'AB' : a ? 'A' : b ? 'B' : 'X'; }) };
|
|
213
|
+
}
|
|
214
|
+
async function copies(root, state, side) {
|
|
215
|
+
const files = new Map();
|
|
216
|
+
try {
|
|
217
|
+
for (const file of state.manifest.files) {
|
|
218
|
+
const image = file[side];
|
|
219
|
+
if (image.exists) {
|
|
220
|
+
const bytes = await readBytes(root, image.ref);
|
|
221
|
+
if (!bytes || bytes.length !== image.bytes || digest(bytes) !== image.sha256)
|
|
222
|
+
return null;
|
|
223
|
+
files.set(file.path, bytes);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
return files;
|
|
231
|
+
}
|
|
232
|
+
async function repair(root, state, paths, reason) { return save(root, { ...state, phase: 'repair_required', issues: paths.map(path => ({ path, reason })) }); }
|
|
233
|
+
async function publish(root, state, side, hooks) {
|
|
234
|
+
let index = 0;
|
|
235
|
+
for (const file of state.manifest.files) {
|
|
236
|
+
const current = await readBytes(root, file.path);
|
|
237
|
+
const bytes = current ?? undefined;
|
|
238
|
+
if (matches(bytes, file[side]))
|
|
239
|
+
continue;
|
|
240
|
+
const opposite = file[side === 'after' ? 'before' : 'after'];
|
|
241
|
+
if (!matches(bytes, opposite))
|
|
242
|
+
throw new MigrationError('migration_unknown_change', [{ path: file.path, reason: 'changed_before_replace' }]);
|
|
243
|
+
const image = file[side];
|
|
244
|
+
if (image.exists) {
|
|
245
|
+
const data = await readBytes(root, image.ref);
|
|
246
|
+
if (!data || digest(data) !== image.sha256)
|
|
247
|
+
throw new MigrationError('migration_copy_invalid');
|
|
248
|
+
const temporaryPath = publicationPath(state, file.path, image.sha256);
|
|
249
|
+
await atomicBytes(root, file.path, data, { temporaryPath, beforeRename: () => hooks.beforeReplace?.(file.path, temporaryPath, index),
|
|
250
|
+
afterWriteChunk: (writtenPath, written, total) => hooks.afterPublicationWrite?.(file.path, writtenPath, written, total) });
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
const target = await safePath(root, file.path);
|
|
254
|
+
await rm(target);
|
|
255
|
+
await syncDirectory(dirname(target));
|
|
256
|
+
}
|
|
257
|
+
await hooks.afterReplace?.(file.path, index++);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function validTarget(files, mappings = [], sourceHashes = new Map()) {
|
|
261
|
+
try {
|
|
262
|
+
validateMigrationTarget(files);
|
|
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;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function manifestSourceHashes(manifest) {
|
|
278
|
+
return new Map(manifest.files
|
|
279
|
+
.flatMap(file => file.before.exists ? [[file.path, file.before.sha256]] : []));
|
|
280
|
+
}
|
|
281
|
+
async function finalState(input, state) {
|
|
282
|
+
const terminalPath = `${opRoot(state.identity)}/terminal.json`;
|
|
283
|
+
const bytes = await readBytes(input.agentDir, terminalPath, 32 * 1024 * 1024);
|
|
284
|
+
let terminal = null;
|
|
285
|
+
if (bytes) {
|
|
286
|
+
terminal = MigrationStateSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
287
|
+
assertIdentity(terminal, input.identity);
|
|
288
|
+
if ((terminal.phase !== 'committed' && terminal.phase !== 'rolled_back') || terminal.planDigest !== state.planDigest)
|
|
289
|
+
throw new MigrationError('migration_terminal_invalid');
|
|
290
|
+
}
|
|
291
|
+
else if (state.phase === 'committed' || state.phase === 'rolled_back') {
|
|
292
|
+
// Persist the already-published commit point before any content-health update can replace the mutable state.
|
|
293
|
+
await immutable(input.agentDir, terminalPath, encode(state));
|
|
294
|
+
terminal = state;
|
|
295
|
+
}
|
|
296
|
+
if (terminal?.phase === 'committed') {
|
|
297
|
+
let valid = false;
|
|
298
|
+
try {
|
|
299
|
+
valid = validTarget((await inventory(input.agentDir)).files, state.manifest.recordMappings, manifestSourceHashes(state.manifest));
|
|
300
|
+
}
|
|
301
|
+
catch { /* Protect damaged content without erasing commit history. */ }
|
|
302
|
+
if (!valid)
|
|
303
|
+
return repair(input.agentDir, terminal, ['MEMORY.md'], 'committed_target_invalid');
|
|
304
|
+
return same(state, terminal) ? terminal : save(input.agentDir, terminal);
|
|
305
|
+
}
|
|
306
|
+
if (terminal?.phase === 'rolled_back')
|
|
307
|
+
return same(state, terminal) ? terminal : save(input.agentDir, terminal);
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
export async function applyMemoryMigration(input, hooks = {}) {
|
|
311
|
+
await validateMigrationRoot(input.agentDir, input.identity);
|
|
312
|
+
let state = await load(input);
|
|
313
|
+
if (!state)
|
|
314
|
+
throw new MigrationError('migration_state_missing');
|
|
315
|
+
const done = await finalState(input, state);
|
|
316
|
+
if (done)
|
|
317
|
+
return done;
|
|
318
|
+
if (state.phase !== 'prepared')
|
|
319
|
+
throw new MigrationError('migration_recovery_required');
|
|
320
|
+
const receipt = verifyIntent(input.intentReceipt, state);
|
|
321
|
+
const classification = await classify(input.agentDir, state);
|
|
322
|
+
if (classification.unknown.length || classification.classes.some(value => value !== 'B' && value !== 'AB'))
|
|
323
|
+
return repair(input.agentDir, state, classification.unknown.length ? classification.unknown : ['MEMORY.md'], 'source_changed');
|
|
324
|
+
const backup = await copies(input.agentDir, state, 'before'), staged = await copies(input.agentDir, state, 'after');
|
|
325
|
+
if (!backup || !staged || !validTarget(staged, state.manifest.recordMappings, manifestSourceHashes(state.manifest)))
|
|
326
|
+
throw new MigrationError('migration_copy_invalid');
|
|
327
|
+
await immutable(input.agentDir, `${opRoot(state.identity)}/intent.json`, encode(receipt));
|
|
328
|
+
state = await save(input.agentDir, { ...state, phase: 'applying', intentReceipt: receipt });
|
|
329
|
+
await publish(input.agentDir, state, 'after', hooks);
|
|
330
|
+
const current = await classify(input.agentDir, state);
|
|
331
|
+
if (current.unknown.length || current.classes.some(value => value === 'X' || value === 'B') || !validTarget(current.files, state.manifest.recordMappings, manifestSourceHashes(state.manifest)))
|
|
332
|
+
return repair(input.agentDir, state, ['MEMORY.md'], 'target_validation_failed');
|
|
333
|
+
return save(input.agentDir, { ...state, phase: 'committed', storageVersion: 2 });
|
|
334
|
+
}
|
|
335
|
+
export async function recoverMemoryMigration(input, hooks = {}) {
|
|
336
|
+
await validateMigrationRoot(input.agentDir, input.identity);
|
|
337
|
+
let state = await readState(input.agentDir);
|
|
338
|
+
if (state && state.migrationId !== input.identity.migrationId) {
|
|
339
|
+
const history = MigrationHistorySchema.parse(input.serverHistory);
|
|
340
|
+
if (history.kind !== 'existing' || history.migrationId !== input.identity.migrationId)
|
|
341
|
+
throw new MigrationError('migration_history_missing_or_mismatch');
|
|
342
|
+
const operations = await readdir(await safePath(input.agentDir, '.crew/memory/migrations'));
|
|
343
|
+
await verifyRolledBackPredecessors(input.agentDir, input.identity, history, operations, state);
|
|
344
|
+
state = null;
|
|
345
|
+
}
|
|
346
|
+
if (state)
|
|
347
|
+
assertIdentity(state, input.identity);
|
|
348
|
+
if (!state) {
|
|
349
|
+
const history = MigrationHistorySchema.parse(input.serverHistory);
|
|
350
|
+
if (history.kind !== 'existing' || history.migrationId !== input.identity.migrationId)
|
|
351
|
+
throw new MigrationError('migration_history_missing_or_mismatch');
|
|
352
|
+
const terminal = await readBytes(input.agentDir, `${opRoot(input.identity)}/terminal.json`, 32 * 1024 * 1024);
|
|
353
|
+
if (terminal) {
|
|
354
|
+
const terminalState = MigrationStateSchema.parse(JSON.parse(terminal.toString('utf8')));
|
|
355
|
+
assertIdentity(terminalState, input.identity);
|
|
356
|
+
if (terminalState.phase !== 'committed' && terminalState.phase !== 'rolled_back')
|
|
357
|
+
throw new MigrationError('migration_terminal_invalid');
|
|
358
|
+
const final = await finalState(input, terminalState);
|
|
359
|
+
if (final)
|
|
360
|
+
return save(input.agentDir, final);
|
|
361
|
+
}
|
|
362
|
+
const bytes = await readBytes(input.agentDir, `${opRoot(input.identity)}/manifest.json`, 32 * 1024 * 1024);
|
|
363
|
+
if (!bytes)
|
|
364
|
+
throw new MigrationError('migration_manifest_missing_repair_required');
|
|
365
|
+
state = MigrationStateSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
366
|
+
assertIdentity(state, input.identity);
|
|
367
|
+
const receipt = await readBytes(input.agentDir, `${opRoot(input.identity)}/intent.json`);
|
|
368
|
+
if (receipt)
|
|
369
|
+
state = { ...state, phase: 'applying', intentReceipt: verifyIntent(JSON.parse(receipt.toString('utf8')), state) };
|
|
370
|
+
else if (history.writeIntentConfirmed && !input.intentReceipt)
|
|
371
|
+
throw new MigrationError('migration_intent_receipt_required');
|
|
372
|
+
}
|
|
373
|
+
const done = await finalState(input, state);
|
|
374
|
+
if (done)
|
|
375
|
+
return done;
|
|
376
|
+
const stopped = MigrationProcessStoppedReceiptSchema.parse(input.stoppedReceipt);
|
|
377
|
+
if (stopped.migrationId !== state.migrationId || stopped.machineId !== state.identity.machineId || stopped.executionId !== state.identity.executionId || stopped.ownerIdentity !== state.identity.ownerIdentity
|
|
378
|
+
|| (stopped.proof === 'not_started' && (state.phase !== 'prepared' || state.intentReceipt || input.intentReceipt)))
|
|
379
|
+
throw new MigrationError('migration_stop_proof_invalid');
|
|
380
|
+
const cancelled = z.boolean().parse(input.cancelled);
|
|
381
|
+
let receipt = state.intentReceipt;
|
|
382
|
+
if (input.intentReceipt)
|
|
383
|
+
receipt = verifyIntent(input.intentReceipt, state);
|
|
384
|
+
let current;
|
|
385
|
+
try {
|
|
386
|
+
current = await classify(input.agentDir, state);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return repair(input.agentDir, state, ['MEMORY.md'], 'unsafe_or_unreadable_source');
|
|
390
|
+
}
|
|
391
|
+
if (current.unknown.length || current.classes.includes('X'))
|
|
392
|
+
return repair(input.agentDir, state, [...current.unknown, ...state.manifest.files.filter((_, index) => current.classes[index] === 'X').map(file => file.path)], 'unknown_change');
|
|
393
|
+
const allA = current.classes.every(value => value === 'A' || value === 'AB'), allB = current.classes.every(value => value === 'B' || value === 'AB');
|
|
394
|
+
if (allA && !cancelled && validTarget(current.files, state.manifest.recordMappings, manifestSourceHashes(state.manifest)))
|
|
395
|
+
return save(input.agentDir, { ...state, phase: 'committed', storageVersion: 2, ...(receipt ? { intentReceipt: receipt } : {}) });
|
|
396
|
+
if (allB && (!receipt || cancelled))
|
|
397
|
+
return save(input.agentDir, { ...state, phase: 'rolled_back', storageVersion: state.identity.sourceStorageVersion });
|
|
398
|
+
const staged = await copies(input.agentDir, state, 'after');
|
|
399
|
+
if (receipt && !cancelled && staged && validTarget(staged, state.manifest.recordMappings, manifestSourceHashes(state.manifest))) {
|
|
400
|
+
state = await save(input.agentDir, { ...state, phase: 'applying', intentReceipt: receipt });
|
|
401
|
+
await publish(input.agentDir, state, 'after', hooks);
|
|
402
|
+
const target = await classify(input.agentDir, state);
|
|
403
|
+
if (!target.unknown.length && target.classes.every(value => value === 'A' || value === 'AB') && validTarget(target.files, state.manifest.recordMappings, manifestSourceHashes(state.manifest)))
|
|
404
|
+
return save(input.agentDir, { ...state, phase: 'committed', storageVersion: 2 });
|
|
405
|
+
}
|
|
406
|
+
const backup = await copies(input.agentDir, state, 'before');
|
|
407
|
+
if (!backup || detectLocalMemoryStorageVersion(backup.get('MEMORY.md')?.toString('utf8') ?? '') !== state.identity.sourceStorageVersion)
|
|
408
|
+
return repair(input.agentDir, state, ['MEMORY.md'], 'backup_unverifiable');
|
|
409
|
+
await publish(input.agentDir, state, 'before', hooks);
|
|
410
|
+
const restored = await classify(input.agentDir, state);
|
|
411
|
+
if (restored.unknown.length || !restored.classes.every(value => value === 'B' || value === 'AB'))
|
|
412
|
+
return repair(input.agentDir, state, ['MEMORY.md'], 'rollback_validation_failed');
|
|
413
|
+
return save(input.agentDir, { ...state, phase: 'rolled_back', storageVersion: state.identity.sourceStorageVersion });
|
|
414
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { runMemoryMigrationCommand, MigrationCommandSchema } from "./migration-cli.js";
|
|
2
|
+
import { withMemoryAccess } from "./migration-access.js";
|
|
3
|
+
async function main() {
|
|
4
|
+
const chunks = [];
|
|
5
|
+
let bytes = 0;
|
|
6
|
+
for await (const chunk of process.stdin) {
|
|
7
|
+
const part = Buffer.from(chunk);
|
|
8
|
+
bytes += part.length;
|
|
9
|
+
if (bytes > 8 * 1024 * 1024)
|
|
10
|
+
throw new Error("migration_input_limit");
|
|
11
|
+
chunks.push(part);
|
|
12
|
+
}
|
|
13
|
+
const input = MigrationCommandSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
14
|
+
const result = await withMemoryAccess(input.agentDir, () => runMemoryMigrationCommand(input), { maintenance: true });
|
|
15
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
16
|
+
process.exitCode = result.result === "success" || result.result === "missing" ? 0 : 1;
|
|
17
|
+
}
|
|
18
|
+
void main().catch(() => {
|
|
19
|
+
process.stdout.write(JSON.stringify({ result: "failed", issues: [{ path: "", reason: "migration_tool_failed" }] }) + "\n");
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
});
|
|
@@ -15,6 +15,7 @@ const SearchSchema = z.object({
|
|
|
15
15
|
cursor: z.string().max(2048).optional(), limit: z.number().int().min(1).max(40).default(10),
|
|
16
16
|
}).strict();
|
|
17
17
|
const excerpt = (text, cap) => Array.from(text).slice(0, cap).join("");
|
|
18
|
+
const pendingState = (reason) => reason === "legacy_requires_migration" ? "migration_deferred" : "capacity_deferred";
|
|
18
19
|
export async function searchPendingMemoryPage(input) {
|
|
19
20
|
const parsed = SearchSchema.safeParse(input);
|
|
20
21
|
if (!parsed.success)
|
|
@@ -61,7 +62,8 @@ export async function searchPendingMemoryPage(input) {
|
|
|
61
62
|
const match = source && isRecallablePending(source.candidate) && pendingRecallScore(source.candidate, args.query) > 0
|
|
62
63
|
? { pendingId: source.candidate.pendingId, sha256: source.sha256, title: source.candidate.candidate.title,
|
|
63
64
|
summary: excerpt(source.candidate.candidate.summary, 180), triggers: source.candidate.candidate.triggers.slice(0, 4),
|
|
64
|
-
operation: "create",
|
|
65
|
+
operation: "create", reason: source.candidate.reason,
|
|
66
|
+
state: pendingState(source.candidate.reason) }
|
|
65
67
|
: null;
|
|
66
68
|
const addition = match ?? warning;
|
|
67
69
|
const size = addition ? Buffer.byteLength(JSON.stringify(addition), "utf8") + 2 : 0;
|
|
@@ -96,5 +98,6 @@ export async function readPendingMemoryBody(input) {
|
|
|
96
98
|
throw new Error("memory_pending_not_recallable");
|
|
97
99
|
const candidate = source.candidate.candidate;
|
|
98
100
|
return { pendingId: source.candidate.pendingId, sha256: source.sha256, title: candidate.title,
|
|
99
|
-
operation: "create",
|
|
101
|
+
operation: "create", reason: source.candidate.reason,
|
|
102
|
+
state: pendingState(source.candidate.reason), content: candidate.detailBody };
|
|
100
103
|
}
|
|
@@ -8,7 +8,7 @@ const ArchivedMemoryChangeSchema = LocalMemoryChangeSchema.extend({
|
|
|
8
8
|
export const PendingIdSchema = z.string().max(200).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u);
|
|
9
9
|
export const PendingMemorySchema = z.object({
|
|
10
10
|
schemaVersion: z.literal(1), pendingId: PendingIdSchema,
|
|
11
|
-
createdAt: z.string().min(1).max(100), reason: z.
|
|
11
|
+
createdAt: z.string().min(1).max(100), reason: z.enum(["index_over_budget", "legacy_requires_migration"]),
|
|
12
12
|
source: z.object({ executionId: z.string().min(1).max(500), pruneTraceId: z.string().min(1).max(500).nullable() }).strict(),
|
|
13
13
|
candidate: ArchivedMemoryChangeSchema,
|
|
14
14
|
}).strict();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { lstat, mkdir, realpath, rename, rm, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { isRecallablePending, parsePendingMemoryCandidate } from "./pending-schema.js";
|
|
5
5
|
import { pendingFileNames, pendingReadDirectory, PENDING_FILE_BYTES, readPendingSource } from "./pending-source.js";
|
|
@@ -103,6 +103,17 @@ export async function persistPendingMemoryCandidate(input) {
|
|
|
103
103
|
const directory = await pendingDirectory(input.agentDir);
|
|
104
104
|
const target = join(directory, `${parsed.pendingId}.json`);
|
|
105
105
|
await assertRegularFile(target);
|
|
106
|
+
try {
|
|
107
|
+
const existing = parsePendingMemoryCandidate(JSON.parse(await readFile(target, "utf8")));
|
|
108
|
+
const dedupKey = (value) => value.reason === "legacy_requires_migration"
|
|
109
|
+
? JSON.stringify({ schemaVersion: value.schemaVersion, reason: value.reason, candidate: value.candidate })
|
|
110
|
+
: JSON.stringify({ ...value, createdAt: "" });
|
|
111
|
+
if (dedupKey(existing) === dedupKey(parsed))
|
|
112
|
+
return target;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// A malformed existing candidate is replaced by the validated input below.
|
|
116
|
+
}
|
|
106
117
|
await atomicWrite(target, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
107
118
|
return target;
|
|
108
119
|
}
|
|
@@ -164,7 +175,10 @@ function truncateCodePoints(value, maxCodePoints) {
|
|
|
164
175
|
return Array.from(value).slice(0, Math.max(0, maxCodePoints)).join("");
|
|
165
176
|
}
|
|
166
177
|
export function renderPendingMemoryContext(candidates, maxCodePoints = 2_000) {
|
|
167
|
-
|
|
178
|
+
// Migration-deferred candidates remain explicitly searchable, but must not
|
|
179
|
+
// be injected into the ordinary prompt before a reviewed migration promotes
|
|
180
|
+
// them to the active catalog.
|
|
181
|
+
const recallable = candidates.filter((candidate) => isRecallablePending(candidate) && candidate.reason === "index_over_budget");
|
|
168
182
|
if (recallable.length === 0 || maxCodePoints <= 0)
|
|
169
183
|
return "";
|
|
170
184
|
const header = `\n\n## Archived memory candidates (daemon-managed)\n容量暂存的新增资料,仅为部分预览,不代表完整检索;可参考但不能覆盖现行规则,未包含内容请按需查询。\n`;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { dslog } from "../slog.js";
|
|
2
2
|
import { renderPendingMemoryContext, searchPendingMemoryCandidates } from "./pending.js";
|
|
3
3
|
import { renderLocalMemoryResultInstructions, renderLocalMemoryV2WriteGuard } from "./runtime.js";
|
|
4
|
-
import { LOCAL_MEMORY_V2_DEFERRED_GUIDANCE } from "./rollout.js";
|
|
5
|
-
const PENDING_READ_GUIDANCE = "\n\nV2新增资料预览未命中不代表没有资料;先查有效索引,必要时用 crew-memory search-pending --query '关键词',按 nextCursor 续查;用 read-pending --id ID
|
|
4
|
+
import { LOCAL_MEMORY_V2_DEFERRED_GUIDANCE, LOCAL_MEMORY_LEGACY_GUIDANCE } from "./rollout.js";
|
|
5
|
+
const PENDING_READ_GUIDANCE = "\n\nV2新增资料预览未命中不代表没有资料;先查有效索引,必要时用 crew-memory search-pending --query '关键词',按 nextCursor 续查;用 read-pending --id ID 分页读正文。容量暂存或迁移延期资料不能覆盖现行规则,未应用修改不作为记忆;迁移延期候选必须等 reviewed migration 后再按当前 ID/revision/hash 处理。用 crew-memory --help 查看参数。\n";
|
|
6
6
|
export async function prepareLocalMemoryPrompt(input) {
|
|
7
7
|
let pending = "";
|
|
8
8
|
if (input.enabled) {
|
|
@@ -22,10 +22,11 @@ export async function prepareLocalMemoryPrompt(input) {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
const original = input.renderBase(0);
|
|
25
|
-
const guard = input.deferred ? LOCAL_MEMORY_V2_DEFERRED_GUIDANCE
|
|
25
|
+
const guard = input.legacyFallback ? LOCAL_MEMORY_LEGACY_GUIDANCE : input.deferred ? LOCAL_MEMORY_V2_DEFERRED_GUIDANCE
|
|
26
26
|
: input.strictRollout && input.enabled && input.resultPath === null ? renderLocalMemoryV2WriteGuard() : "";
|
|
27
|
-
const structured = input.resultPath === null ? "" : renderLocalMemoryResultInstructions(input.resultPath);
|
|
27
|
+
const structured = input.resultPath === null ? "" : renderLocalMemoryResultInstructions(input.resultPath, input.storageVersion, input.migrationDeferred === true);
|
|
28
28
|
const guidance = input.enabled ? PENDING_READ_GUIDANCE : "";
|
|
29
|
+
let requiredMemoryBudgetError;
|
|
29
30
|
// 短命令指引放在当前轮,独立于可舍弃预览;收尾契约仍保持系统与当前轮两份。
|
|
30
31
|
for (const choice of [
|
|
31
32
|
{ suffix: pending + guard + structured, wake: guard + structured + guidance },
|
|
@@ -33,11 +34,26 @@ export async function prepareLocalMemoryPrompt(input) {
|
|
|
33
34
|
{ suffix: guard + structured, wake: guard + structured },
|
|
34
35
|
]) {
|
|
35
36
|
const reserved = Buffer.byteLength(choice.suffix + choice.wake, "utf8");
|
|
36
|
-
|
|
37
|
+
let base;
|
|
38
|
+
try {
|
|
39
|
+
base = reserved === 0 ? original : input.renderBase(reserved);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
// 可选候选先让出预算;最后仍不能容纳身份/置顶时必须拒绝,不能省略必需记忆。
|
|
43
|
+
if (input.storageVersion === 2 && error instanceof Error
|
|
44
|
+
&& ["local_memory_identity_budget_exceeded", "local_memory_pinned_budget_exceeded"].includes(error.message)) {
|
|
45
|
+
requiredMemoryBudgetError = error;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
const systemPrompt = base + choice.suffix;
|
|
37
51
|
if (input.maxBytes === undefined || Buffer.byteLength(systemPrompt + choice.wake, "utf8") <= input.maxBytes) {
|
|
38
52
|
return { systemPrompt, resultPath: input.resultPath, wakePromptSuffix: choice.wake };
|
|
39
53
|
}
|
|
40
54
|
}
|
|
55
|
+
if (requiredMemoryBudgetError)
|
|
56
|
+
throw requiredMemoryBudgetError;
|
|
41
57
|
if (input.strictRollout)
|
|
42
58
|
throw new Error("local_memory_v2_prompt_over_budget");
|
|
43
59
|
// 强制基础指令与契约本身仍无法共存时,保留既有非结构化兼容回退,不裁基础指令。
|