@gonrocca/zero-pi 0.1.12 → 0.1.14
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/LICENSE +21 -21
- package/README.md +296 -258
- package/extensions/autotune-extension.ts +250 -250
- package/extensions/autotune.ts +558 -520
- package/extensions/conversation-resume.ts +400 -399
- package/extensions/provider-guard-extension.ts +195 -195
- package/extensions/provider-guard.ts +183 -183
- package/extensions/spec-merge-extension.ts +286 -286
- package/extensions/spec-merge.ts +373 -373
- package/extensions/startup-banner.ts +237 -237
- package/extensions/win-tree-kill.ts +114 -0
- package/extensions/working-phrases.ts +295 -295
- package/extensions/zero-models.ts +463 -333
- package/package.json +75 -73
- package/prompts/forge.md +34 -34
- package/prompts/orchestrator.md +292 -246
- package/prompts/phases/build.md +20 -20
- package/prompts/phases/explore.md +22 -22
- package/prompts/phases/plan.md +82 -82
- package/prompts/phases/veredicto.md +30 -30
- package/skills/{sdd-routing.md → sdd-routing/SKILL.md} +51 -51
- package/skills/{skill-loop.md → skill-loop/SKILL.md} +29 -29
- package/themes/zero-sdd.json +76 -76
|
@@ -1,250 +1,250 @@
|
|
|
1
|
-
// zero-pi — adaptive model profiles, pi wiring.
|
|
2
|
-
//
|
|
3
|
-
// A thin pi extension that wires the pure decision logic in `autotune.ts` to
|
|
4
|
-
// the `session_start` hook. On every session start it reads the local outcome
|
|
5
|
-
// log (`~/.pi/zero-runs.jsonl`), aggregates it, decides per-phase model
|
|
6
|
-
// adjustments, and — depending on the `autotune` mode stored in
|
|
7
|
-
// `~/.pi/zero.json` — either applies them (`auto`), records them as a pending
|
|
8
|
-
// suggestion (`ask`), or does nothing (`off`).
|
|
9
|
-
//
|
|
10
|
-
// `session_start` is the chosen trigger: it runs deterministic code and
|
|
11
|
-
// applies the freshly-learned profile *before* the next `/forge` run reads
|
|
12
|
-
// `zero.json`. A run's own record only influences the *next* session — the
|
|
13
|
-
// one-run lag the requirements explicitly model ("a change takes effect on the
|
|
14
|
-
// next run").
|
|
15
|
-
//
|
|
16
|
-
// All decisions live in `autotune.ts`; this file only reads files, calls those
|
|
17
|
-
// functions, and applies/notifies. The whole handler is wrapped in a swallowing
|
|
18
|
-
// `try/catch` — exactly like `startup-banner.ts`, a failure must never break a
|
|
19
|
-
// pi session. The package stays dependency-free: `node:fs`/`node:os`/`node:path`
|
|
20
|
-
// only, plus minimal local interfaces for the pi API.
|
|
21
|
-
|
|
22
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
23
|
-
import { homedir } from "node:os";
|
|
24
|
-
import { join } from "node:path";
|
|
25
|
-
|
|
26
|
-
import {
|
|
27
|
-
aggregate,
|
|
28
|
-
decideAdjustments,
|
|
29
|
-
readAutotuneMode,
|
|
30
|
-
readRunRecords,
|
|
31
|
-
type Adjustment,
|
|
32
|
-
} from "./autotune.ts";
|
|
33
|
-
|
|
34
|
-
/** The SDD phases, in pipeline order. Mirrors `zero-models.ts`. */
|
|
35
|
-
const PHASES = ["explore", "plan", "build", "veredicto"] as const;
|
|
36
|
-
type Phase = (typeof PHASES)[number];
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* One pending adjustment recorded under the `autotunePending` key of
|
|
40
|
-
* `~/.pi/zero.json` in `ask` mode. It mirrors `autotune.ts`'s `Adjustment`
|
|
41
|
-
* exactly — a flat `{ phase, from, to, reason }` record — so `/zero-models`
|
|
42
|
-
* (Task 6) can read the key, surface the suggestion, apply it, and clear it.
|
|
43
|
-
*
|
|
44
|
-
* `autotunePending` is the JSON shape `Adjustment[]`: an array of these
|
|
45
|
-
* records. It is never read by the orchestrator.
|
|
46
|
-
*/
|
|
47
|
-
export interface AutotunePending {
|
|
48
|
-
/** SDD phase the suggested change applies to. */
|
|
49
|
-
phase: Phase;
|
|
50
|
-
/** Model the phase currently runs on. */
|
|
51
|
-
from: string;
|
|
52
|
-
/** Model the phase is suggested to move to (always one tier above `from`). */
|
|
53
|
-
to: string;
|
|
54
|
-
/** Human-readable justification, used verbatim when surfacing the suggestion. */
|
|
55
|
-
reason: string;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Absolute path of pi's `zero.json` marker. Mirrors `zero-models.ts`. */
|
|
59
|
-
function zeroJsonPath(): string {
|
|
60
|
-
return join(homedir(), ".pi", "zero.json");
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Absolute path of the append-only run metrics log. */
|
|
64
|
-
function zeroRunsPath(): string {
|
|
65
|
-
return join(homedir(), ".pi", "zero-runs.jsonl");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Whether a value is a non-null, non-array object. */
|
|
69
|
-
function isObject(value: unknown): value is Record<string, unknown> {
|
|
70
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Read `~/.pi/zero.json`.
|
|
75
|
-
*
|
|
76
|
-
* Returns `null` — distinct from an empty object — when the file is absent or
|
|
77
|
-
* unparseable. The autotune flow treats `null` as "no profile to safely tune":
|
|
78
|
-
* it must not synthesize a `models` map (AC 6.4). A present-but-`{}` file is a
|
|
79
|
-
* valid object and is returned as such.
|
|
80
|
-
*/
|
|
81
|
-
function readZeroJson(): Record<string, unknown> | null {
|
|
82
|
-
let parsed: unknown;
|
|
83
|
-
try {
|
|
84
|
-
parsed = JSON.parse(readFileSync(zeroJsonPath(), "utf8"));
|
|
85
|
-
} catch {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
return isObject(parsed) ? parsed : null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Extract the per-phase models actually present in a `zero.json` object. Only
|
|
93
|
-
* string entries for known phases are kept; missing or non-string entries are
|
|
94
|
-
* omitted (no defaults are synthesized — `decideAdjustments` skips a phase with
|
|
95
|
-
* no current model).
|
|
96
|
-
*/
|
|
97
|
-
function readCurrentModels(data: Record<string, unknown>): Partial<Record<Phase, string>> {
|
|
98
|
-
const raw = isObject(data.models) ? data.models : {};
|
|
99
|
-
const models: Partial<Record<Phase, string>> = {};
|
|
100
|
-
for (const phase of PHASES) {
|
|
101
|
-
const value = raw[phase];
|
|
102
|
-
if (typeof value === "string" && value !== "") models[phase] = value;
|
|
103
|
-
}
|
|
104
|
-
return models;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Derive `knownModels` — the distinct, non-empty model ids the autotune logic
|
|
109
|
-
* may step toward. Per the design this is the union of every model seen in the
|
|
110
|
-
* run log plus the current per-phase models, so `stepUp` prefers a model the
|
|
111
|
-
* user already uses anywhere.
|
|
112
|
-
*/
|
|
113
|
-
function deriveKnownModels(
|
|
114
|
-
currentModels: Partial<Record<Phase, string>>,
|
|
115
|
-
loggedModels: Iterable<string>,
|
|
116
|
-
): string[] {
|
|
117
|
-
const known = new Set<string>();
|
|
118
|
-
for (const phase of PHASES) {
|
|
119
|
-
const model = currentModels[phase];
|
|
120
|
-
if (typeof model === "string" && model !== "") known.add(model);
|
|
121
|
-
}
|
|
122
|
-
for (const model of loggedModels) {
|
|
123
|
-
if (typeof model === "string" && model !== "") known.add(model);
|
|
124
|
-
}
|
|
125
|
-
return [...known];
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** The slice of pi's UI surface the autotune extension uses. */
|
|
129
|
-
interface PiUI {
|
|
130
|
-
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
131
|
-
}
|
|
132
|
-
interface PiSessionContext {
|
|
133
|
-
ui: PiUI;
|
|
134
|
-
}
|
|
135
|
-
/** The slice of pi's extension API the autotune extension uses. */
|
|
136
|
-
interface PiExtensionAPI {
|
|
137
|
-
on(event: string, handler: (ctx: PiSessionContext) => void): void;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* The `session_start` evaluate-and-tune handler.
|
|
142
|
-
*
|
|
143
|
-
* Runs entirely inside `register`'s swallowing `try/catch`, but is also written
|
|
144
|
-
* to never throw on the expected paths: a missing log, an absent `zero.json`,
|
|
145
|
-
* and an `off` mode all return cleanly.
|
|
146
|
-
*/
|
|
147
|
-
function evaluateAndTune(ctx: PiSessionContext): void {
|
|
148
|
-
const notify = (message: string, type?: "info" | "warning" | "error"): void => {
|
|
149
|
-
try {
|
|
150
|
-
ctx.ui.notify(message, type);
|
|
151
|
-
} catch {
|
|
152
|
-
// A notification failure must not break the session either.
|
|
153
|
-
}
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
const data = readZeroJson();
|
|
157
|
-
if (data === null) {
|
|
158
|
-
// Absent or unparseable `zero.json` — no profile to safely tune. Skip with
|
|
159
|
-
// a single non-blocking warning; never synthesize a `models` map (AC 6.4).
|
|
160
|
-
notify("zero autotune: ~/.pi/zero.json missing or unreadable — skipping", "warning");
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const mode = readAutotuneMode(data);
|
|
165
|
-
if (mode === "off") return; // metrics still captured by the orchestrator; nothing changes here.
|
|
166
|
-
|
|
167
|
-
const currentModels = readCurrentModels(data);
|
|
168
|
-
|
|
169
|
-
const records = readRunRecords(zeroRunsPath());
|
|
170
|
-
const loggedModels: string[] = [];
|
|
171
|
-
for (const record of records) {
|
|
172
|
-
for (const phase of PHASES) {
|
|
173
|
-
const model = record.phases[phase]?.model;
|
|
174
|
-
if (typeof model === "string" && model !== "") loggedModels.push(model);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
const knownModels = deriveKnownModels(currentModels, loggedModels);
|
|
178
|
-
|
|
179
|
-
const stats = aggregate(records);
|
|
180
|
-
const adjustments: Adjustment[] = decideAdjustments(stats, currentModels, knownModels);
|
|
181
|
-
if (adjustments.length === 0) return; // nothing to do — return silently.
|
|
182
|
-
|
|
183
|
-
if (mode === "auto") {
|
|
184
|
-
// Apply every adjustment, write `zero.json` ONCE preserving all other keys,
|
|
185
|
-
// then emit one notification per change (AC 5.4, 6.1 — never silent).
|
|
186
|
-
const models: Record<string, unknown> = isObject(data.models) ? { ...data.models } : {};
|
|
187
|
-
for (const adj of adjustments) {
|
|
188
|
-
models[adj.phase] = adj.to;
|
|
189
|
-
}
|
|
190
|
-
writeFileSync(
|
|
191
|
-
zeroJsonPath(),
|
|
192
|
-
`${JSON.stringify({ ...data, models }, null, 2)}\n`,
|
|
193
|
-
"utf8",
|
|
194
|
-
);
|
|
195
|
-
for (const adj of adjustments) {
|
|
196
|
-
notify(`zero autotune: ${adj.phase} ${adj.from} → ${adj.to} (${adj.reason})`, "info");
|
|
197
|
-
}
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// mode === "ask" — record the recommendations under `autotunePending` without
|
|
202
|
-
// touching `models`, and tell the user to run /zero-models to apply.
|
|
203
|
-
const pending: AutotunePending[] = adjustments.map((adj) => ({
|
|
204
|
-
phase: adj.phase,
|
|
205
|
-
from: adj.from,
|
|
206
|
-
to: adj.to,
|
|
207
|
-
reason: adj.reason,
|
|
208
|
-
}));
|
|
209
|
-
writeFileSync(
|
|
210
|
-
zeroJsonPath(),
|
|
211
|
-
`${JSON.stringify({ ...data, autotunePending: pending }, null, 2)}\n`,
|
|
212
|
-
"utf8",
|
|
213
|
-
);
|
|
214
|
-
for (const adj of pending) {
|
|
215
|
-
notify(
|
|
216
|
-
`zero autotune suggests: ${adj.phase} → ${adj.to} — run /zero-models to apply`,
|
|
217
|
-
"info",
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* The pi extension entry point.
|
|
224
|
-
*
|
|
225
|
-
* pi calls this once when the extension loads. It wires the evaluate-and-tune
|
|
226
|
-
* handler to the `session_start` event. The whole body is wrapped in a
|
|
227
|
-
* swallowing `try/catch`, and the handler itself is wrapped again, so neither
|
|
228
|
-
* registration nor a later failure can ever break a pi session. Called with no
|
|
229
|
-
* or an invalid `pi`, it no-ops cleanly.
|
|
230
|
-
*/
|
|
231
|
-
export default function register(pi?: unknown): void {
|
|
232
|
-
try {
|
|
233
|
-
if (
|
|
234
|
-
!pi ||
|
|
235
|
-
typeof (pi as PiExtensionAPI).on !== "function"
|
|
236
|
-
) {
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
(pi as PiExtensionAPI).on("session_start", (ctx: PiSessionContext): void => {
|
|
240
|
-
try {
|
|
241
|
-
if (!ctx || !ctx.ui || typeof ctx.ui.notify !== "function") return;
|
|
242
|
-
evaluateAndTune(ctx);
|
|
243
|
-
} catch {
|
|
244
|
-
// An evaluate-and-tune failure must never break a pi session.
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
} catch {
|
|
248
|
-
// Registration itself must never break a pi session.
|
|
249
|
-
}
|
|
250
|
-
}
|
|
1
|
+
// zero-pi — adaptive model profiles, pi wiring.
|
|
2
|
+
//
|
|
3
|
+
// A thin pi extension that wires the pure decision logic in `autotune.ts` to
|
|
4
|
+
// the `session_start` hook. On every session start it reads the local outcome
|
|
5
|
+
// log (`~/.pi/zero-runs.jsonl`), aggregates it, decides per-phase model
|
|
6
|
+
// adjustments, and — depending on the `autotune` mode stored in
|
|
7
|
+
// `~/.pi/zero.json` — either applies them (`auto`), records them as a pending
|
|
8
|
+
// suggestion (`ask`), or does nothing (`off`).
|
|
9
|
+
//
|
|
10
|
+
// `session_start` is the chosen trigger: it runs deterministic code and
|
|
11
|
+
// applies the freshly-learned profile *before* the next `/forge` run reads
|
|
12
|
+
// `zero.json`. A run's own record only influences the *next* session — the
|
|
13
|
+
// one-run lag the requirements explicitly model ("a change takes effect on the
|
|
14
|
+
// next run").
|
|
15
|
+
//
|
|
16
|
+
// All decisions live in `autotune.ts`; this file only reads files, calls those
|
|
17
|
+
// functions, and applies/notifies. The whole handler is wrapped in a swallowing
|
|
18
|
+
// `try/catch` — exactly like `startup-banner.ts`, a failure must never break a
|
|
19
|
+
// pi session. The package stays dependency-free: `node:fs`/`node:os`/`node:path`
|
|
20
|
+
// only, plus minimal local interfaces for the pi API.
|
|
21
|
+
|
|
22
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
aggregate,
|
|
28
|
+
decideAdjustments,
|
|
29
|
+
readAutotuneMode,
|
|
30
|
+
readRunRecords,
|
|
31
|
+
type Adjustment,
|
|
32
|
+
} from "./autotune.ts";
|
|
33
|
+
|
|
34
|
+
/** The SDD phases, in pipeline order. Mirrors `zero-models.ts`. */
|
|
35
|
+
const PHASES = ["explore", "plan", "build", "veredicto"] as const;
|
|
36
|
+
type Phase = (typeof PHASES)[number];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One pending adjustment recorded under the `autotunePending` key of
|
|
40
|
+
* `~/.pi/zero.json` in `ask` mode. It mirrors `autotune.ts`'s `Adjustment`
|
|
41
|
+
* exactly — a flat `{ phase, from, to, reason }` record — so `/zero-models`
|
|
42
|
+
* (Task 6) can read the key, surface the suggestion, apply it, and clear it.
|
|
43
|
+
*
|
|
44
|
+
* `autotunePending` is the JSON shape `Adjustment[]`: an array of these
|
|
45
|
+
* records. It is never read by the orchestrator.
|
|
46
|
+
*/
|
|
47
|
+
export interface AutotunePending {
|
|
48
|
+
/** SDD phase the suggested change applies to. */
|
|
49
|
+
phase: Phase;
|
|
50
|
+
/** Model the phase currently runs on. */
|
|
51
|
+
from: string;
|
|
52
|
+
/** Model the phase is suggested to move to (always one tier above `from`). */
|
|
53
|
+
to: string;
|
|
54
|
+
/** Human-readable justification, used verbatim when surfacing the suggestion. */
|
|
55
|
+
reason: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Absolute path of pi's `zero.json` marker. Mirrors `zero-models.ts`. */
|
|
59
|
+
function zeroJsonPath(): string {
|
|
60
|
+
return join(homedir(), ".pi", "zero.json");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Absolute path of the append-only run metrics log. */
|
|
64
|
+
function zeroRunsPath(): string {
|
|
65
|
+
return join(homedir(), ".pi", "zero-runs.jsonl");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether a value is a non-null, non-array object. */
|
|
69
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
70
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read `~/.pi/zero.json`.
|
|
75
|
+
*
|
|
76
|
+
* Returns `null` — distinct from an empty object — when the file is absent or
|
|
77
|
+
* unparseable. The autotune flow treats `null` as "no profile to safely tune":
|
|
78
|
+
* it must not synthesize a `models` map (AC 6.4). A present-but-`{}` file is a
|
|
79
|
+
* valid object and is returned as such.
|
|
80
|
+
*/
|
|
81
|
+
function readZeroJson(): Record<string, unknown> | null {
|
|
82
|
+
let parsed: unknown;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(readFileSync(zeroJsonPath(), "utf8"));
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return isObject(parsed) ? parsed : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extract the per-phase models actually present in a `zero.json` object. Only
|
|
93
|
+
* string entries for known phases are kept; missing or non-string entries are
|
|
94
|
+
* omitted (no defaults are synthesized — `decideAdjustments` skips a phase with
|
|
95
|
+
* no current model).
|
|
96
|
+
*/
|
|
97
|
+
function readCurrentModels(data: Record<string, unknown>): Partial<Record<Phase, string>> {
|
|
98
|
+
const raw = isObject(data.models) ? data.models : {};
|
|
99
|
+
const models: Partial<Record<Phase, string>> = {};
|
|
100
|
+
for (const phase of PHASES) {
|
|
101
|
+
const value = raw[phase];
|
|
102
|
+
if (typeof value === "string" && value !== "") models[phase] = value;
|
|
103
|
+
}
|
|
104
|
+
return models;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Derive `knownModels` — the distinct, non-empty model ids the autotune logic
|
|
109
|
+
* may step toward. Per the design this is the union of every model seen in the
|
|
110
|
+
* run log plus the current per-phase models, so `stepUp` prefers a model the
|
|
111
|
+
* user already uses anywhere.
|
|
112
|
+
*/
|
|
113
|
+
function deriveKnownModels(
|
|
114
|
+
currentModels: Partial<Record<Phase, string>>,
|
|
115
|
+
loggedModels: Iterable<string>,
|
|
116
|
+
): string[] {
|
|
117
|
+
const known = new Set<string>();
|
|
118
|
+
for (const phase of PHASES) {
|
|
119
|
+
const model = currentModels[phase];
|
|
120
|
+
if (typeof model === "string" && model !== "") known.add(model);
|
|
121
|
+
}
|
|
122
|
+
for (const model of loggedModels) {
|
|
123
|
+
if (typeof model === "string" && model !== "") known.add(model);
|
|
124
|
+
}
|
|
125
|
+
return [...known];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The slice of pi's UI surface the autotune extension uses. */
|
|
129
|
+
interface PiUI {
|
|
130
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
131
|
+
}
|
|
132
|
+
interface PiSessionContext {
|
|
133
|
+
ui: PiUI;
|
|
134
|
+
}
|
|
135
|
+
/** The slice of pi's extension API the autotune extension uses. */
|
|
136
|
+
interface PiExtensionAPI {
|
|
137
|
+
on(event: string, handler: (ctx: PiSessionContext) => void): void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The `session_start` evaluate-and-tune handler.
|
|
142
|
+
*
|
|
143
|
+
* Runs entirely inside `register`'s swallowing `try/catch`, but is also written
|
|
144
|
+
* to never throw on the expected paths: a missing log, an absent `zero.json`,
|
|
145
|
+
* and an `off` mode all return cleanly.
|
|
146
|
+
*/
|
|
147
|
+
function evaluateAndTune(ctx: PiSessionContext): void {
|
|
148
|
+
const notify = (message: string, type?: "info" | "warning" | "error"): void => {
|
|
149
|
+
try {
|
|
150
|
+
ctx.ui.notify(message, type);
|
|
151
|
+
} catch {
|
|
152
|
+
// A notification failure must not break the session either.
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const data = readZeroJson();
|
|
157
|
+
if (data === null) {
|
|
158
|
+
// Absent or unparseable `zero.json` — no profile to safely tune. Skip with
|
|
159
|
+
// a single non-blocking warning; never synthesize a `models` map (AC 6.4).
|
|
160
|
+
notify("zero autotune: ~/.pi/zero.json missing or unreadable — skipping", "warning");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const mode = readAutotuneMode(data);
|
|
165
|
+
if (mode === "off") return; // metrics still captured by the orchestrator; nothing changes here.
|
|
166
|
+
|
|
167
|
+
const currentModels = readCurrentModels(data);
|
|
168
|
+
|
|
169
|
+
const records = readRunRecords(zeroRunsPath());
|
|
170
|
+
const loggedModels: string[] = [];
|
|
171
|
+
for (const record of records) {
|
|
172
|
+
for (const phase of PHASES) {
|
|
173
|
+
const model = record.phases[phase]?.model;
|
|
174
|
+
if (typeof model === "string" && model !== "") loggedModels.push(model);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const knownModels = deriveKnownModels(currentModels, loggedModels);
|
|
178
|
+
|
|
179
|
+
const stats = aggregate(records);
|
|
180
|
+
const adjustments: Adjustment[] = decideAdjustments(stats, currentModels, knownModels);
|
|
181
|
+
if (adjustments.length === 0) return; // nothing to do — return silently.
|
|
182
|
+
|
|
183
|
+
if (mode === "auto") {
|
|
184
|
+
// Apply every adjustment, write `zero.json` ONCE preserving all other keys,
|
|
185
|
+
// then emit one notification per change (AC 5.4, 6.1 — never silent).
|
|
186
|
+
const models: Record<string, unknown> = isObject(data.models) ? { ...data.models } : {};
|
|
187
|
+
for (const adj of adjustments) {
|
|
188
|
+
models[adj.phase] = adj.to;
|
|
189
|
+
}
|
|
190
|
+
writeFileSync(
|
|
191
|
+
zeroJsonPath(),
|
|
192
|
+
`${JSON.stringify({ ...data, models }, null, 2)}\n`,
|
|
193
|
+
"utf8",
|
|
194
|
+
);
|
|
195
|
+
for (const adj of adjustments) {
|
|
196
|
+
notify(`zero autotune: ${adj.phase} ${adj.from} → ${adj.to} (${adj.reason})`, "info");
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// mode === "ask" — record the recommendations under `autotunePending` without
|
|
202
|
+
// touching `models`, and tell the user to run /zero-models to apply.
|
|
203
|
+
const pending: AutotunePending[] = adjustments.map((adj) => ({
|
|
204
|
+
phase: adj.phase,
|
|
205
|
+
from: adj.from,
|
|
206
|
+
to: adj.to,
|
|
207
|
+
reason: adj.reason,
|
|
208
|
+
}));
|
|
209
|
+
writeFileSync(
|
|
210
|
+
zeroJsonPath(),
|
|
211
|
+
`${JSON.stringify({ ...data, autotunePending: pending }, null, 2)}\n`,
|
|
212
|
+
"utf8",
|
|
213
|
+
);
|
|
214
|
+
for (const adj of pending) {
|
|
215
|
+
notify(
|
|
216
|
+
`zero autotune suggests: ${adj.phase} → ${adj.to} — run /zero-models to apply`,
|
|
217
|
+
"info",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The pi extension entry point.
|
|
224
|
+
*
|
|
225
|
+
* pi calls this once when the extension loads. It wires the evaluate-and-tune
|
|
226
|
+
* handler to the `session_start` event. The whole body is wrapped in a
|
|
227
|
+
* swallowing `try/catch`, and the handler itself is wrapped again, so neither
|
|
228
|
+
* registration nor a later failure can ever break a pi session. Called with no
|
|
229
|
+
* or an invalid `pi`, it no-ops cleanly.
|
|
230
|
+
*/
|
|
231
|
+
export default function register(pi?: unknown): void {
|
|
232
|
+
try {
|
|
233
|
+
if (
|
|
234
|
+
!pi ||
|
|
235
|
+
typeof (pi as PiExtensionAPI).on !== "function"
|
|
236
|
+
) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
(pi as PiExtensionAPI).on("session_start", (ctx: PiSessionContext): void => {
|
|
240
|
+
try {
|
|
241
|
+
if (!ctx || !ctx.ui || typeof ctx.ui.notify !== "function") return;
|
|
242
|
+
evaluateAndTune(ctx);
|
|
243
|
+
} catch {
|
|
244
|
+
// An evaluate-and-tune failure must never break a pi session.
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
} catch {
|
|
248
|
+
// Registration itself must never break a pi session.
|
|
249
|
+
}
|
|
250
|
+
}
|