@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.
- package/README.md +165 -0
- package/extensions/scrutiny/analysis.ts +335 -0
- package/extensions/scrutiny/config.ts +407 -0
- package/extensions/scrutiny/engine.ts +513 -0
- package/extensions/scrutiny/history.ts +566 -0
- package/extensions/scrutiny/packet.ts +188 -0
- package/extensions/scrutiny/palette.ts +413 -0
- package/extensions/scrutiny/preview.ts +261 -0
- package/extensions/scrutiny/registry.ts +48 -0
- package/extensions/scrutiny/runner.ts +128 -0
- package/extensions/scrutiny/scout.ts +314 -0
- package/extensions/scrutiny/summary.ts +270 -0
- package/extensions/scrutiny/types.ts +184 -0
- package/extensions/scrutiny/ui.ts +299 -0
- package/extensions/scrutiny/util.ts +123 -0
- package/extensions/scrutiny.ts +333 -0
- package/package.json +48 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Component, Focusable, TUI } from "@earendil-works/pi-tui";
|
|
6
|
+
import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import type { ScrutinySummary } from "./types.js";
|
|
8
|
+
import { formatDuration, scrutinyDataDir, truncate } from "./util.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_LIMIT = 12;
|
|
11
|
+
const MAX_LIMIT = 50;
|
|
12
|
+
const ARTIFACT_CHAR_LIMIT = 24_000;
|
|
13
|
+
|
|
14
|
+
type Freshness = "fresh" | "stale" | "unknown";
|
|
15
|
+
|
|
16
|
+
type HistoryRow = {
|
|
17
|
+
summary: ScrutinySummary;
|
|
18
|
+
freshness: Freshness;
|
|
19
|
+
staleFiles: string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type HistoryLoad = {
|
|
23
|
+
rows: HistoryRow[];
|
|
24
|
+
rebuilt: boolean;
|
|
25
|
+
warnings: string[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type Query = {
|
|
29
|
+
text: string[];
|
|
30
|
+
file?: string;
|
|
31
|
+
symbol?: string;
|
|
32
|
+
surface?: string;
|
|
33
|
+
status?: string;
|
|
34
|
+
freshness?: Freshness;
|
|
35
|
+
since?: number;
|
|
36
|
+
before?: number;
|
|
37
|
+
limit: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export async function historyText(cwd: string, args: string): Promise<string> {
|
|
41
|
+
const trimmed = args.trim();
|
|
42
|
+
if (trimmed.startsWith("open ")) return openArtifactText(cwd, trimmed.slice("open ".length).trim());
|
|
43
|
+
const queryText = trimmed.startsWith("list ") ? trimmed.slice("list ".length).trim() : trimmed;
|
|
44
|
+
const query = parseQuery(queryText);
|
|
45
|
+
const loaded = await loadHistory(cwd);
|
|
46
|
+
const rows = loaded.rows.filter((row) => matchesQuery(row, query)).slice(0, query.limit);
|
|
47
|
+
return renderHistory({ loaded, rows, query, rawQuery: queryText });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function showHistoryPicker(ctx: ExtensionCommandContext): Promise<string | null> {
|
|
51
|
+
const loaded = await loadHistory(ctx.cwd);
|
|
52
|
+
return ctx.ui.custom<string | null>(
|
|
53
|
+
(tui, theme, _kb, done) => new HistoryPicker(tui, theme, ctx.cwd, loaded, done),
|
|
54
|
+
{
|
|
55
|
+
overlay: true,
|
|
56
|
+
overlayOptions: {
|
|
57
|
+
anchor: "center",
|
|
58
|
+
width: "78%",
|
|
59
|
+
minWidth: 72,
|
|
60
|
+
maxHeight: "84%",
|
|
61
|
+
margin: 1,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function loadHistory(cwd: string): Promise<HistoryLoad> {
|
|
68
|
+
const dataDir = scrutinyDataDir(cwd);
|
|
69
|
+
const indexPath = path.join(dataDir, "index.jsonl");
|
|
70
|
+
const warnings: string[] = [];
|
|
71
|
+
let summaries: ScrutinySummary[] = [];
|
|
72
|
+
let rebuilt = false;
|
|
73
|
+
try {
|
|
74
|
+
const content = await fs.readFile(indexPath, "utf8");
|
|
75
|
+
summaries = parseIndex(content, warnings);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") warnings.push(`index read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
78
|
+
summaries = await scanSummaryFiles(dataDir, warnings);
|
|
79
|
+
if (summaries.length > 0) {
|
|
80
|
+
await fs.mkdir(dataDir, { recursive: true, mode: 0o700 });
|
|
81
|
+
await fs.writeFile(indexPath, summaries.map((summary) => JSON.stringify(summary)).join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
82
|
+
rebuilt = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const deduped = dedupeSummaries(summaries).sort((a, b) => b.startedAt - a.startedAt);
|
|
86
|
+
const rows = await Promise.all(deduped.map(async (summary) => ({ summary, ...(await freshnessFor(cwd, summary)) })));
|
|
87
|
+
return { rows, rebuilt, warnings };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseIndex(content: string, warnings: string[]): ScrutinySummary[] {
|
|
91
|
+
const rows: ScrutinySummary[] = [];
|
|
92
|
+
const lines = content.split(/\r?\n/).filter(Boolean);
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(lines[i]) as ScrutinySummary;
|
|
96
|
+
if (parsed?.runId && parsed.surface && parsed.startedAt) rows.push(parsed);
|
|
97
|
+
else warnings.push(`index row ${i + 1} missing required fields`);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
warnings.push(`index row ${i + 1} invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return rows;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function scanSummaryFiles(dataDir: string, warnings: string[]): Promise<ScrutinySummary[]> {
|
|
106
|
+
let entries: import("node:fs").Dirent[];
|
|
107
|
+
try {
|
|
108
|
+
entries = await fs.readdir(dataDir, { withFileTypes: true });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") warnings.push(`run-dir scan failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const summaries: ScrutinySummary[] = [];
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (!entry.isDirectory() || !entry.name.startsWith("scr_")) continue;
|
|
116
|
+
const file = path.join(dataDir, entry.name, "summary.json");
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf8")) as ScrutinySummary;
|
|
119
|
+
if (parsed?.runId) summaries.push(parsed);
|
|
120
|
+
} catch {
|
|
121
|
+
warnings.push(`${entry.name}: missing/corrupt summary.json`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return summaries;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function dedupeSummaries(summaries: ScrutinySummary[]): ScrutinySummary[] {
|
|
128
|
+
const byRun = new Map<string, ScrutinySummary>();
|
|
129
|
+
for (const summary of summaries) byRun.set(summary.runId, summary);
|
|
130
|
+
return [...byRun.values()];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function freshnessFor(cwd: string, summary: ScrutinySummary): Promise<{ freshness: Freshness; staleFiles: string[] }> {
|
|
134
|
+
const entries = Object.entries(summary.fileHashes ?? {});
|
|
135
|
+
if (entries.length === 0) return { freshness: "unknown", staleFiles: [] };
|
|
136
|
+
const staleFiles: string[] = [];
|
|
137
|
+
for (const [file, expected] of entries) {
|
|
138
|
+
try {
|
|
139
|
+
const abs = path.resolve(cwd, file);
|
|
140
|
+
if (!isInside(cwd, abs)) {
|
|
141
|
+
staleFiles.push(file);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const actual = createHash("sha1").update(await fs.readFile(abs)).digest("hex");
|
|
145
|
+
if (actual !== expected) staleFiles.push(file);
|
|
146
|
+
} catch {
|
|
147
|
+
staleFiles.push(file);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { freshness: staleFiles.length ? "stale" : "fresh", staleFiles };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseQuery(raw: string): Query {
|
|
154
|
+
const query: Query = { text: [], limit: DEFAULT_LIMIT };
|
|
155
|
+
for (const token of tokenize(raw)) {
|
|
156
|
+
const [key, value] = splitFilter(token);
|
|
157
|
+
if (!value) {
|
|
158
|
+
query.text.push(token.toLowerCase());
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
switch (key) {
|
|
162
|
+
case "file": query.file = value.toLowerCase(); break;
|
|
163
|
+
case "symbol": query.symbol = value.toLowerCase(); break;
|
|
164
|
+
case "surface": query.surface = value.toLowerCase(); break;
|
|
165
|
+
case "status": query.status = value.toLowerCase(); break;
|
|
166
|
+
case "fresh": query.freshness = truthy(value) ? "fresh" : "stale"; break;
|
|
167
|
+
case "stale": query.freshness = truthy(value) ? "stale" : "fresh"; break;
|
|
168
|
+
case "since":
|
|
169
|
+
case "after": query.since = parseDateWindow(value); break;
|
|
170
|
+
case "before":
|
|
171
|
+
case "until": query.before = parseDateWindow(value); break;
|
|
172
|
+
case "limit": query.limit = clampLimit(value); break;
|
|
173
|
+
default: query.text.push(token.toLowerCase()); break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return query;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function tokenize(raw: string): string[] {
|
|
180
|
+
const tokens: string[] = [];
|
|
181
|
+
const pattern = /"([^"]+)"|'([^']+)'|(\S+)/g;
|
|
182
|
+
let match: RegExpExecArray | null;
|
|
183
|
+
while ((match = pattern.exec(raw))) tokens.push(match[1] ?? match[2] ?? match[3]);
|
|
184
|
+
return tokens;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function splitFilter(token: string): [string, string | undefined] {
|
|
188
|
+
const idx = token.indexOf(":");
|
|
189
|
+
if (idx <= 0) return [token.toLowerCase(), undefined];
|
|
190
|
+
return [token.slice(0, idx).toLowerCase(), token.slice(idx + 1)];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function truthy(value: string): boolean {
|
|
194
|
+
return !/^(?:false|0|no|off)$/i.test(value);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function clampLimit(value: string): number {
|
|
198
|
+
const parsed = Number.parseInt(value, 10);
|
|
199
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIMIT;
|
|
200
|
+
return Math.min(parsed, MAX_LIMIT);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseDateWindow(value: string): number | undefined {
|
|
204
|
+
const relative = value.match(/^(\d+)([smhdw])$/i);
|
|
205
|
+
if (relative) {
|
|
206
|
+
const amount = Number.parseInt(relative[1], 10);
|
|
207
|
+
const unitMs = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[relative[2].toLowerCase() as "s" | "m" | "h" | "d" | "w"];
|
|
208
|
+
return Date.now() - amount * unitMs;
|
|
209
|
+
}
|
|
210
|
+
const parsed = Date.parse(value);
|
|
211
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function matchesQuery(row: HistoryRow, query: Query): boolean {
|
|
215
|
+
const summary = row.summary;
|
|
216
|
+
const fileFilter = query.file;
|
|
217
|
+
const symbolFilter = query.symbol;
|
|
218
|
+
if (fileFilter && !summary.files.some((file) => file.toLowerCase().includes(fileFilter)) && !summary.sourceRefs.some((ref) => ref.toLowerCase().includes(fileFilter))) return false;
|
|
219
|
+
if (symbolFilter && !summary.symbols.some((symbol) => symbol.toLowerCase().includes(symbolFilter))) return false;
|
|
220
|
+
if (query.surface && summary.surface !== query.surface) return false;
|
|
221
|
+
if (query.status && summary.status !== query.status) return false;
|
|
222
|
+
if (query.freshness && row.freshness !== query.freshness) return false;
|
|
223
|
+
if (query.since && summary.startedAt < query.since) return false;
|
|
224
|
+
if (query.before && summary.startedAt > query.before) return false;
|
|
225
|
+
if (query.text.length === 0) return true;
|
|
226
|
+
const haystack = [
|
|
227
|
+
summary.runId,
|
|
228
|
+
summary.prompt,
|
|
229
|
+
summary.surface,
|
|
230
|
+
summary.status,
|
|
231
|
+
summary.failure_reason ?? "",
|
|
232
|
+
summary.error ?? "",
|
|
233
|
+
...summary.files,
|
|
234
|
+
...summary.symbols,
|
|
235
|
+
...summary.keywords,
|
|
236
|
+
...summary.signals,
|
|
237
|
+
...summary.risks,
|
|
238
|
+
...summary.contradictions,
|
|
239
|
+
...summary.missingContext,
|
|
240
|
+
...summary.sourceRefs,
|
|
241
|
+
].join("\n").toLowerCase();
|
|
242
|
+
return query.text.every((token) => haystack.includes(token));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function renderHistory(input: { loaded: HistoryLoad; rows: HistoryRow[]; query: Query; rawQuery: string }): string {
|
|
246
|
+
const lines: string[] = [];
|
|
247
|
+
lines.push("# scrutiny history");
|
|
248
|
+
lines.push("");
|
|
249
|
+
lines.push(`${input.loaded.rows.length} indexed runs${input.rawQuery ? ` · query: \`${input.rawQuery}\`` : ""}${input.loaded.rebuilt ? " · rebuilt index from summaries" : ""}`);
|
|
250
|
+
if (input.loaded.warnings.length > 0) {
|
|
251
|
+
lines.push("");
|
|
252
|
+
lines.push("## index warnings");
|
|
253
|
+
for (const warning of input.loaded.warnings.slice(0, 8)) lines.push(`- ${warning}`);
|
|
254
|
+
}
|
|
255
|
+
if (input.rows.length === 0) {
|
|
256
|
+
lines.push("", "no matching scrutiny runs.", "", "filters: `file:`, `symbol:`, `surface:`, `status:`, `fresh:true|false`, `stale:true|false`, `since:`, `before:`, `limit:`");
|
|
257
|
+
return lines.join("\n");
|
|
258
|
+
}
|
|
259
|
+
lines.push("", "filters: `file:`, `symbol:`, `surface:`, `status:`, `fresh:true|false`, `stale:true|false`, `since:`, `before:`, `limit:`");
|
|
260
|
+
lines.push("open: `/scrutiny history open <runId|latest> [result|summary|surface|packet|responses|verify]`", "");
|
|
261
|
+
for (const row of input.rows) renderRow(lines, row);
|
|
262
|
+
return lines.join("\n");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function renderRow(lines: string[], row: HistoryRow): void {
|
|
266
|
+
const summary = row.summary;
|
|
267
|
+
const age = formatDuration(Date.now() - summary.startedAt);
|
|
268
|
+
const status = summary.failure_reason ? `${summary.status}/${summary.failure_reason}` : summary.status;
|
|
269
|
+
const fresh = row.freshness === "stale" ? `stale: ${row.staleFiles.slice(0, 3).join(", ")}` : row.freshness;
|
|
270
|
+
lines.push(`## ${summary.runId} · ${summary.surface} · ${status} · ${age} ago · ${fresh}`);
|
|
271
|
+
lines.push(truncate(summary.prompt || "(no prompt)", 220));
|
|
272
|
+
pushCompact(lines, "files", summary.files, 5);
|
|
273
|
+
pushCompact(lines, "symbols", summary.symbols, 6);
|
|
274
|
+
pushCompact(lines, "signals", summary.signals, 3);
|
|
275
|
+
pushCompact(lines, "risks", summary.risks, 3);
|
|
276
|
+
pushCompact(lines, "missing", summary.missingContext, 3);
|
|
277
|
+
pushCompact(lines, "refs", summary.sourceRefs, 5);
|
|
278
|
+
const paths = [summary.resultPath, summary.surfaceArtifactPath, summary.packetPath, summary.responsesPath, summary.verifyPath].filter(Boolean).join(" · ");
|
|
279
|
+
if (paths) lines.push(`paths: ${paths}`);
|
|
280
|
+
lines.push("");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function pushCompact(lines: string[], label: string, items: string[], limit: number): void {
|
|
284
|
+
if (!items.length) return;
|
|
285
|
+
lines.push(`${label}: ${items.slice(0, limit).map((item) => truncate(item, 140)).join("; ")}${items.length > limit ? `; +${items.length - limit}` : ""}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
type HistoryArtifact = "summary" | "result" | "surface" | "packet" | "responses" | "verify";
|
|
289
|
+
|
|
290
|
+
class HistoryPicker implements Component, Focusable {
|
|
291
|
+
focused = false;
|
|
292
|
+
private query = "";
|
|
293
|
+
private selected = 0;
|
|
294
|
+
private artifact: HistoryArtifact = "summary";
|
|
295
|
+
|
|
296
|
+
constructor(
|
|
297
|
+
private readonly tui: TUI,
|
|
298
|
+
private readonly theme: Theme,
|
|
299
|
+
private readonly cwd: string,
|
|
300
|
+
private readonly loaded: HistoryLoad,
|
|
301
|
+
private readonly done: (value: string | null) => void,
|
|
302
|
+
) {}
|
|
303
|
+
|
|
304
|
+
handleInput(data: string): void {
|
|
305
|
+
if (matchesKey(data, Key.escape)) return this.done(null);
|
|
306
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, Key.ctrl("o"))) return void this.openSelected();
|
|
307
|
+
if (matchesKey(data, Key.tab)) {
|
|
308
|
+
this.cycleArtifact(1);
|
|
309
|
+
return this.rerender();
|
|
310
|
+
}
|
|
311
|
+
if (matchesKey(data, Key.shift("tab"))) {
|
|
312
|
+
this.cycleArtifact(-1);
|
|
313
|
+
return this.rerender();
|
|
314
|
+
}
|
|
315
|
+
if (matchesKey(data, Key.up)) {
|
|
316
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
317
|
+
return this.rerender();
|
|
318
|
+
}
|
|
319
|
+
if (matchesKey(data, Key.down)) {
|
|
320
|
+
this.selected = Math.min(Math.max(0, this.filteredRows().length - 1), this.selected + 1);
|
|
321
|
+
return this.rerender();
|
|
322
|
+
}
|
|
323
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
324
|
+
this.selected = Math.max(0, this.selected - 8);
|
|
325
|
+
return this.rerender();
|
|
326
|
+
}
|
|
327
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
328
|
+
this.selected = Math.min(Math.max(0, this.filteredRows().length - 1), this.selected + 8);
|
|
329
|
+
return this.rerender();
|
|
330
|
+
}
|
|
331
|
+
if (matchesKey(data, Key.ctrl("u"))) {
|
|
332
|
+
this.query = "";
|
|
333
|
+
this.selected = 0;
|
|
334
|
+
return this.rerender();
|
|
335
|
+
}
|
|
336
|
+
if (matchesKey(data, Key.ctrl("w"))) {
|
|
337
|
+
this.query = this.query.replace(/\s*\S+\s*$/, "");
|
|
338
|
+
this.selected = 0;
|
|
339
|
+
return this.rerender();
|
|
340
|
+
}
|
|
341
|
+
if (matchesKey(data, Key.backspace) || data === "\x7f") {
|
|
342
|
+
this.query = this.query.slice(0, -1);
|
|
343
|
+
this.selected = 0;
|
|
344
|
+
return this.rerender();
|
|
345
|
+
}
|
|
346
|
+
if (isPrintable(data)) {
|
|
347
|
+
this.query += data.replace(/[\r\n\t]/g, " ");
|
|
348
|
+
this.selected = 0;
|
|
349
|
+
return this.rerender();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
render(width: number): string[] {
|
|
354
|
+
const w = Math.max(60, width);
|
|
355
|
+
const lines: string[] = [];
|
|
356
|
+
const accent = (s: string) => this.theme.fg("accent", s);
|
|
357
|
+
const dim = (s: string) => this.theme.fg("dim", s);
|
|
358
|
+
const warning = (s: string) => this.theme.fg("warning", s);
|
|
359
|
+
const rows = this.filteredRows();
|
|
360
|
+
if (this.selected >= rows.length) this.selected = Math.max(0, rows.length - 1);
|
|
361
|
+
const selected = rows[this.selected];
|
|
362
|
+
|
|
363
|
+
lines.push(topBorder(w, `${accent("scrutiny history")} ${dim("search")}`, this.theme));
|
|
364
|
+
lines.push(frameLine(this.inputLine(w - 4), w, this.theme));
|
|
365
|
+
lines.push(frameLine(`${dim("runs")} ${accent(String(this.loaded.rows.length))} ${dim("matches")} ${accent(String(rows.length))} ${dim("artifact")} ${accent(this.artifact)}${this.loaded.rebuilt ? ` ${warning("rebuilt index")}` : ""}`, w, this.theme));
|
|
366
|
+
if (this.loaded.warnings.length) lines.push(frameLine(warning(`warnings: ${this.loaded.warnings.slice(0, 2).join("; ")}`), w, this.theme));
|
|
367
|
+
lines.push(midBorder(w, this.theme));
|
|
368
|
+
|
|
369
|
+
if (rows.length === 0) {
|
|
370
|
+
lines.push(frameLine(dim("no matching scrutiny runs"), w, this.theme));
|
|
371
|
+
} else {
|
|
372
|
+
const windowed = visibleWindow(rows, this.selected, 9);
|
|
373
|
+
for (const item of windowed) lines.push(frameLine(this.rowLine(item.row, item.index === this.selected), w, this.theme));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
lines.push(midBorder(w, this.theme));
|
|
377
|
+
for (const line of this.previewLines(selected).slice(0, 9)) lines.push(frameLine(line, w, this.theme));
|
|
378
|
+
lines.push(midBorder(w, this.theme));
|
|
379
|
+
lines.push(frameLine(dim("type search · ↑↓ move · tab artifact · enter/^o open · ^u clear · esc close"), w, this.theme));
|
|
380
|
+
lines.push(bottomBorder(w, this.theme));
|
|
381
|
+
return lines;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
invalidate(): void {}
|
|
385
|
+
|
|
386
|
+
private inputLine(width: number): string {
|
|
387
|
+
const label = this.theme.fg("muted", "search › ");
|
|
388
|
+
const empty = this.theme.fg("dim", "keyword file:symbol surface:risks status:ok...");
|
|
389
|
+
const cursor = this.focused ? `${CURSOR_MARKER}${this.theme.bg("selectedBg", " ")}` : "";
|
|
390
|
+
return truncateToWidth(`${label}${this.query || empty}${cursor}`, width);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private filteredRows(): HistoryRow[] {
|
|
394
|
+
return rankRows(this.loaded.rows, this.query).slice(0, MAX_LIMIT);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private rowLine(row: HistoryRow, selected: boolean): string {
|
|
398
|
+
const summary = row.summary;
|
|
399
|
+
const age = formatDuration(Date.now() - summary.startedAt);
|
|
400
|
+
const status = summary.failure_reason ? `${summary.status}/${summary.failure_reason}` : summary.status;
|
|
401
|
+
const fresh = row.freshness === "stale" ? `stale:${row.staleFiles[0] ?? "changed"}` : row.freshness;
|
|
402
|
+
const refs = [...summary.files, ...summary.symbols, ...summary.keywords].slice(0, 3).join(" ");
|
|
403
|
+
const prefix = selected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
|
|
404
|
+
const freshText = row.freshness === "stale" ? this.theme.fg("warning", fresh) : this.theme.fg("muted", fresh);
|
|
405
|
+
return `${prefix} ${summary.runId.slice(-8)} ${this.theme.fg("accent", summary.surface)} ${status} ${freshText} ${this.theme.fg("dim", age)} ${truncate(summary.prompt || refs, 90)}`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private previewLines(row: HistoryRow | undefined): string[] {
|
|
409
|
+
if (!row) return [this.theme.fg("dim", "preview: no run selected")];
|
|
410
|
+
const s = row.summary;
|
|
411
|
+
const lines = [`${this.theme.fg("accent", s.runId)} · ${s.surface} · ${s.status} · ${this.artifact}`];
|
|
412
|
+
lines.push(this.theme.fg("dim", truncate(s.prompt || "(no prompt)", 160)));
|
|
413
|
+
pushPreview(lines, this.theme, "files", s.files, 4);
|
|
414
|
+
pushPreview(lines, this.theme, "symbols", s.symbols, 5);
|
|
415
|
+
pushPreview(lines, this.theme, "signals", s.signals, 2);
|
|
416
|
+
pushPreview(lines, this.theme, "risks", s.risks, 2);
|
|
417
|
+
pushPreview(lines, this.theme, "missing", s.missingContext, 2);
|
|
418
|
+
pushPreview(lines, this.theme, "paths", [s.resultPath, s.surfaceArtifactPath, s.packetPath, s.responsesPath, s.verifyPath].filter((item): item is string => Boolean(item)), 3);
|
|
419
|
+
return lines;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private cycleArtifact(delta: number): void {
|
|
423
|
+
const artifacts: HistoryArtifact[] = ["summary", "result", "surface", "packet", "responses", "verify"];
|
|
424
|
+
const index = artifacts.indexOf(this.artifact);
|
|
425
|
+
this.artifact = artifacts[(index + delta + artifacts.length) % artifacts.length]!;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private async openSelected(): Promise<void> {
|
|
429
|
+
const row = this.filteredRows()[this.selected];
|
|
430
|
+
if (!row) return;
|
|
431
|
+
this.done(await artifactTextForSummary(this.cwd, row.summary, this.artifact));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private rerender(): void {
|
|
435
|
+
this.tui.requestRender();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function visibleWindow<T>(items: T[], selected: number, size: number): Array<{ item: T; row: T; index: number }> {
|
|
440
|
+
const start = Math.max(0, Math.min(selected - Math.floor(size / 2), items.length - size));
|
|
441
|
+
return items.slice(start, start + size).map((item, offset) => ({ item, row: item, index: start + offset }));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function rankRows(rows: HistoryRow[], query: string): HistoryRow[] {
|
|
445
|
+
if (/\b(?:file|symbol|surface|status|fresh|stale|since|after|before|until):/i.test(query)) {
|
|
446
|
+
const parsed = parseQuery(query);
|
|
447
|
+
return rows.filter((row) => matchesQuery(row, parsed)).slice(0, parsed.limit);
|
|
448
|
+
}
|
|
449
|
+
const tokens = tokenize(query.toLowerCase());
|
|
450
|
+
if (tokens.length === 0) return rows;
|
|
451
|
+
return rows
|
|
452
|
+
.map((row) => ({ row, score: fuzzyScore(row, tokens) }))
|
|
453
|
+
.filter((item) => item.score > 0)
|
|
454
|
+
.sort((a, b) => b.score - a.score || b.row.summary.startedAt - a.row.summary.startedAt)
|
|
455
|
+
.map((item) => item.row);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function fuzzyScore(row: HistoryRow, tokens: string[]): number {
|
|
459
|
+
const summary = row.summary;
|
|
460
|
+
const haystack = [
|
|
461
|
+
summary.runId,
|
|
462
|
+
summary.prompt,
|
|
463
|
+
summary.surface,
|
|
464
|
+
summary.status,
|
|
465
|
+
summary.failure_reason ?? "",
|
|
466
|
+
...summary.files,
|
|
467
|
+
...summary.symbols,
|
|
468
|
+
...summary.keywords,
|
|
469
|
+
...summary.signals,
|
|
470
|
+
...summary.risks,
|
|
471
|
+
...summary.missingContext,
|
|
472
|
+
...summary.sourceRefs,
|
|
473
|
+
].join("\n").toLowerCase();
|
|
474
|
+
let score = 0;
|
|
475
|
+
for (const token of tokens) {
|
|
476
|
+
if (haystack.includes(token)) score += 10 + token.length;
|
|
477
|
+
else if (isSubsequence(token, haystack)) score += 2;
|
|
478
|
+
else return 0;
|
|
479
|
+
}
|
|
480
|
+
if (row.freshness === "fresh") score += 2;
|
|
481
|
+
if (row.freshness === "stale") score -= 2;
|
|
482
|
+
return score;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function isSubsequence(needle: string, haystack: string): boolean {
|
|
486
|
+
let index = 0;
|
|
487
|
+
for (const char of haystack) if (char === needle[index]) index++;
|
|
488
|
+
return index >= needle.length;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function pushPreview(lines: string[], theme: Theme, label: string, items: string[], limit: number): void {
|
|
492
|
+
if (!items.length) return;
|
|
493
|
+
lines.push(`${theme.fg("muted", `${label}:`)} ${items.slice(0, limit).map((item) => truncate(item, 120)).join("; ")}${items.length > limit ? `; +${items.length - limit}` : ""}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function openArtifactText(cwd: string, args: string): Promise<string> {
|
|
497
|
+
const [runToken, artifactToken = "result"] = tokenize(args);
|
|
498
|
+
if (!runToken) return "# scrutiny history\n\nusage: `/scrutiny history open <runId|latest> [result|summary|surface|packet|responses|verify]`";
|
|
499
|
+
const loaded = await loadHistory(cwd);
|
|
500
|
+
const matches = runToken === "latest"
|
|
501
|
+
? loaded.rows.slice(0, 1)
|
|
502
|
+
: loaded.rows.filter((row) => row.summary.runId === runToken || row.summary.runId.endsWith(runToken) || row.summary.runId.startsWith(runToken));
|
|
503
|
+
if (matches.length === 0) return `# scrutiny history\n\nrun not found: \`${runToken}\``;
|
|
504
|
+
if (matches.length > 1) return [`# scrutiny history`, "", `ambiguous run id: \`${runToken}\``, "", ...matches.slice(0, 10).map((row) => `- ${row.summary.runId} · ${row.summary.surface} · ${row.summary.prompt}`)].join("\n");
|
|
505
|
+
const summary = matches[0].summary;
|
|
506
|
+
const artifact = normalizeArtifact(artifactToken);
|
|
507
|
+
if (!artifact) return "# scrutiny history\n\nunknown artifact. use `result`, `summary`, `surface`, `packet`, `responses`, or `verify`.";
|
|
508
|
+
return artifactTextForSummary(cwd, summary, artifact);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function artifactTextForSummary(cwd: string, summary: ScrutinySummary, artifact: HistoryArtifact): Promise<string> {
|
|
512
|
+
const artifactPath = pathForArtifact(cwd, summary, artifact);
|
|
513
|
+
if (!artifactPath) return `# scrutiny history\n\n${artifact} artifact not available for ${summary.runId}.`;
|
|
514
|
+
try {
|
|
515
|
+
const content = await fs.readFile(artifactPath, "utf8");
|
|
516
|
+
return [`# scrutiny artifact`, "", `${summary.runId} · ${artifact} · ${path.relative(cwd, artifactPath)}`, "", "```", truncate(content.trim(), ARTIFACT_CHAR_LIMIT), "```"].join("\n");
|
|
517
|
+
} catch (error) {
|
|
518
|
+
return `# scrutiny history\n\nfailed to read ${artifact}: ${error instanceof Error ? error.message : String(error)}`;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function normalizeArtifact(token: string): HistoryArtifact | undefined {
|
|
523
|
+
if (["result", "summary", "surface", "packet", "responses", "verify"].includes(token)) return token as HistoryArtifact;
|
|
524
|
+
return undefined;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function pathForArtifact(cwd: string, summary: ScrutinySummary, artifact: HistoryArtifact): string | undefined {
|
|
528
|
+
const runDir = path.join(scrutinyDataDir(cwd), summary.runId);
|
|
529
|
+
const relPath = artifact === "result" ? summary.resultPath
|
|
530
|
+
: artifact === "surface" ? summary.surfaceArtifactPath
|
|
531
|
+
: artifact === "packet" ? summary.packetPath
|
|
532
|
+
: artifact === "responses" ? summary.responsesPath
|
|
533
|
+
: artifact === "verify" ? summary.verifyPath
|
|
534
|
+
: path.join(".pi", "scrutiny", summary.runId, "summary.json");
|
|
535
|
+
const resolved = path.resolve(cwd, relPath ?? path.join(runDir, `${artifact}.json`));
|
|
536
|
+
return isInside(cwd, resolved) ? resolved : undefined;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function topBorder(width: number, title: string, theme: Theme): string {
|
|
540
|
+
const plain = `╭─ ${title} `;
|
|
541
|
+
return theme.fg("borderAccent", truncateToWidth(`${plain}${"─".repeat(width)}`, width - 1, "")) + theme.fg("borderAccent", "╮");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function midBorder(width: number, theme: Theme): string {
|
|
545
|
+
return theme.fg("borderMuted", `├${"─".repeat(Math.max(0, width - 2))}┤`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function bottomBorder(width: number, theme: Theme): string {
|
|
549
|
+
return theme.fg("borderAccent", `╰${"─".repeat(Math.max(0, width - 2))}╯`);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function frameLine(content: string, width: number, theme: Theme): string {
|
|
553
|
+
const innerWidth = Math.max(0, width - 4);
|
|
554
|
+
const clipped = truncateToWidth(content, innerWidth, "…");
|
|
555
|
+
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
|
|
556
|
+
return `${theme.fg("borderMuted", "│ ")}${clipped}${padding}${theme.fg("borderMuted", " │")}`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function isPrintable(data: string): boolean {
|
|
560
|
+
return data.length > 0 && !/^\x1b/.test(data) && [...data].every((char) => char >= " " || char === "\n" || char === "\t");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function isInside(cwd: string, file: string): boolean {
|
|
564
|
+
const relative = path.relative(cwd, file);
|
|
565
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
566
|
+
}
|