@mrclrchtr/supi-debug 4.5.1 → 4.7.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 CHANGED
@@ -1,8 +1,6 @@
1
1
  <div align="center">
2
2
  <a href="https://github.com/mrclrchtr/supi/tree/main/packages/supi-debug">
3
- <picture>
4
- <img src="https://raw.githubusercontent.com/mrclrchtr/supi/main/packages/supi-debug/assets/social-preview.png" alt="SuPi Debug" width="100%">
5
- </picture>
3
+ <img src="https://raw.githubusercontent.com/mrclrchtr/supi/main/packages/supi-debug/assets/social-preview.png" alt="SuPi Debug" width="100%">
6
4
  </a>
7
5
  </div>
8
6
 
@@ -37,6 +35,7 @@ It also registers a **Debug** provider section for `/supi-context`.
37
35
  ## Event behavior
38
36
 
39
37
  - events are session-local
38
+ - sanitized events are also persisted in the session JSONL for later inspection
40
39
  - the event buffer is cleared on `session_start`
41
40
  - if debug capture is disabled, no events are retained
42
41
  - agent-facing access is blocked, sanitized, or raw depending on settings
@@ -86,9 +85,9 @@ Both `/supi-debug` and `supi_debug` support the same basic filters:
86
85
  - `category`
87
86
  - `limit`
88
87
 
89
- The tool also accepts:
90
-
91
- - `includeRaw` request raw event data when settings allow it
88
+ For historical sessions, pass `sessionFile` to `supi_debug`, or
89
+ `sessionFile=<path>` to `/supi-debug`. Historical sessions never retain raw data.
90
+ The tool also accepts `includeRaw` for live-session data when settings allow it.
92
91
 
93
92
  ## Settings
94
93
 
@@ -100,6 +99,9 @@ Available settings:
100
99
  - `agentAccess` — `off`, `sanitized`, or `raw`
101
100
  - `maxEvents` — maximum retained events in memory
102
101
 
102
+ Historical inspection works for events captured after this version is loaded. For example, an
103
+ agent can call `supi_debug` with `sessionFile` set to a PI session JSONL path.
104
+
103
105
  Defaults come from the shared debug registry:
104
106
 
105
107
  ```json
@@ -1,8 +1,6 @@
1
1
  <div align="center">
2
2
  <a href="https://github.com/mrclrchtr/supi/tree/main/packages/supi-core">
3
- <picture>
4
- <img src="https://raw.githubusercontent.com/mrclrchtr/supi/main/packages/supi-core/assets/social-preview.png" alt="SuPi Core" width="100%">
5
- </picture>
3
+ <img src="https://raw.githubusercontent.com/mrclrchtr/supi/main/packages/supi-core/assets/social-preview.png" alt="SuPi Core" width="100%">
6
4
  </a>
7
5
  </div>
8
6
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "4.5.1",
4
- "description": "SuPi core shared infrastructure for SuPi extensions (XML context tags, config system)",
3
+ "version": "4.7.0",
4
+ "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -64,6 +64,9 @@ export interface DebugEventQueryResult {
64
64
  rawAccessDenied: boolean;
65
65
  }
66
66
 
67
+ /** Receives a sanitized event whenever the registry records one. */
68
+ export type DebugEventListener = (event: DebugEventView) => void;
69
+
67
70
  export interface DebugSummary {
68
71
  total: number;
69
72
  byLevel: Partial<Record<DebugLevel, number>>;
@@ -73,6 +76,7 @@ export interface DebugSummary {
73
76
  interface DebugRegistryState {
74
77
  config: DebugRegistryConfig;
75
78
  events: DebugEvent[];
79
+ listeners: Set<DebugEventListener>;
76
80
  nextId: number;
77
81
  }
78
82
 
@@ -97,10 +101,14 @@ function getState(): DebugRegistryState {
97
101
  state = {
98
102
  config: cloneConfig(DEBUG_REGISTRY_DEFAULTS),
99
103
  events: [],
104
+ listeners: new Set(),
100
105
  nextId: 1,
101
106
  };
102
107
  (globalThis as Record<symbol, unknown>)[REGISTRY_KEY] = state;
103
108
  }
109
+ // Keep the shared registry compatible with extension reloads that reuse an
110
+ // instance created before listeners existed.
111
+ if (!state.listeners) state.listeners = new Set();
104
112
  return state;
105
113
  }
106
114
 
@@ -119,7 +127,16 @@ function trimToMaxEvents(state: DebugRegistryState): void {
119
127
  state.events.splice(0, state.events.length - maxEvents);
120
128
  }
121
129
 
122
- function matchesQuery(event: DebugEvent, query: DebugEventQuery): boolean {
130
+ /** Return whether a debug level is recognized by the registry. */
131
+ export function isDebugLevel(value: unknown): value is DebugLevel {
132
+ return value === "debug" || value === "info" || value === "warning" || value === "error";
133
+ }
134
+
135
+ /** Match a debug event against the supported source, level, and category filters. */
136
+ export function matchesDebugEventQuery(
137
+ event: Pick<DebugEventView, "source" | "level" | "category">,
138
+ query: Pick<DebugEventQuery, "source" | "level" | "category">,
139
+ ): boolean {
123
140
  if (query.source && event.source !== query.source) return false;
124
141
  if (query.level && event.level !== query.level) return false;
125
142
  if (query.category && event.category !== query.category) return false;
@@ -168,6 +185,26 @@ export function redactDebugData<T>(value: T): T {
168
185
  return redactValue(value, 8) as T;
169
186
  }
170
187
 
188
+ function toSanitizedView(event: DebugEvent): DebugEventView {
189
+ return {
190
+ id: event.id,
191
+ timestamp: event.timestamp,
192
+ source: event.source,
193
+ level: event.level,
194
+ category: event.category,
195
+ message: event.message,
196
+ cwd: event.cwd,
197
+ data: event.data,
198
+ };
199
+ }
200
+
201
+ /** Subscribe to sanitized events. Listeners are isolated so diagnostics cannot disrupt producers. */
202
+ export function subscribeDebugEvents(listener: DebugEventListener): () => void {
203
+ const state = getState();
204
+ state.listeners.add(listener);
205
+ return () => state.listeners.delete(listener);
206
+ }
207
+
171
208
  /** Record a session-local debug event if debugging is enabled. */
172
209
  export function recordDebugEvent(input: DebugEventInput): DebugEvent | null {
173
210
  const state = getState();
@@ -183,6 +220,14 @@ export function recordDebugEvent(input: DebugEventInput): DebugEvent | null {
183
220
  };
184
221
  state.events.push(event);
185
222
  trimToMaxEvents(state);
223
+ const view = toSanitizedView(event);
224
+ for (const listener of state.listeners) {
225
+ try {
226
+ listener(view);
227
+ } catch {
228
+ // Debug-event consumers must not alter producer behavior.
229
+ }
230
+ }
186
231
  return { ...event };
187
232
  }
188
233
 
@@ -196,21 +241,12 @@ export function getDebugEvents(query: DebugEventQuery = {}): DebugEventQueryResu
196
241
  const limit = query.limit && query.limit > 0 ? Math.floor(query.limit) : state.config.maxEvents;
197
242
 
198
243
  const events = state.events
199
- .filter((event) => matchesQuery(event, query))
244
+ .filter((event) => matchesDebugEventQuery(event, query))
200
245
  .slice()
201
246
  .reverse()
202
247
  .slice(0, limit)
203
248
  .map((event): DebugEventView => {
204
- const view: DebugEventView = {
205
- id: event.id,
206
- timestamp: event.timestamp,
207
- source: event.source,
208
- level: event.level,
209
- category: event.category,
210
- message: event.message,
211
- cwd: event.cwd,
212
- data: event.data,
213
- };
249
+ const view = toSanitizedView(event);
214
250
  if (allowRaw && event.rawData !== undefined) {
215
251
  view.rawData = event.rawData;
216
252
  }
@@ -246,5 +282,6 @@ export function resetDebugRegistry(): void {
246
282
  const state = getState();
247
283
  state.config = cloneConfig(DEBUG_REGISTRY_DEFAULTS);
248
284
  state.events = [];
285
+ state.listeners.clear();
249
286
  state.nextId = 1;
250
287
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-debug",
3
- "version": "4.5.1",
4
- "description": "SuPi Debug extension shared debug event inspection for SuPi extensions",
3
+ "version": "4.7.0",
4
+ "description": "Capture and inspect SuPi debug events",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -31,7 +31,7 @@
31
31
  "README.md"
32
32
  ],
33
33
  "dependencies": {
34
- "@mrclrchtr/supi-core": "4.5.1"
34
+ "@mrclrchtr/supi-core": "4.7.0"
35
35
  },
36
36
  "bundledDependencies": [
37
37
  "@mrclrchtr/supi-core"
package/src/debug.ts CHANGED
@@ -16,14 +16,16 @@ import {
16
16
  type DebugAgentAccess,
17
17
  type DebugEventQuery,
18
18
  type DebugEventView,
19
- type DebugLevel,
20
19
  getDebugEvents,
21
20
  getDebugSummary,
21
+ isDebugLevel,
22
+ subscribeDebugEvents,
22
23
  } from "@mrclrchtr/supi-core/debug";
23
24
  import { registerDeclarativeSettings } from "@mrclrchtr/supi-core/settings";
24
25
  import { Type } from "typebox";
25
26
  import { formatDataLines } from "./format.ts";
26
27
  import { registerDebugMessageRenderer } from "./renderer.ts";
28
+ import { DEBUG_EVENT_ENTRY_TYPE, readSessionDebugEvents } from "./session-events.ts";
27
29
  import { maybeLogLoadStatus } from "./status-log.ts";
28
30
  import { promptGuidelines, promptSnippet, toolDescription } from "./tool/guidance.ts";
29
31
 
@@ -38,7 +40,7 @@ interface DebugConfig extends Record<string, unknown> {
38
40
 
39
41
  const DEBUG_DEFAULTS: DebugConfig = { ...DEBUG_REGISTRY_DEFAULTS };
40
42
 
41
- type DebugToolParams = DebugEventQuery;
43
+ type DebugToolParams = DebugEventQuery & { sessionFile?: string };
42
44
 
43
45
  function normalizeAgentAccess(value: string): DebugAgentAccess {
44
46
  return value === "off" || value === "raw" ? value : "sanitized";
@@ -138,8 +140,8 @@ function registerDebugSettings(pi: ExtensionAPI): void {
138
140
  });
139
141
  }
140
142
 
141
- function parseCommandArgs(args: string): DebugEventQuery {
142
- const query: DebugEventQuery = {};
143
+ function parseCommandArgs(args: string): DebugToolParams {
144
+ const query: DebugToolParams = {};
143
145
  const parts = args.trim().split(/\s+/).filter(Boolean);
144
146
  for (const part of parts) {
145
147
  const [key, value] = part.split("=", 2);
@@ -148,14 +150,11 @@ function parseCommandArgs(args: string): DebugEventQuery {
148
150
  if (key === "category") query.category = value;
149
151
  if (key === "level" && isDebugLevel(value)) query.level = value;
150
152
  if (key === "limit") query.limit = normalizeMaxEvents(value);
153
+ if (key === "sessionFile") query.sessionFile = value;
151
154
  }
152
155
  return query;
153
156
  }
154
157
 
155
- function isDebugLevel(value: string): value is DebugLevel {
156
- return value === "debug" || value === "info" || value === "warning" || value === "error";
157
- }
158
-
159
158
  function pushFormattedData(lines: string[], label: string, value: unknown): void {
160
159
  const dataLines = formatDataLines(value);
161
160
  if (dataLines.length === 0) return;
@@ -169,9 +168,18 @@ function pushFormattedData(lines: string[], label: string, value: unknown): void
169
168
  }
170
169
  }
171
170
 
172
- function formatEvents(events: DebugEventView[], rawAccessDenied: boolean): string[] {
171
+ function formatEvents(
172
+ events: DebugEventView[],
173
+ rawAccessDenied: boolean,
174
+ rawDataUnavailable = false,
175
+ persistedEventCount?: number,
176
+ ): string[] {
173
177
  if (events.length === 0) {
174
- return ["No matching debug events available."];
178
+ return persistedEventCount === 0
179
+ ? [
180
+ "This session has no persisted debug events; sessions recorded before persistence cannot be backfilled.",
181
+ ]
182
+ : ["No matching debug events available."];
175
183
  }
176
184
 
177
185
  const lines: string[] = [];
@@ -183,7 +191,10 @@ function formatEvents(events: DebugEventView[], rawAccessDenied: boolean): strin
183
191
  pushFormattedData(lines, "data", event.data);
184
192
  pushFormattedData(lines, "rawData", event.rawData);
185
193
  }
186
- if (rawAccessDenied) {
194
+ if (rawDataUnavailable) {
195
+ lines.push("");
196
+ lines.push("Raw debug data is not persisted for historical sessions.");
197
+ } else if (rawAccessDenied) {
187
198
  lines.push("");
188
199
  lines.push("Raw debug data was requested but is not enabled in SuPi Debug settings.");
189
200
  }
@@ -225,40 +236,55 @@ function buildSummaryData(): Record<string, string | number> | null {
225
236
  return data;
226
237
  }
227
238
 
228
- function toolAccessAllowed(config: DebugConfig): boolean {
229
- return config.enabled && config.agentAccess !== "off";
230
- }
231
-
232
- function buildToolResult(params: DebugToolParams, config: DebugConfig) {
233
- if (!config.enabled) {
239
+ async function buildToolResult(params: DebugToolParams, config: DebugConfig) {
240
+ if (!config.enabled && !params.sessionFile) {
234
241
  throw new Error(
235
242
  "SuPi debug event capture is disabled. Enable Debug in /supi-settings to retain events.",
236
243
  );
237
244
  }
238
245
 
239
- if (!toolAccessAllowed(config)) {
246
+ if (config.agentAccess === "off") {
240
247
  throw new Error("Agent access to SuPi debug events is disabled.");
241
248
  }
242
249
 
243
- const query: DebugEventQuery = {
250
+ const filters = {
244
251
  source: params.source,
245
252
  level: params.level,
246
253
  category: params.category,
247
254
  limit: params.limit,
255
+ };
256
+ const query: DebugEventQuery = {
257
+ ...filters,
248
258
  includeRaw: params.includeRaw,
249
259
  allowRaw: config.agentAccess === "raw",
250
260
  };
251
- const result = getDebugEvents(query);
261
+ let events: DebugEventView[];
262
+ let rawAccessDenied: boolean;
263
+ let rawDataUnavailable = false;
264
+ let persistedEventCount: number | undefined;
265
+ if (params.sessionFile) {
266
+ const persisted = await readSessionDebugEvents(params.sessionFile, filters);
267
+ events = persisted.events;
268
+ persistedEventCount = persisted.persistedEventCount;
269
+ rawAccessDenied = Boolean(params.includeRaw);
270
+ rawDataUnavailable = rawAccessDenied;
271
+ } else {
272
+ const result = getDebugEvents(query);
273
+ events = result.events;
274
+ rawAccessDenied = result.rawAccessDenied;
275
+ }
252
276
  const output = truncateDebugOutput(
253
- formatEvents(result.events, result.rawAccessDenied).join("\n"),
277
+ formatEvents(events, rawAccessDenied, rawDataUnavailable, persistedEventCount).join("\n"),
254
278
  );
255
279
  return {
256
280
  content: [{ type: "text" as const, text: output.text }],
257
281
  details: {
258
- enabled: true,
282
+ enabled: config.enabled,
259
283
  agentAccess: config.agentAccess,
260
- rawAccessDenied: result.rawAccessDenied,
261
- events: result.events,
284
+ sessionFile: params.sessionFile,
285
+ rawAccessDenied,
286
+ rawDataUnavailable,
287
+ events,
262
288
  truncation: output.truncation,
263
289
  },
264
290
  };
@@ -269,6 +295,9 @@ export default function debugExtension(pi: ExtensionAPI) {
269
295
  applyDebugConfig(process.cwd());
270
296
  registerDebugSettings(pi);
271
297
  registerDebugMessageRenderer(pi);
298
+ const unsubscribeDebugEvents = subscribeDebugEvents((event) => {
299
+ pi.appendEntry(DEBUG_EVENT_ENTRY_TYPE, event);
300
+ });
272
301
 
273
302
  registerContextProvider({
274
303
  id: "debug",
@@ -287,11 +316,16 @@ export default function debugExtension(pi: ExtensionAPI) {
287
316
  maybeLogLoadStatus(pi, ctx.cwd, "resources_discover");
288
317
  });
289
318
 
319
+ pi.on("session_shutdown", () => {
320
+ unsubscribeDebugEvents();
321
+ });
322
+
290
323
  pi.registerCommand("supi-debug", {
291
324
  description: "Show recent SuPi debug events",
292
325
  handler: async (args, ctx) => {
293
326
  const config = applyDebugConfig(ctx.cwd);
294
- if (!config.enabled) {
327
+ const query = parseCommandArgs(args);
328
+ if (!config.enabled && !query.sessionFile) {
295
329
  pi.sendMessage({
296
330
  customType: DEBUG_REPORT_TYPE,
297
331
  content: "SuPi debug event capture is disabled. Enable Debug in /supi-settings.",
@@ -300,7 +334,29 @@ export default function debugExtension(pi: ExtensionAPI) {
300
334
  return;
301
335
  }
302
336
 
303
- const query = parseCommandArgs(args);
337
+ if (query.sessionFile) {
338
+ const persisted = await readSessionDebugEvents(query.sessionFile, {
339
+ source: query.source,
340
+ level: query.level,
341
+ category: query.category,
342
+ limit: query.limit,
343
+ });
344
+ const output = truncateDebugOutput(
345
+ formatEvents(persisted.events, false, false, persisted.persistedEventCount).join("\n"),
346
+ );
347
+ pi.sendMessage({
348
+ customType: DEBUG_REPORT_TYPE,
349
+ content: output.text,
350
+ display: true,
351
+ details: {
352
+ sessionFile: query.sessionFile,
353
+ events: persisted.events,
354
+ truncation: output.truncation,
355
+ },
356
+ });
357
+ return;
358
+ }
359
+
304
360
  const { events, rawAccessDenied } = getDebugEvents(query);
305
361
  const output = truncateDebugOutput(formatEvents(events, rawAccessDenied).join("\n"));
306
362
  pi.sendMessage({
@@ -327,6 +383,9 @@ export default function debugExtension(pi: ExtensionAPI) {
327
383
  ),
328
384
  category: Type.Optional(Type.String({ description: "Filter by event category" })),
329
385
  limit: Type.Optional(Type.Number({ description: "Maximum number of events to return" })),
386
+ sessionFile: Type.Optional(
387
+ Type.String({ description: "PI session JSONL file containing persisted debug events" }),
388
+ ),
330
389
  includeRaw: Type.Optional(
331
390
  Type.Boolean({ description: "Request raw event data when settings permit it" }),
332
391
  ),
@@ -0,0 +1,93 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { createInterface } from "node:readline";
3
+ import {
4
+ type DebugEventQuery,
5
+ type DebugEventView,
6
+ isDebugLevel,
7
+ matchesDebugEventQuery,
8
+ redactDebugData,
9
+ } from "@mrclrchtr/supi-core/debug";
10
+
11
+ /** Custom session-entry type used for sanitized debug-event persistence. */
12
+ export const DEBUG_EVENT_ENTRY_TYPE = "supi-debug-event";
13
+
14
+ type PersistedDebugEventQuery = Pick<DebugEventQuery, "source" | "level" | "category" | "limit">;
15
+
16
+ /** Sanitized events and total persisted entries found in one PI session file. */
17
+ export interface SessionDebugEvents {
18
+ events: DebugEventView[];
19
+ persistedEventCount: number;
20
+ }
21
+
22
+ function parsePersistedEvent(data: unknown): DebugEventView | undefined {
23
+ if (typeof data !== "object" || data === null) return undefined;
24
+ const event = data as Record<string, unknown>;
25
+ if (
26
+ typeof event.id !== "number" ||
27
+ !Number.isFinite(event.id) ||
28
+ typeof event.timestamp !== "number" ||
29
+ !Number.isFinite(event.timestamp) ||
30
+ typeof event.source !== "string" ||
31
+ !isDebugLevel(event.level) ||
32
+ typeof event.category !== "string" ||
33
+ typeof event.message !== "string" ||
34
+ (event.cwd !== undefined && typeof event.cwd !== "string")
35
+ ) {
36
+ return undefined;
37
+ }
38
+
39
+ return {
40
+ id: event.id,
41
+ timestamp: event.timestamp,
42
+ source: event.source,
43
+ level: event.level,
44
+ category: event.category,
45
+ message: event.message,
46
+ cwd: event.cwd,
47
+ data: event.data === undefined ? undefined : redactDebugData(event.data),
48
+ };
49
+ }
50
+
51
+ function parseDebugEntry(line: string): unknown {
52
+ if (!line.includes('"customType"')) return undefined;
53
+ try {
54
+ return JSON.parse(line);
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ /** Read sanitized debug events persisted by SuPi Debug from a PI session file. */
61
+ export async function readSessionDebugEvents(
62
+ sessionFile: string,
63
+ query: PersistedDebugEventQuery = {},
64
+ ): Promise<SessionDebugEvents> {
65
+ const events: DebugEventView[] = [];
66
+ let persistedEventCount = 0;
67
+ const lines = createInterface({
68
+ input: createReadStream(sessionFile, { encoding: "utf8" }),
69
+ crlfDelay: Number.POSITIVE_INFINITY,
70
+ });
71
+
72
+ for await (const line of lines) {
73
+ const entry = parseDebugEntry(line);
74
+ if (
75
+ typeof entry !== "object" ||
76
+ entry === null ||
77
+ (entry as Record<string, unknown>).type !== "custom" ||
78
+ (entry as Record<string, unknown>).customType !== DEBUG_EVENT_ENTRY_TYPE
79
+ ) {
80
+ continue;
81
+ }
82
+
83
+ persistedEventCount++;
84
+ const event = parsePersistedEvent((entry as Record<string, unknown>).data);
85
+ if (event && matchesDebugEventQuery(event, query)) events.push(event);
86
+ }
87
+
88
+ const limit = query.limit && query.limit > 0 ? Math.floor(query.limit) : Number.POSITIVE_INFINITY;
89
+ return {
90
+ events: events.sort((a, b) => b.timestamp - a.timestamp).slice(0, limit),
91
+ persistedEventCount,
92
+ };
93
+ }
@@ -2,10 +2,10 @@
2
2
 
3
3
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "@earendil-works/pi-coding-agent";
4
4
 
5
- export const toolDescription = `Fetch recent session-local SuPi debug events with optional filters; raw data only when allowed. Output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)} (whichever is hit first).`;
5
+ export const toolDescription = `Fetch recent SuPi debug events, or sanitized persisted events from a PI session JSONL via sessionFile, with optional filters. Raw data is available only for the live session when allowed. Output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)} (whichever is hit first).`;
6
6
 
7
- export const promptSnippet = "supi_debug — fetch recent SuPi debug events";
7
+ export const promptSnippet = "supi_debug — fetch live or persisted SuPi debug events";
8
8
 
9
9
  export const promptGuidelines = [
10
- "Use supi_debug for SuPi failures, fallback reasons, or recent session debug events; request raw data only when explicitly asked and settings allow it.",
10
+ "Use supi_debug for SuPi failures, fallback reasons, or session debug events; pass sessionFile to inspect a prior session and request raw data only when explicitly asked and settings allow it.",
11
11
  ];