@nowcrew/daemon 0.6.88-beta.9 → 0.6.88
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/local-memory/pending.js +84 -34
- package/dist/local-memory/prompt.js +11 -13
- package/dist/local-memory/read-cli.js +8 -57
- package/dist/local-memory/runtime.js +2 -2
- package/dist/local-memory/write.js +48 -16
- package/package.json +2 -2
- package/dist/local-memory/candidate.js +0 -43
- package/dist/local-memory/pending-match.js +0 -29
- package/dist/local-memory/pending-query.js +0 -100
- package/dist/local-memory/pending-schema.js +0 -29
- package/dist/local-memory/pending-source.js +0 -126
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { lstat, mkdir, readdir, realpath, rename, rm, writeFile, readFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import { pendingFileNames, pendingReadDirectory, PENDING_FILE_BYTES, readPendingSource } from "./pending-source.js";
|
|
6
|
-
import { pendingRecallScore } from "./pending-match.js";
|
|
4
|
+
import { z } from "zod";
|
|
7
5
|
export const LOCAL_MEMORY_PENDING_SCAN_FILE_CAP = 128;
|
|
8
6
|
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();
|
|
9
32
|
function within(root, candidate) {
|
|
10
33
|
const path = relative(root, candidate);
|
|
11
34
|
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`));
|
|
@@ -99,11 +122,13 @@ async function atomicWrite(path, content) {
|
|
|
99
122
|
}
|
|
100
123
|
}
|
|
101
124
|
export async function persistPendingMemoryCandidate(input) {
|
|
102
|
-
const parsed =
|
|
125
|
+
const parsed = PendingSchema.safeParse(input.candidate);
|
|
126
|
+
if (!parsed.success)
|
|
127
|
+
throw new Error("local_memory_pending_invalid");
|
|
103
128
|
const directory = await pendingDirectory(input.agentDir);
|
|
104
|
-
const target = join(directory, `${parsed.pendingId}.json`);
|
|
129
|
+
const target = join(directory, `${parsed.data.pendingId}.json`);
|
|
105
130
|
await assertRegularFile(target);
|
|
106
|
-
await atomicWrite(target, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
131
|
+
await atomicWrite(target, `${JSON.stringify(parsed.data, null, 2)}\n`);
|
|
107
132
|
return target;
|
|
108
133
|
}
|
|
109
134
|
export async function removePendingMemoryCandidate(input) {
|
|
@@ -115,44 +140,73 @@ export async function removePendingMemoryCandidate(input) {
|
|
|
115
140
|
await rm(target, { force: true });
|
|
116
141
|
}
|
|
117
142
|
export async function readPendingMemoryCandidates(input) {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
throw error;
|
|
123
|
-
});
|
|
124
|
-
const names = await pendingFileNames(directory);
|
|
143
|
+
const directory = await existingPendingDirectory(input.agentDir);
|
|
144
|
+
if (directory === null)
|
|
145
|
+
return [];
|
|
146
|
+
const names = (await readdir(directory)).filter((name) => name.endsWith(".json")).sort();
|
|
125
147
|
const maxFiles = Math.max(0, input.maxFiles ?? LOCAL_MEMORY_PENDING_SCAN_FILE_CAP);
|
|
126
148
|
const maxBytes = Math.max(0, input.maxBytes ?? LOCAL_MEMORY_PENDING_SCAN_BYTE_CAP);
|
|
127
149
|
const result = [];
|
|
128
150
|
let scannedBytes = 0;
|
|
129
|
-
for (const name of names
|
|
130
|
-
if (scannedBytes >= maxBytes)
|
|
151
|
+
for (const name of names) {
|
|
152
|
+
if (result.length >= maxFiles || scannedBytes >= maxBytes)
|
|
131
153
|
break;
|
|
154
|
+
const path = join(directory, name);
|
|
132
155
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
156
|
+
await assertRegularFile(path);
|
|
157
|
+
const stat = await lstat(path);
|
|
158
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
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);
|
|
136
170
|
}
|
|
137
171
|
catch {
|
|
138
|
-
//
|
|
139
|
-
scannedBytes += Math.min(PENDING_FILE_BYTES, maxBytes - scannedBytes);
|
|
172
|
+
// A damaged pending item is diagnosable and must not hide other candidates.
|
|
140
173
|
}
|
|
141
174
|
}
|
|
142
175
|
return result;
|
|
143
176
|
}
|
|
177
|
+
function normalize(value) {
|
|
178
|
+
return value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/gu, " ").trim();
|
|
179
|
+
}
|
|
144
180
|
function priorityRank(priority) {
|
|
145
181
|
return priority === "high" ? 0 : priority === "medium" ? 1 : 2;
|
|
146
182
|
}
|
|
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
|
+
}
|
|
147
202
|
export async function searchPendingMemoryCandidates(input) {
|
|
148
203
|
const candidates = await readPendingMemoryCandidates({ agentDir: input.agentDir });
|
|
149
204
|
const limit = Math.max(0, input.limit ?? 5);
|
|
150
205
|
if (limit === 0)
|
|
151
206
|
return [];
|
|
152
207
|
return candidates
|
|
153
|
-
.
|
|
154
|
-
.
|
|
155
|
-
.filter((item) => item.score > 0)
|
|
208
|
+
.map((candidate) => ({ candidate, score: recallScore(candidate, input.query) }))
|
|
209
|
+
.filter((item) => normalize(input.query) === "" || item.score > 0)
|
|
156
210
|
.sort((left, right) => right.score - left.score
|
|
157
211
|
|| priorityRank(left.candidate.candidate.priority) - priorityRank(right.candidate.candidate.priority)
|
|
158
212
|
|| right.candidate.createdAt.localeCompare(left.candidate.createdAt)
|
|
@@ -164,29 +218,25 @@ function truncateCodePoints(value, maxCodePoints) {
|
|
|
164
218
|
return Array.from(value).slice(0, Math.max(0, maxCodePoints)).join("");
|
|
165
219
|
}
|
|
166
220
|
export function renderPendingMemoryContext(candidates, maxCodePoints = 2_000) {
|
|
167
|
-
|
|
168
|
-
if (recallable.length === 0 || maxCodePoints <= 0)
|
|
221
|
+
if (candidates.length === 0 || maxCodePoints <= 0)
|
|
169
222
|
return "";
|
|
170
|
-
const header = `\n\n## Archived memory candidates (daemon-managed)\n
|
|
223
|
+
const header = `\n\n## Archived memory candidates (daemon-managed)\n这些记忆已持久化,但尚未进入常驻热索引;只在当前请求相关时参考,必要时再由 memory-prune 提升。\n`;
|
|
171
224
|
let remaining = maxCodePoints - Array.from(header).length;
|
|
172
225
|
if (remaining <= 0)
|
|
173
226
|
return truncateCodePoints(header, maxCodePoints);
|
|
174
227
|
const sections = [];
|
|
175
|
-
for (const item of
|
|
228
|
+
for (const item of candidates) {
|
|
176
229
|
const change = item.candidate;
|
|
177
230
|
const prefix = `\n### ${change.title}\n`
|
|
178
|
-
+ `-
|
|
231
|
+
+ `- pending_file: .crew/memory/pending/${item.pendingId}.json\n`
|
|
179
232
|
+ `- summary: ${change.summary}\n`
|
|
180
233
|
+ `- triggers: ${change.triggers.join(", ")}\n`
|
|
181
234
|
+ "- detail: ";
|
|
182
235
|
const prefixLength = Array.from(prefix).length;
|
|
183
236
|
if (prefixLength + 1 > remaining)
|
|
184
237
|
break;
|
|
185
|
-
const
|
|
186
|
-
const
|
|
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`;
|
|
238
|
+
const detail = truncateCodePoints(change.detailBody, remaining - prefixLength - 1);
|
|
239
|
+
const section = `${prefix}${detail}\n`;
|
|
190
240
|
const length = Array.from(section).length;
|
|
191
241
|
sections.push(section);
|
|
192
242
|
remaining -= length;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { dslog } from "../slog.js";
|
|
2
2
|
import { renderPendingMemoryContext, searchPendingMemoryCandidates } from "./pending.js";
|
|
3
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
4
|
export async function prepareLocalMemoryPrompt(input) {
|
|
6
5
|
let pending = "";
|
|
7
6
|
if (input.enabled) {
|
|
@@ -22,18 +21,17 @@ export async function prepareLocalMemoryPrompt(input) {
|
|
|
22
21
|
}
|
|
23
22
|
const original = input.renderBase(0);
|
|
24
23
|
const structured = input.resultPath === null ? "" : renderLocalMemoryResultInstructions(input.resultPath);
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
return { systemPrompt, resultPath: input.resultPath, wakePromptSuffix:
|
|
36
|
-
}
|
|
24
|
+
const wakeBytes = Buffer.byteLength(structured, "utf8");
|
|
25
|
+
const fits = (prompt) => input.maxBytes === undefined || Buffer.byteLength(prompt, "utf8") + wakeBytes <= input.maxBytes;
|
|
26
|
+
const render = (suffix) => (suffix === "" ? original : input.renderBase(Buffer.byteLength(suffix, "utf8") + wakeBytes)) + suffix;
|
|
27
|
+
// 系统及当前消息各保留一份契约,避免 resume 沿用旧指令;两份均先占预算,再裁可选预览。
|
|
28
|
+
const complete = render(pending + structured);
|
|
29
|
+
if (fits(complete))
|
|
30
|
+
return { systemPrompt: complete, resultPath: input.resultPath, wakePromptSuffix: structured };
|
|
31
|
+
if (pending !== "") {
|
|
32
|
+
const withoutPending = render(structured);
|
|
33
|
+
if (fits(withoutPending))
|
|
34
|
+
return { systemPrompt: withoutPending, resultPath: input.resultPath, wakePromptSuffix: structured };
|
|
37
35
|
}
|
|
38
36
|
// 强制基础指令与契约本身仍无法共存时,保留既有非结构化兼容回退,不裁基础指令。
|
|
39
37
|
return { systemPrompt: original, resultPath: null, wakePromptSuffix: "" };
|
|
@@ -5,7 +5,6 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { parseArgs, TextDecoder } from "node:util";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
-
import { readPendingMemoryBody, searchPendingMemoryPage } from "./pending-query.js";
|
|
9
8
|
const OUTPUT_BYTES = 12 * 1024;
|
|
10
9
|
const FILE_BYTES = 32 * 1024 * 1024;
|
|
11
10
|
const FileSchema = z.string().min(1).refine((file) => {
|
|
@@ -34,33 +33,14 @@ const CommandSchema = z.discriminatedUnion("command", [
|
|
|
34
33
|
offset: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),
|
|
35
34
|
limit: z.coerce.number().int().min(1).max(4000).default(2000),
|
|
36
35
|
}).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
36
|
]);
|
|
51
37
|
const USAGE = `只读记忆工具,默认输出 JSON,不修改任何记忆文件。
|
|
52
38
|
crew-memory search --query <关键词> [--file MEMORY.md|notes/文件.md] [--limit 10] [--after-line 0]
|
|
53
39
|
crew-memory read [--file MEMORY.md|notes/文件.md] [--offset 0] [--limit 2000]
|
|
54
|
-
|
|
55
|
-
crew-memory read-pending --id <候选ID> [--offset 0] [--limit 2000] [--if-hash <sha256>]
|
|
56
|
-
search 关键词按空白分隔,同一行须全部命中;返回完整文件中的行号和字符偏移。
|
|
40
|
+
搜索关键词按空白分隔,同一行须全部命中;返回完整文件中的行号和字符偏移。
|
|
57
41
|
读取的 offset/limit 按 Unicode 字符计数;用 nextOffset 继续,eof=true 表示结束。
|
|
58
42
|
后续页可传 --if-hash <前一页的 sha256>,文件变化时停止,避免混读不同版本。
|
|
59
|
-
|
|
60
|
-
search-pending 只查因容量延期的 create 候选;有 nextCursor 就继续,即使本页 matches 为空。
|
|
61
|
-
complete=true 才表示全部候选已检索完成且无遗漏;warnings 或非零退出码表示读取未完成,不能当作零命中。
|
|
62
|
-
read-pending 只返回候选正文;offset 非零时必须传 --if-hash,update/supersede 候选不可读取。
|
|
63
|
-
候选只供相关问题参考,未生效,不能覆盖有效规则;先读取当前有效索引与明细。`;
|
|
43
|
+
搜索结果是原始资料定位,不代表候选已生效;按当前有效索引读取明细,不扫描 pending。`;
|
|
64
44
|
function inside(root, path) {
|
|
65
45
|
const value = relative(root, path);
|
|
66
46
|
return value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value);
|
|
@@ -172,8 +152,8 @@ function searchSource(text, sha256, command) {
|
|
|
172
152
|
nextAfterLine: hasMore ? matches.at(-1)?.line ?? null : null,
|
|
173
153
|
};
|
|
174
154
|
}
|
|
175
|
-
function readPage(text,
|
|
176
|
-
const metadata = {
|
|
155
|
+
function readPage(text, sha256, command) {
|
|
156
|
+
const metadata = { file: command.file, sha256, offset: command.offset, nextOffset: Number.MAX_SAFE_INTEGER, eof: false, content: "" };
|
|
177
157
|
let remaining = OUTPUT_BYTES - Buffer.byteLength(JSON.stringify(metadata), "utf8");
|
|
178
158
|
let position = 0;
|
|
179
159
|
let offset = 0;
|
|
@@ -195,16 +175,8 @@ function readPage(text, source, command) {
|
|
|
195
175
|
count += 1;
|
|
196
176
|
}
|
|
197
177
|
const eof = position === text.length;
|
|
198
|
-
if (!eof && count === 0)
|
|
199
|
-
throw new Error("memory_output_too_large");
|
|
200
178
|
return { ...metadata, content, eof, nextOffset: eof ? null : offset };
|
|
201
179
|
}
|
|
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
180
|
export async function runMemoryReadCommand(argv, io) {
|
|
209
181
|
let command;
|
|
210
182
|
try {
|
|
@@ -214,7 +186,6 @@ export async function runMemoryReadCommand(argv, io) {
|
|
|
214
186
|
query: { type: "string", short: "q" }, file: { type: "string" },
|
|
215
187
|
limit: { type: "string" }, offset: { type: "string" },
|
|
216
188
|
"after-line": { type: "string" }, "if-hash": { type: "string" },
|
|
217
|
-
id: { type: "string" }, cursor: { type: "string" },
|
|
218
189
|
json: { type: "boolean" }, help: { type: "boolean", short: "h" },
|
|
219
190
|
},
|
|
220
191
|
});
|
|
@@ -230,44 +201,24 @@ export async function runMemoryReadCommand(argv, io) {
|
|
|
230
201
|
...(afterLine === undefined ? {} : { afterLine }),
|
|
231
202
|
...(ifHash === undefined ? {} : { ifHash }),
|
|
232
203
|
});
|
|
233
|
-
if (command.command === "read-pending" && command.offset > 0 && command.ifHash === undefined) {
|
|
234
|
-
throw new Error("memory_hash_required");
|
|
235
|
-
}
|
|
236
204
|
}
|
|
237
205
|
catch {
|
|
238
|
-
io.err(`memory_arguments_invalid:
|
|
206
|
+
io.err(`memory_arguments_invalid: 参数不合法。\n${USAGE}`);
|
|
239
207
|
return 2;
|
|
240
208
|
}
|
|
241
209
|
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
210
|
const source = await readSource(io.env.CREW_HOME, command.file);
|
|
260
211
|
if (command.ifHash !== undefined && command.ifHash !== source.sha256)
|
|
261
212
|
throw new Error("memory_file_changed");
|
|
262
213
|
const result = command.command === "search"
|
|
263
214
|
? searchSource(source.text, source.sha256, command)
|
|
264
|
-
: readPage(source.text,
|
|
265
|
-
|
|
215
|
+
: readPage(source.text, source.sha256, command);
|
|
216
|
+
io.out(JSON.stringify(result));
|
|
266
217
|
return 0;
|
|
267
218
|
}
|
|
268
219
|
catch (error) {
|
|
269
220
|
const code = error.code;
|
|
270
|
-
const known = error instanceof Error && /^
|
|
221
|
+
const known = error instanceof Error && /^memory_[a-z_]+$/u.test(error.message) ? error.message : code ?? "memory_read_failed";
|
|
271
222
|
io.err(JSON.stringify({ error: known, message: "记忆读取未完成;检查路径、文件版本或大小,不要将此错误当作没有记忆。" }));
|
|
272
223
|
return 1;
|
|
273
224
|
}
|
|
@@ -69,8 +69,8 @@ outcome 只允许 no_change、proposed、conflict、failed。新增、更新、
|
|
|
69
69
|
\`\`\`
|
|
70
70
|
|
|
71
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
|
|
73
|
-
|
|
72
|
+
只提交可复用、与本次任务明确相关的少量候选;不要写入凭证、token、隐私或内部控制标记。daemon 会校验、去重、限制 MEMORY.md 大小,超出索引容量的候选会保存在 pending 归档中;后续相关任务会按需召回,索引腾出空间后可自动提升。
|
|
73
|
+
写入候选文件不等于已经记住,只有 daemon 校验并提交成功后记忆才生效。本结构化轮次覆盖前文提前清理工作日志的要求:保留 $CREW_TASK_LOG,不要清空或删除;daemon 将在提交成功后清理,失败时保留日志和候选用于排查或重试。
|
|
74
74
|
`;
|
|
75
75
|
}
|
|
76
76
|
/** Prevent ordinary V2 turns from racing the daemon-managed memory writer. */
|
|
@@ -2,12 +2,29 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { lstat, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import {
|
|
6
|
-
import { LocalMemoryChangeSchema, LocalMemorySingleLineText, validateLocalMemoryChange, } from "./candidate.js";
|
|
5
|
+
import { containsLikelySecret } from "../agent-memory/policy.js";
|
|
7
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";
|
|
8
7
|
import { persistPendingMemoryCandidate, readPendingMemoryCandidates, removePendingMemoryCandidate, } 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();
|
|
9
26
|
const StatusChangeSchema = z.object({
|
|
10
|
-
memoryId:
|
|
27
|
+
memoryId: SingleLineText,
|
|
11
28
|
expectedRevision: z.number().int().positive(),
|
|
12
29
|
expectedStatus: z.enum(["candidate", "active", "pinned", "stale", "superseded", "archived"]),
|
|
13
30
|
targetStatus: z.enum(["candidate", "active", "pinned", "stale", "archived"]),
|
|
@@ -18,7 +35,7 @@ const ResultSchema = z.object({
|
|
|
18
35
|
purpose: z.literal("memory_prune"),
|
|
19
36
|
baseCatalogRevision: z.number().int().nonnegative(),
|
|
20
37
|
outcome: z.enum(["no_change", "proposed", "conflict", "failed"]),
|
|
21
|
-
changes: z.array(
|
|
38
|
+
changes: z.array(ChangeSchema).max(50),
|
|
22
39
|
statusChanges: z.array(StatusChangeSchema).max(50),
|
|
23
40
|
}).strict();
|
|
24
41
|
const GENERATED_INDEX_PATTERN = /<!--\s*NOWWORK:MEMORY_INDEX:BEGIN\s+schema=1\s*-->[\s\S]*?<!--\s*NOWWORK:MEMORY_INDEX:END\s*-->/u;
|
|
@@ -26,6 +43,8 @@ const INDEX_HEADING = /^##\s+Knowledge Index\s*$/mu;
|
|
|
26
43
|
const NEXT_SECTION = /^##\s+(?!#)/mu;
|
|
27
44
|
const CARD_HEADING = /^###\s+(.+?)\s*$/gmu;
|
|
28
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;
|
|
29
48
|
function codePointLength(value) {
|
|
30
49
|
return Array.from(value).length;
|
|
31
50
|
}
|
|
@@ -129,12 +148,34 @@ function replaceNote(content, id, replacement) {
|
|
|
129
148
|
const pattern = notePattern(id);
|
|
130
149
|
return pattern.test(content) ? content.replace(pattern, replacement) : null;
|
|
131
150
|
}
|
|
151
|
+
function containsReservedText(change) {
|
|
152
|
+
return RESERVED_MEMORY_MARKER.test([change.title, change.summary, ...change.triggers, change.detailBody].join("\n"));
|
|
153
|
+
}
|
|
132
154
|
function validateCandidate(result) {
|
|
133
155
|
if (result.outcome === "no_change" && (result.changes.length > 0 || result.statusChanges.length > 0)) {
|
|
134
156
|
throw new Error("local_memory_no_change_has_operations");
|
|
135
157
|
}
|
|
136
|
-
for (const change of result.changes)
|
|
137
|
-
|
|
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
|
+
}
|
|
138
179
|
}
|
|
139
180
|
export function parseLocalMemoryResult(raw) {
|
|
140
181
|
let value;
|
|
@@ -436,16 +477,7 @@ export async function applyLocalMemoryResult(input) {
|
|
|
436
477
|
// Pending create candidates are durable overflow, not dead ends. Promote only
|
|
437
478
|
// when the current catalog has room; supersede/update candidates stay pending
|
|
438
479
|
// until a fresh revision-aware decision can safely replay them.
|
|
439
|
-
const pendingCandidatesOnDisk = await readPendingMemoryCandidates({ agentDir: input.agentDir })
|
|
440
|
-
if (!(error instanceof Error) || error.message !== "memory_pending_directory_over_budget")
|
|
441
|
-
throw error;
|
|
442
|
-
// 自动提升是可选工作,目录查询预算不应阻断本轮已验证的更新或腾位操作。
|
|
443
|
-
dslog("local_memory.pending_promotion_skipped", "pending目录超出扫描预算,本轮跳过自动提升", {
|
|
444
|
-
level: "WARN", execution_id: input.executionId, prune_trace_id: input.pruneTraceId,
|
|
445
|
-
reason: "memory_pending_directory_over_budget",
|
|
446
|
-
});
|
|
447
|
-
return [];
|
|
448
|
-
});
|
|
480
|
+
const pendingCandidatesOnDisk = await readPendingMemoryCandidates({ agentDir: input.agentDir });
|
|
449
481
|
for (const pending of pendingCandidatesOnDisk) {
|
|
450
482
|
const change = pending.candidate;
|
|
451
483
|
if (change.operation !== "create")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nowcrew/daemon",
|
|
3
|
-
"version": "0.6.88
|
|
3
|
+
"version": "0.6.88",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@agentclientprotocol/sdk": "1.2.1",
|
|
21
|
-
"@nowcrew/cli": "0.4.16
|
|
21
|
+
"@nowcrew/cli": "0.4.16",
|
|
22
22
|
"cross-spawn": "^7.0.6",
|
|
23
23
|
"semver": "7.8.5",
|
|
24
24
|
"ws": "^8",
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { containsLikelySecret } from "../agent-memory/policy.js";
|
|
3
|
-
const CategorySchema = z.enum([
|
|
4
|
-
"preferences", "project-rules", "testing", "debugging", "git", "incidents", "lessons",
|
|
5
|
-
]);
|
|
6
|
-
export const LocalMemorySingleLineText = z.string().trim().min(1).max(500).regex(/^[^\r\n]+$/u);
|
|
7
|
-
export const LocalMemoryChangeSchema = z.object({
|
|
8
|
-
operation: z.enum(["create", "update", "supersede"]),
|
|
9
|
-
memoryId: LocalMemorySingleLineText.regex(/^[^<>]+$/u).nullable(),
|
|
10
|
-
baseRevision: z.number().int().nonnegative().nullable(),
|
|
11
|
-
supersedesMemoryId: LocalMemorySingleLineText.regex(/^[^<>]+$/u).nullable(),
|
|
12
|
-
title: LocalMemorySingleLineText.max(120),
|
|
13
|
-
kind: z.enum(["procedural", "semantic", "episodic", "preference"]),
|
|
14
|
-
summary: LocalMemorySingleLineText,
|
|
15
|
-
triggers: z.array(LocalMemorySingleLineText.max(80)).min(1).max(16),
|
|
16
|
-
priority: z.enum(["high", "medium", "low"]),
|
|
17
|
-
detailCategory: CategorySchema,
|
|
18
|
-
detailBody: z.string().trim().min(1).max(12_000),
|
|
19
|
-
reviewAfter: LocalMemorySingleLineText.nullable(),
|
|
20
|
-
}).strict();
|
|
21
|
-
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
|
|
22
|
-
const RESERVED_MEMORY_MARKER = /NOWWORK:(?:MEMORY_ENTRY|MEMORY_INDEX):/iu;
|
|
23
|
-
export function validateLocalMemoryChange(change) {
|
|
24
|
-
const text = [change.title, change.summary, ...change.triggers, change.detailBody].join("\n");
|
|
25
|
-
if (containsLikelySecret(text))
|
|
26
|
-
throw new Error("local_memory_secret_candidate");
|
|
27
|
-
if (CONTROL_CHARACTER_PATTERN.test(text))
|
|
28
|
-
throw new Error("local_memory_control_character");
|
|
29
|
-
if (RESERVED_MEMORY_MARKER.test(text))
|
|
30
|
-
throw new Error("local_memory_reserved_marker");
|
|
31
|
-
if (change.operation === "create"
|
|
32
|
-
&& (change.memoryId !== null || change.baseRevision !== null || change.supersedesMemoryId !== null)) {
|
|
33
|
-
throw new Error("local_memory_create_revision_invalid");
|
|
34
|
-
}
|
|
35
|
-
if (change.operation === "update"
|
|
36
|
-
&& (change.memoryId === null || change.baseRevision === null || change.supersedesMemoryId !== null)) {
|
|
37
|
-
throw new Error("local_memory_update_revision_missing");
|
|
38
|
-
}
|
|
39
|
-
if (change.operation === "supersede"
|
|
40
|
-
&& (change.memoryId !== null || change.baseRevision === null || change.supersedesMemoryId === null)) {
|
|
41
|
-
throw new Error("local_memory_supersede_revision_missing");
|
|
42
|
-
}
|
|
43
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
export function normalizePendingQuery(value) {
|
|
2
|
-
return value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/gu, " ").trim();
|
|
3
|
-
}
|
|
4
|
-
function containsTerm(text, term) {
|
|
5
|
-
if (!term)
|
|
6
|
-
return false;
|
|
7
|
-
const startBoundary = /^[a-z0-9]/u.test(term);
|
|
8
|
-
const endBoundary = /[a-z0-9]$/u.test(term);
|
|
9
|
-
let position = text.indexOf(term);
|
|
10
|
-
while (position >= 0) {
|
|
11
|
-
const before = text[position - 1] ?? "";
|
|
12
|
-
const after = text[position + term.length] ?? "";
|
|
13
|
-
if ((!startBoundary || !/[a-z0-9_-]/u.test(before)) && (!endBoundary || !/[a-z0-9_-]/u.test(after)))
|
|
14
|
-
return true;
|
|
15
|
-
position = text.indexOf(term, position + 1);
|
|
16
|
-
}
|
|
17
|
-
return false;
|
|
18
|
-
}
|
|
19
|
-
export function pendingRecallScore(item, query) {
|
|
20
|
-
const normalized = normalizePendingQuery(query);
|
|
21
|
-
if (!normalized)
|
|
22
|
-
return 0;
|
|
23
|
-
const candidate = item.candidate;
|
|
24
|
-
const terms = normalized.split(/[\s,,、。!?;::()[\]{}"'`]+/u).filter(Boolean);
|
|
25
|
-
const text = normalizePendingQuery([candidate.title, candidate.summary, ...candidate.triggers, candidate.detailCategory, candidate.detailBody].join("\n"));
|
|
26
|
-
const explicit = [candidate.title, ...candidate.triggers].map(normalizePendingQuery);
|
|
27
|
-
const explicitScore = explicit.reduce((score, term) => score + (containsTerm(normalized, term) ? 10 : 0), 0);
|
|
28
|
-
return explicitScore + terms.reduce((score, term) => score + (containsTerm(text, term) ? 1 : 0), 0);
|
|
29
|
-
}
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import { isRecallablePending, PendingIdSchema } from "./pending-schema.js";
|
|
4
|
-
import { normalizePendingQuery, pendingRecallScore } from "./pending-match.js";
|
|
5
|
-
import { pendingDirectorySnapshot, pendingSourceError, PENDING_FILE_BYTES, readPendingSource } from "./pending-source.js";
|
|
6
|
-
const PAGE_FILES = 128;
|
|
7
|
-
const PAGE_BYTES = 1_000_000;
|
|
8
|
-
const OUTPUT_BYTES = 12 * 1024;
|
|
9
|
-
const CursorSchema = z.object({
|
|
10
|
-
v: z.literal(1), queryHash: z.string().regex(/^[a-f0-9]{64}$/u), version: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
11
|
-
after: z.string().min(1).max(255), hadWarnings: z.boolean(),
|
|
12
|
-
}).strict();
|
|
13
|
-
const SearchSchema = z.object({
|
|
14
|
-
agentDir: z.string().min(1), query: z.string().trim().min(1).max(256),
|
|
15
|
-
cursor: z.string().max(2048).optional(), limit: z.number().int().min(1).max(40).default(10),
|
|
16
|
-
}).strict();
|
|
17
|
-
const excerpt = (text, cap) => Array.from(text).slice(0, cap).join("");
|
|
18
|
-
export async function searchPendingMemoryPage(input) {
|
|
19
|
-
const parsed = SearchSchema.safeParse(input);
|
|
20
|
-
if (!parsed.success)
|
|
21
|
-
throw new Error("memory_pending_arguments_invalid");
|
|
22
|
-
const args = parsed.data;
|
|
23
|
-
const queryHash = createHash("sha256").update(normalizePendingQuery(args.query)).digest("hex");
|
|
24
|
-
let cursor;
|
|
25
|
-
if (args.cursor !== undefined) {
|
|
26
|
-
try {
|
|
27
|
-
cursor = CursorSchema.parse(JSON.parse(Buffer.from(args.cursor, "base64url").toString("utf8")));
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
throw new Error("memory_pending_cursor_invalid");
|
|
31
|
-
}
|
|
32
|
-
if (cursor.queryHash !== queryHash)
|
|
33
|
-
throw new Error("memory_pending_cursor_invalid");
|
|
34
|
-
}
|
|
35
|
-
const snapshot = await pendingDirectorySnapshot(args.agentDir);
|
|
36
|
-
if (cursor && cursor.version !== snapshot.version)
|
|
37
|
-
throw new Error("memory_pending_source_changed");
|
|
38
|
-
const initial = cursor ? snapshot.names.indexOf(cursor.after) + 1 : 0;
|
|
39
|
-
if (cursor && initial === 0)
|
|
40
|
-
throw new Error("memory_pending_cursor_invalid");
|
|
41
|
-
const matches = [];
|
|
42
|
-
const warnings = [];
|
|
43
|
-
let position = initial;
|
|
44
|
-
let scannedFiles = 0;
|
|
45
|
-
let scannedBytes = 0;
|
|
46
|
-
// 为游标、错误与元数据预留空间;达到输出上限前停止消费来源,不跳过未输出的命中。
|
|
47
|
-
let outputBytes = 1600;
|
|
48
|
-
while (position < snapshot.names.length && scannedFiles < PAGE_FILES && scannedBytes + PENDING_FILE_BYTES <= PAGE_BYTES) {
|
|
49
|
-
const name = snapshot.names[position];
|
|
50
|
-
let source;
|
|
51
|
-
let warning;
|
|
52
|
-
try {
|
|
53
|
-
source = await readPendingSource(args.agentDir, name);
|
|
54
|
-
}
|
|
55
|
-
catch (error) {
|
|
56
|
-
const code = pendingSourceError(error);
|
|
57
|
-
if (code === "memory_pending_source_changed" || code === "memory_pending_not_found")
|
|
58
|
-
throw new Error("memory_pending_source_changed");
|
|
59
|
-
warning = { file: name, code };
|
|
60
|
-
}
|
|
61
|
-
const match = source && isRecallablePending(source.candidate) && pendingRecallScore(source.candidate, args.query) > 0
|
|
62
|
-
? { pendingId: source.candidate.pendingId, sha256: source.sha256, title: source.candidate.candidate.title,
|
|
63
|
-
summary: excerpt(source.candidate.candidate.summary, 180), triggers: source.candidate.candidate.triggers.slice(0, 4),
|
|
64
|
-
operation: "create", state: "capacity_deferred" }
|
|
65
|
-
: null;
|
|
66
|
-
const addition = match ?? warning;
|
|
67
|
-
const size = addition ? Buffer.byteLength(JSON.stringify(addition), "utf8") + 2 : 0;
|
|
68
|
-
if (outputBytes + size > OUTPUT_BYTES)
|
|
69
|
-
break;
|
|
70
|
-
if (match)
|
|
71
|
-
matches.push(match);
|
|
72
|
-
if (warning)
|
|
73
|
-
warnings.push(warning);
|
|
74
|
-
outputBytes += size;
|
|
75
|
-
scannedFiles += 1;
|
|
76
|
-
scannedBytes += source?.bytes ?? PENDING_FILE_BYTES;
|
|
77
|
-
position += 1;
|
|
78
|
-
if (matches.length >= args.limit)
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
if ((await pendingDirectorySnapshot(args.agentDir)).version !== snapshot.version)
|
|
82
|
-
throw new Error("memory_pending_source_changed");
|
|
83
|
-
const hasMore = position < snapshot.names.length;
|
|
84
|
-
const hadWarnings = cursor?.hadWarnings === true || warnings.length > 0;
|
|
85
|
-
const nextCursor = hasMore ? Buffer.from(JSON.stringify({ v: 1, queryHash, version: snapshot.version,
|
|
86
|
-
after: snapshot.names[position - 1], hadWarnings })).toString("base64url") : null;
|
|
87
|
-
return { matches, warnings, scannedFiles, scannedBytes, hasMore, nextCursor, complete: !hasMore && !hadWarnings };
|
|
88
|
-
}
|
|
89
|
-
export async function readPendingMemoryBody(input) {
|
|
90
|
-
if (!PendingIdSchema.safeParse(input.pendingId).success)
|
|
91
|
-
throw new Error("memory_pending_id_invalid");
|
|
92
|
-
const source = await readPendingSource(input.agentDir, `${input.pendingId}.json`);
|
|
93
|
-
if (input.ifHash !== undefined && input.ifHash !== source.sha256)
|
|
94
|
-
throw new Error("memory_pending_source_changed");
|
|
95
|
-
if (!isRecallablePending(source.candidate))
|
|
96
|
-
throw new Error("memory_pending_not_recallable");
|
|
97
|
-
const candidate = source.candidate.candidate;
|
|
98
|
-
return { pendingId: source.candidate.pendingId, sha256: source.sha256, title: candidate.title,
|
|
99
|
-
operation: "create", state: "capacity_deferred", content: candidate.detailBody };
|
|
100
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { LocalMemoryChangeSchema, validateLocalMemoryChange } from "./candidate.js";
|
|
3
|
-
// 索引会按分隔符展开触发词;16 个最多 80 UTF-16 单元的输入可产生 0..640 项。
|
|
4
|
-
const ArchivedMemoryChangeSchema = LocalMemoryChangeSchema.extend({
|
|
5
|
-
triggers: z.array(LocalMemoryChangeSchema.shape.triggers.element).max(640)
|
|
6
|
-
.refine((triggers) => triggers.reduce((length, trigger) => length + trigger.length, 0) <= 16 * 80),
|
|
7
|
-
});
|
|
8
|
-
export const PendingIdSchema = z.string().max(200).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u);
|
|
9
|
-
export const PendingMemorySchema = z.object({
|
|
10
|
-
schemaVersion: z.literal(1), pendingId: PendingIdSchema,
|
|
11
|
-
createdAt: z.string().min(1).max(100), reason: z.literal("index_over_budget"),
|
|
12
|
-
source: z.object({ executionId: z.string().min(1).max(500), pruneTraceId: z.string().min(1).max(500).nullable() }).strict(),
|
|
13
|
-
candidate: ArchivedMemoryChangeSchema,
|
|
14
|
-
}).strict();
|
|
15
|
-
export function parsePendingMemoryCandidate(value) {
|
|
16
|
-
const parsed = PendingMemorySchema.safeParse(value);
|
|
17
|
-
if (!parsed.success)
|
|
18
|
-
throw new Error("local_memory_pending_invalid");
|
|
19
|
-
validateLocalMemoryChange(parsed.data.candidate);
|
|
20
|
-
return parsed.data;
|
|
21
|
-
}
|
|
22
|
-
export function isRecallablePending(value) {
|
|
23
|
-
try {
|
|
24
|
-
return parsePendingMemoryCandidate(value).candidate.operation === "create";
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import { constants } from "node:fs";
|
|
2
|
-
import { lstat, open, opendir, realpath } from "node:fs/promises";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { TextDecoder } from "node:util";
|
|
6
|
-
import { parsePendingMemoryCandidate, PendingIdSchema } from "./pending-schema.js";
|
|
7
|
-
export const PENDING_FILE_BYTES = 128 * 1024;
|
|
8
|
-
const DIRECTORY_ENTRY_CAP = 10_000;
|
|
9
|
-
export function pendingSourceError(error) {
|
|
10
|
-
if (error instanceof Error && /^memory_pending_[a-z_]+$/u.test(error.message))
|
|
11
|
-
return error.message;
|
|
12
|
-
return "memory_pending_file_invalid";
|
|
13
|
-
}
|
|
14
|
-
export async function pendingReadDirectory(agentDir) {
|
|
15
|
-
const root = await realpath(agentDir);
|
|
16
|
-
let directory = root;
|
|
17
|
-
for (const part of [".crew", "memory", "pending"]) {
|
|
18
|
-
directory = join(directory, part);
|
|
19
|
-
let stat;
|
|
20
|
-
try {
|
|
21
|
-
stat = await lstat(directory);
|
|
22
|
-
}
|
|
23
|
-
catch (error) {
|
|
24
|
-
if (error.code === "ENOENT")
|
|
25
|
-
return { root, directory: null };
|
|
26
|
-
throw new Error("memory_pending_directory_unreadable");
|
|
27
|
-
}
|
|
28
|
-
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(directory) !== directory) {
|
|
29
|
-
throw new Error("memory_pending_path_invalid");
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return { root, directory };
|
|
33
|
-
}
|
|
34
|
-
export async function pendingFileNames(directory) {
|
|
35
|
-
if (directory === null)
|
|
36
|
-
return [];
|
|
37
|
-
const names = [];
|
|
38
|
-
let inspected = 0;
|
|
39
|
-
try {
|
|
40
|
-
for await (const item of await opendir(directory)) {
|
|
41
|
-
if (++inspected > DIRECTORY_ENTRY_CAP)
|
|
42
|
-
throw new Error("memory_pending_directory_over_budget");
|
|
43
|
-
if (item.name.endsWith(".json"))
|
|
44
|
-
names.push(item.name);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
catch (error) {
|
|
48
|
-
if (error instanceof Error && error.message.startsWith("memory_pending_"))
|
|
49
|
-
throw error;
|
|
50
|
-
throw new Error("memory_pending_directory_unreadable");
|
|
51
|
-
}
|
|
52
|
-
return names.sort();
|
|
53
|
-
}
|
|
54
|
-
function version(stat) {
|
|
55
|
-
return [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
|
|
56
|
-
}
|
|
57
|
-
export async function pendingDirectorySnapshot(agentDir) {
|
|
58
|
-
const location = await pendingReadDirectory(agentDir);
|
|
59
|
-
const names = await pendingFileNames(location.directory);
|
|
60
|
-
const digest = createHash("sha256").update(location.root);
|
|
61
|
-
if (location.directory !== null)
|
|
62
|
-
digest.update(version(await lstat(location.directory)));
|
|
63
|
-
for (const name of names) {
|
|
64
|
-
try {
|
|
65
|
-
digest.update(JSON.stringify([name, version(await lstat(join(location.directory, name)))]));
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
throw new Error("memory_pending_source_changed");
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return { ...location, names, version: digest.digest("hex") };
|
|
72
|
-
}
|
|
73
|
-
export async function readPendingSource(agentDir, name, byteBudget = PENDING_FILE_BYTES) {
|
|
74
|
-
if (!name.endsWith(".json") || !PendingIdSchema.safeParse(name.slice(0, -5)).success) {
|
|
75
|
-
throw new Error("memory_pending_id_invalid");
|
|
76
|
-
}
|
|
77
|
-
const { directory } = await pendingReadDirectory(agentDir);
|
|
78
|
-
if (directory === null)
|
|
79
|
-
throw new Error("memory_pending_not_found");
|
|
80
|
-
const path = join(directory, name);
|
|
81
|
-
const before = await lstat(path).catch(() => { throw new Error("memory_pending_not_found"); });
|
|
82
|
-
if (!before.isFile() || before.isSymbolicLink())
|
|
83
|
-
throw new Error("memory_pending_path_invalid");
|
|
84
|
-
const limit = Math.min(byteBudget, PENDING_FILE_BYTES);
|
|
85
|
-
if (before.size > limit)
|
|
86
|
-
throw new Error("memory_pending_file_over_budget");
|
|
87
|
-
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
88
|
-
try {
|
|
89
|
-
const opened = await handle.stat();
|
|
90
|
-
if (!opened.isFile() || version(opened) !== version(before))
|
|
91
|
-
throw new Error("memory_pending_source_changed");
|
|
92
|
-
const chunks = [];
|
|
93
|
-
let bytes = 0;
|
|
94
|
-
for (;;) {
|
|
95
|
-
const chunk = Buffer.alloc(Math.min(16 * 1024, limit + 1 - bytes));
|
|
96
|
-
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
97
|
-
if (bytesRead === 0)
|
|
98
|
-
break;
|
|
99
|
-
bytes += bytesRead;
|
|
100
|
-
if (bytes > limit)
|
|
101
|
-
throw new Error("memory_pending_file_over_budget");
|
|
102
|
-
chunks.push(chunk.subarray(0, bytesRead));
|
|
103
|
-
}
|
|
104
|
-
const after = await handle.stat();
|
|
105
|
-
const currentDirectory = await pendingReadDirectory(agentDir);
|
|
106
|
-
const current = await lstat(path);
|
|
107
|
-
if (currentDirectory.directory !== directory || version(after) !== version(opened) || version(current) !== version(opened)) {
|
|
108
|
-
throw new Error("memory_pending_source_changed");
|
|
109
|
-
}
|
|
110
|
-
const buffer = Buffer.concat(chunks);
|
|
111
|
-
let parsed;
|
|
112
|
-
try {
|
|
113
|
-
const raw = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
114
|
-
parsed = parsePendingMemoryCandidate(JSON.parse(raw));
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
throw new Error("memory_pending_file_invalid");
|
|
118
|
-
}
|
|
119
|
-
if (`${parsed.pendingId}.json` !== name)
|
|
120
|
-
throw new Error("memory_pending_id_invalid");
|
|
121
|
-
return { candidate: parsed, bytes, sha256: createHash("sha256").update(buffer).digest("hex") };
|
|
122
|
-
}
|
|
123
|
-
finally {
|
|
124
|
-
await handle.close();
|
|
125
|
-
}
|
|
126
|
-
}
|