@staix/agent-hub 0.12.29 → 0.12.31

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 (95) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +92 -218
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +3 -2
  5. package/docs/agent-notes/budget.md +1 -1
  6. package/docs/agent-notes/bus.md +7 -1
  7. package/docs/agent-notes/daemon.md +14 -1
  8. package/docs/agent-notes/local-worker.md +4 -2
  9. package/docs/agent-notes/tasks.md +14 -0
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/memory.md +70 -152
  12. package/docs/operations.md +317 -25
  13. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  14. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  15. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  16. package/docs/plans/amem-cases.json +482 -0
  17. package/docs/quickstart.md +24 -2
  18. package/docs/security.md +42 -2
  19. package/docs/smoke.md +11 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +142 -7
  21. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
  22. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  23. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  24. package/docs/verification/2026-10-17-418-probes.md +21 -0
  25. package/docs/verified.json +13 -13
  26. package/package.json +5 -2
  27. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  28. package/plugins/agent-hub/server.js +129 -62
  29. package/src/adapters/claude-channel.ts +29 -11
  30. package/src/adapters/local-worker.ts +2 -2
  31. package/src/adapters/pi.ts +5 -0
  32. package/src/cli/attention.ts +5 -1
  33. package/src/cli/console-state.ts +145 -29
  34. package/src/cli/console.ts +40 -10
  35. package/src/cli/help.ts +3 -2
  36. package/src/cli/identity.ts +4 -3
  37. package/src/cli/jcode-mcp.ts +1 -1
  38. package/src/cli/main.js +2 -2
  39. package/src/cli/main.ts +43 -14
  40. package/src/cli/memory.ts +87 -187
  41. package/src/cli/output.ts +19 -2
  42. package/src/cli/recovery-package.ts +59 -10
  43. package/src/cli/upgrade-interactive.ts +2 -1
  44. package/src/cli/upgrade-restore-state.ts +133 -0
  45. package/src/cli/upgrade-runtime.ts +204 -63
  46. package/src/cli/upgrade.ts +57 -24
  47. package/src/hub/board.ts +83 -24
  48. package/src/hub/bus.ts +108 -38
  49. package/src/hub/check-runner.ts +514 -0
  50. package/src/hub/child-process.ts +1 -0
  51. package/src/hub/control-client.ts +5 -1
  52. package/src/hub/daemon.ts +529 -89
  53. package/src/hub/delivery-hints.ts +30 -0
  54. package/src/hub/delivery-journal.ts +185 -12
  55. package/src/hub/delivery-superseded.ts +52 -0
  56. package/src/hub/envelope.ts +31 -1
  57. package/src/hub/git.ts +2 -38
  58. package/src/hub/host-executable.ts +11 -0
  59. package/src/hub/hub-tools.ts +14 -2
  60. package/src/hub/manager.ts +1 -1
  61. package/src/hub/memory-candidates.ts +103 -0
  62. package/src/hub/memory-fail.ts +25 -0
  63. package/src/hub/memory-observations.ts +47 -0
  64. package/src/hub/memory-plan-guard.ts +33 -0
  65. package/src/hub/memory-policy.ts +42 -0
  66. package/src/hub/memory-profile-check.ts +170 -0
  67. package/src/hub/memory-profile.ts +192 -0
  68. package/src/hub/memory-staleness.ts +55 -0
  69. package/src/hub/path-policy.ts +8 -0
  70. package/src/hub/peer-catalog.ts +3 -0
  71. package/src/hub/project.ts +4 -37
  72. package/src/hub/read-tools.ts +127 -0
  73. package/src/hub/routing.ts +35 -16
  74. package/src/hub/settings.ts +2 -1
  75. package/src/hub/spec-read.ts +120 -0
  76. package/src/hub/supervision.ts +21 -6
  77. package/src/hub/task-sweep.ts +7 -6
  78. package/src/hub/tasks.ts +370 -71
  79. package/src/local/deny.ts +4 -17
  80. package/src/memory/brief.ts +8 -5
  81. package/src/memory/capture.ts +3 -1
  82. package/src/memory/recall.ts +50 -110
  83. package/src/pi/process-signature.ts +2 -42
  84. package/src/ui/index.html +12 -9
  85. package/src/ui/needs.ts +7 -5
  86. package/src/ui/office-model.ts +4 -1
  87. package/src/ui/task-board.ts +1 -1
  88. package/src/ui/task-progress.ts +6 -2
  89. package/templates/AGENTS.block.md +1 -1
  90. package/templates/profiles/general.json +1 -1
  91. package/templates/profiles/research.json +1 -1
  92. package/templates/profiles/writing.json +1 -1
  93. package/src/memory/import.ts +0 -90
  94. package/src/memory/store.ts +0 -805
  95. package/src/memory/sync.ts +0 -145
@@ -0,0 +1,103 @@
1
+ import { looksSecret, type CandidateStore, type CounterKey, type MemoryStore, type View } from "@staix/agent-mem";
2
+ import type { MemoryScreen } from "./read-tools.ts";
3
+ import { PreEffectToolRefusal } from "./tool-refusal.ts";
4
+
5
+ const bytes = (text: string) => Buffer.byteLength(text, "utf8");
6
+ const refuse = (): never => { throw new PreEffectToolRefusal("memory read unavailable"); };
7
+ export interface CorrectionInput { id: string; text: string; reason: string }
8
+
9
+ /** Validate model input before resolving a memory id or opening the candidate sidecar. */
10
+ export function correctionInput(args: unknown): CorrectionInput {
11
+ if (!args || typeof args !== "object" || Array.isArray(args)) refuse();
12
+ const input = args as Record<string, unknown>;
13
+ const string = (value: unknown, min: number, max: number): value is string => typeof value === "string" && value.trim().length > 0 && !value.includes("\0") && bytes(value) >= min && bytes(value) <= max;
14
+ if (Object.keys(input).some(key => !["id", "text", "reason"].includes(key)) || !string(input.id, 6, 64) || !string(input.text, 1, 4000) || !string(input.reason, 1, 4000)) refuse();
15
+ const result = { id: input.id, text: input.text, reason: input.reason } as CorrectionInput;
16
+ if (bytes(JSON.stringify(result)) > 8192) refuse();
17
+ return result;
18
+ }
19
+
20
+ function safe(value: unknown, screen: MemoryScreen): boolean {
21
+ try {
22
+ const text = typeof value === "string" ? value : JSON.stringify(value);
23
+ return !looksSecret(text) && screen([{ title: null, text }]).length === 1;
24
+ } catch { return false; }
25
+ }
26
+
27
+ function visibleId(input: string, screen: MemoryScreen): string {
28
+ if (typeof input !== "string" || bytes(input) < 6 || bytes(input) > 64 || !safe(input, screen)) refuse();
29
+ const id = input.replace(/^mem:/, "");
30
+ if (id.length < 6) refuse();
31
+ return id;
32
+ }
33
+
34
+ /** One screened projection per operation; never retained across calls or revisions. */
35
+ function visibleResolver(store: MemoryStore, project: string, screen: MemoryScreen): (id: string) => View {
36
+ const all = screen(store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true })).filter(view => safe(view, screen));
37
+ const byId = new Map(all.map(view => [view.id, view]));
38
+ const promoted = new Map<string, View[]>();
39
+ for (const view of all) if (view.promotedFrom) {
40
+ const group = promoted.get(view.promotedFrom) ?? [];
41
+ group.push(view); promoted.set(view.promotedFrom, group);
42
+ }
43
+ const aliases = [...new Set([...byId.keys(), ...promoted.keys()])], exactAliases = new Set(aliases);
44
+ return id => {
45
+ const matched = exactAliases.has(id) ? [id] : aliases.filter(alias => alias.startsWith(id));
46
+ let found: View | undefined;
47
+ if (matched.length === 1) {
48
+ found = byId.get(matched[0]!);
49
+ if (!found) {
50
+ const copies = promoted.get(matched[0]!) ?? [];
51
+ if (copies.length === 1) found = copies[0];
52
+ }
53
+ } else if (matched.length === 0) {
54
+ // Older promotion chains need the core's redirect, but may terminate only in this screened snapshot.
55
+ const redirected = store.get(id, project);
56
+ found = redirected && byId.get(redirected.id);
57
+ }
58
+ if (!found || found.supersededBy) return refuse();
59
+ return found;
60
+ };
61
+ }
62
+
63
+ /** Resolve only screened visible ids and aliases; unavailable and ambiguous inputs have the same refusal. */
64
+ export function resolveVisibleMemory(store: MemoryStore, project: string, screen: MemoryScreen, input: string): View {
65
+ const id = visibleId(input, screen); // Input rejection precedes any storage work.
66
+ try { return store.withDeferredPromotions(() => visibleResolver(store, project, screen)(id)); }
67
+ catch { return refuse(); }
68
+ }
69
+
70
+ export type MemoryCounterContext = Omit<CounterKey, "entryId" | "project">;
71
+ export type MemoryCounterField = "rendered" | "delivered" | "cited";
72
+
73
+ /** Sidecar-only observations: the absent fields stay unknown; no message body is persisted. */
74
+ export class MemoryTelemetry {
75
+ constructor(private readonly sidecar: CandidateStore, private readonly store: MemoryStore, private readonly project: string, private readonly screen: MemoryScreen, private readonly memoryCheck?: () => void) {}
76
+
77
+ record(text: string, context: MemoryCounterContext, field: MemoryCounterField, privateTurn = false): void {
78
+ if (privateTurn || !["rendered", "delivered", "cited"].includes(field)) return;
79
+ try {
80
+ this.memoryCheck?.();
81
+ if (!safe(context, this.screen)) return;
82
+ const ids = new Set<string>();
83
+ for (const match of text.matchAll(/\bmem:([a-f0-9]{8,64})(?![a-zA-Z0-9])/g)) {
84
+ try { ids.add(visibleId(match[1]!, this.screen)); } catch { /* unavailable input */ }
85
+ }
86
+ if (!ids.size) return;
87
+ this.memoryCheck?.();
88
+ this.store.withDeferredPromotions(() => {
89
+ const resolve = visibleResolver(this.store, this.project, this.screen), seen = new Set<string>();
90
+ for (const id of ids) {
91
+ try {
92
+ this.memoryCheck?.();
93
+ const view = resolve(id);
94
+ if (seen.has(view.id)) continue;
95
+ seen.add(view.id);
96
+ this.memoryCheck?.();
97
+ this.sidecar.recordCounter({ ...context, project: this.project, entryId: view.id }, { [field]: 1 }, "public");
98
+ } catch { /* A hidden, ambiguous or unavailable citation reveals no counters or diagnostics. */ }
99
+ }
100
+ });
101
+ } catch { /* Sidecar telemetry never blocks a delivery or an answer. */ }
102
+ }
103
+ }
@@ -0,0 +1,25 @@
1
+ export const REMEMBER_TEXT_MAX_BYTES = 4000;
2
+ const FAIL_FIELDS = [
3
+ ["approach", "Approach"], ["symptom", "Symptom"], ["cause", "Cause"], ["evidence", "Evidence"], ["retry_when", "Retry when"],
4
+ ] as const;
5
+
6
+ /** Compose ordinary v1 text only. Validation precedes policy screening, storage and display caps. */
7
+ export function composeRememberText(input: unknown): string {
8
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("memory note must be an object");
9
+ const note = input as Record<string, unknown>;
10
+ if (note.text !== undefined && typeof note.text !== "string") throw new Error("text must be a string");
11
+ const text = typeof note.text === "string" ? note.text.trim() : "";
12
+ const fields: string[] = [];
13
+ for (const [name, label] of FAIL_FIELDS) {
14
+ const value = note[name];
15
+ if (value === undefined) continue;
16
+ if (note.kind !== "fail") throw new Error(`${name} is only available for kind fail`);
17
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${name} must be a nonempty string`);
18
+ fields.push(`${label}: ${value.trim()}`);
19
+ }
20
+ if (!text && !fields.length) throw new Error("text is required, or supply structured fields with kind fail");
21
+ const result = text && fields.length ? `${text}\n\n${fields.join("\n")}` : text || fields.join("\n");
22
+ const bytes = Buffer.byteLength(result, "utf8");
23
+ if (bytes > REMEMBER_TEXT_MAX_BYTES) throw new Error(`memory text is ${bytes} UTF-8 bytes; maximum is ${REMEMBER_TEXT_MAX_BYTES} bytes (refused without truncation)`);
24
+ return result;
25
+ }
@@ -0,0 +1,47 @@
1
+ import type { Envelope, PeerId } from "./envelope.ts";
2
+ import { HUB } from "./envelope.ts";
3
+ import type { MemoryCounterContext, MemoryTelemetry } from "./memory-candidates.ts";
4
+
5
+ export type MemoryObservationContext = Omit<MemoryCounterContext, "deliveryId">;
6
+ /** Only positively public context is returned. Missing identity fields remain unknown. */
7
+ export type MemoryContext = (peer: PeerId, envelopes: readonly Envelope[]) => MemoryObservationContext | undefined;
8
+
9
+ /** Bind an acknowledgement to the render's context, never to a later turn or session. */
10
+ export class MemoryObservations {
11
+ private readonly pending = new Map<string, { peer: PeerId; context: MemoryObservationContext; text: string }>();
12
+ constructor(private readonly telemetry: MemoryTelemetry, private readonly context: MemoryContext) {}
13
+
14
+ delivery(peer: PeerId, deliveryId: string, out: Envelope[], field: "rendered" | "delivered"): void {
15
+ try {
16
+ if (field === "rendered") {
17
+ const context = this.context(peer, out);
18
+ if (!context || out.some(env => env.private)) return;
19
+ // Ordinary quoted citations are counted as citations, not as a fresh recall rendering.
20
+ const text = out.filter(env => env.kind === "presence" || env.from === HUB && /Memory brief \(/.test(env.body)).map(env => env.body).join("\n");
21
+ if (!text.includes("mem:")) return;
22
+ this.pending.set(deliveryId, { peer, context, text });
23
+ // ponytail: retain at most 2048 live render contexts; an evicted or pre-restart acknowledgement stays unknown.
24
+ // Persist ids-only render context with the journal if measurements need complete crash/recovery coverage.
25
+ while (this.pending.size > 2048) this.pending.delete(this.pending.keys().next().value!);
26
+ this.telemetry.record(text, { ...context, deliveryId }, "rendered");
27
+ } else {
28
+ const prior = this.pending.get(deliveryId);
29
+ if (!prior || prior.peer !== peer) return;
30
+ this.pending.delete(deliveryId);
31
+ const now = this.context(peer, out);
32
+ if (!now || out.some(env => env.private) || now.session !== prior.context.session) return;
33
+ this.telemetry.record(prior.text, { ...prior.context, deliveryId }, "delivered");
34
+ }
35
+ } catch { /* optional sidecar observations never change transport or task outcomes */ }
36
+ }
37
+
38
+ // ponytail: citations without a proven source delivery use message:/summary: observation ids, not journal receipts.
39
+ // Join citations to a verified source delivery if per-delivery causal ratios become a study requirement.
40
+ cite(peer: PeerId, text: string, deliveryId: string, envelopes: readonly Envelope[] = []): void {
41
+ try {
42
+ const context = this.context(peer, envelopes);
43
+ if (!context || envelopes.some(env => env.private)) return;
44
+ this.telemetry.record(text, { ...context, deliveryId }, "cited");
45
+ } catch { /* unknown, never a fabricated zero */ }
46
+ }
47
+ }
@@ -0,0 +1,33 @@
1
+ import { extractNodes, nodePath, type View } from "@staix/agent-mem";
2
+ import type { BriefTask } from "../memory/brief.ts";
3
+
4
+ export interface PlanGuardContext { cwd: string; project: string; repo: string; deny?: string[] }
5
+
6
+ /** Explicit plan/ref names only, with no filesystem probes or free-text inference. */
7
+ export function planGuardNodes(task: BriefTask, context: PlanGuardContext): string[] {
8
+ // Task refs/plan allow 150 explicit inputs; v1 note extraction caps each result at 64 nodes.
9
+ // Normalize each input separately, retaining the original single-file symbol qualification rule.
10
+ const nodes = new Set<string>();
11
+ for (const path of [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])]) {
12
+ for (const node of extractNodes("", { ...context, paths: [path], checkExists: false })) nodes.add(node);
13
+ }
14
+ const onlyPath = nodes.size === 1 ? nodePath([...nodes][0]!) : undefined;
15
+ for (const symbol of task.plan?.symbols ?? []) {
16
+ for (const node of extractNodes("", { ...context, paths: onlyPath ? [onlyPath] : [], symbols: [symbol], checkExists: false })) nodes.add(node);
17
+ }
18
+ return [...nodes].sort();
19
+ }
20
+ const samePath = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
21
+
22
+ /** Fail.about is the source of the graph's rules_out edges in v1; there is no additional stored rules_out field. */
23
+ export function matchingPlanFails<T extends Pick<View, "kind" | "about" | "supersededBy">>(task: BriefTask, notes: readonly T[], context: PlanGuardContext): T[] {
24
+ const nodes = planGuardNodes(task, context);
25
+ if (!nodes.length) return [];
26
+ return notes.filter(note => note.kind === "fail" && !note.supersededBy && note.about.some(about => nodes.some(node => {
27
+ if (node === about) return true;
28
+ if (node.startsWith("symbol:") && about.startsWith("symbol:")) return false; // Qualified symbols require both name and file identity.
29
+ const path = nodePath(node), other = nodePath(about);
30
+ // File/directory scope intersects at a whole path component, never a lexical substring.
31
+ return !!path && !!other && samePath(path, other);
32
+ })));
33
+ }
@@ -0,0 +1,42 @@
1
+ import { detectSignals, type Routing } from "./routing.ts";
2
+ import { matchesPolicy } from "./path-policy.ts";
3
+ import { looksSecret, type PromotionScreen } from "@staix/agent-mem";
4
+ import { nodePath, pathCandidates } from "@staix/agent-mem";
5
+
6
+ /**
7
+ * Whether a note is about a path the `pii.paths` policy makes private: a stored node's path, a path-like word of its
8
+ * text, or, for a policy entry with no glob that a word split cannot find (a space in it), that entry written out.
9
+ */
10
+ export function mentionsPrivatePath(text: string, about: string[], policy: string[], cwd: string): boolean {
11
+ if (!policy.length) return false;
12
+ const nfc = (v: string) => v.normalize("NFC"), body = nfc(text), entries = policy.map(nfc);
13
+ const paths = [...about.map(nodePath), ...pathCandidates(body)].filter((p): p is string => p !== undefined).map(nfc);
14
+ if (paths.some((p) => matchesPolicy(cwd, p, entries, true))) return true;
15
+ // The entry's literal part (up to its first glob character) written out: catches a path a word split breaks (a space).
16
+ return entries.some((entry) => { const lit = entry.replace(/^\.\//, "").split(/[*?[\]{}]/)[0]!.replace(/\/+$/, ""); return /[/\s]/.test(lit) && body.includes(lit); });
17
+ }
18
+
19
+ /**
20
+ * Notes whose title, text, nodes, task key or project key match the PII patterns of `routing`, or that are about a path
21
+ * the project's `pii.paths` (`policy`) makes private, left out: what may go to a model.
22
+ */
23
+ 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[] {
24
+ // A task key's `#<id>@<created ms>` is the hub's own numbers, not note content: a digit-run pattern must not withhold a
25
+ // clean note for it. The project part of the key stays screened.
26
+ const keyText = (k: string) => k.replace(/#\d+(?:@\d+)?$/, "");
27
+ return notes.filter((v) => !mentionsPrivatePath(`${v.title ?? ""}\n${v.text}`, v.about ?? [], policy, cwd) && !detectSignals({ title: v.title ?? "",
28
+ 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"));
29
+ }
30
+
31
+ /**
32
+ * The promotion screen for one project: another project's notes are left to that project's own processes; this one's
33
+ * move only when no secret, PII pattern or private path (`policy()`, read when asked) is in them.
34
+ */
35
+ export function promotionScreen(project: string, cwd: string, routing: () => Routing, policy: () => string[], taskPaths: (task: string) => string[] = () => []): PromotionScreen {
36
+ return (v) => {
37
+ if (v.project !== project) return "skip";
38
+ // The task's current paths too, with no denylist: a path the note's own nodes leave out is still checked.
39
+ const screened = { ...v, about: [...v.about, ...(v.task ? taskPaths(v.task) : []).map((p) => `path:${p}`)] };
40
+ return looksSecret(`${v.title ?? ""}\n${v.text}`) || !withoutPii([screened], routing(), cwd, policy()).length ? "keep" : "promote";
41
+ };
42
+ }
@@ -0,0 +1,170 @@
1
+ import { accessSync, constants, lstatSync } from "node:fs";
2
+ import { join, parse, resolve, sep } from "node:path";
3
+ import { registeredProjects } from "../cli/upgrade.ts";
4
+ import { ControlClient, readControl, type ControlDescriptor } from "./control-client.ts";
5
+ import { canonicalPath, hubHome, realPath } from "./project.ts";
6
+ import { processLiveness, type Liveness } from "../pi/process-signature.ts";
7
+ import type { Project } from "./registry.ts";
8
+ import type { UnboundMemoryProfileReceipt } from "./memory-profile.ts";
9
+
10
+ export interface ExpectedMemoryProfile {
11
+ home: string;
12
+ profile: "shared-v1" | "generation";
13
+ generation?: string;
14
+ selectorRevision: string;
15
+ }
16
+ export interface RunningMemoryProfile extends ExpectedMemoryProfile {
17
+ agentMemVersion: string;
18
+ state: "ready" | "stale" | "unavailable";
19
+ skippedRows?: number;
20
+ }
21
+ export type UnboundMemoryProfile = UnboundMemoryProfileReceipt;
22
+ export type ReportedMemoryProfile = RunningMemoryProfile | UnboundMemoryProfile;
23
+ export interface ProfileEvidence {
24
+ project: Project;
25
+ liveness: Liveness;
26
+ descriptor?: ControlDescriptor;
27
+ status?: unknown;
28
+ /** Resolved only after authenticated status; unknown is never treated as a different home. */
29
+ canonicalHome?: string;
30
+ }
31
+ export interface CheckedMemoryDaemon {
32
+ projectId: string;
33
+ projectRoot: string;
34
+ pid: number | null;
35
+ instanceId: string | null;
36
+ hubVersion: string | null;
37
+ agentMemVersion: string | null;
38
+ memoryProfile: ReportedMemoryProfile | null;
39
+ scope: "same-home" | "different-home" | "unknown";
40
+ ready: boolean;
41
+ quiesced?: true;
42
+ reason?: string;
43
+ }
44
+ export interface MemoryProfileCheck {
45
+ ready: boolean;
46
+ expected: ExpectedMemoryProfile;
47
+ /** Only this registry was inventoried; other custom registries are outside this receipt. */
48
+ registryHome?: string;
49
+ daemons: CheckedMemoryDaemon[];
50
+ reason?: string;
51
+ }
52
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
53
+ const revision = (value: unknown): value is string => value === "absent" || typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
54
+ const generationCapable = (version: string): boolean => {
55
+ const parts = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(version);
56
+ return !!parts && (Number(parts[1]) > 0 || Number(parts[2]) >= 3);
57
+ };
58
+ function receipt(value: unknown): ReportedMemoryProfile | undefined {
59
+ if (!object(value) || typeof value.home !== "string" || !value.home || value.home.length > 4096 || typeof value.agentMemVersion !== "string") return undefined;
60
+ if (!Object.hasOwn(value, "profile") && ["disabled", "unavailable"].includes(String(value.state))) {
61
+ if (["generation", "selectorRevision", "skippedRows"].some(field => Object.hasOwn(value, field))) return undefined;
62
+ return { home: value.home, agentMemVersion: value.agentMemVersion, state: value.state as UnboundMemoryProfile["state"] };
63
+ }
64
+ if (!["shared-v1", "generation"].includes(String(value.profile)) || !["ready", "stale", "unavailable"].includes(String(value.state)) || !revision(value.selectorRevision)
65
+ || value.profile === "generation" && (typeof value.generation !== "string" || !/^[a-f0-9]{32}$/.test(value.generation))
66
+ || value.profile === "shared-v1" && value.generation !== undefined
67
+ || value.skippedRows !== undefined && (!Number.isSafeInteger(value.skippedRows) || Number(value.skippedRows) < 0)) return undefined;
68
+ return { home: value.home, profile: value.profile as RunningMemoryProfile["profile"], ...(value.generation ? { generation: value.generation as string } : {}),
69
+ selectorRevision: value.selectorRevision, state: value.state as RunningMemoryProfile["state"], agentMemVersion: value.agentMemVersion,
70
+ ...(value.skippedRows !== undefined ? { skippedRows: value.skippedRows as number } : {}) };
71
+ }
72
+
73
+ /** Evaluate finite evidence only; installed package metadata is deliberately not an input. */
74
+ export function evaluateMemoryProfileCheck(expected: ExpectedMemoryProfile, evidence: readonly ProfileEvidence[]): MemoryProfileCheck {
75
+ const daemons: CheckedMemoryDaemon[] = [];
76
+ for (const item of evidence) {
77
+ if (item.liveness === "gone") continue;
78
+ const { project, descriptor, status } = item;
79
+ const result: CheckedMemoryDaemon = { projectId: project.id, projectRoot: project.root, pid: project.pid, instanceId: project.instanceId,
80
+ hubVersion: null, agentMemVersion: null, memoryProfile: null, scope: "unknown", ready: false };
81
+ const reject = (reason: string) => { result.reason = reason; daemons.push(result); };
82
+ if (item.liveness !== "live" || !project.pidSignature || !project.instanceId || !project.pid) { reject("running owner identity is unknown"); continue; }
83
+ if (!descriptor || descriptor.pid !== project.pid || descriptor.pidSignature !== project.pidSignature || descriptor.projectId !== project.id
84
+ || descriptor.instanceId !== project.instanceId || descriptor.cwd !== project.root || !object(status) || status.pid !== project.pid
85
+ || status.projectId !== project.id || status.instanceId !== project.instanceId || status.cwd !== project.root) { reject("authenticated running identity was not confirmed"); continue; }
86
+ if (typeof status.version === "string") result.hubVersion = status.version;
87
+ const bound = receipt(status.memoryProfile);
88
+ if (!bound || !item.canonicalHome) { reject("running memory home/profile receipt is unavailable"); continue; }
89
+ result.memoryProfile = bound; result.agentMemVersion = bound.agentMemVersion;
90
+ if (item.canonicalHome !== expected.home) { result.scope = "different-home"; result.reason = "different canonical memory home"; daemons.push(result); continue; }
91
+ result.scope = "same-home";
92
+ if (!result.hubVersion || !/^[0-9]+\.[0-9]+\.[0-9]+$/.test(result.hubVersion) || !generationCapable(bound.agentMemVersion)) { reject("running memory library does not confirm generation support"); continue; }
93
+ if (bound.profile === undefined) {
94
+ result.ready = true; result.quiesced = true; result.reason = `memory handle ${bound.state}; quiesced`; daemons.push(result); continue;
95
+ }
96
+ if (bound.state !== "ready") { reject("running memory binding is not ready"); continue; }
97
+ if (bound.profile !== expected.profile || bound.generation !== expected.generation || bound.selectorRevision !== expected.selectorRevision) { reject("running memory binding differs from the selected profile"); continue; }
98
+ result.ready = true; daemons.push(result);
99
+ }
100
+ const relevant = daemons.filter(row => row.scope !== "different-home");
101
+ const ready = relevant.every(row => row.ready);
102
+ return { ready, expected, daemons, ...(!relevant.length ? { reason: "no live binding in this registry for this memory home; quiesced in this inventory only" } : {}) };
103
+ }
104
+
105
+ /** Reuse the recovery planner's readonly SQLite reader: never constructs/migrates a Registry. */
106
+ export function readProfileCheckProjects(registryHome = hubHome()): Project[] {
107
+ const absolute = resolve(registryHome), root = parse(absolute).root;
108
+ let cursor = root;
109
+ for (const part of absolute.slice(root.length).split(sep).filter(Boolean)) {
110
+ cursor = join(cursor, part);
111
+ let stat: ReturnType<typeof lstatSync>;
112
+ try { stat = lstatSync(cursor); }
113
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw new Error("profile-check registry path unavailable"); }
114
+ // Existing directory aliases are supported, but a dangling alias is not proof of an absent registry.
115
+ if (stat.isSymbolicLink()) { try { stat = lstatSync(realPath(cursor)); } catch { throw new Error("profile-check registry path unavailable"); } }
116
+ if (!stat.isDirectory() || !(Number(stat.mode) & 0o111)) throw new Error("profile-check registry path unavailable");
117
+ accessSync(cursor, constants.X_OK);
118
+ }
119
+ const file = join(absolute, "registry.db");
120
+ let stat: ReturnType<typeof lstatSync>;
121
+ try { stat = lstatSync(file); }
122
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw new Error("profile-check registry unavailable"); }
123
+ if (!stat.isFile() || stat.isSymbolicLink() || !(Number(stat.mode) & 0o444)) throw new Error("profile-check registry unavailable");
124
+ accessSync(file, constants.R_OK);
125
+ return registeredProjects(canonicalPath(absolute));
126
+ }
127
+
128
+ export async function probeMemoryProfile(project: Project): Promise<ProfileEvidence> {
129
+ const liveness = project.pid ? processLiveness(project.pid, project.pidSignature) : project.instanceId ? "unknown" : "gone";
130
+ const evidence: ProfileEvidence = { project, liveness };
131
+ if (liveness !== "live" || !project.pidSignature || !project.instanceId) return evidence;
132
+ const descriptor = readControl(project.stateDir);
133
+ evidence.descriptor = descriptor;
134
+ if (!descriptor || descriptor.pid !== project.pid || descriptor.pidSignature !== project.pidSignature || descriptor.projectId !== project.id
135
+ || descriptor.instanceId !== project.instanceId || descriptor.cwd !== project.root) return evidence;
136
+ let client: ControlClient | undefined;
137
+ try {
138
+ client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, instanceId: project.instanceId, projectRoot: project.root }, 3000, descriptor.protocol);
139
+ const reply = await client.request({ t: "status" }, 5000);
140
+ if (reply.t !== "status" || reply.ok === false) return evidence;
141
+ evidence.status = reply.status;
142
+ const bound = object(reply.status) ? receipt(reply.status.memoryProfile) : undefined;
143
+ if (bound) evidence.canonicalHome = canonicalPath(bound.home);
144
+ evidence.liveness = processLiveness(project.pid!, project.pidSignature);
145
+ } catch { /* A timeout, old protocol or refusal is unknown evidence, never permission to migrate. */ }
146
+ finally { client?.close(); }
147
+ return evidence;
148
+ }
149
+
150
+ export async function checkMemoryProfiles(expected: ExpectedMemoryProfile, options: {
151
+ registryHome?: string;
152
+ projects?: readonly Project[];
153
+ probe?: (project: Project) => Promise<ProfileEvidence>;
154
+ } = {}): Promise<MemoryProfileCheck> {
155
+ let projects: readonly Project[];
156
+ let registryHome = resolve(options.registryHome ?? hubHome());
157
+ try { projects = options.projects ?? readProfileCheckProjects(registryHome); registryHome = canonicalPath(registryHome); }
158
+ catch { return { ready: false, expected, registryHome, daemons: [], reason: "project registry could not be read without mutation" }; }
159
+ const evidence: ProfileEvidence[] = [];
160
+ for (const project of projects) evidence.push(await (options.probe ?? probeMemoryProfile)(project));
161
+ return { ...evaluateMemoryProfileCheck({ ...expected, home: canonicalPath(expected.home) }, evidence), registryHome };
162
+ }
163
+
164
+ export function formatMemoryProfileCheck(result: MemoryProfileCheck): string {
165
+ const lines = [`memory profile-check: ${result.ready ? "ready" : "not ready"}`, `home: ${result.expected.home}`, `selected: ${result.expected.profile}${result.expected.generation ? ` ${result.expected.generation}` : ""}`];
166
+ if (result.registryHome) lines.push(`registry inventory: ${result.registryHome}`, "scope: this registry only; check other custom registries separately before migration");
167
+ if (result.reason) lines.push(result.reason);
168
+ for (const row of result.daemons) lines.push(`${row.projectId} pid:${row.pid ?? "unknown"} hub:${row.hubVersion ?? "unknown"} agent-mem:${row.agentMemVersion ?? "unknown"} ${row.quiesced ? "unbound" : row.memoryProfile?.profile ?? "unknown"}${row.memoryProfile?.generation ? ` ${row.memoryProfile.generation}` : ""}: ${row.quiesced ? row.reason : row.ready ? "ready" : row.reason ?? "not ready"}`);
169
+ return lines.join("\n");
170
+ }
@@ -0,0 +1,192 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import {
4
+ CandidateStore, MemoryStore, realPath, memoryHome, resolveProfile,
5
+ type BoundProfile, type ProfileInput, type StoreOptions,
6
+ } from "@staix/agent-mem";
7
+ import packageJson from "../../package.json";
8
+
9
+ const EXPECTED_AGENT_MEM_VERSION = packageJson.dependencies["@staix/agent-mem"];
10
+
11
+ /** Validate metadata located through the actual imported entry, never the hub pin alone. */
12
+ export function assertAgentMemVersion(metadata: unknown, expectedVersion = EXPECTED_AGENT_MEM_VERSION): string {
13
+ if (!metadata || typeof metadata !== "object") throw new Error("cannot attest loaded agent-mem version");
14
+ const value = metadata as { name?: unknown; version?: unknown };
15
+ if (value.name !== "@staix/agent-mem" || typeof value.version !== "string"
16
+ || !/^\d+\.\d+\.\d+$/.test(value.version) || value.version !== expectedVersion) {
17
+ throw new Error("loaded agent-mem version does not match the exact dependency pin");
18
+ }
19
+ return value.version;
20
+ }
21
+
22
+ const loadedAgentMem = (() => {
23
+ try {
24
+ if (!new URL(import.meta.url).pathname.endsWith("/src/hub/memory-profile.ts")) throw new Error("unattested bundled memory implementation");
25
+ const entry = import.meta.resolve("@staix/agent-mem");
26
+ if (!new URL(entry).pathname.endsWith("/src/index.ts")) throw new Error("unknown agent-mem entry layout");
27
+ // The package exports src/index.ts. Bundles without this resolvable layout
28
+ // cannot attest the imported implementation and must refuse to open memory.
29
+ const metadata: unknown = JSON.parse(readFileSync(new URL("../package.json", entry), "utf8"));
30
+ const actual = metadata && typeof metadata === "object" && typeof (metadata as { version?: unknown }).version === "string"
31
+ ? (metadata as { version: string }).version : "unknown";
32
+ try { return { version: assertAgentMemVersion(metadata), valid: true }; }
33
+ catch { return { version: actual, valid: false }; }
34
+ } catch { return { version: "unknown", valid: false }; }
35
+ })();
36
+ export const AGENT_MEM_VERSION = loadedAgentMem.version;
37
+ function assertLoadedAgentMem(): void {
38
+ if (!loadedAgentMem.valid) throw new Error("cannot attest loaded agent-mem version against the exact dependency pin");
39
+ }
40
+ export const MEMORY_PROFILE_UNAVAILABLE = "memory profile unavailable; inspect the selector and generation state, run ahub memory profile-check; use amem migrate --deactivate only to stop using the selected generation";
41
+ const UNAVAILABLE = MEMORY_PROFILE_UNAVAILABLE;
42
+
43
+ /** No handle was opened; distinct from an unavailable, still-bound handle. */
44
+ export interface UnboundMemoryProfileReceipt {
45
+ state: "disabled" | "unavailable";
46
+ home: string;
47
+ agentMemVersion: string;
48
+ error?: string;
49
+ profile?: never;
50
+ generation?: never;
51
+ selectorRevision?: never;
52
+ skippedRows?: never;
53
+ }
54
+
55
+ export interface MemoryProfileReceipt {
56
+ profile: BoundProfile["kind"];
57
+ generation?: string;
58
+ state: "ready" | "unavailable";
59
+ selectorRevision: string;
60
+ home: string;
61
+ agentMemVersion: string;
62
+ error?: string;
63
+ skippedRows: number;
64
+ }
65
+
66
+ function canonicalHome(path: string): string {
67
+ if (existsSync(path)) return realPath(path);
68
+ const parent = dirname(path);
69
+ return parent === path ? resolve(path) : join(canonicalHome(parent), basename(path));
70
+ }
71
+ function sameProfile(a: BoundProfile, b: BoundProfile): boolean {
72
+ return a.home === b.home && a.dir === b.dir && a.kind === b.kind
73
+ && a.generation === b.generation && a.selectorRevision === b.selectorRevision;
74
+ }
75
+ function homeFor(opts: Pick<StoreOptions, "home">): string {
76
+ return canonicalHome(opts.home === undefined ? memoryHome() : join(canonicalHome(opts.home), "memory"));
77
+ }
78
+ function receipt(profile: BoundProfile, state: MemoryProfileReceipt["state"], skippedRows = 0): MemoryProfileReceipt {
79
+ return {
80
+ profile: profile.kind, ...(profile.generation === undefined ? {} : { generation: profile.generation }),
81
+ state, home: profile.home, selectorRevision: profile.selectorRevision, agentMemVersion: AGENT_MEM_VERSION, skippedRows,
82
+ ...(state === "unavailable" ? { error: UNAVAILABLE } : {}),
83
+ };
84
+ }
85
+
86
+ /** Inspect selection without constructing a store, index, or candidate sidecar. */
87
+ export function selectedMemoryProfile(opts: StoreOptions = {}): MemoryProfileReceipt {
88
+ assertLoadedAgentMem();
89
+ return receipt(resolveProfile(homeFor(opts)), "ready");
90
+ }
91
+
92
+ export class MemoryProfileBinding {
93
+ readonly home: string;
94
+ readonly profile: BoundProfile;
95
+ readonly explicitProfile: ProfileInput;
96
+ private readonly options: Pick<StoreOptions, "home">;
97
+ private readonly directMemoryDir: string | undefined;
98
+ private unavailable = false;
99
+
100
+ constructor(opts: StoreOptions = {}, private readonly onUnavailable?: (neutralReason: string) => void, directMemoryDir?: string) {
101
+ assertLoadedAgentMem();
102
+ this.options = { home: opts.home };
103
+ this.directMemoryDir = directMemoryDir;
104
+ this.home = directMemoryDir === undefined ? homeFor(this.options) : canonicalHome(directMemoryDir);
105
+ this.profile = Object.freeze({ ...resolveProfile(this.home) });
106
+ this.explicitProfile = this.profile.kind === "shared-v1" ? "shared-v1" : Object.freeze({ generation: this.profile.generation! });
107
+ }
108
+
109
+ assertCurrent(): void {
110
+ if (this.unavailable) throw new Error(UNAVAILABLE);
111
+ try {
112
+ const currentHome = this.directMemoryDir === undefined ? homeFor(this.options) : canonicalHome(this.directMemoryDir);
113
+ const current = resolveProfile(currentHome);
114
+ if (currentHome !== this.home || !sameProfile(current, this.profile)) throw new Error(UNAVAILABLE);
115
+ } catch {
116
+ this.invalidate();
117
+ throw new Error(UNAVAILABLE);
118
+ }
119
+ }
120
+
121
+ /** Entry fence only; migration requires quiescing and closing all consumers first. */
122
+ run<T>(work: () => T): T {
123
+ this.assertCurrent();
124
+ return work();
125
+ }
126
+
127
+ private invalidate(): void {
128
+ if (this.unavailable) return;
129
+ this.unavailable = true;
130
+ try { this.onUnavailable?.(UNAVAILABLE); } catch { /* Observer failures cannot undo the latch. */ }
131
+ }
132
+
133
+ status(store?: MemoryStore): MemoryProfileReceipt {
134
+ try { this.assertCurrent(); } catch { /* Report the latched state without exposing metadata errors. */ }
135
+ let skippedRows = 0;
136
+ if (!this.unavailable && store) {
137
+ try { skippedRows = store.bridgeStatus()?.skippedRows ?? 0; } catch { this.invalidate(); }
138
+ }
139
+ return receipt(this.profile, this.unavailable ? "unavailable" : "ready", skippedRows);
140
+ }
141
+
142
+ candidateOptions(opts: { home?: string; now?: () => number } = {}): { home?: string; now?: () => number } {
143
+ this.assertCurrent();
144
+ const inheritedHome = this.directMemoryDir === undefined ? this.options.home
145
+ : homeFor({}) === this.home ? undefined : dirname(this.home);
146
+ const options = { ...opts, home: opts.home ?? inheritedHome };
147
+ if (homeFor(options) !== this.home) throw new Error(UNAVAILABLE);
148
+ return options;
149
+ }
150
+ }
151
+
152
+ export function openProfileMemory(opts: StoreOptions = {}, onUnavailable?: (neutralReason: string) => void): {
153
+ store: MemoryStore; binding: MemoryProfileBinding;
154
+ } {
155
+ const binding = new MemoryProfileBinding(opts, onUnavailable);
156
+ binding.assertCurrent();
157
+ const raw = new MemoryStore({ ...opts, profile: binding.explicitProfile });
158
+ try {
159
+ binding.assertCurrent();
160
+ if (!sameProfile(raw.profile, binding.profile)) throw new Error(UNAVAILABLE);
161
+ // ponytail: entry checks exclude an in-flight cutover; quiesce and close
162
+ // consumers before migration. Upgrade to a public core pre-write hook if
163
+ // atomic cutover across an in-flight operation becomes required.
164
+ return { store: raw, binding };
165
+ } catch (error) {
166
+ raw.close();
167
+ throw error;
168
+ }
169
+ }
170
+
171
+ /** Bind a caller-owned store only when it exactly matches the current person selection. */
172
+ export function bindProfileMemory(store: MemoryStore, onUnavailable?: (neutralReason: string) => void): {
173
+ store: MemoryStore; binding: MemoryProfileBinding;
174
+ } {
175
+ const binding = new MemoryProfileBinding({}, onUnavailable, store.profile.home);
176
+ binding.assertCurrent();
177
+ if (!sameProfile(store.profile, binding.profile)) throw new Error(UNAVAILABLE);
178
+ return { store, binding };
179
+ }
180
+
181
+ export function openProfileCandidates(binding: MemoryProfileBinding, opts: { home?: string; now?: () => number } = {}): CandidateStore {
182
+ const options = binding.candidateOptions(opts);
183
+ const raw = new CandidateStore(options);
184
+ try {
185
+ binding.assertCurrent();
186
+ if (canonicalHome(raw.dir) !== binding.home) throw new Error(UNAVAILABLE);
187
+ return raw;
188
+ } catch (error) {
189
+ raw.close();
190
+ throw error;
191
+ }
192
+ }