@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
@@ -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
+ }
@@ -0,0 +1,55 @@
1
+ import { lstatSync, type Stats } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import { nodePath } from "@staix/agent-mem";
4
+ import { isDenied } from "../local/deny.ts";
5
+ import { foldSegment } from "./permission-mode.ts";
6
+ import { matchesPolicyLexically } from "./path-policy.ts";
7
+
8
+ export const STALENESS_MAX_PROBES = 32;
9
+ export interface StalenessOptions {
10
+ cwd: string;
11
+ privatePaths?: string[];
12
+ deny?: string[];
13
+ /** A metadata-only seam for privacy and budget regressions. */
14
+ probe?: (path: string) => Pick<Stats, "isSymbolicLink" | "isDirectory">;
15
+ }
16
+
17
+ /** One call-scoped budget/cache, shared by every displayed note. Unknown never produces a marker. */
18
+ export function createStalenessProbe(options: StalenessOptions): (about: readonly string[]) => boolean {
19
+ const root = resolve(options.cwd), cache = new Map<string, boolean>();
20
+ let remaining = STALENESS_MAX_PROBES;
21
+ const allowed = (path: string) => {
22
+ const name = relative(root, resolve(root, path));
23
+ return !isDenied(name, options.deny) && !isDenied(foldSegment(name), options.deny?.map(foldSegment))
24
+ && !matchesPolicyLexically(root, name, [...(options.privatePaths ?? []), ...(options.deny ?? [])]);
25
+ };
26
+ const missing = (path: string): boolean => {
27
+ if (cache.has(path)) return cache.get(path)!;
28
+ cache.set(path, false);
29
+ if (!path || path.length > 4096 || isAbsolute(path) || /[\0\r\n]/.test(path) || path.split("/").includes("..") || !allowed(path)) return false;
30
+ const target = resolve(root, path), rel = relative(root, target);
31
+ if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) return false;
32
+ let current = root;
33
+ // ponytail: private/deny exclusion assumes a tree not modified during this call. A concurrent ancestor swap
34
+ // by a process running as you can expose one existence bit and is outside docs/security.md's threat model.
35
+ // Descriptor-anchored no-follow traversal is needed before supporting adversarial concurrent tree mutation.
36
+ for (const component of rel.split("/")) {
37
+ current = join(current, component);
38
+ if (!remaining || !allowed(current)) return false;
39
+ remaining--;
40
+ try {
41
+ const stat = (options.probe ?? lstatSync)(current);
42
+ // Never follow even a publicly named alias: its target could be private.
43
+ if (stat.isSymbolicLink()) return false;
44
+ if (current !== target && !stat.isDirectory()) return false;
45
+ } catch (error) {
46
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") { cache.set(path, true); return true; }
47
+ return false;
48
+ }
49
+ }
50
+ return false;
51
+ };
52
+ // ponytail: file-level only, so a symbol removed from an existing file is unknown. Add a policy-gated symbol
53
+ // resolver with its own C3 privacy/budget tests before inspecting source contents.
54
+ return about => about.some(node => { const path = nodePath(node); return path !== undefined && missing(path); });
55
+ }
@@ -147,6 +147,14 @@ function globScope(root: string, lexical: string, actual: string, path: string,
147
147
  };
148
148
  return visit(root, start, 0, new Set());
149
149
  }
150
+ /** Policy preflight without filesystem access. Callers must reject symlink traversal separately. */
151
+ export function matchesPolicyLexically(cwd: string, path: string, paths: string[]): boolean {
152
+ const root = resolve(cwd), lexical = resolve(root, path);
153
+ return paths.some(entry => /[?*]/.test(entry)
154
+ ? patternScope(entry.split("/").filter(p => p && p !== "."), new Set([0]), relative(root, lexical), false)
155
+ : beneath(lexical, resolve(root, entry)));
156
+ }
157
+
150
158
  /** Compare declared spelling, real targets and bounded aliases; legacy discovery errors fail closed. */
151
159
  export function matchesPolicy(cwd: string, path: string, paths: string[], includeParents = false): boolean {
152
160
  if (!paths.length) return false;
@@ -1,43 +1,10 @@
1
- import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
1
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
- import { spawnSync } from "node:child_process";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
4
  import { hubGitSync } from "./git.ts";
6
5
 
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
- }
6
+ import { canonicalPath as canonical } from "@staix/agent-mem/platform";
7
+ export { canonicalPath, realPath } from "@staix/agent-mem/platform";
41
8
 
42
9
  function gitRoot(dir: string): string | undefined {
43
10
  const result = hubGitSync(["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
@@ -0,0 +1,127 @@
1
+ import type { MemoryStore, View, Graph } from "@staix/agent-mem";
2
+ import { looksSecret } from "@staix/agent-mem";
3
+ import { PreEffectToolRefusal } from "./tool-refusal.ts";
4
+
5
+ export const MEMORY_READ_TOOLS = new Set(["hub_memory_search", "hub_memory_show", "hub_memory_graph"]);
6
+ const REFUSAL = "memory read unavailable";
7
+ const bytes = (v: string) => Buffer.byteLength(v, "utf8");
8
+ const excerpt = (text: string, max = 200): string => {
9
+ if (bytes(text) <= max) return text;
10
+ const points = Array.from(text), marker = " [incomplete]";
11
+ let length = 0, out = "";
12
+ for (const point of points) { if (length + bytes(point) + bytes(marker) > max) break; out += point; length += bytes(point); }
13
+ return out + marker;
14
+ };
15
+ function reject(): never { throw new PreEffectToolRefusal(REFUSAL); }
16
+ export type MemoryScreen = <T extends { title: string | null; text: string }>(rows: T[]) => T[];
17
+
18
+ /** Validate before capability logging, storage, or privacy work. Schemas alone do not enforce tool input. */
19
+ export function validateMemoryRead(op: string, a: Record<string, unknown>): void {
20
+ if (!a || typeof a !== "object" || Array.isArray(a)) reject();
21
+ const string = (v: unknown, min: number, max: number): v is string => typeof v === "string" && bytes(v) >= min && bytes(v) <= max;
22
+ const array = (v: unknown, min: number, max: number): v is string[] => Array.isArray(v) && v.length > 0 && v.length <= 8 && v.every(s => string(s, min, max));
23
+ const limit = (max: number) => a.limit === undefined || (Number.isInteger(a.limit) && Number(a.limit) >= 1 && Number(a.limit) <= max);
24
+ const keys = op === "hub_memory_search" ? ["query", "limit"] : op === "hub_memory_show" ? ["ids"] : ["starts", "hops", "limit"];
25
+ if (Object.keys(a).some(k => !keys.includes(k))) reject();
26
+ if (op === "hub_memory_search" ? !string(a.query, 0, 200) || !limit(20)
27
+ : op === "hub_memory_show" ? !array(a.ids, 6, 64)
28
+ : !array(a.starts, 1, 200) || (a.hops !== undefined && a.hops !== 1 && a.hops !== 2) || !limit(120)) reject();
29
+ }
30
+
31
+ /** Read-only model projection. Counts and caps are computed only after screening whole records and metadata. */
32
+ export function memoryRead(store: MemoryStore | undefined, project: string, screen: MemoryScreen, op: string, a: Record<string, unknown>, stale: (about: readonly string[]) => boolean = () => false): string {
33
+ validateMemoryRead(op, a);
34
+ const safe = (v: unknown): boolean => {
35
+ try {
36
+ const text = typeof v === "string" ? v : JSON.stringify(v);
37
+ return !looksSecret(text) && screen([{ title: null, text }]).length === 1;
38
+ } catch { return false; }
39
+ };
40
+ if (!safe(a)) reject();
41
+ if (!store) return JSON.stringify({ error: REFUSAL });
42
+ try {
43
+ const all = screen(store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true })).filter(safe);
44
+ const resolve = (input: string): View => {
45
+ const id = input.replace(/^mem:/, "");
46
+ const aliases = [...new Set(all.flatMap(v => [v.id, ...(v.promotedFrom ? [v.promotedFrom] : [])]))];
47
+ const matched = aliases.includes(id) ? [id] : aliases.filter(v => v.startsWith(id));
48
+ if (id.length < 6) reject();
49
+ if (!matched.length) {
50
+ // Older promotion chains are resolved by the store, but only a fully screened visible terminal is exposed.
51
+ const redirected = store.get(id, project);
52
+ const visible = redirected && all.find(v => v.id === redirected.id);
53
+ if (visible) return visible;
54
+ reject();
55
+ }
56
+ if (matched.length !== 1) reject();
57
+ const found = all.find(v => v.id === matched[0]) ?? all.find(v => v.promotedFrom === matched[0]);
58
+ if (!found) reject();
59
+ return found;
60
+ };
61
+ const finish = (v: unknown) => { if (!safe(v)) reject(); return JSON.stringify(v); };
62
+ const order = (x: View, y: View) => Number(y.kind === "fail") - Number(x.kind === "fail") || y.at.localeCompare(x.at) || x.id.localeCompare(y.id);
63
+ if (op !== "hub_memory_graph") {
64
+ const show = op === "hub_memory_show", cap = show ? 8192 : 4096;
65
+ const query = String(a.query ?? "").toLowerCase().split(/\s+/).filter(Boolean);
66
+ const rows = show ? [...new Map((a.ids as string[]).map(id => { const v = resolve(id); return [v.id, v]; })).values()]
67
+ : all.filter(v => !v.supersededBy && query.every(q => `${v.title ?? ""} ${v.text} ${v.about.join(" ")}`.toLowerCase().includes(q))).sort(order);
68
+ const lines: string[] = [];
69
+ const render = () => ({ lines, truncated: rows.length - lines.length });
70
+ for (const v of rows) {
71
+ if (lines.length >= (show ? 8 : Number(a.limit ?? 8))) break;
72
+ const header = `mem:${v.id} ${v.kind} ${v.tier} source:${v.source} peer:${v.peer} project:${v.project} at:${v.at}: `.replace(/\s+/g, " ");
73
+ const body = `${show && stale(v.about) ? "(stale?) " : ""}${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").trim();
74
+ lines.push(header + body);
75
+ if (bytes(JSON.stringify(render())) > cap) {
76
+ const points = Array.from(body);
77
+ let low = 0, high = points.length;
78
+ while (low < high) {
79
+ const mid = Math.ceil((low + high) / 2);
80
+ lines[lines.length - 1] = header + points.slice(0, mid).join("") + " [incomplete]";
81
+ if (bytes(JSON.stringify(render())) <= cap) low = mid; else high = mid - 1;
82
+ }
83
+ lines[lines.length - 1] = header + points.slice(0, low).join("") + " [incomplete]";
84
+ if (bytes(JSON.stringify(render())) > cap) lines.pop();
85
+ }
86
+ }
87
+ return finish(render());
88
+ }
89
+ const visible = new Map(all.map(v => [`mem:${v.id}`, v]));
90
+ const goals = new Map(store.goals(project).filter(safe).map(v => [`goal:${v.id}`, v]));
91
+ const starts = (a.starts as string[]).map(n => {
92
+ if (n.startsWith("mem:")) return `mem:${resolve(n).id}`;
93
+ if (n.startsWith("goal:") && !goals.has(n)) reject();
94
+ return n;
95
+ });
96
+ const nodes = new Map<string, Graph["nodes"][number]>(), edges = new Map<string, Graph["edges"][number]>();
97
+ for (const start of starts) {
98
+ const graph = store.graph(project, start, Number(a.hops ?? 1), Number.MAX_SAFE_INTEGER);
99
+ for (const n of graph.nodes) {
100
+ if ((n.id.startsWith("mem:") && !visible.has(n.id)) || (n.id.startsWith("goal:") && !goals.has(n.id))) continue;
101
+ const memory = visible.get(n.id), goal = goals.get(n.id);
102
+ // The dashboard graph already slices labels; model excerpts must retain a visible incompleteness marker.
103
+ const shown = { ...n, label: excerpt(memory ? memory.title ?? memory.text : goal ? goal.title : n.label) };
104
+ if (safe(shown)) nodes.set(n.id, shown);
105
+ }
106
+ for (const e of graph.edges) if (safe(e)) edges.set(JSON.stringify(e), e);
107
+ }
108
+ const screenedEdges = [...edges.values()].filter(e => nodes.has(e.from) && nodes.has(e.to));
109
+ const connected = new Set(screenedEdges.flatMap(e => [e.from, e.to]));
110
+ const rank = (n: Graph["nodes"][number]) => n.kind === "fail" ? 0 : starts.includes(n.id) ? 1 : n.type !== "memory" ? 2 : 3;
111
+ const eligibleNodes = [...nodes.values()].filter(n => connected.has(n.id) || starts.includes(n.id)).sort((x, y) => rank(x) - rank(y) || x.id.localeCompare(y.id));
112
+ const eligibleEdges = screenedEdges.sort((x, y) => JSON.stringify(x).localeCompare(JSON.stringify(y)));
113
+ const out: Graph & { truncated: number } = { nodes: [], edges: [], truncated: eligibleNodes.length + eligibleEdges.length };
114
+ for (const n of eligibleNodes) {
115
+ if (out.nodes.length >= Number(a.limit ?? 60)) break;
116
+ out.nodes.push(n); out.truncated--;
117
+ if (bytes(JSON.stringify(out)) > (eligibleEdges.length ? 5120 : 6144)) { out.nodes.pop(); out.truncated++; }
118
+ }
119
+ const kept = new Set(out.nodes.map(n => n.id));
120
+ for (const e of eligibleEdges) {
121
+ if (!kept.has(e.from) || !kept.has(e.to) || out.edges.length >= 240) continue;
122
+ out.edges.push(e); out.truncated--;
123
+ if (bytes(JSON.stringify(out)) > 6144) { out.edges.pop(); out.truncated++; }
124
+ }
125
+ return finish(out);
126
+ } catch { return JSON.stringify({ error: REFUSAL }); }
127
+ }