@gonrocca/zero-pi 0.1.56 → 0.1.57
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 +2 -0
- package/extensions/format-tokens.ts +14 -0
- package/extensions/zero-cost-extension.ts +59 -0
- package/extensions/zero-cost.ts +220 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ into `/forge` for you.
|
|
|
76
76
|
| **Strict TDD** | The build phase drives RED → GREEN → TRIANGULATE → REFACTOR with a TDD Cycle Evidence table; veredicto audits it. On by default, runtime-gated on a test runner; `tdd.mode: "off"` disables it. |
|
|
77
77
|
| **`/zero-models`** | Pick the model + provider + thinking level for each SDD phase — a boxed-window picker, or set one directly. |
|
|
78
78
|
| **Autotune** | Learns which model fits each phase from your run history and re-tunes itself. |
|
|
79
|
+
| **`/zero-cost`** | Aggregates a run's sub-agent `meta.json` files into a per-phase token/cost/duration report — no schema change, reads what pi already writes. |
|
|
79
80
|
| **`/zero-sync` / `/zero-archive`** | Folds each run's spec delta into a canonical, project-wide spec store and archives approved runs. |
|
|
80
81
|
| **Git / PR / Issues** | `/zero-branch`, `/zero-git-validate`, `/zero-pr`, and `/zero-issue` keep branches and GitHub links audit-ready. |
|
|
81
82
|
| **Run memory** | Every run recalls and saves traces to Cortex, so runs learn from each other. |
|
|
@@ -98,6 +99,7 @@ into `/forge` for you.
|
|
|
98
99
|
| `/zero-archive <slug>` | Merge an approved run into `.sdd/specs/`, move it to `.sdd/archive/YYYY-MM-DD-<slug>/`, and persist `archivePath`. |
|
|
99
100
|
| `/zero-validate <slug>` | Validate proposal/spec/design/tasks artifacts, including task schema and per-domain specs. |
|
|
100
101
|
| `/zero-status` | Show each `.sdd/` run's artifact, sync, latest-verdict, and GitHub-link status. |
|
|
102
|
+
| `/zero-cost [<slug>]` | Report tokens (in/out/cache), USD cost, duration, and tool-count per SDD phase for a run — `<slug>` for a specific run, no argument for the most recent. |
|
|
101
103
|
| `/zero-branch <slug>` | Create/reuse the configured SDD Git branch and persist `branch`/`baseBranch`. |
|
|
102
104
|
| `/zero-git-validate <slug>` | Check worktree, branch, remote, `gh auth`, and verdict gating before PR/archive. |
|
|
103
105
|
| `/zero-pr <slug>` | Create an audit-ready GitHub PR from a run that already has verdict `pasa`. |
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Pure helper: compact token count to a short human string.
|
|
2
|
+
// 1234 → "1.2k", 4_695_040 → "4.7M", 999 → "999".
|
|
3
|
+
//
|
|
4
|
+
// Intentionally separate from `formatTokenCount` in zero-statusline.ts,
|
|
5
|
+
// which uses a different contract; this helper requires lowercase `k`,
|
|
6
|
+
// uppercase `M`, and uppercase `B` suffixes.
|
|
7
|
+
|
|
8
|
+
export function formatTokens(n: number): string {
|
|
9
|
+
if (!Number.isFinite(n) || n < 0) return "0";
|
|
10
|
+
if (n < 1000) return `${Math.floor(n)}`;
|
|
11
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
12
|
+
if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
13
|
+
return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { parseMeta, selectRunMetas, aggregateRun, formatReport, type PhaseMeta } from "./zero-cost.ts";
|
|
6
|
+
|
|
7
|
+
type NotifyType = "info" | "warning" | "error";
|
|
8
|
+
interface PiCommandContext { ui: { notify(message: string, type?: NotifyType): void } }
|
|
9
|
+
interface PiExtensionAPI { registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> }): void }
|
|
10
|
+
|
|
11
|
+
/** Read every sub-agent `meta.json` across all pi session dirs and parse the
|
|
12
|
+
* zero-<phase> ones. Scanning every session keeps us decoupled from pi's
|
|
13
|
+
* cwd-encoding scheme; selection by slug/timestamp narrows to one run. */
|
|
14
|
+
function readAllPhaseMetas(): PhaseMeta[] {
|
|
15
|
+
const root = join(homedir(), ".pi", "agent", "sessions");
|
|
16
|
+
if (!existsSync(root)) return [];
|
|
17
|
+
const out: PhaseMeta[] = [];
|
|
18
|
+
for (const session of readdirSync(root, { withFileTypes: true })) {
|
|
19
|
+
if (!session.isDirectory()) continue;
|
|
20
|
+
const artifacts = join(root, session.name, "subagent-artifacts");
|
|
21
|
+
if (!existsSync(artifacts)) continue;
|
|
22
|
+
for (const f of readdirSync(artifacts)) {
|
|
23
|
+
if (!f.endsWith("_meta.json")) continue;
|
|
24
|
+
try {
|
|
25
|
+
const meta = parseMeta(JSON.parse(readFileSync(join(artifacts, f), "utf8")));
|
|
26
|
+
if (meta) out.push(meta);
|
|
27
|
+
} catch { /* skip unreadable / malformed meta */ }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function runCost(args: string, ctx: PiCommandContext): void {
|
|
34
|
+
const notify = (m: string, t?: NotifyType) => { try { ctx.ui.notify(m, t); } catch {} };
|
|
35
|
+
const slug = args.trim() || null;
|
|
36
|
+
const metas = readAllPhaseMetas();
|
|
37
|
+
const selected = selectRunMetas(metas, slug);
|
|
38
|
+
if (selected.length === 0) {
|
|
39
|
+
notify(
|
|
40
|
+
slug
|
|
41
|
+
? `zero-cost: no encontré datos de costo para "${slug}".`
|
|
42
|
+
: "zero-cost: no encontré runs con datos de costo todavía.",
|
|
43
|
+
"info",
|
|
44
|
+
);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
notify(formatReport(aggregateRun(selected, slug ?? selected[0]?.slug ?? null)), "info");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function register(pi?: PiExtensionAPI): void {
|
|
51
|
+
if (!pi || typeof pi.registerCommand !== "function") return;
|
|
52
|
+
pi.registerCommand("zero-cost", {
|
|
53
|
+
description: "Reporta tokens, costo y duración por fase de un run SDD (slug opcional; sin slug = el más reciente)",
|
|
54
|
+
handler: (args: string, ctx: PiCommandContext): void => {
|
|
55
|
+
try { if (ctx?.ui?.notify) runCost(args ?? "", ctx); }
|
|
56
|
+
catch (err) { try { ctx.ui.notify(`zero-cost: ${err instanceof Error ? err.message : String(err)}`, "error"); } catch {} }
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// zero-pi — per-run cost/usage report, pure-logic module.
|
|
2
|
+
//
|
|
3
|
+
// A `/forge` run delegates each phase to a sub-agent that writes a
|
|
4
|
+
// `*_meta.json` under `~/.pi/agent/sessions/<session>/subagent-artifacts/`
|
|
5
|
+
// carrying real `usage` (tokens + cost), `model`, `durationMs` and
|
|
6
|
+
// `toolCount`. `~/.pi/zero-runs.jsonl` records the verdict/rounds/model but
|
|
7
|
+
// never cost — so there is no aggregate view of what a run cost, by phase.
|
|
8
|
+
//
|
|
9
|
+
// This module turns those raw meta records into a per-phase + total report.
|
|
10
|
+
// Every decision (phase mapping, slug extraction, selection, aggregation,
|
|
11
|
+
// formatting) lives here as plain, dependency-free TypeScript so it is
|
|
12
|
+
// testable in isolation. The pi wiring lives in `zero-cost-extension.ts`.
|
|
13
|
+
|
|
14
|
+
import { formatTokens } from "./format-tokens.ts";
|
|
15
|
+
|
|
16
|
+
/** The SDD phases a run is composed of, in pipeline order. */
|
|
17
|
+
export const COST_PHASES = ["explore", "plan", "build", "veredicto"] as const;
|
|
18
|
+
export type CostPhase = (typeof COST_PHASES)[number];
|
|
19
|
+
|
|
20
|
+
/** Token + cost usage of a single sub-agent run. */
|
|
21
|
+
export interface PhaseUsage {
|
|
22
|
+
input: number;
|
|
23
|
+
output: number;
|
|
24
|
+
cacheRead: number;
|
|
25
|
+
cacheWrite: number;
|
|
26
|
+
cost: number;
|
|
27
|
+
turns: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One normalized sub-agent `meta.json` belonging to a phase. */
|
|
31
|
+
export interface PhaseMeta {
|
|
32
|
+
runId: string;
|
|
33
|
+
phase: CostPhase;
|
|
34
|
+
/** Feature slug extracted from the sub-agent task, or `null`. */
|
|
35
|
+
slug: string | null;
|
|
36
|
+
model: string;
|
|
37
|
+
usage: PhaseUsage;
|
|
38
|
+
durationMs: number;
|
|
39
|
+
toolCount: number;
|
|
40
|
+
/** Epoch ms the meta was written (newest wins for selection/model). */
|
|
41
|
+
timestamp: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One phase row: summed usage of every sub-agent that ran that phase. */
|
|
45
|
+
export interface PhaseAggregate extends PhaseUsage {
|
|
46
|
+
phase: CostPhase;
|
|
47
|
+
model: string;
|
|
48
|
+
subAgents: number;
|
|
49
|
+
durationMs: number;
|
|
50
|
+
toolCount: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The aggregated cost of a whole run. */
|
|
54
|
+
export interface RunCost {
|
|
55
|
+
slug: string | null;
|
|
56
|
+
phases: PhaseAggregate[];
|
|
57
|
+
total: PhaseUsage & { durationMs: number; toolCount: number; subAgents: number };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PHASE_INDEX: Record<CostPhase, number> = { explore: 0, plan: 1, build: 2, veredicto: 3 };
|
|
61
|
+
|
|
62
|
+
/** Map a sub-agent name `zero-<phase>` to its phase, or `null`. */
|
|
63
|
+
export function phaseFromAgent(agent: unknown): CostPhase | null {
|
|
64
|
+
if (typeof agent !== "string") return null;
|
|
65
|
+
const m = /^zero-(explore|plan|build|veredicto)$/.exec(agent);
|
|
66
|
+
return m ? (m[1] as CostPhase) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Extract the feature slug from the first `.sdd/<slug>/` in a task string.
|
|
70
|
+
* The internal `specs` and `archive` directories are never slugs. */
|
|
71
|
+
export function extractSlug(task: unknown): string | null {
|
|
72
|
+
if (typeof task !== "string") return null;
|
|
73
|
+
const re = /\.sdd\/([A-Za-z0-9._-]+)\//g;
|
|
74
|
+
let m: RegExpExecArray | null;
|
|
75
|
+
while ((m = re.exec(task)) !== null) {
|
|
76
|
+
const slug = m[1];
|
|
77
|
+
if (slug && slug !== "specs" && slug !== "archive") return slug;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function num(v: unknown): number {
|
|
83
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function asUsage(raw: unknown): PhaseUsage | null {
|
|
87
|
+
if (!raw || typeof raw !== "object") return null;
|
|
88
|
+
const u = raw as Record<string, unknown>;
|
|
89
|
+
// A usage object must carry at least the token fields as numbers.
|
|
90
|
+
if (typeof u.input !== "number" && typeof u.output !== "number") return null;
|
|
91
|
+
return {
|
|
92
|
+
input: num(u.input),
|
|
93
|
+
output: num(u.output),
|
|
94
|
+
cacheRead: num(u.cacheRead),
|
|
95
|
+
cacheWrite: num(u.cacheWrite),
|
|
96
|
+
cost: num(u.cost),
|
|
97
|
+
turns: num(u.turns),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Parse one raw `meta.json` object into a `PhaseMeta`, or `null` if it is not
|
|
102
|
+
* a zero-<phase> sub-agent or lacks a usage object. */
|
|
103
|
+
export function parseMeta(raw: unknown): PhaseMeta | null {
|
|
104
|
+
if (!raw || typeof raw !== "object") return null;
|
|
105
|
+
const r = raw as Record<string, unknown>;
|
|
106
|
+
const phase = phaseFromAgent(r.agent);
|
|
107
|
+
if (!phase) return null;
|
|
108
|
+
const usage = asUsage(r.usage);
|
|
109
|
+
if (!usage) return null;
|
|
110
|
+
return {
|
|
111
|
+
runId: typeof r.runId === "string" ? r.runId : "",
|
|
112
|
+
phase,
|
|
113
|
+
slug: extractSlug(r.task),
|
|
114
|
+
model: typeof r.model === "string" ? r.model : "",
|
|
115
|
+
usage,
|
|
116
|
+
durationMs: num(r.durationMs),
|
|
117
|
+
toolCount: num(r.toolCount),
|
|
118
|
+
timestamp: num(r.timestamp),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Select the phase-metas for one run: the given slug, or — by default — the
|
|
123
|
+
* slug whose newest meta has the greatest timestamp. Slug-less metas are
|
|
124
|
+
* ignored for the default pick. */
|
|
125
|
+
export function selectRunMetas(metas: readonly PhaseMeta[], slug?: string | null): PhaseMeta[] {
|
|
126
|
+
if (slug) return metas.filter((m) => m.slug === slug);
|
|
127
|
+
let bestSlug: string | null = null;
|
|
128
|
+
let bestTs = -Infinity;
|
|
129
|
+
for (const m of metas) {
|
|
130
|
+
if (m.slug && m.timestamp > bestTs) {
|
|
131
|
+
bestTs = m.timestamp;
|
|
132
|
+
bestSlug = m.slug;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (bestSlug === null) return [];
|
|
136
|
+
return metas.filter((m) => m.slug === bestSlug);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Aggregate selected phase-metas into per-phase rows (pipeline order) plus a
|
|
140
|
+
* run total. A phase that ran multiple sub-agents sums them; its model is the
|
|
141
|
+
* newest meta's model. */
|
|
142
|
+
export function aggregateRun(metas: readonly PhaseMeta[], slug: string | null): RunCost {
|
|
143
|
+
const byPhase = new Map<CostPhase, PhaseAggregate & { _modelTs: number }>();
|
|
144
|
+
for (const m of metas) {
|
|
145
|
+
let agg = byPhase.get(m.phase);
|
|
146
|
+
if (!agg) {
|
|
147
|
+
agg = {
|
|
148
|
+
phase: m.phase,
|
|
149
|
+
model: m.model,
|
|
150
|
+
subAgents: 0,
|
|
151
|
+
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0,
|
|
152
|
+
durationMs: 0, toolCount: 0,
|
|
153
|
+
_modelTs: -Infinity,
|
|
154
|
+
};
|
|
155
|
+
byPhase.set(m.phase, agg);
|
|
156
|
+
}
|
|
157
|
+
agg.subAgents += 1;
|
|
158
|
+
agg.input += m.usage.input;
|
|
159
|
+
agg.output += m.usage.output;
|
|
160
|
+
agg.cacheRead += m.usage.cacheRead;
|
|
161
|
+
agg.cacheWrite += m.usage.cacheWrite;
|
|
162
|
+
agg.cost += m.usage.cost;
|
|
163
|
+
agg.turns += m.usage.turns;
|
|
164
|
+
agg.durationMs += m.durationMs;
|
|
165
|
+
agg.toolCount += m.toolCount;
|
|
166
|
+
if (m.timestamp >= agg._modelTs) {
|
|
167
|
+
agg._modelTs = m.timestamp;
|
|
168
|
+
agg.model = m.model;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const phases = [...byPhase.values()]
|
|
172
|
+
.sort((a, b) => PHASE_INDEX[a.phase] - PHASE_INDEX[b.phase])
|
|
173
|
+
.map(({ _modelTs, ...row }) => row);
|
|
174
|
+
|
|
175
|
+
const total = phases.reduce(
|
|
176
|
+
(t, p) => {
|
|
177
|
+
t.input += p.input; t.output += p.output; t.cacheRead += p.cacheRead;
|
|
178
|
+
t.cacheWrite += p.cacheWrite; t.cost += p.cost; t.turns += p.turns;
|
|
179
|
+
t.durationMs += p.durationMs; t.toolCount += p.toolCount; t.subAgents += p.subAgents;
|
|
180
|
+
return t;
|
|
181
|
+
},
|
|
182
|
+
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0, durationMs: 0, toolCount: 0, subAgents: 0 },
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
return { slug, phases, total };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Human-readable duration: `12s`, `5m31s`. */
|
|
189
|
+
export function formatDuration(ms: number): string {
|
|
190
|
+
const safe = Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
191
|
+
const totalSec = Math.round(safe / 1000);
|
|
192
|
+
const min = Math.floor(totalSec / 60);
|
|
193
|
+
const sec = totalSec % 60;
|
|
194
|
+
return min > 0 ? `${min}m${sec.toString().padStart(2, "0")}s` : `${sec}s`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** USD with two decimals: `$2.48`. */
|
|
198
|
+
export function formatUsd(n: number): string {
|
|
199
|
+
const safe = Number.isFinite(n) ? n : 0;
|
|
200
|
+
return `$${safe.toFixed(2)}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Render an aligned per-phase + total report. */
|
|
204
|
+
export function formatReport(run: RunCost): string {
|
|
205
|
+
if (run.phases.length === 0) return "zero-cost: no encontré datos de costo para ese run.";
|
|
206
|
+
const head = `zero-cost: ${run.slug ?? "(run reciente)"}`;
|
|
207
|
+
const cols = `fase sub in out cache tools dur costo`;
|
|
208
|
+
const lines = [head, cols];
|
|
209
|
+
const row = (
|
|
210
|
+
label: string, subs: string, inn: number, out: number, cache: number,
|
|
211
|
+
tools: number, dur: number, cost: number,
|
|
212
|
+
): string =>
|
|
213
|
+
`${label.padEnd(10)} ${subs.padStart(3)} ${formatTokens(inn).padStart(6)} ${formatTokens(out).padStart(6)} ${formatTokens(cache).padStart(7)} ${String(tools).padStart(5)} ${formatDuration(dur).padStart(6)} ${formatUsd(cost).padStart(7)}`;
|
|
214
|
+
for (const p of run.phases) {
|
|
215
|
+
lines.push(row(p.phase, String(p.subAgents), p.input, p.output, p.cacheRead, p.toolCount, p.durationMs, p.cost));
|
|
216
|
+
}
|
|
217
|
+
const t = run.total;
|
|
218
|
+
lines.push(row("TOTAL", String(t.subAgents), t.input, t.output, t.cacheRead, t.toolCount, t.durationMs, t.cost));
|
|
219
|
+
return lines.join("\n");
|
|
220
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonrocca/zero-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.57",
|
|
4
4
|
"description": "zero-pi — an installable layer for pi (pi.dev): the zero spec-driven development workflow (explore → plan → build → veredicto) with per-phase model autotune and token-efficient batched builds. Adds capability to pi without modifying pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"./extensions/spec-merge-extension.ts",
|
|
36
36
|
"./extensions/zero-validate-extension.ts",
|
|
37
37
|
"./extensions/zero-status-extension.ts",
|
|
38
|
+
"./extensions/zero-cost-extension.ts",
|
|
38
39
|
"./extensions/zero-pr-extension.ts",
|
|
39
40
|
"./extensions/zero-branch-extension.ts",
|
|
40
41
|
"./extensions/zero-git-validate-extension.ts",
|
|
@@ -66,6 +67,9 @@
|
|
|
66
67
|
"extensions/zero-validate-extension.ts",
|
|
67
68
|
"extensions/zero-status.ts",
|
|
68
69
|
"extensions/zero-status-extension.ts",
|
|
70
|
+
"extensions/zero-cost.ts",
|
|
71
|
+
"extensions/zero-cost-extension.ts",
|
|
72
|
+
"extensions/format-tokens.ts",
|
|
69
73
|
"extensions/pr-body.ts",
|
|
70
74
|
"extensions/sdd-links.ts",
|
|
71
75
|
"extensions/sdd-config.ts",
|