@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,317 @@
|
|
|
1
|
+
import { digest } from './migration-io.js';
|
|
2
|
+
import { MigrationError, MigrationRecordMappingSchema } from './migration-schema.js';
|
|
3
|
+
import { assertLocalMemoryStorageSize, detectLocalMemoryStorageVersion, LOCAL_MEMORY_GENERATED_INDEX_PATTERN, LOCAL_MEMORY_NOTE_BYTE_CAP, LOCAL_MEMORY_READ_BYTE_CAP, parseFullMemoryIndex, renderFullMemoryIndex, selectLocalMemoryPreview } from './storage-format.js';
|
|
4
|
+
import { memorySeed } from '../workspace.js';
|
|
5
|
+
/** Reproduce the current workspace seed with its original handle and Role before accepting it as pristine. */
|
|
6
|
+
export function initializeExactWorkspaceSeed(bytes) {
|
|
7
|
+
const memory = bytes.toString('utf8');
|
|
8
|
+
const prefix = /^# ([^\r\n]+)\n\n## Role\n/u.exec(memory);
|
|
9
|
+
if (!prefix)
|
|
10
|
+
return null;
|
|
11
|
+
const template = memorySeed(prefix[1]);
|
|
12
|
+
const tail = template.slice(template.indexOf('\n\n## Core Goals\n'));
|
|
13
|
+
if (!memory.endsWith(tail))
|
|
14
|
+
return null;
|
|
15
|
+
const role = memory.slice(prefix[0].length, memory.length - tail.length);
|
|
16
|
+
if (!bytes.equals(Buffer.from(memorySeed(prefix[1], role))))
|
|
17
|
+
return null;
|
|
18
|
+
const heading = '\n## Knowledge Index\n';
|
|
19
|
+
const insertion = memory.length - tail.length + tail.indexOf(heading) + heading.length;
|
|
20
|
+
const initialized = `${memory.slice(0, insertion)}${renderFullMemoryIndex([])}\n${memory.slice(insertion)}`;
|
|
21
|
+
return Buffer.from(initialized
|
|
22
|
+
.replace(/<!-- 使用以下格式:[\s\S]*?-->\n<!-- 示例:[\s\S]*?-->\n/u, "<!-- V2 结构化索引由 daemon 管理。规范字段为 id、file、type、status、summary、triggers、priority、revision、anchor、updated,可选 review_after;不包含 scope。 -->\n")
|
|
23
|
+
.replace("- 每条索引必须保留 file、type、scope、summary、triggers、priority、updated 这些英文协议字段名。", "- V2 结构化索引由 daemon 管理;不要直接编辑索引字段或正文锚点。"));
|
|
24
|
+
}
|
|
25
|
+
const categories = /^(?:preferences|project-rules|testing|debugging|git|incidents|lessons)$/u;
|
|
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
|
+
const controlMarker = /<!--\s*NOWWORK:MEMORY_/u;
|
|
28
|
+
export const isHarmlessMigrationRemainder = (text) => !text.replace(/<!--[\s\S]*?-->/gu, '').replace(/^#{1,2}[^\n]*$/gmu, '').trim();
|
|
29
|
+
function migrationSummary(body, title) {
|
|
30
|
+
const clean = (value) => value.replace(/<!--[\s\S]*?-->/gu, ' ')
|
|
31
|
+
.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, 180);
|
|
32
|
+
return clean(body) || clean(title) || 'Migrated memory';
|
|
33
|
+
}
|
|
34
|
+
const MIGRATION_EPOCH_DATE = '1970-01-01';
|
|
35
|
+
function parseLegacyCardMetadata(card) {
|
|
36
|
+
const fieldMatches = [...card.matchAll(/^- ([a-z_]+):[^\S\r\n]*(.*)$/gmu)];
|
|
37
|
+
const fields = Object.fromEntries(fieldMatches.map(match => [match[1], match[2].trim()]));
|
|
38
|
+
const allowedFields = new Set(['file', 'scope', 'type', 'status', 'summary', 'priority', 'updated', 'triggers', 'review_after', 'id', 'anchor', 'revision']);
|
|
39
|
+
const category = /^notes\/(.+)\.md$/u.exec(fields.file ?? '')?.[1] ?? '';
|
|
40
|
+
const valid = !card.replace(/^- ([a-z_]+):[^\S\r\n]*(.*)$/gmu, '').replace(/^### .+\r?\n/u, '').trim()
|
|
41
|
+
&& fieldMatches.length === Object.keys(fields).length
|
|
42
|
+
&& Object.keys(fields).every(key => allowedFields.has(key))
|
|
43
|
+
&& Boolean(categories.test(category) && fields.summary
|
|
44
|
+
&& ['procedural', 'semantic', 'episodic', 'preference'].includes(fields.type ?? '')
|
|
45
|
+
&& ['candidate', 'active', 'pinned', 'stale', 'superseded', 'archived'].includes(fields.status ?? '')
|
|
46
|
+
&& ['high', 'medium', 'low'].includes(fields.priority ?? '') && fields.updated);
|
|
47
|
+
if (!valid)
|
|
48
|
+
return null;
|
|
49
|
+
return {
|
|
50
|
+
type: fields.type, summary: fields.summary,
|
|
51
|
+
priority: fields.priority, updated: fields.updated,
|
|
52
|
+
triggers: (fields.triggers ?? '').split(/[,,、]/u).map(item => item.trim()).filter(Boolean),
|
|
53
|
+
reviewAfter: fields.review_after ?? null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const migrationDate = () => new Date().toISOString().slice(0, 10);
|
|
57
|
+
export function validateMigrationTarget(files) {
|
|
58
|
+
let totalBytes = 0;
|
|
59
|
+
for (const [path, bytes] of files) {
|
|
60
|
+
totalBytes += bytes.length;
|
|
61
|
+
if (bytes.length > LOCAL_MEMORY_NOTE_BYTE_CAP || totalBytes > LOCAL_MEMORY_READ_BYTE_CAP)
|
|
62
|
+
throw new MigrationError('migration_target_resource_limit', [{ path, reason: 'target_bytes_exceeded' }]);
|
|
63
|
+
}
|
|
64
|
+
const memory = files.get('MEMORY.md')?.toString('utf8');
|
|
65
|
+
if (!memory)
|
|
66
|
+
throw new MigrationError('migration_memory_missing');
|
|
67
|
+
const entries = parseFullMemoryIndex(memory);
|
|
68
|
+
selectLocalMemoryPreview(memory);
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const content = files.get(entry.file)?.toString('utf8') ?? '';
|
|
71
|
+
const blocks = [...content.matchAll(blockPattern)].filter(match => match[1] === entry.id);
|
|
72
|
+
if (blocks.length !== 1 || Number(blocks[0][2]) > entry.revision)
|
|
73
|
+
throw new MigrationError('migration_detail_invalid', [{ path: entry.file, reason: `missing_or_newer_detail:${entry.id}` }]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Pure conversion; every source range is measured in UTF-16 offsets in the exact UTF-8 decoded source. */
|
|
77
|
+
export function planMemoryMigration(identity, files, supplied = []) {
|
|
78
|
+
for (const [path, bytes] of files) {
|
|
79
|
+
if ((path === 'MEMORY.md' || path.startsWith('notes/')) && !Buffer.from(bytes.toString('utf8')).equals(bytes)) {
|
|
80
|
+
throw new MigrationError('migration_source_encoding_invalid', [{ path, reason: 'invalid_utf8' }]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const memory = files.get('MEMORY.md')?.toString('utf8');
|
|
84
|
+
if (memory === undefined)
|
|
85
|
+
throw new MigrationError('migration_memory_missing');
|
|
86
|
+
assertLocalMemoryStorageSize(memory);
|
|
87
|
+
if (detectLocalMemoryStorageVersion(memory) !== identity.sourceStorageVersion)
|
|
88
|
+
throw new MigrationError('migration_source_version_mismatch');
|
|
89
|
+
const confirmedMappings = supplied.map(value => MigrationRecordMappingSchema.parse(value));
|
|
90
|
+
const fallbackUpdated = migrationDate();
|
|
91
|
+
const mappingSourceDigests = new Map();
|
|
92
|
+
for (const mapping of confirmedMappings) {
|
|
93
|
+
if (mapping.sourceSha256 === undefined)
|
|
94
|
+
continue;
|
|
95
|
+
const source = files.get(mapping.sourceFile);
|
|
96
|
+
const actual = source === undefined ? undefined : mappingSourceDigests.get(mapping.sourceFile) ?? digest(source);
|
|
97
|
+
if (actual !== undefined)
|
|
98
|
+
mappingSourceDigests.set(mapping.sourceFile, actual);
|
|
99
|
+
if (actual !== mapping.sourceSha256) {
|
|
100
|
+
throw new MigrationError('migration_source_changed', [{ path: mapping.sourceFile, reason: 'mapping_source_digest_mismatch', start: mapping.start, end: mapping.end }]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const output = new Map(files);
|
|
104
|
+
const issues = [];
|
|
105
|
+
let mappings = [];
|
|
106
|
+
if (identity.sourceStorageVersion === 1) {
|
|
107
|
+
const upgraded = memory.replace(LOCAL_MEMORY_GENERATED_INDEX_PATTERN, block => block.replace(/schema=1(?=\s*-->)/u, 'schema=2'));
|
|
108
|
+
const entries = parseFullMemoryIndex(upgraded);
|
|
109
|
+
const existingIds = new Set(entries.map(entry => entry.id));
|
|
110
|
+
const additions = confirmedMappings.map(mapping => ({ ...mapping, targetId: mapping.targetId
|
|
111
|
+
?? `mem_${digest(`${identity.migrationId}:${mapping.sourceFile}:${mapping.start}:${mapping.end}`).slice(0, 24)}` }));
|
|
112
|
+
const retainedHistory = new Map();
|
|
113
|
+
for (const mapping of additions) {
|
|
114
|
+
const body = files.get(mapping.sourceFile)?.toString('utf8');
|
|
115
|
+
if (!mapping.sourceFile.startsWith('notes/') || body === undefined || mapping.end > body.length)
|
|
116
|
+
issues.push({ path: mapping.sourceFile, reason: 'mapping_range_not_unindexed_notes', start: mapping.start, end: mapping.end });
|
|
117
|
+
if (existingIds.has(mapping.targetId))
|
|
118
|
+
issues.push({ path: mapping.sourceFile, reason: 'mapping_target_id_conflict' });
|
|
119
|
+
existingIds.add(mapping.targetId);
|
|
120
|
+
}
|
|
121
|
+
for (const [path, bytes] of files) {
|
|
122
|
+
if (!path.startsWith('notes/'))
|
|
123
|
+
continue;
|
|
124
|
+
const content = bytes.toString('utf8');
|
|
125
|
+
const blocks = [...content.matchAll(blockPattern)];
|
|
126
|
+
const ranges = additions.filter(mapping => mapping.sourceFile === path);
|
|
127
|
+
for (const block of blocks) {
|
|
128
|
+
const entry = entries.find(entry => entry.file === path && entry.id === block[1]);
|
|
129
|
+
if (!entry) {
|
|
130
|
+
const mapping = ranges.find(value => value.start === block.index && value.end === block.index + block[0].length);
|
|
131
|
+
// schema1 归档/替代会移除索引而保留正文。只接入确认过的完整历史块,不重包或推断其状态。
|
|
132
|
+
if (mapping?.sourceSha256 && mapping.targetId === block[1] && path === `notes/${mapping.category}.md`
|
|
133
|
+
&& mapping.status !== 'active' && mapping.status !== 'pinned' && !controlMarker.test(block[3]))
|
|
134
|
+
retainedHistory.set(mapping.targetId, { revision: Number(block[2]), body: block[3] });
|
|
135
|
+
else
|
|
136
|
+
issues.push({ path, reason: 'unindexed_detail', start: block.index, end: block.index + block[0].length });
|
|
137
|
+
}
|
|
138
|
+
else
|
|
139
|
+
mappings.push({ sourceFile: path, start: block.index, end: block.index + block[0].length, scope: `agent:${identity.agentId}`, category: path.slice(6, -3), targetId: entry.id, title: entry.title, status: entry.status });
|
|
140
|
+
}
|
|
141
|
+
const covered = [...blocks.map(block => ({ start: block.index, end: block.index + block[0].length })),
|
|
142
|
+
...ranges.filter(mapping => !retainedHistory.has(mapping.targetId))].sort((a, b) => a.start - b.start);
|
|
143
|
+
let cursor = 0, remainder = '';
|
|
144
|
+
for (const range of covered) {
|
|
145
|
+
if (range.start < cursor)
|
|
146
|
+
issues.push({ path, reason: 'mapping_overlaps_controlled_or_confirmed_range', start: range.start, end: range.end });
|
|
147
|
+
remainder += content.slice(cursor, range.start);
|
|
148
|
+
cursor = Math.max(cursor, range.end);
|
|
149
|
+
}
|
|
150
|
+
remainder += content.slice(cursor);
|
|
151
|
+
if (!isHarmlessMigrationRemainder(remainder))
|
|
152
|
+
issues.push({ path, reason: 'unindexed_text_requires_mapping' });
|
|
153
|
+
}
|
|
154
|
+
for (const mapping of additions) {
|
|
155
|
+
const body = files.get(mapping.sourceFile)?.toString('utf8').slice(mapping.start, mapping.end) ?? '';
|
|
156
|
+
if (!retainedHistory.has(mapping.targetId) && controlMarker.test(body))
|
|
157
|
+
issues.push({ path: mapping.sourceFile, reason: 'mapping_contains_control_marker', start: mapping.start, end: mapping.end });
|
|
158
|
+
}
|
|
159
|
+
if (issues.length)
|
|
160
|
+
throw new MigrationError('migration_needs_review', issues);
|
|
161
|
+
const appended = new Map();
|
|
162
|
+
const replacements = new Map();
|
|
163
|
+
const newEntries = additions.map(mapping => ({ id: mapping.targetId, title: mapping.title,
|
|
164
|
+
file: `notes/${mapping.category}.md`, type: 'semantic', status: mapping.status,
|
|
165
|
+
summary: migrationSummary(retainedHistory.get(mapping.targetId)?.body ?? files.get(mapping.sourceFile).toString('utf8').slice(mapping.start, mapping.end), mapping.title),
|
|
166
|
+
triggers: [], priority: 'medium', updated: fallbackUpdated, anchor: mapping.targetId, revision: retainedHistory.get(mapping.targetId)?.revision ?? 1, reviewAfter: null }));
|
|
167
|
+
for (const mapping of additions) {
|
|
168
|
+
if (retainedHistory.has(mapping.targetId))
|
|
169
|
+
continue;
|
|
170
|
+
const body = files.get(mapping.sourceFile).toString('utf8').slice(mapping.start, mapping.end);
|
|
171
|
+
const file = `notes/${mapping.category}.md`;
|
|
172
|
+
const block = `<!-- NOWWORK:MEMORY_ENTRY:BEGIN id=${mapping.targetId} revision=1 -->\n## ${mapping.title}\n\nScope: ${mapping.scope}\n\n${body}\n<!-- NOWWORK:MEMORY_ENTRY:END id=${mapping.targetId} -->\n`;
|
|
173
|
+
if (mapping.sourceFile === file)
|
|
174
|
+
replacements.set(file, [...(replacements.get(file) ?? []), { start: mapping.start, end: mapping.end, block }]);
|
|
175
|
+
else
|
|
176
|
+
appended.set(file, [...(appended.get(file) ?? []), block]);
|
|
177
|
+
}
|
|
178
|
+
for (const path of new Set([...replacements.keys(), ...appended.keys()])) {
|
|
179
|
+
let content = files.get(path)?.toString('utf8') ?? '';
|
|
180
|
+
for (const replacement of (replacements.get(path) ?? []).sort((a, b) => b.start - a.start))
|
|
181
|
+
content = content.slice(0, replacement.start) + replacement.block + content.slice(replacement.end);
|
|
182
|
+
const blocks = appended.get(path);
|
|
183
|
+
if (blocks)
|
|
184
|
+
content += '\n' + blocks.join('\n');
|
|
185
|
+
output.set(path, Buffer.from(content));
|
|
186
|
+
}
|
|
187
|
+
mappings.push(...additions);
|
|
188
|
+
const target = upgraded.replace(LOCAL_MEMORY_GENERATED_INDEX_PATTERN, () => renderFullMemoryIndex([...entries, ...newEntries]));
|
|
189
|
+
output.set('MEMORY.md', Buffer.from(target));
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
const knownMetadata = new Map();
|
|
193
|
+
mappings = confirmedMappings;
|
|
194
|
+
if (mappings.length > 0) {
|
|
195
|
+
const heading = /^## Knowledge Index\s*$/mu.exec(memory);
|
|
196
|
+
if (heading?.index !== undefined) {
|
|
197
|
+
const start = heading.index + heading[0].length;
|
|
198
|
+
const tail = memory.slice(start);
|
|
199
|
+
const next = /^## (?!#)/mu.exec(tail);
|
|
200
|
+
const end = next?.index === undefined ? memory.length : start + next.index;
|
|
201
|
+
let uncovered = memory.slice(start, end);
|
|
202
|
+
for (const mapping of mappings.filter(value => value.sourceFile === 'MEMORY.md').sort((a, b) => b.start - a.start)) {
|
|
203
|
+
if (mapping.start < start || mapping.end > end) {
|
|
204
|
+
issues.push({ path: 'MEMORY.md', reason: 'mapping_crosses_identity_or_index_boundary' });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
uncovered = uncovered.slice(0, mapping.start - start) + ' '.repeat(mapping.end - mapping.start) + uncovered.slice(mapping.end - start);
|
|
208
|
+
}
|
|
209
|
+
uncovered = uncovered.replace(/^[-*]\s+\[[^\]]+\]\((notes\/[^)]+)\)\s*$/gmu, (line, path) => mappings.some(value => value.sourceFile === path) ? '' : line);
|
|
210
|
+
if (uncovered.trim())
|
|
211
|
+
issues.push({ path: 'MEMORY.md', reason: 'index_text_requires_mapping', start, end });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Known complete cards may declare their exact scope and unique whole-file body.
|
|
215
|
+
if (mappings.length === 0) {
|
|
216
|
+
const section = /^## Knowledge Index\s*\n([\s\S]*?)(?=^## (?!#)|$(?![\s\S]))/mu.exec(memory);
|
|
217
|
+
const content = section?.[1] ?? '';
|
|
218
|
+
const cards = [...content.matchAll(/^### (.+)\r?\n([\s\S]*?)(?=^### |$(?![\s\S]))/gmu)];
|
|
219
|
+
if (content.slice(0, cards[0]?.index ?? content.length).trim())
|
|
220
|
+
issues.push({ path: 'MEMORY.md', reason: 'index_text_requires_mapping' });
|
|
221
|
+
const seenFiles = new Set();
|
|
222
|
+
for (const card of cards) {
|
|
223
|
+
const fieldMatches = [...card[2].matchAll(/^- ([a-z_]+):[^\S\r\n]*(.*)$/gmu)];
|
|
224
|
+
const fields = Object.fromEntries(fieldMatches.map(match => [match[1], match[2].trim()]));
|
|
225
|
+
const allowedFields = new Set(['file', 'scope', 'type', 'status', 'summary', 'priority', 'updated', 'triggers', 'review_after', 'id', 'anchor', 'revision']);
|
|
226
|
+
if (card[2].replace(/^- ([a-z_]+):[^\S\r\n]*(.*)$/gmu, '').trim() || fieldMatches.length !== Object.keys(fields).length || Object.keys(fields).some(key => !allowedFields.has(key))) {
|
|
227
|
+
issues.push({ path: 'MEMORY.md', reason: 'card_contains_unmapped_text', start: card.index });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const path = fields.file ?? '';
|
|
231
|
+
const category = /^notes\/(.+)\.md$/u.exec(path)?.[1] ?? '';
|
|
232
|
+
const body = files.get(path)?.toString('utf8');
|
|
233
|
+
if (!fields.scope || !categories.test(category) || !body?.trim() || seenFiles.has(path)
|
|
234
|
+
|| !fields.summary || !['procedural', 'semantic', 'episodic', 'preference'].includes(fields.type ?? '') || !fields.status || !['high', 'medium', 'low'].includes(fields.priority ?? '') || !fields.updated) {
|
|
235
|
+
issues.push({ path: 'MEMORY.md', reason: 'card_mapping_unconfirmed', start: card.index });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
seenFiles.add(path);
|
|
239
|
+
const parsed = MigrationRecordMappingSchema.safeParse({ sourceFile: path, start: 0, end: body.length, scope: fields.scope, category, title: card[1], status: fields.status });
|
|
240
|
+
if (!parsed.success)
|
|
241
|
+
issues.push({ path, reason: 'card_metadata_invalid' });
|
|
242
|
+
else {
|
|
243
|
+
mappings.push(parsed.data);
|
|
244
|
+
knownMetadata.set(path, { type: fields.type, summary: fields.summary, priority: fields.priority, updated: fields.updated,
|
|
245
|
+
triggers: (fields.triggers ?? '').split(/[,,、]/u).map(value => value.trim()).filter(Boolean), reviewAfter: fields.review_after ?? null });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (!cards.length)
|
|
249
|
+
issues.push({ path: 'MEMORY.md', reason: 'legacy_source_requires_mapping' });
|
|
250
|
+
}
|
|
251
|
+
const entries = [];
|
|
252
|
+
for (const mapping of mappings) {
|
|
253
|
+
const body = files.get(mapping.sourceFile)?.toString('utf8');
|
|
254
|
+
if (body === undefined || mapping.end > body.length) {
|
|
255
|
+
issues.push({ path: mapping.sourceFile, reason: 'range_out_of_bounds' });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const id = mapping.targetId ?? `mem_${digest(`${identity.migrationId}:${mapping.sourceFile}:${mapping.start}:${mapping.end}`).slice(0, 24)}`;
|
|
259
|
+
const confirmedMetadata = mapping.sourceFile === 'MEMORY.md' ? parseLegacyCardMetadata(body.slice(mapping.start, mapping.end)) : null;
|
|
260
|
+
entries.push({ id, title: mapping.title, file: `notes/${mapping.category}.md`, type: 'semantic', status: mapping.status, summary: migrationSummary(body.slice(mapping.start, mapping.end), mapping.title),
|
|
261
|
+
triggers: [], priority: 'medium', updated: fallbackUpdated, anchor: id, revision: 1, reviewAfter: null,
|
|
262
|
+
...knownMetadata.get(mapping.sourceFile),
|
|
263
|
+
...(confirmedMetadata ? (() => {
|
|
264
|
+
const source = confirmedMetadata;
|
|
265
|
+
return { type: source.type, summary: source.summary, triggers: source.triggers, priority: source.priority,
|
|
266
|
+
updated: source.updated === MIGRATION_EPOCH_DATE ? fallbackUpdated : source.updated, reviewAfter: source.reviewAfter };
|
|
267
|
+
})() : {}) });
|
|
268
|
+
}
|
|
269
|
+
for (const [path, bytes] of files) {
|
|
270
|
+
if (!path.startsWith('notes/') && path !== 'MEMORY.md')
|
|
271
|
+
continue;
|
|
272
|
+
const text = bytes.toString('utf8');
|
|
273
|
+
const ranges = mappings.filter(mapping => mapping.sourceFile === path).sort((a, b) => a.start - b.start);
|
|
274
|
+
if (path === 'MEMORY.md')
|
|
275
|
+
continue; // Retained identity stays byte-for-byte; replaced index coverage is checked above.
|
|
276
|
+
let cursor = 0;
|
|
277
|
+
for (const range of ranges) {
|
|
278
|
+
if (range.start < cursor || text.slice(cursor, range.start).trim())
|
|
279
|
+
issues.push({ path, reason: 'mapping_gap_or_overlap', start: cursor, end: range.start });
|
|
280
|
+
cursor = range.end;
|
|
281
|
+
}
|
|
282
|
+
if (text.slice(cursor).trim())
|
|
283
|
+
issues.push({ path, reason: 'unmapped_source_text', start: cursor, end: text.length });
|
|
284
|
+
}
|
|
285
|
+
if (issues.length)
|
|
286
|
+
throw new MigrationError('migration_needs_review', issues);
|
|
287
|
+
// Retain original files (external references may exist); only same-path fully covered sources are rewritten.
|
|
288
|
+
const targets = new Map();
|
|
289
|
+
mappings.forEach((mapping, index) => {
|
|
290
|
+
const entry = entries[index];
|
|
291
|
+
const body = files.get(mapping.sourceFile).toString('utf8').slice(mapping.start, mapping.end);
|
|
292
|
+
const blocks = targets.get(entry.file) ?? [];
|
|
293
|
+
targets.set(entry.file, [...blocks, `<!-- NOWWORK:MEMORY_ENTRY:BEGIN id=${entry.id} revision=1 -->\n## ${entry.title}\n\nScope: ${mapping.scope}\n\n${body}\n<!-- NOWWORK:MEMORY_ENTRY:END id=${entry.id} -->\n`]);
|
|
294
|
+
});
|
|
295
|
+
for (const [path, blocks] of targets) {
|
|
296
|
+
const existing = files.get(path);
|
|
297
|
+
if (existing && !mappings.some(mapping => mapping.sourceFile === path))
|
|
298
|
+
throw new MigrationError('migration_needs_review', [{ path, reason: 'target_contains_unmapped_text' }]);
|
|
299
|
+
output.set(path, Buffer.from(blocks.join('\n')));
|
|
300
|
+
}
|
|
301
|
+
const index = renderFullMemoryIndex(entries);
|
|
302
|
+
const heading = /^## Knowledge Index\s*$/mu.exec(memory);
|
|
303
|
+
let target;
|
|
304
|
+
if (heading?.index !== undefined) {
|
|
305
|
+
const start = heading.index + heading[0].length;
|
|
306
|
+
const tail = memory.slice(start);
|
|
307
|
+
const next = /^## (?!#)/mu.exec(tail);
|
|
308
|
+
target = memory.slice(0, start) + `\n${index}\n` + (next?.index === undefined ? '' : tail.slice(next.index));
|
|
309
|
+
}
|
|
310
|
+
else
|
|
311
|
+
target = `${memory}\n\n## Knowledge Index\n${index}\n`;
|
|
312
|
+
output.set('MEMORY.md', Buffer.from(target));
|
|
313
|
+
mappings = mappings.map((mapping, index) => ({ ...mapping, targetId: entries[index].id }));
|
|
314
|
+
}
|
|
315
|
+
validateMigrationTarget(output);
|
|
316
|
+
return { files: output, recordMappings: mappings };
|
|
317
|
+
}
|
|
@@ -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,65 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { MigrationError, MigrationIntentReceiptSchema, } from './migration-schema.js';
|
|
3
|
+
export const migrationOperationRoot = (identity) => `.crew/memory/migrations/${identity.migrationId}`;
|
|
4
|
+
function canonical(data) {
|
|
5
|
+
if (Array.isArray(data))
|
|
6
|
+
return data.map(canonical);
|
|
7
|
+
if (data !== null && typeof data === 'object') {
|
|
8
|
+
return Object.fromEntries(Object.entries(data)
|
|
9
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
10
|
+
.map(([key, value]) => [key, canonical(value)]));
|
|
11
|
+
}
|
|
12
|
+
return data;
|
|
13
|
+
}
|
|
14
|
+
export const encodeMigrationData = (data) => Buffer.from(JSON.stringify(canonical(data)));
|
|
15
|
+
export const sameMigrationData = (left, right) => encodeMigrationData(left).equals(encodeMigrationData(right));
|
|
16
|
+
export function migrationPlanDigest(identity, sourceDigest, manifest) {
|
|
17
|
+
return createHash('sha256')
|
|
18
|
+
.update(encodeMigrationData({ identity, sourceDigest, manifest }))
|
|
19
|
+
.digest('hex');
|
|
20
|
+
}
|
|
21
|
+
export function verifyMigrationIntent(receipt, state) {
|
|
22
|
+
const parsed = MigrationIntentReceiptSchema.parse(receipt);
|
|
23
|
+
for (const key of Object.keys(state.identity)) {
|
|
24
|
+
if (parsed[key] !== state.identity[key])
|
|
25
|
+
throw new MigrationError('migration_intent_identity_mismatch');
|
|
26
|
+
}
|
|
27
|
+
if (parsed.sourceDigest !== state.sourceDigest || parsed.planDigest !== state.planDigest) {
|
|
28
|
+
throw new MigrationError('migration_intent_digest_mismatch');
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
/** Pure validation shared by migration recovery and read-only inventory. */
|
|
33
|
+
export function assertMigrationStateIntegrity(state, identity) {
|
|
34
|
+
if (!sameMigrationData(state.identity, identity) || state.migrationId !== identity.migrationId
|
|
35
|
+
|| state.planDigest !== migrationPlanDigest(identity, state.sourceDigest, state.manifest)) {
|
|
36
|
+
throw new MigrationError('migration_identity_or_manifest_invalid');
|
|
37
|
+
}
|
|
38
|
+
// A prepared/applying/rolled-back journal still describes the source store;
|
|
39
|
+
// only a committed journal describes the V2 target. repair_required may
|
|
40
|
+
// represent either side because it can be recorded after a committed target
|
|
41
|
+
// is found damaged. Reject contradictory states before any recovery or
|
|
42
|
+
// pending-only escape hatch can use them.
|
|
43
|
+
const sourceVersion = state.identity.sourceStorageVersion;
|
|
44
|
+
const sourcePhase = state.phase === 'prepared' || state.phase === 'applying' || state.phase === 'rolled_back';
|
|
45
|
+
if (state.origin === 'initialized' && sourceVersion !== 0
|
|
46
|
+
|| sourcePhase && state.storageVersion !== sourceVersion
|
|
47
|
+
|| state.phase === 'committed' && state.storageVersion !== state.targetStorageVersion
|
|
48
|
+
|| state.targetStorageVersion !== identity.targetStorageVersion) {
|
|
49
|
+
throw new MigrationError('migration_identity_or_manifest_invalid');
|
|
50
|
+
}
|
|
51
|
+
const paths = state.manifest.files.map(file => file.path);
|
|
52
|
+
if (new Set(paths).size !== paths.length || !paths.includes('MEMORY.md')) {
|
|
53
|
+
throw new MigrationError('migration_manifest_invalid');
|
|
54
|
+
}
|
|
55
|
+
const operationRoot = `${migrationOperationRoot(identity)}/`;
|
|
56
|
+
for (const file of state.manifest.files) {
|
|
57
|
+
for (const image of [file.before, file.after]) {
|
|
58
|
+
if (image.exists && !image.ref.startsWith(operationRoot)) {
|
|
59
|
+
throw new MigrationError('migration_manifest_invalid');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (state.intentReceipt !== undefined)
|
|
64
|
+
verifyMigrationIntent(state.intentReceipt, state);
|
|
65
|
+
}
|