@nowcrew/daemon 0.6.88-beta.6 → 0.6.88-beta.7
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/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 +13 -11
- package/dist/local-memory/read-cli.js +57 -8
- package/dist/local-memory/runtime.js +2 -2
- package/dist/local-memory/write.js +16 -48
- package/dist/project-skills/capability.js +109 -0
- package/dist/project-skills/controller-convergence.js +57 -0
- package/dist/project-skills/projection-set-switch.js +419 -0
- package/dist/project-skills/projection-state-domain.js +170 -0
- package/dist/project-skills/projection-state-store.js +842 -0
- package/dist/project-skills/projection-state-transaction.js +322 -0
- package/dist/project-skills/projection-state.js +3 -0
- package/dist/project-skills/runtime-root-bootstrap.js +47 -0
- package/dist/project-skills/runtime-root-domain.js +268 -0
- package/dist/project-skills/runtime-root-gc.js +293 -0
- package/dist/project-skills/runtime-root-lease-artifact.js +46 -0
- package/dist/project-skills/runtime-root-leases.js +487 -0
- package/dist/project-skills/runtime-root-source-identity.js +60 -0
- package/dist/project-skills/runtime-root-startup.js +49 -0
- package/dist/project-skills/runtime-root-state-artifact-domain.js +143 -0
- package/dist/project-skills/runtime-root-state-index.js +356 -0
- package/dist/project-skills/runtime-root-store.js +722 -0
- package/dist/project-skills/serve-capability.js +28 -0
- package/dist/project-skills/serve-startup.js +37 -0
- package/package.json +1 -1
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
}
|
|
@@ -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;
|
|
@@ -1,6 +1,7 @@
|
|
|
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";
|
|
4
5
|
export async function prepareLocalMemoryPrompt(input) {
|
|
5
6
|
let pending = "";
|
|
6
7
|
if (input.enabled) {
|
|
@@ -21,17 +22,18 @@ export async function prepareLocalMemoryPrompt(input) {
|
|
|
21
22
|
}
|
|
22
23
|
const original = input.renderBase(0);
|
|
23
24
|
const structured = input.resultPath === null ? "" : renderLocalMemoryResultInstructions(input.resultPath);
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
return { systemPrompt
|
|
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
|
+
}
|
|
35
37
|
}
|
|
36
38
|
// 强制基础指令与契约本身仍无法共存时,保留既有非结构化兼容回退,不裁基础指令。
|
|
37
39
|
return { systemPrompt: original, resultPath: null, wakePromptSuffix: "" };
|