@jwilger/pi-development-system 0.4.0 → 0.5.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.
@@ -9,6 +9,7 @@ import { createRecordDepartureTool } from "../src/gates/record-departure-tool.ts
9
9
  import { createRequestApprovalTool } from "../src/gates/request-approval-tool.ts";
10
10
  import { registerTestGuard } from "../src/gates/test-guard.ts";
11
11
  import { createJevHolder } from "../src/jev/holder.ts";
12
+ import { createModelsTool, registerModelsCommand } from "../src/state/models-command.ts";
12
13
  import { createSessionState } from "../src/state/session-state.ts";
13
14
 
14
15
  const nonNegotiables = readFileSync(
@@ -60,6 +61,8 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
60
61
  registerTestGuard({ pi, state, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
61
62
  pi.registerTool(createRecordDepartureTool({ pi, state }));
62
63
  pi.registerTool(createRequestApprovalTool({ pi, approvals }));
64
+ pi.registerTool(createModelsTool());
65
+ registerModelsCommand(pi);
63
66
 
64
67
  pi.registerCommand("devsys-status", {
65
68
  description: "Show development-system phase, sizing, slice, departures and Jev status",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -203,3 +203,19 @@ export function renderMatrixToml(matrix: ModelMatrix): string {
203
203
  );
204
204
  return `[models]\n${lines.join("\n")}\n`;
205
205
  }
206
+
207
+ /** `text` with its `[models]` table replaced by `matrix` (appended when absent); other tables untouched. */
208
+ export function upsertModelsTable(text: string, matrix: ModelMatrix): string {
209
+ const table = renderMatrixToml(matrix);
210
+ const lines = text.split("\n");
211
+ const start = lines.findIndex((l) => l.trim() === "[models]");
212
+ if (start === -1) {
213
+ const body = text.trimEnd();
214
+ return body === "" ? table : `${body}\n\n${table}`;
215
+ }
216
+ const nextHeader = lines.findIndex((l, i) => i > start && l.startsWith("["));
217
+ const head = lines.slice(0, start).join("\n");
218
+ const tail = nextHeader === -1 ? "" : lines.slice(nextHeader).join("\n");
219
+ const before = head === "" ? "" : `${head}\n`;
220
+ return tail === "" ? `${before}${table}` : `${before}${table}\n${tail}`;
221
+ }
@@ -0,0 +1,234 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionCommandContext,
6
+ ExtensionContext,
7
+ ToolDefinition,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { type Static, Type } from "typebox";
10
+ import {
11
+ type Available,
12
+ isSlot,
13
+ type ModelMatrix,
14
+ type Resolution,
15
+ resolveSlot,
16
+ SLOTS,
17
+ type Slot,
18
+ upsertModelsTable,
19
+ } from "../core/models.ts";
20
+ import { CONFIG_FILE, loadConfig } from "./config.ts";
21
+
22
+ export const CONFIG_ENTRY_TYPE = "devsys-config";
23
+
24
+ export type CatalogModel = Available & {
25
+ readonly cost?: { readonly input: number; readonly output: number };
26
+ };
27
+
28
+ /** The slice of pi's `ctx.modelRegistry` this command needs; tests pass a fake. */
29
+ export interface ModelCatalog {
30
+ getAvailable(): readonly CatalogModel[];
31
+ getModelsOfType(type: "classifier"): readonly CatalogModel[];
32
+ hasConfiguredAuth(model: CatalogModel): boolean;
33
+ }
34
+
35
+ export type SlotRow = {
36
+ readonly slot: Slot;
37
+ readonly resolution: Resolution | undefined;
38
+ };
39
+
40
+ /** Chat models with credentials plus classifier models with credentials: everything a slot may name. */
41
+ export function availableModels(catalog: ModelCatalog): CatalogModel[] {
42
+ const classifiers = catalog
43
+ .getModelsOfType("classifier")
44
+ .filter((m) => catalog.hasConfiguredAuth(m));
45
+ return [...catalog.getAvailable(), ...classifiers];
46
+ }
47
+
48
+ export function resolveRows(matrix: ModelMatrix, available: readonly Available[]): SlotRow[] {
49
+ return SLOTS.map((slot) => {
50
+ const r = resolveSlot(matrix, slot, available);
51
+ return { slot, resolution: r.ok ? r.value : undefined };
52
+ });
53
+ }
54
+
55
+ const perMillion = (n: number): string => `$${Number(n.toFixed(2))}`;
56
+
57
+ function priceOf(model: string, available: readonly CatalogModel[]): string {
58
+ const hit = available.find((m) => `${m.provider}/${m.id}` === model);
59
+ return hit?.cost === undefined
60
+ ? ""
61
+ : ` · ${perMillion(hit.cost.input)}/${perMillion(hit.cost.output)} per M`;
62
+ }
63
+
64
+ /** One line per slot: `slot → provider/id (via candidate) · $in/$out per M`, or `UNRESOLVED`. */
65
+ export function renderModelsTable(
66
+ rows: readonly SlotRow[],
67
+ available: readonly CatalogModel[],
68
+ ): string {
69
+ const width = Math.max(...SLOTS.map((s) => s.length));
70
+ return rows
71
+ .map(({ slot, resolution }) =>
72
+ resolution === undefined
73
+ ? `${slot.padEnd(width)} → UNRESOLVED (no candidate matches a model with credentials)`
74
+ : `${slot.padEnd(width)} → ${resolution.model} (via ${resolution.via})${priceOf(resolution.model, available)}`,
75
+ )
76
+ .join("\n");
77
+ }
78
+
79
+ const DEFAULT_FILE = 'version = 1\n\n[delivery]\nmode = "trunk"\n';
80
+
81
+ export type ModelsOutcome = {
82
+ readonly ok: boolean;
83
+ readonly written: boolean;
84
+ readonly table: string;
85
+ };
86
+
87
+ type Choice = "accept" | "accept-rest" | "pick" | "pin";
88
+
89
+ const CHOICE_LABELS: Record<Choice, string> = {
90
+ accept: "accept",
91
+ "accept-rest": "accept this and all remaining slots",
92
+ pick: "pick another available model",
93
+ pin: "pin the current exact id (no auto-roll)",
94
+ };
95
+
96
+ async function chooseSlot(
97
+ ctx: ExtensionCommandContext,
98
+ slot: Slot,
99
+ row: SlotRow,
100
+ available: readonly CatalogModel[],
101
+ matrix: ModelMatrix,
102
+ ): Promise<{ matrix: ModelMatrix; acceptRest: boolean }> {
103
+ const current = row.resolution?.model;
104
+ const labels = current === undefined ? [CHOICE_LABELS.pick] : Object.values(CHOICE_LABELS);
105
+ const picked = await ctx.ui.select(`${slot}: ${current ?? "unresolved"}`, labels);
106
+ const choice = (Object.keys(CHOICE_LABELS) as Choice[]).find((k) => CHOICE_LABELS[k] === picked);
107
+ if (choice === "accept-rest") return { matrix, acceptRest: true };
108
+ if (choice === "pin" && current !== undefined) {
109
+ return { matrix: { ...matrix, [slot]: [current, ...matrix[slot]] }, acceptRest: false };
110
+ }
111
+ if (choice === "pick") {
112
+ const refs = available.map((m) => `${m.provider}/${m.id}`);
113
+ const model = await ctx.ui.select(`${slot}: choose a model`, refs);
114
+ if (model !== undefined) {
115
+ return { matrix: { ...matrix, [slot]: [model, ...matrix[slot]] }, acceptRest: false };
116
+ }
117
+ }
118
+ return { matrix, acceptRest: false };
119
+ }
120
+
121
+ async function readConfigText(repoRoot: string): Promise<string> {
122
+ try {
123
+ return await readFile(join(repoRoot, CONFIG_FILE), "utf8");
124
+ } catch {
125
+ return DEFAULT_FILE;
126
+ }
127
+ }
128
+
129
+ /** Core of `/devsys-models`: resolve, optionally adjust interactively, and write the `[models]` table. */
130
+ export async function runModelsCommand(
131
+ pi: Pick<ExtensionAPI, "appendEntry">,
132
+ ctx: ExtensionCommandContext,
133
+ args: string,
134
+ catalog: ModelCatalog,
135
+ ): Promise<ModelsOutcome> {
136
+ const config = await loadConfig(ctx.cwd);
137
+ if (!config.ok) {
138
+ ctx.ui.notify(`${CONFIG_FILE}: ${config.error.message}`, "error");
139
+ return { ok: false, written: false, table: "" };
140
+ }
141
+ const available = availableModels(catalog);
142
+ let matrix = config.value.models;
143
+ let rows = resolveRows(matrix, available);
144
+ const check = args.split(/\s+/).includes("--check");
145
+ if (!check && ctx.hasUI) {
146
+ let acceptRest = false;
147
+ for (const slot of SLOTS) {
148
+ if (acceptRest) break;
149
+ const row = rows.find((r) => r.slot === slot);
150
+ if (row === undefined) continue;
151
+ const next = await chooseSlot(ctx, slot, row, available, matrix);
152
+ matrix = next.matrix;
153
+ acceptRest = next.acceptRest;
154
+ rows = resolveRows(matrix, available);
155
+ }
156
+ }
157
+ const table = renderModelsTable(rows, available);
158
+ const unresolved = rows.flatMap((r) => (r.resolution === undefined ? [r.slot] : []));
159
+ if (check || unresolved.length > 0) {
160
+ const ok = unresolved.length === 0;
161
+ ctx.ui.notify(
162
+ ok ? table : `${table}\n\nUnresolvable slots: ${unresolved.join(", ")}. Nothing written.`,
163
+ ok ? "info" : "error",
164
+ );
165
+ return { ok, written: false, table };
166
+ }
167
+ const text = upsertModelsTable(await readConfigText(ctx.cwd), matrix);
168
+ await writeFile(join(ctx.cwd, CONFIG_FILE), text);
169
+ pi.appendEntry(CONFIG_ENTRY_TYPE, { models: matrix });
170
+ ctx.ui.notify(`${table}\n\nWrote [models] to ${CONFIG_FILE}.`, "info");
171
+ return { ok: true, written: true, table };
172
+ }
173
+
174
+ export function registerModelsCommand(pi: ExtensionAPI): void {
175
+ pi.registerCommand("devsys-models", {
176
+ description:
177
+ "Resolve the per-project model matrix against the models you can use and write it to .development-system.toml (--check: report only)",
178
+ handler: async (args, ctx) => {
179
+ await runModelsCommand(pi, ctx, args, ctx.modelRegistry);
180
+ },
181
+ });
182
+ }
183
+
184
+ const Parameters = Type.Object({
185
+ slot: Type.Optional(
186
+ Type.String({ description: `One of: ${SLOTS.join(", ")}. Omit for every slot.` }),
187
+ ),
188
+ });
189
+
190
+ const text = (t: string, isError = false) => ({
191
+ content: [{ type: "text" as const, text: t }],
192
+ details: undefined,
193
+ isError,
194
+ });
195
+
196
+ /** `devsys_models`: the resolved model for a slot (or all slots), from the project's matrix. */
197
+ export function createModelsTool(): ToolDefinition<typeof Parameters> {
198
+ return {
199
+ name: "devsys_models",
200
+ label: "Resolve models",
201
+ description:
202
+ "Return the model this project's matrix resolves each slot to (frontier, strong, fast, planning, advisor, implementer, reviewer, lens, researcher, jev) given the credentials on this machine.",
203
+ promptSnippet: "Look up which model a development-system slot resolves to",
204
+ parameters: Parameters,
205
+ exposure: "direct",
206
+ async execute(
207
+ _id,
208
+ params: Static<typeof Parameters>,
209
+ _signal,
210
+ _onUpdate,
211
+ ctx: ExtensionContext,
212
+ ) {
213
+ if (params.slot !== undefined && !isSlot(params.slot)) {
214
+ return text(`unknown slot "${params.slot}". Slots: ${SLOTS.join(", ")}`, true);
215
+ }
216
+ const config = await loadConfig(ctx.cwd);
217
+ if (!config.ok) return text(`${CONFIG_FILE}: ${config.error.message}`, true);
218
+ const available = availableModels(ctx.modelRegistry);
219
+ const rows = resolveRows(config.value.models, available).filter(
220
+ (r) => params.slot === undefined || r.slot === params.slot,
221
+ );
222
+ return text(
223
+ rows
224
+ .map((r) =>
225
+ r.resolution === undefined
226
+ ? `${r.slot}: unresolved`
227
+ : `${r.slot}: ${r.resolution.model} (via ${r.resolution.via})`,
228
+ )
229
+ .join("\n"),
230
+ rows.some((r) => r.resolution === undefined),
231
+ );
232
+ },
233
+ };
234
+ }