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.
- package/README.md +30 -11
- package/bin/projectinator.mjs +56 -11
- package/dist/bakeoff.js +163 -0
- package/dist/bakeoff.js.map +1 -0
- package/dist/build-state.js +26 -0
- package/dist/build-state.js.map +1 -0
- package/dist/burndown.js +19 -0
- package/dist/burndown.js.map +1 -0
- package/dist/calibration.js +86 -0
- package/dist/calibration.js.map +1 -0
- package/dist/cli.js +289 -0
- package/dist/cli.js.map +1 -0
- package/dist/cost.js +35 -0
- package/dist/cost.js.map +1 -0
- package/dist/council.js +147 -0
- package/dist/council.js.map +1 -0
- package/dist/demo.js +93 -0
- package/dist/demo.js.map +1 -0
- package/dist/estimate.js +98 -0
- package/dist/estimate.js.map +1 -0
- package/dist/executor.js +118 -0
- package/dist/executor.js.map +1 -0
- package/dist/git.js +82 -0
- package/dist/git.js.map +1 -0
- package/dist/intake.js +98 -0
- package/dist/intake.js.map +1 -0
- package/dist/models.js +171 -0
- package/dist/models.js.map +1 -0
- package/dist/narrate.js +76 -0
- package/dist/narrate.js.map +1 -0
- package/dist/openrouter.js +112 -0
- package/dist/openrouter.js.map +1 -0
- package/dist/orchestrator.js +273 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/pm.js +245 -0
- package/dist/pm.js.map +1 -0
- package/dist/preview.js +194 -0
- package/dist/preview.js.map +1 -0
- package/dist/registry-store.js +38 -0
- package/dist/registry-store.js.map +1 -0
- package/dist/registry.js +115 -0
- package/dist/registry.js.map +1 -0
- package/dist/research.js +98 -0
- package/dist/research.js.map +1 -0
- package/dist/retro.js +83 -0
- package/dist/retro.js.map +1 -0
- package/dist/roles.js +357 -0
- package/dist/roles.js.map +1 -0
- package/dist/router.js +95 -0
- package/dist/router.js.map +1 -0
- package/dist/run-bakeoff.js +68 -0
- package/dist/run-bakeoff.js.map +1 -0
- package/dist/run-build.js +193 -0
- package/dist/run-build.js.map +1 -0
- package/{src/run-dev.ts → dist/run-dev.js} +23 -39
- package/dist/run-dev.js.map +1 -0
- package/{src/run-pm.ts → dist/run-pm.js} +34 -46
- package/dist/run-pm.js.map +1 -0
- package/{src/run-research.ts → dist/run-research.js} +19 -30
- package/dist/run-research.js.map +1 -0
- package/{src/run-scout.ts → dist/run-scout.js} +17 -27
- package/dist/run-scout.js.map +1 -0
- package/dist/run-web.js +93 -0
- package/dist/run-web.js.map +1 -0
- package/dist/scout.js +81 -0
- package/dist/scout.js.map +1 -0
- package/dist/session-cost.js +15 -0
- package/dist/session-cost.js.map +1 -0
- package/dist/stack.js +34 -0
- package/dist/stack.js.map +1 -0
- package/dist/stuck.js +21 -0
- package/dist/stuck.js.map +1 -0
- package/dist/tui/App.js +1269 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/BakeOff.js +95 -0
- package/dist/tui/BakeOff.js.map +1 -0
- package/dist/tui/BoardEditor.js +183 -0
- package/dist/tui/BoardEditor.js.map +1 -0
- package/dist/tui/EditableBoard.js +140 -0
- package/dist/tui/EditableBoard.js.map +1 -0
- package/dist/tui/Frame.js +78 -0
- package/dist/tui/Frame.js.map +1 -0
- package/dist/tui/Intake.js +59 -0
- package/dist/tui/Intake.js.map +1 -0
- package/dist/tui/Kanban.js +75 -0
- package/dist/tui/Kanban.js.map +1 -0
- package/dist/tui/Settings.js +299 -0
- package/dist/tui/Settings.js.map +1 -0
- package/dist/tui/StackPick.js +44 -0
- package/dist/tui/StackPick.js.map +1 -0
- package/dist/tui/WebAccounts.js +131 -0
- package/dist/tui/WebAccounts.js.map +1 -0
- package/dist/tui/components.js +178 -0
- package/dist/tui/components.js.map +1 -0
- package/dist/tui/config.js +129 -0
- package/dist/tui/config.js.map +1 -0
- package/dist/tui/deploy.js +114 -0
- package/dist/tui/deploy.js.map +1 -0
- package/dist/tui/engine.js +710 -0
- package/dist/tui/engine.js.map +1 -0
- package/dist/tui/notify.js +47 -0
- package/dist/tui/notify.js.map +1 -0
- package/dist/tui/panels.js +42 -0
- package/dist/tui/panels.js.map +1 -0
- package/dist/tui/templates.js +106 -0
- package/dist/tui/templates.js.map +1 -0
- package/dist/tui/theme.js +42 -0
- package/dist/tui/theme.js.map +1 -0
- package/dist/tui/validate.js +51 -0
- package/dist/tui/validate.js.map +1 -0
- package/{src/tui.tsx → dist/tui.js} +23 -33
- package/dist/tui.js.map +1 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- package/dist/web/oauth-anthropic.js +179 -0
- package/dist/web/oauth-anthropic.js.map +1 -0
- package/dist/web/session.js +274 -0
- package/dist/web/session.js.map +1 -0
- package/package.json +5 -4
- package/src/bakeoff.ts +0 -214
- package/src/build-state.ts +0 -47
- package/src/burndown.ts +0 -35
- package/src/calibration.ts +0 -88
- package/src/cost.ts +0 -43
- package/src/council.ts +0 -175
- package/src/demo.ts +0 -106
- package/src/estimate.ts +0 -111
- package/src/executor.ts +0 -169
- package/src/git.ts +0 -72
- package/src/intake.ts +0 -125
- package/src/models.ts +0 -175
- package/src/narrate.ts +0 -87
- package/src/openrouter.ts +0 -119
- package/src/orchestrator.ts +0 -310
- package/src/pm.ts +0 -302
- package/src/preview.ts +0 -206
- package/src/registry-store.ts +0 -41
- package/src/registry.ts +0 -132
- package/src/research.ts +0 -123
- package/src/retro.ts +0 -99
- package/src/roles.ts +0 -401
- package/src/router.ts +0 -120
- package/src/run-bakeoff.ts +0 -77
- package/src/run-build.ts +0 -203
- package/src/run-web.ts +0 -87
- package/src/scout.ts +0 -121
- package/src/session-cost.ts +0 -17
- package/src/stack.ts +0 -46
- package/src/tui/App.tsx +0 -1760
- package/src/tui/BakeOff.tsx +0 -190
- package/src/tui/BoardEditor.tsx +0 -260
- package/src/tui/EditableBoard.tsx +0 -179
- package/src/tui/Frame.tsx +0 -142
- package/src/tui/Intake.tsx +0 -111
- package/src/tui/Kanban.tsx +0 -158
- package/src/tui/Settings.tsx +0 -513
- package/src/tui/StackPick.tsx +0 -79
- package/src/tui/WebAccounts.tsx +0 -197
- package/src/tui/components.tsx +0 -340
- package/src/tui/config.ts +0 -150
- package/src/tui/deploy.ts +0 -137
- package/src/tui/engine.ts +0 -749
- package/src/tui/notify.ts +0 -21
- package/src/tui/panels.tsx +0 -89
- package/src/tui/templates.ts +0 -119
- package/src/tui/theme.ts +0 -44
- package/src/tui/validate.ts +0 -53
- package/src/types.ts +0 -208
- package/src/web/oauth-anthropic.ts +0 -206
- 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
|
-
}
|
package/src/build-state.ts
DELETED
|
@@ -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
|
-
}
|
package/src/calibration.ts
DELETED
|
@@ -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
|
-
}
|