@staix/agent-mem 0.5.0 → 0.7.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/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +15 -0
- package/README.md +186 -21
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +492 -39
- package/src/commands-table.ts +56 -15
- 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 +566 -0
- package/src/hooks.ts +253 -0
- package/src/index.ts +18 -0
- package/src/ingest.ts +351 -0
- package/src/mcp-memory.ts +141 -33
- package/src/mcp.ts +42 -7
- package/src/memory/aliases.ts +87 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +144 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +162 -59
- package/src/memory/capture-input.ts +36 -0
- package/src/memory/capture.ts +153 -39
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +412 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +137 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +150 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/machine.ts +31 -0
- package/src/memory/metrics.ts +63 -0
- package/src/memory/mutation.ts +6 -4
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/platform.ts +9 -4
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +122 -0
- package/src/memory/project.ts +129 -20
- package/src/memory/reader-lease.ts +22 -10
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/shard.ts +3 -80
- package/src/memory/store.ts +286 -56
- package/src/memory/sync-cadence.ts +139 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +79 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +115 -38
- package/src/session-brief-worker.ts +82 -0
- package/src/session-brief.ts +261 -0
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +291 -0
- package/src/setup.ts +327 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { closeSync, constants, existsSync, lstatSync, openSync, rmSync, statSync, writeSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
4
|
+
import { walkShards } from "./catalog.ts";
|
|
5
|
+
import { openCuratedForRead, requireShard } from "./curated-read.ts";
|
|
6
|
+
import { projectState } from "./lifecycle.ts";
|
|
7
|
+
import { realPath } from "./platform.ts";
|
|
8
|
+
import { createStandalonePolicy, type MemoryPolicy } from "./policy.ts";
|
|
9
|
+
import { looksSecret } from "./screen.ts";
|
|
10
|
+
import { aliasedKeys, shardDir } from "./shard.ts";
|
|
11
|
+
import { WorkListStore } from "./work-lists.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* #15 T5 / D3: `amem project export <key> --to <file>`. One project's capture rows, curated notes, goals and work lists as one portable JSON bundle,
|
|
15
|
+
* written once to a new file with mode 0600. It changes no content file of the home: it opens the capture databases read-only and the curated store only to
|
|
16
|
+
* read, and only derived files (the curated `index.db`, `search.db`, SQLite sidecars) may refresh, as they do for any read. A destination inside the home is refused.
|
|
17
|
+
*
|
|
18
|
+
* The bundle leaves the home, so it is screened like sync and promotion are: a secret-looking value is withheld, and so is anything the host policy (when
|
|
19
|
+
* one is passed) declines. `withheld` counts the capture rows, notes and goals left out; a work list the policy declines is left out silently by its own store.
|
|
20
|
+
* A forgotten project cannot be exported (the person's decision stands); an archived one can. The write streams row by row, so a shard at its cap never has to
|
|
21
|
+
* fit in memory, and a failure removes the partial file it created.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const EXPORT_SCHEMA = "agent-mem.project-export/v1";
|
|
25
|
+
/**
|
|
26
|
+
* ponytail: a seam, not a lock. The destination is checked (for the message) and then claimed with O_EXCL (for the guarantee); a test sets `afterCheck` to create
|
|
27
|
+
* the file in the gap between them, which is the only way to show that O_EXCL, not the check, keeps an export from replacing someone else's file.
|
|
28
|
+
*/
|
|
29
|
+
export const exportHooks: { afterCheck?: (file: string) => void } = {};
|
|
30
|
+
export interface ExportCounts { capture: number; curated: number; goals: number; workLists: number }
|
|
31
|
+
export interface ExportResult { file: string; project: string; counts: ExportCounts; withheld: number }
|
|
32
|
+
const PAGE = 500;
|
|
33
|
+
|
|
34
|
+
interface CaptureRowRaw { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string }
|
|
35
|
+
|
|
36
|
+
function destination(base: string, to: string): string {
|
|
37
|
+
if (typeof to !== "string" || !to.trim()) throw new Error("export needs --to <file>");
|
|
38
|
+
const file = resolve(to), parent = dirname(file), name = basename(file);
|
|
39
|
+
if (!name || file === parent) throw new Error("--to names a file, not a directory root");
|
|
40
|
+
let parentReal: string;
|
|
41
|
+
try { if (!statSync(parent).isDirectory()) throw new Error(); parentReal = realPath(parent); }
|
|
42
|
+
catch { throw new Error("the destination directory does not exist"); }
|
|
43
|
+
let homeReal = base;
|
|
44
|
+
try { homeReal = realPath(base); } catch { /* a home that is not there yet holds nothing to collide with */ }
|
|
45
|
+
if (parentReal === homeReal || parentReal.startsWith(homeReal + sep)) throw new Error("the destination is inside the memory home; choose a folder outside it");
|
|
46
|
+
if (lstatSync(file, { throwIfNoEntry: false })) throw new Error("the destination exists; export never overwrites");
|
|
47
|
+
return join(parentReal, name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function exportProject(base: string, key: string, opts: { to: string; policy?: MemoryPolicy }): Promise<ExportResult> {
|
|
51
|
+
const project = requireShard(base, key);
|
|
52
|
+
if (projectState(base, project) === "forgotten") throw new Error(`project ${project} is forgotten: it cannot be exported`);
|
|
53
|
+
const file = destination(base, opts.to), policy = opts.policy ?? createStandalonePolicy();
|
|
54
|
+
exportHooks.afterCheck?.(file);
|
|
55
|
+
const allowed = async (value: unknown): Promise<boolean> => !looksSecret(JSON.stringify(value)) && await policy.screen(value);
|
|
56
|
+
const counts: ExportCounts = { capture: 0, curated: 0, goals: 0, workLists: 0 };
|
|
57
|
+
let withheld = 0;
|
|
58
|
+
let fd: number;
|
|
59
|
+
try { fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
60
|
+
catch { throw new Error("the destination exists; export never overwrites"); }
|
|
61
|
+
const out = (text: string) => { writeSync(fd, text); };
|
|
62
|
+
const stores: { close(): void }[] = [];
|
|
63
|
+
try {
|
|
64
|
+
out(`{"schema":${JSON.stringify(EXPORT_SCHEMA)},"project":${JSON.stringify(project)},"exportedAt":${JSON.stringify(new Date().toISOString())},"capture":[`);
|
|
65
|
+
let first = true;
|
|
66
|
+
const shard = walkShards(base).find(candidate => candidate.key === project);
|
|
67
|
+
for (const dir of shard?.machineDirs ?? []) {
|
|
68
|
+
const dbFile = join(dir, "capture.db");
|
|
69
|
+
if (!existsSync(dbFile)) continue;
|
|
70
|
+
const db = new Database(dbFile, { readonly: true });
|
|
71
|
+
try {
|
|
72
|
+
db.run("PRAGMA busy_timeout = 3000");
|
|
73
|
+
for (let after = 0; ;) {
|
|
74
|
+
const rows = db.query("SELECT id, at, project, agent, event, tool, paths, status, excerpt FROM rows WHERE id > ? ORDER BY id LIMIT ?").all(after, PAGE) as CaptureRowRaw[];
|
|
75
|
+
for (const r of rows) {
|
|
76
|
+
after = r.id;
|
|
77
|
+
const row = { at: r.at, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt };
|
|
78
|
+
if (!await allowed(row)) { withheld++; continue; }
|
|
79
|
+
out(`${first ? "" : ","}${JSON.stringify(row)}`); first = false; counts.capture++;
|
|
80
|
+
}
|
|
81
|
+
if (rows.length < PAGE) break;
|
|
82
|
+
}
|
|
83
|
+
} finally { db.close(); }
|
|
84
|
+
}
|
|
85
|
+
out(`],"curated":[`);
|
|
86
|
+
const store = openCuratedForRead(base, project);
|
|
87
|
+
if (store) stores.push(store);
|
|
88
|
+
// The project's own rows and those of a key aliased to it; another project's shared note is not this project's and stays out of its bundle.
|
|
89
|
+
const keys = new Set([project, ...aliasedKeys(base, project)]);
|
|
90
|
+
first = true;
|
|
91
|
+
for (const view of store?.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }) ?? []) {
|
|
92
|
+
if (!keys.has(view.project)) continue;
|
|
93
|
+
if (!await allowed(view)) { withheld++; continue; }
|
|
94
|
+
out(`${first ? "" : ","}${JSON.stringify(view)}`); first = false; counts.curated++;
|
|
95
|
+
}
|
|
96
|
+
out(`],"goals":[`);
|
|
97
|
+
first = true;
|
|
98
|
+
for (const goal of store?.goals(project) ?? []) {
|
|
99
|
+
if (!keys.has(goal.project)) continue;
|
|
100
|
+
if (!await allowed(goal)) { withheld++; continue; }
|
|
101
|
+
out(`${first ? "" : ","}${JSON.stringify(goal)}`); first = false; counts.goals++;
|
|
102
|
+
}
|
|
103
|
+
out(`],"workLists":[`);
|
|
104
|
+
first = true;
|
|
105
|
+
// The bundle holds what the project's own reads show: the canonical work log and that of every key aliased to it (the same reconciliation the CLI, MCP and brief use).
|
|
106
|
+
const workKeys = [project, ...aliasedKeys(base, project)];
|
|
107
|
+
if (workKeys.some(key => existsSync(join(shardDir(base, key), "work")))) {
|
|
108
|
+
const work = new WorkListStore({ base, project, policy: allowed, aliases: workKeys.slice(1) });
|
|
109
|
+
stores.push({ close: () => work.close() });
|
|
110
|
+
for (const list of await work.read(undefined, true)) { out(`${first ? "" : ","}${JSON.stringify(list)}`); first = false; counts.workLists++; }
|
|
111
|
+
}
|
|
112
|
+
out(`],"counts":${JSON.stringify(counts)},"withheld":${withheld}}\n`);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
115
|
+
rmSync(file, { force: true }); // the file is ours: O_EXCL created it, so a partial bundle is removed, never left behind
|
|
116
|
+
throw error;
|
|
117
|
+
} finally {
|
|
118
|
+
for (const store of stores) { try { store.close(); } catch { /* best effort */ } }
|
|
119
|
+
}
|
|
120
|
+
closeSync(fd);
|
|
121
|
+
return { file, project, counts, withheld };
|
|
122
|
+
}
|
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 {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { lstatSync, type Stats } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { isDenied } from "./platform.ts";
|
|
4
|
+
import { fold, type StandalonePolicy } from "./policy.ts";
|
|
5
|
+
import { nodePath } from "./project.ts";
|
|
6
|
+
import type { View } from "./store.ts";
|
|
7
|
+
|
|
8
|
+
export interface RecallStalenessOptions {
|
|
9
|
+
cwd: string; rootConfirmed?: boolean; deny?: readonly string[]; policy?: () => StandalonePolicy;
|
|
10
|
+
/** Host-only lexical glob/private-path checks run before any filesystem probe. */
|
|
11
|
+
allowedPath?: (path: string) => boolean;
|
|
12
|
+
maxProbes?: number;
|
|
13
|
+
probe?: (path: string) => Pick<Stats, "isSymbolicLink" | "isDirectory" | "mtimeMs">;
|
|
14
|
+
}
|
|
15
|
+
type PathState = { state: "present"; mtime: number } | { state: "missing" | "unknown" };
|
|
16
|
+
/** Call-scoped metadata hint only. Unknown/denied paths never become a marker or a filesystem query. */
|
|
17
|
+
export function createRecallStalenessProbe(options: RecallStalenessOptions): (view: Pick<View, "about" | "at">) => boolean | undefined {
|
|
18
|
+
const root = resolve(options.cwd), cache = new Map<string, PathState>(), requested = options.maxProbes ?? 32;
|
|
19
|
+
let remaining = Number.isSafeInteger(requested) && requested >= 0 ? Math.min(requested, 32) : 0;
|
|
20
|
+
const allowed = (path: string): boolean => {
|
|
21
|
+
try { return !isDenied(path, [...options.deny ?? []]) && !isDenied(fold(path), [...options.deny ?? []].map(fold)) && options.allowedPath?.(path) !== false && (!options.policy || options.policy().screenStandalone({ path })); }
|
|
22
|
+
catch { return false; }
|
|
23
|
+
};
|
|
24
|
+
const inspect = (path: string): PathState => {
|
|
25
|
+
if (!path || path.length > 4096 || isAbsolute(path) || /[\p{Cc}\p{Cf}\\]/u.test(path) || path.split("/").includes("..") || !allowed(path)) return { state: "unknown" };
|
|
26
|
+
const target = resolve(root, path), rel = relative(root, target); let current = root;
|
|
27
|
+
if (!rel || rel.startsWith("../") || isAbsolute(rel)) return { state: "unknown" };
|
|
28
|
+
const chainAllowed = () => { let at = root; for (const part of rel.split("/")) { at = join(at, part); if (!allowed(relative(root, at))) return false; } return true; };
|
|
29
|
+
if (!chainAllowed()) return { state: "unknown" };
|
|
30
|
+
if (cache.has(path)) return cache.get(path)!;
|
|
31
|
+
for (const component of rel.split("/")) {
|
|
32
|
+
current = join(current, component); const name = relative(root, current);
|
|
33
|
+
if (remaining < 1 || !allowed(name)) return { state: "unknown" };
|
|
34
|
+
remaining--;
|
|
35
|
+
try {
|
|
36
|
+
const stat = (options.probe ?? lstatSync)(current);
|
|
37
|
+
if (stat.isSymbolicLink() || current !== target && !stat.isDirectory()) return { state: "unknown" };
|
|
38
|
+
if (current === target) { const value: PathState = Number.isFinite(stat.mtimeMs) ? { state: "present", mtime: stat.mtimeMs } : { state: "unknown" }; if (!chainAllowed()) return { state: "unknown" }; cache.set(path, value); return value; }
|
|
39
|
+
} catch (error) { const value: PathState = { state: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unknown" }; if (!chainAllowed()) return { state: "unknown" }; cache.set(path, value); return value; }
|
|
40
|
+
}
|
|
41
|
+
return { state: "unknown" };
|
|
42
|
+
};
|
|
43
|
+
// ponytail: same-user ancestor swaps are outside the current metadata-only fence. Descriptor-relative traversal
|
|
44
|
+
// is required before treating concurrent hostile filesystem mutation as supported. No source/symbol index is stored.
|
|
45
|
+
return view => {
|
|
46
|
+
const at = Date.parse(view.at); if (!Number.isFinite(at)) return undefined;
|
|
47
|
+
let known = false;
|
|
48
|
+
for (const node of view.about) {
|
|
49
|
+
const path = nodePath(node); if (path === undefined) continue;
|
|
50
|
+
const state = inspect(path);
|
|
51
|
+
if (state.state === "missing" && options.rootConfirmed === true || state.state === "present" && state.mtime > at) return true;
|
|
52
|
+
known ||= state.state === "present";
|
|
53
|
+
}
|
|
54
|
+
return known ? false : undefined;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { MemoryStore, RecallStateOptions, RetainedRecallState, Graph, View } from "./store.ts";
|
|
2
|
+
|
|
3
|
+
export interface RecallOptions extends RecallStateOptions { query?: string; limit?: number }
|
|
4
|
+
/** Only public read semantics; mutation and exact-revision callers continue to use Store.get directly. */
|
|
5
|
+
export function recallViewIsLive(view: View, now: number): boolean {
|
|
6
|
+
return !view.supersededBy && Number.isFinite(Date.parse(view.at))
|
|
7
|
+
&& (view.expiresAt === null || Number.isFinite(Date.parse(view.expiresAt)) && Date.parse(view.expiresAt) > now);
|
|
8
|
+
}
|
|
9
|
+
const PROMOTION_NOTICE = "promotion time unknown: tier before promotion cannot be recovered";
|
|
10
|
+
/** Narrow promotion uncertainty to emitted rows while retaining independent source diagnostics. */
|
|
11
|
+
export function recallForViews(state: RetainedRecallState, views: View[]): RetainedRecallState {
|
|
12
|
+
const notices = state.notices.filter(notice => notice !== PROMOTION_NOTICE || views.some(view => view.promotedFrom));
|
|
13
|
+
return { ...state, views, notices, incomplete: state.sourceIncomplete === true || state.malformed > 0 || notices.length > 0 };
|
|
14
|
+
}
|
|
15
|
+
function personRecall(options: RecallStateOptions): void {
|
|
16
|
+
if ((options.history || options.asOf !== undefined) && options.person !== true) throw Error("historical recall requires authenticated person intent from the host");
|
|
17
|
+
}
|
|
18
|
+
export function recallRows(store: MemoryStore, project: string, options: RecallOptions = {}): RetainedRecallState {
|
|
19
|
+
personRecall(options);
|
|
20
|
+
const limit = options.limit ?? 5000;
|
|
21
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > Number.MAX_SAFE_INTEGER) throw Error("recall limit must be a positive safe integer");
|
|
22
|
+
const state: RetainedRecallState = options.history || options.asOf !== undefined ? store.recallState(project, options)
|
|
23
|
+
: store.withDeferredPromotions(() => ({ at: new Date(store.readNow()).toISOString(), views: store.list({ project, limit: Number.MAX_SAFE_INTEGER }).filter(view => recallViewIsLive(view, store.readNow())), forgotten: [], relations: [], malformed: 0, incomplete: false, notices: [] }));
|
|
24
|
+
const words = options.query?.toLowerCase().split(/\s+/).filter(Boolean) ?? [];
|
|
25
|
+
const rows = state.views.filter(view => words.every(word => `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}`.toLowerCase().includes(word)))
|
|
26
|
+
.sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id));
|
|
27
|
+
const selected = recallForViews(state, rows.slice(0, limit));
|
|
28
|
+
return { ...selected, incomplete: selected.incomplete || rows.length > limit };
|
|
29
|
+
}
|
|
30
|
+
export interface RecallShown { entry?: View; state?: RetainedRecallState; ids: string[] }
|
|
31
|
+
export function recallShow(store: MemoryStore, project: string, rawId: string, options: RecallStateOptions = {}): RecallShown {
|
|
32
|
+
personRecall(options);
|
|
33
|
+
const id = rawId.replace(/^mem:/, "");
|
|
34
|
+
if (!/^[a-f0-9]{6,32}$/.test(id)) return { ids: [] };
|
|
35
|
+
if (!options.history && options.asOf === undefined) {
|
|
36
|
+
const view = store.withDeferredPromotions(() => store.get(id, project));
|
|
37
|
+
return view && recallViewIsLive(view, store.readNow()) ? { entry: view, ids: [view.id] } : { ids: [] };
|
|
38
|
+
}
|
|
39
|
+
const state = store.recallState(project, options), known = [...new Set([...state.views.map(view => view.id), ...state.forgotten.map(row => row.id)])];
|
|
40
|
+
const matches = known.includes(id) ? [id] : known.filter(value => value.startsWith(id));
|
|
41
|
+
if (matches.length !== 1) return { ids: [] };
|
|
42
|
+
const selected = new Set(matches), queue = [...matches], knownIds = new Set(known);
|
|
43
|
+
if (options.history) for (let index = 0; index < queue.length && queue.length <= 64; index++) {
|
|
44
|
+
for (const edge of state.relations) for (const next of edge.from === queue[index] ? [edge.to] : edge.to === queue[index] ? [edge.from] : []) {
|
|
45
|
+
if (knownIds.has(next) && !selected.has(next)) { selected.add(next); queue.push(next); }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (queue.length > 64) throw Error("recall history chain exceeds 64 retained entries");
|
|
49
|
+
const views = state.views.filter(view => selected.has(view.id));
|
|
50
|
+
return { entry: views.find(view => view.id === matches[0]), ids: [...selected], state: { ...recallForViews(state, views), forgotten: state.forgotten.filter(row => selected.has(row.id)), relations: state.relations.filter(edge => selected.has(edge.from) && selected.has(edge.to)) } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Public graph over live recall rows; retained history requires host-authenticated person intent. */
|
|
54
|
+
export function recallGraph(store: MemoryStore, project: string, start: string, hops = 1, options: RecallOptions = {}, readable: (rows: View[]) => View[] = rows => rows): Graph {
|
|
55
|
+
personRecall(options);
|
|
56
|
+
const rows = readable(recallRows(store, project, { ...options, limit: Number.MAX_SAFE_INTEGER }).views);
|
|
57
|
+
let node = start;
|
|
58
|
+
if (!start.includes(":") || start.startsWith("mem:")) {
|
|
59
|
+
const id = start.replace(/^mem:/, ""), exact = rows.find(view => view.id === id), matches = exact ? [exact] : id.length >= 6 ? rows.filter(view => view.id.startsWith(id)) : [];
|
|
60
|
+
if (matches.length !== 1) return { nodes: [], edges: [] };
|
|
61
|
+
node = `mem:${matches[0]!.id}`;
|
|
62
|
+
}
|
|
63
|
+
return store.withDeferredPromotions(() => store.graph(project, node, hops, 200, rows));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One prepared snapshot; its revision guard rejects changes after asynchronous host policy work. */
|
|
67
|
+
export function createIntentBriefSource(store: MemoryStore, project: string): { notes(): View[]; assertCurrent(): void; truncated(): boolean } {
|
|
68
|
+
let snapshot: ReturnType<MemoryStore["intentSnapshot"]> | undefined;
|
|
69
|
+
return {
|
|
70
|
+
notes: () => { snapshot ??= store.intentSnapshot(project); return snapshot.rows; },
|
|
71
|
+
assertCurrent: () => { if (!snapshot) throw Error("intent snapshot has not been prepared"); snapshot.assertCurrent(); },
|
|
72
|
+
truncated: () => snapshot?.truncated ?? false,
|
|
73
|
+
};
|
|
74
|
+
}
|