@animalabs/connectome-host 0.5.2 → 0.5.4

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.
package/CHANGELOG.md CHANGED
@@ -1,7 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.4 — 2026-07-26
4
+
5
+ ### Fixed
6
+
7
+ - **`dry run + show context` was a 110-second agent stall.** Measured 110,348ms
8
+ against ~8s for the numbers-only dry run. `select()` builds the rendered
9
+ entries either way, so the extra ~102s was pure serialization of 353 full
10
+ entries — content the pane never showed, since it truncated every body past
11
+ 600 chars. The server now projects to `{i, who, chars, media, truncated,
12
+ text}` with text capped at 1,200 chars; content blocks never leave the
13
+ process and media is counted rather than inlined, which also removes the
14
+ blob-resolution heap exposure `/curve` warns about.
15
+ - The cost disclosure was understated by ~20× and in the dangerous direction
16
+ ("seconds… briefly pauses the agent"). It now states that the compile runs on
17
+ the agent's thread and the agent does nothing else meanwhile, quotes the
18
+ measured cost, and notes runs are serialized so a second click is refused
19
+ rather than queueing another pause.
20
+
3
21
  ## Unreleased
4
22
 
23
+ ## 0.5.3 — 2026-07-26
24
+
25
+ ### Added
26
+
27
+ - **Dry-run buttons, and the resulting context in the main pane.** Settings now
28
+ has explicit `dry run` and `dry run + show context` buttons; the latter
29
+ renders the context those settings WOULD produce in a new main-pane view,
30
+ behind an unmissable "dry run — not applied" banner. Entries come from
31
+ context-manager's dry-run select, so it is the actual layout, not an estimate.
32
+ - Dry runs report how long they took, and the panel states the cost up front: a
33
+ full compile, seconds on a large store, briefly pausing the agent.
34
+
35
+ ### Fixed
36
+
37
+ - **Preview no longer fires on every keystroke.** It was debounced-on-input, but
38
+ a dry run is a real compile and `select()` is synchronous — so each one blocks
39
+ the agent's event loop (no heartbeat, no Discord, no MCPL). Typing a budget
40
+ stacked those stalls and made the UI look hung. It is now operator-initiated
41
+ only, with server-side single-flight and a 3s cooldown that returns 429 rather
42
+ than queueing more agent pauses.
43
+ - `middleChunkCount` was labelled "middle chunks" but the adaptive picker's unit
44
+ is the MESSAGE (14,057 for a store with 800 chunks). Relabelled "middle
45
+ messages (picker units)".
46
+ - The sidebar was `w-72` (288px) and clipped the dense numeric tables; now
47
+ `w-96`, wider still on xl displays.
48
+
5
49
  ## 0.5.2 — 2026-07-26
6
50
 
7
51
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/connectome-host",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "General-purpose agent TUI host with recipe-based configuration",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -14,7 +14,7 @@
14
14
  "postinstall": "test -d web && npm --prefix web install && npm --prefix web run build || true"
15
15
  },
16
16
  "dependencies": {
17
- "@animalabs/agent-framework": "^0.7.0",
17
+ "@animalabs/agent-framework": "^0.7.2",
18
18
  "@animalabs/chronicle": "^0.2.0",
19
19
  "@animalabs/context-manager": "^0.6.0",
20
20
  "@animalabs/membrane": "^0.5.75",
@@ -34,6 +34,8 @@ const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
34
34
  'summarySystemPrompt',
35
35
  'summaryUserPrompt',
36
36
  'summaryContextLabel',
37
+ 'witnessedBeforeSequence',
38
+ 'witnessedInstruction',
37
39
  ];
38
40
 
39
41
  export function buildFrameworkStrategy(
@@ -1200,6 +1200,63 @@ export class WebUiModule implements Module {
1200
1200
  * Returns 501 when the resolved context-manager predates dry-run support, so
1201
1201
  * the panel can say so instead of rendering an empty result.
1202
1202
  */
1203
+ /**
1204
+ * Single-flight + cooldown for preview.
1205
+ *
1206
+ * A preview is a real compile: ~8s on a large store, and `select()` is
1207
+ * synchronous so it BLOCKS the agent's event loop for that whole time (no
1208
+ * heartbeat, no Discord, no MCPL). Overlapping or rapid-fire previews
1209
+ * therefore don't just queue — they stack agent stalls. Reject instead.
1210
+ */
1211
+ private previewInFlight = false;
1212
+ private previewLastAt = 0;
1213
+ private static readonly PREVIEW_COOLDOWN_MS = 3_000;
1214
+
1215
+ /**
1216
+ * Replace the dry run's full rendered entries with a compact display
1217
+ * projection.
1218
+ *
1219
+ * Shipping the entries verbatim cost ~110s of BLOCKED AGENT on Mythos (353
1220
+ * entries, megabytes of content plus any inlined media) against ~8s for the
1221
+ * numbers-only path — and select() builds those entries either way, so the
1222
+ * extra ~100s was pure serialization of data the UI never shows in full: the
1223
+ * pane truncates every body past 600 chars anyway.
1224
+ *
1225
+ * So: keep identity, size and a bounded text preview; drop content blocks and
1226
+ * never inline media. Also removes the blob-resolution heap risk the /curve
1227
+ * handler warns about.
1228
+ */
1229
+ private projectDryEntries(result: unknown): unknown {
1230
+ const r = result as { entries?: unknown[] } & Record<string, unknown>;
1231
+ if (!Array.isArray(r.entries)) return result;
1232
+ const MAX_TEXT = 1_200;
1233
+ const projected = r.entries.map((e, i) => {
1234
+ const o = (e ?? {}) as { participant?: string; role?: string; content?: unknown };
1235
+ let text = '';
1236
+ let media = 0;
1237
+ const blocks = Array.isArray(o.content) ? o.content : [];
1238
+ for (const b of blocks) {
1239
+ if (!b || typeof b !== 'object') { text += String(b ?? ''); continue; }
1240
+ const t = (b as { type?: string }).type;
1241
+ if (t === 'text') text += (b as { text?: string }).text ?? '';
1242
+ else if (t === 'image') { media++; text += '[image]'; }
1243
+ else if (t === 'thinking' || t === 'redacted_thinking') text += '[thinking]';
1244
+ else if (t === 'tool_use') text += `[tool_use ${(b as { name?: string }).name ?? ''}]`;
1245
+ else if (t === 'tool_result') text += '[tool_result]';
1246
+ }
1247
+ if (typeof o.content === 'string') text = o.content;
1248
+ return {
1249
+ i,
1250
+ who: o.participant ?? o.role ?? '?',
1251
+ chars: text.length,
1252
+ media,
1253
+ truncated: text.length > MAX_TEXT,
1254
+ text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text,
1255
+ };
1256
+ });
1257
+ return { ...r, entries: projected };
1258
+ }
1259
+
1203
1260
  private handleContextPreview(url: URL): Response {
1204
1261
  const app = sharedServer?.app;
1205
1262
  if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
@@ -1225,8 +1282,33 @@ export class WebUiModule implements Module {
1225
1282
  overrides.recentWindowTokens = tail;
1226
1283
  }
1227
1284
 
1285
+ // `render=1` additionally returns the rendered dry context for display.
1286
+ const wantRender = (() => {
1287
+ const v = url.searchParams.get('render');
1288
+ return v !== null && v !== '0' && v !== 'false';
1289
+ })();
1290
+
1291
+ if (this.previewInFlight) {
1292
+ return Response.json(
1293
+ { error: 'a preview is already running — it blocks the agent, so they are serialized' },
1294
+ { status: 429 },
1295
+ );
1296
+ }
1297
+ const sinceLast = Date.now() - this.previewLastAt;
1298
+ if (sinceLast < WebUiModule.PREVIEW_COOLDOWN_MS) {
1299
+ return Response.json(
1300
+ {
1301
+ error: `preview cooling down — ${Math.ceil((WebUiModule.PREVIEW_COOLDOWN_MS - sinceLast) / 1000)}s left. `
1302
+ + 'Each run is a full compile and briefly pauses the agent.',
1303
+ },
1304
+ { status: 429 },
1305
+ );
1306
+ }
1307
+
1228
1308
  const fw = app.framework as unknown as {
1229
- previewContextSettings?: (n: string, b: number, o?: Record<string, unknown>) => unknown;
1309
+ previewContextSettings?: (
1310
+ n: string, b: number, o?: Record<string, unknown>, x?: { render?: boolean },
1311
+ ) => unknown;
1230
1312
  };
1231
1313
  if (typeof fw.previewContextSettings !== 'function') {
1232
1314
  return Response.json(
@@ -1234,11 +1316,14 @@ export class WebUiModule implements Module {
1234
1316
  { status: 501 },
1235
1317
  );
1236
1318
  }
1319
+ this.previewInFlight = true;
1320
+ const startedAt = Date.now();
1237
1321
  try {
1238
1322
  const result = fw.previewContextSettings(
1239
1323
  agentName,
1240
1324
  budget,
1241
1325
  Object.keys(overrides).length > 0 ? overrides : undefined,
1326
+ wantRender ? { render: true } : undefined,
1242
1327
  );
1243
1328
  if (result === null || result === undefined) {
1244
1329
  return Response.json(
@@ -1284,13 +1369,18 @@ export class WebUiModule implements Module {
1284
1369
  /** Exhausted AND over the request => this budget is UNREACHABLE. */
1285
1370
  unreachable: r.exhausted === true && !fitsRequested,
1286
1371
  },
1287
- preview: result,
1372
+ /** How long the agent was blocked, so the operator sees the real cost. */
1373
+ elapsedMs: Date.now() - startedAt,
1374
+ preview: wantRender ? this.projectDryEntries(result) : result,
1288
1375
  });
1289
1376
  } catch (err) {
1290
1377
  return Response.json(
1291
1378
  { error: err instanceof Error ? err.message : String(err) },
1292
1379
  { status: 500 },
1293
1380
  );
1381
+ } finally {
1382
+ this.previewInFlight = false;
1383
+ this.previewLastAt = Date.now();
1294
1384
  }
1295
1385
  }
1296
1386
 
package/src/recipe.ts CHANGED
@@ -85,6 +85,13 @@ export interface RecipeStrategy {
85
85
  summarySystemPrompt?: string;
86
86
  summaryUserPrompt?: string;
87
87
  summaryContextLabel?: string;
88
+ /** As-of perspective pin: chunks wholly below this message sequence are
89
+ * compressed as inherited/witnessed record, not first-person memory.
90
+ * For residents continued from a shared log they joined partway through. */
91
+ witnessedBeforeSequence?: number;
92
+ /** Override wording for the witnessed-record instruction ({targetTokens}
93
+ * substituted). */
94
+ witnessedInstruction?: string;
88
95
  }
89
96
 
90
97
  export interface RecipeAgent {
package/web/src/App.tsx CHANGED
@@ -10,6 +10,7 @@ import { UsagePanel } from './Usage';
10
10
  import { LessonsPanel, type LessonRow } from './Lessons';
11
11
  import { McplPanel, type McplServerRow } from './Mcpl';
12
12
  import { SettingsPanel, type SettingsState } from './Settings';
13
+ import { DryContext, type DryContextData } from './DryContext';
13
14
  import { FilesPanel, FileViewerModal, type Mount, type FlatEntry, type FileViewer } from './Files';
14
15
  import { ContextPanel } from './Context';
15
16
  import { ContextDocument } from './ContextDocument';
@@ -275,7 +276,9 @@ export function App() {
275
276
  * the default; lessons / mcp / files are operator-driven panels. */
276
277
  type SidebarTab = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'settings' | 'health';
277
278
  const [sidebarTab, setSidebarTab] = createSignal<SidebarTab>('tree');
278
- const [mainView, setMainView] = createSignal<'chat' | 'context'>('chat');
279
+ const [mainView, setMainView] = createSignal<'chat' | 'context' | 'dry'>('chat');
280
+ /** Rendered dry-run context awaiting display. Never applied — see DryContext. */
281
+ const [dryContext, setDryContext] = createSignal<DryContextData | null>(null);
279
282
 
280
283
  /** Scope shared by Lessons / Files / Recipe panels. 'local' means the
281
284
  * parent process; otherwise the fleet child's name. The scope is shared
@@ -1075,13 +1078,19 @@ export function App() {
1075
1078
  <div class="flex border-b border-neutral-800 bg-neutral-900/40 text-[11px] font-mono">
1076
1079
  <button type="button" class={`px-3 py-1.5 ${mainView() === 'chat' ? 'text-neutral-100 bg-neutral-900 border-b border-cyan-700' : 'text-neutral-500 hover:text-neutral-300'}`} onClick={() => setMainView('chat')}>Chat</button>
1077
1080
  <button type="button" class={`px-3 py-1.5 ${mainView() === 'context' ? 'text-neutral-100 bg-neutral-900 border-b border-cyan-700' : 'text-neutral-500 hover:text-neutral-300'}`} onClick={() => setMainView('context')}>Context</button>
1081
+ <Show when={dryContext()}>
1082
+ <button type="button" class={`px-3 py-1.5 ${mainView() === 'dry' ? 'text-amber-200 bg-neutral-900 border-b border-amber-600' : 'text-amber-500/70 hover:text-amber-300'}`} onClick={() => setMainView('dry')}>Dry run</button>
1083
+ </Show>
1078
1084
  </div>
1079
1085
  <div
1080
1086
  ref={scrollPane}
1081
1087
  class="flex-1 overflow-y-auto px-4 py-3 space-y-3"
1082
1088
  onScroll={onScrollPane}
1083
1089
  >
1084
- <Show when={mainView() === 'context'} fallback={<>
1090
+ <Show when={mainView() === 'dry'}>
1091
+ <DryContext data={dryContext()} onClose={() => { setDryContext(null); setMainView('chat'); }} />
1092
+ </Show>
1093
+ <Show when={mainView() === 'context'} fallback={<Show when={mainView() === 'chat'}>
1085
1094
  <Show when={(historyInfo()?.startIndex ?? 0) > 0}>
1086
1095
  <button
1087
1096
  type="button"
@@ -1102,7 +1111,7 @@ export function App() {
1102
1111
  <For each={messages}>{(m) => (
1103
1112
  <MessageView msg={m} results={toolResults()} toolUseIds={toolUseIds()} />
1104
1113
  )}</For>
1105
- </>}>
1114
+ </Show>}>
1106
1115
  <ContextDocument agent={panelScope() === 'local' ? undefined : panelScope()} />
1107
1116
  </Show>
1108
1117
  </div>
@@ -1179,7 +1188,9 @@ export function App() {
1179
1188
  onClose={closePanel}
1180
1189
  />
1181
1190
  </Show>
1182
- <aside class="w-72 border-l border-neutral-800 bg-neutral-950 shrink-0 flex flex-col">
1191
+ {/* Was w-72 (288px): the context/settings panels have dense numeric
1192
+ tables that did not fit. Widen, and give large displays more. */}
1193
+ <aside class="w-96 xl:w-[30rem] border-l border-neutral-800 bg-neutral-950 shrink-0 flex flex-col">
1183
1194
  <SidebarTabs
1184
1195
  current={sidebarTab()}
1185
1196
  onSelect={(tab) => {
@@ -1237,6 +1248,7 @@ export function App() {
1237
1248
  onReset={(keys, persist) =>
1238
1249
  wire.send({ type: 'settings-reset', ...(keys ? { keys } : {}), persist })}
1239
1250
  onCancelTransition={() => wire.send({ type: 'settings-cancel-transition' })}
1251
+ onDryContext={(ctx) => { setDryContext(ctx as DryContextData); setMainView('dry'); }}
1240
1252
  />
1241
1253
  </Show>
1242
1254
  <Show when={sidebarTab() === 'files'}>
@@ -0,0 +1,144 @@
1
+ /**
2
+ * DryContext — the context a hypothetical settings change WOULD produce,
3
+ * rendered in the main pane.
4
+ *
5
+ * Distinct from `ContextDocument`, which shows the agent's live context. This
6
+ * one shows the output of a dry run: nothing here has been applied, and the
7
+ * agent is still running the settings shown in the sidebar. The banner says so,
8
+ * because a rendered context that looks exactly like the real thing is a good
9
+ * way for an operator to lose track of which is which.
10
+ *
11
+ * Entries come straight from context-manager's dry-run select, so this is the
12
+ * actual layout that budget would produce — not an approximation.
13
+ */
14
+
15
+ import { For, Show } from 'solid-js';
16
+
17
+ interface Seg { messages: number; tokens: number }
18
+ interface DryStats {
19
+ head: Seg; tail: Seg; middleRaw: Seg;
20
+ summaries?: Record<string, { count: number; tokens: number }>;
21
+ total: Seg;
22
+ }
23
+
24
+ export interface DryContextData {
25
+ entries: unknown[];
26
+ stats?: unknown;
27
+ label: string;
28
+ }
29
+
30
+ const fmt = (n: number) => n.toLocaleString();
31
+
32
+ /** Recall pairs render as a CM prompt followed by the summary text. */
33
+ const RECALL_MARKERS = ['what do you remember', 'recall memory', '[cm]'];
34
+ const isRecallPrompt = (s: string) =>
35
+ s.length < 400 && RECALL_MARKERS.some((m) => s.toLowerCase().includes(m));
36
+
37
+ export function DryContext(props: { data: DryContextData | null; onClose(): void }) {
38
+ const rows = () =>
39
+ (props.data?.entries ?? []).map((e, i) => {
40
+ // Server-side projection (see projectDryEntries): identity, size and a
41
+ // bounded text preview. Full content blocks are deliberately NOT sent —
42
+ // shipping them cost ~110s of blocked agent.
43
+ const o = (e ?? {}) as {
44
+ i?: number; who?: string; text?: string; chars?: number;
45
+ media?: number; truncated?: boolean;
46
+ };
47
+ const body = o.text ?? '';
48
+ return {
49
+ i: o.i ?? i,
50
+ who: o.who ?? '?',
51
+ body,
52
+ recall: isRecallPrompt(body),
53
+ chars: o.chars ?? body.length,
54
+ media: o.media ?? 0,
55
+ truncated: o.truncated === true,
56
+ };
57
+ });
58
+
59
+ const stats = () => props.data?.stats as DryStats | undefined;
60
+
61
+ return (
62
+ <div class="h-full overflow-y-auto">
63
+ <Show
64
+ when={props.data}
65
+ fallback={
66
+ <div class="p-6 text-neutral-500 text-sm">
67
+ No dry run yet. Open <span class="font-mono text-neutral-400">Settings</span> in the
68
+ sidebar, set a budget, and press{' '}
69
+ <span class="font-mono text-cyan-300">dry run + show context</span>.
70
+ </div>
71
+ }
72
+ >
73
+ {/* Unmissable: this is NOT the live context. */}
74
+ <div class="sticky top-0 z-10 bg-amber-950/80 backdrop-blur border-b border-amber-800
75
+ px-4 py-2 text-[11px] text-amber-200 flex items-center gap-3 flex-wrap">
76
+ <span class="font-semibold uppercase tracking-wider">dry run — not applied</span>
77
+ <span class="font-mono text-amber-300/90">{props.data!.label}</span>
78
+ <span class="text-amber-300/70">
79
+ the agent is still running its current settings
80
+ </span>
81
+ <button
82
+ type="button"
83
+ class="ml-auto px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700
84
+ text-neutral-300 font-mono text-[10px]"
85
+ onClick={() => props.onClose()}
86
+ >
87
+ close
88
+ </button>
89
+ </div>
90
+
91
+ <Show when={stats()}>
92
+ <div class="px-4 py-2 border-b border-neutral-800 text-[11px] font-mono
93
+ text-neutral-400 flex gap-4 flex-wrap">
94
+ <span>entries <span class="text-neutral-200">{fmt(rows().length)}</span></span>
95
+ <span>head <span class="text-neutral-200">{fmt(stats()!.head?.tokens ?? 0)}</span></span>
96
+ <span>middle <span class="text-neutral-200">{fmt(stats()!.middleRaw?.tokens ?? 0)}</span></span>
97
+ <span>tail <span class="text-neutral-200">{fmt(stats()!.tail?.tokens ?? 0)}</span></span>
98
+ <span>total <span class="text-neutral-200">{fmt(stats()!.total?.tokens ?? 0)}</span></span>
99
+ </div>
100
+ </Show>
101
+
102
+ <div class="px-4 py-3 space-y-2">
103
+ <For each={rows()}>
104
+ {(r) => (
105
+ <div
106
+ class={`rounded border px-2.5 py-1.5 ${
107
+ r.recall
108
+ ? 'border-orange-900/60 bg-orange-950/20'
109
+ : 'border-neutral-800 bg-neutral-900/40'
110
+ }`}
111
+ >
112
+ <div class="flex items-baseline gap-2 mb-0.5">
113
+ <span class="text-[10px] font-mono text-neutral-500">#{r.i}</span>
114
+ <span
115
+ class={`text-[11px] font-mono ${
116
+ r.recall ? 'text-orange-300' : 'text-cyan-300'
117
+ }`}
118
+ >
119
+ {r.who}
120
+ </span>
121
+ <span class="text-[10px] text-neutral-600">{fmt(r.chars)} chars</span>
122
+ <Show when={r.media > 0}>
123
+ <span class="text-[10px] text-neutral-600">{r.media} media</span>
124
+ </Show>
125
+ <Show when={r.truncated}>
126
+ <span class="ml-auto text-[10px] font-mono text-neutral-600"
127
+ title="Bodies are truncated server-side; the full text is not sent.">
128
+ truncated
129
+ </span>
130
+ </Show>
131
+ </div>
132
+ <div
133
+ class="text-[12px] text-neutral-300 whitespace-pre-wrap break-words leading-relaxed"
134
+ >
135
+ {r.body}{r.truncated ? ' …' : ''}
136
+ </div>
137
+ </div>
138
+ )}
139
+ </For>
140
+ </div>
141
+ </Show>
142
+ </div>
143
+ );
144
+ }
@@ -15,7 +15,7 @@
15
15
  * that looks like "nothing would change".
16
16
  */
17
17
 
18
- import { createSignal, Show, For, onCleanup } from 'solid-js';
18
+ import { createSignal, Show, For } from 'solid-js';
19
19
 
20
20
  export interface SettingsSnapshot {
21
21
  contextBudgetTokens: number;
@@ -101,6 +101,8 @@ export function SettingsPanel(props: {
101
101
  }): void;
102
102
  onReset(keys: string[] | undefined, persist: boolean): void;
103
103
  onCancelTransition(): void;
104
+ /** Hand a rendered dry context to the main pane for display. */
105
+ onDryContext?(ctx: { entries: unknown[]; stats?: unknown; label: string }): void;
104
106
  }) {
105
107
  const [budget, setBudget] = createSignal<string>('');
106
108
  const [tail, setTail] = createSignal<string>('');
@@ -113,8 +115,7 @@ export function SettingsPanel(props: {
113
115
  const [previewErr, setPreviewErr] = createSignal<string | null>(null);
114
116
  const [previewing, setPreviewing] = createSignal(false);
115
117
 
116
- let debounce: number | undefined;
117
- onCleanup(() => { if (debounce) window.clearTimeout(debounce); });
118
+ const [elapsed, setElapsed] = createSignal<number | null>(null);
118
119
 
119
120
  const s = () => props.state?.settings;
120
121
  const isHot = (k: string) => props.state?.hotKeys.includes(k) ?? false;
@@ -134,38 +135,55 @@ export function SettingsPanel(props: {
134
135
  return Number.isSafeInteger(n) && n > 0 ? n : undefined;
135
136
  };
136
137
 
137
- /** Debounced: an operator dragging a value should not fire a compile per keystroke.
138
- * The request itself is non-committing, but it is still real CPU on a live agent. */
139
- const runPreview = () => {
140
- if (!props.state?.previewAvailable) return;
138
+ /**
139
+ * Explicit dry run. NOT wired to input events on purpose.
140
+ *
141
+ * A dry run is a real compile: seconds on a large store, and the compile is
142
+ * synchronous, so it briefly blocks the agent's event loop — no heartbeat, no
143
+ * Discord, no MCPL for the duration. An earlier version of this panel fired it
144
+ * on every keystroke (debounced), which stacked agent stalls and made the UI
145
+ * look hung. The operator asks for it now.
146
+ *
147
+ * `render` also fetches the resulting context for the main pane.
148
+ */
149
+ const runDryRun = async (render: boolean) => {
150
+ if (!props.state?.previewAvailable || previewing()) return;
141
151
  const b = num(budget());
142
- if (b === undefined) { setPreview(null); setPreviewErr(null); return; }
143
- if (debounce) window.clearTimeout(debounce);
144
- debounce = window.setTimeout(async () => {
145
- setPreviewing(true);
146
- setPreviewErr(null);
147
- try {
148
- const qs = new URLSearchParams({ budget: String(b), agent: props.state!.agent });
149
- const t = num(tail());
150
- if (t !== undefined) qs.set('tail', String(t));
151
- const res = await fetch(`/debug/context/preview?${qs}`, { credentials: 'same-origin' });
152
- const body = await res.json();
153
- if (!res.ok) {
154
- setPreview(null);
155
- setAcct(null);
156
- setPreviewErr(body?.error ?? `HTTP ${res.status}`);
157
- return;
158
- }
159
- setPreview(body.preview as PreviewResult);
160
- setAcct((body.accounting ?? null) as PreviewAccounting | null);
161
- } catch (e) {
152
+ if (b === undefined) { setPreviewErr('budget must be a positive integer'); return; }
153
+ setPreviewing(true);
154
+ setPreviewErr(null);
155
+ setElapsed(null);
156
+ try {
157
+ const qs = new URLSearchParams({ budget: String(b), agent: props.state!.agent });
158
+ const t = num(tail());
159
+ if (t !== undefined) qs.set('tail', String(t));
160
+ if (render) qs.set('render', '1');
161
+ const res = await fetch(`/debug/context/preview?${qs}`, { credentials: 'same-origin' });
162
+ const body = await res.json();
163
+ if (!res.ok) {
162
164
  setPreview(null);
163
165
  setAcct(null);
164
- setPreviewErr(e instanceof Error ? e.message : String(e));
165
- } finally {
166
- setPreviewing(false);
166
+ // 429 is the single-flight / cooldown guard, not a failure.
167
+ setPreviewErr(body?.error ?? `HTTP ${res.status}`);
168
+ return;
167
169
  }
168
- }, 350) as unknown as number;
170
+ setPreview(body.preview as PreviewResult);
171
+ setAcct((body.accounting ?? null) as PreviewAccounting | null);
172
+ setElapsed(typeof body.elapsedMs === 'number' ? body.elapsedMs : null);
173
+ if (render && body.preview?.entries) {
174
+ props.onDryContext?.({
175
+ entries: body.preview.entries,
176
+ stats: body.preview.stats,
177
+ label: `budget ${b.toLocaleString()}${t !== undefined ? `, tail ${t.toLocaleString()}` : ''}`,
178
+ });
179
+ }
180
+ } catch (e) {
181
+ setPreview(null);
182
+ setAcct(null);
183
+ setPreviewErr(e instanceof Error ? e.message : String(e));
184
+ } finally {
185
+ setPreviewing(false);
186
+ }
169
187
  };
170
188
 
171
189
  const apply = () => {
@@ -297,7 +315,7 @@ export function SettingsPanel(props: {
297
315
  disabled={!isHot(key)}
298
316
  value={get()}
299
317
  placeholder={isHot(key) ? 'tokens' : 'restart only'}
300
- onInput={(e) => { set(e.currentTarget.value); runPreview(); }}
318
+ onInput={(e) => set(e.currentTarget.value)}
301
319
  class="flex-1 bg-neutral-900 border border-neutral-700 rounded px-1.5 py-0.5
302
320
  font-mono text-[11px] text-neutral-100 disabled:opacity-40"
303
321
  />
@@ -356,13 +374,43 @@ export function SettingsPanel(props: {
356
374
 
357
375
  {/* ---- preview ---- */}
358
376
  <div class="border-t border-neutral-800 pt-2">
359
- <div class="flex items-center gap-2 mb-1">
377
+ <div class="flex items-center gap-2 mb-1 flex-wrap">
360
378
  <span class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold">
361
- preview
379
+ dry run
362
380
  </span>
381
+ <button
382
+ type="button"
383
+ disabled={previewing() || !props.state!.previewAvailable}
384
+ class="px-2 py-0.5 text-[10px] rounded font-mono bg-neutral-800 hover:bg-neutral-700
385
+ text-neutral-200 disabled:opacity-30"
386
+ onClick={() => void runDryRun(false)}
387
+ title="Compile at these settings without applying them. Does not commit anything. Measured ~8s on a large store."
388
+ >
389
+ dry run
390
+ </button>
391
+ <button
392
+ type="button"
393
+ disabled={previewing() || !props.state!.previewAvailable}
394
+ class="px-2 py-0.5 text-[10px] rounded font-mono bg-cyan-900/40 hover:bg-cyan-900/60
395
+ text-cyan-200 disabled:opacity-30"
396
+ onClick={() => void runDryRun(true)}
397
+ title="Dry run, and show the resulting context in the main pane. Costs more than a plain dry run — bodies are truncated server-side to keep it close."
398
+ >
399
+ dry run + show context
400
+ </button>
363
401
  <Show when={previewing()}>
364
- <span class="text-neutral-600 text-[10px]">computing…</span>
402
+ <span class="text-amber-400/90 text-[10px]">running — agent paused…</span>
365
403
  </Show>
404
+ <Show when={!previewing() && elapsed() !== null}>
405
+ <span class="text-neutral-600 text-[10px]">{(elapsed()! / 1000).toFixed(1)}s</span>
406
+ </Show>
407
+ </div>
408
+ <div class="text-[10px] text-neutral-600 mb-1.5 leading-relaxed">
409
+ A dry run is a <b class="text-neutral-500">full compile</b> and it runs on the agent's
410
+ thread — while it runs the agent does nothing else (no heartbeat, no Discord, no MCPL).
411
+ Measured ~8s on a large store; it commits nothing (no fold resolutions, no compression
412
+ queued). Runs are serialized with a short cooldown, so a second click is refused rather
413
+ than queueing another pause.
366
414
  </div>
367
415
 
368
416
  <Show when={!props.state!.previewAvailable}>
@@ -432,7 +480,7 @@ export function SettingsPanel(props: {
432
480
  ['head (verbatim)', p().headTokens],
433
481
  ['tail (verbatim)', p().tailTokens],
434
482
  ['middle (foldable)', p().middleTokens],
435
- ['middle chunks', p().middleChunkCount],
483
+ ['middle messages (picker units)', p().middleChunkCount],
436
484
  ['deepest fold level', `L${p().deepestLevel}`],
437
485
  ['folds applied', p().appliedCount],
438
486
  ] as Array<[string, unknown]>}>