@mrclrchtr/supi-debug 11.0.1 → 11.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 +62 -3
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +41 -1
- package/package.json +4 -2
- package/skills/gather-retrospectives/SKILL.md +72 -0
- package/skills/gather-retrospectives/scripts/collect.mjs +399 -0
- package/skills/gather-retrospectives/scripts/snapshot.mjs +183 -0
- package/src/command.ts +4 -2
- package/src/config.ts +9 -4
- package/src/debug.ts +13 -6
- package/src/format-events.ts +28 -11
- package/src/render-details.ts +26 -3
- package/src/renderer.ts +29 -1
- package/src/session-events.ts +20 -6
- package/src/tool/debug/input.ts +11 -0
- package/src/tool/debug/render.ts +19 -5
- package/src/tool/debug/result-schema.ts +71 -0
- package/src/tool/debug/result.ts +101 -17
- package/src/tool/debug/serialize.ts +66 -0
- package/src/tool/debug/spec.ts +8 -12
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { readdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { displayPage, readEvidence, summarize } from "./snapshot.mjs";
|
|
10
|
+
|
|
11
|
+
const help = `Collect stored Agent Run retrospectives without modifying sessions.
|
|
12
|
+
Usage: node collect.mjs --output <new-file.json> [collection options]
|
|
13
|
+
node collect.mjs --read <evidence.json> [display options]
|
|
14
|
+
--workspace <path> Include sessions in this directory tree (default: cwd)
|
|
15
|
+
--all-workspaces Explicitly include other projects; replaces --workspace
|
|
16
|
+
--sessions-dir <path> Override PI storage discovery
|
|
17
|
+
--source <name> supi-agent or supi-review (default: both, not a topic filter)
|
|
18
|
+
--since <date|Nd> Inclusive ISO date/time or number of days ago
|
|
19
|
+
--max-sessions <n> Bound session scans (default: 5000)
|
|
20
|
+
--output <path> Save all matching reports; existing files are preserved
|
|
21
|
+
--read <path> Read a saved snapshot without accessing session storage
|
|
22
|
+
--refs <ref,ref> Select report references from --read (default: all)
|
|
23
|
+
--limit <1..100> Maximum reports displayed per response (default: 20)
|
|
24
|
+
--offset <number> Report offset within the selection (default: 0)
|
|
25
|
+
--text-offset <number> UTF-16 offset within an oversized report (--read only)
|
|
26
|
+
--help Show this help
|
|
27
|
+
Only persisted supi-debug-event / agent-run-retrospective entries are collected.
|
|
28
|
+
Interactive reports and quoted tool results are excluded. Stdout is bounded to
|
|
29
|
+
16 KB. Follow nextCommand until display.complete; oversized text is continued
|
|
30
|
+
with explicit textRange offsets. coverage.complete describes the scan, not display.
|
|
31
|
+
Storage precedence: flag, PI_CODING_AGENT_SESSION_DIR, project/global sessionDir,
|
|
32
|
+
then <PI_CODING_AGENT_DIR or ~/.pi/agent>/sessions. Relative paths use cwd.
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
function localPath(path) {
|
|
36
|
+
return resolve(
|
|
37
|
+
path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function canonicalPath(path) {
|
|
42
|
+
const absolute = localPath(path);
|
|
43
|
+
try {
|
|
44
|
+
return await realpath(absolute);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error.code === "ENOENT") return absolute;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function settings(path) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code === "ENOENT") return {};
|
|
56
|
+
throw new Error(`Cannot read settings: ${path}`, { cause: error });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function sessionDirectory(explicit) {
|
|
61
|
+
if (explicit) return localPath(explicit);
|
|
62
|
+
if (process.env.PI_CODING_AGENT_SESSION_DIR)
|
|
63
|
+
return localPath(process.env.PI_CODING_AGENT_SESSION_DIR);
|
|
64
|
+
const agentDir = localPath(process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi/agent"));
|
|
65
|
+
const project = await settings(join(process.cwd(), ".pi/settings.json"));
|
|
66
|
+
if (typeof project.sessionDir === "string") return localPath(project.sessionDir);
|
|
67
|
+
const global = await settings(join(agentDir, "settings.json"));
|
|
68
|
+
return typeof global.sessionDir === "string"
|
|
69
|
+
? localPath(global.sessionDir)
|
|
70
|
+
: join(agentDir, "sessions");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function integer(value, name, min, max) {
|
|
74
|
+
const parsed = Number(value);
|
|
75
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
|
|
76
|
+
throw new Error(`${name} must be an integer from ${min} through ${max}.`);
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sinceTimestamp(value) {
|
|
82
|
+
if (!value) return null;
|
|
83
|
+
if (/^\d+d$/.test(value)) {
|
|
84
|
+
const days = integer(value.slice(0, -1), "--since days", 0, 36500);
|
|
85
|
+
return Date.now() - days * 86400000;
|
|
86
|
+
}
|
|
87
|
+
const parsed = /^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(value) ? Date.parse(value) : Number.NaN;
|
|
88
|
+
if (
|
|
89
|
+
!Number.isFinite(parsed) ||
|
|
90
|
+
(value.length === 10 && new Date(parsed).toISOString().slice(0, 10) !== value)
|
|
91
|
+
) {
|
|
92
|
+
throw new Error("--since must be an ISO date/time or a duration such as 30d.");
|
|
93
|
+
}
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readOptions(values, display) {
|
|
98
|
+
const collectionFlags = [
|
|
99
|
+
"workspace",
|
|
100
|
+
"all-workspaces",
|
|
101
|
+
"sessions-dir",
|
|
102
|
+
"source",
|
|
103
|
+
"since",
|
|
104
|
+
"max-sessions",
|
|
105
|
+
"output",
|
|
106
|
+
];
|
|
107
|
+
if (collectionFlags.some((key) => values[key] !== undefined))
|
|
108
|
+
throw new Error("--read cannot be combined with collection options.");
|
|
109
|
+
if (display.refs?.some((ref) => !/^R-[a-f0-9]{16}$/.test(ref)))
|
|
110
|
+
throw new Error("--refs must contain comma-separated report references.");
|
|
111
|
+
return { read: localPath(values.read), display };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function options() {
|
|
115
|
+
const { values } = parseArgs({
|
|
116
|
+
options: {
|
|
117
|
+
workspace: { type: "string" },
|
|
118
|
+
"all-workspaces": { type: "boolean" },
|
|
119
|
+
"sessions-dir": { type: "string" },
|
|
120
|
+
source: { type: "string" },
|
|
121
|
+
since: { type: "string" },
|
|
122
|
+
limit: { type: "string", default: "20" },
|
|
123
|
+
offset: { type: "string", default: "0" },
|
|
124
|
+
"max-sessions": { type: "string" },
|
|
125
|
+
output: { type: "string" },
|
|
126
|
+
read: { type: "string" },
|
|
127
|
+
refs: { type: "string" },
|
|
128
|
+
"text-offset": { type: "string" },
|
|
129
|
+
help: { type: "boolean" },
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
if (values.help) return null;
|
|
133
|
+
const display = {
|
|
134
|
+
limit: integer(values.limit, "--limit", 1, 100),
|
|
135
|
+
offset: integer(values.offset, "--offset", 0, Number.MAX_SAFE_INTEGER),
|
|
136
|
+
textOffset: integer(values["text-offset"] ?? "0", "--text-offset", 0, Number.MAX_SAFE_INTEGER),
|
|
137
|
+
refs: values.refs?.split(",") ?? null,
|
|
138
|
+
};
|
|
139
|
+
if (values.read) return readOptions(values, display);
|
|
140
|
+
if (values.refs !== undefined || values["text-offset"] !== undefined)
|
|
141
|
+
throw new Error("--refs and --text-offset require --read.");
|
|
142
|
+
if (!values.output) throw new Error("--output must name a new JSON evidence file.");
|
|
143
|
+
if (values.workspace && values["all-workspaces"])
|
|
144
|
+
throw new Error("Choose --workspace or --all-workspaces, not both.");
|
|
145
|
+
if (values.source && !["supi-agent", "supi-review"].includes(values.source)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
"--source must be supi-agent or supi-review; topic relevance is assessed separately.",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
workspace: values["all-workspaces"]
|
|
152
|
+
? null
|
|
153
|
+
: await canonicalPath(values.workspace || process.cwd()),
|
|
154
|
+
sessionsDir: await sessionDirectory(values["sessions-dir"]),
|
|
155
|
+
source: values.source ?? null,
|
|
156
|
+
since: sinceTimestamp(values.since),
|
|
157
|
+
maxSessions: integer(values["max-sessions"] ?? "5000", "--max-sessions", 1, 100000),
|
|
158
|
+
output: localPath(values.output),
|
|
159
|
+
display,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function within(directory, path) {
|
|
164
|
+
const child = relative(directory, path);
|
|
165
|
+
return child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function recordFailure(coverage, path, code) {
|
|
169
|
+
coverage.readFailures++;
|
|
170
|
+
if (coverage.failures.length < 10) coverage.failures.push({ path, code });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function directoryEntries(directory, config, coverage) {
|
|
174
|
+
try {
|
|
175
|
+
return await readdir(directory, { withFileTypes: true });
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (directory === config.sessionsDir)
|
|
178
|
+
throw new Error(`Cannot list session storage: ${directory}`, { cause: error });
|
|
179
|
+
recordFailure(coverage, directory, error.code ?? "READ_FAILED");
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function discoverEntry(entry, directory, state) {
|
|
185
|
+
const path = join(directory, entry.name);
|
|
186
|
+
if (entry.isSymbolicLink()) state.coverage.ignoredSymlinks++;
|
|
187
|
+
else if (entry.isDirectory()) state.pending.push(path);
|
|
188
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) state.files.push(path);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function discover(config, coverage) {
|
|
192
|
+
const files = [];
|
|
193
|
+
const pending = [config.sessionsDir];
|
|
194
|
+
while (pending.length) {
|
|
195
|
+
const directory = pending.pop();
|
|
196
|
+
const entries = await directoryEntries(directory, config, coverage);
|
|
197
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
discoverEntry(entry, directory, { files, pending, coverage });
|
|
200
|
+
if (files.length > config.maxSessions) {
|
|
201
|
+
coverage.sessionLimitReached = true;
|
|
202
|
+
return files.slice(0, config.maxSessions);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return files;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseEntry(line, coverage) {
|
|
210
|
+
try {
|
|
211
|
+
return JSON.parse(line);
|
|
212
|
+
} catch {
|
|
213
|
+
coverage.malformedEntries++;
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function assessment(entry) {
|
|
219
|
+
if (entry?.type !== "custom" || entry.customType !== "supi-debug-event") return null;
|
|
220
|
+
const event = entry.data;
|
|
221
|
+
if (event?.category !== "agent-run-retrospective") return null;
|
|
222
|
+
const data = event.data;
|
|
223
|
+
if (
|
|
224
|
+
!["supi-agent", "supi-review"].includes(event.source) ||
|
|
225
|
+
!Number.isFinite(event.timestamp) ||
|
|
226
|
+
Math.abs(event.timestamp) > 8.64e15 ||
|
|
227
|
+
!["completed", "failed"].includes(data?.status) ||
|
|
228
|
+
(data.text !== undefined && typeof data.text !== "string")
|
|
229
|
+
)
|
|
230
|
+
return null;
|
|
231
|
+
return { event, data };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Project only known sanitized assessment fields; never copy whole entries or rawData.
|
|
235
|
+
function report(entry, context) {
|
|
236
|
+
const parsed = assessment(entry);
|
|
237
|
+
if (!parsed) return null;
|
|
238
|
+
const { event, data } = parsed;
|
|
239
|
+
const report = {
|
|
240
|
+
timestamp: event.timestamp,
|
|
241
|
+
source: event.source,
|
|
242
|
+
status: data.status,
|
|
243
|
+
text: data.text,
|
|
244
|
+
...context,
|
|
245
|
+
entryId: typeof entry.id === "string" ? entry.id : null,
|
|
246
|
+
};
|
|
247
|
+
for (const key of ["runKey", "caller", "batchId", "taskId", "label", "conversationId"]) {
|
|
248
|
+
if (typeof data[key] === "string") report[key] = data[key];
|
|
249
|
+
}
|
|
250
|
+
const identity = report.runKey
|
|
251
|
+
? [report.source, report.runKey]
|
|
252
|
+
: [report.source, report.timestamp, report.status, report.text, report.entryId];
|
|
253
|
+
report.ref = `R-${createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 16)}`;
|
|
254
|
+
return report;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function readHeader(line, path, state) {
|
|
258
|
+
const session = parseEntry(line, state.coverage);
|
|
259
|
+
if (session?.type !== "session" || typeof session.cwd !== "string" || !isAbsolute(session.cwd)) {
|
|
260
|
+
recordFailure(state.coverage, path, "INVALID_SESSION_HEADER");
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const cwd = await canonicalPath(session.cwd);
|
|
264
|
+
if (state.config.workspace && !within(state.config.workspace, cwd)) return null;
|
|
265
|
+
state.coverage.scopedSessions++;
|
|
266
|
+
return session;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function collectLine(line, context, state) {
|
|
270
|
+
if (!line.trim()) return;
|
|
271
|
+
const { config, coverage, reports } = state;
|
|
272
|
+
const entry = parseEntry(line, coverage);
|
|
273
|
+
if (
|
|
274
|
+
entry?.type !== "custom" ||
|
|
275
|
+
entry.customType !== "supi-debug-event" ||
|
|
276
|
+
entry.data?.category !== "agent-run-retrospective"
|
|
277
|
+
)
|
|
278
|
+
return;
|
|
279
|
+
const item = report(entry, context);
|
|
280
|
+
if (!item) {
|
|
281
|
+
coverage.malformedEntries++;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (
|
|
285
|
+
(config.source && item.source !== config.source) ||
|
|
286
|
+
(config.since !== null && item.timestamp < config.since)
|
|
287
|
+
)
|
|
288
|
+
return;
|
|
289
|
+
coverage.matchingEvents++;
|
|
290
|
+
if (reports.has(item.ref)) coverage.duplicateEvents++;
|
|
291
|
+
else reports.set(item.ref, item);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Split JSONL only on LF; readline also splits valid JSON string content at U+2028/U+2029. */
|
|
295
|
+
async function* jsonLines(input) {
|
|
296
|
+
let pending = "";
|
|
297
|
+
for await (const chunk of input) {
|
|
298
|
+
pending += chunk;
|
|
299
|
+
let index = pending.indexOf("\n");
|
|
300
|
+
while (index !== -1) {
|
|
301
|
+
yield pending.slice(0, index);
|
|
302
|
+
pending = pending.slice(index + 1);
|
|
303
|
+
index = pending.indexOf("\n");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (pending) yield pending;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function scanSession(path, state) {
|
|
310
|
+
const { coverage } = state;
|
|
311
|
+
coverage.scannedSessions++;
|
|
312
|
+
const input = createReadStream(path, { encoding: "utf8" });
|
|
313
|
+
const lines = jsonLines(input);
|
|
314
|
+
let lineNumber = 0;
|
|
315
|
+
let session;
|
|
316
|
+
try {
|
|
317
|
+
for await (const line of lines) {
|
|
318
|
+
lineNumber++;
|
|
319
|
+
if (lineNumber === 1) {
|
|
320
|
+
session = await readHeader(line, path, state);
|
|
321
|
+
if (!session) break;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
collectLine(
|
|
325
|
+
line,
|
|
326
|
+
{ sessionFile: path, sessionId: session.id, cwd: session.cwd, line: lineNumber },
|
|
327
|
+
state,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (lineNumber === 0) recordFailure(coverage, path, "EMPTY_SESSION");
|
|
331
|
+
} catch (error) {
|
|
332
|
+
recordFailure(coverage, path, error.code ?? "READ_FAILED");
|
|
333
|
+
} finally {
|
|
334
|
+
input.destroy();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function collect(config) {
|
|
339
|
+
const coverage = {
|
|
340
|
+
scannedSessions: 0,
|
|
341
|
+
scopedSessions: 0,
|
|
342
|
+
matchingEvents: 0,
|
|
343
|
+
duplicateEvents: 0,
|
|
344
|
+
malformedEntries: 0,
|
|
345
|
+
ignoredSymlinks: 0,
|
|
346
|
+
sessionLimitReached: false,
|
|
347
|
+
readFailures: 0,
|
|
348
|
+
failures: [],
|
|
349
|
+
};
|
|
350
|
+
const reports = new Map();
|
|
351
|
+
const files = await discover(config, coverage);
|
|
352
|
+
for (const file of files) await scanSession(file, { config, coverage, reports });
|
|
353
|
+
const sorted = [...reports.values()].sort(
|
|
354
|
+
(a, b) => b.timestamp - a.timestamp || a.ref.localeCompare(b.ref),
|
|
355
|
+
);
|
|
356
|
+
const { display: _display, output: _output, ...filters } = config;
|
|
357
|
+
return {
|
|
358
|
+
version: 1,
|
|
359
|
+
filters,
|
|
360
|
+
coverage: {
|
|
361
|
+
...coverage,
|
|
362
|
+
complete:
|
|
363
|
+
!coverage.sessionLimitReached &&
|
|
364
|
+
coverage.ignoredSymlinks === 0 &&
|
|
365
|
+
coverage.readFailures === 0 &&
|
|
366
|
+
coverage.malformedEntries === 0,
|
|
367
|
+
uniqueReports: sorted.length,
|
|
368
|
+
},
|
|
369
|
+
limitations: [
|
|
370
|
+
"Structured persisted retrospectives only; interactive reports are excluded.",
|
|
371
|
+
"Stored text is best-effort sanitized, not guaranteed free of sensitive task details.",
|
|
372
|
+
"Successful-task selection and optional logging prevent population failure-rate estimates.",
|
|
373
|
+
"Source identifies the producer, not the topic; suggestions are not verified improvements.",
|
|
374
|
+
],
|
|
375
|
+
records: sorted,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const config = await options();
|
|
381
|
+
if (!config) process.stdout.write(help);
|
|
382
|
+
else {
|
|
383
|
+
const evidence = config.read ? await readEvidence(config.read) : await collect(config);
|
|
384
|
+
const evidenceFile = config.read ?? config.output;
|
|
385
|
+
if (!config.read) {
|
|
386
|
+
evidence.summary = summarize(evidence.records);
|
|
387
|
+
await writeFile(evidenceFile, `${JSON.stringify(evidence, null, 2)}\n`, {
|
|
388
|
+
flag: "wx",
|
|
389
|
+
mode: 0o600,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
process.stdout.write(
|
|
393
|
+
`${JSON.stringify(displayPage(evidence, evidenceFile, config.display, fileURLToPath(import.meta.url)))}\n`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
} catch (error) {
|
|
397
|
+
process.stderr.write(`Retrospective collection failed: ${error.message}\n`);
|
|
398
|
+
process.exitCode = 1;
|
|
399
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Count deduplicated reports, not independent experiments or all sessions scanned. */
|
|
5
|
+
export function summarize(records) {
|
|
6
|
+
const bySource = { "supi-agent": 0, "supi-review": 0 };
|
|
7
|
+
const byStatus = { completed: 0, failed: 0 };
|
|
8
|
+
let earliest = Infinity;
|
|
9
|
+
let latest = -Infinity;
|
|
10
|
+
for (const record of records) {
|
|
11
|
+
bySource[record.source]++;
|
|
12
|
+
byStatus[record.status]++;
|
|
13
|
+
earliest = Math.min(earliest, record.timestamp);
|
|
14
|
+
latest = Math.max(latest, record.timestamp);
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
reports: records.length,
|
|
18
|
+
containingSessions: new Set(records.map((record) => record.sessionFile)).size,
|
|
19
|
+
bySource,
|
|
20
|
+
byStatus,
|
|
21
|
+
earliest: records.length ? new Date(earliest).toISOString() : null,
|
|
22
|
+
latest: records.length ? new Date(latest).toISOString() : null,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Snapshot reads never discover storage or open original session files. */
|
|
27
|
+
export async function readEvidence(path) {
|
|
28
|
+
const evidence = JSON.parse(await readFile(path, "utf8"));
|
|
29
|
+
if (
|
|
30
|
+
evidence.version !== 1 ||
|
|
31
|
+
!evidence.filters ||
|
|
32
|
+
!evidence.coverage ||
|
|
33
|
+
!Array.isArray(evidence.records) ||
|
|
34
|
+
typeof evidence.filters.sessionsDir !== "string" ||
|
|
35
|
+
!evidence.records.every(
|
|
36
|
+
(record) =>
|
|
37
|
+
/^R-[a-f0-9]{16}$/.test(record.ref) &&
|
|
38
|
+
["supi-agent", "supi-review"].includes(record.source) &&
|
|
39
|
+
["completed", "failed"].includes(record.status) &&
|
|
40
|
+
Number.isFinite(record.timestamp) &&
|
|
41
|
+
Math.abs(record.timestamp) <= 8.64e15 &&
|
|
42
|
+
typeof record.sessionFile === "string" &&
|
|
43
|
+
isAbsolute(record.sessionFile) &&
|
|
44
|
+
Number.isSafeInteger(record.line) &&
|
|
45
|
+
record.line > 0 &&
|
|
46
|
+
(record.text === undefined || typeof record.text === "string"),
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
throw new Error("Invalid retrospective snapshot; collect a new evidence file.");
|
|
50
|
+
return evidence;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function shellQuote(value) {
|
|
54
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fits = (value) => Buffer.byteLength(JSON.stringify(value)) + 1 <= 16_000;
|
|
58
|
+
|
|
59
|
+
function selectRecords(allRecords, options) {
|
|
60
|
+
const refs = options.refs ? new Set(options.refs) : null;
|
|
61
|
+
if (refs && [...refs].some((ref) => !allRecords.some((record) => record.ref === ref)))
|
|
62
|
+
throw new Error("Unknown report reference in --refs.");
|
|
63
|
+
const records = refs ? allRecords.filter((record) => refs.has(record.ref)) : allRecords;
|
|
64
|
+
if (options.offset > records.length)
|
|
65
|
+
throw new Error("--offset exceeds the number of selected reports.");
|
|
66
|
+
const initialText = records[options.offset]?.text ?? "";
|
|
67
|
+
if (
|
|
68
|
+
options.textOffset &&
|
|
69
|
+
(options.textOffset >= initialText.length ||
|
|
70
|
+
safeTextEnd(initialText, options.textOffset) !== options.textOffset)
|
|
71
|
+
)
|
|
72
|
+
throw new Error("--text-offset must address unread text at a Unicode boundary.");
|
|
73
|
+
|
|
74
|
+
return records;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createRenderer(evidence, records, { evidenceFile, options, scriptFile }) {
|
|
78
|
+
const { filters, coverage, records: allRecords } = evidence;
|
|
79
|
+
const { failures: _failures, ...counts } = coverage;
|
|
80
|
+
const summary = summarize(allRecords);
|
|
81
|
+
return (items, offset, textOffset = 0) => {
|
|
82
|
+
const complete = offset === records.length;
|
|
83
|
+
const args = [process.execPath, scriptFile, "--read", evidenceFile];
|
|
84
|
+
if (options.refs) args.push("--refs", options.refs.join(","));
|
|
85
|
+
args.push("--limit", String(options.limit), "--offset", String(offset));
|
|
86
|
+
if (textOffset) args.push("--text-offset", String(textOffset));
|
|
87
|
+
return {
|
|
88
|
+
evidenceFile,
|
|
89
|
+
sessionsDir: filters.sessionsDir,
|
|
90
|
+
scope: {
|
|
91
|
+
workspace: filters.workspace,
|
|
92
|
+
source: filters.source,
|
|
93
|
+
since: filters.since === null ? null : new Date(filters.since).toISOString(),
|
|
94
|
+
},
|
|
95
|
+
coverage: counts,
|
|
96
|
+
summary,
|
|
97
|
+
display: {
|
|
98
|
+
selectedReports: records.length,
|
|
99
|
+
displayedReports: items.length,
|
|
100
|
+
remainingReports: records.length - offset,
|
|
101
|
+
complete,
|
|
102
|
+
nextOffset: complete ? null : offset,
|
|
103
|
+
nextTextOffset: complete ? null : textOffset,
|
|
104
|
+
},
|
|
105
|
+
records: items,
|
|
106
|
+
nextCommand: complete ? null : args.map(shellQuote).join(" "),
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Fit escaped JSON bytes, preserving Unicode and making forward progress on long assessments. */
|
|
112
|
+
function fragmentPage(record, item, { offset, textOffset, render, evidenceFile }) {
|
|
113
|
+
const text = record.text ?? "";
|
|
114
|
+
let low = textOffset + 1;
|
|
115
|
+
let high = text.length;
|
|
116
|
+
let best;
|
|
117
|
+
while (low <= high) {
|
|
118
|
+
const middle = Math.floor((low + high) / 2);
|
|
119
|
+
const end = safeTextEnd(text, middle);
|
|
120
|
+
const fragment = {
|
|
121
|
+
...item,
|
|
122
|
+
text: text.slice(textOffset, end),
|
|
123
|
+
textRange: { start: textOffset, end, total: text.length },
|
|
124
|
+
};
|
|
125
|
+
const page = render(
|
|
126
|
+
[fragment],
|
|
127
|
+
end === text.length ? offset + 1 : offset,
|
|
128
|
+
end === text.length ? 0 : end,
|
|
129
|
+
);
|
|
130
|
+
if (end > textOffset && fits(page)) {
|
|
131
|
+
best = page;
|
|
132
|
+
low = middle + 1;
|
|
133
|
+
} else high = middle - 1;
|
|
134
|
+
}
|
|
135
|
+
if (!best)
|
|
136
|
+
throw new Error(`Display metadata exceeds 16 KB; full evidence is in ${evidenceFile}.`);
|
|
137
|
+
return best;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Display a frozen selection with resumable report and UTF-16 text cursors. */
|
|
141
|
+
export function displayPage(evidence, evidenceFile, options, scriptFile) {
|
|
142
|
+
const records = selectRecords(evidence.records, options);
|
|
143
|
+
const { filters } = evidence;
|
|
144
|
+
const render = createRenderer(evidence, records, { evidenceFile, options, scriptFile });
|
|
145
|
+
const items = [];
|
|
146
|
+
let offset = options.offset;
|
|
147
|
+
let textOffset = options.textOffset;
|
|
148
|
+
let display = render(items, offset, textOffset);
|
|
149
|
+
while (offset < records.length && items.length < options.limit) {
|
|
150
|
+
const record = records[offset];
|
|
151
|
+
const item = {
|
|
152
|
+
ref: record.ref,
|
|
153
|
+
source: record.source,
|
|
154
|
+
date: new Date(record.timestamp).toISOString(),
|
|
155
|
+
status: record.status,
|
|
156
|
+
file: relative(filters.sessionsDir, record.sessionFile),
|
|
157
|
+
line: record.line,
|
|
158
|
+
text: record.text?.slice(textOffset),
|
|
159
|
+
};
|
|
160
|
+
if (textOffset)
|
|
161
|
+
item.textRange = { start: textOffset, end: record.text.length, total: record.text.length };
|
|
162
|
+
const candidate = render([...items, item], offset + 1);
|
|
163
|
+
if (fits(candidate)) {
|
|
164
|
+
items.push(item);
|
|
165
|
+
offset++;
|
|
166
|
+
textOffset = 0;
|
|
167
|
+
display = candidate;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
// Defer whole reports rather than splitting them merely to fill a response.
|
|
171
|
+
if (items.length) break;
|
|
172
|
+
return fragmentPage(record, item, { offset, textOffset, render, evidenceFile });
|
|
173
|
+
}
|
|
174
|
+
if (!fits(display))
|
|
175
|
+
throw new Error(`Display metadata exceeds 16 KB; full evidence is in ${evidenceFile}.`);
|
|
176
|
+
return display;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function safeTextEnd(text, end) {
|
|
180
|
+
const before = text.charCodeAt(end - 1);
|
|
181
|
+
const after = text.charCodeAt(end);
|
|
182
|
+
return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff ? end - 1 : end;
|
|
183
|
+
}
|
package/src/command.ts
CHANGED
|
@@ -54,7 +54,7 @@ async function sendPersistedDebugReport(
|
|
|
54
54
|
})
|
|
55
55
|
: await readSessionDebugEvents(sessionFile, filters);
|
|
56
56
|
const output = truncateDebugOutput(
|
|
57
|
-
formatDebugEvents(persisted.events, false, false, persisted
|
|
57
|
+
formatDebugEvents(persisted.events, false, false, persisted).join("\n"),
|
|
58
58
|
);
|
|
59
59
|
pi.sendMessage({
|
|
60
60
|
customType: DEBUG_REPORT_TYPE,
|
|
@@ -63,10 +63,12 @@ async function sendPersistedDebugReport(
|
|
|
63
63
|
details: createDebugMessageDetails(persisted.events, {
|
|
64
64
|
sessionFile: query.sessionFile,
|
|
65
65
|
persistedEventCount: persisted.persistedEventCount,
|
|
66
|
+
invalidEventCount: persisted.invalidEventCount,
|
|
67
|
+
malformedLineCount: persisted.malformedLineCount,
|
|
66
68
|
eventCount: persisted.events.length,
|
|
67
69
|
emptyReason:
|
|
68
70
|
persisted.events.length === 0
|
|
69
|
-
? persisted.persistedEventCount === 0
|
|
71
|
+
? persisted.persistedEventCount === 0 && persisted.malformedLineCount === 0
|
|
70
72
|
? "no-persisted-events"
|
|
71
73
|
: "no-matches"
|
|
72
74
|
: undefined,
|
package/src/config.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface DebugConfig extends Record<string, unknown> {
|
|
|
14
14
|
enabled: boolean;
|
|
15
15
|
agentAccess: DebugAgentAccess;
|
|
16
16
|
maxEvents: number;
|
|
17
|
+
agentRunRetrospectivesEnabled: boolean;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export const DEBUG_DEFAULTS: DebugConfig = { ...DEBUG_REGISTRY_DEFAULTS };
|
|
@@ -27,7 +28,7 @@ export function normalizeMaxEvents(value: string | number): number {
|
|
|
27
28
|
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEBUG_DEFAULTS.maxEvents;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
function
|
|
31
|
+
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
|
31
32
|
if (typeof value === "boolean") {
|
|
32
33
|
return value;
|
|
33
34
|
}
|
|
@@ -51,20 +52,24 @@ function normalizeEnabled(value: unknown): boolean {
|
|
|
51
52
|
) {
|
|
52
53
|
return false;
|
|
53
54
|
}
|
|
54
|
-
return
|
|
55
|
+
return fallback;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
if (value === 1) return true;
|
|
58
59
|
if (value === 0) return false;
|
|
59
|
-
return
|
|
60
|
+
return fallback;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
export function loadDebugConfig(cwd: string): DebugConfig {
|
|
63
64
|
const config = loadSupiConfig(DEBUG_SECTION, cwd, DEBUG_DEFAULTS);
|
|
64
65
|
return {
|
|
65
|
-
enabled:
|
|
66
|
+
enabled: normalizeBoolean(config.enabled, DEBUG_DEFAULTS.enabled),
|
|
66
67
|
agentAccess: normalizeAgentAccess(String(config.agentAccess)),
|
|
67
68
|
maxEvents: normalizeMaxEvents(config.maxEvents),
|
|
69
|
+
agentRunRetrospectivesEnabled: normalizeBoolean(
|
|
70
|
+
config.agentRunRetrospectivesEnabled,
|
|
71
|
+
DEBUG_DEFAULTS.agentRunRetrospectivesEnabled,
|
|
72
|
+
),
|
|
68
73
|
};
|
|
69
74
|
}
|
|
70
75
|
|