@animalabs/connectome-host 0.5.3 → 0.6.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.
- package/CHANGELOG.md +65 -0
- package/package.json +2 -2
- package/src/framework-strategy.ts +2 -0
- package/src/index.ts +2 -0
- package/src/modules/web-ui-module.ts +199 -2
- package/src/recipe.ts +48 -0
- package/src/web/protocol.ts +99 -1
- package/test/settings-protocol.test.ts +37 -0
- package/web/src/App.tsx +33 -2
- package/web/src/DryContext.tsx +23 -42
- package/web/src/Pins.tsx +318 -0
- package/web/src/Settings.tsx +35 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,71 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.6.0 — 2026-07-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Pins panel** — operator control over protected ranges, using the pin surface
|
|
10
|
+
that already existed in context-manager (`pinRange` / `markDocument` / `unpin`
|
|
11
|
+
/ `listPins`). No cm or af change was needed.
|
|
12
|
+
- Three semantics kept visibly distinct rather than collapsed into one "pin",
|
|
13
|
+
because they do different things to the fold plan: **raw** (never folded),
|
|
14
|
+
**max L<sub>k</sub>** (fold no deeper than k; k=0 ≡ raw), and **at
|
|
15
|
+
L<sub>k</sub>** (pinned at exactly k — the frontier cut passes through that
|
|
16
|
+
node).
|
|
17
|
+
- `at L_k` is honored **only** by `foldingStrategy: 'kv-stable'`; elsewhere it
|
|
18
|
+
degrades to raw. The panel detects this and warns, rather than letting a
|
|
19
|
+
request silently mean something else.
|
|
20
|
+
- Ids are pickable from `/debug/context/curve` (~14ms, the cheapest debug
|
|
21
|
+
endpoint and the only one exposing per-entry store ids with a text preview),
|
|
22
|
+
so ranges are selected from the live context instead of pasted by hand.
|
|
23
|
+
Entries without a store id — merged summaries — are omitted, since a pin
|
|
24
|
+
needs a message id.
|
|
25
|
+
- New `request-pins` / `pin-add` / `pin-remove`, server frame `pins-list`,
|
|
26
|
+
broadcast on change like `settings-state`: pins alter what the next compile
|
|
27
|
+
folds, so operators must not hold divergent views. Full-auth only via
|
|
28
|
+
`observerMaySend`'s default-deny. `level` and `maxLevel` together is rejected
|
|
29
|
+
at the wire as ambiguous.
|
|
30
|
+
- Pins take effect on the next compile — no restart — and pair with dry run,
|
|
31
|
+
which is now ~1.6s rather than minutes.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Dry-run cost text said "~8s"; measured ~1.6s after the context-manager solver
|
|
36
|
+
fixes. Corrected rather than left pessimistic.
|
|
37
|
+
|
|
38
|
+
## 0.5.4 — 2026-07-26
|
|
39
|
+
|
|
40
|
+
### Corrected after release
|
|
41
|
+
|
|
42
|
+
- The `0.5.4` note below claimed the entry projection fixed the 110s stall. **It
|
|
43
|
+
did not.** Re-measuring after the change showed 121,855ms — unchanged. The
|
|
44
|
+
projection cut payload (megabytes → 265KB) and removed the blob-inlining heap
|
|
45
|
+
exposure, both worth keeping, but the time was never serialization. The actual
|
|
46
|
+
cause was an O(members × groupSize) cliff in three kv-control solver loops,
|
|
47
|
+
triggered whenever a head/tail boundary falls inside a deep summary group —
|
|
48
|
+
fixed in context-manager (`1c4c436`, `7f2d5e1`; see
|
|
49
|
+
`docs/incremental-compile-problem.md` §9.3). Measured after that fix: dry run
|
|
50
|
+
**1.56s**, dry run + render **1.69s**, live compile 22.5s → 2.4s.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- **`dry run + show context` was a 110-second agent stall.** Measured 110,348ms
|
|
55
|
+
against ~8s for the numbers-only dry run. `select()` builds the rendered
|
|
56
|
+
entries either way, so the extra ~102s was pure serialization of 353 full
|
|
57
|
+
entries — content the pane never showed, since it truncated every body past
|
|
58
|
+
600 chars. The server now projects to `{i, who, chars, media, truncated,
|
|
59
|
+
text}` with text capped at 1,200 chars; content blocks never leave the
|
|
60
|
+
process and media is counted rather than inlined, which also removes the
|
|
61
|
+
blob-resolution heap exposure `/curve` warns about.
|
|
62
|
+
- The cost disclosure was understated by ~20× and in the dangerous direction
|
|
63
|
+
("seconds… briefly pauses the agent"). It now states that the compile runs on
|
|
64
|
+
the agent's thread and the agent does nothing else meanwhile, quotes the
|
|
65
|
+
measured cost, and notes runs are serialized so a second click is refused
|
|
66
|
+
rather than queueing another pause.
|
|
67
|
+
|
|
68
|
+
## Unreleased
|
|
69
|
+
|
|
5
70
|
## 0.5.3 — 2026-07-26
|
|
6
71
|
|
|
7
72
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@animalabs/connectome-host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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.
|
|
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",
|
package/src/index.ts
CHANGED
|
@@ -448,6 +448,8 @@ agents: [agentConfig],
|
|
|
448
448
|
mcplServers: finalServers,
|
|
449
449
|
gate: gateOptions,
|
|
450
450
|
timeZone,
|
|
451
|
+
// Client-side programmatic tool calling (code_execution) — recipe opt-in.
|
|
452
|
+
...(recipe.codeExecution ? { codeExecution: recipe.codeExecution } : {}),
|
|
451
453
|
});
|
|
452
454
|
|
|
453
455
|
// Wire post-creation hooks
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
type CallLedgerSnapshot,
|
|
60
60
|
type McplListMessage,
|
|
61
61
|
type SettingsStateMessage,
|
|
62
|
+
type PinsListMessage,
|
|
62
63
|
type BranchesListMessage,
|
|
63
64
|
type LessonsListMessage,
|
|
64
65
|
} from '../web/protocol.js';
|
|
@@ -1212,6 +1213,51 @@ export class WebUiModule implements Module {
|
|
|
1212
1213
|
private previewLastAt = 0;
|
|
1213
1214
|
private static readonly PREVIEW_COOLDOWN_MS = 3_000;
|
|
1214
1215
|
|
|
1216
|
+
/**
|
|
1217
|
+
* Replace the dry run's full rendered entries with a compact display
|
|
1218
|
+
* projection.
|
|
1219
|
+
*
|
|
1220
|
+
* Shipping the entries verbatim cost ~110s of BLOCKED AGENT on Mythos (353
|
|
1221
|
+
* entries, megabytes of content plus any inlined media) against ~8s for the
|
|
1222
|
+
* numbers-only path — and select() builds those entries either way, so the
|
|
1223
|
+
* extra ~100s was pure serialization of data the UI never shows in full: the
|
|
1224
|
+
* pane truncates every body past 600 chars anyway.
|
|
1225
|
+
*
|
|
1226
|
+
* So: keep identity, size and a bounded text preview; drop content blocks and
|
|
1227
|
+
* never inline media. Also removes the blob-resolution heap risk the /curve
|
|
1228
|
+
* handler warns about.
|
|
1229
|
+
*/
|
|
1230
|
+
private projectDryEntries(result: unknown): unknown {
|
|
1231
|
+
const r = result as { entries?: unknown[] } & Record<string, unknown>;
|
|
1232
|
+
if (!Array.isArray(r.entries)) return result;
|
|
1233
|
+
const MAX_TEXT = 1_200;
|
|
1234
|
+
const projected = r.entries.map((e, i) => {
|
|
1235
|
+
const o = (e ?? {}) as { participant?: string; role?: string; content?: unknown };
|
|
1236
|
+
let text = '';
|
|
1237
|
+
let media = 0;
|
|
1238
|
+
const blocks = Array.isArray(o.content) ? o.content : [];
|
|
1239
|
+
for (const b of blocks) {
|
|
1240
|
+
if (!b || typeof b !== 'object') { text += String(b ?? ''); continue; }
|
|
1241
|
+
const t = (b as { type?: string }).type;
|
|
1242
|
+
if (t === 'text') text += (b as { text?: string }).text ?? '';
|
|
1243
|
+
else if (t === 'image') { media++; text += '[image]'; }
|
|
1244
|
+
else if (t === 'thinking' || t === 'redacted_thinking') text += '[thinking]';
|
|
1245
|
+
else if (t === 'tool_use') text += `[tool_use ${(b as { name?: string }).name ?? ''}]`;
|
|
1246
|
+
else if (t === 'tool_result') text += '[tool_result]';
|
|
1247
|
+
}
|
|
1248
|
+
if (typeof o.content === 'string') text = o.content;
|
|
1249
|
+
return {
|
|
1250
|
+
i,
|
|
1251
|
+
who: o.participant ?? o.role ?? '?',
|
|
1252
|
+
chars: text.length,
|
|
1253
|
+
media,
|
|
1254
|
+
truncated: text.length > MAX_TEXT,
|
|
1255
|
+
text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text,
|
|
1256
|
+
};
|
|
1257
|
+
});
|
|
1258
|
+
return { ...r, entries: projected };
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1215
1261
|
private handleContextPreview(url: URL): Response {
|
|
1216
1262
|
const app = sharedServer?.app;
|
|
1217
1263
|
if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
|
|
@@ -1326,7 +1372,7 @@ export class WebUiModule implements Module {
|
|
|
1326
1372
|
},
|
|
1327
1373
|
/** How long the agent was blocked, so the operator sees the real cost. */
|
|
1328
1374
|
elapsedMs: Date.now() - startedAt,
|
|
1329
|
-
preview: result,
|
|
1375
|
+
preview: wantRender ? this.projectDryEntries(result) : result,
|
|
1330
1376
|
});
|
|
1331
1377
|
} catch (err) {
|
|
1332
1378
|
return Response.json(
|
|
@@ -1932,6 +1978,64 @@ export class WebUiModule implements Module {
|
|
|
1932
1978
|
return;
|
|
1933
1979
|
}
|
|
1934
1980
|
|
|
1981
|
+
case 'request-pins': {
|
|
1982
|
+
const msg = this.buildPinsList(this.resolveSettingsAgent(parsed.agent));
|
|
1983
|
+
if (!msg) {
|
|
1984
|
+
this.send(client, { type: 'error', message: 'pins unavailable on this build' });
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
this.send(client, msg);
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// Pins change what the NEXT compile folds, so like settings these
|
|
1992
|
+
// broadcast rather than replying to the requester only.
|
|
1993
|
+
case 'pin-add': {
|
|
1994
|
+
const agentName = this.resolveSettingsAgent(parsed.agent);
|
|
1995
|
+
try {
|
|
1996
|
+
const cm = this.pinnableCm(agentName);
|
|
1997
|
+
const opts: Record<string, unknown> = {};
|
|
1998
|
+
if (parsed.name !== undefined) opts.name = parsed.name;
|
|
1999
|
+
if (parsed.level !== undefined) opts.level = parsed.level;
|
|
2000
|
+
if (parsed.maxLevel !== undefined) opts.maxLevel = parsed.maxLevel;
|
|
2001
|
+
if (parsed.kind === 'document') {
|
|
2002
|
+
cm.markDocument!(parsed.firstMessageId, opts);
|
|
2003
|
+
} else {
|
|
2004
|
+
// A single-message pin is a range of one; the strategy takes both ends.
|
|
2005
|
+
cm.pinRange!(parsed.firstMessageId, parsed.lastMessageId ?? parsed.firstMessageId, opts);
|
|
2006
|
+
}
|
|
2007
|
+
} catch (err) {
|
|
2008
|
+
this.send(client, {
|
|
2009
|
+
type: 'error',
|
|
2010
|
+
message: `pin-add failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2011
|
+
});
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
this.broadcastPinsList(agentName);
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
case 'pin-remove': {
|
|
2019
|
+
const agentName = this.resolveSettingsAgent(parsed.agent);
|
|
2020
|
+
try {
|
|
2021
|
+
const cm = this.pinnableCm(agentName);
|
|
2022
|
+
const ok = cm.unpin!(parsed.pinId);
|
|
2023
|
+
if (!ok) {
|
|
2024
|
+
// Not an exception: a stale panel can ask twice. Say so plainly and
|
|
2025
|
+
// still re-broadcast, so the client converges on reality.
|
|
2026
|
+
this.send(client, { type: 'error', message: `no such pin: ${parsed.pinId}` });
|
|
2027
|
+
}
|
|
2028
|
+
} catch (err) {
|
|
2029
|
+
this.send(client, {
|
|
2030
|
+
type: 'error',
|
|
2031
|
+
message: `pin-remove failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2032
|
+
});
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
this.broadcastPinsList(agentName);
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
1935
2039
|
case 'request-settings': {
|
|
1936
2040
|
this.sendSettingsState(client, parsed.agent);
|
|
1937
2041
|
return;
|
|
@@ -1943,10 +2047,11 @@ export class WebUiModule implements Module {
|
|
|
1943
2047
|
case 'settings-update': {
|
|
1944
2048
|
const agentName = this.resolveSettingsAgent(parsed.agent);
|
|
1945
2049
|
try {
|
|
1946
|
-
const patch: Record<string, number> = {};
|
|
2050
|
+
const patch: Record<string, number | boolean> = {};
|
|
1947
2051
|
if (parsed.contextBudgetTokens !== undefined) patch.contextBudgetTokens = parsed.contextBudgetTokens;
|
|
1948
2052
|
if (parsed.tailTokens !== undefined) patch.tailTokens = parsed.tailTokens;
|
|
1949
2053
|
if (parsed.transitionPaceTokens !== undefined) patch.transitionPaceTokens = parsed.transitionPaceTokens;
|
|
2054
|
+
if (parsed.immediate !== undefined) patch.immediate = parsed.immediate;
|
|
1950
2055
|
const fw = sharedServer!.app.framework as unknown as {
|
|
1951
2056
|
updateAgentRuntimeSettings: (n: string, p: unknown, o?: { persist?: boolean }) => unknown;
|
|
1952
2057
|
};
|
|
@@ -2667,6 +2772,98 @@ export class WebUiModule implements Module {
|
|
|
2667
2772
|
}
|
|
2668
2773
|
}
|
|
2669
2774
|
|
|
2775
|
+
/** Duck-typed pin surface. Throws with a clear reason rather than returning a
|
|
2776
|
+
* half-usable object, so handlers can report it to the operator verbatim. */
|
|
2777
|
+
private pinnableCm(agentName: string): {
|
|
2778
|
+
pinRange?: (a: string, b: string, o?: unknown) => string;
|
|
2779
|
+
markDocument?: (a: string, o?: unknown) => string;
|
|
2780
|
+
unpin?: (id: string) => boolean;
|
|
2781
|
+
listPins?: () => ReadonlyArray<Record<string, unknown>>;
|
|
2782
|
+
} {
|
|
2783
|
+
const app = sharedServer?.app;
|
|
2784
|
+
if (!app) throw new Error('app not bound yet');
|
|
2785
|
+
const agent = app.framework.getAgent(agentName);
|
|
2786
|
+
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
2787
|
+
const cm = agent.getContextManager() as unknown as {
|
|
2788
|
+
pinRange?: (a: string, b: string, o?: unknown) => string;
|
|
2789
|
+
markDocument?: (a: string, o?: unknown) => string;
|
|
2790
|
+
unpin?: (id: string) => boolean;
|
|
2791
|
+
listPins?: () => ReadonlyArray<Record<string, unknown>>;
|
|
2792
|
+
};
|
|
2793
|
+
if (typeof cm.listPins !== 'function' || typeof cm.pinRange !== 'function') {
|
|
2794
|
+
throw new Error('the active context strategy does not support pins');
|
|
2795
|
+
}
|
|
2796
|
+
return cm;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
/**
|
|
2800
|
+
* Pin snapshot. Never throws — an unsupported strategy is a legitimate
|
|
2801
|
+
* read-only state for the panel, not an error to surface.
|
|
2802
|
+
*
|
|
2803
|
+
* `levelHonored` matters: pin-AT-level is implemented only by the kv-stable
|
|
2804
|
+
* controller. Elsewhere it degrades to raw, which is a safe superset but not
|
|
2805
|
+
* what the operator asked for, so the UI needs to be able to say so.
|
|
2806
|
+
*/
|
|
2807
|
+
private buildPinsList(agentName: string): PinsListMessage | null {
|
|
2808
|
+
const app = sharedServer?.app;
|
|
2809
|
+
if (!app) return null;
|
|
2810
|
+
let pins: PinsListMessage['pins'] = [];
|
|
2811
|
+
let supported = false;
|
|
2812
|
+
try {
|
|
2813
|
+
const cm = this.pinnableCm(agentName);
|
|
2814
|
+
pins = (cm.listPins!() ?? []).map((p) => ({
|
|
2815
|
+
id: String(p.id),
|
|
2816
|
+
firstMessageId: String(p.firstMessageId),
|
|
2817
|
+
lastMessageId: String(p.lastMessageId),
|
|
2818
|
+
kind: p.kind === 'document' ? 'document' : 'pin',
|
|
2819
|
+
...(typeof p.name === 'string' ? { name: p.name } : {}),
|
|
2820
|
+
created: typeof p.created === 'number' ? p.created : 0,
|
|
2821
|
+
...(typeof p.level === 'number' ? { level: p.level } : {}),
|
|
2822
|
+
...(typeof p.maxLevel === 'number' ? { maxLevel: p.maxLevel } : {}),
|
|
2823
|
+
}));
|
|
2824
|
+
supported = true;
|
|
2825
|
+
} catch {
|
|
2826
|
+
supported = false;
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
let levelHonored = false;
|
|
2830
|
+
let deepestLevel: number | undefined;
|
|
2831
|
+
try {
|
|
2832
|
+
const strategyCfg = (app.recipe.agent as unknown as {
|
|
2833
|
+
strategy?: { foldingStrategy?: string };
|
|
2834
|
+
}).strategy;
|
|
2835
|
+
levelHonored = strategyCfg?.foldingStrategy === 'kv-stable';
|
|
2836
|
+
const agent = app.framework.getAgent(agentName);
|
|
2837
|
+
const cm = agent?.getContextManager() as unknown as {
|
|
2838
|
+
getSummaries?: () => Array<{ level?: number }>;
|
|
2839
|
+
} | undefined;
|
|
2840
|
+
const sums = cm?.getSummaries?.() ?? [];
|
|
2841
|
+
for (const s of sums) {
|
|
2842
|
+
if (typeof s.level === 'number' && (deepestLevel === undefined || s.level > deepestLevel)) {
|
|
2843
|
+
deepestLevel = s.level;
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
} catch { /* informational only */ }
|
|
2847
|
+
|
|
2848
|
+
return {
|
|
2849
|
+
type: 'pins-list',
|
|
2850
|
+
agent: agentName,
|
|
2851
|
+
pins,
|
|
2852
|
+
pinsSupported: supported,
|
|
2853
|
+
levelHonored,
|
|
2854
|
+
...(deepestLevel !== undefined ? { deepestLevel } : {}),
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
private broadcastPinsList(agentName: string): void {
|
|
2859
|
+
if (!sharedServer?.app) return;
|
|
2860
|
+
const msg = this.buildPinsList(agentName);
|
|
2861
|
+
if (!msg) return;
|
|
2862
|
+
for (const c of sharedServer.clients.values()) {
|
|
2863
|
+
if (c.welcomed) this.send(c, msg);
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2670
2867
|
/** Default to the recipe's primary agent when the client omits a name. */
|
|
2671
2868
|
private resolveSettingsAgent(name?: string): string {
|
|
2672
2869
|
if (name) return name;
|
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 {
|
|
@@ -595,6 +602,27 @@ export interface RecipeExtension {
|
|
|
595
602
|
sourceMeta?: Record<string, unknown>;
|
|
596
603
|
}
|
|
597
604
|
|
|
605
|
+
/**
|
|
606
|
+
* Client-side programmatic tool calling (af `code_execution` tool, af ≥0.7.2).
|
|
607
|
+
*
|
|
608
|
+
* SECURITY: like mcpl_deploy, enabling this means trusting the agent with
|
|
609
|
+
* code execution — model-authored python runs as the host user with the
|
|
610
|
+
* agent's full tool surface injected. It is a robustness boundary (deadline,
|
|
611
|
+
* kill, respawn), not a sandbox. The agents we host already have shells;
|
|
612
|
+
* enable deliberately all the same.
|
|
613
|
+
*/
|
|
614
|
+
export interface RecipeCodeExecution {
|
|
615
|
+
enabled: boolean;
|
|
616
|
+
/** Python interpreter (default: python3 from PATH; needs ≥3.8). */
|
|
617
|
+
pythonPath?: string;
|
|
618
|
+
/** Per-inner-tool-call timeout, ms (default 270_000). */
|
|
619
|
+
toolCallTimeoutMs?: number;
|
|
620
|
+
/** Whole-script deadline, ms (default 600_000). */
|
|
621
|
+
scriptTimeoutMs?: number;
|
|
622
|
+
/** Idle interpreter reclaim, ms (default 300_000; 0 disables). */
|
|
623
|
+
idleReclaimMs?: number;
|
|
624
|
+
}
|
|
625
|
+
|
|
598
626
|
export interface Recipe {
|
|
599
627
|
name: string;
|
|
600
628
|
description?: string;
|
|
@@ -605,6 +633,8 @@ export interface Recipe {
|
|
|
605
633
|
/** Deployment-specific code extensions, keyed by a human-readable name. */
|
|
606
634
|
extensions?: Record<string, RecipeExtension>;
|
|
607
635
|
sessionNaming?: { examples?: string[] };
|
|
636
|
+
/** Client-side programmatic tool calling (code_execution tool). */
|
|
637
|
+
codeExecution?: RecipeCodeExecution;
|
|
608
638
|
}
|
|
609
639
|
|
|
610
640
|
// ---------------------------------------------------------------------------
|
|
@@ -1227,6 +1257,24 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
1227
1257
|
}
|
|
1228
1258
|
}
|
|
1229
1259
|
|
|
1260
|
+
if (obj.codeExecution !== undefined) {
|
|
1261
|
+
if (!obj.codeExecution || typeof obj.codeExecution !== 'object' || Array.isArray(obj.codeExecution)) {
|
|
1262
|
+
throw new Error('Recipe codeExecution must be an object.');
|
|
1263
|
+
}
|
|
1264
|
+
const ce = obj.codeExecution as Record<string, unknown>;
|
|
1265
|
+
if (typeof ce.enabled !== 'boolean') {
|
|
1266
|
+
throw new Error('Recipe codeExecution.enabled must be a boolean.');
|
|
1267
|
+
}
|
|
1268
|
+
if (ce.pythonPath !== undefined && (typeof ce.pythonPath !== 'string' || !ce.pythonPath.trim())) {
|
|
1269
|
+
throw new Error('Recipe codeExecution.pythonPath must be a non-empty string.');
|
|
1270
|
+
}
|
|
1271
|
+
for (const k of ['toolCallTimeoutMs', 'scriptTimeoutMs', 'idleReclaimMs'] as const) {
|
|
1272
|
+
if (ce[k] !== undefined && (typeof ce[k] !== 'number' || (ce[k] as number) < 0)) {
|
|
1273
|
+
throw new Error(`Recipe codeExecution.${k} must be a non-negative number.`);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1230
1278
|
return obj as unknown as Recipe;
|
|
1231
1279
|
}
|
|
1232
1280
|
|
package/src/web/protocol.ts
CHANGED
|
@@ -391,6 +391,35 @@ export interface McplListMessage {
|
|
|
391
391
|
}>;
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Protected ranges (pins / documents) for one agent.
|
|
396
|
+
*
|
|
397
|
+
* BROADCAST on change: pins alter what the next compile folds, so two operators
|
|
398
|
+
* must not hold divergent views of them.
|
|
399
|
+
*/
|
|
400
|
+
export interface PinsListMessage {
|
|
401
|
+
type: 'pins-list';
|
|
402
|
+
agent: string;
|
|
403
|
+
pins: Array<{
|
|
404
|
+
id: string;
|
|
405
|
+
firstMessageId: string;
|
|
406
|
+
lastMessageId: string;
|
|
407
|
+
kind: 'pin' | 'document';
|
|
408
|
+
name?: string;
|
|
409
|
+
created: number;
|
|
410
|
+
/** Pinned AT exactly this fold level (0 = raw). */
|
|
411
|
+
level?: number;
|
|
412
|
+
/** Hard cap on fold depth. */
|
|
413
|
+
maxLevel?: number;
|
|
414
|
+
}>;
|
|
415
|
+
/** False when the active strategy has no pin support — panel goes read-only. */
|
|
416
|
+
pinsSupported: boolean;
|
|
417
|
+
/** False when `level` would silently degrade to raw (non-kv-stable folding). */
|
|
418
|
+
levelHonored: boolean;
|
|
419
|
+
/** Deepest fold level currently present, so the UI can bound level inputs. */
|
|
420
|
+
deepestLevel?: number;
|
|
421
|
+
}
|
|
422
|
+
|
|
394
423
|
/**
|
|
395
424
|
* Runtime context-settings snapshot.
|
|
396
425
|
*
|
|
@@ -510,6 +539,7 @@ export type WebUiServerMessage =
|
|
|
510
539
|
| LessonsListMessage
|
|
511
540
|
| McplListMessage
|
|
512
541
|
| SettingsStateMessage
|
|
542
|
+
| PinsListMessage
|
|
513
543
|
| WorkspaceMountsMessage
|
|
514
544
|
| WorkspaceTreeMessage
|
|
515
545
|
| WorkspaceFileMessage
|
|
@@ -669,6 +699,46 @@ export interface McplSetEnvMessage {
|
|
|
669
699
|
env: Record<string, string>;
|
|
670
700
|
}
|
|
671
701
|
|
|
702
|
+
/** Pull the agent's protected ranges. Response is a `pins-list` envelope. */
|
|
703
|
+
export interface RequestPinsMessage {
|
|
704
|
+
type: 'request-pins';
|
|
705
|
+
agent?: string;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Create a protected range.
|
|
710
|
+
*
|
|
711
|
+
* Three distinct semantics, deliberately not collapsed into one "pin":
|
|
712
|
+
* - neither `level` nor `maxLevel` → classic pin: forced RAW, never folded
|
|
713
|
+
* - `maxLevel: k` → fold no deeper than L_k (`0` is equivalent to a classic pin)
|
|
714
|
+
* - `level: k` → pin AT exactly L_k; the frontier cut passes through that
|
|
715
|
+
* node, neither deeper nor shallower
|
|
716
|
+
*
|
|
717
|
+
* `kind: 'document'` marks a single message as a document instead of pinning a
|
|
718
|
+
* range, and ignores `lastMessageId`.
|
|
719
|
+
*
|
|
720
|
+
* NOTE: `level` is honored only by `foldingStrategy: 'kv-stable'`. Other
|
|
721
|
+
* strategies degrade it to raw — a safe superset, but not what was asked for.
|
|
722
|
+
* `pins-list.levelHonored` reports which case the live agent is in.
|
|
723
|
+
*/
|
|
724
|
+
export interface PinAddMessage {
|
|
725
|
+
type: 'pin-add';
|
|
726
|
+
agent?: string;
|
|
727
|
+
kind?: 'pin' | 'document';
|
|
728
|
+
firstMessageId: string;
|
|
729
|
+
/** Ignored for `kind: 'document'`; defaults to `firstMessageId` for a pin. */
|
|
730
|
+
lastMessageId?: string;
|
|
731
|
+
level?: number;
|
|
732
|
+
maxLevel?: number;
|
|
733
|
+
name?: string;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export interface PinRemoveMessage {
|
|
737
|
+
type: 'pin-remove';
|
|
738
|
+
agent?: string;
|
|
739
|
+
pinId: string;
|
|
740
|
+
}
|
|
741
|
+
|
|
672
742
|
/** Pull the current runtime context settings for an agent, plus which knobs are
|
|
673
743
|
* live-appliable vs restart-only. Response is a `settings-state` envelope. */
|
|
674
744
|
export interface RequestSettingsMessage {
|
|
@@ -701,6 +771,10 @@ export interface SettingsUpdateMessage {
|
|
|
701
771
|
contextBudgetTokens?: number;
|
|
702
772
|
tailTokens?: number;
|
|
703
773
|
transitionPaceTokens?: number;
|
|
774
|
+
/** Apply a budget DECREASE immediately (one-shot fold-down, KV
|
|
775
|
+
* invalidation paid this turn) instead of starting a paced descent.
|
|
776
|
+
* The emergency lever for refusal streaks / over-wall wedges. */
|
|
777
|
+
immediate?: boolean;
|
|
704
778
|
persist?: boolean;
|
|
705
779
|
notify?: boolean;
|
|
706
780
|
}
|
|
@@ -794,6 +868,9 @@ export type WebUiClientMessage =
|
|
|
794
868
|
| McplAddMessage
|
|
795
869
|
| McplRemoveMessage
|
|
796
870
|
| McplSetEnvMessage
|
|
871
|
+
| RequestPinsMessage
|
|
872
|
+
| PinAddMessage
|
|
873
|
+
| PinRemoveMessage
|
|
797
874
|
| RequestSettingsMessage
|
|
798
875
|
| SettingsUpdateMessage
|
|
799
876
|
| SettingsResetMessage
|
|
@@ -860,11 +937,32 @@ export function isClientMessage(value: unknown): value is WebUiClientMessage {
|
|
|
860
937
|
return isValidMcplId(v.id);
|
|
861
938
|
case 'mcpl-set-env':
|
|
862
939
|
return isValidMcplId(v.id) && isStringMap(v.env);
|
|
940
|
+
case 'request-pins':
|
|
941
|
+
return isOptionalNonEmptyString(v.agent);
|
|
942
|
+
case 'pin-add': {
|
|
943
|
+
if (!isOptionalNonEmptyString(v.agent)) return false;
|
|
944
|
+
if (!isNonEmptyString(v.firstMessageId)) return false;
|
|
945
|
+
if (v.lastMessageId !== undefined && !isNonEmptyString(v.lastMessageId)) return false;
|
|
946
|
+
if (v.kind !== undefined && v.kind !== 'pin' && v.kind !== 'document') return false;
|
|
947
|
+
if (v.name !== undefined && !isNonEmptyString(v.name)) return false;
|
|
948
|
+
// Fold levels are small non-negative integers (0 = raw). Reject strings
|
|
949
|
+
// and fractions before they reach the strategy's pin normalizer.
|
|
950
|
+
for (const k of ['level', 'maxLevel']) {
|
|
951
|
+
const x = v[k];
|
|
952
|
+
if (x === undefined) continue;
|
|
953
|
+
if (typeof x !== 'number' || !Number.isSafeInteger(x) || x < 0 || x > 32) return false;
|
|
954
|
+
}
|
|
955
|
+
// level and maxLevel are different semantics; asking for both is ambiguous.
|
|
956
|
+
if (v.level !== undefined && v.maxLevel !== undefined) return false;
|
|
957
|
+
return true;
|
|
958
|
+
}
|
|
959
|
+
case 'pin-remove':
|
|
960
|
+
return isOptionalNonEmptyString(v.agent) && isNonEmptyString(v.pinId);
|
|
863
961
|
case 'request-settings':
|
|
864
962
|
return isOptionalNonEmptyString(v.agent);
|
|
865
963
|
case 'settings-update': {
|
|
866
964
|
if (!isOptionalNonEmptyString(v.agent)) return false;
|
|
867
|
-
if (!isOptionalBool(v.persist) || !isOptionalBool(v.notify)) return false;
|
|
965
|
+
if (!isOptionalBool(v.persist) || !isOptionalBool(v.notify) || !isOptionalBool(v.immediate)) return false;
|
|
868
966
|
// Positive-integer-or-absent for each knob. The semantic gate (budget must
|
|
869
967
|
// exceed max response tokens; tail/pace need a hot-configurable strategy)
|
|
870
968
|
// lives in Agent.validateRuntimeSettingsPatch — this only guarantees the
|
|
@@ -62,3 +62,40 @@ describe('settings messages — wire validation', () => {
|
|
|
62
62
|
expect(isClientMessage({ type: 'request-settings', agent: 7 })).toBe(false);
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
describe('pin messages — wire validation', () => {
|
|
67
|
+
it('accepts a single-message raw pin', () => {
|
|
68
|
+
expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1' })).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('accepts a range, a document, a label, and level modes', () => {
|
|
72
|
+
expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', lastMessageId: 'm-9' })).toBe(true);
|
|
73
|
+
expect(isClientMessage({ type: 'pin-add', kind: 'document', firstMessageId: 'm-1' })).toBe(true);
|
|
74
|
+
expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', name: 'seed' })).toBe(true);
|
|
75
|
+
expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', level: 0 })).toBe(true);
|
|
76
|
+
expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', maxLevel: 2 })).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects level AND maxLevel together — different semantics, ambiguous intent', () => {
|
|
80
|
+
expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', level: 1, maxLevel: 2 })).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects bad levels and bad ids', () => {
|
|
84
|
+
for (const bad of ['1', 1.5, -1, 33, NaN, null, {}]) {
|
|
85
|
+
expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', level: bad })).toBe(false);
|
|
86
|
+
}
|
|
87
|
+
expect(isClientMessage({ type: 'pin-add', firstMessageId: '' })).toBe(false);
|
|
88
|
+
expect(isClientMessage({ type: 'pin-add' })).toBe(false);
|
|
89
|
+
expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', kind: 'bogus' })).toBe(false);
|
|
90
|
+
expect(isClientMessage({ type: 'pin-add', firstMessageId: 'm-1', lastMessageId: '' })).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('validates pin-remove and request-pins', () => {
|
|
94
|
+
expect(isClientMessage({ type: 'pin-remove', pinId: 'pin-3' })).toBe(true);
|
|
95
|
+
expect(isClientMessage({ type: 'pin-remove' })).toBe(false);
|
|
96
|
+
expect(isClientMessage({ type: 'pin-remove', pinId: '' })).toBe(false);
|
|
97
|
+
expect(isClientMessage({ type: 'request-pins' })).toBe(true);
|
|
98
|
+
expect(isClientMessage({ type: 'request-pins', agent: 'mythos' })).toBe(true);
|
|
99
|
+
expect(isClientMessage({ type: 'request-pins', agent: 5 })).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
package/web/src/App.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { LessonsPanel, type LessonRow } from './Lessons';
|
|
|
11
11
|
import { McplPanel, type McplServerRow } from './Mcpl';
|
|
12
12
|
import { SettingsPanel, type SettingsState } from './Settings';
|
|
13
13
|
import { DryContext, type DryContextData } from './DryContext';
|
|
14
|
+
import { PinsPanel, type PinsState } from './Pins';
|
|
14
15
|
import { FilesPanel, FileViewerModal, type Mount, type FlatEntry, type FileViewer } from './Files';
|
|
15
16
|
import { ContextPanel } from './Context';
|
|
16
17
|
import { ContextDocument } from './ContextDocument';
|
|
@@ -274,7 +275,7 @@ export function App() {
|
|
|
274
275
|
|
|
275
276
|
/** Right-sidebar tab selection. The Tree is the most-used surface so it's
|
|
276
277
|
* the default; lessons / mcp / files are operator-driven panels. */
|
|
277
|
-
type SidebarTab = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'settings' | 'health';
|
|
278
|
+
type SidebarTab = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'settings' | 'pins' | 'health';
|
|
278
279
|
const [sidebarTab, setSidebarTab] = createSignal<SidebarTab>('tree');
|
|
279
280
|
const [mainView, setMainView] = createSignal<'chat' | 'context' | 'dry'>('chat');
|
|
280
281
|
/** Rendered dry-run context awaiting display. Never applied — see DryContext. */
|
|
@@ -325,6 +326,15 @@ export function App() {
|
|
|
325
326
|
wire.send({ type: 'request-settings' });
|
|
326
327
|
};
|
|
327
328
|
|
|
329
|
+
/** Pins panel state — broadcast on change, like settings: pins alter the next
|
|
330
|
+
* compile's fold plan, so operators must not hold divergent views. */
|
|
331
|
+
const [pinsState, setPinsState] = createSignal<PinsState | null>(null);
|
|
332
|
+
const [pinsLoaded, setPinsLoaded] = createSignal(false);
|
|
333
|
+
const refreshPins = (): void => {
|
|
334
|
+
setPinsLoaded(false);
|
|
335
|
+
wire.send({ type: 'request-pins' });
|
|
336
|
+
};
|
|
337
|
+
|
|
328
338
|
/** Workspace files panel state — mounts list + per-mount tree cache. */
|
|
329
339
|
const [mounts, setMounts] = createSignal<Mount[]>([]);
|
|
330
340
|
const [mountsLoaded, setMountsLoaded] = createSignal(false);
|
|
@@ -894,6 +904,10 @@ export function App() {
|
|
|
894
904
|
setSettingsLoaded(true);
|
|
895
905
|
setSettingsState(state);
|
|
896
906
|
},
|
|
907
|
+
setPins: (state) => {
|
|
908
|
+
setPinsLoaded(true);
|
|
909
|
+
setPinsState(state);
|
|
910
|
+
},
|
|
897
911
|
setMounts: (loaded, moduleLoaded, list) => {
|
|
898
912
|
setMountsLoaded(loaded);
|
|
899
913
|
setWorkspaceModuleLoaded(moduleLoaded);
|
|
@@ -1201,6 +1215,7 @@ export function App() {
|
|
|
1201
1215
|
if (tab === 'mcp' && !mcplLoaded()) refreshMcpl();
|
|
1202
1216
|
if (tab === 'files' && !mountsLoaded()) refreshMounts();
|
|
1203
1217
|
if (tab === 'settings' && !settingsLoaded()) refreshSettings();
|
|
1218
|
+
if (tab === 'pins' && !pinsLoaded()) refreshPins();
|
|
1204
1219
|
}}
|
|
1205
1220
|
/>
|
|
1206
1221
|
<div class="flex-1 min-h-0">
|
|
@@ -1251,6 +1266,16 @@ export function App() {
|
|
|
1251
1266
|
onDryContext={(ctx) => { setDryContext(ctx as DryContextData); setMainView('dry'); }}
|
|
1252
1267
|
/>
|
|
1253
1268
|
</Show>
|
|
1269
|
+
<Show when={sidebarTab() === 'pins'}>
|
|
1270
|
+
<PinsPanel
|
|
1271
|
+
loaded={pinsLoaded()}
|
|
1272
|
+
state={pinsState()}
|
|
1273
|
+
agent={pinsState()?.agent}
|
|
1274
|
+
onRefresh={refreshPins}
|
|
1275
|
+
onAdd={(input) => wire.send({ type: 'pin-add', ...input })}
|
|
1276
|
+
onRemove={(pinId) => wire.send({ type: 'pin-remove', pinId })}
|
|
1277
|
+
/>
|
|
1278
|
+
</Show>
|
|
1254
1279
|
<Show when={sidebarTab() === 'files'}>
|
|
1255
1280
|
<FilesPanel
|
|
1256
1281
|
loaded={mountsLoaded()}
|
|
@@ -1323,6 +1348,8 @@ interface HandlerHooks {
|
|
|
1323
1348
|
setMcpl: (configPath: string, servers: McplServerRow[]) => void;
|
|
1324
1349
|
/** Apply a settings-state broadcast. */
|
|
1325
1350
|
setSettings: (state: SettingsState) => void;
|
|
1351
|
+
/** Apply a pins-list broadcast. */
|
|
1352
|
+
setPins: (state: PinsState) => void;
|
|
1326
1353
|
/** Apply a workspace-mounts response. */
|
|
1327
1354
|
setMounts: (loaded: boolean, moduleLoaded: boolean, mounts: Mount[]) => void;
|
|
1328
1355
|
/** Apply a workspace-tree response for one mount. */
|
|
@@ -1456,6 +1483,9 @@ function handleServerMessage(
|
|
|
1456
1483
|
case 'settings-state':
|
|
1457
1484
|
hooks.setSettings(msg as unknown as SettingsState);
|
|
1458
1485
|
return;
|
|
1486
|
+
case 'pins-list':
|
|
1487
|
+
hooks.setPins(msg as unknown as PinsState);
|
|
1488
|
+
return;
|
|
1459
1489
|
case 'workspace-mounts':
|
|
1460
1490
|
hooks.setMounts(true, msg.loaded, msg.mounts);
|
|
1461
1491
|
return;
|
|
@@ -1986,7 +2016,7 @@ function CommandSuggestions(props: { draft: string; onPick: (cmd: string) => voi
|
|
|
1986
2016
|
);
|
|
1987
2017
|
}
|
|
1988
2018
|
|
|
1989
|
-
type SidebarTabId = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'settings' | 'health';
|
|
2019
|
+
type SidebarTabId = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'settings' | 'pins' | 'health';
|
|
1990
2020
|
|
|
1991
2021
|
function SidebarTabs(props: {
|
|
1992
2022
|
current: SidebarTabId;
|
|
@@ -1999,6 +2029,7 @@ function SidebarTabs(props: {
|
|
|
1999
2029
|
{ id: 'files', label: 'Files', title: 'Workspace mounts + files' },
|
|
2000
2030
|
{ id: 'context', label: 'Context', title: 'Compiled context makeup' },
|
|
2001
2031
|
{ id: 'settings', label: 'Settings', title: 'Live context budget / tail, with preview' },
|
|
2032
|
+
{ id: 'pins', label: 'Pins', title: 'Protected ranges — constrain what the fold plan may fold' },
|
|
2002
2033
|
{ id: 'health', label: 'Health', title: 'Runtime settings, failures, quarantine' },
|
|
2003
2034
|
];
|
|
2004
2035
|
return (
|
package/web/src/DryContext.tsx
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* actual layout that budget would produce — not an approximation.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { For, Show } from 'solid-js';
|
|
16
16
|
|
|
17
17
|
interface Seg { messages: number; tokens: number }
|
|
18
18
|
interface DryStats {
|
|
@@ -29,47 +29,30 @@ export interface DryContextData {
|
|
|
29
29
|
|
|
30
30
|
const fmt = (n: number) => n.toLocaleString();
|
|
31
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
32
|
/** Recall pairs render as a CM prompt followed by the summary text. */
|
|
51
33
|
const RECALL_MARKERS = ['what do you remember', 'recall memory', '[cm]'];
|
|
52
34
|
const isRecallPrompt = (s: string) =>
|
|
53
35
|
s.length < 400 && RECALL_MARKERS.some((m) => s.toLowerCase().includes(m));
|
|
54
36
|
|
|
55
37
|
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
38
|
const rows = () =>
|
|
64
39
|
(props.data?.entries ?? []).map((e, i) => {
|
|
65
|
-
|
|
66
|
-
|
|
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 ?? '';
|
|
67
48
|
return {
|
|
68
|
-
i,
|
|
69
|
-
who: o.
|
|
49
|
+
i: o.i ?? i,
|
|
50
|
+
who: o.who ?? '?',
|
|
70
51
|
body,
|
|
71
52
|
recall: isRecallPrompt(body),
|
|
72
|
-
chars: body.length,
|
|
53
|
+
chars: o.chars ?? body.length,
|
|
54
|
+
media: o.media ?? 0,
|
|
55
|
+
truncated: o.truncated === true,
|
|
73
56
|
};
|
|
74
57
|
});
|
|
75
58
|
|
|
@@ -136,22 +119,20 @@ export function DryContext(props: { data: DryContextData | null; onClose(): void
|
|
|
136
119
|
{r.who}
|
|
137
120
|
</span>
|
|
138
121
|
<span class="text-[10px] text-neutral-600">{fmt(r.chars)} chars</span>
|
|
139
|
-
<Show when={r.
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
</
|
|
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>
|
|
147
130
|
</Show>
|
|
148
131
|
</div>
|
|
149
132
|
<div
|
|
150
133
|
class="text-[12px] text-neutral-300 whitespace-pre-wrap break-words leading-relaxed"
|
|
151
134
|
>
|
|
152
|
-
{
|
|
153
|
-
? r.body
|
|
154
|
-
: r.body.slice(0, 600) + ' …'}
|
|
135
|
+
{r.body}{r.truncated ? ' …' : ''}
|
|
155
136
|
</div>
|
|
156
137
|
</div>
|
|
157
138
|
)}
|
package/web/src/Pins.tsx
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins panel — operator control over protected ranges.
|
|
3
|
+
*
|
|
4
|
+
* Three semantics, kept visibly distinct because collapsing them into one
|
|
5
|
+
* "pin" button would hide what actually happens to the fold plan:
|
|
6
|
+
*
|
|
7
|
+
* raw — never folded (classic pin)
|
|
8
|
+
* max L_k — fold no deeper than k (k=0 is equivalent to raw)
|
|
9
|
+
* at L_k — pinned AT exactly k; the frontier cut passes through that node
|
|
10
|
+
*
|
|
11
|
+
* `at L_k` is honored only by the kv-stable folding strategy. Elsewhere it
|
|
12
|
+
* degrades to raw — a safe superset, but not what was asked for — so the panel
|
|
13
|
+
* warns rather than letting it pass silently.
|
|
14
|
+
*
|
|
15
|
+
* Message ids come from /debug/context/curve (cheap: ~14ms) so the operator
|
|
16
|
+
* picks from the live context instead of pasting ids.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createSignal, For, Show } from 'solid-js';
|
|
20
|
+
|
|
21
|
+
export interface PinRow {
|
|
22
|
+
id: string;
|
|
23
|
+
firstMessageId: string;
|
|
24
|
+
lastMessageId: string;
|
|
25
|
+
kind: 'pin' | 'document';
|
|
26
|
+
name?: string;
|
|
27
|
+
created: number;
|
|
28
|
+
level?: number;
|
|
29
|
+
maxLevel?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PinsState {
|
|
33
|
+
agent: string;
|
|
34
|
+
pins: PinRow[];
|
|
35
|
+
pinsSupported: boolean;
|
|
36
|
+
levelHonored: boolean;
|
|
37
|
+
deepestLevel?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface CurveEntry {
|
|
41
|
+
i: number;
|
|
42
|
+
kind: string;
|
|
43
|
+
id: string | null;
|
|
44
|
+
participant?: string;
|
|
45
|
+
rendered?: number;
|
|
46
|
+
msgCount?: number;
|
|
47
|
+
text?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ago = (ms: number) => {
|
|
51
|
+
if (!ms) return '—';
|
|
52
|
+
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
|
53
|
+
if (s < 60) return `${s}s`;
|
|
54
|
+
if (s < 3600) return `${Math.round(s / 60)}m`;
|
|
55
|
+
if (s < 86400) return `${Math.round(s / 3600)}h`;
|
|
56
|
+
return `${Math.round(s / 86400)}d`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** What the fold plan will actually do with this range. */
|
|
60
|
+
function describe(p: PinRow): string {
|
|
61
|
+
if (p.level !== undefined) return p.level === 0 ? 'at raw' : `at L${p.level}`;
|
|
62
|
+
if (p.maxLevel !== undefined) return p.maxLevel === 0 ? 'raw (max L0)' : `max L${p.maxLevel}`;
|
|
63
|
+
return 'raw';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function PinsPanel(props: {
|
|
67
|
+
loaded: boolean;
|
|
68
|
+
state: PinsState | null;
|
|
69
|
+
agent?: string;
|
|
70
|
+
onRefresh(): void;
|
|
71
|
+
onAdd(input: {
|
|
72
|
+
kind: 'pin' | 'document';
|
|
73
|
+
firstMessageId: string;
|
|
74
|
+
lastMessageId?: string;
|
|
75
|
+
level?: number;
|
|
76
|
+
maxLevel?: number;
|
|
77
|
+
name?: string;
|
|
78
|
+
}): void;
|
|
79
|
+
onRemove(pinId: string): void;
|
|
80
|
+
}) {
|
|
81
|
+
const [first, setFirst] = createSignal('');
|
|
82
|
+
const [last, setLast] = createSignal('');
|
|
83
|
+
const [name, setName] = createSignal('');
|
|
84
|
+
const [mode, setMode] = createSignal<'raw' | 'max' | 'at'>('raw');
|
|
85
|
+
const [lvl, setLvl] = createSignal('1');
|
|
86
|
+
const [asDoc, setAsDoc] = createSignal(false);
|
|
87
|
+
|
|
88
|
+
const [curve, setCurve] = createSignal<CurveEntry[] | null>(null);
|
|
89
|
+
const [curveErr, setCurveErr] = createSignal<string | null>(null);
|
|
90
|
+
const [picking, setPicking] = createSignal(false);
|
|
91
|
+
|
|
92
|
+
/** /debug/context/curve is the cheap endpoint (~14ms) and the only one that
|
|
93
|
+
* exposes per-entry store ids alongside a text preview. */
|
|
94
|
+
const loadCurve = async () => {
|
|
95
|
+
setPicking(true);
|
|
96
|
+
setCurveErr(null);
|
|
97
|
+
try {
|
|
98
|
+
const qs = props.agent ? `?agent=${encodeURIComponent(props.agent)}` : '';
|
|
99
|
+
const res = await fetch(`/debug/context/curve${qs}`, { credentials: 'same-origin' });
|
|
100
|
+
const body = await res.json();
|
|
101
|
+
if (!res.ok) { setCurveErr(body?.error ?? `HTTP ${res.status}`); return; }
|
|
102
|
+
setCurve((body.entries ?? []) as CurveEntry[]);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
setCurveErr(e instanceof Error ? e.message : String(e));
|
|
105
|
+
} finally {
|
|
106
|
+
setPicking(false);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const submit = () => {
|
|
111
|
+
const f = first().trim();
|
|
112
|
+
if (!f) return;
|
|
113
|
+
const n = Number(lvl());
|
|
114
|
+
const levelOk = Number.isSafeInteger(n) && n >= 0 && n <= 32;
|
|
115
|
+
props.onAdd({
|
|
116
|
+
kind: asDoc() ? 'document' : 'pin',
|
|
117
|
+
firstMessageId: f,
|
|
118
|
+
...(asDoc() || !last().trim() ? {} : { lastMessageId: last().trim() }),
|
|
119
|
+
...(mode() === 'at' && levelOk ? { level: n } : {}),
|
|
120
|
+
...(mode() === 'max' && levelOk ? { maxLevel: n } : {}),
|
|
121
|
+
...(name().trim() ? { name: name().trim() } : {}),
|
|
122
|
+
});
|
|
123
|
+
setFirst(''); setLast(''); setName('');
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div class="h-full overflow-y-auto px-3 py-2 text-xs">
|
|
128
|
+
<div class="flex items-center gap-2 mb-2">
|
|
129
|
+
<span class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold">pins</span>
|
|
130
|
+
<Show when={props.state}>
|
|
131
|
+
<span class="text-neutral-600 text-[10px]">{props.state!.pins.length}</span>
|
|
132
|
+
</Show>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
class="ml-auto px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
|
|
136
|
+
onClick={() => props.onRefresh()}
|
|
137
|
+
>
|
|
138
|
+
refresh
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<Show when={!props.loaded}><div class="text-neutral-600 italic">Loading…</div></Show>
|
|
143
|
+
|
|
144
|
+
<Show when={props.state && !props.state.pinsSupported}>
|
|
145
|
+
<div class="text-amber-400/80 text-[11px]">
|
|
146
|
+
The active context strategy does not support pins.
|
|
147
|
+
</div>
|
|
148
|
+
</Show>
|
|
149
|
+
|
|
150
|
+
<Show when={props.state?.pinsSupported}>
|
|
151
|
+
<div class="text-[10px] text-neutral-600 mb-2 leading-relaxed">
|
|
152
|
+
Pins constrain the fold plan and take effect on the <b>next compile</b> — no restart.
|
|
153
|
+
They persist and are branch-scoped. Pair with a dry run to see the effect before it
|
|
154
|
+
reaches a real turn.
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<Show when={!props.state!.levelHonored}>
|
|
158
|
+
<div class="mb-2 px-2 py-1 rounded bg-amber-950/40 border border-amber-800
|
|
159
|
+
text-[10px] text-amber-200 leading-relaxed">
|
|
160
|
+
This agent is not on <span class="font-mono">kv-stable</span> folding, so
|
|
161
|
+
<b> "at L<sub>k</sub>" degrades to raw</b> — a safe superset, but not what you asked
|
|
162
|
+
for. Prefer <span class="font-mono">raw</span> or <span class="font-mono">max L</span>
|
|
163
|
+
here.
|
|
164
|
+
</div>
|
|
165
|
+
</Show>
|
|
166
|
+
|
|
167
|
+
{/* ---- existing pins ---- */}
|
|
168
|
+
<Show when={props.state!.pins.length === 0}>
|
|
169
|
+
<div class="text-neutral-600 italic mb-3">No pins.</div>
|
|
170
|
+
</Show>
|
|
171
|
+
<Show when={props.state!.pins.length > 0}>
|
|
172
|
+
<table class="w-full mb-3 font-mono text-[10px]">
|
|
173
|
+
<tbody>
|
|
174
|
+
<For each={props.state!.pins}>
|
|
175
|
+
{(p) => (
|
|
176
|
+
<tr class="border-b border-neutral-900">
|
|
177
|
+
<td class="pr-1 align-top">
|
|
178
|
+
<span class={p.kind === 'document' ? 'text-indigo-300' : 'text-cyan-300'}>
|
|
179
|
+
{p.kind === 'document' ? 'doc' : 'pin'}
|
|
180
|
+
</span>
|
|
181
|
+
</td>
|
|
182
|
+
<td class="pr-1 align-top text-neutral-200">{describe(p)}</td>
|
|
183
|
+
<td class="pr-1 align-top text-neutral-400 break-all">
|
|
184
|
+
{p.firstMessageId}
|
|
185
|
+
<Show when={p.lastMessageId !== p.firstMessageId}>
|
|
186
|
+
<span class="text-neutral-600"> → {p.lastMessageId}</span>
|
|
187
|
+
</Show>
|
|
188
|
+
<Show when={p.name}>
|
|
189
|
+
<div class="text-neutral-500">“{p.name}”</div>
|
|
190
|
+
</Show>
|
|
191
|
+
</td>
|
|
192
|
+
<td class="pr-1 align-top text-neutral-600">{ago(p.created)}</td>
|
|
193
|
+
<td class="align-top text-right">
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
class="px-1.5 py-0.5 rounded bg-neutral-800 hover:bg-red-900/60
|
|
197
|
+
text-neutral-400 hover:text-red-200"
|
|
198
|
+
title={`unpin ${p.id}`}
|
|
199
|
+
onClick={() => props.onRemove(p.id)}
|
|
200
|
+
>
|
|
201
|
+
unpin
|
|
202
|
+
</button>
|
|
203
|
+
</td>
|
|
204
|
+
</tr>
|
|
205
|
+
)}
|
|
206
|
+
</For>
|
|
207
|
+
</tbody>
|
|
208
|
+
</table>
|
|
209
|
+
</Show>
|
|
210
|
+
|
|
211
|
+
{/* ---- add ---- */}
|
|
212
|
+
<div class="border-t border-neutral-800 pt-2">
|
|
213
|
+
<div class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold mb-1.5">
|
|
214
|
+
add
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div class="space-y-1.5 mb-2">
|
|
218
|
+
<label class="flex items-center gap-2">
|
|
219
|
+
<span class="text-neutral-500 w-10">from</span>
|
|
220
|
+
<input value={first()} onInput={(e) => setFirst(e.currentTarget.value)}
|
|
221
|
+
placeholder="message id"
|
|
222
|
+
class="flex-1 bg-neutral-900 border border-neutral-700 rounded px-1.5 py-0.5
|
|
223
|
+
font-mono text-[11px] text-neutral-100" />
|
|
224
|
+
</label>
|
|
225
|
+
<label class="flex items-center gap-2" title={asDoc() ? 'documents cover a single message' : ''}>
|
|
226
|
+
<span class="text-neutral-500 w-10">to</span>
|
|
227
|
+
<input value={last()} disabled={asDoc()}
|
|
228
|
+
onInput={(e) => setLast(e.currentTarget.value)}
|
|
229
|
+
placeholder={asDoc() ? 'n/a for documents' : 'optional — defaults to “from”'}
|
|
230
|
+
class="flex-1 bg-neutral-900 border border-neutral-700 rounded px-1.5 py-0.5
|
|
231
|
+
font-mono text-[11px] text-neutral-100 disabled:opacity-40" />
|
|
232
|
+
</label>
|
|
233
|
+
<label class="flex items-center gap-2">
|
|
234
|
+
<span class="text-neutral-500 w-10">label</span>
|
|
235
|
+
<input value={name()} onInput={(e) => setName(e.currentTarget.value)}
|
|
236
|
+
placeholder="optional"
|
|
237
|
+
class="flex-1 bg-neutral-900 border border-neutral-700 rounded px-1.5 py-0.5
|
|
238
|
+
font-mono text-[11px] text-neutral-100" />
|
|
239
|
+
</label>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div class="flex items-center gap-3 mb-2 text-[10px] text-neutral-400 flex-wrap">
|
|
243
|
+
<For each={[['raw', 'raw'], ['max', 'max L'], ['at', 'at L']] as Array<[string, string]>}>
|
|
244
|
+
{([v, label]) => (
|
|
245
|
+
<label class="flex items-center gap-1">
|
|
246
|
+
<input type="radio" name="pinmode" checked={mode() === v}
|
|
247
|
+
onChange={() => setMode(v as 'raw' | 'max' | 'at')} />
|
|
248
|
+
{label}
|
|
249
|
+
</label>
|
|
250
|
+
)}
|
|
251
|
+
</For>
|
|
252
|
+
<Show when={mode() !== 'raw'}>
|
|
253
|
+
<input type="number" min="0" max={props.state!.deepestLevel ?? 8}
|
|
254
|
+
value={lvl()} onInput={(e) => setLvl(e.currentTarget.value)}
|
|
255
|
+
class="w-14 bg-neutral-900 border border-neutral-700 rounded px-1 py-0.5
|
|
256
|
+
font-mono text-[11px] text-neutral-100" />
|
|
257
|
+
<Show when={props.state!.deepestLevel !== undefined}>
|
|
258
|
+
<span class="text-neutral-600">deepest present: L{props.state!.deepestLevel}</span>
|
|
259
|
+
</Show>
|
|
260
|
+
</Show>
|
|
261
|
+
<label class="flex items-center gap-1">
|
|
262
|
+
<input type="checkbox" checked={asDoc()} onChange={(e) => setAsDoc(e.currentTarget.checked)} />
|
|
263
|
+
as document
|
|
264
|
+
</label>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<div class="flex items-center gap-2 mb-3">
|
|
268
|
+
<button type="button" disabled={!first().trim()}
|
|
269
|
+
class="px-2 py-0.5 text-[10px] rounded font-mono bg-cyan-900/50 hover:bg-cyan-900/70
|
|
270
|
+
text-cyan-200 disabled:opacity-30"
|
|
271
|
+
onClick={submit}>
|
|
272
|
+
add pin
|
|
273
|
+
</button>
|
|
274
|
+
<button type="button"
|
|
275
|
+
class="px-2 py-0.5 text-[10px] rounded font-mono bg-neutral-800 hover:bg-neutral-700 text-neutral-300"
|
|
276
|
+
onClick={() => void loadCurve()}>
|
|
277
|
+
{picking() ? 'loading…' : curve() ? 'reload ids' : 'pick from context'}
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<Show when={curveErr()}>
|
|
282
|
+
<div class="text-[10px] text-amber-400/90 mb-2">{curveErr()}</div>
|
|
283
|
+
</Show>
|
|
284
|
+
|
|
285
|
+
{/* ---- id picker ---- */}
|
|
286
|
+
<Show when={curve()}>
|
|
287
|
+
<div class="max-h-64 overflow-y-auto border border-neutral-800 rounded">
|
|
288
|
+
<For each={curve()!.filter((e) => e.id)}>
|
|
289
|
+
{(e) => (
|
|
290
|
+
<div class="flex items-start gap-1.5 px-1.5 py-1 border-b border-neutral-900
|
|
291
|
+
hover:bg-neutral-900/60">
|
|
292
|
+
<span class="text-[9px] font-mono text-neutral-600 w-8 shrink-0">#{e.i}</span>
|
|
293
|
+
<span class={`text-[9px] font-mono w-8 shrink-0 ${
|
|
294
|
+
e.kind === 'raw' ? 'text-emerald-400/80' : 'text-orange-400/80'
|
|
295
|
+
}`}>{e.kind}</span>
|
|
296
|
+
<span class="text-[10px] text-neutral-400 flex-1 truncate"
|
|
297
|
+
title={e.text ?? ''}>{(e.text ?? '').slice(0, 90)}</span>
|
|
298
|
+
<button type="button"
|
|
299
|
+
class="text-[9px] font-mono px-1 rounded bg-neutral-800 hover:bg-neutral-700
|
|
300
|
+
text-neutral-400 shrink-0"
|
|
301
|
+
onClick={() => setFirst(e.id!)}>from</button>
|
|
302
|
+
<button type="button" disabled={asDoc()}
|
|
303
|
+
class="text-[9px] font-mono px-1 rounded bg-neutral-800 hover:bg-neutral-700
|
|
304
|
+
text-neutral-400 shrink-0 disabled:opacity-30"
|
|
305
|
+
onClick={() => setLast(e.id!)}>to</button>
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
</For>
|
|
309
|
+
</div>
|
|
310
|
+
<div class="text-[10px] text-neutral-600 mt-1">
|
|
311
|
+
Entries without a store id (merged summaries) are omitted — a pin needs a message id.
|
|
312
|
+
</div>
|
|
313
|
+
</Show>
|
|
314
|
+
</div>
|
|
315
|
+
</Show>
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
package/web/src/Settings.tsx
CHANGED
|
@@ -96,6 +96,7 @@ export function SettingsPanel(props: {
|
|
|
96
96
|
contextBudgetTokens?: number;
|
|
97
97
|
tailTokens?: number;
|
|
98
98
|
transitionPaceTokens?: number;
|
|
99
|
+
immediate?: boolean;
|
|
99
100
|
persist: boolean;
|
|
100
101
|
notify: boolean;
|
|
101
102
|
}): void;
|
|
@@ -109,6 +110,7 @@ export function SettingsPanel(props: {
|
|
|
109
110
|
const [pace, setPace] = createSignal<string>('');
|
|
110
111
|
const [persist, setPersist] = createSignal(true);
|
|
111
112
|
const [notify, setNotify] = createSignal(false);
|
|
113
|
+
const [immediate, setImmediate] = createSignal(false);
|
|
112
114
|
|
|
113
115
|
const [preview, setPreview] = createSignal<PreviewResult | null>(null);
|
|
114
116
|
const [acct, setAcct] = createSignal<PreviewAccounting | null>(null);
|
|
@@ -196,7 +198,12 @@ export function SettingsPanel(props: {
|
|
|
196
198
|
if (t !== undefined && t !== cur?.tailTokens) patch.tailTokens = t;
|
|
197
199
|
if (p !== undefined && p !== cur?.transitionPaceTokens) patch.transitionPaceTokens = p;
|
|
198
200
|
if (Object.keys(patch).length === 0) return;
|
|
199
|
-
props.onApply({
|
|
201
|
+
props.onApply({
|
|
202
|
+
...patch,
|
|
203
|
+
...(lowering() && immediate() ? { immediate: true } : {}),
|
|
204
|
+
persist: persist(),
|
|
205
|
+
notify: notify(),
|
|
206
|
+
});
|
|
200
207
|
};
|
|
201
208
|
|
|
202
209
|
const lowering = () => {
|
|
@@ -339,10 +346,26 @@ export function SettingsPanel(props: {
|
|
|
339
346
|
<div>ephemeral — live now, reverts on restart.</div>
|
|
340
347
|
</Show>
|
|
341
348
|
<Show when={lowering()}>
|
|
342
|
-
<
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
349
|
+
<label
|
|
350
|
+
class="flex items-center gap-1 text-neutral-400 mb-1"
|
|
351
|
+
title="Skip the paced descent: the next compile plans straight at the new budget. The whole fold-down and its KV-prefix invalidation land on that one turn — the emergency lever for refusal streaks or over-budget wedges. Cancels any in-flight descent."
|
|
352
|
+
>
|
|
353
|
+
<input type="checkbox" checked={immediate()} onChange={(e) => setImmediate(e.currentTarget.checked)} />
|
|
354
|
+
drop immediately
|
|
355
|
+
</label>
|
|
356
|
+
<Show when={!immediate()}>
|
|
357
|
+
<div class="text-amber-500/90">
|
|
358
|
+
lowering the budget converges gradually, not instantly — it will report
|
|
359
|
+
<span class="font-mono"> converging</span> until it settles.
|
|
360
|
+
Check <span class="font-mono">drop immediately</span> to skip the descent.
|
|
361
|
+
</div>
|
|
362
|
+
</Show>
|
|
363
|
+
<Show when={immediate()}>
|
|
364
|
+
<div class="text-red-400/90">
|
|
365
|
+
immediate drop: the full fold-down (and its KV re-read) lands on the next
|
|
366
|
+
turn, and any in-flight descent is cancelled.
|
|
367
|
+
</div>
|
|
368
|
+
</Show>
|
|
346
369
|
</Show>
|
|
347
370
|
</div>
|
|
348
371
|
|
|
@@ -384,7 +407,7 @@ export function SettingsPanel(props: {
|
|
|
384
407
|
class="px-2 py-0.5 text-[10px] rounded font-mono bg-neutral-800 hover:bg-neutral-700
|
|
385
408
|
text-neutral-200 disabled:opacity-30"
|
|
386
409
|
onClick={() => void runDryRun(false)}
|
|
387
|
-
title="Compile at these settings without applying them. Does not commit anything."
|
|
410
|
+
title="Compile at these settings without applying them. Does not commit anything. ~1.6s on a large store after the 2026-07-26 solver fixes."
|
|
388
411
|
>
|
|
389
412
|
dry run
|
|
390
413
|
</button>
|
|
@@ -394,7 +417,7 @@ export function SettingsPanel(props: {
|
|
|
394
417
|
class="px-2 py-0.5 text-[10px] rounded font-mono bg-cyan-900/40 hover:bg-cyan-900/60
|
|
395
418
|
text-cyan-200 disabled:opacity-30"
|
|
396
419
|
onClick={() => void runDryRun(true)}
|
|
397
|
-
title="Dry run and show the resulting context in the main pane."
|
|
420
|
+
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
421
|
>
|
|
399
422
|
dry run + show context
|
|
400
423
|
</button>
|
|
@@ -406,8 +429,11 @@ export function SettingsPanel(props: {
|
|
|
406
429
|
</Show>
|
|
407
430
|
</div>
|
|
408
431
|
<div class="text-[10px] text-neutral-600 mb-1.5 leading-relaxed">
|
|
409
|
-
A dry run is a full compile
|
|
410
|
-
agent
|
|
432
|
+
A dry run is a <b class="text-neutral-500">full compile</b> and it runs on the agent's
|
|
433
|
+
thread — while it runs the agent does nothing else (no heartbeat, no Discord, no MCPL).
|
|
434
|
+
Measured ~1.6s on a large store; it commits nothing (no fold resolutions, no compression
|
|
435
|
+
queued). Runs are serialized with a short cooldown, so a second click is refused rather
|
|
436
|
+
than queueing another pause.
|
|
411
437
|
</div>
|
|
412
438
|
|
|
413
439
|
<Show when={!props.state!.previewAvailable}>
|