Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

### Added

- Coalesce identical snapshots across viewers in a bounded in-memory LRU cache with per-panel TTL overrides, stale-while-revalidate, action/reload invalidation and separate principal keys. Declared readOnly principal tools preserve the cache; other principal actions and all console actions invalidate it. Keep producer bodies and error snapshots unchanged, expose freshness only through cache/age/sample-time headers and add System cache/provider timing diagnostics, and add a private-output snapshot timing probe (#105).

- Add the Space Flow view with per-project actors, directed 15-minute message counts, distinct relationship strokes and recent activity. Keep at most 200 public link events for 30 minutes in page memory, and show per-peer counts and the last 10 events in agent detail. Suspend the minute refresh while Flow is hidden (#101).

- Make Team the default Space view while honoring a saved view choice in `acon.space.section`; the Space header now scrolls with the page. Add per-group attention and active-agent summaries, collapsed idle/away rows, and namespaced actor detail routes with responsive presentation and focus restoration; keep the roster in Room. Extend the browser probe for Team/detail navigation and persistence, including a reload with `view` removed (#101).
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,14 @@ Versions and upgrades (#26):
- Versioned registrations: a tool may write `plugins/<tool>@<version>.json` (semantic version) beside `plugins/<tool>.json`. Per tool the newest one that verifies and loads is used; if it fails to load, the next one is tried; the others are listed as `superseded`.
- No restart needed for a new path: the console re-scans the directory at most every 5 s while a page is open. A module at a path not loaded before (a versioned install such as `releases/<version>/`) serves pages opened after it; an open page keeps the load it started with (System says when a newer one is registered), and every load stays in memory until the console restarts. A module from a package the console already imported from (changed in place, or a file first loaded as a dependency) is not hot-loaded, because the runtime keeps one module per path: the current load keeps serving and System says to restart. If a registration turns stale or disappears, the current load keeps serving and System records the problem once.

Snapshot reuse (#105): the server shares an in-process cache between viewers, keyed by tool, provider and canonical snapshot input. Identical concurrent requests share one provider call. The default TTL is `min(pollMs / 2, 2000)` ms (`pollMs` defaults to 5000). A plugin can set `panels[].maxAgeMs`, `capabilities.inboxMaxAgeMs` or `capabilities.spaceMaxAgeMs` to a finite value from 0 to 60000 ms; 0 disables reuse while preserving concurrent-call coalescing. An explicitly declared panel owns its TTL even when its id is `space`; capability TTLs apply only to undeclared reserved panels with the corresponding capability enabled. The fields are additive API 1 options, and conformance checks their bounds.

During one extra TTL of grace, a cached reply can be served immediately while one background refresh runs. A failed refresh retains the last good value during the grace window. Cache diagnostics use `X-Acon-Cache: hit|miss|stale`, `X-Acon-Cache-Age-Ms` and `X-Acon-Sampled-At` (ISO time) response headers, also exposed to configured CORS origins. Producer snapshot bodies, including their own `stale` and `sampledAt` fields, stay unchanged. A producer `ok: false` snapshot stays a status-200 reply and is never retained as a cache entry. Every console action and each principal action not declared `readOnly` invalidate that tool's entries before returning, and a provider reload drops the old provider's entries. Principal snapshots have a separate namespace and include the authenticated principal in their canonical input. The current MCP `tools/call` route uses the principal action port; those calls remain actions rather than cached panel reads. A principal tool declared `readOnly` preserves the cache, matching its availability on a read-only node.

The cache holds at most 256 entries and 4 MiB, evicts least recently used entries, and writes nothing to disk. System > Snapshot cache shows entry/byte totals and hit, miss and stale counts by tool, panel and namespace. Its p50/p95 provider durations use a rolling window of the last 128 calls; cache hits do not add provider timing samples. Older nodes without these diagnostics remain usable.

Run `bun scripts/perf-snapshots.ts` to load registered plugins and print cold and warm provider timings for each declared panel, inbox and Space. It reads the standard `AGENTHUB_HOME`, calls snapshots directly to expose provider cost rather than a cache hit, and closes providers afterward. Treat the output as private operational data; do not commit personal plugin timings or results.

- Inbox: declare `capabilities.inbox` and answer `snapshot({panel: "inbox"})` with `{items: InboxItem[]}`. Verbs run through `action({action: verb.id, id})`. Keep each item's `at` the same across snapshots: snooze and oldest-first order key on it, and conformance checks it.
- Reasons: a verb with `reason: "optional"` or `"required"` makes the console ask for a reason inline and send it as `reason` in the action input; a required reason cannot be sent empty.
- Features (#27): `open(ctx)` receives `ctx.features` from this console: `"inbox.form"`, `"inbox.result"`, `"action.meta"` and `"principal"`. The `"principal"` feature advertises separate principal ports, public JSON projection and `meta.actor`. Send forms and result items only when the matching feature is present; an older console has no `features` and reports both as malformed.
Expand Down
52 changes: 52 additions & 0 deletions scripts/perf-snapshots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { familyHome, loadPlugins, scanPlugins, withTimeout } from "../src/plugins.ts";

/** Direct provider timings, deliberately excluding server cache hits. Never print snapshot contents. */
async function main(): Promise<number> {
if (process.argv.slice(2).includes("--help")) {
console.log("Usage: bun scripts/perf-snapshots.ts\nLoads registered plugins from AGENTHUB_HOME and prints cold/warm provider timings. Output is private; do not commit it.");
return 0;
}
if (process.argv.length > 2) throw new Error("unknown argument; use --help");
const home = familyHome();
const scan = scanPlugins(home);
if (scan.error) { console.error(`snapshot timings: ${scan.error}`); return 1; }
const plugins = await loadPlugins(scan, { home, log: (line) => console.error(`snapshot timings: ${line}`) });
const ready = plugins.filter((entry) => entry.status === "ready" && entry.provider && entry.manifest);
let failed = plugins.some((entry) => !["ready", "superseded"].includes(entry.status));
console.log("tool\tpanel\tcold_ms\twarm_ms");
try {
for (const entry of ready) {
const manifest = entry.manifest!;
const panels = new Set(manifest.panels.map((panel) => panel.id));
if (manifest.capabilities?.inbox) panels.add("inbox");
if (manifest.capabilities?.space) panels.add("space");
for (const panel of panels) {
const timings: number[] = [];
try {
for (let call = 0; call < 2; call++) {
const start = performance.now();
await withTimeout(Promise.resolve().then(() => entry.provider!.snapshot({ panel, after: 0 })), 30_000, `${entry.tool}/${panel} snapshot`);
timings.push(performance.now() - start);
}
console.log(`${entry.tool}\t${panel}\t${timings[0]!.toFixed(2)}\t${timings[1]!.toFixed(2)}`);
} catch (error) {
failed = true;
console.error(`snapshot timings: ${entry.tool}/${panel}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
if (!ready.length) console.log("No ready plugins registered.");
} finally {
const providers = new Set(ready.map((entry) => entry.provider!));
for (const provider of providers) {
try { await withTimeout(Promise.resolve().then(() => provider.close?.()), 5000, "provider close"); }
catch (error) { failed = true; console.error(`snapshot timings: ${error instanceof Error ? error.message : String(error)}`); }
}
}
return failed ? 1 : 0;
}

process.exitCode = await main().catch((error: unknown) => {
console.error(`snapshot timings: ${error instanceof Error ? error.message : String(error)}`);
return 1;
});
29 changes: 27 additions & 2 deletions shell/shell.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const $ = (id) => document.getElementById(id);
// Module references: these move into a Picture-in-Picture document with Mini, where getElementById cannot find them.
const confirmEl = $("confirm");
const state = {
tools: [], pets: [], assetPacks: [], space: new Map(), node: null, serverErrors: [], clientErrors: [],
tools: [], pets: [], assetPacks: [], space: new Map(), node: null, snapshotCache: null, serverErrors: [], clientErrors: [],
route: { area: "inbox", params: {} },
inbox: new Map(), inboxSig: "", selectedKey: null, peekKey: null,
panels: new Map(),
Expand Down Expand Up @@ -2060,8 +2060,31 @@ function staticNodeCards() {
});
}

function snapshotCacheCard() {
const caches = staticTransport
? staticTransport.nodes.filter((node) => node.data?.snapshotCache).map((node) => ({ id: node.id, ...node.data.snapshotCache }))
: state.snapshotCache ? [{ id: "local", ...state.snapshotCache }] : [];
const number = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
const sections = caches.map((cache) => {
const metrics = Array.isArray(cache.metrics) ? cache.metrics : [];
return h("div", {},
h("h3", { class: "card-sub", text: cache.id }),
h("p", { class: "sub", text: `${fmtNum(number(cache.entries))} entries · ${fmtNum(number(cache.bytes) / 1024)} KiB in memory` }),
metrics.length ? h("ul", { class: "list" }, metrics.map((metric) => h("li", {},
h("strong", { text: `${str(metric.tool)} / ${str(metric.panel)}` }),
h("span", { class: "meta", text: str(metric.namespace) }),
h("span", { text: `${fmtNum(number(metric.hits))} hit · ${fmtNum(number(metric.misses))} miss · ${fmtNum(number(metric.stale))} stale` }),
h("span", { class: "meta", text: `Provider p50 ${fmtNum(number(metric.p50Ms))} ms · p95 ${fmtNum(number(metric.p95Ms))} ms · ${fmtNum(number(metric.samples))} samples` }))))
: h("p", { class: "sub", text: "No snapshot requests measured yet." }));
});
return h("section", { class: "card wide", "data-snapshot-cache": "true" },
h("h2", { text: "Snapshot cache" }),
h("p", { class: "sub", text: "Counts are per tool and panel. Provider timings use the latest 128 calls; cache hits do not add timing samples." }),
...(sections.length ? sections : [h("p", { class: "sub", text: "This node does not report snapshot cache diagnostics." })]));
}

function renderSystem(force = false) {
const sig = JSON.stringify([state.node, state.tools, state.serverErrors, state.clientErrors, themeChoice(), state.offline, state.pets, petChoice()?.id ?? "off", staticTransport?.nodes.map((n) => [n.id, n.status, n.detail])]);
const sig = JSON.stringify([state.node, state.tools, state.snapshotCache, state.serverErrors, state.clientErrors, themeChoice(), state.offline, state.pets, petChoice()?.id ?? "off", staticTransport?.nodes.map((n) => [n.id, n.status, n.detail, n.data?.snapshotCache])]);
if (!force && sig === systemSig && $("view-system").childElementCount) return;
systemSig = sig;
// Keep keyboard focus on the same control across re-renders.
Expand Down Expand Up @@ -2092,6 +2115,7 @@ function renderSystem(force = false) {
state.tools.length ? h("ul", { class: "list" }, state.tools.map((t) => h("li", {}, h("strong", { text: t.title ?? t.tool }), h("span", { class: "meta mono", text: t.tool }), statusChip(t.status),
h("span", { class: "meta", text: t.status === "ready" ? `${t.version} · ${plural(t.panels.length, "panel")}${t.capabilities?.inbox ? " · inbox" : ""}${t.capabilities?.search ? " · search" : ""}${t.update ? ` · ${t.update} is registered; reload the page to use it` : ""}` : str(t.reason) }))))
: h("p", { class: "sub", text: "No plugin manifests were found. A tool registers itself with its setup command." })),
snapshotCacheCard(),
h("section", { class: "card wide" }, h("h2", { text: `Errors (${errors.length})` }),
errors.length ? h("ul", { class: "list" }, errors.map((e) => h("li", {}, h("span", { class: "meta mono", text: new Date(e.at).toLocaleTimeString() }), h("strong", { text: e.tool }), h("span", { class: "meta", text: e.where }), h("span", { text: e.message }))))
: h("p", { class: "sub", text: "No errors since the console started." }))));
Expand All @@ -2115,6 +2139,7 @@ async function loadTools() {
updateReadOnly();
state.pets = Array.isArray(r.data.pets) ? r.data.pets.filter((p) => p && PET_ID.test(str(p.id)) && str(p.name)) : [];
state.assetPacks = Array.isArray(r.data.assetPacks) ? r.data.assetPacks.filter((p) => p && PET_ID.test(str(p.id)) && Array.isArray(p.images) && p.images.every((id) => PET_ID.test(str(id))) && Array.isArray(p.tile) && p.tile.length === 2 && p.tile.every((n) => Number.isSafeInteger(n) && n > 0)) : [];
state.snapshotCache = r.data.snapshotCache && typeof r.data.snapshotCache === "object" ? r.data.snapshotCache : null;
state.serverErrors = Array.isArray(r.data.errors) ? r.data.errors : [];
renderRail();
if (state.route.area === "system") renderSystem();
Expand Down
7 changes: 6 additions & 1 deletion src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export interface ConsolePanelManifest {
title: string;
/** Area icon name the shell knows (agents, memory, vault, ...); unknown names fall back to a generic glyph. */
icon?: string;
panels: { id: string; title: string; icon?: string; order?: number }[];
/** maxAgeMs opts a panel into snapshot reuse for 0-60000 ms; 0 disables reuse. */
panels: { id: string; title: string; icon?: string; order?: number; maxAgeMs?: number }[];
/** Palette entries; each runs action({...input, action}) through the same confirm path as a verb. */
commands?: ConsoleCommand[];
/** Poll interval for the visible panel; the shell clamps it to 2-60 s. */
Expand All @@ -32,10 +33,14 @@ export interface ConsolePanelManifest {
multiProject?: boolean;
/** snapshot({panel: "inbox", after}) returns {items: InboxItem[]}. */
inbox?: boolean;
/** Inbox snapshot reuse for 0-60000 ms; absent uses the poll-derived default. */
inboxMaxAgeMs?: number;
/** snapshot({panel: "search", q, limit}) returns {hits: SearchHit[], total}. */
search?: boolean;
/** snapshot({panel: "space", after}) returns {scene: SpaceScene}, public fields only. */
space?: boolean;
/** Space snapshot reuse for 0-60000 ms; absent uses the poll-derived default. */
spaceMaxAgeMs?: number;
/** Principal-only tools; never dispatched through the person snapshot/action ports (#88). */
principal?: { tools: PrincipalTool[] };
};
Expand Down
Loading
Loading