@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/src/debug.ts CHANGED
@@ -5,7 +5,7 @@ import { registerContextProvider } from "@mrclrchtr/supi-core/context";
5
5
  import {
6
6
  clearDebugEvents,
7
7
  getDebugSummary,
8
- subscribeDebugEvents,
8
+ subscribeDebugPersistence,
9
9
  } from "@mrclrchtr/supi-core/debug";
10
10
  import { defineConfigSettings, registerSettings } from "@mrclrchtr/supi-core/settings";
11
11
  import { registerDebugCommand } from "./command.ts";
@@ -52,6 +52,14 @@ function registerDebugSettings(pi: ExtensionAPI): void {
52
52
  description: "Maximum session-local debug events retained in memory",
53
53
  values: ["50", "100", "250", "500"],
54
54
  },
55
+ {
56
+ kind: "boolean" as const,
57
+ // biome-ignore lint/security/noSecrets: Configuration field name, not a credential.
58
+ key: "agentRunRetrospectivesEnabled",
59
+ label: "Agent Run Retrospectives",
60
+ description:
61
+ "Allow completed Agent Runs to request a retrospective when Debug logging is active",
62
+ },
55
63
  ],
56
64
  afterPersist: ({ cwd }) => {
57
65
  syncLiveDebugRegistry(cwd);
@@ -79,7 +87,7 @@ export default function debugExtension(pi: ExtensionAPI) {
79
87
  applyDebugConfig(process.cwd());
80
88
  registerDebugSettings(pi);
81
89
  registerDebugMessageRenderer(pi);
82
- const unsubscribeDebugEvents = subscribeDebugEvents((event) => {
90
+ const unsubscribeDebugPersistence = subscribeDebugPersistence((event) => {
83
91
  pi.appendEntry(DEBUG_EVENT_ENTRY_TYPE, event);
84
92
  });
85
93
 
@@ -96,13 +104,12 @@ export default function debugExtension(pi: ExtensionAPI) {
96
104
 
97
105
  pi.on("resources_discover", async (_event, ctx) => {
98
106
  maybeLogLoadStatus(pi, ctx.cwd, "resources_discover");
99
- // Self-register the package prompt template so standalone installs and
100
- // workspace-root loads expose the same `/supi-tooling-retro` surface.
101
- return { promptPaths: [join(baseDir, "prompts")] };
107
+ // Standalone installs and workspace-root loads expose the same resources.
108
+ return { promptPaths: [join(baseDir, "prompts")], skillPaths: [join(baseDir, "skills")] };
102
109
  });
103
110
 
104
111
  pi.on("session_shutdown", () => {
105
- unsubscribeDebugEvents();
112
+ unsubscribeDebugPersistence();
106
113
  });
107
114
 
108
115
  registerDebugCommand(pi, applyDebugConfig, normalizeMaxEvents);
@@ -7,6 +7,7 @@ import {
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import type { DebugEventView } from "@mrclrchtr/supi-core/debug";
9
9
  import { formatDataLines } from "./format.ts";
10
+ import type { SessionDebugEvents } from "./session-events.ts";
10
11
 
11
12
  const TRUNCATION_RESERVE_LINES = 2;
12
13
  const TRUNCATION_RESERVE_BYTES = 512;
@@ -27,22 +28,28 @@ function formatTimestamp(timestamp: number): string {
27
28
  return Number.isNaN(date.getTime()) ? String(timestamp) : date.toISOString();
28
29
  }
29
30
 
30
- /** Format debug events for model-facing or command output. */
31
+ /** Session-wide scan counts and empty-selection context for both debug surfaces. */
32
+ export type DebugEventFormatOptions = Partial<
33
+ Pick<SessionDebugEvents, "persistedEventCount" | "invalidEventCount" | "malformedLineCount">
34
+ > & { queryLimitExcludedAll?: boolean };
35
+
36
+ function formatEmptySelection(options: DebugEventFormatOptions): string {
37
+ if (options.queryLimitExcludedAll) {
38
+ return "No events selected: the query limit excludes all matching events.";
39
+ }
40
+ return options.persistedEventCount === 0 && !options.malformedLineCount
41
+ ? "This session has no persisted debug events; sessions recorded before persistence cannot be backfilled."
42
+ : "No matching debug events available.";
43
+ }
44
+
45
+ /** Format debug events and access/integrity warnings, including empty selections. */
31
46
  export function formatDebugEvents(
32
47
  events: readonly DebugEventView[],
33
48
  rawAccessDenied: boolean,
34
49
  rawDataUnavailable = false,
35
- persistedEventCount?: number,
50
+ options: DebugEventFormatOptions = {},
36
51
  ): string[] {
37
- if (events.length === 0) {
38
- return persistedEventCount === 0
39
- ? [
40
- "This session has no persisted debug events; sessions recorded before persistence cannot be backfilled.",
41
- ]
42
- : ["No matching debug events available."];
43
- }
44
-
45
- const lines: string[] = [];
52
+ const lines: string[] = events.length === 0 ? [formatEmptySelection(options)] : [];
46
53
  for (const event of events) {
47
54
  lines.push(
48
55
  `[${formatTimestamp(event.timestamp)}] ${event.level.toUpperCase()} ${event.source}/${event.category}: ${event.message}`,
@@ -52,6 +59,16 @@ export function formatDebugEvents(
52
59
  pushFormattedData(lines, "data", event.data);
53
60
  pushFormattedData(lines, "rawData", event.rawData);
54
61
  }
62
+ if (options.invalidEventCount) {
63
+ lines.unshift(
64
+ `Skipped ${options.invalidEventCount} invalid persisted debug entries; evidence is incomplete.`,
65
+ );
66
+ }
67
+ if (options.malformedLineCount) {
68
+ lines.unshift(
69
+ `Skipped ${options.malformedLineCount} malformed JSONL line${options.malformedLineCount === 1 ? "" : "s"}; evidence is incomplete.`,
70
+ );
71
+ }
55
72
  if (rawDataUnavailable) {
56
73
  lines.push("", "Raw debug data is not persisted for historical sessions.");
57
74
  } else if (rawAccessDenied) {
@@ -33,10 +33,16 @@ export interface DebugRenderDetails {
33
33
  events: DebugRenderEvent[];
34
34
  eventCount: number;
35
35
  omittedEventCount: number;
36
+ /** Matching events excluded by the query limit, separate from transcript omissions. */
37
+ queryOmittedEventCount?: number;
38
+ /** Recognized persisted entries with invalid event payloads. */
39
+ invalidEventCount?: number;
40
+ /** Nonblank session lines that could not be parsed. */
41
+ malformedLineCount?: number;
36
42
  eventDataTruncated: boolean;
37
43
  persistedEventCount?: number;
38
44
  truncation?: DebugOutputTruncation;
39
- emptyReason?: "no-persisted-events" | "no-matches";
45
+ emptyReason?: "no-persisted-events" | "no-matches" | "query-limit";
40
46
  }
41
47
 
42
48
  interface DetailValueState {
@@ -55,7 +61,10 @@ interface CreateDebugRenderDetailsOptions {
55
61
  persistedEventCount?: number;
56
62
  truncation?: TruncationResult;
57
63
  eventCount?: number;
58
- emptyReason?: "no-persisted-events" | "no-matches";
64
+ queryOmittedEventCount?: number;
65
+ invalidEventCount?: number;
66
+ malformedLineCount?: number;
67
+ emptyReason?: "no-persisted-events" | "no-matches" | "query-limit";
59
68
  }
60
69
 
61
70
  function boundedText(value: string, maxChars: number): { value: string; truncated: boolean } {
@@ -242,6 +251,15 @@ export function createDebugRenderDetails(
242
251
  events: boundedEvents.map((entry) => entry.event),
243
252
  eventCount,
244
253
  omittedEventCount: Math.max(0, eventCount - boundedEvents.length),
254
+ ...(options.queryOmittedEventCount === undefined
255
+ ? {}
256
+ : { queryOmittedEventCount: boundedCount(options.queryOmittedEventCount, 0) }),
257
+ ...(options.invalidEventCount === undefined
258
+ ? {}
259
+ : { invalidEventCount: boundedCount(options.invalidEventCount, 0) }),
260
+ ...(options.malformedLineCount === undefined
261
+ ? {}
262
+ : { malformedLineCount: boundedCount(options.malformedLineCount, 0) }),
245
263
  eventDataTruncated,
246
264
  ...(options.persistedEventCount === undefined
247
265
  ? {}
@@ -317,12 +335,17 @@ export function readDebugRenderDetails(value: unknown): DebugRenderDetails {
317
335
  rawAccessDenied: record.rawAccessDenied === true,
318
336
  rawDataUnavailable: record.rawDataUnavailable === true,
319
337
  emptyReason:
320
- record.emptyReason === "no-persisted-events" || record.emptyReason === "no-matches"
338
+ record.emptyReason === "no-persisted-events" ||
339
+ record.emptyReason === "no-matches" ||
340
+ record.emptyReason === "query-limit"
321
341
  ? record.emptyReason
322
342
  : undefined,
323
343
  persistedEventCount:
324
344
  typeof record.persistedEventCount === "number" ? record.persistedEventCount : undefined,
325
345
  eventCount: boundedCount(record.eventCount, events.length),
346
+ queryOmittedEventCount: boundedCount(record.queryOmittedEventCount, 0),
347
+ invalidEventCount: boundedCount(record.invalidEventCount, 0),
348
+ malformedLineCount: boundedCount(record.malformedLineCount, 0),
326
349
  });
327
350
 
328
351
  const parsedTruncation = parseTruncation(record.truncation);
package/src/renderer.ts CHANGED
@@ -66,6 +66,30 @@ function formatTruncation(truncation: DebugRenderDetails["truncation"]): string
66
66
 
67
67
  function buildDetailNotes(details: DebugRenderDetails, theme: Theme): string[] {
68
68
  const notes: string[] = [];
69
+ if (details.queryOmittedEventCount) {
70
+ notes.push(
71
+ theme.fg(
72
+ "warning",
73
+ `${details.queryOmittedEventCount} matching events excluded by the query limit.`,
74
+ ),
75
+ );
76
+ }
77
+ if (details.invalidEventCount) {
78
+ notes.push(
79
+ theme.fg(
80
+ "warning",
81
+ `${details.invalidEventCount} invalid persisted debug entries skipped; evidence is incomplete.`,
82
+ ),
83
+ );
84
+ }
85
+ if (details.malformedLineCount) {
86
+ notes.push(
87
+ theme.fg(
88
+ "warning",
89
+ `${details.malformedLineCount} malformed JSONL lines skipped; evidence is incomplete.`,
90
+ ),
91
+ );
92
+ }
69
93
  if (details.omittedEventCount > 0) {
70
94
  notes.push(
71
95
  theme.fg(
@@ -104,7 +128,9 @@ function buildEventLines(details: DebugRenderDetails, theme: Theme): string[] {
104
128
  "muted",
105
129
  details.emptyReason === "no-persisted-events"
106
130
  ? "This session has no persisted debug events."
107
- : "No matching debug events available.",
131
+ : details.emptyReason === "query-limit"
132
+ ? "No events selected by limit."
133
+ : "No matching debug events available.",
108
134
  ),
109
135
  );
110
136
  }
@@ -141,6 +167,8 @@ function renderCollapsedMessage(details: DebugRenderDetails, theme: Theme): Text
141
167
  let summary = `${details.eventCount} event${details.eventCount === 1 ? "" : "s"} — ${first?.source ?? "debug"}/${first?.category ?? "event"}`;
142
168
  if (details.eventCount > 1) summary += ` +${details.eventCount - 1} more`;
143
169
  if (details.omittedEventCount > 0) summary += ` · ${details.omittedEventCount} omitted`;
170
+ if (details.invalidEventCount) summary += ` · ${details.invalidEventCount} invalid entries`;
171
+ if (details.malformedLineCount) summary += ` · ${details.malformedLineCount} malformed lines`;
144
172
  if (details.eventDataTruncated) summary += " · data bounded";
145
173
  if (details.truncation?.truncated) summary += " · output truncated";
146
174
  if (details.rawDataUnavailable || details.rawAccessDenied) summary += " · raw unavailable";
@@ -18,10 +18,16 @@ type PersistedDebugEventQuery = Pick<
18
18
  "operationId" | "source" | "level" | "category" | "limit"
19
19
  >;
20
20
 
21
- /** Sanitized events and total persisted entries found in one PI session file. */
21
+ /** Sanitized selection and scan totals from one PI session file. */
22
22
  export interface SessionDebugEvents {
23
23
  events: DebugEventView[];
24
24
  persistedEventCount: number;
25
+ /** Valid matching events before the query limit is applied. */
26
+ matchedEventCount: number;
27
+ /** Recognized debug entries whose event payload could not be validated. */
28
+ invalidEventCount: number;
29
+ /** Nonblank JSONL lines that could not be parsed; their event identity is unknown. */
30
+ malformedLineCount: number;
25
31
  }
26
32
 
27
33
  /** Small progress facts emitted while a persisted session file is scanned. */
@@ -68,12 +74,13 @@ function parsePersistedEvent(data: unknown): DebugEventView | undefined {
68
74
  };
69
75
  }
70
76
 
71
- function parseDebugEntry(line: string): unknown {
72
- if (!line.includes('"customType"')) return undefined;
77
+ function parseSessionLine(line: string): { entry?: unknown; malformed: boolean } {
78
+ if (!line.trim()) return { malformed: false };
73
79
  try {
74
- return JSON.parse(line);
80
+ return { entry: JSON.parse(line), malformed: false };
75
81
  } catch {
76
- return undefined;
82
+ // Do not retain line contents: damaged rows can contain unsanitized session data.
83
+ return { malformed: true };
77
84
  }
78
85
  }
79
86
 
@@ -99,6 +106,8 @@ export async function readSessionDebugEvents(
99
106
  ): Promise<SessionDebugEvents> {
100
107
  const events: DebugEventView[] = [];
101
108
  let persistedEventCount = 0;
109
+ let invalidEventCount = 0;
110
+ let malformedLineCount = 0;
102
111
  let scannedLines = 0;
103
112
  throwIfAborted(options.signal);
104
113
 
@@ -117,7 +126,8 @@ export async function readSessionDebugEvents(
117
126
  for await (const line of lines) {
118
127
  scannedLines++;
119
128
  throwIfAborted(options.signal);
120
- const entry = parseDebugEntry(line);
129
+ const { entry, malformed } = parseSessionLine(line);
130
+ malformedLineCount += Number(malformed);
121
131
  if (
122
132
  typeof entry !== "object" ||
123
133
  entry === null ||
@@ -136,6 +146,7 @@ export async function readSessionDebugEvents(
136
146
 
137
147
  persistedEventCount++;
138
148
  const event = parsePersistedEvent((entry as Record<string, unknown>).data);
149
+ invalidEventCount += Number(event === undefined);
139
150
  if (event && matchesDebugEventQuery(event, query)) events.push(event);
140
151
 
141
152
  if (scannedLines % 250 === 0) {
@@ -164,5 +175,8 @@ export async function readSessionDebugEvents(
164
175
  return {
165
176
  events: events.sort((a, b) => b.timestamp - a.timestamp).slice(0, limit),
166
177
  persistedEventCount,
178
+ matchedEventCount: events.length,
179
+ invalidEventCount,
180
+ malformedLineCount,
167
181
  };
168
182
  }
@@ -0,0 +1,11 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { DebugLevel } from "@mrclrchtr/supi-core/debug";
3
+ import { Type } from "typebox";
4
+
5
+ /** Shared Debug Operation ID constraint for filters and returned event identity. */
6
+ export const debugOperationIdSchema = Type.String({ pattern: "^op-[A-Za-z0-9_-]{21}[AQgw]$" });
7
+
8
+ /** Shared level vocabulary; StringEnum's string type is narrowed to the registry contract. */
9
+ export const debugLevelSchema = Type.Unsafe<DebugLevel>(
10
+ StringEnum(["debug", "info", "warning", "error"]),
11
+ );
@@ -53,15 +53,29 @@ export function renderDebugToolCall(args: unknown, theme: Theme): Text {
53
53
  return new Text(content, 0, 0);
54
54
  }
55
55
 
56
+ function formatEmptySummary(details: DebugRenderDetails): string {
57
+ const empty =
58
+ details.emptyReason === "no-persisted-events"
59
+ ? "No persisted debug events"
60
+ : details.emptyReason === "query-limit"
61
+ ? "No events selected by limit"
62
+ : "No matching debug events";
63
+ const notes = [empty];
64
+ if (details.invalidEventCount) notes.push("invalid entries skipped");
65
+ if (details.malformedLineCount) notes.push(`${details.malformedLineCount} malformed lines`);
66
+ if (details.rawAccessDenied || details.rawDataUnavailable) notes.push("raw unavailable");
67
+ return notes.join(" · ");
68
+ }
69
+
56
70
  function renderToolSummary(details: DebugRenderDetails, theme: Theme): string {
57
- if (details.eventCount === 0) {
58
- return details.emptyReason === "no-persisted-events"
59
- ? theme.fg("muted", "No persisted debug events")
60
- : theme.fg("muted", "No matching debug events");
61
- }
71
+ if (details.eventCount === 0) return theme.fg("muted", formatEmptySummary(details));
62
72
 
63
73
  let summary = `${details.eventCount} event${details.eventCount === 1 ? "" : "s"}`;
64
74
  if (details.omittedEventCount > 0) summary += ` · ${details.omittedEventCount} omitted`;
75
+ if (details.queryOmittedEventCount)
76
+ summary += ` · ${details.queryOmittedEventCount} excluded by limit`;
77
+ if (details.invalidEventCount) summary += ` · ${details.invalidEventCount} invalid entries`;
78
+ if (details.malformedLineCount) summary += ` · ${details.malformedLineCount} malformed lines`;
65
79
  if (details.eventDataTruncated) summary += " · data bounded";
66
80
  if (details.truncation?.truncated) summary += " · output truncated";
67
81
  if (details.rawDataUnavailable || details.rawAccessDenied) summary += " · raw unavailable";
@@ -0,0 +1,71 @@
1
+ import type { JsonValue } from "@earendil-works/pi-ai";
2
+ import { type Static, Type } from "typebox";
3
+ import { debugLevelSchema, debugOperationIdSchema } from "./input.ts";
4
+
5
+ // Diagnostic payloads have producer-defined keys. Transport values are normalized to JSON.
6
+ const diagnosticDataSchema = Type.Unsafe<JsonValue>(
7
+ Type.Unknown({ description: "JSON-safe diagnostic data with producer-defined fields." }),
8
+ );
9
+
10
+ /** Native structured result contract for the debug tool, independent of UI/text bounds. */
11
+ export const debugOutputSchema = Type.Object({
12
+ events: Type.Array(
13
+ Type.Object({
14
+ id: Type.Number(),
15
+ timestamp: Type.Number({ description: "Unix time in milliseconds." }),
16
+ operationId: Type.Optional(debugOperationIdSchema),
17
+ source: Type.String(),
18
+ level: debugLevelSchema,
19
+ category: Type.String(),
20
+ message: Type.String(),
21
+ cwd: Type.Optional(Type.String()),
22
+ data: Type.Optional(diagnosticDataSchema),
23
+ rawData: Type.Optional(diagnosticDataSchema),
24
+ }),
25
+ ),
26
+ provenance: Type.Union([
27
+ Type.Object({ kind: Type.Literal("live"), scope: Type.Literal("retained-buffer") }),
28
+ Type.Object({
29
+ kind: Type.Literal("persisted"),
30
+ sessionFile: Type.String(),
31
+ persistedEventCount: Type.Integer({ minimum: 0 }),
32
+ invalidEventCount: Type.Integer({ minimum: 0 }),
33
+ malformedLineCount: Type.Integer({
34
+ minimum: 0,
35
+ description:
36
+ "Nonblank session JSONL lines that could not be parsed; their event identity is unknown.",
37
+ }),
38
+ }),
39
+ ]),
40
+ selection: Type.Object({
41
+ matchedEventCount: Type.Integer({ minimum: 0 }),
42
+ returnedEventCount: Type.Integer({ minimum: 0 }),
43
+ omittedEventCount: Type.Integer({ minimum: 0 }),
44
+ complete: Type.Boolean({
45
+ description:
46
+ "All matching events in this source were returned, no recognized persisted debug entries were invalid, and no nonblank session JSONL lines were malformed. Live evidence covers only the retained buffer, not the entire session.",
47
+ }),
48
+ }),
49
+ rawAccessDenied: Type.Boolean(),
50
+ rawDataUnavailable: Type.Boolean(),
51
+ dataNormalized: Type.Boolean({
52
+ description: "Non-JSON values or excessive nesting were replaced with diagnostic text markers.",
53
+ }),
54
+ emptyReason: Type.Optional(
55
+ Type.Union([
56
+ Type.Literal("no-persisted-events"),
57
+ Type.Literal("no-matches"),
58
+ Type.Literal("query-limit"),
59
+ ]),
60
+ ),
61
+ modelOutput: Type.Object({
62
+ truncated: Type.Boolean(),
63
+ outputLines: Type.Optional(Type.Integer({ minimum: 0 })),
64
+ totalLines: Type.Optional(Type.Integer({ minimum: 0 })),
65
+ outputBytes: Type.Optional(Type.Integer({ minimum: 0 })),
66
+ totalBytes: Type.Optional(Type.Integer({ minimum: 0 })),
67
+ }),
68
+ });
69
+
70
+ /** Selected event evidence and query completeness delivered to programmatic callers. */
71
+ export type DebugToolOutput = Static<typeof debugOutputSchema>;
@@ -1,4 +1,4 @@
1
- import type { AgentToolUpdateCallback } from "@earendil-works/pi-coding-agent";
1
+ import type { AgentToolResult, AgentToolUpdateCallback } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  type DebugEventQuery,
4
4
  type DebugEventView,
@@ -8,8 +8,10 @@ import { resolveToolPath } from "@mrclrchtr/supi-core/path";
8
8
  import type { DebugConfig } from "../../config.ts";
9
9
  import { formatDebugEvents, truncateDebugOutput } from "../../format-events.ts";
10
10
  import type { DebugToolParams } from "../../query.ts";
11
- import { createDebugRenderDetails } from "../../render-details.ts";
11
+ import { createDebugRenderDetails, type DebugRenderDetails } from "../../render-details.ts";
12
12
  import { readSessionDebugEvents } from "../../session-events.ts";
13
+ import type { DebugToolOutput } from "./result-schema.ts";
14
+ import { serializeDebugEvents } from "./serialize.ts";
13
15
 
14
16
  interface DebugProgressDetails {
15
17
  scannedLines: number;
@@ -39,8 +41,49 @@ export interface DebugToolExecutionOptions {
39
41
  onUpdate?: AgentToolUpdateCallback<unknown>;
40
42
  }
41
43
 
42
- /** Assemble the model-facing debug tool result for one query. */
43
- export async function buildToolResult(params: DebugToolParams, options: DebugToolExecutionOptions) {
44
+ /** Final native result with separate machine-readable evidence and bounded transcript details. */
45
+ export type DebugToolResult = AgentToolResult<DebugRenderDetails> & {
46
+ structuredContent: DebugToolOutput;
47
+ };
48
+
49
+ function getEmptyReason(
50
+ returnedEventCount: number,
51
+ matchedEventCount: number,
52
+ provenance: DebugToolOutput["provenance"],
53
+ ): DebugToolOutput["emptyReason"] {
54
+ if (returnedEventCount > 0) return undefined;
55
+ if (matchedEventCount > 0) return "query-limit";
56
+ return provenance.kind === "persisted" &&
57
+ provenance.persistedEventCount === 0 &&
58
+ provenance.malformedLineCount === 0
59
+ ? "no-persisted-events"
60
+ : "no-matches";
61
+ }
62
+
63
+ function formatResult(evidence: DebugToolOutput) {
64
+ const { events, selection, provenance, rawAccessDenied, rawDataUnavailable } = evidence;
65
+ const persisted = provenance.kind === "persisted" ? provenance : undefined;
66
+ const lines = formatDebugEvents(events, rawAccessDenied, rawDataUnavailable, {
67
+ persistedEventCount: persisted?.persistedEventCount,
68
+ invalidEventCount: persisted?.invalidEventCount,
69
+ malformedLineCount: persisted?.malformedLineCount,
70
+ queryLimitExcludedAll: evidence.emptyReason === "query-limit",
71
+ });
72
+ if (selection.omittedEventCount > 0) {
73
+ lines.unshift(
74
+ `Showing ${selection.returnedEventCount} of ${selection.matchedEventCount} matching debug events; ${selection.omittedEventCount} omitted by limit.`,
75
+ );
76
+ }
77
+ if (evidence.dataNormalized)
78
+ lines.push("Non-JSON diagnostic values were normalized to text markers.");
79
+ return truncateDebugOutput(lines.join("\n"));
80
+ }
81
+
82
+ /** Assemble both result representations from the same selected event facts. */
83
+ export async function buildToolResult(
84
+ params: DebugToolParams,
85
+ options: DebugToolExecutionOptions,
86
+ ): Promise<DebugToolResult> {
44
87
  const { config, cwd, signal, onUpdate } = options;
45
88
  if (!config.enabled && !params.sessionFile) {
46
89
  throw new Error(
@@ -68,6 +111,10 @@ export async function buildToolResult(params: DebugToolParams, options: DebugToo
68
111
  let rawAccessDenied: boolean;
69
112
  let rawDataUnavailable = false;
70
113
  let persistedEventCount: number | undefined;
114
+ let matchedEventCount: number;
115
+ let invalidEventCount = 0;
116
+ let malformedLineCount = 0;
117
+ let provenance: DebugToolOutput["provenance"];
71
118
  if (params.sessionFile) {
72
119
  const sessionFile = resolveToolPath(cwd, params.sessionFile);
73
120
  const persisted =
@@ -79,35 +126,72 @@ export async function buildToolResult(params: DebugToolParams, options: DebugToo
79
126
  : await readSessionDebugEvents(sessionFile, filters);
80
127
  events = persisted.events;
81
128
  persistedEventCount = persisted.persistedEventCount;
129
+ matchedEventCount = persisted.matchedEventCount;
130
+ invalidEventCount = persisted.invalidEventCount;
131
+ malformedLineCount = persisted.malformedLineCount;
132
+ provenance = {
133
+ kind: "persisted",
134
+ sessionFile,
135
+ persistedEventCount,
136
+ invalidEventCount,
137
+ malformedLineCount,
138
+ };
82
139
  rawAccessDenied = Boolean(params.includeRaw);
83
140
  rawDataUnavailable = rawAccessDenied;
84
141
  } else {
85
- const result = getDebugEvents(query);
86
- events = result.events;
142
+ // The live registry is already retention-bounded. Select locally so omission counts are exact.
143
+ const result = getDebugEvents({ ...query, limit: undefined });
144
+ matchedEventCount = result.events.length;
145
+ const limit = params.limit && params.limit > 0 ? Math.floor(params.limit) : matchedEventCount;
146
+ events = result.events.slice(0, limit);
87
147
  rawAccessDenied = result.rawAccessDenied;
148
+ provenance = { kind: "live", scope: "retained-buffer" };
149
+ }
150
+ const serialized = serializeDebugEvents(events);
151
+ const omittedEventCount = matchedEventCount - serialized.events.length;
152
+ const emptyReason = getEmptyReason(serialized.events.length, matchedEventCount, provenance);
153
+ const structuredContent: DebugToolOutput = {
154
+ ...serialized,
155
+ provenance,
156
+ selection: {
157
+ matchedEventCount,
158
+ returnedEventCount: serialized.events.length,
159
+ omittedEventCount,
160
+ complete: omittedEventCount === 0 && invalidEventCount === 0 && malformedLineCount === 0,
161
+ },
162
+ rawAccessDenied,
163
+ rawDataUnavailable,
164
+ ...(emptyReason === undefined ? {} : { emptyReason }),
165
+ modelOutput: { truncated: false },
166
+ };
167
+ const output = formatResult(structuredContent);
168
+ if (output.truncation) {
169
+ structuredContent.modelOutput = {
170
+ truncated: true,
171
+ outputLines: output.truncation.outputLines,
172
+ totalLines: output.truncation.totalLines,
173
+ outputBytes: output.truncation.outputBytes,
174
+ totalBytes: output.truncation.totalBytes,
175
+ };
88
176
  }
89
- const output = truncateDebugOutput(
90
- formatDebugEvents(events, rawAccessDenied, rawDataUnavailable, persistedEventCount).join("\n"),
91
- );
92
- const details = createDebugRenderDetails(events, {
177
+ const details = createDebugRenderDetails(serialized.events, {
93
178
  enabled: config.enabled,
94
179
  agentAccess: config.agentAccess,
95
180
  sessionFile: params.sessionFile,
96
181
  rawAccessDenied,
97
182
  rawDataUnavailable,
98
183
  persistedEventCount,
99
- eventCount: events.length,
100
- emptyReason:
101
- events.length === 0
102
- ? persistedEventCount === 0
103
- ? "no-persisted-events"
104
- : "no-matches"
105
- : undefined,
184
+ eventCount: structuredContent.selection.returnedEventCount,
185
+ queryOmittedEventCount: structuredContent.selection.omittedEventCount,
186
+ invalidEventCount,
187
+ malformedLineCount,
188
+ emptyReason,
106
189
  truncation: output.truncation,
107
190
  });
108
191
 
109
192
  return {
110
193
  content: [{ type: "text" as const, text: output.text }],
111
194
  details,
195
+ structuredContent,
112
196
  };
113
197
  }
@@ -0,0 +1,66 @@
1
+ import type { JsonValue } from "@earendil-works/pi-ai";
2
+ import type { DebugEventView } from "@mrclrchtr/supi-core/debug";
3
+ import type { DebugToolOutput } from "./result-schema.ts";
4
+
5
+ const MAX_DATA_DEPTH = 64;
6
+
7
+ interface SerializationState {
8
+ normalized: boolean;
9
+ ancestors: WeakSet<object>;
10
+ }
11
+
12
+ function marker(state: SerializationState, text: string): string {
13
+ state.normalized = true;
14
+ return text;
15
+ }
16
+
17
+ function serializeValue(value: unknown, state: SerializationState, depth: number): JsonValue {
18
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
19
+ if (typeof value === "number") {
20
+ return Number.isFinite(value) ? value : marker(state, String(value));
21
+ }
22
+ if (typeof value === "bigint") return marker(state, `${value}n`);
23
+ if (typeof value === "undefined") return marker(state, "[Undefined]");
24
+ if (typeof value === "function") return marker(state, "[Function]");
25
+ if (typeof value === "symbol") return marker(state, "[Symbol]");
26
+ if (state.ancestors.has(value)) return marker(state, "[Circular]");
27
+ if (depth >= MAX_DATA_DEPTH) return marker(state, "[MaxDepth]");
28
+
29
+ // Never transport runtime instances, invoke toJSON, or copy hidden/private state.
30
+ const prototype: unknown = Object.getPrototypeOf(value);
31
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
32
+ return marker(state, "[Non-JSON object]");
33
+ }
34
+
35
+ state.ancestors.add(value);
36
+ try {
37
+ return Array.isArray(value)
38
+ ? Array.from(value, (item) => serializeValue(item, state, depth + 1))
39
+ : Object.fromEntries(
40
+ Object.entries(value).map(([key, item]) => [key, serializeValue(item, state, depth + 1)]),
41
+ );
42
+ } finally {
43
+ state.ancestors.delete(value);
44
+ }
45
+ }
46
+
47
+ /** Snapshot selected events as plain JSON without retaining producer-owned references. */
48
+ export function serializeDebugEvents(events: readonly DebugEventView[]): {
49
+ events: DebugToolOutput["events"];
50
+ dataNormalized: boolean;
51
+ } {
52
+ const state: SerializationState = { normalized: false, ancestors: new WeakSet<object>() };
53
+ const serialized = events.map((event) => ({
54
+ id: event.id,
55
+ timestamp: event.timestamp,
56
+ source: event.source,
57
+ level: event.level,
58
+ category: event.category,
59
+ message: event.message,
60
+ ...(event.operationId === undefined ? {} : { operationId: event.operationId }),
61
+ ...(event.cwd === undefined ? {} : { cwd: event.cwd }),
62
+ ...(event.data === undefined ? {} : { data: serializeValue(event.data, state, 0) }),
63
+ ...(event.rawData === undefined ? {} : { rawData: serializeValue(event.rawData, state, 0) }),
64
+ }));
65
+ return { events: serialized, dataNormalized: state.normalized };
66
+ }