@roodriigoooo/pi-scrutiny 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,261 @@
1
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component, TUI } from "@earendil-works/pi-tui";
3
+ import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import type { ScrutinySurface } from "./types.js";
5
+ import { formatTokens, truncate } from "./util.js";
6
+
7
+ export type PacketPreviewInput = {
8
+ runId: string;
9
+ surface: ScrutinySurface;
10
+ packet: string;
11
+ panelCount: number;
12
+ judgeRan: boolean;
13
+ verifyRan: boolean;
14
+ };
15
+
16
+ export async function confirmPacketPreview(ctx: ExtensionCommandContext, input: PacketPreviewInput): Promise<string | null> {
17
+ return ctx.ui.custom<string | null>(
18
+ (tui, theme, _kb, done) => new PacketPreview(tui, theme, input, done),
19
+ {
20
+ overlay: true,
21
+ overlayOptions: {
22
+ anchor: "center",
23
+ width: "76%",
24
+ minWidth: 72,
25
+ maxHeight: "84%",
26
+ margin: 1,
27
+ },
28
+ },
29
+ );
30
+ }
31
+
32
+ type PacketStats = {
33
+ packetTokens: number;
34
+ replicatedTokens: number;
35
+ sections: string[];
36
+ hasGit: boolean;
37
+ scout?: string;
38
+ scoutCandidates: string[];
39
+ priorCount: number;
40
+ possibleGaps: string[];
41
+ };
42
+
43
+ type CandidateBlock = {
44
+ id: number;
45
+ title: string;
46
+ lines: string[];
47
+ start: number;
48
+ end: number;
49
+ };
50
+
51
+ class PacketPreview implements Component {
52
+ private showPacket = false;
53
+ private selected = 0;
54
+ private readonly candidates: CandidateBlock[];
55
+ private readonly disabled = new Set<number>();
56
+
57
+ constructor(
58
+ private readonly tui: TUI,
59
+ private readonly theme: Theme,
60
+ private readonly input: PacketPreviewInput,
61
+ private readonly done: (value: string | null) => void,
62
+ ) {
63
+ this.candidates = extractCandidateBlocks(input.packet);
64
+ }
65
+
66
+ handleInput(data: string): void {
67
+ if (matchesKey(data, Key.enter)) return this.done(this.currentPacket());
68
+ if (matchesKey(data, Key.escape)) return this.done(null);
69
+ if (matchesKey(data, Key.up)) {
70
+ this.selected = Math.max(0, this.selected - 1);
71
+ return this.rerender();
72
+ }
73
+ if (matchesKey(data, Key.down)) {
74
+ this.selected = Math.min(Math.max(0, this.candidates.length - 1), this.selected + 1);
75
+ return this.rerender();
76
+ }
77
+ if (matchesKey(data, Key.space)) {
78
+ const candidate = this.candidates[this.selected];
79
+ if (candidate) {
80
+ if (this.disabled.has(candidate.id)) this.disabled.delete(candidate.id);
81
+ else this.disabled.add(candidate.id);
82
+ return this.rerender();
83
+ }
84
+ }
85
+ if (matchesKey(data, Key.ctrl("o")) || matchesKey(data, Key.tab)) {
86
+ this.showPacket = !this.showPacket;
87
+ return this.rerender();
88
+ }
89
+ }
90
+
91
+ render(width: number): string[] {
92
+ const w = Math.max(60, width);
93
+ const lines: string[] = [];
94
+ const accent = (s: string) => this.theme.fg("accent", s);
95
+ const dim = (s: string) => this.theme.fg("dim", s);
96
+ const warning = (s: string) => this.theme.fg("warning", s);
97
+ const success = (s: string) => this.theme.fg("success", s);
98
+ const packet = this.currentPacket();
99
+ const stats = analyzePacket(packet, this.input.panelCount);
100
+ const enabledCount = this.candidates.length - this.disabled.size;
101
+
102
+ lines.push(topBorder(w, `${accent("scrutiny packet preview")} ${dim(this.input.runId)}`, this.theme));
103
+ lines.push(frameLine(`${accent(this.input.surface)} ${dim("pre-spend gate · exact packet built, panel not started")}`, w, this.theme));
104
+ lines.push(frameLine(`${accent("budget")} packet ~${formatTokens(stats.packetTokens)} tok × ${this.input.panelCount} panel = ~${formatTokens(stats.replicatedTokens)} replicated input${this.input.judgeRan ? dim(" · map reads outputs") : dim(" · map off")}${this.input.verifyRan ? warning(" · verify after panel") : ""}`, w, this.theme));
105
+ lines.push(frameLine(`${dim("sections")} ${stats.sections.slice(0, 7).join(" · ") || "none"}${stats.sections.length > 7 ? ` · +${stats.sections.length - 7}` : ""}`, w, this.theme));
106
+ lines.push(midBorder(w, this.theme));
107
+
108
+ lines.push(frameLine(`${accent("included")} scout candidates ${enabledCount}/${this.candidates.length} · prior runs ${stats.priorCount} · git ${stats.hasGit ? success("on") : dim("off")}`, w, this.theme));
109
+ if (this.candidates.length === 0) {
110
+ lines.push(frameLine(dim(" no toggleable scout candidates in packet"), w, this.theme));
111
+ } else {
112
+ for (const item of visibleWindow(this.candidates, this.selected, 7)) {
113
+ const candidate = item.row;
114
+ const selected = item.index === this.selected;
115
+ const enabled = !this.disabled.has(candidate.id);
116
+ const prefix = selected ? accent(">") : dim(" ");
117
+ const box = enabled ? success("[x]") : dim("[ ]");
118
+ const text = enabled ? candidate.title : dim(candidate.title);
119
+ lines.push(frameLine(` ${prefix} ${box} ${text}`, w, this.theme));
120
+ }
121
+ if (this.candidates.length > 7) lines.push(frameLine(dim(` ↑↓ to inspect ${this.candidates.length} candidates`), w, this.theme));
122
+ }
123
+
124
+ lines.push(midBorder(w, this.theme));
125
+ if (stats.possibleGaps.length) {
126
+ lines.push(frameLine(warning("possible gaps"), w, this.theme));
127
+ for (const gap of stats.possibleGaps.slice(0, 5)) lines.push(frameLine(` ${warning("!")} ${gap}`, w, this.theme));
128
+ } else {
129
+ lines.push(frameLine(`${success("✓")} ${dim("no obvious packet-context gaps from cheap scout")}`, w, this.theme));
130
+ }
131
+
132
+ if (this.showPacket) {
133
+ lines.push(midBorder(w, this.theme));
134
+ lines.push(frameLine(accent("packet excerpt"), w, this.theme));
135
+ for (const line of packetExcerpt(packet).slice(0, 12)) lines.push(frameLine(dim(line), w, this.theme));
136
+ }
137
+
138
+ lines.push(midBorder(w, this.theme));
139
+ lines.push(frameLine(dim("enter run exact packet · esc cancel · ↑↓ select · space toggle · tab/^o inspect"), w, this.theme));
140
+ lines.push(bottomBorder(w, this.theme));
141
+ return lines;
142
+ }
143
+
144
+ invalidate(): void {}
145
+
146
+ private currentPacket(): string {
147
+ if (this.disabled.size === 0) return this.input.packet;
148
+ return applyCandidatePruning(this.input.packet, this.candidates, this.disabled);
149
+ }
150
+
151
+ private rerender(): void {
152
+ this.tui.requestRender();
153
+ }
154
+ }
155
+
156
+ function analyzePacket(packet: string, panelCount: number): PacketStats {
157
+ const packetTokens = Math.ceil(packet.length / 4);
158
+ const sections = [...packet.matchAll(/^##\s+(.+)$/gm)].map((match) => match[1].trim());
159
+ const hasGit = /^## Git working tree$/m.test(packet);
160
+ const scout = section(packet, "Context scout");
161
+ const scoutCandidates = scout ? scout.split(/\r?\n/)
162
+ .map((line) => line.trim())
163
+ .filter((line) => line.startsWith("- "))
164
+ .map((line) => truncate(line.replace(/^-\s+/, ""), 180)) : [];
165
+ const priorCount = scoutCandidates.filter((line) => /\[prior;/.test(line)).length;
166
+ const possibleGaps: string[] = [];
167
+ if (!scout) possibleGaps.push("no context scout section found");
168
+ else if (/skipped: no .*anchor/i.test(scout)) possibleGaps.push("no anchors found; packet may be too abstract");
169
+ else if (/no local candidates found/i.test(scout)) possibleGaps.push("anchors found, but no local candidates matched");
170
+ else if (/preview pruning: all scout candidates hidden/i.test(scout)) possibleGaps.push("all scout candidates pruned before panel run");
171
+ if (scout && scoutCandidates.length > 10) possibleGaps.push("many scout candidates; consider narrower scope if result feels noisy");
172
+ if (scout && !/test file|tests?\//i.test(scout)) possibleGaps.push("no obvious tests surfaced by scout");
173
+ if (scout && !/doc\/config path|README|CONTEXT|docs\//i.test(scout)) possibleGaps.push("no docs/config/project-frame snippets surfaced yet");
174
+ if (!hasGit) possibleGaps.push("git diff not included for this surface/run");
175
+ return { packetTokens, replicatedTokens: packetTokens * Math.max(1, panelCount), sections, hasGit, scout, scoutCandidates, priorCount, possibleGaps };
176
+ }
177
+
178
+ function extractCandidateBlocks(packet: string): CandidateBlock[] {
179
+ const lines = packet.split(/\r?\n/);
180
+ const scoutStart = lines.findIndex((line) => line.trim() === "## Context scout");
181
+ if (scoutStart < 0) return [];
182
+ const scoutEnd = lines.findIndex((line, index) => index > scoutStart && /^##\s+/.test(line));
183
+ const end = scoutEnd < 0 ? lines.length : scoutEnd;
184
+ const candidates: CandidateBlock[] = [];
185
+ for (let i = scoutStart + 1; i < end; i++) {
186
+ if (!lines[i].trim().startsWith("- ")) continue;
187
+ let blockEnd = i + 1;
188
+ while (blockEnd < end && !lines[blockEnd].trim().startsWith("- ") && !/^#{2,3}\s+/.test(lines[blockEnd])) blockEnd++;
189
+ const blockLines = lines.slice(i, blockEnd);
190
+ candidates.push({
191
+ id: i,
192
+ title: truncate(blockLines.join(" ").replace(/^-\s+/, "").replace(/\s+/g, " "), 180),
193
+ lines: blockLines,
194
+ start: i,
195
+ end: blockEnd,
196
+ });
197
+ }
198
+ return candidates;
199
+ }
200
+
201
+ function applyCandidatePruning(packet: string, candidates: CandidateBlock[], disabled: Set<number>): string {
202
+ const lines = packet.split(/\r?\n/);
203
+ const remove = new Set<number>();
204
+ for (const candidate of candidates) {
205
+ if (!disabled.has(candidate.id)) continue;
206
+ for (let i = candidate.start; i < candidate.end; i++) remove.add(i);
207
+ }
208
+ const output: string[] = [];
209
+ let noteInserted = false;
210
+ for (let i = 0; i < lines.length; i++) {
211
+ if (remove.has(i)) continue;
212
+ output.push(lines[i]);
213
+ if (!noteInserted && lines[i].trim() === "### Candidate context") {
214
+ const hidden = disabled.size;
215
+ const all = hidden >= candidates.length;
216
+ output.push(all ? `preview pruning: all scout candidates hidden before panel run.` : `preview pruning: ${hidden} scout candidate(s) hidden before panel run.`);
217
+ noteInserted = true;
218
+ }
219
+ }
220
+ return output.join("\n");
221
+ }
222
+
223
+ function visibleWindow<T>(items: T[], selected: number, size: number): Array<{ row: T; index: number }> {
224
+ const start = Math.max(0, Math.min(selected - Math.floor(size / 2), items.length - size));
225
+ return items.slice(start, start + size).map((row, offset) => ({ row, index: start + offset }));
226
+ }
227
+
228
+ function section(packet: string, heading: string): string | undefined {
229
+ const lines = packet.split(/\r?\n/);
230
+ const start = lines.findIndex((line) => line.trim() === `## ${heading}`);
231
+ if (start < 0) return undefined;
232
+ const next = lines.findIndex((line, index) => index > start && /^##\s+/.test(line));
233
+ return lines.slice(start + 1, next < 0 ? undefined : next).join("\n").trim();
234
+ }
235
+
236
+ function packetExcerpt(packet: string): string[] {
237
+ return packet
238
+ .split(/\r?\n/)
239
+ .filter((line) => /^#|^surface:|^cwd:|^anchors:|^files:|^symbols:|^terms:|^preview pruning:|^- /.test(line.trim()))
240
+ .map((line) => truncate(line, 220));
241
+ }
242
+
243
+ function topBorder(width: number, title: string, theme: Theme): string {
244
+ const plain = `╭─ ${title} `;
245
+ return theme.fg("borderAccent", truncateToWidth(`${plain}${"─".repeat(width)}`, width - 1, "")) + theme.fg("borderAccent", "╮");
246
+ }
247
+
248
+ function midBorder(width: number, theme: Theme): string {
249
+ return theme.fg("borderMuted", `├${"─".repeat(Math.max(0, width - 2))}┤`);
250
+ }
251
+
252
+ function bottomBorder(width: number, theme: Theme): string {
253
+ return theme.fg("borderAccent", `╰${"─".repeat(Math.max(0, width - 2))}╯`);
254
+ }
255
+
256
+ function frameLine(content: string, width: number, theme: Theme): string {
257
+ const innerWidth = Math.max(0, width - 4);
258
+ const clipped = truncateToWidth(content, innerWidth, "…");
259
+ const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
260
+ return `${theme.fg("borderMuted", "│ ")}${clipped}${padding}${theme.fg("borderMuted", " │")}`;
261
+ }
@@ -0,0 +1,48 @@
1
+ import type { ScrutinyRunProgress, ScrutinySurface } from "./types.js";
2
+
3
+ export type RunRecord = {
4
+ runId: string;
5
+ surface: ScrutinySurface;
6
+ status: "running" | "ok" | "error";
7
+ startedAt: number;
8
+ endedAt?: number;
9
+ runDir?: string;
10
+ error?: string;
11
+ };
12
+
13
+ const MAX_REMEMBERED = 20;
14
+ const runs: RunRecord[] = [];
15
+ const progresses = new Map<string, ScrutinyRunProgress>();
16
+
17
+ export function recordRunStart(rec: RunRecord): void {
18
+ runs.unshift({ ...rec });
19
+ trim();
20
+ }
21
+
22
+ export function recordRunEnd(runId: string, patch: Partial<RunRecord>): void {
23
+ const rec = runs.find((r) => r.runId === runId);
24
+ if (rec) Object.assign(rec, patch);
25
+ const progress = progresses.get(runId);
26
+ if (progress && patch.status) progresses.set(runId, { ...progress, status: patch.status, updatedAt: patch.endedAt ?? Date.now(), message: patch.error ?? progress.message });
27
+ trim();
28
+ }
29
+
30
+ export function recordRunProgress(progress: ScrutinyRunProgress): void {
31
+ progresses.set(progress.runId, { ...progress, panel: progress.panel.map((item) => ({ ...item })), judge: progress.judge ? { ...progress.judge } : undefined });
32
+ }
33
+
34
+ export function activeProgresses(): ScrutinyRunProgress[] {
35
+ return [...progresses.values()].filter((progress) => progress.status === "running").sort((a, b) => a.startedAt - b.startedAt);
36
+ }
37
+
38
+ export function activeRuns(): RunRecord[] {
39
+ return runs.filter((r) => r.status === "running");
40
+ }
41
+
42
+ export function recentRuns(limit = MAX_REMEMBERED): RunRecord[] {
43
+ return runs.slice(0, limit);
44
+ }
45
+
46
+ function trim(): void {
47
+ if (runs.length > MAX_REMEMBERED) runs.length = MAX_REMEMBERED;
48
+ }
@@ -0,0 +1,128 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import type { PanelResponse } from "./types.js";
5
+ import { addUsage, extractUsage, getAssistantText, truncate, usageZero } from "./util.js";
6
+
7
+ type RunModelTaskInput = {
8
+ model: string;
9
+ role: string;
10
+ prompt: string;
11
+ cwd: string;
12
+ tools: string[];
13
+ timeoutMs: number;
14
+ outputCharLimit: number;
15
+ thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
16
+ signal?: AbortSignal;
17
+ };
18
+
19
+ export async function runModelTask(input: RunModelTaskInput): Promise<PanelResponse> {
20
+ const startedAt = Date.now();
21
+ const args = ["--mode", "json", "-p", "--no-session", "--model", input.model];
22
+ if (input.thinkingLevel) args.push("--thinking", input.thinkingLevel);
23
+ if (input.tools.length > 0) args.push("--tools", input.tools.join(","));
24
+ else args.push("--no-tools");
25
+ args.push(input.prompt);
26
+
27
+ let stderr = "";
28
+ let text = "";
29
+ let usage = usageZero();
30
+ let exitCode = 0;
31
+ let timedOut = false;
32
+ let aborted = false;
33
+
34
+ try {
35
+ exitCode = await new Promise<number>((resolve) => {
36
+ const invocation = getPiInvocation(args);
37
+ const proc = spawn(invocation.command, invocation.args, {
38
+ cwd: input.cwd,
39
+ shell: false,
40
+ stdio: ["ignore", "pipe", "pipe"],
41
+ env: { ...process.env, PI_SCRUTINY_DEPTH: "1" },
42
+ });
43
+
44
+ let buffer = "";
45
+ const timeout = setTimeout(() => {
46
+ timedOut = true;
47
+ proc.kill("SIGTERM");
48
+ setTimeout(() => { try { proc.kill("SIGKILL"); } catch { /* already dead */ } }, 3_000);
49
+ }, input.timeoutMs);
50
+
51
+ const abort = () => {
52
+ aborted = true;
53
+ proc.kill("SIGTERM");
54
+ };
55
+ input.signal?.addEventListener("abort", abort, { once: true });
56
+
57
+ proc.stdout.on("data", (chunk) => {
58
+ buffer += chunk.toString();
59
+ let newline = buffer.indexOf("\n");
60
+ while (newline >= 0) {
61
+ const line = buffer.slice(0, newline);
62
+ buffer = buffer.slice(newline + 1);
63
+ processJsonLine(line);
64
+ newline = buffer.indexOf("\n");
65
+ }
66
+ });
67
+
68
+ proc.stderr.on("data", (chunk) => {
69
+ stderr += chunk.toString();
70
+ });
71
+
72
+ proc.on("error", (error) => {
73
+ stderr += `${error instanceof Error ? error.message : String(error)}\n`;
74
+ });
75
+
76
+ proc.on("close", (code) => {
77
+ clearTimeout(timeout);
78
+ input.signal?.removeEventListener("abort", abort);
79
+ if (buffer.trim()) processJsonLine(buffer);
80
+ resolve(code ?? 1);
81
+ });
82
+ });
83
+ } catch (error) {
84
+ stderr += error instanceof Error ? error.message : String(error);
85
+ exitCode = 1;
86
+ }
87
+
88
+ const durationMs = Date.now() - startedAt;
89
+ const error = timedOut ? `timed out after ${input.timeoutMs}ms` : aborted ? "aborted" : exitCode !== 0 ? stderr.trim() || `exit ${exitCode}` : undefined;
90
+ return {
91
+ model: input.model,
92
+ role: input.role,
93
+ status: error ? "error" : "ok",
94
+ content: truncate(text.trim(), input.outputCharLimit),
95
+ error,
96
+ usage,
97
+ durationMs,
98
+ exitCode,
99
+ };
100
+
101
+ function processJsonLine(line: string): void {
102
+ if (!line.trim()) return;
103
+ let event: any;
104
+ try {
105
+ event = JSON.parse(line);
106
+ } catch {
107
+ return;
108
+ }
109
+ if (event.type === "message_end" && event.message) {
110
+ const messageText = getAssistantText(event.message);
111
+ if (messageText.trim()) text = text ? `${text}\n\n${messageText}` : messageText;
112
+ usage = addUsage(usage, extractUsage(event.message));
113
+ }
114
+ }
115
+ }
116
+
117
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
118
+ const currentScript = process.argv[1];
119
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
120
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
121
+ return { command: process.execPath, args: [currentScript, ...args] };
122
+ }
123
+
124
+ const execName = path.basename(process.execPath).toLowerCase();
125
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
126
+ if (!isGenericRuntime) return { command: process.execPath, args };
127
+ return { command: "pi", args };
128
+ }