pi-magi-theme 0.1.2 → 0.1.4
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/README.md +41 -13
- package/extensions/magi/index.ts +730 -282
- package/package.json +1 -1
package/extensions/magi/index.ts
CHANGED
|
@@ -4,22 +4,24 @@
|
|
|
4
4
|
* One public-domain mythology, every symbol bound to a real function:
|
|
5
5
|
* - the TREE OF LIFE shows where the agent is: the upper triad while it thinks, the light descending
|
|
6
6
|
* to Malkuth while it answers, ascending while the model is loaded into VRAM, at rest in Malkuth when idle
|
|
7
|
-
* - the three MAGI (MELCHIOR / BALTHASAR / CASPAR)
|
|
8
|
-
*
|
|
9
|
-
* - the GOLEM acts: it is animated by EMET ("truth") while tools run
|
|
10
|
-
* and EMET becomes MET ("death"). SYNC is the
|
|
7
|
+
* - the three MAGI (MELCHIOR / BALTHASAR / CASPAR) flicker while the agent works, showing what it is doing,
|
|
8
|
+
* and form the /magi council: three real models voting (on a question, or on the pending git diff)
|
|
9
|
+
* - the GOLEM acts: it is animated by EMET ("truth") while tools run, showing the file or command it works on;
|
|
10
|
+
* a failing tool erases the aleph and EMET becomes MET ("death"). SYNC is the tool success rate
|
|
11
11
|
* - CHESED (mercy) and GEBURAH (severity) count successful and failed tools
|
|
12
|
-
* - the SEVEN SEALS measure the context window; compaction
|
|
13
|
-
* - llama-swap telemetry: VRAM, GPU load/temp/power, RAM, server-side tok/s, prompt tok/s, cache hits
|
|
14
|
-
* - /magi config → assign a model to each MAGI
|
|
12
|
+
* - the SEVEN SEALS measure the context window; the sixth warns before compaction, the seventh opens when it runs
|
|
13
|
+
* - llama-swap telemetry: VRAM, GPU load/temp/power, energy used, RAM, server-side tok/s, prompt tok/s, cache hits
|
|
14
|
+
* - /magi config → assign a model to each MAGI; /magi-ui compact|status → smaller panel, llama-swap report
|
|
15
15
|
*
|
|
16
16
|
* Artwork is original; the symbolism is public domain.
|
|
17
17
|
* Use with the theme ../../themes/magi.json
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
20
21
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
21
22
|
import { homedir } from "node:os";
|
|
22
23
|
import { join } from "node:path";
|
|
24
|
+
import { promisify } from "node:util";
|
|
23
25
|
import type { AssistantMessage, Model } from "@earendil-works/pi-ai";
|
|
24
26
|
import { completeSimple } from "@earendil-works/pi-ai";
|
|
25
27
|
import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
@@ -92,20 +94,20 @@ function renderTreeOfLife(th: Theme): string[] {
|
|
|
92
94
|
}
|
|
93
95
|
// ring of radius r, cleared inside and with a small gap around it so paths stop short of the circle;
|
|
94
96
|
// dashed rings keep every other 30° arc
|
|
95
|
-
const ring = (
|
|
97
|
+
const ring = (c: { x: number; y: number }, r: number, v: number, dashed: boolean) => {
|
|
96
98
|
for (let y = -r - 2; y <= r + 2; y++) {
|
|
97
99
|
for (let x = -r - 2; x <= r + 2; x++) {
|
|
98
100
|
const d = Math.hypot(x, y);
|
|
99
101
|
if (d > r + 1.5) continue;
|
|
100
102
|
const onRing = d > r - 0.7 && d <= r + 0.5;
|
|
101
103
|
const arc = Math.floor((Math.atan2(y, x) + Math.PI) / (Math.PI / 6)) % 2 === 0;
|
|
102
|
-
set(
|
|
104
|
+
set(c.x + x, c.y + y, onRing && (!dashed || arc) ? v : 0);
|
|
103
105
|
}
|
|
104
106
|
}
|
|
105
107
|
};
|
|
106
|
-
ring(DAAT
|
|
108
|
+
ring(DAAT, 3, DAAT_PX, true);
|
|
107
109
|
for (const s of SEPHIROT) {
|
|
108
|
-
ring(s
|
|
110
|
+
ring(s, 3.5, NODE, false);
|
|
109
111
|
set(s.x, s.y, NODE);
|
|
110
112
|
}
|
|
111
113
|
|
|
@@ -178,7 +180,6 @@ const GOLEM_FALLEN = [
|
|
|
178
180
|
|
|
179
181
|
const MAGI_UNITS = ["MELCHIOR", "BALTHASAR", "CASPAR"] as const;
|
|
180
182
|
type MagiUnit = (typeof MAGI_UNITS)[number];
|
|
181
|
-
const MAGI_TASKS = ["ANALYSIS", "SYNTHESIS", "VERIFY"] as const;
|
|
182
183
|
|
|
183
184
|
function pulseFrames(theme: Theme): string[] {
|
|
184
185
|
return [
|
|
@@ -191,27 +192,52 @@ function pulseFrames(theme: Theme): string[] {
|
|
|
191
192
|
];
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
/** Pseudo-random on/off per unit, changing every `hold` frames: the MAGI flicker while they work. */
|
|
196
|
+
function flicker(frame: number, unit: number, hold = 2): boolean {
|
|
197
|
+
const x = Math.imul(Math.floor(frame / hold) + 1, 2654435761) ^ Math.imul(unit + 1, 40503);
|
|
198
|
+
return (x >>> 0) % 100 < 55;
|
|
199
|
+
}
|
|
200
|
+
|
|
194
201
|
/* ─────────────────────────────────────────────────────── live state ── */
|
|
195
202
|
|
|
196
203
|
type Phase = "idle" | "thinking" | "responding" | "tool";
|
|
204
|
+
type Vote = "APPROVE" | "CONDITIONAL" | "REJECT";
|
|
197
205
|
|
|
198
206
|
const state = {
|
|
199
207
|
phase: "idle" as Phase,
|
|
200
208
|
phaseSince: Date.now(),
|
|
201
209
|
toolName: "",
|
|
210
|
+
toolTarget: "", // file or command the current tool works on
|
|
202
211
|
turns: 0,
|
|
203
212
|
toolOk: 0, // CHESED
|
|
204
213
|
toolFail: 0, // GEBURAH
|
|
205
214
|
lastFailAt: 0,
|
|
206
215
|
lastFailTool: "",
|
|
216
|
+
lastFailTarget: "",
|
|
207
217
|
runStart: 0,
|
|
208
218
|
lastRunMs: 0,
|
|
209
219
|
compacting: false,
|
|
210
220
|
compactSince: 0,
|
|
211
221
|
compactBy: "", // who opens the seals: "smart-compact" (pi-smart-compact package) or "pi native"
|
|
212
222
|
rebornAt: 0,
|
|
223
|
+
hasSmartCompact: false,
|
|
224
|
+
lastCouncil: undefined as { verdict: Vote | null; tally: number; question: string } | undefined,
|
|
213
225
|
};
|
|
214
226
|
|
|
227
|
+
/** Panel preferences, persisted under "ui" in ~/.pi/agent/magi.json. */
|
|
228
|
+
type Currency = "EUR" | "USD";
|
|
229
|
+
|
|
230
|
+
const ui = {
|
|
231
|
+
compact: false,
|
|
232
|
+
kwhPrice: undefined as number | undefined, // price per kWh, for the COST row (/magi-ui config)
|
|
233
|
+
currency: "EUR" as Currency,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
function fmtMoney(value: number): string {
|
|
237
|
+
const digits = value < 1 ? 3 : 2;
|
|
238
|
+
return ui.currency === "USD" ? `$${value.toFixed(digits)}` : `${value.toFixed(digits)} €`;
|
|
239
|
+
}
|
|
240
|
+
|
|
215
241
|
function setPhase(p: Phase): void {
|
|
216
242
|
if (state.phase === p) return;
|
|
217
243
|
state.phase = p;
|
|
@@ -221,6 +247,8 @@ function setPhase(p: Phase): void {
|
|
|
221
247
|
const ANIM_STEP_MS = 220;
|
|
222
248
|
const FAIL_FLASH_MS = 2500;
|
|
223
249
|
const REBIRTH_MS = 6000;
|
|
250
|
+
const DONE_TITLE_AFTER_MS = 30_000;
|
|
251
|
+
const SIXTH_SEAL_PERCENT = (6 / 7) * 100;
|
|
224
252
|
|
|
225
253
|
/** Golem obedience: share of tool calls that succeeded (null before the first tool). */
|
|
226
254
|
function syncPercent(): number | null {
|
|
@@ -240,6 +268,17 @@ function animating(now = Date.now()): boolean {
|
|
|
240
268
|
);
|
|
241
269
|
}
|
|
242
270
|
|
|
271
|
+
/** What a tool works on, from its arguments: a path, a command, a pattern, or the first short string. */
|
|
272
|
+
function toolTarget(args: unknown): string {
|
|
273
|
+
if (!args || typeof args !== "object") return "";
|
|
274
|
+
const a = args as Record<string, unknown>;
|
|
275
|
+
for (const key of ["path", "file_path", "filePath", "command", "cmd", "pattern", "query", "url"]) {
|
|
276
|
+
if (typeof a[key] === "string" && a[key]) return String(a[key]).replace(/\s+/g, " ").trim();
|
|
277
|
+
}
|
|
278
|
+
const first = Object.values(a).find((v) => typeof v === "string" && v.length > 0 && v.length < 200);
|
|
279
|
+
return typeof first === "string" ? first.replace(/\s+/g, " ").trim() : "";
|
|
280
|
+
}
|
|
281
|
+
|
|
243
282
|
/** Performance of the current stream / last message. */
|
|
244
283
|
const perf = {
|
|
245
284
|
start: 0, // message_start
|
|
@@ -270,6 +309,8 @@ const sessionStart = Date.now();
|
|
|
270
309
|
|
|
271
310
|
let liveCtx: ExtensionContext | undefined;
|
|
272
311
|
|
|
312
|
+
/* ── token totals: counted once per message instead of rescanning the whole session ── */
|
|
313
|
+
|
|
273
314
|
interface TokenStats {
|
|
274
315
|
input: number;
|
|
275
316
|
output: number;
|
|
@@ -277,24 +318,26 @@ interface TokenStats {
|
|
|
277
318
|
cost: number;
|
|
278
319
|
}
|
|
279
320
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
function
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
321
|
+
const tokens: TokenStats = { input: 0, output: 0, cacheRead: 0, cost: 0 };
|
|
322
|
+
|
|
323
|
+
function addUsage(m: AssistantMessage): void {
|
|
324
|
+
tokens.input += m.usage?.input ?? 0;
|
|
325
|
+
tokens.output += m.usage?.output ?? 0;
|
|
326
|
+
tokens.cacheRead += m.usage?.cacheRead ?? 0;
|
|
327
|
+
tokens.cost += m.usage?.cost?.total ?? 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Full recount: on session start and when switching branch. Also restores the last council verdict. */
|
|
331
|
+
function recountSession(ctx: ExtensionContext): void {
|
|
332
|
+
Object.assign(tokens, { input: 0, output: 0, cacheRead: 0, cost: 0 });
|
|
333
|
+
state.lastCouncil = undefined;
|
|
334
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
335
|
+
if (entry.type === "message" && entry.message.role === "assistant") addUsage(entry.message as AssistantMessage);
|
|
336
|
+
if (entry.type === "custom" && entry.customType === "magi-verdict") {
|
|
337
|
+
const d = entry.data as { verdict?: Vote | null; tally?: number; question?: string } | undefined;
|
|
338
|
+
if (d && Array.isArray((d as any).opinions)) state.lastCouncil = { verdict: d.verdict ?? null, tally: d.tally ?? 0, question: d.question ?? "" };
|
|
294
339
|
}
|
|
295
340
|
}
|
|
296
|
-
statsCache = { at: now, value };
|
|
297
|
-
return value;
|
|
298
341
|
}
|
|
299
342
|
|
|
300
343
|
function fmtTokens(n: number): string {
|
|
@@ -322,11 +365,17 @@ function bar(theme: Theme, filled: number, total: number, tone: ThemeColor): str
|
|
|
322
365
|
return theme.fg(tone, "▓".repeat(f)) + theme.fg("dim", "░".repeat(total - f));
|
|
323
366
|
}
|
|
324
367
|
|
|
325
|
-
/** The seven seals of the context window: one breaks every 1/7 of it. */
|
|
326
|
-
function renderSeals(theme: Theme, percent: number): string {
|
|
368
|
+
/** The seven seals of the context window: one breaks every 1/7 of it; the sixth blinks as a warning. */
|
|
369
|
+
function renderSeals(theme: Theme, percent: number, now = Date.now()): string {
|
|
327
370
|
const broken = Math.min(7, Math.floor((percent / 100) * 7));
|
|
328
371
|
const tone = usageTone(percent);
|
|
329
|
-
|
|
372
|
+
let out = "";
|
|
373
|
+
for (let i = 0; i < 7; i++) {
|
|
374
|
+
if (i >= broken) out += theme.fg("dim", "○");
|
|
375
|
+
else if (broken === 6 && i === 5) out += theme.fg(Math.floor(now / 500) % 2 ? "warning" : "error", "◉");
|
|
376
|
+
else out += theme.fg(tone, "◉");
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
330
379
|
}
|
|
331
380
|
|
|
332
381
|
/* ──────────────────────────────────────────────────── MAGI triangle ── */
|
|
@@ -396,14 +445,15 @@ function buildHeader(theme: Theme) {
|
|
|
396
445
|
return lines.map((l) => truncateToWidth(l, width));
|
|
397
446
|
}
|
|
398
447
|
|
|
399
|
-
// Wide layout: tree left, wordmark right.
|
|
448
|
+
// Wide layout: tree left, wordmark right (rows chosen to center the text block on the tree).
|
|
449
|
+
const [wordAt, triadAt] = [5, 12];
|
|
400
450
|
const lines = [""];
|
|
401
451
|
for (let i = 0; i < tree.length; i++) {
|
|
402
452
|
let right = "";
|
|
403
|
-
if (i >=
|
|
404
|
-
else if (i ===
|
|
405
|
-
else if (i ===
|
|
406
|
-
else if (i ===
|
|
453
|
+
if (i >= wordAt && i < wordAt + MAGI_WORD.length) right = orange(MAGI_WORD[i - wordAt]!);
|
|
454
|
+
else if (i === triadAt) right = dim("├─ ") + triad + dim(" ─┤");
|
|
455
|
+
else if (i === triadAt + 1) right = muted(subtitle);
|
|
456
|
+
else if (i === triadAt + 2) right = dim(lore);
|
|
407
457
|
lines.push(truncateToWidth(tree[i]! + " " + right, width));
|
|
408
458
|
}
|
|
409
459
|
lines.push("");
|
|
@@ -424,13 +474,14 @@ interface GpuStat {
|
|
|
424
474
|
power: number;
|
|
425
475
|
}
|
|
426
476
|
|
|
427
|
-
type SwapState = "off" | "checking" | "loading" | "ready" | "error";
|
|
477
|
+
type SwapState = "off" | "checking" | "loading" | "ready" | "asleep" | "error";
|
|
428
478
|
|
|
429
479
|
/** Live state of the llama-swap server behind the session model (only when the provider is "llama-swap"). */
|
|
430
480
|
const swap = {
|
|
431
481
|
base: "", // e.g. http://host:9292
|
|
432
482
|
headers: {} as Record<string, string>,
|
|
433
|
-
modelId: "",
|
|
483
|
+
modelId: "", // the id pi uses (may be an alias)
|
|
484
|
+
realId: "", // the model llama-swap actually runs for that id
|
|
434
485
|
state: "off" as SwapState,
|
|
435
486
|
since: 0, // when the current state started
|
|
436
487
|
loadMs: 0, // how long the last real load took
|
|
@@ -442,6 +493,9 @@ const swap = {
|
|
|
442
493
|
srvPps: 0, // server-measured prompt processing tok/s
|
|
443
494
|
cacheTokens: 0,
|
|
444
495
|
inputTokens: 0,
|
|
496
|
+
energyWh: 0, // GPU energy since the session started
|
|
497
|
+
lastSampleAt: 0,
|
|
498
|
+
lastWatts: 0,
|
|
445
499
|
};
|
|
446
500
|
|
|
447
501
|
function setSwapState(s: SwapState, error = ""): void {
|
|
@@ -478,20 +532,46 @@ function parseSwapMetrics(text: string): void {
|
|
|
478
532
|
swap.gpus = [...gpus.entries()].sort(([a], [b]) => Number(a) - Number(b)).map(([, g]) => g);
|
|
479
533
|
}
|
|
480
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Integrates GPU power over time into Wh.
|
|
537
|
+
* ponytail: trapezoid over the 3–30s poll samples, GPUs only; gaps over 60s are clamped.
|
|
538
|
+
*/
|
|
539
|
+
function sampleEnergy(now = Date.now()): void {
|
|
540
|
+
const watts = swap.gpus.reduce((sum, g) => sum + g.power, 0);
|
|
541
|
+
if (swap.lastSampleAt) {
|
|
542
|
+
const dtH = Math.min(60_000, now - swap.lastSampleAt) / 3_600_000;
|
|
543
|
+
swap.energyWh += ((swap.lastWatts + watts) / 2) * dtH;
|
|
544
|
+
}
|
|
545
|
+
swap.lastSampleAt = now;
|
|
546
|
+
swap.lastWatts = watts;
|
|
547
|
+
}
|
|
548
|
+
|
|
481
549
|
async function refreshSwapMetrics(): Promise<void> {
|
|
482
550
|
if (!swap.base) return;
|
|
483
551
|
try {
|
|
484
552
|
parseSwapMetrics(await (await swapGet("/metrics")).text());
|
|
553
|
+
sampleEnergy();
|
|
485
554
|
} catch {
|
|
486
555
|
// server unreachable: keep the last values
|
|
487
556
|
}
|
|
488
557
|
}
|
|
489
558
|
|
|
490
|
-
/**
|
|
559
|
+
/** Notices when llama-swap unloaded the session model (e.g. its ttl expired) so the panel can say so. */
|
|
560
|
+
async function refreshSwapRunning(): Promise<void> {
|
|
561
|
+
if (!swap.base || swap.state !== "ready") return;
|
|
562
|
+
try {
|
|
563
|
+
const { running } = (await (await swapGet("/running")).json()) as { running?: { model: string }[] };
|
|
564
|
+
if (!(running ?? []).some((r) => r.model === swap.realId)) setSwapState("asleep");
|
|
565
|
+
} catch {
|
|
566
|
+
// optional
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Server-side token metrics of the most recent request: a single row from /api/metrics/activity. */
|
|
491
571
|
async function refreshSwapActivity(): Promise<void> {
|
|
492
572
|
if (!swap.base) return;
|
|
493
573
|
try {
|
|
494
|
-
const { data } = (await (await swapGet("/api/metrics/activity")).json()) as { data?: any[] };
|
|
574
|
+
const { data } = (await (await swapGet("/api/metrics/activity?limit=1")).json()) as { data?: any[] };
|
|
495
575
|
const t = data?.[0]?.tokens;
|
|
496
576
|
if (!t) return;
|
|
497
577
|
if (t.tokens_per_second > 0) swap.srvTps = t.tokens_per_second;
|
|
@@ -515,6 +595,7 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
|
|
|
515
595
|
}
|
|
516
596
|
const id = model.id;
|
|
517
597
|
swap.modelId = id;
|
|
598
|
+
swap.realId = id;
|
|
518
599
|
setSwapState("checking");
|
|
519
600
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
520
601
|
swap.base = model.baseUrl.replace(/\/v1\/?$/, "");
|
|
@@ -524,6 +605,14 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
|
|
|
524
605
|
};
|
|
525
606
|
void refreshSwapMetrics();
|
|
526
607
|
void refreshSwapActivity();
|
|
608
|
+
// an alias (e.g. "… - Instruct") runs another model: /running lists that one
|
|
609
|
+
try {
|
|
610
|
+
const { data } = (await (await swapGet("/v1/models")).json()) as { data?: any[] };
|
|
611
|
+
const entry = data?.find((m) => m.id === id);
|
|
612
|
+
if (entry?.meta?.llamaswap?.type === "alias" && entry.meta.llamaswap.modelID) swap.realId = entry.meta.llamaswap.modelID;
|
|
613
|
+
} catch {
|
|
614
|
+
// keep the id itself
|
|
615
|
+
}
|
|
527
616
|
|
|
528
617
|
// no answer within 400ms → the model isn't in VRAM and is being loaded
|
|
529
618
|
const slow = setTimeout(() => {
|
|
@@ -544,18 +633,87 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
|
|
|
544
633
|
}
|
|
545
634
|
}
|
|
546
635
|
|
|
636
|
+
/* ── /magi-ui status: a report built from the last requests llama-swap recorded ── */
|
|
637
|
+
|
|
638
|
+
interface ActivityRow {
|
|
639
|
+
timestamp: string;
|
|
640
|
+
model: string;
|
|
641
|
+
resp_status_code: number;
|
|
642
|
+
duration_ms: number;
|
|
643
|
+
tokens?: {
|
|
644
|
+
cache_tokens?: number;
|
|
645
|
+
input_tokens?: number;
|
|
646
|
+
output_tokens?: number;
|
|
647
|
+
prompt_per_second?: number;
|
|
648
|
+
tokens_per_second?: number;
|
|
649
|
+
draft_tokens?: number;
|
|
650
|
+
draft_acc_tokens?: number;
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const ACTIVITY_REPORT_ROWS = 100;
|
|
655
|
+
|
|
656
|
+
function activityReport(th: Theme, rows: ActivityRow[], total: number, now = Date.now()): string[] {
|
|
657
|
+
const dim = (s: string) => th.fg("dim", s);
|
|
658
|
+
const mean = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
|
|
659
|
+
const ago = (iso: string) => {
|
|
660
|
+
const m = Math.max(0, Math.round((now - Date.parse(iso)) / 60000));
|
|
661
|
+
return m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ${m % 60}m ago`;
|
|
662
|
+
};
|
|
663
|
+
const out = [th.fg("accent", th.bold("LLAMA-SWAP STATUS")) + dim(` · last ${rows.length} of ${total} requests`)];
|
|
664
|
+
const byModel = new Map<string, ActivityRow[]>();
|
|
665
|
+
for (const r of rows) byModel.set(r.model, [...(byModel.get(r.model) ?? []), r]);
|
|
666
|
+
|
|
667
|
+
for (const [model, rs] of byModel) {
|
|
668
|
+
const t = rs.map((r) => r.tokens ?? {});
|
|
669
|
+
const sum = (k: keyof NonNullable<ActivityRow["tokens"]>) => t.reduce((a, x) => a + Math.max(0, x[k] ?? 0), 0);
|
|
670
|
+
const errors = rs.filter((r) => r.resp_status_code >= 400).length;
|
|
671
|
+
const gen = mean(t.map((x) => x.tokens_per_second ?? 0).filter((v) => v > 0));
|
|
672
|
+
const prompt = mean(t.map((x) => x.prompt_per_second ?? 0).filter((v) => v > 0));
|
|
673
|
+
const cached = sum("cache_tokens");
|
|
674
|
+
const input = sum("input_tokens");
|
|
675
|
+
const draft = sum("draft_tokens");
|
|
676
|
+
const accepted = sum("draft_acc_tokens");
|
|
677
|
+
const durations = rs.map((r) => r.duration_ms).sort((a, b) => a - b);
|
|
678
|
+
const p95 = durations[Math.min(durations.length - 1, Math.floor(durations.length * 0.95))] ?? 0;
|
|
679
|
+
out.push("", th.fg("text", th.bold(model)));
|
|
680
|
+
out.push(dim(" requests ") + th.fg("text", String(rs.length)) + dim(" · errors ") + th.fg(errors ? "error" : "success", String(errors)) + dim(` · last ${ago(rs[0]!.timestamp)}`));
|
|
681
|
+
out.push(
|
|
682
|
+
dim(" speed ") +
|
|
683
|
+
th.fg("success", `gen ${gen.toFixed(1)} tok/s`) +
|
|
684
|
+
dim(" · ") +
|
|
685
|
+
th.fg("text", `prompt ${prompt.toFixed(1)} tok/s`) +
|
|
686
|
+
(draft ? dim(" · draft accepted ") + th.fg("text", `${Math.round((accepted / draft) * 100)}%`) : ""),
|
|
687
|
+
);
|
|
688
|
+
out.push(
|
|
689
|
+
dim(" tokens ") +
|
|
690
|
+
th.fg("text", `in ${fmtTokens(input)} · cached ${fmtTokens(cached)}`) +
|
|
691
|
+
dim(` (hit ${cached + input ? Math.round((cached / (cached + input)) * 100) : 0}%)`) +
|
|
692
|
+
th.fg("text", ` · out ${fmtTokens(sum("output_tokens"))}`),
|
|
693
|
+
);
|
|
694
|
+
out.push(dim(" duration ") + th.fg("text", `avg ${fmtMs(mean(durations))} · p95 ${fmtMs(p95)} · max ${fmtMs(durations.at(-1) ?? 0)}`));
|
|
695
|
+
}
|
|
696
|
+
const slowest = [...rows].sort((a, b) => b.duration_ms - a.duration_ms)[0];
|
|
697
|
+
if (slowest) {
|
|
698
|
+
const st = slowest.tokens ?? {};
|
|
699
|
+
out.push("", dim("slowest ") + th.fg("warning", fmtMs(slowest.duration_ms)) + dim(` · ${slowest.model} · ${ago(slowest.timestamp)} · in ${fmtTokens((st.input_tokens ?? 0) + (st.cache_tokens ?? 0))} out ${fmtTokens(st.output_tokens ?? 0)}`));
|
|
700
|
+
}
|
|
701
|
+
return out;
|
|
702
|
+
}
|
|
703
|
+
|
|
547
704
|
/* ─────────────────────────────────────────────────────── side panel ── */
|
|
548
705
|
|
|
549
706
|
const PANEL_WIDTH = MAGI_DIAGRAM_WIDTH + 2;
|
|
550
707
|
|
|
551
|
-
/** Frames per deliberation cycle while thinking: units light up one by one, then consensus. */
|
|
552
|
-
const CYCLE = 32;
|
|
553
|
-
|
|
554
708
|
class MagiPanel implements Component {
|
|
555
709
|
private frame = 0;
|
|
556
710
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
711
|
+
/** The data rows change slowly: they are rebuilt at most twice a second, only the council animates every frame. */
|
|
712
|
+
private cache: { at: number; inner: number; compact: boolean; lines: string[] } | undefined;
|
|
557
713
|
/** git branch, provided by the footer (the only place pi exposes it). */
|
|
558
714
|
branch?: () => string | null | undefined;
|
|
715
|
+
/** When set (fullscreen column), the panel pads itself down to this many rows. */
|
|
716
|
+
fillHeight?: () => number;
|
|
559
717
|
|
|
560
718
|
constructor(
|
|
561
719
|
private readonly tui: TUI,
|
|
@@ -568,7 +726,9 @@ class MagiPanel implements Component {
|
|
|
568
726
|
}, 125);
|
|
569
727
|
}
|
|
570
728
|
|
|
571
|
-
invalidate(): void {
|
|
729
|
+
invalidate(): void {
|
|
730
|
+
this.cache = undefined;
|
|
731
|
+
}
|
|
572
732
|
|
|
573
733
|
dispose(): void {
|
|
574
734
|
if (this.timer) clearInterval(this.timer);
|
|
@@ -588,12 +748,19 @@ class MagiPanel implements Component {
|
|
|
588
748
|
return b("│") + this.pad(content, inner) + b("│");
|
|
589
749
|
}
|
|
590
750
|
|
|
591
|
-
private
|
|
751
|
+
private labelLine(inner: number, label: string, left: string, right: string, tone: ThemeColor): string {
|
|
592
752
|
const b = (s: string) => this.theme.fg("border", s);
|
|
593
|
-
if (!label) return b("├" + "─".repeat(inner) + "┤");
|
|
594
753
|
const text = ` ${truncateToWidth(label, inner - 4)} `;
|
|
595
754
|
const rest = Math.max(0, inner - visibleWidth(text) - 1);
|
|
596
|
-
return b("
|
|
755
|
+
return b(left + "─") + this.theme.fg(tone, text) + b("─".repeat(rest) + right);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
private sep(inner: number, label: string): string {
|
|
759
|
+
return this.labelLine(inner, label, "├", "┤", "muted");
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private centered(content: string, inner: number): string {
|
|
763
|
+
return this.frameLine(" ".repeat(Math.max(0, Math.floor((inner - visibleWidth(content)) / 2))) + content, inner);
|
|
597
764
|
}
|
|
598
765
|
|
|
599
766
|
private field(label: string, value: string, inner: number, tone: ThemeColor = "text"): string {
|
|
@@ -617,151 +784,113 @@ class MagiPanel implements Component {
|
|
|
617
784
|
});
|
|
618
785
|
}
|
|
619
786
|
|
|
620
|
-
/* ── MAGI
|
|
787
|
+
/* ── top of the panel: the MAGI (flickering while working) or the golem (tools) ── */
|
|
621
788
|
|
|
622
789
|
private councilSection(inner: number): string[] {
|
|
623
790
|
const th = this.theme;
|
|
624
791
|
const f = this.frame;
|
|
625
792
|
const now = Date.now();
|
|
793
|
+
const secs = (t: number) => `${secsSince(t, now)}s`;
|
|
794
|
+
const pulse = animating(now) ? pulseFrames(th)[f % 6]! : th.fg("dim", "◇");
|
|
795
|
+
|
|
796
|
+
const idle = state.phase === "idle";
|
|
797
|
+
const fallen = now - state.lastFailAt < FAIL_FLASH_MS;
|
|
626
798
|
let title: string;
|
|
799
|
+
let titleTone: ThemeColor = "accent";
|
|
627
800
|
let body: string[];
|
|
801
|
+
let status: string;
|
|
628
802
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
const golem = state.phase === "tool" || (idle && now - state.lastFailAt < FAIL_FLASH_MS);
|
|
633
|
-
|
|
634
|
-
if (golem) {
|
|
635
|
-
const fallen = now - state.lastFailAt < FAIL_FLASH_MS;
|
|
636
|
-
title = fallen
|
|
637
|
-
? `GOLEM FELL · MET · ${state.lastFailTool}`
|
|
638
|
-
: `GOLEM · EMET · ${state.toolName || "tool"} ${secsSince(state.phaseSince, now)}s`;
|
|
803
|
+
if (state.phase === "tool" || (idle && fallen)) {
|
|
804
|
+
title = fallen ? `GOLEM FELL · MET · ${state.lastFailTool}` : `GOLEM · ${state.toolName || "tool"} · ${secs(state.phaseSince)}`;
|
|
805
|
+
titleTone = fallen ? "error" : "warning";
|
|
639
806
|
body = this.golemArt(inner);
|
|
807
|
+
const target = (fallen ? state.lastFailTarget || state.lastFailTool : state.toolTarget || state.toolName) || "tool";
|
|
808
|
+
status = `${pulse} ${th.fg(fallen ? "error" : "text", truncateToWidth(target, inner - 4))}`;
|
|
640
809
|
} else {
|
|
641
|
-
let
|
|
642
|
-
let
|
|
643
|
-
let
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
title =
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
hub =
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
} else if (booting) {
|
|
660
|
-
// MAGI boot while llama-swap loads the model into VRAM: units come online one at a time.
|
|
661
|
-
const litCount = Math.floor(f / 6) % 4;
|
|
662
|
-
title = swap.state === "loading" ? `BOOT · LOADING MODEL ${secsSince(swap.since, now)}s` : "BOOT · CHECKING MODEL";
|
|
663
|
-
link = f % 4 < 2 ? "warning" : "dim";
|
|
664
|
-
hub = ["─MAGI─", "━MAGI━"][f % 2]!;
|
|
665
|
-
units = names.map((name, i) =>
|
|
666
|
-
i < litCount
|
|
667
|
-
? { name, status: "ONLINE", tone: "warning", lit: true }
|
|
668
|
-
: { name, status: i === litCount ? "BOOT" : "·····", tone: "warning", lit: i === litCount && f % 2 === 0 },
|
|
669
|
-
);
|
|
670
|
-
} else if (offline) {
|
|
810
|
+
let word: string;
|
|
811
|
+
let tone: ThemeColor;
|
|
812
|
+
let link: ThemeColor = "dim";
|
|
813
|
+
let hub = "─MAGI─";
|
|
814
|
+
let lit = (i: number) => flicker(f, i);
|
|
815
|
+
const breathing = ["─MAGI─", "━MAGI━"][f % 2]!;
|
|
816
|
+
|
|
817
|
+
if (state.compacting) {
|
|
818
|
+
title = `COMPACTING · ${state.compactBy || "context"} · ${secs(state.compactSince)}`;
|
|
819
|
+
[word, tone, link, hub, titleTone] = ["COMPACTING", "warning", "warning", ["─SEAL─", "━SEAL━"][f % 2]!, "warning"];
|
|
820
|
+
} else if (now - state.rebornAt < REBIRTH_MS) {
|
|
821
|
+
title = "SEVENTH SEAL OPENED";
|
|
822
|
+
[word, tone, link, hub, titleTone] = ["REBORN", "success", "success", "═MAGI═", "success"];
|
|
823
|
+
lit = () => true;
|
|
824
|
+
} else if (idle && (swap.state === "checking" || swap.state === "loading")) {
|
|
825
|
+
title = swap.state === "loading" ? `LOADING MODEL · ${secs(swap.since)}` : "CHECKING MODEL";
|
|
826
|
+
[word, tone, link, hub, titleTone] = ["LOADING", "warning", "warning", breathing, "warning"];
|
|
827
|
+
} else if (idle && swap.state === "error") {
|
|
671
828
|
title = "MAGI OFFLINE";
|
|
672
|
-
link = "error";
|
|
673
|
-
|
|
674
|
-
|
|
829
|
+
[word, tone, link, hub, titleTone] = ["OFFLINE", "error", "error", "─ ╳ ──", "error"];
|
|
830
|
+
lit = () => f % 16 < 8;
|
|
831
|
+
} else if (idle && swap.state === "asleep") {
|
|
832
|
+
title = "MODEL ASLEEP · type to wake";
|
|
833
|
+
[word, tone, titleTone] = ["ASLEEP", "muted", "muted"];
|
|
834
|
+
lit = () => false;
|
|
675
835
|
} else if (idle && swap.state === "ready" && now - swap.since < 3000) {
|
|
676
836
|
title = swap.loadMs ? `MAGI ONLINE · ${fmtMs(swap.loadMs)}` : "MAGI ONLINE";
|
|
677
|
-
link = "success";
|
|
678
|
-
|
|
679
|
-
units = names.map((name) => ({ name, status: "ONLINE", tone: "success", lit: true }));
|
|
837
|
+
[word, tone, link, hub, titleTone] = ["ONLINE", "success", "success", "═MAGI═", "success"];
|
|
838
|
+
lit = () => true;
|
|
680
839
|
} else if (state.phase === "thinking") {
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
const consensus = litCount === 3;
|
|
684
|
-
title = consensus ? "CONSENSUS" : `DELIBERATION ${secsSince(state.phaseSince, now)}s`;
|
|
685
|
-
link = consensus ? (f % 4 < 2 ? "warning" : "accent") : "accent";
|
|
686
|
-
const dots = ["· ", " · ", " · ", " · ", " ·"];
|
|
687
|
-
units = names.map((name, i) => {
|
|
688
|
-
if (i < litCount) return { name, status: consensus ? "AGREE" : MAGI_TASKS[i]!, tone: consensus ? "warning" : "accent", lit: true };
|
|
689
|
-
// the unit being processed right now flickers
|
|
690
|
-
return { name, status: dots[(f + i) % dots.length]!, tone: "accent", lit: i === litCount && f % 2 === 0 };
|
|
691
|
-
});
|
|
692
|
-
hub = consensus ? "◆MAGI◆" : ["─MAGI─", "━MAGI━"][f % 2]!;
|
|
840
|
+
title = `THINKING · ${secs(state.phaseSince)}`;
|
|
841
|
+
[word, tone, link, hub] = ["THINKING", "accent", "accent", breathing];
|
|
693
842
|
} else if (state.phase === "responding") {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
hub = "
|
|
697
|
-
units = names.map((name) => ({ name, status: "APPROVE", tone: "success", lit: true }));
|
|
843
|
+
const tps = liveTps();
|
|
844
|
+
title = `RESPONDING${tps ? ` · ${tps.toFixed(1)} tok/s` : ""}`;
|
|
845
|
+
[word, tone, link, hub, titleTone] = ["RESPONDING", "success", "success", breathing, "success"];
|
|
698
846
|
} else {
|
|
699
|
-
title = "
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
units = names.map((name) => ({ name, status: "STANDBY", tone: "success", lit: false }));
|
|
847
|
+
title = "STANDBY";
|
|
848
|
+
[word, tone, titleTone] = ["STANDBY", "muted", "muted"];
|
|
849
|
+
lit = () => false;
|
|
703
850
|
}
|
|
851
|
+
|
|
852
|
+
const units = MAGI_UNITS.map((name, i) => ({ name, status: word, tone, lit: lit(i) }));
|
|
704
853
|
body = magiDiagram(th, units[1]!, units[2]!, units[0]!, link, hub);
|
|
854
|
+
const c = state.lastCouncil;
|
|
855
|
+
status = c
|
|
856
|
+
? `${pulse} ${th.fg("dim", "COUNCIL ")}${th.fg(voteTone(c.verdict), `${c.verdict ?? "NO QUORUM"} ${c.tally}/3`)}`
|
|
857
|
+
: `${pulse} ${th.fg("dim", "COUNCIL · /magi")}`;
|
|
705
858
|
}
|
|
706
859
|
|
|
707
|
-
|
|
708
|
-
for (const l of body) out.push(this.frameLine(l, inner));
|
|
709
|
-
|
|
710
|
-
// status row: what the agent is doing
|
|
711
|
-
const pulse = animating(now) ? pulseFrames(th)[f % 6]! : th.fg("dim", "◇");
|
|
712
|
-
let activity: string;
|
|
713
|
-
if (state.compacting) activity = th.fg("warning", "COMPACTING");
|
|
714
|
-
else if (state.phase === "tool") activity = th.fg("warning", "TOOL CALL");
|
|
715
|
-
else if (state.phase === "thinking") activity = th.fg("accent", "THINKING");
|
|
716
|
-
else if (state.phase === "responding") activity = th.fg("success", "RESPONDING");
|
|
717
|
-
else if (booting) activity = th.fg("warning", "LOADING MODEL");
|
|
718
|
-
else if (offline) activity = th.fg("error", "SERVER OFFLINE");
|
|
719
|
-
else activity = th.fg("dim", "STANDBY");
|
|
720
|
-
const status = `${pulse} ${activity}`;
|
|
721
|
-
out.push(this.frameLine(" ".repeat(Math.max(0, Math.floor((inner - visibleWidth(status)) / 2))) + status, inner));
|
|
722
|
-
return out;
|
|
860
|
+
return [this.labelLine(inner, title, "┌", "┐", titleTone), ...body.map((l) => this.frameLine(l, inner)), this.centered(status, inner)];
|
|
723
861
|
}
|
|
724
862
|
|
|
725
|
-
|
|
726
|
-
const th = this.theme;
|
|
727
|
-
const inner = Math.max(24, width - 2);
|
|
728
|
-
const b = (s: string) => th.fg("border", s);
|
|
729
|
-
const out: string[] = [];
|
|
730
|
-
|
|
731
|
-
const title = " MAGI // SESSION MONITOR ";
|
|
732
|
-
const fill = Math.max(0, inner - visibleWidth(title) - 1);
|
|
733
|
-
out.push(b("┌─") + th.fg("accent", title) + b("─".repeat(fill) + "┐"));
|
|
734
|
-
|
|
735
|
-
out.push(...this.councilSection(inner));
|
|
863
|
+
/* ── data rows ── */
|
|
736
864
|
|
|
737
|
-
|
|
738
|
-
out
|
|
865
|
+
private sessionRows(inner: number, compact: boolean): string[] {
|
|
866
|
+
const out = [this.sep(inner, "SESSION")];
|
|
739
867
|
const model = liveCtx?.model;
|
|
740
868
|
out.push(this.field("MODEL", truncateToWidth(model?.id ?? "—", inner - 11), inner, "text"));
|
|
741
|
-
out.push(this.field("PROVIDER", truncateToWidth(String(model?.provider ?? "—"), inner - 11), inner, "muted"));
|
|
869
|
+
if (!compact) out.push(this.field("PROVIDER", truncateToWidth(String(model?.provider ?? "—"), inner - 11), inner, "muted"));
|
|
742
870
|
out.push(this.field("THINKING", liveCtx?.thinkingLevel ?? "off", inner, "muted"));
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
out
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
871
|
+
if (!compact) {
|
|
872
|
+
const cwd = (liveCtx?.cwd ?? "").split("/").filter(Boolean).pop() ?? "—";
|
|
873
|
+
out.push(this.field("PROJECT", truncateToWidth(cwd, inner - 11), inner, "muted"));
|
|
874
|
+
const branch = this.branch?.();
|
|
875
|
+
if (branch) out.push(this.field("BRANCH", truncateToWidth(branch, inner - 11), inner, "muted"));
|
|
876
|
+
}
|
|
877
|
+
return out;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private telemetryRows(inner: number, compact: boolean): string[] {
|
|
881
|
+
const th = this.theme;
|
|
882
|
+
const out = [this.sep(inner, "TELEMETRY")];
|
|
883
|
+
if (!compact) {
|
|
884
|
+
out.push(this.frameLine(` ${th.fg("dim", "TURNS".padEnd(9))}${th.fg("text", String(state.turns))}`, inner));
|
|
885
|
+
out.push(
|
|
886
|
+
this.frameLine(
|
|
887
|
+
` ${th.fg("dim", "TOKENS".padEnd(9))}${th.fg("success", "↑" + fmtTokens(tokens.input))} ${th.fg("warning", "↓" + fmtTokens(tokens.output))}`,
|
|
888
|
+
inner,
|
|
889
|
+
),
|
|
890
|
+
);
|
|
891
|
+
if (tokens.cacheRead) out.push(this.field("CACHE", fmtTokens(tokens.cacheRead), inner, "muted"));
|
|
892
|
+
if (tokens.cost) out.push(this.field("API COST", `$${tokens.cost.toFixed(3)}`, inner, "muted"));
|
|
893
|
+
}
|
|
765
894
|
|
|
766
895
|
// SYNC: the golem's obedience = tool success rate (CHESED ✓ / GEBURAH ✗)
|
|
767
896
|
const sync = syncPercent();
|
|
@@ -785,12 +914,15 @@ class MagiPanel implements Component {
|
|
|
785
914
|
const usage = liveCtx?.getContextUsage?.();
|
|
786
915
|
const percent = usage?.percent ?? 0;
|
|
787
916
|
out.push(this.frameLine(` ${th.fg("dim", "SEALS".padEnd(9))}${renderSeals(th, percent)} ${th.fg(usageTone(percent), `${percent.toFixed(0)}%`)}`, inner));
|
|
788
|
-
if (usage?.contextWindow) {
|
|
917
|
+
if (!compact && usage?.contextWindow) {
|
|
789
918
|
out.push(this.field("CONTEXT", `${usage.tokens == null ? "?" : fmtTokens(usage.tokens)} / ${fmtTokens(usage.contextWindow)}`, inner, "muted"));
|
|
790
919
|
}
|
|
920
|
+
return out;
|
|
921
|
+
}
|
|
791
922
|
|
|
792
|
-
|
|
793
|
-
|
|
923
|
+
private performanceRows(inner: number, compact: boolean): string[] {
|
|
924
|
+
const th = this.theme;
|
|
925
|
+
const out = [this.sep(inner, "PERFORMANCE")];
|
|
794
926
|
const live = state.phase !== "idle" && perf.chars > 0;
|
|
795
927
|
// idle: prefer the value measured by llama-swap itself
|
|
796
928
|
const tps = live ? liveTps() : swap.srvTps || perf.tps;
|
|
@@ -800,6 +932,7 @@ class MagiPanel implements Component {
|
|
|
800
932
|
inner,
|
|
801
933
|
),
|
|
802
934
|
);
|
|
935
|
+
if (compact) return out;
|
|
803
936
|
if (swap.srvPps) out.push(this.field("PROMPT/S", swap.srvPps.toFixed(1), inner, "muted"));
|
|
804
937
|
// llama-swap's input_tokens are the prompt tokens processed outside the cache
|
|
805
938
|
const promptTotal = swap.cacheTokens + swap.inputTokens;
|
|
@@ -812,86 +945,132 @@ class MagiPanel implements Component {
|
|
|
812
945
|
out.push(this.field("DURATION", perf.lastMs ? fmtMs(perf.lastMs) : "—", inner, "muted"));
|
|
813
946
|
const up = Math.floor((Date.now() - sessionStart) / 60000);
|
|
814
947
|
out.push(this.field("UPTIME", `${Math.floor(up / 60)}h ${String(up % 60).padStart(2, "0")}m`, inner, "muted"));
|
|
948
|
+
return out;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** COST: GPU energy used in the session × price per kWh set with /magi-ui config. */
|
|
952
|
+
private costRow(inner: number): string {
|
|
953
|
+
if (!swap.gpus.length) return this.field("COST", "—", inner, "muted");
|
|
954
|
+
if (ui.kwhPrice === undefined) return this.field("COST", "→ /magi-ui config", inner, "dim");
|
|
955
|
+
return this.field("COST", fmtMoney((swap.energyWh / 1000) * ui.kwhPrice), inner, "warning");
|
|
956
|
+
}
|
|
815
957
|
|
|
816
|
-
|
|
817
|
-
if (swap.base)
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
958
|
+
private swapRows(inner: number, compact: boolean): string[] {
|
|
959
|
+
if (!swap.base) return [];
|
|
960
|
+
const th = this.theme;
|
|
961
|
+
const out = [this.sep(inner, "LLAMA-SWAP")];
|
|
962
|
+
const st =
|
|
963
|
+
swap.state === "ready"
|
|
964
|
+
? th.fg("success", "IN VRAM")
|
|
965
|
+
: swap.state === "asleep"
|
|
966
|
+
? th.fg("muted", "ASLEEP")
|
|
822
967
|
: swap.state === "error"
|
|
823
968
|
? th.fg("error", "OFFLINE")
|
|
824
969
|
: th.fg("warning", swap.state === "loading" ? `LOADING ${secsSince(swap.since)}s` : "CHECKING");
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
)
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
)
|
|
843
|
-
|
|
844
|
-
if (swap.ramTotal) out.push(this.field("RAM", `${gib(swap.ramUsed).toFixed(1)} / ${Math.round(gib(swap.ramTotal))}G`, inner, "muted"));
|
|
970
|
+
const load = swap.state === "ready" && swap.loadMs ? th.fg("dim", ` load ${fmtMs(swap.loadMs)}`) : "";
|
|
971
|
+
out.push(this.frameLine(` ${th.fg("dim", "STATUS".padEnd(9))}${st}${load}`, inner));
|
|
972
|
+
if (compact) return [...out, this.costRow(inner)];
|
|
973
|
+
if (swap.state === "error") out.push(this.frameLine(" " + th.fg("error", swap.error), inner));
|
|
974
|
+
const gib = (n: number) => n / 2 ** 30;
|
|
975
|
+
for (const [i, g] of swap.gpus.entries()) {
|
|
976
|
+
const pct = g.memTotal ? (g.memUsed / g.memTotal) * 100 : 0;
|
|
977
|
+
out.push(
|
|
978
|
+
this.frameLine(
|
|
979
|
+
` ${th.fg("dim", `VRAM${i}`.padEnd(9))}${bar(th, Math.round(pct / 10), 10, "accent")} ${th.fg("muted", `${gib(g.memUsed).toFixed(1)}/${Math.round(gib(g.memTotal))}G`)}`,
|
|
980
|
+
inner,
|
|
981
|
+
),
|
|
982
|
+
);
|
|
983
|
+
out.push(
|
|
984
|
+
this.frameLine(
|
|
985
|
+
` ${th.fg("dim", `GPU${i}`.padEnd(9))}${th.fg(g.util > 0 ? "accent" : "muted", `${Math.round(g.util)}%`.padEnd(6))}${th.fg(g.temp >= 80 ? "error" : "muted", `${Math.round(g.temp)}°C`.padEnd(7))}${th.fg("muted", `${Math.round(g.power)}W`)}`,
|
|
986
|
+
inner,
|
|
987
|
+
),
|
|
988
|
+
);
|
|
845
989
|
}
|
|
990
|
+
if (swap.ramTotal) out.push(this.field("RAM", `${gib(swap.ramUsed).toFixed(1)} / ${Math.round(gib(swap.ramTotal))}G`, inner, "muted"));
|
|
991
|
+
if (swap.gpus.length) out.push(this.field("ENERGY", `${(swap.energyWh / 1000).toFixed(3)} kWh`, inner, "muted"));
|
|
992
|
+
out.push(this.costRow(inner));
|
|
993
|
+
return out;
|
|
994
|
+
}
|
|
846
995
|
|
|
847
|
-
|
|
848
|
-
|
|
996
|
+
render(width: number): string[] {
|
|
997
|
+
const inner = Math.max(24, width - 2);
|
|
998
|
+
const now = Date.now();
|
|
999
|
+
if (!this.cache || this.cache.inner !== inner || this.cache.compact !== ui.compact || now - this.cache.at > 500) {
|
|
1000
|
+
const c = ui.compact;
|
|
1001
|
+
this.cache = {
|
|
1002
|
+
at: now,
|
|
1003
|
+
inner,
|
|
1004
|
+
compact: c,
|
|
1005
|
+
lines: [...this.sessionRows(inner, c), ...this.telemetryRows(inner, c), ...this.performanceRows(inner, c), ...this.swapRows(inner, c)],
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
const bottom = this.theme.fg("border", "└" + "─".repeat(inner) + "┘");
|
|
1009
|
+
const lines = [...this.councilSection(inner), ...this.cache.lines];
|
|
1010
|
+
// fullscreen: keep the frame going down to the bottom of the terminal
|
|
1011
|
+
const target = (this.fillHeight?.() ?? 0) - 1;
|
|
1012
|
+
while (lines.length < target) lines.push(this.frameLine("", inner));
|
|
1013
|
+
return [...lines, bottom].map((l) => truncateToWidth(l, width));
|
|
849
1014
|
}
|
|
850
1015
|
}
|
|
851
1016
|
|
|
852
1017
|
/* ─────────────────────────────────────────────── MAGI deliberation ── */
|
|
853
1018
|
|
|
854
|
-
type Vote = "APPROVE" | "CONDITIONAL" | "REJECT";
|
|
855
|
-
|
|
856
1019
|
function voteTone(v: string | null | undefined): "success" | "warning" | "error" {
|
|
857
1020
|
return v === "APPROVE" ? "success" : v === "CONDITIONAL" ? "warning" : "error";
|
|
858
1021
|
}
|
|
859
1022
|
|
|
860
|
-
/**
|
|
1023
|
+
/**
|
|
1024
|
+
* Three minds, three lenses. A lens works on any subject (a nature, not a specialty), so no MAGI
|
|
1025
|
+
* rejects a question just because it is not "its" topic; software is where each lens gets sharpest.
|
|
1026
|
+
*/
|
|
861
1027
|
const MAGI = [
|
|
862
1028
|
{
|
|
863
1029
|
unit: "MELCHIOR",
|
|
864
1030
|
nature: "PRAGMATIST",
|
|
865
1031
|
persona:
|
|
866
|
-
"You are MELCHIOR, the pragmatist of the MAGI council
|
|
867
|
-
"the simplest
|
|
868
|
-
"
|
|
1032
|
+
"You are MELCHIOR, the pragmatist of the MAGI council. Your lens works on any subject: what actually solves the problem " +
|
|
1033
|
+
"at hand, the simplest path that works, effort and cost versus value, what can be done now with what already exists, " +
|
|
1034
|
+
"and what is unnecessary. In software this means: reuse the current stack and existing code, avoid over-engineering, " +
|
|
1035
|
+
"speculative abstractions and extra dependencies, ship sooner.",
|
|
869
1036
|
},
|
|
870
1037
|
{
|
|
871
1038
|
unit: "BALTHASAR",
|
|
872
1039
|
nature: "GUARDIAN",
|
|
873
1040
|
persona:
|
|
874
|
-
"You are BALTHASAR, the guardian of the MAGI council
|
|
875
|
-
"
|
|
876
|
-
"
|
|
1041
|
+
"You are BALTHASAR, the guardian of the MAGI council. Your lens works on any subject: what can go wrong, how badly and " +
|
|
1042
|
+
"for whom, whether the choice can be undone, which safety nets are missing, the hidden and long-term costs, and what " +
|
|
1043
|
+
"must be protected. In software this means: failure modes, security, data safety, operability (monitoring, rollback, " +
|
|
1044
|
+
"being paged at 3am), maintainability and backward compatibility.",
|
|
877
1045
|
},
|
|
878
1046
|
{
|
|
879
1047
|
unit: "CASPAR",
|
|
880
1048
|
nature: "VISIONARY",
|
|
881
1049
|
persona:
|
|
882
|
-
"You are CASPAR, the visionary of the MAGI council
|
|
883
|
-
"
|
|
884
|
-
"
|
|
1050
|
+
"You are CASPAR, the visionary of the MAGI council. Your lens works on any subject: whether the question is framed right, " +
|
|
1051
|
+
"better or unconventional alternatives, the experience of the people involved, and where the choice leads over time " +
|
|
1052
|
+
"and what it unlocks. In software this means: design alternatives, developer and user experience, how the system " +
|
|
1053
|
+
"evolves over the next year. Always name at least one concrete alternative the other two would likely miss.",
|
|
885
1054
|
},
|
|
886
1055
|
] as const satisfies readonly { unit: MagiUnit; nature: string; persona: string }[];
|
|
887
1056
|
|
|
888
|
-
const MAGI_RULES = `You are one of the three MAGI
|
|
889
|
-
|
|
890
|
-
|
|
1057
|
+
const MAGI_RULES = `You are one of the three MAGI. The council answers whatever the user asks: mostly software engineering, but not only.
|
|
1058
|
+
|
|
1059
|
+
Your nature is a lens, not a specialty, so competence is never a reason to reject. Whatever the subject, first work out the best answer to the question itself, then judge it through your lens. The other two MAGI cover the other lenses: stay in yours.
|
|
1060
|
+
|
|
1061
|
+
Be specific to this question. Every bullet must name something concrete from the question or the conversation: a tool, a number, a scenario, a step, a cost. Never write advice that would fit any question, such as "consider the trade-offs", "it depends", "ensure security" or "test properly".
|
|
1062
|
+
|
|
1063
|
+
How to vote:
|
|
1064
|
+
- APPROVE: you would go ahead as asked, or you have a clear recommendation.
|
|
1065
|
+
- CONDITIONAL: you would go ahead only if specific conditions hold, and you name them. When information is missing, vote CONDITIONAL and say exactly what you need to know and how each answer changes your recommendation.
|
|
1066
|
+
- REJECT: your lens finds a concrete problem that makes the proposal a bad idea, and you say what to do instead. Never reject because the topic is outside software or outside your nature, or because details are missing.
|
|
1067
|
+
For open questions (which one, how to), give your recommendation and vote on how confident you are in it.
|
|
1068
|
+
|
|
1069
|
+
Write in the language of the "Question for the MAGI", even though these instructions and the conversation may be in English.
|
|
891
1070
|
Output format, no preamble:
|
|
892
1071
|
VOTE: APPROVE | CONDITIONAL | REJECT
|
|
893
|
-
-
|
|
894
|
-
|
|
1072
|
+
- first bullet: your direct answer or recommendation
|
|
1073
|
+
- then up to 4 bullets from your lens: 5 bullets at most in total, about 120 words`;
|
|
895
1074
|
|
|
896
1075
|
interface MagiOpinion {
|
|
897
1076
|
unit: string;
|
|
@@ -909,13 +1088,13 @@ interface Deliberation {
|
|
|
909
1088
|
tally: number;
|
|
910
1089
|
}
|
|
911
1090
|
|
|
912
|
-
/* ── config: ~/.pi/agent/magi.json → { "MELCHIOR": { "model": "provider/id", "thinking": "low" },
|
|
1091
|
+
/* ── config: ~/.pi/agent/magi.json → { "MELCHIOR": { "model": "provider/id", "thinking": "low" }, …, "ui": { "compact": true, "kwhPrice": 0.3 } } ── */
|
|
913
1092
|
|
|
914
1093
|
interface MagiUnitConfig {
|
|
915
1094
|
model?: string;
|
|
916
1095
|
thinking?: string;
|
|
917
1096
|
}
|
|
918
|
-
type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig
|
|
1097
|
+
type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig>> & { ui?: { compact?: boolean; kwhPrice?: number; currency?: Currency } };
|
|
919
1098
|
|
|
920
1099
|
const MAGI_CONFIG_PATH = join(homedir(), ".pi", "agent", "magi.json");
|
|
921
1100
|
|
|
@@ -927,6 +1106,10 @@ function loadMagiConfig(): MagiConfig {
|
|
|
927
1106
|
}
|
|
928
1107
|
}
|
|
929
1108
|
|
|
1109
|
+
function saveMagiConfig(cfg: MagiConfig): void {
|
|
1110
|
+
writeFileSync(MAGI_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
1111
|
+
}
|
|
1112
|
+
|
|
930
1113
|
function parseVote(text: string): Vote {
|
|
931
1114
|
const m = /VOTE:\s*\**\s*(APPROVE|CONDITIONAL|REJECT)/i.exec(text);
|
|
932
1115
|
return m ? (m[1]!.toUpperCase() as Vote) : "CONDITIONAL";
|
|
@@ -965,6 +1148,56 @@ function conversationExcerpt(ctx: ExtensionContext, maxChars = 6000): string {
|
|
|
965
1148
|
return joined.length > maxChars ? "…" + joined.slice(-maxChars) : joined;
|
|
966
1149
|
}
|
|
967
1150
|
|
|
1151
|
+
const REVIEW_MAX_CHARS = 24_000;
|
|
1152
|
+
|
|
1153
|
+
/** The pending changes for /magi review: tracked changes against HEAD plus the names of untracked files. */
|
|
1154
|
+
async function pendingChanges(cwd: string): Promise<{ diff: string; untracked: string[] }> {
|
|
1155
|
+
const git = (args: string[]) => promisify(execFile)("git", args, { cwd, maxBuffer: 32 * 1024 * 1024 }).then((r) => r.stdout);
|
|
1156
|
+
let diff: string;
|
|
1157
|
+
try {
|
|
1158
|
+
diff = await git(["diff", "HEAD"]);
|
|
1159
|
+
} catch {
|
|
1160
|
+
// no commits yet: staged + unstaged
|
|
1161
|
+
diff = (await git(["diff", "--cached"])) + (await git(["diff"]));
|
|
1162
|
+
}
|
|
1163
|
+
const untracked = (await git(["ls-files", "--others", "--exclude-standard"]).catch(() => "")).split("\n").filter(Boolean);
|
|
1164
|
+
return { diff, untracked };
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** Common function words per language, used to name the reply language explicitly. */
|
|
1168
|
+
const LANGUAGE_HINTS: readonly [string, readonly string[]][] = [
|
|
1169
|
+
["Italian", ["il", "lo", "la", "gli", "di", "che", "per", "non", "una", "con", "sono", "come", "perché", "è", "dovrei", "meglio", "mettiamo", "questo", "quale"]],
|
|
1170
|
+
["Spanish", ["el", "los", "las", "que", "para", "por", "es", "cómo", "debería", "mejor", "este", "cuál"]],
|
|
1171
|
+
["French", ["le", "les", "des", "est", "pour", "avec", "dois", "comment", "mieux", "ce", "quel"]],
|
|
1172
|
+
["German", ["der", "die", "das", "und", "ist", "nicht", "für", "mit", "ich", "soll", "wie", "besser"]],
|
|
1173
|
+
["English", ["the", "is", "should", "we", "for", "with", "and", "to", "of", "how", "which", "better"]],
|
|
1174
|
+
];
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Guesses the question's language from function words; undefined when unsure.
|
|
1178
|
+
* ponytail: stopword heuristic for five languages, swap in a real detector if other languages matter.
|
|
1179
|
+
*/
|
|
1180
|
+
function guessLanguage(text: string): string | undefined {
|
|
1181
|
+
const words = text.toLowerCase().match(/\p{L}+/gu) ?? [];
|
|
1182
|
+
const scores = LANGUAGE_HINTS.map(([lang, hints]) => [lang, words.filter((w) => hints.includes(w)).length] as const).sort(
|
|
1183
|
+
(a, b) => b[1] - a[1],
|
|
1184
|
+
);
|
|
1185
|
+
const [best, second] = scores;
|
|
1186
|
+
return best![1] >= 2 && best![1] > second![1] ? best![0] : undefined;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** The user message each MAGI receives. The language reminder sits after the question, where the model reads it last. */
|
|
1190
|
+
function councilPrompt(project: string, context: string, question: string, contextLabel = "Recent conversation (context only, may be empty)"): string {
|
|
1191
|
+
const lang = guessLanguage(question);
|
|
1192
|
+
const reminder = lang
|
|
1193
|
+
? `(Write your whole answer in ${lang}, even if technical terms in the question are English.)`
|
|
1194
|
+
: "(Write your whole answer in the language of this question, even if technical terms in it are English.)";
|
|
1195
|
+
return (
|
|
1196
|
+
`Project: ${project}\n\n${contextLabel}:\n<context>\n${context}\n</context>\n\n` +
|
|
1197
|
+
`Question for the MAGI:\n${question}\n\n${reminder}`
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
968
1201
|
function resolveModel(ctx: ExtensionContext, ref?: string): Model<any> | undefined {
|
|
969
1202
|
if (!ref) return ctx.model;
|
|
970
1203
|
const slash = ref.indexOf("/");
|
|
@@ -1039,11 +1272,10 @@ function buildDeliberationView(
|
|
|
1039
1272
|
out.push(row(" " + theme.fg("muted", question)));
|
|
1040
1273
|
out.push(row(""));
|
|
1041
1274
|
|
|
1042
|
-
// Pending units flicker until their model answers.
|
|
1043
|
-
const spin = ["◐", "◓", "◑", "◒"][tick % 4]!;
|
|
1275
|
+
// Pending units flicker at random until their model answers.
|
|
1044
1276
|
const units: UnitView[] = MAGI_UNITS.map((name, i) => {
|
|
1045
1277
|
const o = opinions[i];
|
|
1046
|
-
if (!o) return { name, status:
|
|
1278
|
+
if (!o) return { name, status: "···", tone: "accent", lit: flicker(tick, i) };
|
|
1047
1279
|
if (!o.vote) return { name, status: "ERROR", tone: "error", lit: true };
|
|
1048
1280
|
return { name, status: o.vote === "CONDITIONAL" ? "COND." : o.vote, tone: voteTone(o.vote), lit: true };
|
|
1049
1281
|
});
|
|
@@ -1081,6 +1313,29 @@ function buildDeliberationView(
|
|
|
1081
1313
|
};
|
|
1082
1314
|
}
|
|
1083
1315
|
|
|
1316
|
+
/** A read-only boxed report (used by /magi-ui status); any key closes it. */
|
|
1317
|
+
function buildReportView(theme: Theme, lines: string[], close: () => void) {
|
|
1318
|
+
return {
|
|
1319
|
+
render(width: number): string[] {
|
|
1320
|
+
const inner = Math.max(40, Math.min(width - 2, 96));
|
|
1321
|
+
const row = (s: string) => {
|
|
1322
|
+
const t = truncateToWidth(" " + s, inner);
|
|
1323
|
+
return theme.fg("accent", "│") + t + " ".repeat(Math.max(0, inner - visibleWidth(t))) + theme.fg("accent", "│");
|
|
1324
|
+
};
|
|
1325
|
+
return [
|
|
1326
|
+
theme.fg("accent", "┌" + "─".repeat(inner) + "┐"),
|
|
1327
|
+
...lines.map(row),
|
|
1328
|
+
theme.fg("accent", "└" + "─".repeat(inner) + "┘"),
|
|
1329
|
+
theme.fg("dim", " any key: close"),
|
|
1330
|
+
].map((l) => truncateToWidth(l, width));
|
|
1331
|
+
},
|
|
1332
|
+
handleInput(_data: string) {
|
|
1333
|
+
close();
|
|
1334
|
+
},
|
|
1335
|
+
invalidate() {},
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1084
1339
|
async function configureMagi(ctx: ExtensionContext): Promise<void> {
|
|
1085
1340
|
const cfg = loadMagiConfig();
|
|
1086
1341
|
const pool = ctx.scopedModels.length ? ctx.scopedModels.map((s) => s.model) : ctx.modelRegistry.getAvailable();
|
|
@@ -1095,7 +1350,7 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
|
|
|
1095
1350
|
}
|
|
1096
1351
|
cfg[magi.unit] = { ...cfg[magi.unit], model: pick === SESSION ? undefined : pick };
|
|
1097
1352
|
}
|
|
1098
|
-
|
|
1353
|
+
saveMagiConfig(cfg);
|
|
1099
1354
|
ctx.ui.notify(`MAGI config saved to ${MAGI_CONFIG_PATH}`, "info");
|
|
1100
1355
|
}
|
|
1101
1356
|
|
|
@@ -1104,11 +1359,12 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
|
|
|
1104
1359
|
/**
|
|
1105
1360
|
* The footer's left side, one animation per real state of the agent:
|
|
1106
1361
|
* - seals breaking / seventh seal opened → context compaction
|
|
1107
|
-
* - golem EMET / MET → tool running / tool failed
|
|
1362
|
+
* - golem EMET / MET → tool running (with its file or command) / tool failed
|
|
1108
1363
|
* - light pulsing in the upper triad → thinking
|
|
1109
1364
|
* - light descending to Malkuth → streaming the answer
|
|
1110
1365
|
* - light ascending from Malkuth → loading the model into VRAM
|
|
1111
|
-
* -
|
|
1366
|
+
* - the sixth seal → context close to compaction
|
|
1367
|
+
* - asleep / at rest in Malkuth → model unloaded / idle
|
|
1112
1368
|
*/
|
|
1113
1369
|
function footerLeft(th: Theme, now = Date.now()): string {
|
|
1114
1370
|
const dim = (s: string) => th.fg("dim", s);
|
|
@@ -1140,14 +1396,17 @@ function footerLeft(th: Theme, now = Date.now()): string {
|
|
|
1140
1396
|
const tally = th.fg("success", ` CHESED ✓${state.toolOk}`) + th.fg(state.toolFail ? "error" : "dim", ` GEBURAH ✗${state.toolFail}`);
|
|
1141
1397
|
const syncText = sync === null ? "" : dim(" · sync ") + th.fg(syncTone(sync), `${sync.toFixed(0)}%`);
|
|
1142
1398
|
if (now - state.lastFailAt < FAIL_FLASH_MS) {
|
|
1143
|
-
|
|
1399
|
+
const target = state.lastFailTarget ? dim(" ") + th.fg("error", truncateToWidth(state.lastFailTarget, 40)) : "";
|
|
1400
|
+
return th.fg("error", "✗ GOLEM · MET") + dim(" · ") + th.fg("error", `${state.lastFailTool} failed`) + target + dim(" ·") + tally + syncText;
|
|
1144
1401
|
}
|
|
1145
1402
|
const hammer = ["▚", "▞"][step % 2]!;
|
|
1403
|
+
const target = state.toolTarget ? dim(" ") + th.fg("muted", truncateToWidth(state.toolTarget, 40)) : "";
|
|
1146
1404
|
return (
|
|
1147
1405
|
th.fg("accent", `${hammer} GOLEM · `) +
|
|
1148
1406
|
th.bold(th.fg("warning", "EMET")) +
|
|
1149
1407
|
dim(" · ") +
|
|
1150
1408
|
th.fg("text", state.toolName || "tool") +
|
|
1409
|
+
target +
|
|
1151
1410
|
dim(` ${secsSince(state.phaseSince, now)}s ·`) +
|
|
1152
1411
|
tally +
|
|
1153
1412
|
syncText
|
|
@@ -1183,6 +1442,19 @@ function footerLeft(th: Theme, now = Date.now()): string {
|
|
|
1183
1442
|
dim(` · loading model into VRAM ${secsSince(swap.since, now)}s`)
|
|
1184
1443
|
);
|
|
1185
1444
|
}
|
|
1445
|
+
const percent = liveCtx?.getContextUsage?.()?.percent ?? 0;
|
|
1446
|
+
if (percent >= SIXTH_SEAL_PERCENT) {
|
|
1447
|
+
const cmd = state.hasSmartCompact ? "/smart-compact" : "/compact";
|
|
1448
|
+
return (
|
|
1449
|
+
th.fg(step % 4 < 2 ? "error" : "warning", "⚠ SIXTH SEAL") +
|
|
1450
|
+
dim(` · context ${percent.toFixed(0)}% · compact now with `) +
|
|
1451
|
+
th.fg("text", cmd) +
|
|
1452
|
+
dim(" before it happens mid-task")
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
if (swap.state === "asleep") {
|
|
1456
|
+
return th.fg("muted", "○ MALKUTH") + dim(" · model asleep in llama-swap · type to wake it");
|
|
1457
|
+
}
|
|
1186
1458
|
const last = state.lastRunMs ? ` · last run ${fmtMs(state.lastRunMs)}` : "";
|
|
1187
1459
|
return th.fg("success", "○ ") + th.fg("muted", "MALKUTH") + dim(` · the kingdom · at rest${last}`);
|
|
1188
1460
|
}
|
|
@@ -1215,14 +1487,30 @@ function buildFooter(tui: TUI, theme: Theme, footerData: any) {
|
|
|
1215
1487
|
|
|
1216
1488
|
/* ───────────────────────────────────────────────────────── extension ── */
|
|
1217
1489
|
|
|
1490
|
+
const BUSY_POLL_MS = 3_000;
|
|
1491
|
+
const IDLE_POLL_MS = 30_000;
|
|
1492
|
+
/** pi rewrites the window title on its own (right after startup, on session info changes), so ours is re-applied. */
|
|
1493
|
+
const TITLE_REFRESH_MS = 2_000;
|
|
1494
|
+
|
|
1495
|
+
/** Window title: "π - Magi - <working directory>", prefixed with ✓ after a long run until you are back. */
|
|
1496
|
+
function windowTitle(cwd: string, done = false): string {
|
|
1497
|
+
const home = homedir();
|
|
1498
|
+
const dir = cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
|
|
1499
|
+
return `${done ? "✓ " : ""}π - Magi - ${dir}`;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1218
1502
|
export default function (pi: ExtensionAPI) {
|
|
1219
1503
|
let chrome = true;
|
|
1220
1504
|
let panelEnabled = true;
|
|
1221
1505
|
let tuiRef: TUI | undefined;
|
|
1222
1506
|
let panelHandle: OverlayHandle | undefined;
|
|
1223
1507
|
let panel: MagiPanel | undefined;
|
|
1508
|
+
let titleDone = false;
|
|
1224
1509
|
|
|
1225
|
-
const repaint = () =>
|
|
1510
|
+
const repaint = () => {
|
|
1511
|
+
panel?.invalidate();
|
|
1512
|
+
tuiRef?.requestRender();
|
|
1513
|
+
};
|
|
1226
1514
|
|
|
1227
1515
|
let footerDataRef: any;
|
|
1228
1516
|
let wrappedRoot: any; // pi's original root, when the panel is a fullscreen column
|
|
@@ -1236,6 +1524,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1236
1524
|
// ponytail: reads TuiAltScreen's private layoutRoot field; if pi renames it, falls back to the overlay.
|
|
1237
1525
|
const t = tuiRef as any;
|
|
1238
1526
|
if (t.mode === "fullscreen" && t.layoutRoot && typeof t.setLayoutRoot === "function") {
|
|
1527
|
+
panel.fillHeight = () => process.stdout.rows ?? 0;
|
|
1239
1528
|
wrappedRoot = t.layoutRoot;
|
|
1240
1529
|
t.setLayoutRoot(
|
|
1241
1530
|
new HStack(
|
|
@@ -1297,7 +1586,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1297
1586
|
return buildFooter(tui, theme, footerData);
|
|
1298
1587
|
});
|
|
1299
1588
|
ctx.ui.setWorkingIndicator({ frames: pulseFrames(ctx.ui.theme), intervalMs: 110 });
|
|
1300
|
-
ctx.ui.setTitle(
|
|
1589
|
+
ctx.ui.setTitle(windowTitle(ctx.cwd, titleDone));
|
|
1301
1590
|
};
|
|
1302
1591
|
|
|
1303
1592
|
pi.registerEntryRenderer("magi-verdict", (entry: any, _options: any, theme: Theme) => {
|
|
@@ -1321,15 +1610,54 @@ export default function (pi: ExtensionAPI) {
|
|
|
1321
1610
|
});
|
|
1322
1611
|
|
|
1323
1612
|
let metricsTimer: ReturnType<typeof setInterval> | undefined;
|
|
1613
|
+
let unsubscribeInput: (() => void) | undefined;
|
|
1614
|
+
let lastPoll = 0;
|
|
1615
|
+
let titleTimer: ReturnType<typeof setInterval> | undefined;
|
|
1616
|
+
/** Tools currently executing, by call id: the golem shows one of them, and leaves when none is left. */
|
|
1617
|
+
const runningTools = new Map<string, { name: string; target: string }>();
|
|
1324
1618
|
|
|
1325
1619
|
pi.on("session_start", async (_event, ctx) => {
|
|
1326
1620
|
liveCtx = ctx;
|
|
1621
|
+
recountSession(ctx);
|
|
1622
|
+
state.hasSmartCompact = pi.getCommands().some((c) => c.name.replace(/^\//, "") === "smart-compact");
|
|
1623
|
+
const cfg = loadMagiConfig();
|
|
1624
|
+
ui.compact = cfg.ui?.compact ?? false;
|
|
1625
|
+
ui.kwhPrice = cfg.ui?.kwhPrice;
|
|
1626
|
+
ui.currency = cfg.ui?.currency === "USD" ? "USD" : "EUR";
|
|
1327
1627
|
if (ctx.mode !== "tui") return;
|
|
1328
1628
|
applyChrome(ctx);
|
|
1329
|
-
// load the model into VRAM now (animated in the panel and footer)
|
|
1629
|
+
// load the model into VRAM now (animated in the panel and footer)
|
|
1330
1630
|
void preloadModel(ctx);
|
|
1331
|
-
|
|
1631
|
+
// GPU stats every 3s while something happens, every 30s when idle
|
|
1632
|
+
metricsTimer ??= setInterval(() => {
|
|
1633
|
+
const now = Date.now();
|
|
1634
|
+
if (!animating(now) && now - lastPoll < IDLE_POLL_MS) return;
|
|
1635
|
+
lastPoll = now;
|
|
1636
|
+
void refreshSwapMetrics()
|
|
1637
|
+
.then(refreshSwapRunning)
|
|
1638
|
+
.then(repaint);
|
|
1639
|
+
}, BUSY_POLL_MS);
|
|
1332
1640
|
metricsTimer.unref?.();
|
|
1641
|
+
// back at the keyboard: clear the ✓ from the title, wake a model llama-swap unloaded
|
|
1642
|
+
unsubscribeInput ??= ctx.ui.onTerminalInput(() => {
|
|
1643
|
+
if (titleDone) {
|
|
1644
|
+
titleDone = false;
|
|
1645
|
+
ctx.ui.setTitle(windowTitle(ctx.cwd));
|
|
1646
|
+
}
|
|
1647
|
+
if (swap.state === "asleep" && liveCtx) void preloadModel(liveCtx);
|
|
1648
|
+
return undefined;
|
|
1649
|
+
});
|
|
1650
|
+
// ponytail: re-applies the title every 2s because pi overwrites it without telling extensions
|
|
1651
|
+
titleTimer ??= setInterval(() => {
|
|
1652
|
+
if (chrome) ctx.ui.setTitle(windowTitle(ctx.cwd, titleDone));
|
|
1653
|
+
}, TITLE_REFRESH_MS);
|
|
1654
|
+
titleTimer.unref?.();
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
1658
|
+
liveCtx = ctx;
|
|
1659
|
+
recountSession(ctx);
|
|
1660
|
+
repaint();
|
|
1333
1661
|
});
|
|
1334
1662
|
|
|
1335
1663
|
pi.on("model_select", async (event, ctx) => {
|
|
@@ -1340,6 +1668,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1340
1668
|
pi.on("session_shutdown", async () => {
|
|
1341
1669
|
clearInterval(metricsTimer);
|
|
1342
1670
|
metricsTimer = undefined;
|
|
1671
|
+
unsubscribeInput?.();
|
|
1672
|
+
unsubscribeInput = undefined;
|
|
1673
|
+
clearInterval(titleTimer);
|
|
1674
|
+
titleTimer = undefined;
|
|
1343
1675
|
hidePanel();
|
|
1344
1676
|
});
|
|
1345
1677
|
|
|
@@ -1369,7 +1701,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1369
1701
|
const t = e?.type;
|
|
1370
1702
|
if (t === "thinking_start" || t === "thinking_delta") setPhase("thinking");
|
|
1371
1703
|
else if (t === "text_start" || t === "text_delta") setPhase("responding");
|
|
1372
|
-
else if (t === "toolcall_start" || t === "toolcall_delta")
|
|
1704
|
+
else if (t === "toolcall_start" || t === "toolcall_delta") {
|
|
1705
|
+
// the model is writing a tool call: the golem shows which tool it prepares, not the previous one
|
|
1706
|
+
setPhase("tool");
|
|
1707
|
+
const call = e.partial?.content?.[e.contentIndex];
|
|
1708
|
+
if (call?.type === "toolCall") {
|
|
1709
|
+
state.toolName = call.name ?? "";
|
|
1710
|
+
state.toolTarget = toolTarget(call.arguments);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1373
1713
|
if (typeof e?.delta === "string" && e.delta) {
|
|
1374
1714
|
if (!perf.first) perf.first = Date.now();
|
|
1375
1715
|
perf.chars += e.delta.length;
|
|
@@ -1377,20 +1717,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
1377
1717
|
});
|
|
1378
1718
|
|
|
1379
1719
|
pi.on("message_end", async (event) => {
|
|
1380
|
-
if (event.message.role !== "assistant"
|
|
1720
|
+
if (event.message.role !== "assistant") return;
|
|
1381
1721
|
const m = event.message as AssistantMessage;
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
perf.
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1722
|
+
addUsage(m);
|
|
1723
|
+
if (perf.start) {
|
|
1724
|
+
const end = Date.now();
|
|
1725
|
+
perf.lastMs = end - perf.start;
|
|
1726
|
+
if (perf.first) {
|
|
1727
|
+
perf.ttft = perf.first - perf.start;
|
|
1728
|
+
const genS = (end - perf.first) / 1000;
|
|
1729
|
+
const out = m.usage?.output || perf.chars / 4;
|
|
1730
|
+
if (genS > 0.05 && out) {
|
|
1731
|
+
perf.tps = out / genS;
|
|
1732
|
+
perf.peakTps = Math.max(perf.peakTps, perf.tps);
|
|
1733
|
+
}
|
|
1391
1734
|
}
|
|
1735
|
+
perf.start = 0;
|
|
1392
1736
|
}
|
|
1393
|
-
perf.start = 0;
|
|
1394
1737
|
repaint();
|
|
1395
1738
|
// llama-swap records the request once it completes
|
|
1396
1739
|
setTimeout(() => void refreshSwapActivity().then(repaint), 300);
|
|
@@ -1398,20 +1741,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
1398
1741
|
|
|
1399
1742
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
1400
1743
|
liveCtx = ctx;
|
|
1744
|
+
const tool = { name: event.toolName ?? "", target: toolTarget(event.args) };
|
|
1745
|
+
runningTools.set(event.toolCallId, tool);
|
|
1401
1746
|
setPhase("tool");
|
|
1402
|
-
state.toolName =
|
|
1747
|
+
state.toolName = tool.name;
|
|
1748
|
+
state.toolTarget = tool.target;
|
|
1403
1749
|
repaint();
|
|
1404
1750
|
});
|
|
1405
1751
|
|
|
1406
1752
|
// CHESED (mercy) counts what worked, GEBURAH (severity) what failed; a failure erases the golem's aleph.
|
|
1407
1753
|
pi.on("tool_execution_end", async (event) => {
|
|
1754
|
+
const tool = runningTools.get(event.toolCallId);
|
|
1755
|
+
runningTools.delete(event.toolCallId);
|
|
1408
1756
|
if (event.isError) {
|
|
1409
1757
|
state.toolFail++;
|
|
1410
1758
|
state.lastFailAt = Date.now();
|
|
1411
|
-
state.lastFailTool = event.toolName ?? "tool";
|
|
1759
|
+
state.lastFailTool = event.toolName ?? tool?.name ?? "tool";
|
|
1760
|
+
state.lastFailTarget = tool?.target ?? "";
|
|
1412
1761
|
} else {
|
|
1413
1762
|
state.toolOk++;
|
|
1414
1763
|
}
|
|
1764
|
+
const next = [...runningTools.values()][0];
|
|
1765
|
+
if (next) {
|
|
1766
|
+
// parallel tools: the golem moves on to one still running
|
|
1767
|
+
state.toolName = next.name;
|
|
1768
|
+
state.toolTarget = next.target;
|
|
1769
|
+
} else {
|
|
1770
|
+
// all tools done: the model now reads their results and thinks about the next step
|
|
1771
|
+
setPhase("thinking");
|
|
1772
|
+
state.toolName = "";
|
|
1773
|
+
state.toolTarget = "";
|
|
1774
|
+
}
|
|
1415
1775
|
repaint();
|
|
1416
1776
|
});
|
|
1417
1777
|
|
|
@@ -1420,19 +1780,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
1420
1780
|
if (state.runStart) state.lastRunMs = Date.now() - state.runStart;
|
|
1421
1781
|
state.runStart = 0;
|
|
1422
1782
|
setPhase("idle");
|
|
1783
|
+
runningTools.clear();
|
|
1423
1784
|
state.toolName = "";
|
|
1424
|
-
|
|
1785
|
+
state.toolTarget = "";
|
|
1786
|
+
if (ctx.mode === "tui" && chrome) {
|
|
1787
|
+
ctx.ui.setWorkingMessage();
|
|
1788
|
+
// a long run just finished: mark the window title until you touch the keyboard
|
|
1789
|
+
if (state.lastRunMs > DONE_TITLE_AFTER_MS) {
|
|
1790
|
+
titleDone = true;
|
|
1791
|
+
ctx.ui.setTitle(windowTitle(ctx.cwd, true));
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1425
1794
|
repaint();
|
|
1426
1795
|
});
|
|
1427
1796
|
|
|
1428
1797
|
// The seven seals: compaction breaks them, and the context is reborn.
|
|
1429
1798
|
// When pi-smart-compact is installed it owns the compaction; this theme only watches and names it.
|
|
1430
|
-
const hasSmartCompact = () => pi.getCommands().some((c) => c.name.replace(/^\//, "") === "smart-compact");
|
|
1431
|
-
|
|
1432
1799
|
pi.on("session_before_compact", async () => {
|
|
1433
1800
|
state.compacting = true;
|
|
1434
1801
|
state.compactSince = Date.now();
|
|
1435
|
-
state.compactBy = hasSmartCompact
|
|
1802
|
+
state.compactBy = state.hasSmartCompact ? "smart-compact" : "pi native";
|
|
1436
1803
|
repaint();
|
|
1437
1804
|
});
|
|
1438
1805
|
|
|
@@ -1440,7 +1807,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1440
1807
|
state.compacting = false;
|
|
1441
1808
|
state.rebornAt = Date.now();
|
|
1442
1809
|
// fromExtension: an extension supplied the summary; otherwise pi's own compactor did (e.g. smart-compact fell back)
|
|
1443
|
-
state.compactBy = event.fromExtension ? (hasSmartCompact
|
|
1810
|
+
state.compactBy = event.fromExtension ? (state.hasSmartCompact ? "smart-compact" : "extension") : "pi native";
|
|
1444
1811
|
repaint();
|
|
1445
1812
|
});
|
|
1446
1813
|
|
|
@@ -1449,54 +1816,82 @@ export default function (pi: ExtensionAPI) {
|
|
|
1449
1816
|
repaint();
|
|
1450
1817
|
});
|
|
1451
1818
|
|
|
1819
|
+
/** Runs the council on a question with some context, shows the deliberation, stores the verdict. */
|
|
1820
|
+
async function runCouncil(ctx: ExtensionContext, question: string, context: string, contextLabel?: string): Promise<void> {
|
|
1821
|
+
const cfg = loadMagiConfig();
|
|
1822
|
+
const project = (ctx.cwd ?? "").split("/").filter(Boolean).pop() ?? "";
|
|
1823
|
+
const prompt = councilPrompt(project, context, question, contextLabel);
|
|
1824
|
+
|
|
1825
|
+
const controller = new AbortController();
|
|
1826
|
+
const opinions: (MagiOpinion | undefined)[] = MAGI.map(() => undefined);
|
|
1827
|
+
let finished = false;
|
|
1828
|
+
const all = Promise.all(
|
|
1829
|
+
MAGI.map((_, i) =>
|
|
1830
|
+
askMagi(ctx, i, prompt, cfg, controller.signal).then((o) => {
|
|
1831
|
+
opinions[i] = o;
|
|
1832
|
+
return o;
|
|
1833
|
+
}),
|
|
1834
|
+
),
|
|
1835
|
+
).finally(() => {
|
|
1836
|
+
finished = true;
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
if (ctx.mode === "tui") {
|
|
1840
|
+
const cancelled = await ctx.ui.custom<boolean>((tui, theme, _keys, done) =>
|
|
1841
|
+
buildDeliberationView(tui, theme, question, opinions, () => finished, done),
|
|
1842
|
+
);
|
|
1843
|
+
if (cancelled) {
|
|
1844
|
+
controller.abort();
|
|
1845
|
+
ctx.ui.notify("MAGI deliberation aborted", "warning");
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
const final = await all;
|
|
1851
|
+
const { verdict, tally } = tallyVerdict(final.map((o) => o.vote));
|
|
1852
|
+
pi.appendEntry("magi-verdict", { question, opinions: final, verdict, tally } satisfies Deliberation);
|
|
1853
|
+
state.lastCouncil = { verdict, tally, question };
|
|
1854
|
+
repaint();
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1452
1857
|
pi.registerCommand("magi", {
|
|
1453
|
-
description: "Ask the three MAGI (pragmatist, guardian, visionary); /magi
|
|
1858
|
+
description: "Ask the three MAGI (pragmatist, guardian, visionary); /magi review [focus] judges the git diff; /magi config assigns models",
|
|
1454
1859
|
handler: async (args, ctx) => {
|
|
1455
1860
|
const arg = args.trim();
|
|
1456
1861
|
if (arg === "config") return configureMagi(ctx);
|
|
1457
1862
|
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
let finished = false;
|
|
1470
|
-
const all = Promise.all(
|
|
1471
|
-
MAGI.map((_, i) =>
|
|
1472
|
-
askMagi(ctx, i, prompt, cfg, controller.signal).then((o) => {
|
|
1473
|
-
opinions[i] = o;
|
|
1474
|
-
return o;
|
|
1475
|
-
}),
|
|
1476
|
-
),
|
|
1477
|
-
).finally(() => {
|
|
1478
|
-
finished = true;
|
|
1479
|
-
});
|
|
1480
|
-
|
|
1481
|
-
if (ctx.mode === "tui") {
|
|
1482
|
-
const cancelled = await ctx.ui.custom<boolean>((tui, theme, _keys, done) =>
|
|
1483
|
-
buildDeliberationView(tui, theme, question, opinions, () => finished, done),
|
|
1484
|
-
);
|
|
1485
|
-
if (cancelled) {
|
|
1486
|
-
controller.abort();
|
|
1487
|
-
ctx.ui.notify("MAGI deliberation aborted", "warning");
|
|
1863
|
+
if (arg === "review" || arg.startsWith("review ")) {
|
|
1864
|
+
const focus = arg.slice("review".length).trim();
|
|
1865
|
+
let changes: { diff: string; untracked: string[] };
|
|
1866
|
+
try {
|
|
1867
|
+
changes = await pendingChanges(ctx.cwd);
|
|
1868
|
+
} catch {
|
|
1869
|
+
ctx.ui.notify("MAGI review needs a git repository", "error");
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
if (!changes.diff.trim() && !changes.untracked.length) {
|
|
1873
|
+
ctx.ui.notify("Nothing to review: no changes against HEAD", "info");
|
|
1488
1874
|
return;
|
|
1489
1875
|
}
|
|
1876
|
+
const diff =
|
|
1877
|
+
changes.diff.length > REVIEW_MAX_CHARS
|
|
1878
|
+
? changes.diff.slice(0, REVIEW_MAX_CHARS) + `\n… diff truncated (${changes.diff.length} chars in total)`
|
|
1879
|
+
: changes.diff;
|
|
1880
|
+
const untracked = changes.untracked.length ? `\n\nUntracked files (content not shown):\n${changes.untracked.join("\n")}` : "";
|
|
1881
|
+
const question = focus
|
|
1882
|
+
? `Review these pending changes before committing, focusing on: ${focus}`
|
|
1883
|
+
: "Review these pending changes before committing: are they ready to commit, and what must change first?";
|
|
1884
|
+
return runCouncil(ctx, question, "```diff\n" + diff + "\n```" + untracked, "Pending changes (git diff HEAD)");
|
|
1490
1885
|
}
|
|
1491
1886
|
|
|
1492
|
-
const
|
|
1493
|
-
|
|
1494
|
-
|
|
1887
|
+
const question = arg || (await ctx.ui.input("Question for the MAGI:", "should we …?"))?.trim() || "";
|
|
1888
|
+
if (!question) return;
|
|
1889
|
+
return runCouncil(ctx, question, conversationExcerpt(ctx));
|
|
1495
1890
|
},
|
|
1496
1891
|
});
|
|
1497
1892
|
|
|
1498
1893
|
pi.registerCommand("magi-ui", {
|
|
1499
|
-
description: "MAGI chrome: enable the theme, or manage
|
|
1894
|
+
description: "MAGI chrome: enable the theme, or manage it (on|off|panel|compact|status|config)",
|
|
1500
1895
|
handler: async (args, ctx) => {
|
|
1501
1896
|
liveCtx = ctx;
|
|
1502
1897
|
const arg = args.trim().toLowerCase();
|
|
@@ -1508,6 +1903,59 @@ export default function (pi: ExtensionAPI) {
|
|
|
1508
1903
|
ctx.ui.notify(`Side panel ${panelEnabled ? "enabled" : "disabled"}`, "info");
|
|
1509
1904
|
return;
|
|
1510
1905
|
}
|
|
1906
|
+
if (arg === "compact") {
|
|
1907
|
+
ui.compact = !ui.compact;
|
|
1908
|
+
const cfg = loadMagiConfig();
|
|
1909
|
+
saveMagiConfig({ ...cfg, ui: { ...cfg.ui, compact: ui.compact } });
|
|
1910
|
+
repaint();
|
|
1911
|
+
ctx.ui.notify(`Side panel ${ui.compact ? "compact" : "detailed"}`, "info");
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
if (arg === "config") {
|
|
1915
|
+
const currency = await ctx.ui.select(`Currency for COST (current: ${ui.currency})`, ["EUR", "USD"]);
|
|
1916
|
+
if (!currency) return;
|
|
1917
|
+
const current = ui.kwhPrice !== undefined ? ` (current: ${ui.kwhPrice})` : "";
|
|
1918
|
+
const raw = (await ctx.ui.input(`Electricity price per kWh in ${currency}${current}:`, "0.30"))?.trim();
|
|
1919
|
+
if (raw === undefined) return;
|
|
1920
|
+
const price = raw === "" && ui.kwhPrice !== undefined ? ui.kwhPrice : Number(raw.replace(",", "."));
|
|
1921
|
+
if (raw === "" && ui.kwhPrice === undefined) {
|
|
1922
|
+
ctx.ui.notify("No price entered: COST unchanged", "warning");
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
if (!Number.isFinite(price) || price < 0) {
|
|
1926
|
+
ctx.ui.notify(`Invalid price: "${raw}"`, "error");
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
ui.currency = currency === "USD" ? "USD" : "EUR";
|
|
1930
|
+
ui.kwhPrice = price;
|
|
1931
|
+
const cfg = loadMagiConfig();
|
|
1932
|
+
saveMagiConfig({ ...cfg, ui: { ...cfg.ui, currency: ui.currency, kwhPrice: price } });
|
|
1933
|
+
repaint();
|
|
1934
|
+
ctx.ui.notify(`COST: ${fmtMoney(price)} per kWh`, "info");
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
if (arg === "status") {
|
|
1938
|
+
if (!swap.base) {
|
|
1939
|
+
ctx.ui.notify("/magi-ui status needs a llama-swap session model", "warning");
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
let report: { data?: ActivityRow[]; total?: number };
|
|
1943
|
+
try {
|
|
1944
|
+
report = (await (await swapGet(`/api/metrics/activity?limit=${ACTIVITY_REPORT_ROWS}`, 15_000)).json()) as typeof report;
|
|
1945
|
+
} catch (err) {
|
|
1946
|
+
ctx.ui.notify(`llama-swap status failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const rows = report.data ?? [];
|
|
1950
|
+
if (!rows.length) {
|
|
1951
|
+
ctx.ui.notify("llama-swap has no recorded requests yet", "info");
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
await ctx.ui.custom<void>((_tui, theme, _keys, done) =>
|
|
1955
|
+
buildReportView(theme, activityReport(theme, rows, report.total ?? rows.length), () => done(undefined)),
|
|
1956
|
+
);
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1511
1959
|
if (arg === "off" || arg === "on") {
|
|
1512
1960
|
chrome = arg === "on";
|
|
1513
1961
|
applyChrome(ctx);
|
|
@@ -1522,7 +1970,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1522
1970
|
}
|
|
1523
1971
|
chrome = true;
|
|
1524
1972
|
applyChrome(ctx);
|
|
1525
|
-
ctx.ui.notify("MAGI online — /magi-ui panel
|
|
1973
|
+
ctx.ui.notify("MAGI online — /magi-ui panel|compact|status|off", "info");
|
|
1526
1974
|
},
|
|
1527
1975
|
});
|
|
1528
1976
|
}
|