@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
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/search-fts.ts
CHANGED
|
@@ -165,15 +165,25 @@ export class SearchIndex {
|
|
|
165
165
|
* Best BM25 first (rank is negative: smaller wins). `project` filters inside the MATCH (its own rows plus shared
|
|
166
166
|
* ones), so a busy sibling project can never starve a project's LIMIT. Visibility stays with the caller.
|
|
167
167
|
*/
|
|
168
|
-
query(q: string, limit: number, project?: string, offset = 0): RankedRef[] {
|
|
168
|
+
query(q: string, limit: number, project?: string | readonly string[], offset = 0): RankedRef[] {
|
|
169
169
|
if (!this.db) return [];
|
|
170
170
|
const match = ftsTerms(q);
|
|
171
171
|
if (!match) return [];
|
|
172
|
-
const where = project ? "docs MATCH ? AND (project
|
|
173
|
-
const bind = project ? [match, project] : [match];
|
|
172
|
+
const where = project ? "docs MATCH ? AND (project IN (SELECT value FROM json_each(?)) OR scope = 'shared')" : "docs MATCH ?";
|
|
173
|
+
const bind = project ? [match, JSON.stringify(typeof project === "string" ? [project] : project)] : [match];
|
|
174
174
|
return this.db.query(`SELECT ref, project, tier, at, bm25(docs) AS rank FROM docs WHERE ${where} ORDER BY rank, ref LIMIT ? OFFSET ?`).all(...bind, limit, offset) as RankedRef[];
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/** Matching identities in chronological keyset pages; raw-corpus BM25 never selects a private-policy window. */
|
|
178
|
+
candidates(q: string, window: ReadWindow): ReadRef[] {
|
|
179
|
+
if (!this.db) return [];
|
|
180
|
+
const match = ftsTerms(q); if (!match) return [];
|
|
181
|
+
const { columns, where, bind } = readWindowSql(window, "ref");
|
|
182
|
+
where.push("docs MATCH ?"); bind.push(match);
|
|
183
|
+
return (this.db.query(`SELECT ${columns} FROM docs WHERE ${where.join(" AND ")} ORDER BY candidate_position, candidate_id LIMIT ?`)
|
|
184
|
+
.all(...bind, window.limit) as { candidate_id: string; at: string; candidate_position: number }[]).map(readRef);
|
|
185
|
+
}
|
|
186
|
+
|
|
177
187
|
close(): void { this.db?.close(); }
|
|
178
188
|
}
|
|
179
189
|
|
|
@@ -201,3 +211,34 @@ export function captureSearch(dir: string, q: string, limit: number, notBefore?:
|
|
|
201
211
|
} catch { return []; }
|
|
202
212
|
finally { db?.close(); }
|
|
203
213
|
}
|
|
214
|
+
|
|
215
|
+
/** Internal keyset pages: bounds apply to row bodies, never to the total corpus. */
|
|
216
|
+
export interface ReadWindow {
|
|
217
|
+
project: string;
|
|
218
|
+
/** #27: every key the page covers; `project` alone when absent. */
|
|
219
|
+
projects?: readonly string[];
|
|
220
|
+
limit: number;
|
|
221
|
+
since?: string;
|
|
222
|
+
anchor?: string;
|
|
223
|
+
prefix?: string;
|
|
224
|
+
id?: string;
|
|
225
|
+
after?: { position: number; id: string };
|
|
226
|
+
}
|
|
227
|
+
export interface ReadRef { id: string; at: string; position: number }
|
|
228
|
+
/** Fixed column names supplied by the three derived stores; all caller values remain SQL parameters. */
|
|
229
|
+
export function readWindowSql(window: ReadWindow, id: "id" | "ref") {
|
|
230
|
+
if (!Number.isSafeInteger(window.limit) || window.limit < 1 || window.limit > 256) throw new Error("invalid read page limit");
|
|
231
|
+
const bind: (string | number)[] = [];
|
|
232
|
+
const position = window.anchor ? (bind.push(window.anchor), "abs(julianday(at) - julianday(?))") : "-julianday(at)";
|
|
233
|
+
const columns = `CAST(${id} AS TEXT) AS candidate_id, at, ${position} AS candidate_position`;
|
|
234
|
+
const where = ["project IN (SELECT value FROM json_each(?))"]; bind.push(JSON.stringify(window.projects ?? [window.project]));
|
|
235
|
+
if (window.since) { where.push("julianday(at) >= julianday(?)"); bind.push(window.since); }
|
|
236
|
+
if (window.id !== undefined) { where.push(`${id} = ?`); bind.push(window.id); }
|
|
237
|
+
if (window.prefix !== undefined) { where.push(`substr(${id}, 1, length(?)) = ?`); bind.push(window.prefix, window.prefix); }
|
|
238
|
+
if (window.after) {
|
|
239
|
+
where.push("(candidate_position > ? OR (candidate_position = ? AND candidate_id > ?))");
|
|
240
|
+
bind.push(window.after.position, window.after.position, window.after.id);
|
|
241
|
+
}
|
|
242
|
+
return { columns, where, bind };
|
|
243
|
+
}
|
|
244
|
+
export const readRef = (row: { candidate_id: string; at: string; candidate_position: number }): ReadRef => ({ id: row.candidate_id, at: row.at, position: row.candidate_position });
|
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,50 +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
|
-
export function aliasLogPath(base: string): string {
|
|
79
|
-
return join(base, "aliases.jsonl");
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* One person-reviewed alias op, appended to `aliases.jsonl`. An alias never chains (its target must not itself be
|
|
84
|
-
* an alias), and a machine that races the append keeps both ops: the latest one wins at read time.
|
|
85
|
-
*/
|
|
86
|
-
export function appendAlias(base: string, input: { alias: string; key: string; actor: string; machine: string; at?: string }): AliasOp {
|
|
87
|
-
const alias = input.alias.trim(), key = input.key.trim();
|
|
88
|
-
if (!alias || !key) throw new Error("an alias needs both keys");
|
|
89
|
-
if (alias === key) throw new Error("an alias cannot target itself");
|
|
90
|
-
if (resolveAlias(base, key) !== key) throw new Error("an alias cannot target another alias");
|
|
91
|
-
// The other direction too, using only the latest op per alias: a target abandoned by a later retarget is free again.
|
|
92
|
-
const currentTargets = new Set([...new Map(listAliases(base).map((op) => [op.alias, op.key])).values()]);
|
|
93
|
-
if (currentTargets.has(alias)) throw new Error("an alias target cannot become an alias");
|
|
94
|
-
const op: AliasOp = { schema: SHARD_SCHEMA, op: "alias", at: input.at ?? new Date().toISOString(), machine: input.machine, alias, key, actor: input.actor };
|
|
95
|
-
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
96
|
-
appendFileSync(aliasLogPath(base), JSON.stringify(op) + "\n", { mode: 0o600 });
|
|
97
|
-
return op;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Every alias op in order; a malformed line is skipped, never fatal. */
|
|
101
|
-
export function listAliases(base: string): AliasOp[] {
|
|
102
|
-
let text = "";
|
|
103
|
-
try { text = readFileSync(aliasLogPath(base), "utf8"); } catch { return []; }
|
|
104
|
-
const ops: AliasOp[] = [];
|
|
105
|
-
for (const line of text.split("\n").filter(Boolean)) {
|
|
106
|
-
try {
|
|
107
|
-
const op = JSON.parse(line) as AliasOp;
|
|
108
|
-
if (op?.schema === SHARD_SCHEMA && op.op === "alias" && typeof op.alias === "string" && typeof op.key === "string") ops.push(op);
|
|
109
|
-
} catch { /* a malformed line is skipped */ }
|
|
110
|
-
}
|
|
111
|
-
return ops;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** The canonical key for a possibly aliased one: the latest op for it, one hop (aliases never chain). */
|
|
115
|
-
export function resolveAlias(base: string, key: string): string {
|
|
116
|
-
const ops = listAliases(base).filter((op) => op.alias === key);
|
|
117
|
-
return ops.length ? ops[ops.length - 1]!.key : key;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
77
|
/** `memoryBase(home)` plus the shard of `key` after alias resolution. */
|
|
121
78
|
export function captureShardDir(base: string, key: string, machine: string): string {
|
|
122
79
|
return shardMachineDir(base, resolveAlias(base, key), machine);
|