@nowcrew/daemon 0.6.101-beta.3 → 0.6.101-beta.5

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.
Files changed (64) hide show
  1. package/dist/cloud-report-upload.js +1 -10
  2. package/dist/execution-journal-lock.js +51 -2
  3. package/dist/execution-journal.js +3 -1
  4. package/dist/execution-protocol.js +1 -1
  5. package/dist/execution-runner.js +4 -1
  6. package/dist/host-execution-coordinator.js +10 -0
  7. package/dist/local-execution-prompt.js +8 -3
  8. package/dist/local-executor.js +78 -55
  9. package/dist/local-memory/commit-telemetry.js +30 -0
  10. package/dist/local-memory/execution-workspace.js +117 -0
  11. package/dist/local-memory/full-index-search.js +47 -0
  12. package/dist/local-memory/index-budget.js +9 -3
  13. package/dist/local-memory/initialization-identity.js +36 -0
  14. package/dist/local-memory/inventory-cli.js +52 -8
  15. package/dist/local-memory/inventory-source.js +15 -8
  16. package/dist/local-memory/inventory-v2.js +362 -0
  17. package/dist/local-memory/legacy-adapter.js +233 -0
  18. package/dist/local-memory/migration-access.js +242 -0
  19. package/dist/local-memory/migration-cli.js +54 -0
  20. package/dist/local-memory/migration-io.js +199 -0
  21. package/dist/local-memory/migration-pending.js +9 -0
  22. package/dist/local-memory/migration-plan.js +432 -0
  23. package/dist/local-memory/migration-schema.js +58 -0
  24. package/dist/local-memory/migration-state-invariants.js +65 -0
  25. package/dist/local-memory/migration-store.js +414 -0
  26. package/dist/local-memory/migration-tool-entry.js +21 -0
  27. package/dist/local-memory/pending-query.js +5 -2
  28. package/dist/local-memory/pending-schema.js +1 -1
  29. package/dist/local-memory/pending.js +16 -2
  30. package/dist/local-memory/prompt.js +21 -5
  31. package/dist/local-memory/read-cli.js +98 -76
  32. package/dist/local-memory/release-artifact-guard.js +55 -0
  33. package/dist/local-memory/rollout.js +79 -11
  34. package/dist/local-memory/runtime.js +191 -17
  35. package/dist/local-memory/source-path.js +13 -0
  36. package/dist/local-memory/storage-format.js +249 -0
  37. package/dist/local-memory/write.js +190 -21
  38. package/dist/local-memory/writer-mode.js +13 -0
  39. package/dist/local-memory-diagnostics.js +96 -15
  40. package/dist/local-memory-telemetry.js +31 -3
  41. package/dist/machine-info.js +6 -1
  42. package/dist/machine-runtime-environment.js +1 -0
  43. package/dist/marketplace-protocol.js +1 -1
  44. package/dist/marketplace-remote.js +1 -1
  45. package/dist/marketplace-runner.js +1 -1
  46. package/dist/memory-migration-controller.js +89 -0
  47. package/dist/memory-migration-inspect.js +116 -0
  48. package/dist/memory-migration-journal.js +14 -0
  49. package/dist/memory-migration-protocol.js +14 -0
  50. package/dist/memory-migration-runtime.js +367 -0
  51. package/dist/memory-prune-diagnostics.js +74 -4
  52. package/dist/message-file-preview.js +11 -1
  53. package/dist/prompt.js +9 -17
  54. package/dist/runner.js +1 -1
  55. package/dist/runtime-tool-update-bridge.js +126 -0
  56. package/dist/runtime-tool-update-controller.js +151 -0
  57. package/dist/runtime-tool-update-protocol.js +24 -0
  58. package/dist/runtime-tool-update-serve.js +54 -0
  59. package/dist/runtimes/claude-thread-runner.js +25 -4
  60. package/dist/serve.js +47 -3
  61. package/dist/token.js +1 -0
  62. package/dist/workspace-control-handler.js +16 -3
  63. package/dist/workspace.js +23 -4
  64. package/package.json +1 -1
@@ -0,0 +1,199 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, opendir, realpath, rename, rm } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+ import { MigrationError, MigrationIdentitySchema } from './migration-schema.js';
6
+ import { LOCAL_MEMORY_NOTE_BYTE_CAP, LOCAL_MEMORY_READ_BYTE_CAP, LOCAL_MEMORY_STORAGE_BYTE_CAP } from './storage-format.js';
7
+ export const digest = (data) => createHash('sha256').update(data).digest('hex');
8
+ export const STATE_PATH = '.crew/memory/migration.v1.json';
9
+ export async function validateMigrationRoot(agentDir, raw) {
10
+ const identity = MigrationIdentitySchema.parse(raw);
11
+ if (!isAbsolute(agentDir) || resolve(agentDir) !== agentDir)
12
+ throw new MigrationError('migration_root_invalid');
13
+ // macOS /var -> /private/var is a trusted system alias; the agent root itself may not be a symlink.
14
+ const stat = await lstat(agentDir);
15
+ if (!stat.isDirectory() || stat.isSymbolicLink() || String(stat.uid) !== identity.ownerIdentity)
16
+ throw new MigrationError('migration_owner_invalid');
17
+ if (digest(await realpath(agentDir)) !== identity.rootFingerprint)
18
+ throw new MigrationError('migration_root_fingerprint_invalid');
19
+ return identity;
20
+ }
21
+ export async function safePath(root, path, createParents = false) {
22
+ const target = resolve(root, path);
23
+ const rel = relative(root, target);
24
+ if (!rel || rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel))
25
+ throw new MigrationError('migration_path_invalid', [{ path, reason: 'outside_root' }]);
26
+ const parts = rel.split(sep);
27
+ let current = root;
28
+ for (let i = 0; i < parts.length; i++) {
29
+ current = join(current, parts[i]);
30
+ let stat = await lstat(current).catch((error) => {
31
+ if (error.code === 'ENOENT')
32
+ return null;
33
+ throw error;
34
+ });
35
+ if (!stat && createParents && i < parts.length - 1) {
36
+ await mkdir(current, { mode: 0o700 });
37
+ stat = await lstat(current);
38
+ }
39
+ if (stat?.isSymbolicLink() || (stat && i < parts.length - 1 && !stat.isDirectory()))
40
+ throw new MigrationError('migration_path_unsafe', [{ path, reason: 'symlink_or_non_directory' }]);
41
+ }
42
+ return target;
43
+ }
44
+ export async function readBytes(root, path, cap = LOCAL_MEMORY_NOTE_BYTE_CAP) {
45
+ const target = await safePath(root, path);
46
+ const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW).catch((error) => {
47
+ if (error.code === 'ENOENT')
48
+ return null;
49
+ throw error;
50
+ });
51
+ if (!file)
52
+ return null;
53
+ try {
54
+ const stat = await file.stat();
55
+ if (!stat.isFile() || stat.size > cap)
56
+ throw new MigrationError('migration_read_limit', [{ path, reason: 'invalid_file_or_size' }]);
57
+ const data = Buffer.alloc(stat.size + 1);
58
+ const { bytesRead } = await file.read(data, 0, data.length, 0);
59
+ if (bytesRead !== stat.size || (await file.stat()).size !== stat.size)
60
+ throw new MigrationError('migration_source_changed', [{ path, reason: 'changed_during_read' }]);
61
+ return data.subarray(0, bytesRead);
62
+ }
63
+ finally {
64
+ await file.close();
65
+ }
66
+ }
67
+ export async function atomicBytes(root, path, data, options = {}) {
68
+ const target = await safePath(root, path, true);
69
+ const temporaryPath = options.temporaryPath ?? `${path}.tmp-${randomUUID()}`;
70
+ const temp = await safePath(root, temporaryPath, true);
71
+ if ((await lstat(dirname(target))).dev !== (await lstat(dirname(temp))).dev) {
72
+ throw new MigrationError('migration_publication_filesystem_mismatch');
73
+ }
74
+ const existing = options.temporaryPath ? await readBytes(root, temporaryPath) : null;
75
+ if (existing && digest(existing) !== digest(data)) {
76
+ throw new MigrationError('migration_publication_copy_unknown', [{ path: temporaryPath, reason: 'publication_hash_mismatch' }]);
77
+ }
78
+ if (!existing) {
79
+ // The plan-recognized name is only published after every byte is complete and synced.
80
+ // An interrupted unpublished file is never a recovery source and is preserved as evidence.
81
+ const writePath = options.temporaryPath
82
+ ? `${dirname(dirname(temporaryPath))}/unpublished/${randomUUID()}.tmp`
83
+ : temporaryPath;
84
+ const unpublished = await safePath(root, writePath, true);
85
+ if ((await lstat(dirname(unpublished))).dev !== (await lstat(dirname(temp))).dev) {
86
+ throw new MigrationError('migration_publication_filesystem_mismatch');
87
+ }
88
+ const file = await open(unpublished, 'wx', 0o600);
89
+ try {
90
+ if (options.temporaryPath) {
91
+ for (let offset = 0; offset < data.length; offset += 64 * 1024) {
92
+ const end = Math.min(offset + 64 * 1024, data.length);
93
+ await file.writeFile(data.subarray(offset, end));
94
+ await options.afterWriteChunk?.(writePath, end, data.length);
95
+ }
96
+ }
97
+ else
98
+ await file.writeFile(data);
99
+ await file.sync();
100
+ }
101
+ finally {
102
+ await file.close();
103
+ }
104
+ if (options.temporaryPath) {
105
+ const complete = await readBytes(root, writePath);
106
+ if (!complete || digest(complete) !== digest(data))
107
+ throw new MigrationError('migration_unpublished_copy_invalid');
108
+ const appeared = await readBytes(root, temporaryPath);
109
+ if (appeared && digest(appeared) !== digest(data))
110
+ throw new MigrationError('migration_publication_copy_unknown');
111
+ if (!appeared) {
112
+ await safePath(root, temporaryPath);
113
+ await rename(unpublished, temp);
114
+ await syncDirectory(dirname(temp));
115
+ await syncDirectory(dirname(unpublished));
116
+ }
117
+ }
118
+ }
119
+ try {
120
+ await options.beforeRename?.();
121
+ await safePath(root, path);
122
+ if (options.temporaryPath) {
123
+ const verified = await readBytes(root, temporaryPath);
124
+ if (!verified || digest(verified) !== digest(data))
125
+ throw new MigrationError('migration_publication_copy_unknown');
126
+ }
127
+ await rename(temp, target);
128
+ await syncDirectory(dirname(target));
129
+ }
130
+ finally {
131
+ // Controlled copies survive interruption; recovery verifies their plan-bound name and content before reuse.
132
+ if (!options.temporaryPath)
133
+ await rm(temp, { force: true });
134
+ }
135
+ }
136
+ export async function syncDirectory(path) {
137
+ const fd = await open(path, 'r');
138
+ try {
139
+ await fd.sync();
140
+ }
141
+ finally {
142
+ await fd.close();
143
+ }
144
+ }
145
+ export async function inventory(root) {
146
+ const files = new Map();
147
+ const directories = [];
148
+ let bytes = 0;
149
+ let fileCount = 0;
150
+ let directoryCount = 0;
151
+ function reserveFile() {
152
+ if (++fileCount > 20000)
153
+ throw new MigrationError('migration_file_limit');
154
+ }
155
+ function reserveDirectory() {
156
+ if (++directoryCount > 10000)
157
+ throw new MigrationError('migration_directory_limit');
158
+ }
159
+ async function scan(path, reserved = false) {
160
+ const target = await safePath(root, path);
161
+ const stat = await lstat(target).catch((error) => {
162
+ if (error.code === 'ENOENT')
163
+ return null;
164
+ throw error;
165
+ });
166
+ if (!stat)
167
+ return;
168
+ if (stat.isDirectory()) {
169
+ if (!reserved)
170
+ reserveDirectory();
171
+ directories.push(path);
172
+ const children = [];
173
+ for await (const entry of await opendir(target)) {
174
+ if (entry.isDirectory())
175
+ reserveDirectory();
176
+ else
177
+ reserveFile();
178
+ children.push(entry.name);
179
+ }
180
+ for (const name of children.sort())
181
+ await scan(`${path}/${name}`, true);
182
+ }
183
+ else {
184
+ if (!reserved)
185
+ reserveFile();
186
+ const content = await readBytes(root, path, path === 'MEMORY.md' ? LOCAL_MEMORY_STORAGE_BYTE_CAP : LOCAL_MEMORY_NOTE_BYTE_CAP);
187
+ if (content) {
188
+ bytes += content.length;
189
+ if (bytes > LOCAL_MEMORY_READ_BYTE_CAP)
190
+ throw new MigrationError('migration_read_limit');
191
+ files.set(path, content);
192
+ }
193
+ }
194
+ }
195
+ await scan('MEMORY.md');
196
+ await scan('notes');
197
+ await scan('.crew/memory/pending');
198
+ return { files, directories: directories.sort() };
199
+ }
@@ -0,0 +1,9 @@
1
+ import { detectLocalMemoryStorageVersion } from "./storage-format.js";
2
+ export function shouldUseMigrationDeferredPending(input) {
3
+ return input.pruneTraceId !== null
4
+ && input.writerMode === "blocked"
5
+ && input.rolloutEnabled
6
+ && input.legacyFallback !== true
7
+ && input.failure !== "inspection_failed"
8
+ && detectLocalMemoryStorageVersion(input.memory) === 1;
9
+ }
@@ -0,0 +1,432 @@
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
+ import { isDeferredMemoryArtifactPath } from './source-path.js';
6
+ /** Reproduce the current workspace seed with its original handle and Role before accepting it as pristine. */
7
+ export function initializeExactWorkspaceSeed(bytes) {
8
+ const memory = bytes.toString('utf8');
9
+ const prefix = /^# ([^\r\n]+)\n\n## Role\n/u.exec(memory);
10
+ if (!prefix)
11
+ return null;
12
+ const template = memorySeed(prefix[1]);
13
+ const tail = template.slice(template.indexOf('\n\n## Core Goals\n'));
14
+ if (!memory.endsWith(tail))
15
+ return null;
16
+ const role = memory.slice(prefix[0].length, memory.length - tail.length);
17
+ if (!bytes.equals(Buffer.from(memorySeed(prefix[1], role))))
18
+ return null;
19
+ const heading = '\n## Knowledge Index\n';
20
+ const insertion = memory.length - tail.length + tail.indexOf(heading) + heading.length;
21
+ const initialized = `${memory.slice(0, insertion)}${renderFullMemoryIndex([])}\n${memory.slice(insertion)}`;
22
+ return Buffer.from(initialized
23
+ .replace(/<!-- 使用以下格式:[\s\S]*?-->\n<!-- 示例:[\s\S]*?-->\n/u, "<!-- V2 结构化索引由 daemon 管理。规范字段为 id、file、type、status、summary、triggers、priority、revision、anchor、updated,可选 review_after;不包含 scope。 -->\n")
24
+ .replace("- 每条索引必须保留 file、type、scope、summary、triggers、priority、updated 这些英文协议字段名。", "- V2 结构化索引由 daemon 管理;不要直接编辑索引字段或正文锚点。"));
25
+ }
26
+ const categories = /^(?:preferences|project-rules|testing|debugging|git|incidents|lessons)$/u;
27
+ 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;
28
+ const controlMarker = /<!--\s*NOWWORK:MEMORY_/u;
29
+ const legacyCardPattern = /^### (.+)\r?\n([\s\S]*?)(?=^### |^维护规则:|$(?![\s\S]))/gmu;
30
+ function legacySeedMaintenanceRules() {
31
+ const seed = memorySeed('__memory-migration__');
32
+ return seed.slice(seed.indexOf('\n维护规则:'));
33
+ }
34
+ function matchLegacyCards(content) {
35
+ const masked = content.replace(/<!--[\s\S]*?-->/gu, value => value.replace(/[^\r\n]/gu, ' '));
36
+ return [...masked.matchAll(legacyCardPattern)].map(card => {
37
+ const index = card.index;
38
+ const matchedText = content.slice(index, index + card[0].length);
39
+ const commentIndex = matchedText.indexOf('<!--');
40
+ const text = commentIndex === -1 ? matchedText : matchedText.slice(0, commentIndex);
41
+ return { title: card[1], text, fields: text.slice(text.indexOf('\n') + 1), index };
42
+ });
43
+ }
44
+ export const isHarmlessMigrationRemainder = (text) => !text.replace(/<!--[\s\S]*?-->/gu, '').replace(/^#{1,2}[^\n]*$/gmu, '').trim();
45
+ function isHarmlessLegacyIndexRemainder(text) {
46
+ const normalized = text.replace(/\r\n/gu, '\n');
47
+ // Covered card ranges can consume the newline before the exact seed rules.
48
+ const seedRules = legacySeedMaintenanceRules().trimStart();
49
+ const withoutSeedRules = normalized.includes(seedRules)
50
+ ? normalized.replace(seedRules, '')
51
+ : normalized;
52
+ return isHarmlessMigrationRemainder(withoutSeedRules);
53
+ }
54
+ function migrationSummary(body, title) {
55
+ const clean = (value) => value.replace(/<!--[\s\S]*?-->/gu, ' ')
56
+ .replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, 180);
57
+ return clean(body) || clean(title) || 'Migrated memory';
58
+ }
59
+ const MIGRATION_EPOCH_DATE = '1970-01-01';
60
+ function parseLegacyCardMetadata(card) {
61
+ const fieldMatches = [...card.matchAll(/^- ([a-z_]+):[^\S\r\n]*(.*)$/gmu)];
62
+ const fields = Object.fromEntries(fieldMatches.map(match => [match[1], match[2].trim()]));
63
+ const allowedFields = new Set(['file', 'scope', 'type', 'status', 'summary', 'priority', 'updated', 'triggers', 'review_after', 'id', 'anchor', 'revision']);
64
+ const sourceFile = /^notes\/[^\r\n]+$/u.test(fields.file ?? '');
65
+ const valid = !card.replace(/^- ([a-z_]+):[^\S\r\n]*(.*)$/gmu, '').replace(/^### .+\r?\n/u, '').trim()
66
+ && fieldMatches.length === Object.keys(fields).length
67
+ && Object.keys(fields).every(key => allowedFields.has(key))
68
+ && Boolean(sourceFile && fields.summary
69
+ && ['procedural', 'semantic', 'episodic', 'preference'].includes(fields.type ?? '')
70
+ // Older templates omit status; reviewed mappings still determine the target status.
71
+ && (fields.status === undefined || ['candidate', 'active', 'pinned', 'stale', 'superseded', 'archived'].includes(fields.status))
72
+ && ['high', 'medium', 'low'].includes(fields.priority ?? '') && fields.updated);
73
+ if (!valid)
74
+ return null;
75
+ return {
76
+ type: fields.type, summary: fields.summary,
77
+ priority: fields.priority, updated: fields.updated,
78
+ triggers: (fields.triggers ?? '').split(/[,,、]/u).map(item => item.trim()).filter(Boolean),
79
+ reviewAfter: fields.review_after ?? null,
80
+ };
81
+ }
82
+ /**
83
+ * Read metadata from complete legacy cards even when the caller supplies an
84
+ * explicit notes mapping. A reviewed mapping can select a slice of a notes
85
+ * file, but the legacy card is still the only trustworthy source for its
86
+ * type/summary/priority/date/triggers. Multiple cards pointing at one source
87
+ * file are matched by reviewed title/range pairs; duplicate or unmatched
88
+ * pairs remain fail-closed in the planner.
89
+ */
90
+ function collectLegacyCardMetadata(memory) {
91
+ const section = /^## Knowledge Index\s*\n([\s\S]*?)(?=^## (?!#)|$(?![\s\S]))/mu.exec(memory);
92
+ const content = section?.[1] ?? '';
93
+ const bySourceFile = new Map();
94
+ const bySourceAndTitle = new Map();
95
+ const ambiguousKeys = new Set();
96
+ const recordsBySourceFile = new Map();
97
+ const records = [];
98
+ const contentStart = section?.index === undefined ? 0 : section.index + section[0].length - content.length;
99
+ const cards = matchLegacyCards(content);
100
+ for (const card of cards) {
101
+ const parsed = parseLegacyCardMetadata(card.text);
102
+ if (!parsed)
103
+ continue;
104
+ const file = /^- file:[^\S\r\n]*(notes\/[^\r\n]+)$/mu.exec(card.text)?.[1];
105
+ if (!file)
106
+ continue;
107
+ const record = { sourceFile: file, title: card.title, start: contentStart + card.index, end: contentStart + card.index + card.text.length, metadata: parsed };
108
+ records.push(record);
109
+ const sourceRecords = recordsBySourceFile.get(file) ?? [];
110
+ recordsBySourceFile.set(file, [...sourceRecords, record]);
111
+ const key = `${file}\u0000${record.title}`;
112
+ if (ambiguousKeys.has(key))
113
+ continue;
114
+ if (!bySourceAndTitle.has(key))
115
+ bySourceAndTitle.set(key, parsed);
116
+ else {
117
+ bySourceAndTitle.delete(key);
118
+ ambiguousKeys.add(key);
119
+ }
120
+ }
121
+ for (const [file, sourceRecords] of recordsBySourceFile) {
122
+ if (sourceRecords.length === 1)
123
+ bySourceFile.set(file, sourceRecords[0].metadata);
124
+ }
125
+ return { bySourceFile, bySourceAndTitle, records };
126
+ }
127
+ const migrationDate = () => new Date().toISOString().slice(0, 10);
128
+ export function validateMigrationTarget(files) {
129
+ let totalBytes = 0;
130
+ for (const [path, bytes] of files) {
131
+ totalBytes += bytes.length;
132
+ if (bytes.length > LOCAL_MEMORY_NOTE_BYTE_CAP || totalBytes > LOCAL_MEMORY_READ_BYTE_CAP)
133
+ throw new MigrationError('migration_target_resource_limit', [{ path, reason: 'target_bytes_exceeded' }]);
134
+ }
135
+ const memory = files.get('MEMORY.md')?.toString('utf8');
136
+ if (!memory)
137
+ throw new MigrationError('migration_memory_missing');
138
+ const entries = parseFullMemoryIndex(memory);
139
+ selectLocalMemoryPreview(memory);
140
+ for (const entry of entries) {
141
+ const content = files.get(entry.file)?.toString('utf8') ?? '';
142
+ const blocks = [...content.matchAll(blockPattern)].filter(match => match[1] === entry.id);
143
+ if (blocks.length !== 1 || Number(blocks[0][2]) > entry.revision)
144
+ throw new MigrationError('migration_detail_invalid', [{ path: entry.file, reason: `missing_or_newer_detail:${entry.id}` }]);
145
+ }
146
+ }
147
+ /** Pure conversion; every source range is measured in UTF-16 offsets in the exact UTF-8 decoded source. */
148
+ export function planMemoryMigration(identity, files, supplied = []) {
149
+ for (const [path, bytes] of files) {
150
+ if ((path === 'MEMORY.md' || path.startsWith('notes/')) && !Buffer.from(bytes.toString('utf8')).equals(bytes)) {
151
+ throw new MigrationError('migration_source_encoding_invalid', [{ path, reason: 'invalid_utf8' }]);
152
+ }
153
+ }
154
+ const memory = files.get('MEMORY.md')?.toString('utf8');
155
+ if (memory === undefined)
156
+ throw new MigrationError('migration_memory_missing');
157
+ assertLocalMemoryStorageSize(memory);
158
+ if (detectLocalMemoryStorageVersion(memory) !== identity.sourceStorageVersion)
159
+ throw new MigrationError('migration_source_version_mismatch');
160
+ const confirmedMappings = supplied.map(value => MigrationRecordMappingSchema.parse(value));
161
+ const fallbackUpdated = migrationDate();
162
+ const mappingSourceDigests = new Map();
163
+ for (const mapping of confirmedMappings) {
164
+ if (mapping.sourceSha256 === undefined)
165
+ continue;
166
+ const source = files.get(mapping.sourceFile);
167
+ const actual = source === undefined ? undefined : mappingSourceDigests.get(mapping.sourceFile) ?? digest(source);
168
+ if (actual !== undefined)
169
+ mappingSourceDigests.set(mapping.sourceFile, actual);
170
+ if (actual !== mapping.sourceSha256) {
171
+ throw new MigrationError('migration_source_changed', [{ path: mapping.sourceFile, reason: 'mapping_source_digest_mismatch', start: mapping.start, end: mapping.end }]);
172
+ }
173
+ }
174
+ const output = new Map(files);
175
+ const issues = [];
176
+ let mappings = [];
177
+ if (identity.sourceStorageVersion === 1) {
178
+ const upgraded = memory.replace(LOCAL_MEMORY_GENERATED_INDEX_PATTERN, block => block.replace(/schema=1(?=\s*-->)/u, 'schema=2'));
179
+ const entries = parseFullMemoryIndex(upgraded);
180
+ const existingIds = new Set(entries.map(entry => entry.id));
181
+ const additions = confirmedMappings.map(mapping => ({ ...mapping, targetId: mapping.targetId
182
+ ?? `mem_${digest(`${identity.migrationId}:${mapping.sourceFile}:${mapping.start}:${mapping.end}`).slice(0, 24)}` }));
183
+ const retainedHistory = new Map();
184
+ for (const mapping of additions) {
185
+ const body = files.get(mapping.sourceFile)?.toString('utf8');
186
+ if (!mapping.sourceFile.startsWith('notes/') || body === undefined || mapping.end > body.length)
187
+ issues.push({ path: mapping.sourceFile, reason: 'mapping_range_not_unindexed_notes', start: mapping.start, end: mapping.end });
188
+ if (existingIds.has(mapping.targetId))
189
+ issues.push({ path: mapping.sourceFile, reason: 'mapping_target_id_conflict' });
190
+ existingIds.add(mapping.targetId);
191
+ }
192
+ for (const [path, bytes] of files) {
193
+ if (!path.startsWith('notes/') || isDeferredMemoryArtifactPath(path))
194
+ continue;
195
+ const content = bytes.toString('utf8');
196
+ const blocks = [...content.matchAll(blockPattern)];
197
+ const ranges = additions.filter(mapping => mapping.sourceFile === path);
198
+ for (const block of blocks) {
199
+ const entry = entries.find(entry => entry.file === path && entry.id === block[1]);
200
+ if (!entry) {
201
+ const mapping = ranges.find(value => value.start === block.index && value.end === block.index + block[0].length);
202
+ // schema1 归档/替代会移除索引而保留正文。只接入确认过的完整历史块,不重包或推断其状态。
203
+ if (mapping?.sourceSha256 && mapping.targetId === block[1] && path === `notes/${mapping.category}.md`
204
+ && mapping.status !== 'active' && mapping.status !== 'pinned' && !controlMarker.test(block[3]))
205
+ retainedHistory.set(mapping.targetId, { revision: Number(block[2]), body: block[3] });
206
+ else
207
+ issues.push({ path, reason: 'unindexed_detail', start: block.index, end: block.index + block[0].length });
208
+ }
209
+ else
210
+ 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 });
211
+ }
212
+ const covered = [...blocks.map(block => ({ start: block.index, end: block.index + block[0].length })),
213
+ ...ranges.filter(mapping => !retainedHistory.has(mapping.targetId))].sort((a, b) => a.start - b.start);
214
+ let cursor = 0, remainder = '';
215
+ for (const range of covered) {
216
+ if (range.start < cursor)
217
+ issues.push({ path, reason: 'mapping_overlaps_controlled_or_confirmed_range', start: range.start, end: range.end });
218
+ remainder += content.slice(cursor, range.start);
219
+ cursor = Math.max(cursor, range.end);
220
+ }
221
+ remainder += content.slice(cursor);
222
+ if (!isHarmlessMigrationRemainder(remainder))
223
+ issues.push({ path, reason: 'unindexed_text_requires_mapping' });
224
+ }
225
+ for (const mapping of additions) {
226
+ const body = files.get(mapping.sourceFile)?.toString('utf8').slice(mapping.start, mapping.end) ?? '';
227
+ if (!retainedHistory.has(mapping.targetId) && controlMarker.test(body))
228
+ issues.push({ path: mapping.sourceFile, reason: 'mapping_contains_control_marker', start: mapping.start, end: mapping.end });
229
+ }
230
+ if (issues.length)
231
+ throw new MigrationError('migration_needs_review', issues);
232
+ const appended = new Map();
233
+ const replacements = new Map();
234
+ const newEntries = additions.map(mapping => ({ id: mapping.targetId, title: mapping.title,
235
+ file: `notes/${mapping.category}.md`, type: 'semantic', status: mapping.status,
236
+ summary: migrationSummary(retainedHistory.get(mapping.targetId)?.body ?? files.get(mapping.sourceFile).toString('utf8').slice(mapping.start, mapping.end), mapping.title),
237
+ triggers: [], priority: 'medium', updated: fallbackUpdated, anchor: mapping.targetId, revision: retainedHistory.get(mapping.targetId)?.revision ?? 1, reviewAfter: null }));
238
+ for (const mapping of additions) {
239
+ if (retainedHistory.has(mapping.targetId))
240
+ continue;
241
+ const body = files.get(mapping.sourceFile).toString('utf8').slice(mapping.start, mapping.end);
242
+ const file = `notes/${mapping.category}.md`;
243
+ 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`;
244
+ if (mapping.sourceFile === file)
245
+ replacements.set(file, [...(replacements.get(file) ?? []), { start: mapping.start, end: mapping.end, block }]);
246
+ else
247
+ appended.set(file, [...(appended.get(file) ?? []), block]);
248
+ }
249
+ for (const path of new Set([...replacements.keys(), ...appended.keys()])) {
250
+ let content = files.get(path)?.toString('utf8') ?? '';
251
+ for (const replacement of (replacements.get(path) ?? []).sort((a, b) => b.start - a.start))
252
+ content = content.slice(0, replacement.start) + replacement.block + content.slice(replacement.end);
253
+ const blocks = appended.get(path);
254
+ if (blocks)
255
+ content += '\n' + blocks.join('\n');
256
+ output.set(path, Buffer.from(content));
257
+ }
258
+ mappings.push(...additions);
259
+ const target = upgraded.replace(LOCAL_MEMORY_GENERATED_INDEX_PATTERN, () => renderFullMemoryIndex([...entries, ...newEntries]));
260
+ output.set('MEMORY.md', Buffer.from(target));
261
+ }
262
+ else {
263
+ const legacyCardMetadata = collectLegacyCardMetadata(memory);
264
+ const knownMetadata = new Map(legacyCardMetadata.bySourceFile);
265
+ for (const [key, value] of legacyCardMetadata.bySourceAndTitle)
266
+ knownMetadata.set(key, value);
267
+ mappings = confirmedMappings;
268
+ if (mappings.length > 0) {
269
+ const mappingsBySourceFile = new Map();
270
+ for (const mapping of mappings) {
271
+ const sourceMappings = mappingsBySourceFile.get(mapping.sourceFile) ?? [];
272
+ mappingsBySourceFile.set(mapping.sourceFile, [...sourceMappings, mapping]);
273
+ }
274
+ for (const [path, sourceMappings] of mappingsBySourceFile) {
275
+ const ordered = sourceMappings.sort((left, right) => left.start - right.start || left.end - right.end);
276
+ for (let index = 1; index < ordered.length; index += 1) {
277
+ const previous = ordered[index - 1];
278
+ const current = ordered[index];
279
+ if (current.start < previous.end)
280
+ issues.push({ path, reason: 'mapping_overlaps_controlled_or_confirmed_range', start: current.start, end: current.end });
281
+ }
282
+ }
283
+ const heading = /^## Knowledge Index\s*$/mu.exec(memory);
284
+ if (heading?.index !== undefined) {
285
+ const start = heading.index + heading[0].length;
286
+ const tail = memory.slice(start);
287
+ const next = /^## (?!#)/mu.exec(tail);
288
+ const end = next?.index === undefined ? memory.length : start + next.index;
289
+ let uncovered = memory.slice(start, end);
290
+ for (const mapping of mappings.filter(value => value.sourceFile === 'MEMORY.md').sort((a, b) => b.start - a.start)) {
291
+ if (mapping.start < start || mapping.end > end) {
292
+ issues.push({ path: 'MEMORY.md', reason: 'mapping_crosses_identity_or_index_boundary' });
293
+ continue;
294
+ }
295
+ uncovered = uncovered.slice(0, mapping.start - start) + ' '.repeat(mapping.end - mapping.start) + uncovered.slice(mapping.end - start);
296
+ }
297
+ uncovered = uncovered.replace(/^[-*]\s+\[[^\]]+\]\((notes\/[^)]+)\)\s*$/gmu, (line, path) => mappings.some(value => value.sourceFile === path) ? '' : line);
298
+ const recordsBySourceFile = new Map();
299
+ for (const record of legacyCardMetadata.records) {
300
+ const sourceRecords = recordsBySourceFile.get(record.sourceFile) ?? [];
301
+ recordsBySourceFile.set(record.sourceFile, [...sourceRecords, record]);
302
+ }
303
+ for (const [sourceFile, records] of recordsBySourceFile) {
304
+ const sourceMappings = mappings.filter(mapping => mapping.sourceFile === sourceFile);
305
+ const source = files.get(sourceFile);
306
+ const mappingTitles = new Set(sourceMappings.map(mapping => mapping.title));
307
+ const completeMappingSet = sourceMappings.length === records.length
308
+ && mappingTitles.size === sourceMappings.length
309
+ && sourceMappings.every(mapping => records.some(record => record.title === mapping.title));
310
+ for (const record of records) {
311
+ const matchingMappings = sourceMappings.filter(mapping => mapping.title === record.title);
312
+ const mapping = matchingMappings.length === 1 ? matchingMappings[0] : undefined;
313
+ const rangedUniqueCard = records.length === 1 && sourceMappings.length === 1 && source !== undefined
314
+ && mapping !== undefined && mapping.start >= 0 && mapping.end <= source.toString('utf8').length;
315
+ const rangedMultiCard = records.length > 1 && completeMappingSet && source !== undefined
316
+ && mapping !== undefined && mapping.start >= 0 && mapping.end <= source.toString('utf8').length;
317
+ if (mapping === undefined || (!rangedUniqueCard && !rangedMultiCard)) {
318
+ if (sourceMappings.length > 0)
319
+ issues.push({ path: 'MEMORY.md', reason: 'card_mapping_unconfirmed', start: record.start, end: record.end });
320
+ continue;
321
+ }
322
+ uncovered = uncovered.slice(0, record.start - start) + ' '.repeat(record.end - record.start) + uncovered.slice(record.end - start);
323
+ }
324
+ }
325
+ if (!isHarmlessLegacyIndexRemainder(uncovered))
326
+ issues.push({ path: 'MEMORY.md', reason: 'index_text_requires_mapping', start, end });
327
+ }
328
+ }
329
+ // Known complete cards may declare their exact scope and unique whole-file body.
330
+ if (mappings.length === 0) {
331
+ const section = /^## Knowledge Index\s*\n([\s\S]*?)(?=^## (?!#)|$(?![\s\S]))/mu.exec(memory);
332
+ const content = section?.[1] ?? '';
333
+ const cards = matchLegacyCards(content);
334
+ if (!isHarmlessLegacyIndexRemainder(content.slice(0, cards[0]?.index ?? content.length)))
335
+ issues.push({ path: 'MEMORY.md', reason: 'index_text_requires_mapping' });
336
+ const seenFiles = new Set();
337
+ for (const card of cards) {
338
+ const fieldMatches = [...card.fields.matchAll(/^- ([a-z_]+):[^\S\r\n]*(.*)$/gmu)];
339
+ const fields = Object.fromEntries(fieldMatches.map(match => [match[1], match[2].trim()]));
340
+ const allowedFields = new Set(['file', 'scope', 'type', 'status', 'summary', 'priority', 'updated', 'triggers', 'review_after', 'id', 'anchor', 'revision']);
341
+ if (card.fields.replace(/^- ([a-z_]+):[^\S\r\n]*(.*)$/gmu, '').trim() || fieldMatches.length !== Object.keys(fields).length || Object.keys(fields).some(key => !allowedFields.has(key))) {
342
+ issues.push({ path: 'MEMORY.md', reason: 'card_contains_unmapped_text', start: card.index });
343
+ continue;
344
+ }
345
+ const path = fields.file ?? '';
346
+ const category = /^notes\/(.+)\.md$/u.exec(path)?.[1] ?? '';
347
+ const body = files.get(path)?.toString('utf8');
348
+ if (!fields.scope || !categories.test(category) || !body?.trim() || seenFiles.has(path)
349
+ || !fields.summary || !['procedural', 'semantic', 'episodic', 'preference'].includes(fields.type ?? '') || !fields.status || !['high', 'medium', 'low'].includes(fields.priority ?? '') || !fields.updated) {
350
+ issues.push({ path: 'MEMORY.md', reason: 'card_mapping_unconfirmed', start: card.index });
351
+ continue;
352
+ }
353
+ seenFiles.add(path);
354
+ const parsed = MigrationRecordMappingSchema.safeParse({ sourceFile: path, start: 0, end: body.length, scope: fields.scope, category, title: card.title, status: fields.status });
355
+ if (!parsed.success)
356
+ issues.push({ path, reason: 'card_metadata_invalid' });
357
+ else {
358
+ mappings.push(parsed.data);
359
+ knownMetadata.set(`${path}\u0000${card.title}`, { type: fields.type, summary: fields.summary, priority: fields.priority, updated: fields.updated,
360
+ triggers: (fields.triggers ?? '').split(/[,,、]/u).map(value => value.trim()).filter(Boolean), reviewAfter: fields.review_after ?? null });
361
+ }
362
+ }
363
+ if (!cards.length)
364
+ issues.push({ path: 'MEMORY.md', reason: 'legacy_source_requires_mapping' });
365
+ }
366
+ const entries = [];
367
+ for (const mapping of mappings) {
368
+ const body = files.get(mapping.sourceFile)?.toString('utf8');
369
+ if (body === undefined || mapping.end > body.length) {
370
+ issues.push({ path: mapping.sourceFile, reason: 'range_out_of_bounds' });
371
+ continue;
372
+ }
373
+ const id = mapping.targetId ?? `mem_${digest(`${identity.migrationId}:${mapping.sourceFile}:${mapping.start}:${mapping.end}`).slice(0, 24)}`;
374
+ const confirmedMetadata = mapping.sourceFile === 'MEMORY.md' ? parseLegacyCardMetadata(body.slice(mapping.start, mapping.end)) : null;
375
+ 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),
376
+ triggers: [], priority: 'medium', updated: fallbackUpdated, anchor: id, revision: 1, reviewAfter: null,
377
+ ...(knownMetadata.get(`${mapping.sourceFile}\u0000${mapping.title}`) ?? knownMetadata.get(mapping.sourceFile) ?? {}),
378
+ ...(confirmedMetadata ? (() => {
379
+ const source = confirmedMetadata;
380
+ return { type: source.type, summary: source.summary, triggers: source.triggers, priority: source.priority,
381
+ updated: source.updated === MIGRATION_EPOCH_DATE ? fallbackUpdated : source.updated, reviewAfter: source.reviewAfter };
382
+ })() : {}) });
383
+ }
384
+ for (const [path, bytes] of files) {
385
+ if ((!path.startsWith('notes/') || isDeferredMemoryArtifactPath(path)) && path !== 'MEMORY.md')
386
+ continue;
387
+ const text = bytes.toString('utf8');
388
+ const ranges = mappings.filter(mapping => mapping.sourceFile === path).sort((a, b) => a.start - b.start);
389
+ if (path === 'MEMORY.md')
390
+ continue; // Retained identity stays byte-for-byte; replaced index coverage is checked above.
391
+ let cursor = 0;
392
+ for (const range of ranges) {
393
+ if (range.start < cursor || text.slice(cursor, range.start).trim())
394
+ issues.push({ path, reason: 'mapping_gap_or_overlap', start: cursor, end: range.start });
395
+ cursor = range.end;
396
+ }
397
+ if (text.slice(cursor).trim())
398
+ issues.push({ path, reason: 'unmapped_source_text', start: cursor, end: text.length });
399
+ }
400
+ if (issues.length)
401
+ throw new MigrationError('migration_needs_review', issues);
402
+ // Retain original files (external references may exist); only same-path fully covered sources are rewritten.
403
+ const targets = new Map();
404
+ mappings.forEach((mapping, index) => {
405
+ const entry = entries[index];
406
+ const body = files.get(mapping.sourceFile).toString('utf8').slice(mapping.start, mapping.end);
407
+ const blocks = targets.get(entry.file) ?? [];
408
+ 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`]);
409
+ });
410
+ for (const [path, blocks] of targets) {
411
+ const existing = files.get(path);
412
+ if (existing && !mappings.some(mapping => mapping.sourceFile === path))
413
+ throw new MigrationError('migration_needs_review', [{ path, reason: 'target_contains_unmapped_text' }]);
414
+ output.set(path, Buffer.from(blocks.join('\n')));
415
+ }
416
+ const index = renderFullMemoryIndex(entries);
417
+ const heading = /^## Knowledge Index\s*$/mu.exec(memory);
418
+ let target;
419
+ if (heading?.index !== undefined) {
420
+ const start = heading.index + heading[0].length;
421
+ const tail = memory.slice(start);
422
+ const next = /^## (?!#)/mu.exec(tail);
423
+ target = memory.slice(0, start) + `\n${index}\n` + (next?.index === undefined ? '' : tail.slice(next.index));
424
+ }
425
+ else
426
+ target = `${memory}\n\n## Knowledge Index\n${index}\n`;
427
+ output.set('MEMORY.md', Buffer.from(target));
428
+ mappings = mappings.map((mapping, index) => ({ ...mapping, targetId: entries[index].id }));
429
+ }
430
+ validateMigrationTarget(output);
431
+ return { files: output, recordMappings: mappings };
432
+ }