pi-bro 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +7 -1
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/docs/README.md +2 -0
- package/docs/features/architecture.md +41 -0
- package/docs/overthink.md +48 -0
- package/docs/reference/commands.md +4 -2
- package/docs/reference/data-and-permissions.md +12 -0
- package/docs/reference/settings.md +2 -0
- package/package.json +10 -3
- package/resources/architecture-docs/SKILL.md +57 -0
- package/resources/architecture-docs/assets/architecture-viewer.html +86 -0
- package/resources/architecture-docs/references/evidence-and-updates.md +14 -0
- package/resources/architecture-docs/references/project-patterns.md +31 -0
- package/resources/architecture-docs/references/writing-and-diagrams.md +19 -0
- package/resources/architecture-docs/scripts/check.mjs +87 -0
- package/resources/architecture-docs/scripts/inventory.mjs +83 -0
- package/resources/architecture-docs/scripts/layout.mjs +61 -0
- package/resources/architecture-docs/scripts/pipeline.mjs +228 -0
- package/resources/architecture-docs/scripts/render.mjs +66 -0
- package/src/architecture.ts +150 -0
- package/src/bro.ts +21 -1
- package/src/config-navigation-ui.ts +15 -3
- package/src/config-navigation.ts +7 -5
- package/src/doctor.ts +19 -1
- package/src/explain-modal.ts +10 -1
- package/src/modal-kit.ts +7 -4
- package/src/overthink-questions-ui.ts +131 -0
- package/src/overthink-state.ts +204 -0
- package/src/overthink.ts +323 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/** Small private Overthink settings and session entries; custom entries never enter LLM context. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getAgentDir, type ExtensionAPI, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { isRecord } from "./util.ts";
|
|
7
|
+
|
|
8
|
+
export const OVERTHINK_ENTRY = "bro-overthink";
|
|
9
|
+
export const OVERTHINK_DEFAULTS_FILE = "bro-overthink-defaults.json";
|
|
10
|
+
export const FACTORY_QUESTIONS = [
|
|
11
|
+
"Are we going in the right direction?",
|
|
12
|
+
"Where are we now, where do we want to be, and where are we actually going?",
|
|
13
|
+
"Are we solving the real problem, or just completing the tasks in front of us?",
|
|
14
|
+
"What are we taking for granted that might be wrong?",
|
|
15
|
+
"What important risks or failure modes are we overlooking?",
|
|
16
|
+
"Have we drifted from the original intent or added complexity without enough value?",
|
|
17
|
+
"What is the most useful correction or next move we can make now?",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export const MAX_OVERTHINK_QUESTIONS = 32;
|
|
21
|
+
export const MAX_OVERTHINK_QUESTION_CHARS = 500;
|
|
22
|
+
const MAX_NOTE_CHARS = 8000;
|
|
23
|
+
export type OverthinkUsage = { cacheRead?: number; cacheWrite?: number; input?: number; output?: number; reasoning?: number; costTotal?: number };
|
|
24
|
+
export type OverthinkNote = {
|
|
25
|
+
text: string; checkedAt: string; model: string; questionKey: string;
|
|
26
|
+
/** Last model-relevant session entry at the time of the assessment. */
|
|
27
|
+
sourcePosition?: string;
|
|
28
|
+
usage?: OverthinkUsage;
|
|
29
|
+
};
|
|
30
|
+
export type OverthinkSession = { questions?: string[]; note?: OverthinkNote; initialized: boolean };
|
|
31
|
+
|
|
32
|
+
export function checkQuestions(value: unknown): string[] {
|
|
33
|
+
if (!Array.isArray(value) || value.length > MAX_OVERTHINK_QUESTIONS) {
|
|
34
|
+
throw new Error("Overthink allows at most 32 questions.");
|
|
35
|
+
}
|
|
36
|
+
const questions = value.map((item: unknown) => {
|
|
37
|
+
if (typeof item !== "string" || !item.trim() || item.length > MAX_OVERTHINK_QUESTION_CHARS || /[\r\n\u0000-\u001f]/.test(item)) {
|
|
38
|
+
throw new Error("Each question must be a single nonempty line of at most 500 characters.");
|
|
39
|
+
}
|
|
40
|
+
return item.trim();
|
|
41
|
+
});
|
|
42
|
+
return questions;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function questionKey(questions: readonly string[]): string { return JSON.stringify(questions); }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Session location represented by an assessment. Ignore Bro's custom entries
|
|
49
|
+
* so recording a note or editing questions does not itself make the note stale.
|
|
50
|
+
* Content edits and compaction count: they can change what the model sees.
|
|
51
|
+
*/
|
|
52
|
+
export function overthinkContextPosition(branch: readonly SessionEntry[]): string | undefined {
|
|
53
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
54
|
+
const entry = branch[i]!;
|
|
55
|
+
if (entry.type === "message" || entry.type === "custom_message" ||
|
|
56
|
+
entry.type === "context_edit" || entry.type === "compaction" || entry.type === "branch_summary") {
|
|
57
|
+
return entry.id;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function overthinkIsStale(note: OverthinkNote | undefined, position: string | undefined): boolean {
|
|
64
|
+
return Boolean(note && (!note.sourcePosition || note.sourcePosition !== position));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
export function resolveOverthinkSession(branch: readonly SessionEntry[]): OverthinkSession {
|
|
69
|
+
const state: OverthinkSession = { initialized: false };
|
|
70
|
+
for (const entry of branch) {
|
|
71
|
+
if (entry.type !== "custom" || entry.customType !== OVERTHINK_ENTRY || !isRecord(entry.data) || entry.data.version !== 1) continue;
|
|
72
|
+
if (entry.data.kind === "questions") {
|
|
73
|
+
try { state.questions = checkQuestions(entry.data.questions); state.initialized = true; }
|
|
74
|
+
catch { /* Ignore damaged historical records without granting them authority. */ }
|
|
75
|
+
} else if (entry.data.kind === "note") {
|
|
76
|
+
const data = entry.data;
|
|
77
|
+
if (typeof data.text === "string" && data.text.length <= MAX_NOTE_CHARS &&
|
|
78
|
+
typeof data.checkedAt === "string" && typeof data.model === "string" &&
|
|
79
|
+
typeof data.questionKey === "string") {
|
|
80
|
+
const note: OverthinkNote = {
|
|
81
|
+
text: data.text, checkedAt: data.checkedAt, model: data.model, questionKey: data.questionKey,
|
|
82
|
+
...(typeof data.sourcePosition === "string" && data.sourcePosition ? { sourcePosition: data.sourcePosition } : {}),
|
|
83
|
+
};
|
|
84
|
+
if (isRecord(data.usage)) {
|
|
85
|
+
const usage: OverthinkUsage = {};
|
|
86
|
+
for (const key of ["cacheRead", "cacheWrite", "input", "output", "reasoning", "costTotal"] as const) {
|
|
87
|
+
const val = data.usage[key];
|
|
88
|
+
if (typeof val === "number" && Number.isFinite(val) && val >= 0) usage[key] = val;
|
|
89
|
+
}
|
|
90
|
+
note.usage = usage;
|
|
91
|
+
}
|
|
92
|
+
state.note = note;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return state;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function globalQuestionsFile(dir = getAgentDir()): string {
|
|
100
|
+
return join(dir, OVERTHINK_DEFAULTS_FILE);
|
|
101
|
+
}
|
|
102
|
+
export type OverthinkGlobal = { questions: string[]; automatic: boolean };
|
|
103
|
+
/** Missing flag defaults to on, including files written before the on/off switch existed. */
|
|
104
|
+
export async function readGlobalOverthink(dir = getAgentDir()): Promise<OverthinkGlobal> {
|
|
105
|
+
const file = globalQuestionsFile(dir);
|
|
106
|
+
let info;
|
|
107
|
+
try { info = await lstat(file); }
|
|
108
|
+
catch (error) {
|
|
109
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { questions: [...FACTORY_QUESTIONS], automatic: true };
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 32768) throw new Error("Unsafe or oversized Overthink defaults file: " + file);
|
|
113
|
+
const data = JSON.parse(await readFile(file, "utf8")) as unknown;
|
|
114
|
+
if (!isRecord(data) || data.version !== 1 ||
|
|
115
|
+
(data.automatic !== undefined && typeof data.automatic !== "boolean")) {
|
|
116
|
+
throw new Error("Unsupported Overthink defaults format: " + file);
|
|
117
|
+
}
|
|
118
|
+
return { questions: checkQuestions(data.questions), automatic: data.automatic ?? true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function readGlobalQuestions(dir = getAgentDir()): Promise<string[]> {
|
|
122
|
+
return (await readGlobalOverthink(dir)).questions;
|
|
123
|
+
}
|
|
124
|
+
export async function readAutomaticChecks(dir = getAgentDir()): Promise<boolean> {
|
|
125
|
+
return (await readGlobalOverthink(dir)).automatic;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function writeGlobalOverthink(settings: OverthinkGlobal, dir = getAgentDir()): Promise<void> {
|
|
129
|
+
const cleaned = checkQuestions([...settings.questions]);
|
|
130
|
+
if (typeof settings.automatic !== "boolean") throw new Error("Automatic checks must be on or off.");
|
|
131
|
+
await mkdir(dir, { recursive: true });
|
|
132
|
+
const file = globalQuestionsFile(dir);
|
|
133
|
+
try {
|
|
134
|
+
const info = await lstat(file);
|
|
135
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error("Unsafe Overthink defaults file: " + file);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
138
|
+
}
|
|
139
|
+
const temp = file + "." + randomUUID() + ".tmp";
|
|
140
|
+
try {
|
|
141
|
+
await writeFile(temp, JSON.stringify({ version: 1, questions: cleaned, automatic: settings.automatic }, null, 2) + "\n", {
|
|
142
|
+
encoding: "utf8", mode: 0o600, flag: "wx", flush: true,
|
|
143
|
+
});
|
|
144
|
+
await rename(temp, file);
|
|
145
|
+
} finally { await rm(temp, { force: true }).catch(() => undefined); }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function writeGlobalQuestions(questions: readonly string[], dir = getAgentDir()): Promise<void> {
|
|
149
|
+
const previous = await readGlobalOverthink(dir);
|
|
150
|
+
await writeGlobalOverthink({ ...previous, questions: checkQuestions([...questions]) }, dir);
|
|
151
|
+
}
|
|
152
|
+
export async function writeAutomaticChecks(automatic: boolean, dir = getAgentDir()): Promise<void> {
|
|
153
|
+
const previous = await readGlobalOverthink(dir);
|
|
154
|
+
await writeGlobalOverthink({ ...previous, automatic }, dir);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function ensureOverthinkSession(pi: ExtensionAPI, branch: readonly SessionEntry[]): Promise<OverthinkSession> {
|
|
158
|
+
const current = resolveOverthinkSession(branch);
|
|
159
|
+
if (current.initialized) return current;
|
|
160
|
+
const questions = await readGlobalQuestions();
|
|
161
|
+
pi.appendEntry(OVERTHINK_ENTRY, { version: 1, kind: "questions", questions });
|
|
162
|
+
return { ...current, initialized: true, questions };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function saveSessionQuestions(pi: ExtensionAPI, questions: readonly string[]): void {
|
|
166
|
+
pi.appendEntry(OVERTHINK_ENTRY, { version: 1, kind: "questions", questions: checkQuestions([...questions]) });
|
|
167
|
+
}
|
|
168
|
+
export function saveOverthinkNote(pi: ExtensionAPI, note: OverthinkNote): void {
|
|
169
|
+
pi.appendEntry(OVERTHINK_ENTRY, { version: 1, kind: "note", ...note });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function overthinkPrompt(questions: readonly string[]): string {
|
|
173
|
+
return [
|
|
174
|
+
"[Bro Overthink: private second look]",
|
|
175
|
+
"Reassess the current work using the preceding Pi conversation as evidence. Consider these user-written questions:",
|
|
176
|
+
...questions.map((q, i) => String(i + 1) + ". " + q),
|
|
177
|
+
"Find only material points that could change the next decision or the quality of the work.",
|
|
178
|
+
"Prefer direction, assumptions, evidence, risks, coherence, and the next useful move. Do not fabricate problems, demand checks for hypothetical edge cases, or restate concerns already clear in the last assistant reply.",
|
|
179
|
+
"Write in the user's language. Keep it short: at most three specific findings with evidence and a useful next move. Answer in plain text only; do not call tools or take actions. No mandatory template or question-by-question answers.",
|
|
180
|
+
"When nothing material is worth bringing up, return exactly NONE.",
|
|
181
|
+
].join("\n");
|
|
182
|
+
}
|
|
183
|
+
export function parseOverthinkAnswer(text: string): string {
|
|
184
|
+
const cleaned = text.replace(/\u001b\[[0-9;]*m/g, "").trim();
|
|
185
|
+
if (/^none\.?$/i.test(cleaned)) return "";
|
|
186
|
+
return cleaned.slice(0, MAX_NOTE_CHARS);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Only use provider-reported values; absence means unknown, never zero. */
|
|
190
|
+
export function readOverthinkUsage(raw: unknown): OverthinkUsage | undefined {
|
|
191
|
+
if (!isRecord(raw)) return undefined;
|
|
192
|
+
const usage: OverthinkUsage = {};
|
|
193
|
+
const fields = { cacheRead: ["cacheReadTokens", "cacheRead"], cacheWrite: ["cacheWriteTokens", "cacheWrite"], input: ["inputTokens", "input"], output: ["outputTokens", "output"], reasoning: ["reasoningTokens", "reasoning"] } as const;
|
|
194
|
+
for (const key of Object.keys(fields) as (keyof typeof fields)[]) {
|
|
195
|
+
for (const field of fields[key]) {
|
|
196
|
+
const value = raw[field];
|
|
197
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) { usage[key] = value; break; }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (isRecord(raw.cost) && typeof raw.cost.total === "number" && Number.isFinite(raw.cost.total) && raw.cost.total >= 0) {
|
|
201
|
+
usage.costTotal = raw.cost.total;
|
|
202
|
+
}
|
|
203
|
+
return Object.keys(usage).length ? usage : undefined;
|
|
204
|
+
}
|
package/src/overthink.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/** Automatic same-model second look. No separate CLI, agent or model override. */
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { convertToLlm, copyToClipboard } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { normalizeContext } from "@earendil-works/pi-ai";
|
|
5
|
+
import { matchesKey, wrapTextWithAnsi, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
6
|
+
import { modalHeight, openBroOverlay, renderFrame, Scroller, formatNotice, type Theme } from "./modal-kit.ts";
|
|
7
|
+
import { hasBroCustomUi } from "./ui-capabilities.ts";
|
|
8
|
+
import {
|
|
9
|
+
FACTORY_QUESTIONS, ensureOverthinkSession, overthinkPrompt, parseOverthinkAnswer,
|
|
10
|
+
questionKey, readAutomaticChecks, readGlobalQuestions, readOverthinkUsage, resolveOverthinkSession,
|
|
11
|
+
overthinkContextPosition, overthinkIsStale,
|
|
12
|
+
saveOverthinkNote, saveSessionQuestions, writeGlobalQuestions,
|
|
13
|
+
type OverthinkNote,
|
|
14
|
+
} from "./overthink-state.ts";
|
|
15
|
+
import { openOverthinkQuestionEditor } from "./overthink-questions-ui.ts";
|
|
16
|
+
import { errorMessage } from "./util.ts";
|
|
17
|
+
|
|
18
|
+
const MIN_TOOL_RESULTS = 3;
|
|
19
|
+
const WIDGET = "bro-overthink";
|
|
20
|
+
const NOTE_TIMEOUT_MS = 90_000;
|
|
21
|
+
|
|
22
|
+
type ViewerAction = "close" | "edit";
|
|
23
|
+
type CheckResult = OverthinkNote;
|
|
24
|
+
type CheckContext = ExtensionContext | ExtensionCommandContext;
|
|
25
|
+
|
|
26
|
+
export type OverthinkRunEvidence = { toolResults: number; normalFinish: boolean };
|
|
27
|
+
/** Collect evidence at each agent_end, but never start a check until agent_settled. */
|
|
28
|
+
export function accumulateOverthinkEvidence(
|
|
29
|
+
prior: OverthinkRunEvidence | undefined,
|
|
30
|
+
event: { messages: readonly { role: string; stopReason?: string }[] },
|
|
31
|
+
): OverthinkRunEvidence {
|
|
32
|
+
const last = event.messages.at(-1);
|
|
33
|
+
return {
|
|
34
|
+
toolResults: (prior?.toolResults ?? 0) + event.messages.filter(m => m.role === "toolResult").length,
|
|
35
|
+
normalFinish: last?.role === "assistant" && last.stopReason === "stop",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function overthinkReady(evidence: OverthinkRunEvidence | undefined, aborted: boolean, pending: boolean): boolean {
|
|
40
|
+
return !aborted && !pending && Boolean(evidence?.normalFinish && evidence.toolResults >= MIN_TOOL_RESULTS);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
function noteWidget(ctx: CheckContext, note?: string): void {
|
|
45
|
+
if (ctx.mode !== "tui") return;
|
|
46
|
+
ctx.ui.setWidget(WIDGET, note
|
|
47
|
+
? (_tui, theme) => ({
|
|
48
|
+
invalidate() {},
|
|
49
|
+
render(width: number) {
|
|
50
|
+
const label = "◇ Overthink: ";
|
|
51
|
+
const lines = wrapTextWithAnsi(note.replace(/\s+/g, " "), Math.max(12, width - label.length - 2)).slice(0, 3);
|
|
52
|
+
return ["", ...lines.map((line, index) =>
|
|
53
|
+
(index === 0 ? theme.fg("warning", label) : " ".repeat(label.length)) + theme.fg("muted", line)
|
|
54
|
+
), theme.fg("dim", " /bro overthink to view the private assessment")];
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
: undefined);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function questionStatus(note: OverthinkNote | undefined, questions: string[], position?: string): string {
|
|
61
|
+
if (!note) return "No assessment yet.";
|
|
62
|
+
const changed = note.questionKey !== questionKey(questions) ? " · questions changed since check" : "";
|
|
63
|
+
const freshness = overthinkIsStale(note, position)
|
|
64
|
+
? (note.sourcePosition ? " · OUTDATED: conversation has changed since this check" : " · source position not recorded")
|
|
65
|
+
: " · current for this session position";
|
|
66
|
+
return "Checked: " + note.checkedAt + " · " + note.model + freshness + changed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A reported zero may mean a miss OR a provider that does not support cache accounting. */
|
|
70
|
+
export function usageStatus(note: OverthinkNote | undefined): string[] {
|
|
71
|
+
const u = note?.usage;
|
|
72
|
+
if (!u) return ["Cost/usage: not reported by provider."];
|
|
73
|
+
const totals = [
|
|
74
|
+
u.input !== undefined ? "input " + u.input.toLocaleString("en-US") : undefined,
|
|
75
|
+
u.cacheRead !== undefined ? "cache read " + u.cacheRead.toLocaleString("en-US") : undefined,
|
|
76
|
+
u.cacheWrite !== undefined ? "cache write " + u.cacheWrite.toLocaleString("en-US") : undefined,
|
|
77
|
+
u.output !== undefined ? "output " + u.output.toLocaleString("en-US") : undefined,
|
|
78
|
+
u.reasoning !== undefined ? "reasoning " + u.reasoning.toLocaleString("en-US") : undefined,
|
|
79
|
+
].filter((value): value is string => value !== undefined);
|
|
80
|
+
const cost = u.costTotal !== undefined ? " · est. cost $" + u.costTotal.toFixed(6) + " (Pi price table; not billed amount)" : "";
|
|
81
|
+
return [
|
|
82
|
+
"Provider-reported tokens: " + (totals.length ? totals.join(" · ") : "token counts unavailable") + cost,
|
|
83
|
+
...(u.cacheRead === 0 ? ["Cache read 0 can mean no hit or caching unsupported."] : []),
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createOverthinkViewer(
|
|
88
|
+
read: () => { questions: string[]; note?: OverthinkNote; position?: string },
|
|
89
|
+
retry: () => Promise<CheckResult>,
|
|
90
|
+
) {
|
|
91
|
+
return (tui: TUI, theme: Theme, _keys: unknown, done: (action: ViewerAction) => void): Component => {
|
|
92
|
+
let help = false;
|
|
93
|
+
let busy = false;
|
|
94
|
+
let closed = false;
|
|
95
|
+
let notice = "";
|
|
96
|
+
const scroller = new Scroller(() => tui.requestRender());
|
|
97
|
+
const copy = async () => {
|
|
98
|
+
const note = read().note;
|
|
99
|
+
if (!note?.text) { notice = formatNotice(theme, "Nothing to copy yet.", "warning"); tui.requestRender(); return; }
|
|
100
|
+
try { await copyToClipboard(note.text); notice = formatNotice(theme, "Assessment copied.", "success"); }
|
|
101
|
+
catch (error) { notice = formatNotice(theme, "Copy failed: " + errorMessage(error), "error"); }
|
|
102
|
+
tui.requestRender();
|
|
103
|
+
};
|
|
104
|
+
const rerun = async () => {
|
|
105
|
+
if (busy || closed) return;
|
|
106
|
+
busy = true; notice = ""; tui.requestRender();
|
|
107
|
+
try {
|
|
108
|
+
const result = await retry();
|
|
109
|
+
if (!closed) { scroller.toStart(); notice = formatNotice(theme, result.text ? "Assessment refreshed." : "No material concerns found.", "success"); }
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (!closed) notice = formatNotice(theme, "Retry failed: " + errorMessage(error), "error");
|
|
112
|
+
} finally { busy = false; if (!closed) tui.requestRender(); }
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
render(width: number) {
|
|
116
|
+
const { questions, note, position } = read();
|
|
117
|
+
const inner = Math.max(1, width - 2);
|
|
118
|
+
const body = help
|
|
119
|
+
? [
|
|
120
|
+
"Overthink checks substantive Pi tasks automatically with the active session model.",
|
|
121
|
+
"Results are private and never enter the main conversation.",
|
|
122
|
+
"",
|
|
123
|
+
"Ctrl+R Force a fresh check (even after a small task)",
|
|
124
|
+
"Ctrl+E Edit this session's questions",
|
|
125
|
+
"? Toggle help",
|
|
126
|
+
"Ctrl+C Copy latest findings",
|
|
127
|
+
"Esc Close / back from help",
|
|
128
|
+
"",
|
|
129
|
+
"No separate backend or model picker. Cached input is measured when the provider reports it.",
|
|
130
|
+
]
|
|
131
|
+
: [
|
|
132
|
+
questionStatus(note, questions, position),
|
|
133
|
+
"Questions: " + questions.length + (questions.length ? "" : " (checks disabled)"),
|
|
134
|
+
...usageStatus(note),
|
|
135
|
+
"",
|
|
136
|
+
busy ? "Checking the current Pi session…" : note
|
|
137
|
+
? note.text || "No material concerns found in the last check."
|
|
138
|
+
: "No assessment yet. Bro checks automatically after meaningful work. Ctrl+R forces one now.",
|
|
139
|
+
];
|
|
140
|
+
const lines = body.flatMap(line => wrapTextWithAnsi(line, inner));
|
|
141
|
+
return renderFrame(theme, width, modalHeight(tui), {
|
|
142
|
+
header: () => theme.fg("accent", theme.bold("Bro · Overthink")) + (busy ? " · running" : ""),
|
|
143
|
+
body: (rows) => scroller.view(lines, rows),
|
|
144
|
+
notice,
|
|
145
|
+
hints: "↑/↓ scroll · Ctrl+R retry · Ctrl+E session questions · ? help · Ctrl+C copy · Esc close",
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
invalidate() {},
|
|
149
|
+
handleInput(data: string) {
|
|
150
|
+
if (matchesKey(data, "escape")) {
|
|
151
|
+
if (help) { help = false; scroller.toStart(); tui.requestRender(); }
|
|
152
|
+
else { closed = true; done("close"); }
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (matchesKey(data, "?")) { help = !help; scroller.toStart(); tui.requestRender(); return; }
|
|
156
|
+
if (matchesKey(data, "ctrl+r")) { void rerun(); return; }
|
|
157
|
+
if (matchesKey(data, "ctrl+c")) { void copy(); return; }
|
|
158
|
+
if (matchesKey(data, "ctrl+e") && !busy) { closed = true; done("edit"); return; }
|
|
159
|
+
scroller.handleKey(data);
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function openOverthinkDefaults(ctx: ExtensionCommandContext): Promise<void> {
|
|
166
|
+
const current = await readGlobalQuestions();
|
|
167
|
+
await openOverthinkQuestionEditor(ctx, {
|
|
168
|
+
title: "Bro · Overthink / Default questions",
|
|
169
|
+
questions: current,
|
|
170
|
+
reset: async () => [...FACTORY_QUESTIONS],
|
|
171
|
+
save: writeGlobalQuestions,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function registerOverthink(pi: ExtensionAPI, loadAutomaticChecks = readAutomaticChecks): {
|
|
176
|
+
open(ctx: ExtensionCommandContext): Promise<void>;
|
|
177
|
+
} {
|
|
178
|
+
let active: AbortController | undefined;
|
|
179
|
+
let generation = 0;
|
|
180
|
+
let evidence: OverthinkRunEvidence | undefined;
|
|
181
|
+
const invalidate = (_event: unknown, ctx: ExtensionContext) => {
|
|
182
|
+
generation++;
|
|
183
|
+
evidence = undefined;
|
|
184
|
+
active?.abort(); active = undefined;
|
|
185
|
+
noteWidget(ctx);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// The first session event locks its question defaults; subsequent branch/fork views
|
|
189
|
+
// inherit the nearest private custom entry rather than sharing a project setting.
|
|
190
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
191
|
+
invalidate(_event, ctx);
|
|
192
|
+
try { await ensureOverthinkSession(pi, ctx.sessionManager.getBranch()); }
|
|
193
|
+
catch (error) { if (ctx.mode === "tui") ctx.ui.notify("Overthink settings: " + errorMessage(error), "warning"); }
|
|
194
|
+
});
|
|
195
|
+
pi.on("session_tree", invalidate);
|
|
196
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
197
|
+
// A continuation or retry can begin a new agent loop before the overall
|
|
198
|
+
// run settles. Cancel in-flight checks and stale scheduling, but keep the
|
|
199
|
+
// tool evidence until agent_settled decides whether the full run qualifies.
|
|
200
|
+
generation++;
|
|
201
|
+
active?.abort(); active = undefined;
|
|
202
|
+
noteWidget(ctx);
|
|
203
|
+
});
|
|
204
|
+
pi.on("session_shutdown", async (_event, ctx) => { invalidate(_event, ctx); });
|
|
205
|
+
|
|
206
|
+
type Trigger = { generation: number; sessionId: string; position?: string };
|
|
207
|
+
const run = async (ctx: CheckContext, forced: boolean, trigger?: Trigger): Promise<CheckResult> => {
|
|
208
|
+
if (!ctx.model) throw new Error("No active Pi model; Overthink cannot choose a different one.");
|
|
209
|
+
const marker = trigger?.generation ?? generation;
|
|
210
|
+
const sessionId = trigger?.sessionId ?? ctx.sessionManager.getSessionId();
|
|
211
|
+
const position = trigger?.position ?? overthinkContextPosition(ctx.sessionManager.getBranch());
|
|
212
|
+
const stillCurrent = () => marker === generation &&
|
|
213
|
+
sessionId === ctx.sessionManager.getSessionId() &&
|
|
214
|
+
position === overthinkContextPosition(ctx.sessionManager.getBranch());
|
|
215
|
+
if (!stillCurrent()) throw new Error("Session moved before assessment could start.");
|
|
216
|
+
const state = await ensureOverthinkSession(pi, ctx.sessionManager.getBranch());
|
|
217
|
+
const questions = state.questions ?? [];
|
|
218
|
+
if (!questions.length) throw new Error("No Overthink questions. Edit session questions to enable checks.");
|
|
219
|
+
if (!stillCurrent()) throw new Error("Session changed; assessment cancelled.");
|
|
220
|
+
|
|
221
|
+
const projected = ctx.sessionManager.buildSessionProjection().messages;
|
|
222
|
+
if (!projected.length && forced) throw new Error("Nothing in the current conversation to assess.");
|
|
223
|
+
if (!projected.length) throw new Error("No conversation to assess.");
|
|
224
|
+
|
|
225
|
+
active?.abort();
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
active = controller;
|
|
228
|
+
const model = ctx.model;
|
|
229
|
+
const name = model.provider + "/" + model.id;
|
|
230
|
+
const effort = pi.getThinkingLevel();
|
|
231
|
+
const ask = { role: "user" as const, content: [{ type: "text" as const, text: overthinkPrompt(questions) }], timestamp: Date.now() };
|
|
232
|
+
const timer = setTimeout(() => controller.abort(), NOTE_TIMEOUT_MS);
|
|
233
|
+
try {
|
|
234
|
+
// The exact projected message prefix is the basis of provider cache reuse.
|
|
235
|
+
// No second agent session, generated summary, extra tools or backend override.
|
|
236
|
+
const answer = await ctx.modelRegistry.streamSimple(
|
|
237
|
+
model,
|
|
238
|
+
normalizeContext({ messages: convertToLlm([...projected, ask]) }),
|
|
239
|
+
{
|
|
240
|
+
reasoning: model.reasoning && effort !== "off" ? effort : undefined,
|
|
241
|
+
maxTokens: 2200,
|
|
242
|
+
sessionId: sessionId ?? undefined,
|
|
243
|
+
signal: controller.signal,
|
|
244
|
+
},
|
|
245
|
+
).result();
|
|
246
|
+
if (controller.signal.aborted) throw new Error("Assessment cancelled or timed out.");
|
|
247
|
+
if (!stillCurrent() ||
|
|
248
|
+
ctx.model?.provider !== model.provider || ctx.model?.id !== model.id ||
|
|
249
|
+
active !== controller) throw new Error("Session or model changed; stale result discarded.");
|
|
250
|
+
if (answer.stopReason !== "stop") throw new Error("Model did not finish its assessment (" + answer.stopReason + ").");
|
|
251
|
+
const currentQuestions = resolveOverthinkSession(ctx.sessionManager.getBranch()).questions ?? [];
|
|
252
|
+
if (questionKey(currentQuestions) !== questionKey(questions)) throw new Error("Questions changed during check; stale result discarded.");
|
|
253
|
+
const text = parseOverthinkAnswer(answer.content.filter(part => part.type === "text").map(part => part.text).join(""));
|
|
254
|
+
const usage = readOverthinkUsage(answer.usage);
|
|
255
|
+
const note: OverthinkNote = {
|
|
256
|
+
text, checkedAt: new Date().toISOString(), model: name, questionKey: questionKey(questions),
|
|
257
|
+
...(position ? { sourcePosition: position } : {}),
|
|
258
|
+
...(usage ? { usage } : {}),
|
|
259
|
+
};
|
|
260
|
+
saveOverthinkNote(pi, note);
|
|
261
|
+
noteWidget(ctx, text);
|
|
262
|
+
return note;
|
|
263
|
+
} finally {
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
if (active === controller) active = undefined;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
pi.on("agent_end", (event, ctx) => {
|
|
270
|
+
if (ctx.mode !== "tui") return;
|
|
271
|
+
evidence = accumulateOverthinkEvidence(evidence, event);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// agent_end is not guaranteed to mean Pi has finished continuation, retry or
|
|
275
|
+
// compaction. agent_settled is available in Pi >=1.3, our supported floor.
|
|
276
|
+
pi.on("agent_settled", (event, ctx) => {
|
|
277
|
+
const completed = evidence;
|
|
278
|
+
evidence = undefined;
|
|
279
|
+
if (ctx.mode !== "tui" || !ctx.model ||
|
|
280
|
+
!overthinkReady(completed, event.aborted, ctx.hasPendingMessages())) return;
|
|
281
|
+
// Capture BEFORE the asynchronous settings read. A new run, fork or
|
|
282
|
+
// compaction must not turn an older settlement into a request for new work.
|
|
283
|
+
const trigger = {
|
|
284
|
+
generation, sessionId: ctx.sessionManager.getSessionId(),
|
|
285
|
+
position: overthinkContextPosition(ctx.sessionManager.getBranch()),
|
|
286
|
+
};
|
|
287
|
+
void (async () => {
|
|
288
|
+
if (!(await loadAutomaticChecks())) return;
|
|
289
|
+
if (trigger.generation !== generation ||
|
|
290
|
+
trigger.sessionId !== ctx.sessionManager.getSessionId() ||
|
|
291
|
+
trigger.position !== overthinkContextPosition(ctx.sessionManager.getBranch()) ||
|
|
292
|
+
ctx.hasPendingMessages()) return;
|
|
293
|
+
await run(ctx, false, trigger);
|
|
294
|
+
})().catch(() => undefined); // Optional review must never fail the main task.
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const open = async (ctx: ExtensionCommandContext): Promise<void> => {
|
|
298
|
+
if (!hasBroCustomUi(ctx)) {
|
|
299
|
+
ctx.ui.notify("Use /bro overthink in Pi's interactive UI.", "warning");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
await ensureOverthinkSession(pi, ctx.sessionManager.getBranch());
|
|
303
|
+
for (;;) {
|
|
304
|
+
const action = await openBroOverlay<ViewerAction>(ctx, createOverthinkViewer(
|
|
305
|
+
() => {
|
|
306
|
+
const state = resolveOverthinkSession(ctx.sessionManager.getBranch());
|
|
307
|
+
return { questions: state.questions ?? [], note: state.note,
|
|
308
|
+
position: overthinkContextPosition(ctx.sessionManager.getBranch()) };
|
|
309
|
+
},
|
|
310
|
+
() => run(ctx, true),
|
|
311
|
+
));
|
|
312
|
+
if (action !== "edit") return;
|
|
313
|
+
const state = resolveOverthinkSession(ctx.sessionManager.getBranch());
|
|
314
|
+
await openOverthinkQuestionEditor(ctx, {
|
|
315
|
+
title: "Bro · Overthink / Session questions",
|
|
316
|
+
questions: state.questions ?? [],
|
|
317
|
+
reset: readGlobalQuestions,
|
|
318
|
+
save: (questions) => saveSessionQuestions(pi, questions),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
return { open };
|
|
323
|
+
}
|