@staix/agent-mem 0.0.0-stage → 0.1.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.
@@ -0,0 +1,203 @@
1
+ import { extractNodes, GOAL_STATES, KINDS, MemoryStore, setMachineLabel, type GoalState, type GoalView, type Kind, type Tier, type View } from "./store.ts";
2
+ import { syncMemory, type SyncOptions } from "./sync.ts";
3
+ import { importClaudeMem } from "./import.ts";
4
+ import { memoryProjectKey, projectChain } from "./project.ts";
5
+
6
+ export interface MemoryCommandOptions {
7
+ store?: MemoryStore;
8
+ sync?: Omit<SyncOptions, "remote">;
9
+ open: () => MemoryStore;
10
+ readable: (notes: View[]) => View[];
11
+ importPolicy: () => (text: string) => boolean;
12
+ }
13
+ export interface GoalCommandOptions {
14
+ store?: MemoryStore;
15
+ link?: (goal: string, task: string) => Promise<string>;
16
+ open: () => MemoryStore;
17
+ clean: (title: string, milestones: string[], project: string) => boolean;
18
+ }
19
+
20
+ /**
21
+ * #258: `ahub memory`, straight on the machine's store (no hub needed). Reading is anyone's; promote, demote, forget,
22
+ * supersede and rebuild are a person's (the identity gate refuses agent shells).
23
+ */
24
+ export const MEMORY_USAGE = "usage: ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> | show <id> | graph <id|node> [--hops 1|2] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]";
25
+ export const GOAL_USAGE = "usage: ahub goal [list] [--all] | show <id> | new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task id> | state <id> open|active|blocked|done|dropped | done <id> | take <id> [--force] [--json]";
26
+
27
+ const fail = (message: string): never => { throw new Error(message); };
28
+ const one = (v: View) => `${v.id.slice(0, 8)} ${v.at.slice(0, 16).replace("T", " ")} ${v.tier.padEnd(5)} ${v.kind.padEnd(8)} ${v.peer.padEnd(8)} ${v.scope === "shared" ? "shared " : ""}${`${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").slice(0, 100)}${v.supersededBy ? ` (superseded by ${v.supersededBy.slice(0, 8)})` : ""}`;
29
+ function detail(v: View): string[] {
30
+ return [`${v.id} ${v.kind}, ${v.tier}-term${v.expiresAt ? ` until ${v.expiresAt}` : ""}, ${v.scope}`, `by ${v.peer} on machine ${v.machine}, ${v.at}, project ${v.project}${v.task ? `, task ${v.task}` : ""}`,
31
+ ...(v.promotedFrom ? [`promoted from ${v.promotedFrom}`] : []), ...(v.supersededBy ? [`superseded by ${v.supersededBy}`] : []), ...(v.about.length ? [`about: ${v.about.join(", ")}`] : []), "", ...(v.title ? [v.title] : []), v.text];
32
+ }
33
+
34
+ export async function rawMemoryCommand(args: string[], cwd: string, opts: MemoryCommandOptions): Promise<string> {
35
+ const json = args.includes("--json");
36
+ const rest = args.filter((a) => a !== "--json");
37
+ const flag = (name: string) => { const i = rest.indexOf(name); if (i < 0) return undefined; const v = rest[i + 1] ?? fail(`${name} needs a value`); rest.splice(i, 2); return v; };
38
+ const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
39
+ const store = opts.store ?? opts.open();
40
+ const actor = "user";
41
+ const project = memoryProjectKey(cwd);
42
+ const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (ahub memory list shows the ids)`);
43
+ try {
44
+ switch (sub) {
45
+ case "list": case "search": {
46
+ const tier = flag("--tier"), kind = flag("--kind"), peer = flag("--peer"), machine = flag("--machine"), since = flag("--since"), limit = flag("--limit");
47
+ if (since !== undefined && Number.isNaN(Date.parse(since))) fail(`--since needs a date or time (ISO 8601), not ${since}`);
48
+ if (tier && tier !== "short" && tier !== "long") fail("--tier takes short or long");
49
+ if (kind && !KINDS.includes(kind as Kind)) fail(`--kind takes ${KINDS.join(", ")}`);
50
+ if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
51
+ const q = sub === "search" ? rest.join(" ") || fail("usage: ahub memory search <words>") : rest.length ? fail(MEMORY_USAGE) : undefined;
52
+ // Agent shells may run this: notes are screened under this project's current PII policy, as briefs and recall are
53
+ // (a shared note was accepted under another project's patterns). The dashboard shows a person everything.
54
+ const all = store.list({ project, ...(tier ? { tier: tier as Tier } : {}), ...(kind ? { kind: kind as Kind } : {}), ...(peer ? { peer } : {}), ...(machine ? { machine } : {}), ...(since ? { since } : {}), ...(q ? { q } : {}), limit: Number.MAX_SAFE_INTEGER });
55
+ // Screened first, then cut to the limit: withheld entries never crowd out readable ones.
56
+ const readable = opts.readable(all), rows = readable.slice(0, limit ? Number(limit) : 50);
57
+ if (json) return JSON.stringify(rows, null, 2);
58
+ const c = store.counts(project);
59
+ const withheld = all.length - readable.length;
60
+ return [`hub memory for ${project} on machine ${c.machine.label}: ${c.long} long-term, ${c.short} short-term`, ...(rows.length ? rows.map(one) : ["(nothing)"]),
61
+ ...(withheld ? [`(${withheld} withheld: they match a PII pattern; the dashboard shows them)`] : [])].join("\n");
62
+ }
63
+ case "show": {
64
+ const id = rest[0] ?? fail("usage: ahub memory show <id>");
65
+ const v = need(store.get(id, project), id);
66
+ if (!opts.readable([v]).length) fail(`${v.id.slice(0, 8)} is withheld: it matches a PII pattern (the dashboard shows it)`);
67
+ return json ? JSON.stringify(v, null, 2) : detail(v).join("\n");
68
+ }
69
+ case "graph": {
70
+ const hops = Number(flag("--hops") ?? 1);
71
+ if (hops !== 1 && hops !== 2) fail("--hops takes 1 or 2");
72
+ const start = rest[0] ?? fail("usage: ahub memory graph <id|node>");
73
+ const node = start.includes(":") && !start.startsWith("mem:") ? start : `mem:${need(store.get(start, project), start).id}`;
74
+ const g = store.graph(project, node, hops);
75
+ return json ? JSON.stringify(g, null, 2) : [`${g.nodes.length} nodes, ${g.edges.length} edges around ${node}`, ...g.edges.map((e) => `${e.from} -${e.rel}-> ${e.to}`)].join("\n");
76
+ }
77
+ case "promote": { const v = need(store.get(rest[0] ?? fail(MEMORY_USAGE), project), rest[0]!); if (v.supersededBy) fail(`${v.id.slice(0, 8)} is replaced by ${v.supersededBy.slice(0, 8)}: promote that one instead`); const id = store.promote(v.id, actor, project) ?? fail(v.project !== project ? `${v.id.slice(0, 8)} is another project's note: promote it from that project` : `${v.id.slice(0, 8)} stays short-term: it holds a secret, matches a PII pattern or is about a private path`); return id === v.id ? `${v.id.slice(0, 8)} is already long-term` : `promoted to long-term as ${id.slice(0, 8)}`; }
78
+ case "demote": { const v = need(store.get(rest[0] ?? fail(MEMORY_USAGE), project), rest[0]!); if (v.tier !== "long") return `${v.id.slice(0, 8)} is already short-term`; store.demote(v.id, actor, project); return `demoted ${v.id.slice(0, 8)}: it expires ${store.get(v.id, project)?.expiresAt ?? "with its short-term lifetime"}`; }
79
+ case "forget": { const v = need(store.forget(rest[0] ?? fail(MEMORY_USAGE), actor, project), rest[0]!); return `forgot ${v.id.slice(0, 8)}${v.local ? "" : "; it stays in the sync remote's git history until a person rewrites it (docs/memory.md)"}`; }
80
+ case "supersede": {
81
+ if (rest.length !== 2) fail("usage: ahub memory supersede <old> <new>");
82
+ return store.supersede(rest[0]!, rest[1]!, actor, project) ? `${rest[0]} is superseded by ${rest[1]}` : fail("both ids must name different entries, and the newer one must reach as far: shared for a shared entry, long-term for a long-term one (ahub memory promote it first)");
83
+ }
84
+ case "sync": {
85
+ const remote = flag("--remote");
86
+ if (rest.length) fail("usage: ahub memory sync [--remote <private git url>]");
87
+ const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
88
+ if (json) return JSON.stringify(r, null, 2);
89
+ if (!r.ok) fail(`hub memory sync: ${r.error}`);
90
+ return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
91
+ }
92
+ case "dedupe": {
93
+ const apply = rest.includes("--apply");
94
+ if (rest.some(arg => arg !== "--apply")) fail("usage: ahub memory dedupe [--apply] [--json]");
95
+ const result = store.dedupe(project, apply);
96
+ if (json) return JSON.stringify(result, null, 2);
97
+ return [result.groups.length ? `${result.groups.length} duplicate import group(s)${apply ? `; appended ${result.applied} supersede operation(s)` : "; use --apply to reconcile"}` : "no duplicate import groups to reconcile",
98
+ ...result.groups.map(g => `${g.members.map(v => v.id.slice(0, 8)).join(", ")} -> ${g.target.slice(0, 8)}: ${g.members[0]!.text}`)].join("\n");
99
+ }
100
+ case "import": {
101
+ const db = flag("--db"), limit = flag("--limit");
102
+ const restart = rest.includes("--restart");
103
+ if (restart) rest.splice(rest.indexOf("--restart"), 1);
104
+ if (rest[0] !== "claude-mem" || rest.length !== 1) fail("usage: ahub memory import claude-mem [--db <path>] [--limit N] [--restart]");
105
+ if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
106
+ const isPii = opts.importPolicy();
107
+ const key = memoryProjectKey(cwd);
108
+ const nodes = (text: string) => extractNodes(text, { cwd, project: key, repo: key.startsWith("github.com/") ? key.slice("github.com/".length) : key, checkExists: false });
109
+ const r = importClaudeMem(store, { project: key, chain: projectChain(cwd), nodes, restart, isPii, ...(db ? { db } : {}), ...(limit ? { limit: Number(limit) } : {}) });
110
+ if (json) return JSON.stringify(r, null, 2);
111
+ return `imported ${r.added} of ${r.read} hub notes from claude-mem as long-term entries (skipped: ${r.skipped.model} model answers, ${r.skipped.pii} PII, ${r.skipped.secret} secret-looking; other candidates were empty, malformed or already imported)${r.more ? "\nmore remain: run it again" : ""}`;
112
+ }
113
+ case "suggestions": {
114
+ const pending = store.suggestions(project).filter((x) => x.status === "suggested");
115
+ if (json) return JSON.stringify(pending, null, 2);
116
+ return pending.length ? pending.map((x) => `${x.id.slice(0, 8)} ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)} (suggested by the hub's model ${x.at}; not evidence until confirmed)`).join("\n") : "no edge suggestions waiting";
117
+ }
118
+ case "confirm": case "reject": {
119
+ const x = store.judge(rest[0] ?? fail(`usage: ahub memory ${sub} <suggestion id>`), sub, actor, project) ?? fail(`no suggestion ${rest[0]} (ahub memory suggestions lists them)`);
120
+ return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
121
+ }
122
+ case "machine": {
123
+ const label = flag("--label");
124
+ const m = label ? setMachineLabel(store.dir, label) : store.machine;
125
+ return json ? JSON.stringify(m) : `this machine: ${m.id} (${m.label})`;
126
+ }
127
+ case "rebuild": { store.rebuild(); const c = store.counts(); return `rebuilt the index from ${c.segments} segment${c.segments === 1 ? "" : "s"}: ${c.long} long-term, ${c.short} short-term entries`; }
128
+ default: return fail(MEMORY_USAGE);
129
+ }
130
+ } finally { if (!opts.store) store.close(); }
131
+ }
132
+
133
+ const goalOne = (g: GoalView, me: string) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me)}`;
134
+ function leaseText(g: GoalView, me: string): string {
135
+ const who = (m: string) => m === me ? "this machine" : `machine ${m.slice(0, 8)}`;
136
+ if (g.lease.holder) return `held by ${who(g.lease.holder)} until ${g.lease.until}${g.lease.conflict.length ? `, CONFLICT with ${g.lease.conflict.map(who).join(", ")} (the earlier claim holds; a person settles it with ahub goal take --force)` : ""}`;
137
+ return g.lease.expired ? `free (${who(g.lease.lastHolder!)}'s lease ended ${g.lease.until})` : "free";
138
+ }
139
+
140
+ /**
141
+ * #258: `ahub goal`, long-term tasks. Listing and showing are anyone's; new, link, state, done and take are a
142
+ * person's. A peer links its own board task with hub_task_propose's `goal`.
143
+ */
144
+ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalCommandOptions): Promise<string> {
145
+ const json = args.includes("--json");
146
+ const rest = args.filter((a) => a !== "--json");
147
+ const take = (name: string) => { const out: string[] = []; for (let i = rest.indexOf(name); i >= 0; i = rest.indexOf(name)) { out.push(rest[i + 1] ?? fail(`${name} needs a value`)); rest.splice(i, 2); } return out; };
148
+ const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
149
+ const store = opts.store ?? opts.open();
150
+ const project = memoryProjectKey(cwd);
151
+ const me = store.machine.id;
152
+ // Only this project's long-term tasks and shared ones, like every other lookup an agent may run.
153
+ const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(GOAL_USAGE), project) ?? fail(`no long-term task ${id} for this project (ahub goal list shows them)`);
154
+ const clean = (title: string, milestones: string[], of = project) => opts.clean(title, milestones, of);
155
+ try {
156
+ switch (sub) {
157
+ case "list": {
158
+ const all = rest.includes("--all");
159
+ const listed = store.goals(project).filter((g) => all || (g.state !== "done" && g.state !== "dropped"));
160
+ const goals = listed.filter((g) => clean(g.title, g.milestones, g.project)), withheld = listed.length - goals.length;
161
+ if (json) return JSON.stringify(goals, null, 2);
162
+ return [...(goals.length ? goals.map((g) => goalOne(g, me)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (ahub goal new <title>)`]),
163
+ ...(withheld ? [`(${withheld} withheld: they match a PII pattern or a private path)`] : [])].join("\n");
164
+ }
165
+ case "show": {
166
+ const g = need(rest[0]);
167
+ if (!clean(g.title, g.milestones, g.project)) fail(`${g.id.slice(0, 8)} is withheld: it matches a PII pattern or a private path`);
168
+ if (json) return JSON.stringify(g, null, 2);
169
+ return [goalOne(g, me), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
170
+ ...g.milestones.map((m) => `milestone: ${m}`), ...g.dependsOn.map((d) => `depends on ${d.slice(0, 8)}`),
171
+ ...(g.links.length ? g.links.map((l) => `board task ${l.task} (linked ${l.at} on machine ${l.machine.slice(0, 8)})`) : ["no board tasks linked yet"])].join("\n");
172
+ }
173
+ case "new": {
174
+ const milestones = take("--milestone"), depends = take("--depends").map((d) => need(d).id);
175
+ const shared = rest.includes("--shared");
176
+ const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(GOAL_USAGE);
177
+ if (!clean(title, milestones)) fail("a long-term task syncs to every machine: this one holds a secret, a PII pattern or a private path and is not saved");
178
+ const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor: "user" });
179
+ return `long-term task ${id.slice(0, 8)} created: ${title}`;
180
+ }
181
+ case "link": {
182
+ const g = need(rest[0]);
183
+ const task = rest[1] ?? fail("usage: ahub goal link <id> <task id>");
184
+ if (!/^\d+$/.test(task)) fail("the task is a board task id of this project");
185
+ return await (opts.link ?? fail("linking needs this project's hub running (ahub up)"))(g.id, task);
186
+ }
187
+ case "state": case "done": {
188
+ const g = need(rest[0]);
189
+ const state = (sub === "done" ? "done" : rest[1]) as GoalState;
190
+ if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
191
+ store.goalState(g.id, state, "user");
192
+ return `long-term task ${g.id.slice(0, 8)} is ${state}`;
193
+ }
194
+ case "take": {
195
+ const g = need(rest[0]);
196
+ const r = store.take(g.id, "user", { force: rest.includes("--force") });
197
+ if (!r.ok) fail(`held by machine ${r.holder.slice(0, 8)} until ${r.until}; wait for it to expire or take it over with --force`);
198
+ return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
199
+ }
200
+ default: return fail(GOAL_USAGE);
201
+ }
202
+ } finally { if (!opts.store) store.close(); }
203
+ }
@@ -0,0 +1,10 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { canonicalPath } from "./platform.ts";
4
+
5
+ /** Core memory directory. AGENTHUB_HOME retains its historical parent-home meaning. */
6
+ export function memoryHome(env: NodeJS.ProcessEnv = process.env): string {
7
+ const own = env.AMEM_HOME?.trim();
8
+ if (own) return canonicalPath(own);
9
+ return join(canonicalPath(env.AGENTHUB_HOME?.trim() || join(homedir(), ".agenthub")), "memory");
10
+ }
@@ -0,0 +1,90 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { realPath } from "./platform.ts";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { KINDS, looksSecret, type Kind, type MemoryStore } from "./store.ts";
8
+
9
+ /** The claude-mem store is another program's: opened read-only, never written, its locks waited on briefly. */
10
+ export const CLAUDE_MEM_BUSY_TIMEOUT_MS = 3000;
11
+ /** Titles `ahub ask --remember` gives a model's answer (ask.ts ASK_NOTE_TITLE): never imported as a finding. */
12
+ const MODEL_ANSWER = "ahub ask (model answer)";
13
+
14
+ export interface ImportResult { read: number; added: number; skipped: { model: number; pii: number; secret: number }; more: boolean; cursor: number }
15
+
16
+ /** Machine-local progress is disposable; malformed/unreadable state simply replays the idempotent import. */
17
+ function readCursors(file: string): Record<string, number> {
18
+ try {
19
+ const value: unknown = JSON.parse(readFileSync(file, "utf8"));
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
21
+ return Object.fromEntries(Object.entries(value).filter(([, id]) => Number.isSafeInteger(id) && id >= 0));
22
+ } catch { return {}; }
23
+ }
24
+ function writeCursor(file: string, key: string, cursor: number, log: (line: string) => void): void {
25
+ const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
26
+ try {
27
+ writeFileSync(temp, JSON.stringify({ ...readCursors(file), [key]: cursor }) + "\n", { mode: 0o600, flag: "wx" });
28
+ renameSync(temp, file);
29
+ } catch { log("claude-mem import progress could not be saved; the next run may replay handled rows"); }
30
+ finally { try { rmSync(temp, { force: true }); } catch { /* disposable progress */ } }
31
+ }
32
+
33
+ /**
34
+ * #258 phase 3: copy the hub's own notes from claude-mem (observations whose metadata names a `peer`: `hub_remember`
35
+ * and the hub's task notes) into long-term hub memory as `source: claude-mem` under the hub memory key `project`, for
36
+ * the projects in `chain` (claude-mem files them under the repository's basename). Model answers, PII-pattern text and secret-looking text are skipped.
37
+ * Idempotent: an observation always maps to the same entry.
38
+ */
39
+ export function importClaudeMem(store: MemoryStore, opts: { project: string; chain: string[]; isPii: (text: string) => boolean; nodes?: (text: string) => string[]; db?: string; limit?: number; restart?: boolean; log?: (line: string) => void }): ImportResult {
40
+ const file = opts.db ?? join(homedir(), ".claude-mem", "claude-mem.db");
41
+ if (!existsSync(file)) throw new Error(`no claude-mem store at ${file}`);
42
+ const limit = opts.limit ?? 10_000;
43
+ if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("--limit takes a positive integer");
44
+ const state = join(store.dir, "import-state.json");
45
+ const name = opts.chain.at(-1) ?? "";
46
+ const key = createHash("sha256").update(JSON.stringify([realPath(file), name, opts.project])).digest("hex");
47
+ let cursor = opts.restart ? 0 : readCursors(state)[key] ?? 0;
48
+ const db = new Database(file, { readonly: true });
49
+ db.run(`PRAGMA busy_timeout = ${CLAUDE_MEM_BUSY_TIMEOUT_MS}`);
50
+ const result: ImportResult = { read: 0, added: 0, skipped: { model: 0, pii: 0, secret: 0 }, more: false, cursor };
51
+ try {
52
+ const max = db.query("SELECT MAX(id) AS id FROM observations WHERE project = ? AND metadata LIKE '%\"peer\"%'").get(name) as { id: number | null };
53
+ if ((max.id ?? 0) < cursor) cursor = 0;
54
+ const rows = db.query("SELECT id, project, title, text, narrative, created_at_epoch, metadata FROM observations WHERE project = ? AND id > ? AND metadata LIKE '%\"peer\"%' ORDER BY id LIMIT ?")
55
+ .all(name, cursor, limit) as { id: number; project: string | null; title: string | null; text: string | null; narrative: string | null; created_at_epoch: number | null; metadata: string | null }[];
56
+ // The hub files its claude-mem notes under the repository's own name (the chain's last); the names above it are other
57
+ // projects (a superproject) whose notes and privacy policy are not this one's. ponytail: claude-mem keys projects by
58
+ // basename, so two unrelated repositories with one name still share it.
59
+ const project = opts.project;
60
+ result.read = rows.length;
61
+ result.more = rows.length === limit;
62
+ const handle = (r: typeof rows[number]) => {
63
+ let meta: Record<string, unknown>;
64
+ try { meta = JSON.parse(r.metadata ?? "{}"); } catch { return; }
65
+ if (!meta || typeof meta !== "object" || typeof meta.peer !== "string" || !meta.peer) return;
66
+ const title = r.title ?? "", text = (r.text ?? r.narrative ?? "").trim();
67
+ if (!text) return;
68
+ if (meta.source === "ahub ask" || title.startsWith(MODEL_ANSWER)) { result.skipped.model++; return; }
69
+ if (opts.isPii(`${title}\n${text}`)) { result.skipped.pii++; return; }
70
+ if (looksSecret(`${title}\n${text}`)) { result.skipped.secret++; return; }
71
+ const kind: Kind = KINDS.includes(meta.kind as Kind) ? meta.kind as Kind : "finding";
72
+ const at = new Date(r.created_at_epoch ?? 0).toISOString();
73
+ const { added } = store.importNote({ op: "note", project, scope: "project", tier: "long", kind, peer: meta.peer.slice(0, 32), ...(title ? { title } : {}), text,
74
+ // No task node: claude-mem keeps no proposal time, so a bare task number would join unrelated tasks. The text's
75
+ // own paths and symbols place the note in the graph.
76
+ about: opts.nodes?.(`${title}\n${text}`) ?? [], source: "claude-mem" }, at);
77
+ if (added) result.added++;
78
+ };
79
+ for (const row of rows) {
80
+ handle(row);
81
+ cursor = row.id; // only fully handled rows, including every skip
82
+ }
83
+ } finally {
84
+ db.close();
85
+ result.cursor = cursor;
86
+ writeCursor(state, key, cursor, opts.log ?? console.error);
87
+ }
88
+ store.refresh();
89
+ return result;
90
+ }
@@ -0,0 +1,131 @@
1
+ // Single-source platform helpers. Hub, local and Pi modules re-export these functions unchanged.
2
+ import { realpathSync } from "node:fs";
3
+ import { basename, dirname, join, resolve, sep } from "node:path";
4
+ import { spawnSync, execFileSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from "node:child_process";
5
+ import { createHash } from "node:crypto";
6
+
7
+ const canonical = (path: string): string => {
8
+ const absolute = resolve(path);
9
+ try {
10
+ return realpathSync(absolute);
11
+ } catch {
12
+ const parent = dirname(absolute);
13
+ return parent === absolute ? absolute : join(canonical(parent), basename(absolute));
14
+ }
15
+ };
16
+
17
+ export function canonicalPath(path: string): string {
18
+ return canonical(path);
19
+ }
20
+
21
+ /**
22
+ * realpathSync, except that Bun (1.3.14, and 1.4.2 still: #121) throws ENOENT for an existing path containing a
23
+ * backslash (issue #26). Such a path goes to the system realpath, which handles it and returns every component as
24
+ * stored on disk: guardPath checks names, and on a case-insensitive disk `.GIT/config` or `id_rſa` would otherwise
25
+ * pass for another file. A missing path or a dangling symlink still throws, which guardPath relies on. Other paths take
26
+ * realpathSync's answer unchanged.
27
+ */
28
+ export function realPath(path: string): string {
29
+ try {
30
+ return realpathSync(path);
31
+ } catch (error) {
32
+ if (!resolve(path).includes("\\")) throw error; // a relative path can sit under a backslash directory too
33
+ // The path as given: resolve() would fold a `..` after a symlink by text. GNU realpath accepts a dangling last
34
+ // component unless told -e; the BSD one on macOS refuses it by default.
35
+ const argv = process.platform === "darwin" ? ["/bin/realpath", "--", path] : ["realpath", "-e", "--", path];
36
+ const r = spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8" });
37
+ if (r.status !== 0 || !r.stdout.endsWith("\n")) throw error;
38
+ return r.stdout.slice(0, -1);
39
+ }
40
+ }
41
+
42
+ /** One denylist for core extraction, hub path guards, sandbox rules and capture. */
43
+ // `.agenthub/archive` holds reset state directories, including task text and PII.
44
+ export const DENY_SEGMENTS = [".maru/secrets", ".agenthub/state", ".agenthub/archive"];
45
+ /** Regex sources, matched against a file's basename. */
46
+ export const DENY_NAMES = ["^\\.env", "\\.pem$", "\\.key$", "^id_rsa", "^id_ed25519", "^credentials(\\.|$)", "^auth\\.json$", "^\\.netrc$", "^\\.npmrc$", "^\\.pypirc$"];
47
+
48
+ const hasSegment = (rel: string, seg: string) => `/${rel}/`.includes(`/${seg}/`);
49
+
50
+ /** True when a path names something the worker must never read, write or report. `extra`: substrings from `local.deny`. */
51
+ export function isDenied(path: string, extra: string[] = []): boolean {
52
+ const norm = path.split(sep).join("/");
53
+ return DENY_SEGMENTS.some((s) => hasSegment(norm, s)) || DENY_NAMES.some((re) => new RegExp(re).test(basename(norm))) || extra.some((e) => e && norm.includes(e));
54
+ }
55
+
56
+ /**
57
+ * The one helper for every git call the hub makes outside the sandbox (#281): the repository's own configuration
58
+ * must not name a program the hub then runs with its own rights (defense in depth behind the seatbelt profile,
59
+ * which refuses a sandboxed command the writes that would plant one). Every call gets:
60
+ * - core.fsmonitor=false: the file-system monitor is an arbitrary command read from the repo config.
61
+ * - core.hooksPath=/dev/null: hooks come from a non-directory, so every hook lookup fails closed.
62
+ * - diff.external= (empty): no external diff driver.
63
+ * - core.pager=cat and --no-pager: no pager, whatever the config says (stdout is a pipe everywhere anyway).
64
+ * - credential.helper= (empty resets the list) and core.askpass= (empty fails closed), with GIT_TERMINAL_PROMPT=0:
65
+ * no hub command authenticates, so any prompt is a bug and must fail instead of running a configured helper.
66
+ * git has no global switch for filter drivers or textconv: both are per-driver config keys. Of the hub's commands
67
+ * only the snapshot's `add`/`restore` would run a filter driver, and only a content diff would run textconv — the
68
+ * hub's one content diff is `diff --no-index`, which DOES read the repository's config (verified: a planted
69
+ * diff.external runs for it), so that call passes `--no-ext-diff` and `--no-textconv` on top of the config here,
70
+ * and the driver command of a filter lives in the `.git/config` the seatbelt profile refuses to a sandboxed
71
+ * command. GIT_CONFIG_NOSYSTEM/global are left alone: those files are the user's own.
72
+ */
73
+ const SAFE_CONFIG = ["core.fsmonitor=false", "core.hooksPath=/dev/null", "diff.external=", "core.pager=cat", "credential.helper=", "core.askpass="];
74
+ /** Hub memory sync (#258) pushes to the person's own remote: their global credential helper stays, the rest does not. */
75
+ const CREDENTIAL_KEYS = new Set(["credential.helper=", "core.askpass="]);
76
+
77
+ /**
78
+ * `["git", ...safe options, ...args]`: use instead of a literal "git" argv anywhere under src/. `credentials` keeps the
79
+ * person's credential helper, for a repository the hub created itself (the memory store), never a project's.
80
+ */
81
+ export const memoryGitArgv = (args: string[], opts: { credentials?: boolean } = {}): string[] =>
82
+ ["git", "--no-pager", ...SAFE_CONFIG.filter((c) => !opts.credentials || !CREDENTIAL_KEYS.has(c)).flatMap((c) => ["-c", c]), ...args];
83
+
84
+ /** The caller's env plus the no-prompt switch; existing keys (childEnv, GIT_OPTIONAL_LOCKS=0) pass through. */
85
+ export const memoryGitEnv = (env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => ({ ...env, GIT_TERMINAL_PROMPT: "0" });
86
+
87
+ /** spawnSync form of the same call: the env option is merged with memoryGitEnv, everything else passes through. */
88
+ export function memoryGitSync(args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string> {
89
+ const argv = memoryGitArgv(args);
90
+ return spawnSync(argv[0]!, argv.slice(1), { ...options, env: memoryGitEnv(options.env ?? process.env) });
91
+ }
92
+
93
+ /**
94
+ * The one process-identity contract behind the Pi owner claim, every later liveness
95
+ * re-inspection, the terminal-recovery launcher signature and any other hub identity
96
+ * hash: pid plus the `ps` rendering of its start time and command. `lstart` follows
97
+ * the reader's TZ and locale, so the read pins LC_ALL=C and TZ=UTC, the same contract
98
+ * as `processTable` in src/hub/child-process.ts. Without the pin a writer and a reader
99
+ * in different environments hash different strings for the same process and the valid
100
+ * owner is refused (#174, #177). Only the `ps` invocation is pinned; the user's own
101
+ * environment is untouched. Node APIs only: the extension runs inside Pi, not Bun.
102
+ */
103
+ export function processSignature(pid: number): string | undefined {
104
+ try {
105
+ const text = execFileSync("ps", ["-p", String(pid), "-o", "lstart=,comm="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" } }).trim();
106
+ return text ? createHash("sha256").update(text).digest("hex") : undefined;
107
+ } catch { return undefined; }
108
+ }
109
+
110
+ export type Liveness = "live" | "gone" | "unknown";
111
+
112
+ /**
113
+ * #226: the one ownership check behind every recorded pid (hub manifest, registry claim, recovery runner, manager
114
+ * owner, launcher records). With a recorded `signature` the identity decides: equal is live, different is gone (the
115
+ * pid was reused, after a reboot for example, also by a process we may not signal), and a pid that exists but whose
116
+ * identity cannot be read is unknown. Without one (records written by 0.12.20 and older) the pid probe answers as it
117
+ * always did: live, gone on ESRCH, unknown otherwise. Unknown is never gone.
118
+ */
119
+ // ponytail: `ps lstart` is derived from boot time plus the process's age; a wall-clock step on Linux can change it for a
120
+ // live process, which then reads as gone (a second hub could take its claim). Upgrade path: a boot-id or start-tick
121
+ // (Linux /proc/<pid>/stat field 22) component in the signature.
122
+ export function processLiveness(pid: unknown, signature?: string | null, identity: (pid: number) => string | undefined = processSignature): Liveness {
123
+ if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return "unknown";
124
+ if (signature) {
125
+ let current: string | undefined;
126
+ try { current = identity(pid); } catch { /* unreadable: the pid probe below decides between gone and unknown */ }
127
+ if (current) return current === signature ? "live" : "gone";
128
+ }
129
+ try { process.kill(pid, 0); return signature ? "unknown" : "live"; }
130
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? "gone" : "unknown"; }
131
+ }
@@ -0,0 +1,72 @@
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";
5
+
6
+ /** Project names from the outermost superproject down to this repo. */
7
+ export function projectChain(cwd: string): string[] {
8
+ const git = (dir: string, arg: string) => memoryGitSync(["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
9
+ const top = git(cwd, "--show-toplevel");
10
+ if (!top) return [basename(cwd)];
11
+
12
+ // claude-mem's native identity for a worktree is [parent, parent/worktree].
13
+ // The worktree's .git file points into <parent>/.git/worktrees/<name>; the
14
+ // ordinary superproject walk below remains unchanged for repos and submodules.
15
+ const gitFile = resolve(top, ".git");
16
+ try {
17
+ const match = readFileSync(gitFile, "utf8").trim().match(/^gitdir:\s*(.+)$/i);
18
+ if (match) {
19
+ const gitDir = resolve(top, match[1]!);
20
+ if (/[/\\]\.git[/\\]worktrees[/\\][^/\\]+$/.test(gitDir)) {
21
+ const parentRoot = dirname(dirname(dirname(gitDir)));
22
+ const parent = projectChain(parentRoot);
23
+ const parentName = basename(parentRoot);
24
+ return [...parent, `${parentName}/${basename(top)}`];
25
+ }
26
+ }
27
+ } catch {
28
+ // A missing or unreadable .git file falls through to git's normal chain.
29
+ }
30
+
31
+ const chain: string[] = [];
32
+ for (let dir = top; dir && chain.length < 8; dir = git(dir, "--show-superproject-working-tree")) {
33
+ chain.unshift(basename(dir));
34
+ }
35
+ return chain.length ? chain : [basename(cwd)];
36
+ }
37
+
38
+ /**
39
+ * #258: the name hub memory files a project's entries under. The project's `origin` remote, normalized to host and
40
+ * path (`github.com/owner/repo`), is the same on every machine and unique; without one, the git chain plus a hash of
41
+ * the directory keeps two unrelated repositories that share a name apart (that key is this machine's only).
42
+ */
43
+ export function memoryProjectKey(cwd: string): string {
44
+ const url = memoryGitSync(["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8" }).stdout?.trim() ?? "";
45
+ // 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
46
+ // port is, since another service may run there. An scp-style `host:path` has no port.
47
+ const u = /^([a-z+]+):\/\/(?:[^@/]+@)?(\[[^\]/]+\]|[^/:]+)(?::(\d*))?\/(.+?)(?:\.git)?\/?$/i.exec(url); // an IPv6 host is bracketed
48
+ const defaults: Record<string, string> = { ssh: "22", "git+ssh": "22", "ssh+git": "22", https: "443", http: "80", git: "9418" };
49
+ // A URL's path is absolute (`ssh://h/team/r` is /team/r) unless it starts with `~`; an scp-style path is relative to the
50
+ // remote home (`h:team/r` is ~/team/r) unless it starts with `/`. Two different repositories on a self-hosted server.
51
+ const scp = u || url.includes("://") ? null : /^(?:[^@/]+@)?(\[[^\]/]+\]|[^/:]+):(.+?)(?:\.git)?\/?$/.exec(url); // a file:// remote has no host; an IPv6 host is bracketed
52
+ const m = u ? [!u[3] || u[3] === defaults[u[1]!.toLowerCase()] ? u[2]! : `${u[2]}:${u[3]}`, u[4]!] as const
53
+ : scp ? [scp[1]!, scp[2]!.startsWith("/") ? scp[2]!.replace(/^\/+/, "") : scp[2]!.startsWith("~") ? scp[2]! : `~/${scp[2]}`] as const : undefined;
54
+ if (m && m[1]) {
55
+ const host = m[0].toLowerCase();
56
+ // GitHub serves one repository under every form, and its owner and repository names ignore case.
57
+ return host === "github.com" ? `${host}/${m[1].replace(/^~\/?/, "").toLowerCase()}` : `${host}/${m[1]}`;
58
+ }
59
+ let real = cwd;
60
+ try { real = realPath(cwd); } catch { /* as given */ }
61
+ return `${projectChain(cwd).join("/")}@${createHash("sha256").update(real).digest("hex").slice(0, 8)}`;
62
+ }
63
+
64
+ /**
65
+ * Every path-like word of a text (`dir/file`, `file.ext`), whether or not it exists or a denylist hides it from the
66
+ * graph: what a private-path policy is checked against.
67
+ */
68
+ export const pathCandidates = (text: string): string[] => [...new Set(text.normalize("NFC").match(/\/?[\p{L}\p{M}\p{N}_.~-]+(?:\/[\p{L}\p{M}\p{N}_.~-]+)+|[\p{L}\p{M}\p{N}_-]+\.\p{L}[\p{L}\p{M}\p{N}_]{0,9}/gu) ?? [])]; // any script, combining marks included; an absolute path keeps its slash
69
+
70
+ /** The file a graph node names: `path:<p>` and `symbol:<name>@<p>`; nothing for other nodes. */
71
+ export const nodePath = (n: string): string | undefined => n.startsWith("path:") ? n.slice(5) : n.startsWith("symbol:") && n.includes("@") ? n.slice(n.lastIndexOf("@") + 1) : undefined;
72
+