projectinator 0.1.5 → 0.3.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 CHANGED
@@ -6,8 +6,8 @@
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/projectinator.svg?color=e0a72d&label=npm)](https://www.npmjs.com/package/projectinator)
8
8
  ![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)
9
- ![node](https://img.shields.io/badge/node-%E2%89%A520-brightgreen.svg)
10
- ![tests: 140 passing](https://img.shields.io/badge/tests-140%20passing-brightgreen.svg)
9
+ ![node](https://img.shields.io/badge/node-%E2%89%A522.19-brightgreen.svg)
10
+ ![tests: 181 passing](https://img.shields.io/badge/tests-181%20passing-brightgreen.svg)
11
11
  ![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6.svg)
12
12
  ![built on Pi](https://img.shields.io/badge/built%20on-Pi%20agent%20harness-e0a72d.svg)
13
13
 
@@ -22,7 +22,7 @@ watch it happen from a terminal cockpit: a live board, budget bar, and a standup
22
22
 
23
23
  Built on the [Pi](https://pi.dev) agent harness (Node/TypeScript). Bring your own API key.
24
24
 
25
- **Install & run** (Node ≥ 20):
25
+ **Install & run** (Node ≥ 22.19):
26
26
 
27
27
  ```bash
28
28
  npx projectinator # run without installing
@@ -49,7 +49,8 @@ npm start
49
49
  </details>
50
50
 
51
51
  > **Optional:** `npx playwright install chromium` lets the tester actually run web apps in a
52
- > headless browser and enables live preview. Everything else works without it.
52
+ > headless browser and enables live preview. Without it the tester can only read the code —
53
+ > such passes show as **PASS\*** with a warning. Everything else works without it.
53
54
 
54
55
  > Projectinator spends **your** API money. Every screen shows the running cost; you set a
55
56
  > budget cap and it halts before crossing it. A tiny landing page is cents; a full app is
@@ -64,13 +65,15 @@ Type an idea → it plans → you approve → it builds, tests, and hands you wo
64
65
  - **Best model per role.** Roles bind to a *capability + tier*, never a model name. A
65
66
  swappable registry maps capabilities to models — new frontier model next month, edit one
66
67
  place, every route updates. Run a **bake-off** to pick empirically.
67
- - **A real pipeline.** PM decomposes → Designer specs → Developer writes files → Tester
68
- **runs the app headless and catches real bugs** → feedback loop re-runs the dev on failure.
68
+ - **A real pipeline.** PM decomposes → Designer specs → Developer writes files → **Reviewer**
69
+ (cheap, read-only) checks the wiring → Tester **runs the app headless and catches real
70
+ bugs** → feedback loop re-runs the dev on failure.
69
71
  - **Multi-file apps.** Vanilla HTML/CSS/JS or **React (CDN, no build)** — your choice.
70
72
  - **The cockpit.** A polished terminal UI: editable board, Kanban, standup, per-task cost,
71
73
  live budget bar, desktop notification when done.
72
74
  - **Honest cost.** Live spend tracking, per-project budget cap + an alert before the cap,
73
- and predicted-vs-actual reporting that sharpens itself over real runs.
75
+ **per-task timeout and cost ceiling**, and predicted-vs-actual reporting that sharpens
76
+ itself over real runs.
74
77
 
75
78
  ## Highlights
76
79
 
@@ -79,13 +82,16 @@ Type an idea → it plans → you approve → it builds, tests, and hands you wo
79
82
  | 🧠 **PM intake** | Vague request? The PM asks 2–4 clarifying questions (with pickable options) before planning. Specific requests skip straight through. |
80
83
  | 🏛 **Deep plan (council)** | Opt-in: architect + product + risk leads propose epics in parallel, a synthesizer merges them, you approve, then they expand into the backlog. |
81
84
  | 🆚 **Model bake-off** | Run one task across models, an LLM judge scores the outputs, compare cost/latency/quality — save the winner to the registry. |
82
- | 🧪 **Real test execution** | The tester loads the built app in headless Chromium and fails on JS/console errors not just by reading the code. |
85
+ | 🔍 **Reviewer** | A cheap read-only model checks each code task's wiring (missing files, unresolved `<script src>`, undefined functions, ES modules on a static site) before the Tester spends a browser run. Fails feed the same Developer fix loop. |
86
+ | 🧪 **Real test execution** | The tester loads the built app in headless Chromium and fails on JS/console errors — not just by reading the code. Without Chromium, passes are marked **PASS\*** so you know the app was never run. |
83
87
  | 👁 **Live preview** | Local server + auto-reload; ES modules and fetch resolve like production. |
84
88
  | 🚀 **Deploy** | One click to Cloudflare Pages, Vercel, or Netlify (their CLI + your login). |
85
89
  | 📤 **Export** | Backlog → Markdown, CSV, **Jira** CSV, **Trello** CSV. |
86
90
  | 📜 **Git per build** | The workspace is a git repo; one commit per task. History view + **undo a task**. |
87
91
  | 📊 **Analytics** | Retro (with optional AI narrative), burndown, cost by epic/model, and estimate accuracy that self-calibrates from real runs. |
88
92
  | 💾 **Templates** | Save a project's brief as a reusable template; import/share as a file. |
93
+ | ⛔ **Task limits** | Per-task timeout and cost ceiling (Settings → Preferences). A runaway task is aborted, billed for what it spent, and the build halts — resumable. |
94
+ | ✎ **Task notes** | Annotate any task on the board (`n`). Yours only — never sent to a model; included in exports. |
89
95
 
90
96
  ## How it works
91
97
 
@@ -95,8 +101,7 @@ idea
95
101
  └─ intake? PM asks clarifying questions if the request is vague
96
102
  └─ plan mode? Quick (one PM) or Deep (planning council → approve epics)
97
103
  └─ decompose → a routed, epic-tagged backlog with a cost estimate
98
- └─ approve auto-run, or gate the backlog / gate again before dev
99
- └─ build toposort deps · design → code → test · Tester→Dev feedback loop
104
+ └─ build toposort deps · design code review test · Reviewer/Tester→Dev feedback loop
100
105
  └─ done working files + retro + deploy/export/preview
101
106
  ```
102
107
 
@@ -120,8 +125,9 @@ npm start # the cockpit (the normal way
120
125
  npm run build -- --live --mini # cheap end-to-end proof (~$0.10)
121
126
  npm run build -- --live --lock anthropic "idea" # full pipeline on one provider
122
127
  npm run build -- --live --mini --resume # resume a halted/finished build (skips done tasks)
128
+ npm run build -- --live --mini --task-cap 0.5 --task-timeout 5 # per-task limits (USD / minutes)
123
129
  npm run bakeoff -- --capability design "Design a pricing page" # model bake-off
124
- npm test # 140 tests
130
+ npm test # 181 tests
125
131
  npm run typecheck
126
132
  ```
127
133
 
@@ -129,7 +135,8 @@ npm run typecheck
129
135
 
130
136
  - **Keys** are stored at `~/.projectinator/config.json` (chmod 0600) — never in the repo.
131
137
  - **Settings** (in the app): API keys, preferred provider, default workflow, default stack,
132
- model assignments, budget cap + alert %, estimate accuracy.
138
+ model assignments (incl. the Reviewer), budget cap + alert %, per-task timeout + cost cap,
139
+ estimate accuracy.
133
140
  - **User data** lives under `~/.projectinator/` (config, calibration, templates, exports).
134
141
  Each build gets its own git-versioned workspace folder (one commit per finished task).
135
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectinator",
3
- "version": "0.1.5",
3
+ "version": "0.3.0",
4
4
  "description": "Your AI build team in the terminal — hand it an app idea, a PM model plans a Scrum backlog, and the best model per role designs, codes, and tests it into working files. Bring your own API key.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -36,7 +36,7 @@
36
36
  "src"
37
37
  ],
38
38
  "engines": {
39
- "node": ">=20"
39
+ "node": ">=22.19.0"
40
40
  },
41
41
  "scripts": {
42
42
  "start": "tsx src/tui.tsx",
@@ -61,13 +61,13 @@
61
61
  "vitest": "^2.1.8"
62
62
  },
63
63
  "dependencies": {
64
- "@earendil-works/pi-coding-agent": "0.80.7",
64
+ "@earendil-works/pi-coding-agent": "0.85.1",
65
65
  "@inkjs/ui": "2.0.0",
66
66
  "ink": "7.1.0",
67
67
  "ink-spinner": "5.0.0",
68
68
  "playwright": "1.61.1",
69
69
  "react": "19.2.7",
70
70
  "tsx": "4.23.1",
71
- "typebox": "1.1.38"
71
+ "typebox": "1.3.7"
72
72
  }
73
73
  }
package/src/bakeoff.ts CHANGED
@@ -7,15 +7,13 @@
7
7
  // scoring) is a later step.
8
8
 
9
9
  import {
10
- AuthStorage,
11
- ModelRegistry,
12
10
  createAgentSession,
13
11
  defineTool,
14
12
  type AgentSession,
15
13
  } from "@earendil-works/pi-coding-agent";
16
14
  import { Type, type Static } from "typebox";
17
15
  import type { Capability, Difficulty, Provider, Task } from "./types.js";
18
- import { resolvePiModel } from "./executor.js";
16
+ import { piRuntime, resolvePiModel } from "./executor.js"
19
17
  import { buildRolePrompt } from "./roles.js";
20
18
  import { estimateTokens } from "./estimate.js";
21
19
  import { addSessionCost } from "./session-cost.js";
@@ -72,13 +70,11 @@ const id = (c: Candidate) => `${c.provider}/${c.model}`;
72
70
  async function runCandidate(task: Task, cand: Candidate): Promise<BakeoffEntry> {
73
71
  const base: BakeoffEntry = { provider: cand.provider, model: cand.model, output: "", cost: 0, ms: 0, outputTokens: 0 };
74
72
  try {
75
- const authStorage = AuthStorage.create();
76
- const registry = ModelRegistry.create(authStorage);
77
- const model = resolvePiModel(registry, cand.provider, cand.model);
73
+ const runtime = await piRuntime();
74
+ const model = resolvePiModel(runtime, cand.provider, cand.model);
78
75
  const { session } = await createAgentSession({
79
76
  model,
80
- authStorage,
81
- modelRegistry: registry,
77
+ modelRuntime: runtime,
82
78
  thinkingLevel: "medium",
83
79
  noTools: "all",
84
80
  });
@@ -144,14 +140,12 @@ async function judge(task: Task, entries: BakeoffEntry[], judgeCand: Candidate):
144
140
 
145
141
  const letters = scored.map((_, i) => String.fromCharCode(65 + i)); // A, B, C…
146
142
  const blocks = scored.map((e, i) => `### Option ${letters[i]}\n${e.output}`).join("\n\n");
147
- const authStorage = AuthStorage.create();
148
- const registry = ModelRegistry.create(authStorage);
149
- const model = resolvePiModel(registry, judgeCand.provider, judgeCand.model);
143
+ const runtime = await piRuntime();
144
+ const model = resolvePiModel(runtime, judgeCand.provider, judgeCand.model);
150
145
  const { tool, get } = buildJudgeTool();
151
146
  const { session } = await createAgentSession({
152
147
  model,
153
- authStorage,
154
- modelRegistry: registry,
148
+ modelRuntime: runtime,
155
149
  thinkingLevel: "medium",
156
150
  noTools: "all",
157
151
  customTools: [tool],
@@ -40,7 +40,8 @@ export function loadState(path: string): BuildState | undefined {
40
40
  }
41
41
  }
42
42
 
43
- /** Which task ids are already finished (last outcome wins). Used to skip on resume. */
43
+ /** Which task ids are already finished (last outcome wins; a failed attempt does not
44
+ * count). Used to skip on resume. */
44
45
  export function completedIds(state: BuildState): Set<string> {
45
- return new Set(state.outcomes.map((o) => o.taskId));
46
+ return new Set(state.outcomes.filter((o) => !o.error).map((o) => o.taskId));
46
47
  }
package/src/cost.ts CHANGED
@@ -22,7 +22,9 @@ function ratesFor(model: Model, inputTokens: number) {
22
22
  }
23
23
 
24
24
  /** Estimate USD cost for one task on a given model.
25
- * A fraction of input can be served from cache at the cheaper cacheRead rate. */
25
+ * A fraction of input can be served from cache at the cheaper cacheRead rate.
26
+ * Kept to 4 decimals, not cents: a backlog of sub-cent tasks would otherwise
27
+ * estimate to $0.00 and under-reserve budget in the parallel scheduler. */
26
28
  export function estimateCost(est: TokenEstimate, model: Model): number {
27
29
  const cachedFraction = clamp01(est.cachedInputFraction ?? 0);
28
30
  const rate = ratesFor(model, est.input);
@@ -33,13 +35,9 @@ export function estimateCost(est: TokenEstimate, model: Model): number {
33
35
  const inputCost = (freshInput / PER_MILLION) * rate.input + (cachedInput / PER_MILLION) * rate.cacheRead;
34
36
  const outputCost = (est.output / PER_MILLION) * rate.output;
35
37
 
36
- return round2(inputCost + outputCost);
38
+ return Math.round((inputCost + outputCost) * 10_000) / 10_000;
37
39
  }
38
40
 
39
41
  function clamp01(n: number): number {
40
42
  return Math.max(0, Math.min(1, n));
41
43
  }
42
-
43
- function round2(n: number): number {
44
- return Math.round(n * 100) / 100;
45
- }
package/src/council.ts CHANGED
@@ -4,8 +4,6 @@
4
4
  // via the normal decomposer seeded with these epics.
5
5
 
6
6
  import {
7
- AuthStorage,
8
- ModelRegistry,
9
7
  createAgentSession,
10
8
  defineTool,
11
9
  type AgentSession,
@@ -13,7 +11,7 @@ import {
13
11
  import { Type, type Static } from "typebox";
14
12
  import type { Backend, Provider } from "./types.js";
15
13
  import { findEntry } from "./registry.js";
16
- import { resolvePiModel } from "./executor.js";
14
+ import { piRuntime, resolvePiModel } from "./executor.js"
17
15
  import { addSessionCost } from "./session-cost.js";
18
16
 
19
17
  export interface Epic {
@@ -67,17 +65,15 @@ interface Ctx {
67
65
  }
68
66
 
69
67
  async function runEpicAgent(idea: string, system: string, toolName: string, ctx: Ctx): Promise<Epic[]> {
70
- const authStorage = AuthStorage.create();
71
- const registry = ModelRegistry.create(authStorage);
68
+ const runtime = await piRuntime();
72
69
  const { entry } = findEntry("plan", "mid");
73
70
  const pick = ctx.modelOverride ?? entry.byBackend[ctx.backend];
74
71
  try {
75
- const model = resolvePiModel(registry, pick.provider, pick.model);
72
+ const model = resolvePiModel(runtime, pick.provider, pick.model);
76
73
  const { tool, get } = buildEpicsTool(toolName);
77
74
  const { session } = await createAgentSession({
78
75
  model,
79
- authStorage,
80
- modelRegistry: registry,
76
+ modelRuntime: runtime,
81
77
  thinkingLevel: "low",
82
78
  noTools: "all",
83
79
  customTools: [tool],
@@ -145,15 +141,14 @@ export async function councilEpics(idea: string, ctx: Ctx): Promise<CouncilResul
145
141
  if (!proposals.length) return { epics: [], proposals: [] };
146
142
 
147
143
  // Synthesize.
148
- const authStorage = AuthStorage.create();
149
- const registry = ModelRegistry.create(authStorage);
144
+ const runtime = await piRuntime();
150
145
  const { entry } = findEntry("plan", "mid");
151
146
  const pick = ctx.modelOverride ?? entry.byBackend[ctx.backend];
152
147
  try {
153
- const model = resolvePiModel(registry, pick.provider, pick.model);
148
+ const model = resolvePiModel(runtime, pick.provider, pick.model);
154
149
  const { tool, get } = buildEpicsTool("submit_epics");
155
150
  const { session } = await createAgentSession({
156
- model, authStorage, modelRegistry: registry, thinkingLevel: "low",
151
+ model, modelRuntime: runtime, thinkingLevel: "low",
157
152
  noTools: "all", customTools: [tool], tools: ["submit_epics"],
158
153
  });
159
154
  try {
package/src/estimate.ts CHANGED
@@ -37,6 +37,13 @@ const BUCKETS: Record<Capability, Record<Difficulty, Bucket>> = {
37
37
  medium: { input: 25_000, output: 7_000 },
38
38
  high: { input: 60_000, output: 14_000 },
39
39
  },
40
+ // Review reads the same tree the tester does but never runs it — shorter output.
41
+ review: {
42
+ trivial: { input: 15_000, output: 800 },
43
+ low: { input: 25_000, output: 1_500 },
44
+ medium: { input: 50_000, output: 3_000 },
45
+ high: { input: 80_000, output: 5_000 },
46
+ },
40
47
  test: {
41
48
  trivial: { input: 15_000, output: 1_200 },
42
49
  low: { input: 25_000, output: 2_500 },
package/src/executor.ts CHANGED
@@ -6,8 +6,7 @@
6
6
  // hits the provider API and spends money — that path is guarded by the caller.
7
7
 
8
8
  import {
9
- AuthStorage,
10
- ModelRegistry,
9
+ ModelRuntime,
11
10
  createAgentSession,
12
11
  type AgentSession,
13
12
  type AgentSessionEvent,
@@ -20,17 +19,23 @@ import { getModel } from "./models.js";
20
19
  import { addSessionCost } from "./session-cost.js";
21
20
 
22
21
  /** Pi's own Model type, derived so we don't depend on a deep sub-path import. */
23
- type PiModel = NonNullable<ReturnType<ModelRegistry["find"]>>;
22
+ export type PiModel = NonNullable<ReturnType<ModelRuntime["getModel"]>>;
23
+
24
+ /** Pi's model/auth runtime: built-in catalog + env keys / ~/.pi/agent/auth.json.
25
+ * Created per call on purpose — a key added in Settings must apply to the next session. */
26
+ export function piRuntime(): Promise<ModelRuntime> {
27
+ return ModelRuntime.create();
28
+ }
24
29
 
25
30
  /** Resolve a Projectinator (provider, modelId) to Pi's executable Model.
26
31
  * Offline + free — reads Pi's built-in registry. Throws with a clear message
27
32
  * if the id isn't one Pi knows (our ids are kept identical to Pi's on purpose). */
28
33
  export function resolvePiModel(
29
- registry: ModelRegistry,
34
+ runtime: ModelRuntime,
30
35
  provider: Provider,
31
36
  modelId: string,
32
37
  ): PiModel {
33
- const m = registry.find(provider, modelId);
38
+ const m = runtime.getModel(provider, modelId);
34
39
  if (!m) {
35
40
  throw new Error(
36
41
  `Pi has no model "${provider}/${modelId}". ` +
@@ -47,8 +52,6 @@ export interface ExecuteOptions {
47
52
  thinkingLevel?: "off" | "low" | "medium" | "high";
48
53
  /** Optional progress hook — receives raw Pi session events. */
49
54
  onEvent?: (event: AgentSessionEvent) => void;
50
- /** Override auth (tests). Default resolves env keys / ~/.pi/agent/auth.json. */
51
- authStorage?: AuthStorage;
52
55
  /** Tools the agent may use. Default: the coding set. */
53
56
  tools?: string[];
54
57
  }
@@ -95,15 +98,13 @@ export async function executeTask(
95
98
  decision: RouteDecision,
96
99
  opts: ExecuteOptions,
97
100
  ): Promise<ExecuteResult> {
98
- const authStorage = opts.authStorage ?? AuthStorage.create();
99
- const registry = ModelRegistry.create(authStorage);
100
- const model = resolvePiModel(registry, decision.provider, decision.model.id);
101
+ const runtime = await piRuntime();
102
+ const model = resolvePiModel(runtime, decision.provider, decision.model.id);
101
103
 
102
104
  const { session } = await createAgentSession({
103
105
  model,
104
106
  cwd: opts.workspace,
105
- authStorage,
106
- modelRegistry: registry,
107
+ modelRuntime: runtime,
107
108
  thinkingLevel: opts.thinkingLevel ?? "medium",
108
109
  tools: opts.tools ?? ["read", "write", "edit", "bash", "ls", "grep", "find"],
109
110
  });
package/src/intake.ts CHANGED
@@ -6,15 +6,13 @@
6
6
  // schema, coerced/validated in code.
7
7
 
8
8
  import {
9
- AuthStorage,
10
- ModelRegistry,
11
9
  createAgentSession,
12
10
  defineTool,
13
11
  } from "@earendil-works/pi-coding-agent";
14
12
  import { Type, type Static } from "typebox";
15
13
  import type { Backend, Provider } from "./types.js";
16
14
  import { findEntry } from "./registry.js";
17
- import { resolvePiModel } from "./executor.js";
15
+ import { piRuntime, resolvePiModel } from "./executor.js"
18
16
  import { addSessionCost } from "./session-cost.js";
19
17
 
20
18
  const IntakeSchema = Type.Object(
@@ -78,18 +76,16 @@ export interface AssessOptions {
78
76
  /** Ask the PM whether the request needs clarification; returns up to 4 questions
79
77
  * (empty = clear enough to plan directly). Never throws — returns [] on trouble. */
80
78
  export async function assessIntake(idea: string, opts: AssessOptions): Promise<IntakeQuestion[]> {
81
- const authStorage = AuthStorage.create();
82
- const registry = ModelRegistry.create(authStorage);
79
+ const runtime = await piRuntime();
83
80
  const { entry } = findEntry("plan", "mid");
84
81
  const pick = opts.modelOverride ?? entry.byBackend[opts.backend];
85
82
 
86
83
  try {
87
- const model = resolvePiModel(registry, pick.provider, pick.model);
84
+ const model = resolvePiModel(runtime, pick.provider, pick.model);
88
85
  const { tool, get } = buildIntakeTool();
89
86
  const { session } = await createAgentSession({
90
87
  model,
91
- authStorage,
92
- modelRegistry: registry,
88
+ modelRuntime: runtime,
93
89
  thinkingLevel: "low",
94
90
  noTools: "all",
95
91
  customTools: [tool],
package/src/models.ts CHANGED
@@ -1,36 +1,42 @@
1
- // Model pricing table. Rates USD per 1,000,000 tokens (verified July 2026).
2
- // Shape mirrors Pi's models.json `cost` block so this ports to Pi later.
3
- // cacheRead ~= 0.1x input, cacheWrite ~= 1.25x input (Anthropic-style; approximations
4
- // for models whose exact cache rates we haven't pinned — refine against provider docs).
1
+ // Model pricing table. Rates USD per 1,000,000 tokens copied verbatim from Pi's
2
+ // bundled catalog (pi-coding-agent 0.85.1, Sept 2026); test/executor.test.ts pins every
3
+ // entry against it. Shape mirrors Pi's models.json `cost` block.
5
4
 
6
5
  import type { Model } from "./types.js";
7
6
  import { findOpenRouterModel } from "./openrouter.js";
8
7
 
9
8
  export const MODELS: Record<string, Model> = {
10
- // ---- OpenAI: GPT-5.6 family ----
9
+ // ---- OpenAI: GPT-5.6 family (prices cut Sept 2026; past 272k input costs 2x) ----
11
10
  "gpt-5.6-sol": {
12
11
  id: "gpt-5.6-sol",
13
12
  provider: "openai",
14
13
  name: "GPT-5.6 Sol",
15
14
  contextWindow: 272_000,
16
- cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
15
+ cost: { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5, tiers: [{ inputTokensAbove: 272_000, input: 8, output: 30, cacheRead: 0.8, cacheWrite: 10 }] },
17
16
  },
18
17
  "gpt-5.6-terra": {
19
18
  id: "gpt-5.6-terra",
20
19
  provider: "openai",
21
20
  name: "GPT-5.6 Terra",
22
21
  contextWindow: 272_000,
23
- cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125 },
22
+ cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5, tiers: [{ inputTokensAbove: 272_000, input: 4, output: 18, cacheRead: 0.4, cacheWrite: 5 }] },
24
23
  },
25
24
  "gpt-5.6-luna": {
26
25
  id: "gpt-5.6-luna",
27
26
  provider: "openai",
28
27
  name: "GPT-5.6 Luna",
29
28
  contextWindow: 272_000,
30
- cost: { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25 },
29
+ cost: { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25, tiers: [{ inputTokensAbove: 272_000, input: 0.4, output: 1.8, cacheRead: 0.04, cacheWrite: 0.5 }] },
31
30
  },
32
31
 
33
32
  // ---- Anthropic: Claude ----
33
+ "claude-fable-5-1": {
34
+ id: "claude-fable-5-1",
35
+ provider: "anthropic",
36
+ name: "Claude Fable 5.1",
37
+ contextWindow: 1_000_000,
38
+ cost: { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 },
39
+ },
34
40
  "claude-fable-5": {
35
41
  id: "claude-fable-5",
36
42
  provider: "anthropic",
@@ -38,6 +44,13 @@ export const MODELS: Record<string, Model> = {
38
44
  contextWindow: 1_000_000,
39
45
  cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
40
46
  },
47
+ "claude-opus-5": {
48
+ id: "claude-opus-5",
49
+ provider: "anthropic",
50
+ name: "Claude Opus 5",
51
+ contextWindow: 1_000_000,
52
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
53
+ },
41
54
  "claude-opus-4-8": {
42
55
  id: "claude-opus-4-8",
43
56
  provider: "anthropic",
@@ -45,6 +58,13 @@ export const MODELS: Record<string, Model> = {
45
58
  contextWindow: 1_000_000,
46
59
  cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
47
60
  },
61
+ "claude-sonnet-5": {
62
+ id: "claude-sonnet-5",
63
+ provider: "anthropic",
64
+ name: "Claude Sonnet 5",
65
+ contextWindow: 1_000_000,
66
+ cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
67
+ },
48
68
  "claude-sonnet-4-6": {
49
69
  id: "claude-sonnet-4-6",
50
70
  provider: "anthropic",
@@ -77,6 +97,13 @@ export const MODELS: Record<string, Model> = {
77
97
  tiers: [{ inputTokensAbove: 200_000, input: 4, output: 18, cacheRead: 0.4, cacheWrite: 5 }],
78
98
  },
79
99
  },
100
+ "gemini-3.8-flash": {
101
+ id: "gemini-3.8-flash",
102
+ provider: "google",
103
+ name: "Gemini 3.8 Flash",
104
+ contextWindow: 1_000_000,
105
+ cost: { input: 0.75, output: 3.75, cacheRead: 0.075 },
106
+ },
80
107
  "gemini-3-flash-preview": {
81
108
  id: "gemini-3-flash-preview",
82
109
  provider: "google",
@@ -88,6 +115,13 @@ export const MODELS: Record<string, Model> = {
88
115
  // ---- OpenRouter (one key → frontier models). ids are Pi's OpenRouter-catalog
89
116
  // slugs (vendor/model). Pricing mirrors the underlying model (OpenRouter passes
90
117
  // it through, ~small margin); ACTUAL cost still comes from Pi per run.
118
+ "anthropic/claude-opus-5": {
119
+ id: "anthropic/claude-opus-5",
120
+ provider: "openrouter",
121
+ name: "Claude Opus 5 (OpenRouter)",
122
+ contextWindow: 1_000_000,
123
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
124
+ },
91
125
  "anthropic/claude-opus-4.8": {
92
126
  id: "anthropic/claude-opus-4.8",
93
127
  provider: "openrouter",
@@ -95,6 +129,13 @@ export const MODELS: Record<string, Model> = {
95
129
  contextWindow: 1_000_000,
96
130
  cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
97
131
  },
132
+ "anthropic/claude-sonnet-5": {
133
+ id: "anthropic/claude-sonnet-5",
134
+ provider: "openrouter",
135
+ name: "Claude Sonnet 5 (OpenRouter)",
136
+ contextWindow: 1_000_000,
137
+ cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
138
+ },
98
139
  "anthropic/claude-sonnet-4.6": {
99
140
  id: "anthropic/claude-sonnet-4.6",
100
141
  provider: "openrouter",
@@ -102,12 +143,19 @@ export const MODELS: Record<string, Model> = {
102
143
  contextWindow: 1_000_000,
103
144
  cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
104
145
  },
146
+ "google/gemini-3.8-flash": {
147
+ id: "google/gemini-3.8-flash",
148
+ provider: "openrouter",
149
+ name: "Gemini 3.8 Flash (OpenRouter)",
150
+ contextWindow: 1_000_000,
151
+ cost: { input: 0.75, output: 3.75, cacheRead: 0.075 },
152
+ },
105
153
  "openai/gpt-5.6-luna": {
106
154
  id: "openai/gpt-5.6-luna",
107
155
  provider: "openrouter",
108
156
  name: "GPT-5.6 Luna (OpenRouter)",
109
- contextWindow: 272_000,
110
- cost: { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25 },
157
+ contextWindow: 1_050_000,
158
+ cost: { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
111
159
  },
112
160
  };
113
161
 
package/src/narrate.ts CHANGED
@@ -3,15 +3,13 @@
3
3
  // (costs a small call) and cached on the build state by the caller.
4
4
 
5
5
  import {
6
- AuthStorage,
7
- ModelRegistry,
8
6
  createAgentSession,
9
7
  type AgentSession,
10
8
  } from "@earendil-works/pi-coding-agent";
11
9
  import type { Backend, Provider } from "./types.js";
12
10
  import type { RetroReport } from "./retro.js";
13
11
  import { findEntry } from "./registry.js";
14
- import { resolvePiModel } from "./executor.js";
12
+ import { piRuntime, resolvePiModel } from "./executor.js"
15
13
  import { addSessionCost } from "./session-cost.js";
16
14
 
17
15
  function lastAssistantText(session: AgentSession): string {
@@ -66,16 +64,14 @@ export interface NarrateOptions {
66
64
 
67
65
  /** Generate the narrative. Throws on failure (caller shows the error). */
68
66
  export async function narrateRetro(report: RetroReport, opts: NarrateOptions): Promise<string> {
69
- const authStorage = AuthStorage.create();
70
- const registry = ModelRegistry.create(authStorage);
67
+ const runtime = await piRuntime();
71
68
  const { entry } = findEntry("plan", "mid");
72
69
  const pick = opts.modelOverride ?? entry.byBackend[opts.backend];
73
- const model = resolvePiModel(registry, pick.provider, pick.model);
70
+ const model = resolvePiModel(runtime, pick.provider, pick.model);
74
71
 
75
72
  const { session } = await createAgentSession({
76
73
  model,
77
- authStorage,
78
- modelRegistry: registry,
74
+ modelRuntime: runtime,
79
75
  thinkingLevel: "low",
80
76
  noTools: "all",
81
77
  });
package/src/openrouter.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // - openRouterModels() / findOpenRouterModel(): SYNC reads (disk cache, else Pi's
7
7
  // built-in list) so cost estimation (getModel) can price any picked model.
8
8
 
9
- import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
9
+ import { piRuntime } from "./executor.js";
10
10
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { join } from "node:path";
@@ -20,19 +20,25 @@ const CACHE = join(homedir(), ".projectinator", "openrouter-models.json");
20
20
  let builtinMemo: ORModel[] | null = null;
21
21
  let diskMemo: ORModel[] | null | undefined; // undefined = not read yet, null = no cache
22
22
 
23
- /** Pi's built-in OpenRouter catalog — offline, always available, names + pricing. */
23
+ /** Pi's built-in OpenRouter catalog — offline, names + pricing. Sync read of the memo;
24
+ * call warmBuiltinOpenRouterModels() once at startup to fill it (Pi's runtime is async). */
24
25
  export function builtinOpenRouterModels(): ORModel[] {
26
+ return builtinMemo ?? [];
27
+ }
28
+
29
+ /** Load Pi's built-in OpenRouter list into the memo. Safe to call repeatedly. */
30
+ export async function warmBuiltinOpenRouterModels(): Promise<ORModel[]> {
25
31
  if (builtinMemo) return builtinMemo;
26
32
  try {
27
- const reg = ModelRegistry.create(AuthStorage.create());
28
- const all = (reg.getAll() as unknown as Array<{ id: string; provider: string; name?: string; contextWindow?: number; cost?: ModelCost }>);
29
- builtinMemo = all
30
- .filter((m) => m.provider === "openrouter" && m.cost)
33
+ const runtime = await piRuntime();
34
+ builtinMemo = runtime
35
+ .getModels("openrouter")
36
+ .filter((m) => !!m.cost)
31
37
  .map((m) => ({
32
38
  id: m.id,
33
39
  name: m.name ?? m.id,
34
40
  contextWindow: m.contextWindow ?? 200_000,
35
- cost: m.cost as ModelCost,
41
+ cost: m.cost,
36
42
  }))
37
43
  .sort((a, b) => a.name.localeCompare(b.name));
38
44
  } catch {