@nowcrew/daemon 0.6.90 → 0.6.91-beta.10
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/agent-ability/types.js +1 -1
- package/dist/console.js +5 -4
- package/dist/daemon-update-controller.js +16 -8
- package/dist/daemon-updater.js +10 -22
- package/dist/execution-concurrency-telemetry.js +1 -0
- package/dist/execution-journal.js +3 -1
- package/dist/execution-protocol.js +2 -2
- package/dist/execution-runner.js +6 -3
- package/dist/external-output.js +2 -1
- package/dist/host-execution-coordinator.js +12 -6
- package/dist/i18n.js +4 -3
- package/dist/local-execution-prompt.js +8 -3
- package/dist/local-executor.js +16 -37
- package/dist/local-memory/execution-workspace.js +36 -0
- package/dist/local-memory/full-index-search.js +47 -0
- package/dist/local-memory/index-budget.js +9 -3
- package/dist/local-memory/migration-access.js +118 -0
- package/dist/local-memory/migration-cli.js +54 -0
- package/dist/local-memory/migration-io.js +199 -0
- package/dist/local-memory/migration-plan.js +283 -0
- package/dist/local-memory/migration-schema.js +55 -0
- package/dist/local-memory/migration-store.js +424 -0
- package/dist/local-memory/migration-tool-entry.js +21 -0
- package/dist/local-memory/prompt.js +20 -4
- package/dist/local-memory/read-cli.js +36 -1
- package/dist/local-memory/rollout.js +53 -8
- package/dist/local-memory/runtime.js +5 -2
- package/dist/local-memory/storage-format.js +152 -0
- package/dist/local-memory/write.js +108 -17
- package/dist/machine-environment-cache.js +18 -4
- package/dist/machine-environment-contract.js +3 -1
- package/dist/machine-environment-process.js +21 -1
- package/dist/machine-info.js +3 -0
- package/dist/marketplace-serve.js +25 -1
- package/dist/memory-migration-controller.js +84 -0
- package/dist/memory-migration-inspect.js +74 -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/message-file-preview.js +11 -1
- package/dist/normalize.js +9 -5
- package/dist/pre-admission-rejection.js +5 -4
- package/dist/prompt.js +5 -1
- package/dist/runner.js +1 -1
- package/dist/runtime-capabilities.js +2 -1
- package/dist/runtime-startup-gate.js +1 -0
- package/dist/runtimes/pi-runner.js +28 -0
- package/dist/runtimes/pi.js +17 -0
- package/dist/serve.js +37 -6
- package/dist/shared-execution-slots.js +2 -0
- package/dist/supervised-runtime.js +20 -0
- package/dist/token.js +1 -0
- package/dist/workspace-control-handler.js +16 -3
- package/package.json +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const identifier = z.string().min(1).max(256).regex(/^[A-Za-z0-9_.:@-]+$/u);
|
|
3
|
+
export const MigrationDigestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
|
4
|
+
export const MigrationIdentitySchema = z.object({
|
|
5
|
+
workspaceId: identifier, agentId: identifier, migrationId: identifier,
|
|
6
|
+
sourceStorageVersion: z.union([z.literal(0), z.literal(1)]), targetStorageVersion: z.literal(2),
|
|
7
|
+
rootFingerprint: MigrationDigestSchema, ownerIdentity: identifier, machineId: identifier, executionId: identifier,
|
|
8
|
+
}).strict();
|
|
9
|
+
export const MigrationIntentReceiptSchema = MigrationIdentitySchema.extend({
|
|
10
|
+
kind: z.literal('server_write_intent'), sourceDigest: MigrationDigestSchema, planDigest: MigrationDigestSchema,
|
|
11
|
+
intentVersion: z.number().int().positive(), confirmedAt: z.string().datetime(),
|
|
12
|
+
}).strict();
|
|
13
|
+
export const MigrationProcessStoppedReceiptSchema = z.object({
|
|
14
|
+
kind: z.literal('migration_process_stopped'), migrationId: identifier, machineId: identifier,
|
|
15
|
+
executionId: identifier, ownerIdentity: identifier, pid: z.number().int().positive().nullable(),
|
|
16
|
+
stoppedAt: z.string().datetime(), proof: z.enum(['supervisor_exit', 'journal_reconciled', 'not_started']),
|
|
17
|
+
}).strict().refine(value => value.proof === 'not_started' ? value.pid === null : value.proof === 'supervisor_exit' ? value.pid !== null : true, 'migration_stop_proof_invalid');
|
|
18
|
+
export const MigrationPathSchema = z.string().max(512).refine(value => (value === 'MEMORY.md' || value === 'notes' || value === '.crew/memory/pending'
|
|
19
|
+
|| value.startsWith('notes/') || value.startsWith('.crew/memory/pending/'))
|
|
20
|
+
&& !/[\\\u0000-\u001f\u007f:]/u.test(value)
|
|
21
|
+
&& value.split('/').every(part => part !== '.' && part !== '..' && part.length > 0), 'migration_path_invalid');
|
|
22
|
+
export const MigrationRecordMappingSchema = z.object({
|
|
23
|
+
sourceFile: MigrationPathSchema.refine(value => value === 'MEMORY.md' || value.startsWith('notes/'), 'migration_pending_promotion_forbidden'), start: z.number().int().nonnegative(), end: z.number().int().positive(),
|
|
24
|
+
sourceSha256: MigrationDigestSchema.optional(),
|
|
25
|
+
scope: z.string().min(1).max(1000), category: z.enum(['preferences', 'project-rules', 'testing', 'debugging', 'git', 'incidents', 'lessons']),
|
|
26
|
+
targetId: z.string().regex(/^[A-Za-z0-9_-]+$/u).optional(), title: z.string().min(1).max(300),
|
|
27
|
+
status: z.enum(['active', 'pinned', 'candidate', 'stale', 'superseded', 'archived']).default('active'),
|
|
28
|
+
}).strict().refine(value => value.end > value.start, 'migration_mapping_range_invalid');
|
|
29
|
+
const image = z.discriminatedUnion('exists', [z.object({ exists: z.literal(false) }).strict(),
|
|
30
|
+
z.object({ exists: z.literal(true), sha256: MigrationDigestSchema, ref: z.string().regex(/^\.crew\/memory\/migrations\/[A-Za-z0-9_.:@-]+\/(?:backup|staged)\/[a-f0-9]{64}$/u), bytes: z.number().int().nonnegative() }).strict()]);
|
|
31
|
+
export const MigrationManifestSchema = z.object({
|
|
32
|
+
directories: z.array(MigrationPathSchema).max(10000),
|
|
33
|
+
files: z.array(z.object({ path: MigrationPathSchema, before: image, after: image }).strict()).max(20000),
|
|
34
|
+
recordMappings: z.array(MigrationRecordMappingSchema).max(10000),
|
|
35
|
+
}).strict();
|
|
36
|
+
export const MigrationIssueSchema = z.object({ path: z.string(), reason: z.string(), start: z.number().optional(), end: z.number().optional() }).strict();
|
|
37
|
+
export const MigrationStateSchema = z.object({
|
|
38
|
+
schemaVersion: z.literal(1), identity: MigrationIdentitySchema,
|
|
39
|
+
storageVersion: z.union([z.literal(0), z.literal(1), z.literal(2)]), targetStorageVersion: z.literal(2), origin: z.enum(['migrated', 'initialized']),
|
|
40
|
+
migrationId: identifier, phase: z.enum(['prepared', 'applying', 'committed', 'rolled_back', 'repair_required']),
|
|
41
|
+
sourceDigest: MigrationDigestSchema, planDigest: MigrationDigestSchema, manifest: MigrationManifestSchema,
|
|
42
|
+
intentReceipt: MigrationIntentReceiptSchema.optional(), issues: z.array(MigrationIssueSchema),
|
|
43
|
+
}).strict();
|
|
44
|
+
export const MigrationHistorySchema = z.object({ kind: z.enum(['none', 'existing', 'unavailable']), migrationId: identifier.optional(), writeIntentConfirmed: z.boolean().optional(), isNewAgent: z.boolean().optional(),
|
|
45
|
+
rolledBackOperations: z.array(z.object({ migrationId: identifier, sourceDigest: MigrationDigestSchema, planDigest: MigrationDigestSchema }).strict()).max(10000).optional(),
|
|
46
|
+
}).strict();
|
|
47
|
+
export class MigrationError extends Error {
|
|
48
|
+
code;
|
|
49
|
+
issues;
|
|
50
|
+
constructor(code, issues = []) {
|
|
51
|
+
super(code);
|
|
52
|
+
this.code = code;
|
|
53
|
+
this.issues = issues;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,424 @@
|
|
|
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, MigrationIntentReceiptSchema, MigrationProcessStoppedReceiptSchema, MigrationStateSchema } from './migration-schema.js';
|
|
7
|
+
import { detectLocalMemoryStorageVersion } from './storage-format.js';
|
|
8
|
+
const opRoot = (identity) => `.crew/memory/migrations/${identity.migrationId}`;
|
|
9
|
+
function canonical(data) {
|
|
10
|
+
if (Array.isArray(data))
|
|
11
|
+
return data.map(canonical);
|
|
12
|
+
if (data !== null && typeof data === 'object')
|
|
13
|
+
return Object.fromEntries(Object.entries(data).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => [key, canonical(value)]));
|
|
14
|
+
return data;
|
|
15
|
+
}
|
|
16
|
+
const encode = (data) => Buffer.from(JSON.stringify(canonical(data)));
|
|
17
|
+
const same = (a, b) => encode(a).equals(encode(b));
|
|
18
|
+
const planDigest = (identity, sourceDigest, manifest) => digest(encode({ identity, sourceDigest, manifest }));
|
|
19
|
+
function assertIdentity(state, identity) {
|
|
20
|
+
if (!same(state.identity, identity) || state.migrationId !== identity.migrationId
|
|
21
|
+
|| state.planDigest !== planDigest(identity, state.sourceDigest, state.manifest))
|
|
22
|
+
throw new MigrationError('migration_identity_or_manifest_invalid');
|
|
23
|
+
const paths = state.manifest.files.map(file => file.path);
|
|
24
|
+
if (new Set(paths).size !== paths.length || !paths.includes('MEMORY.md'))
|
|
25
|
+
throw new MigrationError('migration_manifest_invalid');
|
|
26
|
+
for (const file of state.manifest.files)
|
|
27
|
+
for (const image of [file.before, file.after]) {
|
|
28
|
+
if (image.exists && !image.ref.startsWith(`${opRoot(identity)}/`))
|
|
29
|
+
throw new MigrationError('migration_manifest_invalid');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function immutable(root, path, data) {
|
|
33
|
+
const previous = await readBytes(root, path, 64 * 1024 * 1024);
|
|
34
|
+
if (previous) {
|
|
35
|
+
if (digest(previous) !== digest(data))
|
|
36
|
+
throw new MigrationError('migration_immutable_hash_mismatch', [{ path, reason: 'hash_mismatch' }]);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
await atomicBytes(root, path, data);
|
|
40
|
+
}
|
|
41
|
+
async function save(root, state) {
|
|
42
|
+
const parsed = MigrationStateSchema.parse(state);
|
|
43
|
+
if (parsed.phase === 'committed' || parsed.phase === 'rolled_back') {
|
|
44
|
+
const previous = await readBytes(root, `${opRoot(parsed.identity)}/terminal.json`, 32 * 1024 * 1024);
|
|
45
|
+
if (previous && !same(JSON.parse(previous.toString('utf8')), parsed)) {
|
|
46
|
+
throw new MigrationError('migration_terminal_conflict');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
await atomicBytes(root, STATE_PATH, encode(parsed));
|
|
50
|
+
// State is the commit point. Retain the same terminal fact separately so accidental state loss cannot replay a completed plan.
|
|
51
|
+
if (parsed.phase === 'committed' || parsed.phase === 'rolled_back')
|
|
52
|
+
await immutable(root, `${opRoot(parsed.identity)}/terminal.json`, encode(parsed));
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
async function readState(root) {
|
|
56
|
+
const bytes = await readBytes(root, STATE_PATH, 32 * 1024 * 1024);
|
|
57
|
+
if (!bytes)
|
|
58
|
+
return null;
|
|
59
|
+
let state;
|
|
60
|
+
try {
|
|
61
|
+
state = MigrationStateSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new MigrationError('migration_state_invalid');
|
|
65
|
+
}
|
|
66
|
+
return state;
|
|
67
|
+
}
|
|
68
|
+
async function load(input) {
|
|
69
|
+
const state = await readState(input.agentDir);
|
|
70
|
+
if (state)
|
|
71
|
+
assertIdentity(state, input.identity);
|
|
72
|
+
return state;
|
|
73
|
+
}
|
|
74
|
+
export async function statusMemoryMigration(input) {
|
|
75
|
+
const identity = await validateMigrationRoot(input.agentDir, input.identity);
|
|
76
|
+
return load({ ...input, identity });
|
|
77
|
+
}
|
|
78
|
+
function verifyIntent(receipt, state) {
|
|
79
|
+
const parsed = MigrationIntentReceiptSchema.parse(receipt);
|
|
80
|
+
for (const key of Object.keys(state.identity))
|
|
81
|
+
if (parsed[key] !== state.identity[key])
|
|
82
|
+
throw new MigrationError('migration_intent_identity_mismatch');
|
|
83
|
+
if (parsed.sourceDigest !== state.sourceDigest || parsed.planDigest !== state.planDigest)
|
|
84
|
+
throw new MigrationError('migration_intent_digest_mismatch');
|
|
85
|
+
return parsed;
|
|
86
|
+
}
|
|
87
|
+
export async function prepareMemoryMigration(input, hooks = {}) {
|
|
88
|
+
const identity = await validateMigrationRoot(input.agentDir, input.identity);
|
|
89
|
+
const history = MigrationHistorySchema.parse(input.serverHistory);
|
|
90
|
+
const existing = await readState(input.agentDir);
|
|
91
|
+
if (existing?.migrationId === identity.migrationId) {
|
|
92
|
+
assertIdentity(existing, identity);
|
|
93
|
+
return existing;
|
|
94
|
+
}
|
|
95
|
+
if (history.kind !== 'none' || history.writeIntentConfirmed === true)
|
|
96
|
+
throw new MigrationError(history.kind === 'existing' ? 'migration_recovery_required' : 'migration_server_history_unavailable');
|
|
97
|
+
const operations = await readdir(await safePath(input.agentDir, '.crew/memory/migrations')).catch((error) => {
|
|
98
|
+
if (error.code === 'ENOENT')
|
|
99
|
+
return [];
|
|
100
|
+
throw error;
|
|
101
|
+
});
|
|
102
|
+
await verifyRolledBackPredecessors(input.agentDir, identity, history, operations, existing);
|
|
103
|
+
const leftovers = await readBytes(input.agentDir, `${opRoot(identity)}/manifest.json`, 32 * 1024 * 1024);
|
|
104
|
+
if (leftovers) {
|
|
105
|
+
const prepared = MigrationStateSchema.parse(JSON.parse(leftovers.toString('utf8')));
|
|
106
|
+
assertIdentity(prepared, identity);
|
|
107
|
+
const intent = await readBytes(input.agentDir, `${opRoot(identity)}/intent.json`);
|
|
108
|
+
const current = await classify(input.agentDir, prepared);
|
|
109
|
+
if (intent || current.unknown.length || current.classes.some(value => value !== 'B' && value !== 'AB'))
|
|
110
|
+
throw new MigrationError('migration_recovery_required');
|
|
111
|
+
if (!await copies(input.agentDir, prepared, 'before') || !await copies(input.agentDir, prepared, 'after'))
|
|
112
|
+
throw new MigrationError('migration_copy_invalid');
|
|
113
|
+
return save(input.agentDir, prepared);
|
|
114
|
+
}
|
|
115
|
+
const before = await inventory(input.agentDir);
|
|
116
|
+
const sourceMemory = before.files.get('MEMORY.md');
|
|
117
|
+
if (history.isNewAgent === true && identity.sourceStorageVersion === 0 && before.files.size === 0 && before.directories.length === 0)
|
|
118
|
+
throw new MigrationError('migration_workspace_seed_required');
|
|
119
|
+
const previousSeed = existing?.origin === 'initialized' && existing.phase === 'rolled_back'
|
|
120
|
+
? existing.manifest.files.find(file => file.path === 'MEMORY.md')?.before : undefined;
|
|
121
|
+
const retryingOriginalSeed = previousSeed?.exists === true && sourceMemory !== undefined && digest(sourceMemory) === previousSeed.sha256;
|
|
122
|
+
const pristineSeed = (history.isNewAgent === true && operations.length === 0 || retryingOriginalSeed) && identity.sourceStorageVersion === 0
|
|
123
|
+
&& before.files.size === 1 && sourceMemory !== undefined && before.directories.every(path => path === 'notes' || path === '.crew/memory/pending')
|
|
124
|
+
? initializeExactWorkspaceSeed(sourceMemory) : null;
|
|
125
|
+
const initialized = history.isNewAgent === true && identity.sourceStorageVersion === 0
|
|
126
|
+
&& pristineSeed !== null;
|
|
127
|
+
const after = pristineSeed !== null ? { files: new Map([['MEMORY.md', pristineSeed]]), recordMappings: [] } : planMemoryMigration(identity, before.files, input.recordMappings);
|
|
128
|
+
if (pristineSeed !== null)
|
|
129
|
+
validateMigrationTarget(after.files);
|
|
130
|
+
const paths = [...new Set([...before.files.keys(), ...after.files.keys()])].sort();
|
|
131
|
+
const manifest = { directories: before.directories, recordMappings: after.recordMappings, files: paths.map(path => {
|
|
132
|
+
const b = before.files.get(path);
|
|
133
|
+
const a = after.files.get(path);
|
|
134
|
+
return { path, before: b ? { exists: true, sha256: digest(b), ref: `${opRoot(identity)}/backup/${digest(b)}`, bytes: b.length } : { exists: false },
|
|
135
|
+
after: a ? { exists: true, sha256: digest(a), ref: `${opRoot(identity)}/staged/${digest(a)}`, bytes: a.length } : { exists: false } };
|
|
136
|
+
}) };
|
|
137
|
+
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 } })) }));
|
|
138
|
+
const state = { schemaVersion: 1, identity, storageVersion: identity.sourceStorageVersion, targetStorageVersion: 2, origin: initialized ? 'initialized' : 'migrated', migrationId: identity.migrationId, phase: 'prepared', sourceDigest,
|
|
139
|
+
planDigest: planDigest(identity, sourceDigest, manifest), manifest, issues: [] };
|
|
140
|
+
MigrationStateSchema.parse(state);
|
|
141
|
+
const beforeBytes = [...before.files.values()].reduce((sum, value) => sum + value.length, 0);
|
|
142
|
+
const afterBytes = [...after.files.values()].reduce((sum, value) => sum + value.length, 0);
|
|
143
|
+
const max = Math.max(0, ...[...before.files.values(), ...after.files.values()].map(value => value.length));
|
|
144
|
+
const available = hooks.availableBytes ? await hooks.availableBytes() : await statfs(input.agentDir).then(value => value.bavail * value.bsize);
|
|
145
|
+
if (available < beforeBytes + afterBytes + max + encode(state).length * 3)
|
|
146
|
+
throw new MigrationError('migration_insufficient_disk_space');
|
|
147
|
+
for (const file of manifest.files) {
|
|
148
|
+
if (file.before.exists)
|
|
149
|
+
await immutable(input.agentDir, file.before.ref, before.files.get(file.path));
|
|
150
|
+
if (file.after.exists)
|
|
151
|
+
await immutable(input.agentDir, file.after.ref, after.files.get(file.path));
|
|
152
|
+
}
|
|
153
|
+
const current = await inventory(input.agentDir);
|
|
154
|
+
if (!same([...current.files].map(([path, bytes]) => [path, digest(bytes)]), [...before.files].map(([path, bytes]) => [path, digest(bytes)])) || !same(current.directories, before.directories))
|
|
155
|
+
throw new MigrationError('migration_source_changed');
|
|
156
|
+
await immutable(input.agentDir, `${opRoot(identity)}/manifest.json`, encode(state));
|
|
157
|
+
return save(input.agentDir, state);
|
|
158
|
+
}
|
|
159
|
+
/** 新操作只接纳服务端明确授权、摘要相符的回退终态;旧备份和历史保持不可变。 */
|
|
160
|
+
async function verifyRolledBackPredecessors(root, identity, history, operations, existing) {
|
|
161
|
+
const authorized = history.rolledBackOperations ?? [];
|
|
162
|
+
const allowed = new Map(authorized.map(operation => [operation.migrationId, operation]));
|
|
163
|
+
if (allowed.size !== authorized.length || allowed.has(identity.migrationId)
|
|
164
|
+
|| authorized.length > 0 && history.migrationId !== identity.migrationId
|
|
165
|
+
|| operations.some(name => name !== identity.migrationId && !allowed.has(name))
|
|
166
|
+
|| authorized.some(operation => !operations.includes(operation.migrationId))
|
|
167
|
+
|| existing && !allowed.has(existing.migrationId))
|
|
168
|
+
throw new MigrationError('migration_operation_residue');
|
|
169
|
+
for (const previous of authorized) {
|
|
170
|
+
const bytes = await readBytes(root, `.crew/memory/migrations/${previous.migrationId}/terminal.json`, 32 * 1024 * 1024);
|
|
171
|
+
if (!bytes)
|
|
172
|
+
throw new MigrationError('migration_rollback_history_invalid');
|
|
173
|
+
const terminal = MigrationStateSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
174
|
+
assertIdentity(terminal, terminal.identity);
|
|
175
|
+
if (terminal.phase !== 'rolled_back' || terminal.migrationId !== previous.migrationId
|
|
176
|
+
|| terminal.sourceDigest !== previous.sourceDigest || terminal.planDigest !== previous.planDigest
|
|
177
|
+
|| terminal.storageVersion !== terminal.identity.sourceStorageVersion
|
|
178
|
+
|| ['workspaceId', 'agentId', 'rootFingerprint', 'ownerIdentity', 'machineId'].some(key => terminal.identity[key] !== identity[key])
|
|
179
|
+
|| existing?.migrationId === terminal.migrationId && !same(existing, terminal))
|
|
180
|
+
throw new MigrationError('migration_rollback_history_invalid');
|
|
181
|
+
const manifest = await readBytes(root, `${opRoot(terminal.identity)}/manifest.json`, 32 * 1024 * 1024);
|
|
182
|
+
if (!manifest)
|
|
183
|
+
throw new MigrationError('migration_rollback_history_invalid');
|
|
184
|
+
const prepared = MigrationStateSchema.parse(JSON.parse(manifest.toString('utf8')));
|
|
185
|
+
assertIdentity(prepared, terminal.identity);
|
|
186
|
+
if (prepared.planDigest !== terminal.planDigest || !await copies(root, terminal, 'before'))
|
|
187
|
+
throw new MigrationError('migration_rollback_history_invalid');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function matches(bytes, image) { return image.exists ? bytes !== undefined && digest(bytes) === image.sha256 : bytes === undefined; }
|
|
191
|
+
function publicationPath(state, path, sha256) {
|
|
192
|
+
return `${opRoot(state.identity)}/publish/${digest(path)}-${sha256}.tmp`;
|
|
193
|
+
}
|
|
194
|
+
async function validatePublicationCopies(root, state) {
|
|
195
|
+
const directory = `${opRoot(state.identity)}/publish`;
|
|
196
|
+
const allowed = new Map();
|
|
197
|
+
for (const file of state.manifest.files)
|
|
198
|
+
for (const image of [file.before, file.after]) {
|
|
199
|
+
if (image.exists)
|
|
200
|
+
allowed.set(publicationPath(state, file.path, image.sha256), image.sha256);
|
|
201
|
+
}
|
|
202
|
+
const stream = await opendir(await safePath(root, directory)).catch((error) => {
|
|
203
|
+
if (error.code === 'ENOENT')
|
|
204
|
+
return null;
|
|
205
|
+
throw error;
|
|
206
|
+
});
|
|
207
|
+
if (!stream)
|
|
208
|
+
return;
|
|
209
|
+
for await (const entry of stream) {
|
|
210
|
+
const path = `${directory}/${entry.name}`;
|
|
211
|
+
const expected = allowed.get(path);
|
|
212
|
+
if (!expected || !entry.isFile())
|
|
213
|
+
throw new MigrationError('migration_publication_copy_unknown', [{ path, reason: 'unrecognized_publication_copy' }]);
|
|
214
|
+
const content = await readBytes(root, path);
|
|
215
|
+
if (!content || digest(content) !== expected)
|
|
216
|
+
throw new MigrationError('migration_publication_copy_unknown', [{ path, reason: 'publication_hash_mismatch' }]);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function classify(root, state) {
|
|
220
|
+
await validatePublicationCopies(root, state);
|
|
221
|
+
const current = await inventory(root);
|
|
222
|
+
const known = new Set(state.manifest.files.map(file => file.path));
|
|
223
|
+
const allowedDirs = new Set(state.manifest.directories);
|
|
224
|
+
for (const file of state.manifest.files)
|
|
225
|
+
if (file.after.exists) {
|
|
226
|
+
let dir = dirname(file.path);
|
|
227
|
+
while (dir !== '.' && dir !== '.crew' && dir !== '.crew/memory') {
|
|
228
|
+
allowedDirs.add(dir);
|
|
229
|
+
dir = dirname(dir);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const unknown = [...current.files.keys()].filter(path => !known.has(path));
|
|
233
|
+
unknown.push(...current.directories.filter(path => !allowedDirs.has(path)), ...state.manifest.directories.filter(path => !current.directories.includes(path)));
|
|
234
|
+
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'; }) };
|
|
235
|
+
}
|
|
236
|
+
async function copies(root, state, side) {
|
|
237
|
+
const files = new Map();
|
|
238
|
+
try {
|
|
239
|
+
for (const file of state.manifest.files) {
|
|
240
|
+
const image = file[side];
|
|
241
|
+
if (image.exists) {
|
|
242
|
+
const bytes = await readBytes(root, image.ref);
|
|
243
|
+
if (!bytes || bytes.length !== image.bytes || digest(bytes) !== image.sha256)
|
|
244
|
+
return null;
|
|
245
|
+
files.set(file.path, bytes);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return files;
|
|
253
|
+
}
|
|
254
|
+
async function repair(root, state, paths, reason) { return save(root, { ...state, phase: 'repair_required', issues: paths.map(path => ({ path, reason })) }); }
|
|
255
|
+
async function publish(root, state, side, hooks) {
|
|
256
|
+
let index = 0;
|
|
257
|
+
for (const file of state.manifest.files) {
|
|
258
|
+
const current = await readBytes(root, file.path);
|
|
259
|
+
const bytes = current ?? undefined;
|
|
260
|
+
if (matches(bytes, file[side]))
|
|
261
|
+
continue;
|
|
262
|
+
const opposite = file[side === 'after' ? 'before' : 'after'];
|
|
263
|
+
if (!matches(bytes, opposite))
|
|
264
|
+
throw new MigrationError('migration_unknown_change', [{ path: file.path, reason: 'changed_before_replace' }]);
|
|
265
|
+
const image = file[side];
|
|
266
|
+
if (image.exists) {
|
|
267
|
+
const data = await readBytes(root, image.ref);
|
|
268
|
+
if (!data || digest(data) !== image.sha256)
|
|
269
|
+
throw new MigrationError('migration_copy_invalid');
|
|
270
|
+
const temporaryPath = publicationPath(state, file.path, image.sha256);
|
|
271
|
+
await atomicBytes(root, file.path, data, { temporaryPath, beforeRename: () => hooks.beforeReplace?.(file.path, temporaryPath, index),
|
|
272
|
+
afterWriteChunk: (writtenPath, written, total) => hooks.afterPublicationWrite?.(file.path, writtenPath, written, total) });
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
const target = await safePath(root, file.path);
|
|
276
|
+
await rm(target);
|
|
277
|
+
await syncDirectory(dirname(target));
|
|
278
|
+
}
|
|
279
|
+
await hooks.afterReplace?.(file.path, index++);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function validTarget(files) {
|
|
283
|
+
try {
|
|
284
|
+
validateMigrationTarget(files);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async function finalState(input, state) {
|
|
292
|
+
const terminalPath = `${opRoot(state.identity)}/terminal.json`;
|
|
293
|
+
const bytes = await readBytes(input.agentDir, terminalPath, 32 * 1024 * 1024);
|
|
294
|
+
let terminal = null;
|
|
295
|
+
if (bytes) {
|
|
296
|
+
terminal = MigrationStateSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
297
|
+
assertIdentity(terminal, input.identity);
|
|
298
|
+
if ((terminal.phase !== 'committed' && terminal.phase !== 'rolled_back') || terminal.planDigest !== state.planDigest)
|
|
299
|
+
throw new MigrationError('migration_terminal_invalid');
|
|
300
|
+
}
|
|
301
|
+
else if (state.phase === 'committed' || state.phase === 'rolled_back') {
|
|
302
|
+
// Persist the already-published commit point before any content-health update can replace the mutable state.
|
|
303
|
+
await immutable(input.agentDir, terminalPath, encode(state));
|
|
304
|
+
terminal = state;
|
|
305
|
+
}
|
|
306
|
+
if (terminal?.phase === 'committed') {
|
|
307
|
+
let valid = false;
|
|
308
|
+
try {
|
|
309
|
+
valid = validTarget((await inventory(input.agentDir)).files);
|
|
310
|
+
}
|
|
311
|
+
catch { /* Protect damaged content without erasing commit history. */ }
|
|
312
|
+
if (!valid)
|
|
313
|
+
return repair(input.agentDir, terminal, ['MEMORY.md'], 'committed_target_invalid');
|
|
314
|
+
return same(state, terminal) ? terminal : save(input.agentDir, terminal);
|
|
315
|
+
}
|
|
316
|
+
if (terminal?.phase === 'rolled_back')
|
|
317
|
+
return same(state, terminal) ? terminal : save(input.agentDir, terminal);
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
export async function applyMemoryMigration(input, hooks = {}) {
|
|
321
|
+
await validateMigrationRoot(input.agentDir, input.identity);
|
|
322
|
+
let state = await load(input);
|
|
323
|
+
if (!state)
|
|
324
|
+
throw new MigrationError('migration_state_missing');
|
|
325
|
+
const done = await finalState(input, state);
|
|
326
|
+
if (done)
|
|
327
|
+
return done;
|
|
328
|
+
if (state.phase !== 'prepared')
|
|
329
|
+
throw new MigrationError('migration_recovery_required');
|
|
330
|
+
const receipt = verifyIntent(input.intentReceipt, state);
|
|
331
|
+
const classification = await classify(input.agentDir, state);
|
|
332
|
+
if (classification.unknown.length || classification.classes.some(value => value !== 'B' && value !== 'AB'))
|
|
333
|
+
return repair(input.agentDir, state, classification.unknown.length ? classification.unknown : ['MEMORY.md'], 'source_changed');
|
|
334
|
+
const backup = await copies(input.agentDir, state, 'before'), staged = await copies(input.agentDir, state, 'after');
|
|
335
|
+
if (!backup || !staged || !validTarget(staged))
|
|
336
|
+
throw new MigrationError('migration_copy_invalid');
|
|
337
|
+
await immutable(input.agentDir, `${opRoot(state.identity)}/intent.json`, encode(receipt));
|
|
338
|
+
state = await save(input.agentDir, { ...state, phase: 'applying', intentReceipt: receipt });
|
|
339
|
+
await publish(input.agentDir, state, 'after', hooks);
|
|
340
|
+
const current = await classify(input.agentDir, state);
|
|
341
|
+
if (current.unknown.length || current.classes.some(value => value === 'X' || value === 'B') || !validTarget(current.files))
|
|
342
|
+
return repair(input.agentDir, state, ['MEMORY.md'], 'target_validation_failed');
|
|
343
|
+
return save(input.agentDir, { ...state, phase: 'committed', storageVersion: 2 });
|
|
344
|
+
}
|
|
345
|
+
export async function recoverMemoryMigration(input, hooks = {}) {
|
|
346
|
+
await validateMigrationRoot(input.agentDir, input.identity);
|
|
347
|
+
let state = await readState(input.agentDir);
|
|
348
|
+
if (state && state.migrationId !== input.identity.migrationId) {
|
|
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 operations = await readdir(await safePath(input.agentDir, '.crew/memory/migrations'));
|
|
353
|
+
await verifyRolledBackPredecessors(input.agentDir, input.identity, history, operations, state);
|
|
354
|
+
state = null;
|
|
355
|
+
}
|
|
356
|
+
if (state)
|
|
357
|
+
assertIdentity(state, input.identity);
|
|
358
|
+
if (!state) {
|
|
359
|
+
const history = MigrationHistorySchema.parse(input.serverHistory);
|
|
360
|
+
if (history.kind !== 'existing' || history.migrationId !== input.identity.migrationId)
|
|
361
|
+
throw new MigrationError('migration_history_missing_or_mismatch');
|
|
362
|
+
const terminal = await readBytes(input.agentDir, `${opRoot(input.identity)}/terminal.json`, 32 * 1024 * 1024);
|
|
363
|
+
if (terminal) {
|
|
364
|
+
const terminalState = MigrationStateSchema.parse(JSON.parse(terminal.toString('utf8')));
|
|
365
|
+
assertIdentity(terminalState, input.identity);
|
|
366
|
+
if (terminalState.phase !== 'committed' && terminalState.phase !== 'rolled_back')
|
|
367
|
+
throw new MigrationError('migration_terminal_invalid');
|
|
368
|
+
const final = await finalState(input, terminalState);
|
|
369
|
+
if (final)
|
|
370
|
+
return save(input.agentDir, final);
|
|
371
|
+
}
|
|
372
|
+
const bytes = await readBytes(input.agentDir, `${opRoot(input.identity)}/manifest.json`, 32 * 1024 * 1024);
|
|
373
|
+
if (!bytes)
|
|
374
|
+
throw new MigrationError('migration_manifest_missing_repair_required');
|
|
375
|
+
state = MigrationStateSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
376
|
+
assertIdentity(state, input.identity);
|
|
377
|
+
const receipt = await readBytes(input.agentDir, `${opRoot(input.identity)}/intent.json`);
|
|
378
|
+
if (receipt)
|
|
379
|
+
state = { ...state, phase: 'applying', intentReceipt: verifyIntent(JSON.parse(receipt.toString('utf8')), state) };
|
|
380
|
+
else if (history.writeIntentConfirmed && !input.intentReceipt)
|
|
381
|
+
throw new MigrationError('migration_intent_receipt_required');
|
|
382
|
+
}
|
|
383
|
+
const done = await finalState(input, state);
|
|
384
|
+
if (done)
|
|
385
|
+
return done;
|
|
386
|
+
const stopped = MigrationProcessStoppedReceiptSchema.parse(input.stoppedReceipt);
|
|
387
|
+
if (stopped.migrationId !== state.migrationId || stopped.machineId !== state.identity.machineId || stopped.executionId !== state.identity.executionId || stopped.ownerIdentity !== state.identity.ownerIdentity
|
|
388
|
+
|| (stopped.proof === 'not_started' && (state.phase !== 'prepared' || state.intentReceipt || input.intentReceipt)))
|
|
389
|
+
throw new MigrationError('migration_stop_proof_invalid');
|
|
390
|
+
const cancelled = z.boolean().parse(input.cancelled);
|
|
391
|
+
let receipt = state.intentReceipt;
|
|
392
|
+
if (input.intentReceipt)
|
|
393
|
+
receipt = verifyIntent(input.intentReceipt, state);
|
|
394
|
+
let current;
|
|
395
|
+
try {
|
|
396
|
+
current = await classify(input.agentDir, state);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return repair(input.agentDir, state, ['MEMORY.md'], 'unsafe_or_unreadable_source');
|
|
400
|
+
}
|
|
401
|
+
if (current.unknown.length || current.classes.includes('X'))
|
|
402
|
+
return repair(input.agentDir, state, [...current.unknown, ...state.manifest.files.filter((_, index) => current.classes[index] === 'X').map(file => file.path)], 'unknown_change');
|
|
403
|
+
const allA = current.classes.every(value => value === 'A' || value === 'AB'), allB = current.classes.every(value => value === 'B' || value === 'AB');
|
|
404
|
+
if (allA && !cancelled && validTarget(current.files))
|
|
405
|
+
return save(input.agentDir, { ...state, phase: 'committed', storageVersion: 2, ...(receipt ? { intentReceipt: receipt } : {}) });
|
|
406
|
+
if (allB && (!receipt || cancelled))
|
|
407
|
+
return save(input.agentDir, { ...state, phase: 'rolled_back', storageVersion: state.identity.sourceStorageVersion });
|
|
408
|
+
const staged = await copies(input.agentDir, state, 'after');
|
|
409
|
+
if (receipt && !cancelled && staged && validTarget(staged)) {
|
|
410
|
+
state = await save(input.agentDir, { ...state, phase: 'applying', intentReceipt: receipt });
|
|
411
|
+
await publish(input.agentDir, state, 'after', hooks);
|
|
412
|
+
const target = await classify(input.agentDir, state);
|
|
413
|
+
if (!target.unknown.length && target.classes.every(value => value === 'A' || value === 'AB') && validTarget(target.files))
|
|
414
|
+
return save(input.agentDir, { ...state, phase: 'committed', storageVersion: 2 });
|
|
415
|
+
}
|
|
416
|
+
const backup = await copies(input.agentDir, state, 'before');
|
|
417
|
+
if (!backup || detectLocalMemoryStorageVersion(backup.get('MEMORY.md')?.toString('utf8') ?? '') !== state.identity.sourceStorageVersion)
|
|
418
|
+
return repair(input.agentDir, state, ['MEMORY.md'], 'backup_unverifiable');
|
|
419
|
+
await publish(input.agentDir, state, 'before', hooks);
|
|
420
|
+
const restored = await classify(input.agentDir, state);
|
|
421
|
+
if (restored.unknown.length || !restored.classes.every(value => value === 'B' || value === 'AB'))
|
|
422
|
+
return repair(input.agentDir, state, ['MEMORY.md'], 'rollback_validation_failed');
|
|
423
|
+
return save(input.agentDir, { ...state, phase: 'rolled_back', storageVersion: state.identity.sourceStorageVersion });
|
|
424
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
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";
|
|
4
|
+
import { LOCAL_MEMORY_V2_DEFERRED_GUIDANCE, LOCAL_MEMORY_LEGACY_GUIDANCE } from "./rollout.js";
|
|
5
5
|
const PENDING_READ_GUIDANCE = "\n\nV2新增资料预览未命中不代表没有资料;先查有效索引,必要时用 crew-memory search-pending --query '关键词',按 nextCursor 续查;用 read-pending --id ID 分页读正文。容量暂存资料不能覆盖现行规则,未应用修改不作为记忆。用 crew-memory --help 查看参数。\n";
|
|
6
6
|
export async function prepareLocalMemoryPrompt(input) {
|
|
7
7
|
let pending = "";
|
|
@@ -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);
|
|
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
|
// 强制基础指令与契约本身仍无法共存时,保留既有非结构化兼容回退,不裁基础指令。
|
|
@@ -6,6 +6,9 @@ import { pathToFileURL } from "node:url";
|
|
|
6
6
|
import { parseArgs, TextDecoder } from "node:util";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { readPendingMemoryBody, searchPendingMemoryPage } from "./pending-query.js";
|
|
9
|
+
import { detectLocalMemoryStorageVersion } from "./storage-format.js";
|
|
10
|
+
import { searchFullMemoryIndex } from "./full-index-search.js";
|
|
11
|
+
import { withMemoryAccess } from "./migration-access.js";
|
|
9
12
|
const OUTPUT_BYTES = 12 * 1024;
|
|
10
13
|
const FILE_BYTES = 32 * 1024 * 1024;
|
|
11
14
|
const READ_LIMIT_MAX = 4_000;
|
|
@@ -27,6 +30,12 @@ const Common = {
|
|
|
27
30
|
ifHash: z.string().regex(/^[a-f0-9]{64}$/u).optional(),
|
|
28
31
|
};
|
|
29
32
|
const CommandSchema = z.discriminatedUnion("command", [
|
|
33
|
+
z.object({
|
|
34
|
+
command: z.literal("search-history"), ...Common,
|
|
35
|
+
query: z.string().trim().min(1).max(256),
|
|
36
|
+
limit: z.coerce.number().int().min(1).max(Number.MAX_SAFE_INTEGER).default(10),
|
|
37
|
+
afterLine: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),
|
|
38
|
+
}).strict(),
|
|
30
39
|
z.object({
|
|
31
40
|
command: z.literal("search"), ...Common,
|
|
32
41
|
query: z.string().trim().min(1).max(256),
|
|
@@ -54,6 +63,7 @@ const CommandSchema = z.discriminatedUnion("command", [
|
|
|
54
63
|
]);
|
|
55
64
|
const USAGE = `只读记忆工具,默认输出 JSON,不修改任何记忆文件。
|
|
56
65
|
crew-memory search --query <关键词> [--file MEMORY.md|notes/文件.md] [--limit 10] [--after-line 0]
|
|
66
|
+
crew-memory search-history --query <关键词> [--limit 10] [--after-line 0] [--if-hash <sha256>]
|
|
57
67
|
crew-memory read [--file MEMORY.md|notes/文件.md] [--offset 0] [--limit 2000]
|
|
58
68
|
crew-memory search-pending --query <关键词> [--cursor <游标>] [--limit 10]
|
|
59
69
|
crew-memory read-pending --id <候选ID> [--offset 0] [--limit 2000] [--if-hash <sha256>]
|
|
@@ -62,7 +72,8 @@ search 关键词按空白分隔,同一行须全部命中;返回完整文件
|
|
|
62
72
|
读取的 offset/limit 按 Unicode 字符计数;用 nextOffset 继续,eof=true 表示结束。
|
|
63
73
|
读取的 --limit 超出范围时,工具会自动降到 2000 并在结果中标记 warning;保留原文件或候选 ID 及 offset,按 nextOffset 分页,不要一次读完整文件。
|
|
64
74
|
后续页可传 --if-hash <前一页的 sha256>,文件变化时停止,避免混读不同版本。
|
|
65
|
-
|
|
75
|
+
schema=2 的 search 查询完整有效目录(active/pinned),按标题、摘要、触发词联合匹配并返回 ID、revision 和正文引用;search-history 显式查询历史/失效记录。预览未展示不代表记录不存在。
|
|
76
|
+
旧格式 search 及 read 读取原文;read 按指定文件和哈希分页,不扫描 pending。
|
|
66
77
|
search-pending 只查因容量延期的 create 候选;有 nextCursor 就继续,即使本页 matches 为空。
|
|
67
78
|
complete=true 才表示全部候选已检索完成且无遗漏;warnings 或非零退出码表示读取未完成,不能当作零命中。
|
|
68
79
|
read-pending 只返回候选正文;offset 非零时必须传 --if-hash,update/supersede 候选不可读取。
|
|
@@ -232,6 +243,18 @@ function writeResult(result, io) {
|
|
|
232
243
|
io.out(output);
|
|
233
244
|
}
|
|
234
245
|
export async function runMemoryReadCommand(argv, io) {
|
|
246
|
+
if (!io.env.CREW_HOME || argv.includes("--help") || argv.includes("-h"))
|
|
247
|
+
return runMemoryReadUnlocked(argv, io);
|
|
248
|
+
try {
|
|
249
|
+
return await withMemoryAccess(io.env.CREW_HOME, () => runMemoryReadUnlocked(argv, io));
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
const reason = error instanceof Error && /^(?:local_)?memory_[a-z_]+$/u.test(error.message) ? error.message : "memory_migration_access_failed";
|
|
253
|
+
io.err(JSON.stringify({ error: reason, message: "记忆读取受到迁移或状态保护,不能当作没有记忆。" }));
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async function runMemoryReadUnlocked(argv, io) {
|
|
235
258
|
let command;
|
|
236
259
|
try {
|
|
237
260
|
const { values, positionals } = parseArgs({
|
|
@@ -289,6 +312,18 @@ export async function runMemoryReadCommand(argv, io) {
|
|
|
289
312
|
const source = await readSource(io.env.CREW_HOME, command.file);
|
|
290
313
|
if (command.ifHash !== undefined && command.ifHash !== source.sha256)
|
|
291
314
|
throw new Error("memory_file_changed");
|
|
315
|
+
const fullIndex = command.file === "MEMORY.md" && detectLocalMemoryStorageVersion(source.text) === 2;
|
|
316
|
+
if ((command.command === "search" || command.command === "search-history") && fullIndex) {
|
|
317
|
+
const normalized = normalizeLimit(command.limit, SEARCH_LIMIT_MAX, SEARCH_LIMIT_SAFE);
|
|
318
|
+
const result = searchFullMemoryIndex(source.text, source.sha256, {
|
|
319
|
+
...command, limit: normalized.limit, history: command.command === "search-history",
|
|
320
|
+
}, OUTPUT_BYTES - 256);
|
|
321
|
+
writeResult({ ...result, ...(normalized.warning ? { warning: normalized.warning,
|
|
322
|
+
requestedLimit: normalized.warning.requestedLimit, limit: normalized.warning.appliedLimit } : {}) }, io);
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
if (command.command === "search-history")
|
|
326
|
+
throw new Error("memory_history_requires_full_index");
|
|
292
327
|
const result = command.command === "search"
|
|
293
328
|
? (() => {
|
|
294
329
|
const normalized = normalizeLimit(command.limit, SEARCH_LIMIT_MAX, SEARCH_LIMIT_SAFE);
|