projectinator 0.3.0 → 0.4.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.
Files changed (170) hide show
  1. package/README.md +30 -11
  2. package/bin/projectinator.mjs +56 -11
  3. package/dist/bakeoff.js +163 -0
  4. package/dist/bakeoff.js.map +1 -0
  5. package/dist/build-state.js +26 -0
  6. package/dist/build-state.js.map +1 -0
  7. package/dist/burndown.js +19 -0
  8. package/dist/burndown.js.map +1 -0
  9. package/dist/calibration.js +86 -0
  10. package/dist/calibration.js.map +1 -0
  11. package/dist/cli.js +289 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/cost.js +35 -0
  14. package/dist/cost.js.map +1 -0
  15. package/dist/council.js +147 -0
  16. package/dist/council.js.map +1 -0
  17. package/dist/demo.js +93 -0
  18. package/dist/demo.js.map +1 -0
  19. package/dist/estimate.js +98 -0
  20. package/dist/estimate.js.map +1 -0
  21. package/dist/executor.js +118 -0
  22. package/dist/executor.js.map +1 -0
  23. package/dist/git.js +82 -0
  24. package/dist/git.js.map +1 -0
  25. package/dist/intake.js +98 -0
  26. package/dist/intake.js.map +1 -0
  27. package/dist/models.js +171 -0
  28. package/dist/models.js.map +1 -0
  29. package/dist/narrate.js +76 -0
  30. package/dist/narrate.js.map +1 -0
  31. package/dist/openrouter.js +112 -0
  32. package/dist/openrouter.js.map +1 -0
  33. package/dist/orchestrator.js +273 -0
  34. package/dist/orchestrator.js.map +1 -0
  35. package/dist/pm.js +245 -0
  36. package/dist/pm.js.map +1 -0
  37. package/dist/preview.js +194 -0
  38. package/dist/preview.js.map +1 -0
  39. package/dist/registry-store.js +38 -0
  40. package/dist/registry-store.js.map +1 -0
  41. package/dist/registry.js +115 -0
  42. package/dist/registry.js.map +1 -0
  43. package/dist/research.js +98 -0
  44. package/dist/research.js.map +1 -0
  45. package/dist/retro.js +83 -0
  46. package/dist/retro.js.map +1 -0
  47. package/dist/roles.js +357 -0
  48. package/dist/roles.js.map +1 -0
  49. package/dist/router.js +95 -0
  50. package/dist/router.js.map +1 -0
  51. package/dist/run-bakeoff.js +68 -0
  52. package/dist/run-bakeoff.js.map +1 -0
  53. package/dist/run-build.js +193 -0
  54. package/dist/run-build.js.map +1 -0
  55. package/{src/run-dev.ts → dist/run-dev.js} +23 -39
  56. package/dist/run-dev.js.map +1 -0
  57. package/{src/run-pm.ts → dist/run-pm.js} +34 -46
  58. package/dist/run-pm.js.map +1 -0
  59. package/{src/run-research.ts → dist/run-research.js} +19 -30
  60. package/dist/run-research.js.map +1 -0
  61. package/{src/run-scout.ts → dist/run-scout.js} +17 -27
  62. package/dist/run-scout.js.map +1 -0
  63. package/dist/run-web.js +93 -0
  64. package/dist/run-web.js.map +1 -0
  65. package/dist/scout.js +81 -0
  66. package/dist/scout.js.map +1 -0
  67. package/dist/session-cost.js +15 -0
  68. package/dist/session-cost.js.map +1 -0
  69. package/dist/stack.js +34 -0
  70. package/dist/stack.js.map +1 -0
  71. package/dist/stuck.js +21 -0
  72. package/dist/stuck.js.map +1 -0
  73. package/dist/tui/App.js +1269 -0
  74. package/dist/tui/App.js.map +1 -0
  75. package/dist/tui/BakeOff.js +95 -0
  76. package/dist/tui/BakeOff.js.map +1 -0
  77. package/dist/tui/BoardEditor.js +183 -0
  78. package/dist/tui/BoardEditor.js.map +1 -0
  79. package/dist/tui/EditableBoard.js +140 -0
  80. package/dist/tui/EditableBoard.js.map +1 -0
  81. package/dist/tui/Frame.js +78 -0
  82. package/dist/tui/Frame.js.map +1 -0
  83. package/dist/tui/Intake.js +59 -0
  84. package/dist/tui/Intake.js.map +1 -0
  85. package/dist/tui/Kanban.js +75 -0
  86. package/dist/tui/Kanban.js.map +1 -0
  87. package/dist/tui/Settings.js +299 -0
  88. package/dist/tui/Settings.js.map +1 -0
  89. package/dist/tui/StackPick.js +44 -0
  90. package/dist/tui/StackPick.js.map +1 -0
  91. package/dist/tui/WebAccounts.js +131 -0
  92. package/dist/tui/WebAccounts.js.map +1 -0
  93. package/dist/tui/components.js +178 -0
  94. package/dist/tui/components.js.map +1 -0
  95. package/dist/tui/config.js +129 -0
  96. package/dist/tui/config.js.map +1 -0
  97. package/dist/tui/deploy.js +114 -0
  98. package/dist/tui/deploy.js.map +1 -0
  99. package/dist/tui/engine.js +710 -0
  100. package/dist/tui/engine.js.map +1 -0
  101. package/dist/tui/notify.js +47 -0
  102. package/dist/tui/notify.js.map +1 -0
  103. package/dist/tui/panels.js +42 -0
  104. package/dist/tui/panels.js.map +1 -0
  105. package/dist/tui/templates.js +106 -0
  106. package/dist/tui/templates.js.map +1 -0
  107. package/dist/tui/theme.js +42 -0
  108. package/dist/tui/theme.js.map +1 -0
  109. package/dist/tui/validate.js +51 -0
  110. package/dist/tui/validate.js.map +1 -0
  111. package/{src/tui.tsx → dist/tui.js} +23 -33
  112. package/dist/tui.js.map +1 -0
  113. package/dist/types.js +19 -0
  114. package/dist/types.js.map +1 -0
  115. package/dist/web/oauth-anthropic.js +179 -0
  116. package/dist/web/oauth-anthropic.js.map +1 -0
  117. package/dist/web/session.js +274 -0
  118. package/dist/web/session.js.map +1 -0
  119. package/package.json +5 -4
  120. package/src/bakeoff.ts +0 -214
  121. package/src/build-state.ts +0 -47
  122. package/src/burndown.ts +0 -35
  123. package/src/calibration.ts +0 -88
  124. package/src/cost.ts +0 -43
  125. package/src/council.ts +0 -175
  126. package/src/demo.ts +0 -106
  127. package/src/estimate.ts +0 -111
  128. package/src/executor.ts +0 -169
  129. package/src/git.ts +0 -72
  130. package/src/intake.ts +0 -125
  131. package/src/models.ts +0 -175
  132. package/src/narrate.ts +0 -87
  133. package/src/openrouter.ts +0 -119
  134. package/src/orchestrator.ts +0 -310
  135. package/src/pm.ts +0 -302
  136. package/src/preview.ts +0 -206
  137. package/src/registry-store.ts +0 -41
  138. package/src/registry.ts +0 -132
  139. package/src/research.ts +0 -123
  140. package/src/retro.ts +0 -99
  141. package/src/roles.ts +0 -401
  142. package/src/router.ts +0 -120
  143. package/src/run-bakeoff.ts +0 -77
  144. package/src/run-build.ts +0 -203
  145. package/src/run-web.ts +0 -87
  146. package/src/scout.ts +0 -121
  147. package/src/session-cost.ts +0 -17
  148. package/src/stack.ts +0 -46
  149. package/src/tui/App.tsx +0 -1760
  150. package/src/tui/BakeOff.tsx +0 -190
  151. package/src/tui/BoardEditor.tsx +0 -260
  152. package/src/tui/EditableBoard.tsx +0 -179
  153. package/src/tui/Frame.tsx +0 -142
  154. package/src/tui/Intake.tsx +0 -111
  155. package/src/tui/Kanban.tsx +0 -158
  156. package/src/tui/Settings.tsx +0 -513
  157. package/src/tui/StackPick.tsx +0 -79
  158. package/src/tui/WebAccounts.tsx +0 -197
  159. package/src/tui/components.tsx +0 -340
  160. package/src/tui/config.ts +0 -150
  161. package/src/tui/deploy.ts +0 -137
  162. package/src/tui/engine.ts +0 -749
  163. package/src/tui/notify.ts +0 -21
  164. package/src/tui/panels.tsx +0 -89
  165. package/src/tui/templates.ts +0 -119
  166. package/src/tui/theme.ts +0 -44
  167. package/src/tui/validate.ts +0 -53
  168. package/src/types.ts +0 -208
  169. package/src/web/oauth-anthropic.ts +0 -206
  170. package/src/web/session.ts +0 -299
package/src/bakeoff.ts DELETED
@@ -1,214 +0,0 @@
1
- // Model bake-off — the founding idea: run ONE task across several models, then
2
- // compare cost, latency, and quality so you can pick the best model per role and
3
- // feed that back into the routing registry.
4
- //
5
- // v1 covers TEXT roles (plan, design, test-reasoning) where the deliverable is
6
- // text a judge can score. Code bake-off (per-candidate sandbox + real test
7
- // scoring) is a later step.
8
-
9
- import {
10
- createAgentSession,
11
- defineTool,
12
- type AgentSession,
13
- } from "@earendil-works/pi-coding-agent";
14
- import { Type, type Static } from "typebox";
15
- import type { Capability, Difficulty, Provider, Task } from "./types.js";
16
- import { piRuntime, resolvePiModel } from "./executor.js"
17
- import { buildRolePrompt } from "./roles.js";
18
- import { estimateTokens } from "./estimate.js";
19
- import { addSessionCost } from "./session-cost.js";
20
-
21
- export interface Candidate {
22
- provider: Provider;
23
- model: string;
24
- }
25
-
26
- export interface BakeoffEntry {
27
- provider: Provider;
28
- model: string;
29
- output: string;
30
- cost: number;
31
- ms: number;
32
- outputTokens: number;
33
- error?: string;
34
- }
35
-
36
- export interface JudgeScore {
37
- model: string;
38
- score: number; // 0-10
39
- reason: string;
40
- }
41
-
42
- export interface BakeoffResult {
43
- task: Task;
44
- entries: BakeoffEntry[];
45
- scores: JudgeScore[];
46
- winner?: string; // "provider/model"
47
- judge?: string; // judge model id
48
- }
49
-
50
- function lastAssistantText(session: AgentSession): string {
51
- const msgs = session.messages as Array<{ role?: string; content?: unknown }>;
52
- for (let i = msgs.length - 1; i >= 0; i--) {
53
- const m = msgs[i];
54
- if (m?.role !== "assistant") continue;
55
- const c = m.content;
56
- if (typeof c === "string") return c;
57
- if (Array.isArray(c)) {
58
- return c
59
- .map((p: unknown) => (typeof p === "string" ? p : p && typeof p === "object" && "text" in p ? String((p as { text: unknown }).text) : ""))
60
- .join("")
61
- .trim();
62
- }
63
- }
64
- return "";
65
- }
66
-
67
- const id = (c: Candidate) => `${c.provider}/${c.model}`;
68
-
69
- /** Run one candidate on the task, capturing output, cost, and latency. */
70
- async function runCandidate(task: Task, cand: Candidate): Promise<BakeoffEntry> {
71
- const base: BakeoffEntry = { provider: cand.provider, model: cand.model, output: "", cost: 0, ms: 0, outputTokens: 0 };
72
- try {
73
- const runtime = await piRuntime();
74
- const model = resolvePiModel(runtime, cand.provider, cand.model);
75
- const { session } = await createAgentSession({
76
- model,
77
- modelRuntime: runtime,
78
- thinkingLevel: "medium",
79
- noTools: "all",
80
- });
81
- try {
82
- const t0 = Date.now();
83
- await session.prompt(buildRolePrompt(task, ""));
84
- const ms = Date.now() - t0;
85
- const stats = session.getSessionStats();
86
- addSessionCost(stats.cost);
87
- const out: BakeoffEntry = {
88
- ...base,
89
- output: lastAssistantText(session),
90
- cost: Math.round(stats.cost * 10000) / 10000,
91
- ms,
92
- outputTokens: stats.tokens.output,
93
- };
94
- if (stats.tokens.total === 0) out.error = "returned 0 tokens (invalid key, no credit/balance, or no model access)";
95
- return out;
96
- } finally {
97
- session.dispose(); // dispose even when prompt() throws (expected for inaccessible models)
98
- }
99
- } catch (e) {
100
- return { ...base, error: e instanceof Error ? e.message : String(e) };
101
- }
102
- }
103
-
104
- // ---- judge: score every output on one rubric, forced structured output ----
105
-
106
- const JudgeSchema = Type.Object(
107
- {
108
- scores: Type.Array(
109
- Type.Object({
110
- option: Type.String({ description: "the option letter, e.g. A" }),
111
- score: Type.Number({ description: "0-10 quality for this deliverable" }),
112
- reason: Type.String({ description: "one sentence" }),
113
- }),
114
- ),
115
- winner: Type.String({ description: "the option letter of the best output" }),
116
- },
117
- { additionalProperties: true },
118
- );
119
- type JudgeRaw = Static<typeof JudgeSchema>;
120
-
121
- function buildJudgeTool() {
122
- let captured: JudgeRaw | undefined;
123
- const tool = defineTool({
124
- name: "submit_scores",
125
- label: "Submit Scores",
126
- description: "Submit a 0-10 quality score and one-sentence reason for every option, plus the winning option letter.",
127
- parameters: JudgeSchema,
128
- execute: async (_id, params: JudgeRaw) => {
129
- captured = params;
130
- return { content: [{ type: "text", text: `Scored ${params.scores.length} options; winner ${params.winner}.` }], details: {} };
131
- },
132
- });
133
- return { tool, get: () => captured };
134
- }
135
-
136
- /** Judge anonymised outputs (A, B, C…) on one rubric for the task's capability. */
137
- async function judge(task: Task, entries: BakeoffEntry[], judgeCand: Candidate): Promise<{ scores: JudgeScore[]; winner?: string; judgeId: string }> {
138
- const scored = entries.filter((e) => !e.error && e.output);
139
- if (scored.length < 2) return { scores: [], winner: undefined, judgeId: id(judgeCand) };
140
-
141
- const letters = scored.map((_, i) => String.fromCharCode(65 + i)); // A, B, C…
142
- const blocks = scored.map((e, i) => `### Option ${letters[i]}\n${e.output}`).join("\n\n");
143
- const runtime = await piRuntime();
144
- const model = resolvePiModel(runtime, judgeCand.provider, judgeCand.model);
145
- const { tool, get } = buildJudgeTool();
146
- const { session } = await createAgentSession({
147
- model,
148
- modelRuntime: runtime,
149
- thinkingLevel: "medium",
150
- noTools: "all",
151
- customTools: [tool],
152
- tools: ["submit_scores"],
153
- });
154
-
155
- const prompt = [
156
- `You are judging ${scored.length} anonymous attempts at the same ${task.capability} task. Be a strict, fair critic.`,
157
- `Task: ${task.title}`,
158
- "",
159
- `Score each option 0-10 on how well it delivers a high-quality ${task.capability} result (correctness, completeness, clarity, usefulness). Then pick the single best.`,
160
- "Call submit_scores exactly once with a score+reason for EVERY option letter and the winner.",
161
- "",
162
- blocks,
163
- ].join("\n");
164
-
165
- try {
166
- await session.prompt(prompt);
167
- let raw = get();
168
- for (let i = 0; i < 2 && !raw; i++) {
169
- await session.prompt("Call submit_scores now with a score for every option letter and the winner.");
170
- raw = get();
171
- }
172
- addSessionCost(session.getSessionStats().cost);
173
- if (!raw) return { scores: [], winner: undefined, judgeId: id(judgeCand) };
174
-
175
- const byLetter = new Map(letters.map((l, i) => [l, scored[i]!]));
176
- const scores: JudgeScore[] = raw.scores
177
- .map((s) => {
178
- const e = byLetter.get(s.option.trim().toUpperCase().slice(0, 1));
179
- return e ? { model: id(e), score: s.score, reason: s.reason } : undefined;
180
- })
181
- .filter((x): x is JudgeScore => !!x);
182
- const winEntry = byLetter.get(String(raw.winner).trim().toUpperCase().slice(0, 1));
183
- return { scores, winner: winEntry ? id(winEntry) : undefined, judgeId: id(judgeCand) };
184
- } finally {
185
- session.dispose();
186
- }
187
- }
188
-
189
- export interface BakeoffOptions {
190
- /** Model that scores the outputs. Defaults to the first candidate. */
191
- judge?: Candidate;
192
- onProgress?: (msg: string) => void;
193
- }
194
-
195
- /** Run the full bake-off: every candidate on the task, then judge. */
196
- export async function runBakeoff(task: Task, candidates: Candidate[], opts: BakeoffOptions = {}): Promise<BakeoffResult> {
197
- const log = opts.onProgress ?? (() => {});
198
- const entries: BakeoffEntry[] = [];
199
- for (const c of candidates) {
200
- log(`running ${id(c)}…`);
201
- const e = await runCandidate(task, c);
202
- log(e.error ? ` ${id(c)}: ERROR ${e.error}` : ` ${id(c)}: $${e.cost.toFixed(4)} ${(e.ms / 1000).toFixed(1)}s ${e.outputTokens} tok`);
203
- entries.push(e);
204
- }
205
- const judgeCand = opts.judge ?? candidates[0]!;
206
- log(`judging with ${id(judgeCand)}…`);
207
- const { scores, winner, judgeId } = await judge(task, entries, judgeCand);
208
- return { task, entries, scores, winner, judge: judgeId };
209
- }
210
-
211
- /** Convenience: build a one-off Task for a capability/difficulty from a prompt. */
212
- export function bakeoffTask(prompt: string, capability: Capability, difficulty: Difficulty = "medium"): Task {
213
- return { id: "BAKE", title: prompt, capability, difficulty, dependsOn: [], estTokens: estimateTokens(capability, difficulty) };
214
- }
@@ -1,47 +0,0 @@
1
- // Build persistence — checkpoint a run so a halt/crash/cancel can resume without
2
- // re-paying for finished tasks. The orchestrator itself stays fs-free; this module
3
- // (and run-build) own the disk I/O.
4
-
5
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
- import type { Task, TaskOutcome } from "./types.js";
7
-
8
- export interface BuildState {
9
- id: string;
10
- /** The original idea/request text, for display in the projects list. */
11
- idea?: string;
12
- /** Workflow used: auto-run, or approval-gated. */
13
- mode?: "auto" | "approval";
14
- tasks: Task[];
15
- /** Full record of every task run, including feedback-loop retries (append-only). */
16
- outcomes: TaskOutcome[];
17
- totalCost: number;
18
- status: "running" | "complete" | "halted";
19
- haltReason?: string;
20
- /** Per-project budget cap (USD). Overrides the global default when set. */
21
- budgetCapUSD?: number;
22
- /** Cached AI retro narrative (generated on demand). */
23
- retroNarrative?: string;
24
- }
25
-
26
- export function newBuildState(id: string, tasks: Task[], idea?: string, mode?: "auto" | "approval"): BuildState {
27
- return { id, idea, mode, tasks, outcomes: [], totalCost: 0, status: "running" };
28
- }
29
-
30
- export function saveState(state: BuildState, path: string): void {
31
- writeFileSync(path, JSON.stringify(state, null, 2) + "\n");
32
- }
33
-
34
- export function loadState(path: string): BuildState | undefined {
35
- if (!existsSync(path)) return undefined;
36
- try {
37
- return JSON.parse(readFileSync(path, "utf-8")) as BuildState;
38
- } catch (e) {
39
- throw new Error(`Bad build state at ${path}: ${e instanceof Error ? e.message : e}`);
40
- }
41
- }
42
-
43
- /** Which task ids are already finished (last outcome wins; a failed attempt does not
44
- * count). Used to skip on resume. */
45
- export function completedIds(state: BuildState): Set<string> {
46
- return new Set(state.outcomes.filter((o) => !o.error).map((o) => o.taskId));
47
- }
package/src/burndown.ts DELETED
@@ -1,35 +0,0 @@
1
- // Burndown — tasks remaining and cumulative spend across the build. There are no
2
- // timestamps in build-state, so the X axis is task-completion order (step 1..N),
3
- // which is the natural timeline for a build. Retries add a step (and cost) without
4
- // burning down a task, so they show up as flat-remaining / rising-cost.
5
-
6
- import type { BuildState } from "./build-state.js";
7
-
8
- export interface BurndownStep {
9
- taskId: string;
10
- remaining: number; // distinct tasks still to do after this step
11
- cumCost: number; // cumulative spend through this step
12
- retry: boolean; // this step re-ran an already-done task
13
- }
14
-
15
- export interface Burndown {
16
- taskCount: number;
17
- totalCost: number;
18
- steps: BurndownStep[];
19
- }
20
-
21
- const round2 = (n: number) => Math.round(n * 100) / 100;
22
-
23
- export function computeBurndown(state: BuildState): Burndown {
24
- const taskCount = state.tasks.length;
25
- const done = new Set<string>();
26
- let cum = 0;
27
- const steps: BurndownStep[] = [];
28
- for (const o of state.outcomes) {
29
- const retry = done.has(o.taskId);
30
- done.add(o.taskId);
31
- cum += o.cost;
32
- steps.push({ taskId: o.taskId, remaining: taskCount - done.size, cumCost: round2(cum), retry });
33
- }
34
- return { taskCount, totalCost: round2(cum), steps };
35
- }
@@ -1,88 +0,0 @@
1
- // Self-calibrating token estimates. After each real task, we record its measured
2
- // token usage per (capability, difficulty). estimateTokens uses the running average
3
- // once there are enough samples, so estimates sharpen with use. Persisted globally.
4
-
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
- import { homedir } from "node:os";
7
- import { join } from "node:path";
8
- import type { Capability, Difficulty } from "./types.js";
9
-
10
- interface Sample {
11
- input: number; // total input tokens (fresh + cache-read)
12
- output: number;
13
- cachedFraction: number; // share of input served from cache
14
- n: number; // sample count (capped so recent runs still move the average)
15
- }
16
-
17
- type Calibration = Record<string, Sample>;
18
-
19
- const MIN_SAMPLES = 2; // trust calibration only after a couple of runs
20
- const MAX_N = 20; // cap so old runs don't dominate
21
-
22
- function calPath(): string {
23
- return join(homedir(), ".projectinator", "calibration.json");
24
- }
25
- const key = (c: Capability, d: Difficulty) => `${c}/${d}`;
26
-
27
- function load(): Calibration {
28
- try {
29
- if (!existsSync(calPath())) return {};
30
- return JSON.parse(readFileSync(calPath(), "utf-8")) as Calibration;
31
- } catch {
32
- return {};
33
- }
34
- }
35
-
36
- function save(cal: Calibration): void {
37
- try {
38
- mkdirSync(join(homedir(), ".projectinator"), { recursive: true });
39
- writeFileSync(calPath(), JSON.stringify(cal, null, 2) + "\n");
40
- } catch {
41
- /* best effort — never break a build on a calibration write */
42
- }
43
- }
44
-
45
- /** Fold a real measurement into the running average for its bucket. */
46
- export function recordActual(
47
- capability: Capability,
48
- difficulty: Difficulty,
49
- inputTotal: number,
50
- output: number,
51
- cachedFraction: number,
52
- ): void {
53
- if (!(inputTotal > 0)) return;
54
- const cal = load();
55
- const k = key(capability, difficulty);
56
- const prev = cal[k];
57
- if (!prev) {
58
- cal[k] = { input: inputTotal, output, cachedFraction, n: 1 };
59
- } else {
60
- const n = Math.min(prev.n, MAX_N);
61
- cal[k] = {
62
- input: (prev.input * n + inputTotal) / (n + 1),
63
- output: (prev.output * n + output) / (n + 1),
64
- cachedFraction: (prev.cachedFraction * n + cachedFraction) / (n + 1),
65
- n: prev.n + 1,
66
- };
67
- }
68
- save(cal);
69
- }
70
-
71
- /** All recorded samples keyed "capability/difficulty" (for the accuracy view). */
72
- export function allSamples(): Record<string, { input: number; output: number; cachedFraction: number; n: number }> {
73
- return load();
74
- }
75
-
76
- /** Calibrated estimate for a bucket, once enough samples exist. */
77
- export function calibratedTokens(
78
- capability: Capability,
79
- difficulty: Difficulty,
80
- ): { input: number; output: number; cachedInputFraction: number } | undefined {
81
- const s = load()[key(capability, difficulty)];
82
- if (!s || s.n < MIN_SAMPLES) return undefined;
83
- return {
84
- input: Math.round(s.input),
85
- output: Math.round(s.output),
86
- cachedInputFraction: Math.min(0.95, Math.max(0, s.cachedFraction)),
87
- };
88
- }
package/src/cost.ts DELETED
@@ -1,43 +0,0 @@
1
- // Cost estimation. Pure math over a token estimate + a model's price block.
2
-
3
- import type { Model, TokenEstimate } from "./types.js";
4
-
5
- const PER_MILLION = 1_000_000;
6
-
7
- /** Resolve the applicable input/output/cache rates, honoring volume tiers.
8
- * Highest matching `inputTokensAbove` threshold wins (Pi semantics). */
9
- function ratesFor(model: Model, inputTokens: number) {
10
- const base = model.cost;
11
- let rate = { input: base.input, output: base.output, cacheRead: base.cacheRead ?? base.input };
12
- for (const tier of base.tiers ?? []) {
13
- if (inputTokens > tier.inputTokensAbove) {
14
- rate = {
15
- input: tier.input,
16
- output: tier.output,
17
- cacheRead: tier.cacheRead ?? tier.input,
18
- };
19
- }
20
- }
21
- return rate;
22
- }
23
-
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.
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. */
28
- export function estimateCost(est: TokenEstimate, model: Model): number {
29
- const cachedFraction = clamp01(est.cachedInputFraction ?? 0);
30
- const rate = ratesFor(model, est.input);
31
-
32
- const freshInput = est.input * (1 - cachedFraction);
33
- const cachedInput = est.input * cachedFraction;
34
-
35
- const inputCost = (freshInput / PER_MILLION) * rate.input + (cachedInput / PER_MILLION) * rate.cacheRead;
36
- const outputCost = (est.output / PER_MILLION) * rate.output;
37
-
38
- return Math.round((inputCost + outputCost) * 10_000) / 10_000;
39
- }
40
-
41
- function clamp01(n: number): number {
42
- return Math.max(0, Math.min(1, n));
43
- }
package/src/council.ts DELETED
@@ -1,175 +0,0 @@
1
- // Council planning (opt-in "deep plan"). Three perspectives propose epics from
2
- // different lenses in parallel, then a synthesizer merges them into one ordered
3
- // epic list. The user approves the epics; expansion into tasks happens after,
4
- // via the normal decomposer seeded with these epics.
5
-
6
- import {
7
- createAgentSession,
8
- defineTool,
9
- type AgentSession,
10
- } from "@earendil-works/pi-coding-agent";
11
- import { Type, type Static } from "typebox";
12
- import type { Backend, Provider } from "./types.js";
13
- import { findEntry } from "./registry.js";
14
- import { piRuntime, resolvePiModel } from "./executor.js"
15
- import { addSessionCost } from "./session-cost.js";
16
-
17
- export interface Epic {
18
- name: string;
19
- rationale: string;
20
- }
21
-
22
- const EpicsSchema = Type.Object(
23
- {
24
- epics: Type.Array(
25
- Type.Object(
26
- { name: Type.String(), rationale: Type.String({ description: "one line: why this epic exists" }) },
27
- { additionalProperties: true },
28
- ),
29
- ),
30
- },
31
- { additionalProperties: true },
32
- );
33
- type EpicsRaw = Static<typeof EpicsSchema>;
34
-
35
- function buildEpicsTool(toolName: string) {
36
- let captured: EpicsRaw | undefined;
37
- const tool = defineTool({
38
- name: toolName,
39
- label: "Submit Epics",
40
- description: "Submit the epic list. Call exactly once.",
41
- parameters: EpicsSchema,
42
- execute: async (_id, params) => {
43
- captured = params as EpicsRaw;
44
- return { content: [{ type: "text", text: `Got ${params.epics.length} epics.` }], details: {} };
45
- },
46
- });
47
- return { tool, get: () => captured };
48
- }
49
-
50
- function lastAssistantText(session: AgentSession): string {
51
- const msgs = session.messages as Array<{ role?: string; content?: unknown }>;
52
- for (let i = msgs.length - 1; i >= 0; i--) {
53
- const m = msgs[i];
54
- if (m?.role !== "assistant") continue;
55
- const c = m.content;
56
- if (typeof c === "string") return c;
57
- if (Array.isArray(c)) return c.map((p: unknown) => (p && typeof p === "object" && "text" in p ? String((p as { text: unknown }).text) : "")).join("");
58
- }
59
- return "";
60
- }
61
-
62
- interface Ctx {
63
- backend: Backend;
64
- modelOverride?: { provider: Provider; model: string };
65
- }
66
-
67
- async function runEpicAgent(idea: string, system: string, toolName: string, ctx: Ctx): Promise<Epic[]> {
68
- const runtime = await piRuntime();
69
- const { entry } = findEntry("plan", "mid");
70
- const pick = ctx.modelOverride ?? entry.byBackend[ctx.backend];
71
- try {
72
- const model = resolvePiModel(runtime, pick.provider, pick.model);
73
- const { tool, get } = buildEpicsTool(toolName);
74
- const { session } = await createAgentSession({
75
- model,
76
- modelRuntime: runtime,
77
- thinkingLevel: "low",
78
- noTools: "all",
79
- customTools: [tool],
80
- tools: [toolName],
81
- });
82
- try {
83
- await session.prompt(`${system}\n\n--- REQUEST ---\n${idea}`);
84
- let raw = get();
85
- if (!raw) { await session.prompt(`Call ${toolName} now.`); raw = get(); }
86
- addSessionCost(session.getSessionStats().cost);
87
- return (raw?.epics ?? [])
88
- .map((e) => ({ name: String(e.name ?? "").trim(), rationale: String(e.rationale ?? "").trim() }))
89
- .filter((e) => e.name);
90
- } finally {
91
- session.dispose();
92
- }
93
- } catch {
94
- return [];
95
- }
96
- }
97
-
98
- const LENSES: { key: string; system: string }[] = [
99
- {
100
- key: "architect",
101
- system:
102
- "You are the ARCHITECT on a planning council. Propose the EPICS (big areas of work) for this build " +
103
- "from a TECHNICAL structure lens — components, data, integration, scaffolding. 3-6 epics, each a short " +
104
- "name + one-line rationale. Call submit_epics_architect once.",
105
- },
106
- {
107
- key: "product",
108
- system:
109
- "You are the PRODUCT lead on a planning council. Propose the EPICS from a USER-VALUE lens — the features " +
110
- "and flows a user needs, in priority order. 3-6 epics, each a short name + one-line rationale. Call " +
111
- "submit_epics_product once.",
112
- },
113
- {
114
- key: "risk",
115
- system:
116
- "You are the RISK/QA lead on a planning council. Propose the EPICS from a WHAT-COULD-GO-WRONG lens — " +
117
- "validation, edge cases, error/empty states, testing, and anything easy to forget. 3-6 epics, each a " +
118
- "short name + one-line rationale. Call submit_epics_risk once.",
119
- },
120
- ];
121
-
122
- const SYNTH_SYSTEM = [
123
- "You are the PROJECT MANAGER chairing a planning council. Three leads proposed epics from different lenses",
124
- "(architect, product, risk). Merge them into ONE clean, ordered epic list for the build: dedupe overlaps,",
125
- "keep what matters, drop noise, and order them the way the work should proceed. Aim for 3-7 epics. Each epic:",
126
- "a short name + a one-line rationale that folds in the strongest point(s) from the leads. Call submit_epics once.",
127
- ].join("\n");
128
-
129
- export interface CouncilResult {
130
- epics: Epic[];
131
- proposals: { lens: string; epics: Epic[] }[];
132
- }
133
-
134
- /** Run the council: 3 lenses in parallel, then synthesize. Falls back to a single
135
- * lens's epics if synthesis fails; empty only if everything fails. */
136
- export async function councilEpics(idea: string, ctx: Ctx): Promise<CouncilResult> {
137
- const proposalsRaw = await Promise.all(
138
- LENSES.map((l) => runEpicAgent(idea, l.system, `submit_epics_${l.key}`, ctx).then((epics) => ({ lens: l.key, epics }))),
139
- );
140
- const proposals = proposalsRaw.filter((p) => p.epics.length);
141
- if (!proposals.length) return { epics: [], proposals: [] };
142
-
143
- // Synthesize.
144
- const runtime = await piRuntime();
145
- const { entry } = findEntry("plan", "mid");
146
- const pick = ctx.modelOverride ?? entry.byBackend[ctx.backend];
147
- try {
148
- const model = resolvePiModel(runtime, pick.provider, pick.model);
149
- const { tool, get } = buildEpicsTool("submit_epics");
150
- const { session } = await createAgentSession({
151
- model, modelRuntime: runtime, thinkingLevel: "low",
152
- noTools: "all", customTools: [tool], tools: ["submit_epics"],
153
- });
154
- try {
155
- const block = proposals
156
- .map((p) => `### ${p.lens}\n${p.epics.map((e) => `- ${e.name}: ${e.rationale}`).join("\n")}`)
157
- .join("\n\n");
158
- await session.prompt(`${SYNTH_SYSTEM}\n\n--- REQUEST ---\n${idea}\n\n--- LEAD PROPOSALS ---\n${block}`);
159
- let raw = get();
160
- if (!raw) { await session.prompt("Call submit_epics now."); raw = get(); }
161
- addSessionCost(session.getSessionStats().cost);
162
- const epics = (raw?.epics ?? [])
163
- .map((e) => ({ name: String(e.name ?? "").trim(), rationale: String(e.rationale ?? "").trim() }))
164
- .filter((e) => e.name);
165
- // Fall back to the largest single proposal if synthesis produced nothing.
166
- const best = proposals.slice().sort((a, b) => b.epics.length - a.epics.length)[0]!.epics;
167
- return { epics: epics.length ? epics : best, proposals };
168
- } finally {
169
- session.dispose();
170
- }
171
- } catch {
172
- const best = proposals.slice().sort((a, b) => b.epics.length - a.epics.length)[0]!.epics;
173
- return { epics: best, proposals };
174
- }
175
- }