@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.
Files changed (76) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +15 -0
  3. package/README.md +186 -21
  4. package/bin/amem.js +26 -3
  5. package/hooks/hooks.json +81 -0
  6. package/package.json +3 -1
  7. package/src/cli.ts +492 -39
  8. package/src/commands-table.ts +56 -15
  9. package/src/hook-contract.ts +3 -0
  10. package/src/hook-git.ts +42 -0
  11. package/src/hook-native.ts +92 -0
  12. package/src/hook-project.ts +206 -0
  13. package/src/hook-resources.ts +15 -0
  14. package/src/hook-spool.ts +566 -0
  15. package/src/hooks.ts +253 -0
  16. package/src/index.ts +18 -0
  17. package/src/ingest.ts +351 -0
  18. package/src/mcp-memory.ts +141 -33
  19. package/src/mcp.ts +42 -7
  20. package/src/memory/aliases.ts +87 -0
  21. package/src/memory/bridge.ts +8 -2
  22. package/src/memory/candidate-pending.ts +119 -0
  23. package/src/memory/candidates.ts +144 -47
  24. package/src/memory/capacity-split.ts +76 -0
  25. package/src/memory/capacity.ts +162 -59
  26. package/src/memory/capture-input.ts +36 -0
  27. package/src/memory/capture.ts +153 -39
  28. package/src/memory/catalog.ts +43 -10
  29. package/src/memory/commands.ts +98 -44
  30. package/src/memory/config.ts +167 -0
  31. package/src/memory/cross-project.ts +75 -0
  32. package/src/memory/curated-read.ts +29 -0
  33. package/src/memory/decay-review.ts +136 -0
  34. package/src/memory/doctor.ts +412 -0
  35. package/src/memory/editor.ts +58 -0
  36. package/src/memory/eval-fixture.ts +137 -0
  37. package/src/memory/eval-set.json +443 -0
  38. package/src/memory/eval.ts +150 -0
  39. package/src/memory/gc.ts +97 -0
  40. package/src/memory/generation-index.ts +22 -17
  41. package/src/memory/generation-sync.ts +3 -0
  42. package/src/memory/goal-promotion.ts +71 -0
  43. package/src/memory/home.ts +15 -4
  44. package/src/memory/intent-brief.ts +89 -0
  45. package/src/memory/lifecycle-log.ts +54 -0
  46. package/src/memory/lifecycle.ts +61 -0
  47. package/src/memory/machine.ts +31 -0
  48. package/src/memory/metrics.ts +63 -0
  49. package/src/memory/mutation.ts +6 -4
  50. package/src/memory/note-add.ts +100 -0
  51. package/src/memory/path-glob.ts +94 -0
  52. package/src/memory/plan-guard.ts +47 -0
  53. package/src/memory/platform.ts +9 -4
  54. package/src/memory/policy.ts +44 -13
  55. package/src/memory/private-spans.ts +32 -0
  56. package/src/memory/project-export.ts +122 -0
  57. package/src/memory/project.ts +129 -20
  58. package/src/memory/reader-lease.ts +22 -10
  59. package/src/memory/recall-staleness.ts +56 -0
  60. package/src/memory/recall.ts +74 -0
  61. package/src/memory/repo-scope.ts +404 -0
  62. package/src/memory/shard-sync-format.ts +116 -0
  63. package/src/memory/shard-sync.ts +421 -0
  64. package/src/memory/shard.ts +3 -80
  65. package/src/memory/store.ts +286 -56
  66. package/src/memory/sync-cadence.ts +139 -0
  67. package/src/memory/sync-screen.ts +86 -0
  68. package/src/memory/sync.ts +79 -11
  69. package/src/memory/usage.ts +35 -0
  70. package/src/memory/use-telemetry.ts +51 -0
  71. package/src/memory/work-lists.ts +115 -38
  72. package/src/session-brief-worker.ts +82 -0
  73. package/src/session-brief.ts +261 -0
  74. package/src/setup-owned.ts +120 -0
  75. package/src/setup-toml.ts +291 -0
  76. package/src/setup.ts +327 -0
@@ -0,0 +1,404 @@
1
+ /**
2
+ * #15 T4 / D6: the `repo` scope. Team-shareable knowledge lives in the repository itself, `.amem/log.jsonl`, and travels
3
+ * with git. amem never runs `git add`, `commit` or `push`: the person commits the file through normal review.
4
+ *
5
+ * Trust: the file is ordinary repository content, so a pull request can put any line in it. Every line is strictly
6
+ * validated, its id is recomputed, and every row passes the standalone policy before it reaches a recall, a brief or a
7
+ * search. Writing is a person's act (the CLI refuses agents and non-terminals before it gets here) and goes through
8
+ * the full screen plus a visibility check of every remote.
9
+ *
10
+ * Own schema: `amem.repo/v1`. The `agent-hub.memory/v1` hash domain, scopes and kinds stay frozen; a repo line carries
11
+ * no machine, peer, task or project field, so nothing about this machine or its private projects is committed.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, writeSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ import { realPath, memoryGitEnv, memoryGitSync } from "./platform.ts";
18
+ import { createStandalonePolicy, type MemoryPolicy, type StandalonePolicyOptions } from "./policy.ts";
19
+ import { memoryProjectKey } from "./project.ts";
20
+ import { mentionsPrivatePath, policyEntries } from "./path-glob.ts";
21
+ import { resolveAlias } from "./shard.ts";
22
+ import type { Visibility } from "./sync.ts";
23
+ import type { View } from "./store.ts";
24
+
25
+ export const REPO_SCHEMA = "amem.repo/v1";
26
+ export const REPO_DIR = ".amem";
27
+ export const REPO_LOG = "log.jsonl";
28
+ /** The kinds the spec names for the repo scope: contracts, decisions and `fail` lessons. A finding stays private. */
29
+ export const REPO_KINDS = ["decision", "contract", "fail"] as const;
30
+ export type RepoKind = (typeof REPO_KINDS)[number];
31
+ export const REPO_ATTRIBUTES_LINE = `${REPO_DIR}/${REPO_LOG} merge=union`;
32
+
33
+ const MAX_LINE_BYTES = 16 * 1024, MAX_FILE_BYTES = 1024 * 1024, MAX_TEXT = 4000, MAX_TITLE = 200, MAX_ABOUT = 64, MAX_NODE = 300;
34
+ const MAX_CONFIG_BYTES = 1024 * 1024;
35
+ /** C0 (except tab and newline), DEL and C1 controls. */
36
+ const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/;
37
+
38
+ export interface RepoView {
39
+ id: string; at: string; kind: RepoKind; title: string | null; text: string; about: string[];
40
+ scope: "repo"; source: "repo";
41
+ }
42
+ /** No `promotedFrom`: the private entry's id (a hash of its machine and project too) never enters a file that may be public. The line's own id makes a repeat promotion a no-op. */
43
+ export interface RepoLine {
44
+ schema: typeof REPO_SCHEMA; op: "note"; id: string; at: string; kind: RepoKind; title?: string; text: string; about: string[]; actor: "person";
45
+ }
46
+ export interface RepoSkipped { malformed: number; mismatched: number; duplicate: number; withheld: number }
47
+ export interface RepoRead {
48
+ /** `absent`: no repository or no file. `unsafe`: the file or its directory is not a plain one (a symlink, a device, a directory). */
49
+ state: "absent" | "ok" | "unsafe";
50
+ root?: string; views: RepoView[]; skipped: RepoSkipped; partial: boolean; reason?: string;
51
+ }
52
+
53
+ function canonical(value: unknown): string {
54
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
55
+ if (value && typeof value === "object") return `{${Object.keys(value).sort().filter(k => (value as Record<string, unknown>)[k] !== undefined).map(k => `${JSON.stringify(k)}:${canonical((value as Record<string, unknown>)[k])}`).join(",")}}`;
56
+ return JSON.stringify(value);
57
+ }
58
+ /** The same derivation shape as `entryId` (first 32 hex of sha256 over canonical JSON), over this scope's own schema. */
59
+ export const repoEntryId = (line: Omit<RepoLine, "id">): string => createHash("sha256").update(canonical(line)).digest("hex").slice(0, 32);
60
+
61
+ /** The working-tree root of the repository that holds `cwd`, from git itself and never from a name or an entry. Bare or not a repository: undefined. */
62
+ export function repoRoot(cwd: string): string | undefined {
63
+ try {
64
+ const result = memoryGitSync(["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", env: memoryGitEnv(process.env, { repositoryIsolation: true }) });
65
+ const top = result.status === 0 && typeof result.stdout === "string" ? result.stdout.trim() : "";
66
+ if (!top || /[\r\n\0]/.test(top)) return undefined;
67
+ return realPath(top);
68
+ } catch { return undefined; }
69
+ }
70
+
71
+ /**
72
+ * The repository root whose `.amem/` this project may recall: only when the repository's own project key resolves to the
73
+ * bound project (AC3). A server bound to project B started inside repository A reads nothing of A's.
74
+ */
75
+ export function boundRepoRoot(cwd: string, base: string, project: string): string | undefined {
76
+ const root = repoRoot(cwd);
77
+ if (!root) return undefined;
78
+ try { return resolveAlias(base, memoryProjectKey(root, { repositoryIsolation: true })) === project ? root : undefined; } catch { return undefined; }
79
+ }
80
+
81
+ export const repoLogPath = (root: string): string => join(root, REPO_DIR, REPO_LOG);
82
+
83
+ type Plain = { kind: "absent" } | { kind: "unsafe"; why: string } | { kind: "file"; size: number };
84
+ /** lstat, never stat: a symlinked `.amem` or `log.jsonl` is refused, not followed out of the repository. */
85
+ function plainLog(root: string): Plain {
86
+ try {
87
+ const dir = lstatSync(join(root, REPO_DIR), { throwIfNoEntry: false });
88
+ if (!dir) return { kind: "absent" };
89
+ if (!dir.isDirectory()) return { kind: "unsafe", why: `${REPO_DIR} is not a plain directory` };
90
+ if (realPath(join(root, REPO_DIR)) !== join(root, REPO_DIR)) return { kind: "unsafe", why: `${REPO_DIR} resolves outside the repository root` };
91
+ const file = lstatSync(repoLogPath(root), { throwIfNoEntry: false });
92
+ if (!file) return { kind: "absent" };
93
+ if (!file.isFile()) return { kind: "unsafe", why: `${REPO_DIR}/${REPO_LOG} is not a regular file` };
94
+ return { kind: "file", size: file.size };
95
+ } catch { return { kind: "unsafe", why: `${REPO_DIR} could not be inspected` }; }
96
+ }
97
+
98
+ /** Strict shape and identity check of one line; undefined for anything that is not exactly a repo entry with the id its content hashes to. */
99
+ export function parseRepoLine(raw: string): { view: RepoView } | { error: "malformed" | "mismatched" } {
100
+ if (Buffer.byteLength(raw) > MAX_LINE_BYTES) return { error: "malformed" };
101
+ let value: unknown;
102
+ try { value = JSON.parse(raw); } catch { return { error: "malformed" }; }
103
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { error: "malformed" };
104
+ const e = value as Record<string, unknown>;
105
+ const allowed = new Set(["schema", "op", "id", "at", "kind", "title", "text", "about", "actor"]);
106
+ if (Object.keys(e).some(key => !allowed.has(key))) return { error: "malformed" };
107
+ // C0 and C1 controls other than tab and newline never belong in a shared line: a pull request could carry a terminal escape (ESC, CSI U+009B) that the CLI would print.
108
+ const str = (v: unknown, max: number): v is string => typeof v === "string" && v.length > 0 && Buffer.byteLength(v) <= max && !CONTROL.test(v);
109
+ if (e.schema !== REPO_SCHEMA || e.op !== "note" || e.actor !== "person") return { error: "malformed" };
110
+ if (typeof e.id !== "string" || !/^[a-f0-9]{32}$/.test(e.id)) return { error: "malformed" };
111
+ if (typeof e.at !== "string" || Number.isNaN(Date.parse(e.at)) || new Date(e.at).toISOString() !== e.at) return { error: "malformed" };
112
+ if (typeof e.kind !== "string" || !REPO_KINDS.includes(e.kind as RepoKind)) return { error: "malformed" };
113
+ if (e.title !== undefined && !str(e.title, MAX_TITLE)) return { error: "malformed" };
114
+ if (!str(e.text, MAX_TEXT)) return { error: "malformed" };
115
+ if (!Array.isArray(e.about) || e.about.length > MAX_ABOUT || !e.about.every(n => str(n, MAX_NODE))) return { error: "malformed" };
116
+ const { id, ...rest } = e as unknown as RepoLine;
117
+ if (repoEntryId(rest) !== id) return { error: "mismatched" };
118
+ return { view: { id, at: e.at, kind: e.kind as RepoKind, title: (e.title as string | undefined) ?? null, text: e.text, about: [...(e.about as string[])], scope: "repo", source: "repo" } };
119
+ }
120
+
121
+ function readLog(root: string): { lines: string[]; partial: boolean } | { unsafe: string } {
122
+ const plain = plainLog(root);
123
+ if (plain.kind === "absent") return { lines: [], partial: false };
124
+ if (plain.kind === "unsafe") return { unsafe: plain.why };
125
+ let fd: number | undefined;
126
+ try {
127
+ fd = openSync(repoLogPath(root), constants.O_RDONLY | constants.O_NOFOLLOW);
128
+ const stat = fstatSync(fd);
129
+ if (!stat.isFile()) return { unsafe: `${REPO_DIR}/${REPO_LOG} is not a regular file` };
130
+ // The TAIL window: the log is append-only and read newest first, so a file over the cap keeps its last MAX_FILE_BYTES (the newest lines), never its head.
131
+ const truncated = stat.size > MAX_FILE_BYTES, length = Math.min(stat.size, MAX_FILE_BYTES), start = stat.size - length, buffer = Buffer.alloc(length);
132
+ let filled = 0;
133
+ while (filled < length) { const n = readSync(fd, buffer, filled, length - filled, start + filled); if (n === 0) break; filled += n; }
134
+ let text = buffer.subarray(0, filled).toString("utf8");
135
+ // The window starts mid-line: drop that first piece, whole lines only, and say the read was partial.
136
+ if (truncated) text = text.slice(text.indexOf("\n") + 1);
137
+ return { lines: text.split("\n").filter(line => line.trim() !== ""), partial: truncated };
138
+ } catch { return { unsafe: `${REPO_DIR}/${REPO_LOG} could not be read` }; }
139
+ finally { if (fd !== undefined) closeSync(fd); }
140
+ }
141
+
142
+ export interface RepoReadOptions {
143
+ /** Stop once this many screened rows are collected: the brief shows a handful, so it never parses the whole file. */
144
+ limit?: number;
145
+ /** Milliseconds left before the caller's hard deadline (the hook): checked before every line, because the screen's awaits are microtasks and no timer can fire in between. */
146
+ remainingMs?: () => number;
147
+ /** Time held back from `remainingMs` for the caller's own work after the read. */
148
+ reserveMs?: number;
149
+ }
150
+ /**
151
+ * The valid, screened rows of the repository's `.amem/log.jsonl`, NEWEST FIRST BY FILE POSITION (append order: a line's own `at` is the author's claim and never orders
152
+ * anything, so a line dated in the future cannot pin a brief; its displayed `at` is clamped to now). Duplicates by id collapse to the newest copy (`merge=union` can repeat
153
+ * a line). The policy sees each complete row, as it does a curated one: what it declines is counted, not shown. With a `limit` or a deadline the read stops early and reports `partial`.
154
+ */
155
+ export async function readRepoScope(root: string | undefined, policy: MemoryPolicy, options: RepoReadOptions = {}): Promise<RepoRead> {
156
+ const skipped: RepoSkipped = { malformed: 0, mismatched: 0, duplicate: 0, withheld: 0 };
157
+ if (!root) return { state: "absent", views: [], skipped, partial: false };
158
+ const read = readLog(root);
159
+ if ("unsafe" in read) return { state: "unsafe", root, views: [], skipped, partial: true, reason: read.unsafe };
160
+ const views: RepoView[] = [], seen = new Set<string>(), now = Date.now();
161
+ let stoppedEarly = false;
162
+ for (let index = read.lines.length - 1; index >= 0; index--) {
163
+ if (options.limit !== undefined && views.length >= options.limit) { stoppedEarly = true; break; }
164
+ if (options.remainingMs && options.remainingMs() - (options.reserveMs ?? 0) <= 0) { stoppedEarly = true; break; }
165
+ const parsed = parseRepoLine(read.lines[index]!);
166
+ if ("error" in parsed) { skipped[parsed.error]++; continue; }
167
+ if (seen.has(parsed.view.id)) { skipped.duplicate++; continue; }
168
+ seen.add(parsed.view.id);
169
+ if (namesPrivatePath(`${parsed.view.title ?? ""}\n${parsed.view.text}\n${parsed.view.about.join("\n")}`) || !await policy.screen(parsed.view)) { skipped.withheld++; continue; }
170
+ views.push(Date.parse(parsed.view.at) > now ? { ...parsed.view, at: new Date(now).toISOString() } : parsed.view);
171
+ }
172
+ return { state: read.lines.length || plainLog(root).kind === "file" ? "ok" : "absent", root, views, skipped, partial: read.partial || stoppedEarly || skipped.malformed + skipped.mismatched > 0 };
173
+ }
174
+
175
+ /**
176
+ * The repository root that holds `cwd` AND a `.amem/log.jsonl`, found by `lstat` alone: the nearest ancestor with a `.git` (directory or file, so worktrees and submodules count) must
177
+ * be the directory that holds the log. For a caller whose project key was itself resolved from this `cwd` (the SessionStart hook) the "this repository is the bound project" check of
178
+ * `boundRepoRoot` holds by construction, so no git process is spawned on the hook's deadline (two synchronous git processes would otherwise run inside the hook's 140 ms).
179
+ * ponytail: lexical, trusts the `.git` entry on disk; a caller with a project it did not derive from `cwd` must use `boundRepoRoot`.
180
+ */
181
+ export function nearestRepoRootWithLog(cwd: string): string | undefined {
182
+ try {
183
+ for (let dir = cwd, depth = 0; depth < 32; dir = dirname(dir), depth++) {
184
+ if (lstatSync(join(dir, ".git"), { throwIfNoEntry: false })) return lstatSync(join(dir, REPO_DIR, REPO_LOG), { throwIfNoEntry: false }) ? realPath(dir) : undefined;
185
+ if (dir === dirname(dir)) return undefined;
186
+ }
187
+ } catch { /* unreadable: no section */ }
188
+ return undefined;
189
+ }
190
+
191
+ /** Whether a repo log exists at all, by lstat only: the brief reserves header room for a section only when there is one. */
192
+ export function repoLogPresent(root: string | undefined): boolean { return !!root && plainLog(root).kind !== "absent"; }
193
+
194
+ /**
195
+ * Whether `.amem/log.jsonl` exists at or above `cwd`, by `lstat` alone (no git, no read). Every brief asks this first, so a repository without a repo scope (almost all
196
+ * of them) costs the hook a few syscalls and never a git process. A symlinked `.amem` directory still counts (its log resolves), so the reader can report it unsafe.
197
+ */
198
+ export function repoLogNearby(cwd: string): boolean {
199
+ try {
200
+ for (let dir = cwd, depth = 0; depth < 32; dir = dirname(dir), depth++) {
201
+ if (lstatSync(join(dir, REPO_DIR, REPO_LOG), { throwIfNoEntry: false })) return true;
202
+ if (dir === dirname(dir)) return false;
203
+ }
204
+ } catch { /* unreadable: no section */ }
205
+ return false;
206
+ }
207
+
208
+ /** `git check-ignore`: true when git would ignore the log, so the person is told before they wait for a file that never shows in `git status`. A tracked file is never reported (git shows its changes whatever a pattern says). */
209
+ export function repoLogIgnored(root: string): boolean {
210
+ try {
211
+ const result = memoryGitSync(["-C", root, "check-ignore", "-q", `${REPO_DIR}/${REPO_LOG}`], { encoding: "utf8", env: memoryGitEnv(process.env, { repositoryIsolation: true }) });
212
+ return result.status === 0;
213
+ } catch { return false; }
214
+ }
215
+
216
+ /**
217
+ * `local.deny` and `pii.paths` of the repository's own agent-hub config, read as data (no agent-hub import). Anything that exists but is unreadable or
218
+ * malformed refuses. `local.deny` entries are substrings (the hub's `isDenied` extra list); `pii.paths` entries are the hub's bounded globs and
219
+ * subtrees (`policyEntries`, a port of `policyPaths`), matched by `src/memory/path-glob.ts`, never as substrings.
220
+ */
221
+ export function repoPolicyOptions(root: string): { denyPaths: string[]; piiPaths: string[] } {
222
+ const denyPaths: string[] = [], piiPaths: string[] = [];
223
+ for (const name of ["config.json", "config.local.json"]) {
224
+ const file = join(root, ".agenthub", name);
225
+ const stat = lstatSync(file, { throwIfNoEntry: false });
226
+ if (!stat) continue;
227
+ const fix = "; fix it before using the repo scope";
228
+ if (!stat.isFile() || stat.size > MAX_CONFIG_BYTES) throw new Error(`.agenthub/${name} is not a plain file within ${MAX_CONFIG_BYTES} bytes${fix}`);
229
+ let config: unknown;
230
+ try { config = JSON.parse(readFileSync(file, "utf8")); } catch { throw new Error(`.agenthub/${name} does not parse${fix}`); }
231
+ if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error(`.agenthub/${name} is not a JSON object${fix}`);
232
+ const body = config as { local?: { deny?: unknown }; pii?: { paths?: unknown } };
233
+ if (body.local !== undefined && (typeof body.local !== "object" || body.local === null || Array.isArray(body.local))) throw new Error(`.agenthub/${name}: local is not an object${fix}`);
234
+ if (body.pii !== undefined && (typeof body.pii !== "object" || body.pii === null || Array.isArray(body.pii))) throw new Error(`.agenthub/${name}: pii is not an object${fix}`);
235
+ const deny = body.local?.deny;
236
+ if (deny !== undefined) {
237
+ // The standalone policy takes at most 64 literals of 256 bytes: say so here, so a long list refuses by name instead of as a generic "invalid deny paths".
238
+ if (!Array.isArray(deny) || !deny.every(item => typeof item === "string" && item.trim() !== "" && Buffer.byteLength(item) <= 256 && !/[\x00-\x1f\x7f]/.test(item))) throw new Error(`.agenthub/${name}: local.deny is not a list of non-empty strings of at most 256 bytes${fix}`);
239
+ denyPaths.push(...deny as string[]);
240
+ }
241
+ try { piiPaths.push(...policyEntries(body.pii?.paths, `.agenthub/${name}: pii.paths`)); } catch (error) { throw new Error(`${(error as Error).message}${fix}`); }
242
+ }
243
+ const unique = (list: string[]) => [...new Set(list)];
244
+ if (unique(denyPaths).length > 64) throw new Error(`.agenthub config: local.deny names more than 64 entries across config.json and config.local.json, more than amem's screen holds; trim it${" before using the repo scope"}`);
245
+ return { denyPaths: unique(denyPaths), piiPaths: unique(piiPaths) };
246
+ }
247
+
248
+ /** Every string in a screened value, and its `about` links: what `mentionsPrivatePath` reads. Bounded like the policy's own snapshot. */
249
+ function textOf(value: unknown): { text: string; about: string[] } {
250
+ const strings: string[] = []; let budget = 4096;
251
+ const visit = (input: unknown, depth: number) => {
252
+ if (budget-- <= 0 || depth > 16) return;
253
+ if (typeof input === "string") strings.push(input);
254
+ else if (Array.isArray(input)) for (const item of input) visit(item, depth + 1);
255
+ else if (input && typeof input === "object") for (const [key, item] of Object.entries(input)) { strings.push(key); visit(item, depth + 1); }
256
+ };
257
+ visit(value, 0);
258
+ const about = value && typeof value === "object" && Array.isArray((value as { about?: unknown }).about) ? (value as { about: unknown[] }).about.filter((x): x is string => typeof x === "string") : [];
259
+ return { text: strings.join("\n"), about };
260
+ }
261
+
262
+ /** The standalone screen, plus the repository's own `pii.paths` matched with the hub's glob semantics. Either declining declines. */
263
+ export function repoPolicy(options: { denyPaths: string[]; piiPaths: string[] }, base: StandalonePolicyOptions = {}): MemoryPolicy {
264
+ const standalone = createStandalonePolicy({ ...base, denyPaths: [...new Set([...(base.denyPaths ?? []), ...options.denyPaths])] });
265
+ return { async screen(value: unknown): Promise<boolean> {
266
+ if (!await standalone.screen(value)) return false;
267
+ if (!options.piiPaths.length) return true;
268
+ const { text, about } = textOf(value);
269
+ return !mentionsPrivatePath(text, about, options.piiPaths);
270
+ } };
271
+ }
272
+
273
+ /** The policy a repo-bound reader applies: the caller's standalone screen (host policy and patterns included) narrowed by this repository's own deny list and `pii.paths`. Unreadable config withholds repo rows (reason), never shows them. */
274
+ export function repoReadPolicy(root: string, base: StandalonePolicyOptions = {}): { policy: MemoryPolicy } | { reason: string } {
275
+ try { return { policy: repoPolicy(repoPolicyOptions(root), base) }; } catch (error) { return { reason: (error as Error).message }; }
276
+ }
277
+
278
+ /** The text as a case-insensitive disk and a terminal see it: compatibility forms folded, ignorable code points dropped, backslashes as slashes, lower case. */
279
+ const foldText = (text: string): string => text.normalize("NFKC").replace(/\p{Default_Ignorable_Code_Point}/gu, "").replace(/\\/g, "/").toLowerCase();
280
+ const HOME_PATH = [/\/users\/[^/\s"'`]+\//, /\/home\/[^/\s"'`]+\//, /\b[a-z]:\/users\/[^/\s"'`]+\//, /~\/\.(?:ssh|aws|gnupg|config|agenthub|maru|claude|codex|kube|docker|netrc)\b/, /\$\{?home\}?\//, /%(?:userprofile|homepath|appdata|localappdata)%/];
281
+ /** A home directory or a credential location, in any text (folded first: `\Users\x\`, a width-folded or zero-width-split spelling, `$HOME/`, `%USERPROFILE%`). Refused when promoting and withheld when reading: a pull request can add such a line too. */
282
+ export function namesPrivatePath(text: string): boolean {
283
+ const folded = foldText(text), home = foldText(homedir());
284
+ return HOME_PATH.some(re => re.test(folded)) || (home.length > 3 && folded.includes(home));
285
+ }
286
+ const SAFE_NODE = /^(?:path|symbol|issue):/;
287
+ /** A graph node that may be committed: a repository-relative path or symbol, or an issue. Private task, goal and memory references and absolute or escaping paths stay out. */
288
+ function committableNode(node: string): boolean {
289
+ if (!SAFE_NODE.test(node)) return false;
290
+ const where = node.startsWith("path:") ? node.slice(5) : node.startsWith("symbol:") && node.includes("@") ? node.slice(node.lastIndexOf("@") + 1) : "";
291
+ if (!where) return !node.startsWith("path:") && !node.startsWith("symbol:");
292
+ return !/^(?:\/|~|[A-Za-z]:[\\/])/.test(where) && !where.split(/[\\/]/).includes("..");
293
+ }
294
+
295
+ export interface PromotionPlan {
296
+ line: RepoLine; text: string; path: string; droppedNodes: string[]; alreadyPresent: boolean; refusals: string[];
297
+ }
298
+ /**
299
+ * What `amem promote <id> --to repo` would commit, and why it may not. Pure of effects: nothing is written, no remote is asked.
300
+ * `refusals` is empty only for a long-term decision, contract or fail note that passes the full screen.
301
+ */
302
+ export async function planRepoPromotion(root: string, note: View, base: StandalonePolicyOptions = {}): Promise<PromotionPlan> {
303
+ const refusals: string[] = [];
304
+ const about = note.about.filter(committableNode), droppedNodes = note.about.filter(node => !committableNode(node));
305
+ const body: Omit<RepoLine, "id"> = { schema: REPO_SCHEMA, op: "note", at: note.at, kind: note.kind as RepoKind, ...(note.title ? { title: note.title } : {}), text: note.text, about: about.slice(0, MAX_ABOUT), actor: "person" };
306
+ const line: RepoLine = { ...body, id: repoEntryId(body) };
307
+ if (note.tier !== "long" || note.local) refusals.push("only a long-term note enters repo scope: promote it to long-term first");
308
+ if (note.supersededBy) refusals.push(`replaced by ${note.supersededBy.slice(0, 8)}: promote that one instead`);
309
+ if (!REPO_KINDS.includes(note.kind as RepoKind)) refusals.push(`a ${note.kind} stays private: repo scope holds ${REPO_KINDS.join(", ")}`);
310
+ if (Buffer.byteLength(note.text) > MAX_TEXT || Buffer.byteLength(note.title ?? "") > MAX_TITLE) refusals.push(`the note is longer than a repo entry may be (title ${MAX_TITLE} bytes, text ${MAX_TEXT} bytes)`);
311
+ const flat = `${note.title ?? ""}\n${note.text}\n${about.join("\n")}`;
312
+ if (namesPrivatePath(flat)) refusals.push("it names a private path (a home directory or a credential location)");
313
+ let policy: MemoryPolicy | undefined;
314
+ try { policy = repoPolicy(repoPolicyOptions(root), base); } catch (error) { refusals.push((error as Error).message); }
315
+ // The policy's shape check refuses `undefined` values, so the screened value is exactly the committed line's own fields.
316
+ const { id: _lineId, ...committed } = line;
317
+ // The writer and every reader must agree: a line the reader's own parser rejects (a byte limit, a shape) is never appended, whatever the checks above said.
318
+ if (!refusals.length && "error" in parseRepoLine(JSON.stringify(line))) refusals.push("the line it would write does not pass the repo reader's own validation (a title, text or link over a limit, or an empty field); shorten it first");
319
+ if (policy && !await policy.screen(committed)) refusals.push("the screen declined it: a secret, personal data, a private path, a local.deny entry or a pii.paths entry");
320
+ const read = readLog(root);
321
+ if ("unsafe" in read) refusals.push(read.unsafe);
322
+ const alreadyPresent = !("unsafe" in read) && read.lines.some(raw => { const parsed = parseRepoLine(raw); return "view" in parsed && parsed.view.id === line.id; });
323
+ return { line, text: JSON.stringify(line), path: repoLogPath(root), droppedNodes, alreadyPresent, refusals };
324
+ }
325
+
326
+ export interface RepoVisibility { state: Visibility; remotes: { name: string; kind: "fetch" | "push"; url: string; visibility: Visibility }[] }
327
+ const redact = (url: string) => url.replace(/\/\/[^/@\s]*@/, "//");
328
+ /**
329
+ * Every remote's EFFECTIVE fetch and push URLs, each asked through the same visibility check sync uses (`git remote get-url [--push] --all`: it applies `url.<base>.insteadOf`
330
+ * and `pushInsteadOf`, which a raw `remote.*.url` read does not, and it lists every `pushurl`). Only all-private is private; no remote, a remote git cannot resolve or a
331
+ * non-GitHub host is unknown. `internal` visibility counts as private, as `amem sync` always has (a GitHub Enterprise Cloud repository is not public).
332
+ */
333
+ export async function repoVisibility(root: string, visibility?: (url: string) => Promise<Visibility>): Promise<RepoVisibility> {
334
+ // ponytail: `sync.ts` drags in the store and the generation code; the brief and hooks import this module on their cold path, so only a promotion loads it. Upgrade: move `remoteVisibility` to a leaf.
335
+ visibility ??= (await import("./sync.ts")).remoteVisibility;
336
+ const env = memoryGitEnv(process.env, { repositoryIsolation: true });
337
+ const git = (...args: string[]) => memoryGitSync(["-C", root, ...args], { encoding: "utf8", env });
338
+ const listed = git("remote");
339
+ if (listed.status !== 0) return { state: "unknown", remotes: [] };
340
+ const names = (listed.stdout ?? "").split("\n").map(line => line.trim()).filter(Boolean);
341
+ const remotes: RepoVisibility["remotes"] = [];
342
+ let unresolved = false;
343
+ for (const name of names) {
344
+ for (const kind of ["fetch", "push"] as const) {
345
+ const got = git("remote", "get-url", ...(kind === "push" ? ["--push"] : []), "--all", name);
346
+ const urls = got.status === 0 ? (got.stdout ?? "").split("\n").map(line => line.trim()).filter(Boolean) : [];
347
+ if (!urls.length) { unresolved = true; continue; }
348
+ for (const url of urls) {
349
+ let state: Visibility = "unknown";
350
+ try { state = await visibility(url); } catch { /* unknown */ }
351
+ remotes.push({ name, kind, url: redact(url), visibility: state });
352
+ }
353
+ }
354
+ }
355
+ const state: Visibility = remotes.length === 0 || unresolved ? "unknown" : remotes.some(r => r.visibility === "public") ? "public" : remotes.every(r => r.visibility === "private") ? "private" : "unknown";
356
+ return { state, remotes };
357
+ }
358
+
359
+ // ponytail: between the `plainLog` check and the open another process with write access to the repository could swap `.amem` for a symlink. `O_NOFOLLOW` covers the file itself and the post-open
360
+ // `realPath(dir)` check refuses a directory that resolves elsewhere, but the open of a path THROUGH a swapped directory is not atomic. The ceiling is a local attacker who can already write the repository.
361
+ // Upgrade: open the directory first and use `openat` relative to its descriptor (not available in Bun's fs today).
362
+ /** Append one line to `.amem/log.jsonl`: O_NOFOLLOW, a regular file, the directory inside the repository, a newline first when the file lacks one. Never runs git. */
363
+ export function appendRepoLine(root: string, line: RepoLine): { path: string; created: boolean } {
364
+ const dir = join(root, REPO_DIR), path = repoLogPath(root);
365
+ if (!lstatSync(dir, { throwIfNoEntry: false })) mkdirSync(dir, { mode: 0o755 });
366
+ const plain = plainLog(root);
367
+ if (plain.kind === "unsafe") throw new Error(plain.why);
368
+ if (plain.kind === "file" && plain.size + Buffer.byteLength(JSON.stringify(line)) > MAX_FILE_BYTES) throw new Error(`${REPO_DIR}/${REPO_LOG} would exceed ${MAX_FILE_BYTES} bytes: split or prune it through review first`);
369
+ const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o644);
370
+ try {
371
+ const info = fstatSync(fd);
372
+ if (!info.isFile()) throw new Error(`${REPO_DIR}/${REPO_LOG} is not a regular file`);
373
+ if (realPath(dir) !== dir) throw new Error(`${REPO_DIR} resolves outside the repository root`);
374
+ let prefix = "";
375
+ if (info.size > 0) { const check = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); try { const last = Buffer.alloc(1); readSync(check, last, 0, 1, info.size - 1); if (last[0] !== 0x0a) prefix = "\n"; } finally { closeSync(check); } }
376
+ writeSync(fd, `${prefix}${JSON.stringify(line)}\n`);
377
+ return { path, created: info.size === 0 };
378
+ } finally { closeSync(fd); }
379
+ }
380
+
381
+ /** `.gitattributes` at the repository root gains `.amem/log.jsonl merge=union`, only when the person asks. */
382
+ export function planGitattributes(root: string): { path: string; present: boolean; unsafe?: string } {
383
+ const path = join(root, ".gitattributes");
384
+ const stat = lstatSync(path, { throwIfNoEntry: false });
385
+ if (!stat) return { path, present: false };
386
+ if (!stat.isFile()) return { path, present: false, unsafe: ".gitattributes is not a regular file" };
387
+ if (stat.size > MAX_CONFIG_BYTES) return { path, present: false, unsafe: ".gitattributes is larger than expected" };
388
+ const present = readFileSync(path, "utf8").split(/\r?\n/).some(row => row.trim().split(/\s+/).join(" ") === REPO_ATTRIBUTES_LINE);
389
+ return { path, present };
390
+ }
391
+ export function appendGitattributes(root: string): { path: string; added: boolean } {
392
+ const plan = planGitattributes(root);
393
+ if (plan.unsafe) throw new Error(plan.unsafe);
394
+ if (plan.present) return { path: plan.path, added: false };
395
+ const fd = openSync(plan.path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o644);
396
+ try {
397
+ const info = fstatSync(fd);
398
+ if (!info.isFile()) throw new Error(".gitattributes is not a regular file");
399
+ let prefix = "";
400
+ if (info.size > 0) { const check = openSync(plan.path, constants.O_RDONLY | constants.O_NOFOLLOW); try { const last = Buffer.alloc(1); readSync(check, last, 0, 1, info.size - 1); if (last[0] !== 0x0a) prefix = "\n"; } finally { closeSync(check); } }
401
+ writeSync(fd, `${prefix}${REPO_ATTRIBUTES_LINE}\n`);
402
+ } finally { closeSync(fd); }
403
+ return { path: plan.path, added: true };
404
+ }
@@ -0,0 +1,116 @@
1
+ import { createHash } from "node:crypto";
2
+ import { legacyEntries } from "./bridge.ts";
3
+ import { canonicalJSON, validGeneration } from "./generation.ts";
4
+ import { validatePruneRow, type PruneRow } from "./prune.ts";
5
+ import { shardKeyHash } from "./shard.ts";
6
+ import type { Entry } from "./store.ts";
7
+
8
+ export type ShardSyncRealm = { scope: "project"; key: string } | { scope: "user" };
9
+ export type ShardSyncRow = Entry | PruneRow;
10
+ export const SHARD_SYNC_SCHEMA = "agent-mem.shard-sync/v1";
11
+ export const SHARD_PACK_MAX_BYTES = 1024 * 1024;
12
+ const PACK = /^rows\/([a-f0-9]{64})\/([a-f0-9]{64})\.jsonl$/;
13
+ const hash = (value: string | Uint8Array): string => createHash("sha256").update(value).digest("hex");
14
+
15
+ export class ShardSyncFormatError extends Error {
16
+ constructor(label: string, reason: string) { super(`${label}: ${reason}`); this.name = "ShardSyncFormatError"; }
17
+ }
18
+ function checkRealm(realm: ShardSyncRealm): void {
19
+ if (!realm || (realm.scope !== "user" && (realm.scope !== "project" || typeof realm.key !== "string" || !realm.key))) {
20
+ throw new ShardSyncFormatError("shard", "invalid realm");
21
+ }
22
+ }
23
+ export function shardSyncLabel(realm: ShardSyncRealm): string {
24
+ checkRealm(realm);
25
+ return realm.scope === "user" ? "user shard" : `project shard ${shardKeyHash(realm.key)}`;
26
+ }
27
+ export function shardSyncRef(realm: ShardSyncRealm): string {
28
+ checkRealm(realm);
29
+ return realm.scope === "user" ? "refs/heads/amem/u" : `refs/heads/amem/p/${shardKeyHash(realm.key)}`;
30
+ }
31
+ export function shardSyncDescriptor(realm: ShardSyncRealm): string {
32
+ checkRealm(realm);
33
+ return canonicalJSON(realm.scope === "user" ? { schema: SHARD_SYNC_SCHEMA, scope: "user" } : { schema: SHARD_SYNC_SCHEMA, scope: "project", key: realm.key }) + "\n";
34
+ }
35
+
36
+ /** Only row shape/public placement here. The sync engine must prove target/basis realm ownership across the full source set. */
37
+ function publicRow(value: unknown, label: string): ShardSyncRow {
38
+ try {
39
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("row");
40
+ if ((value as { schema?: unknown }).schema === "agent-mem.prune/v1") return validatePruneRow(value, undefined, "synced");
41
+ const raw = Buffer.from(canonicalJSON(value) + "\n");
42
+ const [row] = legacyEntries([{ source: "legacy-main", path: "log/format.jsonl", bytes: raw, offset: raw.length, hash: hash(raw), local: false, tailBytes: 0 }], { strict: true });
43
+ if (!row || row.op === "settle" || row.op === "note" && row.tier !== "long") throw new Error("placement");
44
+ return row;
45
+ } catch { throw new ShardSyncFormatError(label, "invalid public row"); }
46
+ }
47
+ function collision(seen: Map<string, string>, row: ShardSyncRow, line: string, label: string): void {
48
+ const previous = seen.get(row.id);
49
+ if (previous !== undefined && previous !== line) throw new ShardSyncFormatError(label, "row id collision");
50
+ seen.set(row.id, line);
51
+ }
52
+
53
+ /** Content-addressed immutable packs retain original attribution, ids and even repeated identical input rows. */
54
+ export function encodeShardPacks(rows: readonly ShardSyncRow[]): Map<string, Buffer> {
55
+ const groups = new Map<string, { id: string; line: string }[]>(), seen = new Map<string, string>();
56
+ const label = "shard pack";
57
+ for (const input of rows) {
58
+ let line: string;
59
+ if (!input || typeof input !== "object" || Array.isArray(input) || typeof input.id !== "string") throw new ShardSyncFormatError(label, "invalid public row");
60
+ try { line = canonicalJSON(input); } catch { throw new ShardSyncFormatError(label, "invalid public row"); }
61
+ // Check before validation too: a conflicting claimed id is never silently preferred over the other payload.
62
+ collision(seen, input, line, label);
63
+ const row = publicRow(input, label);
64
+ if (Buffer.byteLength(line) + 1 > SHARD_PACK_MAX_BYTES) throw new ShardSyncFormatError(label, "row exceeds pack bound");
65
+ const held = groups.get(row.machine) ?? [];
66
+ held.push({ id: row.id, line: line + "\n" }); groups.set(row.machine, held);
67
+ }
68
+ const packs = new Map<string, Buffer>();
69
+ for (const machine of [...groups.keys()].sort()) {
70
+ const group = groups.get(machine)!; group.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
71
+ let parts: string[] = [], bytes = 0;
72
+ const publish = () => {
73
+ if (!parts.length) return;
74
+ const body = Buffer.from(parts.join(""));
75
+ const path = `rows/${hash(machine)}/${hash(body)}.jsonl`;
76
+ if (packs.has(path)) throw new ShardSyncFormatError(label, "duplicate immutable pack");
77
+ packs.set(path, body); parts = []; bytes = 0;
78
+ };
79
+ for (const row of group) {
80
+ const size = Buffer.byteLength(row.line);
81
+ if (bytes + size > SHARD_PACK_MAX_BYTES) publish();
82
+ parts.push(row.line); bytes += size;
83
+ }
84
+ publish();
85
+ }
86
+ return packs;
87
+ }
88
+
89
+ export function decodeShardPack(path: string, bytes: Uint8Array, realm: ShardSyncRealm, generation: string): ShardSyncRow[] {
90
+ const label = shardSyncLabel(realm), match = PACK.exec(path);
91
+ if (!match || !bytes.byteLength || bytes.byteLength > SHARD_PACK_MAX_BYTES || hash(bytes) !== match[2] || !validGeneration(generation)) {
92
+ throw new ShardSyncFormatError(label, "invalid pack identity or bound");
93
+ }
94
+ let text: string;
95
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
96
+ catch { throw new ShardSyncFormatError(label, "invalid pack encoding"); }
97
+ if (!text.endsWith("\n")) throw new ShardSyncFormatError(label, "incomplete pack tail");
98
+ const rows: ShardSyncRow[] = [], seen = new Map<string, string>();
99
+ for (const line of text.slice(0, -1).split("\n")) {
100
+ let value: unknown;
101
+ try { if (!line.trim()) throw new Error("empty"); value = JSON.parse(line); }
102
+ catch { throw new ShardSyncFormatError(label, "malformed pack row"); }
103
+ const row = publicRow(value, label);
104
+ if (hash(row.machine) !== match[1]) throw new ShardSyncFormatError(label, "pack author mismatch");
105
+ if (row.op === "prune") {
106
+ // ponytail: #404 needs a generation-neutral public-witness transport; never retag or skip a foreign prune.
107
+ if (row.generation !== generation) throw new ShardSyncFormatError(label, "foreign-generation prune");
108
+ } else if (row.op === "note" || row.op === "goal") {
109
+ if (realm.scope === "project" ? row.scope !== "project" || row.project !== realm.key : row.scope !== "shared") {
110
+ throw new ShardSyncFormatError(label, "row scope mismatch");
111
+ }
112
+ }
113
+ collision(seen, row, canonicalJSON(row), label); rows.push(row);
114
+ }
115
+ return rows;
116
+ }