@bunshin-cc/bunshin 0.10.0 → 0.10.2
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/dist/bin.js
CHANGED
|
@@ -6508,9 +6508,11 @@ var route_exports = {};
|
|
|
6508
6508
|
__export(route_exports, {
|
|
6509
6509
|
ROUTER_MODEL: () => ROUTER_MODEL,
|
|
6510
6510
|
lastReply: () => lastReply,
|
|
6511
|
+
localDay: () => localDay,
|
|
6511
6512
|
promptContext: () => promptContext,
|
|
6512
6513
|
readAnswer: () => readAnswer,
|
|
6513
6514
|
readJob: () => readJob,
|
|
6515
|
+
readStats: () => readStats,
|
|
6514
6516
|
routeJob: () => routeJob,
|
|
6515
6517
|
sweepRouter: () => sweepRouter,
|
|
6516
6518
|
writeJob: () => writeJob
|
|
@@ -6636,17 +6638,24 @@ async function routeJob(store, sessionId, seq) {
|
|
|
6636
6638
|
const job = readJob(v2, sessionId);
|
|
6637
6639
|
if (!job || job.seq !== seq || job.cancelled) return null;
|
|
6638
6640
|
const started = Date.parse(job.at) || Date.now();
|
|
6639
|
-
const
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
};
|
|
6641
|
+
const model = config.recall.routerModel || process.env.BUNSHIN_ROUTER_MODEL || ROUTER_MODEL;
|
|
6642
|
+
const { picks, indexHash } = await askRouter(store, v2, sessionId, model);
|
|
6643
|
+
const answer = { seq, at: (/* @__PURE__ */ new Date()).toISOString(), ms: Date.now() - started, ...picks };
|
|
6644
|
+
writeJsonAtomic(answerFile(v2, sessionId, seq), answer);
|
|
6645
|
+
const dense = (readJob(v2, sessionId) ?? job).dense;
|
|
6646
|
+
v2.log("v2-router", { session: sessionId, seq, serve: answer.serve ?? null, rules: answer.rules ?? [], hint: answer.hint ?? [], ms: answer.ms, modelMs: answer.modelMs, error: answer.error, dense: dense?.serve ?? null, denseHint: dense?.hint ?? [], usage: answer.usage, costUsd: answer.costUsd });
|
|
6647
|
+
await recordStats(v2, sessionId, answer, model, indexHash);
|
|
6648
|
+
return answer;
|
|
6649
|
+
}
|
|
6650
|
+
async function askRouter(store, v2, sessionId, model) {
|
|
6651
|
+
const config = store.config();
|
|
6652
|
+
let indexHash;
|
|
6645
6653
|
try {
|
|
6646
6654
|
const session = v2.readSession(sessionId);
|
|
6647
|
-
if (!session?.lastPrompt) return
|
|
6648
|
-
if (!canCall(config)) return
|
|
6655
|
+
if (!session?.lastPrompt) return { picks: { error: "no prompt in the session file" } };
|
|
6656
|
+
if (!canCall(config)) return { picks: { error: "offline: the router's index comes from the server" } };
|
|
6649
6657
|
const index = await routerIndex(store, v2, session.source ?? "");
|
|
6658
|
+
indexHash = index.hash;
|
|
6650
6659
|
const systemFile = path11.join(dir(v2), `system-${index.hash}.txt`);
|
|
6651
6660
|
if (!fs16.existsSync(systemFile)) fs16.writeFileSync(systemFile, index.system);
|
|
6652
6661
|
const cwd = path11.join(dir(v2), "cwd");
|
|
@@ -6654,16 +6663,44 @@ async function routeJob(store, sessionId, seq) {
|
|
|
6654
6663
|
const inContext2 = session.inContext ?? [...session.loaded, ...session.pushed];
|
|
6655
6664
|
const titles = new Map((v2.readBundle()?.sections ?? []).map((s) => [s.id, s.skill?.name ?? s.title]));
|
|
6656
6665
|
const message = routerMessage(session.lastPrompt, promptContext(session), inContext2.map((id) => titles.get(id)).filter((t) => !!t));
|
|
6657
|
-
const res = await askClaude(systemFile, message,
|
|
6666
|
+
const res = await askClaude(systemFile, message, model, cwd);
|
|
6658
6667
|
const u = res.usage ?? {};
|
|
6659
6668
|
const usage = { input: u.input_tokens ?? 0, cacheRead: u.cache_read_input_tokens ?? 0, cacheWrite: u.cache_creation_input_tokens ?? 0, output: u.output_tokens ?? 0, ...u.output_tokens_details?.thinking_tokens ? { thinking: u.output_tokens_details.thinking_tokens } : {} };
|
|
6660
|
-
const meta = { ...res.duration_ms ? { modelMs: res.duration_ms } : {}, usage };
|
|
6661
|
-
if (res.is_error) return
|
|
6669
|
+
const meta = { ...res.duration_ms ? { modelMs: res.duration_ms } : {}, usage, ...typeof res.total_cost_usd === "number" ? { costUsd: res.total_cost_usd } : {} };
|
|
6670
|
+
if (res.is_error) return { picks: { ...meta, error: `claude: ${(res.result ?? "error").slice(0, 300)}` }, indexHash };
|
|
6662
6671
|
const picks = parseRouterAnswer(res.result ?? "", index.items, inContext2);
|
|
6663
|
-
if (!picks) return
|
|
6664
|
-
return
|
|
6672
|
+
if (!picks) return { picks: { ...meta, error: `not JSON: ${(res.result ?? "").slice(0, 120)}` }, indexHash };
|
|
6673
|
+
return { picks: { ...picks, ...meta }, indexHash };
|
|
6665
6674
|
} catch (err) {
|
|
6666
|
-
return
|
|
6675
|
+
return { picks: { error: (err instanceof Error ? err.message : String(err)).slice(0, 300) }, ...indexHash ? { indexHash } : {} };
|
|
6676
|
+
}
|
|
6677
|
+
}
|
|
6678
|
+
async function recordStats(v2, sessionId, a, model, indexHash) {
|
|
6679
|
+
let release = null;
|
|
6680
|
+
for (let i = 0; i < 40 && !(release = v2.tryLock("router-stats", 3e4)); i++) await new Promise((r) => setTimeout(r, 50));
|
|
6681
|
+
try {
|
|
6682
|
+
const day = localDay();
|
|
6683
|
+
const prev = readStats(v2);
|
|
6684
|
+
const stats = prev?.v === 1 && prev.day === day ? prev : { v: 1, day, model, today: emptyTally(), sessions: {} };
|
|
6685
|
+
const u = a.usage ?? { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
6686
|
+
const add = (t) => {
|
|
6687
|
+
t.calls++;
|
|
6688
|
+
if (a.error) t.errors++;
|
|
6689
|
+
t.costUsd += a.costUsd ?? 0;
|
|
6690
|
+
t.input += u.input;
|
|
6691
|
+
t.cacheRead += u.cacheRead;
|
|
6692
|
+
t.cacheWrite += u.cacheWrite;
|
|
6693
|
+
t.output += u.output;
|
|
6694
|
+
t.msTotal += a.ms;
|
|
6695
|
+
};
|
|
6696
|
+
add(stats.today);
|
|
6697
|
+
add(stats.sessions[sessionId] ??= emptyTally());
|
|
6698
|
+
stats.model = model;
|
|
6699
|
+
if (a.usage || !stats.last) stats.last = { at: a.at, session: sessionId, ms: a.ms, costUsd: a.costUsd ?? 0, input: u.input, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite, ...indexHash ? { indexHash } : {}, ...a.error ? { error: a.error } : {} };
|
|
6700
|
+
writeJsonAtomic(path11.join(dir(v2), "stats.json"), stats);
|
|
6701
|
+
} catch {
|
|
6702
|
+
} finally {
|
|
6703
|
+
release?.();
|
|
6667
6704
|
}
|
|
6668
6705
|
}
|
|
6669
6706
|
function sweepRouter(v2, now = Date.now()) {
|
|
@@ -6676,7 +6713,7 @@ function sweepRouter(v2, now = Date.now()) {
|
|
|
6676
6713
|
} catch {
|
|
6677
6714
|
}
|
|
6678
6715
|
}
|
|
6679
|
-
var ROUTER_MODEL, INDEX_TTL_MS, CLAUDE_TIMEOUT_MS, dir, safe2, jobFile, answerFile, readJob, writeJob, readAnswer;
|
|
6716
|
+
var ROUTER_MODEL, INDEX_TTL_MS, CLAUDE_TIMEOUT_MS, dir, safe2, jobFile, answerFile, readJob, writeJob, readAnswer, emptyTally, localDay, readStats;
|
|
6680
6717
|
var init_route = __esm({
|
|
6681
6718
|
"src/v2/route.ts"() {
|
|
6682
6719
|
"use strict";
|
|
@@ -6696,6 +6733,9 @@ var init_route = __esm({
|
|
|
6696
6733
|
readJob = (v2, session) => readJson2(jobFile(v2, session));
|
|
6697
6734
|
writeJob = (v2, session, job) => writeJsonAtomic(jobFile(v2, session), job);
|
|
6698
6735
|
readAnswer = (v2, session, seq) => readJson2(answerFile(v2, session, seq));
|
|
6736
|
+
emptyTally = () => ({ calls: 0, errors: 0, costUsd: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0, msTotal: 0 });
|
|
6737
|
+
localDay = (d = /* @__PURE__ */ new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
6738
|
+
readStats = (v2) => readJson2(path11.join(dir(v2), "stats.json"));
|
|
6699
6739
|
}
|
|
6700
6740
|
});
|
|
6701
6741
|
|
|
@@ -7324,7 +7364,7 @@ async function routedText(v2, ctx, session) {
|
|
|
7324
7364
|
const rules = (await Promise.all(fresh(fallback ? [] : answer.rules ?? []).filter((id) => id !== doc?.id).map(find))).filter((s) => !!s);
|
|
7325
7365
|
const hints = fresh(fallback ? job.dense?.hint ?? [] : answer.hint ?? []).filter((id) => id !== doc?.id && !session.hinted?.includes(id));
|
|
7326
7366
|
const hintTitles = hints.map((id) => view.sections.find((s) => s.id === id)?.title).filter((t) => !!t);
|
|
7327
|
-
v2.log("v2-router-delivered", { session: session.sessionId, seq: job.seq, tool: ctx.input.tool_name, afterMs: Date.now() - Date.parse(job.at), serve: doc?.id ?? null, rules: rules.map((r) => r.id), hint: hints, fallback, ...answer.error ? { error: answer.error } : {} });
|
|
7367
|
+
v2.log("v2-router-delivered", { session: session.sessionId, seq: job.seq, tool: ctx.input.tool_name, afterMs: Date.now() - Date.parse(job.at), serve: doc?.id ?? null, rules: rules.map((r) => r.id), hint: hints, fallback, dense: job.dense?.serve ?? null, ...answer.error ? { error: answer.error } : {} });
|
|
7328
7368
|
const ids = [...doc ? [doc.id] : [], ...rules.map((r) => r.id)];
|
|
7329
7369
|
if (!ids.length && !hintTitles.length) return "";
|
|
7330
7370
|
v2.updateSession(session.sessionId, ctx.cwd, (s) => {
|
|
@@ -6,9 +6,14 @@
|
|
|
6
6
|
// checkpoints, reviews waiting. This mod draws it:
|
|
7
7
|
//
|
|
8
8
|
// above the prompt one line: the latest document served and why, then the session's counts
|
|
9
|
-
//
|
|
9
|
+
// (and, with the claude-cli recall router, whether its cache is warm and today's cost)
|
|
10
|
+
// /bunshin a pane: everything served, the lessons, recent events, the router's calls and
|
|
11
|
+
// cost; Learn now, Sync, Review
|
|
10
12
|
// toasts library updates, lessons learned, reviews waiting, the /learn nudge
|
|
11
13
|
//
|
|
14
|
+
// The claude-cli router (packages/cli/src/v2/route.ts) keeps its calls, tokens and cost at API
|
|
15
|
+
// prices in .bunshin/v2/router/stats.json; the index stays cached for an hour after each call.
|
|
16
|
+
//
|
|
12
17
|
// While it runs it writes a heartbeat beside the feed (<session>.mod.json); the CLI then stops
|
|
13
18
|
// printing its plain one-line fallback (systemMessage), so nothing is said twice.
|
|
14
19
|
|
|
@@ -16,6 +21,9 @@ const FEED = { plugin: "bunshin-ui", key: "feed" };
|
|
|
16
21
|
const WHERE = { plugin: "bunshin-ui", key: "where" };
|
|
17
22
|
const SEEN = { plugin: "bunshin-ui", key: "seen" };
|
|
18
23
|
const PANEL = { plugin: "bunshin-ui", key: "panel" };
|
|
24
|
+
const ROUTER = { plugin: "bunshin-ui", key: "router" };
|
|
25
|
+
/** Claude Code caches the router's index for an hour; each call that reads it starts the hour again. */
|
|
26
|
+
const CACHE_TTL_MS = 60 * 60_000;
|
|
19
27
|
|
|
20
28
|
const PANE_ID = "bunshin";
|
|
21
29
|
const POLL_MS = 15_000;
|
|
@@ -90,16 +98,18 @@ export function register(on) {
|
|
|
90
98
|
if (e.props.hasSurvey) return next(e);
|
|
91
99
|
const { value: feed } = await $.state.get(FEED);
|
|
92
100
|
if (!feed) return next(e);
|
|
101
|
+
const { value: router = null } = await $.state.get(ROUTER);
|
|
93
102
|
const { Box, Text } = $.ui.resolve(e);
|
|
94
|
-
return band(Box, Text, feed, e.props.bodyColumns);
|
|
103
|
+
return band(Box, Text, feed, e.props.bodyColumns, router);
|
|
95
104
|
});
|
|
96
105
|
|
|
97
106
|
on("ui.render", { component: "Pane" }, async ($, e, next) => {
|
|
98
107
|
if (e.requestId !== PANE_ID || e.surface === "mobile") return next(e);
|
|
99
108
|
const { value: feed } = await $.state.get(FEED);
|
|
100
109
|
const { value: panel = { view: "session", busy: "", output: "" } } = await $.state.get(PANEL);
|
|
110
|
+
const { value: router = null } = await $.state.get(ROUTER);
|
|
101
111
|
const { Box, Text, Button } = $.ui.resolve(e);
|
|
102
|
-
return pane({ Box, Text, Button }, feed, panel, e.props.bodyColumns, e.props.scroll?.bodyRows ?? 30);
|
|
112
|
+
return pane({ Box, Text, Button }, feed, panel, e.props.bodyColumns, e.props.scroll?.bodyRows ?? 30, router);
|
|
103
113
|
});
|
|
104
114
|
}
|
|
105
115
|
|
|
@@ -149,6 +159,7 @@ async function refresh($, { first = false } = {}) {
|
|
|
149
159
|
feed = null;
|
|
150
160
|
}
|
|
151
161
|
const now = await $.clock.now();
|
|
162
|
+
await refreshRouter($, where, now);
|
|
152
163
|
if (now - lastBeat >= BEAT_MS) {
|
|
153
164
|
lastBeat = now;
|
|
154
165
|
await $.fs.write(`${where.store}/ui/${safe(where.sessionId)}.mod.json`, JSON.stringify({ at: new Date(now).toISOString(), mod: "bunshin-ui" })).catch(() => undefined);
|
|
@@ -176,6 +187,48 @@ async function refresh($, { first = false } = {}) {
|
|
|
176
187
|
}
|
|
177
188
|
}
|
|
178
189
|
|
|
190
|
+
/** The local date, YYYY-MM-DD, as the CLI writes it. */
|
|
191
|
+
const localDay = (ms) => {
|
|
192
|
+
const d = new Date(ms);
|
|
193
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* What the router's stats say now: is the cache warm (the latest call read or wrote it under an
|
|
198
|
+
* hour ago) and for how much longer, and today's and this session's calls and cost. Null when the
|
|
199
|
+
* claude-cli router has not run. Written to state only when it reads differently (at most a minute).
|
|
200
|
+
*/
|
|
201
|
+
function routerView(stats, sessionId, now) {
|
|
202
|
+
if (!stats || stats.v !== 1) return null;
|
|
203
|
+
const fresh = stats.day === localDay(now);
|
|
204
|
+
const tally = (t) => (t && fresh ? { calls: t.calls, errors: t.errors, costUsd: t.costUsd, avgMs: t.calls ? Math.round(t.msTotal / t.calls) : 0 } : { calls: 0, errors: 0, costUsd: 0, avgMs: 0 });
|
|
205
|
+
const last = stats.last;
|
|
206
|
+
const age = last ? now - Date.parse(last.at) : Number.POSITIVE_INFINITY;
|
|
207
|
+
const cached = last ? last.cacheRead + last.cacheWrite : 0;
|
|
208
|
+
const warm = !!last && cached > 0 && age >= 0 && age < CACHE_TTL_MS;
|
|
209
|
+
return {
|
|
210
|
+
model: stats.model,
|
|
211
|
+
warm,
|
|
212
|
+
minutesLeft: warm ? Math.max(1, Math.ceil((CACHE_TTL_MS - age) / 60_000)) : 0,
|
|
213
|
+
last: last ? { cacheRead: last.cacheRead, tokens: cached + last.input, costUsd: last.costUsd, ms: last.ms, at: last.at } : null,
|
|
214
|
+
today: tally(stats.today),
|
|
215
|
+
session: tally(stats.sessions?.[sessionId]),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function refreshRouter($, where, now) {
|
|
220
|
+
const file = `${where.store}/router/stats.json`;
|
|
221
|
+
let stats = null;
|
|
222
|
+
try {
|
|
223
|
+
if (await $.fs.exists(file)) stats = JSON.parse(await $.fs.read(file));
|
|
224
|
+
} catch {
|
|
225
|
+
stats = null;
|
|
226
|
+
}
|
|
227
|
+
const view = routerView(stats, where.sessionId, now);
|
|
228
|
+
const { value: prev = null } = await $.state.get(ROUTER);
|
|
229
|
+
if (JSON.stringify(prev) !== JSON.stringify(view)) await $.state.set(ROUTER, view);
|
|
230
|
+
}
|
|
231
|
+
|
|
179
232
|
/** Run the CLI for the session (its argv is in the feed, so the runtime copy the hooks use). */
|
|
180
233
|
async function act($, what) {
|
|
181
234
|
const { value: where } = await $.state.get(WHERE);
|
|
@@ -211,6 +264,11 @@ async function showReview($) {
|
|
|
211
264
|
// ── drawing ─────────────────────────────────────────────────────────────────────────────────────
|
|
212
265
|
|
|
213
266
|
const label = (s) => (s.skill ? `${s.skill} skill` : `${s.title}`);
|
|
267
|
+
const usd = (n) => `$${n < 0.1 ? n.toFixed(3) : n.toFixed(2)}`;
|
|
268
|
+
const kTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
269
|
+
const modelName = (m) => String(m ?? "").replace(/^claude-/, "").replace(/-(\d+)-(\d+)$/, " $1.$2").replace(/^./, (c) => c.toUpperCase());
|
|
270
|
+
/** The router in the band's few columns: "cache warm 52m · $0.04 today". */
|
|
271
|
+
const routerShort = (r) => (r ? `${r.warm ? `cache warm ${r.minutesLeft}m` : "cache cold"} · ${usd(r.today.costUsd)} today` : "");
|
|
214
272
|
const clip = (s, n) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s);
|
|
215
273
|
const hhmm = (iso) => String(iso).slice(11, 16);
|
|
216
274
|
|
|
@@ -237,10 +295,12 @@ const shortWhy = (why) =>
|
|
|
237
295
|
* ◆ bunshin complete-ticket skill embedders agree lib@209 · 1 served · 7 to review
|
|
238
296
|
* ◇ bunshin maybe agent-grid · manage-agent-fleet skill lib@209 · 7 to review
|
|
239
297
|
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
298
|
+
* ◆ bunshin complete-ticket skill router lib@249 · 1 served · cache warm 52m · $0.04 today
|
|
299
|
+
*
|
|
300
|
+
* What does not fit is dropped whole, least useful first: the quiet counts, then the reason, then
|
|
301
|
+
* the router's cache and cost (never the reviews waiting, in yellow); only then does the name end in "…".
|
|
242
302
|
*/
|
|
243
|
-
function band(Box, Text, feed, columns) {
|
|
303
|
+
function band(Box, Text, feed, columns, router = null) {
|
|
244
304
|
const latest = feed.served.find((s) => s.how !== "hint");
|
|
245
305
|
const hint = feed.served[0]?.how === "hint" ? feed.served.filter((s) => s.how === "hint" && s.at === feed.served[0].at) : [];
|
|
246
306
|
const served = feed.served.filter((s) => s.how !== "hint").length;
|
|
@@ -250,13 +310,15 @@ function band(Box, Text, feed, columns) {
|
|
|
250
310
|
const why = latest?.why ? ` ${shortWhy(latest.why)}` : "";
|
|
251
311
|
// The cells each piece takes: "◆ bunshin " (11), the name, the reason; the counts plus a 2-cell gap.
|
|
252
312
|
const room = columns - 2;
|
|
253
|
-
const
|
|
254
|
-
|
|
313
|
+
const cache = routerShort(router);
|
|
314
|
+
const fits = (withWhy, withQuiet, withCache) => {
|
|
315
|
+
const right = [withQuiet ? quiet : "", withCache ? cache : "", review].filter(Boolean).join(" · ");
|
|
255
316
|
return 11 + name.length + (withWhy ? why.length : 0) + (right ? right.length + 2 : 0) <= room;
|
|
256
317
|
};
|
|
257
|
-
// Keep, in order: the name, the reviews waiting, the reason, the quiet counts.
|
|
258
|
-
const
|
|
259
|
-
const
|
|
318
|
+
// Keep, in order: the name, the reviews waiting, the router's cache and cost, the reason, the quiet counts.
|
|
319
|
+
const showCache = !!cache && fits(false, false, true);
|
|
320
|
+
const showWhy = !!why && fits(true, false, showCache);
|
|
321
|
+
const showQuiet = !!quiet && fits(showWhy, true, showCache);
|
|
260
322
|
|
|
261
323
|
const left = [];
|
|
262
324
|
if (latest) {
|
|
@@ -273,6 +335,11 @@ function band(Box, Text, feed, columns) {
|
|
|
273
335
|
}
|
|
274
336
|
const right = [];
|
|
275
337
|
if (showQuiet) right.push(Text({ dimColor: true, children: quiet }));
|
|
338
|
+
if (showCache) {
|
|
339
|
+
if (right.length) right.push(Text({ dimColor: true, children: " · " }));
|
|
340
|
+
right.push(router.warm ? Text({ color: "green", children: `cache warm ${router.minutesLeft}m` }) : Text({ dimColor: true, children: "cache cold" }));
|
|
341
|
+
right.push(Text({ dimColor: true, children: ` · ${usd(router.today.costUsd)} today` }));
|
|
342
|
+
}
|
|
276
343
|
if (review) {
|
|
277
344
|
if (right.length) right.push(Text({ dimColor: true, children: " · " }));
|
|
278
345
|
right.push(Text({ color: "yellow", children: review }));
|
|
@@ -300,7 +367,26 @@ function heading(Text, text) {
|
|
|
300
367
|
return Text({ bold: true, color: BRAND, children: text });
|
|
301
368
|
}
|
|
302
369
|
|
|
303
|
-
|
|
370
|
+
/** The pane's router section: the cache, then today's and this session's calls and cost. */
|
|
371
|
+
function routerLines(Text, r, width) {
|
|
372
|
+
const out = [Text({ children: " " }), heading(Text, `Recall router · ${modelName(r.model)} on your Claude plan`)];
|
|
373
|
+
const hit = r.last && r.last.tokens ? Math.round((100 * r.last.cacheRead) / r.last.tokens) : 0;
|
|
374
|
+
out.push(
|
|
375
|
+
Text({
|
|
376
|
+
wrap: "truncate-end",
|
|
377
|
+
children: r.warm
|
|
378
|
+
? [Text({ children: " cache " }), Text({ color: "green", children: `warm, ${r.minutesLeft} min left` }), Text({ dimColor: true, children: r.last ? ` · last call read ${kTokens(r.last.cacheRead)} of ${kTokens(r.last.tokens)} tokens from cache (${hit}%)` : "" })]
|
|
379
|
+
: [Text({ children: " cache " }), Text({ color: "yellow", children: "cold" }), Text({ dimColor: true, children: " · the next call writes the index again (it stays cached an hour after each call)" })],
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
const t = r.today;
|
|
383
|
+
const each = t.calls ? ` · ≈${usd(t.costUsd / t.calls)} a call · ${(t.avgMs / 1000).toFixed(1)} s on average` : "";
|
|
384
|
+
out.push(Text({ children: clip(` today ${t.calls} call${t.calls === 1 ? "" : "s"} · ${usd(t.costUsd)} at API prices${each}${t.errors ? ` · ${t.errors} failed` : ""}`, width), wrap: "truncate-end" }));
|
|
385
|
+
out.push(Text({ dimColor: true, children: clip(` this session ${r.session.calls} call${r.session.calls === 1 ? "" : "s"} · ${usd(r.session.costUsd)}`, width), wrap: "truncate-end" }));
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function pane({ Box, Text, Button }, feed, panel, columns, rows, router = null) {
|
|
304
390
|
const width = Math.max(30, columns - 2);
|
|
305
391
|
const body = [];
|
|
306
392
|
if (!feed) {
|
|
@@ -333,6 +419,7 @@ function pane({ Box, Text, Button }, feed, panel, columns, rows) {
|
|
|
333
419
|
body.push(heading(Text, "Learned from this session"));
|
|
334
420
|
if (!feed.lessons.length) body.push(Text({ dimColor: true, children: feed.reflectedAt ? " nothing new" : " not reflected on yet (Learn now, or at session end)" }));
|
|
335
421
|
for (const l of feed.lessons.slice(-6)) body.push(Text({ children: clip(` ${l.status.padEnd(10)} ${l.target}: ${l.summary}`, width), wrap: "truncate-end" }));
|
|
422
|
+
if (router) body.push(...routerLines(Text, router, width));
|
|
336
423
|
const recent = feed.events.filter((x) => x.kind !== "served" && x.kind !== "hint" && x.kind !== "push").slice(-4);
|
|
337
424
|
if (recent.length) {
|
|
338
425
|
body.push(Text({ children: " " }));
|
|
@@ -18,6 +18,16 @@ export type BunshinUiFeed = {
|
|
|
18
18
|
};
|
|
19
19
|
export type BunshinUiWhere = { store: string; project: string; sessionId: string };
|
|
20
20
|
export type BunshinUiPanel = { view: "session" | "review"; busy: string; output: string };
|
|
21
|
+
export type BunshinUiRouterTally = { calls: number; errors: number; costUsd: number; avgMs: number };
|
|
22
|
+
/** The claude-cli recall router as drawn: its cache and its cost (router/stats.json, read). */
|
|
23
|
+
export type BunshinUiRouter = {
|
|
24
|
+
model: string;
|
|
25
|
+
warm: boolean;
|
|
26
|
+
minutesLeft: number;
|
|
27
|
+
last: { cacheRead: number; tokens: number; costUsd: number; ms: number; at: string } | null;
|
|
28
|
+
today: BunshinUiRouterTally;
|
|
29
|
+
session: BunshinUiRouterTally;
|
|
30
|
+
};
|
|
21
31
|
|
|
22
32
|
declare module "claude-code" {
|
|
23
33
|
interface PluginState {
|
|
@@ -26,6 +36,7 @@ declare module "claude-code" {
|
|
|
26
36
|
where: BunshinUiWhere | null;
|
|
27
37
|
seen: number;
|
|
28
38
|
panel: BunshinUiPanel;
|
|
39
|
+
router: BunshinUiRouter | null;
|
|
29
40
|
};
|
|
30
41
|
}
|
|
31
42
|
}
|