pi-magi-theme 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -13
- package/extensions/magi/index.ts +1320 -405
- package/package.json +3 -3
package/extensions/magi/index.ts
CHANGED
|
@@ -2,24 +2,32 @@
|
|
|
2
2
|
* MAGI — a three-mind council chrome for pi
|
|
3
3
|
*
|
|
4
4
|
* One public-domain mythology, every symbol bound to a real function:
|
|
5
|
-
* - the
|
|
5
|
+
* - the panel draws the three MAGI as the panels of their control screen
|
|
6
|
+
* - the sephirot of the Tree of Life show where the agent is: the upper triad while it thinks, the light descending
|
|
6
7
|
* 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
|
|
8
|
+
* - the three MAGI (MELCHIOR / BALTHASAR / CASPAR) flicker while the agent works, showing what it is doing,
|
|
9
|
+
* and form the /magi council: three real models voting (on a question, or on the pending git diff)
|
|
10
|
+
* - the GOLEM acts: it is animated by EMET ("truth") while tools run, showing the file or command it works on;
|
|
11
|
+
* a failing tool erases the aleph and EMET becomes MET ("death"). SYNC is the tool success rate
|
|
11
12
|
* - CHESED (mercy) and GEBURAH (severity) count successful and failed tools
|
|
12
|
-
* - the SEVEN SEALS measure the context window; compaction
|
|
13
|
-
* - llama-swap
|
|
14
|
-
*
|
|
13
|
+
* - the SEVEN SEALS measure the context window; the sixth warns before compaction, the seventh opens when it runs
|
|
14
|
+
* - MECHA SELECT: a new llama-swap session loads nothing until you pick the unit (model) to activate;
|
|
15
|
+
* each model gets a MECHA head, lit when it is already in VRAM, waking while it loads;
|
|
16
|
+
* /magi mecha reopens it
|
|
17
|
+
* - while a model loads an angel attacks the MAGI: red spreads through BALTHASAR, MELCHIOR and CASPAR at the pace of
|
|
18
|
+
* the model's last load, a corner of CASPAR holds out blinking; once loaded, blue takes the MAGI back from that corner
|
|
19
|
+
* - llama-swap telemetry: VRAM, GPU load/temp/power, energy used, RAM, server-side tok/s, prompt tok/s, cache hits
|
|
20
|
+
* - /magi config → assign a model to each MAGI; /magi-ui compact|status → smaller panel, llama-swap report
|
|
15
21
|
*
|
|
16
|
-
*
|
|
22
|
+
* Fan art: the MAGI and their screen come from Neon Genesis Evangelion, all rights reserved to khara, Inc.
|
|
17
23
|
* Use with the theme ../../themes/magi.json
|
|
18
24
|
*/
|
|
19
25
|
|
|
26
|
+
import { execFile } from "node:child_process";
|
|
20
27
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
21
28
|
import { homedir } from "node:os";
|
|
22
29
|
import { join } from "node:path";
|
|
30
|
+
import { promisify } from "node:util";
|
|
23
31
|
import type { AssistantMessage, Model } from "@earendil-works/pi-ai";
|
|
24
32
|
import { completeSimple } from "@earendil-works/pi-ai";
|
|
25
33
|
import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
@@ -37,9 +45,30 @@ const MAGI_WORD = [
|
|
|
37
45
|
"╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝",
|
|
38
46
|
];
|
|
39
47
|
|
|
40
|
-
/**
|
|
41
|
-
const
|
|
42
|
-
|
|
48
|
+
/** The seven-eyed seal, 26×11 cells of braille, traced from the original emblem. */
|
|
49
|
+
const SEELE_LOGO = [
|
|
50
|
+
" ⠹⡍⠉⠉⠉⠉⠉⠉⠉⠉⠉⢹⠉⠉⠉⠉⠉⠉⠉⣉⡉⠉⢩⠇",
|
|
51
|
+
"⣠⠖⠛⢩⠋⠉⢯⠙⠲⢤⡀ ⢸ ⢠⣴⠚⠉⡝⠉⠙⡍⠙⢲⣤",
|
|
52
|
+
" ⠙⠒⠾⡷⠴⠿⠖⠚⠁ ⢸ ⠈⠙⠒⠻⠶⣾⠗⠚⠉",
|
|
53
|
+
" ⢀⣠⣤⣼⢦⣤⣤⣀ ⢸ ⢀⣀⡤⢤⠴⢧⠤⣄⣀",
|
|
54
|
+
"⠺⢭⣀⣸⡁ ⣽⣀⣨⠽⠂ ⢸ ⠐⠫⢤⣀⣯⡀⣠⣇⣀⡬⠓",
|
|
55
|
+
" ⠉⠉⠉⠙⣇ ⢸ ⡼⠉⠉⠉⠉",
|
|
56
|
+
"⣠⠖⠚⢩⠏⠉⢯⠙⠓⢦⡀ ⢸ ⢀⡴⠚⠋⡽⠉⠙⡍⠓⠲⣄",
|
|
57
|
+
" ⠙⠒⠾⠷⠴⠿⠖⠚⢧ ⢸ ⢀⡼⠛⠒⠿⠦⠾⠗⠚⠉",
|
|
58
|
+
" ⠈⢧ ⢸ ⢀⡞ ⣀⣤⢤⡤⣤⣤⣄⡀",
|
|
59
|
+
" ⠈⢧⢸⢀⡞⠐⠯⣅⣀⣯ ⢈⣇⣀⡭⠗",
|
|
60
|
+
" ⢻⡟ ⠈⠉⠉⠉⠉",
|
|
61
|
+
];
|
|
62
|
+
const SEELE_WIDTH = 26;
|
|
63
|
+
|
|
64
|
+
const SYSTEM_WORD = [
|
|
65
|
+
"███████╗██╗ ██╗███████╗████████╗███████╗███╗ ███╗",
|
|
66
|
+
"██╔════╝╚██╗ ██╔╝██╔════╝╚══██╔══╝██╔════╝████╗ ████║",
|
|
67
|
+
"███████╗ ╚████╔╝ ███████╗ ██║ █████╗ ██╔████╔██║",
|
|
68
|
+
"╚════██║ ╚██╔╝ ╚════██║ ██║ ██╔══╝ ██║╚██╔╝██║",
|
|
69
|
+
"███████║ ██║ ███████║ ██║ ███████╗██║ ╚═╝ ██║",
|
|
70
|
+
"╚══════╝ ╚═╝ ╚══════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝",
|
|
71
|
+
];
|
|
43
72
|
|
|
44
73
|
/** Sephirot in the order of the "lightning flash" (Keter → Malkuth), positioned in sub-pixels. */
|
|
45
74
|
const SEPHIROT = [
|
|
@@ -55,82 +84,6 @@ const SEPHIROT = [
|
|
|
55
84
|
{ name: "MALKUTH", meaning: "the kingdom", x: 30, y: 75 },
|
|
56
85
|
] as const;
|
|
57
86
|
|
|
58
|
-
/** Da'at, the hidden sephirah: drawn dashed, not part of the flash. */
|
|
59
|
-
const DAAT = { x: 30, y: 28 };
|
|
60
|
-
|
|
61
|
-
/** The 22 paths, as pairs of SEPHIROT indices. */
|
|
62
|
-
const TREE_PATHS: readonly [number, number][] = [
|
|
63
|
-
[0, 1], [0, 2], [0, 5], [1, 2], [1, 5], [1, 3], [2, 5], [2, 4], [3, 4], [3, 5], [3, 6],
|
|
64
|
-
[4, 5], [4, 7], [5, 6], [5, 8], [5, 7], [6, 7], [6, 8], [6, 9], [7, 8], [7, 9], [8, 9],
|
|
65
|
-
];
|
|
66
|
-
|
|
67
|
-
const BRAILLE_BITS = [
|
|
68
|
-
[0x01, 0x08],
|
|
69
|
-
[0x02, 0x10],
|
|
70
|
-
[0x04, 0x20],
|
|
71
|
-
[0x40, 0x80],
|
|
72
|
-
]; // [row][col] → dot bit
|
|
73
|
-
|
|
74
|
-
/** Rasterizes the Tree of Life into braille: paths (muted), Da'at (dim, dashed), sephirot (accent rings). */
|
|
75
|
-
function renderTreeOfLife(th: Theme): string[] {
|
|
76
|
-
const W = TREE_W * 2;
|
|
77
|
-
const H = TREE_H * 4;
|
|
78
|
-
const PATH = 1;
|
|
79
|
-
const DAAT_PX = 2;
|
|
80
|
-
const NODE = 3;
|
|
81
|
-
const px = new Uint8Array(W * H);
|
|
82
|
-
const set = (x: number, y: number, v: number) => {
|
|
83
|
-
const rx = Math.round(x);
|
|
84
|
-
const ry = Math.round(y);
|
|
85
|
-
if (rx >= 0 && ry >= 0 && rx < W && ry < H) px[ry * W + rx] = v;
|
|
86
|
-
};
|
|
87
|
-
for (const [a, b] of TREE_PATHS) {
|
|
88
|
-
const p = SEPHIROT[a]!;
|
|
89
|
-
const q = SEPHIROT[b]!;
|
|
90
|
-
const n = Math.ceil(Math.max(Math.abs(q.x - p.x), Math.abs(q.y - p.y)));
|
|
91
|
-
for (let i = 0; i <= n; i++) set(p.x + ((q.x - p.x) * i) / n, p.y + ((q.y - p.y) * i) / n, PATH);
|
|
92
|
-
}
|
|
93
|
-
// ring of radius r, cleared inside and with a small gap around it so paths stop short of the circle;
|
|
94
|
-
// dashed rings keep every other 30° arc
|
|
95
|
-
const ring = (cx: number, cy: number, r: number, v: number, dashed: boolean) => {
|
|
96
|
-
for (let y = -r - 2; y <= r + 2; y++) {
|
|
97
|
-
for (let x = -r - 2; x <= r + 2; x++) {
|
|
98
|
-
const d = Math.hypot(x, y);
|
|
99
|
-
if (d > r + 1.5) continue;
|
|
100
|
-
const onRing = d > r - 0.7 && d <= r + 0.5;
|
|
101
|
-
const arc = Math.floor((Math.atan2(y, x) + Math.PI) / (Math.PI / 6)) % 2 === 0;
|
|
102
|
-
set(cx + x, cy + y, onRing && (!dashed || arc) ? v : 0);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
ring(DAAT.x, DAAT.y, 3, DAAT_PX, true);
|
|
107
|
-
for (const s of SEPHIROT) {
|
|
108
|
-
ring(s.x, s.y, 3.5, NODE, false);
|
|
109
|
-
set(s.x, s.y, NODE);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const lines: string[] = [];
|
|
113
|
-
for (let row = 0; row < TREE_H; row++) {
|
|
114
|
-
let line = "";
|
|
115
|
-
for (let col = 0; col < TREE_W; col++) {
|
|
116
|
-
let bits = 0;
|
|
117
|
-
let top = 0;
|
|
118
|
-
for (let dy = 0; dy < 4; dy++) {
|
|
119
|
-
for (let dx = 0; dx < 2; dx++) {
|
|
120
|
-
const v = px[(row * 4 + dy) * W + col * 2 + dx]!;
|
|
121
|
-
if (!v) continue;
|
|
122
|
-
bits |= BRAILLE_BITS[dy]![dx]!;
|
|
123
|
-
top = Math.max(top, v);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
const ch = bits ? String.fromCharCode(0x2800 + bits) : " ";
|
|
127
|
-
line += bits ? th.fg(top === NODE ? "accent" : top === DAAT_PX ? "dim" : "muted", ch) : ch;
|
|
128
|
-
}
|
|
129
|
-
lines.push(line);
|
|
130
|
-
}
|
|
131
|
-
return lines;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
87
|
type NodeLight = "off" | "on" | "hot";
|
|
135
88
|
|
|
136
89
|
/** The ten sephirot as a tiny path: ●━●━◉─○ … (hot = where the light is now). */
|
|
@@ -176,9 +129,169 @@ const GOLEM_FALLEN = [
|
|
|
176
129
|
" ▀▀▀▀ ▀▀▀▀ ",
|
|
177
130
|
];
|
|
178
131
|
|
|
132
|
+
/* ── MECHA units: the heads of the model picker, one unit per model llama-swap runs ── */
|
|
133
|
+
|
|
134
|
+
type MechaUnit = "I" | "II" | "III" | "LEGION";
|
|
135
|
+
type MechaState = "dormant" | "waking" | "active";
|
|
136
|
+
type Rgb = readonly [number, number, number];
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Heads of 13×7 cells. mask: e = eyes (the light), a = accent, anything else = armor.
|
|
140
|
+
* jaw: rows that replace the art from row `at` while the unit wakes (the jaw lock breaks).
|
|
141
|
+
* Colors are the units' own, outside the theme palette on purpose.
|
|
142
|
+
*/
|
|
143
|
+
const MECHA: Record<MechaUnit, { name: string; armor: Rgb; accent: Rgb; eyes: Rgb; art: string[]; mask: string[]; jaw: { at: number; rows: string[] } }> = {
|
|
144
|
+
"I": {
|
|
145
|
+
name: "MECHA-I",
|
|
146
|
+
armor: [124, 82, 196],
|
|
147
|
+
accent: [132, 222, 84],
|
|
148
|
+
eyes: [170, 255, 120],
|
|
149
|
+
art: [
|
|
150
|
+
" ▲ ",
|
|
151
|
+
" ███ ",
|
|
152
|
+
" ▄█████▄ ",
|
|
153
|
+
" ██▀▀█▀▀██ ",
|
|
154
|
+
" █ ◣ ◢ █ ",
|
|
155
|
+
" ▀█▄▄█▄▄█▀ ",
|
|
156
|
+
" ▀▄▄▄▀ ",
|
|
157
|
+
],
|
|
158
|
+
mask: [
|
|
159
|
+
" ",
|
|
160
|
+
" ",
|
|
161
|
+
" ",
|
|
162
|
+
" aa aa ",
|
|
163
|
+
" e e ",
|
|
164
|
+
" ",
|
|
165
|
+
" aaaaa ",
|
|
166
|
+
],
|
|
167
|
+
jaw: { at: 5, rows: [" ▀█▀▀▀▀▀█▀ ", " █▄▀▄▀▄█ "] },
|
|
168
|
+
},
|
|
169
|
+
"II": {
|
|
170
|
+
name: "MECHA-II",
|
|
171
|
+
armor: [236, 140, 44],
|
|
172
|
+
accent: [90, 160, 255],
|
|
173
|
+
eyes: [120, 200, 255],
|
|
174
|
+
art: [
|
|
175
|
+
" ▄▄▄▄▄ ",
|
|
176
|
+
" ▄█▀▀▀▀▀█▄ ",
|
|
177
|
+
" ▐█ ▄▄▄ █▌ ",
|
|
178
|
+
" ▐█ ███ █▌ ",
|
|
179
|
+
" ▀█▄▄▄▄▄█▀ ",
|
|
180
|
+
" ▀███▀ ",
|
|
181
|
+
" ",
|
|
182
|
+
],
|
|
183
|
+
mask: [
|
|
184
|
+
" aaaaa ",
|
|
185
|
+
" ",
|
|
186
|
+
" eee ",
|
|
187
|
+
" eee ",
|
|
188
|
+
" ",
|
|
189
|
+
" ",
|
|
190
|
+
" ",
|
|
191
|
+
],
|
|
192
|
+
jaw: { at: 5, rows: [" ▀▄▀▄▀ "] },
|
|
193
|
+
},
|
|
194
|
+
"III": {
|
|
195
|
+
name: "MECHA-III",
|
|
196
|
+
armor: [214, 40, 44],
|
|
197
|
+
accent: [245, 160, 40],
|
|
198
|
+
eyes: [120, 255, 160],
|
|
199
|
+
art: [
|
|
200
|
+
" ◥▄ ▄◤ ",
|
|
201
|
+
" ██▄▄▄▄▄██ ",
|
|
202
|
+
" █ ● ● █ ",
|
|
203
|
+
" █ ● ● █ ",
|
|
204
|
+
" ▀██▄▄▄██▀ ",
|
|
205
|
+
" ▀███▀ ",
|
|
206
|
+
" ",
|
|
207
|
+
],
|
|
208
|
+
mask: [
|
|
209
|
+
" aa aa ",
|
|
210
|
+
" ",
|
|
211
|
+
" e e ",
|
|
212
|
+
" e e ",
|
|
213
|
+
" ",
|
|
214
|
+
" ",
|
|
215
|
+
" ",
|
|
216
|
+
],
|
|
217
|
+
jaw: { at: 4, rows: [" ▀█▀▄▀▄▀█▀ ", " ▀▄▀▄▀▄▀ "] },
|
|
218
|
+
},
|
|
219
|
+
LEGION: {
|
|
220
|
+
name: "LEGION",
|
|
221
|
+
armor: [228, 228, 232],
|
|
222
|
+
accent: [205, 40, 64],
|
|
223
|
+
eyes: [255, 90, 110],
|
|
224
|
+
art: [
|
|
225
|
+
" ▄▄▄ ",
|
|
226
|
+
" ▄█████▄ ",
|
|
227
|
+
" ▐███████▌ ",
|
|
228
|
+
" ▐█▀▄▀▄▀█▌ ",
|
|
229
|
+
" ▀▄▀▄▀▄▀ ",
|
|
230
|
+
" ",
|
|
231
|
+
" ",
|
|
232
|
+
],
|
|
233
|
+
mask: [
|
|
234
|
+
" ",
|
|
235
|
+
" ",
|
|
236
|
+
" ",
|
|
237
|
+
" eeeee ",
|
|
238
|
+
" eeeeeee ",
|
|
239
|
+
" ",
|
|
240
|
+
" ",
|
|
241
|
+
],
|
|
242
|
+
jaw: { at: 3, rows: [" ▐█▄▀▄▀▄█▌ ", " ▄▀▄▀▄▀▄ "] },
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const rgb = ([r, g, b]: Rgb, s: string) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
|
|
247
|
+
const shade = (c: Rgb, k: number): Rgb => [Math.round(c[0] * k), Math.round(c[1] * k), Math.round(c[2] * k)];
|
|
248
|
+
|
|
249
|
+
/** One MECHA head: dormant (dark, eyes off), waking (eyes flicker, the jaw lock breaks), active (armor lit, eyes pulse). */
|
|
250
|
+
function renderMechaHead(unit: MechaUnit, state: MechaState, frame: number): string[] {
|
|
251
|
+
const u = MECHA[unit];
|
|
252
|
+
const jawOpen = state === "waking" && frame % 8 < 4;
|
|
253
|
+
const eyesOn = state === "active" || (state === "waking" && flicker(frame, 7));
|
|
254
|
+
const armor = state === "dormant" ? shade(u.armor, 0.3) : state === "waking" ? shade(u.armor, 0.7) : u.armor;
|
|
255
|
+
const accent = state === "dormant" ? shade(u.accent, 0.3) : u.accent;
|
|
256
|
+
const eyes = eyesOn ? shade(u.eyes, state === "active" ? 0.75 + 0.25 * Math.sin(frame / 3) : 1) : shade(u.eyes, 0.18);
|
|
257
|
+
return u.art.map((row, r) => {
|
|
258
|
+
const jaw = jawOpen ? u.jaw.rows[r - u.jaw.at] : undefined;
|
|
259
|
+
const art = jaw ?? row;
|
|
260
|
+
let out = "";
|
|
261
|
+
for (let c = 0; c < art.length; c++) {
|
|
262
|
+
const ch = art[c]!;
|
|
263
|
+
const m = u.mask[r]![c];
|
|
264
|
+
out += ch === " " ? " " : rgb(m === "e" ? eyes : m === "a" ? accent : armor, ch);
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** MECHA-I goes to the default model, II and III to the next real models, the rest is the LEGION. */
|
|
271
|
+
function assignMechaUnits(realIds: string[], defaultRealId: string): Map<string, MechaUnit> {
|
|
272
|
+
const order = [...new Set(realIds.includes(defaultRealId) ? [defaultRealId, ...realIds] : realIds)];
|
|
273
|
+
const units: MechaUnit[] = ["I", "II", "III"];
|
|
274
|
+
return new Map(order.map((id, i) => [id, units[i] ?? "LEGION"]));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The unit of every llama-swap model, stable across /model switches: MECHA-I is settings.json's defaultModel.
|
|
279
|
+
* ponytail: reads only the global settings, a project .pi/settings.json override is ignored.
|
|
280
|
+
*/
|
|
281
|
+
function mechaUnitsOf(ctx: ExtensionContext, aliases: Map<string, string>): Map<string, MechaUnit> {
|
|
282
|
+
let def = ctx.model?.id ?? "";
|
|
283
|
+
try {
|
|
284
|
+
def = JSON.parse(readFileSync(join(homedir(), ".pi", "agent", "settings.json"), "utf8")).defaultModel || def;
|
|
285
|
+
} catch {
|
|
286
|
+
// no settings: the session model leads
|
|
287
|
+
}
|
|
288
|
+
const realOf = (id: string) => aliases.get(id) ?? id;
|
|
289
|
+
const ids = ctx.modelRegistry.getAvailable().filter((m) => m.provider === "llama-swap").map((m) => realOf(m.id));
|
|
290
|
+
return assignMechaUnits(ids, realOf(def));
|
|
291
|
+
}
|
|
292
|
+
|
|
179
293
|
const MAGI_UNITS = ["MELCHIOR", "BALTHASAR", "CASPAR"] as const;
|
|
180
294
|
type MagiUnit = (typeof MAGI_UNITS)[number];
|
|
181
|
-
const MAGI_TASKS = ["ANALYSIS", "SYNTHESIS", "VERIFY"] as const;
|
|
182
295
|
|
|
183
296
|
function pulseFrames(theme: Theme): string[] {
|
|
184
297
|
return [
|
|
@@ -191,27 +304,52 @@ function pulseFrames(theme: Theme): string[] {
|
|
|
191
304
|
];
|
|
192
305
|
}
|
|
193
306
|
|
|
307
|
+
/** Pseudo-random on/off per unit, changing every `hold` frames: the MAGI flicker while they work. */
|
|
308
|
+
function flicker(frame: number, unit: number, hold = 2): boolean {
|
|
309
|
+
const x = Math.imul(Math.floor(frame / hold) + 1, 2654435761) ^ Math.imul(unit + 1, 40503);
|
|
310
|
+
return (x >>> 0) % 100 < 55;
|
|
311
|
+
}
|
|
312
|
+
|
|
194
313
|
/* ─────────────────────────────────────────────────────── live state ── */
|
|
195
314
|
|
|
196
315
|
type Phase = "idle" | "thinking" | "responding" | "tool";
|
|
316
|
+
type Vote = "APPROVE" | "CONDITIONAL" | "REJECT";
|
|
197
317
|
|
|
198
318
|
const state = {
|
|
199
319
|
phase: "idle" as Phase,
|
|
200
320
|
phaseSince: Date.now(),
|
|
201
321
|
toolName: "",
|
|
322
|
+
toolTarget: "", // file or command the current tool works on
|
|
202
323
|
turns: 0,
|
|
203
324
|
toolOk: 0, // CHESED
|
|
204
325
|
toolFail: 0, // GEBURAH
|
|
205
326
|
lastFailAt: 0,
|
|
206
327
|
lastFailTool: "",
|
|
328
|
+
lastFailTarget: "",
|
|
207
329
|
runStart: 0,
|
|
208
330
|
lastRunMs: 0,
|
|
209
331
|
compacting: false,
|
|
210
332
|
compactSince: 0,
|
|
211
333
|
compactBy: "", // who opens the seals: "smart-compact" (pi-smart-compact package) or "pi native"
|
|
212
334
|
rebornAt: 0,
|
|
335
|
+
hasSmartCompact: false,
|
|
336
|
+
lastCouncil: undefined as { verdict: Vote | null; tally: number; question: string } | undefined,
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/** Panel preferences, persisted under "ui" in ~/.pi/agent/magi.json. */
|
|
340
|
+
type Currency = "EUR" | "USD";
|
|
341
|
+
|
|
342
|
+
const ui = {
|
|
343
|
+
compact: false,
|
|
344
|
+
kwhPrice: undefined as number | undefined, // price per kWh, for the COST row (/magi-ui config)
|
|
345
|
+
currency: "EUR" as Currency,
|
|
213
346
|
};
|
|
214
347
|
|
|
348
|
+
function fmtMoney(value: number): string {
|
|
349
|
+
const digits = value < 1 ? 3 : 2;
|
|
350
|
+
return ui.currency === "USD" ? `$${value.toFixed(digits)}` : `${value.toFixed(digits)} €`;
|
|
351
|
+
}
|
|
352
|
+
|
|
215
353
|
function setPhase(p: Phase): void {
|
|
216
354
|
if (state.phase === p) return;
|
|
217
355
|
state.phase = p;
|
|
@@ -221,6 +359,8 @@ function setPhase(p: Phase): void {
|
|
|
221
359
|
const ANIM_STEP_MS = 220;
|
|
222
360
|
const FAIL_FLASH_MS = 2500;
|
|
223
361
|
const REBIRTH_MS = 6000;
|
|
362
|
+
const DONE_TITLE_AFTER_MS = 30_000;
|
|
363
|
+
const SIXTH_SEAL_PERCENT = (6 / 7) * 100;
|
|
224
364
|
|
|
225
365
|
/** Golem obedience: share of tool calls that succeeded (null before the first tool). */
|
|
226
366
|
function syncPercent(): number | null {
|
|
@@ -234,12 +374,24 @@ function animating(now = Date.now()): boolean {
|
|
|
234
374
|
state.phase !== "idle" ||
|
|
235
375
|
swap.state === "checking" ||
|
|
236
376
|
swap.state === "loading" ||
|
|
377
|
+
(swap.state === "ready" && now - swap.since < ONLINE_MS) ||
|
|
237
378
|
state.compacting ||
|
|
238
379
|
now - state.rebornAt < REBIRTH_MS ||
|
|
239
380
|
now - state.lastFailAt < FAIL_FLASH_MS
|
|
240
381
|
);
|
|
241
382
|
}
|
|
242
383
|
|
|
384
|
+
/** What a tool works on, from its arguments: a path, a command, a pattern, or the first short string. */
|
|
385
|
+
function toolTarget(args: unknown): string {
|
|
386
|
+
if (!args || typeof args !== "object") return "";
|
|
387
|
+
const a = args as Record<string, unknown>;
|
|
388
|
+
for (const key of ["path", "file_path", "filePath", "command", "cmd", "pattern", "query", "url"]) {
|
|
389
|
+
if (typeof a[key] === "string" && a[key]) return String(a[key]).replace(/\s+/g, " ").trim();
|
|
390
|
+
}
|
|
391
|
+
const first = Object.values(a).find((v) => typeof v === "string" && v.length > 0 && v.length < 200);
|
|
392
|
+
return typeof first === "string" ? first.replace(/\s+/g, " ").trim() : "";
|
|
393
|
+
}
|
|
394
|
+
|
|
243
395
|
/** Performance of the current stream / last message. */
|
|
244
396
|
const perf = {
|
|
245
397
|
start: 0, // message_start
|
|
@@ -270,6 +422,8 @@ const sessionStart = Date.now();
|
|
|
270
422
|
|
|
271
423
|
let liveCtx: ExtensionContext | undefined;
|
|
272
424
|
|
|
425
|
+
/* ── token totals: counted once per message instead of rescanning the whole session ── */
|
|
426
|
+
|
|
273
427
|
interface TokenStats {
|
|
274
428
|
input: number;
|
|
275
429
|
output: number;
|
|
@@ -277,24 +431,26 @@ interface TokenStats {
|
|
|
277
431
|
cost: number;
|
|
278
432
|
}
|
|
279
433
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
function
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
434
|
+
const tokens: TokenStats = { input: 0, output: 0, cacheRead: 0, cost: 0 };
|
|
435
|
+
|
|
436
|
+
function addUsage(m: AssistantMessage): void {
|
|
437
|
+
tokens.input += m.usage?.input ?? 0;
|
|
438
|
+
tokens.output += m.usage?.output ?? 0;
|
|
439
|
+
tokens.cacheRead += m.usage?.cacheRead ?? 0;
|
|
440
|
+
tokens.cost += m.usage?.cost?.total ?? 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Full recount: on session start and when switching branch. Also restores the last council verdict. */
|
|
444
|
+
function recountSession(ctx: ExtensionContext): void {
|
|
445
|
+
Object.assign(tokens, { input: 0, output: 0, cacheRead: 0, cost: 0 });
|
|
446
|
+
state.lastCouncil = undefined;
|
|
447
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
448
|
+
if (entry.type === "message" && entry.message.role === "assistant") addUsage(entry.message as AssistantMessage);
|
|
449
|
+
if (entry.type === "custom" && entry.customType === "magi-verdict") {
|
|
450
|
+
const d = entry.data as { verdict?: Vote | null; tally?: number; question?: string } | undefined;
|
|
451
|
+
if (d && Array.isArray((d as any).opinions)) state.lastCouncil = { verdict: d.verdict ?? null, tally: d.tally ?? 0, question: d.question ?? "" };
|
|
294
452
|
}
|
|
295
453
|
}
|
|
296
|
-
statsCache = { at: now, value };
|
|
297
|
-
return value;
|
|
298
454
|
}
|
|
299
455
|
|
|
300
456
|
function fmtTokens(n: number): string {
|
|
@@ -322,11 +478,17 @@ function bar(theme: Theme, filled: number, total: number, tone: ThemeColor): str
|
|
|
322
478
|
return theme.fg(tone, "▓".repeat(f)) + theme.fg("dim", "░".repeat(total - f));
|
|
323
479
|
}
|
|
324
480
|
|
|
325
|
-
/** The seven seals of the context window: one breaks every 1/7 of it. */
|
|
326
|
-
function renderSeals(theme: Theme, percent: number): string {
|
|
481
|
+
/** The seven seals of the context window: one breaks every 1/7 of it; the sixth blinks as a warning. */
|
|
482
|
+
function renderSeals(theme: Theme, percent: number, now = Date.now()): string {
|
|
327
483
|
const broken = Math.min(7, Math.floor((percent / 100) * 7));
|
|
328
484
|
const tone = usageTone(percent);
|
|
329
|
-
|
|
485
|
+
let out = "";
|
|
486
|
+
for (let i = 0; i < 7; i++) {
|
|
487
|
+
if (i >= broken) out += theme.fg("dim", "○");
|
|
488
|
+
else if (broken === 6 && i === 5) out += theme.fg(Math.floor(now / 500) % 2 ? "warning" : "error", "◉");
|
|
489
|
+
else out += theme.fg(tone, "◉");
|
|
490
|
+
}
|
|
491
|
+
return out;
|
|
330
492
|
}
|
|
331
493
|
|
|
332
494
|
/* ──────────────────────────────────────────────────── MAGI triangle ── */
|
|
@@ -340,74 +502,193 @@ interface UnitView {
|
|
|
340
502
|
|
|
341
503
|
const MAGI_DIAGRAM_WIDTH = 32;
|
|
342
504
|
|
|
505
|
+
/* ── MAGI screen: pixel art drawn with half blocks, two pixels per cell (one above the other) ── */
|
|
506
|
+
|
|
343
507
|
/**
|
|
344
|
-
* The three MAGI
|
|
345
|
-
*
|
|
508
|
+
* The three MAGI as the panels of their control screen, 32×20 pixels: B = BALTHASAR, C = CASPAR, M = MELCHIOR
|
|
509
|
+
* (CASPAR and MELCHIOR are mirror images), o = the wires joining them.
|
|
346
510
|
*/
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
511
|
+
const MAGI_SCREEN = [
|
|
512
|
+
"..........BBBBBBBBBBBB..........",
|
|
513
|
+
"..........BBBBBBBBBBBB..........",
|
|
514
|
+
"..........BBBBBBBBBBBB..........",
|
|
515
|
+
"..........BBBBBBBBBBBB..........",
|
|
516
|
+
"..........BBBBBBBBBBBB..........",
|
|
517
|
+
"..........BBBBBBBBBBBB..........",
|
|
518
|
+
"..........BBBBBBBBBBBB..........",
|
|
519
|
+
"..........BBBBBBBBBBBB..........",
|
|
520
|
+
"...........BBBBBBBBBB...........",
|
|
521
|
+
"...........oBBBBBBBBo...........",
|
|
522
|
+
"CCCCCCCC..oo.BBBBBB.oo..MMMMMMMM",
|
|
523
|
+
"CCCCCCCCCoo..........ooMMMMMMMMM",
|
|
524
|
+
"CCCCCCCCCC............MMMMMMMMMM",
|
|
525
|
+
"CCCCCCCCCCC..........MMMMMMMMMMM",
|
|
526
|
+
"CCCCCCCCCCCC........MMMMMMMMMMMM",
|
|
527
|
+
"CCCCCCCCCCCCC......MMMMMMMMMMMMM",
|
|
528
|
+
"CCCCCCCCCCCCC......MMMMMMMMMMMMM",
|
|
529
|
+
"CCCCCCCCCCCCCooooooMMMMMMMMMMMMM",
|
|
530
|
+
"CCCCCCCCCCCCC......MMMMMMMMMMMMM",
|
|
531
|
+
"CCCCCCCCCCCCC......MMMMMMMMMMMMM",
|
|
532
|
+
];
|
|
533
|
+
|
|
534
|
+
interface ScreenLabel {
|
|
535
|
+
row: number;
|
|
536
|
+
center: number;
|
|
537
|
+
text: string;
|
|
538
|
+
fg: string; // SGR foreground escape
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const SCREEN_TEAL = "\x1b[38;2;64;180;220m";
|
|
542
|
+
const SCREEN_TEAL_DARK = "\x1b[38;2;22;62;78m";
|
|
543
|
+
const SCREEN_INK = "\x1b[38;2;10;10;10m";
|
|
544
|
+
const SGR_RESET = "\x1b[0m";
|
|
545
|
+
const toBg = (fg: string) => fg.replace("[38;", "[48;");
|
|
546
|
+
|
|
547
|
+
/** Paints each pixel of a rows×2 by cols grid, then writes the labels in bold over whatever is under them. */
|
|
548
|
+
function renderScreen(rows: number, cols: number, at: (y: number, x: number) => string | undefined, labels: readonly ScreenLabel[]): string[] {
|
|
549
|
+
const cells = Array.from({ length: rows }, (_, r) =>
|
|
550
|
+
Array.from({ length: cols }, (_, c) => {
|
|
551
|
+
const top = at(r * 2, c);
|
|
552
|
+
const bot = at(r * 2 + 1, c);
|
|
553
|
+
if (!top && !bot) return " ";
|
|
554
|
+
if (top === bot) return top + "█" + SGR_RESET;
|
|
555
|
+
if (top && bot) return top + toBg(bot) + "▀" + SGR_RESET;
|
|
556
|
+
return top ? top + "▀" + SGR_RESET : bot + "▄" + SGR_RESET;
|
|
557
|
+
}),
|
|
558
|
+
);
|
|
559
|
+
for (const l of labels) {
|
|
560
|
+
const chars = [...l.text];
|
|
561
|
+
const start = Math.round(l.center - chars.length / 2);
|
|
562
|
+
chars.forEach((ch, i) => {
|
|
563
|
+
const c = start + i;
|
|
564
|
+
const row = cells[l.row];
|
|
565
|
+
if (!row || c < 0 || c >= row.length) return;
|
|
566
|
+
const under = at(l.row * 2, c);
|
|
567
|
+
row[c] = (under ? toBg(under) : "") + l.fg + "\x1b[1m" + ch + SGR_RESET;
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return cells.map((r) => r.join(""));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/* ── the angel attack: red spreads through the MAGI while a model loads, blue takes them back when it is ready ── */
|
|
574
|
+
|
|
575
|
+
const SCREEN_RED = "\x1b[38;2;206;36;24m";
|
|
576
|
+
/** CASPAR's bottom-left corner: the red never gets there; once everything else has fallen it blinks until the model is loaded. */
|
|
577
|
+
const SAFE_PIXELS = new Set(["18,0", "18,1", "19,0", "19,1"]);
|
|
578
|
+
const LOAD_DEFAULT_MS = 60_000;
|
|
579
|
+
const RECOVER_MS = 1_600;
|
|
580
|
+
const ONLINE_MS = 3_000;
|
|
581
|
+
|
|
582
|
+
/** Blocky noise (2×2 pixel blocks), stable across renders: makes the front ragged like a corrupted screen. */
|
|
583
|
+
const blockNoise = (y: number, x: number) => {
|
|
584
|
+
const h = Math.sin(Math.floor(y / 2) * 127.1 + Math.floor(x / 2) * 311.7) * 43758.5453;
|
|
585
|
+
return h - Math.floor(h);
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
/** Pixel → 0..1 order in which it falls: BALTHASAR, then MELCHIOR, then CASPAR, each from where the previous one touches it. */
|
|
589
|
+
const INFECTION_ORDER: ReadonlyMap<string, number> = (() => {
|
|
590
|
+
const entry: Record<string, { unit: number; y: number; x: number }> = {
|
|
591
|
+
B: { unit: 0, y: 0, x: 10 },
|
|
592
|
+
M: { unit: 1, y: 10, x: 22 },
|
|
593
|
+
C: { unit: 2, y: 10, x: 9 },
|
|
352
594
|
};
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
595
|
+
const byUnit = new Map<string, { key: string; d: number }[]>();
|
|
596
|
+
MAGI_SCREEN.forEach((row, y) =>
|
|
597
|
+
[...row].forEach((ch, x) => {
|
|
598
|
+
const e = entry[ch];
|
|
599
|
+
if (!e || SAFE_PIXELS.has(`${y},${x}`)) return;
|
|
600
|
+
const list = byUnit.get(ch) ?? [];
|
|
601
|
+
list.push({ key: `${y},${x}`, d: Math.hypot(y - e.y, x - e.x) + blockNoise(y, x) * 7 });
|
|
602
|
+
byUnit.set(ch, list);
|
|
603
|
+
}),
|
|
604
|
+
);
|
|
605
|
+
const order = new Map<string, number>();
|
|
606
|
+
for (const [ch, list] of byUnit) {
|
|
607
|
+
list.sort((a, b) => a.d - b.d);
|
|
608
|
+
list.forEach((p, i) => order.set(p.key, (entry[ch]!.unit + i / list.length) / 3));
|
|
609
|
+
}
|
|
610
|
+
return order;
|
|
611
|
+
})();
|
|
612
|
+
|
|
613
|
+
/** Pixel → 0..1 order in which blue takes it back, spreading from the safe corner. */
|
|
614
|
+
const RECOVERY_ORDER: ReadonlyMap<string, number> = (() => {
|
|
615
|
+
const list: { key: string; d: number }[] = [];
|
|
616
|
+
MAGI_SCREEN.forEach((row, y) =>
|
|
617
|
+
[...row].forEach((ch, x) => {
|
|
618
|
+
if ("BCM".includes(ch)) list.push({ key: `${y},${x}`, d: Math.hypot(y - 19, x) + blockNoise(y, x) * 7 });
|
|
619
|
+
}),
|
|
620
|
+
);
|
|
621
|
+
list.sort((a, b) => a.d - b.d);
|
|
622
|
+
return new Map(list.map((p, i) => [p.key, i / list.length]));
|
|
623
|
+
})();
|
|
624
|
+
|
|
625
|
+
/** How far the attack went (0..1) and how far the recovery is (0..1); blink toggles the safe corner. */
|
|
626
|
+
interface Virus {
|
|
627
|
+
infected: number;
|
|
628
|
+
recovered: number;
|
|
629
|
+
blink: boolean;
|
|
630
|
+
}
|
|
358
631
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
632
|
+
/**
|
|
633
|
+
* The MAGI screen with each unit's name and status: a lit unit is filled (blue, or its state's color),
|
|
634
|
+
* a dark one is dimmed; the wires and the hub stay orange. 32×10 cells.
|
|
635
|
+
*/
|
|
636
|
+
function magiDiagram(th: Theme, top: UnitView, left: UnitView, right: UnitView, hub: string, virus?: Virus): string[] {
|
|
637
|
+
const fill = (u: UnitView) => (!u.lit ? SCREEN_TEAL_DARK : u.tone === "accent" ? SCREEN_TEAL : th.getFgAnsi(u.tone));
|
|
638
|
+
const ink = (u: UnitView) => (u.lit ? SCREEN_INK : th.getFgAnsi("muted"));
|
|
639
|
+
const wire = th.getFgAnsi("accent");
|
|
640
|
+
const unit = (u: UnitView, row: number, name: number, status: number): ScreenLabel[] => [
|
|
641
|
+
{ row, center: name, text: u.name, fg: ink(u) },
|
|
642
|
+
{ row: row + 1, center: status, text: u.status, fg: ink(u) },
|
|
368
643
|
];
|
|
644
|
+
const paint: Record<string, string> = { B: fill(top), C: fill(left), M: fill(right), o: wire };
|
|
645
|
+
const at = (y: number, x: number) => {
|
|
646
|
+
const ch = MAGI_SCREEN[y]![x]!;
|
|
647
|
+
if (virus && ch !== "o" && ch !== ".") {
|
|
648
|
+
const key = `${y},${x}`;
|
|
649
|
+
// the last stronghold: it blinks only once everything else has fallen, until the recovery starts
|
|
650
|
+
if (SAFE_PIXELS.has(key)) return virus.infected >= 1 && virus.recovered <= 0 && virus.blink ? SCREEN_TEAL_DARK : SCREEN_TEAL;
|
|
651
|
+
if ((INFECTION_ORDER.get(key) ?? 1) < virus.infected && (RECOVERY_ORDER.get(key) ?? 0) >= virus.recovered) return SCREEN_RED;
|
|
652
|
+
}
|
|
653
|
+
return paint[ch];
|
|
654
|
+
};
|
|
655
|
+
return renderScreen(
|
|
656
|
+
10,
|
|
657
|
+
MAGI_DIAGRAM_WIDTH,
|
|
658
|
+
at,
|
|
659
|
+
[...unit(top, 2, 16, 16), ...unit(left, 7, 6, 6), ...unit(right, 7, 26, 26), { row: 7, center: 16, text: hub, fg: wire }],
|
|
660
|
+
);
|
|
369
661
|
}
|
|
370
662
|
|
|
371
663
|
/* ─────────────────────────────────────────────────────────── header ── */
|
|
372
664
|
|
|
373
665
|
/** Static header: it scrolls away with the conversation, so nothing here animates. */
|
|
374
666
|
function buildHeader(theme: Theme) {
|
|
375
|
-
const tree = renderTreeOfLife(theme);
|
|
376
|
-
|
|
377
667
|
return {
|
|
378
668
|
render(width: number): string[] {
|
|
379
669
|
const orange = (s: string) => theme.fg("accent", s);
|
|
380
|
-
const
|
|
381
|
-
const
|
|
382
|
-
const
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const lines = [""];
|
|
401
|
-
for (let i = 0; i < tree.length; i++) {
|
|
402
|
-
let right = "";
|
|
403
|
-
if (i >= 5 && i <= 10) right = orange(MAGI_WORD[i - 5]!);
|
|
404
|
-
else if (i === 12) right = dim("├─ ") + triad + dim(" ─┤");
|
|
405
|
-
else if (i === 13) right = muted(subtitle);
|
|
406
|
-
else if (i === 14) right = dim(lore);
|
|
407
|
-
lines.push(truncateToWidth(tree[i]! + " " + right, width));
|
|
670
|
+
const note = theme.fg("dim", "fan-art theme inspired by Neon Genesis Evangelion · all rights reserved to khara, Inc.");
|
|
671
|
+
const oneLine = MAGI_WORD.map((l, i) => l + " " + SYSTEM_WORD[i]);
|
|
672
|
+
const stacked = [...MAGI_WORD, "", ...SYSTEM_WORD];
|
|
673
|
+
const word = width >= SEELE_WIDTH + 4 + oneLine[0]!.length ? oneLine : stacked;
|
|
674
|
+
|
|
675
|
+
let lines: string[];
|
|
676
|
+
if (width >= SEELE_WIDTH + 4 + word[0]!.length) {
|
|
677
|
+
// the seal on the left of the wordmark, both centered on the taller one
|
|
678
|
+
const h = Math.max(SEELE_LOGO.length, word.length);
|
|
679
|
+
const sealAt = Math.floor((h - SEELE_LOGO.length) / 2);
|
|
680
|
+
const wordAt = Math.floor((h - word.length) / 2);
|
|
681
|
+
lines = Array.from({ length: h }, (_, i) => {
|
|
682
|
+
const seal = (SEELE_LOGO[i - sealAt] ?? "").padEnd(SEELE_WIDTH);
|
|
683
|
+
const w = word[i - wordAt];
|
|
684
|
+
return theme.fg("text", seal) + (w ? " " + orange(w) : "");
|
|
685
|
+
});
|
|
686
|
+
} else if (width >= SYSTEM_WORD[0]!.length) {
|
|
687
|
+
lines = stacked.map(orange);
|
|
688
|
+
} else {
|
|
689
|
+
lines = [orange(theme.bold("◆ MAGI SYSTEM"))];
|
|
408
690
|
}
|
|
409
|
-
lines.
|
|
410
|
-
return lines;
|
|
691
|
+
return ["", ...lines, note, ""].map((l) => truncateToWidth(l, width));
|
|
411
692
|
},
|
|
412
693
|
invalidate() {},
|
|
413
694
|
};
|
|
@@ -424,16 +705,19 @@ interface GpuStat {
|
|
|
424
705
|
power: number;
|
|
425
706
|
}
|
|
426
707
|
|
|
427
|
-
type SwapState = "off" | "checking" | "loading" | "ready" | "error";
|
|
708
|
+
type SwapState = "off" | "checking" | "loading" | "ready" | "asleep" | "error";
|
|
428
709
|
|
|
429
710
|
/** Live state of the llama-swap server behind the session model (only when the provider is "llama-swap"). */
|
|
430
711
|
const swap = {
|
|
431
712
|
base: "", // e.g. http://host:9292
|
|
432
713
|
headers: {} as Record<string, string>,
|
|
433
|
-
modelId: "",
|
|
714
|
+
modelId: "", // the id pi uses (may be an alias)
|
|
715
|
+
realId: "", // the model llama-swap actually runs for that id
|
|
434
716
|
state: "off" as SwapState,
|
|
435
717
|
since: 0, // when the current state started
|
|
436
718
|
loadMs: 0, // how long the last real load took
|
|
719
|
+
expectedLoadMs: LOAD_DEFAULT_MS, // how long this model took to load last time
|
|
720
|
+
infection: 0, // how far the red went when the model became ready (0 = it was already in VRAM)
|
|
437
721
|
error: "",
|
|
438
722
|
gpus: [] as GpuStat[],
|
|
439
723
|
ramUsed: 0,
|
|
@@ -442,6 +726,9 @@ const swap = {
|
|
|
442
726
|
srvPps: 0, // server-measured prompt processing tok/s
|
|
443
727
|
cacheTokens: 0,
|
|
444
728
|
inputTokens: 0,
|
|
729
|
+
energyWh: 0, // GPU energy since the session started
|
|
730
|
+
lastSampleAt: 0,
|
|
731
|
+
lastWatts: 0,
|
|
445
732
|
};
|
|
446
733
|
|
|
447
734
|
function setSwapState(s: SwapState, error = ""): void {
|
|
@@ -478,20 +765,46 @@ function parseSwapMetrics(text: string): void {
|
|
|
478
765
|
swap.gpus = [...gpus.entries()].sort(([a], [b]) => Number(a) - Number(b)).map(([, g]) => g);
|
|
479
766
|
}
|
|
480
767
|
|
|
768
|
+
/**
|
|
769
|
+
* Integrates GPU power over time into Wh.
|
|
770
|
+
* ponytail: trapezoid over the 3–30s poll samples, GPUs only; gaps over 60s are clamped.
|
|
771
|
+
*/
|
|
772
|
+
function sampleEnergy(now = Date.now()): void {
|
|
773
|
+
const watts = swap.gpus.reduce((sum, g) => sum + g.power, 0);
|
|
774
|
+
if (swap.lastSampleAt) {
|
|
775
|
+
const dtH = Math.min(60_000, now - swap.lastSampleAt) / 3_600_000;
|
|
776
|
+
swap.energyWh += ((swap.lastWatts + watts) / 2) * dtH;
|
|
777
|
+
}
|
|
778
|
+
swap.lastSampleAt = now;
|
|
779
|
+
swap.lastWatts = watts;
|
|
780
|
+
}
|
|
781
|
+
|
|
481
782
|
async function refreshSwapMetrics(): Promise<void> {
|
|
482
783
|
if (!swap.base) return;
|
|
483
784
|
try {
|
|
484
785
|
parseSwapMetrics(await (await swapGet("/metrics")).text());
|
|
786
|
+
sampleEnergy();
|
|
485
787
|
} catch {
|
|
486
788
|
// server unreachable: keep the last values
|
|
487
789
|
}
|
|
488
790
|
}
|
|
489
791
|
|
|
490
|
-
/**
|
|
792
|
+
/** Notices when llama-swap unloaded the session model (e.g. its ttl expired) so the panel can say so. */
|
|
793
|
+
async function refreshSwapRunning(): Promise<void> {
|
|
794
|
+
if (!swap.base || swap.state !== "ready") return;
|
|
795
|
+
try {
|
|
796
|
+
const { running } = (await (await swapGet("/running")).json()) as { running?: { model: string }[] };
|
|
797
|
+
if (!(running ?? []).some((r) => r.model === swap.realId)) setSwapState("asleep");
|
|
798
|
+
} catch {
|
|
799
|
+
// optional
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Server-side token metrics of the most recent request: a single row from /api/metrics/activity. */
|
|
491
804
|
async function refreshSwapActivity(): Promise<void> {
|
|
492
805
|
if (!swap.base) return;
|
|
493
806
|
try {
|
|
494
|
-
const { data } = (await (await swapGet("/api/metrics/activity")).json()) as { data?: any[] };
|
|
807
|
+
const { data } = (await (await swapGet("/api/metrics/activity?limit=1")).json()) as { data?: any[] };
|
|
495
808
|
const t = data?.[0]?.tokens;
|
|
496
809
|
if (!t) return;
|
|
497
810
|
if (t.tokens_per_second > 0) swap.srvTps = t.tokens_per_second;
|
|
@@ -503,18 +816,74 @@ async function refreshSwapActivity(): Promise<void> {
|
|
|
503
816
|
}
|
|
504
817
|
}
|
|
505
818
|
|
|
819
|
+
/** Context filled right now in llama-server's busy slot: prompt tokens placed so far plus the ones generated. */
|
|
820
|
+
const live = { tokens: 0, at: 0 };
|
|
821
|
+
const LIVE_POLL_MS = 500;
|
|
822
|
+
const LIVE_STALE_MS = 2_000;
|
|
823
|
+
|
|
506
824
|
/**
|
|
507
|
-
*
|
|
508
|
-
*
|
|
825
|
+
* Reads the busy slot of the session model (llama-server /slots): n_prompt_tokens grows batch by batch
|
|
826
|
+
* while the prompt is processed, then token by token while the model thinks and answers.
|
|
827
|
+
* ponytail: with several slots it takes the fullest busy one, assuming it is this session's request.
|
|
509
828
|
*/
|
|
510
|
-
async function
|
|
829
|
+
async function refreshLiveContext(): Promise<void> {
|
|
830
|
+
if (!swap.base || swap.state !== "ready") return;
|
|
831
|
+
try {
|
|
832
|
+
const slots = (await (await swapGet(`/upstream/${encodeURIComponent(swap.realId)}/slots`, 1_500)).json()) as any[];
|
|
833
|
+
const busy = slots.filter((s) => s.is_processing && s.n_prompt_tokens > 0);
|
|
834
|
+
if (!busy.length) return;
|
|
835
|
+
live.tokens = Math.max(...busy.map((s) => s.n_prompt_tokens));
|
|
836
|
+
live.at = Date.now();
|
|
837
|
+
} catch {
|
|
838
|
+
// the pi estimate stays
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Context usage for the seals: live from the slot while the model works, pi's estimate otherwise. */
|
|
843
|
+
function contextUsage(): { tokens: number | null; contextWindow: number; percent: number; live: boolean } | undefined {
|
|
844
|
+
const usage = liveCtx?.getContextUsage?.();
|
|
845
|
+
if (!usage) return undefined;
|
|
846
|
+
if (state.phase !== "idle" && usage.contextWindow && Date.now() - live.at < LIVE_STALE_MS) {
|
|
847
|
+
return { tokens: live.tokens, contextWindow: usage.contextWindow, percent: Math.min(100, (live.tokens / usage.contextWindow) * 100), live: true };
|
|
848
|
+
}
|
|
849
|
+
return { tokens: usage.tokens, contextWindow: usage.contextWindow, percent: usage.percent ?? 0, live: false };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** id → the model llama-swap actually runs for it: an alias (e.g. "… - Instruct") runs another model. */
|
|
853
|
+
async function swapAliases(): Promise<Map<string, string>> {
|
|
854
|
+
const aliases = new Map<string, string>();
|
|
855
|
+
try {
|
|
856
|
+
const { data } = (await (await swapGet("/v1/models")).json()) as { data?: any[] };
|
|
857
|
+
for (const m of data ?? []) {
|
|
858
|
+
const ls = m.meta?.llamaswap;
|
|
859
|
+
aliases.set(m.id, ls?.type === "alias" && ls.modelID ? ls.modelID : m.id);
|
|
860
|
+
}
|
|
861
|
+
} catch {
|
|
862
|
+
// ids stay as they are
|
|
863
|
+
}
|
|
864
|
+
return aliases;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Models llama-swap keeps in memory: real id → "ready" | "starting" | … (empty when the server is unreachable). */
|
|
868
|
+
async function swapRunning(): Promise<Map<string, string>> {
|
|
869
|
+
try {
|
|
870
|
+
const { running } = (await (await swapGet("/running")).json()) as { running?: { model: string; state: string }[] };
|
|
871
|
+
return new Map((running ?? []).map((r) => [r.model, r.state]));
|
|
872
|
+
} catch {
|
|
873
|
+
return new Map();
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Points the llama-swap monitor at the server behind `model`, without loading anything; false when llama-swap doesn't serve it. */
|
|
878
|
+
async function connectSwap(ctx: ExtensionContext, model: Model<any> | undefined): Promise<boolean> {
|
|
511
879
|
if (!model || model.provider !== "llama-swap" || !model.baseUrl) {
|
|
512
880
|
swap.base = "";
|
|
513
881
|
setSwapState("off");
|
|
514
|
-
return;
|
|
882
|
+
return false;
|
|
515
883
|
}
|
|
516
884
|
const id = model.id;
|
|
517
885
|
swap.modelId = id;
|
|
886
|
+
swap.realId = id;
|
|
518
887
|
setSwapState("checking");
|
|
519
888
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
520
889
|
swap.base = model.baseUrl.replace(/\/v1\/?$/, "");
|
|
@@ -524,6 +893,27 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
|
|
|
524
893
|
};
|
|
525
894
|
void refreshSwapMetrics();
|
|
526
895
|
void refreshSwapActivity();
|
|
896
|
+
const aliases = await swapAliases();
|
|
897
|
+
if (swap.modelId !== id) return true;
|
|
898
|
+
swap.realId = aliases.get(id) ?? id;
|
|
899
|
+
return true;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Shows whether the session model is already in VRAM or asleep, without loading it: typing wakes it. */
|
|
903
|
+
async function probeModel(ctx: ExtensionContext, model: Model<any> | undefined = ctx.model): Promise<void> {
|
|
904
|
+
if (!(await connectSwap(ctx, model))) return;
|
|
905
|
+
const state = (await swapRunning()).get(swap.realId);
|
|
906
|
+
if (swap.modelId === model!.id) setSwapState(state === "ready" ? "ready" : "asleep");
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Makes sure the session model is in VRAM. Any request under /upstream/<model>/ makes llama-swap
|
|
911
|
+
* load the model if needed, and it only answers once the model is ready.
|
|
912
|
+
*/
|
|
913
|
+
async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined = ctx.model): Promise<void> {
|
|
914
|
+
if (!(await connectSwap(ctx, model))) return;
|
|
915
|
+
const id = model!.id;
|
|
916
|
+
swap.expectedLoadMs = loadMagiConfig().loads?.[swap.realId] ?? LOAD_DEFAULT_MS;
|
|
527
917
|
|
|
528
918
|
// no answer within 400ms → the model isn't in VRAM and is being loaded
|
|
529
919
|
const slow = setTimeout(() => {
|
|
@@ -534,8 +924,15 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
|
|
|
534
924
|
const res = await swapGet(`/upstream/${encodeURIComponent(id)}/health`, 15 * 60_000);
|
|
535
925
|
if (swap.modelId !== id) return; // the model changed meanwhile
|
|
536
926
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
537
|
-
|
|
927
|
+
const loaded = swap.state === "loading";
|
|
928
|
+
if (loaded) {
|
|
929
|
+
swap.loadMs = Date.now() - started;
|
|
930
|
+
swap.infection = Math.min(1, swap.loadMs / swap.expectedLoadMs);
|
|
931
|
+
const cfg = loadMagiConfig();
|
|
932
|
+
saveMagiConfig({ ...cfg, loads: { ...cfg.loads, [swap.realId]: swap.loadMs } });
|
|
933
|
+
} else swap.infection = 0;
|
|
538
934
|
setSwapState("ready");
|
|
935
|
+
if (!ctx.sessionManager.getBranch().some((e) => e.type === "message")) void prewarmPrefix(ctx.cwd);
|
|
539
936
|
} catch (err) {
|
|
540
937
|
if (swap.modelId === id) setSwapState("error", err instanceof Error ? err.message : String(err));
|
|
541
938
|
} finally {
|
|
@@ -544,18 +941,158 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
|
|
|
544
941
|
}
|
|
545
942
|
}
|
|
546
943
|
|
|
944
|
+
/* ── prefix prewarm: a new session's system prompt and tools are processed before its first message ── */
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* ~/.pi/agent/magi-prefix.json: "<project dir>\n<model id>" → the request body every new session shares
|
|
948
|
+
* (the system message, tools and request options of pi's last real request), so the prewarmed text is identical.
|
|
949
|
+
*/
|
|
950
|
+
const PREFIX_STORE_PATH = join(homedir(), ".pi", "agent", "magi-prefix.json");
|
|
951
|
+
|
|
952
|
+
const prewarm = { state: "" as "" | "running" | "done", tokens: 0, ms: 0, abort: undefined as AbortController | undefined };
|
|
953
|
+
let prefixMemo = "";
|
|
954
|
+
|
|
955
|
+
function loadPrefixStore(): Record<string, any> {
|
|
956
|
+
try {
|
|
957
|
+
return JSON.parse(readFileSync(PREFIX_STORE_PATH, "utf8"));
|
|
958
|
+
} catch {
|
|
959
|
+
return {};
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Keeps the shared part of pi's real llama-swap request; written only when it changes (new tools, another thinking level…). */
|
|
964
|
+
function rememberPrefix(cwd: string, payload: any): void {
|
|
965
|
+
if (!swap.base || payload?.model !== swap.modelId || payload.messages?.[0]?.role !== "system") return;
|
|
966
|
+
const { stream, stream_options, max_tokens, max_completion_tokens, messages, ...options } = payload;
|
|
967
|
+
const body = { ...options, messages: [messages[0]] };
|
|
968
|
+
const json = JSON.stringify(body);
|
|
969
|
+
if (json === prefixMemo) return;
|
|
970
|
+
prefixMemo = json;
|
|
971
|
+
const store = loadPrefixStore();
|
|
972
|
+
store[`${cwd}\n${payload.model}`] = body;
|
|
973
|
+
writeFileSync(PREFIX_STORE_PATH, JSON.stringify(store));
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Processes the stored prefix so llama-server checkpoints the state exactly where a new session's prompt diverges:
|
|
978
|
+
* the first message then costs its own tokens (seconds) instead of the whole system prompt and tools (~80s cold).
|
|
979
|
+
* ponytail: cuts at the ChatML user marker (Qwen and most llama.cpp templates); other templates are skipped.
|
|
980
|
+
*/
|
|
981
|
+
async function prewarmPrefix(cwd: string): Promise<void> {
|
|
982
|
+
const body = loadPrefixStore()[`${cwd}\n${swap.modelId}`];
|
|
983
|
+
if (!body || prewarm.state === "running") return;
|
|
984
|
+
const abort = new AbortController();
|
|
985
|
+
prewarm.abort = abort;
|
|
986
|
+
prewarm.state = "running";
|
|
987
|
+
const started = Date.now();
|
|
988
|
+
const post = async (path: string, json: unknown): Promise<any> =>
|
|
989
|
+
(
|
|
990
|
+
await fetch(`${swap.base}/upstream/${encodeURIComponent(swap.realId)}${path}`, {
|
|
991
|
+
method: "POST",
|
|
992
|
+
headers: { ...swap.headers, "Content-Type": "application/json" },
|
|
993
|
+
body: JSON.stringify(json),
|
|
994
|
+
signal: abort.signal,
|
|
995
|
+
})
|
|
996
|
+
).json();
|
|
997
|
+
try {
|
|
998
|
+
// the template renders only with a user turn: the prefix ends where that turn starts
|
|
999
|
+
const { prompt } = await post("/apply-template", { ...body, messages: [...body.messages, { role: "user", content: "." }] });
|
|
1000
|
+
const cut = typeof prompt === "string" ? prompt.indexOf("<|im_start|>user") : -1;
|
|
1001
|
+
if (cut <= 0) {
|
|
1002
|
+
prewarm.state = "";
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const { timings } = await post("/completion", { prompt: prompt.slice(0, cut), n_predict: 1, cache_prompt: true });
|
|
1006
|
+
prewarm.tokens = (timings?.prompt_n ?? 0) + (timings?.cache_n ?? 0);
|
|
1007
|
+
prewarm.ms = Date.now() - started;
|
|
1008
|
+
prewarm.state = "done";
|
|
1009
|
+
} catch {
|
|
1010
|
+
// aborted by the first message, or the server went away
|
|
1011
|
+
prewarm.state = "";
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/* ── /magi-ui status: a report built from the last requests llama-swap recorded ── */
|
|
1016
|
+
|
|
1017
|
+
interface ActivityRow {
|
|
1018
|
+
timestamp: string;
|
|
1019
|
+
model: string;
|
|
1020
|
+
resp_status_code: number;
|
|
1021
|
+
duration_ms: number;
|
|
1022
|
+
tokens?: {
|
|
1023
|
+
cache_tokens?: number;
|
|
1024
|
+
input_tokens?: number;
|
|
1025
|
+
output_tokens?: number;
|
|
1026
|
+
prompt_per_second?: number;
|
|
1027
|
+
tokens_per_second?: number;
|
|
1028
|
+
draft_tokens?: number;
|
|
1029
|
+
draft_acc_tokens?: number;
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const ACTIVITY_REPORT_ROWS = 100;
|
|
1034
|
+
|
|
1035
|
+
function activityReport(th: Theme, rows: ActivityRow[], total: number, now = Date.now()): string[] {
|
|
1036
|
+
const dim = (s: string) => th.fg("dim", s);
|
|
1037
|
+
const mean = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
|
|
1038
|
+
const ago = (iso: string) => {
|
|
1039
|
+
const m = Math.max(0, Math.round((now - Date.parse(iso)) / 60000));
|
|
1040
|
+
return m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ${m % 60}m ago`;
|
|
1041
|
+
};
|
|
1042
|
+
const out = [th.fg("accent", th.bold("LLAMA-SWAP STATUS")) + dim(` · last ${rows.length} of ${total} requests`)];
|
|
1043
|
+
const byModel = new Map<string, ActivityRow[]>();
|
|
1044
|
+
for (const r of rows) byModel.set(r.model, [...(byModel.get(r.model) ?? []), r]);
|
|
1045
|
+
|
|
1046
|
+
for (const [model, rs] of byModel) {
|
|
1047
|
+
const t = rs.map((r) => r.tokens ?? {});
|
|
1048
|
+
const sum = (k: keyof NonNullable<ActivityRow["tokens"]>) => t.reduce((a, x) => a + Math.max(0, x[k] ?? 0), 0);
|
|
1049
|
+
const errors = rs.filter((r) => r.resp_status_code >= 400).length;
|
|
1050
|
+
const gen = mean(t.map((x) => x.tokens_per_second ?? 0).filter((v) => v > 0));
|
|
1051
|
+
const prompt = mean(t.map((x) => x.prompt_per_second ?? 0).filter((v) => v > 0));
|
|
1052
|
+
const cached = sum("cache_tokens");
|
|
1053
|
+
const input = sum("input_tokens");
|
|
1054
|
+
const draft = sum("draft_tokens");
|
|
1055
|
+
const accepted = sum("draft_acc_tokens");
|
|
1056
|
+
const durations = rs.map((r) => r.duration_ms).sort((a, b) => a - b);
|
|
1057
|
+
const p95 = durations[Math.min(durations.length - 1, Math.floor(durations.length * 0.95))] ?? 0;
|
|
1058
|
+
out.push("", th.fg("text", th.bold(model)));
|
|
1059
|
+
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)}`));
|
|
1060
|
+
out.push(
|
|
1061
|
+
dim(" speed ") +
|
|
1062
|
+
th.fg("success", `gen ${gen.toFixed(1)} tok/s`) +
|
|
1063
|
+
dim(" · ") +
|
|
1064
|
+
th.fg("text", `prompt ${prompt.toFixed(1)} tok/s`) +
|
|
1065
|
+
(draft ? dim(" · draft accepted ") + th.fg("text", `${Math.round((accepted / draft) * 100)}%`) : ""),
|
|
1066
|
+
);
|
|
1067
|
+
out.push(
|
|
1068
|
+
dim(" tokens ") +
|
|
1069
|
+
th.fg("text", `in ${fmtTokens(input)} · cached ${fmtTokens(cached)}`) +
|
|
1070
|
+
dim(` (hit ${cached + input ? Math.round((cached / (cached + input)) * 100) : 0}%)`) +
|
|
1071
|
+
th.fg("text", ` · out ${fmtTokens(sum("output_tokens"))}`),
|
|
1072
|
+
);
|
|
1073
|
+
out.push(dim(" duration ") + th.fg("text", `avg ${fmtMs(mean(durations))} · p95 ${fmtMs(p95)} · max ${fmtMs(durations.at(-1) ?? 0)}`));
|
|
1074
|
+
}
|
|
1075
|
+
const slowest = [...rows].sort((a, b) => b.duration_ms - a.duration_ms)[0];
|
|
1076
|
+
if (slowest) {
|
|
1077
|
+
const st = slowest.tokens ?? {};
|
|
1078
|
+
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)}`));
|
|
1079
|
+
}
|
|
1080
|
+
return out;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
547
1083
|
/* ─────────────────────────────────────────────────────── side panel ── */
|
|
548
1084
|
|
|
549
1085
|
const PANEL_WIDTH = MAGI_DIAGRAM_WIDTH + 2;
|
|
550
1086
|
|
|
551
|
-
/** Frames per deliberation cycle while thinking: units light up one by one, then consensus. */
|
|
552
|
-
const CYCLE = 32;
|
|
553
|
-
|
|
554
1087
|
class MagiPanel implements Component {
|
|
555
1088
|
private frame = 0;
|
|
556
1089
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
1090
|
+
/** The data rows change slowly: they are rebuilt at most twice a second, only the council animates every frame. */
|
|
1091
|
+
private cache: { at: number; inner: number; compact: boolean; lines: string[] } | undefined;
|
|
557
1092
|
/** git branch, provided by the footer (the only place pi exposes it). */
|
|
558
1093
|
branch?: () => string | null | undefined;
|
|
1094
|
+
/** When set (fullscreen column), the panel pads itself down to this many rows. */
|
|
1095
|
+
fillHeight?: () => number;
|
|
559
1096
|
|
|
560
1097
|
constructor(
|
|
561
1098
|
private readonly tui: TUI,
|
|
@@ -568,7 +1105,9 @@ class MagiPanel implements Component {
|
|
|
568
1105
|
}, 125);
|
|
569
1106
|
}
|
|
570
1107
|
|
|
571
|
-
invalidate(): void {
|
|
1108
|
+
invalidate(): void {
|
|
1109
|
+
this.cache = undefined;
|
|
1110
|
+
}
|
|
572
1111
|
|
|
573
1112
|
dispose(): void {
|
|
574
1113
|
if (this.timer) clearInterval(this.timer);
|
|
@@ -588,12 +1127,19 @@ class MagiPanel implements Component {
|
|
|
588
1127
|
return b("│") + this.pad(content, inner) + b("│");
|
|
589
1128
|
}
|
|
590
1129
|
|
|
591
|
-
private
|
|
1130
|
+
private labelLine(inner: number, label: string, left: string, right: string, tone: ThemeColor): string {
|
|
592
1131
|
const b = (s: string) => this.theme.fg("border", s);
|
|
593
|
-
if (!label) return b("├" + "─".repeat(inner) + "┤");
|
|
594
1132
|
const text = ` ${truncateToWidth(label, inner - 4)} `;
|
|
595
1133
|
const rest = Math.max(0, inner - visibleWidth(text) - 1);
|
|
596
|
-
return b("
|
|
1134
|
+
return b(left + "─") + this.theme.fg(tone, text) + b("─".repeat(rest) + right);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
private sep(inner: number, label: string): string {
|
|
1138
|
+
return this.labelLine(inner, label, "├", "┤", "muted");
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
private centered(content: string, inner: number): string {
|
|
1142
|
+
return this.frameLine(" ".repeat(Math.max(0, Math.floor((inner - visibleWidth(content)) / 2))) + content, inner);
|
|
597
1143
|
}
|
|
598
1144
|
|
|
599
1145
|
private field(label: string, value: string, inner: number, tone: ThemeColor = "text"): string {
|
|
@@ -617,151 +1163,117 @@ class MagiPanel implements Component {
|
|
|
617
1163
|
});
|
|
618
1164
|
}
|
|
619
1165
|
|
|
620
|
-
/* ── MAGI
|
|
1166
|
+
/* ── top of the panel: the MAGI (flickering while working) or the golem (tools) ── */
|
|
621
1167
|
|
|
622
1168
|
private councilSection(inner: number): string[] {
|
|
623
1169
|
const th = this.theme;
|
|
624
1170
|
const f = this.frame;
|
|
625
1171
|
const now = Date.now();
|
|
1172
|
+
const secs = (t: number) => `${secsSince(t, now)}s`;
|
|
1173
|
+
const pulse = animating(now) ? pulseFrames(th)[f % 6]! : th.fg("dim", "◇");
|
|
1174
|
+
|
|
1175
|
+
const idle = state.phase === "idle";
|
|
1176
|
+
const fallen = now - state.lastFailAt < FAIL_FLASH_MS;
|
|
626
1177
|
let title: string;
|
|
1178
|
+
let titleTone: ThemeColor = "accent";
|
|
627
1179
|
let body: string[];
|
|
1180
|
+
let status: string;
|
|
628
1181
|
|
|
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`;
|
|
1182
|
+
if (state.phase === "tool" || (idle && fallen)) {
|
|
1183
|
+
title = fallen ? `GOLEM FELL · MET · ${state.lastFailTool}` : `GOLEM · ${state.toolName || "tool"} · ${secs(state.phaseSince)}`;
|
|
1184
|
+
titleTone = fallen ? "error" : "warning";
|
|
639
1185
|
body = this.golemArt(inner);
|
|
1186
|
+
const target = (fallen ? state.lastFailTarget || state.lastFailTool : state.toolTarget || state.toolName) || "tool";
|
|
1187
|
+
status = `${pulse} ${th.fg(fallen ? "error" : "text", truncateToWidth(target, inner - 4))}`;
|
|
640
1188
|
} 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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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) {
|
|
1189
|
+
let word: string;
|
|
1190
|
+
let tone: ThemeColor;
|
|
1191
|
+
let hub = "─MAGI─";
|
|
1192
|
+
let lit = (i: number) => flicker(f, i);
|
|
1193
|
+
let virus: Virus | undefined;
|
|
1194
|
+
const breathing = ["─MAGI─", "━MAGI━"][f % 2]!;
|
|
1195
|
+
|
|
1196
|
+
if (state.compacting) {
|
|
1197
|
+
title = `COMPACTING · ${state.compactBy || "context"} · ${secs(state.compactSince)}`;
|
|
1198
|
+
[word, tone, hub, titleTone] = ["COMPACTING", "warning", ["─SEAL─", "━SEAL━"][f % 2]!, "warning"];
|
|
1199
|
+
} else if (now - state.rebornAt < REBIRTH_MS) {
|
|
1200
|
+
title = "SEVENTH SEAL OPENED";
|
|
1201
|
+
[word, tone, hub, titleTone] = ["REBORN", "success", "═MAGI═", "success"];
|
|
1202
|
+
lit = () => true;
|
|
1203
|
+
} else if (idle && (swap.state === "checking" || swap.state === "loading")) {
|
|
1204
|
+
title = swap.state === "loading" ? `LOADING MODEL · ${secs(swap.since)}` : "CHECKING MODEL";
|
|
1205
|
+
[word, tone, hub, titleTone] = ["LOADING", "accent", breathing, "warning"];
|
|
1206
|
+
lit = () => true;
|
|
1207
|
+
if (swap.state === "loading")
|
|
1208
|
+
virus = { infected: Math.min(1, (now - swap.since) / swap.expectedLoadMs), recovered: 0, blink: f % 4 < 2 };
|
|
1209
|
+
} else if (idle && swap.state === "error") {
|
|
671
1210
|
title = "MAGI OFFLINE";
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
1211
|
+
[word, tone, hub, titleTone] = ["OFFLINE", "error", "─ ╳ ──", "error"];
|
|
1212
|
+
lit = () => f % 16 < 8;
|
|
1213
|
+
} else if (idle && swap.state === "asleep") {
|
|
1214
|
+
title = "MODEL ASLEEP · type to wake";
|
|
1215
|
+
[word, tone, titleTone] = ["ASLEEP", "muted", "muted"];
|
|
1216
|
+
lit = () => false;
|
|
1217
|
+
} else if (idle && swap.state === "ready" && now - swap.since < ONLINE_MS) {
|
|
676
1218
|
title = swap.loadMs ? `MAGI ONLINE · ${fmtMs(swap.loadMs)}` : "MAGI ONLINE";
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1219
|
+
[word, tone, hub, titleTone] = ["ONLINE", "accent", "═MAGI═", "success"];
|
|
1220
|
+
lit = () => true;
|
|
1221
|
+
virus = { infected: swap.infection, recovered: (now - swap.since) / RECOVER_MS, blink: f % 4 < 2 };
|
|
680
1222
|
} 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]!;
|
|
1223
|
+
title = `THINKING · ${secs(state.phaseSince)}`;
|
|
1224
|
+
[word, tone, hub] = ["THINKING", "accent", breathing];
|
|
693
1225
|
} else if (state.phase === "responding") {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
hub = "
|
|
697
|
-
units = names.map((name) => ({ name, status: "APPROVE", tone: "success", lit: true }));
|
|
1226
|
+
const tps = liveTps();
|
|
1227
|
+
title = `RESPONDING${tps ? ` · ${tps.toFixed(1)} tok/s` : ""}`;
|
|
1228
|
+
[word, tone, hub, titleTone] = ["RESPONDING", "success", breathing, "success"];
|
|
698
1229
|
} else {
|
|
699
|
-
title = "
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
units = names.map((name) => ({ name, status: "STANDBY", tone: "success", lit: false }));
|
|
1230
|
+
title = "STANDBY";
|
|
1231
|
+
[word, tone, titleTone] = ["STANDBY", "muted", "muted"];
|
|
1232
|
+
lit = () => false;
|
|
703
1233
|
}
|
|
704
|
-
body = magiDiagram(th, units[1]!, units[2]!, units[0]!, link, hub);
|
|
705
|
-
}
|
|
706
1234
|
|
|
707
|
-
|
|
708
|
-
|
|
1235
|
+
const units = MAGI_UNITS.map((name, i) => ({ name, status: word, tone, lit: lit(i) }));
|
|
1236
|
+
body = magiDiagram(th, units[1]!, units[2]!, units[0]!, hub, virus);
|
|
1237
|
+
const c = state.lastCouncil;
|
|
1238
|
+
status = c
|
|
1239
|
+
? `${pulse} ${th.fg("dim", "COUNCIL ")}${th.fg(voteTone(c.verdict), `${c.verdict ?? "NO QUORUM"} ${c.tally}/3`)}`
|
|
1240
|
+
: `${pulse} ${th.fg("dim", "COUNCIL · /magi")}`;
|
|
1241
|
+
}
|
|
709
1242
|
|
|
710
|
-
|
|
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;
|
|
1243
|
+
return [this.labelLine(inner, title, "┌", "┐", titleTone), ...body.map((l) => this.frameLine(l, inner)), this.centered(status, inner)];
|
|
723
1244
|
}
|
|
724
1245
|
|
|
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) + "┐"));
|
|
1246
|
+
/* ── data rows ── */
|
|
734
1247
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
// session data
|
|
738
|
-
out.push(this.sep(inner, "SESSION"));
|
|
1248
|
+
private sessionRows(inner: number, compact: boolean): string[] {
|
|
1249
|
+
const out = [this.sep(inner, "SESSION")];
|
|
739
1250
|
const model = liveCtx?.model;
|
|
740
1251
|
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"));
|
|
1252
|
+
if (!compact) out.push(this.field("PROVIDER", truncateToWidth(String(model?.provider ?? "—"), inner - 11), inner, "muted"));
|
|
742
1253
|
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
|
-
|
|
1254
|
+
if (!compact) {
|
|
1255
|
+
const cwd = (liveCtx?.cwd ?? "").split("/").filter(Boolean).pop() ?? "—";
|
|
1256
|
+
out.push(this.field("PROJECT", truncateToWidth(cwd, inner - 11), inner, "muted"));
|
|
1257
|
+
const branch = this.branch?.();
|
|
1258
|
+
if (branch) out.push(this.field("BRANCH", truncateToWidth(branch, inner - 11), inner, "muted"));
|
|
1259
|
+
}
|
|
1260
|
+
return out;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
private telemetryRows(inner: number, compact: boolean): string[] {
|
|
1264
|
+
const th = this.theme;
|
|
1265
|
+
const out = [this.sep(inner, "TELEMETRY")];
|
|
1266
|
+
if (!compact) {
|
|
1267
|
+
out.push(this.frameLine(` ${th.fg("dim", "TURNS".padEnd(9))}${th.fg("text", String(state.turns))}`, inner));
|
|
1268
|
+
out.push(
|
|
1269
|
+
this.frameLine(
|
|
1270
|
+
` ${th.fg("dim", "TOKENS".padEnd(9))}${th.fg("success", "↑" + fmtTokens(tokens.input))} ${th.fg("warning", "↓" + fmtTokens(tokens.output))}`,
|
|
1271
|
+
inner,
|
|
1272
|
+
),
|
|
1273
|
+
);
|
|
1274
|
+
if (tokens.cacheRead) out.push(this.field("CACHE", fmtTokens(tokens.cacheRead), inner, "muted"));
|
|
1275
|
+
if (tokens.cost) out.push(this.field("API COST", `$${tokens.cost.toFixed(3)}`, inner, "muted"));
|
|
1276
|
+
}
|
|
765
1277
|
|
|
766
1278
|
// SYNC: the golem's obedience = tool success rate (CHESED ✓ / GEBURAH ✗)
|
|
767
1279
|
const sync = syncPercent();
|
|
@@ -782,15 +1294,19 @@ class MagiPanel implements Component {
|
|
|
782
1294
|
);
|
|
783
1295
|
|
|
784
1296
|
// SEALS: the context window, one seal per seventh
|
|
785
|
-
const usage =
|
|
1297
|
+
const usage = contextUsage();
|
|
786
1298
|
const percent = usage?.percent ?? 0;
|
|
787
|
-
|
|
788
|
-
|
|
1299
|
+
const liveMark = usage?.live ? th.fg("accent", " ▲ live") : "";
|
|
1300
|
+
out.push(this.frameLine(` ${th.fg("dim", "SEALS".padEnd(9))}${renderSeals(th, percent)} ${th.fg(usageTone(percent), `${percent.toFixed(0)}%`)}${liveMark}`, inner));
|
|
1301
|
+
if (!compact && usage?.contextWindow) {
|
|
789
1302
|
out.push(this.field("CONTEXT", `${usage.tokens == null ? "?" : fmtTokens(usage.tokens)} / ${fmtTokens(usage.contextWindow)}`, inner, "muted"));
|
|
790
1303
|
}
|
|
1304
|
+
return out;
|
|
1305
|
+
}
|
|
791
1306
|
|
|
792
|
-
|
|
793
|
-
|
|
1307
|
+
private performanceRows(inner: number, compact: boolean): string[] {
|
|
1308
|
+
const th = this.theme;
|
|
1309
|
+
const out = [this.sep(inner, "PERFORMANCE")];
|
|
794
1310
|
const live = state.phase !== "idle" && perf.chars > 0;
|
|
795
1311
|
// idle: prefer the value measured by llama-swap itself
|
|
796
1312
|
const tps = live ? liveTps() : swap.srvTps || perf.tps;
|
|
@@ -800,6 +1316,7 @@ class MagiPanel implements Component {
|
|
|
800
1316
|
inner,
|
|
801
1317
|
),
|
|
802
1318
|
);
|
|
1319
|
+
if (compact) return out;
|
|
803
1320
|
if (swap.srvPps) out.push(this.field("PROMPT/S", swap.srvPps.toFixed(1), inner, "muted"));
|
|
804
1321
|
// llama-swap's input_tokens are the prompt tokens processed outside the cache
|
|
805
1322
|
const promptTotal = swap.cacheTokens + swap.inputTokens;
|
|
@@ -812,47 +1329,83 @@ class MagiPanel implements Component {
|
|
|
812
1329
|
out.push(this.field("DURATION", perf.lastMs ? fmtMs(perf.lastMs) : "—", inner, "muted"));
|
|
813
1330
|
const up = Math.floor((Date.now() - sessionStart) / 60000);
|
|
814
1331
|
out.push(this.field("UPTIME", `${Math.floor(up / 60)}h ${String(up % 60).padStart(2, "0")}m`, inner, "muted"));
|
|
1332
|
+
return out;
|
|
1333
|
+
}
|
|
815
1334
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1335
|
+
/** COST: GPU energy used in the session × price per kWh set with /magi-ui config. */
|
|
1336
|
+
private costRow(inner: number): string {
|
|
1337
|
+
if (!swap.gpus.length) return this.field("COST", "—", inner, "muted");
|
|
1338
|
+
if (ui.kwhPrice === undefined) return this.field("COST", "→ /magi-ui config", inner, "dim");
|
|
1339
|
+
return this.field("COST", fmtMoney((swap.energyWh / 1000) * ui.kwhPrice), inner, "warning");
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
private swapRows(inner: number, compact: boolean): string[] {
|
|
1343
|
+
if (!swap.base) return [];
|
|
1344
|
+
const th = this.theme;
|
|
1345
|
+
const out = [this.sep(inner, "LLAMA-SWAP")];
|
|
1346
|
+
const st =
|
|
1347
|
+
swap.state === "ready"
|
|
1348
|
+
? th.fg("success", "IN VRAM")
|
|
1349
|
+
: swap.state === "asleep"
|
|
1350
|
+
? th.fg("muted", "ASLEEP")
|
|
822
1351
|
: swap.state === "error"
|
|
823
1352
|
? th.fg("error", "OFFLINE")
|
|
824
1353
|
: 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
|
-
|
|
1354
|
+
const load = swap.state === "ready" && swap.loadMs ? th.fg("dim", ` load ${fmtMs(swap.loadMs)}`) : "";
|
|
1355
|
+
const warm =
|
|
1356
|
+
prewarm.state === "running"
|
|
1357
|
+
? th.fg("warning", " · PREWARM")
|
|
1358
|
+
: prewarm.state === "done"
|
|
1359
|
+
? th.fg("dim", ` · ${fmtTokens(prewarm.tokens)} warm`)
|
|
1360
|
+
: "";
|
|
1361
|
+
out.push(this.frameLine(` ${th.fg("dim", "STATUS".padEnd(9))}${st}${load}${warm}`, inner));
|
|
1362
|
+
if (compact) return [...out, this.costRow(inner)];
|
|
1363
|
+
if (swap.state === "error") out.push(this.frameLine(" " + th.fg("error", swap.error), inner));
|
|
1364
|
+
const gib = (n: number) => n / 2 ** 30;
|
|
1365
|
+
for (const [i, g] of swap.gpus.entries()) {
|
|
1366
|
+
const pct = g.memTotal ? (g.memUsed / g.memTotal) * 100 : 0;
|
|
1367
|
+
out.push(
|
|
1368
|
+
this.frameLine(
|
|
1369
|
+
` ${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`)}`,
|
|
1370
|
+
inner,
|
|
1371
|
+
),
|
|
1372
|
+
);
|
|
1373
|
+
out.push(
|
|
1374
|
+
this.frameLine(
|
|
1375
|
+
` ${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`)}`,
|
|
1376
|
+
inner,
|
|
1377
|
+
),
|
|
1378
|
+
);
|
|
845
1379
|
}
|
|
1380
|
+
if (swap.ramTotal) out.push(this.field("RAM", `${gib(swap.ramUsed).toFixed(1)} / ${Math.round(gib(swap.ramTotal))}G`, inner, "muted"));
|
|
1381
|
+
if (swap.gpus.length) out.push(this.field("ENERGY", `${(swap.energyWh / 1000).toFixed(3)} kWh`, inner, "muted"));
|
|
1382
|
+
out.push(this.costRow(inner));
|
|
1383
|
+
return out;
|
|
1384
|
+
}
|
|
846
1385
|
|
|
847
|
-
|
|
848
|
-
|
|
1386
|
+
render(width: number): string[] {
|
|
1387
|
+
const inner = Math.max(24, width - 2);
|
|
1388
|
+
const now = Date.now();
|
|
1389
|
+
if (!this.cache || this.cache.inner !== inner || this.cache.compact !== ui.compact || now - this.cache.at > 500) {
|
|
1390
|
+
const c = ui.compact;
|
|
1391
|
+
this.cache = {
|
|
1392
|
+
at: now,
|
|
1393
|
+
inner,
|
|
1394
|
+
compact: c,
|
|
1395
|
+
lines: [...this.sessionRows(inner, c), ...this.telemetryRows(inner, c), ...this.performanceRows(inner, c), ...this.swapRows(inner, c)],
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
const bottom = this.theme.fg("border", "└" + "─".repeat(inner) + "┘");
|
|
1399
|
+
const lines = [...this.councilSection(inner), ...this.cache.lines];
|
|
1400
|
+
// fullscreen: keep the frame going down to the bottom of the terminal
|
|
1401
|
+
const target = (this.fillHeight?.() ?? 0) - 1;
|
|
1402
|
+
while (lines.length < target) lines.push(this.frameLine("", inner));
|
|
1403
|
+
return [...lines, bottom].map((l) => truncateToWidth(l, width));
|
|
849
1404
|
}
|
|
850
1405
|
}
|
|
851
1406
|
|
|
852
1407
|
/* ─────────────────────────────────────────────── MAGI deliberation ── */
|
|
853
1408
|
|
|
854
|
-
type Vote = "APPROVE" | "CONDITIONAL" | "REJECT";
|
|
855
|
-
|
|
856
1409
|
function voteTone(v: string | null | undefined): "success" | "warning" | "error" {
|
|
857
1410
|
return v === "APPROVE" ? "success" : v === "CONDITIONAL" ? "warning" : "error";
|
|
858
1411
|
}
|
|
@@ -925,13 +1478,16 @@ interface Deliberation {
|
|
|
925
1478
|
tally: number;
|
|
926
1479
|
}
|
|
927
1480
|
|
|
928
|
-
/* ── config: ~/.pi/agent/magi.json → { "MELCHIOR": { "model": "provider/id", "thinking": "low" },
|
|
1481
|
+
/* ── config: ~/.pi/agent/magi.json → { "MELCHIOR": { "model": "provider/id", "thinking": "low" }, …, "ui": { "compact": true, "kwhPrice": 0.3 } } ── */
|
|
929
1482
|
|
|
930
1483
|
interface MagiUnitConfig {
|
|
931
1484
|
model?: string;
|
|
932
1485
|
thinking?: string;
|
|
933
1486
|
}
|
|
934
|
-
type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig
|
|
1487
|
+
type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig>> & {
|
|
1488
|
+
ui?: { compact?: boolean; kwhPrice?: number; currency?: Currency };
|
|
1489
|
+
loads?: Record<string, number>; // real model id → ms its last load took
|
|
1490
|
+
};
|
|
935
1491
|
|
|
936
1492
|
const MAGI_CONFIG_PATH = join(homedir(), ".pi", "agent", "magi.json");
|
|
937
1493
|
|
|
@@ -943,6 +1499,10 @@ function loadMagiConfig(): MagiConfig {
|
|
|
943
1499
|
}
|
|
944
1500
|
}
|
|
945
1501
|
|
|
1502
|
+
function saveMagiConfig(cfg: MagiConfig): void {
|
|
1503
|
+
writeFileSync(MAGI_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
1504
|
+
}
|
|
1505
|
+
|
|
946
1506
|
function parseVote(text: string): Vote {
|
|
947
1507
|
const m = /VOTE:\s*\**\s*(APPROVE|CONDITIONAL|REJECT)/i.exec(text);
|
|
948
1508
|
return m ? (m[1]!.toUpperCase() as Vote) : "CONDITIONAL";
|
|
@@ -981,6 +1541,22 @@ function conversationExcerpt(ctx: ExtensionContext, maxChars = 6000): string {
|
|
|
981
1541
|
return joined.length > maxChars ? "…" + joined.slice(-maxChars) : joined;
|
|
982
1542
|
}
|
|
983
1543
|
|
|
1544
|
+
const REVIEW_MAX_CHARS = 24_000;
|
|
1545
|
+
|
|
1546
|
+
/** The pending changes for /magi review: tracked changes against HEAD plus the names of untracked files. */
|
|
1547
|
+
async function pendingChanges(cwd: string): Promise<{ diff: string; untracked: string[] }> {
|
|
1548
|
+
const git = (args: string[]) => promisify(execFile)("git", args, { cwd, maxBuffer: 32 * 1024 * 1024 }).then((r) => r.stdout);
|
|
1549
|
+
let diff: string;
|
|
1550
|
+
try {
|
|
1551
|
+
diff = await git(["diff", "HEAD"]);
|
|
1552
|
+
} catch {
|
|
1553
|
+
// no commits yet: staged + unstaged
|
|
1554
|
+
diff = (await git(["diff", "--cached"])) + (await git(["diff"]));
|
|
1555
|
+
}
|
|
1556
|
+
const untracked = (await git(["ls-files", "--others", "--exclude-standard"]).catch(() => "")).split("\n").filter(Boolean);
|
|
1557
|
+
return { diff, untracked };
|
|
1558
|
+
}
|
|
1559
|
+
|
|
984
1560
|
/** Common function words per language, used to name the reply language explicitly. */
|
|
985
1561
|
const LANGUAGE_HINTS: readonly [string, readonly string[]][] = [
|
|
986
1562
|
["Italian", ["il", "lo", "la", "gli", "di", "che", "per", "non", "una", "con", "sono", "come", "perché", "è", "dovrei", "meglio", "mettiamo", "questo", "quale"]],
|
|
@@ -1004,13 +1580,13 @@ function guessLanguage(text: string): string | undefined {
|
|
|
1004
1580
|
}
|
|
1005
1581
|
|
|
1006
1582
|
/** The user message each MAGI receives. The language reminder sits after the question, where the model reads it last. */
|
|
1007
|
-
function councilPrompt(project: string,
|
|
1583
|
+
function councilPrompt(project: string, context: string, question: string, contextLabel = "Recent conversation (context only, may be empty)"): string {
|
|
1008
1584
|
const lang = guessLanguage(question);
|
|
1009
1585
|
const reminder = lang
|
|
1010
1586
|
? `(Write your whole answer in ${lang}, even if technical terms in the question are English.)`
|
|
1011
1587
|
: "(Write your whole answer in the language of this question, even if technical terms in it are English.)";
|
|
1012
1588
|
return (
|
|
1013
|
-
`Project: ${project}\n\
|
|
1589
|
+
`Project: ${project}\n\n${contextLabel}:\n<context>\n${context}\n</context>\n\n` +
|
|
1014
1590
|
`Question for the MAGI:\n${question}\n\n${reminder}`
|
|
1015
1591
|
);
|
|
1016
1592
|
}
|
|
@@ -1089,19 +1665,17 @@ function buildDeliberationView(
|
|
|
1089
1665
|
out.push(row(" " + theme.fg("muted", question)));
|
|
1090
1666
|
out.push(row(""));
|
|
1091
1667
|
|
|
1092
|
-
// Pending units flicker until their model answers.
|
|
1093
|
-
const spin = ["◐", "◓", "◑", "◒"][tick % 4]!;
|
|
1668
|
+
// Pending units flicker at random until their model answers.
|
|
1094
1669
|
const units: UnitView[] = MAGI_UNITS.map((name, i) => {
|
|
1095
1670
|
const o = opinions[i];
|
|
1096
|
-
if (!o) return { name, status:
|
|
1671
|
+
if (!o) return { name, status: "···", tone: "accent", lit: flicker(tick, i) };
|
|
1097
1672
|
if (!o.vote) return { name, status: "ERROR", tone: "error", lit: true };
|
|
1098
1673
|
return { name, status: o.vote === "CONDITIONAL" ? "COND." : o.vote, tone: voteTone(o.vote), lit: true };
|
|
1099
1674
|
});
|
|
1100
1675
|
const { verdict, tally } = tallyVerdict(opinions.map((o) => o?.vote ?? null));
|
|
1101
|
-
const link: ThemeColor = done ? voteTone(verdict) : "accent";
|
|
1102
1676
|
const hub = done ? "◆MAGI◆" : ["─MAGI─", "━MAGI━"][tick % 2]!;
|
|
1103
1677
|
const left = " ".repeat(Math.max(0, Math.floor((inner - MAGI_DIAGRAM_WIDTH) / 2)));
|
|
1104
|
-
for (const l of magiDiagram(theme, units[1]!, units[2]!, units[0]!,
|
|
1678
|
+
for (const l of magiDiagram(theme, units[1]!, units[2]!, units[0]!, hub)) out.push(row(left + l));
|
|
1105
1679
|
|
|
1106
1680
|
out.push(row(""));
|
|
1107
1681
|
for (let i = 0; i < MAGI.length; i++) {
|
|
@@ -1131,6 +1705,96 @@ function buildDeliberationView(
|
|
|
1131
1705
|
};
|
|
1132
1706
|
}
|
|
1133
1707
|
|
|
1708
|
+
/** A read-only boxed report (used by /magi-ui status); any key closes it. */
|
|
1709
|
+
function buildReportView(theme: Theme, lines: string[], close: () => void) {
|
|
1710
|
+
return {
|
|
1711
|
+
render(width: number): string[] {
|
|
1712
|
+
const inner = Math.max(40, Math.min(width - 2, 96));
|
|
1713
|
+
const row = (s: string) => {
|
|
1714
|
+
const t = truncateToWidth(" " + s, inner);
|
|
1715
|
+
return theme.fg("accent", "│") + t + " ".repeat(Math.max(0, inner - visibleWidth(t))) + theme.fg("accent", "│");
|
|
1716
|
+
};
|
|
1717
|
+
return [
|
|
1718
|
+
theme.fg("accent", "┌" + "─".repeat(inner) + "┐"),
|
|
1719
|
+
...lines.map(row),
|
|
1720
|
+
theme.fg("accent", "└" + "─".repeat(inner) + "┘"),
|
|
1721
|
+
theme.fg("dim", " any key: close"),
|
|
1722
|
+
].map((l) => truncateToWidth(l, width));
|
|
1723
|
+
},
|
|
1724
|
+
handleInput(_data: string) {
|
|
1725
|
+
close();
|
|
1726
|
+
},
|
|
1727
|
+
invalidate() {},
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
interface MechaEntry {
|
|
1732
|
+
model: Model<any>;
|
|
1733
|
+
unit: MechaUnit;
|
|
1734
|
+
state: MechaState;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/** MECHA SELECT: the model picker of a new session. The highlighted unit's head is animated by its real state in llama-swap. */
|
|
1738
|
+
function buildMechaPicker(tui: TUI, theme: Theme, entries: MechaEntry[], start: number, close: (picked: MechaEntry | undefined) => void) {
|
|
1739
|
+
let frame = 0;
|
|
1740
|
+
let index = start;
|
|
1741
|
+
const timer = setInterval(() => {
|
|
1742
|
+
frame++;
|
|
1743
|
+
tui.requestRender();
|
|
1744
|
+
}, 125);
|
|
1745
|
+
const nameWidth = Math.max(...entries.map((e) => visibleWidth(e.model.id)));
|
|
1746
|
+
|
|
1747
|
+
return {
|
|
1748
|
+
render(width: number): string[] {
|
|
1749
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
1750
|
+
const inner = Math.max(40, Math.min(width - 2, 96));
|
|
1751
|
+
const row = (s: string) => {
|
|
1752
|
+
const t = truncateToWidth(s, inner);
|
|
1753
|
+
return theme.fg("accent", "║") + t + " ".repeat(Math.max(0, inner - visibleWidth(t))) + theme.fg("accent", "║");
|
|
1754
|
+
};
|
|
1755
|
+
const sel = entries[index]!;
|
|
1756
|
+
const head = renderMechaHead(sel.unit, sel.state, frame);
|
|
1757
|
+
const list = entries.map((e, i) => {
|
|
1758
|
+
const u = MECHA[e.unit];
|
|
1759
|
+
const mark = i === index ? theme.fg("accent", "▸ ") : " ";
|
|
1760
|
+
const unit = rgb(e.state === "dormant" ? shade(u.armor, 0.6) : u.armor, u.name.padEnd(10));
|
|
1761
|
+
const name = i === index ? theme.bold(theme.fg("text", e.model.id)) : theme.fg("muted", e.model.id);
|
|
1762
|
+
const status =
|
|
1763
|
+
e.state === "active" ? theme.fg("success", "● IN VRAM") : e.state === "waking" ? theme.fg("warning", "◌ WAKING") : dim("○ dormant");
|
|
1764
|
+
return mark + unit + name + " ".repeat(nameWidth - visibleWidth(e.model.id) + 2) + status;
|
|
1765
|
+
});
|
|
1766
|
+
const u = MECHA[sel.unit];
|
|
1767
|
+
const sync =
|
|
1768
|
+
sel.state === "active"
|
|
1769
|
+
? theme.fg("success", `${u.name} · SYNC READY · no wait`)
|
|
1770
|
+
: sel.state === "waking"
|
|
1771
|
+
? theme.fg("warning", `${u.name} · LIFT OFF`)
|
|
1772
|
+
: theme.fg("muted", `${u.name} · DORMANT · it will be loaded into VRAM`);
|
|
1773
|
+
|
|
1774
|
+
const out = [
|
|
1775
|
+
theme.fg("accent", "╔" + "═".repeat(inner) + "╗"),
|
|
1776
|
+
row(theme.bold(theme.fg("accent", " MECHA SELECT")) + dim(" :: choose the unit to activate")),
|
|
1777
|
+
theme.fg("accent", "╟" + "─".repeat(inner) + "╢"),
|
|
1778
|
+
];
|
|
1779
|
+
for (let r = 0; r < Math.max(head.length, list.length); r++) out.push(row(` ${head[r] ?? " ".repeat(13)} ${list[r] ?? ""}`));
|
|
1780
|
+
out.push(theme.fg("accent", "╟" + "─".repeat(inner) + "╢"), row(" " + sync), theme.fg("accent", "╚" + "═".repeat(inner) + "╝"));
|
|
1781
|
+
out.push(dim(" ↑↓ select · enter: activate · esc: keep the current model, it loads when you type"));
|
|
1782
|
+
return out.map((l) => truncateToWidth(l, width));
|
|
1783
|
+
},
|
|
1784
|
+
handleInput(data: string) {
|
|
1785
|
+
if (matchesKey(data, "up")) index = (index + entries.length - 1) % entries.length;
|
|
1786
|
+
else if (matchesKey(data, "down")) index = (index + 1) % entries.length;
|
|
1787
|
+
else if (matchesKey(data, "enter")) close(entries[index]);
|
|
1788
|
+
else if (matchesKey(data, "escape")) close(undefined);
|
|
1789
|
+
tui.requestRender();
|
|
1790
|
+
},
|
|
1791
|
+
invalidate() {},
|
|
1792
|
+
dispose() {
|
|
1793
|
+
clearInterval(timer);
|
|
1794
|
+
},
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1134
1798
|
async function configureMagi(ctx: ExtensionContext): Promise<void> {
|
|
1135
1799
|
const cfg = loadMagiConfig();
|
|
1136
1800
|
const pool = ctx.scopedModels.length ? ctx.scopedModels.map((s) => s.model) : ctx.modelRegistry.getAvailable();
|
|
@@ -1145,7 +1809,7 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
|
|
|
1145
1809
|
}
|
|
1146
1810
|
cfg[magi.unit] = { ...cfg[magi.unit], model: pick === SESSION ? undefined : pick };
|
|
1147
1811
|
}
|
|
1148
|
-
|
|
1812
|
+
saveMagiConfig(cfg);
|
|
1149
1813
|
ctx.ui.notify(`MAGI config saved to ${MAGI_CONFIG_PATH}`, "info");
|
|
1150
1814
|
}
|
|
1151
1815
|
|
|
@@ -1154,11 +1818,12 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
|
|
|
1154
1818
|
/**
|
|
1155
1819
|
* The footer's left side, one animation per real state of the agent:
|
|
1156
1820
|
* - seals breaking / seventh seal opened → context compaction
|
|
1157
|
-
* - golem EMET / MET → tool running / tool failed
|
|
1821
|
+
* - golem EMET / MET → tool running (with its file or command) / tool failed
|
|
1158
1822
|
* - light pulsing in the upper triad → thinking
|
|
1159
1823
|
* - light descending to Malkuth → streaming the answer
|
|
1160
1824
|
* - light ascending from Malkuth → loading the model into VRAM
|
|
1161
|
-
* -
|
|
1825
|
+
* - the sixth seal → context close to compaction
|
|
1826
|
+
* - asleep / at rest in Malkuth → model unloaded / idle
|
|
1162
1827
|
*/
|
|
1163
1828
|
function footerLeft(th: Theme, now = Date.now()): string {
|
|
1164
1829
|
const dim = (s: string) => th.fg("dim", s);
|
|
@@ -1190,14 +1855,17 @@ function footerLeft(th: Theme, now = Date.now()): string {
|
|
|
1190
1855
|
const tally = th.fg("success", ` CHESED ✓${state.toolOk}`) + th.fg(state.toolFail ? "error" : "dim", ` GEBURAH ✗${state.toolFail}`);
|
|
1191
1856
|
const syncText = sync === null ? "" : dim(" · sync ") + th.fg(syncTone(sync), `${sync.toFixed(0)}%`);
|
|
1192
1857
|
if (now - state.lastFailAt < FAIL_FLASH_MS) {
|
|
1193
|
-
|
|
1858
|
+
const target = state.lastFailTarget ? dim(" ") + th.fg("error", truncateToWidth(state.lastFailTarget, 40)) : "";
|
|
1859
|
+
return th.fg("error", "✗ GOLEM · MET") + dim(" · ") + th.fg("error", `${state.lastFailTool} failed`) + target + dim(" ·") + tally + syncText;
|
|
1194
1860
|
}
|
|
1195
1861
|
const hammer = ["▚", "▞"][step % 2]!;
|
|
1862
|
+
const target = state.toolTarget ? dim(" ") + th.fg("muted", truncateToWidth(state.toolTarget, 40)) : "";
|
|
1196
1863
|
return (
|
|
1197
1864
|
th.fg("accent", `${hammer} GOLEM · `) +
|
|
1198
1865
|
th.bold(th.fg("warning", "EMET")) +
|
|
1199
1866
|
dim(" · ") +
|
|
1200
1867
|
th.fg("text", state.toolName || "tool") +
|
|
1868
|
+
target +
|
|
1201
1869
|
dim(` ${secsSince(state.phaseSince, now)}s ·`) +
|
|
1202
1870
|
tally +
|
|
1203
1871
|
syncText
|
|
@@ -1233,6 +1901,19 @@ function footerLeft(th: Theme, now = Date.now()): string {
|
|
|
1233
1901
|
dim(` · loading model into VRAM ${secsSince(swap.since, now)}s`)
|
|
1234
1902
|
);
|
|
1235
1903
|
}
|
|
1904
|
+
const percent = contextUsage()?.percent ?? 0;
|
|
1905
|
+
if (percent >= SIXTH_SEAL_PERCENT) {
|
|
1906
|
+
const cmd = state.hasSmartCompact ? "/smart-compact" : "/compact";
|
|
1907
|
+
return (
|
|
1908
|
+
th.fg(step % 4 < 2 ? "error" : "warning", "⚠ SIXTH SEAL") +
|
|
1909
|
+
dim(` · context ${percent.toFixed(0)}% · compact now with `) +
|
|
1910
|
+
th.fg("text", cmd) +
|
|
1911
|
+
dim(" before it happens mid-task")
|
|
1912
|
+
);
|
|
1913
|
+
}
|
|
1914
|
+
if (swap.state === "asleep") {
|
|
1915
|
+
return th.fg("muted", "○ MALKUTH") + dim(" · model asleep in llama-swap · type to wake it");
|
|
1916
|
+
}
|
|
1236
1917
|
const last = state.lastRunMs ? ` · last run ${fmtMs(state.lastRunMs)}` : "";
|
|
1237
1918
|
return th.fg("success", "○ ") + th.fg("muted", "MALKUTH") + dim(` · the kingdom · at rest${last}`);
|
|
1238
1919
|
}
|
|
@@ -1265,14 +1946,30 @@ function buildFooter(tui: TUI, theme: Theme, footerData: any) {
|
|
|
1265
1946
|
|
|
1266
1947
|
/* ───────────────────────────────────────────────────────── extension ── */
|
|
1267
1948
|
|
|
1949
|
+
const BUSY_POLL_MS = 3_000;
|
|
1950
|
+
const IDLE_POLL_MS = 30_000;
|
|
1951
|
+
/** pi rewrites the window title on its own (right after startup, on session info changes), so ours is re-applied. */
|
|
1952
|
+
const TITLE_REFRESH_MS = 2_000;
|
|
1953
|
+
|
|
1954
|
+
/** Window title: "π - Magi - <working directory>", prefixed with ✓ after a long run until you are back. */
|
|
1955
|
+
function windowTitle(cwd: string, done = false): string {
|
|
1956
|
+
const home = homedir();
|
|
1957
|
+
const dir = cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
|
|
1958
|
+
return `${done ? "✓ " : ""}π - Magi - ${dir}`;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1268
1961
|
export default function (pi: ExtensionAPI) {
|
|
1269
1962
|
let chrome = true;
|
|
1270
1963
|
let panelEnabled = true;
|
|
1271
1964
|
let tuiRef: TUI | undefined;
|
|
1272
1965
|
let panelHandle: OverlayHandle | undefined;
|
|
1273
1966
|
let panel: MagiPanel | undefined;
|
|
1967
|
+
let titleDone = false;
|
|
1274
1968
|
|
|
1275
|
-
const repaint = () =>
|
|
1969
|
+
const repaint = () => {
|
|
1970
|
+
panel?.invalidate();
|
|
1971
|
+
tuiRef?.requestRender();
|
|
1972
|
+
};
|
|
1276
1973
|
|
|
1277
1974
|
let footerDataRef: any;
|
|
1278
1975
|
let wrappedRoot: any; // pi's original root, when the panel is a fullscreen column
|
|
@@ -1286,6 +1983,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1286
1983
|
// ponytail: reads TuiAltScreen's private layoutRoot field; if pi renames it, falls back to the overlay.
|
|
1287
1984
|
const t = tuiRef as any;
|
|
1288
1985
|
if (t.mode === "fullscreen" && t.layoutRoot && typeof t.setLayoutRoot === "function") {
|
|
1986
|
+
panel.fillHeight = () => process.stdout.rows ?? 0;
|
|
1289
1987
|
wrappedRoot = t.layoutRoot;
|
|
1290
1988
|
t.setLayoutRoot(
|
|
1291
1989
|
new HStack(
|
|
@@ -1347,7 +2045,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1347
2045
|
return buildFooter(tui, theme, footerData);
|
|
1348
2046
|
});
|
|
1349
2047
|
ctx.ui.setWorkingIndicator({ frames: pulseFrames(ctx.ui.theme), intervalMs: 110 });
|
|
1350
|
-
ctx.ui.setTitle(
|
|
2048
|
+
ctx.ui.setTitle(windowTitle(ctx.cwd, titleDone));
|
|
1351
2049
|
};
|
|
1352
2050
|
|
|
1353
2051
|
pi.registerEntryRenderer("magi-verdict", (entry: any, _options: any, theme: Theme) => {
|
|
@@ -1371,15 +2069,101 @@ export default function (pi: ExtensionAPI) {
|
|
|
1371
2069
|
});
|
|
1372
2070
|
|
|
1373
2071
|
let metricsTimer: ReturnType<typeof setInterval> | undefined;
|
|
2072
|
+
let unsubscribeInput: (() => void) | undefined;
|
|
2073
|
+
let lastPoll = 0;
|
|
2074
|
+
let titleTimer: ReturnType<typeof setInterval> | undefined;
|
|
2075
|
+
let liveTimer: ReturnType<typeof setInterval> | undefined;
|
|
2076
|
+
/** Tools currently executing, by call id: the golem shows one of them, and leaves when none is left. */
|
|
2077
|
+
const runningTools = new Map<string, { name: string; target: string }>();
|
|
2078
|
+
|
|
2079
|
+
/** The picker has the keyboard: keys pressed there must not wake the default model. */
|
|
2080
|
+
let pickerOpen = false;
|
|
2081
|
+
|
|
2082
|
+
/** MECHA SELECT on a new llama-swap session: nothing goes into VRAM until a unit is chosen. */
|
|
2083
|
+
const pickModel = async (ctx: ExtensionContext) => {
|
|
2084
|
+
const current = ctx.model!;
|
|
2085
|
+
const models = ctx.modelRegistry.getAvailable().filter((m) => m.provider === "llama-swap");
|
|
2086
|
+
if (models.length < 2) return;
|
|
2087
|
+
pickerOpen = true;
|
|
2088
|
+
try {
|
|
2089
|
+
const [aliases, running] = await Promise.all([swapAliases(), swapRunning()]);
|
|
2090
|
+
const realOf = (m: Model<any>) => aliases.get(m.id) ?? m.id;
|
|
2091
|
+
const units = mechaUnitsOf(ctx, aliases);
|
|
2092
|
+
const rank: MechaUnit[] = ["I", "II", "III", "LEGION"];
|
|
2093
|
+
const entries: MechaEntry[] = models
|
|
2094
|
+
.map((model) => {
|
|
2095
|
+
const st = running.get(realOf(model));
|
|
2096
|
+
const mechaState: MechaState = st === "ready" ? "active" : st === "starting" ? "waking" : "dormant";
|
|
2097
|
+
return { model, unit: units.get(realOf(model))!, state: mechaState };
|
|
2098
|
+
})
|
|
2099
|
+
.sort((a, b) => rank.indexOf(a.unit) - rank.indexOf(b.unit));
|
|
2100
|
+
// the session's model if it is in VRAM, else any unit already in VRAM (no wait), else the session's model
|
|
2101
|
+
const own = entries.findIndex((e) => e.model.id === current.id);
|
|
2102
|
+
let start = entries[own]?.state === "active" ? own : entries.findIndex((e) => e.state === "active");
|
|
2103
|
+
if (start < 0) start = Math.max(0, own);
|
|
2104
|
+
|
|
2105
|
+
const picked = await ctx.ui.custom<MechaEntry | undefined>((tui, theme, _keys, done) => buildMechaPicker(tui, theme, entries, start, done));
|
|
2106
|
+
if (!picked) return;
|
|
2107
|
+
// same model: pi emits no model_select, so load it here
|
|
2108
|
+
if (picked.model.id === current.id) return void preloadModel(ctx, picked.model);
|
|
2109
|
+
if (!(await pi.setModel(picked.model))) ctx.ui.notify(`${picked.model.id}: no credentials for llama-swap`, "error");
|
|
2110
|
+
} finally {
|
|
2111
|
+
pickerOpen = false;
|
|
2112
|
+
}
|
|
2113
|
+
};
|
|
1374
2114
|
|
|
1375
|
-
pi.on("session_start", async (
|
|
2115
|
+
pi.on("session_start", async (event, ctx) => {
|
|
1376
2116
|
liveCtx = ctx;
|
|
2117
|
+
recountSession(ctx);
|
|
2118
|
+
state.hasSmartCompact = pi.getCommands().some((c) => c.name.replace(/^\//, "") === "smart-compact");
|
|
2119
|
+
const cfg = loadMagiConfig();
|
|
2120
|
+
ui.compact = cfg.ui?.compact ?? false;
|
|
2121
|
+
ui.kwhPrice = cfg.ui?.kwhPrice;
|
|
2122
|
+
ui.currency = cfg.ui?.currency === "USD" ? "USD" : "EUR";
|
|
1377
2123
|
if (ctx.mode !== "tui") return;
|
|
1378
2124
|
applyChrome(ctx);
|
|
1379
|
-
//
|
|
1380
|
-
|
|
1381
|
-
|
|
2125
|
+
// nothing is loaded at startup: a new session picks its MECHA unit, a resumed one shows whether its model is in VRAM
|
|
2126
|
+
const fresh = event.reason === "new" || (event.reason === "startup" && !ctx.sessionManager.getBranch().some((e) => e.type === "message"));
|
|
2127
|
+
void probeModel(ctx).then(() => (fresh && chrome && swap.base ? pickModel(ctx) : undefined));
|
|
2128
|
+
// GPU stats every 3s while something happens, every 30s when idle
|
|
2129
|
+
metricsTimer ??= setInterval(() => {
|
|
2130
|
+
const now = Date.now();
|
|
2131
|
+
if (!animating(now) && now - lastPoll < IDLE_POLL_MS) return;
|
|
2132
|
+
lastPoll = now;
|
|
2133
|
+
void refreshSwapMetrics()
|
|
2134
|
+
.then(refreshSwapRunning)
|
|
2135
|
+
.then(repaint);
|
|
2136
|
+
}, BUSY_POLL_MS);
|
|
1382
2137
|
metricsTimer.unref?.();
|
|
2138
|
+
// the seals follow the context live while the model works
|
|
2139
|
+
liveTimer ??= setInterval(() => {
|
|
2140
|
+
if (state.phase !== "idle") void refreshLiveContext().then(repaint);
|
|
2141
|
+
}, LIVE_POLL_MS);
|
|
2142
|
+
liveTimer.unref?.();
|
|
2143
|
+
// back at the keyboard: clear the ✓ from the title, wake a model llama-swap unloaded
|
|
2144
|
+
unsubscribeInput ??= ctx.ui.onTerminalInput(() => {
|
|
2145
|
+
if (titleDone) {
|
|
2146
|
+
titleDone = false;
|
|
2147
|
+
ctx.ui.setTitle(windowTitle(ctx.cwd));
|
|
2148
|
+
}
|
|
2149
|
+
if (swap.state === "asleep" && liveCtx && !pickerOpen) void preloadModel(liveCtx);
|
|
2150
|
+
return undefined;
|
|
2151
|
+
});
|
|
2152
|
+
// ponytail: re-applies the title every 2s because pi overwrites it without telling extensions
|
|
2153
|
+
titleTimer ??= setInterval(() => {
|
|
2154
|
+
if (chrome) ctx.ui.setTitle(windowTitle(ctx.cwd, titleDone));
|
|
2155
|
+
}, TITLE_REFRESH_MS);
|
|
2156
|
+
titleTimer.unref?.();
|
|
2157
|
+
});
|
|
2158
|
+
|
|
2159
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
2160
|
+
liveCtx = ctx;
|
|
2161
|
+
recountSession(ctx);
|
|
2162
|
+
repaint();
|
|
2163
|
+
});
|
|
2164
|
+
|
|
2165
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
2166
|
+
rememberPrefix(ctx.cwd, event.payload);
|
|
1383
2167
|
});
|
|
1384
2168
|
|
|
1385
2169
|
pi.on("model_select", async (event, ctx) => {
|
|
@@ -1388,8 +2172,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1388
2172
|
});
|
|
1389
2173
|
|
|
1390
2174
|
pi.on("session_shutdown", async () => {
|
|
2175
|
+
prewarm.abort?.abort();
|
|
1391
2176
|
clearInterval(metricsTimer);
|
|
1392
2177
|
metricsTimer = undefined;
|
|
2178
|
+
unsubscribeInput?.();
|
|
2179
|
+
unsubscribeInput = undefined;
|
|
2180
|
+
clearInterval(titleTimer);
|
|
2181
|
+
titleTimer = undefined;
|
|
2182
|
+
clearInterval(liveTimer);
|
|
2183
|
+
liveTimer = undefined;
|
|
1393
2184
|
hidePanel();
|
|
1394
2185
|
});
|
|
1395
2186
|
|
|
@@ -1399,6 +2190,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1399
2190
|
|
|
1400
2191
|
pi.on("turn_start", async (_event, ctx) => {
|
|
1401
2192
|
liveCtx = ctx;
|
|
2193
|
+
prewarm.abort?.abort();
|
|
1402
2194
|
state.turns++;
|
|
1403
2195
|
setPhase("thinking");
|
|
1404
2196
|
if (ctx.mode === "tui" && chrome) ctx.ui.setWorkingMessage("the MAGI deliberate…");
|
|
@@ -1419,7 +2211,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1419
2211
|
const t = e?.type;
|
|
1420
2212
|
if (t === "thinking_start" || t === "thinking_delta") setPhase("thinking");
|
|
1421
2213
|
else if (t === "text_start" || t === "text_delta") setPhase("responding");
|
|
1422
|
-
else if (t === "toolcall_start" || t === "toolcall_delta")
|
|
2214
|
+
else if (t === "toolcall_start" || t === "toolcall_delta") {
|
|
2215
|
+
// the model is writing a tool call: the golem shows which tool it prepares, not the previous one
|
|
2216
|
+
setPhase("tool");
|
|
2217
|
+
const call = e.partial?.content?.[e.contentIndex];
|
|
2218
|
+
if (call?.type === "toolCall") {
|
|
2219
|
+
state.toolName = call.name ?? "";
|
|
2220
|
+
state.toolTarget = toolTarget(call.arguments);
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
1423
2223
|
if (typeof e?.delta === "string" && e.delta) {
|
|
1424
2224
|
if (!perf.first) perf.first = Date.now();
|
|
1425
2225
|
perf.chars += e.delta.length;
|
|
@@ -1427,20 +2227,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
1427
2227
|
});
|
|
1428
2228
|
|
|
1429
2229
|
pi.on("message_end", async (event) => {
|
|
1430
|
-
if (event.message.role !== "assistant"
|
|
2230
|
+
if (event.message.role !== "assistant") return;
|
|
1431
2231
|
const m = event.message as AssistantMessage;
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
perf.
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
2232
|
+
addUsage(m);
|
|
2233
|
+
if (perf.start) {
|
|
2234
|
+
const end = Date.now();
|
|
2235
|
+
perf.lastMs = end - perf.start;
|
|
2236
|
+
if (perf.first) {
|
|
2237
|
+
perf.ttft = perf.first - perf.start;
|
|
2238
|
+
const genS = (end - perf.first) / 1000;
|
|
2239
|
+
const out = m.usage?.output || perf.chars / 4;
|
|
2240
|
+
if (genS > 0.05 && out) {
|
|
2241
|
+
perf.tps = out / genS;
|
|
2242
|
+
perf.peakTps = Math.max(perf.peakTps, perf.tps);
|
|
2243
|
+
}
|
|
1441
2244
|
}
|
|
2245
|
+
perf.start = 0;
|
|
1442
2246
|
}
|
|
1443
|
-
perf.start = 0;
|
|
1444
2247
|
repaint();
|
|
1445
2248
|
// llama-swap records the request once it completes
|
|
1446
2249
|
setTimeout(() => void refreshSwapActivity().then(repaint), 300);
|
|
@@ -1448,20 +2251,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
1448
2251
|
|
|
1449
2252
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
1450
2253
|
liveCtx = ctx;
|
|
2254
|
+
const tool = { name: event.toolName ?? "", target: toolTarget(event.args) };
|
|
2255
|
+
runningTools.set(event.toolCallId, tool);
|
|
1451
2256
|
setPhase("tool");
|
|
1452
|
-
state.toolName =
|
|
2257
|
+
state.toolName = tool.name;
|
|
2258
|
+
state.toolTarget = tool.target;
|
|
1453
2259
|
repaint();
|
|
1454
2260
|
});
|
|
1455
2261
|
|
|
1456
2262
|
// CHESED (mercy) counts what worked, GEBURAH (severity) what failed; a failure erases the golem's aleph.
|
|
1457
2263
|
pi.on("tool_execution_end", async (event) => {
|
|
2264
|
+
const tool = runningTools.get(event.toolCallId);
|
|
2265
|
+
runningTools.delete(event.toolCallId);
|
|
1458
2266
|
if (event.isError) {
|
|
1459
2267
|
state.toolFail++;
|
|
1460
2268
|
state.lastFailAt = Date.now();
|
|
1461
|
-
state.lastFailTool = event.toolName ?? "tool";
|
|
2269
|
+
state.lastFailTool = event.toolName ?? tool?.name ?? "tool";
|
|
2270
|
+
state.lastFailTarget = tool?.target ?? "";
|
|
1462
2271
|
} else {
|
|
1463
2272
|
state.toolOk++;
|
|
1464
2273
|
}
|
|
2274
|
+
const next = [...runningTools.values()][0];
|
|
2275
|
+
if (next) {
|
|
2276
|
+
// parallel tools: the golem moves on to one still running
|
|
2277
|
+
state.toolName = next.name;
|
|
2278
|
+
state.toolTarget = next.target;
|
|
2279
|
+
} else {
|
|
2280
|
+
// all tools done: the model now reads their results and thinks about the next step
|
|
2281
|
+
setPhase("thinking");
|
|
2282
|
+
state.toolName = "";
|
|
2283
|
+
state.toolTarget = "";
|
|
2284
|
+
}
|
|
1465
2285
|
repaint();
|
|
1466
2286
|
});
|
|
1467
2287
|
|
|
@@ -1470,19 +2290,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
1470
2290
|
if (state.runStart) state.lastRunMs = Date.now() - state.runStart;
|
|
1471
2291
|
state.runStart = 0;
|
|
1472
2292
|
setPhase("idle");
|
|
2293
|
+
runningTools.clear();
|
|
1473
2294
|
state.toolName = "";
|
|
1474
|
-
|
|
2295
|
+
state.toolTarget = "";
|
|
2296
|
+
if (ctx.mode === "tui" && chrome) {
|
|
2297
|
+
ctx.ui.setWorkingMessage();
|
|
2298
|
+
// a long run just finished: mark the window title until you touch the keyboard
|
|
2299
|
+
if (state.lastRunMs > DONE_TITLE_AFTER_MS) {
|
|
2300
|
+
titleDone = true;
|
|
2301
|
+
ctx.ui.setTitle(windowTitle(ctx.cwd, true));
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
1475
2304
|
repaint();
|
|
1476
2305
|
});
|
|
1477
2306
|
|
|
1478
2307
|
// The seven seals: compaction breaks them, and the context is reborn.
|
|
1479
2308
|
// 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
2309
|
pi.on("session_before_compact", async () => {
|
|
1483
2310
|
state.compacting = true;
|
|
1484
2311
|
state.compactSince = Date.now();
|
|
1485
|
-
state.compactBy = hasSmartCompact
|
|
2312
|
+
state.compactBy = state.hasSmartCompact ? "smart-compact" : "pi native";
|
|
1486
2313
|
repaint();
|
|
1487
2314
|
});
|
|
1488
2315
|
|
|
@@ -1490,7 +2317,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1490
2317
|
state.compacting = false;
|
|
1491
2318
|
state.rebornAt = Date.now();
|
|
1492
2319
|
// fromExtension: an extension supplied the summary; otherwise pi's own compactor did (e.g. smart-compact fell back)
|
|
1493
|
-
state.compactBy = event.fromExtension ? (hasSmartCompact
|
|
2320
|
+
state.compactBy = event.fromExtension ? (state.hasSmartCompact ? "smart-compact" : "extension") : "pi native";
|
|
1494
2321
|
repaint();
|
|
1495
2322
|
});
|
|
1496
2323
|
|
|
@@ -1499,52 +2326,87 @@ export default function (pi: ExtensionAPI) {
|
|
|
1499
2326
|
repaint();
|
|
1500
2327
|
});
|
|
1501
2328
|
|
|
2329
|
+
/** Runs the council on a question with some context, shows the deliberation, stores the verdict. */
|
|
2330
|
+
async function runCouncil(ctx: ExtensionContext, question: string, context: string, contextLabel?: string): Promise<void> {
|
|
2331
|
+
const cfg = loadMagiConfig();
|
|
2332
|
+
const project = (ctx.cwd ?? "").split("/").filter(Boolean).pop() ?? "";
|
|
2333
|
+
const prompt = councilPrompt(project, context, question, contextLabel);
|
|
2334
|
+
|
|
2335
|
+
const controller = new AbortController();
|
|
2336
|
+
const opinions: (MagiOpinion | undefined)[] = MAGI.map(() => undefined);
|
|
2337
|
+
let finished = false;
|
|
2338
|
+
const all = Promise.all(
|
|
2339
|
+
MAGI.map((_, i) =>
|
|
2340
|
+
askMagi(ctx, i, prompt, cfg, controller.signal).then((o) => {
|
|
2341
|
+
opinions[i] = o;
|
|
2342
|
+
return o;
|
|
2343
|
+
}),
|
|
2344
|
+
),
|
|
2345
|
+
).finally(() => {
|
|
2346
|
+
finished = true;
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
if (ctx.mode === "tui") {
|
|
2350
|
+
const cancelled = await ctx.ui.custom<boolean>((tui, theme, _keys, done) =>
|
|
2351
|
+
buildDeliberationView(tui, theme, question, opinions, () => finished, done),
|
|
2352
|
+
);
|
|
2353
|
+
if (cancelled) {
|
|
2354
|
+
controller.abort();
|
|
2355
|
+
ctx.ui.notify("MAGI deliberation aborted", "warning");
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
const final = await all;
|
|
2361
|
+
const { verdict, tally } = tallyVerdict(final.map((o) => o.vote));
|
|
2362
|
+
pi.appendEntry("magi-verdict", { question, opinions: final, verdict, tally } satisfies Deliberation);
|
|
2363
|
+
state.lastCouncil = { verdict, tally, question };
|
|
2364
|
+
repaint();
|
|
2365
|
+
}
|
|
2366
|
+
|
|
1502
2367
|
pi.registerCommand("magi", {
|
|
1503
|
-
description: "Ask the three MAGI (pragmatist, guardian, visionary); /magi config assigns
|
|
2368
|
+
description: "Ask the three MAGI (pragmatist, guardian, visionary); /magi review [focus] judges the git diff; /magi config assigns models; /magi mecha picks the model",
|
|
1504
2369
|
handler: async (args, ctx) => {
|
|
1505
2370
|
const arg = args.trim();
|
|
1506
2371
|
if (arg === "config") return configureMagi(ctx);
|
|
2372
|
+
if (arg === "mecha") {
|
|
2373
|
+
if (ctx.mode !== "tui" || !swap.base) return ctx.ui.notify("MECHA SELECT needs the TUI and a llama-swap model", "error");
|
|
2374
|
+
liveCtx = ctx;
|
|
2375
|
+
return pickModel(ctx);
|
|
2376
|
+
}
|
|
1507
2377
|
|
|
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");
|
|
2378
|
+
if (arg === "review" || arg.startsWith("review ")) {
|
|
2379
|
+
const focus = arg.slice("review".length).trim();
|
|
2380
|
+
let changes: { diff: string; untracked: string[] };
|
|
2381
|
+
try {
|
|
2382
|
+
changes = await pendingChanges(ctx.cwd);
|
|
2383
|
+
} catch {
|
|
2384
|
+
ctx.ui.notify("MAGI review needs a git repository", "error");
|
|
1536
2385
|
return;
|
|
1537
2386
|
}
|
|
2387
|
+
if (!changes.diff.trim() && !changes.untracked.length) {
|
|
2388
|
+
ctx.ui.notify("Nothing to review: no changes against HEAD", "info");
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
const diff =
|
|
2392
|
+
changes.diff.length > REVIEW_MAX_CHARS
|
|
2393
|
+
? changes.diff.slice(0, REVIEW_MAX_CHARS) + `\n… diff truncated (${changes.diff.length} chars in total)`
|
|
2394
|
+
: changes.diff;
|
|
2395
|
+
const untracked = changes.untracked.length ? `\n\nUntracked files (content not shown):\n${changes.untracked.join("\n")}` : "";
|
|
2396
|
+
const question = focus
|
|
2397
|
+
? `Review these pending changes before committing, focusing on: ${focus}`
|
|
2398
|
+
: "Review these pending changes before committing: are they ready to commit, and what must change first?";
|
|
2399
|
+
return runCouncil(ctx, question, "```diff\n" + diff + "\n```" + untracked, "Pending changes (git diff HEAD)");
|
|
1538
2400
|
}
|
|
1539
2401
|
|
|
1540
|
-
const
|
|
1541
|
-
|
|
1542
|
-
|
|
2402
|
+
const question = arg || (await ctx.ui.input("Question for the MAGI:", "should we …?"))?.trim() || "";
|
|
2403
|
+
if (!question) return;
|
|
2404
|
+
return runCouncil(ctx, question, conversationExcerpt(ctx));
|
|
1543
2405
|
},
|
|
1544
2406
|
});
|
|
1545
2407
|
|
|
1546
2408
|
pi.registerCommand("magi-ui", {
|
|
1547
|
-
description: "MAGI chrome: enable the theme, or manage
|
|
2409
|
+
description: "MAGI chrome: enable the theme, or manage it (on|off|panel|compact|status|config)",
|
|
1548
2410
|
handler: async (args, ctx) => {
|
|
1549
2411
|
liveCtx = ctx;
|
|
1550
2412
|
const arg = args.trim().toLowerCase();
|
|
@@ -1556,6 +2418,59 @@ export default function (pi: ExtensionAPI) {
|
|
|
1556
2418
|
ctx.ui.notify(`Side panel ${panelEnabled ? "enabled" : "disabled"}`, "info");
|
|
1557
2419
|
return;
|
|
1558
2420
|
}
|
|
2421
|
+
if (arg === "compact") {
|
|
2422
|
+
ui.compact = !ui.compact;
|
|
2423
|
+
const cfg = loadMagiConfig();
|
|
2424
|
+
saveMagiConfig({ ...cfg, ui: { ...cfg.ui, compact: ui.compact } });
|
|
2425
|
+
repaint();
|
|
2426
|
+
ctx.ui.notify(`Side panel ${ui.compact ? "compact" : "detailed"}`, "info");
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
if (arg === "config") {
|
|
2430
|
+
const currency = await ctx.ui.select(`Currency for COST (current: ${ui.currency})`, ["EUR", "USD"]);
|
|
2431
|
+
if (!currency) return;
|
|
2432
|
+
const current = ui.kwhPrice !== undefined ? ` (current: ${ui.kwhPrice})` : "";
|
|
2433
|
+
const raw = (await ctx.ui.input(`Electricity price per kWh in ${currency}${current}:`, "0.30"))?.trim();
|
|
2434
|
+
if (raw === undefined) return;
|
|
2435
|
+
const price = raw === "" && ui.kwhPrice !== undefined ? ui.kwhPrice : Number(raw.replace(",", "."));
|
|
2436
|
+
if (raw === "" && ui.kwhPrice === undefined) {
|
|
2437
|
+
ctx.ui.notify("No price entered: COST unchanged", "warning");
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
if (!Number.isFinite(price) || price < 0) {
|
|
2441
|
+
ctx.ui.notify(`Invalid price: "${raw}"`, "error");
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
ui.currency = currency === "USD" ? "USD" : "EUR";
|
|
2445
|
+
ui.kwhPrice = price;
|
|
2446
|
+
const cfg = loadMagiConfig();
|
|
2447
|
+
saveMagiConfig({ ...cfg, ui: { ...cfg.ui, currency: ui.currency, kwhPrice: price } });
|
|
2448
|
+
repaint();
|
|
2449
|
+
ctx.ui.notify(`COST: ${fmtMoney(price)} per kWh`, "info");
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
if (arg === "status") {
|
|
2453
|
+
if (!swap.base) {
|
|
2454
|
+
ctx.ui.notify("/magi-ui status needs a llama-swap session model", "warning");
|
|
2455
|
+
return;
|
|
2456
|
+
}
|
|
2457
|
+
let report: { data?: ActivityRow[]; total?: number };
|
|
2458
|
+
try {
|
|
2459
|
+
report = (await (await swapGet(`/api/metrics/activity?limit=${ACTIVITY_REPORT_ROWS}`, 15_000)).json()) as typeof report;
|
|
2460
|
+
} catch (err) {
|
|
2461
|
+
ctx.ui.notify(`llama-swap status failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
const rows = report.data ?? [];
|
|
2465
|
+
if (!rows.length) {
|
|
2466
|
+
ctx.ui.notify("llama-swap has no recorded requests yet", "info");
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
await ctx.ui.custom<void>((_tui, theme, _keys, done) =>
|
|
2470
|
+
buildReportView(theme, activityReport(theme, rows, report.total ?? rows.length), () => done(undefined)),
|
|
2471
|
+
);
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
1559
2474
|
if (arg === "off" || arg === "on") {
|
|
1560
2475
|
chrome = arg === "on";
|
|
1561
2476
|
applyChrome(ctx);
|
|
@@ -1570,7 +2485,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1570
2485
|
}
|
|
1571
2486
|
chrome = true;
|
|
1572
2487
|
applyChrome(ctx);
|
|
1573
|
-
ctx.ui.notify("MAGI online — /magi-ui panel
|
|
2488
|
+
ctx.ui.notify("MAGI online — /magi-ui panel|compact|status|off", "info");
|
|
1574
2489
|
},
|
|
1575
2490
|
});
|
|
1576
2491
|
}
|