@staix/agent-hub 0.12.28 → 0.12.30

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 (68) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +79 -219
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -1
  6. package/docs/agent-notes/daemon.md +13 -0
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +9 -0
  9. package/docs/agent-notes/tests.md +1 -1
  10. package/docs/memory.md +70 -152
  11. package/docs/operations.md +237 -14
  12. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  13. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  14. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  15. package/docs/plans/amem-cases.json +482 -0
  16. package/docs/quickstart.md +9 -2
  17. package/docs/security.md +39 -2
  18. package/docs/smoke.md +65 -0
  19. package/docs/specs/2026-09-19-agent-hub-design.md +113 -5
  20. package/docs/verification/2026-10-16-0.12.29.md +25 -0
  21. package/docs/verification/2026-10-16-273-live-verification.md +150 -0
  22. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  23. package/docs/verified.json +13 -13
  24. package/package.json +5 -2
  25. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  26. package/plugins/agent-hub/server.js +64 -44
  27. package/src/cli/console-state.ts +92 -20
  28. package/src/cli/console.ts +25 -6
  29. package/src/cli/help.ts +4 -2
  30. package/src/cli/identity-audit.ts +2 -2
  31. package/src/cli/identity.ts +15 -1
  32. package/src/cli/main.js +2 -2
  33. package/src/cli/main.ts +78 -39
  34. package/src/cli/memory.ts +87 -187
  35. package/src/cli/output.ts +6 -0
  36. package/src/cli/recovery-package.ts +59 -10
  37. package/src/cli/upgrade-runtime.ts +60 -18
  38. package/src/cli/upgrade.ts +8 -0
  39. package/src/hub/board.ts +2 -1
  40. package/src/hub/bus.ts +33 -12
  41. package/src/hub/daemon.ts +141 -31
  42. package/src/hub/envelope.ts +30 -0
  43. package/src/hub/events.ts +1 -1
  44. package/src/hub/git.ts +2 -38
  45. package/src/hub/hub-tools.ts +5 -1
  46. package/src/hub/lifecycle.ts +2 -2
  47. package/src/hub/manager.ts +15 -11
  48. package/src/hub/memory-candidates.ts +103 -0
  49. package/src/hub/memory-fail.ts +25 -0
  50. package/src/hub/memory-observations.ts +47 -0
  51. package/src/hub/memory-plan-guard.ts +33 -0
  52. package/src/hub/memory-policy.ts +42 -0
  53. package/src/hub/memory-profile-check.ts +170 -0
  54. package/src/hub/memory-profile.ts +192 -0
  55. package/src/hub/memory-staleness.ts +55 -0
  56. package/src/hub/path-policy.ts +8 -0
  57. package/src/hub/project.ts +4 -37
  58. package/src/hub/read-tools.ts +127 -0
  59. package/src/hub/tasks.ts +201 -41
  60. package/src/hub/ui.ts +10 -7
  61. package/src/local/deny.ts +4 -17
  62. package/src/memory/brief.ts +8 -5
  63. package/src/memory/capture.ts +2 -1
  64. package/src/memory/recall.ts +50 -110
  65. package/src/pi/process-signature.ts +2 -42
  66. package/src/memory/import.ts +0 -90
  67. package/src/memory/store.ts +0 -805
  68. package/src/memory/sync.ts +0 -145
@@ -28,7 +28,7 @@ export async function related(client: MemoryClient, project: string, query: stri
28
28
  }
29
29
 
30
30
  /** A native memory line for a brief (#258): `mem:<id8> <kind> <tier>: <title or text>`, fails first. */
31
- export interface NativeItem { id: string; line: string; kind?: string }
31
+ export interface NativeItem { id: string; line: string; kind?: string; about?: readonly string[] }
32
32
  export type NativeBrief = (task: BriefTask) => NativeItem[];
33
33
  export interface BriefTask { id?: number; created?: number; title: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }
34
34
 
@@ -37,7 +37,7 @@ export interface BriefTask { id?: number; created?: number; title: string; refs:
37
37
  * entries one or two hops from the task's paths, symbols, issue and task, a linked fail first, superseded entries
38
38
  * dropped), then claude-mem: index first (search), then the neighbourhood of the top hit (timeline); details stay in
39
39
  * the store for the receiver to fetch by id. `brief_items` caps both together. A peer is never shown the same entry
40
- * twice in one hub run.
40
+ * twice in one hub run, except a directly matching plan fail that must accompany a new task.
41
41
  */
42
42
  export class Briefs {
43
43
  private readonly seen = new Map<PeerId, Set<string>>();
@@ -49,13 +49,16 @@ export class Briefs {
49
49
  private readonly native?: NativeBrief,
50
50
  /** Counts of what a brief carried, for the research records (#258 AC11); never its text. */
51
51
  private readonly observe?: (peer: PeerId, task: number, counts: { native: number; fails: number; claudeMem: number }) => void,
52
+ private readonly displayNative?: (items: readonly NativeItem[]) => string[],
52
53
  ) {}
53
54
 
54
- async forTask(peer: PeerId, task: BriefTask): Promise<string | undefined> {
55
+ async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
55
56
  const seen = this.seen.get(peer) ?? new Set<string>();
56
57
  this.seen.set(peer, seen);
57
58
  const own: NativeItem[] = [];
58
- try { for (const item of this.native?.(task) ?? []) if (!seen.has(item.id) && own.length < this.maxItems) own.push(item); } catch { /* fail-open: the store is optional */ }
59
+ // A plan fail is task-specific guidance even when a previous task already carried it.
60
+ for (const item of priority) if (own.length < this.maxItems && !own.some(existing => existing.id === item.id)) own.push(item);
61
+ try { for (const item of this.native?.(task) ?? []) if (!seen.has(item.id) && !own.some(existing => existing.id === item.id) && own.length < this.maxItems) own.push(item); } catch { /* fail-open: the store is optional */ }
59
62
  const found = this.client && own.length < this.maxItems ? await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" ")) : [];
60
63
  const items: BriefItem[] = [];
61
64
  for (const item of found) {
@@ -68,6 +71,6 @@ export class Briefs {
68
71
  for (const item of items) seen.add(`#${item.id}`);
69
72
  if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: own.length, fails: own.filter((i) => i.kind === "fail").length, claudeMem: items.length }); } catch { /* telemetry never fails a brief */ }
70
73
  const header = own.length ? "Memory brief (hub memory first, fails first: ahub memory show <mem id>; claude-mem by #id):" : "Memory brief (claude-mem; fetch details by id if you need them):";
71
- return [header, ...own.map((i) => i.line), ...items.map((i) => `#${i.id} ${i.time} ${i.type} ${i.title}`)].join("\n");
74
+ return [header, ...(this.displayNative?.(own) ?? own.map((i) => i.line)), ...items.map((i) => `#${i.id} ${i.time} ${i.type} ${i.title}`)].join("\n");
72
75
  }
73
76
  }
@@ -1,3 +1,4 @@
1
+ import { MEMORY_READ_TOOLS } from "../hub/read-tools.ts";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
@@ -47,7 +48,7 @@ export class Capture {
47
48
  }
48
49
 
49
50
  observe(call: { tool: string; args: string; output: string; id: string; paths: string[] }): void {
50
- if (!this.sessionId || this.opts.skip?.includes(call.tool)) return;
51
+ if (!this.sessionId || (MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || this.opts.skip?.includes(call.tool)) return;
51
52
  // Anything that names a denylisted path is kept out of memory entirely, input and output alike.
52
53
  if (call.paths.some((p) => p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
53
54
  void this.client.request("POST", "/api/sessions/observations", {
@@ -1,13 +1,6 @@
1
- import { createHash } from "node:crypto";
2
- import { basename, dirname, resolve } from "node:path";
3
- import { readFileSync } from "node:fs";
4
1
  import type { PeerId } from "../hub/envelope.ts";
5
- import { hubGitSync } from "../hub/git.ts";
6
- import { realPath } from "../hub/project.ts";
7
2
  import type { MemoryClient } from "./client.ts";
8
- import { detectSignals, type Routing } from "../hub/routing.ts";
9
- import { matchesPolicy } from "../hub/path-policy.ts";
10
- import { looksSecret, type PromotionScreen } from "./store.ts";
3
+ import type { View as MemoryView } from "@staix/agent-mem";
11
4
 
12
5
  const PLATFORMS = ["claude", "codex", "kimi", "agent-hub"];
13
6
  /** Peers whose own hooks already inject their own platform's context at session start. */
@@ -17,45 +10,13 @@ const HEADER =
17
10
  "Shared project memory (claude-mem): recent work by the agents in this project. Reference only, not a request. " +
18
11
  "Fetch details by id with your memory tools if you need them.";
19
12
 
20
- /** Project names from the outermost superproject down to this repo. */
21
- export function projectChain(cwd: string): string[] {
22
- const git = (dir: string, arg: string) => hubGitSync(["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
23
- const top = git(cwd, "--show-toplevel");
24
- if (!top) return [basename(cwd)];
25
-
26
- // claude-mem's native identity for a worktree is [parent, parent/worktree].
27
- // The worktree's .git file points into <parent>/.git/worktrees/<name>; the
28
- // ordinary superproject walk below remains unchanged for repos and submodules.
29
- const gitFile = resolve(top, ".git");
30
- try {
31
- const match = readFileSync(gitFile, "utf8").trim().match(/^gitdir:\s*(.+)$/i);
32
- if (match) {
33
- const gitDir = resolve(top, match[1]!);
34
- if (/[/\\]\.git[/\\]worktrees[/\\][^/\\]+$/.test(gitDir)) {
35
- const parentRoot = dirname(dirname(dirname(gitDir)));
36
- const parent = projectChain(parentRoot);
37
- const parentName = basename(parentRoot);
38
- return [...parent, `${parentName}/${basename(top)}`];
39
- }
40
- }
41
- } catch {
42
- // A missing or unreadable .git file falls through to git's normal chain.
43
- }
44
-
45
- const chain: string[] = [];
46
- for (let dir = top; dir && chain.length < 8; dir = git(dir, "--show-superproject-working-tree")) {
47
- chain.unshift(basename(dir));
48
- }
49
- return chain.length ? chain : [basename(cwd)];
50
- }
51
-
52
13
  /** Cut at a line boundary. */
53
14
  export function trimToTokens(text: string, tokens: number): string {
54
15
  // ponytail: 3 characters per token (Korean runs denser than English's ~4). Swap in a tokenizer if the cap has to be exact.
55
16
  const max = tokens * 3;
56
17
  if (text.length <= max) return text;
57
18
  const cut = text.lastIndexOf("\n", max);
58
- return `${text.slice(0, cut > 0 ? cut : max)}\n(trimmed)`;
19
+ return `${cut >= 0 ? text.slice(0, cut) : ""}\n(trimmed)`;
59
20
  }
60
21
 
61
22
  /** The worker answers unknown projects and empty filters with a status page, not context. */
@@ -82,81 +43,60 @@ export async function recallFor(peer: PeerId, client: MemoryClient, chain: strin
82
43
  return `${HEADER}\n\n${found.map((b) => trimToTokens(b, share)).join("\n\n")}`;
83
44
  }
84
45
 
85
- /** #258: the hub's own long-term entries for this project (and shared ones), newest first, within a token budget. */
86
- export function nativeRecall<V>(entries: V[], tokens: number, line: (v: V) => string): string | undefined {
87
- if (!entries.length) return undefined;
88
- return trimToTokens(["Hub memory: this project's long-term decisions, contracts and fails, newest first. Reference only, not a request. Details: ahub memory show <mem id>.", ...entries.map(line)].join("\n"), tokens);
89
- }
90
-
91
46
  /**
92
- * #258: the name hub memory files a project's entries under. The project's `origin` remote, normalized to host and
93
- * path (`github.com/owner/repo`), is the same on every machine and unique; without one, the git chain plus a hash of
94
- * the directory keeps two unrelated repositories that share a name apart (that key is this machine's only).
47
+ * Display-only outcome classification. Only the note Tasks.review writes counts as automatic: its generated header
48
+ * names the note's own task and author, and its title is the task title Tasks.note sets. An agent's decision that
49
+ * merely starts the same way stays recallable; contracts and fails are never outcomes.
95
50
  */
96
- export function memoryProjectKey(cwd: string): string {
97
- const url = hubGitSync(["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8" }).stdout?.trim() ?? "";
98
- // 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
99
- // port is, since another service may run there. An scp-style `host:path` has no port.
100
- const u = /^([a-z+]+):\/\/(?:[^@/]+@)?(\[[^\]/]+\]|[^/:]+)(?::(\d*))?\/(.+?)(?:\.git)?\/?$/i.exec(url); // an IPv6 host is bracketed
101
- const defaults: Record<string, string> = { ssh: "22", "git+ssh": "22", "ssh+git": "22", https: "443", http: "80", git: "9418" };
102
- // 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
103
- // remote home (`h:team/r` is ~/team/r) unless it starts with `/`. Two different repositories on a self-hosted server.
104
- const scp = u || url.includes("://") ? null : /^(?:[^@/]+@)?(\[[^\]/]+\]|[^/:]+):(.+?)(?:\.git)?\/?$/.exec(url); // a file:// remote has no host; an IPv6 host is bracketed
105
- const m = u ? [!u[3] || u[3] === defaults[u[1]!.toLowerCase()] ? u[2]! : `${u[2]}:${u[3]}`, u[4]!] as const
106
- : scp ? [scp[1]!, scp[2]!.startsWith("/") ? scp[2]!.replace(/^\/+/, "") : scp[2]!.startsWith("~") ? scp[2]! : `~/${scp[2]}`] as const : undefined;
107
- if (m && m[1]) {
108
- const host = m[0].toLowerCase();
109
- // GitHub serves one repository under every form, and its owner and repository names ignore case.
110
- return host === "github.com" ? `${host}/${m[1].replace(/^~\/?/, "").toLowerCase()}` : `${host}/${m[1]}`;
111
- }
112
- let real = cwd;
113
- try { real = realPath(cwd); } catch { /* as given */ }
114
- return `${projectChain(cwd).join("/")}@${createHash("sha256").update(real).digest("hex").slice(0, 8)}`;
51
+ export function recallDisplayText(note: Pick<MemoryView, "kind" | "text"> & Partial<Pick<MemoryView, "peer" | "task" | "title">>): string | undefined {
52
+ if (note.kind !== "decision") return note.text;
53
+ const [first = "", ...rest] = note.text.split(/\r?\n/);
54
+ const m = /^Task #(\d+) (approved|changes requested) by (\S+): /.exec(first);
55
+ if (!m || m[3] !== note.peer || !note.task?.match(new RegExp(`#${m[1]}@\\d+$`)) || !note.title?.startsWith(`agent-hub task #${m[1]}: `)) return note.text;
56
+ return m[2] === "approved" ? undefined : rest.join("\n").trim() || undefined;
115
57
  }
116
58
 
117
- /**
118
- * Every path-like word of a text (`dir/file`, `file.ext`), whether or not it exists or a denylist hides it from the
119
- * graph: what a private-path policy is checked against.
120
- */
121
- 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
122
-
123
- /**
124
- * Whether a note is about a path the `pii.paths` policy makes private: a stored node's path, a path-like word of its
125
- * text, or, for a policy entry with no glob that a word split cannot find (a space in it), that entry written out.
126
- */
127
- export function mentionsPrivatePath(text: string, about: string[], policy: string[], cwd: string): boolean {
128
- if (!policy.length) return false;
129
- const nfc = (v: string) => v.normalize("NFC"), body = nfc(text), entries = policy.map(nfc);
130
- const paths = [...about.map(nodePath), ...pathCandidates(body)].filter((p): p is string => p !== undefined).map(nfc);
131
- if (paths.some((p) => matchesPolicy(cwd, p, entries, true))) return true;
132
- // The entry's literal part (up to its first glob character) written out: catches a path a word split breaks (a space).
133
- return entries.some((entry) => { const lit = entry.replace(/^\.\//, "").split(/[*?[\]{}]/)[0]!.replace(/\/+$/, ""); return /[/\s]/.test(lit) && body.includes(lit); });
59
+ export const RECALL_LINE_CHARS = 200;
60
+ type RecallNote = Pick<MemoryView, "id" | "kind" | "tier" | "title" | "text"> & Partial<Pick<MemoryView, "peer" | "task" | "about">>;
61
+
62
+ /** A complete-word excerpt with its memory reference intact, capped in Unicode code points. */
63
+ export function recallMemoryLine(note: RecallNote, idChars = 8, stale = false): string | undefined {
64
+ const text = recallDisplayText(note);
65
+ if (text === undefined) return undefined;
66
+ // A rejection's title is another outcome header; only its actionable detail belongs in this view.
67
+ const body = `${stale ? "(stale?) " : ""}${text === note.text && note.title ? `${note.title}: ` : ""}${text}`.replace(/\s+/g, " ").trim();
68
+ const header = `mem:${note.id.slice(0, idChars)} ${note.kind} ${note.tier}: `;
69
+ const chars = Array.from(body), available = RECALL_LINE_CHARS - header.length;
70
+ if (chars.length <= available) return header + body;
71
+ const count = available - 4;
72
+ const prefix = chars.slice(0, count).join("");
73
+ const whole = chars[count] === " " || prefix.endsWith(" ") ? prefix.trimEnd() : prefix.slice(0, Math.max(0, prefix.lastIndexOf(" ")));
74
+ return header + (whole ? `${whole} ...` : "...");
134
75
  }
135
76
 
136
- /** The file a graph node names: `path:<p>` and `symbol:<name>@<p>`; nothing for other nodes. */
137
- export const nodePath = (n: string): string | undefined => n.startsWith("path:") ? n.slice(5) : n.startsWith("symbol:") && n.includes("@") ? n.slice(n.lastIndexOf("@") + 1) : undefined;
138
-
139
- /**
140
- * Notes whose title, text, nodes, task key or project key match the PII patterns of `routing`, or that are about a path
141
- * the project's `pii.paths` (`policy`) makes private, left out: what may go to a model.
142
- */
143
- export function withoutPii<T extends { title: string | null; text: string; about?: string[]; task?: string | null; project?: string }>(notes: T[], routing: Routing, cwd: string, policy: string[] = []): T[] {
144
- // A task key's `#<id>@<created ms>` is the hub's own numbers, not note content: a digit-run pattern must not withhold a
145
- // clean note for it. The project part of the key stays screened.
146
- const keyText = (k: string) => k.replace(/#\d+(?:@\d+)?$/, "");
147
- return notes.filter((v) => !mentionsPrivatePath(`${v.title ?? ""}\n${v.text}`, v.about ?? [], policy, cwd) && !detectSignals({ title: v.title ?? "",
148
- detail: [v.text, ...(v.about ?? []).map((n) => n.startsWith("task:") ? keyText(n) : n), keyText(v.task ?? ""), v.project ?? ""].join("\n"), refs: {} }, routing, cwd).includes("pii"));
77
+ /** Filter before the display cap, so outcome noise cannot displace an older relevant contract or fail. */
78
+ export function recallMemoryLines(notes: readonly RecallNote[], limit = 30, stale: (about: readonly string[]) => boolean = () => false): string[] {
79
+ const prefixes = new Map<string, string | null>();
80
+ for (const note of notes) {
81
+ const prefix = note.id.slice(0, 8), previous = prefixes.get(prefix);
82
+ prefixes.set(prefix, previous === undefined || previous === note.id ? note.id : null);
83
+ }
84
+ const ids = new Set<string>(), lines: string[] = [];
85
+ for (const note of notes) {
86
+ if (ids.has(note.id)) continue;
87
+ // A colliding short prefix is not an identity; its full id keeps both entries visible.
88
+ const idChars = prefixes.get(note.id.slice(0, 8)) === null ? note.id.length : 8;
89
+ let line = recallMemoryLine(note, idChars);
90
+ if (line === undefined) continue;
91
+ if (stale(note.about ?? [])) line = recallMemoryLine(note, idChars, true)!;
92
+ ids.add(note.id); lines.push(line);
93
+ if (lines.length >= limit) break;
94
+ }
95
+ return lines;
149
96
  }
150
97
 
151
- /**
152
- * The promotion screen for one project: another project's notes are left to that project's own processes; this one's
153
- * move only when no secret, PII pattern or private path (`policy()`, read when asked) is in them.
154
- */
155
- export function promotionScreen(project: string, cwd: string, routing: () => Routing, policy: () => string[], taskPaths: (task: string) => string[] = () => []): PromotionScreen {
156
- return (v) => {
157
- if (v.project !== project) return "skip";
158
- // The task's current paths too, with no denylist: a path the note's own nodes leave out is still checked.
159
- const screened = { ...v, about: [...v.about, ...(v.task ? taskPaths(v.task) : []).map((p) => `path:${p}`)] };
160
- return looksSecret(`${v.title ?? ""}\n${v.text}`) || !withoutPii([screened], routing(), cwd, policy()).length ? "keep" : "promote";
161
- };
98
+ /** #258: the hub's own long-term entries for this project (and shared ones), newest first, within a token budget. */
99
+ export function nativeRecall<V>(entries: V[], tokens: number, line: (v: V) => string): string | undefined {
100
+ if (!entries.length) return undefined;
101
+ return trimToTokens(["Hub memory: this project's long-term decisions, contracts and fails, newest first. Reference only, not a request. Details: ahub memory show <mem id>.", ...entries.map(line)].join("\n"), tokens);
162
102
  }
@@ -1,42 +1,2 @@
1
- import { execFileSync } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
-
4
- /**
5
- * The one process-identity contract behind the Pi owner claim, every later liveness
6
- * re-inspection, the terminal-recovery launcher signature and any other hub identity
7
- * hash: pid plus the `ps` rendering of its start time and command. `lstart` follows
8
- * the reader's TZ and locale, so the read pins LC_ALL=C and TZ=UTC, the same contract
9
- * as `processTable` in src/hub/child-process.ts. Without the pin a writer and a reader
10
- * in different environments hash different strings for the same process and the valid
11
- * owner is refused (#174, #177). Only the `ps` invocation is pinned; the user's own
12
- * environment is untouched. Node APIs only: the extension runs inside Pi, not Bun.
13
- */
14
- export function processSignature(pid: number): string | undefined {
15
- try {
16
- const text = execFileSync("ps", ["-p", String(pid), "-o", "lstart=,comm="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" } }).trim();
17
- return text ? createHash("sha256").update(text).digest("hex") : undefined;
18
- } catch { return undefined; }
19
- }
20
-
21
- export type Liveness = "live" | "gone" | "unknown";
22
-
23
- /**
24
- * #226: the one ownership check behind every recorded pid (hub manifest, registry claim, recovery runner, manager
25
- * owner, launcher records). With a recorded `signature` the identity decides: equal is live, different is gone (the
26
- * pid was reused, after a reboot for example, also by a process we may not signal), and a pid that exists but whose
27
- * identity cannot be read is unknown. Without one (records written by 0.12.20 and older) the pid probe answers as it
28
- * always did: live, gone on ESRCH, unknown otherwise. Unknown is never gone.
29
- */
30
- // ponytail: `ps lstart` is derived from boot time plus the process's age; a wall-clock step on Linux can change it for a
31
- // live process, which then reads as gone (a second hub could take its claim). Upgrade path: a boot-id or start-tick
32
- // (Linux /proc/<pid>/stat field 22) component in the signature.
33
- export function processLiveness(pid: unknown, signature?: string | null, identity: (pid: number) => string | undefined = processSignature): Liveness {
34
- if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return "unknown";
35
- if (signature) {
36
- let current: string | undefined;
37
- try { current = identity(pid); } catch { /* unreadable: the pid probe below decides between gone and unknown */ }
38
- if (current) return current === signature ? "live" : "gone";
39
- }
40
- try { process.kill(pid, 0); return signature ? "unknown" : "live"; }
41
- catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? "gone" : "unknown"; }
42
- }
1
+ // Pi ownership and memory sync locks share the same Node-safe core identity contract.
2
+ export { processSignature, processLiveness, type Liveness } from "@staix/agent-mem/platform";
@@ -1,90 +0,0 @@
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 "../hub/project.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
- }