blun-king-cli 9.1.563 → 9.1.565
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/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
- package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
- package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
- package/agent-spine-plugin/.mcp.json +8 -8
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/LICENSE +186 -186
- package/agent-spine-plugin/README.md +394 -0
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
- package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
- package/agent-spine-plugin/bin/agentspine.js +7 -7
- package/agent-spine-plugin/blun.plugin.json +33 -33
- package/agent-spine-plugin/hooks/codex.json +47 -47
- package/agent-spine-plugin/hooks/hooks.json +106 -106
- package/agent-spine-plugin/hooks/version.json +5 -5
- package/agent-spine-plugin/package.json +69 -69
- package/agent-spine-plugin/scripts/check-hosts.js +199 -199
- package/agent-spine-plugin/skill/SKILL.md +76 -76
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
- package/agent-spine-plugin/src/cli.js +1488 -1442
- package/agent-spine-plugin/src/hook.js +886 -812
- package/agent-spine-plugin/src/index.js +93 -93
- package/agent-spine-plugin/src/lib/acceptance.js +333 -333
- package/agent-spine-plugin/src/lib/attention.js +755 -755
- package/agent-spine-plugin/src/lib/audit.js +351 -342
- package/agent-spine-plugin/src/lib/authentication.js +515 -515
- package/agent-spine-plugin/src/lib/briefing.js +317 -317
- package/agent-spine-plugin/src/lib/catalog.js +167 -167
- package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
- package/agent-spine-plugin/src/lib/context.js +154 -154
- package/agent-spine-plugin/src/lib/continuity.js +338 -338
- package/agent-spine-plugin/src/lib/coordination.js +577 -577
- package/agent-spine-plugin/src/lib/documents.js +217 -217
- package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
- package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
- package/agent-spine-plugin/src/lib/graph.js +337 -337
- package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
- package/agent-spine-plugin/src/lib/https-transport.js +392 -392
- package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
- package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
- package/agent-spine-plugin/src/lib/learning.js +6923 -6466
- package/agent-spine-plugin/src/lib/object-transport.js +206 -206
- package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
- package/agent-spine-plugin/src/lib/paths.js +109 -109
- package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
- package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
- package/agent-spine-plugin/src/lib/preflight.js +702 -702
- package/agent-spine-plugin/src/lib/runtime.js +13 -13
- package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
- package/agent-spine-plugin/src/lib/sharing.js +969 -969
- package/agent-spine-plugin/src/lib/source-roots.js +529 -482
- package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
- package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
- package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
- package/agent-spine-plugin/src/mcp.js +597 -572
- package/agent-spine-plugin/src/version.js +1 -1
- package/agent-spine-plugin/src/worker.js +202 -195
- package/bin/active-steer-priority-policy.cjs +24 -0
- package/bin/launcher-runtime.js +8 -1
- package/bin/mnemo-tool-agent-policy.cjs +22 -0
- package/bin/thinking-activity-status-policy.cjs +132 -0
- package/bin/thinking-only-guard.cjs +75 -0
- package/bin/tool-call-loop-policy.cjs +53 -0
- package/bin/turn-thinking-policy.cjs +25 -1
- package/blun.mjs +554 -64
- package/package.json +4 -1
- package/standard-skills/translate-native/LICENSE +21 -21
- package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
- package/standard-skills/translate-native/references/native-orthography.md +79 -79
- package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
- package/standard-skills/translate-native/references/structured-content.md +72 -72
- package/standard-skills/translate-native/references/translationese-review.md +77 -77
- package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
- package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
- package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
- package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
- package/standard-skills/translate-native/scripts/language_quality.py +377 -377
- package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
- package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
- package/standard-tools/language-guard/blun_language_guard.py +697 -697
- package/standard-tools/language-guard/check_diacritics.py +353 -353
- package/standard-tools/language-guard/guard_service_client.py +264 -264
- package/standard-tools/language-guard/language_quality.py +377 -377
- package/standard-tools/language-guard/translation_guard.py +916 -916
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
import { opendir, realpath } from "node:fs/promises";
|
|
2
|
-
import { join, relative } from "node:path";
|
|
3
|
-
import { parseMemoryIndex, safeIndexedSnapshot } from "./indexed-memory.js";
|
|
4
|
-
|
|
5
|
-
const MAX_ENTRIES = 100000;
|
|
6
|
-
|
|
7
|
-
export async function scanIndexedMemoryOrphans(root) {
|
|
8
|
-
const canonicalRoot = await realpath(root);
|
|
9
|
-
const index = await safeIndexedSnapshot(canonicalRoot, "MEMORY.md");
|
|
10
|
-
if (index.rejected) throw new Error(`offline memory scan cannot read MEMORY.md: ${index.rejected}`);
|
|
11
|
-
const indexed = new Set(parseMemoryIndex(index.buffer.toString("utf8"), index.path, canonicalRoot)
|
|
12
|
-
.map((entry) => entry.relativePath));
|
|
13
|
-
let markdownFiles = 0;
|
|
14
|
-
let orphaned = 0;
|
|
15
|
-
let rejectedSymlinks = 0;
|
|
16
|
-
let visited = 0;
|
|
17
|
-
async function walk(directory) {
|
|
18
|
-
const entries = [];
|
|
19
|
-
for await (const entry of await opendir(directory)) {
|
|
20
|
-
visited += 1;
|
|
21
|
-
if (visited > MAX_ENTRIES) throw new Error(`offline memory scan exceeds ${MAX_ENTRIES} entries`);
|
|
22
|
-
entries.push(entry);
|
|
23
|
-
}
|
|
24
|
-
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
25
|
-
for (const entry of entries) {
|
|
26
|
-
if (entry.isSymbolicLink()) { rejectedSymlinks += 1; continue; }
|
|
27
|
-
const path = join(directory, entry.name);
|
|
28
|
-
if (entry.isDirectory()) await walk(path);
|
|
29
|
-
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md") && entry.name !== "MEMORY.md") {
|
|
30
|
-
markdownFiles += 1;
|
|
31
|
-
if (!indexed.has(relative(canonicalRoot, path).replaceAll("\\", "/"))) orphaned += 1;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
await walk(canonicalRoot);
|
|
36
|
-
return {
|
|
37
|
-
mode: "explicit-offline-doctor-scan", indexed: indexed.size, markdownFiles, orphaned,
|
|
38
|
-
rejectedSymlinks, visitedEntries: visited, contentsExposed: false, authority: "diagnostic-only"
|
|
39
|
-
};
|
|
40
|
-
}
|
|
1
|
+
import { opendir, realpath } from "node:fs/promises";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { parseMemoryIndex, safeIndexedSnapshot } from "./indexed-memory.js";
|
|
4
|
+
|
|
5
|
+
const MAX_ENTRIES = 100000;
|
|
6
|
+
|
|
7
|
+
export async function scanIndexedMemoryOrphans(root) {
|
|
8
|
+
const canonicalRoot = await realpath(root);
|
|
9
|
+
const index = await safeIndexedSnapshot(canonicalRoot, "MEMORY.md");
|
|
10
|
+
if (index.rejected) throw new Error(`offline memory scan cannot read MEMORY.md: ${index.rejected}`);
|
|
11
|
+
const indexed = new Set(parseMemoryIndex(index.buffer.toString("utf8"), index.path, canonicalRoot)
|
|
12
|
+
.map((entry) => entry.relativePath));
|
|
13
|
+
let markdownFiles = 0;
|
|
14
|
+
let orphaned = 0;
|
|
15
|
+
let rejectedSymlinks = 0;
|
|
16
|
+
let visited = 0;
|
|
17
|
+
async function walk(directory) {
|
|
18
|
+
const entries = [];
|
|
19
|
+
for await (const entry of await opendir(directory)) {
|
|
20
|
+
visited += 1;
|
|
21
|
+
if (visited > MAX_ENTRIES) throw new Error(`offline memory scan exceeds ${MAX_ENTRIES} entries`);
|
|
22
|
+
entries.push(entry);
|
|
23
|
+
}
|
|
24
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (entry.isSymbolicLink()) { rejectedSymlinks += 1; continue; }
|
|
27
|
+
const path = join(directory, entry.name);
|
|
28
|
+
if (entry.isDirectory()) await walk(path);
|
|
29
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md") && entry.name !== "MEMORY.md") {
|
|
30
|
+
markdownFiles += 1;
|
|
31
|
+
if (!indexed.has(relative(canonicalRoot, path).replaceAll("\\", "/"))) orphaned += 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
await walk(canonicalRoot);
|
|
36
|
+
return {
|
|
37
|
+
mode: "explicit-offline-doctor-scan", indexed: indexed.size, markdownFiles, orphaned,
|
|
38
|
+
rejectedSymlinks, visitedEntries: visited, contentsExposed: false, authority: "diagnostic-only"
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -1,281 +1,281 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { constants as fsConstants } from "node:fs";
|
|
3
|
-
import { lstat, mkdir, open, readFile, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
-
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
-
import { isFileLockContention } from "./filesystem-retry.js";
|
|
7
|
-
import { isInside, stateRoot } from "./paths.js";
|
|
8
|
-
import { markdownOutsideCode } from "./documents.js";
|
|
9
|
-
|
|
10
|
-
export const INDEXED_MEMORY_CACHE_SCHEMA = "agentspine.indexed-memory-cache/v1";
|
|
11
|
-
const CACHE_LIMIT = 16 * 1024 * 1024;
|
|
12
|
-
const FILE_LIMIT = 4 * 1024 * 1024;
|
|
13
|
-
const MAX_ATTEMPTS = 3;
|
|
14
|
-
const MAX_INDEXED_LINKS = 128;
|
|
15
|
-
|
|
16
|
-
function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
|
|
17
|
-
function cachePath(env) { return join(stateRoot(env), "indexed-memory-cache.json"); }
|
|
18
|
-
function emptyCache() { return { schema: INDEXED_MEMORY_CACHE_SCHEMA, revision: 0, roots: {} }; }
|
|
19
|
-
function rootKey(root) { return sha256(root).slice(0, 32); }
|
|
20
|
-
|
|
21
|
-
function validateCache(value) {
|
|
22
|
-
if (!value || value.schema !== INDEXED_MEMORY_CACHE_SCHEMA || !Number.isInteger(value.revision)
|
|
23
|
-
|| !value.roots || typeof value.roots !== "object" || Array.isArray(value.roots)) {
|
|
24
|
-
throw new Error("indexed memory cache is corrupt; memory recall is disabled until repaired");
|
|
25
|
-
}
|
|
26
|
-
for (const records of Object.values(value.roots)) {
|
|
27
|
-
if (!records || typeof records !== "object" || Array.isArray(records)) throw new Error("indexed memory cache contains an invalid root");
|
|
28
|
-
for (const [path, record] of Object.entries(records)) {
|
|
29
|
-
if (!path || path.startsWith("../") || path === ".." || !record || typeof record !== "object"
|
|
30
|
-
|| typeof record.contentBase64 !== "string" || typeof record.sha256 !== "string"
|
|
31
|
-
|| typeof record.identity !== "string" || !Number.isInteger(record.bytes)) {
|
|
32
|
-
throw new Error("indexed memory cache contains an invalid record");
|
|
33
|
-
}
|
|
34
|
-
const buffer = Buffer.from(record.contentBase64, "base64");
|
|
35
|
-
if (buffer.byteLength !== record.bytes || sha256(buffer) !== record.sha256) {
|
|
36
|
-
throw new Error("indexed memory cache integrity check failed");
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
return value;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async function readCache(env) {
|
|
44
|
-
const path = cachePath(env);
|
|
45
|
-
try {
|
|
46
|
-
const metadata = await stat(path);
|
|
47
|
-
if (metadata.size > CACHE_LIMIT) throw new Error("indexed memory cache exceeds 16 MiB");
|
|
48
|
-
return { cache: validateCache(JSON.parse(await readFile(path, "utf8"))), path };
|
|
49
|
-
} catch (error) {
|
|
50
|
-
if (error.code !== "ENOENT") throw error;
|
|
51
|
-
return { cache: emptyCache(), path };
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function mutateCache(env, task) {
|
|
56
|
-
const target = cachePath(env);
|
|
57
|
-
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
58
|
-
const lock = `${target}.lock`;
|
|
59
|
-
let handle;
|
|
60
|
-
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
61
|
-
try { handle = await open(lock, "wx", 0o600); break; } catch (error) {
|
|
62
|
-
if (!isFileLockContention(error)) throw error;
|
|
63
|
-
try {
|
|
64
|
-
const metadata = await stat(lock);
|
|
65
|
-
if (Date.now() - metadata.mtimeMs > 15000) await unlink(lock);
|
|
66
|
-
} catch (lockError) {
|
|
67
|
-
if (lockError.code !== "ENOENT") throw lockError;
|
|
68
|
-
}
|
|
69
|
-
await delay(25);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
if (!handle) throw new Error("indexed memory cache is busy; retry shortly");
|
|
73
|
-
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
74
|
-
try {
|
|
75
|
-
const { cache } = await readCache(env);
|
|
76
|
-
const result = await task(cache);
|
|
77
|
-
cache.revision += 1;
|
|
78
|
-
const content = `${JSON.stringify(cache)}\n`;
|
|
79
|
-
if (Buffer.byteLength(content) > CACHE_LIMIT) throw new Error("indexed memory cache exceeds 16 MiB");
|
|
80
|
-
await writeFile(temporary, content, { mode: 0o600 });
|
|
81
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
82
|
-
try { await rename(temporary, target); break; } catch (error) {
|
|
83
|
-
const transientWindowsReplace = process.platform === "win32"
|
|
84
|
-
&& ["EACCES", "EBUSY", "EPERM"].includes(error.code);
|
|
85
|
-
if (!transientWindowsReplace || attempt >= 7) throw error;
|
|
86
|
-
await delay(10 * (attempt + 1));
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return { ...result, cachePath: target, cacheRevision: cache.revision };
|
|
90
|
-
} finally {
|
|
91
|
-
await handle.close();
|
|
92
|
-
await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
93
|
-
await unlink(lock).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function identity(metadata) {
|
|
98
|
-
return [metadata.dev, metadata.ino, metadata.size, metadata.mtimeNs, metadata.ctimeNs].join(":");
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function sameFile(left, right) {
|
|
102
|
-
return left.isFile() && right.isFile() && !left.isSymbolicLink?.() && !right.isSymbolicLink?.()
|
|
103
|
-
&& left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
|
104
|
-
&& left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async function pathClassification(root, relativePath) {
|
|
108
|
-
const candidate = resolve(root, relativePath);
|
|
109
|
-
if (!isInside(root, candidate) || candidate === root) return { rejected: "path", candidate: null };
|
|
110
|
-
const parts = relative(root, candidate).split(sep);
|
|
111
|
-
let cursor = root;
|
|
112
|
-
for (let index = 0; index < parts.length; index += 1) {
|
|
113
|
-
cursor = join(cursor, parts[index]);
|
|
114
|
-
let metadata;
|
|
115
|
-
try { metadata = await lstat(cursor, { bigint: true }); } catch (error) {
|
|
116
|
-
if (["ENOENT", "ENOTDIR"].includes(error.code)) return { rejected: "missing", candidate };
|
|
117
|
-
throw error;
|
|
118
|
-
}
|
|
119
|
-
if (metadata.isSymbolicLink()) return { rejected: "symlink", candidate };
|
|
120
|
-
if (index < parts.length - 1 && !metadata.isDirectory()) return { rejected: "path", candidate };
|
|
121
|
-
if (index === parts.length - 1 && !metadata.isFile()) return { rejected: "path", candidate };
|
|
122
|
-
}
|
|
123
|
-
return { rejected: null, candidate };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function safeSnapshotOnce(root, relativePath, cached, hooks, attempt) {
|
|
127
|
-
const classified = await pathClassification(root, relativePath);
|
|
128
|
-
if (classified.rejected) return { rejected: classified.rejected };
|
|
129
|
-
const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0);
|
|
130
|
-
let handle;
|
|
131
|
-
try { handle = await open(classified.candidate, flags); } catch (error) {
|
|
132
|
-
if (["ENOENT", "ENOTDIR"].includes(error.code)) return { rejected: "missing" };
|
|
133
|
-
if (["ELOOP", "EMLINK"].includes(error.code)) return { rejected: "symlink" };
|
|
134
|
-
throw error;
|
|
135
|
-
}
|
|
136
|
-
try {
|
|
137
|
-
await hooks.onOpen?.({ relativePath, attempt });
|
|
138
|
-
const before = await handle.stat({ bigint: true });
|
|
139
|
-
if (!before.isFile() || before.size > BigInt(FILE_LIMIT)) return { rejected: before.size > BigInt(FILE_LIMIT) ? "size" : "path" };
|
|
140
|
-
const canonical = await realpath(classified.candidate);
|
|
141
|
-
if (!isInside(root, canonical)) return { rejected: "scope" };
|
|
142
|
-
const expectedIdentity = identity(before);
|
|
143
|
-
let buffer;
|
|
144
|
-
let cacheHit = false;
|
|
145
|
-
if (cached?.identity === expectedIdentity) {
|
|
146
|
-
buffer = Buffer.from(cached.contentBase64, "base64");
|
|
147
|
-
if (buffer.byteLength !== cached.bytes || sha256(buffer) !== cached.sha256) throw new Error("indexed memory cache integrity check failed");
|
|
148
|
-
cacheHit = true;
|
|
149
|
-
} else {
|
|
150
|
-
buffer = await handle.readFile();
|
|
151
|
-
}
|
|
152
|
-
await hooks.afterRead?.({ relativePath, attempt, path: classified.candidate });
|
|
153
|
-
const after = await handle.stat({ bigint: true });
|
|
154
|
-
let pathname;
|
|
155
|
-
try { pathname = await lstat(classified.candidate, { bigint: true }); } catch (error) {
|
|
156
|
-
if (["ENOENT", "ENOTDIR"].includes(error.code)) return { rejected: "race" };
|
|
157
|
-
throw error;
|
|
158
|
-
}
|
|
159
|
-
if (!sameFile(before, after) || !sameFile(after, pathname)) return { rejected: "race" };
|
|
160
|
-
if (buffer.byteLength !== Number(after.size)) return { rejected: "race" };
|
|
161
|
-
const digest = cacheHit ? cached.sha256 : sha256(buffer);
|
|
162
|
-
return {
|
|
163
|
-
rejected: null, cacheHit, path: canonical, relativePath, buffer,
|
|
164
|
-
record: { identity: expectedIdentity, bytes: buffer.byteLength, sha256: digest, contentBase64: buffer.toString("base64") },
|
|
165
|
-
metadata: { bytes: buffer.byteLength, sha256: digest, modifiedAt: new Date(Number(after.mtimeNs / 1000000n)).toISOString() }
|
|
166
|
-
};
|
|
167
|
-
} finally {
|
|
168
|
-
await handle.close();
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export async function safeIndexedSnapshot(root, relativePath, cached = null, hooks = {}) {
|
|
173
|
-
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
174
|
-
const result = await safeSnapshotOnce(root, relativePath, cached, hooks, attempt);
|
|
175
|
-
if (result.rejected !== "race") return result;
|
|
176
|
-
await hooks.onRace?.({ relativePath, attempt });
|
|
177
|
-
}
|
|
178
|
-
return { rejected: "race" };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function tokens(value) {
|
|
182
|
-
return new Set(String(value || "").toLowerCase().normalize("NFKC").match(/[\p{L}\p{N}]{3,}/gu) || []);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function parseMemoryIndex(text, indexPath, root) {
|
|
186
|
-
const entries = [];
|
|
187
|
-
const seen = new Set();
|
|
188
|
-
const pattern = /\[([^\]]+)\]\(([^)]+\.md)(?:#[^)]+)?\)([^\n]*)/gi;
|
|
189
|
-
for (const match of markdownOutsideCode(text).matchAll(pattern)) {
|
|
190
|
-
const label = match[1].trim();
|
|
191
|
-
let raw = match[2].trim().replace(/^<|>$/g, "");
|
|
192
|
-
if (/^[a-z]+:/i.test(raw)) continue;
|
|
193
|
-
try { raw = decodeURIComponent(raw); } catch { /* Retain malformed local paths for safe rejection. */ }
|
|
194
|
-
const target = resolve(dirname(indexPath), raw);
|
|
195
|
-
const relativePath = relative(root, target).replaceAll("\\", "/");
|
|
196
|
-
if (seen.has(relativePath) || relativePath === "MEMORY.md") continue;
|
|
197
|
-
seen.add(relativePath);
|
|
198
|
-
const suffix = match[3] || "";
|
|
199
|
-
const marker = suffix.match(/<!--\s*agentspine:([^>]+)-->/i)?.[1] || "";
|
|
200
|
-
const directives = Object.fromEntries([...marker.matchAll(/([a-z]+)\s*=\s*([^,;\s]+)/gi)].map((item) => [item[1].toLowerCase(), item[2]]));
|
|
201
|
-
const always = /(?:^|[,;\s])always(?:$|[,;\s])/i.test(marker) || /(?:—|-)\s*always\s*$/i.test(suffix);
|
|
202
|
-
entries.push({ relativePath, label, always, directives, promptTokens: [...tokens(`${label} ${basename(relativePath)} ${directives.keywords || ""}`)] });
|
|
203
|
-
}
|
|
204
|
-
return entries;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function relevantMemoryEntry(entry, scope = {}) {
|
|
208
|
-
if (entry.always) return { relevant: true, reason: "always" };
|
|
209
|
-
const exact = [
|
|
210
|
-
["entity", scope.entityId], ["person", scope.entityId], ["project", scope.projectId],
|
|
211
|
-
["group", scope.groupId], ["task", scope.currentTaskId]
|
|
212
|
-
];
|
|
213
|
-
for (const [key, value] of exact) {
|
|
214
|
-
if (entry.directives[key] && value && entry.directives[key] === value) return { relevant: true, reason: key };
|
|
215
|
-
}
|
|
216
|
-
const prompt = tokens(scope.prompt);
|
|
217
|
-
if (entry.promptTokens.some((value) => prompt.has(value))) return { relevant: true, reason: "prompt" };
|
|
218
|
-
return { relevant: false, reason: "scope" };
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export async function resolveIndexedMemory({ root, scope = {}, env = process.env, hooks = {}, deadline = Infinity }) {
|
|
222
|
-
function checkDeadline() {
|
|
223
|
-
if (Date.now() > deadline) throw new Error("indexed memory resolution exceeded its time limit");
|
|
224
|
-
}
|
|
225
|
-
checkDeadline();
|
|
226
|
-
const canonicalRoot = await realpath(root);
|
|
227
|
-
const { cache } = await readCache(env);
|
|
228
|
-
const key = rootKey(canonicalRoot);
|
|
229
|
-
const cached = cache.roots[key] || {};
|
|
230
|
-
const diagnostics = {
|
|
231
|
-
indexed: 0, relevant: 0, loaded: 0, cacheHits: 0, cacheMisses: 0, missing: 0,
|
|
232
|
-
rejected: { scope: 0, path: 0, symlink: 0, race: 0, size: 0 }, directoryEnumeration: 0
|
|
233
|
-
};
|
|
234
|
-
const index = await safeIndexedSnapshot(canonicalRoot, "MEMORY.md", cached["MEMORY.md"], hooks);
|
|
235
|
-
if (index.rejected) {
|
|
236
|
-
if (index.rejected === "missing") diagnostics.missing += 1;
|
|
237
|
-
else diagnostics.rejected[index.rejected] = (diagnostics.rejected[index.rejected] || 0) + 1;
|
|
238
|
-
await mutateCache(env, (value) => { delete value.roots[key]; return {}; });
|
|
239
|
-
return { sources: [], diagnostics };
|
|
240
|
-
}
|
|
241
|
-
diagnostics.cacheHits += Number(index.cacheHit);
|
|
242
|
-
diagnostics.cacheMisses += Number(!index.cacheHit);
|
|
243
|
-
const entries = parseMemoryIndex(index.buffer.toString("utf8"), index.path, canonicalRoot);
|
|
244
|
-
if (entries.length > MAX_INDEXED_LINKS) throw new Error(`Claude MEMORY.md indexes more than ${MAX_INDEXED_LINKS} direct files`);
|
|
245
|
-
diagnostics.indexed = entries.length;
|
|
246
|
-
const sources = [{ path: index.path, relativePath: "MEMORY.md", snapshot: index, relevance: "index" }];
|
|
247
|
-
for (const entry of entries) {
|
|
248
|
-
checkDeadline();
|
|
249
|
-
const relevance = relevantMemoryEntry(entry, scope);
|
|
250
|
-
if (!relevance.relevant) { diagnostics.rejected.scope += 1; continue; }
|
|
251
|
-
diagnostics.relevant += 1;
|
|
252
|
-
const snapshot = await safeIndexedSnapshot(canonicalRoot, entry.relativePath, cached[entry.relativePath], hooks);
|
|
253
|
-
if (snapshot.rejected) {
|
|
254
|
-
if (snapshot.rejected === "missing") diagnostics.missing += 1;
|
|
255
|
-
else diagnostics.rejected[snapshot.rejected] = (diagnostics.rejected[snapshot.rejected] || 0) + 1;
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
diagnostics.loaded += 1;
|
|
259
|
-
diagnostics.cacheHits += Number(snapshot.cacheHit);
|
|
260
|
-
diagnostics.cacheMisses += Number(!snapshot.cacheHit);
|
|
261
|
-
sources.push({ path: snapshot.path, relativePath: entry.relativePath, snapshot, relevance: relevance.reason });
|
|
262
|
-
}
|
|
263
|
-
checkDeadline();
|
|
264
|
-
const finalIndex = await safeIndexedSnapshot(canonicalRoot, "MEMORY.md", index.record, hooks);
|
|
265
|
-
if (finalIndex.rejected || finalIndex.metadata.sha256 !== index.metadata.sha256) throw new Error("Claude MEMORY.md changed during indexed resolution; retry the hook");
|
|
266
|
-
await mutateCache(env, (value) => {
|
|
267
|
-
const records = { "MEMORY.md": index.record };
|
|
268
|
-
for (const source of sources.slice(1)) records[source.relativePath] = source.snapshot.record;
|
|
269
|
-
value.roots[key] = records;
|
|
270
|
-
return {};
|
|
271
|
-
});
|
|
272
|
-
return { sources, diagnostics };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
export async function purgeIndexedMemoryCache(root, env = process.env) {
|
|
276
|
-
let canonical;
|
|
277
|
-
try { canonical = await realpath(root); } catch (error) {
|
|
278
|
-
if (error.code === "ENOENT") canonical = resolve(root); else throw error;
|
|
279
|
-
}
|
|
280
|
-
return mutateCache(env, (cache) => { delete cache.roots[rootKey(canonical)]; return { purged: true }; });
|
|
281
|
-
}
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, readFile, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
import { isFileLockContention } from "./filesystem-retry.js";
|
|
7
|
+
import { isInside, stateRoot } from "./paths.js";
|
|
8
|
+
import { markdownOutsideCode } from "./documents.js";
|
|
9
|
+
|
|
10
|
+
export const INDEXED_MEMORY_CACHE_SCHEMA = "agentspine.indexed-memory-cache/v1";
|
|
11
|
+
const CACHE_LIMIT = 16 * 1024 * 1024;
|
|
12
|
+
const FILE_LIMIT = 4 * 1024 * 1024;
|
|
13
|
+
const MAX_ATTEMPTS = 3;
|
|
14
|
+
const MAX_INDEXED_LINKS = 128;
|
|
15
|
+
|
|
16
|
+
function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
|
|
17
|
+
function cachePath(env) { return join(stateRoot(env), "indexed-memory-cache.json"); }
|
|
18
|
+
function emptyCache() { return { schema: INDEXED_MEMORY_CACHE_SCHEMA, revision: 0, roots: {} }; }
|
|
19
|
+
function rootKey(root) { return sha256(root).slice(0, 32); }
|
|
20
|
+
|
|
21
|
+
function validateCache(value) {
|
|
22
|
+
if (!value || value.schema !== INDEXED_MEMORY_CACHE_SCHEMA || !Number.isInteger(value.revision)
|
|
23
|
+
|| !value.roots || typeof value.roots !== "object" || Array.isArray(value.roots)) {
|
|
24
|
+
throw new Error("indexed memory cache is corrupt; memory recall is disabled until repaired");
|
|
25
|
+
}
|
|
26
|
+
for (const records of Object.values(value.roots)) {
|
|
27
|
+
if (!records || typeof records !== "object" || Array.isArray(records)) throw new Error("indexed memory cache contains an invalid root");
|
|
28
|
+
for (const [path, record] of Object.entries(records)) {
|
|
29
|
+
if (!path || path.startsWith("../") || path === ".." || !record || typeof record !== "object"
|
|
30
|
+
|| typeof record.contentBase64 !== "string" || typeof record.sha256 !== "string"
|
|
31
|
+
|| typeof record.identity !== "string" || !Number.isInteger(record.bytes)) {
|
|
32
|
+
throw new Error("indexed memory cache contains an invalid record");
|
|
33
|
+
}
|
|
34
|
+
const buffer = Buffer.from(record.contentBase64, "base64");
|
|
35
|
+
if (buffer.byteLength !== record.bytes || sha256(buffer) !== record.sha256) {
|
|
36
|
+
throw new Error("indexed memory cache integrity check failed");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readCache(env) {
|
|
44
|
+
const path = cachePath(env);
|
|
45
|
+
try {
|
|
46
|
+
const metadata = await stat(path);
|
|
47
|
+
if (metadata.size > CACHE_LIMIT) throw new Error("indexed memory cache exceeds 16 MiB");
|
|
48
|
+
return { cache: validateCache(JSON.parse(await readFile(path, "utf8"))), path };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error.code !== "ENOENT") throw error;
|
|
51
|
+
return { cache: emptyCache(), path };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function mutateCache(env, task) {
|
|
56
|
+
const target = cachePath(env);
|
|
57
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
58
|
+
const lock = `${target}.lock`;
|
|
59
|
+
let handle;
|
|
60
|
+
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
61
|
+
try { handle = await open(lock, "wx", 0o600); break; } catch (error) {
|
|
62
|
+
if (!isFileLockContention(error)) throw error;
|
|
63
|
+
try {
|
|
64
|
+
const metadata = await stat(lock);
|
|
65
|
+
if (Date.now() - metadata.mtimeMs > 15000) await unlink(lock);
|
|
66
|
+
} catch (lockError) {
|
|
67
|
+
if (lockError.code !== "ENOENT") throw lockError;
|
|
68
|
+
}
|
|
69
|
+
await delay(25);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!handle) throw new Error("indexed memory cache is busy; retry shortly");
|
|
73
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
74
|
+
try {
|
|
75
|
+
const { cache } = await readCache(env);
|
|
76
|
+
const result = await task(cache);
|
|
77
|
+
cache.revision += 1;
|
|
78
|
+
const content = `${JSON.stringify(cache)}\n`;
|
|
79
|
+
if (Buffer.byteLength(content) > CACHE_LIMIT) throw new Error("indexed memory cache exceeds 16 MiB");
|
|
80
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
81
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
82
|
+
try { await rename(temporary, target); break; } catch (error) {
|
|
83
|
+
const transientWindowsReplace = process.platform === "win32"
|
|
84
|
+
&& ["EACCES", "EBUSY", "EPERM"].includes(error.code);
|
|
85
|
+
if (!transientWindowsReplace || attempt >= 7) throw error;
|
|
86
|
+
await delay(10 * (attempt + 1));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { ...result, cachePath: target, cacheRevision: cache.revision };
|
|
90
|
+
} finally {
|
|
91
|
+
await handle.close();
|
|
92
|
+
await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
93
|
+
await unlink(lock).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function identity(metadata) {
|
|
98
|
+
return [metadata.dev, metadata.ino, metadata.size, metadata.mtimeNs, metadata.ctimeNs].join(":");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sameFile(left, right) {
|
|
102
|
+
return left.isFile() && right.isFile() && !left.isSymbolicLink?.() && !right.isSymbolicLink?.()
|
|
103
|
+
&& left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
|
104
|
+
&& left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function pathClassification(root, relativePath) {
|
|
108
|
+
const candidate = resolve(root, relativePath);
|
|
109
|
+
if (!isInside(root, candidate) || candidate === root) return { rejected: "path", candidate: null };
|
|
110
|
+
const parts = relative(root, candidate).split(sep);
|
|
111
|
+
let cursor = root;
|
|
112
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
113
|
+
cursor = join(cursor, parts[index]);
|
|
114
|
+
let metadata;
|
|
115
|
+
try { metadata = await lstat(cursor, { bigint: true }); } catch (error) {
|
|
116
|
+
if (["ENOENT", "ENOTDIR"].includes(error.code)) return { rejected: "missing", candidate };
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
if (metadata.isSymbolicLink()) return { rejected: "symlink", candidate };
|
|
120
|
+
if (index < parts.length - 1 && !metadata.isDirectory()) return { rejected: "path", candidate };
|
|
121
|
+
if (index === parts.length - 1 && !metadata.isFile()) return { rejected: "path", candidate };
|
|
122
|
+
}
|
|
123
|
+
return { rejected: null, candidate };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function safeSnapshotOnce(root, relativePath, cached, hooks, attempt) {
|
|
127
|
+
const classified = await pathClassification(root, relativePath);
|
|
128
|
+
if (classified.rejected) return { rejected: classified.rejected };
|
|
129
|
+
const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0);
|
|
130
|
+
let handle;
|
|
131
|
+
try { handle = await open(classified.candidate, flags); } catch (error) {
|
|
132
|
+
if (["ENOENT", "ENOTDIR"].includes(error.code)) return { rejected: "missing" };
|
|
133
|
+
if (["ELOOP", "EMLINK"].includes(error.code)) return { rejected: "symlink" };
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await hooks.onOpen?.({ relativePath, attempt });
|
|
138
|
+
const before = await handle.stat({ bigint: true });
|
|
139
|
+
if (!before.isFile() || before.size > BigInt(FILE_LIMIT)) return { rejected: before.size > BigInt(FILE_LIMIT) ? "size" : "path" };
|
|
140
|
+
const canonical = await realpath(classified.candidate);
|
|
141
|
+
if (!isInside(root, canonical)) return { rejected: "scope" };
|
|
142
|
+
const expectedIdentity = identity(before);
|
|
143
|
+
let buffer;
|
|
144
|
+
let cacheHit = false;
|
|
145
|
+
if (cached?.identity === expectedIdentity) {
|
|
146
|
+
buffer = Buffer.from(cached.contentBase64, "base64");
|
|
147
|
+
if (buffer.byteLength !== cached.bytes || sha256(buffer) !== cached.sha256) throw new Error("indexed memory cache integrity check failed");
|
|
148
|
+
cacheHit = true;
|
|
149
|
+
} else {
|
|
150
|
+
buffer = await handle.readFile();
|
|
151
|
+
}
|
|
152
|
+
await hooks.afterRead?.({ relativePath, attempt, path: classified.candidate });
|
|
153
|
+
const after = await handle.stat({ bigint: true });
|
|
154
|
+
let pathname;
|
|
155
|
+
try { pathname = await lstat(classified.candidate, { bigint: true }); } catch (error) {
|
|
156
|
+
if (["ENOENT", "ENOTDIR"].includes(error.code)) return { rejected: "race" };
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
if (!sameFile(before, after) || !sameFile(after, pathname)) return { rejected: "race" };
|
|
160
|
+
if (buffer.byteLength !== Number(after.size)) return { rejected: "race" };
|
|
161
|
+
const digest = cacheHit ? cached.sha256 : sha256(buffer);
|
|
162
|
+
return {
|
|
163
|
+
rejected: null, cacheHit, path: canonical, relativePath, buffer,
|
|
164
|
+
record: { identity: expectedIdentity, bytes: buffer.byteLength, sha256: digest, contentBase64: buffer.toString("base64") },
|
|
165
|
+
metadata: { bytes: buffer.byteLength, sha256: digest, modifiedAt: new Date(Number(after.mtimeNs / 1000000n)).toISOString() }
|
|
166
|
+
};
|
|
167
|
+
} finally {
|
|
168
|
+
await handle.close();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function safeIndexedSnapshot(root, relativePath, cached = null, hooks = {}) {
|
|
173
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
174
|
+
const result = await safeSnapshotOnce(root, relativePath, cached, hooks, attempt);
|
|
175
|
+
if (result.rejected !== "race") return result;
|
|
176
|
+
await hooks.onRace?.({ relativePath, attempt });
|
|
177
|
+
}
|
|
178
|
+
return { rejected: "race" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function tokens(value) {
|
|
182
|
+
return new Set(String(value || "").toLowerCase().normalize("NFKC").match(/[\p{L}\p{N}]{3,}/gu) || []);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function parseMemoryIndex(text, indexPath, root) {
|
|
186
|
+
const entries = [];
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
const pattern = /\[([^\]]+)\]\(([^)]+\.md)(?:#[^)]+)?\)([^\n]*)/gi;
|
|
189
|
+
for (const match of markdownOutsideCode(text).matchAll(pattern)) {
|
|
190
|
+
const label = match[1].trim();
|
|
191
|
+
let raw = match[2].trim().replace(/^<|>$/g, "");
|
|
192
|
+
if (/^[a-z]+:/i.test(raw)) continue;
|
|
193
|
+
try { raw = decodeURIComponent(raw); } catch { /* Retain malformed local paths for safe rejection. */ }
|
|
194
|
+
const target = resolve(dirname(indexPath), raw);
|
|
195
|
+
const relativePath = relative(root, target).replaceAll("\\", "/");
|
|
196
|
+
if (seen.has(relativePath) || relativePath === "MEMORY.md") continue;
|
|
197
|
+
seen.add(relativePath);
|
|
198
|
+
const suffix = match[3] || "";
|
|
199
|
+
const marker = suffix.match(/<!--\s*agentspine:([^>]+)-->/i)?.[1] || "";
|
|
200
|
+
const directives = Object.fromEntries([...marker.matchAll(/([a-z]+)\s*=\s*([^,;\s]+)/gi)].map((item) => [item[1].toLowerCase(), item[2]]));
|
|
201
|
+
const always = /(?:^|[,;\s])always(?:$|[,;\s])/i.test(marker) || /(?:—|-)\s*always\s*$/i.test(suffix);
|
|
202
|
+
entries.push({ relativePath, label, always, directives, promptTokens: [...tokens(`${label} ${basename(relativePath)} ${directives.keywords || ""}`)] });
|
|
203
|
+
}
|
|
204
|
+
return entries;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function relevantMemoryEntry(entry, scope = {}) {
|
|
208
|
+
if (entry.always) return { relevant: true, reason: "always" };
|
|
209
|
+
const exact = [
|
|
210
|
+
["entity", scope.entityId], ["person", scope.entityId], ["project", scope.projectId],
|
|
211
|
+
["group", scope.groupId], ["task", scope.currentTaskId]
|
|
212
|
+
];
|
|
213
|
+
for (const [key, value] of exact) {
|
|
214
|
+
if (entry.directives[key] && value && entry.directives[key] === value) return { relevant: true, reason: key };
|
|
215
|
+
}
|
|
216
|
+
const prompt = tokens(scope.prompt);
|
|
217
|
+
if (entry.promptTokens.some((value) => prompt.has(value))) return { relevant: true, reason: "prompt" };
|
|
218
|
+
return { relevant: false, reason: "scope" };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function resolveIndexedMemory({ root, scope = {}, env = process.env, hooks = {}, deadline = Infinity }) {
|
|
222
|
+
function checkDeadline() {
|
|
223
|
+
if (Date.now() > deadline) throw new Error("indexed memory resolution exceeded its time limit");
|
|
224
|
+
}
|
|
225
|
+
checkDeadline();
|
|
226
|
+
const canonicalRoot = await realpath(root);
|
|
227
|
+
const { cache } = await readCache(env);
|
|
228
|
+
const key = rootKey(canonicalRoot);
|
|
229
|
+
const cached = cache.roots[key] || {};
|
|
230
|
+
const diagnostics = {
|
|
231
|
+
indexed: 0, relevant: 0, loaded: 0, cacheHits: 0, cacheMisses: 0, missing: 0,
|
|
232
|
+
rejected: { scope: 0, path: 0, symlink: 0, race: 0, size: 0 }, directoryEnumeration: 0
|
|
233
|
+
};
|
|
234
|
+
const index = await safeIndexedSnapshot(canonicalRoot, "MEMORY.md", cached["MEMORY.md"], hooks);
|
|
235
|
+
if (index.rejected) {
|
|
236
|
+
if (index.rejected === "missing") diagnostics.missing += 1;
|
|
237
|
+
else diagnostics.rejected[index.rejected] = (diagnostics.rejected[index.rejected] || 0) + 1;
|
|
238
|
+
await mutateCache(env, (value) => { delete value.roots[key]; return {}; });
|
|
239
|
+
return { sources: [], diagnostics };
|
|
240
|
+
}
|
|
241
|
+
diagnostics.cacheHits += Number(index.cacheHit);
|
|
242
|
+
diagnostics.cacheMisses += Number(!index.cacheHit);
|
|
243
|
+
const entries = parseMemoryIndex(index.buffer.toString("utf8"), index.path, canonicalRoot);
|
|
244
|
+
if (entries.length > MAX_INDEXED_LINKS) throw new Error(`Claude MEMORY.md indexes more than ${MAX_INDEXED_LINKS} direct files`);
|
|
245
|
+
diagnostics.indexed = entries.length;
|
|
246
|
+
const sources = [{ path: index.path, relativePath: "MEMORY.md", snapshot: index, relevance: "index" }];
|
|
247
|
+
for (const entry of entries) {
|
|
248
|
+
checkDeadline();
|
|
249
|
+
const relevance = relevantMemoryEntry(entry, scope);
|
|
250
|
+
if (!relevance.relevant) { diagnostics.rejected.scope += 1; continue; }
|
|
251
|
+
diagnostics.relevant += 1;
|
|
252
|
+
const snapshot = await safeIndexedSnapshot(canonicalRoot, entry.relativePath, cached[entry.relativePath], hooks);
|
|
253
|
+
if (snapshot.rejected) {
|
|
254
|
+
if (snapshot.rejected === "missing") diagnostics.missing += 1;
|
|
255
|
+
else diagnostics.rejected[snapshot.rejected] = (diagnostics.rejected[snapshot.rejected] || 0) + 1;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
diagnostics.loaded += 1;
|
|
259
|
+
diagnostics.cacheHits += Number(snapshot.cacheHit);
|
|
260
|
+
diagnostics.cacheMisses += Number(!snapshot.cacheHit);
|
|
261
|
+
sources.push({ path: snapshot.path, relativePath: entry.relativePath, snapshot, relevance: relevance.reason });
|
|
262
|
+
}
|
|
263
|
+
checkDeadline();
|
|
264
|
+
const finalIndex = await safeIndexedSnapshot(canonicalRoot, "MEMORY.md", index.record, hooks);
|
|
265
|
+
if (finalIndex.rejected || finalIndex.metadata.sha256 !== index.metadata.sha256) throw new Error("Claude MEMORY.md changed during indexed resolution; retry the hook");
|
|
266
|
+
await mutateCache(env, (value) => {
|
|
267
|
+
const records = { "MEMORY.md": index.record };
|
|
268
|
+
for (const source of sources.slice(1)) records[source.relativePath] = source.snapshot.record;
|
|
269
|
+
value.roots[key] = records;
|
|
270
|
+
return {};
|
|
271
|
+
});
|
|
272
|
+
return { sources, diagnostics };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function purgeIndexedMemoryCache(root, env = process.env) {
|
|
276
|
+
let canonical;
|
|
277
|
+
try { canonical = await realpath(root); } catch (error) {
|
|
278
|
+
if (error.code === "ENOENT") canonical = resolve(root); else throw error;
|
|
279
|
+
}
|
|
280
|
+
return mutateCache(env, (cache) => { delete cache.roots[rootKey(canonical)]; return { purged: true }; });
|
|
281
|
+
}
|