@staix/agent-mem 0.6.0 → 0.8.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 (71) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +143 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +537 -39
  6. package/src/commands-table.ts +60 -16
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +20 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +168 -33
  12. package/src/mcp.ts +43 -7
  13. package/src/memory/aliases.ts +4 -0
  14. package/src/memory/bridge.ts +8 -2
  15. package/src/memory/candidate-pending.ts +119 -0
  16. package/src/memory/candidates.ts +228 -47
  17. package/src/memory/capacity-split.ts +76 -0
  18. package/src/memory/capacity.ts +139 -11
  19. package/src/memory/capture-input.ts +8 -3
  20. package/src/memory/capture.ts +30 -5
  21. package/src/memory/catalog.ts +43 -10
  22. package/src/memory/commands.ts +98 -44
  23. package/src/memory/config.ts +167 -0
  24. package/src/memory/consolidate.ts +78 -0
  25. package/src/memory/consolidation-clusters.ts +56 -0
  26. package/src/memory/consolidation-model.ts +97 -0
  27. package/src/memory/cross-project.ts +75 -0
  28. package/src/memory/curated-read.ts +29 -0
  29. package/src/memory/decay-review.ts +136 -0
  30. package/src/memory/doctor.ts +415 -0
  31. package/src/memory/editor.ts +58 -0
  32. package/src/memory/eval-fixture.ts +145 -0
  33. package/src/memory/eval-set-ko.json +90 -0
  34. package/src/memory/eval-set.json +443 -0
  35. package/src/memory/eval.ts +152 -0
  36. package/src/memory/evidence.ts +76 -0
  37. package/src/memory/external-import.ts +267 -0
  38. package/src/memory/gc.ts +97 -0
  39. package/src/memory/generation-index.ts +22 -17
  40. package/src/memory/generation-sync.ts +3 -0
  41. package/src/memory/goal-promotion.ts +71 -0
  42. package/src/memory/home.ts +15 -4
  43. package/src/memory/intent-brief.ts +89 -0
  44. package/src/memory/lifecycle-log.ts +54 -0
  45. package/src/memory/lifecycle.ts +61 -0
  46. package/src/memory/metrics.ts +2 -2
  47. package/src/memory/note-add.ts +100 -0
  48. package/src/memory/path-glob.ts +94 -0
  49. package/src/memory/plan-guard.ts +47 -0
  50. package/src/memory/platform.ts +12 -1
  51. package/src/memory/policy.ts +44 -13
  52. package/src/memory/private-spans.ts +32 -0
  53. package/src/memory/project-export.ts +157 -0
  54. package/src/memory/project-import.ts +147 -0
  55. package/src/memory/recall-staleness.ts +56 -0
  56. package/src/memory/recall.ts +74 -0
  57. package/src/memory/repo-scope.ts +404 -0
  58. package/src/memory/shard-sync-format.ts +116 -0
  59. package/src/memory/shard-sync.ts +421 -0
  60. package/src/memory/store.ts +344 -35
  61. package/src/memory/sync-cadence.ts +149 -0
  62. package/src/memory/sync-screen.ts +86 -0
  63. package/src/memory/sync.ts +91 -11
  64. package/src/memory/usage.ts +35 -0
  65. package/src/memory/use-telemetry.ts +51 -0
  66. package/src/memory/work-lists.ts +10 -4
  67. package/src/session-brief-worker.ts +3 -2
  68. package/src/session-brief.ts +74 -15
  69. package/src/setup-owned.ts +120 -0
  70. package/src/setup-toml.ts +296 -0
  71. package/src/setup.ts +327 -0
@@ -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
+ }
@@ -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
+ }