@nowcrew/daemon 0.6.56 → 0.6.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.js +35 -0
- package/dist/execution-runner.js +1 -0
- package/dist/local-executor.js +44 -1
- package/dist/local-memory/index-budget.js +74 -0
- package/dist/local-memory/pending.js +111 -0
- package/dist/local-memory/runtime.js +87 -0
- package/dist/local-memory/types.js +1 -0
- package/dist/local-memory/write.js +448 -0
- package/dist/runner.js +1 -0
- package/dist/storage-runtime-controller.js +1 -0
- package/dist/storage-runtime-readiness.js +4 -1
- package/dist/storage-worker-handoff.js +39 -4
- package/dist/workspace.js +24 -1
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -59,6 +59,40 @@ function defaultCliPath() {
|
|
|
59
59
|
return resolve(here, "../../cli/dist/main.js");
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
// 临时 dev 验收名单。测试完成后删除此常量,恢复未配置时默认关闭的行为。
|
|
63
|
+
const TEMP_LOCAL_MEMORY_V2_TEST_HANDLES = [
|
|
64
|
+
"memory-v2-test-a",
|
|
65
|
+
"memory-v2-test-b",
|
|
66
|
+
"memory-v2-test-c",
|
|
67
|
+
];
|
|
68
|
+
function isTemporaryDevServer(serverUrl) {
|
|
69
|
+
try {
|
|
70
|
+
const hostname = new URL(serverUrl).hostname.toLowerCase();
|
|
71
|
+
return hostname === "localhost"
|
|
72
|
+
|| hostname === "127.0.0.1"
|
|
73
|
+
|| hostname === "::1"
|
|
74
|
+
|| hostname === "nowwork-dev.nowcoder.com";
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function localMemoryV2Handles(env, serverUrl) {
|
|
81
|
+
const configured = env.CREW_LOCAL_MEMORY_V2_HANDLES;
|
|
82
|
+
if (configured === undefined) {
|
|
83
|
+
return isTemporaryDevServer(serverUrl)
|
|
84
|
+
? new Set(TEMP_LOCAL_MEMORY_V2_TEST_HANDLES)
|
|
85
|
+
: new Set();
|
|
86
|
+
}
|
|
87
|
+
const raw = configured.trim();
|
|
88
|
+
if (raw === "")
|
|
89
|
+
return new Set();
|
|
90
|
+
const handles = raw.split(",").map((value) => value.trim()).filter(Boolean);
|
|
91
|
+
if (handles.some((handle) => !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(handle))) {
|
|
92
|
+
throw new ConfigError("CREW_LOCAL_MEMORY_V2_HANDLES contains an invalid agent handle");
|
|
93
|
+
}
|
|
94
|
+
return new Set(handles);
|
|
95
|
+
}
|
|
62
96
|
export const DEFAULT_SERVER_URL = "http://127.0.0.1:3001";
|
|
63
97
|
function codexHomeModeEnv(env) {
|
|
64
98
|
const value = env.CREW_CODEX_HOME_MODE;
|
|
@@ -108,6 +142,7 @@ export function loadConfig(env = process.env) {
|
|
|
108
142
|
sessionMaxTurns: env.CREW_SESSION_MAX_TURNS != null ? Number(env.CREW_SESSION_MAX_TURNS) : 30,
|
|
109
143
|
productName: env.CREW_PRODUCT_NAME ?? "nowwork",
|
|
110
144
|
agentMemory: loadAgentMemoryConfig(env),
|
|
145
|
+
localMemoryV2Handles: localMemoryV2Handles(env, serverUrl),
|
|
111
146
|
executionLimits,
|
|
112
147
|
};
|
|
113
148
|
}
|
package/dist/execution-runner.js
CHANGED
|
@@ -847,6 +847,7 @@ export async function runExecution(config, input, dependencies) {
|
|
|
847
847
|
softTokens: config.sessionSoftTokens,
|
|
848
848
|
maxTurns: config.sessionMaxTurns,
|
|
849
849
|
},
|
|
850
|
+
localMemoryV2Enabled: config.localMemoryV2Handles?.has(spec.agent.handle) === true,
|
|
850
851
|
};
|
|
851
852
|
const result = await Promise.race([
|
|
852
853
|
execute(localInput, callbacks, localDependencies),
|
package/dist/local-executor.js
CHANGED
|
@@ -31,6 +31,7 @@ import { diffMemoryPruneNotes, evaluateMemoryPrunePostcondition, inspectMemoryPr
|
|
|
31
31
|
import { createLocalMemoryTelemetry, logLocalMemoryContextPrepareFailure, logLocalMemoryDiagnosticsFailure, } from "./local-memory-telemetry.js";
|
|
32
32
|
import { CodexStartupStageParser } from "./codex-startup-stage.js";
|
|
33
33
|
import { formatRuntimeStartupError } from "./runtime-startup-error.js";
|
|
34
|
+
import { commitLocalMemoryResultAfterExit, initializeLocalMemoryV2ForWorkspace, isLocalMemoryV2Eligible, localMemoryResultPath as makeLocalMemoryResultPath, renderLocalMemoryResultInstructions, } from "./local-memory/runtime.js";
|
|
34
35
|
function memoryPruneFileFactFields(fileFacts) {
|
|
35
36
|
const fields = {};
|
|
36
37
|
for (const [label, fact] of Object.entries(fileFacts)) {
|
|
@@ -277,6 +278,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
277
278
|
}, "workspace_prepare", error);
|
|
278
279
|
throw error;
|
|
279
280
|
}
|
|
281
|
+
const localMemoryV2Initialized = await initializeLocalMemoryV2ForWorkspace(workspace, input.localMemoryV2Enabled === true);
|
|
280
282
|
let materialized = null;
|
|
281
283
|
let knownAttachmentDirectory = null;
|
|
282
284
|
const startupReservationState = { current: null };
|
|
@@ -286,6 +288,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
286
288
|
let memoryPruneFailurePhase = "diagnostics_before";
|
|
287
289
|
let memoryPruneBeforeSnapshot = null;
|
|
288
290
|
let memoryPruneSharedWriteKey = null;
|
|
291
|
+
let localMemoryResultPath = null;
|
|
289
292
|
let executionWorkspace = workspace;
|
|
290
293
|
let localMemoryTelemetry = null;
|
|
291
294
|
const inheritedEnv = { ...process.env };
|
|
@@ -371,7 +374,6 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
371
374
|
nearBudget,
|
|
372
375
|
...(input.projectContext === undefined ? {} : { projectContext: input.projectContext }),
|
|
373
376
|
};
|
|
374
|
-
const systemPrompt = resolvePrompt(input.systemPrompt, promptContext);
|
|
375
377
|
if (input.attachments && input.attachments.length > 0) {
|
|
376
378
|
if (dependencies.cancellation?.isRequested())
|
|
377
379
|
throw new RuntimeCancelledError();
|
|
@@ -405,6 +407,29 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
405
407
|
const attachmentPlan = routeRuntimeAttachments(runtime.name, materialized?.attachments ?? []);
|
|
406
408
|
const wakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
|
|
407
409
|
memoryPruneTraceId = parseMemoryPruneTraceId(wakePrompt);
|
|
410
|
+
if (input.localMemoryV2Enabled === true && memoryPruneTraceId !== null
|
|
411
|
+
&& isLocalMemoryV2Eligible({
|
|
412
|
+
enabledForHandle: true,
|
|
413
|
+
agentHomePreexisted: workspace.agentHomePreexisted === true,
|
|
414
|
+
memorySeedCreated: executionWorkspace.memorySeedCreated === true && (localMemoryV2Initialized || workspace.localMemoryV2Initialized !== false),
|
|
415
|
+
localMemoryV2Initialized,
|
|
416
|
+
memoryPrune: true,
|
|
417
|
+
})) {
|
|
418
|
+
localMemoryResultPath = makeLocalMemoryResultPath(executionWorkspace.runDir, input.executionId);
|
|
419
|
+
}
|
|
420
|
+
const baseSystemPrompt = resolvePrompt(input.systemPrompt, promptContext);
|
|
421
|
+
const structuredMemoryInstructions = localMemoryResultPath === null
|
|
422
|
+
? null
|
|
423
|
+
: renderLocalMemoryResultInstructions(localMemoryResultPath);
|
|
424
|
+
const structuredPromptFits = structuredMemoryInstructions === null
|
|
425
|
+
|| input.maxSystemPromptBytes === undefined
|
|
426
|
+
|| Buffer.byteLength(baseSystemPrompt, "utf8") + Buffer.byteLength(structuredMemoryInstructions, "utf8")
|
|
427
|
+
<= input.maxSystemPromptBytes;
|
|
428
|
+
if (!structuredPromptFits)
|
|
429
|
+
localMemoryResultPath = null;
|
|
430
|
+
const systemPrompt = localMemoryResultPath === null
|
|
431
|
+
? baseSystemPrompt
|
|
432
|
+
: `${baseSystemPrompt}${structuredMemoryInstructions ?? ""}`;
|
|
408
433
|
try {
|
|
409
434
|
localMemoryTelemetry = createLocalMemoryTelemetry({
|
|
410
435
|
executionId: input.executionId,
|
|
@@ -892,6 +917,24 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
892
917
|
const { exitCode, spawnError, terminationSignal } = runtimeExit;
|
|
893
918
|
localMemoryTelemetry?.markRuntimeExited(exitCode);
|
|
894
919
|
memoryPruneRuntimeExitCode = exitCode;
|
|
920
|
+
if (localMemoryResultPath !== null) {
|
|
921
|
+
const committed = await commitLocalMemoryResultAfterExit({
|
|
922
|
+
resultPath: localMemoryResultPath,
|
|
923
|
+
agentDir: executionWorkspace.dir,
|
|
924
|
+
executionId: input.executionId,
|
|
925
|
+
pruneTraceId: memoryPruneTraceId,
|
|
926
|
+
exitCode,
|
|
927
|
+
});
|
|
928
|
+
if (committed.status === "failed") {
|
|
929
|
+
dslog("local_memory.commit_failed", "结构化本地记忆候选未落地,保留结果文件", {
|
|
930
|
+
level: "WARN",
|
|
931
|
+
execution_id: input.executionId,
|
|
932
|
+
agent_handle: input.handle,
|
|
933
|
+
error_code: committed.error,
|
|
934
|
+
result_path: localMemoryResultPath,
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
}
|
|
895
938
|
const errorTail = [
|
|
896
939
|
stderrTail.trim(),
|
|
897
940
|
spawnError === undefined
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export const LOCAL_MEMORY_TOTAL_CODE_POINT_CAP = 6_000;
|
|
2
|
+
export const LOCAL_MEMORY_INDEX_CODE_POINT_CAP = 3_000;
|
|
3
|
+
export const LOCAL_MEMORY_INDEX_ENTRY_CAP = 20;
|
|
4
|
+
const PROJECTABLE_STATUSES = new Set(["pinned", "active"]);
|
|
5
|
+
function codePointLength(value) {
|
|
6
|
+
return Array.from(value).length;
|
|
7
|
+
}
|
|
8
|
+
export function renderLocalMemoryIndexCard(entry) {
|
|
9
|
+
return `### ${entry.title}\n`
|
|
10
|
+
+ `- id: ${entry.id}\n`
|
|
11
|
+
+ `- file: ${entry.file}\n`
|
|
12
|
+
+ `- type: ${entry.type}\n`
|
|
13
|
+
+ `- status: ${entry.status}\n`
|
|
14
|
+
+ `- summary: ${entry.summary}\n`
|
|
15
|
+
+ `- triggers: ${entry.triggers.join(", ")}\n`
|
|
16
|
+
+ `- priority: ${entry.priority}\n`
|
|
17
|
+
+ `- revision: ${entry.revision}\n`
|
|
18
|
+
+ `- anchor: ${entry.anchor ?? ""}\n`
|
|
19
|
+
+ `- updated: ${entry.updated}\n`
|
|
20
|
+
+ (entry.reviewAfter === null ? "" : `- review_after: ${entry.reviewAfter}\n`);
|
|
21
|
+
}
|
|
22
|
+
function compareEntries(left, right) {
|
|
23
|
+
const statusRank = (status) => status === "pinned" ? 0 : 1;
|
|
24
|
+
const priorityRank = (priority) => (priority === "high" ? 0 : priority === "medium" ? 1 : 2);
|
|
25
|
+
return statusRank(left.status) - statusRank(right.status)
|
|
26
|
+
|| priorityRank(left.priority) - priorityRank(right.priority)
|
|
27
|
+
|| right.updated.localeCompare(left.updated)
|
|
28
|
+
|| left.id.localeCompare(right.id);
|
|
29
|
+
}
|
|
30
|
+
function cardLength(entry, hasPrevious) {
|
|
31
|
+
return codePointLength(renderLocalMemoryIndexCard(entry)) + (hasPrevious ? 1 : 0);
|
|
32
|
+
}
|
|
33
|
+
function overBudgetReason(entries) {
|
|
34
|
+
return entries.some((entry) => entry.status === "pinned")
|
|
35
|
+
? "index_over_budget_pinned"
|
|
36
|
+
: "index_over_budget_active";
|
|
37
|
+
}
|
|
38
|
+
export function selectLocalMemoryIndexEntries(entries, budget, options = {}) {
|
|
39
|
+
const projectable = entries.filter((entry) => PROJECTABLE_STATUSES.has(entry.status));
|
|
40
|
+
const omittedIds = entries
|
|
41
|
+
.filter((entry) => !PROJECTABLE_STATUSES.has(entry.status))
|
|
42
|
+
.map((entry) => entry.id);
|
|
43
|
+
const protectedIds = options.protectedIds ?? new Set();
|
|
44
|
+
const protectedEntries = projectable.filter((entry) => protectedIds.has(entry.id)).sort(compareEntries);
|
|
45
|
+
const candidates = projectable.filter((entry) => !protectedIds.has(entry.id)).sort(compareEntries);
|
|
46
|
+
const selected = [];
|
|
47
|
+
let renderedLength = 0;
|
|
48
|
+
for (const entry of protectedEntries) {
|
|
49
|
+
selected.push(entry);
|
|
50
|
+
renderedLength += cardLength(entry, selected.length > 1);
|
|
51
|
+
}
|
|
52
|
+
if (selected.length > budget.entryCap || renderedLength > budget.indexCodePointCap) {
|
|
53
|
+
return {
|
|
54
|
+
entries: selected,
|
|
55
|
+
omittedIds: [...omittedIds, ...candidates.map((entry) => entry.id)],
|
|
56
|
+
reason: overBudgetReason(selected),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
for (const entry of candidates) {
|
|
60
|
+
if (selected.length >= budget.entryCap || renderedLength + cardLength(entry, selected.length > 0) > budget.indexCodePointCap) {
|
|
61
|
+
omittedIds.push(entry.id);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
selected.push(entry);
|
|
65
|
+
renderedLength += cardLength(entry, selected.length > 1);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
entries: selected,
|
|
69
|
+
omittedIds,
|
|
70
|
+
reason: omittedIds.length === entries.filter((item) => !PROJECTABLE_STATUSES.has(item.status)).length
|
|
71
|
+
? null
|
|
72
|
+
: "index_over_budget",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readdir, realpath, rename, rm, writeFile, readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
const CandidateSchema = z.object({
|
|
6
|
+
operation: z.enum(["create", "update", "supersede"]),
|
|
7
|
+
memoryId: z.string().nullable(),
|
|
8
|
+
baseRevision: z.number().int().nonnegative().nullable(),
|
|
9
|
+
supersedesMemoryId: z.string().nullable(),
|
|
10
|
+
title: z.string().min(1),
|
|
11
|
+
kind: z.enum(["procedural", "semantic", "episodic", "preference"]),
|
|
12
|
+
summary: z.string().min(1),
|
|
13
|
+
triggers: z.array(z.string()).min(1),
|
|
14
|
+
priority: z.enum(["high", "medium", "low"]),
|
|
15
|
+
detailCategory: z.enum(["preferences", "project-rules", "testing", "debugging", "git", "incidents", "lessons"]),
|
|
16
|
+
detailBody: z.string().min(1),
|
|
17
|
+
reviewAfter: z.string().nullable(),
|
|
18
|
+
}).strict();
|
|
19
|
+
const PendingSchema = z.object({
|
|
20
|
+
schemaVersion: z.literal(1),
|
|
21
|
+
pendingId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u),
|
|
22
|
+
createdAt: z.string().min(1),
|
|
23
|
+
reason: z.literal("index_over_budget"),
|
|
24
|
+
source: z.object({
|
|
25
|
+
executionId: z.string().min(1),
|
|
26
|
+
pruneTraceId: z.string().min(1).nullable(),
|
|
27
|
+
}).strict(),
|
|
28
|
+
candidate: CandidateSchema,
|
|
29
|
+
}).strict();
|
|
30
|
+
function within(root, candidate) {
|
|
31
|
+
const path = relative(root, candidate);
|
|
32
|
+
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`));
|
|
33
|
+
}
|
|
34
|
+
async function pendingDirectory(agentDir) {
|
|
35
|
+
const agentRoot = await realpath(agentDir);
|
|
36
|
+
const memoryRoot = resolve(agentRoot, ".crew", "memory");
|
|
37
|
+
await mkdir(memoryRoot, { recursive: true });
|
|
38
|
+
const memoryStat = await lstat(memoryRoot);
|
|
39
|
+
if (!memoryStat.isDirectory() || memoryStat.isSymbolicLink())
|
|
40
|
+
throw new Error("local_memory_pending_path_invalid");
|
|
41
|
+
const resolvedMemoryRoot = await realpath(memoryRoot);
|
|
42
|
+
if (resolvedMemoryRoot !== memoryRoot || !within(agentRoot, resolvedMemoryRoot)) {
|
|
43
|
+
throw new Error("local_memory_pending_path_invalid");
|
|
44
|
+
}
|
|
45
|
+
const directory = join(memoryRoot, "pending");
|
|
46
|
+
try {
|
|
47
|
+
const stat = await lstat(directory);
|
|
48
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
49
|
+
throw new Error("local_memory_pending_path_invalid");
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code !== "ENOENT")
|
|
53
|
+
throw error;
|
|
54
|
+
await mkdir(directory);
|
|
55
|
+
}
|
|
56
|
+
const resolvedDirectory = await realpath(directory);
|
|
57
|
+
if (resolvedDirectory !== directory || !within(agentRoot, resolvedDirectory)) {
|
|
58
|
+
throw new Error("local_memory_pending_path_invalid");
|
|
59
|
+
}
|
|
60
|
+
return directory;
|
|
61
|
+
}
|
|
62
|
+
async function assertRegularFile(path) {
|
|
63
|
+
try {
|
|
64
|
+
const stat = await lstat(path);
|
|
65
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
66
|
+
throw new Error("local_memory_pending_path_invalid");
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error.code !== "ENOENT")
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function atomicWrite(path, content) {
|
|
74
|
+
const temporary = join(dirname(path), `.${basename(path)}.tmp-${randomUUID()}`);
|
|
75
|
+
try {
|
|
76
|
+
await writeFile(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
77
|
+
await rename(temporary, path);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export async function persistPendingMemoryCandidate(input) {
|
|
85
|
+
const parsed = PendingSchema.safeParse(input.candidate);
|
|
86
|
+
if (!parsed.success)
|
|
87
|
+
throw new Error("local_memory_pending_invalid");
|
|
88
|
+
const directory = await pendingDirectory(input.agentDir);
|
|
89
|
+
const target = join(directory, `${parsed.data.pendingId}.json`);
|
|
90
|
+
await assertRegularFile(target);
|
|
91
|
+
await atomicWrite(target, `${JSON.stringify(parsed.data, null, 2)}\n`);
|
|
92
|
+
return target;
|
|
93
|
+
}
|
|
94
|
+
export async function readPendingMemoryCandidates(input) {
|
|
95
|
+
const directory = await pendingDirectory(input.agentDir);
|
|
96
|
+
const names = (await readdir(directory)).filter((name) => name.endsWith(".json")).sort();
|
|
97
|
+
const result = [];
|
|
98
|
+
for (const name of names) {
|
|
99
|
+
const path = join(directory, name);
|
|
100
|
+
try {
|
|
101
|
+
await assertRegularFile(path);
|
|
102
|
+
const parsed = PendingSchema.safeParse(JSON.parse(await readFile(path, "utf8")));
|
|
103
|
+
if (parsed.success && `${parsed.data.pendingId}.json` === name)
|
|
104
|
+
result.push(parsed.data);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A damaged pending item is diagnosable and must not hide other candidates.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { applyLocalMemoryResult, parseLocalMemoryResult } from "./write.js";
|
|
5
|
+
export function isLocalMemoryV2Eligible(input) {
|
|
6
|
+
return input.enabledForHandle
|
|
7
|
+
&& (input.localMemoryV2Initialized || (!input.agentHomePreexisted && input.memorySeedCreated))
|
|
8
|
+
&& input.memoryPrune;
|
|
9
|
+
}
|
|
10
|
+
const LOCAL_MEMORY_V2_MARKER = "v2-enabled";
|
|
11
|
+
export async function initializeLocalMemoryV2(agentDir) {
|
|
12
|
+
const crewDir = join(agentDir, ".crew");
|
|
13
|
+
const memoryDir = join(crewDir, "memory");
|
|
14
|
+
await mkdir(crewDir, { recursive: true });
|
|
15
|
+
const crewStat = await lstat(crewDir);
|
|
16
|
+
if (!crewStat.isDirectory() || crewStat.isSymbolicLink())
|
|
17
|
+
throw new Error("local_memory_marker_path_invalid");
|
|
18
|
+
await mkdir(memoryDir, { recursive: true });
|
|
19
|
+
const memoryStat = await lstat(memoryDir);
|
|
20
|
+
if (!memoryStat.isDirectory() || memoryStat.isSymbolicLink())
|
|
21
|
+
throw new Error("local_memory_marker_path_invalid");
|
|
22
|
+
try {
|
|
23
|
+
await writeFile(join(memoryDir, LOCAL_MEMORY_V2_MARKER), "1\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code !== "EEXIST")
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function initializeLocalMemoryV2ForWorkspace(workspace, enabledForHandle) {
|
|
31
|
+
const initialized = workspace.localMemoryV2Initialized === true;
|
|
32
|
+
if (!enabledForHandle || initialized || workspace.localMemoryV2Initialized !== false
|
|
33
|
+
|| workspace.agentHomePreexisted === true || workspace.memorySeedCreated !== true)
|
|
34
|
+
return initialized;
|
|
35
|
+
try {
|
|
36
|
+
await initializeLocalMemoryV2(workspace.dir);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function localMemoryResultPath(runDir, executionId) {
|
|
44
|
+
const suffix = createHash("sha256").update(executionId, "utf8").digest("hex").slice(0, 32);
|
|
45
|
+
return join(runDir, `.memory-result.v1-${suffix}.json`);
|
|
46
|
+
}
|
|
47
|
+
export function renderLocalMemoryResultInstructions(resultPath) {
|
|
48
|
+
return `
|
|
49
|
+
|
|
50
|
+
## Structured memory prune (daemon-managed)
|
|
51
|
+
本轮长期记忆收尾只提交候选,不要直接编辑 $CREW_HOME/MEMORY.md 或 $CREW_HOME/notes/。
|
|
52
|
+
将一个合法 JSON 对象写入以下 daemon 提供的文件,写入完成后再结束本轮:
|
|
53
|
+
${resultPath}
|
|
54
|
+
|
|
55
|
+
JSON 顶层必须包含:
|
|
56
|
+
{"schemaVersion":1,"purpose":"memory_prune","baseCatalogRevision":0,"outcome":"no_change","changes":[],"statusChanges":[]}
|
|
57
|
+
每个 changes 项使用 operation=create|update|supersede、title、kind、summary、triggers、priority、detailCategory、detailBody、reviewAfter;create 的 memoryId/baseRevision/supersedesMemoryId 必须为 null,update/supersede 按现有索引填写版本和 id。
|
|
58
|
+
只提交可复用、与本次任务明确相关的少量候选;不要写入凭证、token、隐私或内部控制标记。daemon 会校验、去重、限制 MEMORY.md 大小,超出索引容量的候选会保存在 pending 中等待后续治理。
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
export async function commitLocalMemoryResultAfterExit(input) {
|
|
62
|
+
if (input.exitCode !== 0)
|
|
63
|
+
return { status: "skipped" };
|
|
64
|
+
let raw;
|
|
65
|
+
try {
|
|
66
|
+
raw = await readFile(input.resultPath, "utf8");
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error.code === "ENOENT")
|
|
70
|
+
return { status: "missing" };
|
|
71
|
+
return { status: "failed", error: error instanceof Error ? error.message : "read_failed" };
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const result = parseLocalMemoryResult(raw);
|
|
75
|
+
await applyLocalMemoryResult({
|
|
76
|
+
agentDir: input.agentDir,
|
|
77
|
+
result,
|
|
78
|
+
executionId: input.executionId,
|
|
79
|
+
pruneTraceId: input.pruneTraceId,
|
|
80
|
+
});
|
|
81
|
+
await rm(input.resultPath, { force: true });
|
|
82
|
+
return { status: "applied" };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
return { status: "failed", error: error instanceof Error ? error.message : "apply_failed" };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { containsLikelySecret } from "../agent-memory/policy.js";
|
|
6
|
+
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 { persistPendingMemoryCandidate } from "./pending.js";
|
|
8
|
+
const CategorySchema = z.enum([
|
|
9
|
+
"preferences", "project-rules", "testing", "debugging", "git", "incidents", "lessons",
|
|
10
|
+
]);
|
|
11
|
+
const SingleLineText = z.string().trim().min(1).max(500).regex(/^[^\r\n]+$/u);
|
|
12
|
+
const ChangeSchema = z.object({
|
|
13
|
+
operation: z.enum(["create", "update", "supersede"]),
|
|
14
|
+
memoryId: SingleLineText.regex(/^[^<>]+$/u).nullable(),
|
|
15
|
+
baseRevision: z.number().int().nonnegative().nullable(),
|
|
16
|
+
supersedesMemoryId: SingleLineText.regex(/^[^<>]+$/u).nullable(),
|
|
17
|
+
title: SingleLineText.max(120),
|
|
18
|
+
kind: z.enum(["procedural", "semantic", "episodic", "preference"]),
|
|
19
|
+
summary: SingleLineText,
|
|
20
|
+
triggers: z.array(SingleLineText.max(80)).min(1).max(16),
|
|
21
|
+
priority: z.enum(["high", "medium", "low"]),
|
|
22
|
+
detailCategory: CategorySchema,
|
|
23
|
+
detailBody: z.string().trim().min(1).max(12_000),
|
|
24
|
+
reviewAfter: SingleLineText.nullable(),
|
|
25
|
+
}).strict();
|
|
26
|
+
const StatusChangeSchema = z.object({
|
|
27
|
+
memoryId: SingleLineText,
|
|
28
|
+
expectedRevision: z.number().int().positive(),
|
|
29
|
+
expectedStatus: z.enum(["candidate", "active", "pinned", "stale", "superseded", "archived"]),
|
|
30
|
+
targetStatus: z.enum(["candidate", "active", "pinned", "stale", "archived"]),
|
|
31
|
+
reason: z.enum(["user_explicit", "manual_review"]),
|
|
32
|
+
}).strict();
|
|
33
|
+
const ResultSchema = z.object({
|
|
34
|
+
schemaVersion: z.literal(1),
|
|
35
|
+
purpose: z.literal("memory_prune"),
|
|
36
|
+
baseCatalogRevision: z.number().int().nonnegative(),
|
|
37
|
+
outcome: z.enum(["no_change", "proposed", "conflict", "failed"]),
|
|
38
|
+
changes: z.array(ChangeSchema).max(50),
|
|
39
|
+
statusChanges: z.array(StatusChangeSchema).max(50),
|
|
40
|
+
}).strict();
|
|
41
|
+
const GENERATED_INDEX_PATTERN = /<!--\s*NOWWORK:MEMORY_INDEX:BEGIN\s+schema=1\s*-->[\s\S]*?<!--\s*NOWWORK:MEMORY_INDEX:END\s*-->/u;
|
|
42
|
+
const INDEX_HEADING = /^##\s+Knowledge Index\s*$/mu;
|
|
43
|
+
const NEXT_SECTION = /^##\s+(?!#)/mu;
|
|
44
|
+
const CARD_HEADING = /^###\s+(.+?)\s*$/gmu;
|
|
45
|
+
const FIELD_LINE = /^-\s+([a-z][a-z_]*)\s*:\s*(.*?)\s*$/iu;
|
|
46
|
+
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
|
|
47
|
+
const RESERVED_MEMORY_MARKER = /NOWWORK:(?:MEMORY_ENTRY|MEMORY_INDEX):/iu;
|
|
48
|
+
function codePointLength(value) {
|
|
49
|
+
return Array.from(value).length;
|
|
50
|
+
}
|
|
51
|
+
function detailPath(category) {
|
|
52
|
+
return `notes/${category}.md`;
|
|
53
|
+
}
|
|
54
|
+
function parseStatus(value) {
|
|
55
|
+
return value === "candidate" || value === "active" || value === "pinned"
|
|
56
|
+
|| value === "stale" || value === "superseded" || value === "archived"
|
|
57
|
+
? value
|
|
58
|
+
: "active";
|
|
59
|
+
}
|
|
60
|
+
function parseCard(titleLine, body) {
|
|
61
|
+
const fields = {};
|
|
62
|
+
for (const line of body.split(/\r?\n/u)) {
|
|
63
|
+
const match = FIELD_LINE.exec(line);
|
|
64
|
+
if (match?.[1] !== undefined && match[2] !== undefined)
|
|
65
|
+
fields[match[1].toLowerCase()] = match[2];
|
|
66
|
+
}
|
|
67
|
+
const title = titleLine.trim().replace(/^\[|\]$/gu, "").trim();
|
|
68
|
+
const file = fields.file?.trim();
|
|
69
|
+
if (!title || !file)
|
|
70
|
+
return null;
|
|
71
|
+
const priority = fields.priority === "high" || fields.priority === "low" ? fields.priority : "medium";
|
|
72
|
+
const revision = Number(fields.revision);
|
|
73
|
+
return {
|
|
74
|
+
id: fields.id?.trim() || `legacy:${file}:${title}`,
|
|
75
|
+
title,
|
|
76
|
+
file,
|
|
77
|
+
type: fields.type === "procedural" || fields.type === "episodic" || fields.type === "preference"
|
|
78
|
+
? fields.type
|
|
79
|
+
: "semantic",
|
|
80
|
+
status: parseStatus(fields.status),
|
|
81
|
+
summary: fields.summary?.trim() ?? "",
|
|
82
|
+
triggers: [...new Set((fields.triggers ?? "").split(/[,,、]/u).map((value) => value.trim()).filter(Boolean))],
|
|
83
|
+
priority,
|
|
84
|
+
updated: fields.updated?.trim() ?? "",
|
|
85
|
+
anchor: fields.anchor?.trim() || null,
|
|
86
|
+
revision: Number.isInteger(revision) && revision > 0 ? revision : 1,
|
|
87
|
+
reviewAfter: fields.review_after?.trim() || null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function indexSection(memory) {
|
|
91
|
+
const heading = INDEX_HEADING.exec(memory);
|
|
92
|
+
if (heading === null || heading.index === undefined)
|
|
93
|
+
return "";
|
|
94
|
+
const start = heading.index + heading[0].length;
|
|
95
|
+
const rest = memory.slice(start);
|
|
96
|
+
const next = NEXT_SECTION.exec(rest);
|
|
97
|
+
return next?.index === undefined ? rest : rest.slice(0, next.index);
|
|
98
|
+
}
|
|
99
|
+
function parseCards(section) {
|
|
100
|
+
const withoutComments = section.replace(/<!--[\s\S]*?-->/gu, "");
|
|
101
|
+
const headings = [...withoutComments.matchAll(CARD_HEADING)];
|
|
102
|
+
return headings.flatMap((heading, index) => {
|
|
103
|
+
if (heading.index === undefined || heading[1] === undefined)
|
|
104
|
+
return [];
|
|
105
|
+
const bodyStart = heading.index + heading[0].length;
|
|
106
|
+
const bodyEnd = headings[index + 1]?.index ?? withoutComments.length;
|
|
107
|
+
const card = parseCard(heading[1], withoutComments.slice(bodyStart, bodyEnd));
|
|
108
|
+
return card === null ? [] : [card];
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export function parseLocalMemoryIndex(memory) {
|
|
112
|
+
const section = indexSection(memory);
|
|
113
|
+
const generated = GENERATED_INDEX_PATTERN.exec(section)?.[0];
|
|
114
|
+
return parseCards(generated ?? section);
|
|
115
|
+
}
|
|
116
|
+
function parseUnmarkedMemoryIndex(memory) {
|
|
117
|
+
const section = indexSection(memory).replace(GENERATED_INDEX_PATTERN, "");
|
|
118
|
+
return parseCards(section);
|
|
119
|
+
}
|
|
120
|
+
function renderGeneratedIndex(entries) {
|
|
121
|
+
const cards = entries
|
|
122
|
+
.filter((entry) => entry.status === "pinned" || entry.status === "active")
|
|
123
|
+
.map(renderLocalMemoryIndexCard)
|
|
124
|
+
.join("\n");
|
|
125
|
+
return `<!-- NOWWORK:MEMORY_INDEX:BEGIN schema=1 -->\n${cards}<!-- NOWWORK:MEMORY_INDEX:END -->`;
|
|
126
|
+
}
|
|
127
|
+
function replaceGeneratedIndex(memory, entries) {
|
|
128
|
+
const generated = renderGeneratedIndex(entries);
|
|
129
|
+
if (GENERATED_INDEX_PATTERN.test(memory))
|
|
130
|
+
return memory.replace(GENERATED_INDEX_PATTERN, generated);
|
|
131
|
+
const heading = INDEX_HEADING.exec(memory);
|
|
132
|
+
if (heading?.index !== undefined) {
|
|
133
|
+
const afterHeading = heading.index + heading[0].length;
|
|
134
|
+
return `${memory.slice(0, afterHeading)}\n${generated}${memory.slice(afterHeading)}`;
|
|
135
|
+
}
|
|
136
|
+
return `${memory.trimEnd()}\n\n## Knowledge Index\n${generated}\n`;
|
|
137
|
+
}
|
|
138
|
+
function noteSection(id, revision, title, body) {
|
|
139
|
+
return `<!-- NOWWORK:MEMORY_ENTRY:BEGIN id=${id} revision=${revision} -->\n`
|
|
140
|
+
+ `## ${title}\n\n${body.trim()}\n`
|
|
141
|
+
+ `<!-- NOWWORK:MEMORY_ENTRY:END id=${id} -->\n`;
|
|
142
|
+
}
|
|
143
|
+
function notePattern(id) {
|
|
144
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
145
|
+
return new RegExp(`<!--\\s*NOWWORK:MEMORY_ENTRY:BEGIN\\s+id=${escaped}[^>]*-->[\\s\\S]*?<!--\\s*NOWWORK:MEMORY_ENTRY:END\\s+id=${escaped}\\s*-->\\s*`, "u");
|
|
146
|
+
}
|
|
147
|
+
function replaceNote(content, id, replacement) {
|
|
148
|
+
const pattern = notePattern(id);
|
|
149
|
+
return pattern.test(content) ? content.replace(pattern, replacement) : null;
|
|
150
|
+
}
|
|
151
|
+
function containsReservedText(change) {
|
|
152
|
+
return RESERVED_MEMORY_MARKER.test([change.title, change.summary, ...change.triggers, change.detailBody].join("\n"));
|
|
153
|
+
}
|
|
154
|
+
function validateCandidate(result) {
|
|
155
|
+
if (result.outcome === "no_change" && (result.changes.length > 0 || result.statusChanges.length > 0)) {
|
|
156
|
+
throw new Error("local_memory_no_change_has_operations");
|
|
157
|
+
}
|
|
158
|
+
for (const change of result.changes) {
|
|
159
|
+
const text = [change.title, change.summary, ...change.triggers, change.detailBody].join("\n");
|
|
160
|
+
if (containsLikelySecret(text))
|
|
161
|
+
throw new Error("local_memory_secret_candidate");
|
|
162
|
+
if (CONTROL_CHARACTER_PATTERN.test(text))
|
|
163
|
+
throw new Error("local_memory_control_character");
|
|
164
|
+
if (containsReservedText(change))
|
|
165
|
+
throw new Error("local_memory_reserved_marker");
|
|
166
|
+
if (change.operation === "create"
|
|
167
|
+
&& (change.memoryId !== null || change.baseRevision !== null || change.supersedesMemoryId !== null)) {
|
|
168
|
+
throw new Error("local_memory_create_revision_invalid");
|
|
169
|
+
}
|
|
170
|
+
if (change.operation === "update"
|
|
171
|
+
&& (change.memoryId === null || change.baseRevision === null || change.supersedesMemoryId !== null)) {
|
|
172
|
+
throw new Error("local_memory_update_revision_missing");
|
|
173
|
+
}
|
|
174
|
+
if (change.operation === "supersede"
|
|
175
|
+
&& (change.memoryId !== null || change.baseRevision === null || change.supersedesMemoryId === null)) {
|
|
176
|
+
throw new Error("local_memory_supersede_revision_missing");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export function parseLocalMemoryResult(raw) {
|
|
181
|
+
let value;
|
|
182
|
+
try {
|
|
183
|
+
value = JSON.parse(raw);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
throw new Error("local_memory_result_json_invalid");
|
|
187
|
+
}
|
|
188
|
+
const parsed = ResultSchema.safeParse(value);
|
|
189
|
+
if (!parsed.success)
|
|
190
|
+
throw new Error(`local_memory_result_invalid:${parsed.error.issues[0]?.path.join(".") ?? "unknown"}`);
|
|
191
|
+
validateCandidate(parsed.data);
|
|
192
|
+
return parsed.data;
|
|
193
|
+
}
|
|
194
|
+
function entryFromChange(change, id, revision, status) {
|
|
195
|
+
return {
|
|
196
|
+
id,
|
|
197
|
+
title: change.title,
|
|
198
|
+
file: detailPath(change.detailCategory),
|
|
199
|
+
type: change.kind,
|
|
200
|
+
status,
|
|
201
|
+
summary: change.summary,
|
|
202
|
+
triggers: [...new Set(change.triggers)],
|
|
203
|
+
priority: change.priority,
|
|
204
|
+
updated: new Date().toISOString().slice(0, 10),
|
|
205
|
+
anchor: id,
|
|
206
|
+
revision,
|
|
207
|
+
reviewAfter: change.reviewAfter,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function matchesCreateCandidate(entry, change) {
|
|
211
|
+
const triggers = [...new Set(change.triggers)];
|
|
212
|
+
return entry.title === change.title
|
|
213
|
+
&& entry.file === detailPath(change.detailCategory)
|
|
214
|
+
&& entry.type === change.kind
|
|
215
|
+
&& entry.summary === change.summary
|
|
216
|
+
&& entry.priority === change.priority
|
|
217
|
+
&& entry.reviewAfter === change.reviewAfter
|
|
218
|
+
&& entry.triggers.length === triggers.length
|
|
219
|
+
&& entry.triggers.every((trigger, index) => trigger === triggers[index]);
|
|
220
|
+
}
|
|
221
|
+
function stableId(executionId, index, change) {
|
|
222
|
+
const input = [executionId, String(index), change.operation, change.title, change.summary, change.detailBody].join("\0");
|
|
223
|
+
return `mem_${createHash("sha256").update(input, "utf8").digest("hex").slice(0, 12)}`;
|
|
224
|
+
}
|
|
225
|
+
function normalize(value) {
|
|
226
|
+
return value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/gu, " ").trim();
|
|
227
|
+
}
|
|
228
|
+
function activeOrPinnedIds(entries) {
|
|
229
|
+
return new Set(entries.filter((entry) => entry.status === "active" || entry.status === "pinned").map((entry) => entry.id));
|
|
230
|
+
}
|
|
231
|
+
function emptyResult() {
|
|
232
|
+
return { createdIds: [], updatedIds: [], statusChangedIds: [], deferredIds: [], pendingIds: [] };
|
|
233
|
+
}
|
|
234
|
+
function isWithin(root, candidate) {
|
|
235
|
+
const path = relative(root, candidate);
|
|
236
|
+
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`));
|
|
237
|
+
}
|
|
238
|
+
async function assertWritablePaths(agentDir, memoryPath) {
|
|
239
|
+
const agentRoot = await realpath(agentDir);
|
|
240
|
+
const notesPath = join(agentDir, "notes");
|
|
241
|
+
const notesRoot = await realpath(notesPath);
|
|
242
|
+
if (notesRoot !== resolve(agentRoot, "notes") || !isWithin(agentRoot, notesRoot))
|
|
243
|
+
throw new Error("local_memory_notes_path_invalid");
|
|
244
|
+
const memoryStat = await lstat(memoryPath);
|
|
245
|
+
if (!memoryStat.isFile() || memoryStat.isSymbolicLink())
|
|
246
|
+
throw new Error("local_memory_memory_path_invalid");
|
|
247
|
+
}
|
|
248
|
+
async function readNote(path) {
|
|
249
|
+
try {
|
|
250
|
+
const stat = await lstat(path);
|
|
251
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
252
|
+
throw new Error("local_memory_note_path_invalid");
|
|
253
|
+
return await readFile(path, "utf8");
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (error.code === "ENOENT")
|
|
257
|
+
return "";
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function atomicWrite(path, content) {
|
|
262
|
+
const temporary = `${path}.tmp-${randomUUID()}`;
|
|
263
|
+
try {
|
|
264
|
+
await writeFile(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
265
|
+
await rename(temporary, path);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function projectIndex(memory, entries, protectedIds) {
|
|
273
|
+
const empty = replaceGeneratedIndex(memory, []);
|
|
274
|
+
if (codePointLength(empty) > LOCAL_MEMORY_TOTAL_CODE_POINT_CAP)
|
|
275
|
+
throw new Error("local_memory_memory_over_budget");
|
|
276
|
+
const emptyIndex = renderGeneratedIndex([]);
|
|
277
|
+
const remainingTotal = LOCAL_MEMORY_TOTAL_CODE_POINT_CAP - codePointLength(empty);
|
|
278
|
+
const selection = selectLocalMemoryIndexEntries(entries, {
|
|
279
|
+
indexCodePointCap: Math.min(Math.max(0, LOCAL_MEMORY_INDEX_CODE_POINT_CAP - codePointLength(emptyIndex)), remainingTotal),
|
|
280
|
+
entryCap: LOCAL_MEMORY_INDEX_ENTRY_CAP,
|
|
281
|
+
}, { protectedIds });
|
|
282
|
+
return {
|
|
283
|
+
memory: replaceGeneratedIndex(memory, selection.entries),
|
|
284
|
+
omittedIds: selection.omittedIds,
|
|
285
|
+
reason: selection.reason,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const applyTails = new Map();
|
|
289
|
+
async function withApplyLease(agentDir, operation) {
|
|
290
|
+
const key = resolve(agentDir);
|
|
291
|
+
const previous = applyTails.get(key) ?? Promise.resolve();
|
|
292
|
+
let release;
|
|
293
|
+
const current = new Promise((resolveCurrent) => { release = resolveCurrent; });
|
|
294
|
+
const tail = previous.then(() => current);
|
|
295
|
+
applyTails.set(key, tail);
|
|
296
|
+
await previous;
|
|
297
|
+
try {
|
|
298
|
+
return await operation();
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
release();
|
|
302
|
+
void tail.then(() => {
|
|
303
|
+
if (applyTails.get(key) === tail)
|
|
304
|
+
applyTails.delete(key);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
export async function applyLocalMemoryResult(input) {
|
|
309
|
+
validateCandidate(input.result);
|
|
310
|
+
if (input.result.outcome !== "proposed")
|
|
311
|
+
return emptyResult();
|
|
312
|
+
const memoryPath = join(input.agentDir, "MEMORY.md");
|
|
313
|
+
await assertWritablePaths(input.agentDir, memoryPath);
|
|
314
|
+
return withApplyLease(input.agentDir, async () => {
|
|
315
|
+
const memory = await readFile(memoryPath, "utf8");
|
|
316
|
+
if (parseUnmarkedMemoryIndex(memory).length > 0)
|
|
317
|
+
throw new Error("local_memory_legacy_index_requires_migration");
|
|
318
|
+
let entries = [...parseLocalMemoryIndex(memory)];
|
|
319
|
+
for (const entry of entries) {
|
|
320
|
+
if (!isWithin(resolve(input.agentDir, "notes"), resolve(input.agentDir, entry.file))) {
|
|
321
|
+
throw new Error("local_memory_note_path_invalid");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const contentIds = new Set(input.result.changes.flatMap((change) => [
|
|
325
|
+
...(change.operation === "update" && change.memoryId !== null ? [change.memoryId] : []),
|
|
326
|
+
...(change.supersedesMemoryId === null ? [] : [change.supersedesMemoryId]),
|
|
327
|
+
]));
|
|
328
|
+
for (const statusChange of input.result.statusChanges) {
|
|
329
|
+
if (contentIds.has(statusChange.memoryId))
|
|
330
|
+
throw new Error("local_memory_status_conflict");
|
|
331
|
+
const current = entries.find((entry) => entry.id === statusChange.memoryId);
|
|
332
|
+
if (current === undefined || current.revision !== statusChange.expectedRevision || current.status !== statusChange.expectedStatus) {
|
|
333
|
+
throw new Error("local_memory_status_conflict");
|
|
334
|
+
}
|
|
335
|
+
entries = entries.map((entry) => entry.id === statusChange.memoryId
|
|
336
|
+
? { ...entry, status: statusChange.targetStatus, revision: entry.revision + 1 }
|
|
337
|
+
: entry);
|
|
338
|
+
}
|
|
339
|
+
const createdIds = [];
|
|
340
|
+
const updatedIds = [];
|
|
341
|
+
const statusChangedIds = input.result.statusChanges.map((change) => change.memoryId);
|
|
342
|
+
const deferredIds = [];
|
|
343
|
+
const pendingCandidates = [];
|
|
344
|
+
const noteUpdates = new Map();
|
|
345
|
+
const randomId = input.randomId ?? randomUUID;
|
|
346
|
+
const executionId = input.executionId ?? "unknown";
|
|
347
|
+
const defer = (change, index) => {
|
|
348
|
+
const candidateId = input.executionId === undefined ? `mem_${randomId()}` : stableId(executionId, index, change);
|
|
349
|
+
const pendingId = `pending-${candidateId.slice("mem_".length)}`;
|
|
350
|
+
pendingCandidates.push({
|
|
351
|
+
schemaVersion: 1,
|
|
352
|
+
pendingId,
|
|
353
|
+
createdAt: new Date().toISOString(),
|
|
354
|
+
reason: "index_over_budget",
|
|
355
|
+
source: { executionId, pruneTraceId: input.pruneTraceId ?? null },
|
|
356
|
+
candidate: change,
|
|
357
|
+
});
|
|
358
|
+
deferredIds.push(pendingId);
|
|
359
|
+
};
|
|
360
|
+
for (const [index, change] of input.result.changes.entries()) {
|
|
361
|
+
const id = change.operation === "update"
|
|
362
|
+
? change.memoryId
|
|
363
|
+
: input.executionId === undefined ? `mem_${randomId()}` : stableId(executionId, index, change);
|
|
364
|
+
const existingCreate = change.operation === "create" ? entries.find((entry) => entry.id === id) : undefined;
|
|
365
|
+
const current = change.operation === "update" ? entries.find((entry) => entry.id === id) : undefined;
|
|
366
|
+
if (change.operation === "update") {
|
|
367
|
+
if (current === undefined)
|
|
368
|
+
throw new Error("local_memory_revision_conflict_missing_id");
|
|
369
|
+
if (change.baseRevision !== current.revision)
|
|
370
|
+
throw new Error("local_memory_revision_conflict");
|
|
371
|
+
}
|
|
372
|
+
const superseded = change.operation === "supersede"
|
|
373
|
+
? entries.find((entry) => entry.id === change.supersedesMemoryId)
|
|
374
|
+
: undefined;
|
|
375
|
+
if (change.operation === "supersede") {
|
|
376
|
+
if (superseded === undefined)
|
|
377
|
+
throw new Error("local_memory_supersede_missing_id");
|
|
378
|
+
if (change.baseRevision !== superseded.revision)
|
|
379
|
+
throw new Error("local_memory_supersede_revision_conflict");
|
|
380
|
+
}
|
|
381
|
+
if (change.operation === "create") {
|
|
382
|
+
if (existingCreate !== undefined && !matchesCreateCandidate(existingCreate, change)) {
|
|
383
|
+
throw new Error("local_memory_duplicate_candidate");
|
|
384
|
+
}
|
|
385
|
+
if (existingCreate === undefined && entries.some((entry) => normalize(entry.title) === normalize(change.title)
|
|
386
|
+
|| normalize(entry.summary) === normalize(change.summary))) {
|
|
387
|
+
throw new Error("local_memory_duplicate_candidate");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const revision = existingCreate?.revision ?? (current === undefined ? 1 : current.revision + 1);
|
|
391
|
+
const entry = existingCreate ?? entryFromChange(change, id, revision, current?.status ?? "active");
|
|
392
|
+
const candidateEntries = existingCreate !== undefined
|
|
393
|
+
? entries
|
|
394
|
+
: change.supersedesMemoryId === null
|
|
395
|
+
? current === undefined ? [...entries, entry] : entries.map((item) => item.id === id ? entry : item)
|
|
396
|
+
: [
|
|
397
|
+
...entries.map((item) => item.id === change.supersedesMemoryId
|
|
398
|
+
? { ...item, status: "superseded", revision: item.revision + 1 }
|
|
399
|
+
: item),
|
|
400
|
+
entry,
|
|
401
|
+
];
|
|
402
|
+
const protectedIds = new Set(activeOrPinnedIds(entries));
|
|
403
|
+
if (change.supersedesMemoryId !== null)
|
|
404
|
+
protectedIds.delete(change.supersedesMemoryId);
|
|
405
|
+
const projection = projectIndex(memory, candidateEntries, protectedIds);
|
|
406
|
+
const omitted = (entry.status === "active" || entry.status === "pinned") && projection.omittedIds.includes(id);
|
|
407
|
+
if (change.operation === "update" && omitted)
|
|
408
|
+
throw new Error(projection.reason ?? "local_memory_index_over_budget");
|
|
409
|
+
if ((change.operation === "create" || change.operation === "supersede") && omitted) {
|
|
410
|
+
defer(change, index);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const notePath = resolve(input.agentDir, entry.file);
|
|
414
|
+
if (!isWithin(resolve(input.agentDir, "notes"), notePath))
|
|
415
|
+
throw new Error("local_memory_note_path_invalid");
|
|
416
|
+
const previous = noteUpdates.get(notePath) ?? await readNote(notePath);
|
|
417
|
+
const replacement = noteSection(id, revision, change.title, change.detailBody);
|
|
418
|
+
const existingSection = replaceNote(previous, id, replacement);
|
|
419
|
+
const next = existingSection ?? (current === undefined
|
|
420
|
+
? `${previous.trimEnd()}${previous.trimEnd() ? "\n\n" : ""}${replacement}`
|
|
421
|
+
: null);
|
|
422
|
+
if (next === null)
|
|
423
|
+
throw new Error("local_memory_note_anchor_missing");
|
|
424
|
+
noteUpdates.set(notePath, next);
|
|
425
|
+
entries = candidateEntries;
|
|
426
|
+
if (change.operation === "update")
|
|
427
|
+
updatedIds.push(id);
|
|
428
|
+
else if (existingCreate === undefined)
|
|
429
|
+
createdIds.push(id);
|
|
430
|
+
}
|
|
431
|
+
const finalProjection = projectIndex(memory, entries, activeOrPinnedIds(entries));
|
|
432
|
+
if (finalProjection.reason !== null)
|
|
433
|
+
throw new Error(finalProjection.reason);
|
|
434
|
+
for (const candidate of pendingCandidates)
|
|
435
|
+
await persistPendingMemoryCandidate({ agentDir: input.agentDir, candidate });
|
|
436
|
+
for (const [path, content] of noteUpdates)
|
|
437
|
+
await atomicWrite(path, content);
|
|
438
|
+
if (noteUpdates.size > 0 || statusChangedIds.length > 0)
|
|
439
|
+
await atomicWrite(memoryPath, finalProjection.memory);
|
|
440
|
+
return {
|
|
441
|
+
createdIds,
|
|
442
|
+
updatedIds,
|
|
443
|
+
statusChangedIds,
|
|
444
|
+
deferredIds,
|
|
445
|
+
pendingIds: deferredIds,
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
}
|
package/dist/runner.js
CHANGED
|
@@ -105,6 +105,7 @@ export async function runAgent(config, input, onActivity = defaultPrint, onConso
|
|
|
105
105
|
softTokens: config.sessionSoftTokens,
|
|
106
106
|
maxTurns: config.sessionMaxTurns,
|
|
107
107
|
},
|
|
108
|
+
localMemoryV2Enabled: config.localMemoryV2Handles?.has(input.handle) === true,
|
|
108
109
|
}, {
|
|
109
110
|
onActivity,
|
|
110
111
|
onConsole,
|
|
@@ -236,6 +236,7 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
236
236
|
migrationId: operation.migrationId,
|
|
237
237
|
platform: input.platform,
|
|
238
238
|
userHome: input.userHome,
|
|
239
|
+
...(input.uid === undefined ? {} : { uid: input.uid }),
|
|
239
240
|
});
|
|
240
241
|
workerLaunched = true;
|
|
241
242
|
const outcome = await dependencies.monitorWorker({
|
|
@@ -58,9 +58,12 @@ function findSourceRecord(records, input) {
|
|
|
58
58
|
return matches.length === 1 ? matches[0] : null;
|
|
59
59
|
}
|
|
60
60
|
export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
|
|
61
|
-
if (rawInput.platform !== "linux") {
|
|
61
|
+
if (rawInput.platform !== "darwin" && rawInput.platform !== "linux") {
|
|
62
62
|
return notReady("platform_not_supported");
|
|
63
63
|
}
|
|
64
|
+
if (rawInput.platform === "darwin" && rawInput.uid === undefined) {
|
|
65
|
+
return notReady("platform_identity_missing");
|
|
66
|
+
}
|
|
64
67
|
const configuredNowworkHome = rawInput.environment.NOWWORK_HOME;
|
|
65
68
|
if (configuredNowworkHome === undefined)
|
|
66
69
|
return notReady("nowwork_home_missing");
|
|
@@ -62,6 +62,16 @@ function workerUnitName(migrationId) {
|
|
|
62
62
|
function workerUnitPath(userHome, migrationId) {
|
|
63
63
|
return resolve(userHome, ".config", "systemd", "user", workerUnitName(migrationId));
|
|
64
64
|
}
|
|
65
|
+
function workerLaunchdLabel(migrationId) {
|
|
66
|
+
return `com.nowcrew.storage-migration.${migrationId}`;
|
|
67
|
+
}
|
|
68
|
+
function workerLaunchdPath(userHome, migrationId) {
|
|
69
|
+
return resolve(userHome, "Library", "LaunchAgents", `${workerLaunchdLabel(migrationId)}.plist`);
|
|
70
|
+
}
|
|
71
|
+
function xml(value) {
|
|
72
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
73
|
+
.replaceAll('"', """).replaceAll("'", "'");
|
|
74
|
+
}
|
|
65
75
|
function systemdArg(value) {
|
|
66
76
|
if (/[\0\r\n]/u.test(value))
|
|
67
77
|
throw new Error("storage worker service argument must be a single line");
|
|
@@ -77,9 +87,26 @@ export async function launchStorageMigrationWorker(input, runSupervisor = system
|
|
|
77
87
|
AbsolutePathSchema.parse(input.handoffPath);
|
|
78
88
|
AbsolutePathSchema.parse(input.userHome);
|
|
79
89
|
const migrationId = z.string().uuid().parse(input.migrationId).toLowerCase();
|
|
80
|
-
if (input.platform !== "linux")
|
|
81
|
-
throw new Error("Persistent storage migration workers require Linux systemd");
|
|
82
90
|
const worker = [process.execPath, input.entryPath, "storage-migration-worker", "--job", input.handoffPath];
|
|
91
|
+
if (input.platform === "darwin") {
|
|
92
|
+
if (input.uid === undefined || !Number.isSafeInteger(input.uid) || input.uid < 0) {
|
|
93
|
+
throw new Error("Persistent storage migration launchd worker requires a numeric uid");
|
|
94
|
+
}
|
|
95
|
+
const label = workerLaunchdLabel(migrationId);
|
|
96
|
+
const path = workerLaunchdPath(input.userHome, migrationId);
|
|
97
|
+
const args = worker.map((arg) => ` <string>${xml(arg)}</string>`).join("\n");
|
|
98
|
+
const descriptor = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key><string>${xml(label)}</string>\n <key>ProgramArguments</key>\n <array>\n${args}\n </array>\n <key>RunAtLoad</key><true/>\n <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key><false/>\n </dict>\n <key>ProcessType</key><string>Background</string>\n</dict>\n</plist>\n`;
|
|
99
|
+
await atomicPrivateWrite(path, descriptor);
|
|
100
|
+
try {
|
|
101
|
+
await checkedSupervisor(runSupervisor, "launchctl", ["bootstrap", `gui/${input.uid}`, path]);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
await runSupervisor("launchctl", ["bootout", `gui/${input.uid}/${label}`]).catch(() => undefined);
|
|
105
|
+
await rm(path, { force: true });
|
|
106
|
+
throw new Error("Storage migration persistent service launch failed", { cause: error });
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
83
110
|
const unit = workerUnitName(migrationId);
|
|
84
111
|
const path = workerUnitPath(input.userHome, migrationId);
|
|
85
112
|
const descriptor = `[Unit]\nDescription=NowWork storage migration ${migrationId}\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=exec\nExecStart=${worker.map(systemdArg).join(" ")}\nRestart=on-abnormal\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n`;
|
|
@@ -97,9 +124,16 @@ export async function launchStorageMigrationWorker(input, runSupervisor = system
|
|
|
97
124
|
}
|
|
98
125
|
export async function completeStorageMigrationWorkerService(input, runSupervisor = systemSupervisorRun) {
|
|
99
126
|
const migrationId = z.string().uuid().parse(input.migrationId).toLowerCase();
|
|
100
|
-
if (input.platform !== "linux")
|
|
101
|
-
return;
|
|
102
127
|
AbsolutePathSchema.parse(input.userHome);
|
|
128
|
+
if (input.platform === "darwin") {
|
|
129
|
+
if (input.uid === undefined || !Number.isSafeInteger(input.uid) || input.uid < 0) {
|
|
130
|
+
throw new Error("Persistent storage migration launchd worker requires a numeric uid");
|
|
131
|
+
}
|
|
132
|
+
const label = workerLaunchdLabel(migrationId);
|
|
133
|
+
await rm(workerLaunchdPath(input.userHome, migrationId), { force: true });
|
|
134
|
+
await runSupervisor("launchctl", ["bootout", `gui/${input.uid}/${label}`]);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
103
137
|
const unit = workerUnitName(migrationId);
|
|
104
138
|
await checkedSupervisor(runSupervisor, "systemctl", ["--user", "disable", unit]);
|
|
105
139
|
await rm(workerUnitPath(input.userHome, migrationId), { force: true });
|
|
@@ -112,5 +146,6 @@ export async function completeStorageMigrationWorkerServiceForHandoff(path) {
|
|
|
112
146
|
migrationId: operation.migrationId,
|
|
113
147
|
platform: operation.service.platform,
|
|
114
148
|
userHome: operation.service.userHome,
|
|
149
|
+
...(operation.service.uid === undefined ? {} : { uid: operation.service.uid }),
|
|
115
150
|
});
|
|
116
151
|
}
|
package/dist/workspace.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* spawn runtime 时把 <ws>/.crew 置于 PATH 最前,于是 agent 的 `crew ...` 命中 wrapper。
|
|
10
10
|
*/
|
|
11
|
-
import { mkdir, writeFile, readFile, chmod, access } from "node:fs/promises";
|
|
11
|
+
import { mkdir, writeFile, readFile, chmod, access, lstat } from "node:fs/promises";
|
|
12
12
|
import { createHash, randomUUID } from "node:crypto";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
14
|
import { dslog } from "./slog.js";
|
|
@@ -34,8 +34,28 @@ async function exists(p) {
|
|
|
34
34
|
return false;
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
async function hasLocalMemoryV2Marker(agentDir) {
|
|
38
|
+
const crewDir = join(agentDir, ".crew");
|
|
39
|
+
const memoryDir = join(crewDir, "memory");
|
|
40
|
+
const markerPath = join(agentDir, ".crew", "memory", "v2-enabled");
|
|
41
|
+
try {
|
|
42
|
+
const crewStat = await lstat(crewDir);
|
|
43
|
+
const memoryStat = await lstat(memoryDir);
|
|
44
|
+
if (!crewStat.isDirectory() || crewStat.isSymbolicLink()
|
|
45
|
+
|| !memoryStat.isDirectory() || memoryStat.isSymbolicLink())
|
|
46
|
+
return false;
|
|
47
|
+
const stat = await lstat(markerPath);
|
|
48
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
49
|
+
return false;
|
|
50
|
+
return (await readFile(markerPath, "utf8")) === "1\n";
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
37
56
|
export async function prepareWorkspace(input) {
|
|
38
57
|
const dir = join(input.agentsRoot, input.handle);
|
|
58
|
+
const agentHomePreexisted = await exists(dir);
|
|
39
59
|
const crewDir = join(dir, ".crew");
|
|
40
60
|
await mkdir(crewDir, { recursive: true });
|
|
41
61
|
// 预先创建明细目录,让收尾流程可以按功能创建 notes/<topic>.md。
|
|
@@ -43,6 +63,7 @@ export async function prepareWorkspace(input) {
|
|
|
43
63
|
// MEMORY.md:首次创建"持久身份 + 知识索引"骨架,之后由 agent 自己维护
|
|
44
64
|
const memoryPath = join(dir, "MEMORY.md");
|
|
45
65
|
const memorySeedCreated = !(await exists(memoryPath));
|
|
66
|
+
const localMemoryV2Initialized = await hasLocalMemoryV2Marker(dir);
|
|
46
67
|
if (memorySeedCreated) {
|
|
47
68
|
try {
|
|
48
69
|
await writeFile(memoryPath, memorySeed(input.handle, input.description), "utf8");
|
|
@@ -124,7 +145,9 @@ export async function prepareWorkspace(input) {
|
|
|
124
145
|
crewDir,
|
|
125
146
|
systemPromptPath,
|
|
126
147
|
memory,
|
|
148
|
+
agentHomePreexisted,
|
|
127
149
|
memorySeedCreated,
|
|
150
|
+
localMemoryV2Initialized,
|
|
128
151
|
homeDir,
|
|
129
152
|
runDir,
|
|
130
153
|
workLogPath,
|