@animalabs/connectome-host 0.5.2 → 0.5.3
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 +26 -0
- package/package.json +1 -1
- package/src/modules/web-ui-module.ts +46 -1
- package/web/src/App.tsx +16 -4
- package/web/src/DryContext.tsx +163 -0
- package/web/src/Settings.tsx +81 -36
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.3 — 2026-07-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Dry-run buttons, and the resulting context in the main pane.** Settings now
|
|
10
|
+
has explicit `dry run` and `dry run + show context` buttons; the latter
|
|
11
|
+
renders the context those settings WOULD produce in a new main-pane view,
|
|
12
|
+
behind an unmissable "dry run — not applied" banner. Entries come from
|
|
13
|
+
context-manager's dry-run select, so it is the actual layout, not an estimate.
|
|
14
|
+
- Dry runs report how long they took, and the panel states the cost up front: a
|
|
15
|
+
full compile, seconds on a large store, briefly pausing the agent.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **Preview no longer fires on every keystroke.** It was debounced-on-input, but
|
|
20
|
+
a dry run is a real compile and `select()` is synchronous — so each one blocks
|
|
21
|
+
the agent's event loop (no heartbeat, no Discord, no MCPL). Typing a budget
|
|
22
|
+
stacked those stalls and made the UI look hung. It is now operator-initiated
|
|
23
|
+
only, with server-side single-flight and a 3s cooldown that returns 429 rather
|
|
24
|
+
than queueing more agent pauses.
|
|
25
|
+
- `middleChunkCount` was labelled "middle chunks" but the adaptive picker's unit
|
|
26
|
+
is the MESSAGE (14,057 for a store with 800 chunks). Relabelled "middle
|
|
27
|
+
messages (picker units)".
|
|
28
|
+
- The sidebar was `w-72` (288px) and clipped the dense numeric tables; now
|
|
29
|
+
`w-96`, wider still on xl displays.
|
|
30
|
+
|
|
5
31
|
## 0.5.2 — 2026-07-26
|
|
6
32
|
|
|
7
33
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1200,6 +1200,18 @@ 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
|
+
|
|
1203
1215
|
private handleContextPreview(url: URL): Response {
|
|
1204
1216
|
const app = sharedServer?.app;
|
|
1205
1217
|
if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
|
|
@@ -1225,8 +1237,33 @@ export class WebUiModule implements Module {
|
|
|
1225
1237
|
overrides.recentWindowTokens = tail;
|
|
1226
1238
|
}
|
|
1227
1239
|
|
|
1240
|
+
// `render=1` additionally returns the rendered dry context for display.
|
|
1241
|
+
const wantRender = (() => {
|
|
1242
|
+
const v = url.searchParams.get('render');
|
|
1243
|
+
return v !== null && v !== '0' && v !== 'false';
|
|
1244
|
+
})();
|
|
1245
|
+
|
|
1246
|
+
if (this.previewInFlight) {
|
|
1247
|
+
return Response.json(
|
|
1248
|
+
{ error: 'a preview is already running — it blocks the agent, so they are serialized' },
|
|
1249
|
+
{ status: 429 },
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
const sinceLast = Date.now() - this.previewLastAt;
|
|
1253
|
+
if (sinceLast < WebUiModule.PREVIEW_COOLDOWN_MS) {
|
|
1254
|
+
return Response.json(
|
|
1255
|
+
{
|
|
1256
|
+
error: `preview cooling down — ${Math.ceil((WebUiModule.PREVIEW_COOLDOWN_MS - sinceLast) / 1000)}s left. `
|
|
1257
|
+
+ 'Each run is a full compile and briefly pauses the agent.',
|
|
1258
|
+
},
|
|
1259
|
+
{ status: 429 },
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1228
1263
|
const fw = app.framework as unknown as {
|
|
1229
|
-
previewContextSettings?: (
|
|
1264
|
+
previewContextSettings?: (
|
|
1265
|
+
n: string, b: number, o?: Record<string, unknown>, x?: { render?: boolean },
|
|
1266
|
+
) => unknown;
|
|
1230
1267
|
};
|
|
1231
1268
|
if (typeof fw.previewContextSettings !== 'function') {
|
|
1232
1269
|
return Response.json(
|
|
@@ -1234,11 +1271,14 @@ export class WebUiModule implements Module {
|
|
|
1234
1271
|
{ status: 501 },
|
|
1235
1272
|
);
|
|
1236
1273
|
}
|
|
1274
|
+
this.previewInFlight = true;
|
|
1275
|
+
const startedAt = Date.now();
|
|
1237
1276
|
try {
|
|
1238
1277
|
const result = fw.previewContextSettings(
|
|
1239
1278
|
agentName,
|
|
1240
1279
|
budget,
|
|
1241
1280
|
Object.keys(overrides).length > 0 ? overrides : undefined,
|
|
1281
|
+
wantRender ? { render: true } : undefined,
|
|
1242
1282
|
);
|
|
1243
1283
|
if (result === null || result === undefined) {
|
|
1244
1284
|
return Response.json(
|
|
@@ -1284,6 +1324,8 @@ export class WebUiModule implements Module {
|
|
|
1284
1324
|
/** Exhausted AND over the request => this budget is UNREACHABLE. */
|
|
1285
1325
|
unreachable: r.exhausted === true && !fitsRequested,
|
|
1286
1326
|
},
|
|
1327
|
+
/** How long the agent was blocked, so the operator sees the real cost. */
|
|
1328
|
+
elapsedMs: Date.now() - startedAt,
|
|
1287
1329
|
preview: result,
|
|
1288
1330
|
});
|
|
1289
1331
|
} catch (err) {
|
|
@@ -1291,6 +1333,9 @@ export class WebUiModule implements Module {
|
|
|
1291
1333
|
{ error: err instanceof Error ? err.message : String(err) },
|
|
1292
1334
|
{ status: 500 },
|
|
1293
1335
|
);
|
|
1336
|
+
} finally {
|
|
1337
|
+
this.previewInFlight = false;
|
|
1338
|
+
this.previewLastAt = Date.now();
|
|
1294
1339
|
}
|
|
1295
1340
|
}
|
|
1296
1341
|
|
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() === '
|
|
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
|
-
|
|
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,163 @@
|
|
|
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 { createSignal, 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
|
+
function textOf(content: unknown): string {
|
|
33
|
+
if (Array.isArray(content)) {
|
|
34
|
+
return content
|
|
35
|
+
.map((b) => {
|
|
36
|
+
if (!b || typeof b !== 'object') return String(b ?? '');
|
|
37
|
+
const t = (b as { type?: string }).type;
|
|
38
|
+
if (t === 'text') return (b as { text?: string }).text ?? '';
|
|
39
|
+
if (t === 'image') return '[image]';
|
|
40
|
+
if (t === 'thinking' || t === 'redacted_thinking') return '[thinking]';
|
|
41
|
+
if (t === 'tool_use') return `[tool_use ${(b as { name?: string }).name ?? ''}]`;
|
|
42
|
+
if (t === 'tool_result') return '[tool_result]';
|
|
43
|
+
return '';
|
|
44
|
+
})
|
|
45
|
+
.join('');
|
|
46
|
+
}
|
|
47
|
+
return String(content ?? '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Recall pairs render as a CM prompt followed by the summary text. */
|
|
51
|
+
const RECALL_MARKERS = ['what do you remember', 'recall memory', '[cm]'];
|
|
52
|
+
const isRecallPrompt = (s: string) =>
|
|
53
|
+
s.length < 400 && RECALL_MARKERS.some((m) => s.toLowerCase().includes(m));
|
|
54
|
+
|
|
55
|
+
export function DryContext(props: { data: DryContextData | null; onClose(): void }) {
|
|
56
|
+
const [expanded, setExpanded] = createSignal<Set<number>>(new Set());
|
|
57
|
+
const toggle = (i: number) => {
|
|
58
|
+
const next = new Set(expanded());
|
|
59
|
+
next.has(i) ? next.delete(i) : next.add(i);
|
|
60
|
+
setExpanded(next);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const rows = () =>
|
|
64
|
+
(props.data?.entries ?? []).map((e, i) => {
|
|
65
|
+
const o = (e ?? {}) as { participant?: string; role?: string; content?: unknown };
|
|
66
|
+
const body = textOf(o.content);
|
|
67
|
+
return {
|
|
68
|
+
i,
|
|
69
|
+
who: o.participant ?? o.role ?? '?',
|
|
70
|
+
body,
|
|
71
|
+
recall: isRecallPrompt(body),
|
|
72
|
+
chars: body.length,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const stats = () => props.data?.stats as DryStats | undefined;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div class="h-full overflow-y-auto">
|
|
80
|
+
<Show
|
|
81
|
+
when={props.data}
|
|
82
|
+
fallback={
|
|
83
|
+
<div class="p-6 text-neutral-500 text-sm">
|
|
84
|
+
No dry run yet. Open <span class="font-mono text-neutral-400">Settings</span> in the
|
|
85
|
+
sidebar, set a budget, and press{' '}
|
|
86
|
+
<span class="font-mono text-cyan-300">dry run + show context</span>.
|
|
87
|
+
</div>
|
|
88
|
+
}
|
|
89
|
+
>
|
|
90
|
+
{/* Unmissable: this is NOT the live context. */}
|
|
91
|
+
<div class="sticky top-0 z-10 bg-amber-950/80 backdrop-blur border-b border-amber-800
|
|
92
|
+
px-4 py-2 text-[11px] text-amber-200 flex items-center gap-3 flex-wrap">
|
|
93
|
+
<span class="font-semibold uppercase tracking-wider">dry run — not applied</span>
|
|
94
|
+
<span class="font-mono text-amber-300/90">{props.data!.label}</span>
|
|
95
|
+
<span class="text-amber-300/70">
|
|
96
|
+
the agent is still running its current settings
|
|
97
|
+
</span>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
class="ml-auto px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700
|
|
101
|
+
text-neutral-300 font-mono text-[10px]"
|
|
102
|
+
onClick={() => props.onClose()}
|
|
103
|
+
>
|
|
104
|
+
close
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<Show when={stats()}>
|
|
109
|
+
<div class="px-4 py-2 border-b border-neutral-800 text-[11px] font-mono
|
|
110
|
+
text-neutral-400 flex gap-4 flex-wrap">
|
|
111
|
+
<span>entries <span class="text-neutral-200">{fmt(rows().length)}</span></span>
|
|
112
|
+
<span>head <span class="text-neutral-200">{fmt(stats()!.head?.tokens ?? 0)}</span></span>
|
|
113
|
+
<span>middle <span class="text-neutral-200">{fmt(stats()!.middleRaw?.tokens ?? 0)}</span></span>
|
|
114
|
+
<span>tail <span class="text-neutral-200">{fmt(stats()!.tail?.tokens ?? 0)}</span></span>
|
|
115
|
+
<span>total <span class="text-neutral-200">{fmt(stats()!.total?.tokens ?? 0)}</span></span>
|
|
116
|
+
</div>
|
|
117
|
+
</Show>
|
|
118
|
+
|
|
119
|
+
<div class="px-4 py-3 space-y-2">
|
|
120
|
+
<For each={rows()}>
|
|
121
|
+
{(r) => (
|
|
122
|
+
<div
|
|
123
|
+
class={`rounded border px-2.5 py-1.5 ${
|
|
124
|
+
r.recall
|
|
125
|
+
? 'border-orange-900/60 bg-orange-950/20'
|
|
126
|
+
: 'border-neutral-800 bg-neutral-900/40'
|
|
127
|
+
}`}
|
|
128
|
+
>
|
|
129
|
+
<div class="flex items-baseline gap-2 mb-0.5">
|
|
130
|
+
<span class="text-[10px] font-mono text-neutral-500">#{r.i}</span>
|
|
131
|
+
<span
|
|
132
|
+
class={`text-[11px] font-mono ${
|
|
133
|
+
r.recall ? 'text-orange-300' : 'text-cyan-300'
|
|
134
|
+
}`}
|
|
135
|
+
>
|
|
136
|
+
{r.who}
|
|
137
|
+
</span>
|
|
138
|
+
<span class="text-[10px] text-neutral-600">{fmt(r.chars)} chars</span>
|
|
139
|
+
<Show when={r.chars > 600}>
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
class="ml-auto text-[10px] font-mono text-neutral-500 hover:text-neutral-300"
|
|
143
|
+
onClick={() => toggle(r.i)}
|
|
144
|
+
>
|
|
145
|
+
{expanded().has(r.i) ? 'collapse' : 'expand'}
|
|
146
|
+
</button>
|
|
147
|
+
</Show>
|
|
148
|
+
</div>
|
|
149
|
+
<div
|
|
150
|
+
class="text-[12px] text-neutral-300 whitespace-pre-wrap break-words leading-relaxed"
|
|
151
|
+
>
|
|
152
|
+
{expanded().has(r.i) || r.chars <= 600
|
|
153
|
+
? r.body
|
|
154
|
+
: r.body.slice(0, 600) + ' …'}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</For>
|
|
159
|
+
</div>
|
|
160
|
+
</Show>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
package/web/src/Settings.tsx
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* that looks like "nothing would change".
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { createSignal, Show, For
|
|
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
|
-
|
|
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
|
-
/**
|
|
138
|
-
*
|
|
139
|
-
|
|
140
|
-
|
|
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) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
// 429 is the single-flight / cooldown guard, not a failure.
|
|
167
|
+
setPreviewErr(body?.error ?? `HTTP ${res.status}`);
|
|
168
|
+
return;
|
|
167
169
|
}
|
|
168
|
-
|
|
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) =>
|
|
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,40 @@ 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
|
-
|
|
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."
|
|
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."
|
|
398
|
+
>
|
|
399
|
+
dry run + show context
|
|
400
|
+
</button>
|
|
363
401
|
<Show when={previewing()}>
|
|
364
|
-
<span class="text-
|
|
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 full compile — seconds on a large store — and it briefly pauses the
|
|
410
|
+
agent. It commits nothing: no fold resolutions, no compression queued.
|
|
366
411
|
</div>
|
|
367
412
|
|
|
368
413
|
<Show when={!props.state!.previewAvailable}>
|
|
@@ -432,7 +477,7 @@ export function SettingsPanel(props: {
|
|
|
432
477
|
['head (verbatim)', p().headTokens],
|
|
433
478
|
['tail (verbatim)', p().tailTokens],
|
|
434
479
|
['middle (foldable)', p().middleTokens],
|
|
435
|
-
['middle
|
|
480
|
+
['middle messages (picker units)', p().middleChunkCount],
|
|
436
481
|
['deepest fold level', `L${p().deepestLevel}`],
|
|
437
482
|
['folds applied', p().appliedCount],
|
|
438
483
|
] as Array<[string, unknown]>}>
|