pi-magi-theme 0.1.3 → 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 +36 -10
- package/extensions/magi/index.ts +667 -267
- 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) + "┐"));
|
|
863
|
+
/* ── data rows ── */
|
|
734
864
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
// session data
|
|
738
|
-
out.push(this.sep(inner, "SESSION"));
|
|
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,47 +945,77 @@ 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
|
+
}
|
|
815
950
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
+
}
|
|
957
|
+
|
|
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
|
}
|
|
@@ -925,13 +1088,13 @@ interface Deliberation {
|
|
|
925
1088
|
tally: number;
|
|
926
1089
|
}
|
|
927
1090
|
|
|
928
|
-
/* ── 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 } } ── */
|
|
929
1092
|
|
|
930
1093
|
interface MagiUnitConfig {
|
|
931
1094
|
model?: string;
|
|
932
1095
|
thinking?: string;
|
|
933
1096
|
}
|
|
934
|
-
type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig
|
|
1097
|
+
type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig>> & { ui?: { compact?: boolean; kwhPrice?: number; currency?: Currency } };
|
|
935
1098
|
|
|
936
1099
|
const MAGI_CONFIG_PATH = join(homedir(), ".pi", "agent", "magi.json");
|
|
937
1100
|
|
|
@@ -943,6 +1106,10 @@ function loadMagiConfig(): MagiConfig {
|
|
|
943
1106
|
}
|
|
944
1107
|
}
|
|
945
1108
|
|
|
1109
|
+
function saveMagiConfig(cfg: MagiConfig): void {
|
|
1110
|
+
writeFileSync(MAGI_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
1111
|
+
}
|
|
1112
|
+
|
|
946
1113
|
function parseVote(text: string): Vote {
|
|
947
1114
|
const m = /VOTE:\s*\**\s*(APPROVE|CONDITIONAL|REJECT)/i.exec(text);
|
|
948
1115
|
return m ? (m[1]!.toUpperCase() as Vote) : "CONDITIONAL";
|
|
@@ -981,6 +1148,22 @@ function conversationExcerpt(ctx: ExtensionContext, maxChars = 6000): string {
|
|
|
981
1148
|
return joined.length > maxChars ? "…" + joined.slice(-maxChars) : joined;
|
|
982
1149
|
}
|
|
983
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
|
+
|
|
984
1167
|
/** Common function words per language, used to name the reply language explicitly. */
|
|
985
1168
|
const LANGUAGE_HINTS: readonly [string, readonly string[]][] = [
|
|
986
1169
|
["Italian", ["il", "lo", "la", "gli", "di", "che", "per", "non", "una", "con", "sono", "come", "perché", "è", "dovrei", "meglio", "mettiamo", "questo", "quale"]],
|
|
@@ -1004,13 +1187,13 @@ function guessLanguage(text: string): string | undefined {
|
|
|
1004
1187
|
}
|
|
1005
1188
|
|
|
1006
1189
|
/** The user message each MAGI receives. The language reminder sits after the question, where the model reads it last. */
|
|
1007
|
-
function councilPrompt(project: string,
|
|
1190
|
+
function councilPrompt(project: string, context: string, question: string, contextLabel = "Recent conversation (context only, may be empty)"): string {
|
|
1008
1191
|
const lang = guessLanguage(question);
|
|
1009
1192
|
const reminder = lang
|
|
1010
1193
|
? `(Write your whole answer in ${lang}, even if technical terms in the question are English.)`
|
|
1011
1194
|
: "(Write your whole answer in the language of this question, even if technical terms in it are English.)";
|
|
1012
1195
|
return (
|
|
1013
|
-
`Project: ${project}\n\
|
|
1196
|
+
`Project: ${project}\n\n${contextLabel}:\n<context>\n${context}\n</context>\n\n` +
|
|
1014
1197
|
`Question for the MAGI:\n${question}\n\n${reminder}`
|
|
1015
1198
|
);
|
|
1016
1199
|
}
|
|
@@ -1089,11 +1272,10 @@ function buildDeliberationView(
|
|
|
1089
1272
|
out.push(row(" " + theme.fg("muted", question)));
|
|
1090
1273
|
out.push(row(""));
|
|
1091
1274
|
|
|
1092
|
-
// Pending units flicker until their model answers.
|
|
1093
|
-
const spin = ["◐", "◓", "◑", "◒"][tick % 4]!;
|
|
1275
|
+
// Pending units flicker at random until their model answers.
|
|
1094
1276
|
const units: UnitView[] = MAGI_UNITS.map((name, i) => {
|
|
1095
1277
|
const o = opinions[i];
|
|
1096
|
-
if (!o) return { name, status:
|
|
1278
|
+
if (!o) return { name, status: "···", tone: "accent", lit: flicker(tick, i) };
|
|
1097
1279
|
if (!o.vote) return { name, status: "ERROR", tone: "error", lit: true };
|
|
1098
1280
|
return { name, status: o.vote === "CONDITIONAL" ? "COND." : o.vote, tone: voteTone(o.vote), lit: true };
|
|
1099
1281
|
});
|
|
@@ -1131,6 +1313,29 @@ function buildDeliberationView(
|
|
|
1131
1313
|
};
|
|
1132
1314
|
}
|
|
1133
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
|
+
|
|
1134
1339
|
async function configureMagi(ctx: ExtensionContext): Promise<void> {
|
|
1135
1340
|
const cfg = loadMagiConfig();
|
|
1136
1341
|
const pool = ctx.scopedModels.length ? ctx.scopedModels.map((s) => s.model) : ctx.modelRegistry.getAvailable();
|
|
@@ -1145,7 +1350,7 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
|
|
|
1145
1350
|
}
|
|
1146
1351
|
cfg[magi.unit] = { ...cfg[magi.unit], model: pick === SESSION ? undefined : pick };
|
|
1147
1352
|
}
|
|
1148
|
-
|
|
1353
|
+
saveMagiConfig(cfg);
|
|
1149
1354
|
ctx.ui.notify(`MAGI config saved to ${MAGI_CONFIG_PATH}`, "info");
|
|
1150
1355
|
}
|
|
1151
1356
|
|
|
@@ -1154,11 +1359,12 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
|
|
|
1154
1359
|
/**
|
|
1155
1360
|
* The footer's left side, one animation per real state of the agent:
|
|
1156
1361
|
* - seals breaking / seventh seal opened → context compaction
|
|
1157
|
-
* - golem EMET / MET → tool running / tool failed
|
|
1362
|
+
* - golem EMET / MET → tool running (with its file or command) / tool failed
|
|
1158
1363
|
* - light pulsing in the upper triad → thinking
|
|
1159
1364
|
* - light descending to Malkuth → streaming the answer
|
|
1160
1365
|
* - light ascending from Malkuth → loading the model into VRAM
|
|
1161
|
-
* -
|
|
1366
|
+
* - the sixth seal → context close to compaction
|
|
1367
|
+
* - asleep / at rest in Malkuth → model unloaded / idle
|
|
1162
1368
|
*/
|
|
1163
1369
|
function footerLeft(th: Theme, now = Date.now()): string {
|
|
1164
1370
|
const dim = (s: string) => th.fg("dim", s);
|
|
@@ -1190,14 +1396,17 @@ function footerLeft(th: Theme, now = Date.now()): string {
|
|
|
1190
1396
|
const tally = th.fg("success", ` CHESED ✓${state.toolOk}`) + th.fg(state.toolFail ? "error" : "dim", ` GEBURAH ✗${state.toolFail}`);
|
|
1191
1397
|
const syncText = sync === null ? "" : dim(" · sync ") + th.fg(syncTone(sync), `${sync.toFixed(0)}%`);
|
|
1192
1398
|
if (now - state.lastFailAt < FAIL_FLASH_MS) {
|
|
1193
|
-
|
|
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;
|
|
1194
1401
|
}
|
|
1195
1402
|
const hammer = ["▚", "▞"][step % 2]!;
|
|
1403
|
+
const target = state.toolTarget ? dim(" ") + th.fg("muted", truncateToWidth(state.toolTarget, 40)) : "";
|
|
1196
1404
|
return (
|
|
1197
1405
|
th.fg("accent", `${hammer} GOLEM · `) +
|
|
1198
1406
|
th.bold(th.fg("warning", "EMET")) +
|
|
1199
1407
|
dim(" · ") +
|
|
1200
1408
|
th.fg("text", state.toolName || "tool") +
|
|
1409
|
+
target +
|
|
1201
1410
|
dim(` ${secsSince(state.phaseSince, now)}s ·`) +
|
|
1202
1411
|
tally +
|
|
1203
1412
|
syncText
|
|
@@ -1233,6 +1442,19 @@ function footerLeft(th: Theme, now = Date.now()): string {
|
|
|
1233
1442
|
dim(` · loading model into VRAM ${secsSince(swap.since, now)}s`)
|
|
1234
1443
|
);
|
|
1235
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
|
+
}
|
|
1236
1458
|
const last = state.lastRunMs ? ` · last run ${fmtMs(state.lastRunMs)}` : "";
|
|
1237
1459
|
return th.fg("success", "○ ") + th.fg("muted", "MALKUTH") + dim(` · the kingdom · at rest${last}`);
|
|
1238
1460
|
}
|
|
@@ -1265,14 +1487,30 @@ function buildFooter(tui: TUI, theme: Theme, footerData: any) {
|
|
|
1265
1487
|
|
|
1266
1488
|
/* ───────────────────────────────────────────────────────── extension ── */
|
|
1267
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
|
+
|
|
1268
1502
|
export default function (pi: ExtensionAPI) {
|
|
1269
1503
|
let chrome = true;
|
|
1270
1504
|
let panelEnabled = true;
|
|
1271
1505
|
let tuiRef: TUI | undefined;
|
|
1272
1506
|
let panelHandle: OverlayHandle | undefined;
|
|
1273
1507
|
let panel: MagiPanel | undefined;
|
|
1508
|
+
let titleDone = false;
|
|
1274
1509
|
|
|
1275
|
-
const repaint = () =>
|
|
1510
|
+
const repaint = () => {
|
|
1511
|
+
panel?.invalidate();
|
|
1512
|
+
tuiRef?.requestRender();
|
|
1513
|
+
};
|
|
1276
1514
|
|
|
1277
1515
|
let footerDataRef: any;
|
|
1278
1516
|
let wrappedRoot: any; // pi's original root, when the panel is a fullscreen column
|
|
@@ -1286,6 +1524,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1286
1524
|
// ponytail: reads TuiAltScreen's private layoutRoot field; if pi renames it, falls back to the overlay.
|
|
1287
1525
|
const t = tuiRef as any;
|
|
1288
1526
|
if (t.mode === "fullscreen" && t.layoutRoot && typeof t.setLayoutRoot === "function") {
|
|
1527
|
+
panel.fillHeight = () => process.stdout.rows ?? 0;
|
|
1289
1528
|
wrappedRoot = t.layoutRoot;
|
|
1290
1529
|
t.setLayoutRoot(
|
|
1291
1530
|
new HStack(
|
|
@@ -1347,7 +1586,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1347
1586
|
return buildFooter(tui, theme, footerData);
|
|
1348
1587
|
});
|
|
1349
1588
|
ctx.ui.setWorkingIndicator({ frames: pulseFrames(ctx.ui.theme), intervalMs: 110 });
|
|
1350
|
-
ctx.ui.setTitle(
|
|
1589
|
+
ctx.ui.setTitle(windowTitle(ctx.cwd, titleDone));
|
|
1351
1590
|
};
|
|
1352
1591
|
|
|
1353
1592
|
pi.registerEntryRenderer("magi-verdict", (entry: any, _options: any, theme: Theme) => {
|
|
@@ -1371,15 +1610,54 @@ export default function (pi: ExtensionAPI) {
|
|
|
1371
1610
|
});
|
|
1372
1611
|
|
|
1373
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 }>();
|
|
1374
1618
|
|
|
1375
1619
|
pi.on("session_start", async (_event, ctx) => {
|
|
1376
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";
|
|
1377
1627
|
if (ctx.mode !== "tui") return;
|
|
1378
1628
|
applyChrome(ctx);
|
|
1379
|
-
// 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)
|
|
1380
1630
|
void preloadModel(ctx);
|
|
1381
|
-
|
|
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);
|
|
1382
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();
|
|
1383
1661
|
});
|
|
1384
1662
|
|
|
1385
1663
|
pi.on("model_select", async (event, ctx) => {
|
|
@@ -1390,6 +1668,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1390
1668
|
pi.on("session_shutdown", async () => {
|
|
1391
1669
|
clearInterval(metricsTimer);
|
|
1392
1670
|
metricsTimer = undefined;
|
|
1671
|
+
unsubscribeInput?.();
|
|
1672
|
+
unsubscribeInput = undefined;
|
|
1673
|
+
clearInterval(titleTimer);
|
|
1674
|
+
titleTimer = undefined;
|
|
1393
1675
|
hidePanel();
|
|
1394
1676
|
});
|
|
1395
1677
|
|
|
@@ -1419,7 +1701,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1419
1701
|
const t = e?.type;
|
|
1420
1702
|
if (t === "thinking_start" || t === "thinking_delta") setPhase("thinking");
|
|
1421
1703
|
else if (t === "text_start" || t === "text_delta") setPhase("responding");
|
|
1422
|
-
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
|
+
}
|
|
1423
1713
|
if (typeof e?.delta === "string" && e.delta) {
|
|
1424
1714
|
if (!perf.first) perf.first = Date.now();
|
|
1425
1715
|
perf.chars += e.delta.length;
|
|
@@ -1427,20 +1717,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
1427
1717
|
});
|
|
1428
1718
|
|
|
1429
1719
|
pi.on("message_end", async (event) => {
|
|
1430
|
-
if (event.message.role !== "assistant"
|
|
1720
|
+
if (event.message.role !== "assistant") return;
|
|
1431
1721
|
const m = event.message as AssistantMessage;
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
perf.
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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
|
+
}
|
|
1441
1734
|
}
|
|
1735
|
+
perf.start = 0;
|
|
1442
1736
|
}
|
|
1443
|
-
perf.start = 0;
|
|
1444
1737
|
repaint();
|
|
1445
1738
|
// llama-swap records the request once it completes
|
|
1446
1739
|
setTimeout(() => void refreshSwapActivity().then(repaint), 300);
|
|
@@ -1448,20 +1741,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
1448
1741
|
|
|
1449
1742
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
1450
1743
|
liveCtx = ctx;
|
|
1744
|
+
const tool = { name: event.toolName ?? "", target: toolTarget(event.args) };
|
|
1745
|
+
runningTools.set(event.toolCallId, tool);
|
|
1451
1746
|
setPhase("tool");
|
|
1452
|
-
state.toolName =
|
|
1747
|
+
state.toolName = tool.name;
|
|
1748
|
+
state.toolTarget = tool.target;
|
|
1453
1749
|
repaint();
|
|
1454
1750
|
});
|
|
1455
1751
|
|
|
1456
1752
|
// CHESED (mercy) counts what worked, GEBURAH (severity) what failed; a failure erases the golem's aleph.
|
|
1457
1753
|
pi.on("tool_execution_end", async (event) => {
|
|
1754
|
+
const tool = runningTools.get(event.toolCallId);
|
|
1755
|
+
runningTools.delete(event.toolCallId);
|
|
1458
1756
|
if (event.isError) {
|
|
1459
1757
|
state.toolFail++;
|
|
1460
1758
|
state.lastFailAt = Date.now();
|
|
1461
|
-
state.lastFailTool = event.toolName ?? "tool";
|
|
1759
|
+
state.lastFailTool = event.toolName ?? tool?.name ?? "tool";
|
|
1760
|
+
state.lastFailTarget = tool?.target ?? "";
|
|
1462
1761
|
} else {
|
|
1463
1762
|
state.toolOk++;
|
|
1464
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
|
+
}
|
|
1465
1775
|
repaint();
|
|
1466
1776
|
});
|
|
1467
1777
|
|
|
@@ -1470,19 +1780,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
1470
1780
|
if (state.runStart) state.lastRunMs = Date.now() - state.runStart;
|
|
1471
1781
|
state.runStart = 0;
|
|
1472
1782
|
setPhase("idle");
|
|
1783
|
+
runningTools.clear();
|
|
1473
1784
|
state.toolName = "";
|
|
1474
|
-
|
|
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
|
+
}
|
|
1475
1794
|
repaint();
|
|
1476
1795
|
});
|
|
1477
1796
|
|
|
1478
1797
|
// The seven seals: compaction breaks them, and the context is reborn.
|
|
1479
1798
|
// When pi-smart-compact is installed it owns the compaction; this theme only watches and names it.
|
|
1480
|
-
const hasSmartCompact = () => pi.getCommands().some((c) => c.name.replace(/^\//, "") === "smart-compact");
|
|
1481
|
-
|
|
1482
1799
|
pi.on("session_before_compact", async () => {
|
|
1483
1800
|
state.compacting = true;
|
|
1484
1801
|
state.compactSince = Date.now();
|
|
1485
|
-
state.compactBy = hasSmartCompact
|
|
1802
|
+
state.compactBy = state.hasSmartCompact ? "smart-compact" : "pi native";
|
|
1486
1803
|
repaint();
|
|
1487
1804
|
});
|
|
1488
1805
|
|
|
@@ -1490,7 +1807,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1490
1807
|
state.compacting = false;
|
|
1491
1808
|
state.rebornAt = Date.now();
|
|
1492
1809
|
// fromExtension: an extension supplied the summary; otherwise pi's own compactor did (e.g. smart-compact fell back)
|
|
1493
|
-
state.compactBy = event.fromExtension ? (hasSmartCompact
|
|
1810
|
+
state.compactBy = event.fromExtension ? (state.hasSmartCompact ? "smart-compact" : "extension") : "pi native";
|
|
1494
1811
|
repaint();
|
|
1495
1812
|
});
|
|
1496
1813
|
|
|
@@ -1499,52 +1816,82 @@ export default function (pi: ExtensionAPI) {
|
|
|
1499
1816
|
repaint();
|
|
1500
1817
|
});
|
|
1501
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
|
+
|
|
1502
1857
|
pi.registerCommand("magi", {
|
|
1503
|
-
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",
|
|
1504
1859
|
handler: async (args, ctx) => {
|
|
1505
1860
|
const arg = args.trim();
|
|
1506
1861
|
if (arg === "config") return configureMagi(ctx);
|
|
1507
1862
|
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
const controller = new AbortController();
|
|
1516
|
-
const opinions: (MagiOpinion | undefined)[] = MAGI.map(() => undefined);
|
|
1517
|
-
let finished = false;
|
|
1518
|
-
const all = Promise.all(
|
|
1519
|
-
MAGI.map((_, i) =>
|
|
1520
|
-
askMagi(ctx, i, prompt, cfg, controller.signal).then((o) => {
|
|
1521
|
-
opinions[i] = o;
|
|
1522
|
-
return o;
|
|
1523
|
-
}),
|
|
1524
|
-
),
|
|
1525
|
-
).finally(() => {
|
|
1526
|
-
finished = true;
|
|
1527
|
-
});
|
|
1528
|
-
|
|
1529
|
-
if (ctx.mode === "tui") {
|
|
1530
|
-
const cancelled = await ctx.ui.custom<boolean>((tui, theme, _keys, done) =>
|
|
1531
|
-
buildDeliberationView(tui, theme, question, opinions, () => finished, done),
|
|
1532
|
-
);
|
|
1533
|
-
if (cancelled) {
|
|
1534
|
-
controller.abort();
|
|
1535
|
-
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");
|
|
1536
1870
|
return;
|
|
1537
1871
|
}
|
|
1872
|
+
if (!changes.diff.trim() && !changes.untracked.length) {
|
|
1873
|
+
ctx.ui.notify("Nothing to review: no changes against HEAD", "info");
|
|
1874
|
+
return;
|
|
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)");
|
|
1538
1885
|
}
|
|
1539
1886
|
|
|
1540
|
-
const
|
|
1541
|
-
|
|
1542
|
-
|
|
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));
|
|
1543
1890
|
},
|
|
1544
1891
|
});
|
|
1545
1892
|
|
|
1546
1893
|
pi.registerCommand("magi-ui", {
|
|
1547
|
-
description: "MAGI chrome: enable the theme, or manage
|
|
1894
|
+
description: "MAGI chrome: enable the theme, or manage it (on|off|panel|compact|status|config)",
|
|
1548
1895
|
handler: async (args, ctx) => {
|
|
1549
1896
|
liveCtx = ctx;
|
|
1550
1897
|
const arg = args.trim().toLowerCase();
|
|
@@ -1556,6 +1903,59 @@ export default function (pi: ExtensionAPI) {
|
|
|
1556
1903
|
ctx.ui.notify(`Side panel ${panelEnabled ? "enabled" : "disabled"}`, "info");
|
|
1557
1904
|
return;
|
|
1558
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
|
+
}
|
|
1559
1959
|
if (arg === "off" || arg === "on") {
|
|
1560
1960
|
chrome = arg === "on";
|
|
1561
1961
|
applyChrome(ctx);
|
|
@@ -1570,7 +1970,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1570
1970
|
}
|
|
1571
1971
|
chrome = true;
|
|
1572
1972
|
applyChrome(ctx);
|
|
1573
|
-
ctx.ui.notify("MAGI online — /magi-ui panel
|
|
1973
|
+
ctx.ui.notify("MAGI online — /magi-ui panel|compact|status|off", "info");
|
|
1574
1974
|
},
|
|
1575
1975
|
});
|
|
1576
1976
|
}
|