@nowcrew/daemon 0.6.87 → 0.6.88-beta.10
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/README.md +45 -10
- package/dist/agent-memory/policy.js +3 -1
- package/dist/cloud-report-receipt.js +83 -0
- package/dist/cloud-report-uploader.js +36 -25
- package/dist/execution-protocol.js +1 -1
- package/dist/execution-runner.js +9 -5
- package/dist/list-models.js +2 -0
- package/dist/local-execution-prompt.js +19 -25
- package/dist/local-executor.js +67 -85
- package/dist/local-memory/candidate.js +43 -0
- package/dist/local-memory/pending-match.js +29 -0
- package/dist/local-memory/pending-query.js +100 -0
- package/dist/local-memory/pending-schema.js +29 -0
- package/dist/local-memory/pending-source.js +126 -0
- package/dist/local-memory/pending.js +34 -84
- package/dist/local-memory/prompt.js +40 -0
- package/dist/local-memory/read-cli.js +281 -0
- package/dist/local-memory/runtime.js +56 -7
- package/dist/local-memory/write.js +16 -48
- package/dist/local-memory-diagnostics.js +79 -12
- package/dist/native-thread-context.js +59 -0
- package/dist/prompt.js +39 -24
- package/dist/runner.js +14 -15
- package/dist/runtime-environment-telemetry.js +27 -0
- package/dist/runtimes/claude-session-start-hook.js +24 -0
- package/dist/runtimes/claude-thread-runner.js +266 -0
- package/dist/runtimes/claude.js +23 -4
- package/dist/runtimes/codex-app-server-runner.js +43 -4
- package/dist/runtimes/kimi-acp-runner.js +6 -2
- package/dist/runtimes/opencode-runner.js +7 -1
- package/dist/serve.js +7 -8
- package/dist/session.js +4 -0
- package/dist/supervised-runtime.js +11 -16
- package/dist/thread-input-delivery.js +217 -0
- package/dist/workspace.js +15 -0
- package/package.json +2 -2
|
@@ -1,34 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir,
|
|
2
|
+
import { lstat, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { isRecallablePending, parsePendingMemoryCandidate } from "./pending-schema.js";
|
|
5
|
+
import { pendingFileNames, pendingReadDirectory, PENDING_FILE_BYTES, readPendingSource } from "./pending-source.js";
|
|
6
|
+
import { pendingRecallScore } from "./pending-match.js";
|
|
5
7
|
export const LOCAL_MEMORY_PENDING_SCAN_FILE_CAP = 128;
|
|
6
8
|
export const LOCAL_MEMORY_PENDING_SCAN_BYTE_CAP = 1_000_000;
|
|
7
|
-
const CandidateSchema = z.object({
|
|
8
|
-
operation: z.enum(["create", "update", "supersede"]),
|
|
9
|
-
memoryId: z.string().nullable(),
|
|
10
|
-
baseRevision: z.number().int().nonnegative().nullable(),
|
|
11
|
-
supersedesMemoryId: z.string().nullable(),
|
|
12
|
-
title: z.string().min(1),
|
|
13
|
-
kind: z.enum(["procedural", "semantic", "episodic", "preference"]),
|
|
14
|
-
summary: z.string().min(1),
|
|
15
|
-
triggers: z.array(z.string()).min(1),
|
|
16
|
-
priority: z.enum(["high", "medium", "low"]),
|
|
17
|
-
detailCategory: z.enum(["preferences", "project-rules", "testing", "debugging", "git", "incidents", "lessons"]),
|
|
18
|
-
detailBody: z.string().min(1),
|
|
19
|
-
reviewAfter: z.string().nullable(),
|
|
20
|
-
}).strict();
|
|
21
|
-
const PendingSchema = z.object({
|
|
22
|
-
schemaVersion: z.literal(1),
|
|
23
|
-
pendingId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u),
|
|
24
|
-
createdAt: z.string().min(1),
|
|
25
|
-
reason: z.literal("index_over_budget"),
|
|
26
|
-
source: z.object({
|
|
27
|
-
executionId: z.string().min(1),
|
|
28
|
-
pruneTraceId: z.string().min(1).nullable(),
|
|
29
|
-
}).strict(),
|
|
30
|
-
candidate: CandidateSchema,
|
|
31
|
-
}).strict();
|
|
32
9
|
function within(root, candidate) {
|
|
33
10
|
const path = relative(root, candidate);
|
|
34
11
|
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`));
|
|
@@ -122,13 +99,11 @@ async function atomicWrite(path, content) {
|
|
|
122
99
|
}
|
|
123
100
|
}
|
|
124
101
|
export async function persistPendingMemoryCandidate(input) {
|
|
125
|
-
const parsed =
|
|
126
|
-
if (!parsed.success)
|
|
127
|
-
throw new Error("local_memory_pending_invalid");
|
|
102
|
+
const parsed = parsePendingMemoryCandidate(input.candidate);
|
|
128
103
|
const directory = await pendingDirectory(input.agentDir);
|
|
129
|
-
const target = join(directory, `${parsed.
|
|
104
|
+
const target = join(directory, `${parsed.pendingId}.json`);
|
|
130
105
|
await assertRegularFile(target);
|
|
131
|
-
await atomicWrite(target, `${JSON.stringify(parsed
|
|
106
|
+
await atomicWrite(target, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
132
107
|
return target;
|
|
133
108
|
}
|
|
134
109
|
export async function removePendingMemoryCandidate(input) {
|
|
@@ -140,73 +115,44 @@ export async function removePendingMemoryCandidate(input) {
|
|
|
140
115
|
await rm(target, { force: true });
|
|
141
116
|
}
|
|
142
117
|
export async function readPendingMemoryCandidates(input) {
|
|
143
|
-
const directory = await
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
118
|
+
const { directory } = await pendingReadDirectory(input.agentDir).catch((error) => {
|
|
119
|
+
if (error instanceof Error && error.message === "memory_pending_path_invalid") {
|
|
120
|
+
throw new Error("local_memory_pending_path_invalid");
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
});
|
|
124
|
+
const names = await pendingFileNames(directory);
|
|
147
125
|
const maxFiles = Math.max(0, input.maxFiles ?? LOCAL_MEMORY_PENDING_SCAN_FILE_CAP);
|
|
148
126
|
const maxBytes = Math.max(0, input.maxBytes ?? LOCAL_MEMORY_PENDING_SCAN_BYTE_CAP);
|
|
149
127
|
const result = [];
|
|
150
128
|
let scannedBytes = 0;
|
|
151
|
-
for (const name of names) {
|
|
152
|
-
if (
|
|
129
|
+
for (const name of names.slice(0, maxFiles)) {
|
|
130
|
+
if (scannedBytes >= maxBytes)
|
|
153
131
|
break;
|
|
154
|
-
const path = join(directory, name);
|
|
155
132
|
try {
|
|
156
|
-
await
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
throw new Error("local_memory_pending_path_invalid");
|
|
160
|
-
if (scannedBytes + stat.size > maxBytes)
|
|
161
|
-
break;
|
|
162
|
-
const raw = await readFile(path, "utf8");
|
|
163
|
-
const size = Buffer.byteLength(raw, "utf8");
|
|
164
|
-
if (scannedBytes + size > maxBytes)
|
|
165
|
-
break;
|
|
166
|
-
scannedBytes += size;
|
|
167
|
-
const parsed = PendingSchema.safeParse(JSON.parse(raw));
|
|
168
|
-
if (parsed.success && `${parsed.data.pendingId}.json` === name)
|
|
169
|
-
result.push(parsed.data);
|
|
133
|
+
const source = await readPendingSource(input.agentDir, name, maxBytes - scannedBytes);
|
|
134
|
+
scannedBytes += source.bytes;
|
|
135
|
+
result.push(source.candidate);
|
|
170
136
|
}
|
|
171
137
|
catch {
|
|
172
|
-
//
|
|
138
|
+
// 失败读取也消耗预算,显式分页命令负责返回诊断和继续扫描。
|
|
139
|
+
scannedBytes += Math.min(PENDING_FILE_BYTES, maxBytes - scannedBytes);
|
|
173
140
|
}
|
|
174
141
|
}
|
|
175
142
|
return result;
|
|
176
143
|
}
|
|
177
|
-
function normalize(value) {
|
|
178
|
-
return value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/gu, " ").trim();
|
|
179
|
-
}
|
|
180
144
|
function priorityRank(priority) {
|
|
181
145
|
return priority === "high" ? 0 : priority === "medium" ? 1 : 2;
|
|
182
146
|
}
|
|
183
|
-
function candidateText(candidate) {
|
|
184
|
-
return [
|
|
185
|
-
candidate.candidate.title,
|
|
186
|
-
candidate.candidate.summary,
|
|
187
|
-
...candidate.candidate.triggers,
|
|
188
|
-
candidate.candidate.detailCategory,
|
|
189
|
-
candidate.candidate.detailBody,
|
|
190
|
-
].join("\n");
|
|
191
|
-
}
|
|
192
|
-
function recallScore(candidate, query) {
|
|
193
|
-
const normalizedQuery = normalize(query);
|
|
194
|
-
if (normalizedQuery === "")
|
|
195
|
-
return 1;
|
|
196
|
-
const text = normalize(candidateText(candidate));
|
|
197
|
-
if (text.includes(normalizedQuery))
|
|
198
|
-
return 100;
|
|
199
|
-
const terms = normalizedQuery.split(/[\s,,、。!?;::()[\]{}"'`]+/u).filter((term) => term.length > 0);
|
|
200
|
-
return terms.reduce((score, term) => score + (text.includes(term) ? 1 : 0), 0);
|
|
201
|
-
}
|
|
202
147
|
export async function searchPendingMemoryCandidates(input) {
|
|
203
148
|
const candidates = await readPendingMemoryCandidates({ agentDir: input.agentDir });
|
|
204
149
|
const limit = Math.max(0, input.limit ?? 5);
|
|
205
150
|
if (limit === 0)
|
|
206
151
|
return [];
|
|
207
152
|
return candidates
|
|
208
|
-
.
|
|
209
|
-
.
|
|
153
|
+
.filter(isRecallablePending)
|
|
154
|
+
.map((candidate) => ({ candidate, score: pendingRecallScore(candidate, input.query) }))
|
|
155
|
+
.filter((item) => item.score > 0)
|
|
210
156
|
.sort((left, right) => right.score - left.score
|
|
211
157
|
|| priorityRank(left.candidate.candidate.priority) - priorityRank(right.candidate.candidate.priority)
|
|
212
158
|
|| right.candidate.createdAt.localeCompare(left.candidate.createdAt)
|
|
@@ -218,25 +164,29 @@ function truncateCodePoints(value, maxCodePoints) {
|
|
|
218
164
|
return Array.from(value).slice(0, Math.max(0, maxCodePoints)).join("");
|
|
219
165
|
}
|
|
220
166
|
export function renderPendingMemoryContext(candidates, maxCodePoints = 2_000) {
|
|
221
|
-
|
|
167
|
+
const recallable = candidates.filter(isRecallablePending);
|
|
168
|
+
if (recallable.length === 0 || maxCodePoints <= 0)
|
|
222
169
|
return "";
|
|
223
|
-
const header = `\n\n## Archived memory candidates (daemon-managed)\n
|
|
170
|
+
const header = `\n\n## Archived memory candidates (daemon-managed)\n容量暂存的新增资料,仅为部分预览,不代表完整检索;可参考但不能覆盖现行规则,未包含内容请按需查询。\n`;
|
|
224
171
|
let remaining = maxCodePoints - Array.from(header).length;
|
|
225
172
|
if (remaining <= 0)
|
|
226
173
|
return truncateCodePoints(header, maxCodePoints);
|
|
227
174
|
const sections = [];
|
|
228
|
-
for (const item of
|
|
175
|
+
for (const item of recallable) {
|
|
229
176
|
const change = item.candidate;
|
|
230
177
|
const prefix = `\n### ${change.title}\n`
|
|
231
|
-
+ `-
|
|
178
|
+
+ `- pending_id: ${item.pendingId}\n`
|
|
232
179
|
+ `- summary: ${change.summary}\n`
|
|
233
180
|
+ `- triggers: ${change.triggers.join(", ")}\n`
|
|
234
181
|
+ "- detail: ";
|
|
235
182
|
const prefixLength = Array.from(prefix).length;
|
|
236
183
|
if (prefixLength + 1 > remaining)
|
|
237
184
|
break;
|
|
238
|
-
const
|
|
239
|
-
const
|
|
185
|
+
const notice = " [正文未完,请用 read-pending 分页读取]";
|
|
186
|
+
const available = remaining - prefixLength - 1;
|
|
187
|
+
const truncated = Array.from(change.detailBody).length > available;
|
|
188
|
+
const detail = truncateCodePoints(change.detailBody, truncated ? Math.max(0, available - Array.from(notice).length) : available);
|
|
189
|
+
const section = `${prefix}${detail}${truncated ? truncateCodePoints(notice, available - Array.from(detail).length) : ""}\n`;
|
|
240
190
|
const length = Array.from(section).length;
|
|
241
191
|
sections.push(section);
|
|
242
192
|
remaining -= length;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { dslog } from "../slog.js";
|
|
2
|
+
import { renderPendingMemoryContext, searchPendingMemoryCandidates } from "./pending.js";
|
|
3
|
+
import { renderLocalMemoryResultInstructions } from "./runtime.js";
|
|
4
|
+
const PENDING_READ_GUIDANCE = "\n\nV2新增资料预览未命中不代表没有资料;先查有效索引,必要时用 crew-memory search-pending --query '关键词',按 nextCursor 续查;用 read-pending --id ID 分页读正文。容量暂存资料不能覆盖现行规则,未应用修改不作为记忆。用 crew-memory --help 查看参数。\n";
|
|
5
|
+
export async function prepareLocalMemoryPrompt(input) {
|
|
6
|
+
let pending = "";
|
|
7
|
+
if (input.enabled) {
|
|
8
|
+
try {
|
|
9
|
+
pending = renderPendingMemoryContext(await searchPendingMemoryCandidates({
|
|
10
|
+
agentDir: input.agentDir, query: input.query, limit: 5,
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
dslog("local_memory.pending_recall_failed", "本地归档记忆召回失败", {
|
|
15
|
+
level: "WARN",
|
|
16
|
+
execution_id: input.executionId,
|
|
17
|
+
agent_handle: input.agentHandle,
|
|
18
|
+
error_name: error instanceof Error ? error.name : "unknown",
|
|
19
|
+
error_code: error?.code,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const original = input.renderBase(0);
|
|
24
|
+
const structured = input.resultPath === null ? "" : renderLocalMemoryResultInstructions(input.resultPath);
|
|
25
|
+
const guidance = input.enabled ? PENDING_READ_GUIDANCE : "";
|
|
26
|
+
// 短命令指引放在当前轮,独立于可舍弃预览;收尾契约仍保持系统与当前轮两份。
|
|
27
|
+
for (const choice of [
|
|
28
|
+
{ suffix: pending + structured, wake: structured + guidance },
|
|
29
|
+
{ suffix: structured, wake: structured + guidance },
|
|
30
|
+
{ suffix: structured, wake: structured },
|
|
31
|
+
]) {
|
|
32
|
+
const reserved = Buffer.byteLength(choice.suffix + choice.wake, "utf8");
|
|
33
|
+
const systemPrompt = (reserved === 0 ? original : input.renderBase(reserved)) + choice.suffix;
|
|
34
|
+
if (input.maxBytes === undefined || Buffer.byteLength(systemPrompt + choice.wake, "utf8") <= input.maxBytes) {
|
|
35
|
+
return { systemPrompt, resultPath: input.resultPath, wakePromptSuffix: choice.wake };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// 强制基础指令与契约本身仍无法共存时,保留既有非结构化兼容回退,不裁基础指令。
|
|
39
|
+
return { systemPrompt: original, resultPath: null, wakePromptSuffix: "" };
|
|
40
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open, realpath } from "node:fs/promises";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { parseArgs, TextDecoder } from "node:util";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { readPendingMemoryBody, searchPendingMemoryPage } from "./pending-query.js";
|
|
9
|
+
const OUTPUT_BYTES = 12 * 1024;
|
|
10
|
+
const FILE_BYTES = 32 * 1024 * 1024;
|
|
11
|
+
const FileSchema = z.string().min(1).refine((file) => {
|
|
12
|
+
if (isAbsolute(file) || /[\\\u0000-\u001f\u007f]/u.test(file))
|
|
13
|
+
return false;
|
|
14
|
+
if (Buffer.byteLength(file, "utf8") > 1024)
|
|
15
|
+
return false;
|
|
16
|
+
const parts = file.split("/");
|
|
17
|
+
if (parts.some((part) => part === "" || part === "." || part === ".."))
|
|
18
|
+
return false;
|
|
19
|
+
return file === "MEMORY.md" || (parts[0] === "notes" && parts.length > 1 && file.endsWith(".md"));
|
|
20
|
+
}, "memory_path_invalid");
|
|
21
|
+
const Common = {
|
|
22
|
+
file: FileSchema.default("MEMORY.md"),
|
|
23
|
+
ifHash: z.string().regex(/^[a-f0-9]{64}$/u).optional(),
|
|
24
|
+
};
|
|
25
|
+
const CommandSchema = z.discriminatedUnion("command", [
|
|
26
|
+
z.object({
|
|
27
|
+
command: z.literal("search"), ...Common,
|
|
28
|
+
query: z.string().trim().min(1).max(256),
|
|
29
|
+
limit: z.coerce.number().int().min(1).max(40).default(10),
|
|
30
|
+
afterLine: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),
|
|
31
|
+
}).strict(),
|
|
32
|
+
z.object({
|
|
33
|
+
command: z.literal("read"), ...Common,
|
|
34
|
+
offset: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),
|
|
35
|
+
limit: z.coerce.number().int().min(1).max(4000).default(2000),
|
|
36
|
+
}).strict(),
|
|
37
|
+
z.object({
|
|
38
|
+
command: z.literal("search-pending"),
|
|
39
|
+
query: z.string().trim().min(1).max(256),
|
|
40
|
+
cursor: z.string().min(1).max(4096).optional(),
|
|
41
|
+
limit: z.coerce.number().int().min(1).max(40).default(10),
|
|
42
|
+
}).strict(),
|
|
43
|
+
z.object({
|
|
44
|
+
command: z.literal("read-pending"),
|
|
45
|
+
id: z.string().min(1).max(250).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u),
|
|
46
|
+
ifHash: Common.ifHash,
|
|
47
|
+
offset: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),
|
|
48
|
+
limit: z.coerce.number().int().min(1).max(4000).default(2000),
|
|
49
|
+
}).strict(),
|
|
50
|
+
]);
|
|
51
|
+
const USAGE = `只读记忆工具,默认输出 JSON,不修改任何记忆文件。
|
|
52
|
+
crew-memory search --query <关键词> [--file MEMORY.md|notes/文件.md] [--limit 10] [--after-line 0]
|
|
53
|
+
crew-memory read [--file MEMORY.md|notes/文件.md] [--offset 0] [--limit 2000]
|
|
54
|
+
crew-memory search-pending --query <关键词> [--cursor <游标>] [--limit 10]
|
|
55
|
+
crew-memory read-pending --id <候选ID> [--offset 0] [--limit 2000] [--if-hash <sha256>]
|
|
56
|
+
search 关键词按空白分隔,同一行须全部命中;返回完整文件中的行号和字符偏移。
|
|
57
|
+
读取的 offset/limit 按 Unicode 字符计数;用 nextOffset 继续,eof=true 表示结束。
|
|
58
|
+
后续页可传 --if-hash <前一页的 sha256>,文件变化时停止,避免混读不同版本。
|
|
59
|
+
search/read 读取 MEMORY.md 和 notes 原文,不过滤失效段落,也不扫描 pending。
|
|
60
|
+
search-pending 只查因容量延期的 create 候选;有 nextCursor 就继续,即使本页 matches 为空。
|
|
61
|
+
complete=true 才表示全部候选已检索完成且无遗漏;warnings 或非零退出码表示读取未完成,不能当作零命中。
|
|
62
|
+
read-pending 只返回候选正文;offset 非零时必须传 --if-hash,update/supersede 候选不可读取。
|
|
63
|
+
候选只供相关问题参考,未生效,不能覆盖有效规则;先读取当前有效索引与明细。`;
|
|
64
|
+
function inside(root, path) {
|
|
65
|
+
const value = relative(root, path);
|
|
66
|
+
return value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value);
|
|
67
|
+
}
|
|
68
|
+
async function readSource(home, file) {
|
|
69
|
+
if (!home)
|
|
70
|
+
throw new Error("memory_home_missing");
|
|
71
|
+
const root = await realpath(home);
|
|
72
|
+
const path = await realpath(resolve(root, file));
|
|
73
|
+
if (!inside(root, path) || !FileSchema.safeParse(relative(root, path).split(sep).join("/")).success) {
|
|
74
|
+
throw new Error("memory_path_invalid");
|
|
75
|
+
}
|
|
76
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
77
|
+
try {
|
|
78
|
+
const stat = await handle.stat();
|
|
79
|
+
if (!stat.isFile())
|
|
80
|
+
throw new Error("memory_file_not_regular");
|
|
81
|
+
if (stat.size > FILE_BYTES)
|
|
82
|
+
throw new Error("memory_file_too_large");
|
|
83
|
+
// 允许读完整来源,但绝不能在文件并发增长时无界分配内存。
|
|
84
|
+
const chunks = [];
|
|
85
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
86
|
+
let bytes = 0;
|
|
87
|
+
for (;;) {
|
|
88
|
+
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, FILE_BYTES + 1 - bytes), null);
|
|
89
|
+
if (bytesRead === 0)
|
|
90
|
+
break;
|
|
91
|
+
bytes += bytesRead;
|
|
92
|
+
if (bytes > FILE_BYTES)
|
|
93
|
+
throw new Error("memory_file_too_large");
|
|
94
|
+
chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
|
|
95
|
+
}
|
|
96
|
+
const buffer = Buffer.concat(chunks);
|
|
97
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer);
|
|
98
|
+
return { text, sha256: createHash("sha256").update(buffer).digest("hex") };
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await handle.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function characterCount(text) {
|
|
105
|
+
let count = 0;
|
|
106
|
+
for (const _character of text)
|
|
107
|
+
count += 1;
|
|
108
|
+
return count;
|
|
109
|
+
}
|
|
110
|
+
function excerpt(text, limit) {
|
|
111
|
+
let value = "";
|
|
112
|
+
let count = 0;
|
|
113
|
+
for (const character of text) {
|
|
114
|
+
if (count++ >= limit)
|
|
115
|
+
return `${value}...`;
|
|
116
|
+
value += character;
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
function originalLowercaseIndex(text, index) {
|
|
121
|
+
let originalIndex = 0;
|
|
122
|
+
let lowerIndex = 0;
|
|
123
|
+
for (const character of text) {
|
|
124
|
+
// 逐字符只累计小写长度;匹配仍用整行小写,保留长度不变的词尾 sigma 上下文规则。
|
|
125
|
+
lowerIndex += character.toLowerCase().length;
|
|
126
|
+
if (index < lowerIndex)
|
|
127
|
+
return originalIndex;
|
|
128
|
+
originalIndex += character.length;
|
|
129
|
+
}
|
|
130
|
+
return text.length;
|
|
131
|
+
}
|
|
132
|
+
function searchSource(text, sha256, command) {
|
|
133
|
+
const terms = command.query.toLowerCase().split(/\s+/u);
|
|
134
|
+
const matches = [];
|
|
135
|
+
let line = 0;
|
|
136
|
+
let offset = 0;
|
|
137
|
+
let heading = "";
|
|
138
|
+
let matchCount = 0;
|
|
139
|
+
let hasMore = false;
|
|
140
|
+
let remaining = OUTPUT_BYTES - Buffer.byteLength(JSON.stringify({ file: command.file, sha256 })) - 512;
|
|
141
|
+
for (const match of text.matchAll(/[^\n]*(?:\n|$)/gu)) {
|
|
142
|
+
if (match[0] === "")
|
|
143
|
+
break;
|
|
144
|
+
line += 1;
|
|
145
|
+
const raw = match[0];
|
|
146
|
+
const content = raw.replace(/\r?\n$/u, "");
|
|
147
|
+
const title = /^#{1,6}\s+(.+?)\s*$/u.exec(content)?.[1];
|
|
148
|
+
if (title !== undefined)
|
|
149
|
+
heading = excerpt(title, 96);
|
|
150
|
+
const lower = content.toLowerCase();
|
|
151
|
+
if (terms.every((term) => lower.includes(term))) {
|
|
152
|
+
matchCount += 1;
|
|
153
|
+
if (line > command.afterLine) {
|
|
154
|
+
let start = Math.max(0, originalLowercaseIndex(content, lower.indexOf(terms[0])) - 60);
|
|
155
|
+
if (start > 0 && /[\uDC00-\uDFFF]/u.test(content[start]))
|
|
156
|
+
start -= 1;
|
|
157
|
+
const item = { line, offset, heading, text: `${start > 0 ? "..." : ""}${excerpt(content.slice(start), 240)}` };
|
|
158
|
+
const bytes = Buffer.byteLength(JSON.stringify(item), "utf8") + 1;
|
|
159
|
+
if (hasMore || matches.length >= command.limit || bytes > remaining) {
|
|
160
|
+
hasMore = true;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
matches.push(item);
|
|
164
|
+
remaining -= bytes;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
offset += characterCount(raw);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
file: command.file, sha256, matches, matchCount, hasMore,
|
|
172
|
+
nextAfterLine: hasMore ? matches.at(-1)?.line ?? null : null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function readPage(text, source, command) {
|
|
176
|
+
const metadata = { ...source, offset: command.offset, nextOffset: Number.MAX_SAFE_INTEGER, eof: false, content: "" };
|
|
177
|
+
let remaining = OUTPUT_BYTES - Buffer.byteLength(JSON.stringify(metadata), "utf8");
|
|
178
|
+
let position = 0;
|
|
179
|
+
let offset = 0;
|
|
180
|
+
while (position < text.length && offset < command.offset) {
|
|
181
|
+
position += text.codePointAt(position) > 0xffff ? 2 : 1;
|
|
182
|
+
offset += 1;
|
|
183
|
+
}
|
|
184
|
+
let content = "";
|
|
185
|
+
let count = 0;
|
|
186
|
+
while (position < text.length && count < command.limit) {
|
|
187
|
+
const character = String.fromCodePoint(text.codePointAt(position));
|
|
188
|
+
const bytes = Buffer.byteLength(JSON.stringify(character), "utf8") - 2;
|
|
189
|
+
if (bytes > remaining)
|
|
190
|
+
break;
|
|
191
|
+
remaining -= bytes;
|
|
192
|
+
content += character;
|
|
193
|
+
position += character.length;
|
|
194
|
+
offset += 1;
|
|
195
|
+
count += 1;
|
|
196
|
+
}
|
|
197
|
+
const eof = position === text.length;
|
|
198
|
+
if (!eof && count === 0)
|
|
199
|
+
throw new Error("memory_output_too_large");
|
|
200
|
+
return { ...metadata, content, eof, nextOffset: eof ? null : offset };
|
|
201
|
+
}
|
|
202
|
+
function writeResult(result, io) {
|
|
203
|
+
const output = JSON.stringify(result);
|
|
204
|
+
if (Buffer.byteLength(output, "utf8") > OUTPUT_BYTES)
|
|
205
|
+
throw new Error("memory_output_too_large");
|
|
206
|
+
io.out(output);
|
|
207
|
+
}
|
|
208
|
+
export async function runMemoryReadCommand(argv, io) {
|
|
209
|
+
let command;
|
|
210
|
+
try {
|
|
211
|
+
const { values, positionals } = parseArgs({
|
|
212
|
+
args: [...argv], allowPositionals: true,
|
|
213
|
+
options: {
|
|
214
|
+
query: { type: "string", short: "q" }, file: { type: "string" },
|
|
215
|
+
limit: { type: "string" }, offset: { type: "string" },
|
|
216
|
+
"after-line": { type: "string" }, "if-hash": { type: "string" },
|
|
217
|
+
id: { type: "string" }, cursor: { type: "string" },
|
|
218
|
+
json: { type: "boolean" }, help: { type: "boolean", short: "h" },
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
if (values.help) {
|
|
222
|
+
io.out(USAGE);
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
if (positionals.length !== 1)
|
|
226
|
+
throw new Error("memory_command_invalid");
|
|
227
|
+
const { help: _help, json: _json, "after-line": afterLine, "if-hash": ifHash, ...options } = values;
|
|
228
|
+
command = CommandSchema.parse({
|
|
229
|
+
command: positionals[0], ...options,
|
|
230
|
+
...(afterLine === undefined ? {} : { afterLine }),
|
|
231
|
+
...(ifHash === undefined ? {} : { ifHash }),
|
|
232
|
+
});
|
|
233
|
+
if (command.command === "read-pending" && command.offset > 0 && command.ifHash === undefined) {
|
|
234
|
+
throw new Error("memory_hash_required");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
io.err(`memory_arguments_invalid: 参数不合法,记忆读取未完成。\n${USAGE}`);
|
|
239
|
+
return 2;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
if (!io.env.CREW_HOME)
|
|
243
|
+
throw new Error("memory_home_missing");
|
|
244
|
+
if (command.command === "search-pending") {
|
|
245
|
+
const result = await searchPendingMemoryPage({ agentDir: io.env.CREW_HOME, query: command.query,
|
|
246
|
+
...(command.cursor === undefined ? {} : { cursor: command.cursor }), limit: command.limit });
|
|
247
|
+
writeResult(result, io);
|
|
248
|
+
if (result.warnings.length > 0 || (!result.hasMore && !result.complete)) {
|
|
249
|
+
throw new Error("memory_pending_search_incomplete");
|
|
250
|
+
}
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
if (command.command === "read-pending") {
|
|
254
|
+
const { content, ...metadata } = await readPendingMemoryBody({ agentDir: io.env.CREW_HOME,
|
|
255
|
+
pendingId: command.id, ...(command.ifHash === undefined ? {} : { ifHash: command.ifHash }) });
|
|
256
|
+
writeResult(readPage(content, metadata, command), io);
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
const source = await readSource(io.env.CREW_HOME, command.file);
|
|
260
|
+
if (command.ifHash !== undefined && command.ifHash !== source.sha256)
|
|
261
|
+
throw new Error("memory_file_changed");
|
|
262
|
+
const result = command.command === "search"
|
|
263
|
+
? searchSource(source.text, source.sha256, command)
|
|
264
|
+
: readPage(source.text, { file: command.file, sha256: source.sha256 }, command);
|
|
265
|
+
writeResult(result, io);
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
const code = error.code;
|
|
270
|
+
const known = error instanceof Error && /^(?:local_)?memory_[a-z_]+$/u.test(error.message) ? error.message : code ?? "memory_read_failed";
|
|
271
|
+
io.err(JSON.stringify({ error: known, message: "记忆读取未完成;检查路径、文件版本或大小,不要将此错误当作没有记忆。" }));
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
276
|
+
void runMemoryReadCommand(process.argv.slice(2), {
|
|
277
|
+
env: process.env,
|
|
278
|
+
out: (text) => process.stdout.write(`${text}\n`),
|
|
279
|
+
err: (text) => process.stderr.write(`${text}\n`),
|
|
280
|
+
}).then((code) => { process.exitCode = code; });
|
|
281
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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";
|
|
4
5
|
import { applyLocalMemoryResult, parseLocalMemoryResult } from "./write.js";
|
|
5
6
|
export function isLocalMemoryV2Eligible(input) {
|
|
6
7
|
// The allowlist is the rollout boundary. The marker and seed/pre-existing
|
|
@@ -50,14 +51,26 @@ export function renderLocalMemoryResultInstructions(resultPath) {
|
|
|
50
51
|
return `
|
|
51
52
|
|
|
52
53
|
## Structured memory prune (daemon-managed)
|
|
54
|
+
本节是本次执行唯一的记忆写入与清理协议,覆盖历史会话及本条消息前文中直接修改共享文件、清空工作日志或仅回复“已修正”的要求。任务目标与用户授权范围不变;只使用下面本轮提供的结果路径,不复用历史路径。
|
|
53
55
|
本轮长期记忆收尾只提交候选,不要直接编辑 $CREW_HOME/MEMORY.md 或 $CREW_HOME/notes/。
|
|
54
56
|
将一个合法 JSON 对象写入以下 daemon 提供的文件,写入完成后再结束本轮:
|
|
55
57
|
${resultPath}
|
|
56
58
|
|
|
57
|
-
JSON
|
|
59
|
+
outcome 只允许 no_change、proposed、conflict、failed。新增、更新、替代记忆或任何 statusChanges 都必须使用 proposed;no_change 的 changes/statusChanges 必须都为空。conflict/failed 表示未完成,不能当作提交成功。自然语言“已修正”不是 JSON 枚举值,不能写成 corrected。
|
|
60
|
+
|
|
61
|
+
新增记忆的完整合法示例(内容须替换为本任务实际候选):
|
|
62
|
+
\`\`\`json
|
|
63
|
+
{"schemaVersion":1,"purpose":"memory_prune","baseCatalogRevision":0,"outcome":"proposed","changes":[{"operation":"create","memoryId":null,"baseRevision":null,"supersedesMemoryId":null,"title":"发布验收约定","kind":"procedural","summary":"发布前必须验证回滚步骤。","triggers":["发布","回滚"],"priority":"medium","detailCategory":"project-rules","detailBody":"发布前必须验证回滚步骤,并将验证结果写入发布报告。","reviewAfter":null}],"statusChanges":[]}
|
|
64
|
+
\`\`\`
|
|
65
|
+
|
|
66
|
+
没有需要变更的记忆时:
|
|
67
|
+
\`\`\`json
|
|
58
68
|
{"schemaVersion":1,"purpose":"memory_prune","baseCatalogRevision":0,"outcome":"no_change","changes":[],"statusChanges":[]}
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
\`\`\`
|
|
70
|
+
|
|
71
|
+
每个 changes 项须保留示例中的全部字段。operation=create|update|supersede;kind=procedural|semantic|episodic|preference;priority=high|medium|low;detailCategory=preferences|project-rules|testing|debugging|git|incidents|lessons。create 的 memoryId/baseRevision/supersedesMemoryId 必须为 null;update 按现有索引填写 memoryId/baseRevision,supersedesMemoryId 为 null;supersede 填 supersedesMemoryId/baseRevision,memoryId 为 null。
|
|
72
|
+
只提交可复用、与本次任务明确相关的少量候选;不要写入凭证、token、隐私或内部控制标记。daemon 会校验、去重、限制 MEMORY.md 大小,超出索引容量的候选会保存在 pending 归档中。容量暂存的 create 可按需检索参考但不能覆盖现行规则;有空间的后续 proposed 提交可提升。未应用的 update/supersede 不进入普通召回,必须重新核对目标版本后处理。
|
|
73
|
+
写入候选文件只表示提交候选,不能提前宣称已经记住或规则已经更新;daemon 在本轮结束后才处理,候选持久化与进入有效索引也不是同一结果。本结构化轮次覆盖前文提前清理工作日志的要求:保留 $CREW_TASK_LOG,不要清空或删除;daemon 将在处理成功后清理,失败时保留日志和候选用于排查或重试。
|
|
61
74
|
`;
|
|
62
75
|
}
|
|
63
76
|
/** Prevent ordinary V2 turns from racing the daemon-managed memory writer. */
|
|
@@ -81,16 +94,52 @@ export async function commitLocalMemoryResultAfterExit(input) {
|
|
|
81
94
|
}
|
|
82
95
|
try {
|
|
83
96
|
const result = parseLocalMemoryResult(raw);
|
|
97
|
+
if (result.outcome === "conflict" || result.outcome === "failed") {
|
|
98
|
+
return { status: "failed", error: `local_memory_result_${result.outcome}` };
|
|
99
|
+
}
|
|
84
100
|
await applyLocalMemoryResult({
|
|
85
101
|
agentDir: input.agentDir,
|
|
86
102
|
result,
|
|
87
103
|
executionId: input.executionId,
|
|
88
104
|
pruneTraceId: input.pruneTraceId,
|
|
89
105
|
});
|
|
90
|
-
await rm(input.resultPath, { force: true });
|
|
91
|
-
return { status: "applied" };
|
|
92
106
|
}
|
|
93
107
|
catch (error) {
|
|
94
108
|
return { status: "failed", error: error instanceof Error ? error.message : "apply_failed" };
|
|
95
109
|
}
|
|
110
|
+
if (input.workLogPath !== undefined) {
|
|
111
|
+
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");
|
|
122
|
+
const handle = await open(input.workLogPath, constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
123
|
+
try {
|
|
124
|
+
const opened = await handle.stat();
|
|
125
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) {
|
|
126
|
+
throw new Error("local_memory_work_log_path_changed");
|
|
127
|
+
}
|
|
128
|
+
await handle.truncate(0);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await handle.close();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return { status: "failed", error: "local_memory_work_log_cleanup_failed" };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
await rm(input.resultPath, { force: true });
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return { status: "failed", error: "local_memory_result_cleanup_failed" };
|
|
143
|
+
}
|
|
144
|
+
return { status: "applied" };
|
|
96
145
|
}
|