@nowcrew/daemon 0.6.96 → 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.
Files changed (55) hide show
  1. package/dist/cloud-report-upload.js +1 -10
  2. package/dist/execution-journal.js +3 -1
  3. package/dist/execution-protocol.js +1 -1
  4. package/dist/execution-runner.js +4 -1
  5. package/dist/local-execution-prompt.js +8 -3
  6. package/dist/local-executor.js +63 -39
  7. package/dist/local-memory/commit-telemetry.js +30 -0
  8. package/dist/local-memory/execution-workspace.js +62 -0
  9. package/dist/local-memory/full-index-search.js +47 -0
  10. package/dist/local-memory/index-budget.js +9 -3
  11. package/dist/local-memory/initialization-identity.js +36 -0
  12. package/dist/local-memory/inventory-cli.js +49 -8
  13. package/dist/local-memory/inventory-source.js +15 -8
  14. package/dist/local-memory/inventory-v2.js +361 -0
  15. package/dist/local-memory/legacy-adapter.js +233 -0
  16. package/dist/local-memory/migration-access.js +242 -0
  17. package/dist/local-memory/migration-cli.js +54 -0
  18. package/dist/local-memory/migration-io.js +199 -0
  19. package/dist/local-memory/migration-pending.js +9 -0
  20. package/dist/local-memory/migration-plan.js +317 -0
  21. package/dist/local-memory/migration-schema.js +55 -0
  22. package/dist/local-memory/migration-state-invariants.js +65 -0
  23. package/dist/local-memory/migration-store.js +414 -0
  24. package/dist/local-memory/migration-tool-entry.js +21 -0
  25. package/dist/local-memory/pending-query.js +5 -2
  26. package/dist/local-memory/pending-schema.js +1 -1
  27. package/dist/local-memory/pending.js +16 -2
  28. package/dist/local-memory/prompt.js +21 -5
  29. package/dist/local-memory/read-cli.js +98 -76
  30. package/dist/local-memory/rollout.js +79 -11
  31. package/dist/local-memory/runtime.js +181 -13
  32. package/dist/local-memory/storage-format.js +152 -0
  33. package/dist/local-memory/write.js +189 -21
  34. package/dist/local-memory/writer-mode.js +13 -0
  35. package/dist/local-memory-diagnostics.js +94 -13
  36. package/dist/local-memory-telemetry.js +19 -2
  37. package/dist/machine-info.js +3 -0
  38. package/dist/marketplace-protocol.js +1 -1
  39. package/dist/marketplace-remote.js +1 -1
  40. package/dist/marketplace-runner.js +1 -1
  41. package/dist/memory-migration-controller.js +86 -0
  42. package/dist/memory-migration-inspect.js +108 -0
  43. package/dist/memory-migration-journal.js +14 -0
  44. package/dist/memory-migration-protocol.js +14 -0
  45. package/dist/memory-migration-runtime.js +305 -0
  46. package/dist/memory-prune-diagnostics.js +60 -4
  47. package/dist/message-file-preview.js +11 -1
  48. package/dist/prompt.js +6 -2
  49. package/dist/runner.js +1 -1
  50. package/dist/runtimes/claude-thread-runner.js +25 -4
  51. package/dist/serve.js +38 -3
  52. package/dist/token.js +1 -0
  53. package/dist/workspace-control-handler.js +16 -3
  54. package/dist/workspace.js +13 -2
  55. package/package.json +1 -1
@@ -73,7 +73,8 @@ export async function readMemoryInventorySource(root, file, budget) {
73
73
  await handle.close();
74
74
  }
75
75
  }
76
- export async function scanMemorySources(agentDir) {
76
+ export async function scanMemorySources(agentDir, options = {}) {
77
+ const includeOperationalFiles = options.includeOperationalFiles ?? true;
77
78
  const root = await realpath(agentDir);
78
79
  if ((await lstat(agentDir)).isSymbolicLink())
79
80
  throw new Error("agent_root_symlink");
@@ -124,10 +125,14 @@ export async function scanMemorySources(agentDir) {
124
125
  }
125
126
  await visit("MEMORY.md");
126
127
  await visit("notes", true);
127
- await visit(".crew/memory/pending", true);
128
- const resultNames = (await readdir(root)).filter((name) => /^\.memory-result\.v1-.*\.json$/u.test(name)).sort();
129
- for (const name of resultNames)
130
- await visit(name);
128
+ const resultNames = includeOperationalFiles
129
+ ? (await readdir(root)).filter((name) => /^\.memory-result\.v1-.*\.json$/u.test(name)).sort()
130
+ : [];
131
+ if (includeOperationalFiles) {
132
+ await visit(".crew/memory/pending", true);
133
+ for (const name of resultNames)
134
+ await visit(name);
135
+ }
131
136
  // 第二遍比对摘要和目录清单,发现扫描期间的变化就拒绝完整性结论;这不是跨文件事务锁。
132
137
  for (const source of sources) {
133
138
  try {
@@ -150,9 +155,11 @@ export async function scanMemorySources(agentDir) {
150
155
  addIssue("source_changed", file);
151
156
  }
152
157
  }
153
- const currentResults = (await readdir(root)).filter((name) => /^\.memory-result\.v1-.*\.json$/u.test(name)).sort();
154
- if (JSON.stringify(currentResults) !== JSON.stringify(resultNames))
155
- addIssue("source_changed", ".memory-result.v1-*.json");
158
+ if (includeOperationalFiles) {
159
+ const currentResults = (await readdir(root)).filter((name) => /^\.memory-result\.v1-.*\.json$/u.test(name)).sort();
160
+ if (JSON.stringify(currentResults) !== JSON.stringify(resultNames))
161
+ addIssue("source_changed", ".memory-result.v1-*.json");
162
+ }
156
163
  if (await realpath(agentDir) !== root)
157
164
  addIssue("source_changed", "agent_root");
158
165
  return { root, sources: sources.sort((a, b) => a.path.localeCompare(b.path)), issues, totalBytes };
@@ -0,0 +1,361 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, realpath } from "node:fs/promises";
3
+ import { renderLocalMemoryIndexCard } from "./index-budget.js";
4
+ import { inventoryLimits, readMemoryInventorySource } from "./inventory-source.js";
5
+ import { MigrationStateSchema } from "./migration-schema.js";
6
+ import { assertMigrationStateIntegrity } from "./migration-state-invariants.js";
7
+ import { isHarmlessMigrationRemainder } from "./migration-plan.js";
8
+ import { parseFullMemoryIndex, selectLocalMemoryPreview } from "./storage-format.js";
9
+ const STATE_PATH = ".crew/memory/migration.v1.json";
10
+ const DETAIL_MARKER_PATTERN = /<!--\s*NOWWORK:MEMORY_ENTRY:(?:BEGIN\s+id=([A-Za-z0-9_-]+)\s+revision=([1-9][0-9]*)|END\s+id=([A-Za-z0-9_-]+))\s*-->/gu;
11
+ const CONTROL_MARKER_PATTERN = /NOWWORK:MEMORY_(?:ENTRY|INDEX):/u;
12
+ function errorCode(error, fallback) {
13
+ if (error instanceof Error && /^[a-z0-9_]+$/u.test(error.message))
14
+ return error.message;
15
+ return fallback;
16
+ }
17
+ function sourceFields(entry) {
18
+ return {
19
+ id: entry.id,
20
+ file: entry.file,
21
+ type: entry.type,
22
+ status: entry.status,
23
+ summary: entry.summary,
24
+ triggers: entry.triggers.join(", "),
25
+ priority: entry.priority,
26
+ revision: String(entry.revision),
27
+ anchor: entry.anchor ?? "",
28
+ updated: entry.updated,
29
+ ...(entry.reviewAfter === null ? {} : { review_after: entry.reviewAfter }),
30
+ };
31
+ }
32
+ function exactManagedDetailBodies(detail, targetId) {
33
+ const escapedId = targetId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
34
+ const pattern = new RegExp(`<!--\\s*NOWWORK:MEMORY_ENTRY:BEGIN\\s+id=${escapedId}\\s+revision=[1-9][0-9]*\\s*-->[\\s\\S]*?<!--\\s*NOWWORK:MEMORY_ENTRY:END\\s+id=${escapedId}\\s*-->`, "gu");
35
+ return [...detail.matchAll(pattern)].map(match => {
36
+ const beginEnd = match[0].indexOf("-->") + 3;
37
+ const endStart = match[0].lastIndexOf("<!--");
38
+ return match[0].slice(beginEnd, endStart);
39
+ });
40
+ }
41
+ function harmlessDetailRemainder(text) {
42
+ let cursor = 0;
43
+ let lineStart = true;
44
+ const whitespace = /\s+/uy;
45
+ while (cursor < text.length) {
46
+ whitespace.lastIndex = cursor;
47
+ const spacing = whitespace.exec(text);
48
+ if (spacing !== null) {
49
+ const lastNewline = spacing[0].lastIndexOf("\n");
50
+ lineStart = lastNewline === spacing[0].length - 1;
51
+ cursor = whitespace.lastIndex;
52
+ continue;
53
+ }
54
+ if (text.startsWith("<!--", cursor)) {
55
+ const end = text.indexOf("-->", cursor + 4);
56
+ if (end === -1)
57
+ return false;
58
+ cursor = end + 3;
59
+ continue;
60
+ }
61
+ if (lineStart && text[cursor] === "#") {
62
+ const end = text.indexOf("\n", cursor + 1);
63
+ if (end === -1)
64
+ return true;
65
+ cursor = end + 1;
66
+ lineStart = true;
67
+ continue;
68
+ }
69
+ return false;
70
+ }
71
+ return true;
72
+ }
73
+ /**
74
+ * A migrated legacy note can remain on disk for external references. It is
75
+ * safe to exclude it from the V2 orphan scan only when the immutable
76
+ * migration mapping proves that the complete, unchanged source was copied to
77
+ * the indexed target entry.
78
+ */
79
+ export function findControlledPreservedSourcePaths(entries, sources, mappings, sourceHashes = new Map()) {
80
+ const entriesById = new Map(entries.map(entry => [entry.id, entry]));
81
+ const sourcesByPath = new Map(sources.map(source => [source.path, source]));
82
+ const grouped = new Map();
83
+ for (const mapping of mappings) {
84
+ if (!mapping.sourceFile.startsWith("notes/"))
85
+ continue;
86
+ const current = grouped.get(mapping.sourceFile) ?? [];
87
+ grouped.set(mapping.sourceFile, [...current, mapping]);
88
+ }
89
+ const preserved = [];
90
+ for (const [path, values] of grouped) {
91
+ const source = sourcesByPath.get(path);
92
+ if (source === undefined || values.length === 0)
93
+ continue;
94
+ const ranges = [...values].sort((left, right) => left.start - right.start);
95
+ let cursor = 0;
96
+ let valid = true;
97
+ for (const mapping of ranges) {
98
+ const targetFile = `notes/${mapping.category}.md`;
99
+ const target = mapping.targetId === undefined ? undefined : entriesById.get(mapping.targetId);
100
+ const targetSource = target === undefined ? undefined : sourcesByPath.get(target.file);
101
+ const expectedSourceSha256 = mapping.sourceSha256 ?? sourceHashes.get(path);
102
+ const sourceBody = source.content.slice(mapping.start, mapping.end);
103
+ const renderedBody = mapping.sourceFile === targetFile
104
+ ? sourceBody
105
+ : `\n## ${mapping.title}\n\nScope: ${mapping.scope}\n\n${sourceBody}\n`;
106
+ const sourceBlockBodies = mapping.targetId === undefined
107
+ ? []
108
+ : exactManagedDetailBodies(sourceBody, mapping.targetId);
109
+ const expectedBody = mapping.sourceFile === targetFile && sourceBlockBodies.length === 1
110
+ ? sourceBlockBodies[0]
111
+ : renderedBody;
112
+ const targetBodies = mapping.targetId === undefined || targetSource === undefined
113
+ ? []
114
+ : exactManagedDetailBodies(targetSource.content, mapping.targetId);
115
+ if (expectedSourceSha256 === undefined || expectedSourceSha256 !== source.sha256
116
+ || mapping.start < cursor || !isHarmlessMigrationRemainder(source.content.slice(cursor, mapping.start))
117
+ || mapping.end > source.content.length
118
+ || target === undefined || target.file !== targetFile || targetSource === undefined
119
+ || targetBodies.length !== 1 || targetBodies[0] !== expectedBody) {
120
+ valid = false;
121
+ break;
122
+ }
123
+ cursor = mapping.end;
124
+ }
125
+ if (valid && isHarmlessMigrationRemainder(source.content.slice(cursor)))
126
+ preserved.push(path);
127
+ }
128
+ return preserved.sort((left, right) => left.localeCompare(right));
129
+ }
130
+ export function* scanManagedDetailMarkers(detail) {
131
+ for (const marker of detail.matchAll(DETAIL_MARKER_PATTERN)) {
132
+ const beginId = marker[1];
133
+ yield {
134
+ start: marker.index,
135
+ end: marker.index + marker[0].length,
136
+ kind: beginId === undefined ? "end" : "begin",
137
+ id: beginId ?? marker[3],
138
+ revision: beginId === undefined ? null : Number(marker[2]),
139
+ };
140
+ }
141
+ }
142
+ /** Parse one note once, then let every index entry use constant-time lookups. */
143
+ export function analyzeManagedDetailFile(detail, expectedIds, scan = scanManagedDetailMarkers) {
144
+ const revisionsById = new Map();
145
+ const remainder = [];
146
+ let cursor = 0;
147
+ let markerInvalid = false;
148
+ let unindexed = false;
149
+ let open = null;
150
+ for (const marker of scan(detail)) {
151
+ const between = detail.slice(cursor, marker.start);
152
+ if (CONTROL_MARKER_PATTERN.test(between))
153
+ markerInvalid = true;
154
+ if (open === null)
155
+ remainder.push(between);
156
+ cursor = marker.end;
157
+ if (marker.kind === "begin") {
158
+ if (open !== null || marker.revision === null)
159
+ markerInvalid = true;
160
+ else
161
+ open = { id: marker.id, revision: marker.revision };
162
+ continue;
163
+ }
164
+ if (open === null || marker.id !== open.id) {
165
+ markerInvalid = true;
166
+ continue;
167
+ }
168
+ const revisions = revisionsById.get(open.id) ?? [];
169
+ revisions.push(open.revision);
170
+ revisionsById.set(open.id, revisions);
171
+ if (!expectedIds.has(open.id))
172
+ unindexed = true;
173
+ open = null;
174
+ }
175
+ const tail = detail.slice(cursor);
176
+ if (CONTROL_MARKER_PATTERN.test(tail) || open !== null)
177
+ markerInvalid = true;
178
+ if (open === null)
179
+ remainder.push(tail);
180
+ const uncovered = remainder.join("");
181
+ const coverageIssue = markerInvalid
182
+ ? "managed_anchor_invalid"
183
+ : unindexed
184
+ ? "unindexed_detail"
185
+ : harmlessDetailRemainder(uncovered)
186
+ ? null
187
+ : "unindexed_text_requires_mapping";
188
+ return { revisionsById, coverageIssue };
189
+ }
190
+ function inspectDetailEntry(entry, analysis) {
191
+ if (analysis === undefined)
192
+ return "missing_detail";
193
+ if (analysis.coverageIssue === "managed_anchor_invalid")
194
+ return "managed_anchor_invalid";
195
+ const revisions = analysis.revisionsById.get(entry.id) ?? [];
196
+ if (revisions.length !== 1)
197
+ return "managed_anchor_invalid";
198
+ return revisions[0] > entry.revision ? "managed_revision_invalid" : null;
199
+ }
200
+ export function inspectManagedDetailFiles(entries, sources, analyze = analyzeManagedDetailFile) {
201
+ return inspectManagedDetailFilesWithMappings(entries, sources, [], new Map(), analyze);
202
+ }
203
+ export function inspectManagedDetailFilesWithMappings(entries, sources, mappings, sourceHashes = new Map(), analyze = analyzeManagedDetailFile) {
204
+ const sourceMap = new Map(sources.map(source => [source.path, source]));
205
+ const preservedSourcePaths = findControlledPreservedSourcePaths(entries, sources, mappings, sourceHashes);
206
+ const preserved = new Set(preservedSourcePaths);
207
+ const expectedByFile = new Map();
208
+ for (const entry of entries) {
209
+ const expected = expectedByFile.get(entry.file) ?? new Set();
210
+ expected.add(entry.id);
211
+ expectedByFile.set(entry.file, expected);
212
+ }
213
+ const paths = new Set([
214
+ ...expectedByFile.keys(),
215
+ ...sources.filter(source => source.path.startsWith("notes/")).map(source => source.path),
216
+ ]);
217
+ const analyses = new Map();
218
+ const fileIssues = [];
219
+ for (const path of paths) {
220
+ const source = sourceMap.get(path);
221
+ if (source === undefined)
222
+ continue;
223
+ const analysis = analyze(source.content, expectedByFile.get(path) ?? new Set());
224
+ analyses.set(path, analysis);
225
+ if (analysis.coverageIssue !== null
226
+ && !(preserved.has(path) && analysis.coverageIssue === "unindexed_text_requires_mapping")) {
227
+ fileIssues.push({ code: analysis.coverageIssue, path });
228
+ }
229
+ }
230
+ const entryIssues = new Map();
231
+ for (const entry of entries) {
232
+ const issue = inspectDetailEntry(entry, analyses.get(entry.file));
233
+ if (issue !== null)
234
+ entryIssues.set(entry.id, issue);
235
+ }
236
+ return { entryIssues, fileIssues, preservedSourcePaths };
237
+ }
238
+ async function inspectCommittedState(root, budget) {
239
+ let source;
240
+ try {
241
+ source = await readMemoryInventorySource(root, STATE_PATH, Math.min(inventoryLimits.fileBytes, budget));
242
+ }
243
+ catch (error) {
244
+ return {
245
+ valid: false,
246
+ source: null,
247
+ state: null,
248
+ issue: {
249
+ code: error.code === "ENOENT"
250
+ ? "memory_migration_state_missing"
251
+ : errorCode(error, "memory_migration_state_invalid"),
252
+ path: STATE_PATH,
253
+ },
254
+ };
255
+ }
256
+ try {
257
+ const state = MigrationStateSchema.parse(JSON.parse(source.content));
258
+ const fingerprint = createHash("sha256").update(await realpath(root)).digest("hex");
259
+ const ownerIdentity = String((await lstat(root)).uid);
260
+ if (state.phase !== "committed" || state.storageVersion !== 2
261
+ || state.identity.rootFingerprint !== fingerprint
262
+ || state.identity.ownerIdentity !== ownerIdentity) {
263
+ throw new Error("memory_migration_state_invalid");
264
+ }
265
+ assertMigrationStateIntegrity(state, state.identity);
266
+ const confirmed = await readMemoryInventorySource(root, STATE_PATH, Math.min(inventoryLimits.fileBytes, budget));
267
+ if (confirmed.sha256 !== source.sha256) {
268
+ return { valid: false, source, state: null, issue: { code: "source_changed", path: STATE_PATH } };
269
+ }
270
+ return { valid: true, source, state, issue: null };
271
+ }
272
+ catch (error) {
273
+ return {
274
+ valid: false,
275
+ source,
276
+ state: null,
277
+ issue: {
278
+ code: "memory_migration_state_invalid",
279
+ path: STATE_PATH,
280
+ },
281
+ };
282
+ }
283
+ }
284
+ /** Validate an already-committed schema2 store without sending it through the schema1 migration preview. */
285
+ export async function inspectV2MemoryInventory(root, sources, readComplete) {
286
+ const sourceMap = new Map(sources.map(source => [source.path, source]));
287
+ const memory = sourceMap.get("MEMORY.md")?.content ?? "";
288
+ const issues = [];
289
+ const addIssue = (code, path) => {
290
+ if (!issues.some(issue => issue.code === code && issue.path === path))
291
+ issues.push({ code, path });
292
+ };
293
+ let entries = [];
294
+ try {
295
+ entries = parseFullMemoryIndex(memory);
296
+ }
297
+ catch (error) {
298
+ addIssue(errorCode(error, "local_memory_storage_entry_invalid"), "MEMORY.md");
299
+ }
300
+ const scannedBytes = sources.reduce((total, source) => total + source.bytes, 0);
301
+ const state = await inspectCommittedState(root, Math.max(0, inventoryLimits.totalBytes - scannedBytes));
302
+ if (state.issue)
303
+ addIssue(state.issue.code, state.issue.path);
304
+ const sourceHashes = new Map((state.state?.manifest.files ?? [])
305
+ .flatMap(file => file.before.exists ? [[file.path, file.before.sha256]] : []));
306
+ const detailInspection = inspectManagedDetailFilesWithMappings(entries, sources, state.state?.manifest.recordMappings ?? [], sourceHashes);
307
+ for (const issue of detailInspection.fileIssues)
308
+ addIssue(issue.code, issue.path);
309
+ for (const entry of entries) {
310
+ const issue = detailInspection.entryIssues.get(entry.id);
311
+ if (issue !== undefined)
312
+ addIssue(issue, entry.file);
313
+ }
314
+ let preview = null;
315
+ if (readComplete && !issues.some(issue => issue.path === "MEMORY.md")) {
316
+ try {
317
+ const selected = selectLocalMemoryPreview(memory);
318
+ preview = {
319
+ memory: selected.memory,
320
+ budget: {
321
+ entries: selected.entries.length,
322
+ omittedEntries: selected.omittedIds.length,
323
+ // The bounded projection itself fits; reason records normal long-tail omission.
324
+ fits: true,
325
+ reason: selected.reason,
326
+ },
327
+ writerCompatible: state.valid && issues.length === 0,
328
+ notes: [],
329
+ };
330
+ }
331
+ catch (error) {
332
+ addIssue(errorCode(error, "local_memory_preview_invalid"), "MEMORY.md");
333
+ }
334
+ }
335
+ const shownDetails = new Set();
336
+ const writeReady = readComplete && state.valid && issues.length === 0;
337
+ return {
338
+ stateValid: state.valid,
339
+ writeReady,
340
+ stateSource: state.source,
341
+ preservedSourcePaths: detailInspection.preservedSourcePaths,
342
+ analysis: {
343
+ entries: entries.map(entry => {
344
+ const detail = shownDetails.has(entry.file) ? null : sourceMap.get(entry.file)?.content ?? null;
345
+ shownDetails.add(entry.file);
346
+ return {
347
+ title: entry.title,
348
+ format: "managed",
349
+ disposition: detailInspection.entryIssues.has(entry.id) || !writeReady ? "blocked" : "review_required",
350
+ sourceFields: sourceFields(entry),
351
+ rawCard: renderLocalMemoryIndexCard(entry),
352
+ detail,
353
+ proposed: entry,
354
+ };
355
+ }),
356
+ issues,
357
+ preview,
358
+ rawMemory: memory,
359
+ },
360
+ };
361
+ }
@@ -0,0 +1,233 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { realpath } from "node:fs/promises";
3
+ import { inventoryLimits, readMemoryInventorySource, scanMemorySources } from "./inventory-source.js";
4
+ import { detectLocalMemoryStorageVersion, LOCAL_MEMORY_GENERATED_INDEX_PATTERN } from "./storage-format.js";
5
+ const CURSOR_VERSION = 1;
6
+ const MAX_METADATA_PATH_BYTES = 4 * 1024;
7
+ const MAX_METADATA_WARNING_BYTES = 2 * 1024;
8
+ const excerpt = (text, size) => {
9
+ const characters = Array.from(text);
10
+ return characters.length > size ? `${characters.slice(0, size).join("")}...` : text;
11
+ };
12
+ function originalLowercaseIndex(text, index) {
13
+ let originalIndex = 0;
14
+ let lowerIndex = 0;
15
+ for (const character of text) {
16
+ lowerIndex += character.toLowerCase().length;
17
+ if (index < lowerIndex)
18
+ return originalIndex;
19
+ originalIndex += character.length;
20
+ }
21
+ return text.length;
22
+ }
23
+ function encodeCursor(cursor) {
24
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
25
+ }
26
+ function decodeCursor(value) {
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
30
+ }
31
+ catch {
32
+ throw new Error("memory_cursor_invalid");
33
+ }
34
+ if (!parsed || typeof parsed !== "object")
35
+ throw new Error("memory_cursor_invalid");
36
+ const record = parsed;
37
+ if (record.version !== CURSOR_VERSION || typeof record.file !== "string"
38
+ || !/^[a-f0-9]{64}$/u.test(String(record.sha256))
39
+ || typeof record.afterLine !== "number" || !Number.isSafeInteger(record.afterLine) || record.afterLine < 0) {
40
+ throw new Error("memory_cursor_invalid");
41
+ }
42
+ return { version: 1, file: record.file, sha256: String(record.sha256), afterLine: record.afterLine };
43
+ }
44
+ function boundStringList(values, maxBytes) {
45
+ const bounded = [];
46
+ for (const value of values) {
47
+ const candidate = [...bounded, value];
48
+ if (Buffer.byteLength(JSON.stringify(candidate), "utf8") > maxBytes)
49
+ break;
50
+ bounded.push(value);
51
+ }
52
+ return { values: bounded, truncated: bounded.length < values.length };
53
+ }
54
+ function sourceMatches(source, query, afterLine, limit, outputBytes) {
55
+ const terms = query.normalize("NFKC").toLowerCase().split(/\s+/u).filter(Boolean);
56
+ const matches = [];
57
+ let line = 0;
58
+ let offset = 0;
59
+ let heading = "";
60
+ let matchCount = 0;
61
+ let hasMore = false;
62
+ let outputTooLarge = false;
63
+ let firstMatchLine = null;
64
+ let remaining = outputBytes;
65
+ const generatedIndex = source.path === "MEMORY.md"
66
+ ? LOCAL_MEMORY_GENERATED_INDEX_PATTERN.exec(source.content)
67
+ : null;
68
+ const generatedStart = generatedIndex === null ? -1 : Array.from(source.content.slice(0, generatedIndex.index)).length;
69
+ const generatedEnd = generatedIndex === null ? -1 : generatedStart + Array.from(generatedIndex[0]).length;
70
+ for (const match of source.content.matchAll(/[^\n]*(?:\n|$)/gu)) {
71
+ if (match[0] === "")
72
+ break;
73
+ line += 1;
74
+ const raw = match[0];
75
+ const lineStart = offset;
76
+ const lineEnd = offset + Array.from(raw).length;
77
+ const content = raw.replace(/\r?\n$/u, "");
78
+ const title = /^#{1,6}\s+(.+?)\s*$/u.exec(content)?.[1];
79
+ if (title !== undefined)
80
+ heading = excerpt(title, 96);
81
+ const lower = content.normalize("NFKC").toLowerCase();
82
+ const insideGeneratedIndex = generatedStart >= 0 && lineStart < generatedEnd && lineEnd > generatedStart;
83
+ if (!insideGeneratedIndex && terms.every(term => lower.includes(term))) {
84
+ matchCount += 1;
85
+ if (line > afterLine) {
86
+ firstMatchLine ??= line;
87
+ const matchIndex = lower.indexOf(terms[0] ?? "");
88
+ let start = Math.max(0, originalLowercaseIndex(content, matchIndex) - 60);
89
+ if (start > 0 && /[\uDC00-\uDFFF]/u.test(content[start]))
90
+ start -= 1;
91
+ const item = { file: source.path, sourceSha256: source.sha256, legacy: true, opaque: true,
92
+ line, offset, heading, text: `${start > 0 ? "..." : ""}${excerpt(content.slice(start), 240)}` };
93
+ const bytes = Buffer.byteLength(JSON.stringify(item), "utf8") + 1;
94
+ if (matches.length >= limit)
95
+ hasMore = true;
96
+ else if (bytes > remaining) {
97
+ hasMore = true;
98
+ outputTooLarge = matches.length === 0;
99
+ }
100
+ else {
101
+ matches.push(item);
102
+ remaining -= bytes;
103
+ }
104
+ }
105
+ }
106
+ offset += Array.from(raw).length;
107
+ }
108
+ return { matches, matchCount, hasMore, outputTooLarge, firstMatchLine,
109
+ bytesUsed: outputBytes - remaining,
110
+ nextAfterLine: hasMore ? matches.at(-1)?.line ?? null : null };
111
+ }
112
+ function issueCode(error) {
113
+ if (error instanceof TypeError)
114
+ return "invalid_utf8";
115
+ if (error instanceof Error)
116
+ return error.code ?? error.message;
117
+ return "read_failed";
118
+ }
119
+ async function readRequestedSources(agentDir, files, excludeFiles) {
120
+ const root = await realpath(agentDir);
121
+ const sources = [];
122
+ const issues = [];
123
+ let remaining = inventoryLimits.totalBytes;
124
+ for (const file of [...new Set(files)].sort((left, right) => left.localeCompare(right))) {
125
+ if (excludeFiles?.has(file))
126
+ continue;
127
+ try {
128
+ const source = await readMemoryInventorySource(root, file, remaining);
129
+ remaining -= source.bytes;
130
+ sources.push(source);
131
+ }
132
+ catch (error) {
133
+ issues.push({ code: issueCode(error), path: file });
134
+ }
135
+ }
136
+ return { sources, issues };
137
+ }
138
+ /**
139
+ * Read-only adapter for old MEMORY.md/notes content. It is only called from
140
+ * explicit search commands; prompt preparation never invokes it.
141
+ */
142
+ export async function searchLegacyMemorySources(input) {
143
+ const scan = input.files === undefined
144
+ ? await scanMemorySources(input.agentDir, { includeOperationalFiles: false })
145
+ : null;
146
+ const requestedResult = input.files === undefined
147
+ ? null
148
+ : await readRequestedSources(input.agentDir, input.files, input.excludeFiles);
149
+ const requested = input.files === undefined
150
+ ? scan.sources.filter(source => source.path === "MEMORY.md" || source.path.startsWith("notes/"))
151
+ : requestedResult.sources;
152
+ const sources = input.files === undefined
153
+ ? requested.filter(source => !(input.excludeFiles?.has(source.path) ?? false))
154
+ : requested;
155
+ const detectedIndexVersion = await (async () => {
156
+ try {
157
+ const memory = sources.find(source => source.path === "MEMORY.md")
158
+ ?? await readMemoryInventorySource(await realpath(input.agentDir), "MEMORY.md", inventoryLimits.fileBytes);
159
+ return detectLocalMemoryStorageVersion(memory.content);
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ })();
165
+ const relevantIssues = input.files === undefined
166
+ ? scan.issues.filter(issue => issue.path === "MEMORY.md" || issue.path.startsWith("notes/"))
167
+ : requestedResult.issues;
168
+ const cursor = input.cursor === undefined ? null : decodeCursor(input.cursor);
169
+ if (cursor !== null && !sources.some(source => source.path === cursor.file))
170
+ throw new Error("memory_cursor_invalid");
171
+ const ordered = [...sources].sort((left, right) => left.path.localeCompare(right.path));
172
+ const matches = [];
173
+ let matchCount = 0;
174
+ let hasMore = false;
175
+ let nextCursor = null;
176
+ let started = cursor === null;
177
+ let pageFull = false;
178
+ const allUnindexedFiles = ordered.map(source => source.path);
179
+ const boundedFiles = boundStringList(allUnindexedFiles, MAX_METADATA_PATH_BYTES);
180
+ const allWarnings = relevantIssues.map(issue => ({ code: issue.code, path: issue.path }));
181
+ const boundedWarnings = boundStringList(allWarnings.map(warning => `${warning.code}\u0000${warning.path}`), MAX_METADATA_WARNING_BYTES);
182
+ const warnings = boundedWarnings.values.map(value => {
183
+ const separator = value.indexOf("\u0000");
184
+ return { code: separator < 0 ? value : value.slice(0, separator), path: separator < 0 ? "" : value.slice(separator + 1) };
185
+ });
186
+ const outputReservation = Buffer.byteLength(JSON.stringify({
187
+ source: "legacy", legacy: true, opaque: true, readerMode: "legacy", storageVersion: detectedIndexVersion,
188
+ detectedIndexVersion,
189
+ ...(ordered.length === 1 ? { file: ordered[0].path, sha256: ordered[0].sha256 } : {}),
190
+ matches: [], matchCount: 0, hasMore: false, complete: false, nextAfterLine: null,
191
+ unindexedFiles: boundedFiles.values, unindexedFileCount: allUnindexedFiles.length,
192
+ unindexedFilesTruncated: boundedFiles.truncated, nextCursor: null,
193
+ warnings, warningCount: allWarnings.length, warningsTruncated: boundedWarnings.truncated,
194
+ }), "utf8") + 256;
195
+ let remainingOutputBytes = Math.max(0, input.outputBytes - outputReservation);
196
+ for (const source of ordered) {
197
+ if (!started) {
198
+ if (source.path !== cursor.file)
199
+ continue;
200
+ if (source.sha256 !== cursor.sha256)
201
+ throw new Error("memory_file_changed");
202
+ started = true;
203
+ }
204
+ const afterLine = source.path === cursor?.file ? cursor.afterLine
205
+ : cursor === null && source.path === ordered[0]?.path ? input.afterLine ?? 0 : 0;
206
+ const result = sourceMatches(source, input.query, afterLine, pageFull ? 0 : Math.max(0, input.limit - matches.length), remainingOutputBytes);
207
+ matches.push(...result.matches);
208
+ matchCount += result.matchCount;
209
+ remainingOutputBytes -= result.bytesUsed;
210
+ if (result.hasMore) {
211
+ hasMore = true;
212
+ pageFull = true;
213
+ if (result.outputTooLarge && matches.length === 0)
214
+ throw new Error("memory_output_too_large");
215
+ if (nextCursor === null) {
216
+ const last = result.matches.at(-1);
217
+ nextCursor = encodeCursor({ version: 1, file: source.path, sha256: source.sha256,
218
+ afterLine: Number(last?.line ?? Math.max(0, result.firstMatchLine ?? 1) - 1) });
219
+ }
220
+ }
221
+ }
222
+ const complete = !hasMore && relevantIssues.length === 0;
223
+ return {
224
+ source: "legacy", legacy: true, opaque: true, readerMode: "legacy",
225
+ storageVersion: detectedIndexVersion, detectedIndexVersion,
226
+ ...(ordered.length === 1 ? { file: ordered[0].path, sha256: ordered[0].sha256 } : {}),
227
+ matches, matchCount, hasMore, complete, nextAfterLine: hasMore && ordered.length === 1
228
+ ? Number(matches.at(-1)?.line ?? 0) : null,
229
+ unindexedFiles: boundedFiles.values, unindexedFileCount: allUnindexedFiles.length,
230
+ unindexedFilesTruncated: boundedFiles.truncated, nextCursor, warnings,
231
+ warningCount: allWarnings.length, warningsTruncated: boundedWarnings.truncated,
232
+ };
233
+ }