@staix/agent-mem 0.5.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 +53 -5
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +16 -0
- package/src/commands-table.ts +3 -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/memory/aliases.ts +83 -0
- package/src/memory/capacity.ts +23 -48
- package/src/memory/capture-input.ts +31 -0
- package/src/memory/capture.ts +124 -35
- 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/shard.ts +3 -80
- package/src/memory/store.ts +4 -29
- package/src/memory/work-lists.ts +105 -34
- package/src/session-brief-worker.ts +81 -0
- package/src/session-brief.ts +202 -0
package/src/memory/mutation.ts
CHANGED
|
@@ -6,10 +6,11 @@ export const MUTATION_BUSY_TIMEOUT_MS = 3000;
|
|
|
6
6
|
// ponytail: older hubs (before consuming this agent-mem), raw/legacy log writers and any segment editor
|
|
7
7
|
// that bypasses this lock are not serialized. Require every writer to take it, or make segment appends API-only.
|
|
8
8
|
/** An empty, machine-local SQLite file used only for crash-safe writer exclusion, never for memory data. */
|
|
9
|
-
export function mutationDatabase(dir: string): Database {
|
|
9
|
+
export function mutationDatabase(dir: string, busyTimeoutMs = MUTATION_BUSY_TIMEOUT_MS): Database {
|
|
10
|
+
if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 0 || busyTimeoutMs > MUTATION_BUSY_TIMEOUT_MS) throw new Error("invalid mutation busy timeout");
|
|
10
11
|
const file = join(dir, "mutation-lock.db"), db = new Database(file, { create: true });
|
|
11
12
|
chmodSync(file, 0o600);
|
|
12
|
-
db.run(`PRAGMA busy_timeout = ${
|
|
13
|
+
db.run(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
|
|
13
14
|
return db;
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -22,13 +23,14 @@ export function acquireMutation(dir: string): (() => void) | undefined {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
/** Short local admission fence, never held across sync/network or data mutations. */
|
|
25
|
-
export function readerAdmissionDatabase(dir: string): Database {
|
|
26
|
+
export function readerAdmissionDatabase(dir: string, busyTimeoutMs = MUTATION_BUSY_TIMEOUT_MS): Database {
|
|
27
|
+
if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 0 || busyTimeoutMs > MUTATION_BUSY_TIMEOUT_MS) throw new Error("invalid reader admission busy timeout");
|
|
26
28
|
const file = join(dir, "reader-admission.db");
|
|
27
29
|
for (const suffix of ["", "-wal", "-shm", "-journal"]) {
|
|
28
30
|
const stat = lstatSync(file + suffix, { throwIfNoEntry: false });
|
|
29
31
|
if (stat && (stat.isSymbolicLink() || !stat.isFile())) throw new Error("reader admission fence is not a regular file");
|
|
30
32
|
}
|
|
31
33
|
const db = new Database(file, { create: true });
|
|
32
|
-
try { chmodSync(file, 0o600); db.run(`PRAGMA busy_timeout = ${
|
|
34
|
+
try { chmodSync(file, 0o600); db.run(`PRAGMA busy_timeout = ${busyTimeoutMs}`); return db; }
|
|
33
35
|
catch (error) { db.close(); throw error; }
|
|
34
36
|
}
|
package/src/memory/platform.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// Single-source platform helpers. Hub, local and Pi modules re-export these functions unchanged.
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import type { SpawnSyncOptionsWithStringEncoding, SpawnSyncReturns } from "node:child_process";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
let processes: typeof import("node:child_process") | undefined;
|
|
8
|
+
// Path/policy-only hook parents never need a process module. Node-safe synchronous users load it on demand.
|
|
9
|
+
const childProcess = () => processes ??= require("node:child_process") as typeof import("node:child_process");
|
|
5
10
|
import { createHash } from "node:crypto";
|
|
6
11
|
|
|
7
12
|
const canonical = (path: string): string => {
|
|
@@ -33,7 +38,7 @@ export function realPath(path: string): string {
|
|
|
33
38
|
// The path as given: resolve() would fold a `..` after a symlink by text. GNU realpath accepts a dangling last
|
|
34
39
|
// component unless told -e; the BSD one on macOS refuses it by default.
|
|
35
40
|
const argv = process.platform === "darwin" ? ["/bin/realpath", "--", path] : ["realpath", "-e", "--", path];
|
|
36
|
-
const r = spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8" });
|
|
41
|
+
const r = childProcess().spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8" });
|
|
37
42
|
if (r.status !== 0 || !r.stdout.endsWith("\n")) throw error;
|
|
38
43
|
return r.stdout.slice(0, -1);
|
|
39
44
|
}
|
|
@@ -94,7 +99,7 @@ export function memoryGitEnv(env: NodeJS.ProcessEnv = process.env, options: { re
|
|
|
94
99
|
/** spawnSync form of the same call: the env option is merged with memoryGitEnv, everything else passes through. */
|
|
95
100
|
export function memoryGitSync(args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string> {
|
|
96
101
|
const argv = memoryGitArgv(args);
|
|
97
|
-
return spawnSync(argv[0]!, argv.slice(1), { ...options, env: memoryGitEnv(options.env ?? process.env) });
|
|
102
|
+
return childProcess().spawnSync(argv[0]!, argv.slice(1), { ...options, env: memoryGitEnv(options.env ?? process.env) });
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
/**
|
|
@@ -109,7 +114,7 @@ export function memoryGitSync(args: string[], options: SpawnSyncOptionsWithStrin
|
|
|
109
114
|
*/
|
|
110
115
|
export function processSignature(pid: number): string | undefined {
|
|
111
116
|
try {
|
|
112
|
-
const text = execFileSync("ps", ["-p", String(pid), "-o", "lstart=,comm="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" } }).trim();
|
|
117
|
+
const text = childProcess().execFileSync("ps", ["-p", String(pid), "-o", "lstart=,comm="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" } }).trim();
|
|
113
118
|
return text ? createHash("sha256").update(text).digest("hex") : undefined;
|
|
114
119
|
} catch { return undefined; }
|
|
115
120
|
}
|
package/src/memory/project.ts
CHANGED
|
@@ -1,14 +1,66 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { basename, dirname, resolve } from "node:path";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { memoryGitSync, realPath } from "./platform.ts";
|
|
2
|
+
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
4
|
+
import { memoryGitEnv, memoryGitSync, realPath } from "./platform.ts";
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
export
|
|
8
|
-
|
|
6
|
+
/** Host-owned read-only Git transport. Arguments are the existing Git operation, before platform argv hardening. */
|
|
7
|
+
export type ProjectGitRead = (args: string[], env: NodeJS.ProcessEnv) => {
|
|
8
|
+
stdout: string; status: number | null; error?: unknown; signal?: string | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
interface ProjectIdentityOptions {
|
|
12
|
+
repositoryIsolation?: boolean;
|
|
13
|
+
/** Host-only: native intake must not rebind a failed Git identity to a local fallback. */
|
|
14
|
+
strictGitErrors?: boolean;
|
|
15
|
+
/** Host-only transport seam; callers retain the same strict status and shape checks. */
|
|
16
|
+
gitRead?: ProjectGitRead;
|
|
17
|
+
}
|
|
18
|
+
class GitIdentityUnavailable extends Error { constructor() { super("Git project identity unavailable"); } }
|
|
19
|
+
const unavailable = (): never => { throw new GitIdentityUnavailable(); };
|
|
20
|
+
function canonicalCwd(cwd: string, strict: boolean | undefined): string {
|
|
21
|
+
if (!strict) return cwd;
|
|
22
|
+
try { const canonical = realPath(cwd); if (!lstatSync(canonical).isDirectory()) unavailable(); return canonical; }
|
|
23
|
+
catch { return unavailable(); }
|
|
24
|
+
}
|
|
25
|
+
/** Filesystem evidence only; unknown ancestry never certifies an ordinary non-Git directory. */
|
|
26
|
+
function physicalGit(cwd: string): boolean {
|
|
27
|
+
const present = (file: string) => {
|
|
28
|
+
try { return !!lstatSync(file, { throwIfNoEntry: false }); }
|
|
29
|
+
catch { return unavailable(); }
|
|
30
|
+
};
|
|
31
|
+
for (let dir = cwd, depth = 0; depth < 32; dir = dirname(dir), depth++) {
|
|
32
|
+
if (present(resolve(dir, ".git")) || present(resolve(dir, "HEAD")) && present(resolve(dir, "objects"))) return true;
|
|
33
|
+
if (dir === dirname(dir)) return false;
|
|
34
|
+
}
|
|
35
|
+
return unavailable();
|
|
36
|
+
}
|
|
37
|
+
function checkedGit(args: string[], env: NodeJS.ProcessEnv, strict: boolean, gitRead?: ProjectGitRead, statuses = [0]) {
|
|
38
|
+
const result = gitRead ? gitRead(args, env) : memoryGitSync(args, { encoding: "utf8", env });
|
|
39
|
+
if (strict && (result.error || result.signal || !statuses.includes(result.status ?? -1) || typeof result.stdout !== "string"
|
|
40
|
+
|| result.stdout.length > 64 * 1024 || result.stdout.includes("\0"))) unavailable();
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
function pathText(text: string, strict: boolean, empty = false): string {
|
|
44
|
+
if (strict && (text ? !isAbsolute(text) || /[\r\n]/.test(text) : !empty)) unavailable();
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Project names from the outermost superproject down to this repo; isolation binds native hook cwd over inherited Git routing. */
|
|
49
|
+
export function projectChain(cwd: string, options: ProjectIdentityOptions = {}): string[] {
|
|
50
|
+
cwd = canonicalCwd(cwd, options.strictGitErrors);
|
|
51
|
+
const strict = options.strictGitErrors === true && physicalGit(cwd);
|
|
52
|
+
const env = memoryGitEnv(process.env, options);
|
|
53
|
+
const git = (dir: string, arg: string) => pathText(checkedGit(["-C", dir, "rev-parse", arg], env, strict, options.gitRead).stdout?.trim() ?? "", strict, arg === "--show-superproject-working-tree");
|
|
9
54
|
const top = git(cwd, "--show-toplevel");
|
|
10
55
|
if (!top) return [basename(cwd)];
|
|
56
|
+
return projectChainAtRoot(top, options);
|
|
57
|
+
}
|
|
11
58
|
|
|
59
|
+
/** Internal callers that have already resolved the working root do not repeat its git lookup. */
|
|
60
|
+
function projectChainAtRoot(top: string, options: ProjectIdentityOptions): string[] {
|
|
61
|
+
const strict = options.strictGitErrors === true;
|
|
62
|
+
const env = memoryGitEnv(process.env, options);
|
|
63
|
+
const git = (dir: string, arg: string) => pathText(checkedGit(["-C", dir, "rev-parse", arg], env, strict, options.gitRead).stdout?.trim() ?? "", strict, true);
|
|
12
64
|
// claude-mem's native identity for a worktree is [parent, parent/worktree].
|
|
13
65
|
// The worktree's .git file points into <parent>/.git/worktrees/<name>; the
|
|
14
66
|
// ordinary superproject walk below remains unchanged for repos and submodules.
|
|
@@ -19,12 +71,13 @@ export function projectChain(cwd: string): string[] {
|
|
|
19
71
|
const gitDir = resolve(top, match[1]!);
|
|
20
72
|
if (/[/\\]\.git[/\\]worktrees[/\\][^/\\]+$/.test(gitDir)) {
|
|
21
73
|
const parentRoot = dirname(dirname(dirname(gitDir)));
|
|
22
|
-
const parent = projectChain(parentRoot);
|
|
74
|
+
const parent = projectChain(parentRoot, options);
|
|
23
75
|
const parentName = basename(parentRoot);
|
|
24
76
|
return [...parent, `${parentName}/${basename(top)}`];
|
|
25
77
|
}
|
|
26
78
|
}
|
|
27
|
-
} catch {
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error instanceof GitIdentityUnavailable) throw error;
|
|
28
81
|
// A missing or unreadable .git file falls through to git's normal chain.
|
|
29
82
|
}
|
|
30
83
|
|
|
@@ -32,7 +85,7 @@ export function projectChain(cwd: string): string[] {
|
|
|
32
85
|
for (let dir = top; dir && chain.length < 8; dir = git(dir, "--show-superproject-working-tree")) {
|
|
33
86
|
chain.unshift(basename(dir));
|
|
34
87
|
}
|
|
35
|
-
return chain.length ? chain : [basename(
|
|
88
|
+
return chain.length ? chain : [basename(top)];
|
|
36
89
|
}
|
|
37
90
|
|
|
38
91
|
/**
|
|
@@ -41,8 +94,13 @@ export function projectChain(cwd: string): string[] {
|
|
|
41
94
|
* the main working root keeps two unrelated repositories that share a name apart while the main checkout, a
|
|
42
95
|
* subdirectory and a worktree resolve to one key (that key is this machine's only, and root-invoked keys stay stable).
|
|
43
96
|
*/
|
|
44
|
-
export function memoryProjectKey(cwd: string): string {
|
|
45
|
-
|
|
97
|
+
export function memoryProjectKey(cwd: string, options: ProjectIdentityOptions = {}): string {
|
|
98
|
+
cwd = canonicalCwd(cwd, options.strictGitErrors);
|
|
99
|
+
const strict = options.strictGitErrors === true && physicalGit(cwd);
|
|
100
|
+
const env = memoryGitEnv(process.env, options);
|
|
101
|
+
const origin = checkedGit(["-C", cwd, "remote", "get-url", "origin"], env, strict, options.gitRead, [0, 2]);
|
|
102
|
+
const url = origin.stdout?.trim() ?? "";
|
|
103
|
+
if (strict && (origin.status === 0 ? !url || /[\r\n]/.test(url) : !!url)) unavailable();
|
|
46
104
|
// A URL's default port is not part of the repository (`ssh://git@github.com:22/o/r` is `github.com/o/r`); any other
|
|
47
105
|
// port is, since another service may run there. An scp-style `host:path` has no port.
|
|
48
106
|
const u = /^([a-z+]+):\/\/(?:[^@/]+@)?(\[[^\]/]+\]|[^/:]+)(?::(\d*))?\/(.+?)(?:\.git)?\/?$/i.exec(url); // an IPv6 host is bracketed
|
|
@@ -60,26 +118,77 @@ export function memoryProjectKey(cwd: string): string {
|
|
|
60
118
|
// No usable remote: hash the main working root, not the cwd or git dir. Root-invoked keys therefore keep their
|
|
61
119
|
// pre-#15 value while the main checkout, a subdirectory and a worktree resolve to ONE key (#15 D2/AC2). A bare
|
|
62
120
|
// repository has no working root: its own directory is the stable identity for it and its worktrees.
|
|
63
|
-
|
|
121
|
+
// The bounded native hook port reads all ordinary-repository metadata in its second Git call. Git itself
|
|
122
|
+
// resolves core.worktree for --show-toplevel; no config interpretation or root guess replaces that result.
|
|
123
|
+
let location: string[];
|
|
124
|
+
if (strict && options.gitRead) {
|
|
125
|
+
const metadata = checkedGit(["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir", "--git-dir", "--is-bare-repository", "--show-toplevel", "--show-superproject-working-tree"], env, true, options.gitRead, [0, 128]);
|
|
126
|
+
const fields = metadata.stdout.trim().split("\n");
|
|
127
|
+
if (fields.length < 3 || !isAbsolute(fields[0]!) || !isAbsolute(fields[1]!) || !/^(true|false)$/.test(fields[2]!)) unavailable();
|
|
128
|
+
location = fields.slice(0, 3);
|
|
129
|
+
if (metadata.status === 128) {
|
|
130
|
+
// A bare repository emits exactly the three proven metadata rows before show-toplevel refuses.
|
|
131
|
+
// Any other failed/partial shape stays unknown; stderr never participates in an identity decision.
|
|
132
|
+
if (fields.length !== 3 || fields[2] !== "true") unavailable();
|
|
133
|
+
if (fields[0] === fields[1]) {
|
|
134
|
+
const root = canonicalCwd(fields[0]!, true);
|
|
135
|
+
return `${basename(root)}@${createHash("sha256").update(root).digest("hex").slice(0, 8)}`;
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
if ((fields.length !== 4 && fields.length !== 5) || fields[2] !== "false") unavailable();
|
|
139
|
+
pathText(fields[3]!, true);
|
|
140
|
+
if (fields.length === 5) pathText(fields[4]!, true);
|
|
141
|
+
if (fields[0] === fields[1]) {
|
|
142
|
+
const root = canonicalCwd(fields[3]!, true), realCommon = canonicalCwd(fields[0]!, true);
|
|
143
|
+
// A separate/symlinked directory literally named .git can carry a historical parent-root key.
|
|
144
|
+
// Only the established config/root path below can distinguish an explicit core.worktree there.
|
|
145
|
+
if (basename(realCommon) !== ".git" || dirname(realCommon) === root) {
|
|
146
|
+
const chain = [basename(root)];
|
|
147
|
+
// Match the existing outermost-superproject walk, without reinterpreting a parent's worktree label.
|
|
148
|
+
for (let parent = fields[4] ?? ""; parent && chain.length < 8;) {
|
|
149
|
+
chain.unshift(basename(parent));
|
|
150
|
+
parent = pathText(checkedGit(["-C", parent, "rev-parse", "--show-superproject-working-tree"], env, true, options.gitRead).stdout.trim(), true, true);
|
|
151
|
+
}
|
|
152
|
+
return `${chain.join("/")}@${createHash("sha256").update(root).digest("hex").slice(0, 8)}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Linked worktrees and ambiguous .git directory roots retain the established resolution below.
|
|
157
|
+
} else {
|
|
158
|
+
location = checkedGit(["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir", "--git-dir", "--is-bare-repository"], env, strict, options.gitRead).stdout?.trim().split("\n") ?? [];
|
|
159
|
+
}
|
|
160
|
+
if (strict && (location.length !== 3 || !isAbsolute(location[0]!) || !isAbsolute(location[1]!) || !/^(true|false)$/.test(location[2]!))) unavailable();
|
|
161
|
+
const ordinary = location.length === 3 && location[0] === location[1] && /^(true|false)$/.test(location[2]!);
|
|
162
|
+
const common = location.length === 3 ? location[0]! : checkedGit(["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], env, strict, options.gitRead).stdout?.trim() ?? "";
|
|
64
163
|
if (common) {
|
|
65
164
|
let realCommon = common;
|
|
66
|
-
try { realCommon = realPath(common); } catch { /* as given */ }
|
|
67
|
-
const
|
|
68
|
-
|
|
165
|
+
try { realCommon = realPath(common); } catch { if (strict) unavailable(); /* as given */ }
|
|
166
|
+
const bareText = ordinary ? location[2]! : checkedGit(["--git-dir", realCommon, "rev-parse", "--is-bare-repository"], env, strict, options.gitRead).stdout?.trim() ?? "";
|
|
167
|
+
if (strict && !/^(true|false)$/.test(bareText)) unavailable();
|
|
168
|
+
const bare = bareText === "true";
|
|
169
|
+
let root = realCommon, knownWorkingRoot = false;
|
|
69
170
|
if (!bare) {
|
|
70
|
-
const
|
|
71
|
-
const
|
|
171
|
+
const config = checkedGit(["--git-dir", realCommon, "config", "--path", "core.worktree"], env, strict, options.gitRead, [0, 1]);
|
|
172
|
+
const configured = config.stdout?.trim() ?? "";
|
|
173
|
+
if (strict && (config.status === 0 ? !configured || /[\r\n]/.test(configured) : !!configured)) unavailable();
|
|
174
|
+
// Only a separate git directory without core.worktree needs another root lookup.
|
|
175
|
+
// Ordinary repositories already bind their root through the canonical .git directory.
|
|
176
|
+
const top = !configured && basename(realCommon) !== ".git"
|
|
177
|
+
? checkedGit(["-C", cwd, "rev-parse", "--show-toplevel"], env, strict, options.gitRead).stdout?.trim() ?? "" : "";
|
|
72
178
|
// Only an actual `.git` directory makes its parent the working root. A separate git dir shares its parent
|
|
73
179
|
// with unrelated repositories, so the real worktree root (or, as a last resort, the unique git dir) is safer.
|
|
180
|
+
if (strict && !configured && basename(realCommon) !== ".git") pathText(top, true);
|
|
74
181
|
root = configured ? resolve(realCommon, configured) : basename(realCommon) === ".git" ? dirname(realCommon) : top || realCommon;
|
|
182
|
+
knownWorkingRoot = !configured && basename(realCommon) === ".git";
|
|
75
183
|
}
|
|
76
184
|
let realRoot = root;
|
|
77
|
-
try { realRoot = realPath(root); } catch { /* as given */ }
|
|
78
|
-
|
|
185
|
+
try { realRoot = realPath(root); } catch { if (strict) unavailable(); /* as given */ }
|
|
186
|
+
const chain = strict && bare ? [basename(realRoot)] : knownWorkingRoot ? projectChainAtRoot(realRoot, options) : projectChain(realRoot, options);
|
|
187
|
+
return `${chain.join("/")}@${createHash("sha256").update(realRoot).digest("hex").slice(0, 8)}`;
|
|
79
188
|
}
|
|
80
189
|
let real = cwd;
|
|
81
190
|
try { real = realPath(cwd); } catch { /* as given */ }
|
|
82
|
-
return `${projectChain(cwd).join("/")}@${createHash("sha256").update(real).digest("hex").slice(0, 8)}`;
|
|
191
|
+
return `${projectChain(cwd, options).join("/")}@${createHash("sha256").update(real).digest("hex").slice(0, 8)}`;
|
|
83
192
|
}
|
|
84
193
|
|
|
85
194
|
/**
|
|
@@ -8,14 +8,15 @@ const READER_LEASE_SCHEMA = "agent-mem.reader-lease/v1";
|
|
|
8
8
|
const RENEW_MS = 25_000;
|
|
9
9
|
interface Lease { schema: typeof READER_LEASE_SCHEMA; pid: number; signature: string; renewedAt: string }
|
|
10
10
|
export interface ReaderLease { (): void; verify(): void }
|
|
11
|
-
interface OpenLease { refs: number; timer: ReturnType<typeof setInterval
|
|
11
|
+
interface OpenLease { refs: number; timer: ReturnType<typeof setInterval>; busyTimeoutMs: number }
|
|
12
12
|
const open = new Map<string, OpenLease>();
|
|
13
|
+
let activeIdentity: { pid: number; signature: string } | undefined;
|
|
13
14
|
|
|
14
15
|
const fileFor = (memoryDir: string, pid: number, signature: string) => join(memoryDir, "readers", `${pid}-${signature}.json`);
|
|
15
16
|
const record = (pid: number, signature: string): Lease => ({ schema: READER_LEASE_SCHEMA, pid, signature, renewedAt: new Date().toISOString() });
|
|
16
17
|
|
|
17
|
-
function withFence<T>(memoryDir: string, work: () => T): T {
|
|
18
|
-
const db = readerAdmissionDatabase(memoryDir);
|
|
18
|
+
function withFence<T>(memoryDir: string, work: () => T, busyTimeoutMs = 3000): T {
|
|
19
|
+
const db = readerAdmissionDatabase(memoryDir, busyTimeoutMs);
|
|
19
20
|
try { return db.transaction(work).immediate(); } finally { db.close(); }
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -52,10 +53,20 @@ function admitReader(memoryDir: string): void {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/** One lease per process, reference-counted across handles; an unref'd timer does not keep the process resident. */
|
|
55
|
-
export function registerReaderLease(memoryDir: string): ReaderLease {
|
|
56
|
-
const
|
|
56
|
+
export function registerReaderLease(memoryDir: string, options: { busyTimeoutMs?: number; signature?: string } = {}): ReaderLease {
|
|
57
|
+
const requestedTimeout = options.busyTimeoutMs ?? 3000;
|
|
58
|
+
if (!Number.isSafeInteger(requestedTimeout) || requestedTimeout < 0 || requestedTimeout > 3000) throw new Error("invalid reader admission busy timeout");
|
|
59
|
+
if (options.signature !== undefined && (typeof options.signature !== "string" || !/^[a-f0-9]{64}$/.test(options.signature))) throw new Error("invalid reader lease identity");
|
|
60
|
+
const pid = process.pid;
|
|
61
|
+
// A live reference already established this process lifetime; nested readers keep the same identity.
|
|
62
|
+
// Ownership and admission are still checked under the fence for every registration.
|
|
63
|
+
const inherited = open.size > 0 && activeIdentity?.pid === pid ? activeIdentity.signature : undefined;
|
|
64
|
+
if (inherited && options.signature !== undefined && options.signature !== inherited) throw new Error("reader lease identity changed");
|
|
65
|
+
// Host-only: the supplied hash must already describe this process's pinned native ps identity.
|
|
66
|
+
const signature = inherited ?? options.signature ?? processSignature(pid);
|
|
57
67
|
if (!signature) throw new Error("cannot establish reader lease identity");
|
|
58
68
|
const file = fileFor(memoryDir, process.pid, signature);
|
|
69
|
+
const busyTimeoutMs = open.get(file)?.busyTimeoutMs ?? requestedTimeout;
|
|
59
70
|
mkdirSync(memoryDir, { recursive: true, mode: 0o700 });
|
|
60
71
|
withFence(memoryDir, () => {
|
|
61
72
|
admitReader(memoryDir);
|
|
@@ -77,12 +88,13 @@ export function registerReaderLease(memoryDir: string): ReaderLease {
|
|
|
77
88
|
withFence(memoryDir, () => {
|
|
78
89
|
ownedLease(memoryDir, file, signature);
|
|
79
90
|
writeProfileJSON(file, record(process.pid, signature));
|
|
80
|
-
});
|
|
91
|
+
}, busyTimeoutMs);
|
|
81
92
|
} catch { /* keep unknown evidence; migration admission remains fail-closed */ }
|
|
82
93
|
}, RENEW_MS);
|
|
83
94
|
timer.unref();
|
|
84
|
-
open.set(file, { refs: 1, timer });
|
|
85
|
-
|
|
95
|
+
open.set(file, { refs: 1, timer, busyTimeoutMs });
|
|
96
|
+
activeIdentity = { pid, signature };
|
|
97
|
+
}, busyTimeoutMs);
|
|
86
98
|
let released = false;
|
|
87
99
|
const release = () => {
|
|
88
100
|
if (released) return;
|
|
@@ -97,8 +109,8 @@ export function registerReaderLease(memoryDir: string): ReaderLease {
|
|
|
97
109
|
withFence(memoryDir, () => {
|
|
98
110
|
ownedLease(memoryDir, file, signature);
|
|
99
111
|
rmSync(file);
|
|
100
|
-
});
|
|
101
|
-
} catch { /*
|
|
112
|
+
}, held.busyTimeoutMs);
|
|
113
|
+
} catch { /* Contention or replacement keeps evidence; never remove another actor's lease. */ }
|
|
102
114
|
};
|
|
103
115
|
return Object.assign(release, {
|
|
104
116
|
verify(): void {
|
package/src/memory/shard.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { canonicalJSON, generationDescriptor, hashJSON, readProfileJSON, readSelector, validGeneration, type BoundProfile, type GenerationSeal } from "./generation.ts";
|
|
5
|
+
import { SHARD_SCHEMA, resolveAlias } from "./aliases.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* #15 T1: the per-project shard layout under the user home. One shard per project key at `projects/<key-hash>/`;
|
|
@@ -17,7 +18,7 @@ import { canonicalJSON, generationDescriptor, hashJSON, readProfileJSON, readSel
|
|
|
17
18
|
* append-only ops in `aliases.jsonl`, resolved one hop, and never merged automatically on a name match.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
|
-
export
|
|
21
|
+
export { SHARD_SCHEMA, aliasLogPath, appendAlias, appendUnalias, listAliases, effectiveAliases, resolveAlias, aliasedKeys, type AliasOp, type UnaliasOp, type AliasLogOp } from "./aliases.ts";
|
|
21
22
|
|
|
22
23
|
/** The directory name of one project's shard: a hash, since keys are URLs and path-hash forms. */
|
|
23
24
|
export function shardKeyHash(key: string): string {
|
|
@@ -73,84 +74,6 @@ export function readShardDescriptor(base: string, hash: string): ShardDescriptor
|
|
|
73
74
|
return undefined;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
export interface AliasOp { schema: typeof SHARD_SCHEMA; op: "alias"; at: string; machine: string; alias: string; key: string; actor: string }
|
|
77
|
-
/**
|
|
78
|
-
* #27 option 2: the person's removal of one alias. An append-only op in the same local `aliases.jsonl` (never a shared-v1 segment, so no shared-v1 reader sees a new op
|
|
79
|
-
* kind there: an older reader skips the line and keeps resolving the alias, which is the C1 "local only" placement). The latest op for an alias wins, so after an
|
|
80
|
-
* `unalias` the key resolves to itself again, and a later `alias` op maps it again.
|
|
81
|
-
*/
|
|
82
|
-
export interface UnaliasOp { schema: typeof SHARD_SCHEMA; op: "unalias"; at: string; machine: string; alias: string; actor: string }
|
|
83
|
-
export type AliasLogOp = AliasOp | UnaliasOp;
|
|
84
|
-
|
|
85
|
-
export function aliasLogPath(base: string): string {
|
|
86
|
-
return join(base, "aliases.jsonl");
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* One person-reviewed alias op, appended to `aliases.jsonl`. An alias never chains (its target must not itself be
|
|
91
|
-
* an alias), and a machine that races the append keeps both ops: the latest one wins at read time.
|
|
92
|
-
*/
|
|
93
|
-
export function appendAlias(base: string, input: { alias: string; key: string; actor: string; machine: string; at?: string }): AliasOp {
|
|
94
|
-
const alias = input.alias.trim(), key = input.key.trim();
|
|
95
|
-
if (!alias || !key) throw new Error("an alias needs both keys");
|
|
96
|
-
if (alias === key) throw new Error("an alias cannot target itself");
|
|
97
|
-
if (resolveAlias(base, key) !== key) throw new Error("an alias cannot target another alias");
|
|
98
|
-
// The other direction too, using only the latest op per alias: a target abandoned by a later retarget or a removal is free again.
|
|
99
|
-
const currentTargets = new Set(effectiveAliases(base).values());
|
|
100
|
-
if (currentTargets.has(alias)) throw new Error("an alias target cannot become an alias");
|
|
101
|
-
const op: AliasOp = { schema: SHARD_SCHEMA, op: "alias", at: input.at ?? new Date().toISOString(), machine: input.machine, alias, key, actor: input.actor };
|
|
102
|
-
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
103
|
-
appendFileSync(aliasLogPath(base), JSON.stringify(op) + "\n", { mode: 0o600 });
|
|
104
|
-
return op;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Every alias and unalias op in order (the full history); a malformed or unknown line is skipped, never fatal. */
|
|
108
|
-
export function listAliases(base: string): AliasLogOp[] {
|
|
109
|
-
let text = "";
|
|
110
|
-
try { text = readFileSync(aliasLogPath(base), "utf8"); } catch { return []; }
|
|
111
|
-
const ops: AliasLogOp[] = [];
|
|
112
|
-
for (const line of text.split("\n").filter(Boolean)) {
|
|
113
|
-
try {
|
|
114
|
-
const op = JSON.parse(line) as AliasLogOp;
|
|
115
|
-
if (op?.schema !== SHARD_SCHEMA || typeof op.alias !== "string") continue;
|
|
116
|
-
if (op.op === "alias" && typeof op.key === "string") ops.push(op);
|
|
117
|
-
else if (op.op === "unalias") ops.push(op);
|
|
118
|
-
} catch { /* a malformed line is skipped */ }
|
|
119
|
-
}
|
|
120
|
-
return ops;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** The aliases in force: for each alias key the latest op decides, and an `unalias` removes it. Map of alias key to canonical key. */
|
|
124
|
-
export function effectiveAliases(base: string): Map<string, string> {
|
|
125
|
-
const latest = new Map<string, string>();
|
|
126
|
-
for (const op of listAliases(base)) { if (op.op === "alias") latest.set(op.alias, op.key); else latest.delete(op.alias); }
|
|
127
|
-
return latest;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** The canonical key for a possibly aliased one: the latest op for it, one hop (aliases never chain). An unaliased key is its own canonical key. */
|
|
131
|
-
export function resolveAlias(base: string, key: string): string {
|
|
132
|
-
return effectiveAliases(base).get(key) ?? key;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Every key currently aliased to `canonical`, sorted (the keys whose curated rows the CLI and MCP project filter now covers, #27 option 1). */
|
|
136
|
-
export function aliasedKeys(base: string, canonical: string): string[] {
|
|
137
|
-
return [...effectiveAliases(base)].filter(([, key]) => key === canonical).map(([alias]) => alias).sort();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* One person-reviewed removal, appended to `aliases.jsonl`. Nothing is rewritten: the alias line stays in the history and the later op wins. Refused when the key is
|
|
142
|
-
* not aliased now, so a typo never records a removal that means nothing.
|
|
143
|
-
*/
|
|
144
|
-
export function appendUnalias(base: string, input: { alias: string; actor: string; machine: string; at?: string }): UnaliasOp {
|
|
145
|
-
const alias = input.alias.trim();
|
|
146
|
-
if (!alias) throw new Error("an alias removal needs the alias key");
|
|
147
|
-
if (!effectiveAliases(base).has(alias)) throw new Error(`no alias to remove: ${alias} is not aliased now`);
|
|
148
|
-
const op: UnaliasOp = { schema: SHARD_SCHEMA, op: "unalias", at: input.at ?? new Date().toISOString(), machine: input.machine, alias, actor: input.actor };
|
|
149
|
-
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
150
|
-
appendFileSync(aliasLogPath(base), JSON.stringify(op) + "\n", { mode: 0o600 });
|
|
151
|
-
return op;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
77
|
/** `memoryBase(home)` plus the shard of `key` after alias resolution. */
|
|
155
78
|
export function captureShardDir(base: string, key: string, machine: string): string {
|
|
156
79
|
return shardMachineDir(base, resolveAlias(base, key), machine);
|
package/src/memory/store.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { createHash
|
|
3
|
-
import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync,
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { isAbsolute, join, normalize } from "node:path";
|
|
5
5
|
import { isDenied } from "./platform.ts";
|
|
6
6
|
import { looksSecret } from "./screen.ts";
|
|
7
7
|
import { mutationDatabase } from "./mutation.ts";
|
|
8
8
|
import { memoryHome } from "./home.ts";
|
|
9
|
+
import { machineId, type Machine } from "./machine.ts";
|
|
10
|
+
export { machineId, type Machine } from "./machine.ts";
|
|
9
11
|
import { readWindowSql, readRef, type ReadWindow, type ReadRef, SearchIndex, captureSearch } from "./search-fts.ts";
|
|
10
12
|
import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
|
|
11
13
|
import { readCaptureConfig, type CaptureRow } from "./capture.ts";
|
|
@@ -124,33 +126,6 @@ const oneLine = (text: string): string => text.replace(/\s+/g, " ").trim().slice
|
|
|
124
126
|
/** A board task's key in hub memory: unique across `ahub reset --all`, which restarts the ids. */
|
|
125
127
|
export const taskKey = (project: string, task: { id: number; created: number }): string => `${project}#${task.id}@${task.created}`;
|
|
126
128
|
|
|
127
|
-
export interface Machine { id: string; label: string }
|
|
128
|
-
/** Random per `~/.agenthub`, with a person-given label; created on first use. */
|
|
129
|
-
export function machineId(dir: string): Machine {
|
|
130
|
-
const file = join(dir, "machine.json");
|
|
131
|
-
const read = (): Machine | undefined => {
|
|
132
|
-
try {
|
|
133
|
-
const m = JSON.parse(readFileSync(file, "utf8")) as Machine;
|
|
134
|
-
if (/^[0-9a-f]{16}$/.test(m?.id)) return { id: m.id, label: typeof m.label === "string" ? m.label : m.id };
|
|
135
|
-
} catch { /* none, or unreadable */ }
|
|
136
|
-
return undefined;
|
|
137
|
-
};
|
|
138
|
-
const found = read();
|
|
139
|
-
if (found) return found;
|
|
140
|
-
// Hubs of several projects can start at once on a fresh home: each writes its own temp file and links it into place,
|
|
141
|
-
// which fails if another got there first; then everyone reads the one identity that won.
|
|
142
|
-
const id = randomBytes(8).toString("hex");
|
|
143
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
144
|
-
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
145
|
-
writeFileSync(tmp, JSON.stringify({ id, label: id }) + "\n", { mode: 0o600 });
|
|
146
|
-
try { linkSync(tmp, file); } catch { /* another process created it: its identity wins */ } finally { rmSync(tmp, { force: true }); }
|
|
147
|
-
// A file that is there but holds no valid id is never overwritten (another process may be reading it): the store
|
|
148
|
-
// fails to open, so the hub says hub memory is unavailable instead of filing notes under no machine.
|
|
149
|
-
const won = read();
|
|
150
|
-
if (!won) throw new Error(`${file} holds no valid machine id; remove it and a new one is made`);
|
|
151
|
-
return won;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
129
|
/** A person's name for this machine, shown beside its id. */
|
|
155
130
|
export function setMachineLabel(dir: string, label: string): Machine {
|
|
156
131
|
const m = machineId(dir);
|