@animalabs/connectome-host 0.3.10 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -58,6 +58,7 @@ import {
58
58
  type TokenUsage,
59
59
  type CallLedgerSnapshot,
60
60
  type McplListMessage,
61
+ type SettingsStateMessage,
61
62
  type BranchesListMessage,
62
63
  type LessonsListMessage,
63
64
  } from '../web/protocol.js';
@@ -1076,6 +1077,9 @@ export class WebUiModule implements Module {
1076
1077
  if (url.pathname === '/debug/context/maintenance') {
1077
1078
  return this.handleContextMaintenance();
1078
1079
  }
1080
+ if (url.pathname === '/debug/context/preview') {
1081
+ return this.handleContextPreview(url);
1082
+ }
1079
1083
  if (url.pathname === '/curve') {
1080
1084
  return new Response(CURVE_PAGE_HTML, {
1081
1085
  headers: { 'content-type': 'text/html; charset=utf-8' },
@@ -1181,6 +1185,79 @@ export class WebUiModule implements Module {
1181
1185
  return Response.json(buildContextCoverageSnapshot(agentName, cm));
1182
1186
  }
1183
1187
 
1188
+ /**
1189
+ * Preview the fold plan at a HYPOTHETICAL budget / tail, without applying it.
1190
+ *
1191
+ * GET /debug/context/preview?budget=<tokens>[&tail=<tokens>][&agent=<name>]
1192
+ *
1193
+ * Commits nothing — no fold resolutions persisted, no compression enqueued,
1194
+ * no transition bookkeeping advanced. That guarantee lives in
1195
+ * context-manager's `previewContext` (dry-run select); this endpoint only
1196
+ * forwards. An infeasible budget is reported as `fits: false` with the
1197
+ * per-component diagnostics, NOT as an error: learning that a budget can't
1198
+ * work is the reason to preview instead of applying and taking the outage.
1199
+ *
1200
+ * Returns 501 when the resolved context-manager predates dry-run support, so
1201
+ * the panel can say so instead of rendering an empty result.
1202
+ */
1203
+ private handleContextPreview(url: URL): Response {
1204
+ const app = sharedServer?.app;
1205
+ if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
1206
+ const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
1207
+ const agent = app.framework.getAgent(agentName);
1208
+ if (!agent) {
1209
+ return Response.json({ error: `Agent not found: ${agentName}` }, { status: 404 });
1210
+ }
1211
+
1212
+ const budgetRaw = url.searchParams.get('budget');
1213
+ const budget = budgetRaw === null ? NaN : Number(budgetRaw);
1214
+ if (!Number.isSafeInteger(budget) || budget <= 0) {
1215
+ return Response.json({ error: 'budget must be a positive integer' }, { status: 400 });
1216
+ }
1217
+ const tailRaw = url.searchParams.get('tail');
1218
+ const overrides: Record<string, unknown> = {};
1219
+ if (tailRaw !== null) {
1220
+ const tail = Number(tailRaw);
1221
+ if (!Number.isSafeInteger(tail) || tail < 0) {
1222
+ return Response.json({ error: 'tail must be a non-negative integer' }, { status: 400 });
1223
+ }
1224
+ // The strategy knob behind "tail" is recentWindowTokens.
1225
+ overrides.recentWindowTokens = tail;
1226
+ }
1227
+
1228
+ const fw = app.framework as unknown as {
1229
+ previewContextSettings?: (n: string, b: number, o?: Record<string, unknown>) => unknown;
1230
+ };
1231
+ if (typeof fw.previewContextSettings !== 'function') {
1232
+ return Response.json(
1233
+ { error: 'preview unsupported: this agent-framework build has no previewContextSettings' },
1234
+ { status: 501 },
1235
+ );
1236
+ }
1237
+ try {
1238
+ const result = fw.previewContextSettings(
1239
+ agentName,
1240
+ budget,
1241
+ Object.keys(overrides).length > 0 ? overrides : undefined,
1242
+ );
1243
+ if (result === null || result === undefined) {
1244
+ return Response.json(
1245
+ {
1246
+ error: 'preview unavailable: the resolved context-manager has no dry-run support, '
1247
+ + 'or the active strategy has no fold plan (non-adaptive)',
1248
+ },
1249
+ { status: 501 },
1250
+ );
1251
+ }
1252
+ return Response.json({ agent: agentName, budget, ...(overrides as object), preview: result });
1253
+ } catch (err) {
1254
+ return Response.json(
1255
+ { error: err instanceof Error ? err.message : String(err) },
1256
+ { status: 500 },
1257
+ );
1258
+ }
1259
+ }
1260
+
1184
1261
  /**
1185
1262
  * Debug endpoint: return the membrane-normalized request the framework would
1186
1263
  * hand to the model if the agent were activated right now. Delegates to
@@ -1254,7 +1331,18 @@ export class WebUiModule implements Module {
1254
1331
  }
1255
1332
  try {
1256
1333
  const cm = (agent as unknown as { getContextManager: () => any }).getContextManager();
1257
- const maxTokens = app.recipe.agent.contextBudgetTokens ?? 200_000;
1334
+ // Use the LIVE budget, not the recipe's. Runtime overrides persist in the
1335
+ // `framework/state` Chronicle slot and win over the recipe, so reading
1336
+ // app.recipe here plotted the wrong curve on any agent whose budget had
1337
+ // ever been changed at runtime — which is every agent the settings panel
1338
+ // touches. Fall back to the recipe only if the live read is unavailable.
1339
+ let maxTokens = app.recipe.agent.contextBudgetTokens ?? 200_000;
1340
+ try {
1341
+ const live = (app.framework as unknown as {
1342
+ getAgentRuntimeSettings?: (n: string) => { contextBudgetTokens?: number };
1343
+ }).getAgentRuntimeSettings?.(agentName)?.contextBudgetTokens;
1344
+ if (typeof live === 'number' && live > 0) maxTokens = live;
1345
+ } catch { /* keep the recipe fallback */ }
1258
1346
  const reserveForResponse = app.recipe.agent.maxTokens ?? 16_384;
1259
1347
  const compiled = await cm.compile({ maxTokens, reserveForResponse });
1260
1348
 
@@ -1763,6 +1851,78 @@ export class WebUiModule implements Module {
1763
1851
  return;
1764
1852
  }
1765
1853
 
1854
+ case 'request-settings': {
1855
+ this.sendSettingsState(client, parsed.agent);
1856
+ return;
1857
+ }
1858
+
1859
+ // Settings mutations are live process state, so every case BROADCASTS
1860
+ // rather than replying to the requester only (contrast sendMcplList,
1861
+ // which is file-only). Two operators must not see divergent budgets.
1862
+ case 'settings-update': {
1863
+ const agentName = this.resolveSettingsAgent(parsed.agent);
1864
+ try {
1865
+ const patch: Record<string, number> = {};
1866
+ if (parsed.contextBudgetTokens !== undefined) patch.contextBudgetTokens = parsed.contextBudgetTokens;
1867
+ if (parsed.tailTokens !== undefined) patch.tailTokens = parsed.tailTokens;
1868
+ if (parsed.transitionPaceTokens !== undefined) patch.transitionPaceTokens = parsed.transitionPaceTokens;
1869
+ const fw = sharedServer!.app.framework as unknown as {
1870
+ updateAgentRuntimeSettings: (n: string, p: unknown, o?: { persist?: boolean }) => unknown;
1871
+ };
1872
+ fw.updateAgentRuntimeSettings(agentName, patch, { persist: parsed.persist !== false });
1873
+ } catch (err) {
1874
+ // Expected failures land here and must reach the operator verbatim:
1875
+ // budget ≤ max response tokens, or a strategy that cannot prepare a
1876
+ // smaller window live. Silently swallowing them would look like the
1877
+ // apply worked.
1878
+ this.send(client, {
1879
+ type: 'error',
1880
+ message: `settings-update failed: ${err instanceof Error ? err.message : String(err)}`,
1881
+ });
1882
+ return;
1883
+ }
1884
+ if (parsed.notify === true) this.notifyAgentOfSettingsChange(agentName, 'update');
1885
+ this.broadcastSettingsState(agentName);
1886
+ return;
1887
+ }
1888
+
1889
+ case 'settings-reset': {
1890
+ const agentName = this.resolveSettingsAgent(parsed.agent);
1891
+ try {
1892
+ const fw = sharedServer!.app.framework as unknown as {
1893
+ resetAgentRuntimeSettings: (n: string, k?: string[], o?: { persist?: boolean }) => unknown;
1894
+ };
1895
+ fw.resetAgentRuntimeSettings(agentName, parsed.keys, { persist: parsed.persist !== false });
1896
+ } catch (err) {
1897
+ this.send(client, {
1898
+ type: 'error',
1899
+ message: `settings-reset failed: ${err instanceof Error ? err.message : String(err)}`,
1900
+ });
1901
+ return;
1902
+ }
1903
+ if (parsed.notify === true) this.notifyAgentOfSettingsChange(agentName, 'reset');
1904
+ this.broadcastSettingsState(agentName);
1905
+ return;
1906
+ }
1907
+
1908
+ case 'settings-cancel-transition': {
1909
+ const agentName = this.resolveSettingsAgent(parsed.agent);
1910
+ try {
1911
+ const fw = sharedServer!.app.framework as unknown as {
1912
+ cancelAgentRuntimeSettingsTransition: (n: string) => unknown;
1913
+ };
1914
+ fw.cancelAgentRuntimeSettingsTransition(agentName);
1915
+ } catch (err) {
1916
+ this.send(client, {
1917
+ type: 'error',
1918
+ message: `settings-cancel-transition failed: ${err instanceof Error ? err.message : String(err)}`,
1919
+ });
1920
+ return;
1921
+ }
1922
+ this.broadcastSettingsState(agentName);
1923
+ return;
1924
+ }
1925
+
1766
1926
  case 'request-workspace-mounts': {
1767
1927
  if (parsed.scope && parsed.scope !== 'local') {
1768
1928
  this.routeFleetRequest(client, parsed.scope, 'workspace-mounts',
@@ -2426,6 +2586,125 @@ export class WebUiModule implements Module {
2426
2586
  }
2427
2587
  }
2428
2588
 
2589
+ /** Default to the recipe's primary agent when the client omits a name. */
2590
+ private resolveSettingsAgent(name?: string): string {
2591
+ if (name) return name;
2592
+ const app = sharedServer?.app;
2593
+ return app?.recipe.agent.name ?? app?.framework.getAllAgents()[0]?.name ?? 'agent';
2594
+ }
2595
+
2596
+ /**
2597
+ * Build the settings snapshot. Never throws: this feeds a panel, and a
2598
+ * strategy that isn't hot-configurable is a legitimate state to render
2599
+ * read-only rather than an error to surface.
2600
+ */
2601
+ private buildSettingsState(agentName: string): SettingsStateMessage | null {
2602
+ const app = sharedServer?.app;
2603
+ if (!app) return null;
2604
+ const fw = app.framework as unknown as {
2605
+ getAgentRuntimeSettings?: (n: string) => Record<string, unknown>;
2606
+ getAgent?: (n: string) => unknown;
2607
+ };
2608
+ if (typeof fw.getAgentRuntimeSettings !== 'function') return null;
2609
+
2610
+ let settings: Record<string, unknown>;
2611
+ try {
2612
+ settings = fw.getAgentRuntimeSettings(agentName);
2613
+ } catch {
2614
+ return null;
2615
+ }
2616
+
2617
+ // Which knobs this build can apply live, probed rather than assumed: the
2618
+ // hot set is a property of the ACTIVE strategy, not of the host version.
2619
+ let hotConfigurable = false;
2620
+ let previewAvailable = false;
2621
+ try {
2622
+ const agent = app.framework.getAgent(agentName);
2623
+ const cm = agent?.getContextManager() as unknown as {
2624
+ getHotContextSettings?: () => unknown;
2625
+ previewContext?: unknown;
2626
+ } | undefined;
2627
+ hotConfigurable = !!cm && typeof cm.getHotContextSettings === 'function'
2628
+ && cm.getHotContextSettings() !== null;
2629
+ // Older context-manager builds resolve without previewContext. Report it
2630
+ // so the panel says "preview unavailable on this build" instead of
2631
+ // rendering an empty chart and looking broken.
2632
+ previewAvailable = !!cm && typeof cm.previewContext === 'function';
2633
+ } catch { /* leave both false — read-only panel */ }
2634
+
2635
+ const overrides: string[] = [];
2636
+ try {
2637
+ const agent = app.framework.getAgent(agentName) as unknown as {
2638
+ getRuntimeSettingsOverrides?: () => Record<string, unknown>;
2639
+ } | undefined;
2640
+ const ov = agent?.getRuntimeSettingsOverrides?.() ?? {};
2641
+ for (const [k, val] of Object.entries(ov)) if (val !== undefined) overrides.push(k);
2642
+ } catch { /* informational only */ }
2643
+
2644
+ return {
2645
+ type: 'settings-state',
2646
+ agent: agentName,
2647
+ settings: settings as SettingsStateMessage['settings'],
2648
+ overrides,
2649
+ // contextBudgetTokens is applied by the Agent itself; the other three are
2650
+ // forwarded into the strategy's hot-settings channel, so they need a
2651
+ // hot-configurable strategy to mean anything.
2652
+ hotKeys: hotConfigurable
2653
+ ? ['contextBudgetTokens', 'tailTokens', 'transitionPaceTokens', 'sameRoundThinkTextPolicy']
2654
+ : ['contextBudgetTokens'],
2655
+ hotConfigurable,
2656
+ previewAvailable,
2657
+ };
2658
+ }
2659
+
2660
+ private sendSettingsState(client: ClientState, agentName?: string): void {
2661
+ const msg = this.buildSettingsState(this.resolveSettingsAgent(agentName));
2662
+ if (!msg) {
2663
+ this.send(client, { type: 'error', message: 'runtime settings unavailable on this build' });
2664
+ return;
2665
+ }
2666
+ this.send(client, msg);
2667
+ }
2668
+
2669
+ /** Fan out to every welcomed client — settings are live process state. */
2670
+ private broadcastSettingsState(agentName: string): void {
2671
+ if (!sharedServer?.app) return;
2672
+ const msg = this.buildSettingsState(agentName);
2673
+ if (!msg) return;
2674
+ for (const c of sharedServer.clients.values()) {
2675
+ if (c.welcomed) this.send(c, msg);
2676
+ }
2677
+ }
2678
+
2679
+ /**
2680
+ * Opt-in push notice to the agent that an operator changed its context
2681
+ * settings. OFF by default at the protocol level, because this injects text
2682
+ * into the very context being tuned: it invalidates the KV prefix and is
2683
+ * itself classifier-visible. The zero-cost alternative the agent always has
2684
+ * is to PULL via its `agent_settings` tool.
2685
+ */
2686
+ private notifyAgentOfSettingsChange(agentName: string, kind: 'update' | 'reset'): void {
2687
+ try {
2688
+ const app = sharedServer?.app;
2689
+ if (!app) return;
2690
+ const s = this.buildSettingsState(agentName);
2691
+ const budget = s?.settings.contextBudgetTokens;
2692
+ const tail = s?.settings.tailTokens;
2693
+ const transition = s?.settings.transition;
2694
+ const text = kind === 'reset'
2695
+ ? `[operator] context settings reset to recipe defaults`
2696
+ : `[operator] context settings changed`
2697
+ + (budget !== undefined ? ` — budget ${budget}` : '')
2698
+ + (tail !== undefined ? `, tail ${tail}` : '')
2699
+ + (transition === 'converging' ? ' (converging gradually)' : '');
2700
+ const cm = app.framework.getAgent(agentName)?.getContextManager();
2701
+ cm?.addMessage('Context Manager', [{ type: 'text', text }], { system: true });
2702
+ } catch (err) {
2703
+ // A failed notice must never fail the apply that already succeeded.
2704
+ console.warn('[settings] notify failed (change still applied):', err);
2705
+ }
2706
+ }
2707
+
2429
2708
  private broadcastBranchChanged(): void {
2430
2709
  if (!sharedServer?.app) return;
2431
2710
  const cm = sharedServer?.app.framework.getAllAgents()[0]?.getContextManager();
package/src/recipe.ts CHANGED
@@ -28,6 +28,10 @@ export interface RecipeStrategy {
28
28
  headWindowTokens?: number;
29
29
  recentWindowTokens?: number;
30
30
  compressionModel?: string;
31
+ /** Cap compression `max_tokens` at the compression model's OUTPUT ceiling.
32
+ * Required for pre-4.x models (Claude 3 Opus caps at 4096) — without it the
33
+ * summarizer's 16k floor is rejected and the agent never folds. */
34
+ compressionMaxTokens?: number;
31
35
  maxMessageTokens?: number;
32
36
  overBudgetGraceRatio?: number;
33
37
  // Compression/merge tuning passed through to the underlying
@@ -117,6 +121,12 @@ export interface RecipeAgent {
117
121
  * Omitted preserves the compatibility carry-forward in Agent Framework.
118
122
  */
119
123
  sameRoundThinkTextPolicy?: 'public' | 'private';
124
+ /**
125
+ * Prose delivery mode (agent-framework docs/explicit-prose-routing.md).
126
+ * 'explicit' = model prefixes plain text with `>>destination`; unprefixed
127
+ * prose bounces to a clipboard instead of auto-routing. Default 'locus'.
128
+ */
129
+ proseRouting?: 'locus' | 'explicit';
120
130
  /**
121
131
  * Extra Anthropic beta flags sent as the `anthropic-beta` header on every
122
132
  * request (e.g. `["context-1m-2025-08-07"]` for the 1M context window on
@@ -420,6 +430,18 @@ export interface RecipeModules {
420
430
  */
421
431
  webui?: boolean | RecipeWebUi;
422
432
 
433
+ /**
434
+ * Live TTS streaming tap (melodeus-tts-relay). Off by default. When set,
435
+ * the host mirrors the agent's streaming generation — chunks, block
436
+ * boundaries, activation start/end, tagged with the turn's channel — to the
437
+ * relay's `/bot` WebSocket, where voice clients (Melodeus / iOS) speak it.
438
+ * Also handles the relay's `interruption` events: the just-posted Discord
439
+ * message is edited down to the words actually voiced, and a note lands in
440
+ * the agent's context. Pure trace-bus tap — no effect on the agent loop.
441
+ * `token` should be `${VAR}`-substituted from .env, never literal.
442
+ */
443
+ ttsRelay?: RecipeTtsRelay;
444
+
423
445
  /**
424
446
  * Auto-unsubscribe noisy ambient channels. On by default. Per subscribed
425
447
  * channel, the host counts ambient (non-mention, non-DM) characters since the
@@ -428,7 +450,7 @@ export interface RecipeModules {
428
450
  * tracking what's then missed). Counters reset on every activation — the
429
451
  * agent sees all subscribed channels in context, compressed if large — and
430
452
  * persist across restarts. The agent can override the per-channel limit at
431
- * runtime via the `subscription-gc--set_channel_idle_limit` tool.
453
+ * runtime via `agent_settings` (field `channel_idle_limits`).
432
454
  *
433
455
  * `false` disables entirely. `defaultLimitChars` sets the global default
434
456
  * (20000 if omitted). `serverId`/`toolPrefix` target the MCPL surface that
@@ -471,6 +493,25 @@ export interface RecipeWebUi {
471
493
  allowedOrigins?: string[];
472
494
  }
473
495
 
496
+ export interface RecipeTtsRelay {
497
+ /** Relay bot endpoint, e.g. "ws://localhost:8800/bot". */
498
+ url: string;
499
+ /** Shared secret for the relay's /bot auth (its BOT_TOKENS). */
500
+ token: string;
501
+ /** Relay-side bot identifier. Defaults to the agent's name. */
502
+ botId?: string;
503
+ /** Discord user id stamped on stream events (client voice-config key).
504
+ * Defaults to botId. */
505
+ userId?: string;
506
+ /** Display name stamped on stream events. Defaults to the agent's name. */
507
+ username?: string;
508
+ /** MCPL server id owning `edit_message` for interruption trims. Default 'discord'. */
509
+ editServerId?: string;
510
+ reconnectIntervalMs?: number;
511
+ /** Drop a context note when an interruption trims a posted message. Default true. */
512
+ notifyOnInterruption?: boolean;
513
+ }
514
+
474
515
  export interface RecipeFleet {
475
516
  /** Children to manage. autoStart children launch when the framework starts. */
476
517
  children?: RecipeFleetChild[];
@@ -1110,6 +1151,22 @@ export function validateRecipe(raw: unknown): Recipe {
1110
1151
  }
1111
1152
  }
1112
1153
 
1154
+ // Validate ttsRelay if present — url + token are load-bearing, and a
1155
+ // recipe that names the module but can't reach a relay should fail at
1156
+ // load, not silently stream nowhere.
1157
+ if (mods.ttsRelay !== undefined && mods.ttsRelay !== false) {
1158
+ if (typeof mods.ttsRelay !== 'object' || mods.ttsRelay === null) {
1159
+ throw new Error('modules.ttsRelay must be an object ({ url, token, ... })');
1160
+ }
1161
+ const tr = mods.ttsRelay as Record<string, unknown>;
1162
+ if (typeof tr.url !== 'string' || !/^wss?:\/\//.test(tr.url)) {
1163
+ throw new Error('modules.ttsRelay.url must be a ws:// or wss:// URL');
1164
+ }
1165
+ if (typeof tr.token !== 'string' || !tr.token) {
1166
+ throw new Error('modules.ttsRelay.token must be a non-empty string (use ${VAR} substitution)');
1167
+ }
1168
+ }
1169
+
1113
1170
  // Validate fleet if present (object form only — boolean is trivially valid).
1114
1171
  if (mods.fleet && typeof mods.fleet === 'object') {
1115
1172
  const fleet = mods.fleet as Record<string, unknown>;