@yagni-app/code-staging 0.2.1-staging.1027.1 → 0.2.1-staging.1032.1
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/dist/cli.js +1 -1
- package/dist/extension/advisor.d.ts +4 -4
- package/dist/extension/advisor.js +6 -7
- package/dist/extension/askAdvisorTool.d.ts +2 -2
- package/dist/extension/askAdvisorTool.js +5 -5
- package/dist/extension/config.js +0 -2
- package/dist/extension/costHud.d.ts +16 -22
- package/dist/extension/costHud.js +8 -47
- package/dist/extension/footer.d.ts +100 -0
- package/dist/extension/footer.js +272 -0
- package/dist/extension/index.js +17 -16
- package/dist/launch.js +5 -12
- package/package.json +2 -2
- package/dist/extension/boostCommand.d.ts +0 -144
- package/dist/extension/boostCommand.js +0 -263
package/dist/cli.js
CHANGED
|
@@ -161,7 +161,7 @@ export const HELP_TEXT = [
|
|
|
161
161
|
" yagni version Print the CLI version.",
|
|
162
162
|
"",
|
|
163
163
|
"Common agent flags (passed straight through):",
|
|
164
|
-
" --model <tier>
|
|
164
|
+
" --model <tier> Model tier (fixed to advanced).",
|
|
165
165
|
" --thinking <level> off | minimal | low | medium | high | xhigh | max",
|
|
166
166
|
" --session <id> Open a specific session; --fork <id> branches one.",
|
|
167
167
|
" --mode json Emit machine-readable events (for scripts and CI).",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* State is held in a closure via {@link makeAdvisorState}; no module-level
|
|
23
23
|
* mutable state, so two sessions in one process cannot bleed into each other.
|
|
24
24
|
*/
|
|
25
|
-
/** The session tier that may escalate.
|
|
26
|
-
export declare const ADVISOR_TIER = "
|
|
25
|
+
/** The session tier that may escalate. Advanced only, by design. */
|
|
26
|
+
export declare const ADVISOR_TIER = "advanced";
|
|
27
27
|
/** The tier a consult itself runs on. */
|
|
28
28
|
export declare const ADVISOR_MODEL_TIER = "peak";
|
|
29
29
|
/** Bounds on escalation within a single session. */
|
|
@@ -64,8 +64,8 @@ export interface ConsultGateInput {
|
|
|
64
64
|
* The tier check reads the model at CALL time rather than at registration time:
|
|
65
65
|
* tools register once at activation but pi's picker can switch the session model
|
|
66
66
|
* afterwards, so a registration-time check would both leave the tool live after
|
|
67
|
-
* switching away from
|
|
68
|
-
* to
|
|
67
|
+
* switching away from Advanced and hide it forever from a session that switched
|
|
68
|
+
* to Advanced.
|
|
69
69
|
*/
|
|
70
70
|
export declare function decideConsult(input: ConsultGateInput): ConsultDecision;
|
|
71
71
|
/**
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* State is held in a closure via {@link makeAdvisorState}; no module-level
|
|
23
23
|
* mutable state, so two sessions in one process cannot bleed into each other.
|
|
24
24
|
*/
|
|
25
|
-
/** The session tier that may escalate.
|
|
26
|
-
export const ADVISOR_TIER = "
|
|
25
|
+
/** The session tier that may escalate. Advanced only, by design. */
|
|
26
|
+
export const ADVISOR_TIER = "advanced";
|
|
27
27
|
/** The tier a consult itself runs on. */
|
|
28
28
|
export const ADVISOR_MODEL_TIER = "peak";
|
|
29
29
|
export const DEFAULT_ADVISOR_LIMITS = {
|
|
@@ -49,17 +49,16 @@ export function makeAdvisorState() {
|
|
|
49
49
|
* The tier check reads the model at CALL time rather than at registration time:
|
|
50
50
|
* tools register once at activation but pi's picker can switch the session model
|
|
51
51
|
* afterwards, so a registration-time check would both leave the tool live after
|
|
52
|
-
* switching away from
|
|
53
|
-
* to
|
|
52
|
+
* switching away from Advanced and hide it forever from a session that switched
|
|
53
|
+
* to Advanced.
|
|
54
54
|
*/
|
|
55
55
|
export function decideConsult(input) {
|
|
56
56
|
const { model, state, limits } = input;
|
|
57
57
|
if (model !== ADVISOR_TIER) {
|
|
58
58
|
return {
|
|
59
59
|
allow: false,
|
|
60
|
-
reason: `ask_advisor is only available on the
|
|
61
|
-
`"${model ?? "unknown"}")
|
|
62
|
-
`peak-tier advisor, or reason it through on the current tier.`,
|
|
60
|
+
reason: `ask_advisor is only available on the Advanced tier (this session is on ` +
|
|
61
|
+
`"${model ?? "unknown"}").`,
|
|
63
62
|
};
|
|
64
63
|
}
|
|
65
64
|
if (state.consults >= limits.maxConsults) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `ask_advisor` — the peak-tier escalation available to
|
|
2
|
+
* `ask_advisor` — the peak-tier escalation available to Advanced sessions (YAG-380).
|
|
3
3
|
*
|
|
4
|
-
* Cheap driver, expensive consultant:
|
|
4
|
+
* Cheap driver, expensive consultant: an Advanced session drives on `advanced` and
|
|
5
5
|
* calls this when it hits a judgment call worth the strongest model. The consult
|
|
6
6
|
* spawns a child pi at `peak` through the same `runStage` seam `/go` uses, and
|
|
7
7
|
* returns the advice as plain TEXT the driver acts on — text we own, so
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `ask_advisor` — the peak-tier escalation available to
|
|
2
|
+
* `ask_advisor` — the peak-tier escalation available to Advanced sessions (YAG-380).
|
|
3
3
|
*
|
|
4
|
-
* Cheap driver, expensive consultant:
|
|
4
|
+
* Cheap driver, expensive consultant: an Advanced session drives on `advanced` and
|
|
5
5
|
* calls this when it hits a judgment call worth the strongest model. The consult
|
|
6
6
|
* spawns a child pi at `peak` through the same `runStage` seam `/go` uses, and
|
|
7
7
|
* returns the advice as plain TEXT the driver acts on — text we own, so
|
|
@@ -79,7 +79,7 @@ export function makeAskAdvisorTool(opts) {
|
|
|
79
79
|
label: "Ask the advisor",
|
|
80
80
|
description: "Escalate ONE hard judgment call to the peak-tier advisor — the strongest " +
|
|
81
81
|
"model available, which reads the code itself and returns a recommendation. " +
|
|
82
|
-
"Available only on the
|
|
82
|
+
"Available only on the Advanced tier, and capped per session, so use it for " +
|
|
83
83
|
"calls that are genuinely worth it: an architectural fork with no obvious " +
|
|
84
84
|
"right answer, a subtle correctness question you cannot settle by reading, " +
|
|
85
85
|
"a change whose blast radius you are unsure of, or a second opinion before " +
|
|
@@ -87,7 +87,7 @@ export function makeAskAdvisorTool(opts) {
|
|
|
87
87
|
"lookups (use ask_yagni), for anything you can settle by reading the code, " +
|
|
88
88
|
"or to review work you have already finished. Pass a sharp question plus the " +
|
|
89
89
|
"relevant excerpts — never a conversation transcript.",
|
|
90
|
-
promptSnippet: "ask_advisor: escalate one hard judgment call to the peak-tier advisor (
|
|
90
|
+
promptSnippet: "ask_advisor: escalate one hard judgment call to the peak-tier advisor (Advanced sessions, capped).",
|
|
91
91
|
promptGuidelines: [
|
|
92
92
|
"Call ask_advisor only for a genuine judgment fork — an architectural choice, a subtle correctness question, or a second opinion before an approach you would have to unwind. Reading the code is cheaper; do that first.",
|
|
93
93
|
"Ask ONE specific question per consult, and include the excerpts that matter. The advisor reads the repo itself, so point it at the right place rather than pasting everything.",
|
|
@@ -159,7 +159,7 @@ export function makeAskAdvisorTool(opts) {
|
|
|
159
159
|
*/
|
|
160
160
|
export function registerAdviseCommand(pi, tool) {
|
|
161
161
|
pi.registerCommand("advise", {
|
|
162
|
-
description: "Escalate one hard call to the peak-tier advisor (
|
|
162
|
+
description: "Escalate one hard call to the peak-tier advisor (Advanced sessions only, capped per session).",
|
|
163
163
|
handler: async (args, ctx) => {
|
|
164
164
|
const notify = (message, type) => {
|
|
165
165
|
if (ctx.hasUI)
|
package/dist/extension/config.js
CHANGED
|
@@ -129,8 +129,6 @@ export function attributionHeaders(env = process.env) {
|
|
|
129
129
|
headers["x-yagni-run-id"] = runId;
|
|
130
130
|
const caller = env.YAGNI_CALLER ?? "";
|
|
131
131
|
headers["x-yagni-caller"] = CALLER_LABEL_RE.test(caller) ? caller : "driver";
|
|
132
|
-
if (env.YAGNI_BOOST === "1")
|
|
133
|
-
headers["x-yagni-boost"] = "1";
|
|
134
132
|
return headers;
|
|
135
133
|
}
|
|
136
134
|
/**
|
|
@@ -52,7 +52,20 @@ export interface Headroom {
|
|
|
52
52
|
remaining: number;
|
|
53
53
|
unit: string;
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Render the /cost line. Pure, no em-dashes. `headroom` null means it was
|
|
57
|
+
* unavailable. `advisorLine` is the ask_advisor subtotal (empty when the session
|
|
58
|
+
* never escalated). `source`, when given, is parenthesized right after "Session
|
|
59
|
+
* usage", used to explicitly label this as the LOCAL, driver-only fallback when
|
|
60
|
+
* the server-authoritative spend fetch is unavailable (see
|
|
61
|
+
* {@link formatServerCostLines}, the preferred path).
|
|
62
|
+
*
|
|
63
|
+
* HONESTY NOTE: this counter can only see the DRIVER session, because it is fed
|
|
64
|
+
* by pi's `turn_end`. Work that runs in a child process — every /go stage, and
|
|
65
|
+
* every advisor consult — never emits a parent `turn_end`, so it is invisible
|
|
66
|
+
* here. The advisor subtotal is threaded in explicitly for exactly that reason.
|
|
67
|
+
*/
|
|
68
|
+
export declare function formatCostLine(snap: CostSnapshot, headroom?: Headroom | null, advisorLine?: string, source?: string): string;
|
|
56
69
|
/**
|
|
57
70
|
* One row of `GET /api/yagni-code/spend`'s per-caller x rate-tier breakdown.
|
|
58
71
|
* Mirrored locally rather than importing `@yagni/shared`, since this extension
|
|
@@ -102,25 +115,15 @@ export declare const usd: (millicents: number) => string;
|
|
|
102
115
|
* (joined with "\n").
|
|
103
116
|
*
|
|
104
117
|
* Line order: total spend, one line per rate tier (aggregated across callers,
|
|
105
|
-
* sorted by spend descending),
|
|
106
|
-
* boosted rows, a live-toggle boost line (see below), the savings line (only
|
|
118
|
+
* sorted by spend descending), the savings line (only
|
|
107
119
|
* when a counterfactual total resolved), an incomplete-counterfactual note,
|
|
108
120
|
* an unbilled note, a dropped-run-ids note (carry-over from the /cost
|
|
109
121
|
* re-review — see sessionRuns.ts's `droppedSessionRuns`), then headroom.
|
|
110
122
|
*
|
|
111
123
|
* `droppedRunCount` defaults to 0 (no note) so every existing direct caller
|
|
112
124
|
* of this pure function keeps behaving identically without passing it.
|
|
113
|
-
*
|
|
114
|
-
* `boosted` (default false) is the LIVE client-side toggle from
|
|
115
|
-
* `boostCommand.ts`'s `isBoosted()`, threaded in exactly like `advisorLine`
|
|
116
|
-
* is for {@link formatCostLine}. It is independent of the per-tier "Boosted
|
|
117
|
-
* (tier) spend" subtotal above: that subtotal only reflects server rows
|
|
118
|
-
* carrying `x-yagni-boost`, which only `/go` children (and other spawned
|
|
119
|
-
* processes) ever send — the driver's own turns never do (see
|
|
120
|
-
* boostCommand.ts's KNOWN-asymmetry docblock). Without this flag, a session
|
|
121
|
-
* that only chats while boosted would show no boost line at all.
|
|
122
125
|
*/
|
|
123
|
-
export declare function formatServerCostLines(spend: SpendResponse, headroom?: Headroom | null, droppedRunCount?: number
|
|
126
|
+
export declare function formatServerCostLines(spend: SpendResponse, headroom?: Headroom | null, droppedRunCount?: number): string;
|
|
124
127
|
export interface RegisterCostDeps {
|
|
125
128
|
/** Fetch remaining credit headroom; return null when unavailable. Fail-soft. */
|
|
126
129
|
fetchHeadroom?: (signal?: AbortSignal) => Promise<Headroom | null>;
|
|
@@ -132,15 +135,6 @@ export interface RegisterCostDeps {
|
|
|
132
135
|
* ordinary caller row.
|
|
133
136
|
*/
|
|
134
137
|
advisorSubtotal?: () => string;
|
|
135
|
-
/**
|
|
136
|
-
* Whether the session is currently boosted to Peak (boostCommand.ts's
|
|
137
|
-
* `isBoosted()`, threaded in the same way as `advisorSubtotal`). Unlike
|
|
138
|
-
* `advisorSubtotal`, this is read on BOTH the server-authoritative and the
|
|
139
|
-
* local-fallback branch: the driver's own turns never carry a server-side
|
|
140
|
-
* boost marker (see boostCommand.ts's KNOWN-asymmetry docblock), so this is
|
|
141
|
-
* the only signal that would otherwise be missing from the server branch.
|
|
142
|
-
*/
|
|
143
|
-
isBoosted?: () => boolean;
|
|
144
138
|
/**
|
|
145
139
|
* Fetch the server-authoritative session spend (YAG-383). Absent, throwing,
|
|
146
140
|
* or resolving null all fall back to the local `turn_end` accumulator, with
|
|
@@ -75,21 +75,18 @@ const fmt = (n) => n.toLocaleString("en-US");
|
|
|
75
75
|
* every advisor consult — never emits a parent `turn_end`, so it is invisible
|
|
76
76
|
* here. The advisor subtotal is threaded in explicitly for exactly that reason.
|
|
77
77
|
*/
|
|
78
|
-
|
|
79
|
-
const BOOST_LINE = "Boost is on. Driver turns bill at the peak tier.";
|
|
80
|
-
export function formatCostLine(snap, headroom, advisorLine, source, boosted = false) {
|
|
78
|
+
export function formatCostLine(snap, headroom, advisorLine, source) {
|
|
81
79
|
const turns = `${snap.turns} turn${snap.turns === 1 ? "" : "s"}`;
|
|
82
80
|
const cached = snap.cacheRead > 0 ? ` (${fmt(snap.cacheRead)} cached)` : "";
|
|
83
81
|
const label = source ? `Session usage (${source})` : "Session usage";
|
|
84
82
|
const base = `${label}: ${turns}, ${fmt(snap.input)} in / ${fmt(snap.output)} out tokens${cached}, ` +
|
|
85
83
|
`$${snap.cost.toFixed(2)} this session.`;
|
|
86
84
|
const advisor = advisorLine?.trim() ? ` ${advisorLine.trim()}` : "";
|
|
87
|
-
const boost = boosted ? ` ${BOOST_LINE}` : "";
|
|
88
85
|
if (headroom)
|
|
89
|
-
return `${base}${advisor}
|
|
86
|
+
return `${base}${advisor} Credit headroom: ${headroom.remaining} ${headroom.unit}.`;
|
|
90
87
|
if (headroom === null)
|
|
91
|
-
return `${base}${advisor}
|
|
92
|
-
return `${base}${advisor}
|
|
88
|
+
return `${base}${advisor} Credit headroom unavailable right now.`;
|
|
89
|
+
return `${base}${advisor}`;
|
|
93
90
|
}
|
|
94
91
|
/**
|
|
95
92
|
* Millicents (1/1000 of a cent) -> dollars, 2 decimals. Guards against a
|
|
@@ -115,25 +112,15 @@ export const usd = (millicents) => {
|
|
|
115
112
|
* (joined with "\n").
|
|
116
113
|
*
|
|
117
114
|
* Line order: total spend, one line per rate tier (aggregated across callers,
|
|
118
|
-
* sorted by spend descending),
|
|
119
|
-
* boosted rows, a live-toggle boost line (see below), the savings line (only
|
|
115
|
+
* sorted by spend descending), the savings line (only
|
|
120
116
|
* when a counterfactual total resolved), an incomplete-counterfactual note,
|
|
121
117
|
* an unbilled note, a dropped-run-ids note (carry-over from the /cost
|
|
122
118
|
* re-review — see sessionRuns.ts's `droppedSessionRuns`), then headroom.
|
|
123
119
|
*
|
|
124
120
|
* `droppedRunCount` defaults to 0 (no note) so every existing direct caller
|
|
125
121
|
* of this pure function keeps behaving identically without passing it.
|
|
126
|
-
*
|
|
127
|
-
* `boosted` (default false) is the LIVE client-side toggle from
|
|
128
|
-
* `boostCommand.ts`'s `isBoosted()`, threaded in exactly like `advisorLine`
|
|
129
|
-
* is for {@link formatCostLine}. It is independent of the per-tier "Boosted
|
|
130
|
-
* (tier) spend" subtotal above: that subtotal only reflects server rows
|
|
131
|
-
* carrying `x-yagni-boost`, which only `/go` children (and other spawned
|
|
132
|
-
* processes) ever send — the driver's own turns never do (see
|
|
133
|
-
* boostCommand.ts's KNOWN-asymmetry docblock). Without this flag, a session
|
|
134
|
-
* that only chats while boosted would show no boost line at all.
|
|
135
122
|
*/
|
|
136
|
-
export function formatServerCostLines(spend, headroom, droppedRunCount = 0
|
|
123
|
+
export function formatServerCostLines(spend, headroom, droppedRunCount = 0) {
|
|
137
124
|
const lines = [`Session spend: $${usd(spend.totalSellMillicents)} (server).`];
|
|
138
125
|
const tierTotals = new Map();
|
|
139
126
|
for (const row of spend.rows) {
|
|
@@ -147,23 +134,6 @@ export function formatServerCostLines(spend, headroom, droppedRunCount = 0, boos
|
|
|
147
134
|
const calls = `${fmt(agg.dispatches)} call${agg.dispatches === 1 ? "" : "s"}`;
|
|
148
135
|
lines.push(` ${tier}: $${usd(agg.sellMillicents)} over ${calls}.`);
|
|
149
136
|
}
|
|
150
|
-
const boostTotals = new Map();
|
|
151
|
-
for (const row of spend.rows) {
|
|
152
|
-
if (!row.boost)
|
|
153
|
-
continue;
|
|
154
|
-
boostTotals.set(row.rateTier, (boostTotals.get(row.rateTier) ?? 0) + row.sellMillicents);
|
|
155
|
-
}
|
|
156
|
-
if (boostTotals.size > 0) {
|
|
157
|
-
for (const [tier] of tiersSorted) {
|
|
158
|
-
const tierBoosted = boostTotals.get(tier);
|
|
159
|
-
if (tierBoosted === undefined)
|
|
160
|
-
continue;
|
|
161
|
-
lines.push(` Boosted (${tier}) spend: $${usd(tierBoosted)}.`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
if (boosted) {
|
|
165
|
-
lines.push(BOOST_LINE);
|
|
166
|
-
}
|
|
167
137
|
// typeof guard (not just !== null): a network response is untyped at
|
|
168
138
|
// runtime, and a stray string/boolean here must never sneak "NN%" into the
|
|
169
139
|
// rendered line.
|
|
@@ -233,15 +203,6 @@ export function registerCostCommand(pi, deps = {}) {
|
|
|
233
203
|
: Promise.resolve(null),
|
|
234
204
|
]);
|
|
235
205
|
const localSnap = acc.snapshot();
|
|
236
|
-
// Read fresh on every /cost call, both branches (see RegisterCostDeps's
|
|
237
|
-
// isBoosted doc comment for why the local fallback needs it too).
|
|
238
|
-
let boosted = false;
|
|
239
|
-
try {
|
|
240
|
-
boosted = deps.isBoosted?.() ?? false;
|
|
241
|
-
}
|
|
242
|
-
catch {
|
|
243
|
-
/* a diagnostic must never break /cost */
|
|
244
|
-
}
|
|
245
206
|
// Quiet divergence check: both totals must be available, and it never
|
|
246
207
|
// affects what the user sees. Scoped to `caller === "driver"` rows only
|
|
247
208
|
// (like-for-like with the local `turn_end` accumulator, which can only
|
|
@@ -276,7 +237,7 @@ export function registerCostCommand(pi, deps = {}) {
|
|
|
276
237
|
catch {
|
|
277
238
|
/* a diagnostic must never break /cost */
|
|
278
239
|
}
|
|
279
|
-
await pi.sendUserMessage(formatServerCostLines(spend, headroom, dropped
|
|
240
|
+
await pi.sendUserMessage(formatServerCostLines(spend, headroom, dropped));
|
|
280
241
|
return;
|
|
281
242
|
}
|
|
282
243
|
let advisorLine = "";
|
|
@@ -286,7 +247,7 @@ export function registerCostCommand(pi, deps = {}) {
|
|
|
286
247
|
catch {
|
|
287
248
|
/* usage accounting must never break /cost */
|
|
288
249
|
}
|
|
289
|
-
await pi.sendUserMessage(formatCostLine(localSnap, headroom, advisorLine, "local, driver only"
|
|
250
|
+
await pi.sendUserMessage(formatCostLine(localSnap, headroom, advisorLine, "local, driver only"));
|
|
290
251
|
},
|
|
291
252
|
});
|
|
292
253
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom footer for the YAGNI CLI — replaces pi's built-in footer.
|
|
3
|
+
*
|
|
4
|
+
* Renders three lines:
|
|
5
|
+
* 1. folder · [worktree] · branch (git context; ~-path fallback off-repo)
|
|
6
|
+
* 2. model · ↑in ↓out $cost · ctx% (session stats; context % is an integer)
|
|
7
|
+
* 3. extension statuses (branding, todo counter, mode) joined by " · "
|
|
8
|
+
*
|
|
9
|
+
* --- How to customize the status bar (for future tickets) ---
|
|
10
|
+
*
|
|
11
|
+
* LIFECYCLE: `ctx.ui.setFooter()` is NOT available at extension factory time.
|
|
12
|
+
* The `pi` (ExtensionAPI) object passed to the factory has no UI methods —
|
|
13
|
+
* they live on `ctx.ui` (ExtensionUIContext), which is only bound after
|
|
14
|
+
* `_applyExtensionBindings` runs, immediately before the `session_start`
|
|
15
|
+
* event. So the footer must be set inside a `pi.on("session_start", ...)`
|
|
16
|
+
* handler, guarded by `ctx.mode === "tui"` (no footer in RPC/print mode).
|
|
17
|
+
* See index.ts's session_start handler for the wiring, and pi's
|
|
18
|
+
* `docs/extensions.md` § "Widgets, Status, and Footer" + the `custom-header.ts`
|
|
19
|
+
* example for the canonical pattern.
|
|
20
|
+
*
|
|
21
|
+
* FACTORY SIGNATURE: `setFooter((tui, theme, footerData) => Component)` where:
|
|
22
|
+
* - `tui` — the TUI instance (screen dimensions, focus)
|
|
23
|
+
* - `theme` — the current Theme (use `theme.fg("dim", text)` etc.)
|
|
24
|
+
* - `footerData` — ReadonlyFooterDataProvider: `getGitBranch()`,
|
|
25
|
+
* `getExtensionStatuses()` (statuses set via `ctx.ui.setStatus(key, text)`),
|
|
26
|
+
* `getAvailableProviderCount()`
|
|
27
|
+
* Model info, token stats, and context usage are NOT on `footerData` — they're
|
|
28
|
+
* on `ctx` (the ExtensionContext passed to the session_start handler):
|
|
29
|
+
* `ctx.model`, `ctx.sessionManager`, `ctx.getContextUsage()`. Thread them
|
|
30
|
+
* through a closure if the footer needs them (this module's
|
|
31
|
+
* `createYagniFooterFactory` does exactly that).
|
|
32
|
+
*
|
|
33
|
+
* COMPONENT CONTRACT: the returned object needs `render(width: number): string[]`
|
|
34
|
+
* (returns the lines to display, one string per row) and `invalidate()` (called
|
|
35
|
+
* when the component should re-render). Optionally `dispose()` for cleanup.
|
|
36
|
+
*
|
|
37
|
+
* REFERENCE: pi's built-in footer lives at
|
|
38
|
+
* `node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js`
|
|
39
|
+
* — useful reference for what data to replicate and how to format it.
|
|
40
|
+
*/
|
|
41
|
+
import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
42
|
+
export declare const BRANCH_MAX_WIDTH = 60;
|
|
43
|
+
/** Format token counts for compact footer display (mirrors pi's formatTokens). */
|
|
44
|
+
export declare function formatTokens(count: number): string;
|
|
45
|
+
/** Shorten cwd relative to home, like pi's built-in footer. */
|
|
46
|
+
export declare function formatCwd(cwd: string, home: string | undefined): string;
|
|
47
|
+
interface UsageTotals {
|
|
48
|
+
input: number;
|
|
49
|
+
output: number;
|
|
50
|
+
cacheRead: number;
|
|
51
|
+
cacheWrite: number;
|
|
52
|
+
cost: number;
|
|
53
|
+
}
|
|
54
|
+
/** Accumulate usage from all session entries (mirrors pi's built-in footer). */
|
|
55
|
+
export declare function collectUsage(sessionManager: ExtensionContext["sessionManager"]): UsageTotals;
|
|
56
|
+
export interface GitInfo {
|
|
57
|
+
/** Repo-root folder basename (the MAIN repo, stable across worktrees), or ~-path off-repo. */
|
|
58
|
+
folder: string;
|
|
59
|
+
inRepo: boolean;
|
|
60
|
+
/** Current branch, null off-repo. "detached" on detached HEAD. */
|
|
61
|
+
branch: string | null;
|
|
62
|
+
/** Linked-worktree label for [brackets], or null to hide. */
|
|
63
|
+
worktree: string | null;
|
|
64
|
+
}
|
|
65
|
+
/** Git probes `resolveWorktreeLabel` needs, injectable so the rules can be unit-tested. */
|
|
66
|
+
export interface WorktreeProbes {
|
|
67
|
+
isLinkedWorktree(repoRoot: string): boolean;
|
|
68
|
+
worktrees(repoRoot: string): string[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Show [worktree] only on a LINKED worktree (never the main checkout), when the repo has
|
|
72
|
+
* >1 worktree, AND the dir differs from the branch slug. The main checkout is the default
|
|
73
|
+
* context and earns no label; a [bracket] only disambiguates a secondary working tree.
|
|
74
|
+
*/
|
|
75
|
+
export declare function resolveWorktreeLabel(repoRoot: string, branch: string | null, probes?: WorktreeProbes): string | null;
|
|
76
|
+
/**
|
|
77
|
+
* Detect git info for a cwd, gracefully. Never throws: non-git folders, missing git
|
|
78
|
+
* binary, and corrupt repos all degrade to a safe partial/empty result.
|
|
79
|
+
*/
|
|
80
|
+
export declare function detectGitInfo(cwd: string, home: string | undefined): GitInfo;
|
|
81
|
+
/** Pure line-builder, exported for tests. All data injected; colors via theme. */
|
|
82
|
+
export declare function renderFooterLines(input: {
|
|
83
|
+
git: GitInfo;
|
|
84
|
+
model: string;
|
|
85
|
+
usage: UsageTotals;
|
|
86
|
+
contextPercent: number | null;
|
|
87
|
+
statuses: string[];
|
|
88
|
+
}, theme: Pick<Theme, "fg">, width: number): string[];
|
|
89
|
+
/**
|
|
90
|
+
* Create a footer factory that captures the session `ctx` (for session data)
|
|
91
|
+
* and returns the component `setFooter` expects. Called from the
|
|
92
|
+
* `session_start` handler in index.ts.
|
|
93
|
+
*/
|
|
94
|
+
export declare function createYagniFooterFactory(ctx: ExtensionContext): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
|
|
95
|
+
render(width: number): string[];
|
|
96
|
+
invalidate(): void;
|
|
97
|
+
dispose(): void;
|
|
98
|
+
};
|
|
99
|
+
export {};
|
|
100
|
+
//# sourceMappingURL=footer.d.ts.map
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom footer for the YAGNI CLI — replaces pi's built-in footer.
|
|
3
|
+
*
|
|
4
|
+
* Renders three lines:
|
|
5
|
+
* 1. folder · [worktree] · branch (git context; ~-path fallback off-repo)
|
|
6
|
+
* 2. model · ↑in ↓out $cost · ctx% (session stats; context % is an integer)
|
|
7
|
+
* 3. extension statuses (branding, todo counter, mode) joined by " · "
|
|
8
|
+
*
|
|
9
|
+
* --- How to customize the status bar (for future tickets) ---
|
|
10
|
+
*
|
|
11
|
+
* LIFECYCLE: `ctx.ui.setFooter()` is NOT available at extension factory time.
|
|
12
|
+
* The `pi` (ExtensionAPI) object passed to the factory has no UI methods —
|
|
13
|
+
* they live on `ctx.ui` (ExtensionUIContext), which is only bound after
|
|
14
|
+
* `_applyExtensionBindings` runs, immediately before the `session_start`
|
|
15
|
+
* event. So the footer must be set inside a `pi.on("session_start", ...)`
|
|
16
|
+
* handler, guarded by `ctx.mode === "tui"` (no footer in RPC/print mode).
|
|
17
|
+
* See index.ts's session_start handler for the wiring, and pi's
|
|
18
|
+
* `docs/extensions.md` § "Widgets, Status, and Footer" + the `custom-header.ts`
|
|
19
|
+
* example for the canonical pattern.
|
|
20
|
+
*
|
|
21
|
+
* FACTORY SIGNATURE: `setFooter((tui, theme, footerData) => Component)` where:
|
|
22
|
+
* - `tui` — the TUI instance (screen dimensions, focus)
|
|
23
|
+
* - `theme` — the current Theme (use `theme.fg("dim", text)` etc.)
|
|
24
|
+
* - `footerData` — ReadonlyFooterDataProvider: `getGitBranch()`,
|
|
25
|
+
* `getExtensionStatuses()` (statuses set via `ctx.ui.setStatus(key, text)`),
|
|
26
|
+
* `getAvailableProviderCount()`
|
|
27
|
+
* Model info, token stats, and context usage are NOT on `footerData` — they're
|
|
28
|
+
* on `ctx` (the ExtensionContext passed to the session_start handler):
|
|
29
|
+
* `ctx.model`, `ctx.sessionManager`, `ctx.getContextUsage()`. Thread them
|
|
30
|
+
* through a closure if the footer needs them (this module's
|
|
31
|
+
* `createYagniFooterFactory` does exactly that).
|
|
32
|
+
*
|
|
33
|
+
* COMPONENT CONTRACT: the returned object needs `render(width: number): string[]`
|
|
34
|
+
* (returns the lines to display, one string per row) and `invalidate()` (called
|
|
35
|
+
* when the component should re-render). Optionally `dispose()` for cleanup.
|
|
36
|
+
*
|
|
37
|
+
* REFERENCE: pi's built-in footer lives at
|
|
38
|
+
* `node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js`
|
|
39
|
+
* — useful reference for what data to replicate and how to format it.
|
|
40
|
+
*/
|
|
41
|
+
import { spawnSync } from "node:child_process";
|
|
42
|
+
import { statSync } from "node:fs";
|
|
43
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
44
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
45
|
+
export const BRANCH_MAX_WIDTH = 60;
|
|
46
|
+
const WORKTREE_MAX_WIDTH = 30;
|
|
47
|
+
/** Section separator: single space + middle dot + single space. */
|
|
48
|
+
const SEP = " · ";
|
|
49
|
+
/** Format token counts for compact footer display (mirrors pi's formatTokens). */
|
|
50
|
+
export function formatTokens(count) {
|
|
51
|
+
if (count < 1000)
|
|
52
|
+
return count.toString();
|
|
53
|
+
if (count < 10000)
|
|
54
|
+
return `${(count / 1000).toFixed(1)}k`;
|
|
55
|
+
if (count < 1000000)
|
|
56
|
+
return `${Math.round(count / 1000)}k`;
|
|
57
|
+
if (count < 10000000)
|
|
58
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
59
|
+
return `${Math.round(count / 1000000)}M`;
|
|
60
|
+
}
|
|
61
|
+
/** Shorten cwd relative to home, like pi's built-in footer. */
|
|
62
|
+
export function formatCwd(cwd, home) {
|
|
63
|
+
if (!home)
|
|
64
|
+
return cwd;
|
|
65
|
+
const resolvedCwd = resolve(cwd);
|
|
66
|
+
const resolvedHome = resolve(home);
|
|
67
|
+
const rel = relative(resolvedHome, resolvedCwd);
|
|
68
|
+
const inside = rel === "" ||
|
|
69
|
+
(rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
70
|
+
return inside ? (rel === "" ? "~" : `~/${rel}`) : cwd;
|
|
71
|
+
}
|
|
72
|
+
/** Accumulate usage from all session entries (mirrors pi's built-in footer). */
|
|
73
|
+
export function collectUsage(sessionManager) {
|
|
74
|
+
const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
75
|
+
for (const entry of sessionManager.getEntries()) {
|
|
76
|
+
let u;
|
|
77
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
78
|
+
u = entry.message.usage;
|
|
79
|
+
}
|
|
80
|
+
else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
|
|
81
|
+
u = entry.message.usage;
|
|
82
|
+
}
|
|
83
|
+
else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
|
|
84
|
+
u = entry.usage;
|
|
85
|
+
}
|
|
86
|
+
if (u) {
|
|
87
|
+
totals.input += u.input;
|
|
88
|
+
totals.output += u.output;
|
|
89
|
+
totals.cacheRead += u.cacheRead;
|
|
90
|
+
totals.cacheWrite += u.cacheWrite;
|
|
91
|
+
totals.cost += u.cost.total;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return totals;
|
|
95
|
+
}
|
|
96
|
+
/** End-cut ellipsis truncation (ANSI-aware) so the branch prefix stays readable. */
|
|
97
|
+
function truncateEnd(text, maxWidth) {
|
|
98
|
+
if (visibleWidth(text) <= maxWidth)
|
|
99
|
+
return text;
|
|
100
|
+
return truncateToWidth(text, maxWidth, "…");
|
|
101
|
+
}
|
|
102
|
+
function runGit(args, cwd) {
|
|
103
|
+
try {
|
|
104
|
+
const r = spawnSync("git", ["--no-optional-locks", ...args], {
|
|
105
|
+
cwd,
|
|
106
|
+
encoding: "utf8",
|
|
107
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
108
|
+
});
|
|
109
|
+
if (r.error || r.status !== 0)
|
|
110
|
+
return null;
|
|
111
|
+
return r.stdout.trim();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function gitRepoRoot(cwd) {
|
|
118
|
+
return runGit(["rev-parse", "--show-toplevel"], cwd) || null;
|
|
119
|
+
}
|
|
120
|
+
function gitBranch(repoRoot) {
|
|
121
|
+
const out = runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], repoRoot);
|
|
122
|
+
if (out)
|
|
123
|
+
return out;
|
|
124
|
+
// Detached HEAD, or repo with no commits yet.
|
|
125
|
+
return runGit(["rev-parse", "--verify", "HEAD"], repoRoot) ? "detached" : null;
|
|
126
|
+
}
|
|
127
|
+
function gitWorktrees(repoRoot) {
|
|
128
|
+
const out = runGit(["worktree", "list", "--porcelain"], repoRoot);
|
|
129
|
+
if (!out)
|
|
130
|
+
return [];
|
|
131
|
+
return out
|
|
132
|
+
.split("\n")
|
|
133
|
+
.filter((l) => l.startsWith("worktree "))
|
|
134
|
+
.map((l) => l.slice("worktree ".length).trim())
|
|
135
|
+
.filter(Boolean);
|
|
136
|
+
}
|
|
137
|
+
/** The MAIN repository root (stable across worktrees) — used for the folder name. */
|
|
138
|
+
function gitMainRepoRoot(repoRoot) {
|
|
139
|
+
const common = runGit(["rev-parse", "--git-common-dir"], repoRoot);
|
|
140
|
+
if (!common)
|
|
141
|
+
return repoRoot;
|
|
142
|
+
const abs = isAbsolute(common) ? common : join(repoRoot, common);
|
|
143
|
+
return basename(abs) === ".git" ? dirname(abs) : repoRoot;
|
|
144
|
+
}
|
|
145
|
+
/** Main checkout: .git is a directory. Linked worktree: .git is a "gitdir:" file. */
|
|
146
|
+
function isLinkedWorktree(repoRoot) {
|
|
147
|
+
try {
|
|
148
|
+
return statSync(join(repoRoot, ".git")).isFile();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const liveWorktreeProbes = { isLinkedWorktree, worktrees: gitWorktrees };
|
|
155
|
+
/**
|
|
156
|
+
* Show [worktree] only on a LINKED worktree (never the main checkout), when the repo has
|
|
157
|
+
* >1 worktree, AND the dir differs from the branch slug. The main checkout is the default
|
|
158
|
+
* context and earns no label; a [bracket] only disambiguates a secondary working tree.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveWorktreeLabel(repoRoot, branch, probes = liveWorktreeProbes) {
|
|
161
|
+
if (!probes.isLinkedWorktree(repoRoot))
|
|
162
|
+
return null;
|
|
163
|
+
if (probes.worktrees(repoRoot).length < 2)
|
|
164
|
+
return null;
|
|
165
|
+
const currentDir = basename(repoRoot);
|
|
166
|
+
if (branch && currentDir === branch)
|
|
167
|
+
return null;
|
|
168
|
+
return truncateEnd(currentDir, WORKTREE_MAX_WIDTH);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Detect git info for a cwd, gracefully. Never throws: non-git folders, missing git
|
|
172
|
+
* binary, and corrupt repos all degrade to a safe partial/empty result.
|
|
173
|
+
*/
|
|
174
|
+
export function detectGitInfo(cwd, home) {
|
|
175
|
+
const root = gitRepoRoot(cwd);
|
|
176
|
+
if (!root) {
|
|
177
|
+
return { folder: formatCwd(cwd, home), inRepo: false, branch: null, worktree: null };
|
|
178
|
+
}
|
|
179
|
+
const branch = gitBranch(root);
|
|
180
|
+
const worktree = resolveWorktreeLabel(root, branch);
|
|
181
|
+
return { folder: basename(gitMainRepoRoot(root)), inRepo: true, branch, worktree };
|
|
182
|
+
}
|
|
183
|
+
/** Context color: dim below 70, warning 70-90, error above 90. */
|
|
184
|
+
function contextColor(percent) {
|
|
185
|
+
if (percent === null)
|
|
186
|
+
return "dim";
|
|
187
|
+
if (percent > 90)
|
|
188
|
+
return "error";
|
|
189
|
+
if (percent > 70)
|
|
190
|
+
return "warning";
|
|
191
|
+
return "dim";
|
|
192
|
+
}
|
|
193
|
+
/** Pure line-builder, exported for tests. All data injected; colors via theme. */
|
|
194
|
+
export function renderFooterLines(input, theme, width) {
|
|
195
|
+
const dim = (s) => theme.fg("dim", s);
|
|
196
|
+
const sep = dim(SEP);
|
|
197
|
+
// Line 1: folder · [worktree] · branch
|
|
198
|
+
const line1Parts = [theme.fg("accent", input.git.folder)];
|
|
199
|
+
if (input.git.inRepo) {
|
|
200
|
+
if (input.git.worktree)
|
|
201
|
+
line1Parts.push(theme.fg("warning", `[${input.git.worktree}]`));
|
|
202
|
+
if (input.git.branch)
|
|
203
|
+
line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH)));
|
|
204
|
+
}
|
|
205
|
+
const line1 = truncateToWidth(line1Parts.join(sep), width, dim("…"));
|
|
206
|
+
// Line 2: model · ↑in ↓out $cost · ctx%
|
|
207
|
+
const statParts = [];
|
|
208
|
+
if (input.usage.input)
|
|
209
|
+
statParts.push(`↑${formatTokens(input.usage.input)}`);
|
|
210
|
+
if (input.usage.output)
|
|
211
|
+
statParts.push(`↓${formatTokens(input.usage.output)}`);
|
|
212
|
+
if (input.usage.cost)
|
|
213
|
+
statParts.push(`$${input.usage.cost.toFixed(3)}`);
|
|
214
|
+
const stats = statParts.join(" ");
|
|
215
|
+
const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
|
|
216
|
+
const line2Parts = [dim(input.model)];
|
|
217
|
+
if (stats)
|
|
218
|
+
line2Parts.push(dim(stats));
|
|
219
|
+
line2Parts.push(theme.fg(contextColor(input.contextPercent), percentText));
|
|
220
|
+
const line2 = truncateToWidth(line2Parts.join(sep), width, dim("…"));
|
|
221
|
+
// Line 3: extension statuses (branding, todo counter, mode), joined by " · ".
|
|
222
|
+
const statuses = input.statuses.map((s) => s.replace(/[\r\n\t]/g, " ").trim()).filter(Boolean);
|
|
223
|
+
const lines = [line1, line2];
|
|
224
|
+
if (statuses.length > 0) {
|
|
225
|
+
lines.push(truncateToWidth(dim(statuses.join(SEP)), width, dim("…")));
|
|
226
|
+
}
|
|
227
|
+
return lines;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Create a footer factory that captures the session `ctx` (for session data)
|
|
231
|
+
* and returns the component `setFooter` expects. Called from the
|
|
232
|
+
* `session_start` handler in index.ts.
|
|
233
|
+
*/
|
|
234
|
+
export function createYagniFooterFactory(ctx) {
|
|
235
|
+
return (_tui, theme, footerData) => {
|
|
236
|
+
// Recompute git/worktree info only when the branch actually changes. Optional-
|
|
237
|
+
// chained: onBranchChange is typed on ReadonlyFooterDataProvider, but the runtime
|
|
238
|
+
// provider is whatever pi version is installed — guard so a mismatch can't break
|
|
239
|
+
// footer construction (worst case, git info just doesn't auto-invalidate).
|
|
240
|
+
let gitCache;
|
|
241
|
+
const unsubscribeBranch = footerData.onBranchChange?.(() => {
|
|
242
|
+
gitCache = undefined;
|
|
243
|
+
});
|
|
244
|
+
const gitInfo = () => {
|
|
245
|
+
if (!gitCache) {
|
|
246
|
+
gitCache = detectGitInfo(ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
|
|
247
|
+
}
|
|
248
|
+
return gitCache;
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
render(width) {
|
|
252
|
+
const statuses = [...footerData.getExtensionStatuses().entries()]
|
|
253
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
254
|
+
.map(([, text]) => text);
|
|
255
|
+
return renderFooterLines({
|
|
256
|
+
git: gitInfo(),
|
|
257
|
+
model: ctx.model?.id ?? "no-model",
|
|
258
|
+
usage: collectUsage(ctx.sessionManager),
|
|
259
|
+
contextPercent: ctx.getContextUsage()?.percent ?? null,
|
|
260
|
+
statuses,
|
|
261
|
+
}, theme, width);
|
|
262
|
+
},
|
|
263
|
+
invalidate() {
|
|
264
|
+
gitCache = undefined;
|
|
265
|
+
},
|
|
266
|
+
dispose() {
|
|
267
|
+
unsubscribeBranch?.();
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
//# sourceMappingURL=footer.js.map
|
package/dist/extension/index.js
CHANGED
|
@@ -3,7 +3,6 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
|
|
5
5
|
import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
6
|
-
import { registerBoostCommand } from "./boostCommand.js";
|
|
7
6
|
import { makeAskYagniTool } from "./askYagniTool.js";
|
|
8
7
|
import { makeReviewBusinessMatchTool } from "./reviewTool.js";
|
|
9
8
|
import { makeRecordEngineeringContextTool } from "./recordContextTool.js";
|
|
@@ -15,6 +14,7 @@ import { registerCostCommand } from "./costHud.js";
|
|
|
15
14
|
import { isDebug } from "./diagnostics.js";
|
|
16
15
|
import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
|
|
17
16
|
import { codeStateHome } from "./stateHome.js";
|
|
17
|
+
import { createYagniFooterFactory } from "./footer.js";
|
|
18
18
|
import { RerouteNotifier } from "./rerouteNotice.js";
|
|
19
19
|
import { isFreshWorkspace, registerTeamSetupCommand, runInitPass as defaultRunInitPass } from "./initPass.js";
|
|
20
20
|
import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone } from "./initDone.js";
|
|
@@ -111,7 +111,13 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
111
111
|
if (!evalMode) {
|
|
112
112
|
installUncaughtExceptionMonitor({ baseUrl, getToken: getTokenFn, env: deps.env });
|
|
113
113
|
}
|
|
114
|
-
const
|
|
114
|
+
const fullCatalog = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
|
|
115
|
+
// Lock the interactive session to the `advanced` tier only. The backend
|
|
116
|
+
// catalog returns all tiers, but only `advanced` is registered with the
|
|
117
|
+
// `yagni` provider, so /model and Ctrl+P show a single entry. Child
|
|
118
|
+
// processes (/go, subagents, advisor) fetch their own catalog and register
|
|
119
|
+
// their own provider, so they are unaffected by this filter.
|
|
120
|
+
const catalog = fullCatalog.filter((m) => m.id === "advanced");
|
|
115
121
|
// YAG-471: the driver's own completions carry attribution headers read from
|
|
116
122
|
// this process's env (YAGNI_SESSION_ID minted by the launcher; YAGNI_CALLER
|
|
117
123
|
// defaults to "driver" when unset, i.e. every session that is not a /go
|
|
@@ -119,11 +125,11 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
119
125
|
pi.registerProvider("yagni", buildYagniProvider(catalog, baseUrl, attributionHeaders(deps.env)));
|
|
120
126
|
const toolOpts = { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch };
|
|
121
127
|
pi.registerTool(makeAskYagniTool(toolOpts));
|
|
122
|
-
// The peak-tier escalation for
|
|
128
|
+
// The peak-tier escalation for Advanced sessions (YAG-380). Registered
|
|
123
129
|
// UNCONDITIONALLY and gated at execute time on the live session model: pi's
|
|
124
130
|
// picker can switch the model after activation, so a registration-time tier
|
|
125
|
-
// check would both leave the tool live after switching away from
|
|
126
|
-
// hide it forever from a session that switched TO
|
|
131
|
+
// check would both leave the tool live after switching away from Advanced and
|
|
132
|
+
// hide it forever from a session that switched TO Advanced. One state handle
|
|
127
133
|
// per session, shared with /advise so they draw on the same cap.
|
|
128
134
|
const advisorState = makeAdvisorState();
|
|
129
135
|
const askAdvisorTool = makeAskAdvisorTool({ state: advisorState });
|
|
@@ -131,12 +137,6 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
131
137
|
// /advise runs the SAME tool, sharing the state handle, so a manual consult
|
|
132
138
|
// draws on the same cap rather than opening a side channel around it.
|
|
133
139
|
registerAdviseCommand(pi, askAdvisorTool);
|
|
134
|
-
// /boost — the sanctioned session-scoped escalation to Peak (spec §7,
|
|
135
|
-
// YAG-380 follow-on). Unlike /advise's per-call consult, this flips the
|
|
136
|
-
// DRIVER's own live model to peak until /boost off or the session ends.
|
|
137
|
-
// The returned handle threads isBoosted() into /cost below, the same way
|
|
138
|
-
// advisorState threads into advisorSubtotal.
|
|
139
|
-
const boostCommand = registerBoostCommand(pi, { env });
|
|
140
140
|
// The differentiated business-grounded tools (loop bricks): review a change
|
|
141
141
|
// for business fit, rank the next work by business priority, and record the
|
|
142
142
|
// engineering rationale back onto the work-item.
|
|
@@ -248,11 +248,6 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
248
248
|
// fallback line; the server-authoritative line already counts advisor
|
|
249
249
|
// spend as an ordinary caller row.
|
|
250
250
|
advisorSubtotal: () => formatAdvisorSubtotal(advisorState.read(), DEFAULT_ADVISOR_LIMITS),
|
|
251
|
-
// YAG-380 follow-on: a boosted session that only chats never produces a
|
|
252
|
-
// server-side boosted row (see boostCommand.ts's KNOWN-asymmetry
|
|
253
|
-
// docblock), so /cost needs the live client-side toggle on top of
|
|
254
|
-
// whatever the server rows show.
|
|
255
|
-
isBoosted: () => boostCommand.isBoosted(),
|
|
256
251
|
fetchHeadroom: async (signal) => {
|
|
257
252
|
try {
|
|
258
253
|
const res = await resilientFetch(`${baseUrl}/api/yagni-code/credits`, { method: "GET", headers: { authorization: `Bearer ${getTokenFn() ?? ""}` } }, { fetchImpl: authedFetch, signal, policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: COST_FETCH_TIMEOUT_MS, jitterRatio: 0 } });
|
|
@@ -487,6 +482,12 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
487
482
|
// setHeader replaces the built-in header in place (verified against pi
|
|
488
483
|
// 0.80.2 setExtensionHeader); the factory returns a simple Text component.
|
|
489
484
|
ctx.ui?.setHeader?.((_tui, theme) => new Text(buildMastheadString(theme)));
|
|
485
|
+
// Replace the built-in footer with the YAGNI status bar: folder +
|
|
486
|
+
// [worktree] + branch on line 1, model + token/cost stats + integer
|
|
487
|
+
// context % on line 2, and extension statuses (brand, todos, mode) on
|
|
488
|
+
// line 3. The factory captures ctx so the footer can read session data
|
|
489
|
+
// (token stats, context usage) that isn't on the footerData provider.
|
|
490
|
+
ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx)(tui, theme, footerData));
|
|
490
491
|
}
|
|
491
492
|
// Best-effort, once at session start: if the token is at or near expiry, say
|
|
492
493
|
// so via a single notice so a long session does not silently start 401-ing
|
package/dist/launch.js
CHANGED
|
@@ -99,23 +99,16 @@ export function buildLaunch(creds, passthroughArgs, opts) {
|
|
|
99
99
|
// Always load our extension. Default the provider to `yagni` unless the user
|
|
100
100
|
// explicitly chose one (so power users can still point pi elsewhere).
|
|
101
101
|
const userChoseProvider = passthroughArgs.some(arg => arg === "--provider" || arg.startsWith("--provider="));
|
|
102
|
-
// Default the model to the `
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// can reason better. A user `--model` (e.g. `standard` or `efficient`) still
|
|
107
|
-
// wins. Without this, pi's default-model heuristic could land an interactive
|
|
108
|
-
// session on a weaker tier.
|
|
109
|
-
// Detect if the user explicitly set a model, whether via "--model" as a separate
|
|
110
|
-
// argument or using the equals form "--model=efficient". The previous check only
|
|
111
|
-
// caught the separate form, causing a duplicate "--model balanced" to be added
|
|
112
|
-
// when the equals form was used.
|
|
102
|
+
// Default the model to the `advanced` tier. The model is locked: the
|
|
103
|
+
// catalog is filtered to only `advanced` (see index.ts), so the user
|
|
104
|
+
// cannot switch to a different tier via /model or Ctrl+P. The proxy still
|
|
105
|
+
// resolves the tier to the concrete backing model.
|
|
113
106
|
const userChoseModel = passthroughArgs.some(arg => arg === "--model" || arg.startsWith("--model="));
|
|
114
107
|
const argv = [
|
|
115
108
|
"-e",
|
|
116
109
|
opts.extensionPath,
|
|
117
110
|
...(userChoseProvider ? [] : ["--provider", "yagni"]),
|
|
118
|
-
...(userChoseModel ? [] : ["--model", "
|
|
111
|
+
...(userChoseModel ? [] : ["--model", "advanced"]),
|
|
119
112
|
...(opts.extraAgentArgs ?? []),
|
|
120
113
|
...passthroughArgs,
|
|
121
114
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "0.2.1-staging.
|
|
3
|
+
"version": "0.2.1-staging.1032.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -38,5 +38,5 @@
|
|
|
38
38
|
"@earendil-works/pi-tui": "0.83.0",
|
|
39
39
|
"typebox": "^1.1.38"
|
|
40
40
|
},
|
|
41
|
-
"yagniSourceSha": "
|
|
41
|
+
"yagniSourceSha": "7629e1f12b3d8e06e68101daadd2d8258dd271e9"
|
|
42
42
|
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `/boost` — the sanctioned session-scoped escalation to Peak (spec §7).
|
|
3
|
-
*
|
|
4
|
-
* The session default is the `balanced` tier. `/boost` flips the
|
|
5
|
-
* DRIVER session's live model to `peak` until `/boost off` or the process
|
|
6
|
-
* exits; `peak` stays directly pickable through pi's own model picker too,
|
|
7
|
-
* this just adds a one-word lever plus an attribution flag.
|
|
8
|
-
*
|
|
9
|
-
* Two halves, same split as `advisor.ts` / `permission.ts`:
|
|
10
|
-
*
|
|
11
|
-
* - `boostOn` / `boostOff` are PURE. They own every transition and the exact
|
|
12
|
-
* notice copy; no pi, no I/O, no env. That is what needs exhaustive tests.
|
|
13
|
-
* - `registerBoostCommand` is the wiring: it applies the pure result's
|
|
14
|
-
* `targetModelId` through pi's real model-switch API, toggles
|
|
15
|
-
* `process.env.YAGNI_BOOST`, and paints the notice + a status-bar chip.
|
|
16
|
-
*
|
|
17
|
-
* The model-switch API (verified against pi 0.83.0's typings, not guessed):
|
|
18
|
-
* `ctx.modelRegistry.find(provider, modelId)` resolves a tier id to a
|
|
19
|
-
* `Model`, and `pi.setModel(model)` (the top-level `ExtensionAPI` method, NOT
|
|
20
|
-
* `ctx.setModel` — `ExtensionCommandContext` does not expose a setter) applies
|
|
21
|
-
* it to the live session, resolving `false` when no API key is configured.
|
|
22
|
-
* The `yagni` provider's catalog entries key `id` on the tier id itself (see
|
|
23
|
-
* `provider.ts`), so `peak` is the pi model id for the peak tier.
|
|
24
|
-
*
|
|
25
|
-
* State lives in the registration closure — module/session-scoped, mirroring
|
|
26
|
-
* `costHud.ts`'s accumulator — because a session's process exit is the only
|
|
27
|
-
* "end" there is; nothing needs to persist across it. `registerBoostCommand`
|
|
28
|
-
* returns a small `{ isBoosted() }` handle onto that same closure so other
|
|
29
|
-
* registrations (currently just `/cost`, see below) can read the live flag
|
|
30
|
-
* without a second source of truth.
|
|
31
|
-
*
|
|
32
|
-
* Picker-drift guard (spec review item 3): pi's OWN model picker (Ctrl+P,
|
|
33
|
-
* `/model`) can change the live model independently of `/boost` in either
|
|
34
|
-
* direction, so `state.active` can go stale relative to `ctx.model.id`. Both
|
|
35
|
-
* halves read the live model and reconcile rather than trusting `state.active`
|
|
36
|
-
* blindly:
|
|
37
|
-
*
|
|
38
|
-
* - `boostOn` while already active but the live model has drifted OFF peak
|
|
39
|
-
* (the user cycled models mid-boost without `/boost off`): re-applies
|
|
40
|
-
* peak instead of a silent "Already boosted." that would leave the
|
|
41
|
-
* driver quietly running a cheaper tier under an attribution header that
|
|
42
|
-
* claims otherwise. The remembered `priorModelId` is left untouched — the
|
|
43
|
-
* tier to restore is still whatever was active before the ORIGINAL boost,
|
|
44
|
-
* not the tier the user happened to drift to.
|
|
45
|
-
* - `boostOff` while active but the live model is no longer peak (same
|
|
46
|
-
* drift, encountered from the other command): the user already left
|
|
47
|
-
* boost manually, so forcing a switch back to the remembered prior tier
|
|
48
|
-
* would clobber a choice they just made on purpose. Instead this clears
|
|
49
|
-
* `/boost`'s own bookkeeping (state, env, chip) without touching the
|
|
50
|
-
* model, and names where they actually landed.
|
|
51
|
-
*
|
|
52
|
-
* KNOWN asymmetry, documented rather than worked around: `YAGNI_BOOST=1`
|
|
53
|
-
* while boosted makes every CHILD process spawned during the boost (a /go
|
|
54
|
-
* run, a subagent, an advisor consult) inherit it, and those children's
|
|
55
|
-
* completions carry `x-yagni-boost` because their provider is registered
|
|
56
|
-
* fresh per child. The DRIVER's own completions do not gain that header
|
|
57
|
-
* retroactively — `buildYagniProvider`'s headers are baked into the
|
|
58
|
-
* `registerProvider` call once, at session start, from the env snapshot at
|
|
59
|
-
* that moment (see `provider.ts` / `attributionHeaders`). Re-registering the
|
|
60
|
-
* provider mid-session to pick up a new header is explicitly NOT the fix
|
|
61
|
-
* here: it would race the in-flight request the switch itself triggers and
|
|
62
|
-
* has no test coverage as a live-swap path.
|
|
63
|
-
*
|
|
64
|
-
* This is exactly why `/cost` cannot rely on server rows alone: the server's
|
|
65
|
-
* per-tier "Boosted (tier) spend" subtotal (`formatServerCostLines`, Task 7)
|
|
66
|
-
* only ever sees rows carrying `x-yagni-boost` — i.e. `/go` children, never
|
|
67
|
-
* the driver's own turns. A session that only chats while boosted would
|
|
68
|
-
* otherwise show no boost line at all. `registerCostCommand`'s `isBoosted`
|
|
69
|
-
* dep (threaded from THIS module's return handle, the same way
|
|
70
|
-
* `advisorSubtotal` is threaded from `advisor.ts`) closes that gap: `/cost`
|
|
71
|
-
* appends a fixed "Boost is on. Driver turns bill at the peak tier." line
|
|
72
|
-
* whenever the live toggle is on, on BOTH the server-authoritative and the
|
|
73
|
-
* local-fallback branch, independent of what server rows happen to show.
|
|
74
|
-
*/
|
|
75
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
76
|
-
/** Session-scoped boost state, held in the registration closure. */
|
|
77
|
-
export interface BoostState {
|
|
78
|
-
active: boolean;
|
|
79
|
-
/** The tier to restore on `/boost off`. Set only while `active`. */
|
|
80
|
-
priorModelId?: string;
|
|
81
|
-
}
|
|
82
|
-
/** The pure outcome of a `/boost` transition. */
|
|
83
|
-
export interface BoostResult {
|
|
84
|
-
state: BoostState;
|
|
85
|
-
/** Exact notice text to surface to the user. */
|
|
86
|
-
notice: string;
|
|
87
|
-
/** The tier id to switch the session to, or undefined for a pure no-op. */
|
|
88
|
-
targetModelId?: string;
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* Turn boost on. PURE.
|
|
92
|
-
*
|
|
93
|
-
* - Already active AND the live model is still `peak`: genuine no-op,
|
|
94
|
-
* "Already boosted." (no switch — nothing about the prior tier changes).
|
|
95
|
-
* - Already active but the live model has drifted off `peak` (picker-drift
|
|
96
|
-
* guard 3b, see module docblock): re-applies `peak`, keeping the ORIGINAL
|
|
97
|
-
* `priorModelId` rather than adopting the drifted-to tier.
|
|
98
|
-
* - Not active, current model IS already `peak`: activates anyway (so
|
|
99
|
-
* attribution and `/boost off` behave correctly) with a distinct notice,
|
|
100
|
-
* remembering `peak` itself as the tier to restore.
|
|
101
|
-
* - Not active, otherwise: remembers the current tier, targets `peak`.
|
|
102
|
-
*/
|
|
103
|
-
export declare function boostOn(state: BoostState, currentModelId: string | undefined): BoostResult;
|
|
104
|
-
/**
|
|
105
|
-
* Turn boost off. PURE.
|
|
106
|
-
*
|
|
107
|
-
* - Not active: no-op, "Boost is not on."
|
|
108
|
-
* - Active but the live model is no longer `peak` (picker-drift guard 3a,
|
|
109
|
-
* see module docblock): the user already left boost manually. Clears
|
|
110
|
-
* `/boost`'s own bookkeeping WITHOUT a switch — forcing one back to the
|
|
111
|
-
* remembered prior tier would clobber a choice just made on purpose — and
|
|
112
|
-
* names the model they actually landed on.
|
|
113
|
-
* - Active and still on `peak`: restores the remembered prior tier (falling
|
|
114
|
-
* back to the session default when none was recorded, which should not
|
|
115
|
-
* normally happen). When the prior tier is itself `peak` (the
|
|
116
|
-
* already-on-Peak activation path), the resulting switch is a no-op in
|
|
117
|
-
* effect — still applied, harmlessly.
|
|
118
|
-
*/
|
|
119
|
-
export declare function boostOff(state: BoostState, currentModelId: string | undefined): BoostResult;
|
|
120
|
-
/** Injectable seams for `registerBoostCommand`. */
|
|
121
|
-
export interface RegisterBoostCommandDeps {
|
|
122
|
-
/** Environment `YAGNI_BOOST` is toggled on. Defaults to `process.env`. */
|
|
123
|
-
env?: NodeJS.ProcessEnv;
|
|
124
|
-
}
|
|
125
|
-
/** What `registerBoostCommand` hands back so other registrations (`/cost`) can read the live flag. */
|
|
126
|
-
export interface BoostCommandHandle {
|
|
127
|
-
/** Whether the session is currently boosted, read fresh off the same closure `/boost` mutates. */
|
|
128
|
-
isBoosted(): boolean;
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Register the `/boost` command.
|
|
132
|
-
*
|
|
133
|
-
* `/boost` (no args) turns boost on; `/boost off` turns it off; any other
|
|
134
|
-
* argument shows a usage notice and changes nothing. Every transition that
|
|
135
|
-
* requires a model switch runs it through pi's real API and is guarded end to
|
|
136
|
-
* end: state and `YAGNI_BOOST` are only ever committed together, AFTER the
|
|
137
|
-
* switch succeeds (or is deliberately skipped — the picker-drift guards), so
|
|
138
|
-
* a half-applied boost (env set but model unchanged, or vice versa) can never
|
|
139
|
-
* happen. A failed ON reports "Boost failed."; a failed OFF reports a
|
|
140
|
-
* DISTINCT message, because the failure mode is materially worse — the
|
|
141
|
-
* driver is left still running (and billing) on Peak, not merely unchanged.
|
|
142
|
-
*/
|
|
143
|
-
export declare function registerBoostCommand(pi: ExtensionAPI, deps?: RegisterBoostCommandDeps): BoostCommandHandle;
|
|
144
|
-
//# sourceMappingURL=boostCommand.d.ts.map
|
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `/boost` — the sanctioned session-scoped escalation to Peak (spec §7).
|
|
3
|
-
*
|
|
4
|
-
* The session default is the `balanced` tier. `/boost` flips the
|
|
5
|
-
* DRIVER session's live model to `peak` until `/boost off` or the process
|
|
6
|
-
* exits; `peak` stays directly pickable through pi's own model picker too,
|
|
7
|
-
* this just adds a one-word lever plus an attribution flag.
|
|
8
|
-
*
|
|
9
|
-
* Two halves, same split as `advisor.ts` / `permission.ts`:
|
|
10
|
-
*
|
|
11
|
-
* - `boostOn` / `boostOff` are PURE. They own every transition and the exact
|
|
12
|
-
* notice copy; no pi, no I/O, no env. That is what needs exhaustive tests.
|
|
13
|
-
* - `registerBoostCommand` is the wiring: it applies the pure result's
|
|
14
|
-
* `targetModelId` through pi's real model-switch API, toggles
|
|
15
|
-
* `process.env.YAGNI_BOOST`, and paints the notice + a status-bar chip.
|
|
16
|
-
*
|
|
17
|
-
* The model-switch API (verified against pi 0.83.0's typings, not guessed):
|
|
18
|
-
* `ctx.modelRegistry.find(provider, modelId)` resolves a tier id to a
|
|
19
|
-
* `Model`, and `pi.setModel(model)` (the top-level `ExtensionAPI` method, NOT
|
|
20
|
-
* `ctx.setModel` — `ExtensionCommandContext` does not expose a setter) applies
|
|
21
|
-
* it to the live session, resolving `false` when no API key is configured.
|
|
22
|
-
* The `yagni` provider's catalog entries key `id` on the tier id itself (see
|
|
23
|
-
* `provider.ts`), so `peak` is the pi model id for the peak tier.
|
|
24
|
-
*
|
|
25
|
-
* State lives in the registration closure — module/session-scoped, mirroring
|
|
26
|
-
* `costHud.ts`'s accumulator — because a session's process exit is the only
|
|
27
|
-
* "end" there is; nothing needs to persist across it. `registerBoostCommand`
|
|
28
|
-
* returns a small `{ isBoosted() }` handle onto that same closure so other
|
|
29
|
-
* registrations (currently just `/cost`, see below) can read the live flag
|
|
30
|
-
* without a second source of truth.
|
|
31
|
-
*
|
|
32
|
-
* Picker-drift guard (spec review item 3): pi's OWN model picker (Ctrl+P,
|
|
33
|
-
* `/model`) can change the live model independently of `/boost` in either
|
|
34
|
-
* direction, so `state.active` can go stale relative to `ctx.model.id`. Both
|
|
35
|
-
* halves read the live model and reconcile rather than trusting `state.active`
|
|
36
|
-
* blindly:
|
|
37
|
-
*
|
|
38
|
-
* - `boostOn` while already active but the live model has drifted OFF peak
|
|
39
|
-
* (the user cycled models mid-boost without `/boost off`): re-applies
|
|
40
|
-
* peak instead of a silent "Already boosted." that would leave the
|
|
41
|
-
* driver quietly running a cheaper tier under an attribution header that
|
|
42
|
-
* claims otherwise. The remembered `priorModelId` is left untouched — the
|
|
43
|
-
* tier to restore is still whatever was active before the ORIGINAL boost,
|
|
44
|
-
* not the tier the user happened to drift to.
|
|
45
|
-
* - `boostOff` while active but the live model is no longer peak (same
|
|
46
|
-
* drift, encountered from the other command): the user already left
|
|
47
|
-
* boost manually, so forcing a switch back to the remembered prior tier
|
|
48
|
-
* would clobber a choice they just made on purpose. Instead this clears
|
|
49
|
-
* `/boost`'s own bookkeeping (state, env, chip) without touching the
|
|
50
|
-
* model, and names where they actually landed.
|
|
51
|
-
*
|
|
52
|
-
* KNOWN asymmetry, documented rather than worked around: `YAGNI_BOOST=1`
|
|
53
|
-
* while boosted makes every CHILD process spawned during the boost (a /go
|
|
54
|
-
* run, a subagent, an advisor consult) inherit it, and those children's
|
|
55
|
-
* completions carry `x-yagni-boost` because their provider is registered
|
|
56
|
-
* fresh per child. The DRIVER's own completions do not gain that header
|
|
57
|
-
* retroactively — `buildYagniProvider`'s headers are baked into the
|
|
58
|
-
* `registerProvider` call once, at session start, from the env snapshot at
|
|
59
|
-
* that moment (see `provider.ts` / `attributionHeaders`). Re-registering the
|
|
60
|
-
* provider mid-session to pick up a new header is explicitly NOT the fix
|
|
61
|
-
* here: it would race the in-flight request the switch itself triggers and
|
|
62
|
-
* has no test coverage as a live-swap path.
|
|
63
|
-
*
|
|
64
|
-
* This is exactly why `/cost` cannot rely on server rows alone: the server's
|
|
65
|
-
* per-tier "Boosted (tier) spend" subtotal (`formatServerCostLines`, Task 7)
|
|
66
|
-
* only ever sees rows carrying `x-yagni-boost` — i.e. `/go` children, never
|
|
67
|
-
* the driver's own turns. A session that only chats while boosted would
|
|
68
|
-
* otherwise show no boost line at all. `registerCostCommand`'s `isBoosted`
|
|
69
|
-
* dep (threaded from THIS module's return handle, the same way
|
|
70
|
-
* `advisorSubtotal` is threaded from `advisor.ts`) closes that gap: `/cost`
|
|
71
|
-
* appends a fixed "Boost is on. Driver turns bill at the peak tier." line
|
|
72
|
-
* whenever the live toggle is on, on BOTH the server-authoritative and the
|
|
73
|
-
* local-fallback branch, independent of what server rows happen to show.
|
|
74
|
-
*/
|
|
75
|
-
/** The pi provider name the YAGNI catalog registers under (see `provider.ts`). */
|
|
76
|
-
const YAGNI_PROVIDER = "yagni";
|
|
77
|
-
/** The tier `/boost` escalates to. */
|
|
78
|
-
const BOOST_TIER = "peak";
|
|
79
|
-
/** The session default tier, restored when no prior tier is known. */
|
|
80
|
-
const DEFAULT_TIER = "balanced";
|
|
81
|
-
/** Tier ids are lowercase; every known tier's display name is just Title Case of the id. */
|
|
82
|
-
function displayTierName(id) {
|
|
83
|
-
return id.length === 0 ? id : id.charAt(0).toUpperCase() + id.slice(1);
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Turn boost on. PURE.
|
|
87
|
-
*
|
|
88
|
-
* - Already active AND the live model is still `peak`: genuine no-op,
|
|
89
|
-
* "Already boosted." (no switch — nothing about the prior tier changes).
|
|
90
|
-
* - Already active but the live model has drifted off `peak` (picker-drift
|
|
91
|
-
* guard 3b, see module docblock): re-applies `peak`, keeping the ORIGINAL
|
|
92
|
-
* `priorModelId` rather than adopting the drifted-to tier.
|
|
93
|
-
* - Not active, current model IS already `peak`: activates anyway (so
|
|
94
|
-
* attribution and `/boost off` behave correctly) with a distinct notice,
|
|
95
|
-
* remembering `peak` itself as the tier to restore.
|
|
96
|
-
* - Not active, otherwise: remembers the current tier, targets `peak`.
|
|
97
|
-
*/
|
|
98
|
-
export function boostOn(state, currentModelId) {
|
|
99
|
-
if (state.active) {
|
|
100
|
-
if (currentModelId === BOOST_TIER) {
|
|
101
|
-
return { state, notice: "Already boosted." };
|
|
102
|
-
}
|
|
103
|
-
return {
|
|
104
|
-
state,
|
|
105
|
-
notice: `Re-boosted to Peak. /boost off to return to ${displayTierName(state.priorModelId ?? DEFAULT_TIER)}.`,
|
|
106
|
-
targetModelId: BOOST_TIER,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
if (currentModelId === BOOST_TIER) {
|
|
110
|
-
return {
|
|
111
|
-
state: { active: true, priorModelId: BOOST_TIER },
|
|
112
|
-
notice: "Already on Peak. /boost off returns to Peak.",
|
|
113
|
-
targetModelId: BOOST_TIER,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
return {
|
|
117
|
-
state: { active: true, priorModelId: currentModelId },
|
|
118
|
-
notice: `Boosted to Peak. /boost off to return to ${displayTierName(currentModelId ?? DEFAULT_TIER)}.`,
|
|
119
|
-
targetModelId: BOOST_TIER,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* Turn boost off. PURE.
|
|
124
|
-
*
|
|
125
|
-
* - Not active: no-op, "Boost is not on."
|
|
126
|
-
* - Active but the live model is no longer `peak` (picker-drift guard 3a,
|
|
127
|
-
* see module docblock): the user already left boost manually. Clears
|
|
128
|
-
* `/boost`'s own bookkeeping WITHOUT a switch — forcing one back to the
|
|
129
|
-
* remembered prior tier would clobber a choice just made on purpose — and
|
|
130
|
-
* names the model they actually landed on.
|
|
131
|
-
* - Active and still on `peak`: restores the remembered prior tier (falling
|
|
132
|
-
* back to the session default when none was recorded, which should not
|
|
133
|
-
* normally happen). When the prior tier is itself `peak` (the
|
|
134
|
-
* already-on-Peak activation path), the resulting switch is a no-op in
|
|
135
|
-
* effect — still applied, harmlessly.
|
|
136
|
-
*/
|
|
137
|
-
export function boostOff(state, currentModelId) {
|
|
138
|
-
if (!state.active) {
|
|
139
|
-
return { state, notice: "Boost is not on." };
|
|
140
|
-
}
|
|
141
|
-
if (currentModelId !== BOOST_TIER) {
|
|
142
|
-
return {
|
|
143
|
-
state: { active: false, priorModelId: undefined },
|
|
144
|
-
notice: `Boost off. Leaving the model on ${displayTierName(currentModelId ?? DEFAULT_TIER)}.`,
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
const priorId = state.priorModelId ?? DEFAULT_TIER;
|
|
148
|
-
return {
|
|
149
|
-
state: { active: false, priorModelId: undefined },
|
|
150
|
-
notice: `Back to ${displayTierName(priorId)}.`,
|
|
151
|
-
targetModelId: priorId,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* Resolve `tierId` to a live `Model` and apply it via `pi.setModel`.
|
|
156
|
-
*
|
|
157
|
-
* On a registry lookup miss, `allowFallback` (true only on the OFF path —
|
|
158
|
-
* MINOR 5) retries once against the session default tier rather than
|
|
159
|
-
* stranding the user on Peak over one missing catalog entry. Returns the
|
|
160
|
-
* tier id that was ACTUALLY applied, so the caller can tell whether the
|
|
161
|
-
* fallback fired and adjust the notice to name it honestly.
|
|
162
|
-
*
|
|
163
|
-
* @throws when neither the requested tier nor (if allowed) the fallback
|
|
164
|
-
* resolves, or when `pi.setModel` itself throws or resolves `false`.
|
|
165
|
-
*/
|
|
166
|
-
async function applyTier(pi, ctx, tierId, allowFallback) {
|
|
167
|
-
let model = ctx.modelRegistry?.find(YAGNI_PROVIDER, tierId);
|
|
168
|
-
let applied = tierId;
|
|
169
|
-
if (!model && allowFallback && tierId !== DEFAULT_TIER) {
|
|
170
|
-
model = ctx.modelRegistry?.find(YAGNI_PROVIDER, DEFAULT_TIER);
|
|
171
|
-
applied = DEFAULT_TIER;
|
|
172
|
-
}
|
|
173
|
-
if (!model) {
|
|
174
|
-
throw new Error(`Unknown YAGNI tier "${tierId}".`);
|
|
175
|
-
}
|
|
176
|
-
const ok = await pi.setModel(model);
|
|
177
|
-
if (!ok) {
|
|
178
|
-
throw new Error("setModel reported no API key configured.");
|
|
179
|
-
}
|
|
180
|
-
return applied;
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* Register the `/boost` command.
|
|
184
|
-
*
|
|
185
|
-
* `/boost` (no args) turns boost on; `/boost off` turns it off; any other
|
|
186
|
-
* argument shows a usage notice and changes nothing. Every transition that
|
|
187
|
-
* requires a model switch runs it through pi's real API and is guarded end to
|
|
188
|
-
* end: state and `YAGNI_BOOST` are only ever committed together, AFTER the
|
|
189
|
-
* switch succeeds (or is deliberately skipped — the picker-drift guards), so
|
|
190
|
-
* a half-applied boost (env set but model unchanged, or vice versa) can never
|
|
191
|
-
* happen. A failed ON reports "Boost failed."; a failed OFF reports a
|
|
192
|
-
* DISTINCT message, because the failure mode is materially worse — the
|
|
193
|
-
* driver is left still running (and billing) on Peak, not merely unchanged.
|
|
194
|
-
*/
|
|
195
|
-
export function registerBoostCommand(pi, deps = {}) {
|
|
196
|
-
const env = deps.env ?? process.env;
|
|
197
|
-
let state = { active: false };
|
|
198
|
-
const paintStatus = (ctx, active) => {
|
|
199
|
-
try {
|
|
200
|
-
if (ctx.hasUI)
|
|
201
|
-
ctx.ui.setStatus?.("yagni-boost", active ? "⚡ Peak" : undefined);
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
// The chip is chrome; never let it break /boost.
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
pi.registerCommand("boost", {
|
|
208
|
-
description: "Escalate this session's model to Peak until /boost off or the session ends. /boost off returns to the prior tier.",
|
|
209
|
-
handler: async (args, ctx) => {
|
|
210
|
-
const notify = (message, type) => {
|
|
211
|
-
if (ctx.hasUI)
|
|
212
|
-
ctx.ui.notify(message, type);
|
|
213
|
-
};
|
|
214
|
-
// MINOR 4: case-insensitive "off" match, mirroring /mode's arg handling.
|
|
215
|
-
const arg = args.trim().toLowerCase();
|
|
216
|
-
if (arg !== "" && arg !== "off") {
|
|
217
|
-
notify("Usage: /boost (turn on) or /boost off.", "warning");
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
const isOff = arg === "off";
|
|
221
|
-
const wasActive = state.active;
|
|
222
|
-
const result = isOff ? boostOff(state, ctx.model?.id) : boostOn(state, ctx.model?.id);
|
|
223
|
-
try {
|
|
224
|
-
let notice = result.notice;
|
|
225
|
-
if (result.targetModelId) {
|
|
226
|
-
const applied = await applyTier(pi, ctx, result.targetModelId, isOff);
|
|
227
|
-
if (applied !== result.targetModelId) {
|
|
228
|
-
// MINOR 5: the remembered/target tier no longer resolves in the
|
|
229
|
-
// catalog; we landed on the session default instead of
|
|
230
|
-
// stranding the user on Peak. Name the fallback so the notice
|
|
231
|
-
// stays honest about what actually happened.
|
|
232
|
-
notice = `Back to ${displayTierName(applied)} (could not restore ${displayTierName(result.targetModelId)}).`;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
state = result.state;
|
|
236
|
-
// MINOR 6: only touch the env on a real active/inactive flip. Both
|
|
237
|
-
// pure no-op paths ("Already boosted.", "Boost is not on.") return
|
|
238
|
-
// `state` unchanged, so this never fires for them — an inherited
|
|
239
|
-
// YAGNI_BOOST from outside this session is left exactly as found.
|
|
240
|
-
if (state.active !== wasActive) {
|
|
241
|
-
if (state.active) {
|
|
242
|
-
env.YAGNI_BOOST = "1";
|
|
243
|
-
}
|
|
244
|
-
else {
|
|
245
|
-
delete env.YAGNI_BOOST;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
notify(notice, "info");
|
|
249
|
-
paintStatus(ctx, state.active);
|
|
250
|
-
}
|
|
251
|
-
catch {
|
|
252
|
-
// IMPORTANT 1: a failed OFF is materially worse than a failed ON —
|
|
253
|
-
// the driver is STILL on Peak, still billing at Peak rates, which a
|
|
254
|
-
// generic "Boost failed." does not convey. Neither `state` nor
|
|
255
|
-
// `YAGNI_BOOST` was touched above (the throw lands before both), so
|
|
256
|
-
// the chip is also left exactly as it was: still lit.
|
|
257
|
-
notify(isOff ? "Could not leave boost. Still on Peak; try /boost off again." : "Boost failed.", "error");
|
|
258
|
-
}
|
|
259
|
-
},
|
|
260
|
-
});
|
|
261
|
-
return { isBoosted: () => state.active };
|
|
262
|
-
}
|
|
263
|
-
//# sourceMappingURL=boostCommand.js.map
|