@animalabs/connectome-host 0.3.1 → 0.3.7
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/.env.example +6 -0
- package/.github/workflows/publish.yml +9 -4
- package/README.md +5 -0
- package/bun.lock +8 -4
- package/docs/AGENT-ONBOARDING.md +6 -1
- package/package.json +5 -5
- package/scripts/import-codex-rollout.ts +288 -0
- package/src/call-ledger.ts +371 -0
- package/src/call-pricing.ts +119 -0
- package/src/commands.ts +57 -3
- package/src/index.ts +87 -27
- package/src/logging-adapter.ts +72 -9
- package/src/modules/fleet-module.ts +21 -9
- package/src/modules/mcpl-admin-module.ts +6 -0
- package/src/modules/observers-module.ts +180 -0
- package/src/modules/time-module.ts +15 -9
- package/src/modules/web-ui-module.ts +415 -16
- package/src/modules/web-ui-observers.ts +322 -0
- package/src/recipe.ts +48 -3
- package/src/strategies/frontdesk-strategy.ts +10 -4
- package/src/web/protocol.ts +141 -0
- package/test/call-ledger.test.ts +91 -0
- package/test/call-pricing.test.ts +69 -0
- package/test/fleet-subscribe-union-e2e.test.ts +5 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
- package/test/frontdesk-strategy.test.ts +10 -0
- package/test/import-codex-rollout.test.ts +36 -0
- package/test/logging-adapter.test.ts +58 -1
- package/test/recipe-cache-ttl.test.ts +7 -3
- package/test/recipe-provider.test.ts +36 -0
- package/test/recipe-timezone.test.ts +20 -0
- package/test/time-module.test.ts +13 -0
- package/test/web-ui-context-coverage.test.ts +61 -0
- package/test/web-ui-observers.test.ts +344 -0
- package/web/package-lock.json +2446 -0
- package/web/src/App.tsx +24 -0
- package/web/src/Context.tsx +207 -7
- package/web/src/ObserverGate.tsx +78 -0
- package/web/src/Usage.tsx +116 -2
- package/web/src/observer-identity.ts +110 -0
- package/web/src/wire.ts +60 -1
package/web/src/App.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import { McplPanel, type McplServerRow } from './Mcpl';
|
|
|
12
12
|
import { FilesPanel, FileViewerModal, type Mount, type FlatEntry, type FileViewer } from './Files';
|
|
13
13
|
import { ContextPanel } from './Context';
|
|
14
14
|
import { ContextDocument } from './ContextDocument';
|
|
15
|
+
import { ObserverGateScreen } from './ObserverGate';
|
|
15
16
|
import {
|
|
16
17
|
WEB_PROTOCOL_VERSION,
|
|
17
18
|
type WebUiServerMessage,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
type MessageBlock,
|
|
22
23
|
type TokenUsage,
|
|
23
24
|
type PerAgentCost,
|
|
25
|
+
type CallLedgerSnapshot,
|
|
24
26
|
} from '@conhost/web/protocol';
|
|
25
27
|
|
|
26
28
|
/** Client-side block: the wire MessageBlock plus live-stream bookkeeping. */
|
|
@@ -115,6 +117,7 @@ export function App() {
|
|
|
115
117
|
let pendingHistoryTimer: number | undefined;
|
|
116
118
|
const [usage, setUsage] = createSignal<TokenUsage>({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
|
117
119
|
const [perAgentCost, setPerAgentCost] = createSignal<PerAgentCost[]>([]);
|
|
120
|
+
const [callLedger, setCallLedger] = createSignal<CallLedgerSnapshot | null>(null);
|
|
118
121
|
const [draft, setDraft] = createSignal('');
|
|
119
122
|
/** Currently-focused tree node + the panel mode rendered on its behalf.
|
|
120
123
|
* `mode` decides whether the side panel shows live stream events or a
|
|
@@ -704,6 +707,7 @@ export function App() {
|
|
|
704
707
|
appendMessage: (m) => setMessages(produce(arr => arr.push(m))),
|
|
705
708
|
setUsage,
|
|
706
709
|
setPerAgentCost,
|
|
710
|
+
setCallLedger,
|
|
707
711
|
appendStreamToken,
|
|
708
712
|
appendToolUseBlocks,
|
|
709
713
|
updateToolStatus,
|
|
@@ -836,6 +840,20 @@ export function App() {
|
|
|
836
840
|
<div class="flex flex-col h-screen">
|
|
837
841
|
<Header welcome={welcome()} usage={usage()} status={wire.status()} />
|
|
838
842
|
<ReconnectBanner status={wire.status()} />
|
|
843
|
+
<Show when={wire.observerState() === 'observer' && wire.observer()}>
|
|
844
|
+
{(info) => (
|
|
845
|
+
<div class="bg-violet-950/60 border-b border-violet-900 px-4 py-1.5 text-xs text-violet-200 flex items-center gap-2">
|
|
846
|
+
<span class="w-2 h-2 rounded-full bg-violet-500" />
|
|
847
|
+
<span>
|
|
848
|
+
Observing as <span class="font-semibold">{info().label}</span> — read-only, scopes:{' '}
|
|
849
|
+
<code class="font-mono">{info().scopes.join(', ')}</code>. Content outside your scopes is elided.
|
|
850
|
+
</span>
|
|
851
|
+
</div>
|
|
852
|
+
)}
|
|
853
|
+
</Show>
|
|
854
|
+
<Show when={wire.observerState() === 'denied' || wire.observerState() === 'unavailable'}>
|
|
855
|
+
<ObserverGateScreen state={wire.observerState() as 'denied' | 'unavailable'} />
|
|
856
|
+
</Show>
|
|
839
857
|
<Show when={protoMismatch() !== null}>
|
|
840
858
|
<div class="bg-amber-950/60 border-b border-amber-900 px-4 py-1.5 text-xs text-amber-200 flex items-center gap-2">
|
|
841
859
|
<span class="w-2 h-2 rounded-full bg-amber-500" />
|
|
@@ -952,6 +970,7 @@ export function App() {
|
|
|
952
970
|
node={focusedNode()!}
|
|
953
971
|
sessionUsage={usage()}
|
|
954
972
|
perAgentCost={perAgentCost()}
|
|
973
|
+
callLedger={callLedger()}
|
|
955
974
|
onClose={closePanel}
|
|
956
975
|
/>
|
|
957
976
|
)}
|
|
@@ -1051,6 +1070,7 @@ interface HandlerHooks {
|
|
|
1051
1070
|
appendMessage: (msg: Message) => void;
|
|
1052
1071
|
setUsage: (u: TokenUsage) => void;
|
|
1053
1072
|
setPerAgentCost: (c: PerAgentCost[]) => void;
|
|
1073
|
+
setCallLedger: (ledger: CallLedgerSnapshot | null) => void;
|
|
1054
1074
|
appendStreamToken: (token: string, blockType?: string) => void;
|
|
1055
1075
|
/** Attach yielded tool calls to the streaming assistant message. */
|
|
1056
1076
|
appendToolUseBlocks: (calls: Array<{ id: string; name: string; input?: unknown }>) => void;
|
|
@@ -1084,6 +1104,7 @@ function handleServerMessage(
|
|
|
1084
1104
|
hooks.applyWelcome(msg);
|
|
1085
1105
|
hooks.setUsage(msg.usage);
|
|
1086
1106
|
hooks.setPerAgentCost(msg.perAgentCost ?? []);
|
|
1107
|
+
hooks.setCallLedger(msg.callLedger ?? null);
|
|
1087
1108
|
return;
|
|
1088
1109
|
}
|
|
1089
1110
|
case 'message-appended':
|
|
@@ -1096,6 +1117,9 @@ function handleServerMessage(
|
|
|
1096
1117
|
hooks.setUsage(msg.usage);
|
|
1097
1118
|
if (msg.perAgentCost) hooks.setPerAgentCost(msg.perAgentCost);
|
|
1098
1119
|
return;
|
|
1120
|
+
case 'call-ledger':
|
|
1121
|
+
hooks.setCallLedger(msg.ledger);
|
|
1122
|
+
return;
|
|
1099
1123
|
case 'trace': {
|
|
1100
1124
|
const e = msg.event;
|
|
1101
1125
|
switch (e.type) {
|
package/web/src/Context.tsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* a per-segment table, with an exact total token count.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { createSignal, onMount, For, Show } from 'solid-js';
|
|
11
|
+
import { createSignal, onCleanup, onMount, For, Show } from 'solid-js';
|
|
12
12
|
|
|
13
13
|
interface Seg { messages: number; tokens: number }
|
|
14
14
|
interface Stats {
|
|
@@ -28,9 +28,55 @@ interface Makeup {
|
|
|
28
28
|
error?: string;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
interface CoverageLevel {
|
|
32
|
+
level: number;
|
|
33
|
+
summaries: number;
|
|
34
|
+
frontier: number;
|
|
35
|
+
tokens: number;
|
|
36
|
+
coveredChunks: number;
|
|
37
|
+
coveredMessages: number;
|
|
38
|
+
coveredTokens: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CoverageChunk {
|
|
42
|
+
index: number;
|
|
43
|
+
messages: number;
|
|
44
|
+
tokens: number;
|
|
45
|
+
compressed: boolean;
|
|
46
|
+
summaryId: string | null;
|
|
47
|
+
maxLevel: number;
|
|
48
|
+
selectedMin: number;
|
|
49
|
+
selectedMax: number;
|
|
50
|
+
queued: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Coverage {
|
|
54
|
+
agent: string;
|
|
55
|
+
branch: string;
|
|
56
|
+
generatedAt: string;
|
|
57
|
+
supported: boolean;
|
|
58
|
+
totals: {
|
|
59
|
+
chunks: number;
|
|
60
|
+
compressedChunks: number;
|
|
61
|
+
coveredMessages: number;
|
|
62
|
+
coveredTokens: number;
|
|
63
|
+
summaries: number;
|
|
64
|
+
};
|
|
65
|
+
levels: CoverageLevel[];
|
|
66
|
+
chunks: CoverageChunk[];
|
|
67
|
+
queue: {
|
|
68
|
+
inFlight: boolean;
|
|
69
|
+
pending: string | null;
|
|
70
|
+
l1: number[];
|
|
71
|
+
merges: Array<{ targetLevel: number; sourceCount: number; firstSource: string | null; lastSource: string | null }>;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
31
75
|
type Row = { key: string; label: string; messages: number; tokens: number; color: string };
|
|
32
76
|
|
|
33
77
|
const fmt = (n: number) => n.toLocaleString();
|
|
78
|
+
const levelColors = ['#52525b', '#06b6d4', '#10b981', '#f59e0b', '#f43f5e', '#a78bfa', '#60a5fa'];
|
|
79
|
+
const levelColor = (level: number) => levelColors[Math.min(level, levelColors.length - 1)];
|
|
34
80
|
|
|
35
81
|
function rowsOf(s: Stats): Row[] {
|
|
36
82
|
return [
|
|
@@ -45,15 +91,19 @@ function rowsOf(s: Stats): Row[] {
|
|
|
45
91
|
|
|
46
92
|
export function ContextPanel(props: { agent?: string }) {
|
|
47
93
|
const [data, setData] = createSignal<Makeup | null>(null);
|
|
94
|
+
const [coverage, setCoverage] = createSignal<Coverage | null>(null);
|
|
48
95
|
const [loading, setLoading] = createSignal(false);
|
|
96
|
+
const [coverageLoading, setCoverageLoading] = createSignal(false);
|
|
49
97
|
const [err, setErr] = createSignal<string | null>(null);
|
|
98
|
+
const [coverageErr, setCoverageErr] = createSignal<string | null>(null);
|
|
99
|
+
|
|
100
|
+
const query = () => props.agent ? `?agent=${encodeURIComponent(props.agent)}` : '';
|
|
50
101
|
|
|
51
102
|
const load = async () => {
|
|
52
103
|
setLoading(true);
|
|
53
104
|
setErr(null);
|
|
54
105
|
try {
|
|
55
|
-
const
|
|
56
|
-
const res = await fetch(`/debug/context/makeup${q}`, { credentials: 'same-origin' });
|
|
106
|
+
const res = await fetch(`/debug/context/makeup${query()}`, { credentials: 'same-origin' });
|
|
57
107
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
58
108
|
const j = (await res.json()) as Makeup;
|
|
59
109
|
if (j.error) throw new Error(j.error);
|
|
@@ -65,7 +115,30 @@ export function ContextPanel(props: { agent?: string }) {
|
|
|
65
115
|
}
|
|
66
116
|
};
|
|
67
117
|
|
|
68
|
-
|
|
118
|
+
const loadCoverage = async () => {
|
|
119
|
+
setCoverageLoading(true);
|
|
120
|
+
setCoverageErr(null);
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch(`/debug/context/coverage${query()}`, { credentials: 'same-origin' });
|
|
123
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
124
|
+
setCoverage((await res.json()) as Coverage);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
setCoverageErr(e instanceof Error ? e.message : String(e));
|
|
127
|
+
} finally {
|
|
128
|
+
setCoverageLoading(false);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const refreshAll = () => {
|
|
133
|
+
void load();
|
|
134
|
+
void loadCoverage();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
onMount(() => {
|
|
138
|
+
refreshAll();
|
|
139
|
+
const timer = window.setInterval(() => void loadCoverage(), 5_000);
|
|
140
|
+
onCleanup(() => window.clearInterval(timer));
|
|
141
|
+
});
|
|
69
142
|
|
|
70
143
|
const estTotal = () => data()?.stats?.total.tokens ?? 0;
|
|
71
144
|
const rows = () => {
|
|
@@ -80,10 +153,11 @@ export function ContextPanel(props: { agent?: string }) {
|
|
|
80
153
|
<button
|
|
81
154
|
type="button"
|
|
82
155
|
class="px-2 py-0.5 text-neutral-400 hover:text-neutral-100 border border-neutral-700 rounded"
|
|
83
|
-
onClick={
|
|
84
|
-
disabled={loading()}
|
|
156
|
+
onClick={refreshAll}
|
|
157
|
+
disabled={loading() || coverageLoading()}
|
|
158
|
+
title="Refresh context diagnostics"
|
|
85
159
|
>
|
|
86
|
-
{loading() ? '…' : 'refresh'}
|
|
160
|
+
{loading() || coverageLoading() ? '…' : 'refresh'}
|
|
87
161
|
</button>
|
|
88
162
|
</div>
|
|
89
163
|
|
|
@@ -91,6 +165,106 @@ export function ContextPanel(props: { agent?: string }) {
|
|
|
91
165
|
<div class="text-rose-400">error: {err()}</div>
|
|
92
166
|
</Show>
|
|
93
167
|
|
|
168
|
+
<Show when={coverageErr()}>
|
|
169
|
+
<div class="text-rose-400">coverage: {coverageErr()}</div>
|
|
170
|
+
</Show>
|
|
171
|
+
|
|
172
|
+
<Show when={coverage()?.supported}>
|
|
173
|
+
<section class="border-y border-neutral-800 py-3 space-y-2.5">
|
|
174
|
+
<div class="flex items-center justify-between">
|
|
175
|
+
<span class="text-neutral-300">Summary coverage</span>
|
|
176
|
+
<span class="text-neutral-600">
|
|
177
|
+
{coverage()!.queue.inFlight || coverage()!.queue.l1.length > 0 || coverage()!.queue.merges.length > 0
|
|
178
|
+
? 'active'
|
|
179
|
+
: 'idle'}
|
|
180
|
+
</span>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div class="grid grid-cols-3 gap-2 text-neutral-500">
|
|
184
|
+
<div>
|
|
185
|
+
<div class="text-base text-neutral-200">{coverage()!.totals.compressedChunks}/{coverage()!.totals.chunks}</div>
|
|
186
|
+
<div>chunks</div>
|
|
187
|
+
</div>
|
|
188
|
+
<div>
|
|
189
|
+
<div class="text-base text-neutral-200">{fmt(coverage()!.totals.coveredMessages)}</div>
|
|
190
|
+
<div>msgs covered</div>
|
|
191
|
+
</div>
|
|
192
|
+
<div>
|
|
193
|
+
<div class="text-base text-neutral-200">{fmt(coverage()!.totals.summaries)}</div>
|
|
194
|
+
<div>summaries</div>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div class="space-y-1.5">
|
|
199
|
+
<div class="flex justify-between text-neutral-500"><span>available depth</span><span>oldest → newest</span></div>
|
|
200
|
+
<CoverageStrip chunks={coverage()!.chunks} field="available" />
|
|
201
|
+
<div class="flex justify-between text-neutral-500"><span>selected depth</span><span>{fmt(coverage()!.totals.coveredTokens)} tok covered</span></div>
|
|
202
|
+
<CoverageStrip chunks={coverage()!.chunks} field="selected" />
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div class="flex flex-wrap gap-x-2.5 gap-y-1 text-neutral-500">
|
|
206
|
+
<For each={[0, ...coverage()!.levels.map(level => level.level)]}>
|
|
207
|
+
{(level) => (
|
|
208
|
+
<span><span class="inline-block w-2 h-2 rounded-sm mr-1" style={{ background: levelColor(level) }} />L{level}</span>
|
|
209
|
+
)}
|
|
210
|
+
</For>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<table class="w-full">
|
|
214
|
+
<thead>
|
|
215
|
+
<tr class="text-neutral-500 text-left">
|
|
216
|
+
<th class="font-normal py-1">level</th>
|
|
217
|
+
<th class="font-normal text-right">frontier</th>
|
|
218
|
+
<th class="font-normal text-right">total</th>
|
|
219
|
+
<th class="font-normal text-right">coverage</th>
|
|
220
|
+
</tr>
|
|
221
|
+
</thead>
|
|
222
|
+
<tbody>
|
|
223
|
+
<For each={coverage()!.levels}>
|
|
224
|
+
{(level) => (
|
|
225
|
+
<tr class="border-t border-neutral-900">
|
|
226
|
+
<td class="py-1 text-neutral-200">L{level.level}</td>
|
|
227
|
+
<td class="text-right text-neutral-300">{level.frontier}</td>
|
|
228
|
+
<td class="text-right text-neutral-500">{level.summaries}</td>
|
|
229
|
+
<td class="text-right text-neutral-400">{fmt(level.coveredMessages)} msg</td>
|
|
230
|
+
</tr>
|
|
231
|
+
)}
|
|
232
|
+
</For>
|
|
233
|
+
</tbody>
|
|
234
|
+
</table>
|
|
235
|
+
|
|
236
|
+
<div class="border-t border-neutral-900 pt-2 space-y-1">
|
|
237
|
+
<div class="flex items-center justify-between">
|
|
238
|
+
<span class="text-neutral-400">Queue</span>
|
|
239
|
+
<span class={coverage()!.queue.inFlight ? 'text-amber-300' : 'text-neutral-600'}>
|
|
240
|
+
{coverage()!.queue.inFlight ? 'in flight' : 'waiting'}
|
|
241
|
+
</span>
|
|
242
|
+
</div>
|
|
243
|
+
<Show when={coverage()!.queue.pending}>
|
|
244
|
+
<div class="text-amber-200 truncate" title={coverage()!.queue.pending ?? ''}>{coverage()!.queue.pending}</div>
|
|
245
|
+
</Show>
|
|
246
|
+
<For each={coverage()!.queue.l1}>
|
|
247
|
+
{(index) => <div class="text-neutral-400">L1 · chunk {index}</div>}
|
|
248
|
+
</For>
|
|
249
|
+
<For each={coverage()!.queue.merges}>
|
|
250
|
+
{(merge) => (
|
|
251
|
+
<div class="text-neutral-400" title={`${merge.firstSource ?? ''} → ${merge.lastSource ?? ''}`}>
|
|
252
|
+
L{merge.targetLevel} · {merge.sourceCount} source summaries
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
</For>
|
|
256
|
+
<Show when={!coverage()!.queue.inFlight && coverage()!.queue.l1.length === 0 && coverage()!.queue.merges.length === 0}>
|
|
257
|
+
<div class="text-neutral-600">No queued work</div>
|
|
258
|
+
</Show>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<div class="flex items-center justify-between gap-2 text-neutral-600">
|
|
262
|
+
<span class="min-w-0 truncate" title={coverage()!.branch}>{coverage()!.branch}</span>
|
|
263
|
+
<a class="shrink-0 text-cyan-500 hover:text-cyan-300" href="/curve" target="_blank" rel="noreferrer">curve ↗</a>
|
|
264
|
+
</div>
|
|
265
|
+
</section>
|
|
266
|
+
</Show>
|
|
267
|
+
|
|
94
268
|
<Show when={data()?.stats} fallback={<Show when={!err()}><div class="text-neutral-500">loading…</div></Show>}>
|
|
95
269
|
{/* headline totals */}
|
|
96
270
|
<div class="flex gap-4">
|
|
@@ -156,3 +330,29 @@ export function ContextPanel(props: { agent?: string }) {
|
|
|
156
330
|
</div>
|
|
157
331
|
);
|
|
158
332
|
}
|
|
333
|
+
|
|
334
|
+
function CoverageStrip(props: { chunks: CoverageChunk[]; field: 'available' | 'selected' }) {
|
|
335
|
+
const total = () => props.chunks.reduce((sum, chunk) => sum + Math.max(1, chunk.tokens), 0);
|
|
336
|
+
return (
|
|
337
|
+
<div class="flex h-4 w-full overflow-hidden rounded border border-neutral-800 bg-neutral-900">
|
|
338
|
+
<For each={props.chunks}>
|
|
339
|
+
{(chunk) => {
|
|
340
|
+
const level = () => props.field === 'available' ? chunk.maxLevel : chunk.selectedMax;
|
|
341
|
+
const title = () => {
|
|
342
|
+
const selected = chunk.selectedMin === chunk.selectedMax
|
|
343
|
+
? `L${chunk.selectedMax}`
|
|
344
|
+
: `L${chunk.selectedMin}–L${chunk.selectedMax}`;
|
|
345
|
+
return `chunk ${chunk.index} · ${chunk.messages} msg · ${fmt(chunk.tokens)} tok · available L${chunk.maxLevel} · selected ${selected}${chunk.queued ? ' · queued' : ''}`;
|
|
346
|
+
};
|
|
347
|
+
return (
|
|
348
|
+
<div
|
|
349
|
+
class={`h-full min-w-px ${chunk.queued ? 'ring-1 ring-inset ring-amber-200' : ''}`}
|
|
350
|
+
style={{ width: `${(Math.max(1, chunk.tokens) / Math.max(1, total())) * 100}%`, background: levelColor(level()) }}
|
|
351
|
+
title={title()}
|
|
352
|
+
/>
|
|
353
|
+
);
|
|
354
|
+
}}
|
|
355
|
+
</For>
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-screen gate shown when this device's observer key is not granted on
|
|
3
|
+
* this agent ('denied') or WebCrypto is unavailable ('unavailable' —
|
|
4
|
+
* insecure context). Shows the device fingerprint to hand to the agent /
|
|
5
|
+
* operator, and offers password sign-in as the fallback (navigating to
|
|
6
|
+
* /auth/basic triggers the browser's basic-auth prompt; with cached
|
|
7
|
+
* credentials the subsequent WS upgrade authenticates as a full client).
|
|
8
|
+
*/
|
|
9
|
+
import { createResource, Show } from 'solid-js';
|
|
10
|
+
import { deviceFingerprint } from './observer-identity';
|
|
11
|
+
|
|
12
|
+
export function ObserverGateScreen(props: { state: 'denied' | 'unavailable' }) {
|
|
13
|
+
const [fingerprint] = createResource(deviceFingerprint);
|
|
14
|
+
|
|
15
|
+
const copy = (): void => {
|
|
16
|
+
const fp = fingerprint();
|
|
17
|
+
if (fp) void navigator.clipboard?.writeText(fp);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div class="fixed inset-0 z-50 bg-zinc-950/95 flex items-center justify-center p-6">
|
|
22
|
+
<div class="max-w-xl w-full border border-zinc-800 rounded-lg bg-zinc-900 p-6 space-y-4 text-sm text-zinc-300">
|
|
23
|
+
<Show
|
|
24
|
+
when={props.state === 'denied'}
|
|
25
|
+
fallback={
|
|
26
|
+
<>
|
|
27
|
+
<h2 class="text-base font-semibold text-zinc-100">Observer keys unavailable here</h2>
|
|
28
|
+
<p>
|
|
29
|
+
Device-key authentication needs a secure context (https or localhost) for WebCrypto.
|
|
30
|
+
This page was loaded over plain http, so only password sign-in is available.
|
|
31
|
+
</p>
|
|
32
|
+
</>
|
|
33
|
+
}
|
|
34
|
+
>
|
|
35
|
+
<h2 class="text-base font-semibold text-zinc-100">This device isn't authorized yet</h2>
|
|
36
|
+
<p>
|
|
37
|
+
This viewer shows an agent's interior — access is granted per device, by the agent or
|
|
38
|
+
its operator. Share this device's key fingerprint with them:
|
|
39
|
+
</p>
|
|
40
|
+
<Show
|
|
41
|
+
when={!fingerprint.loading && fingerprint() === null}
|
|
42
|
+
fallback={
|
|
43
|
+
<div class="flex items-center gap-2">
|
|
44
|
+
<code class="font-mono text-xs bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 break-all flex-1">
|
|
45
|
+
{fingerprint.loading ? 'generating…' : fingerprint()}
|
|
46
|
+
</code>
|
|
47
|
+
<button
|
|
48
|
+
class="px-2 py-1.5 text-xs rounded border border-zinc-700 hover:bg-zinc-800"
|
|
49
|
+
onClick={copy}
|
|
50
|
+
>
|
|
51
|
+
copy
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
}
|
|
55
|
+
>
|
|
56
|
+
<p class="text-amber-300/90">
|
|
57
|
+
This origin can't generate a device key (WebCrypto needs https or localhost) —
|
|
58
|
+
use password sign-in below, or open the agent's https URL.
|
|
59
|
+
</p>
|
|
60
|
+
</Show>
|
|
61
|
+
<p class="text-zinc-400">
|
|
62
|
+
Grant (agent tool or operator edit of <code class="font-mono">data/observers.json</code>):{' '}
|
|
63
|
+
<code class="font-mono text-xs">
|
|
64
|
+
observers--grant key=<fingerprint> label=<who-you-are> scopes=[health,…]
|
|
65
|
+
</code>
|
|
66
|
+
. Takes effect within ~3s — then{' '}
|
|
67
|
+
<button class="underline" onClick={() => location.reload()}>reload</button>.
|
|
68
|
+
</p>
|
|
69
|
+
</Show>
|
|
70
|
+
<div class="pt-2 border-t border-zinc-800">
|
|
71
|
+
<a class="underline text-zinc-400 hover:text-zinc-200" href="/auth/basic">
|
|
72
|
+
Sign in with password instead
|
|
73
|
+
</a>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
package/web/src/Usage.tsx
CHANGED
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
import { For, Show } from 'solid-js';
|
|
15
15
|
import type { UiNode } from './tree';
|
|
16
16
|
import { aggregateTokens } from './tree';
|
|
17
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
TokenUsage,
|
|
19
|
+
PerAgentCost,
|
|
20
|
+
CallLedgerSnapshot,
|
|
21
|
+
CallLedgerRow,
|
|
22
|
+
CallLedgerVerdict,
|
|
23
|
+
} from '@conhost/web/protocol';
|
|
18
24
|
|
|
19
25
|
export function UsagePanel(props: {
|
|
20
26
|
node: UiNode;
|
|
@@ -24,6 +30,10 @@ export function UsagePanel(props: {
|
|
|
24
30
|
/** Per-agent cost slice (parent-process agents only). Used to label
|
|
25
31
|
* per-agent cost in process / per-agent breakdown views. */
|
|
26
32
|
perAgentCost: PerAgentCost[];
|
|
33
|
+
/** Recent provider calls. The ledger is process-local, so it is shown on
|
|
34
|
+
* the process usage view rather than pretending it can be split across
|
|
35
|
+
* fleet children. */
|
|
36
|
+
callLedger: CallLedgerSnapshot | null;
|
|
27
37
|
onClose(): void;
|
|
28
38
|
}) {
|
|
29
39
|
const costFor = (agentName: string): { total: number; currency: string } | undefined => {
|
|
@@ -70,7 +80,7 @@ export function UsagePanel(props: {
|
|
|
70
80
|
};
|
|
71
81
|
|
|
72
82
|
return (
|
|
73
|
-
<div class="border-l border-neutral-800 w-
|
|
83
|
+
<div class="border-l border-neutral-800 w-[52rem] max-w-[68vw] shrink-0 bg-neutral-950 flex flex-col h-full">
|
|
74
84
|
<div class="border-b border-neutral-800 px-3 py-2 flex items-center gap-2">
|
|
75
85
|
<span class="text-[10px] uppercase tracking-wider text-neutral-500 font-semibold">usage</span>
|
|
76
86
|
<span class="font-mono text-sm text-neutral-200 truncate">{props.node.label}</span>
|
|
@@ -99,6 +109,8 @@ export function UsagePanel(props: {
|
|
|
99
109
|
</Show>
|
|
100
110
|
|
|
101
111
|
<Show when={props.node.kind === 'process'}>
|
|
112
|
+
<CallLedgerSection ledger={props.callLedger} />
|
|
113
|
+
|
|
102
114
|
<section class="text-[10px] text-neutral-600 italic leading-snug">
|
|
103
115
|
Session total comes from the membrane's per-call usage stream.
|
|
104
116
|
"By node" is the per-agent ledger from the tree reducer; the two
|
|
@@ -110,6 +122,108 @@ export function UsagePanel(props: {
|
|
|
110
122
|
);
|
|
111
123
|
}
|
|
112
124
|
|
|
125
|
+
function CallLedgerSection(props: { ledger: CallLedgerSnapshot | null }) {
|
|
126
|
+
const rows = (): CallLedgerRow[] => [...(props.ledger?.rows ?? [])].slice(-30).reverse();
|
|
127
|
+
return (
|
|
128
|
+
<section>
|
|
129
|
+
<div class="flex items-baseline gap-2 mb-1">
|
|
130
|
+
<div class="text-neutral-500 uppercase tracking-wider text-[10px]">recent calls · cache ledger</div>
|
|
131
|
+
<Show when={props.ledger}>
|
|
132
|
+
{(ledger) => (
|
|
133
|
+
<span class="ml-auto text-[10px] text-neutral-500">
|
|
134
|
+
${fmtCost(ledger().summary.cost?.total ?? 0)} retained · {Math.round(ledger().summary.cacheHitRatio * 100)}% cached
|
|
135
|
+
<Show when={(ledger().summary.cost?.unpricedCalls ?? 0) > 0}>
|
|
136
|
+
{' '}· <span class="text-amber-400">{ledger().summary.cost!.unpricedCalls} unpriced</span>
|
|
137
|
+
</Show>
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
</Show>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<Show
|
|
144
|
+
when={rows().length > 0}
|
|
145
|
+
fallback={<div class="border border-neutral-800 rounded px-2 py-2 text-neutral-600">No provider calls recorded yet.</div>}
|
|
146
|
+
>
|
|
147
|
+
<div class="border border-neutral-800 rounded overflow-hidden">
|
|
148
|
+
<div class="grid grid-cols-[4.7rem_3.5rem_3rem_4rem_4rem_4rem_4rem_4.7rem_minmax(8rem,1fr)] gap-x-2 px-2 py-1 bg-neutral-900 text-[9px] uppercase tracking-wider text-neutral-600">
|
|
149
|
+
<span>time</span><span>origin</span><span>msgs</span><span class="text-right">in</span>
|
|
150
|
+
<span class="text-right">read</span><span class="text-right">write</span><span class="text-right">out</span>
|
|
151
|
+
<span class="text-right">cost</span><span>verdict / cause</span>
|
|
152
|
+
</div>
|
|
153
|
+
<div class="max-h-80 overflow-y-auto divide-y divide-neutral-900">
|
|
154
|
+
<For each={rows()}>{(row) => <CallLedgerRowView row={row} />}</For>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="mt-1 text-[9px] text-neutral-600">
|
|
158
|
+
Costs marked in USD use provider-reported token buckets and the versioned public rate card. Calls missing an exact cache-write split or known rate stay explicitly unpriced. origin~ remains an estimated call class.
|
|
159
|
+
</div>
|
|
160
|
+
</Show>
|
|
161
|
+
</section>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function CallLedgerRowView(props: { row: CallLedgerRow }) {
|
|
166
|
+
const time = (): string => {
|
|
167
|
+
const d = new Date(props.row.timestamp);
|
|
168
|
+
return Number.isNaN(d.getTime()) ? '?' : d.toLocaleTimeString([], { hour12: false });
|
|
169
|
+
};
|
|
170
|
+
const flags = (): string => {
|
|
171
|
+
const bp = props.row.cache.breakpoints;
|
|
172
|
+
if (bp === undefined) return 'flags unknown';
|
|
173
|
+
if (bp === 0) return 'no cache flags';
|
|
174
|
+
return `${bp}bp:${props.row.cache.effectiveTtl}`;
|
|
175
|
+
};
|
|
176
|
+
const costTitle = (): string => {
|
|
177
|
+
const c = props.row.cost;
|
|
178
|
+
if (!c) return 'Unpriced: an authoritative usage bucket or public rate was unavailable';
|
|
179
|
+
return [
|
|
180
|
+
`billing-grade ${c.currency} · ${c.pricingVersion}`,
|
|
181
|
+
`input $${c.input.toFixed(6)}`,
|
|
182
|
+
`read $${c.cacheRead.toFixed(6)}`,
|
|
183
|
+
`write 5m $${c.cacheWrite5m.toFixed(6)}`,
|
|
184
|
+
`write 1h $${c.cacheWrite1h.toFixed(6)}`,
|
|
185
|
+
`output $${c.output.toFixed(6)}`,
|
|
186
|
+
].join(' · ');
|
|
187
|
+
};
|
|
188
|
+
return (
|
|
189
|
+
<div class="grid grid-cols-[4.7rem_3.5rem_3rem_4rem_4rem_4rem_4rem_4.7rem_minmax(8rem,1fr)] gap-x-2 px-2 py-1.5 items-start hover:bg-neutral-900/60">
|
|
190
|
+
<span class="text-neutral-400" title={`${props.row.timestamp} · ${Math.round(props.row.durationMs / 1000)}s`}>{time()}</span>
|
|
191
|
+
<span class="text-neutral-500">{props.row.originEstimate}</span>
|
|
192
|
+
<span class="text-neutral-400">{props.row.messages}</span>
|
|
193
|
+
<span class="text-right text-neutral-400">{fmt(props.row.tokens.input)}</span>
|
|
194
|
+
<span class="text-right text-sky-300">{fmt(props.row.tokens.cacheRead)}</span>
|
|
195
|
+
<span class="text-right text-amber-300">{fmt(props.row.tokens.cacheWrite)}</span>
|
|
196
|
+
<span class="text-right text-neutral-400">{fmt(props.row.tokens.output)}</span>
|
|
197
|
+
<span
|
|
198
|
+
class={`text-right ${props.row.cost ? 'text-emerald-300' : 'text-amber-500'}`}
|
|
199
|
+
title={costTitle()}
|
|
200
|
+
>
|
|
201
|
+
{props.row.cost ? `$${fmtCost(props.row.cost.total)}` : 'unpriced'}
|
|
202
|
+
</span>
|
|
203
|
+
<span class="min-w-0">
|
|
204
|
+
<span class={`font-semibold ${verdictColor(props.row.verdict)}`}>{props.row.verdict}</span>
|
|
205
|
+
<span class="ml-1 text-[9px] text-neutral-600">{flags()}</span>
|
|
206
|
+
<span class="block text-[9px] leading-snug text-neutral-500 break-words">{props.row.cause}</span>
|
|
207
|
+
</span>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function verdictColor(verdict: CallLedgerVerdict): string {
|
|
213
|
+
switch (verdict) {
|
|
214
|
+
case 'HIT': return 'text-emerald-300';
|
|
215
|
+
case 'hit+extend': return 'text-lime-300';
|
|
216
|
+
case 'rewrite:expired': return 'text-orange-300';
|
|
217
|
+
case 'rewrite:prefix-mutated':
|
|
218
|
+
case 'rewrite:prefix-truncated': return 'text-red-300';
|
|
219
|
+
case 'rewrite:unexplained': return 'text-violet-300';
|
|
220
|
+
case 'ERROR': return 'text-red-400';
|
|
221
|
+
case 'first-write': return 'text-slate-300';
|
|
222
|
+
case 'uncached': return 'text-stone-400';
|
|
223
|
+
default: return 'text-neutral-400';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
113
227
|
function TotalsBlock(props: { totals: { input: number; output: number; cacheRead: number; cacheWrite: number; cost?: { total: number; currency: string } } }) {
|
|
114
228
|
const t = props.totals;
|
|
115
229
|
return (
|