@nowcrew/daemon 0.6.93-beta.7 → 0.6.93-beta.9

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 (50) 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 +70 -37
  7. package/dist/local-memory/execution-workspace.js +62 -0
  8. package/dist/local-memory/full-index-search.js +47 -0
  9. package/dist/local-memory/index-budget.js +9 -3
  10. package/dist/local-memory/initialization-identity.js +36 -0
  11. package/dist/local-memory/inventory-cli.js +45 -7
  12. package/dist/local-memory/inventory-source.js +15 -8
  13. package/dist/local-memory/inventory-v2.js +282 -0
  14. package/dist/local-memory/legacy-adapter.js +233 -0
  15. package/dist/local-memory/migration-access.js +196 -0
  16. package/dist/local-memory/migration-cli.js +54 -0
  17. package/dist/local-memory/migration-io.js +199 -0
  18. package/dist/local-memory/migration-plan.js +317 -0
  19. package/dist/local-memory/migration-schema.js +55 -0
  20. package/dist/local-memory/migration-state-invariants.js +52 -0
  21. package/dist/local-memory/migration-store.js +401 -0
  22. package/dist/local-memory/migration-tool-entry.js +21 -0
  23. package/dist/local-memory/prompt.js +20 -4
  24. package/dist/local-memory/read-cli.js +75 -77
  25. package/dist/local-memory/rollout.js +73 -11
  26. package/dist/local-memory/runtime.js +176 -13
  27. package/dist/local-memory/storage-format.js +152 -0
  28. package/dist/local-memory/write.js +135 -21
  29. package/dist/local-memory/writer-mode.js +13 -0
  30. package/dist/local-memory-diagnostics.js +94 -13
  31. package/dist/local-memory-telemetry.js +19 -2
  32. package/dist/machine-info.js +3 -0
  33. package/dist/marketplace-protocol.js +1 -1
  34. package/dist/marketplace-remote.js +1 -1
  35. package/dist/marketplace-runner.js +1 -1
  36. package/dist/memory-migration-controller.js +86 -0
  37. package/dist/memory-migration-inspect.js +108 -0
  38. package/dist/memory-migration-journal.js +14 -0
  39. package/dist/memory-migration-protocol.js +14 -0
  40. package/dist/memory-migration-runtime.js +305 -0
  41. package/dist/memory-prune-diagnostics.js +60 -4
  42. package/dist/message-file-preview.js +11 -1
  43. package/dist/prompt.js +6 -2
  44. package/dist/runner.js +1 -1
  45. package/dist/runtimes/claude-thread-runner.js +25 -4
  46. package/dist/serve.js +38 -3
  47. package/dist/token.js +1 -0
  48. package/dist/workspace-control-handler.js +16 -3
  49. package/dist/workspace.js +13 -2
  50. package/package.json +1 -1
@@ -0,0 +1,152 @@
1
+ import { LOCAL_MEMORY_INDEX_CODE_POINT_CAP, LOCAL_MEMORY_INDEX_ENTRY_CAP, LOCAL_MEMORY_TOTAL_CODE_POINT_CAP, renderGeneratedIndex, renderLocalMemoryIndexCard, selectLocalMemoryIndexEntries, } from "./index-budget.js";
2
+ export const LOCAL_MEMORY_STORAGE_BYTE_CAP = 16 * 1024 * 1024;
3
+ export const LOCAL_MEMORY_STORAGE_ENTRY_CAP = 10_000;
4
+ export const LOCAL_MEMORY_NOTE_BYTE_CAP = 32 * 1024 * 1024;
5
+ export const LOCAL_MEMORY_READ_BYTE_CAP = 64 * 1024 * 1024;
6
+ export const LOCAL_MEMORY_GENERATED_INDEX_PATTERN = /<!--\s*NOWWORK:MEMORY_INDEX:BEGIN\s+schema=[12]\s*-->[\s\S]*?<!--\s*NOWWORK:MEMORY_INDEX:END\s*-->/u;
7
+ const length = (text) => {
8
+ let count = 0;
9
+ for (const _character of text)
10
+ count += 1;
11
+ return count;
12
+ };
13
+ const TYPES = new Set(["procedural", "semantic", "episodic", "preference"]);
14
+ const STATUSES = new Set(["candidate", "active", "pinned", "stale", "superseded", "archived"]);
15
+ const PRIORITIES = new Set(["high", "medium", "low"]);
16
+ const FIELDS = new Set(["id", "file", "type", "status", "summary", "triggers", "priority", "revision", "anchor", "updated", "review_after"]);
17
+ /** A malformed/unknown marker is never treated as an unmarked legacy file. */
18
+ export function detectLocalMemoryStorageVersion(memory) {
19
+ const markerCount = (memory.match(/NOWWORK:MEMORY_INDEX:/gu) ?? []).length;
20
+ if (markerCount === 0)
21
+ return 0;
22
+ const block = LOCAL_MEMORY_GENERATED_INDEX_PATTERN.exec(memory);
23
+ if (markerCount !== 2 || block?.index === undefined)
24
+ throw new Error("local_memory_storage_version_invalid");
25
+ const headings = [...memory.matchAll(/^##[\t ]+Knowledge Index[\t ]*\r?$/gmu)];
26
+ const heading = headings[0];
27
+ if (headings.length !== 1 || heading?.index === undefined)
28
+ throw new Error("local_memory_storage_index_invalid");
29
+ const sectionStart = heading.index + heading[0].length;
30
+ const nextSection = /^##[\t ]+(?!#)/mu.exec(memory.slice(sectionStart));
31
+ const sectionEnd = nextSection?.index === undefined ? memory.length : sectionStart + nextSection.index;
32
+ if (block.index < sectionStart || block.index + block[0].length > sectionEnd) {
33
+ throw new Error("local_memory_storage_index_invalid");
34
+ }
35
+ return /schema=2\s*-->/u.test(block[0].slice(0, block[0].indexOf("\n") + 1) || block[0]) ? 2 : 1;
36
+ }
37
+ function validateEntry(entry) {
38
+ const singleLine = (value) => !/[\r\n\u0000-\u001f\u007f]/u.test(value)
39
+ && !/<!--/u.test(value);
40
+ if (!entry.title.trim() || !entry.summary.trim()
41
+ || ![entry.title, entry.summary, entry.updated, ...entry.triggers].every(singleLine)
42
+ || !/^[A-Za-z0-9_-]+$/u.test(entry.id) || entry.anchor !== entry.id
43
+ || !/^notes\/(?:preferences|project-rules|testing|debugging|git|incidents|lessons)\.md$/u.test(entry.file)
44
+ || !TYPES.has(entry.type) || !STATUSES.has(entry.status) || !PRIORITIES.has(entry.priority)
45
+ || !Number.isSafeInteger(entry.revision) || entry.revision < 1
46
+ || !entry.updated.trim()
47
+ || (entry.reviewAfter !== null && !singleLine(entry.reviewAfter))) {
48
+ throw new Error("local_memory_storage_entry_invalid");
49
+ }
50
+ }
51
+ function validateEntries(entries) {
52
+ if (entries.length > LOCAL_MEMORY_STORAGE_ENTRY_CAP)
53
+ throw new Error("local_memory_storage_entry_limit");
54
+ const ids = new Set();
55
+ for (const entry of entries) {
56
+ validateEntry(entry);
57
+ if (ids.has(entry.id))
58
+ throw new Error("local_memory_storage_duplicate_id");
59
+ ids.add(entry.id);
60
+ }
61
+ }
62
+ export function assertLocalMemoryStorageSize(memory) {
63
+ if (Buffer.byteLength(memory, "utf8") > LOCAL_MEMORY_STORAGE_BYTE_CAP)
64
+ throw new Error("local_memory_storage_byte_limit");
65
+ }
66
+ /** schema=2 stores every lifecycle state, unlike the schema=1 prompt projection. */
67
+ export function parseFullMemoryIndex(memory) {
68
+ assertLocalMemoryStorageSize(memory);
69
+ if (detectLocalMemoryStorageVersion(memory) !== 2)
70
+ throw new Error("local_memory_storage_version_invalid");
71
+ const block = LOCAL_MEMORY_GENERATED_INDEX_PATTERN.exec(memory)[0];
72
+ const body = block.replace(/^<!--[^>]*-->\s*/u, "").replace(/<!--[^>]*-->$/u, "");
73
+ const headings = [...body.matchAll(/^###[\t ]+(.+?)[\t ]*\r?$/gmu)];
74
+ if (headings.length > LOCAL_MEMORY_STORAGE_ENTRY_CAP)
75
+ throw new Error("local_memory_storage_entry_limit");
76
+ if (body.slice(0, headings[0]?.index ?? body.length).trim())
77
+ throw new Error("local_memory_storage_entry_invalid");
78
+ const entries = headings.map((heading, index) => {
79
+ const fields = {};
80
+ const tail = body.slice(heading.index + heading[0].length, headings[index + 1]?.index ?? body.length);
81
+ for (const line of tail.split(/\r?\n/u)) {
82
+ if (!line.trim())
83
+ continue;
84
+ const field = /^-\s+([a-z][a-z_]*)\s*:\s*(.*?)\s*$/u.exec(line);
85
+ if (!field || !FIELDS.has(field[1]) || fields[field[1]] !== undefined)
86
+ throw new Error("local_memory_storage_entry_invalid");
87
+ fields[field[1]] = field[2];
88
+ }
89
+ if ([...FIELDS].filter(field => field !== "review_after").some(field => fields[field] === undefined)) {
90
+ throw new Error("local_memory_storage_entry_invalid");
91
+ }
92
+ if (!/^[1-9][0-9]*$/u.test(fields.revision))
93
+ throw new Error("local_memory_storage_entry_invalid");
94
+ return {
95
+ id: fields.id, title: heading[1], file: fields.file, type: fields.type,
96
+ status: fields.status, summary: fields.summary,
97
+ triggers: fields.triggers.split(/[,,、]/u).map(value => value.trim()).filter(Boolean),
98
+ priority: fields.priority, revision: Number(fields.revision),
99
+ anchor: fields.anchor, updated: fields.updated, reviewAfter: fields.review_after ?? null,
100
+ };
101
+ });
102
+ validateEntries(entries);
103
+ const section = memory.slice(memory.indexOf("## Knowledge Index") + "## Knowledge Index".length);
104
+ const remaining = section.replace(LOCAL_MEMORY_GENERATED_INDEX_PATTERN, "").split(/^##[\t ]+(?!#)/mu)[0] ?? "";
105
+ if (/^###[\t ]+/mu.test(remaining.replace(/<!--[\s\S]*?-->/gu, "")))
106
+ throw new Error("local_memory_legacy_index_requires_migration");
107
+ return entries;
108
+ }
109
+ export function renderFullMemoryIndex(entries) {
110
+ validateEntries(entries);
111
+ const block = `<!-- NOWWORK:MEMORY_INDEX:BEGIN schema=2 -->\n${entries.map(renderLocalMemoryIndexCard).join("\n")}<!-- NOWWORK:MEMORY_INDEX:END -->`;
112
+ assertLocalMemoryStorageSize(block);
113
+ return block;
114
+ }
115
+ export function replaceFullMemoryIndex(memory, entries) {
116
+ if (detectLocalMemoryStorageVersion(memory) !== 2)
117
+ throw new Error("local_memory_storage_version_invalid");
118
+ const updated = memory.replace(LOCAL_MEMORY_GENERATED_INDEX_PATTERN, () => renderFullMemoryIndex(entries));
119
+ assertLocalMemoryStorageSize(updated);
120
+ return updated;
121
+ }
122
+ export function selectLocalMemoryPreview(memory, query = "", options = {}) {
123
+ const entries = parseFullMemoryIndex(memory);
124
+ const totalActiveEntries = entries.filter(entry => entry.status === "active" || entry.status === "pinned").length;
125
+ const emptyIndex = renderGeneratedIndex([]);
126
+ // Reserve the largest count label before selecting; all discovery text counts toward the same cap.
127
+ const hint = (shown) => `\nMemory preview: ${shown}/${totalActiveEntries} active entries. Use crew-memory search --query <text> and crew-memory read --file <file> for more.\n`;
128
+ const base = memory.replace(LOCAL_MEMORY_GENERATED_INDEX_PATTERN, () => `${emptyIndex}${hint(totalActiveEntries)}`);
129
+ const remaining = Math.min(LOCAL_MEMORY_TOTAL_CODE_POINT_CAP, options.maxCodePoints ?? Infinity) - length(base);
130
+ const remainingBytes = (options.maxBytes ?? Infinity) - Buffer.byteLength(base, "utf8");
131
+ if (remaining < 0 || remainingBytes < 0 || Number.isNaN(remaining) || Number.isNaN(remainingBytes)) {
132
+ throw new Error("local_memory_identity_budget_exceeded");
133
+ }
134
+ const terms = query.normalize("NFKC").toLowerCase().split(/\s+/u).filter(Boolean);
135
+ const selection = selectLocalMemoryIndexEntries(entries, {
136
+ entryCap: LOCAL_MEMORY_INDEX_ENTRY_CAP,
137
+ indexCodePointCap: Math.min(LOCAL_MEMORY_INDEX_CODE_POINT_CAP - length(emptyIndex), remaining),
138
+ indexByteCap: remainingBytes,
139
+ }, {
140
+ protectedIds: new Set(entries.filter(entry => entry.status === "pinned").map(entry => entry.id)),
141
+ relevance: entry => {
142
+ const searchable = `${entry.title} ${entry.summary} ${entry.triggers.join(" ")}`.normalize("NFKC").toLowerCase();
143
+ return terms.filter(term => searchable.includes(term)).length;
144
+ },
145
+ });
146
+ if (selection.reason === "index_over_budget_pinned")
147
+ throw new Error("local_memory_pinned_budget_exceeded");
148
+ return {
149
+ ...selection, totalActiveEntries,
150
+ memory: memory.replace(LOCAL_MEMORY_GENERATED_INDEX_PATTERN, () => `${renderGeneratedIndex(selection.entries)}${hint(selection.entries.length)}`),
151
+ };
152
+ }
@@ -6,6 +6,8 @@ import { dslog } from "../slog.js";
6
6
  import { LocalMemoryChangeSchema, LocalMemorySingleLineText, validateLocalMemoryChange, } from "./candidate.js";
7
7
  import { LOCAL_MEMORY_INDEX_CODE_POINT_CAP, LOCAL_MEMORY_INDEX_ENTRY_CAP, LOCAL_MEMORY_TOTAL_CODE_POINT_CAP, renderGeneratedIndex, selectLocalMemoryIndexEntries, } from "./index-budget.js";
8
8
  import { persistPendingMemoryCandidate, readPendingMemoryCandidates, removePendingMemoryCandidate, } from "./pending.js";
9
+ import { assertLocalMemoryStorageSize, detectLocalMemoryStorageVersion, LOCAL_MEMORY_GENERATED_INDEX_PATTERN, LOCAL_MEMORY_NOTE_BYTE_CAP, LOCAL_MEMORY_READ_BYTE_CAP, parseFullMemoryIndex, replaceFullMemoryIndex, selectLocalMemoryPreview, } from "./storage-format.js";
10
+ import { assertMemoryWriterAllowed, withMemoryAccess } from "./migration-access.js";
9
11
  const StatusChangeSchema = z.object({
10
12
  memoryId: LocalMemorySingleLineText,
11
13
  expectedRevision: z.number().int().positive(),
@@ -90,12 +92,14 @@ function parseCards(section) {
90
92
  });
91
93
  }
92
94
  export function parseLocalMemoryIndex(memory) {
95
+ if (detectLocalMemoryStorageVersion(memory) === 2)
96
+ return parseFullMemoryIndex(memory);
93
97
  const section = indexSection(memory);
94
98
  const generated = GENERATED_INDEX_PATTERN.exec(section)?.[0];
95
99
  return parseCards(generated ?? section);
96
100
  }
97
101
  function parseUnmarkedMemoryIndex(memory) {
98
- const section = indexSection(memory).replace(GENERATED_INDEX_PATTERN, "");
102
+ const section = indexSection(memory).replace(LOCAL_MEMORY_GENERATED_INDEX_PATTERN, "");
99
103
  return parseCards(section);
100
104
  }
101
105
  function replaceGeneratedIndex(memory, entries) {
@@ -122,11 +126,36 @@ function noteSection(id, revision, title, body) {
122
126
  }
123
127
  function notePattern(id) {
124
128
  const escaped = id.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
125
- return new RegExp(`<!--\\s*NOWWORK:MEMORY_ENTRY:BEGIN\\s+id=${escaped}[^>]*-->[\\s\\S]*?<!--\\s*NOWWORK:MEMORY_ENTRY:END\\s+id=${escaped}\\s*-->\\s*`, "u");
129
+ return new RegExp(`<!--\\s*NOWWORK:MEMORY_ENTRY:BEGIN\\s+id=${escaped}(?=\\s|-->)[^>]*-->[\\s\\S]*?<!--\\s*NOWWORK:MEMORY_ENTRY:END\\s+id=${escaped}\\s*-->\\s*`, "u");
130
+ }
131
+ function uniqueNoteSection(content, id) {
132
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
133
+ const begins = [...content.matchAll(new RegExp(`<!--\\s*NOWWORK:MEMORY_ENTRY:BEGIN\\s+id=${escaped}(?=\\s|-->)`, "gu"))];
134
+ const ends = [...content.matchAll(new RegExp(`<!--\\s*NOWWORK:MEMORY_ENTRY:END\\s+id=${escaped}\\s*-->`, "gu"))];
135
+ if (begins.length === 0 && ends.length === 0)
136
+ return null;
137
+ const section = notePattern(id).exec(content)?.[0];
138
+ if (begins.length !== 1 || ends.length !== 1 || section === undefined
139
+ || (section.match(/NOWWORK:MEMORY_ENTRY:(?:BEGIN|END)/gu) ?? []).length !== 2) {
140
+ throw new Error("local_memory_note_anchor_invalid");
141
+ }
142
+ return section;
126
143
  }
127
144
  function replaceNote(content, id, replacement) {
128
145
  const pattern = notePattern(id);
129
- return pattern.test(content) ? content.replace(pattern, replacement) : null;
146
+ return uniqueNoteSection(content, id) !== null ? content.replace(pattern, () => replacement) : null;
147
+ }
148
+ function updateNoteRevision(content, id, revision) {
149
+ const section = uniqueNoteSection(content, id);
150
+ const beginning = `<!-- NOWWORK:MEMORY_ENTRY:BEGIN id=${id} `;
151
+ const ending = `<!-- NOWWORK:MEMORY_ENTRY:END id=${id} -->`;
152
+ if (section === null || content.split(beginning).length !== 2 || content.split(ending).length !== 2) {
153
+ throw new Error("local_memory_note_anchor_missing");
154
+ }
155
+ const updated = section.replace(/^(<!--\s*NOWWORK:MEMORY_ENTRY:BEGIN\s+id=[A-Za-z0-9_-]+\s+revision=)[1-9][0-9]*(\s*-->)/u, (_match, prefix, suffix) => `${prefix}${revision}${suffix}`);
156
+ if (!updated.startsWith(`${beginning}revision=${revision} -->`))
157
+ throw new Error("local_memory_note_anchor_missing");
158
+ return content.replace(notePattern(id), () => updated);
130
159
  }
131
160
  function validateCandidate(result) {
132
161
  if (result.outcome === "no_change" && (result.changes.length > 0 || result.statusChanges.length > 0)) {
@@ -199,14 +228,14 @@ function detailCategoryFromFile(file) {
199
228
  return DETAIL_CATEGORIES.has(category) ? category : null;
200
229
  }
201
230
  function readNoteBody(content, id) {
202
- const begin = content.indexOf(`<!-- NOWWORK:MEMORY_ENTRY:BEGIN id=${id}`);
203
- if (begin < 0)
231
+ const section = uniqueNoteSection(content, id);
232
+ if (section === null)
204
233
  return null;
205
- const bodyStart = content.indexOf("\n\n", begin);
206
- const bodyEnd = content.indexOf(`\n<!-- NOWWORK:MEMORY_ENTRY:END id=${id}`, bodyStart + 2);
234
+ const bodyStart = section.indexOf("\n\n");
235
+ const bodyEnd = section.indexOf(`\n<!-- NOWWORK:MEMORY_ENTRY:END id=${id}`, bodyStart + 2);
207
236
  if (bodyStart < 0 || bodyEnd < 0)
208
237
  return null;
209
- return content.slice(bodyStart + 2, bodyEnd).trim();
238
+ return section.slice(bodyStart + 2, bodyEnd).trim();
210
239
  }
211
240
  function archivedChange(entry, detailBody) {
212
241
  const detailCategory = detailCategoryFromFile(entry.file);
@@ -244,12 +273,29 @@ async function assertWritablePaths(agentDir, memoryPath) {
244
273
  if (!memoryStat.isFile() || memoryStat.isSymbolicLink())
245
274
  throw new Error("local_memory_memory_path_invalid");
246
275
  }
276
+ async function assertMemoryAccessDirectory(agentDir) {
277
+ try {
278
+ const stat = await lstat(join(agentDir, ".crew/memory"));
279
+ if (!stat.isDirectory() || stat.isSymbolicLink())
280
+ throw new Error("local_memory_pending_path_invalid");
281
+ }
282
+ catch (error) {
283
+ if (error.code === "ENOENT")
284
+ return;
285
+ throw error;
286
+ }
287
+ }
247
288
  async function readNote(path) {
248
289
  try {
249
290
  const stat = await lstat(path);
250
291
  if (!stat.isFile() || stat.isSymbolicLink())
251
292
  throw new Error("local_memory_note_path_invalid");
252
- return await readFile(path, "utf8");
293
+ if (stat.size > LOCAL_MEMORY_NOTE_BYTE_CAP)
294
+ throw new Error("local_memory_note_byte_limit");
295
+ const content = await readFile(path, "utf8");
296
+ if (Buffer.byteLength(content, "utf8") > LOCAL_MEMORY_NOTE_BYTE_CAP)
297
+ throw new Error("local_memory_note_byte_limit");
298
+ return content;
253
299
  }
254
300
  catch (error) {
255
301
  if (error.code === "ENOENT")
@@ -269,6 +315,11 @@ async function atomicWrite(path, content) {
269
315
  }
270
316
  }
271
317
  function projectIndex(memory, entries, protectedIds) {
318
+ if (detectLocalMemoryStorageVersion(memory) === 2) {
319
+ const updated = replaceFullMemoryIndex(memory, entries);
320
+ selectLocalMemoryPreview(updated);
321
+ return { memory: updated, omittedIds: [], reason: null };
322
+ }
272
323
  const empty = replaceGeneratedIndex(memory, []);
273
324
  if (codePointLength(empty) > LOCAL_MEMORY_TOTAL_CODE_POINT_CAP)
274
325
  throw new Error("local_memory_memory_over_budget");
@@ -306,12 +357,27 @@ async function withApplyLease(agentDir, operation) {
306
357
  }
307
358
  export async function applyLocalMemoryResult(input) {
308
359
  validateCandidate(input.result);
309
- if (input.result.outcome !== "proposed")
360
+ if (input.result.outcome !== "proposed" && input.requiredStorageVersion !== 2)
310
361
  return emptyResult();
311
362
  const memoryPath = join(input.agentDir, "MEMORY.md");
312
- await assertWritablePaths(input.agentDir, memoryPath);
313
- return withApplyLease(input.agentDir, async () => {
363
+ if (input.result.outcome === "proposed")
364
+ await assertWritablePaths(input.agentDir, memoryPath);
365
+ await assertMemoryAccessDirectory(input.agentDir);
366
+ return withApplyLease(input.agentDir, async () => withMemoryAccess(input.agentDir, async () => {
367
+ await assertMemoryWriterAllowed(input.agentDir, input.writerCapabilities ?? []);
368
+ const memoryStat = await lstat(memoryPath);
369
+ if (memoryStat.size > 16 * 1024 * 1024)
370
+ throw new Error("local_memory_storage_byte_limit");
314
371
  const memory = await readFile(memoryPath, "utf8");
372
+ assertLocalMemoryStorageSize(memory);
373
+ const fullStorage = detectLocalMemoryStorageVersion(memory) === 2;
374
+ if (input.requiredStorageVersion === 2 && !fullStorage) {
375
+ throw new Error("local_memory_v2_state_invalid_before_commit");
376
+ }
377
+ // A V2 no-op is still a commit decision. Return only after the access
378
+ // lock and committed-state writer gate have validated the same snapshot.
379
+ if (input.result.outcome !== "proposed")
380
+ return emptyResult();
315
381
  if (parseUnmarkedMemoryIndex(memory).length > 0)
316
382
  throw new Error("local_memory_legacy_index_requires_migration");
317
383
  let entries = [...parseLocalMemoryIndex(memory)];
@@ -342,6 +408,26 @@ export async function applyLocalMemoryResult(input) {
342
408
  const pendingCandidates = [];
343
409
  const resolvedPendingIds = new Set();
344
410
  const noteUpdates = new Map();
411
+ const noteCache = new Map();
412
+ let totalReadBytes = Buffer.byteLength(memory, "utf8");
413
+ const readStoredNote = async (path) => {
414
+ const cached = noteUpdates.get(path) ?? noteCache.get(path);
415
+ if (cached !== undefined)
416
+ return cached;
417
+ const stat = await lstat(path).catch((error) => {
418
+ if (error.code === "ENOENT")
419
+ return null;
420
+ throw error;
421
+ });
422
+ if (stat !== null && totalReadBytes + stat.size > LOCAL_MEMORY_READ_BYTE_CAP)
423
+ throw new Error("local_memory_read_byte_limit");
424
+ const content = await readNote(path);
425
+ totalReadBytes += Buffer.byteLength(content, "utf8");
426
+ if (totalReadBytes > LOCAL_MEMORY_READ_BYTE_CAP)
427
+ throw new Error("local_memory_read_byte_limit");
428
+ noteCache.set(path, content);
429
+ return content;
430
+ };
345
431
  let allowActiveEviction = false;
346
432
  const randomId = input.randomId ?? randomUUID;
347
433
  const executionId = input.executionId ?? "unknown";
@@ -383,10 +469,21 @@ export async function applyLocalMemoryResult(input) {
383
469
  if (existingCreate !== undefined && !matchesCreateCandidate(existingCreate, change)) {
384
470
  throw new Error("local_memory_duplicate_candidate");
385
471
  }
386
- if (existingCreate === undefined && entries.some((entry) => normalize(entry.title) === normalize(change.title)
387
- || normalize(entry.summary) === normalize(change.summary))) {
472
+ if (existingCreate === undefined && entries.some((entry) => (!fullStorage || entry.status === "active" || entry.status === "pinned")
473
+ && (normalize(entry.title) === normalize(change.title) || normalize(entry.summary) === normalize(change.summary)))) {
388
474
  throw new Error("local_memory_duplicate_candidate");
389
475
  }
476
+ if (existingCreate !== undefined) {
477
+ if (existingCreate.revision !== 1 || existingCreate.status !== "active") {
478
+ throw new Error("local_memory_create_replay_conflict");
479
+ }
480
+ const stored = await readStoredNote(resolve(input.agentDir, existingCreate.file));
481
+ if (uniqueNoteSection(stored, id)?.trimEnd() !== noteSection(id, 1, change.title, change.detailBody).trimEnd()) {
482
+ throw new Error("local_memory_create_replay_conflict");
483
+ }
484
+ // A proven duplicate is a no-op; it must never rewrite a newer body or status.
485
+ continue;
486
+ }
390
487
  }
391
488
  const revision = existingCreate?.revision ?? (current === undefined ? 1 : current.revision + 1);
392
489
  const entry = existingCreate ?? entryFromChange(change, id, revision, current?.status ?? "active");
@@ -400,7 +497,7 @@ export async function applyLocalMemoryResult(input) {
400
497
  : item),
401
498
  entry,
402
499
  ];
403
- const allowEvictionForChange = change.operation === "create" && change.priority === "high";
500
+ const allowEvictionForChange = !fullStorage && change.operation === "create" && change.priority === "high";
404
501
  const protectedIds = new Set(allowEvictionForChange ? pinnedIds(entries) : activeOrPinnedIds(entries));
405
502
  if (allowEvictionForChange)
406
503
  allowActiveEviction = true;
@@ -417,7 +514,7 @@ export async function applyLocalMemoryResult(input) {
417
514
  const notePath = resolve(input.agentDir, entry.file);
418
515
  if (!isWithin(resolve(input.agentDir, "notes"), notePath))
419
516
  throw new Error("local_memory_note_path_invalid");
420
- const previous = noteUpdates.get(notePath) ?? await readNote(notePath);
517
+ const previous = await readStoredNote(notePath);
421
518
  const replacement = noteSection(id, revision, change.title, change.detailBody);
422
519
  const existingSection = replaceNote(previous, id, replacement);
423
520
  const next = existingSection ?? (current === undefined
@@ -435,7 +532,7 @@ export async function applyLocalMemoryResult(input) {
435
532
  // Pending create candidates are durable overflow, not dead ends. Promote only
436
533
  // when the current catalog has room; supersede/update candidates stay pending
437
534
  // until a fresh revision-aware decision can safely replay them.
438
- const pendingCandidatesOnDisk = await readPendingMemoryCandidates({ agentDir: input.agentDir }).catch((error) => {
535
+ const pendingCandidatesOnDisk = fullStorage ? [] : await readPendingMemoryCandidates({ agentDir: input.agentDir }).catch((error) => {
439
536
  if (!(error instanceof Error) || error.message !== "memory_pending_directory_over_budget")
440
537
  throw error;
441
538
  // 自动提升是可选工作,目录查询预算不应阻断本轮已验证的更新或腾位操作。
@@ -460,7 +557,7 @@ export async function applyLocalMemoryResult(input) {
460
557
  || normalize(entry.summary) === normalize(change.summary)));
461
558
  if (duplicate !== undefined) {
462
559
  const duplicatePath = resolve(input.agentDir, duplicate.file);
463
- const duplicateBody = readNoteBody(noteUpdates.get(duplicatePath) ?? await readNote(duplicatePath), duplicate.id);
560
+ const duplicateBody = readNoteBody(await readStoredNote(duplicatePath), duplicate.id);
464
561
  if (duplicateBody === change.detailBody
465
562
  || duplicateBody === unwrappedNoteBody(change.title, change.detailBody).trim()) {
466
563
  resolvedPendingIds.add(pending.pendingId);
@@ -478,7 +575,7 @@ export async function applyLocalMemoryResult(input) {
478
575
  const notePath = resolve(input.agentDir, entry.file);
479
576
  if (!isWithin(resolve(input.agentDir, "notes"), notePath))
480
577
  throw new Error("local_memory_note_path_invalid");
481
- const previous = noteUpdates.get(notePath) ?? await readNote(notePath);
578
+ const previous = await readStoredNote(notePath);
482
579
  const replacement = noteSection(id, 1, change.title, change.detailBody);
483
580
  const existingSection = replaceNote(previous, id, replacement);
484
581
  const next = existingSection ?? `${previous.trimEnd()}${previous.trimEnd() ? "\n\n" : ""}${replacement}`;
@@ -487,6 +584,17 @@ export async function applyLocalMemoryResult(input) {
487
584
  createdIds.push(id);
488
585
  resolvedPendingIds.add(pending.pendingId);
489
586
  }
587
+ if (fullStorage) {
588
+ const changedMetadataIds = new Set([
589
+ ...statusChangedIds,
590
+ ...input.result.changes.flatMap(change => change.supersedesMemoryId === null ? [] : [change.supersedesMemoryId]),
591
+ ]);
592
+ for (const id of changedMetadataIds) {
593
+ const entry = entries.find(item => item.id === id);
594
+ const notePath = resolve(input.agentDir, entry.file);
595
+ noteUpdates.set(notePath, updateNoteRevision(await readStoredNote(notePath), id, entry.revision));
596
+ }
597
+ }
490
598
  let finalProjection = projectIndex(memory, entries, allowActiveEviction ? pinnedIds(entries) : activeOrPinnedIds(entries));
491
599
  if (finalProjection.reason !== null
492
600
  && (!allowActiveEviction || finalProjection.reason === "index_over_budget_pinned")) {
@@ -498,7 +606,7 @@ export async function applyLocalMemoryResult(input) {
498
606
  .filter((entry) => entry?.status === "active");
499
607
  for (const entry of omittedActiveEntries) {
500
608
  const notePath = resolve(input.agentDir, entry.file);
501
- const content = noteUpdates.get(notePath) ?? await readNote(notePath);
609
+ const content = await readStoredNote(notePath);
502
610
  const detailBody = readNoteBody(content, entry.id);
503
611
  const change = detailBody === null ? null : archivedChange(entry, detailBody);
504
612
  if (change === null)
@@ -519,6 +627,12 @@ export async function applyLocalMemoryResult(input) {
519
627
  if (finalProjection.reason !== null)
520
628
  throw new Error(finalProjection.reason);
521
629
  }
630
+ // Complete every resource check before the first persistent write.
631
+ assertLocalMemoryStorageSize(finalProjection.memory);
632
+ for (const content of noteUpdates.values()) {
633
+ if (Buffer.byteLength(content, "utf8") > LOCAL_MEMORY_NOTE_BYTE_CAP)
634
+ throw new Error("local_memory_note_byte_limit");
635
+ }
522
636
  for (const candidate of pendingCandidates)
523
637
  await persistPendingMemoryCandidate({ agentDir: input.agentDir, candidate });
524
638
  for (const [path, content] of noteUpdates)
@@ -535,5 +649,5 @@ export async function applyLocalMemoryResult(input) {
535
649
  deferredIds,
536
650
  pendingIds: deferredIds,
537
651
  };
538
- });
652
+ }));
539
653
  }
@@ -0,0 +1,13 @@
1
+ import { detectLocalMemoryStorageVersion } from "./storage-format.js";
2
+ /** Resolve format and rollout into one writer decision before a prune result is requested. */
3
+ export function resolveLocalMemoryWriterMode(workspace, rollout) {
4
+ if (rollout.legacyFallback)
5
+ return "legacy";
6
+ if (!rollout.enabled)
7
+ return rollout.deferred ? "blocked" : "legacy";
8
+ // Production workspaces always report this fact. Historical partial unit
9
+ // callers retain their already-resolved rollout witness.
10
+ if (workspace.localMemoryV2Initialized === undefined)
11
+ return "v2";
12
+ return detectLocalMemoryStorageVersion(workspace.memory) === 2 ? "v2" : "blocked";
13
+ }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readFile, stat } from "node:fs/promises";
2
+ import { lstat, readFile, readdir, stat } from "node:fs/promises";
3
3
  import { isAbsolute, join, normalize, relative, resolve } from "node:path";
4
4
  import { parseArgs } from "node:util";
5
5
  import { capMemoryForInject, MEMORY_INJECT_CAP } from "./prompt.js";
@@ -8,6 +8,7 @@ const INJECTION_MARKERS = [
8
8
  "## [注入] 你的 MEMORY.md(索引,只读参考)\n",
9
9
  ];
10
10
  const LOCAL_MEMORY_HASH_CAP_BYTES = 10 * 1024 * 1024;
11
+ export const LOCAL_MEMORY_DIAGNOSTICS_TIMEOUT_MS = 2_000;
11
12
  function errorCode(error) {
12
13
  const code = error?.code;
13
14
  return typeof code === "string" ? code : error instanceof Error ? error.name : "unknown";
@@ -39,30 +40,75 @@ async function inspectFile(path, signal) {
39
40
  return { ...base, error_code: errorCode(error) };
40
41
  }
41
42
  }
42
- export async function inspectLocalMemoryFilesWithinDeadline(homeDir, timeoutMs) {
43
+ async function inspectPendingDirectory(path, signal) {
44
+ let metadata;
45
+ try {
46
+ metadata = await lstat(path);
47
+ }
48
+ catch (error) {
49
+ const code = errorCode(error);
50
+ return code === "ENOENT" ? { exists: false, entry_count: 0 } : { exists: false, error_code: code };
51
+ }
52
+ const base = { exists: true, mtime_ms: metadata.mtimeMs };
53
+ if (metadata.isSymbolicLink() || !metadata.isDirectory())
54
+ return { ...base, hash_skipped_reason: "not_regular_file" };
55
+ try {
56
+ const names = (await readdir(path, { withFileTypes: true }))
57
+ .filter(entry => entry.name.endsWith(".json"))
58
+ .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
59
+ if (names.length > 128)
60
+ return { ...base, entry_count: names.length, error_code: "entry_limit" };
61
+ const hash = createHash("sha256");
62
+ let size = 0;
63
+ for (const entry of names) {
64
+ if (signal.aborted)
65
+ throw signal.reason;
66
+ if (!entry.isFile() || entry.isSymbolicLink())
67
+ return { ...base, entry_count: names.length, error_code: "invalid_entry" };
68
+ const fact = await inspectFile(join(path, entry.name), signal);
69
+ if (!fact.exists || fact.sha256 === undefined || fact.error_code !== undefined
70
+ || fact.hash_skipped_reason !== undefined)
71
+ return { ...base, entry_count: names.length, error_code: "incomplete_entry" };
72
+ size += fact.size_bytes ?? 0;
73
+ hash.update(entry.name).update("\0").update(fact.sha256).update("\0");
74
+ }
75
+ return { ...base, size_bytes: size, entry_count: names.length, sha256: hash.digest("hex") };
76
+ }
77
+ catch (error) {
78
+ return { ...base, error_code: errorCode(error) };
79
+ }
80
+ }
81
+ export async function withinLocalMemoryDiagnosticsDeadline(operation, timeoutMs) {
43
82
  const controller = new AbortController();
44
83
  let timer;
45
- const inspection = Promise.all([
46
- inspectFile(join(homeDir, "MEMORY.md"), controller.signal),
47
- inspectFile(join(homeDir, "notes", "lessons.md"), controller.signal),
48
- ]).then(([memory, lessons]) => ({ memory, lessons }));
49
84
  const timeout = new Promise((_resolve, reject) => {
50
85
  timer = setTimeout(() => {
51
- controller.abort();
52
- reject(Object.assign(new Error("local memory diagnostics timed out"), {
86
+ const error = Object.assign(new Error("local memory diagnostics timed out"), {
53
87
  code: "diagnostics_timeout",
54
- }));
88
+ });
89
+ controller.abort(error);
90
+ reject(error);
55
91
  }, Math.max(1, timeoutMs));
56
92
  timer.unref?.();
57
93
  });
58
94
  try {
59
- return await Promise.race([inspection, timeout]);
95
+ return await Promise.race([operation(controller.signal), timeout]);
60
96
  }
61
97
  finally {
62
98
  if (timer !== undefined)
63
99
  clearTimeout(timer);
64
100
  }
65
101
  }
102
+ export async function inspectLocalMemoryFilesWithinDeadline(homeDir, timeoutMs) {
103
+ return withinLocalMemoryDiagnosticsDeadline(async (signal) => {
104
+ const [memory, lessons, pending] = await Promise.all([
105
+ inspectFile(join(homeDir, "MEMORY.md"), signal),
106
+ inspectFile(join(homeDir, "notes", "lessons.md"), signal),
107
+ inspectPendingDirectory(join(homeDir, ".crew", "memory", "pending"), signal),
108
+ ]);
109
+ return { memory, lessons, pending };
110
+ }, timeoutMs);
111
+ }
66
112
  export function localMemoryContentFacts(content, maxScanBytes = LOCAL_MEMORY_HASH_CAP_BYTES) {
67
113
  const sizeBytes = Buffer.byteLength(content, "utf8");
68
114
  if (sizeBytes > Math.max(0, maxScanBytes)) {
@@ -217,15 +263,50 @@ function crewMemoryReadAccess(command, paths) {
217
263
  return null;
218
264
  }
219
265
  }
266
+ /**
267
+ * Decode only the shell wrapper shape emitted by supported runtimes. The body must be one complete
268
+ * quoted argument. Double-quoted bodies may escape only a quote or backslash; shell expansion and
269
+ * concatenated quoting stay unknown so telemetry never guesses what the shell actually executed.
270
+ */
271
+ function unwrapLiteralShellCommand(command) {
272
+ const trimmed = command.trim();
273
+ const wrapper = /^\/bin\/(?:(?:zsh|bash)\s+-lc|sh\s+-c)\s+([\s\S]+)$/u.exec(trimmed);
274
+ if (wrapper === null)
275
+ return trimmed;
276
+ const quoted = wrapper[1];
277
+ const quote = quoted[0];
278
+ if ((quote !== "'" && quote !== '"') || quoted.at(-1) !== quote || quoted.length < 2)
279
+ return null;
280
+ const body = quoted.slice(1, -1);
281
+ if (quote === "'")
282
+ return body.includes("'") ? null : body;
283
+ let decoded = "";
284
+ for (let index = 0; index < body.length; index += 1) {
285
+ const character = body[index];
286
+ if (character === '"' || character === "$" || character === "`")
287
+ return null;
288
+ if (character !== "\\") {
289
+ decoded += character;
290
+ continue;
291
+ }
292
+ const escaped = body[index + 1];
293
+ if (escaped !== '"' && escaped !== "\\")
294
+ return null;
295
+ decoded += escaped;
296
+ index += 1;
297
+ }
298
+ return decoded;
299
+ }
220
300
  function commandAccesses(command, paths) {
221
- if (/[;|&\n]/.test(command))
301
+ const unwrapped = unwrapLiteralShellCommand(command);
302
+ if (unwrapped === null || /[;|&\n]/.test(unwrapped))
222
303
  return [];
223
304
  const accesses = new Map();
224
- const memoryRead = crewMemoryReadAccess(command, paths);
305
+ const memoryRead = crewMemoryReadAccess(unwrapped, paths);
225
306
  if (memoryRead !== null) {
226
307
  accesses.set(`${memoryRead.target}:${memoryRead.relative_path ?? ""}:read`, memoryRead);
227
308
  }
228
- const expanded = command
309
+ const expanded = unwrapped
229
310
  .replaceAll("${CREW_HOME}", paths.homeDir)
230
311
  .replaceAll("$CREW_HOME", paths.homeDir)
231
312
  .replace(/["']/g, "");