@nowcrew/daemon 0.6.88-beta.10 → 0.6.88-beta.12

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.
@@ -0,0 +1,22 @@
1
+ import { awaitWithCancellation } from "./runtime-cancellation.js";
2
+ const leaseTails = new Map();
3
+ /** 同一进程内的短操作或原生会话排队;等待取消也不会越过前一位持有者。 */
4
+ export async function withKeyedLease(key, operation, cancellation) {
5
+ const predecessor = leaseTails.get(key) ?? Promise.resolve();
6
+ // 解锁函数在等待 predecessor 前可用,保留取消等待时的队列顺序。
7
+ let unlock;
8
+ const current = new Promise(resolve => { unlock = resolve; });
9
+ const queued = predecessor.then(() => current);
10
+ leaseTails.set(key, queued);
11
+ try {
12
+ await awaitWithCancellation(predecessor, cancellation);
13
+ return await operation();
14
+ }
15
+ finally {
16
+ unlock();
17
+ void queued.then(() => {
18
+ if (leaseTails.get(key) === queued)
19
+ leaseTails.delete(key);
20
+ });
21
+ }
22
+ }
@@ -17,6 +17,9 @@ import { extractFinalText, extractRunMeta, normalizeEvent, parseLine, } from "./
17
17
  import { readSession, writeSession, pickResumeId, isNearBudget } from "./session.js";
18
18
  import { NativeThreadContext, isolateMaintenanceSession } from "./native-thread-context.js";
19
19
  import { prepareThreadDelivery } from "./thread-input-delivery.js";
20
+ import { resolveMemoryPruneWorkLog, workLogSha256 } from "./local-memory/work-log.js";
21
+ import { withKeyedLease } from "./local-execution-lease.js";
22
+ import { withWorkLogExecution, withWorkLogCommit, workLogExecutionKey } from "./local-memory/work-log-execution.js";
20
23
  import { providerErrorFromExit } from "./provider-error.js";
21
24
  import { createConsoleFormatter } from "./console-formatter.js";
22
25
  import { withLocalExecutionFacts, } from "./local-execution-prompt.js";
@@ -159,28 +162,12 @@ async function launchLegacyRuntime(request) {
159
162
  function resolvePrompt(prompt, context) {
160
163
  return typeof prompt === "string" ? prompt : prompt(context);
161
164
  }
162
- const nativeSessionLeaseTails = new Map();
163
165
  const activeMemoryPrunes = new Map();
164
- async function withKeyedLease(key, operation, cancellation) {
165
- const predecessor = nativeSessionLeaseTails.get(key) ?? Promise.resolve();
166
- let release;
167
- const current = new Promise((resolve) => { release = resolve; });
168
- const tail = predecessor.then(() => current);
169
- nativeSessionLeaseTails.set(key, tail);
170
- try {
171
- await awaitWithCancellation(predecessor, cancellation);
172
- return await operation();
173
- }
174
- finally {
175
- release();
176
- void tail.then(() => {
177
- if (nativeSessionLeaseTails.get(key) === tail)
178
- nativeSessionLeaseTails.delete(key);
179
- });
180
- }
181
- }
182
166
  export async function executeLocal(input, callbacks = {}, dependencies = {}) {
183
167
  input = isolateMaintenanceSession(input);
168
+ return withWorkLogExecution(workLogExecutionKey(input.launch.agentsRoot, input.handle, input.taskKey), () => executeLocalWithSession(input, callbacks, dependencies), dependencies.cancellation);
169
+ }
170
+ async function executeLocalWithSession(input, callbacks, dependencies) {
184
171
  const versionedProjectSkills = input.projectSkills !== undefined
185
172
  && !Array.isArray(input.projectSkills);
186
173
  if (versionedProjectSkills && (input.runtime.name !== "codex" && input.runtime.name !== "claude")) {
@@ -357,7 +344,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
357
344
  ? await rotateAgentSession(workspace.sessionDir)
358
345
  : workspace.agentSessionId);
359
346
  await materializePrivateCodexHome({ runtime: runtime.name, homeDir: workspace.homeDir, ...(input.launch.codexHomeMode === undefined ? {} : { mode: input.launch.codexHomeMode }), ...(sourceCodexHome === undefined ? {} : { sourceHome: sourceCodexHome }), ...(launchSessionId === undefined ? {} : { sessionId: launchSessionId }), ...(dependencies.cancellation === undefined ? {} : { cancellation: dependencies.cancellation }), ...(dependencies.materializeDefaultCodexHome === undefined ? {} : { materialize: dependencies.materializeDefaultCodexHome }) });
360
- const promptContext = {
347
+ let promptContext = {
361
348
  workspace: executionWorkspace,
362
349
  resuming,
363
350
  rotatedForBudget,
@@ -396,8 +383,13 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
396
383
  }
397
384
  }
398
385
  const attachmentPlan = routeRuntimeAttachments(runtime.name, materialized?.attachments ?? []);
399
- const baseWakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
386
+ let baseWakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
400
387
  memoryPruneTraceId = parseMemoryPruneTraceId(baseWakePrompt);
388
+ if (memoryPruneTraceId !== null) {
389
+ executionWorkspace = await resolveMemoryPruneWorkLog(executionWorkspace, input);
390
+ promptContext = { ...promptContext, workspace: executionWorkspace };
391
+ baseWakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
392
+ }
401
393
  if (input.localMemoryV2Enabled === true && memoryPruneTraceId !== null
402
394
  && isLocalMemoryV2Eligible({
403
395
  enabledForHandle: true,
@@ -937,14 +929,17 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
937
929
  memoryPruneRuntimeExitCode = exitCode;
938
930
  if (localMemoryResultPath !== null) {
939
931
  memoryPruneFailurePhase = "memory_commit";
940
- const committed = await commitLocalMemoryResultAfterExit({
941
- resultPath: localMemoryResultPath,
932
+ const resultPath = localMemoryResultPath;
933
+ const commit = () => commitLocalMemoryResultAfterExit({
934
+ resultPath,
942
935
  agentDir: executionWorkspace.dir,
943
936
  executionId: input.executionId,
944
937
  pruneTraceId: memoryPruneTraceId,
945
938
  exitCode,
946
939
  workLogPath: executionWorkspace.workLogPath,
940
+ expectedWorkLogSha256: workLogSha256(executionWorkspace.workLog),
947
941
  });
942
+ const committed = exitCode !== 0 ? await commit() : await withWorkLogCommit(workLogExecutionKey(input.launch.agentsRoot, input.handle, input.taskKey), commit, dependencies.cancellation);
948
943
  if (committed.status === "failed" || committed.status === "missing") {
949
944
  const reason = committed.error ?? "local_memory_result_missing";
950
945
  dslog("local_memory.commit_failed", "结构化本地记忆提交或清理未完成,保留可用恢复材料", {
@@ -19,6 +19,14 @@ export function renderLocalMemoryIndexCard(entry) {
19
19
  + `- updated: ${entry.updated}\n`
20
20
  + (entry.reviewAfter === null ? "" : `- review_after: ${entry.reviewAfter}\n`);
21
21
  }
22
+ /** 写入和预演共用完整索引,预算包含起止标记与分隔换行。 */
23
+ export function renderGeneratedIndex(entries) {
24
+ const cards = entries
25
+ .filter(entry => PROJECTABLE_STATUSES.has(entry.status))
26
+ .map(renderLocalMemoryIndexCard)
27
+ .join("\n");
28
+ return `<!-- NOWWORK:MEMORY_INDEX:BEGIN schema=1 -->\n${cards}<!-- NOWWORK:MEMORY_INDEX:END -->`;
29
+ }
22
30
  function compareEntries(left, right) {
23
31
  const statusRank = (status) => status === "pinned" ? 0 : 1;
24
32
  const priorityRank = (priority) => (priority === "high" ? 0 : priority === "medium" ? 1 : 2);
@@ -0,0 +1,61 @@
1
+ const CommandSchema = z.object({
2
+ "agent-dir": z.string().min(1).refine(isAbsolute),
3
+ "dry-run": z.literal(true),
4
+ }).strict();
5
+ const USAGE = "只读记忆盘点与转换预演,不写文件,不支持 --apply。\n"
6
+ + "pnpm --filter @nowcrew/daemon exec tsx src/local-memory/inventory-cli.ts --agent-dir <单个Agent绝对路径> --dry-run\n"
7
+ + "报告包含原始记忆,需作为敏感本地资料保存;退出码0仅代表盘点及格式预演无阻塞,不代表批准迁移。\n";
8
+ export async function runMemoryInventoryCommand(args, io) {
9
+ if (args.length === 1 && args[0] === "--help") {
10
+ io.out(USAGE);
11
+ return 0;
12
+ }
13
+ let command;
14
+ try {
15
+ const parsed = parseArgs({ args, allowPositionals: false, options: {
16
+ "agent-dir": { type: "string" }, "dry-run": { type: "boolean" },
17
+ } });
18
+ command = CommandSchema.parse(parsed.values);
19
+ }
20
+ catch {
21
+ io.err(USAGE);
22
+ return 2;
23
+ }
24
+ try {
25
+ const scan = await scanMemorySources(command["agent-dir"]);
26
+ const scanComplete = scan.issues.length === 0;
27
+ const analysis = previewMemoryInventory(scan.sources, scanComplete);
28
+ const complete = scanComplete && !analysis.issues.some((issue) => issue.code === "missing_detail");
29
+ const issues = [...scan.issues, ...analysis.issues];
30
+ const referenced = new Set(analysis.entries.map((entry) => entry.sourceFields.file));
31
+ const files = scan.sources.map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 }));
32
+ const report = {
33
+ schemaVersion: 1, mode: "dry-run", agentDir: scan.root, applySupported: false,
34
+ complete, scanComplete, requiresHumanReview: true,
35
+ coverage: ["MEMORY.md", "notes/**", ".crew/memory/pending/**", "根目录的 .memory-result.v1-*.json"],
36
+ limitations: ["不扫描历史执行目录;result清单仅覆盖Agent根目录。", "两遍摘要比对不等同跨文件原子快照,正式迁移仍需停止写入并重新核对。"],
37
+ limits: inventoryLimits, totalBytes: scan.totalBytes, files, ...analysis, issues,
38
+ pending: files.filter((file) => file.path.startsWith(".crew/memory/pending/")).map((file) => file.path),
39
+ orphanNotes: files.filter((file) => file.path.startsWith("notes/") && !referenced.has(file.path)).map((file) => file.path),
40
+ resultFiles: files.filter((file) => file.path.startsWith(".memory-result.v1-")).map((file) => file.path),
41
+ };
42
+ io.out(JSON.stringify(report, null, 2) + "\n");
43
+ return issues.length ? 1 : 0;
44
+ }
45
+ catch (error) {
46
+ const code = error instanceof Error ? error.code ?? error.message : "inventory_failed";
47
+ io.err(`记忆盘点失败:${code}\n`);
48
+ return 1;
49
+ }
50
+ }
51
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
52
+ process.exitCode = await runMemoryInventoryCommand(process.argv.slice(2), {
53
+ out: (text) => process.stdout.write(text), err: (text) => process.stderr.write(text),
54
+ });
55
+ }
56
+ import { isAbsolute, resolve } from "node:path";
57
+ import { pathToFileURL } from "node:url";
58
+ import { parseArgs } from "node:util";
59
+ import { z } from "zod";
60
+ import { inventoryLimits, scanMemorySources } from "./inventory-source.js";
61
+ import { previewMemoryInventory } from "./inventory-preview.js";
@@ -0,0 +1,154 @@
1
+ import { createHash } from "node:crypto";
2
+ import { LOCAL_MEMORY_INDEX_CODE_POINT_CAP, LOCAL_MEMORY_INDEX_ENTRY_CAP, LOCAL_MEMORY_TOTAL_CODE_POINT_CAP, renderGeneratedIndex, } from "./index-budget.js";
3
+ import { parseLocalMemoryIndex } from "./write.js";
4
+ const GENERATED = /<!--\s*NOWWORK:MEMORY_INDEX:BEGIN\s+schema=1\s*-->[\s\S]*?<!--\s*NOWWORK:MEMORY_INDEX:END\s*-->/gu;
5
+ const REFERENCE = /^notes\/(?!.*(?:^|\/)\.\.?\/)(?:[^/\\\u0000-\u001f]+\/)*[^/\\\u0000-\u001f]+\.md$/u;
6
+ const FIELD = /^-\s+([a-z][a-z_]*)\s*:\s*(.*?)\s*$/iu;
7
+ const WRITABLE_FILES = new Set(["preferences", "project-rules", "testing", "debugging", "git", "incidents", "lessons"]
8
+ .map((category) => `notes/${category}.md`));
9
+ const length = (text) => Array.from(text).length;
10
+ const digest = (text) => createHash("sha256").update(text).digest("hex");
11
+ export function previewMemoryInventory(sources, readComplete) {
12
+ const issues = [];
13
+ const entries = [];
14
+ const sourceMap = new Map(sources.map((source) => [source.path, source]));
15
+ const memory = sourceMap.get("MEMORY.md")?.content ?? "";
16
+ const add = (code, path = "MEMORY.md") => issues.push({ code, path });
17
+ // 掩盖注释但保留UTF-16位置,避免把模板示例当成条目,也保留原文供人工复核。
18
+ const comments = [...memory.matchAll(/<!--[\s\S]*?-->/gu)];
19
+ if ((memory.match(/<!--/gu) ?? []).length !== comments.length
20
+ || (memory.match(/-->/gu) ?? []).length !== comments.length)
21
+ add("comment_boundary_invalid");
22
+ const masked = memory.replace(/<!--[\s\S]*?-->/gu, (comment) => comment.replace(/[^\r\n]/gu, (character) => " ".repeat(character.length)));
23
+ const sections = [...masked.matchAll(/^##[\t ]+Knowledge Index[\t ]*\r?$/gmu)];
24
+ const heading = sections[0];
25
+ if (sections.length !== 1 || heading?.index === undefined)
26
+ add("index_heading_invalid");
27
+ const start = heading === undefined ? 0 : heading.index + heading[0].length;
28
+ const next = /^##[\t ]+(?!#)/mu.exec(masked.slice(start));
29
+ const end = next?.index === undefined ? memory.length : start + next.index;
30
+ const generated = [...memory.matchAll(GENERATED)];
31
+ const markerCount = (memory.match(/NOWWORK:MEMORY_INDEX:/gu) ?? []).length;
32
+ if (markerCount !== generated.length * 2 || generated.length > 1
33
+ || generated.some((block) => block.index < start || block.index + block[0].length > end))
34
+ add("index_marker_invalid");
35
+ const heads = [...masked.slice(start, end).matchAll(/^###[\t ]+(.+?)[\t ]*\r?$/gmu)];
36
+ if (heads.length > 1000)
37
+ add("card_limit_exceeded");
38
+ const ranges = [];
39
+ for (const [i, head] of heads.slice(0, 1000).entries()) {
40
+ const cardStart = start + head.index;
41
+ const cardEnd = heads[i + 1]?.index === undefined ? end : start + heads[i + 1].index;
42
+ const tailStart = cardStart + head[0].length;
43
+ const tail = masked.slice(tailStart, cardEnd);
44
+ const fields = {};
45
+ const issueStart = issues.length;
46
+ let lastFieldEnd = tailStart;
47
+ let proseSeen = false;
48
+ for (const line of tail.matchAll(/[^\n]*(?:\n|$)/gu)) {
49
+ const field = FIELD.exec(line[0].replace(/\r?\n$/u, ""));
50
+ if (field) {
51
+ const key = field[1].toLowerCase();
52
+ if (fields[key] !== undefined)
53
+ add("duplicate_field");
54
+ if (proseSeen)
55
+ add("ambiguous_card_body");
56
+ fields[key] = field[2];
57
+ lastFieldEnd = tailStart + line.index + line[0].length;
58
+ }
59
+ else if (line[0].trim())
60
+ proseSeen = true;
61
+ }
62
+ const rawCard = memory.slice(cardStart, lastFieldEnd);
63
+ const format = generated.some((block) => cardStart >= block.index && cardStart < block.index + block[0].length)
64
+ ? "managed" : "legacy";
65
+ const parsed = parseLocalMemoryIndex(`## Knowledge Index\n${rawCard}`)[0];
66
+ if (!parsed || !fields.type || !fields.summary || !fields.triggers || !fields.priority || !fields.updated
67
+ || (format === "legacy" && !fields.scope)
68
+ || !["procedural", "semantic", "episodic", "preference"].includes(fields.type)
69
+ || !["high", "medium", "low"].includes(fields.priority))
70
+ add("invalid_card");
71
+ const file = fields.file ?? "";
72
+ if (!REFERENCE.test(file) || file.split("/").some((part) => part === "." || part === ".."))
73
+ add("invalid_reference", file);
74
+ const detail = sourceMap.get(file)?.content ?? null;
75
+ if (detail === null)
76
+ add("missing_detail", file);
77
+ let proposed = null;
78
+ if (parsed) {
79
+ const id = format === "legacy" ? `mem_${digest(rawCard).slice(0, 32)}` : parsed.id;
80
+ proposed = { ...parsed, id, anchor: id, revision: format === "legacy" ? 1 : parsed.revision };
81
+ if (format === "managed") {
82
+ if (!fields.id || !/^[a-zA-Z0-9_-]+$/u.test(fields.id) || fields.anchor !== fields.id
83
+ || !/^[1-9][0-9]*$/u.test(fields.revision ?? "") || !Number.isSafeInteger(parsed.revision)
84
+ || !["active", "pinned"].includes(fields.status ?? ""))
85
+ add("managed_metadata_invalid");
86
+ const begin = `<!-- NOWWORK:MEMORY_ENTRY:BEGIN id=${parsed.id} revision=${parsed.revision} -->`;
87
+ const finish = `<!-- NOWWORK:MEMORY_ENTRY:END id=${parsed.id} -->`;
88
+ if (!detail?.includes(begin) || !detail.includes(finish)
89
+ || detail.indexOf(finish) < detail.indexOf(begin)
90
+ || detail.split(begin).length !== 2 || detail.split(finish).length !== 2)
91
+ add("managed_anchor_invalid", file);
92
+ }
93
+ else if (fields.status && fields.status !== "active") {
94
+ add("legacy_status_requires_review", file);
95
+ }
96
+ if (format === "legacy" && /NOWWORK:MEMORY_(?:ENTRY|INDEX):/u.test(detail ?? ""))
97
+ add("legacy_detail_contains_markers", file);
98
+ }
99
+ entries.push({
100
+ title: head[1], format, disposition: issues.length === issueStart ? "review_required" : "blocked",
101
+ sourceFields: fields, rawCard, detail, proposed,
102
+ });
103
+ ranges.push({ start: cardStart, end: lastFieldEnd });
104
+ }
105
+ for (const entry of entries) {
106
+ if (entries.filter((other) => other.sourceFields.file === entry.sourceFields.file).length > 1
107
+ && entry.format === "legacy")
108
+ add("shared_legacy_detail", entry.sourceFields.file);
109
+ if (entries.filter((other) => other.proposed?.id === entry.proposed?.id).length > 1)
110
+ add("duplicate_id");
111
+ if (entries.filter((other) => other.title.trim().toLowerCase() === entry.title.trim().toLowerCase()).length > 1)
112
+ add("duplicate_title");
113
+ }
114
+ const compatibilityIssues = entries.filter((entry) => !WRITABLE_FILES.has(entry.sourceFields.file ?? ""))
115
+ .map((entry) => ({ code: "detail_category_mapping_required", path: entry.sourceFields.file ?? "" }));
116
+ let preview = null;
117
+ if (readComplete && issues.length === 0) {
118
+ const index = renderGeneratedIndex(entries.map(entry => entry.proposed));
119
+ let retained = memory;
120
+ for (const range of [...ranges].reverse())
121
+ retained = retained.slice(0, range.start) + retained.slice(range.end);
122
+ retained = retained.replace(GENERATED, "");
123
+ // 新生成块仅替换索引卡片,角色、目标、维护说明和其它段落保留。
124
+ const insertAt = start;
125
+ const projected = retained.slice(0, insertAt) + `\n${index}\n` + retained.slice(insertAt);
126
+ const budget = {
127
+ entries: entries.length, indexCodePoints: length(index), totalCodePoints: length(projected),
128
+ entryCap: LOCAL_MEMORY_INDEX_ENTRY_CAP, indexCodePointCap: LOCAL_MEMORY_INDEX_CODE_POINT_CAP,
129
+ totalCodePointCap: LOCAL_MEMORY_TOTAL_CODE_POINT_CAP,
130
+ fits: entries.length <= LOCAL_MEMORY_INDEX_ENTRY_CAP && length(index) <= LOCAL_MEMORY_INDEX_CODE_POINT_CAP
131
+ && length(projected) <= LOCAL_MEMORY_TOTAL_CODE_POINT_CAP,
132
+ };
133
+ preview = {
134
+ memory: projected, budget, writerCompatible: compatibilityIssues.length === 0,
135
+ notes: entries.filter((entry) => entry.format === "legacy").map((entry) => ({
136
+ path: entry.sourceFields.file,
137
+ content: `<!-- NOWWORK:MEMORY_ENTRY:BEGIN id=${entry.proposed.id} revision=1 -->\n`
138
+ + `## ${entry.title}\n\n旧索引范围(仍需结合正文人工确认):${entry.sourceFields.scope}\n\n`
139
+ + `${entry.detail}${entry.detail.endsWith("\n") ? "" : "\n"}`
140
+ + `<!-- NOWWORK:MEMORY_ENTRY:END id=${entry.proposed.id} -->\n`,
141
+ })),
142
+ };
143
+ if (!budget.fits)
144
+ add("projection_over_budget");
145
+ }
146
+ const shownDetails = new Set();
147
+ const compactEntries = entries.map((entry) => {
148
+ const file = entry.sourceFields.file ?? "";
149
+ const detail = shownDetails.has(file) ? null : entry.detail;
150
+ shownDetails.add(file);
151
+ return { ...entry, detail };
152
+ });
153
+ return { entries: compactEntries, issues: [...issues, ...compatibilityIssues], preview, rawMemory: memory };
154
+ }
@@ -0,0 +1,159 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, readdir, realpath } from "node:fs/promises";
4
+ import { isAbsolute, join, relative, sep } from "node:path";
5
+ import { TextDecoder } from "node:util";
6
+ const MAX_FILE_BYTES = 32 * 1024 * 1024;
7
+ const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
8
+ const MAX_ITEMS = 10_000;
9
+ const MAX_DEPTH = 32;
10
+ export const inventoryLimits = {
11
+ fileBytes: MAX_FILE_BYTES, totalBytes: MAX_TOTAL_BYTES, items: MAX_ITEMS, depth: MAX_DEPTH,
12
+ };
13
+ function errorCode(error) {
14
+ if (error instanceof TypeError)
15
+ return "invalid_utf8";
16
+ if (error instanceof Error)
17
+ return error.code ?? error.message;
18
+ return "read_failed";
19
+ }
20
+ async function safePath(root, file) {
21
+ const parts = file.split("/");
22
+ if (parts.some((part) => !part || part === "." || part === ".." || part.includes("\\"))) {
23
+ throw new Error("unsafe_path");
24
+ }
25
+ let path = root;
26
+ for (const part of parts) {
27
+ path = join(path, part);
28
+ if ((await lstat(path)).isSymbolicLink())
29
+ throw new Error("unsafe_path");
30
+ }
31
+ const actual = await realpath(path);
32
+ const rel = relative(root, actual);
33
+ if (actual !== path || isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) {
34
+ throw new Error("unsafe_path");
35
+ }
36
+ return path;
37
+ }
38
+ async function readSource(root, file, budget) {
39
+ const path = await safePath(root, file);
40
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
41
+ try {
42
+ const before = await handle.stat();
43
+ if (!before.isFile())
44
+ throw new Error("not_regular_file");
45
+ const limit = Math.min(MAX_FILE_BYTES, budget);
46
+ if (before.size > limit)
47
+ throw new Error("read_limit_exceeded");
48
+ const chunks = [];
49
+ let bytes = 0;
50
+ for (;;) {
51
+ const chunk = Buffer.alloc(Math.min(64 * 1024, limit + 1 - bytes));
52
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
53
+ if (bytesRead === 0)
54
+ break;
55
+ bytes += bytesRead;
56
+ if (bytes > limit)
57
+ throw new Error("read_limit_exceeded");
58
+ chunks.push(chunk.subarray(0, bytesRead));
59
+ }
60
+ const after = await handle.stat();
61
+ const current = await lstat(await safePath(root, file));
62
+ if (before.ino !== current.ino || before.dev !== current.dev || before.size !== after.size
63
+ || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
64
+ throw new Error("source_changed");
65
+ const data = Buffer.concat(chunks);
66
+ return {
67
+ path: file, bytes,
68
+ sha256: createHash("sha256").update(data).digest("hex"),
69
+ content: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(data),
70
+ };
71
+ }
72
+ finally {
73
+ await handle.close();
74
+ }
75
+ }
76
+ export async function scanMemorySources(agentDir) {
77
+ const root = await realpath(agentDir);
78
+ if ((await lstat(agentDir)).isSymbolicLink())
79
+ throw new Error("agent_root_symlink");
80
+ if (!(await lstat(root)).isDirectory())
81
+ throw new Error("agent_root_not_directory");
82
+ const sources = [];
83
+ const issues = [];
84
+ const directories = new Map();
85
+ let totalBytes = 0;
86
+ let items = 0;
87
+ const addIssue = (code, path) => issues.push({ code, path });
88
+ async function visit(file, optional = false, depth = 0) {
89
+ if (++items > MAX_ITEMS || depth > MAX_DEPTH) {
90
+ addIssue("scan_limit_exceeded", file);
91
+ return;
92
+ }
93
+ try {
94
+ const path = await safePath(root, file);
95
+ const stat = await lstat(path);
96
+ if (file === "MEMORY.md" && !stat.isFile())
97
+ throw new Error("not_regular_file");
98
+ if ((file === "notes" || file === ".crew/memory/pending") && !stat.isDirectory())
99
+ throw new Error("not_directory");
100
+ if (stat.isDirectory()) {
101
+ const names = (await readdir(path)).sort();
102
+ directories.set(file, names);
103
+ for (const name of names) {
104
+ if (items >= MAX_ITEMS) {
105
+ addIssue("scan_limit_exceeded", file);
106
+ break;
107
+ }
108
+ await visit(`${file}/${name}`, false, depth + 1);
109
+ }
110
+ }
111
+ else {
112
+ const source = await readSource(root, file, MAX_TOTAL_BYTES - totalBytes);
113
+ totalBytes += source.bytes;
114
+ sources.push(source);
115
+ }
116
+ }
117
+ catch (error) {
118
+ const code = errorCode(error);
119
+ if (optional && code === "ENOENT")
120
+ directories.set(file, []);
121
+ else
122
+ addIssue(code, file);
123
+ }
124
+ }
125
+ await visit("MEMORY.md");
126
+ 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);
131
+ // 第二遍比对摘要和目录清单,发现扫描期间的变化就拒绝完整性结论;这不是跨文件事务锁。
132
+ for (const source of sources) {
133
+ try {
134
+ if ((await readSource(root, source.path, MAX_FILE_BYTES)).sha256 !== source.sha256) {
135
+ addIssue("source_changed", source.path);
136
+ }
137
+ }
138
+ catch (error) {
139
+ addIssue(errorCode(error), source.path);
140
+ }
141
+ }
142
+ for (const [file, names] of directories) {
143
+ try {
144
+ const current = (await readdir(await safePath(root, file))).sort();
145
+ if (JSON.stringify(current) !== JSON.stringify(names))
146
+ addIssue("source_changed", file);
147
+ }
148
+ catch (error) {
149
+ if (errorCode(error) !== "ENOENT" || names.length > 0)
150
+ addIssue("source_changed", file);
151
+ }
152
+ }
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");
156
+ if (await realpath(agentDir) !== root)
157
+ addIssue("source_changed", "agent_root");
158
+ return { root, sources: sources.sort((a, b) => a.path.localeCompare(b.path)), issues, totalBytes };
159
+ }
@@ -53,8 +53,10 @@ crew-memory search --query <关键词> [--file MEMORY.md|notes/文件.md] [--lim
53
53
  crew-memory read [--file MEMORY.md|notes/文件.md] [--offset 0] [--limit 2000]
54
54
  crew-memory search-pending --query <关键词> [--cursor <游标>] [--limit 10]
55
55
  crew-memory read-pending --id <候选ID> [--offset 0] [--limit 2000] [--if-hash <sha256>]
56
+ read/read-pending 的 --limit 必须为 1—4000 的整数,默认 2000;search/search-pending 为 1—40 的整数,默认 10。
56
57
  search 关键词按空白分隔,同一行须全部命中;返回完整文件中的行号和字符偏移。
57
58
  读取的 offset/limit 按 Unicode 字符计数;用 nextOffset 继续,eof=true 表示结束。
59
+ 读取的 --limit 超出范围时,保留原文件或候选 ID 及 offset,将 --limit 改为 2000 后重试;成功后按 nextOffset 分页,不要增大 limit 来一次读完整文件。
58
60
  后续页可传 --if-hash <前一页的 sha256>,文件变化时停止,避免混读不同版本。
59
61
  search/read 读取 MEMORY.md 和 notes 原文,不过滤失效段落,也不扫描 pending。
60
62
  search-pending 只查因容量延期的 create 候选;有 nextCursor 就继续,即使本页 matches 为空。
@@ -235,7 +237,7 @@ export async function runMemoryReadCommand(argv, io) {
235
237
  }
236
238
  }
237
239
  catch {
238
- io.err(`memory_arguments_invalid: 参数不合法,记忆读取未完成。\n${USAGE}`);
240
+ io.err(`memory_arguments_invalid: 参数不合法,记忆读取未完成;请按以下参数范围修正后重试,不要当作没有记忆。\n${USAGE}`);
239
241
  return 2;
240
242
  }
241
243
  try {
@@ -1,8 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { lstat, mkdir, open, readFile, realpath, rm, writeFile } from "node:fs/promises";
4
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { lstat, mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
5
  import { applyLocalMemoryResult, parseLocalMemoryResult } from "./write.js";
6
+ import { verifyWorkLogSource } from "./work-log.js";
6
7
  export function isLocalMemoryV2Eligible(input) {
7
8
  // The allowlist is the rollout boundary. The marker and seed/pre-existing
8
9
  // state are retained as diagnostics and for backward-compatible marker
@@ -80,6 +81,11 @@ export function renderLocalMemoryV2WriteGuard() {
80
81
  ## V2 shared memory write safety
81
82
  本节覆盖前文关于普通任务直接更新共享记忆的例外规则。本 Agent 已启用结构化本地记忆 V2。普通任务不得直接编辑共享的 $CREW_HOME/MEMORY.md 或 $CREW_HOME/notes/ 文件;即使用户明确要求记住内容,也只把候选、用户授权和分类理由记录到本任务的 $CREW_TASK_LOG,留给任务结束后的 memory-prune 结构化收尾应用。只有 memory-prune 收尾提示明确要求写入 daemon 提供的 result 文件时,才按该协议提交候选。`;
82
83
  }
84
+ function workLogFailure(error) {
85
+ const reason = error instanceof Error ? error.message : "";
86
+ return { status: "failed", error: reason === "local_memory_work_log_source_changed"
87
+ || reason === "local_memory_work_log_version_missing" ? reason : "local_memory_work_log_cleanup_failed" };
88
+ }
83
89
  export async function commitLocalMemoryResultAfterExit(input) {
84
90
  if (input.exitCode !== 0)
85
91
  return { status: "skipped" };
@@ -97,6 +103,14 @@ export async function commitLocalMemoryResultAfterExit(input) {
97
103
  if (result.outcome === "conflict" || result.outcome === "failed") {
98
104
  return { status: "failed", error: `local_memory_result_${result.outcome}` };
99
105
  }
106
+ if (input.workLogPath !== undefined) {
107
+ try {
108
+ await verifyWorkLogSource(input.agentDir, input.workLogPath, input.expectedWorkLogSha256);
109
+ }
110
+ catch (error) {
111
+ return workLogFailure(error);
112
+ }
113
+ }
100
114
  await applyLocalMemoryResult({
101
115
  agentDir: input.agentDir,
102
116
  result,
@@ -109,20 +123,12 @@ export async function commitLocalMemoryResultAfterExit(input) {
109
123
  }
110
124
  if (input.workLogPath !== undefined) {
111
125
  try {
112
- const relativeLogPath = relative(resolve(input.agentDir), resolve(input.workLogPath));
113
- if (relativeLogPath === "" || relativeLogPath === ".." || relativeLogPath.startsWith(`..${sep}`)
114
- || isAbsolute(relativeLogPath))
115
- throw new Error("local_memory_work_log_path_invalid");
116
- const expectedLogPath = join(await realpath(input.agentDir), relativeLogPath);
117
- const before = await lstat(input.workLogPath);
118
- if (!before.isFile() || before.isSymbolicLink())
119
- throw new Error("local_memory_work_log_path_invalid");
120
- if (await realpath(input.workLogPath) !== expectedLogPath)
121
- throw new Error("local_memory_work_log_path_invalid");
126
+ const before = await verifyWorkLogSource(input.agentDir, input.workLogPath, input.expectedWorkLogSha256);
122
127
  const handle = await open(input.workLogPath, constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
123
128
  try {
124
129
  const opened = await handle.stat();
125
- if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) {
130
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
131
+ || opened.size !== before.size || opened.mtimeMs !== before.mtimeMs || opened.ctimeMs !== before.ctimeMs) {
126
132
  throw new Error("local_memory_work_log_path_changed");
127
133
  }
128
134
  await handle.truncate(0);
@@ -131,8 +137,8 @@ export async function commitLocalMemoryResultAfterExit(input) {
131
137
  await handle.close();
132
138
  }
133
139
  }
134
- catch {
135
- return { status: "failed", error: "local_memory_work_log_cleanup_failed" };
140
+ catch (error) {
141
+ return workLogFailure(error);
136
142
  }
137
143
  }
138
144
  try {
@@ -0,0 +1,31 @@
1
+ import { resolve } from "node:path";
2
+ import { withKeyedLease } from "../local-execution-lease.js";
3
+ // 只记录尚未结束的本机执行,最后一个执行结束即移除,不落盘。
4
+ const activeExecutions = new Map();
5
+ export function workLogExecutionKey(agentsRoot, handle, taskKey) {
6
+ return `work-log:${JSON.stringify([resolve(agentsRoot, handle), taskKey ?? "default"])}`;
7
+ }
8
+ export async function withWorkLogExecution(key, operation, cancellation) {
9
+ // 只在登记期间持锁;普通任务与收尾的模型调用仍可并行。
10
+ await withKeyedLease(key, async () => {
11
+ activeExecutions.set(key, (activeExecutions.get(key) ?? 0) + 1);
12
+ }, cancellation);
13
+ try {
14
+ return await operation();
15
+ }
16
+ finally {
17
+ const remaining = (activeExecutions.get(key) ?? 1) - 1;
18
+ if (remaining === 0)
19
+ activeExecutions.delete(key);
20
+ else
21
+ activeExecutions.set(key, remaining);
22
+ }
23
+ }
24
+ export async function withWorkLogCommit(key, operation, cancellation) {
25
+ return withKeyedLease(key, async () => {
26
+ // 已有同任务执行可能继续写日志,不能提交旧提案;新执行等短提交结束后再登记。
27
+ if (activeExecutions.get(key) !== 1)
28
+ throw new Error("local_memory_work_log_execution_conflict");
29
+ return operation();
30
+ }, cancellation);
31
+ }
@@ -0,0 +1,58 @@
1
+ import { lstat, readFile, realpath } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { safeKey } from "../workspace.js";
5
+ /** 收尾会话可以换目录,但仍应消费同一任务已写好的日志;不迁移文件或复用会话。 */
6
+ export async function resolveMemoryPruneWorkLog(workspace, input) {
7
+ // 旧映射有损时无法证明是同一任务,不能用它查找或清理来源。
8
+ if (input.keyMode !== "opaque" || !input.taskKey || safeKey(input.taskKey) !== input.taskKey) {
9
+ return workspace;
10
+ }
11
+ const legacyPath = join(workspace.dir, "tasks", input.taskKey, "work-log.md");
12
+ if (legacyPath === workspace.workLogPath)
13
+ return workspace;
14
+ let legacyStat;
15
+ try {
16
+ legacyStat = await lstat(legacyPath);
17
+ }
18
+ catch (error) {
19
+ if (error.code === "ENOENT")
20
+ return workspace;
21
+ throw error;
22
+ }
23
+ if (!legacyStat.isFile() || legacyStat.isSymbolicLink()
24
+ || await realpath(legacyPath) !== join(await realpath(workspace.dir), "tasks", input.taskKey, "work-log.md")) {
25
+ throw new Error("local_memory_work_log_path_invalid");
26
+ }
27
+ const workLog = await readFile(legacyPath, "utf8");
28
+ if (workLog.length === 0)
29
+ return workspace;
30
+ if ((await readFile(workspace.workLogPath, "utf8")).length > 0) {
31
+ throw new Error("local_memory_work_log_source_conflict");
32
+ }
33
+ return { ...workspace, workLogPath: legacyPath, workLog };
34
+ }
35
+ export function workLogSha256(content) {
36
+ return createHash("sha256").update(content).digest("hex");
37
+ }
38
+ /** 调用方须在同任务的提交互斥边界内校验;指纹对应实际注入模型的那一版内容。 */
39
+ export async function verifyWorkLogSource(agentDir, path, expectedSha256) {
40
+ if (expectedSha256 === undefined)
41
+ throw new Error("local_memory_work_log_version_missing");
42
+ const relativePath = relative(resolve(agentDir), resolve(path));
43
+ if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
44
+ throw new Error("local_memory_work_log_path_invalid");
45
+ }
46
+ const before = await lstat(path);
47
+ if (!before.isFile() || before.isSymbolicLink()
48
+ || await realpath(path) !== join(await realpath(agentDir), relativePath)) {
49
+ throw new Error("local_memory_work_log_path_invalid");
50
+ }
51
+ const content = await readFile(path);
52
+ const after = await lstat(path);
53
+ if (workLogSha256(content) !== expectedSha256 || before.dev !== after.dev || before.ino !== after.ino
54
+ || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
55
+ throw new Error("local_memory_work_log_source_changed");
56
+ }
57
+ return after;
58
+ }
@@ -4,7 +4,7 @@ import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { dslog } from "../slog.js";
6
6
  import { LocalMemoryChangeSchema, LocalMemorySingleLineText, validateLocalMemoryChange, } from "./candidate.js";
7
- import { LOCAL_MEMORY_INDEX_CODE_POINT_CAP, LOCAL_MEMORY_INDEX_ENTRY_CAP, LOCAL_MEMORY_TOTAL_CODE_POINT_CAP, renderLocalMemoryIndexCard, selectLocalMemoryIndexEntries, } from "./index-budget.js";
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
9
  const StatusChangeSchema = z.object({
10
10
  memoryId: LocalMemorySingleLineText,
@@ -98,13 +98,6 @@ function parseUnmarkedMemoryIndex(memory) {
98
98
  const section = indexSection(memory).replace(GENERATED_INDEX_PATTERN, "");
99
99
  return parseCards(section);
100
100
  }
101
- function renderGeneratedIndex(entries) {
102
- const cards = entries
103
- .filter((entry) => entry.status === "pinned" || entry.status === "active")
104
- .map(renderLocalMemoryIndexCard)
105
- .join("\n");
106
- return `<!-- NOWWORK:MEMORY_INDEX:BEGIN schema=1 -->\n${cards}<!-- NOWWORK:MEMORY_INDEX:END -->`;
107
- }
108
101
  function replaceGeneratedIndex(memory, entries) {
109
102
  const generated = renderGeneratedIndex(entries);
110
103
  if (GENERATED_INDEX_PATTERN.test(memory))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.88-beta.10",
3
+ "version": "0.6.88-beta.12",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",