@fiale-plus/pi-rogue-advisor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,599 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Box, Text } from "@earendil-works/pi-tui";
4
+ import { completeSimple, type ThinkingLevel } from "@earendil-works/pi-ai";
5
+ import { Type } from "typebox";
6
+ import { featureFile, readText, truncate, writeText } from "./internal.js";
7
+ import {
8
+ appendRouteLog,
9
+ binaryGatePredict,
10
+ formatAdvisorDisplay,
11
+ heuristicRoute,
12
+ mergeReviewPolicy,
13
+ routeNote,
14
+ summarizeRoute,
15
+ type AdvisorRouteDecision,
16
+ type AdvisorRouteInput,
17
+ type ReviewPolicy,
18
+ } from "./router.js";
19
+ import { classifyIntent, classifyMode } from "./preflight-signals.js";
20
+
21
+ // ── Config: 3 optional fields ────────────────────────────────────────────
22
+
23
+ export interface AdvisorConfig {
24
+ /** "auto" (preflight+post+cache), "manual" (just /advisor), "off" */
25
+ mode: "auto" | "manual" | "off";
26
+ /** "light" (file changes/errors only) | "strict" (every 3 turns) | "off" */
27
+ review: "light" | "strict" | "off";
28
+ /** Optional model override. Auto-detects SOTA (gpt-5.5, claude-opus-4-6…) if unset */
29
+ model?: string;
30
+ }
31
+
32
+ const DEFAULT_CONFIG: AdvisorConfig = {
33
+ mode: "auto",
34
+ review: "light",
35
+ };
36
+
37
+ const CONFIG_PATH = featureFile("advisor", "config.json");
38
+ const STATE_PATH = featureFile("advisor", "state.json");
39
+ const CACHE_PATH = featureFile("advisor", "cache.json");
40
+ const CURRENT_PATH = featureFile("advisor", "current.md");
41
+ const HISTORY_PATH = featureFile("advisor", "history.jsonl");
42
+
43
+ const MAX_CACHE = 64;
44
+ const MAX_NOTES = 12;
45
+ const MAX_FILES = 8;
46
+ const MAX_ERRORS = 5;
47
+
48
+ // ── SOTA models (ordered by preference) ───────────────────────────────────
49
+ const SOTA_CHAIN: Array<{ provider: string; model: string; label: string }> = [
50
+ { provider: "openai-codex", model: "gpt-5.5", label: "GPT-5.5 (Codex)" },
51
+ { provider: "anthropic", model: "claude-opus-4-6", label: "Claude Opus 4.6" },
52
+ { provider: "anthropic", model: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
53
+ { provider: "openai-codex", model: "gpt-5.4-mini", label: "GPT-5.4 Mini" },
54
+ ];
55
+
56
+ // ── Internal state ────────────────────────────────────────────────────────
57
+ interface SessionState {
58
+ turns: number;
59
+ lastTask: string;
60
+ notes: string[];
61
+ files: string[];
62
+ errors: string[];
63
+ advisorCalls: number;
64
+ cacheHits: number;
65
+ followUp: string;
66
+ router: {
67
+ preflight?: AdvisorRouteDecision;
68
+ review?: AdvisorRouteDecision;
69
+ };
70
+ }
71
+
72
+ function defaultState(): SessionState {
73
+ return {
74
+ turns: 0,
75
+ lastTask: "",
76
+ notes: [],
77
+ files: [],
78
+ errors: [],
79
+ advisorCalls: 0,
80
+ cacheHits: 0,
81
+ followUp: "",
82
+ router: {},
83
+ };
84
+ }
85
+
86
+ // ── File I/O ──────────────────────────────────────────────────────────────
87
+ function readJson<T>(path: string, fallback: T): T {
88
+ try {
89
+ return JSON.parse(readText(path) || "null") ?? fallback;
90
+ } catch {
91
+ return fallback;
92
+ }
93
+ }
94
+
95
+ function writeJson(path: string, v: unknown) {
96
+ writeText(path, JSON.stringify(v, null, 2) + "\n");
97
+ }
98
+
99
+ function loadConfig(): AdvisorConfig {
100
+ const raw = readJson<Partial<AdvisorConfig>>(CONFIG_PATH, {});
101
+ return {
102
+ mode: (raw.mode === "manual" || raw.mode === "off") ? raw.mode : "auto",
103
+ review: (raw.review === "strict" || raw.review === "off") ? raw.review : "light",
104
+ model: raw.model || undefined,
105
+ };
106
+ }
107
+
108
+ function saveConfig(c: AdvisorConfig) {
109
+ writeJson(CONFIG_PATH, c);
110
+ }
111
+
112
+ function loadState(): SessionState {
113
+ const raw = readJson<Partial<SessionState>>(STATE_PATH, {});
114
+ return {
115
+ turns: raw.turns ?? 0,
116
+ lastTask: raw.lastTask ?? "",
117
+ notes: (raw.notes ?? []).slice(-MAX_NOTES),
118
+ files: (raw.files ?? []).slice(-MAX_FILES),
119
+ errors: (raw.errors ?? []).slice(-MAX_ERRORS),
120
+ advisorCalls: raw.advisorCalls ?? 0,
121
+ cacheHits: raw.cacheHits ?? 0,
122
+ followUp: raw.followUp ?? "",
123
+ router: {
124
+ preflight: raw.router?.preflight,
125
+ review: raw.router?.review,
126
+ },
127
+ };
128
+ }
129
+
130
+ function saveState(s: SessionState) {
131
+ writeJson(STATE_PATH, s);
132
+ }
133
+
134
+ function loadCache(): Record<string, string> {
135
+ return readJson<Record<string, string>>(CACHE_PATH, {});
136
+ }
137
+
138
+ function saveCache(c: Record<string, string>) {
139
+ const entries = Object.entries(c);
140
+ if (entries.length > MAX_CACHE) {
141
+ entries.sort((a, b) => a[0].localeCompare(b[0]));
142
+ for (const [k] of entries.slice(0, entries.length - MAX_CACHE)) delete c[k];
143
+ }
144
+ writeJson(CACHE_PATH, c);
145
+ }
146
+
147
+ // ── Prompts ───────────────────────────────────────────────────────────────
148
+
149
+ const ADVISOR_SYSTEM = `You are a senior engineering advisor. Use the session brief only. Return terse, specific advice with concrete recommendations. 200 words max.`;
150
+
151
+ const REVIEW_SYSTEM = `You are a senior reviewer. An AI agent just completed work. Assess it. Return ONLY valid JSON:
152
+ {
153
+ "verdict": "on_track"|"course_correct"|"not_done",
154
+ "summary": "1-2 sentence assessment",
155
+ "actions": ["action1"],
156
+ "checklist": ["item"],
157
+ "notify": true
158
+ }`;
159
+
160
+ // ── Helpers ───────────────────────────────────────────────────────────────
161
+
162
+ function hash(...parts: string[]): string {
163
+ return createHash("sha256").update(parts.join("||")).digest("hex").slice(0, 16);
164
+ }
165
+
166
+ function brief(s: SessionState): string {
167
+ const lines: string[] = [];
168
+ if (s.lastTask) lines.push(`Task: ${truncate(s.lastTask, 200)}`);
169
+ if (s.turns) lines.push(`Turns: ${s.turns}`);
170
+ if (s.notes.length) { lines.push("Notes:"); s.notes.slice(-4).forEach(n => lines.push(`- ${truncate(n, 200)}`)); }
171
+ if (s.files.length) lines.push(`Files: ${s.files.slice(-4).join(", ")}`);
172
+ if (s.errors.length) lines.push(`Errors: ${s.errors.slice(-2).join(" | ")}`);
173
+ return lines.join("\n").slice(0, 1200);
174
+ }
175
+
176
+ function squish(t: unknown, max = 200): string {
177
+ const s = String(t ?? "").replace(/\s+/g, " ").trim();
178
+ return s.length <= max ? s : s.slice(0, max - 1).trimEnd() + "…";
179
+ }
180
+
181
+ type AdvisorHintDetails = {
182
+ decision?: "continue" | "review" | "defer";
183
+ reason?: string;
184
+ summary?: string;
185
+ actions?: string[];
186
+ };
187
+
188
+ function sendAdvisorHint(pi: ExtensionAPI, decision: "continue" | "review" | "defer", reason: string, summary: string, actions: string[] = []) {
189
+ pi.sendMessage(
190
+ {
191
+ customType: "advisor:llm",
192
+ content: reason,
193
+ display: true,
194
+ details: { decision, reason, summary, actions: actions.slice(0, 2) },
195
+ },
196
+ { deliverAs: "followUp" },
197
+ );
198
+ }
199
+
200
+ function renderAdvisorHint(message: any, options: { expanded?: boolean }, theme: any) {
201
+ const details = (message?.details ?? {}) as AdvisorHintDetails;
202
+ const customType = String(message?.customType ?? "advisor:rules");
203
+ const decision = details.decision ?? "defer";
204
+ const sourceColor = customType === "advisor:llm" ? "success" : customType === "advisor:model" ? "accent" : "muted";
205
+ const decisionColor = decision === "review" ? "accent" : decision === "continue" ? "muted" : "dim";
206
+ const source = theme.bold(theme.fg(sourceColor, `[${customType}]`));
207
+ const verdict = theme.bold(theme.fg(decisionColor, decision));
208
+ const glyph = decision === "review" ? "↗" : decision === "defer" ? "…" : "·";
209
+ const reason = squish(details.reason || contentText(message?.content) || "no extra detail", 180);
210
+
211
+ const box = new Box(1, 1, (s: string) => theme.bg("customMessageBg", s));
212
+ box.addChild(new Text(`${theme.bold(theme.fg(decisionColor, glyph))} ${source} ${verdict} · ${theme.fg("dim", "reason: ")}${reason}`, 0, 0));
213
+
214
+ if (options.expanded && details.summary) {
215
+ box.addChild(new Text(theme.fg("dim", `summary: ${squish(details.summary, 220)}`), 0, 0));
216
+ }
217
+ if (options.expanded && details.actions?.length) {
218
+ box.addChild(new Text(theme.fg("dim", `actions: ${details.actions.map((a) => squish(a, 80)).join(" • ")}`), 0, 0));
219
+ }
220
+
221
+ return box;
222
+ }
223
+
224
+ /** Extract readable text from message content (handles both string and content-block arrays) */
225
+ function contentText(content: unknown): string {
226
+ if (typeof content === "string") return content.trim();
227
+ if (!Array.isArray(content)) return String(content ?? "").trim();
228
+ const parts: string[] = [];
229
+ for (const item of content) {
230
+ if (!item) continue;
231
+ if (typeof item === "string") { parts.push(item); continue; }
232
+ const obj = item as Record<string, unknown>;
233
+ if (obj.type === "text" && typeof obj.text === "string") parts.push(obj.text);
234
+ else if (typeof obj.text === "string") parts.push(obj.text);
235
+ }
236
+ return parts.join("\n").replace(/\s+/g, " ").trim();
237
+ }
238
+
239
+ /** Check if a tool result or message indicates an actual execution failure */
240
+ function isActualFailure(tool: any): boolean {
241
+ if (tool?.isError === true) return true;
242
+ if (tool?.status === "error" || tool?.status === "failure") return true;
243
+ if (tool?.error && String(tool.error).length > 0) return true;
244
+ return false;
245
+ }
246
+
247
+ function responseText(resp: { content?: Array<{ type?: string; text?: string }> } | null | undefined): string {
248
+ return (resp?.content ?? []).filter((b: any) => b?.type === "text").map((b: any) => b.text).join("\n").trim();
249
+ }
250
+
251
+ function mergeRouteReview(configReview: AdvisorConfig["review"], route?: ReviewPolicy): ReviewPolicy {
252
+ if (configReview === "off") return "off";
253
+ if (!route) return configReview;
254
+ return mergeReviewPolicy(configReview, route);
255
+ }
256
+
257
+ // ── Model resolution (auto-fallback through SOTA chain) ────────────────────
258
+ async function resolveModel(ctx: any, config: AdvisorConfig): Promise<{ model: any; auth: any; label: string } | null> {
259
+ // Try user's configured model first
260
+ if (config.model && config.model.includes("/")) {
261
+ const [p, ...m] = config.model.split("/");
262
+ const found = ctx.modelRegistry?.find(p, m.join("/"));
263
+ if (found) {
264
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(found);
265
+ if (auth?.ok && auth.apiKey) return { model: found, auth, label: p + "/" + m.join("/") };
266
+ }
267
+ }
268
+ // Fall through SOTA chain
269
+ for (const sota of SOTA_CHAIN) {
270
+ const found = ctx.modelRegistry?.find(sota.provider, sota.model);
271
+ if (!found) continue;
272
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(found);
273
+ if (auth?.ok && auth.apiKey) return { model: found, auth, label: sota.label };
274
+ }
275
+ // Any text model
276
+ const avail = (ctx.modelRegistry?.getAvailable() ?? []).filter((m: any) => m.input?.includes?.("text"));
277
+ if (avail.length) {
278
+ const m = avail[0];
279
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(m);
280
+ if (auth?.ok && auth.apiKey) return { model: m, auth, label: m.id || "unknown" };
281
+ }
282
+ return null;
283
+ }
284
+
285
+ async function askAdvisor(pi: ExtensionAPI, ctx: any, question: string, scope: string, includeWork: boolean) {
286
+ const config = loadConfig();
287
+ const state = loadState();
288
+ if (!question.trim()) return { text: "Ask a question.", error: "empty" };
289
+
290
+ const ck = hash("adv", config.model ?? "auto", squish(question, 300), includeWork ? brief(state) : "");
291
+ const cache = loadCache();
292
+ if (cache[ck]) { state.cacheHits++; saveState(state); return { text: cache[ck], cached: true }; }
293
+
294
+ const resolved = await resolveModel(ctx, config);
295
+ if (!resolved) return { text: "No model available. Install one via pi config.", error: "no_model" };
296
+
297
+ const msgs = [
298
+ { role: "user", content: [ `Question: ${question}`, scope ? `Scope: ${scope}` : "", includeWork && brief(state) ? `Session:\n${brief(state)}` : "" ].filter(Boolean).join("\n"), timestamp: new Date().toISOString() },
299
+ ] as any[];
300
+
301
+ const resp = await completeSimple(resolved.model, { systemPrompt: ADVISOR_SYSTEM, messages: msgs as any }, {
302
+ apiKey: resolved.auth.apiKey, headers: resolved.auth.headers,
303
+ maxTokens: 600, reasoning: "medium" as ThinkingLevel,
304
+ });
305
+ const text = responseText(resp) || "(empty)";
306
+ if (text && text !== "(empty)") { cache[ck] = text; saveCache(cache); }
307
+ state.advisorCalls++;
308
+ saveState(state);
309
+ return { text, model: resolved.label };
310
+ }
311
+
312
+ async function doReview(pi: ExtensionAPI, ctx: any, trigger: string, delta: string, meta: { fileChanged: boolean; failed: boolean; isAgentEnd: boolean }) {
313
+ const config = loadConfig();
314
+ if (config.review === "off") return;
315
+ const state = loadState();
316
+
317
+ const phase: AdvisorRouteInput["phase"] = meta.isAgentEnd ? "closeout" : "review";
318
+ const reviewInput: AdvisorRouteInput = {
319
+ phase,
320
+ text: delta || "(none)",
321
+ brief: brief(state),
322
+ fileChanged: meta.fileChanged,
323
+ failed: meta.failed,
324
+ };
325
+ const reviewHeuristic = heuristicRoute(reviewInput);
326
+ const gatePrediction = binaryGatePredict(reviewInput.text);
327
+ let reviewRoute = reviewHeuristic;
328
+ if (gatePrediction && gatePrediction.confidence >= 0.55 && !reviewHeuristic.safety) {
329
+ const binLabel = gatePrediction.decision === "continue" ? "continue" as const : "escalate_to_advisor" as const;
330
+ reviewRoute = {
331
+ ...reviewHeuristic,
332
+ source: "model",
333
+ reason: gatePrediction.decision === "continue"
334
+ ? "local gate predicts continue"
335
+ : "local gate predicts review",
336
+ review: binLabel === "continue" ? "off" as const : reviewHeuristic.review,
337
+ escalate: binLabel === "escalate_to_advisor",
338
+ };
339
+ }
340
+ appendRouteLog(reviewRoute);
341
+ state.router.review = reviewRoute;
342
+ saveState(state);
343
+
344
+ if (gatePrediction && gatePrediction.confidence >= 0.55 && gatePrediction.decision === "continue" && !reviewHeuristic.safety) {
345
+ return;
346
+ }
347
+
348
+ const effectiveReview = mergeRouteReview(config.review, state.router.preflight?.review);
349
+ const finalReview = mergeReviewPolicy(effectiveReview, reviewRoute.review);
350
+ if (finalReview === "off") return;
351
+
352
+ const shouldRun =
353
+ finalReview === "strict"
354
+ ? meta.isAgentEnd || meta.fileChanged || meta.failed || reviewRoute.label !== "abstain" || state.turns % 3 === 0
355
+ : meta.fileChanged || meta.failed;
356
+ if (!shouldRun) return;
357
+
358
+ const b = brief(state);
359
+ if (!b) return;
360
+
361
+ const rk = hash("rev", trigger, b, delta, String(meta.fileChanged), String(meta.failed), String(meta.isAgentEnd), String(reviewRoute.label));
362
+ const cache = loadCache();
363
+ if (cache[rk]) return; // already reviewed this
364
+
365
+ const resolved = await resolveModel(ctx, config);
366
+ if (!resolved) return;
367
+ const msgs = [
368
+ { role: "user", content: [
369
+ `Trigger: ${trigger}`,
370
+ `Task: ${state.lastTask || "(unknown)"}`,
371
+ `Delta: ${delta || "(none)"}`,
372
+ `Files: ${meta.fileChanged} Errors: ${meta.failed}`,
373
+ `Route: ${summarizeRoute(reviewRoute)}`,
374
+ `Brief:\n${b}`,
375
+ ].join("\n"), timestamp: new Date().toISOString() },
376
+ ] as any[];
377
+ const resp = await completeSimple(resolved.model, { systemPrompt: REVIEW_SYSTEM, messages: msgs as any }, {
378
+ apiKey: resolved.auth.apiKey, headers: resolved.auth.headers,
379
+ maxTokens: 400, reasoning: "low" as ThinkingLevel,
380
+ });
381
+ const raw = responseText(resp);
382
+ if (!raw) return;
383
+
384
+ cache[rk] = raw;
385
+ saveCache(cache);
386
+
387
+ // Try to parse JSON verdict
388
+ let json: any = null;
389
+ try { json = JSON.parse(raw.replace(/^```(?:json)?\n?/i, "").replace(/\n?```$/i, "")); } catch { /* ignore */ }
390
+ if (!json) return;
391
+
392
+ if (json.verdict === "on_track" && json.notify !== true) return;
393
+ if (json.verdict === "skip") return;
394
+ const decision = json.verdict === "on_track" ? "continue"
395
+ : json.verdict === "course_correct" ? "review"
396
+ : json.verdict === "not_done" ? "review"
397
+ : "defer";
398
+ const explanation = (json.reason || json.summary || "review result").slice(0, 120);
399
+ const display = formatAdvisorDisplay("advisor:llm", decision, explanation);
400
+ writeText(CURRENT_PATH, `${display}\n`);
401
+ sendAdvisorHint(pi, decision, explanation, json.summary || "", json.actions || []);
402
+
403
+ if (json.verdict !== "on_track") {
404
+ state.followUp = [json.summary, ...(json.actions?.slice(0, 2) || [])].filter(Boolean).join(" — ");
405
+ saveState(state);
406
+ }
407
+ }
408
+
409
+ // ── Extension entry point ──────────────────────────────────────────────────
410
+
411
+ export function registerAdvisor(pi: ExtensionAPI): void {
412
+ const config = loadConfig();
413
+
414
+ for (const customType of ["advisor:model", "advisor:rules", "advisor:llm"] as const) {
415
+ pi.registerMessageRenderer(customType, renderAdvisorHint);
416
+ }
417
+
418
+ // ── Tool ───────────────────────────────────────────────────────────────
419
+ pi.registerTool({
420
+ name: "advisor",
421
+ label: "Advisor",
422
+ description: "Strategic advisor. Call before architecture/refactor/tradeoff decisions. Uses best available model (default gpt-5.5).",
423
+ parameters: Type.Object({
424
+ question: Type.String({ description: "1 concise question" }),
425
+ scope: Type.Optional(Type.String({ description: "architecture|implementation|debug|review|planning" })),
426
+ includeRecentWork: Type.Optional(Type.Boolean({ description: "default: true" })),
427
+ }),
428
+ async execute(_id, params, _signal, onUpdate, ctx) {
429
+ const r = await askAdvisor(pi, ctx, String(params.question || ""), String(params.scope || ""), params.includeRecentWork !== false);
430
+ onUpdate?.({ content: [{ type: "text", text: r.cached ? "(cached)" : r.model ? `Consulting ${r.model}…` : "" }], details: {} });
431
+ return { content: [{ type: "text", text: r.text }], details: { cached: r.cached, error: r.error } };
432
+ },
433
+ });
434
+
435
+ // ── Preflight (heuristics only — no LLM call, <1ms) ──────────────────
436
+ pi.on("before_agent_start", async (event: any, ctx: any) => {
437
+ if (config.mode === "off" || config.mode === "manual") return { systemPrompt: event.systemPrompt };
438
+ const state = loadState();
439
+ const prompt = typeof event.prompt === "string" && event.prompt.trim() ? squish(event.prompt, 1000) : "";
440
+ if (prompt) state.lastTask = prompt;
441
+ const briefText = brief(state);
442
+ const intent = prompt ? classifyIntent(prompt) : "";
443
+ const mode = prompt ? classifyMode(prompt) : "";
444
+ const intentTag = intent ? `Intent: ${intent}` : "";
445
+ const modeTag = mode ? `Mode: ${mode}` : "";
446
+ // Enrich preflight text with session context so the binary gate has more signal
447
+ const enrichedText = [prompt, event.systemPrompt || "", briefText ? `Brief: ${briefText}` : "", intentTag, modeTag].filter(Boolean).join(" ");
448
+ const routeInput: AdvisorRouteInput = { phase: "preflight", text: enrichedText || prompt || event.systemPrompt || briefText || intentTag || modeTag || "", brief: briefText };
449
+
450
+ // Binary gate model — fast local classifier for continue/escalate decisions
451
+ const gatePrediction = binaryGatePredict(routeInput.text);
452
+ const heuristic = heuristicRoute(routeInput);
453
+ let route: AdvisorRouteDecision;
454
+ if (gatePrediction && gatePrediction.confidence >= 0.55) {
455
+ const binLabel = gatePrediction.decision === "continue" ? "continue" as const : "escalate_to_advisor" as const;
456
+ if (heuristic.safety) {
457
+ route = heuristic;
458
+ } else {
459
+ route = {
460
+ ...heuristic,
461
+ label: binLabel,
462
+ confidence: gatePrediction.confidence,
463
+ reason: gatePrediction.decision === "continue"
464
+ ? "local gate predicts continue"
465
+ : "local gate predicts review",
466
+ source: "model",
467
+ preflight: binLabel === "continue" ? "off" as const : "full" as const,
468
+ escalate: binLabel === "escalate_to_advisor",
469
+ };
470
+ }
471
+ } else {
472
+ route = heuristic;
473
+ }
474
+ appendRouteLog(route);
475
+ state.router.preflight = route;
476
+ const follow = state.followUp;
477
+ if (follow) { state.followUp = ""; }
478
+ saveState(state);
479
+
480
+ const note = routeNote(route);
481
+ writeText(CURRENT_PATH, `${note}\n`);
482
+ return {
483
+ systemPrompt: [
484
+ event.systemPrompt,
485
+ follow ? `Advisor follow-up:\n${follow}` : "",
486
+ note,
487
+ briefText ? `Brief (cache-aware):\n${briefText}` : "",
488
+ ].filter(Boolean).join("\n\n"),
489
+ };
490
+ });
491
+
492
+ // ── Post-review (turn_end) ─────────────────────────────────────────────
493
+ pi.on("turn_end", async (event: any, ctx: any) => {
494
+ if (config.mode === "off") return;
495
+ const state = loadState();
496
+ state.turns++;
497
+ const tools = (event.toolResults || []).map((t: any) => String(t?.toolName || t?.name || "tool"));
498
+ const fileChanged = tools.some((t: string) => /^(edit|write)$/i.test(t));
499
+ const failed = (event.toolResults || []).some((t: any) => isActualFailure(t));
500
+ const text = squish(event.message?.content || "");
501
+ if (text && text !== state.notes[state.notes.length - 1]) state.notes.push(text);
502
+ saveState(state);
503
+
504
+ if (config.review !== "off") {
505
+ await doReview(pi, ctx, `turn-${state.turns}`, text, { fileChanged, failed, isAgentEnd: false });
506
+ }
507
+ });
508
+
509
+ // ── Post-review (agent_end) ────────────────────────────────────────────
510
+ pi.on("agent_end", async (event: any, ctx: any) => {
511
+ if (config.mode === "off" || config.review === "off") return;
512
+ const state = loadState();
513
+ const msgs = (event.messages || []).filter((m: any) => m.role === "assistant" || m.role === "toolResult");
514
+ const last = msgs[msgs.length - 1];
515
+ const delta = contentText(last?.content) || "(none)";
516
+ const fileChanged = msgs.some((m: any) => /(?:write|edit)/i.test(JSON.stringify(m)));
517
+ const failed = msgs.some((m: any) => isActualFailure(m));
518
+ await doReview(pi, ctx, "agent-end", delta, { fileChanged, failed, isAgentEnd: true });
519
+ });
520
+
521
+ // ── /advisor command ───────────────────────────────────────────────────
522
+ pi.registerCommand("advisor", {
523
+ description: "Senior engineering advisor. Usage: /advisor [on|off|status|config|question]",
524
+ handler: async (args, ctx) => {
525
+ const a = String(args ?? "").trim().toLowerCase();
526
+ const [cmd, ...rest] = a.split(/\s+/);
527
+ const cfg = loadConfig();
528
+ const state = loadState();
529
+
530
+ if (!a || cmd === "status") {
531
+ const note = readText(CURRENT_PATH).trim();
532
+ const resolved = await resolveModel(ctx, cfg);
533
+ const route = state.router.review ?? state.router.preflight;
534
+ ctx.ui.notify([
535
+ note ? `🧭 ${truncate(note, 200)}` : "",
536
+ route ? `Router: ${summarizeRoute(route)}${route.safety ? " · safety" : ""}` : "",
537
+ "",
538
+ `Mode: ${cfg.mode} | Review: ${cfg.review} | Model: ${resolved?.label || cfg.model || "auto"}`,
539
+ `Turns: ${state.turns} | Calls: ${state.advisorCalls} | Cache hits: ${state.cacheHits}`,
540
+ "",
541
+ "Commands: /advisor on|off | /advisor status | /advisor config | <question>",
542
+ "Tip: SOTA models auto-detected. No config needed.",
543
+ ].filter(Boolean).join("\n"), "info");
544
+ return;
545
+ }
546
+
547
+ if (cmd === "on" && cfg.mode === "off") { saveConfig({ ...cfg, mode: "auto" }); ctx.ui.notify("Advisor enabled (auto mode).", "info"); return; }
548
+ if (cmd === "off") { saveConfig({ ...cfg, mode: "off" }); ctx.ui.notify("Advisor disabled.", "info"); return; }
549
+ if (cmd === "mode") {
550
+ const v = rest[0];
551
+ if (v === "auto" || v === "manual") { saveConfig({ ...cfg, mode: v }); ctx.ui.notify(`Mode set to ${v}.`, "info"); return; }
552
+ if (v === "off") { saveConfig({ ...cfg, mode: "off" }); ctx.ui.notify("Advisor disabled.", "info"); return; }
553
+ ctx.ui.notify("Usage: /advisor mode auto|manual|off", "error");
554
+ return;
555
+ }
556
+ if (cmd === "model") {
557
+ const v = rest.join("/").trim();
558
+ if (!v || !v.includes("/")) {
559
+ const resolved = await resolveModel(ctx, cfg);
560
+ ctx.ui.notify([
561
+ `Current: ${resolved?.label || "auto"}`,
562
+ "",
563
+ "Usage: /advisor model <provider>/<model>",
564
+ '(e.g. "openai-codex/gpt-5.5" or "anthropic/claude-opus-4-6")',
565
+ "Run /advisor status for SOTA options.",
566
+ ].join("\n"), "info");
567
+ return;
568
+ }
569
+ saveConfig({ ...cfg, model: v });
570
+ ctx.ui.notify(`Model set to ${v}. Remove field to auto-detect.`, "info");
571
+ return;
572
+ }
573
+ if (cmd === "config") {
574
+ ctx.ui.notify([
575
+ "Advisor config (3 fields, all optional):",
576
+ ` mode: "${cfg.mode}" — auto (preflight+post+cache) | manual | off`,
577
+ ` review: "${cfg.review}" — light (changes/errors) | strict (every 3) | off`,
578
+ ` model: "${cfg.model || "auto"}" — optional override`,
579
+ "",
580
+ "Router logs: evals/advisor-router.jsonl",
581
+ "Run /advisor <question> for immediate advice.",
582
+ ].join("\n"), "info");
583
+ return;
584
+ }
585
+ if (cmd === "review") {
586
+ const v = rest[0];
587
+ if (v === "light" || v === "strict" || v === "off") { saveConfig({ ...cfg, review: v }); ctx.ui.notify(`Review set to ${v}.`, "info"); return; }
588
+ ctx.ui.notify("Usage: /advisor review light|strict|off", "error");
589
+ return;
590
+ }
591
+
592
+ // Anything else: treat as a question to the advisor
593
+ const r = await askAdvisor(pi, ctx, a, "slash", true);
594
+ ctx.ui.notify(r.text, r.error ? "warning" : "info");
595
+ },
596
+ });
597
+ }
598
+
599
+ export default function advisorExtension(pi: ExtensionAPI) { registerAdvisor(pi); }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default, registerAdvisor } from "./extension.js";
2
+ export * from "./router.js";
@@ -0,0 +1,48 @@
1
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ const ROOT_DIR = join(homedir(), ".pi", "agent", "pi-rogue");
6
+
7
+ export function appDir(): string {
8
+ mkdirSync(ROOT_DIR, { recursive: true });
9
+ return ROOT_DIR;
10
+ }
11
+
12
+ export function featureDir(feature: string): string {
13
+ const dir = join(appDir(), feature);
14
+ mkdirSync(dir, { recursive: true });
15
+ return dir;
16
+ }
17
+
18
+ export function featureFile(feature: string, filename: string): string {
19
+ return join(featureDir(feature), filename);
20
+ }
21
+
22
+ export function truncate(text: string, max: number): string {
23
+ if (text.length <= max) return text;
24
+ return `${text.slice(0, Math.max(0, max - 1))}…`;
25
+ }
26
+
27
+ export function readText(filePath: string, fallback = ""): string {
28
+ try {
29
+ return readFileSync(filePath, "utf8");
30
+ } catch {
31
+ return fallback;
32
+ }
33
+ }
34
+
35
+ function ensureParent(filePath: string): string {
36
+ mkdirSync(dirname(filePath), { recursive: true });
37
+ return filePath;
38
+ }
39
+
40
+ export function writeText(filePath: string, text: string): void {
41
+ ensureParent(filePath);
42
+ writeFileSync(filePath, text, "utf8");
43
+ }
44
+
45
+ export function appendText(filePath: string, text: string): void {
46
+ ensureParent(filePath);
47
+ appendFileSync(filePath, text, "utf8");
48
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { classifyIntent, classifyMode } from "./preflight-signals.js";
3
+
4
+ describe("preflight signal classifiers", () => {
5
+ it("classifies planning prompts", () => {
6
+ expect(classifyIntent("what should we do next, design the architecture")).toBe("plan");
7
+ });
8
+
9
+ it("classifies implementation prompts", () => {
10
+ expect(classifyIntent("implement the auth flow and add tests")).toBe("implement");
11
+ });
12
+
13
+ it("classifies review prompts", () => {
14
+ expect(classifyIntent("please review this PR and check the diff")).toBe("review");
15
+ });
16
+
17
+ it("classifies questions vs commands", () => {
18
+ expect(classifyMode("what should we do next?")).toBe("question");
19
+ expect(classifyMode("run the tests and fix the bug")).toBe("command");
20
+ expect(classifyMode("hello there")).toBe("neutral");
21
+ });
22
+ });