@animalabs/connectome-host 0.5.4 → 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 +47 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/modules/web-ui-module.ts +153 -1
- package/src/recipe.ts +41 -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/Pins.tsx +318 -0
- package/web/src/Settings.tsx +30 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
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
|
+
|
|
3
38
|
## 0.5.4 — 2026-07-26
|
|
4
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
|
+
|
|
5
52
|
### Fixed
|
|
6
53
|
|
|
7
54
|
- **`dry run + show context` was a 110-second agent stall.** Measured 110,348ms
|
package/package.json
CHANGED
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';
|
|
@@ -1977,6 +1978,64 @@ export class WebUiModule implements Module {
|
|
|
1977
1978
|
return;
|
|
1978
1979
|
}
|
|
1979
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
|
+
|
|
1980
2039
|
case 'request-settings': {
|
|
1981
2040
|
this.sendSettingsState(client, parsed.agent);
|
|
1982
2041
|
return;
|
|
@@ -1988,10 +2047,11 @@ export class WebUiModule implements Module {
|
|
|
1988
2047
|
case 'settings-update': {
|
|
1989
2048
|
const agentName = this.resolveSettingsAgent(parsed.agent);
|
|
1990
2049
|
try {
|
|
1991
|
-
const patch: Record<string, number> = {};
|
|
2050
|
+
const patch: Record<string, number | boolean> = {};
|
|
1992
2051
|
if (parsed.contextBudgetTokens !== undefined) patch.contextBudgetTokens = parsed.contextBudgetTokens;
|
|
1993
2052
|
if (parsed.tailTokens !== undefined) patch.tailTokens = parsed.tailTokens;
|
|
1994
2053
|
if (parsed.transitionPaceTokens !== undefined) patch.transitionPaceTokens = parsed.transitionPaceTokens;
|
|
2054
|
+
if (parsed.immediate !== undefined) patch.immediate = parsed.immediate;
|
|
1995
2055
|
const fw = sharedServer!.app.framework as unknown as {
|
|
1996
2056
|
updateAgentRuntimeSettings: (n: string, p: unknown, o?: { persist?: boolean }) => unknown;
|
|
1997
2057
|
};
|
|
@@ -2712,6 +2772,98 @@ export class WebUiModule implements Module {
|
|
|
2712
2772
|
}
|
|
2713
2773
|
}
|
|
2714
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
|
+
|
|
2715
2867
|
/** Default to the recipe's primary agent when the client omits a name. */
|
|
2716
2868
|
private resolveSettingsAgent(name?: string): string {
|
|
2717
2869
|
if (name) return name;
|
package/src/recipe.ts
CHANGED
|
@@ -602,6 +602,27 @@ export interface RecipeExtension {
|
|
|
602
602
|
sourceMeta?: Record<string, unknown>;
|
|
603
603
|
}
|
|
604
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
|
+
|
|
605
626
|
export interface Recipe {
|
|
606
627
|
name: string;
|
|
607
628
|
description?: string;
|
|
@@ -612,6 +633,8 @@ export interface Recipe {
|
|
|
612
633
|
/** Deployment-specific code extensions, keyed by a human-readable name. */
|
|
613
634
|
extensions?: Record<string, RecipeExtension>;
|
|
614
635
|
sessionNaming?: { examples?: string[] };
|
|
636
|
+
/** Client-side programmatic tool calling (code_execution tool). */
|
|
637
|
+
codeExecution?: RecipeCodeExecution;
|
|
615
638
|
}
|
|
616
639
|
|
|
617
640
|
// ---------------------------------------------------------------------------
|
|
@@ -1234,6 +1257,24 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
1234
1257
|
}
|
|
1235
1258
|
}
|
|
1236
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
|
+
|
|
1237
1278
|
return obj as unknown as Recipe;
|
|
1238
1279
|
}
|
|
1239
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/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>
|
|
@@ -408,7 +431,7 @@ export function SettingsPanel(props: {
|
|
|
408
431
|
<div class="text-[10px] text-neutral-600 mb-1.5 leading-relaxed">
|
|
409
432
|
A dry run is a <b class="text-neutral-500">full compile</b> and it runs on the agent's
|
|
410
433
|
thread — while it runs the agent does nothing else (no heartbeat, no Discord, no MCPL).
|
|
411
|
-
Measured ~
|
|
434
|
+
Measured ~1.6s on a large store; it commits nothing (no fold resolutions, no compression
|
|
412
435
|
queued). Runs are serialized with a short cooldown, so a second click is refused rather
|
|
413
436
|
than queueing another pause.
|
|
414
437
|
</div>
|