pi-mtplx 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,11 +10,21 @@ pi install npm:pi-mtplx
10
10
 
11
11
  Restart Pi after installation.
12
12
 
13
+ ## Prerequisites
14
+
15
+ **pi-mtplx ships no model.** It only wires Pi to MTPLX, the inference engine — which you must install separately, and whose model weights you must download yourself. The extension has nothing to run until you do:
16
+
17
+ 1. Install MTPLX (see its own docs).
18
+ 2. Download the model(s) you want, e.g. `mtplx install Youssofal/Qwen3.8-27B-MTPLX-Optimized-Quality`.
19
+ 3. Register a downloaded model with Pi via `/mtplx` → **Models** (or add it manually to `~/.pi/agent/mtplx-models.json`), then open `/model` to activate it.
20
+
21
+ If you pick an MTPLX model that isn't installed, the server won't start — Pi will warn you. Use `mtplx list` to see what you've downloaded.
22
+
13
23
  ## What happens on first run
14
24
 
15
25
  Once installed, Pi automatically manages your MTPLX workflow:
16
26
 
17
- - **Model discovery** — A built-in model (`mtplx-qwen38-27b-optimized-quality`) is pre-registered. More are discoverable via `/mtplx`.
27
+ - **Model discovery** — `/mtplx` **Models** scans what you've downloaded (`mtplx list`) and registers any of them with Pi. No model is bundled or pre-hardcoded: each Pi model id and its capabilities (context window, vision, reasoning) are derived live from the installed artifact, so models beyond the MTPLX stock set work too.
18
28
  - **Auto-start** — The MTPLX server starts when you switch to an `mtplx` model and shuts down cleanly when Pi exits.
19
29
  - **Token speed** — A `⚡N.N tk/s` indicator appears in the footer showing the generation speed of the last assistant turn.
20
30
 
@@ -26,10 +36,11 @@ Run `/mtplx` to open an interactive menu:
26
36
  | -------- | ------------- |
27
37
  | **Toggle (on/off)** | Start or stop the MTPLX server |
28
38
  | **Fan Curves** | Set the thermal profile (`default`, `smart`, `max`) |
29
- | **Models (register)** | Scan installed MTPLX models and register one with Pi |
30
- | **Remove Model** | Unregister a model from Pi |
39
+ | **Models** | Register or unregister models means registered (click to unregister), ✗ means available (click to register) |
31
40
  | **Uninstall** | Remove the `mtplx` provider from Pi's config |
32
41
 
42
+ > **To activate a model** after registering or unregistering it, open **`/model`** (or `/scoped-models`). pi-mtplx writes Pi's config files immediately, but Pi loads them into memory on startup; opening either picker refreshes that in-memory list. No `/reload` needed.
43
+
33
44
  ## Configuration
34
45
 
35
46
  ### Model registry
@@ -38,13 +49,13 @@ Models are registered in `~/.pi/agent/mtplx-models.json`. Each entry maps a Pi m
38
49
 
39
50
  ```json
40
51
  {
41
- "mtplx-qwen38-27b-optimized-quality": {
52
+ "mtplx-qwen3.8-27b-mtplx-optimized-quality": {
42
53
  "ref": "Youssofal/Qwen3.8-27B-MTPLX-Optimized-Quality"
43
54
  }
44
55
  }
45
56
  ```
46
57
 
47
- Register new models via the `/mtplx` → **Models** menu, or add entries manually to this file (then run `/reload`).
58
+ Register new models via the `/mtplx` → **Models** menu, or add entries manually to this file; then open `/model` (or `/scoped-models`) to activate them.
48
59
 
49
60
  ### Fan mode
50
61
 
@@ -8,18 +8,18 @@
8
8
  */
9
9
  import { acquire, release, stopServer } from "../src/mtplx-process.ts";
10
10
  import { getFanMode, health, setFanMode, setFanModeValue } from "../src/mtplx-client.ts";
11
- import { MTPLX_PROVIDER, listModels, removeModel, removePiMtplxProvider, MTPLX_MODELS } from "../src/model-discovery.ts";
11
+ import { MTPLX_PROVIDER, manageModels, removeModel, removePiMtplxProvider } from "../src/model-discovery.ts";
12
12
  import { FAN_MODES, isMtplxModel, saveFanMode, type FanMode } from "../src/utils.ts";
13
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
14
 
15
15
  export default function mtplxAutostart(pi: ExtensionAPI): void {
16
16
  pi.registerCommand("mtplx", {
17
- description: "MTPLX — toggle the server, pick a fan curve, or register a model",
17
+ description: "MTPLX — toggle the server, pick a fan curve, manage models, or uninstall",
18
18
  handler: async (_args, ctx) => {
19
19
  const current = await health();
20
20
  const status = current ? "on" : "off";
21
21
  await ctx.ui.setStatus("mtplx", `MTPLX: ${status}`);
22
- const topChoices = [`Toggle (${status})`, `Fan Curves (current: ${getFanMode()})`, `Models (register)`, `Remove Model`, "Uninstall (remove provider)"];
22
+ const topChoices = [`Toggle (${status})`, `Fan Curves (current: ${getFanMode()})`, "Models", "Uninstall (remove provider)"];
23
23
  const top = await ctx.ui.select("MTPLX", topChoices, undefined);
24
24
  if (!top) return;
25
25
  if (top.startsWith("Toggle")) {
@@ -48,23 +48,7 @@ export default function mtplxAutostart(pi: ExtensionAPI): void {
48
48
  ctx.ui.notify(`MTPLX fan mode set to ${getFanMode()}`, "info");
49
49
  }
50
50
  if (top.startsWith("Models")) {
51
- await listModels(ctx);
52
- }
53
- if (top.startsWith("Remove Model")) {
54
- const registered = Object.keys(MTPLX_MODELS);
55
- if (registered.length === 0) {
56
- ctx.ui.notify("No MTPLX models registered. Register one first via Models (register).", "warning");
57
- return;
58
- }
59
- const choices = registered.map((id) => `${id} (registered)`);
60
- choices.push("Cancel");
61
- const picked = await ctx.ui.select("Remove a registered MTPLX model", choices, undefined);
62
- if (!picked || picked === "Cancel") return;
63
- if (removeModel(picked)) {
64
- ctx.ui.notify(`Removed ${picked} from models.json and enabledModels. Run /reload.`, "info");
65
- } else {
66
- ctx.ui.notify(`${picked} not found in registered models.`, "warning");
67
- }
51
+ await manageModels(ctx);
68
52
  }
69
53
  if (top.startsWith("Uninstall")) {
70
54
  const ok = await ctx.ui.confirm(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mtplx",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Zero-config MTPLX integration for Pi coding agent",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -4,8 +4,11 @@
4
4
  * The refs are the artifact identifiers reported by `mtplx models --json`;
5
5
  * `--model-id` makes /health and /v1/models report Pi's id.
6
6
  *
7
- * The built-in map is the fallback; registrations added from `/mtplx` are
8
- * persisted next to it (~/.pi/agent/mtplx-models.json) and override it at load.
7
+ * The extension ships no model weights and hard-codes no model. The registry
8
+ * holds only models registered through `/mtplx`, persisted to
9
+ * ~/.pi/agent/mtplx-models.json; each Pi model id is derived live from the
10
+ * artifact's ref (see modelIdFromRef) so models.json, enabledModels and this
11
+ * registry always agree on the id.
9
12
  *
10
13
  * Pi's model catalog is the USER's own ~/.pi/agent/models.json — a provider
11
14
  * config that pre-existed this package (created by `mtplx start pi` / the
@@ -19,7 +22,7 @@ import { join } from "node:path";
19
22
  import { execFile } from "node:child_process";
20
23
  import { promisify } from "node:util";
21
24
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
22
- import { modelIdFromRef, displayNameFromId, slugFromId } from "./utils.ts";
25
+ import { modelIdFromRef, displayNameFromId } from "./utils.ts";
23
26
 
24
27
  const execFileAsync = promisify(execFile);
25
28
 
@@ -29,12 +32,6 @@ export const MTPLX_PROVIDER = "mtplx";
29
32
  const MODELS_FILE = join(homedir(), ".pi", "agent", "mtplx-models.json");
30
33
  const SETTINGS_FILE = join(homedir(), ".pi", "agent", "settings.json");
31
34
 
32
- const BUILTIN_MODELS: Record<string, { ref: string }> = {
33
- "mtplx-qwen38-27b-optimized-quality": {
34
- ref: "Youssofal/Qwen3.8-27B-MTPLX-Optimized-Quality",
35
- },
36
- };
37
-
38
35
  export function loadRegisteredModels(): Record<string, { ref: string }> {
39
36
  try {
40
37
  const parsed = JSON.parse(readFileSync(MODELS_FILE, "utf8")) as Record<string, { ref?: unknown }>;
@@ -44,12 +41,12 @@ export function loadRegisteredModels(): Record<string, { ref: string }> {
44
41
  }
45
42
  return out;
46
43
  } catch {
47
- // missing or corrupt file → fall back to the built-ins only
44
+ // missing or corrupt file → empty registry
48
45
  }
49
46
  return {};
50
47
  }
51
48
 
52
- export const MTPLX_MODELS: Record<string, { ref: string }> = { ...BUILTIN_MODELS, ...loadRegisteredModels() };
49
+ export const MTPLX_MODELS: Record<string, { ref: string }> = loadRegisteredModels();
53
50
 
54
51
  export function saveRegisteredModels(): void {
55
52
  try {
@@ -93,7 +90,7 @@ export function disableModelInSettings(modelId: string): boolean {
93
90
  const enabled = Array.isArray(catalog.enabledModels) ? catalog.enabledModels : [];
94
91
  const entry = `mtplx/${modelId}`;
95
92
  const idx = enabled.indexOf(entry);
96
- if (idx === -1) return true;
93
+ if (idx === -1) return false; // nothing to remove
97
94
  enabled.splice(idx, 1);
98
95
  catalog.enabledModels = enabled;
99
96
  writeFileSync(SETTINGS_FILE, JSON.stringify(catalog, null, 2) + "\n");
@@ -121,39 +118,129 @@ export function listedIdentity(model: MtplxListedModel): string {
121
118
  }
122
119
 
123
120
  /**
124
- * Ask the user which MTPLX models are installed, then register the pick into
125
- * the mapping file and into the `mtplx` provider of the user's models.json
126
- * catalog. Every other provider is left untouched.
121
+ * Model ids currently present in the `mtplx` provider of Pi's catalog
122
+ * (models.json). This is the source of truth for whether a model is actually
123
+ * registered with Pi (and thus usable via /model) — independent of the registry,
124
+ * which only records which MTPLX artifacts are available, not which are registered.
127
125
  */
128
- export async function listModels(ctx: ExtensionContext): Promise<void> {
126
+ function catalogModelIds(): string[] {
127
+ const modelsJsonPath = join(homedir(), ".pi", "agent", "models.json");
128
+ try {
129
+ const catalog = JSON.parse(readFileSync(modelsJsonPath, "utf8")) as { providers?: Record<string, unknown> };
130
+ const provider = catalog.providers?.[MTPLX_PROVIDER] as { models?: { id?: unknown }[] } | undefined;
131
+ if (!provider || !Array.isArray(provider.models)) return [];
132
+ return provider.models.filter((m): m is { id: string } => typeof m.id === "string").map((m) => m.id);
133
+ } catch {
134
+ return [];
135
+ }
136
+ }
137
+
138
+ type ModelProfile = {
139
+ contextWindow: number;
140
+ maxTokens: number;
141
+ reasoning: boolean;
142
+ input: string[];
143
+ };
144
+
145
+ /**
146
+ * Neutral fallback used only when an installed artifact's metadata cannot be read
147
+ * (mtplx list validates that config.json exists, so this is a last resort). It
148
+ * deliberately assumes nothing about the model: text-only, modest context, no
149
+ * reasoning capability.
150
+ */
151
+ const FALLBACK_PROFILE: ModelProfile = { contextWindow: 131072, maxTokens: 131072, reasoning: false, input: ["text"] };
152
+
153
+ /**
154
+ * Derive a Pi provider model profile from the installed artifact's OWN metadata
155
+ * (config.json + mtplx_runtime.json). Context window, vision support, reasoning
156
+ * and token limits are read from the model itself — never assumed for a brand —
157
+ * so an arbitrary MTPLX model registers with correct values.
158
+ */
159
+ function readModelProfile(model: MtplxListedModel | undefined): ModelProfile {
160
+ const dir = typeof model?.path === "string" ? model.path : "";
161
+ if (!dir) return FALLBACK_PROFILE;
162
+ try {
163
+ const config = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as {
164
+ text_config?: Record<string, unknown>;
165
+ max_position_embeddings?: unknown;
166
+ sliding_window?: unknown;
167
+ vision_config?: unknown;
168
+ model_type?: unknown;
169
+ architectures?: unknown;
170
+ };
171
+ const raw = config.text_config ?? config;
172
+ const ctx = Number(raw.max_position_embeddings ?? config.max_position_embeddings ?? config.sliding_window);
173
+ const contextWindow = Number.isFinite(ctx) && ctx > 0 ? ctx : FALLBACK_PROFILE.contextWindow;
174
+ const arch = String(config.model_type ?? (config.architectures as unknown[] | undefined)?.[0] ?? "");
175
+ return {
176
+ contextWindow,
177
+ // max output tokens can't exceed the model's own context (both from the live read)
178
+ maxTokens: contextWindow,
179
+ // detect reasoning support from the architecture string rather than assuming a brand
180
+ reasoning: /reasoning|thinking|^qwen3/i.test(arch.toLowerCase()),
181
+ input: config.vision_config ? ["text", "image"] : ["text"],
182
+ };
183
+ } catch {
184
+ return FALLBACK_PROFILE;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Toggle registration of MTPLX models — register unregistered models,
190
+ * unregister registered ones. Shows ✓/✗ marks to indicate status.
191
+ *
192
+ * Returns true if a change was made (open /model to apply it).
193
+ */
194
+ export async function manageModels(ctx: ExtensionContext): Promise<boolean> {
129
195
  const installed = await listMtplxModels();
130
196
  if (installed.length === 0) {
131
197
  ctx.ui.notify("No MTPLX models found. Install one with `mtplx install`.", "warning");
132
- return;
198
+ return false;
133
199
  }
200
+ const catalog = new Set(catalogModelIds());
134
201
  const choices = installed.map((model) => {
135
202
  const ref = listedIdentity(model);
136
- const refId = modelIdFromRef(ref);
137
- const existingId = Object.keys(MTPLX_MODELS).find((id) => MTPLX_MODELS[id].ref === ref || slugFromId(id) === slugFromId(refId));
138
- const id = existingId ?? refId;
139
- const mark = existingId ? "✓" : "✗";
203
+ // Each artifact maps to exactly one Pi model id, derived live from its ref.
204
+ // No hard-coded ids the same id is written to models.json, enabledModels and
205
+ // the registry so the three never drift apart.
206
+ const id = modelIdFromRef(ref);
207
+ // ✓ means registered in Pi's catalog (models.json); ✗ means just installed in MTPLX.
208
+ const mark = catalog.has(id) ? "✓" : "✗";
140
209
  return `${mark} ${id} — ${ref}`;
141
210
  });
142
211
  choices.push("Cancel");
143
- const picked = await ctx.ui.select("MTPLX models — ✓ registered in Pi · ✗ available in MTPLX — run /reload after making changes", choices, undefined);
144
- if (!picked || picked === "Cancel") return;
212
+ const picked = await ctx.ui.select("MTPLX models — ✓ registered in Pi · ✗ available in MTPLX — open /model after making changes", choices, undefined);
213
+ if (!picked || picked === "Cancel") return false;
145
214
  const ref = picked.split(" — ").slice(1).join(" — ");
146
- const modelId = Object.keys(MTPLX_MODELS).find((id) => MTPLX_MODELS[id].ref === ref) ?? modelIdFromRef(ref);
147
- if (MTPLX_MODELS[modelId]) {
148
- ctx.ui.notify(`${modelId} is already registered run /reload, then switch with /model.`, "info");
149
- return;
215
+ const modelId = modelIdFromRef(ref);
216
+ // The installed-model record for the chosen artifact (used to read its live metadata).
217
+ const chosen = installed.find((m) => listedIdentity(m) === ref);
218
+
219
+ if (catalog.has(modelId)) {
220
+ // Unregister: remove from models.json, enabledModels, and the id→ref registry.
221
+ const wasActive = ctx.model?.provider === MTPLX_PROVIDER && ctx.model?.id === modelId;
222
+ if (removeModel(modelId)) {
223
+ ctx.ui.notify(
224
+ wasActive
225
+ ? `Unregistered ${modelId} — still active for this session; it will drop from /model once you switch away.`
226
+ : `Unregistered ${modelId}. Open /model to apply.`,
227
+ "info",
228
+ );
229
+ return true;
230
+ }
231
+ ctx.ui.notify(`${modelId} could not be unregistered.`, "warning");
232
+ return false;
150
233
  }
151
234
 
152
- // 1) Persist the Pi id → artifact ref mapping (also used to resolve autostart model ids).
235
+ // Register the model
236
+ // 1) Persist the Pi id → artifact ref mapping.
153
237
  MTPLX_MODELS[modelId] = { ref };
154
238
  saveRegisteredModels();
155
239
 
156
- // 2) Register the model in the `mtplx` provider of Pi's catalog (models.json).
240
+ // 2) Register in the mtplx provider of Pi's catalog (models.json).
241
+ // Context window, vision, reasoning and token limits are read LIVE from the
242
+ // installed artifact's config/runtime contract — never assumed for a brand.
243
+ const profile = readModelProfile(chosen);
157
244
  try {
158
245
  const modelsJsonPath = join(homedir(), ".pi", "agent", "models.json");
159
246
  const catalog = JSON.parse(readFileSync(modelsJsonPath, "utf8")) as { providers?: Record<string, unknown> };
@@ -174,17 +261,17 @@ export async function listModels(ctx: ExtensionContext): Promise<void> {
174
261
  }) as { models?: unknown[] };
175
262
  const models = Array.isArray(provider.models) ? (provider.models as unknown[]) : [];
176
263
  if (models.some((entry) => (entry as { id?: unknown }).id === modelId)) {
177
- ctx.ui.notify(`Already in models.json: ${modelId}`, "warning");
178
- return;
264
+ ctx.ui.notify(`${modelId} is already registered — open /model to refresh, then switch.`, "info");
265
+ return false;
179
266
  }
180
267
  models.push({
181
268
  id: modelId,
182
269
  name: displayNameFromId(modelId),
183
270
  api: "openai-completions",
184
- reasoning: true,
185
- input: ["text", "image"],
186
- contextWindow: 262144,
187
- maxTokens: 65536,
271
+ reasoning: profile.reasoning,
272
+ input: profile.input,
273
+ contextWindow: profile.contextWindow,
274
+ maxTokens: profile.maxTokens,
188
275
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
189
276
  thinkingLevelMap: {
190
277
  off: null,
@@ -200,35 +287,46 @@ export async function listModels(ctx: ExtensionContext): Promise<void> {
200
287
  writeFileSync(modelsJsonPath, JSON.stringify(catalog, null, 2) + "\n");
201
288
  } catch (error) {
202
289
  ctx.ui.notify(`MTPLX model mapping saved, but models.json update failed: ${error instanceof Error ? error.message : String(error)}`, "error");
203
- return;
290
+ return false;
204
291
  }
205
- // 3) Also enable the model in settings.json so it shows on first boot.
292
+ // 3) Also enable the model in settings.json.
206
293
  enableModelInSettings(modelId);
207
- ctx.ui.notify(`Registered ${modelId} → ${ref}. Switch to it with /model.`, "info");
294
+ ctx.ui.notify(`Registered ${modelId} → ${ref}. Open /model to activate it.`, "info");
295
+ return true;
208
296
  }
209
-
210
297
  /**
211
298
  * Remove a single registered model from the provider and from enabledModels.
212
299
  */
213
300
  export function removeModel(modelId: string): boolean {
301
+ let changed = false;
214
302
  // 1) Remove from the mtplx provider in models.json
215
303
  const modelsJsonPath = join(homedir(), ".pi", "agent", "models.json");
216
- if (!existsSync(modelsJsonPath)) return false;
217
- try {
218
- const catalog = JSON.parse(readFileSync(modelsJsonPath, "utf8")) as { providers?: Record<string, unknown> };
219
- const provider = (catalog.providers?.[MTPLX_PROVIDER] as { models?: unknown[] }) ?? {};
220
- const models = Array.isArray(provider.models) ? provider.models : [];
221
- const filtered = (models as { id?: string }[]).filter((m) => m.id !== modelId);
222
- if (filtered.length === models.length) return false; // not found
223
- (provider as { models?: unknown[] }).models = filtered;
224
- writeFileSync(modelsJsonPath, JSON.stringify(catalog, null, 2) + "\n");
225
- } catch (error) {
226
- console.error(`pi-mtplx could not remove ${modelId} from models.json: ${error instanceof Error ? error.message : String(error)}`);
227
- return false;
304
+ if (existsSync(modelsJsonPath)) {
305
+ try {
306
+ const catalog = JSON.parse(readFileSync(modelsJsonPath, "utf8")) as { providers?: Record<string, unknown> };
307
+ const provider = (catalog.providers?.[MTPLX_PROVIDER] as { models?: { id?: string }[] }) ?? {};
308
+ const models = Array.isArray(provider.models) ? provider.models : [];
309
+ const filtered = models.filter((m) => m.id !== modelId);
310
+ if (filtered.length !== models.length) {
311
+ provider.models = filtered;
312
+ writeFileSync(modelsJsonPath, JSON.stringify(catalog, null, 2) + "\n");
313
+ changed = true;
314
+ }
315
+ } catch (error) {
316
+ console.error(`pi-mtplx could not remove ${modelId} from models.json: ${error instanceof Error ? error.message : String(error)}`);
317
+ }
228
318
  }
229
- // 2) Also remove from enabledModels in settings.json
230
- disableModelInSettings(modelId);
231
- return true;
319
+ // 2) Remove from enabledModels in settings.json (no-op if already absent).
320
+ if (disableModelInSettings(modelId)) changed = true;
321
+ // 3) Remove from the extension registry (id → artifact ref). Built-in entries are
322
+ // re-merged on reload, but removing the own property here clears any persisted
323
+ // mtplx-models.json copy so the registry can't drift from Pi's catalog.
324
+ if (Object.prototype.hasOwnProperty.call(MTPLX_MODELS, modelId)) {
325
+ delete MTPLX_MODELS[modelId];
326
+ saveRegisteredModels();
327
+ changed = true;
328
+ }
329
+ return changed;
232
330
  }
233
331
 
234
332
  /**