@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.
@@ -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 { spawnSync, execFileSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from "node:child_process";
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
  }
@@ -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
- /** Project names from the outermost superproject down to this repo. */
7
- export function projectChain(cwd: string): string[] {
8
- const git = (dir: string, arg: string) => memoryGitSync(["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
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(cwd)];
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
- const url = memoryGitSync(["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8" }).stdout?.trim() ?? "";
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
- const common = memoryGitSync(["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" }).stdout?.trim() ?? "";
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 bare = (memoryGitSync(["--git-dir", realCommon, "rev-parse", "--is-bare-repository"], { encoding: "utf8" }).stdout?.trim() ?? "") === "true";
68
- let root = realCommon;
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 configured = memoryGitSync(["--git-dir", realCommon, "config", "--path", "core.worktree"], { encoding: "utf8" }).stdout?.trim() ?? "";
71
- const top = memoryGitSync(["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" }).stdout?.trim() ?? "";
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
- return `${projectChain(realRoot).join("/")}@${createHash("sha256").update(realRoot).digest("hex").slice(0, 8)}`;
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 pid = process.pid, signature = processSignature(pid);
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 { /* another actor replaced it: never remove its lease */ }
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 {
@@ -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 = ? OR scope = 'shared')" : "docs MATCH ?";
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 });
@@ -1,7 +1,8 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { appendFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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 const SHARD_SCHEMA = "agent-hub.memory.shard/v1";
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);