@staix/agent-mem 0.4.0 → 0.6.0
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/.claude-plugin/plugin.json +5 -0
- package/README.md +57 -7
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +33 -7
- package/src/commands-table.ts +4 -0
- package/src/hook-contract.ts +3 -0
- package/src/hook-git.ts +42 -0
- package/src/hook-native.ts +92 -0
- package/src/hook-project.ts +206 -0
- package/src/hook-resources.ts +15 -0
- package/src/hook-spool.ts +544 -0
- package/src/hooks.ts +249 -0
- package/src/index.ts +4 -0
- package/src/ingest.ts +350 -0
- package/src/mcp-memory.ts +71 -28
- package/src/memory/aliases.ts +83 -0
- package/src/memory/candidates.ts +11 -7
- package/src/memory/capacity.ts +23 -48
- package/src/memory/capture-input.ts +31 -0
- package/src/memory/capture.ts +245 -59
- package/src/memory/catalog.ts +3 -4
- package/src/memory/import.ts +2 -3
- package/src/memory/machine.ts +31 -0
- package/src/memory/metrics.ts +63 -0
- package/src/memory/mutation.ts +6 -4
- package/src/memory/platform.ts +9 -4
- package/src/memory/project.ts +129 -20
- package/src/memory/reader-lease.ts +22 -10
- package/src/memory/search-fts.ts +44 -3
- package/src/memory/shard.ts +3 -46
- package/src/memory/store.ts +108 -62
- package/src/memory/work-lists.ts +227 -68
- package/src/session-brief-worker.ts +81 -0
- package/src/session-brief.ts +202 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { hookGit, hookGitBinary } from "./hook-git.ts";
|
|
5
|
+
import { hookCaptureScope } from "./hook-spool.ts";
|
|
6
|
+
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
7
|
+
import { memoryProjectKey, type ProjectGitRead } from "./memory/project.ts";
|
|
8
|
+
import { memoryGitArgv, memoryGitEnv } from "./memory/platform.ts";
|
|
9
|
+
import { hookNative, type HookNativeUsage } from "./hook-native.ts";
|
|
10
|
+
|
|
11
|
+
// Bump the derived cache schema if the shared native project-key mapping changes.
|
|
12
|
+
const SCHEMA = "agent-mem.hook-project/v1", CACHE_BYTES = 4096, CACHE_CWDS = 128;
|
|
13
|
+
const hash = (text: string) => createHash("sha256").update(text).digest("hex");
|
|
14
|
+
interface Binding { schema: typeof SCHEMA; cwd: string; scope: string; validity: string; binary: string; project: string }
|
|
15
|
+
function read(file: string): Binding | undefined {
|
|
16
|
+
let fd: number | undefined;
|
|
17
|
+
try {
|
|
18
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
19
|
+
const stat = fstatSync(fd);
|
|
20
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > CACHE_BYTES || stat.mode & 0o077 || stat.uid !== process.getuid?.()) return undefined;
|
|
21
|
+
const bytes = Buffer.alloc(stat.size);
|
|
22
|
+
if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) return undefined;
|
|
23
|
+
const after = lstatSync(file);
|
|
24
|
+
if (after.dev !== stat.dev || after.ino !== stat.ino || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) return undefined;
|
|
25
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
26
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join() !== "binary,cwd,project,schema,scope,validity"
|
|
27
|
+
|| value.schema !== SCHEMA || typeof value.cwd !== "string" || typeof value.scope !== "string" || !/^[a-f0-9]{64}$/.test(value.scope)
|
|
28
|
+
|| typeof value.validity !== "string" || !/^[a-f0-9]{64}$/.test(value.validity) || typeof value.binary !== "string" || !/^[a-f0-9]{64}$/.test(value.binary) || typeof value.project !== "string" || !value.project || value.project.length > 2048 || /[\x00-\x1f\x7f]/.test(value.project)) return undefined;
|
|
29
|
+
return value;
|
|
30
|
+
} catch { return undefined; }
|
|
31
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
32
|
+
}
|
|
33
|
+
function publish(dir: string, file: string, value: Binding, maintain: boolean): void {
|
|
34
|
+
let temporary: string | undefined;
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
37
|
+
const stat = lstatSync(dir); if (!stat.isDirectory() || stat.isSymbolicLink()) return;
|
|
38
|
+
chmodSync(dir, 0o700);
|
|
39
|
+
// Only old, owned publication names are eligible; a recent writer keeps its temp.
|
|
40
|
+
if (maintain) {
|
|
41
|
+
const scan = opendirSync(dir);
|
|
42
|
+
try {
|
|
43
|
+
for (let count = 0; count < CACHE_CWDS * 2; count++) {
|
|
44
|
+
const entry = scan.readSync(); if (!entry) break;
|
|
45
|
+
if (!/^\.[a-f0-9]{64}-[a-f0-9]{24}$/.test(entry.name)) continue;
|
|
46
|
+
const stale = join(dir, entry.name), before = lstatSync(stale, { throwIfNoEntry: false });
|
|
47
|
+
if (!before?.isFile() || before.nlink !== 1 || before.uid !== process.getuid?.() || Date.now() - before.mtimeMs < 60_000) continue;
|
|
48
|
+
const after = lstatSync(stale, { throwIfNoEntry: false });
|
|
49
|
+
if (after?.dev === before.dev && after.ino === before.ino && after.size === before.size && after.mtimeMs === before.mtimeMs) rmSync(stale);
|
|
50
|
+
}
|
|
51
|
+
} finally { scan.closeSync(); }
|
|
52
|
+
}
|
|
53
|
+
// ponytail: 128 cached cwd identities, one bounded derived value each. New cwd misses
|
|
54
|
+
// still resolve natively; a future cache policy may retire old entries without touching capture.
|
|
55
|
+
if (!lstatSync(file, { throwIfNoEntry: false })) {
|
|
56
|
+
const entries = opendirSync(dir);
|
|
57
|
+
try { for (let count = 0; count <= CACHE_CWDS; count++) { if (!entries.readSync()) break; if (count === CACHE_CWDS - 1) return; } }
|
|
58
|
+
finally { entries.closeSync(); }
|
|
59
|
+
}
|
|
60
|
+
const text = JSON.stringify(value); if (Buffer.byteLength(text) > CACHE_BYTES) return;
|
|
61
|
+
temporary = join(dir, `.${hash(value.cwd)}-${randomBytes(12).toString("hex")}`);
|
|
62
|
+
writeFileSync(temporary, text, { mode: 0o600, flag: "wx" }); renameSync(temporary, file);
|
|
63
|
+
} catch { /* Derived cache I/O never changes a successfully resolved identity. */ }
|
|
64
|
+
finally { if (temporary) try { rmSync(temporary, { force: true }); } catch { /* Cache cleanup stays advisory too. */ } }
|
|
65
|
+
}
|
|
66
|
+
interface ResolverOptions { cwd: string; base: string; scope?: string; timeRemaining: () => number; maintainCache?: boolean; gitRead?: ProjectGitRead; assertComplete?: () => void; env?: NodeJS.ProcessEnv }
|
|
67
|
+
|
|
68
|
+
/** Prepare filesystem/cache binding once; suspended native reads replay only the shared resolver. */
|
|
69
|
+
function prepareHookProject(options: ResolverOptions) {
|
|
70
|
+
const checkDeadline = () => {
|
|
71
|
+
const remaining = options.timeRemaining();
|
|
72
|
+
if (!Number.isFinite(remaining) || remaining <= 0) throw new Error("project deadline");
|
|
73
|
+
};
|
|
74
|
+
checkDeadline();
|
|
75
|
+
const cwd = realPath(options.cwd), base = canonicalPath(options.base), binary = hookGitBinary(options.env);
|
|
76
|
+
const scope = options.scope;
|
|
77
|
+
if (scope !== undefined && (!scope || hookCaptureScope(cwd, base) !== scope)) throw new Error("project scope changed");
|
|
78
|
+
const cwdStat = lstatSync(cwd);
|
|
79
|
+
const prefixConfig = join(dirname(dirname(binary.file)), "etc", "gitconfig");
|
|
80
|
+
// Unknown semantic scope cannot certify a reusable identity, but native Git can still
|
|
81
|
+
// resolve the current project for a read-only brief. Never read or publish cache in this case.
|
|
82
|
+
const validity = scope === undefined ? undefined : hookCaptureScope(cwd, base, true, prefixConfig);
|
|
83
|
+
const dir = join(base, "hook-project"), named = lstatSync(dir, { throwIfNoEntry: false });
|
|
84
|
+
const file = join(dir, `${hash(cwd)}.json`);
|
|
85
|
+
const cached = scope !== undefined && (!named || named.isDirectory() && !named.isSymbolicLink() && !(named.mode & 0o077)) ? read(file) : undefined;
|
|
86
|
+
const hit = cached?.cwd === cwd && cached.scope === scope && validity && cached.validity === validity && cached.binary === binary.revision && cacheableLocal(cwd, cached.project);
|
|
87
|
+
const verifyBinary = () => {
|
|
88
|
+
checkDeadline();
|
|
89
|
+
const currentBinary = hookGitBinary(options.env);
|
|
90
|
+
if (currentBinary.file !== binary.file || currentBinary.revision !== binary.revision) throw new Error("project binary changed");
|
|
91
|
+
checkDeadline();
|
|
92
|
+
};
|
|
93
|
+
const verify = () => {
|
|
94
|
+
verifyBinary();
|
|
95
|
+
// A known strong binding includes every semantic field. Only the uncached,
|
|
96
|
+
// unknown-validity case also needs its independent semantic scope check.
|
|
97
|
+
if (scope === undefined) {
|
|
98
|
+
const currentCwd = lstatSync(cwd);
|
|
99
|
+
if (realPath(options.cwd) !== cwd || currentCwd.dev !== cwdStat.dev || currentCwd.ino !== cwdStat.ino) throw new Error("project cwd changed");
|
|
100
|
+
} else {
|
|
101
|
+
const currentValidity = hookCaptureScope(cwd, base, true, prefixConfig);
|
|
102
|
+
if (currentValidity !== validity || !validity && hookCaptureScope(cwd, base) !== scope) throw new Error("project scope changed");
|
|
103
|
+
}
|
|
104
|
+
checkDeadline();
|
|
105
|
+
};
|
|
106
|
+
return { binary, verifyBinary, resolve(gitRead: ProjectGitRead, assertComplete?: () => void): string {
|
|
107
|
+
checkDeadline();
|
|
108
|
+
if (hit && cached) {
|
|
109
|
+
assertComplete?.(); verify();
|
|
110
|
+
return cached.project;
|
|
111
|
+
}
|
|
112
|
+
const project = memoryProjectKey(cwd, { repositoryIsolation: true, strictGitErrors: true, gitRead });
|
|
113
|
+
// The synchronous resolver can catch an unknown native read internally. Fence that
|
|
114
|
+
// suspended pass before any completion checks, cache publication or returned key.
|
|
115
|
+
assertComplete?.(); verify();
|
|
116
|
+
if (scope !== undefined && validity && cacheableLocal(cwd, project)) {
|
|
117
|
+
checkDeadline();
|
|
118
|
+
publish(dir, file, { schema: SCHEMA, cwd, scope, validity, binary: binary.revision, project }, options.maintainCache === true);
|
|
119
|
+
}
|
|
120
|
+
checkDeadline();
|
|
121
|
+
return project;
|
|
122
|
+
} };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Cache only a successful shared native resolver result, before alias resolution. */
|
|
126
|
+
export function resolveHookProject(options: ResolverOptions): string {
|
|
127
|
+
const prepared = prepareHookProject(options);
|
|
128
|
+
return prepared.resolve(options.gitRead ?? hookGit(options.timeRemaining, prepared.binary.file), options.assertComplete);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A local key's superproject chain depends on outer Git indexes. Cache only ordinary
|
|
132
|
+
* standalone roots; linked/submodule/local nested layouts retain the native resolver. */
|
|
133
|
+
function cacheableLocal(cwd: string, project: string): boolean {
|
|
134
|
+
if (!/.+@[a-f0-9]{8}$/.test(project)) return true;
|
|
135
|
+
if (!project.endsWith(`@${hash(cwd).slice(0, 8)}`)) return false;
|
|
136
|
+
try {
|
|
137
|
+
const git = lstatSync(join(cwd, ".git"));
|
|
138
|
+
if (!git.isDirectory() || git.isSymbolicLink()) return false;
|
|
139
|
+
for (let folder = dirname(cwd), depth = 0; depth < 32; folder = dirname(folder), depth++) {
|
|
140
|
+
if (lstatSync(join(folder, ".git"), { throwIfNoEntry: false })) return false;
|
|
141
|
+
if (folder === dirname(folder)) return true;
|
|
142
|
+
}
|
|
143
|
+
} catch { /* Unprovable ancestry keeps native resolution. */ }
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Replay the shared synchronous resolver only after every requested native read is known.
|
|
148
|
+
* A suspended/partially resolved pass can never publish a cache entry or return a guessed key. */
|
|
149
|
+
export async function resolveHookProjectAsync(options: { cwd: string; base: string; scope?: string; timeRemaining: () => number; maintainCache?: boolean; prefetch?: boolean }, usage: HookNativeUsage): Promise<string> {
|
|
150
|
+
const reads = new Map<string, ReturnType<ProjectGitRead>>();
|
|
151
|
+
const env = { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AMEM_HOME: options.base };
|
|
152
|
+
const prepared = prepareHookProject({ ...options, env });
|
|
153
|
+
const suspended = new Error("native identity suspended");
|
|
154
|
+
const pending = new Map<string, { abort: AbortController; result: Promise<ReturnType<ProjectGitRead>>; usage: HookNativeUsage }>();
|
|
155
|
+
const active = new Set<string>(), overlaps: string[][] = [];
|
|
156
|
+
let prefetched = false;
|
|
157
|
+
const query = (args: string[]) => {
|
|
158
|
+
const key = JSON.stringify(args), existing = pending.get(key); if (existing) return existing.result;
|
|
159
|
+
if (pending.size >= 64) throw new Error("native identity query bound");
|
|
160
|
+
prepared.verifyBinary(); // No awaited native read may execute a replaced binary.
|
|
161
|
+
const argv = memoryGitArgv(args); argv[0] = prepared.binary.file;
|
|
162
|
+
const abort = new AbortController(), own = { rssKB: 0, rawRssKB: 0 };
|
|
163
|
+
active.add(key); overlaps.push([...active]);
|
|
164
|
+
const result = hookNative(argv, memoryGitEnv(env, { repositoryIsolation: true }), options.timeRemaining, own, abort.signal)
|
|
165
|
+
.finally(() => { active.delete(key); });
|
|
166
|
+
// Speculative failures have no authority until the shared resolver requests that read.
|
|
167
|
+
void result.catch(() => {});
|
|
168
|
+
pending.set(key, { abort, result, usage: own }); return result;
|
|
169
|
+
};
|
|
170
|
+
try {
|
|
171
|
+
for (let count = 0; count < 64; count++) {
|
|
172
|
+
let needed: { key: string; args: string[] } | undefined;
|
|
173
|
+
let result: string | undefined, failure: unknown;
|
|
174
|
+
try {
|
|
175
|
+
result = prepared.resolve((args) => {
|
|
176
|
+
const key = JSON.stringify(args);
|
|
177
|
+
const value = reads.get(key); if (value) return value;
|
|
178
|
+
needed ??= { key, args }; throw suspended;
|
|
179
|
+
}, () => { if (needed) throw suspended; });
|
|
180
|
+
} catch (error) { failure = error; }
|
|
181
|
+
if (!needed) { if (failure) throw failure; if (result !== undefined) return result; throw suspended; }
|
|
182
|
+
const request = needed as { key: string; args: string[] };
|
|
183
|
+
const demanded = query(request.args);
|
|
184
|
+
// A native read is requested only on a real cache miss. Prefetch the portable
|
|
185
|
+
// resolver's ordinary-repository metadata alongside origin, never an alternate
|
|
186
|
+
// identity algorithm. Only memoryProjectKey decides which replies it consumes.
|
|
187
|
+
if (options.prefetch === true && !prefetched && request.args.length === 5 && request.args[0] === "-C" && request.args.slice(2).join(" ") === "remote get-url origin") {
|
|
188
|
+
prefetched = true;
|
|
189
|
+
void query(["-C", request.args[1]!, "rev-parse", "--path-format=absolute", "--git-common-dir", "--git-dir", "--is-bare-repository", "--show-toplevel", "--show-superproject-working-tree"]);
|
|
190
|
+
}
|
|
191
|
+
reads.set(request.key, await demanded);
|
|
192
|
+
}
|
|
193
|
+
throw new Error("native identity query bound");
|
|
194
|
+
} finally {
|
|
195
|
+
// Never leave an unused prefetch or its descendants behind, even if origin alone
|
|
196
|
+
// resolved the key or a requested read failed. Abort still uses the owned reaper.
|
|
197
|
+
for (const child of pending.values()) child.abort.abort();
|
|
198
|
+
await Promise.allSettled([...pending.values()].map(child => child.result));
|
|
199
|
+
// Per-call peaks arrive at reap. Conservatively sum all calls whose owned
|
|
200
|
+
// lifetimes overlapped, rather than hiding concurrent helpers behind max().
|
|
201
|
+
for (const group of overlaps) {
|
|
202
|
+
usage.rssKB = Math.max(usage.rssKB, group.reduce((sum, key) => sum + pending.get(key)!.usage.rssKB, 0));
|
|
203
|
+
usage.rawRssKB = Math.max(usage.rawRssKB, group.reduce((sum, key) => sum + pending.get(key)!.usage.rawRssKB, 0));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** Linux rusage includes the pre-exec launcher's inherited high-water value. VmHWM belongs
|
|
4
|
+
* to this process image, so a large test runner cannot be mistaken for resident hook memory.
|
|
5
|
+
* Unavailable telemetry falls back conservatively to rusage, never to zero. */
|
|
6
|
+
export function hookPeakRssKB(): number {
|
|
7
|
+
if (process.platform === "linux") {
|
|
8
|
+
try {
|
|
9
|
+
const text = readFileSync("/proc/self/status", "utf8");
|
|
10
|
+
const match = /^VmHWM:\s+(\d+)\s+kB$/m.exec(text), peak = match && Number(match[1]);
|
|
11
|
+
if (peak && Number.isSafeInteger(peak) && peak > 0) return peak;
|
|
12
|
+
} catch { /* Non-proc Linux hosts retain the conservative historical measurement. */ }
|
|
13
|
+
}
|
|
14
|
+
return process.resourceUsage().maxRSS;
|
|
15
|
+
}
|