pi-pignon 0.1.1

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/src/types.ts ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Core type definitions for pignon.
3
+ *
4
+ * Types and a few constants only: no runtime dependencies. Defaults live in
5
+ * `config/defaults.ts`.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Worker protocol types (see worker/laya_worker.py)
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /** JSON-compatible values. */
13
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
14
+
15
+ /** A choice question for the Laya model. */
16
+ export interface LayaChoiceQuestion {
17
+ type: "choice";
18
+ instructions?: string | null;
19
+ criteria: Record<string, string>;
20
+ }
21
+
22
+ /** A score question for the Laya model. */
23
+ export interface LayaScoreQuestion {
24
+ type: "score";
25
+ instructions?: string | null;
26
+ criteria: readonly string[];
27
+ }
28
+
29
+ /** A noul (yes/no) question for the Laya model. */
30
+ export interface LayaNoulQuestion {
31
+ type: "noul";
32
+ instructions?: string | null;
33
+ criteria?: { true?: string | null; false?: string | null } | null;
34
+ }
35
+
36
+ /** Any question type accepted by Laya. */
37
+ export type LayaQuestion = LayaChoiceQuestion | LayaScoreQuestion | LayaNoulQuestion;
38
+
39
+ /** Parameters of the worker's `decide` method. */
40
+ export interface LayaDecisionRequest {
41
+ /** Observation text (alternative to state). */
42
+ text?: string;
43
+ /** Observation: string, JSON object, or conversation list. */
44
+ state?: string | JsonValue;
45
+ /** Laya question map with type, instructions, criteria. */
46
+ questions: Record<string, LayaQuestion>;
47
+ }
48
+
49
+ /** A single answer from the Laya decision engine. */
50
+ export interface LayaChoiceAnswer {
51
+ type: "choice";
52
+ choice: string;
53
+ confidence: number;
54
+ probabilities: Record<string, number>;
55
+ }
56
+
57
+ export interface LayaScoreAnswer {
58
+ type: "score";
59
+ score: number;
60
+ confidence: number;
61
+ legend: Record<string, string>;
62
+ probabilities: Record<string, number>;
63
+ }
64
+
65
+ export interface LayaNoulAnswer {
66
+ type: "noul";
67
+ noul: number;
68
+ }
69
+
70
+ export type LayaAnswer = LayaChoiceAnswer | LayaScoreAnswer | LayaNoulAnswer;
71
+
72
+ /** Result of the worker's `decide` method. */
73
+ export interface LayaDecisionResponse {
74
+ answers: Record<string, LayaAnswer>;
75
+ model: string;
76
+ }
77
+
78
+ /** Result of the worker's `health` method. */
79
+ export interface LayaHealthResponse {
80
+ status: string;
81
+ version: string;
82
+ backend: string;
83
+ loaded_model: string | null;
84
+ ready: boolean;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Router domain types
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Id of a difficulty tier, as named in the config (`trivial`, `standard`…).
93
+ * Tiers are ordered by the routing table, easiest first.
94
+ */
95
+ export type Tier = string;
96
+
97
+ /** What form a task takes: direct (fits in head) or exploration (needs iteration). */
98
+ export type Form = "direct" | "exploration";
99
+
100
+ /** Pi thinking level (mirrored from ExtensionAPI["setThinkingLevel"] param). */
101
+ export type ThinkingLevel = "off" | "low" | "medium" | "high" | "xhigh";
102
+
103
+ /** Specification for a target LLM model. */
104
+ export interface ModelSpec {
105
+ provider: string;
106
+ modelId: string;
107
+ thinking: ThinkingLevel;
108
+ }
109
+
110
+ /** One difficulty tier and the models that serve it. */
111
+ export interface TierSpec {
112
+ id: Tier;
113
+ /** How the decider recognizes a task of this tier. */
114
+ criterion: string;
115
+ models: Readonly<Record<Form, ModelSpec>>;
116
+ /**
117
+ * When false, a task that needs exploration never runs at this tier: it
118
+ * moves up to the next tier that allows exploration (the tool loop will be
119
+ * long, so the cheapest models are a poor fit).
120
+ */
121
+ explorationAllowed: boolean;
122
+ }
123
+
124
+ /** Tiers, easiest first. Position is rank: moving to a later tier is an upgrade. */
125
+ export type RoutingTable = readonly TierSpec[];
126
+
127
+ /** Wording of the two questions every decider is asked (see `deciders/questions.ts`). */
128
+ export interface QuestionWording {
129
+ /**
130
+ * Label stored with each decision. Change it whenever the wording or the
131
+ * tier criteria change: thresholds calibrated on one wording do not carry
132
+ * over to another.
133
+ */
134
+ version: string;
135
+ tierInstructions: string;
136
+ explorationInstructions: string;
137
+ explorationCriteria: Readonly<{ yes: string; no: string }>;
138
+ }
139
+
140
+ /**
141
+ * Which number to route on:
142
+ * - `reported`: the answer's `confidence` field (Laya/Jev calibrated confidence);
143
+ * - `top-probability`: the probability of the chosen option, for checkpoints
144
+ * whose reported confidence is uncalibrated.
145
+ */
146
+ export type ConfidenceSource = "reported" | "top-probability";
147
+
148
+ /** Routing profile: a cell in the tier x form matrix. */
149
+ export interface Profile {
150
+ tier: Tier;
151
+ form: Form;
152
+ }
153
+
154
+ /** Model prices per million tokens (the shape of Pi's `Model.cost`). */
155
+ export interface Price {
156
+ input: number;
157
+ output: number;
158
+ cacheRead: number;
159
+ cacheWrite: number;
160
+ }
161
+
162
+ /** Tunable routing thresholds. */
163
+ export interface Thresholds {
164
+ /** Minimum confidence to downgrade tier, or to move in from a model outside the table. */
165
+ minConfidenceDowngrade: number;
166
+ /** Minimum confidence to upgrade tier. */
167
+ minConfidenceUpgrade: number;
168
+ /** Minimum confidence to declare a task as "direct" rather than "exploration". */
169
+ minConfidenceForm: number;
170
+ /**
171
+ * Context size above which lateral switches are refused, and downgrades too
172
+ * when model prices are unknown (a switch re-reads the context uncached).
173
+ */
174
+ cacheGuardTokens: number;
175
+ /** Prompts to wait after a switch before the next downgrade or lateral switch. */
176
+ minPromptsBetweenSwitches: number;
177
+ /** A downgrade must recoup its cache-miss cost within this many LLM requests. */
178
+ maxPaybackRequests: number;
179
+ /** Output tokens per LLM request assumed when estimating what a downgrade saves. */
180
+ assumedOutputTokensPerRequest: number;
181
+ /** Timeout for one request to the local Laya worker. */
182
+ layaTimeoutMs: number;
183
+ }
184
+
185
+ /** Pignon's own Laya worker (experimental, Apple Silicon, not published). */
186
+ export interface LayaLocalDeciderSpec {
187
+ type: "laya-local";
188
+ /** Timeout for one decision; defaults to `thresholds.layaTimeoutMs`. */
189
+ timeoutMs?: number;
190
+ /** Command that starts the worker, instead of finding it (e.g. a development checkout). */
191
+ command?: string[];
192
+ }
193
+
194
+ /** TypeSafe's hosted Jev model. The API key is read from an environment variable, never the config. */
195
+ export interface JevDeciderSpec {
196
+ type: "jev";
197
+ model?: string;
198
+ baseURL?: string;
199
+ apiKeyEnv?: string;
200
+ timeoutMs?: number;
201
+ maxRetries?: number;
202
+ }
203
+
204
+ /** A Laya model served by the official `laya-serve` (Jev-compatible HTTP API). */
205
+ export interface LayaServeDeciderSpec {
206
+ type: "laya-serve";
207
+ /** Server root; defaults to http://127.0.0.1:8000, laya-serve's default port. */
208
+ url?: string;
209
+ /** Laya checkpoint (`english`, `multilingual`, `typed-decisions`); the server picks one when omitted. */
210
+ model?: string;
211
+ /** Environment variable holding the server's key (its LAYA_API_KEY), when it requires one. */
212
+ apiKeyEnv?: string;
213
+ timeoutMs?: number;
214
+ }
215
+
216
+ export type DeciderSpec = LayaServeDeciderSpec | LayaLocalDeciderSpec | JevDeciderSpec;
217
+
218
+ /** How several deciders are combined (see `deciders/strategy.ts`). */
219
+ export interface StrategyConfig {
220
+ /** `sequential`: in order, until one is confident enough. `parallel`: all at once. */
221
+ mode: "sequential" | "parallel";
222
+ /** Sequential: move to the next decider when tier confidence is below this. */
223
+ escalateBelow: number;
224
+ /** Parallel: route on the most confident answer, or on the first decider in the list that answered. */
225
+ pick: "most-confident" | "first";
226
+ /** Wall-time limit for one decision, all deciders included. */
227
+ budgetMs: number;
228
+ }
229
+
230
+ /** What happened to one decider during one decision. */
231
+ export interface DeciderAttempt {
232
+ decider: string;
233
+ remote: boolean;
234
+ /** `not-ready`: still loading or missing a key. `not-asked`: an earlier decider was confident enough. */
235
+ outcome: "answered" | "failed" | "not-ready" | "not-asked";
236
+ /** Whether this answer is the one routed on. */
237
+ used: boolean;
238
+ model?: string;
239
+ tier?: Tier | null;
240
+ tierConfidence?: number;
241
+ needsExploration?: boolean;
242
+ explorationConfidence?: number;
243
+ latencyMs?: number;
244
+ costUsd?: number;
245
+ error?: string;
246
+ }
247
+
248
+ /** Full, resolved router configuration. */
249
+ export interface RouterConfig {
250
+ /** Deciders in the order to try them, or null to pick one automatically. */
251
+ deciders: readonly DeciderSpec[] | null;
252
+ strategy: StrategyConfig;
253
+ table: RoutingTable;
254
+ thresholds: Thresholds;
255
+ questions: QuestionWording;
256
+ confidenceSource: ConfidenceSource;
257
+ }
258
+
259
+ /** Routing-relevant reading of a decider's answers (see `deciders/parse.ts`). */
260
+ export interface RoutingDecision {
261
+ tier: Tier | null;
262
+ tierConfidence: number;
263
+ needsExploration: boolean;
264
+ explorationConfidence: number;
265
+ latencyMs: number;
266
+ }
267
+
268
+ /** Inputs to the routing policy. */
269
+ export interface PolicyInput {
270
+ decision: RoutingDecision | null;
271
+ /** Profile of the current model, or null when it is not in the routing table. */
272
+ current: Profile | null;
273
+ contextTokens: number;
274
+ /** Prompts since the router last switched models; undefined if it never has. */
275
+ promptsSinceSwitch?: number;
276
+ /** Price of the current model, when known. */
277
+ currentPrice?: Price;
278
+ /** Price lookup for a routing-table model, when known. */
279
+ priceOf?: (spec: ModelSpec) => Price | undefined;
280
+ /** Defaults to DEFAULT_CONFIG. */
281
+ config?: RouterConfig;
282
+ }
283
+
284
+ /** Output of the routing policy. */
285
+ export interface PolicyOutput {
286
+ /** Target profile, or null if the current one should be kept. */
287
+ target: Profile | null;
288
+ reason: string;
289
+ }
290
+
291
+ /** Runtime mode for the extension. */
292
+ export type RouterMode = "shadow" | "live" | "off";
293
+
294
+ /** A persisted log entry written to the session store. */
295
+ export interface RouterLogEntry {
296
+ ts: number;
297
+ mode: RouterMode;
298
+ /** `Decider.id` of the decider asked. Absent in entries written before pignon. */
299
+ decider?: string;
300
+ /** Whether the prompt was sent off the machine to decide. */
301
+ remote?: boolean;
302
+ /** Price of the decision in USD, for remote deciders that report it. */
303
+ costUsd?: number;
304
+ /** Every decider tried, when several are configured. */
305
+ attempts?: DeciderAttempt[];
306
+ /** Model of the decider that answered. */
307
+ deciderModel?: string;
308
+ /** Same as `deciderModel`, in entries written before pignon. */
309
+ layaModel?: string;
310
+ /** `QuestionWording.version` the decision was made with. Absent in older entries. */
311
+ questionsVersion?: string;
312
+ /** SHA-256 prefix of the prompt: lets you correlate entries without storing the text. */
313
+ promptHash: string;
314
+ promptLength: number;
315
+ tier: Tier | null;
316
+ tierConfidence: number | null;
317
+ needsExploration: boolean | null;
318
+ explorationConfidence: number | null;
319
+ form: Form | null;
320
+ latencyMs: number | null;
321
+ currentTier: Tier | null;
322
+ currentForm: Form | null;
323
+ /** "provider/modelId" of the model active when the prompt arrived. Absent in older entries. */
324
+ currentModel?: string;
325
+ contextTokens: number;
326
+ targetTier: Tier | null;
327
+ targetForm: Form | null;
328
+ /** "provider/modelId" of the routing-table model for the target profile. Absent in older entries. */
329
+ targetModel?: string;
330
+ targetThinking?: ThinkingLevel;
331
+ reason: string;
332
+ applied: boolean;
333
+ error?: string;
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Configuration constants
338
+ // ---------------------------------------------------------------------------
339
+
340
+ export const FORMS: readonly Form[] = ["direct", "exploration"];
341
+ export const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "low", "medium", "high", "xhigh"];
package/src/ui.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * TUI pieces for the Laya LLM Router: the "deciding" spinner widget and the
3
+ * decision card rendered in the transcript for each decision entry.
4
+ *
5
+ * Card text is built by pure functions over a minimal theme so it can be
6
+ * tested without a terminal.
7
+ */
8
+
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { Box, Loader, Text } from "@earendil-works/pi-tui";
11
+
12
+ import type { RouterLogEntry } from "./types.js";
13
+
14
+ /** The subset of Pi's theme the card uses. */
15
+ export interface CardTheme {
16
+ fg(color: "accent" | "success" | "warning" | "error" | "muted" | "dim" | "text", text: string): string;
17
+ bold(text: string): string;
18
+ }
19
+
20
+ export const DECIDING_WIDGET = "pignon-deciding";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Spinner
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Show an animated "deciding" line above the editor until `hideDeciding`. */
27
+ export function showDeciding(ctx: ExtensionContext, deciderModel: string): void {
28
+ if (!ctx.hasUI) return;
29
+ ctx.ui.setWidget(DECIDING_WIDGET, (tui, theme) => {
30
+ const loader = new Loader(
31
+ tui,
32
+ (s) => theme.fg("accent", s),
33
+ (s) => theme.fg("muted", s),
34
+ `pignon is choosing a model… ${theme.fg("dim", `(${deciderModel})`)}`,
35
+ );
36
+ return Object.assign(loader, { dispose: () => loader.stop() });
37
+ });
38
+ }
39
+
40
+ export function hideDeciding(ctx: ExtensionContext): void {
41
+ if (ctx.hasUI) ctx.ui.setWidget(DECIDING_WIDGET, undefined);
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Decision card
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const BAR_WIDTH = 10;
49
+
50
+ /** Short names on the card; ☁ is added for remote deciders. */
51
+ const DECIDER_LABELS: Record<string, string> = { "laya-serve": "laya-serve", "laya-local": "laya", jev: "jev" };
52
+
53
+ export function confidenceBar(confidence: number, theme: CardTheme): string {
54
+ const clamped = Math.min(1, Math.max(0, confidence));
55
+ const filled = Math.round(clamped * BAR_WIDTH);
56
+ const color = clamped >= 0.85 ? "success" : clamped >= 0.6 ? "warning" : "error";
57
+ return (
58
+ theme.fg(color, "█".repeat(filled)) +
59
+ theme.fg("dim", "░".repeat(BAR_WIDTH - filled)) +
60
+ ` ${clamped.toFixed(2)}`
61
+ );
62
+ }
63
+
64
+ function formatTokens(n: number): string {
65
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
66
+ }
67
+
68
+ function profileLabel(tier: string | null, form: string | null): string {
69
+ return tier ? `${tier}/${form ?? "?"}` : "unknown";
70
+ }
71
+
72
+ /** What happened to the verdict, as one styled phrase. */
73
+ function outcome(entry: RouterLogEntry, theme: CardTheme): string {
74
+ if (entry.error !== undefined) return theme.fg("error", `✗ ${entry.error}`);
75
+ const target = profileLabel(entry.targetTier, entry.targetForm);
76
+ const model = entry.targetModel ?? target;
77
+ const thinking = entry.targetThinking ? theme.fg("dim", ` · thinking ${entry.targetThinking}`) : "";
78
+ if (entry.applied) return theme.fg("success", `⚡ switched to ${theme.bold(model)}`) + thinking;
79
+ if (entry.targetTier) {
80
+ return entry.mode === "shadow"
81
+ ? theme.fg("warning", `👁 would switch to ${model}`) + thinking
82
+ : theme.fg("warning", `! could not switch to ${model}`);
83
+ }
84
+ return theme.fg("muted", "· kept current model");
85
+ }
86
+
87
+ /** One line per decision with several deciders: who answered what, and which answer was used (✓). */
88
+ function attemptsLine(entry: RouterLogEntry, theme: CardTheme): string | undefined {
89
+ const asked = (entry.attempts ?? []).filter((a) => a.outcome !== "not-asked");
90
+ if (asked.length < 2) return undefined;
91
+ const parts = asked.map((a) => {
92
+ const name = `${DECIDER_LABELS[a.decider] ?? a.decider}${a.remote ? " ☁" : ""}`;
93
+ switch (a.outcome) {
94
+ case "answered": {
95
+ const text = `${name} ${a.tier ?? "?"} ${(a.tierConfidence ?? 0).toFixed(2)}`;
96
+ return a.used ? theme.fg("text", `${text} ✓`) : theme.fg("dim", text);
97
+ }
98
+ case "failed":
99
+ return theme.fg("error", `${name} ✗ ${(a.error ?? "failed").slice(0, 40)}`);
100
+ default:
101
+ return theme.fg("muted", `${name} ⏳ not ready`);
102
+ }
103
+ });
104
+ return ` ${parts.join(theme.fg("dim", " · "))}`;
105
+ }
106
+
107
+ /** Lines of the decision card; the first line is the collapsed view. */
108
+ export function decisionCardLines(entry: RouterLogEntry, expanded: boolean, theme: CardTheme): string[] {
109
+ const decider = entry.decider ? ` ${DECIDER_LABELS[entry.decider] ?? entry.decider}${entry.remote ? " ☁" : ""}` : "";
110
+ const head = theme.fg("accent", theme.bold("pignon")) + theme.fg("muted", decider);
111
+ const profile = entry.tier ? theme.bold(profileLabel(entry.tier, entry.form)) : theme.fg("muted", "no decision");
112
+ const facts = [
113
+ entry.tierConfidence !== null ? `p=${entry.tierConfidence.toFixed(2)}` : null,
114
+ entry.latencyMs !== null ? `${Math.round(entry.latencyMs)} ms` : null,
115
+ ].filter((f): f is string => f !== null);
116
+ const summary = facts.length ? theme.fg("dim", ` ${facts.join(" · ")}`) : "";
117
+
118
+ const lines = [`${head} ${profile}${summary} ${outcome(entry, theme)}`];
119
+ if (entry.error === undefined) lines.push(theme.fg("dim", ` ${entry.reason}`));
120
+ const attempts = attemptsLine(entry, theme);
121
+ if (attempts) lines.push(attempts);
122
+ if (!expanded) return lines;
123
+
124
+ const label = (s: string) => theme.fg("muted", ` ${s.padEnd(12)}`);
125
+ if (entry.tierConfidence !== null) {
126
+ lines.push(`${label("tier")}${confidenceBar(entry.tierConfidence, theme)} ${entry.tier ?? "?"}`);
127
+ }
128
+ if (entry.explorationConfidence !== null) {
129
+ const explores = entry.needsExploration ? "needs exploration" : "direct";
130
+ lines.push(`${label("exploration")}${confidenceBar(entry.explorationConfidence, theme)} ${explores}`);
131
+ }
132
+ const current = entry.currentModel
133
+ ? `${entry.currentModel} (${profileLabel(entry.currentTier, entry.currentForm)})`
134
+ : profileLabel(entry.currentTier, entry.currentForm);
135
+ lines.push(`${label("current")}${current} · context ${formatTokens(entry.contextTokens)} tokens`);
136
+ lines.push(
137
+ `${label("run")}` +
138
+ theme.fg("dim", `mode ${entry.mode} · ${entry.deciderModel ?? entry.layaModel ?? "?"}${entry.questionsVersion ? ` · questions ${entry.questionsVersion}` : ""}${entry.costUsd !== undefined ? ` · $${entry.costUsd.toFixed(6)}` : ""} · prompt #${entry.promptHash} (${entry.promptLength} chars)`),
139
+ );
140
+ return lines;
141
+ }
142
+
143
+ /** Entry renderer for decision entries (`pignon-decision`, and `laya-decision` from older sessions). */
144
+ export function renderDecisionCard(
145
+ entry: { data?: RouterLogEntry },
146
+ options: { expanded: boolean },
147
+ theme: CardTheme & { bg(color: "customMessageBg", text: string): string },
148
+ ) {
149
+ if (!entry.data) return undefined;
150
+ const box = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
151
+ box.addChild(new Text(decisionCardLines(entry.data, options.expanded, theme).join("\n"), 0, 0));
152
+ return box;
153
+ }