pi-magi-theme 0.1.4 → 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 +16 -7
- package/extensions/magi/index.ts +685 -170
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# pi-magi-theme
|
|
2
2
|
|
|
3
|
-
A three-mind council theme + extension for [pi](https://pi.dev): a
|
|
3
|
+
A three-mind council theme + extension for [pi](https://pi.dev): a MAGI SYSTEM header, the three MAGI as their control screen in a fixed side panel, live llama-swap telemetry, and `/magi`, a council of three models that votes on your engineering questions and reviews your pending changes.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Fan-art theme inspired by Neon Genesis Evangelion: the MAGI and their screen belong to their creators, all rights reserved to khara, Inc. This project is not affiliated with them. The rest of the symbolism (the Tree of Life, the golem, the seven seals) is public domain.
|
|
6
6
|
|
|
7
|
-

|
|
8
8
|
|
|
9
9
|

|
|
10
10
|
|
|
@@ -39,6 +39,7 @@ Clone the repo and point pi at it instead (edits in the repo are live on the nex
|
|
|
39
39
|
| `/magi <question>` | the council answers a question (recent conversation as context) |
|
|
40
40
|
| `/magi review [focus]` | the council reviews your pending changes (`git diff HEAD` plus untracked file names) before you commit |
|
|
41
41
|
| `/magi config` | pick a model for each MAGI |
|
|
42
|
+
| `/magi mecha` | MECHA SELECT: pick the llama-swap model to activate, each shown as a mecha head lit by its real state |
|
|
42
43
|
| `/magi-ui compact` | toggle the compact side panel (basic info and animations only); remembered across sessions |
|
|
43
44
|
| `/magi-ui status` | llama-swap report from its last 100 requests: speed, tokens, cache hits, MTP draft acceptance, durations, errors per model |
|
|
44
45
|
| `/magi-ui config` | set the electricity price per kWh and the currency (EUR or USD) for the COST row |
|
|
@@ -54,7 +55,10 @@ Every symbol stands for something real the agent is doing.
|
|
|
54
55
|
| light descending Tiferet → Malkuth | manifestation | the model is **streaming the answer** (with live tok/s) | footer |
|
|
55
56
|
| light ascending Malkuth → Keter | ascent | the model is **being loaded into VRAM** | footer |
|
|
56
57
|
| Malkuth at rest | the kingdom | **idle**, with the last run duration | footer |
|
|
57
|
-
| MELCHIOR · BALTHASAR · CASPAR flickering | the three Magi at work | what the agent **is doing** (thinking, responding,
|
|
58
|
+
| MELCHIOR · BALTHASAR · CASPAR flickering | the three Magi at work | what the agent **is doing** (thinking, responding, compacting); below them, the **last real `/magi` verdict** | panel |
|
|
59
|
+
| red spreading through BALTHASAR, MELCHIOR, CASPAR | an angel hacking the MAGI | the model is **being loaded into VRAM**, at the pace of its last load; CASPAR's last corner blinks once everything else has fallen | panel |
|
|
60
|
+
| blue taking the MAGI back from that corner | the attack repelled | the model is **loaded** | panel |
|
|
61
|
+
| MECHA-I · II · III · LEGION | units waiting for a pilot | the **llama-swap models**: dormant, waking while loading, eyes lit in VRAM | MECHA SELECT |
|
|
58
62
|
| the golem, EMET ("truth") | a clay servant that acts | a **tool is running**, with the file or command it works on | panel + footer |
|
|
59
63
|
| the golem, MET ("death") | the aleph is erased | a **tool failed** | panel + footer |
|
|
60
64
|
| SYNC | the golem's obedience | **tool success rate** | panel + footer |
|
|
@@ -99,19 +103,24 @@ Each nature is a lens, not a specialty, so the council answers any question, not
|
|
|
99
103
|
```json
|
|
100
104
|
{
|
|
101
105
|
"MELCHIOR": { "model": "llama-swap/Qwen3.8 27B Q4_K_M - Thinking", "thinking": "low" },
|
|
102
|
-
"ui": { "compact": false, "kwhPrice": 0.30, "currency": "EUR" }
|
|
106
|
+
"ui": { "compact": false, "kwhPrice": 0.30, "currency": "EUR" },
|
|
107
|
+
"loads": { "qwen3.8-27b": 41200 }
|
|
103
108
|
}
|
|
104
109
|
```
|
|
105
110
|
|
|
106
111
|
- per MAGI: `model` (unset = current session model) and optional `thinking` level;
|
|
107
112
|
- `ui.compact`: start with the compact side panel;
|
|
108
|
-
- `ui.kwhPrice` and `ui.currency` (`EUR` or `USD`): the COST row multiplies the GPU energy used in the session by this price
|
|
113
|
+
- `ui.kwhPrice` and `ui.currency` (`EUR` or `USD`): the COST row multiplies the GPU energy used in the session by this price;
|
|
114
|
+
- `loads`: written by the theme, how long each llama-swap model took to load last time (paces the angel attack; 60s when unknown).
|
|
109
115
|
|
|
110
116
|
## llama-swap
|
|
111
117
|
|
|
112
118
|
When the session model uses the `llama-swap` provider, the side panel:
|
|
113
119
|
|
|
114
|
-
- loads
|
|
120
|
+
- loads nothing at startup: a new session opens MECHA SELECT, a resumed one shows whether its model is already in VRAM;
|
|
121
|
+
- loads the model into VRAM when you pick it, change it with `/model` or type (`GET /upstream/<model>/health`), with the angel attack animation;
|
|
122
|
+
- prewarms a new session: pi's real system prompt and tools are processed as soon as the model is ready, so the first answer doesn't wait for them;
|
|
123
|
+
- follows the context live in the seven seals while the model works (`/upstream/<model>/slots`);
|
|
115
124
|
- notices when llama-swap unloads the model (`/running`), shows it asleep and reloads it as soon as you type;
|
|
116
125
|
- shows VRAM, GPU load/temperature/power, RAM and the GPU energy used in the session from `/metrics` (every 3s while working, every 30s when idle);
|
|
117
126
|
- shows server-measured tok/s, prompt tok/s and KV cache hits of the last request (`/api/metrics/activity?limit=1`).
|
package/extensions/magi/index.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
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
8
|
* - the three MAGI (MELCHIOR / BALTHASAR / CASPAR) flicker while the agent works, showing what it is doing,
|
|
8
9
|
* and form the /magi council: three real models voting (on a question, or on the pending git diff)
|
|
@@ -10,10 +11,15 @@
|
|
|
10
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
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
|
|
13
19
|
* - llama-swap telemetry: VRAM, GPU load/temp/power, energy used, RAM, server-side tok/s, prompt tok/s, cache hits
|
|
14
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
|
|
|
@@ -39,9 +45,30 @@ const MAGI_WORD = [
|
|
|
39
45
|
"╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝",
|
|
40
46
|
];
|
|
41
47
|
|
|
42
|
-
/**
|
|
43
|
-
const
|
|
44
|
-
|
|
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
|
+
];
|
|
45
72
|
|
|
46
73
|
/** Sephirot in the order of the "lightning flash" (Keter → Malkuth), positioned in sub-pixels. */
|
|
47
74
|
const SEPHIROT = [
|
|
@@ -57,82 +84,6 @@ const SEPHIROT = [
|
|
|
57
84
|
{ name: "MALKUTH", meaning: "the kingdom", x: 30, y: 75 },
|
|
58
85
|
] as const;
|
|
59
86
|
|
|
60
|
-
/** Da'at, the hidden sephirah: drawn dashed, not part of the flash. */
|
|
61
|
-
const DAAT = { x: 30, y: 28 };
|
|
62
|
-
|
|
63
|
-
/** The 22 paths, as pairs of SEPHIROT indices. */
|
|
64
|
-
const TREE_PATHS: readonly [number, number][] = [
|
|
65
|
-
[0, 1], [0, 2], [0, 5], [1, 2], [1, 5], [1, 3], [2, 5], [2, 4], [3, 4], [3, 5], [3, 6],
|
|
66
|
-
[4, 5], [4, 7], [5, 6], [5, 8], [5, 7], [6, 7], [6, 8], [6, 9], [7, 8], [7, 9], [8, 9],
|
|
67
|
-
];
|
|
68
|
-
|
|
69
|
-
const BRAILLE_BITS = [
|
|
70
|
-
[0x01, 0x08],
|
|
71
|
-
[0x02, 0x10],
|
|
72
|
-
[0x04, 0x20],
|
|
73
|
-
[0x40, 0x80],
|
|
74
|
-
]; // [row][col] → dot bit
|
|
75
|
-
|
|
76
|
-
/** Rasterizes the Tree of Life into braille: paths (muted), Da'at (dim, dashed), sephirot (accent rings). */
|
|
77
|
-
function renderTreeOfLife(th: Theme): string[] {
|
|
78
|
-
const W = TREE_W * 2;
|
|
79
|
-
const H = TREE_H * 4;
|
|
80
|
-
const PATH = 1;
|
|
81
|
-
const DAAT_PX = 2;
|
|
82
|
-
const NODE = 3;
|
|
83
|
-
const px = new Uint8Array(W * H);
|
|
84
|
-
const set = (x: number, y: number, v: number) => {
|
|
85
|
-
const rx = Math.round(x);
|
|
86
|
-
const ry = Math.round(y);
|
|
87
|
-
if (rx >= 0 && ry >= 0 && rx < W && ry < H) px[ry * W + rx] = v;
|
|
88
|
-
};
|
|
89
|
-
for (const [a, b] of TREE_PATHS) {
|
|
90
|
-
const p = SEPHIROT[a]!;
|
|
91
|
-
const q = SEPHIROT[b]!;
|
|
92
|
-
const n = Math.ceil(Math.max(Math.abs(q.x - p.x), Math.abs(q.y - p.y)));
|
|
93
|
-
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);
|
|
94
|
-
}
|
|
95
|
-
// ring of radius r, cleared inside and with a small gap around it so paths stop short of the circle;
|
|
96
|
-
// dashed rings keep every other 30° arc
|
|
97
|
-
const ring = (c: { x: number; y: number }, r: number, v: number, dashed: boolean) => {
|
|
98
|
-
for (let y = -r - 2; y <= r + 2; y++) {
|
|
99
|
-
for (let x = -r - 2; x <= r + 2; x++) {
|
|
100
|
-
const d = Math.hypot(x, y);
|
|
101
|
-
if (d > r + 1.5) continue;
|
|
102
|
-
const onRing = d > r - 0.7 && d <= r + 0.5;
|
|
103
|
-
const arc = Math.floor((Math.atan2(y, x) + Math.PI) / (Math.PI / 6)) % 2 === 0;
|
|
104
|
-
set(c.x + x, c.y + y, onRing && (!dashed || arc) ? v : 0);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
ring(DAAT, 3, DAAT_PX, true);
|
|
109
|
-
for (const s of SEPHIROT) {
|
|
110
|
-
ring(s, 3.5, NODE, false);
|
|
111
|
-
set(s.x, s.y, NODE);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const lines: string[] = [];
|
|
115
|
-
for (let row = 0; row < TREE_H; row++) {
|
|
116
|
-
let line = "";
|
|
117
|
-
for (let col = 0; col < TREE_W; col++) {
|
|
118
|
-
let bits = 0;
|
|
119
|
-
let top = 0;
|
|
120
|
-
for (let dy = 0; dy < 4; dy++) {
|
|
121
|
-
for (let dx = 0; dx < 2; dx++) {
|
|
122
|
-
const v = px[(row * 4 + dy) * W + col * 2 + dx]!;
|
|
123
|
-
if (!v) continue;
|
|
124
|
-
bits |= BRAILLE_BITS[dy]![dx]!;
|
|
125
|
-
top = Math.max(top, v);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
const ch = bits ? String.fromCharCode(0x2800 + bits) : " ";
|
|
129
|
-
line += bits ? th.fg(top === NODE ? "accent" : top === DAAT_PX ? "dim" : "muted", ch) : ch;
|
|
130
|
-
}
|
|
131
|
-
lines.push(line);
|
|
132
|
-
}
|
|
133
|
-
return lines;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
87
|
type NodeLight = "off" | "on" | "hot";
|
|
137
88
|
|
|
138
89
|
/** The ten sephirot as a tiny path: ●━●━◉─○ … (hot = where the light is now). */
|
|
@@ -178,6 +129,167 @@ const GOLEM_FALLEN = [
|
|
|
178
129
|
" ▀▀▀▀ ▀▀▀▀ ",
|
|
179
130
|
];
|
|
180
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
|
+
|
|
181
293
|
const MAGI_UNITS = ["MELCHIOR", "BALTHASAR", "CASPAR"] as const;
|
|
182
294
|
type MagiUnit = (typeof MAGI_UNITS)[number];
|
|
183
295
|
|
|
@@ -262,6 +374,7 @@ function animating(now = Date.now()): boolean {
|
|
|
262
374
|
state.phase !== "idle" ||
|
|
263
375
|
swap.state === "checking" ||
|
|
264
376
|
swap.state === "loading" ||
|
|
377
|
+
(swap.state === "ready" && now - swap.since < ONLINE_MS) ||
|
|
265
378
|
state.compacting ||
|
|
266
379
|
now - state.rebornAt < REBIRTH_MS ||
|
|
267
380
|
now - state.lastFailAt < FAIL_FLASH_MS
|
|
@@ -389,75 +502,193 @@ interface UnitView {
|
|
|
389
502
|
|
|
390
503
|
const MAGI_DIAGRAM_WIDTH = 32;
|
|
391
504
|
|
|
505
|
+
/* ── MAGI screen: pixel art drawn with half blocks, two pixels per cell (one above the other) ── */
|
|
506
|
+
|
|
392
507
|
/**
|
|
393
|
-
* The three MAGI
|
|
394
|
-
*
|
|
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.
|
|
395
510
|
*/
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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 },
|
|
401
594
|
};
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
+
}
|
|
407
631
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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) },
|
|
417
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
|
+
);
|
|
418
661
|
}
|
|
419
662
|
|
|
420
663
|
/* ─────────────────────────────────────────────────────────── header ── */
|
|
421
664
|
|
|
422
665
|
/** Static header: it scrolls away with the conversation, so nothing here animates. */
|
|
423
666
|
function buildHeader(theme: Theme) {
|
|
424
|
-
const tree = renderTreeOfLife(theme);
|
|
425
|
-
|
|
426
667
|
return {
|
|
427
668
|
render(width: number): string[] {
|
|
428
669
|
const orange = (s: string) => theme.fg("accent", s);
|
|
429
|
-
const
|
|
430
|
-
const
|
|
431
|
-
const
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const [wordAt, triadAt] = [5, 12];
|
|
450
|
-
const lines = [""];
|
|
451
|
-
for (let i = 0; i < tree.length; i++) {
|
|
452
|
-
let right = "";
|
|
453
|
-
if (i >= wordAt && i < wordAt + MAGI_WORD.length) right = orange(MAGI_WORD[i - wordAt]!);
|
|
454
|
-
else if (i === triadAt) right = dim("├─ ") + triad + dim(" ─┤");
|
|
455
|
-
else if (i === triadAt + 1) right = muted(subtitle);
|
|
456
|
-
else if (i === triadAt + 2) right = dim(lore);
|
|
457
|
-
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"))];
|
|
458
690
|
}
|
|
459
|
-
lines.
|
|
460
|
-
return lines;
|
|
691
|
+
return ["", ...lines, note, ""].map((l) => truncateToWidth(l, width));
|
|
461
692
|
},
|
|
462
693
|
invalidate() {},
|
|
463
694
|
};
|
|
@@ -485,6 +716,8 @@ const swap = {
|
|
|
485
716
|
state: "off" as SwapState,
|
|
486
717
|
since: 0, // when the current state started
|
|
487
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)
|
|
488
721
|
error: "",
|
|
489
722
|
gpus: [] as GpuStat[],
|
|
490
723
|
ramUsed: 0,
|
|
@@ -583,15 +816,70 @@ async function refreshSwapActivity(): Promise<void> {
|
|
|
583
816
|
}
|
|
584
817
|
}
|
|
585
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
|
+
|
|
586
824
|
/**
|
|
587
|
-
*
|
|
588
|
-
*
|
|
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.
|
|
589
828
|
*/
|
|
590
|
-
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> {
|
|
591
879
|
if (!model || model.provider !== "llama-swap" || !model.baseUrl) {
|
|
592
880
|
swap.base = "";
|
|
593
881
|
setSwapState("off");
|
|
594
|
-
return;
|
|
882
|
+
return false;
|
|
595
883
|
}
|
|
596
884
|
const id = model.id;
|
|
597
885
|
swap.modelId = id;
|
|
@@ -605,14 +893,27 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
|
|
|
605
893
|
};
|
|
606
894
|
void refreshSwapMetrics();
|
|
607
895
|
void refreshSwapActivity();
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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;
|
|
616
917
|
|
|
617
918
|
// no answer within 400ms → the model isn't in VRAM and is being loaded
|
|
618
919
|
const slow = setTimeout(() => {
|
|
@@ -623,8 +924,15 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
|
|
|
623
924
|
const res = await swapGet(`/upstream/${encodeURIComponent(id)}/health`, 15 * 60_000);
|
|
624
925
|
if (swap.modelId !== id) return; // the model changed meanwhile
|
|
625
926
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
626
|
-
|
|
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;
|
|
627
934
|
setSwapState("ready");
|
|
935
|
+
if (!ctx.sessionManager.getBranch().some((e) => e.type === "message")) void prewarmPrefix(ctx.cwd);
|
|
628
936
|
} catch (err) {
|
|
629
937
|
if (swap.modelId === id) setSwapState("error", err instanceof Error ? err.message : String(err));
|
|
630
938
|
} finally {
|
|
@@ -633,6 +941,77 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
|
|
|
633
941
|
}
|
|
634
942
|
}
|
|
635
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
|
+
|
|
636
1015
|
/* ── /magi-ui status: a report built from the last requests llama-swap recorded ── */
|
|
637
1016
|
|
|
638
1017
|
interface ActivityRow {
|
|
@@ -809,40 +1188,44 @@ class MagiPanel implements Component {
|
|
|
809
1188
|
} else {
|
|
810
1189
|
let word: string;
|
|
811
1190
|
let tone: ThemeColor;
|
|
812
|
-
let link: ThemeColor = "dim";
|
|
813
1191
|
let hub = "─MAGI─";
|
|
814
1192
|
let lit = (i: number) => flicker(f, i);
|
|
1193
|
+
let virus: Virus | undefined;
|
|
815
1194
|
const breathing = ["─MAGI─", "━MAGI━"][f % 2]!;
|
|
816
1195
|
|
|
817
1196
|
if (state.compacting) {
|
|
818
1197
|
title = `COMPACTING · ${state.compactBy || "context"} · ${secs(state.compactSince)}`;
|
|
819
|
-
[word, tone,
|
|
1198
|
+
[word, tone, hub, titleTone] = ["COMPACTING", "warning", ["─SEAL─", "━SEAL━"][f % 2]!, "warning"];
|
|
820
1199
|
} else if (now - state.rebornAt < REBIRTH_MS) {
|
|
821
1200
|
title = "SEVENTH SEAL OPENED";
|
|
822
|
-
[word, tone,
|
|
1201
|
+
[word, tone, hub, titleTone] = ["REBORN", "success", "═MAGI═", "success"];
|
|
823
1202
|
lit = () => true;
|
|
824
1203
|
} else if (idle && (swap.state === "checking" || swap.state === "loading")) {
|
|
825
1204
|
title = swap.state === "loading" ? `LOADING MODEL · ${secs(swap.since)}` : "CHECKING MODEL";
|
|
826
|
-
[word, tone,
|
|
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 };
|
|
827
1209
|
} else if (idle && swap.state === "error") {
|
|
828
1210
|
title = "MAGI OFFLINE";
|
|
829
|
-
[word, tone,
|
|
1211
|
+
[word, tone, hub, titleTone] = ["OFFLINE", "error", "─ ╳ ──", "error"];
|
|
830
1212
|
lit = () => f % 16 < 8;
|
|
831
1213
|
} else if (idle && swap.state === "asleep") {
|
|
832
1214
|
title = "MODEL ASLEEP · type to wake";
|
|
833
1215
|
[word, tone, titleTone] = ["ASLEEP", "muted", "muted"];
|
|
834
1216
|
lit = () => false;
|
|
835
|
-
} else if (idle && swap.state === "ready" && now - swap.since <
|
|
1217
|
+
} else if (idle && swap.state === "ready" && now - swap.since < ONLINE_MS) {
|
|
836
1218
|
title = swap.loadMs ? `MAGI ONLINE · ${fmtMs(swap.loadMs)}` : "MAGI ONLINE";
|
|
837
|
-
[word, tone,
|
|
1219
|
+
[word, tone, hub, titleTone] = ["ONLINE", "accent", "═MAGI═", "success"];
|
|
838
1220
|
lit = () => true;
|
|
1221
|
+
virus = { infected: swap.infection, recovered: (now - swap.since) / RECOVER_MS, blink: f % 4 < 2 };
|
|
839
1222
|
} else if (state.phase === "thinking") {
|
|
840
1223
|
title = `THINKING · ${secs(state.phaseSince)}`;
|
|
841
|
-
[word, tone,
|
|
1224
|
+
[word, tone, hub] = ["THINKING", "accent", breathing];
|
|
842
1225
|
} else if (state.phase === "responding") {
|
|
843
1226
|
const tps = liveTps();
|
|
844
1227
|
title = `RESPONDING${tps ? ` · ${tps.toFixed(1)} tok/s` : ""}`;
|
|
845
|
-
[word, tone,
|
|
1228
|
+
[word, tone, hub, titleTone] = ["RESPONDING", "success", breathing, "success"];
|
|
846
1229
|
} else {
|
|
847
1230
|
title = "STANDBY";
|
|
848
1231
|
[word, tone, titleTone] = ["STANDBY", "muted", "muted"];
|
|
@@ -850,7 +1233,7 @@ class MagiPanel implements Component {
|
|
|
850
1233
|
}
|
|
851
1234
|
|
|
852
1235
|
const units = MAGI_UNITS.map((name, i) => ({ name, status: word, tone, lit: lit(i) }));
|
|
853
|
-
body = magiDiagram(th, units[1]!, units[2]!, units[0]!,
|
|
1236
|
+
body = magiDiagram(th, units[1]!, units[2]!, units[0]!, hub, virus);
|
|
854
1237
|
const c = state.lastCouncil;
|
|
855
1238
|
status = c
|
|
856
1239
|
? `${pulse} ${th.fg("dim", "COUNCIL ")}${th.fg(voteTone(c.verdict), `${c.verdict ?? "NO QUORUM"} ${c.tally}/3`)}`
|
|
@@ -911,9 +1294,10 @@ class MagiPanel implements Component {
|
|
|
911
1294
|
);
|
|
912
1295
|
|
|
913
1296
|
// SEALS: the context window, one seal per seventh
|
|
914
|
-
const usage =
|
|
1297
|
+
const usage = contextUsage();
|
|
915
1298
|
const percent = usage?.percent ?? 0;
|
|
916
|
-
|
|
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));
|
|
917
1301
|
if (!compact && usage?.contextWindow) {
|
|
918
1302
|
out.push(this.field("CONTEXT", `${usage.tokens == null ? "?" : fmtTokens(usage.tokens)} / ${fmtTokens(usage.contextWindow)}`, inner, "muted"));
|
|
919
1303
|
}
|
|
@@ -968,7 +1352,13 @@ class MagiPanel implements Component {
|
|
|
968
1352
|
? th.fg("error", "OFFLINE")
|
|
969
1353
|
: th.fg("warning", swap.state === "loading" ? `LOADING ${secsSince(swap.since)}s` : "CHECKING");
|
|
970
1354
|
const load = swap.state === "ready" && swap.loadMs ? th.fg("dim", ` load ${fmtMs(swap.loadMs)}`) : "";
|
|
971
|
-
|
|
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));
|
|
972
1362
|
if (compact) return [...out, this.costRow(inner)];
|
|
973
1363
|
if (swap.state === "error") out.push(this.frameLine(" " + th.fg("error", swap.error), inner));
|
|
974
1364
|
const gib = (n: number) => n / 2 ** 30;
|
|
@@ -1094,7 +1484,10 @@ interface MagiUnitConfig {
|
|
|
1094
1484
|
model?: string;
|
|
1095
1485
|
thinking?: string;
|
|
1096
1486
|
}
|
|
1097
|
-
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
|
+
};
|
|
1098
1491
|
|
|
1099
1492
|
const MAGI_CONFIG_PATH = join(homedir(), ".pi", "agent", "magi.json");
|
|
1100
1493
|
|
|
@@ -1280,10 +1673,9 @@ function buildDeliberationView(
|
|
|
1280
1673
|
return { name, status: o.vote === "CONDITIONAL" ? "COND." : o.vote, tone: voteTone(o.vote), lit: true };
|
|
1281
1674
|
});
|
|
1282
1675
|
const { verdict, tally } = tallyVerdict(opinions.map((o) => o?.vote ?? null));
|
|
1283
|
-
const link: ThemeColor = done ? voteTone(verdict) : "accent";
|
|
1284
1676
|
const hub = done ? "◆MAGI◆" : ["─MAGI─", "━MAGI━"][tick % 2]!;
|
|
1285
1677
|
const left = " ".repeat(Math.max(0, Math.floor((inner - MAGI_DIAGRAM_WIDTH) / 2)));
|
|
1286
|
-
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));
|
|
1287
1679
|
|
|
1288
1680
|
out.push(row(""));
|
|
1289
1681
|
for (let i = 0; i < MAGI.length; i++) {
|
|
@@ -1336,6 +1728,73 @@ function buildReportView(theme: Theme, lines: string[], close: () => void) {
|
|
|
1336
1728
|
};
|
|
1337
1729
|
}
|
|
1338
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
|
+
|
|
1339
1798
|
async function configureMagi(ctx: ExtensionContext): Promise<void> {
|
|
1340
1799
|
const cfg = loadMagiConfig();
|
|
1341
1800
|
const pool = ctx.scopedModels.length ? ctx.scopedModels.map((s) => s.model) : ctx.modelRegistry.getAvailable();
|
|
@@ -1442,7 +1901,7 @@ function footerLeft(th: Theme, now = Date.now()): string {
|
|
|
1442
1901
|
dim(` · loading model into VRAM ${secsSince(swap.since, now)}s`)
|
|
1443
1902
|
);
|
|
1444
1903
|
}
|
|
1445
|
-
const percent =
|
|
1904
|
+
const percent = contextUsage()?.percent ?? 0;
|
|
1446
1905
|
if (percent >= SIXTH_SEAL_PERCENT) {
|
|
1447
1906
|
const cmd = state.hasSmartCompact ? "/smart-compact" : "/compact";
|
|
1448
1907
|
return (
|
|
@@ -1613,10 +2072,47 @@ export default function (pi: ExtensionAPI) {
|
|
|
1613
2072
|
let unsubscribeInput: (() => void) | undefined;
|
|
1614
2073
|
let lastPoll = 0;
|
|
1615
2074
|
let titleTimer: ReturnType<typeof setInterval> | undefined;
|
|
2075
|
+
let liveTimer: ReturnType<typeof setInterval> | undefined;
|
|
1616
2076
|
/** Tools currently executing, by call id: the golem shows one of them, and leaves when none is left. */
|
|
1617
2077
|
const runningTools = new Map<string, { name: string; target: string }>();
|
|
1618
2078
|
|
|
1619
|
-
|
|
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
|
+
};
|
|
2114
|
+
|
|
2115
|
+
pi.on("session_start", async (event, ctx) => {
|
|
1620
2116
|
liveCtx = ctx;
|
|
1621
2117
|
recountSession(ctx);
|
|
1622
2118
|
state.hasSmartCompact = pi.getCommands().some((c) => c.name.replace(/^\//, "") === "smart-compact");
|
|
@@ -1626,8 +2122,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1626
2122
|
ui.currency = cfg.ui?.currency === "USD" ? "USD" : "EUR";
|
|
1627
2123
|
if (ctx.mode !== "tui") return;
|
|
1628
2124
|
applyChrome(ctx);
|
|
1629
|
-
//
|
|
1630
|
-
|
|
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));
|
|
1631
2128
|
// GPU stats every 3s while something happens, every 30s when idle
|
|
1632
2129
|
metricsTimer ??= setInterval(() => {
|
|
1633
2130
|
const now = Date.now();
|
|
@@ -1638,13 +2135,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
1638
2135
|
.then(repaint);
|
|
1639
2136
|
}, BUSY_POLL_MS);
|
|
1640
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?.();
|
|
1641
2143
|
// back at the keyboard: clear the ✓ from the title, wake a model llama-swap unloaded
|
|
1642
2144
|
unsubscribeInput ??= ctx.ui.onTerminalInput(() => {
|
|
1643
2145
|
if (titleDone) {
|
|
1644
2146
|
titleDone = false;
|
|
1645
2147
|
ctx.ui.setTitle(windowTitle(ctx.cwd));
|
|
1646
2148
|
}
|
|
1647
|
-
if (swap.state === "asleep" && liveCtx) void preloadModel(liveCtx);
|
|
2149
|
+
if (swap.state === "asleep" && liveCtx && !pickerOpen) void preloadModel(liveCtx);
|
|
1648
2150
|
return undefined;
|
|
1649
2151
|
});
|
|
1650
2152
|
// ponytail: re-applies the title every 2s because pi overwrites it without telling extensions
|
|
@@ -1660,18 +2162,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
1660
2162
|
repaint();
|
|
1661
2163
|
});
|
|
1662
2164
|
|
|
2165
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
2166
|
+
rememberPrefix(ctx.cwd, event.payload);
|
|
2167
|
+
});
|
|
2168
|
+
|
|
1663
2169
|
pi.on("model_select", async (event, ctx) => {
|
|
1664
2170
|
liveCtx = ctx;
|
|
1665
2171
|
if (ctx.mode === "tui") void preloadModel(ctx, event.model);
|
|
1666
2172
|
});
|
|
1667
2173
|
|
|
1668
2174
|
pi.on("session_shutdown", async () => {
|
|
2175
|
+
prewarm.abort?.abort();
|
|
1669
2176
|
clearInterval(metricsTimer);
|
|
1670
2177
|
metricsTimer = undefined;
|
|
1671
2178
|
unsubscribeInput?.();
|
|
1672
2179
|
unsubscribeInput = undefined;
|
|
1673
2180
|
clearInterval(titleTimer);
|
|
1674
2181
|
titleTimer = undefined;
|
|
2182
|
+
clearInterval(liveTimer);
|
|
2183
|
+
liveTimer = undefined;
|
|
1675
2184
|
hidePanel();
|
|
1676
2185
|
});
|
|
1677
2186
|
|
|
@@ -1681,6 +2190,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1681
2190
|
|
|
1682
2191
|
pi.on("turn_start", async (_event, ctx) => {
|
|
1683
2192
|
liveCtx = ctx;
|
|
2193
|
+
prewarm.abort?.abort();
|
|
1684
2194
|
state.turns++;
|
|
1685
2195
|
setPhase("thinking");
|
|
1686
2196
|
if (ctx.mode === "tui" && chrome) ctx.ui.setWorkingMessage("the MAGI deliberate…");
|
|
@@ -1855,10 +2365,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1855
2365
|
}
|
|
1856
2366
|
|
|
1857
2367
|
pi.registerCommand("magi", {
|
|
1858
|
-
description: "Ask the three MAGI (pragmatist, guardian, visionary); /magi review [focus] judges the git diff; /magi config assigns models",
|
|
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",
|
|
1859
2369
|
handler: async (args, ctx) => {
|
|
1860
2370
|
const arg = args.trim();
|
|
1861
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
|
+
}
|
|
1862
2377
|
|
|
1863
2378
|
if (arg === "review" || arg.startsWith("review ")) {
|
|
1864
2379
|
const focus = arg.slice("review".length).trim();
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-magi-theme",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MAGI theme + extension for pi:
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MAGI SYSTEM theme + extension for pi (Evangelion fan art): MAGI control screen panel, three-model /magi council, MECHA SELECT model picker, angel-attack loading, seven-seal context gauge, llama-swap telemetry",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi-extension",
|
|
8
8
|
"pi-theme",
|
|
9
9
|
"pi",
|
|
10
10
|
"magi",
|
|
11
|
-
"
|
|
11
|
+
"evangelion",
|
|
12
12
|
"llama-swap"
|
|
13
13
|
],
|
|
14
14
|
"author": "b-iurea",
|