@slack/radar-mcp 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ import type { ParsedSSE } from "../shared/stream.js";
2
+ export interface SessionQueryFilter {
3
+ source?: string;
4
+ id?: number;
5
+ cursor?: string;
6
+ url_filter?: string;
7
+ status_code?: number;
8
+ event_type?: string;
9
+ channel?: string;
10
+ name?: string;
11
+ text?: string;
12
+ limit?: number;
13
+ byte_cap?: number;
14
+ }
15
+ export interface SessionQueryResult {
16
+ frames: ParsedSSE[];
17
+ count: number;
18
+ truncated: boolean;
19
+ cursor?: string;
20
+ note?: string;
21
+ }
22
+ export declare function clampByteCap(byteCap: number | undefined): number;
23
+ export declare function toNum(v: unknown): number | undefined;
24
+ export declare function detailTypeFor(source: string): `${string}_detail`;
25
+ export declare function baseSourceOf(type: string): string | null;
26
+ export interface DetailSelection {
27
+ frame: ParsedSSE;
28
+ isDetail: boolean;
29
+ }
30
+ export declare function selectById(frames: ParsedSSE[], id: number, source?: string): DetailSelection | null;
31
+ /**
32
+ * Shape parsed spool frames into a byte-capped result.
33
+ *
34
+ * frames arrive oldest-first (spool append order). Two paths:
35
+ *
36
+ * 1. id-bypass: when `id` is set (optionally scoped by `source`), return the single whole matching
37
+ * record regardless of age or byte budget. This is the detail-by-id path; a caller asking for a
38
+ * specific record wants that record, not the newest N.
39
+ *
40
+ * 2. newest-first packing: filter, walk newest-first, and accumulate serialized bytes; stop BEFORE
41
+ * a frame would push the running size over the cap. `truncated` is true when the cap or `limit`
42
+ * left older matches unreturned; only then is `cursor` (the "<epoch>:<seq>" token for the oldest
43
+ * frame returned) set, so a caller pages older by passing it back as `cursor`. Returned frames
44
+ * stay newest-first. Paging keys off append position, not device id: each source has its own id
45
+ * namespace so ids overlap and are not globally ordered, but append order is one monotonic key
46
+ * across every source, so a cursor pages the whole session cleanly.
47
+ *
48
+ * `epoch` identifies the spool file the caller read. It is baked into every emitted cursor and
49
+ * checked on every incoming one: a cursor whose epoch does not match came from a rotated or
50
+ * re-enabled spool (whose seq indexes a different file), so it is rejected with a note rather than
51
+ * applied to the wrong file. querySession stays PURE: the caller derives epoch from the spool path
52
+ * and passes it in; this function reads no fs.
53
+ *
54
+ * A caller cannot exceed the hard cap: clampByteCap bounds byte_cap to the shared response budget.
55
+ * An empty frame list returns an empty result with a note so a missing spool reads clearly.
56
+ */
57
+ export declare function querySession(frames: ParsedSSE[], rawFilter: SessionQueryFilter, epoch: string): SessionQueryResult;
@@ -0,0 +1,327 @@
1
+ // Pure shaper for the query_session tool, split out of the index.ts handler (like logs-result.ts
2
+ // and api-paths.ts) so it can be unit-tested without importing index.ts. No fs, no device, no
3
+ // index import: it takes already-parsed spool frames plus a filter and returns a byte-capped
4
+ // result. The reader (event-spool.readSpool) and the dispatch (index.ts) live elsewhere.
5
+ //
6
+ // The type tag is OPAQUE. A frame is matched by `frame.type === filter.source` as a plain string
7
+ // compare; this module never enumerates or switches on the known source names, so a future plugin
8
+ // source matches for free. Do not add a source name switch here.
9
+ import { MAX_RADAR_RESPONSE_BYTES } from "../shared/constants.js";
10
+ // Clamp the requested output ceiling. Default AND hard cap are the shared response budget: a
11
+ // caller cannot raise byte_cap above it, so a query can never flood the model context. Mirrors
12
+ // clampLogLimit's shape (default when unset, hard ceiling always).
13
+ export function clampByteCap(byteCap) {
14
+ const cap = byteCap && byteCap > 0 ? byteCap : MAX_RADAR_RESPONSE_BYTES;
15
+ return Math.min(cap, MAX_RADAR_RESPONSE_BYTES);
16
+ }
17
+ // Read a frame's numeric id if it carries one. Frames are objects of unknown shape; the summary
18
+ // frames and clog detail frames all carry `event.id` today, but treat its absence as "no id".
19
+ function frameId(frame) {
20
+ const event = frame?.event;
21
+ if (typeof event !== "object" || event === null)
22
+ return undefined;
23
+ const raw = event.id;
24
+ return typeof raw === "number" ? raw : undefined;
25
+ }
26
+ // Coerce a filter value that should be numeric. The MCP SDK does not enforce the input schema, so
27
+ // id / status_code can arrive as a digit string; accept a clean digit string and treat anything
28
+ // else as unset, mirroring detailPath in api-paths.ts. Without this, id:"2" strict-compares against
29
+ // a numeric id and silently returns nothing. cursor is a string token, not a number, so it does not
30
+ // pass through here.
31
+ export function toNum(v) {
32
+ if (typeof v === "number" && Number.isFinite(v))
33
+ return v;
34
+ if (typeof v === "string" && /^\d+$/.test(v.trim()))
35
+ return Number(v.trim());
36
+ return undefined;
37
+ }
38
+ // Parse an opaque "<epoch>:<seq>" cursor token. epoch is everything before the first colon (it may
39
+ // hold characters, so split once, not by every colon) and must be non-empty; a real token is minted
40
+ // against a spool file whose <ts> epoch is never empty. seq is the rest and must be all digits. A
41
+ // bare number, empty string, empty epoch, or a non-numeric seq returns undefined so the caller never
42
+ // applies a guessed seq. This never throws.
43
+ function parseCursor(v) {
44
+ if (typeof v !== "string")
45
+ return undefined;
46
+ const colon = v.indexOf(":");
47
+ if (colon <= 0)
48
+ return undefined;
49
+ const epoch = v.slice(0, colon);
50
+ const seqStr = v.slice(colon + 1);
51
+ if (!/^\d+$/.test(seqStr))
52
+ return undefined;
53
+ return { epoch, seq: Number(seqStr) };
54
+ }
55
+ // The spool tag for a pulled body of a summary source. A `network` summary's pulled detail is
56
+ // stored under `network_detail`, and so on. Pure string building; the impure layer (index.ts)
57
+ // owns the device pull and the write-back, this only names the tag both sides agree on.
58
+ export function detailTypeFor(source) {
59
+ return `${source}_detail`;
60
+ }
61
+ // The base source a detail tag came from, or null if the tag is not a detail tag. Inverse of
62
+ // detailTypeFor. `network_detail` -> `network`; a plain `network` -> null.
63
+ export function baseSourceOf(type) {
64
+ return type.endsWith("_detail") ? type.slice(0, -"_detail".length) : null;
65
+ }
66
+ // The base source a frame belongs to: a detail tag folds to its base, any other tag is itself. So a
67
+ // `network` summary and its `network_detail` share base `network`, but a `clog` stays `clog`.
68
+ function baseSourceOfFrame(frame) {
69
+ return baseSourceOf(frame.type) ?? frame.type;
70
+ }
71
+ // After a lazy pull the spool holds BOTH a summary and its `<source>_detail` for the same id, so an
72
+ // unfiltered list read would show that id twice. Collapse each (base source, id) pair to one frame,
73
+ // preferring the detail (the fuller record). A frame with no numeric id, or whose (base, id) pair
74
+ // appears once, passes through untouched. Order is preserved so the newest-first walk is unaffected.
75
+ // Pure: it only reshapes the frame list. The dedup is by base source via baseSourceOfFrame, never a
76
+ // hard-coded source-name switch, so a future plugin source dedups for free.
77
+ function dedupSummaryDetail(frames) {
78
+ // First pass: for each (base source, id) key, remember the LAST frame that carried it. Append
79
+ // order means the last write wins, so a reused id resolves to its freshest record, not a stale
80
+ // detail. Mirrors selectById's recency rule.
81
+ const winner = new Map();
82
+ for (const frame of frames) {
83
+ const id = frameId(frame);
84
+ if (id === undefined)
85
+ continue;
86
+ winner.set(`${baseSourceOfFrame(frame)}\u0000${id}`, frame);
87
+ }
88
+ // Second pass: keep no-id frames as-is; for id-bearing frames emit only the winner, at the
89
+ // position of the key's FIRST appearance so the newest-first walk order stays stable.
90
+ const emitted = new Set();
91
+ const out = [];
92
+ for (const frame of frames) {
93
+ const id = frameId(frame);
94
+ if (id === undefined) {
95
+ out.push(frame);
96
+ continue;
97
+ }
98
+ const key = `${baseSourceOfFrame(frame)}\u0000${id}`;
99
+ if (emitted.has(key))
100
+ continue;
101
+ emitted.add(key);
102
+ out.push(winner.get(key));
103
+ }
104
+ return out;
105
+ }
106
+ export function selectById(frames, id, source) {
107
+ // Return the LAST-appended in-scope frame for the id. The spool is append-ordered, so the newest
108
+ // record wins: in the normal case a detail is appended after its summary and still wins, and after
109
+ // an app restart reuses an id (the device id counter is per-process) the newer summary wins instead
110
+ // of a stale cached detail shadowing it. A detail frame is served whole (isDetail true); a plain
111
+ // summary/clog frame is served as a summary (isDetail false).
112
+ let picked;
113
+ let pickedIsDetail = false;
114
+ for (const frame of frames) {
115
+ if (frameId(frame) !== id)
116
+ continue;
117
+ const base = baseSourceOf(frame.type);
118
+ if (base !== null) {
119
+ // A stored detail frame. In scope when unscoped, or its base equals the requested source.
120
+ if (source === undefined || base === source) {
121
+ picked = frame;
122
+ pickedIsDetail = true;
123
+ }
124
+ continue;
125
+ }
126
+ // A plain (summary or clog) frame. In scope when unscoped or its type equals the source.
127
+ if (source !== undefined && frame.type !== source)
128
+ continue;
129
+ picked = frame;
130
+ pickedIsDetail = false;
131
+ }
132
+ return picked ? { frame: picked, isDetail: pickedIsDetail } : null;
133
+ }
134
+ // Case-insensitive substring test that tolerates a non-string field value.
135
+ function includesCI(haystack, needle) {
136
+ if (typeof haystack !== "string")
137
+ return false;
138
+ return haystack.toLowerCase().includes(needle.toLowerCase());
139
+ }
140
+ // Does one frame pass every SET filter field? Unset fields do not constrain. `source` is the
141
+ // opaque tag compare. The field filters read whatever the frame's event carries; a filter on a
142
+ // field a given frame lacks simply fails to match that frame (never throws).
143
+ function matchesFilter(frame, f) {
144
+ // A non-object frame (should not occur post-readSpool guard, but querySession is also called
145
+ // directly) carries no fields to match, so it passes only an all-unset filter's other checks.
146
+ if (typeof frame !== "object" || frame === null)
147
+ return false;
148
+ if (f.source !== undefined && frame.type !== f.source)
149
+ return false;
150
+ // A malformed frame (event missing or not an object) is not a real record: drop it so it never
151
+ // appears in results and the field reads below never throw.
152
+ const ev = frame?.event;
153
+ if (typeof ev !== "object" || ev === null)
154
+ return false;
155
+ const e = ev;
156
+ if (f.url_filter !== undefined && !includesCI(e.url, f.url_filter))
157
+ return false;
158
+ if (f.status_code !== undefined && e.status_code !== f.status_code)
159
+ return false;
160
+ if (f.event_type !== undefined && e.event_type !== f.event_type)
161
+ return false;
162
+ if (f.channel !== undefined && e.channel !== f.channel)
163
+ return false;
164
+ if (f.name !== undefined && !includesCI(e.name, f.name))
165
+ return false;
166
+ // `text` searches the whole serialized frame, so it catches content in any field including the
167
+ // clog detail payload, not just the named summary fields above.
168
+ if (f.text !== undefined && !JSON.stringify(frame).toLowerCase().includes(f.text.toLowerCase())) {
169
+ return false;
170
+ }
171
+ return true;
172
+ }
173
+ /**
174
+ * Shape parsed spool frames into a byte-capped result.
175
+ *
176
+ * frames arrive oldest-first (spool append order). Two paths:
177
+ *
178
+ * 1. id-bypass: when `id` is set (optionally scoped by `source`), return the single whole matching
179
+ * record regardless of age or byte budget. This is the detail-by-id path; a caller asking for a
180
+ * specific record wants that record, not the newest N.
181
+ *
182
+ * 2. newest-first packing: filter, walk newest-first, and accumulate serialized bytes; stop BEFORE
183
+ * a frame would push the running size over the cap. `truncated` is true when the cap or `limit`
184
+ * left older matches unreturned; only then is `cursor` (the "<epoch>:<seq>" token for the oldest
185
+ * frame returned) set, so a caller pages older by passing it back as `cursor`. Returned frames
186
+ * stay newest-first. Paging keys off append position, not device id: each source has its own id
187
+ * namespace so ids overlap and are not globally ordered, but append order is one monotonic key
188
+ * across every source, so a cursor pages the whole session cleanly.
189
+ *
190
+ * `epoch` identifies the spool file the caller read. It is baked into every emitted cursor and
191
+ * checked on every incoming one: a cursor whose epoch does not match came from a rotated or
192
+ * re-enabled spool (whose seq indexes a different file), so it is rejected with a note rather than
193
+ * applied to the wrong file. querySession stays PURE: the caller derives epoch from the spool path
194
+ * and passes it in; this function reads no fs.
195
+ *
196
+ * A caller cannot exceed the hard cap: clampByteCap bounds byte_cap to the shared response budget.
197
+ * An empty frame list returns an empty result with a note so a missing spool reads clearly.
198
+ */
199
+ export function querySession(frames, rawFilter, epoch) {
200
+ if (frames.length === 0) {
201
+ return {
202
+ frames: [],
203
+ count: 0,
204
+ truncated: false,
205
+ note: "no session spool; is radar enabled?",
206
+ };
207
+ }
208
+ // Normalize numeric fields once so a digit-string arg (the SDK does not enforce the schema)
209
+ // matches; an unparseable value becomes unset rather than a silent no-match. cursor is a string
210
+ // token parsed separately below, not coerced here.
211
+ const filter = {
212
+ ...rawFilter,
213
+ id: toNum(rawFilter.id),
214
+ status_code: toNum(rawFilter.status_code),
215
+ };
216
+ // id-bypass, PURE mirror of the id path. In production handleQuerySession resolves an id read via
217
+ // resolveDetailById (which does the lazy device pull) before this function is reached, so this
218
+ // branch does not run for the MCP tool. It stays as the pure, no-pull reference the unit tests
219
+ // exercise: same selectById + byte-cap semantics, minus the device fetch. Keep the two in step.
220
+ // Prefer a stored detail frame over a summary; honor the hard cap so no single id floods context.
221
+ if (filter.id !== undefined) {
222
+ const idCap = clampByteCap(filter.byte_cap);
223
+ const hit = selectById(frames, filter.id, filter.source);
224
+ if (hit) {
225
+ if (Buffer.byteLength(JSON.stringify(hit.frame)) > idCap) {
226
+ return {
227
+ frames: [],
228
+ count: 0,
229
+ truncated: true,
230
+ note: "the frame with that id alone exceeds the byte cap",
231
+ };
232
+ }
233
+ return { frames: [hit.frame], count: 1, truncated: false };
234
+ }
235
+ return { frames: [], count: 0, truncated: false, note: "no frame with that id in the spool" };
236
+ }
237
+ const hardCap = clampByteCap(filter.byte_cap);
238
+ const limit = filter.limit && filter.limit > 0 ? filter.limit : undefined;
239
+ // The handler emits this result compact, so measured bytes must budget for the wrapper the
240
+ // frames sit inside, not the frames alone. Reserve the largest wrapper this call can emit
241
+ // (count, truncated, plus a cursor token and the byte-cap note) so JSON.stringify(result) stays
242
+ // under the cap. The cursor is now a "<epoch>:<seq>" STRING, longer than the old int, so reserve
243
+ // for the biggest realistic token: both segments at the max safe integer. Overshooting the
244
+ // reserve only truncates a touch early, never over the cap.
245
+ const cursorReserve = String(Number.MAX_SAFE_INTEGER) + ":" + String(Number.MAX_SAFE_INTEGER);
246
+ const wrapperReserve = Buffer.byteLength(JSON.stringify({
247
+ frames: [],
248
+ count: Number.MAX_SAFE_INTEGER,
249
+ truncated: true,
250
+ cursor: cursorReserve,
251
+ note: "the newest matching frame alone exceeds the byte cap; fetch it by id",
252
+ }));
253
+ const cap = Math.max(0, hardCap - wrapperReserve);
254
+ // Tag each frame with its append position before filtering so paging keys off spool order, not
255
+ // device id. seq is the incoming index (oldest = 0); it survives filtering unchanged.
256
+ const tagged = frames.map((frame, seq) => ({ frame, seq }));
257
+ // Field filter first: keep only entries whose frame passes every set field. Both the frame and
258
+ // its seq survive as Seqd so seq is available at pack time and in the returned cursor.
259
+ let matched = tagged.filter((t) => matchesFilter(t.frame, filter));
260
+ // Collapse each (base source, id) to one frame on EVERY read (scoped or not): after a lazy pull the
261
+ // spool holds a summary and its detail for one id, and a backfill that races the live SSE push can
262
+ // append a second summary for an id. Deduping unconditionally means no read path can ever return a
263
+ // duplicated id, whatever write race produced it. Reuse the pure dedupSummaryDetail over the matched
264
+ // frames, then keep the tagged tuples whose frame survived so seq (append position) is preserved for
265
+ // paging.
266
+ const kept = new Set(dedupSummaryDetail(matched.map((t) => t.frame)));
267
+ matched = matched.filter((t) => kept.has(t.frame));
268
+ // The cursor page-older cut. A cursor is applied only when it parses AND its epoch matches the
269
+ // current spool file. A bad or stale cursor never applies a guessed seq: it degrades to the
270
+ // newest page (no cut) with a note, so the agent sees a signal rather than a silent wrong page.
271
+ let cursorNote;
272
+ if (rawFilter.cursor !== undefined) {
273
+ const parsed = parseCursor(rawFilter.cursor);
274
+ if (parsed === undefined) {
275
+ cursorNote =
276
+ "cursor was not a valid paging token; paging from the newest page. A cursor is the opaque token from a truncated result, not an id or a count.";
277
+ }
278
+ else if (parsed.epoch !== epoch) {
279
+ cursorNote =
280
+ "cursor is from a previous spool session (a rotation or re-enable reset it); start paging again from the newest page.";
281
+ }
282
+ else {
283
+ matched = matched.filter((t) => t.seq < parsed.seq);
284
+ }
285
+ }
286
+ const picked = [];
287
+ let bytes = 0;
288
+ let truncated = false;
289
+ // The comma between array entries costs a byte per frame after the first; count it so the packed
290
+ // size tracks the eventual serialized array, not a per-frame sum that would under-count.
291
+ for (let idx = matched.length - 1; idx >= 0; idx--) {
292
+ if (limit !== undefined && picked.length >= limit) {
293
+ truncated = true;
294
+ break;
295
+ }
296
+ const entry = matched[idx];
297
+ const size = Buffer.byteLength(JSON.stringify(entry.frame)) + (picked.length > 0 ? 1 : 0);
298
+ if (bytes + size > cap) {
299
+ // This frame does not fit. Older frames are even further back, so stop and flag truncation.
300
+ truncated = true;
301
+ break;
302
+ }
303
+ bytes += size;
304
+ picked.push(entry);
305
+ }
306
+ // A single frame larger than the cap would leave picked empty with truncated true; surface that
307
+ // rather than a silent empty, so the caller knows a record exists but could not fit. A bad or
308
+ // stale cursor note takes precedence: it explains why the caller is looking at the newest page.
309
+ let note = cursorNote;
310
+ if (note === undefined && picked.length === 0 && matched.length > 0) {
311
+ note = "the newest matching frame alone exceeds the byte cap; fetch it by id";
312
+ }
313
+ // cursor is emitted only when truncated, so a caller sees an actionable pager token exactly when
314
+ // older frames remain. It is the "<epoch>:<seq>" token for the oldest frame returned (the last
315
+ // pushed): epoch pins it to this spool file, seq is that frame's append position, so the next
316
+ // call pages strictly older across all sources and a token from a later rotation is detectably
317
+ // stale. A complete result pages nothing.
318
+ const cursor = truncated && picked.length > 0 ? epoch + ":" + picked[picked.length - 1].seq : undefined;
319
+ // Strip seq: the returned frames are the bare frames, seq is internal to paging.
320
+ return {
321
+ frames: picked.map((t) => t.frame),
322
+ count: picked.length,
323
+ truncated,
324
+ ...(cursor !== undefined ? { cursor } : {}),
325
+ ...(note !== undefined ? { note } : {}),
326
+ };
327
+ }
@@ -13,6 +13,7 @@ export interface SnapshotResult {
13
13
  network: number;
14
14
  rtm: number;
15
15
  clogs: number;
16
+ traces: number;
16
17
  log_lines: number;
17
18
  log_lines_truncated: boolean;
18
19
  screenshot: boolean;
@@ -21,6 +22,7 @@ export interface SnapshotResult {
21
22
  network: boolean;
22
23
  rtm: boolean;
23
24
  clogs: boolean;
25
+ traces: boolean;
24
26
  };
25
27
  errors: string[];
26
28
  privacy_notice: string;
@@ -35,7 +37,7 @@ export declare function buildSnapshotDir(now?: number, root?: string): string;
35
37
  * intentionally larger than point-query defaults — snapshots are bulk
36
38
  * exports and the device-side ring buffers cap their own responses.
37
39
  */
38
- export declare function buildBufferPath(kind: "network" | "rtm" | "clogs"): string;
40
+ export declare function buildBufferPath(kind: "network" | "rtm" | "clogs" | "traces"): string;
39
41
  export interface LogTailResult {
40
42
  text: string;
41
43
  truncated: boolean;
@@ -60,4 +62,4 @@ export declare function readRecentLogTail(requestedLimit?: number): LogTailResul
60
62
  * snapshot still lands on disk with whatever succeeded; failures are
61
63
  * collected in `result.errors` so the model can tell the user.
62
64
  */
63
- export declare function createSnapshot(device: DeviceTransport, fetchBuffer: (kind: "network" | "rtm" | "clogs") => Promise<unknown>, opts?: SnapshotOptions, now?: number, snapshotRoot?: string): Promise<SnapshotResult>;
65
+ export declare function createSnapshot(device: DeviceTransport, fetchBuffer: (kind: "network" | "rtm" | "clogs" | "traces") => Promise<unknown>, opts?: SnapshotOptions, now?: number, snapshotRoot?: string): Promise<SnapshotResult>;
@@ -10,7 +10,7 @@ import { getCaptureFilePath, readTailBytes } from "../shared/logcat-capture.js";
10
10
  * tool outputs.
11
11
  *
12
12
  * Snapshot contents:
13
- * - `bundle.json` — device-side buffers (net + RTM + clogs) plus metadata
13
+ * - `bundle.json` — device-side buffers (net + RTM + clogs + traces) plus metadata
14
14
  * - `screenshot.png` — current screen (optional)
15
15
  * - `logcat.tail.log` — last N lines of the active capture file (optional)
16
16
  *
@@ -33,19 +33,19 @@ const BUNDLE_README = `Slack Radar snapshot
33
33
  This directory is a point-in-time bundle of Radar state.
34
34
 
35
35
  Files:
36
- - bundle.json: device-side network/RTM/clog buffers, counts, and metadata.
36
+ - bundle.json: device-side network/RTM/clog/trace buffers, counts, and metadata.
37
37
  - logcat.tail.log (optional): last N lines of the on-device logcat capture.
38
38
  - screenshot.png (optional): current screen.
39
39
 
40
40
  Privacy: bundle.json contains raw API request/response bodies (auth headers,
41
- tokens), RTM message content, user IDs, channel IDs, and clog payloads.
42
- Review before sharing externally. This bundle lives on your local disk only;
43
- nothing is uploaded anywhere by Radar.
41
+ tokens), RTM message content, user IDs, channel IDs, clog payloads, and
42
+ performance trace spans. Review before sharing externally. This bundle lives
43
+ on your local disk only; nothing is uploaded anywhere by Radar.
44
44
 
45
45
  To inspect: cat bundle.json | jq .
46
46
  To share: zip -r snapshot.zip . then attach the zip.
47
47
  `;
48
- const PRIVACY_NOTICE = "bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, and clog payloads. Review before sharing externally.";
48
+ const PRIVACY_NOTICE = "bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, clog payloads, and performance trace spans. Review before sharing externally.";
49
49
  /**
50
50
  * Resolve the output directory for a snapshot. Exported for tests.
51
51
  */
@@ -137,7 +137,8 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
137
137
  let network = null;
138
138
  let rtm = null;
139
139
  let clogs = null;
140
- const fetched = { network: false, rtm: false, clogs: false };
140
+ let traces = null;
141
+ const fetched = { network: false, rtm: false, clogs: false, traces: false };
141
142
  // Device-side buffers. Failures are individually caught so one failing
142
143
  // buffer does not nuke the whole snapshot. `fetched` distinguishes
143
144
  // "empty because the buffer was empty" from "null because the fetch
@@ -163,6 +164,17 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
163
164
  catch (e) {
164
165
  errors.push(`clog buffer: ${e.message}`);
165
166
  }
167
+ // Traces capture from app start (like clogs), so they are worth
168
+ // bundling. An older debug build without the /api/traces route fails
169
+ // this one fetch and it is recorded in errors[] like any other buffer;
170
+ // the rest of the snapshot still lands.
171
+ try {
172
+ traces = await fetchBuffer("traces");
173
+ fetched.traces = true;
174
+ }
175
+ catch (e) {
176
+ errors.push(`trace buffer: ${e.message}`);
177
+ }
166
178
  // Logcat tail.
167
179
  let logTail = { text: "", truncated: false };
168
180
  if (includeLogs) {
@@ -221,6 +233,7 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
221
233
  network: countItems(network),
222
234
  rtm: countItems(rtm),
223
235
  clogs: countItems(clogs),
236
+ traces: countItems(traces),
224
237
  log_lines: logTail.text
225
238
  ? logTail.text.split("\n").filter(Boolean).length
226
239
  : 0,
@@ -228,12 +241,12 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
228
241
  screenshot: screenshotCaptured,
229
242
  };
230
243
  const bundle = {
231
- version: 1,
244
+ version: 2,
232
245
  timestamp: now,
233
246
  platform: device.platform,
234
247
  active_user_id: device.getActiveUserId(),
235
248
  capture_file: getCaptureFilePath(),
236
- buffers: { network, rtm, clogs },
249
+ buffers: { network, rtm, clogs, traces },
237
250
  counts,
238
251
  fetched,
239
252
  errors,
@@ -285,14 +298,14 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
285
298
  }
286
299
  /**
287
300
  * Count items inside a buffer response. Handles the `{ calls: [...] }`,
288
- * `{ events: [...] }`, `{ clogs: [...] }` shapes the radar HTTP server
289
- * uses. Falls back to 0 on malformed responses.
301
+ * `{ events: [...] }`, `{ clogs: [...] }`, `{ traces: [...] }` shapes the
302
+ * radar HTTP server uses. Falls back to 0 on malformed responses.
290
303
  */
291
304
  function countItems(body) {
292
305
  if (!body || typeof body !== "object")
293
306
  return 0;
294
307
  const b = body;
295
- for (const key of ["calls", "events", "clogs", "items"]) {
308
+ for (const key of ["calls", "events", "clogs", "traces", "items"]) {
296
309
  const v = b[key];
297
310
  if (Array.isArray(v))
298
311
  return v.length;
package/dist/mcp/tools.js CHANGED
@@ -23,6 +23,7 @@ export const SERVER_INSTRUCTIONS = [
23
23
  '- "why is this slow" / "how long did X take" / "show me the traces" / "check performance" -> get_recent_traces(name="...") then get_trace_detail',
24
24
  '- "what API call was that" -> get_recent_network_calls then get_network_call_detail',
25
25
  '- "did I get X" / "did X happen" -> search(query="X") across ALL buffers, not just one',
26
+ '- "everything since I enabled" / "the whole session" / "what did I miss" -> query_session',
26
27
  '- "check the debug logs" -> get_recent_logs (tag is OPTIONAL: pass tag="FKDebug" to filter, or omit it for all recent lines)',
27
28
  '- "save this" / "file this as a bug" / "share this" / "bundle this" / "attach to ticket" / "give me a repro" / "snapshot this" / "preserve this" / "hold this state" / "save what you just saw" -> capture_snapshot',
28
29
  "",
@@ -102,10 +103,10 @@ export const SERVER_INSTRUCTIONS = [
102
103
  "When capture_snapshot returns, tell the user:",
103
104
  " 1. The result.zip_path if non-null (the single shareable file). Fall back to",
104
105
  " result.path (a directory) if zip_path is null.",
105
- " 2. A one-line summary of counts (network/rtm/clogs/log_lines/screenshot).",
106
+ " 2. A one-line summary of counts (network/rtm/clogs/traces/log_lines/screenshot).",
106
107
  " 3. Print this EXACT sentence as the privacy line, verbatim — no paraphrase, no",
107
108
  " compression, no skipping even under time pressure:",
108
- ' "Privacy: bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, and clog payloads. Review before sharing externally."',
109
+ ' "Privacy: bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, clog payloads, and performance trace spans. Review before sharing externally."',
109
110
  " 4. If result.log_lines_truncated is true, say: 'you got fewer log lines than",
110
111
  " requested because the 1 MB read cap was the limiting factor.'",
111
112
  " 5. If the user's original ask was 'file this as a bug' / 'attach to ticket' /",
@@ -302,7 +303,7 @@ export const TOOL_DEFINITIONS = [
302
303
  },
303
304
  {
304
305
  name: "capture_snapshot",
305
- description: "Bundle the current Radar state into a single on-disk artifact for sharing. Writes four files to ~/.slack-radar/snapshots/snapshot-<ts>/: bundle.json (network + RTM + clog buffers, up to 1000 items each — a bulk export, much larger than point queries like get_network_call_detail), logcat.tail.log (last N lines of the active capture), screenshot.png (current screen), and README.txt (receiver-facing explainer). Use when the user says 'save this', 'file this as a bug', 'share this', 'bundle this up', 'attach to a ticket', 'make me a repro', or otherwise wants a shareable reproducible handoff. PRIVACY: bundle.json contains raw API request/response bodies including auth headers, RTM message content, user/team/channel IDs, and clog payloads — review before sharing externally.",
306
+ description: "Bundle the current Radar state into a single on-disk artifact for sharing. Writes four files to ~/.slack-radar/snapshots/snapshot-<ts>/: bundle.json (network + RTM + clog + trace buffers, up to 1000 items each — a bulk export, much larger than point queries like get_network_call_detail), logcat.tail.log (last N lines of the active capture), screenshot.png (current screen), and README.txt (receiver-facing explainer). Use when the user says 'save this', 'file this as a bug', 'share this', 'bundle this up', 'attach to a ticket', 'make me a repro', or otherwise wants a shareable reproducible handoff. PRIVACY: bundle.json contains raw API request/response bodies including auth headers, RTM message content, user/team/channel IDs, clog payloads, and performance trace spans — review before sharing externally.",
306
307
  inputSchema: {
307
308
  type: "object",
308
309
  properties: {
@@ -481,6 +482,27 @@ export const TOOL_DEFINITIONS = [
481
482
  },
482
483
  annotations: { readOnlyHint: true },
483
484
  },
485
+ {
486
+ name: "query_session",
487
+ description: "Read the whole current Radar session from the host event spool (every network/RTM/clog/trace frame captured since radar was enabled), filtered and byte-capped so it cannot flood the model context. Covers network/rtm/clog/trace; device logs are not the spool's primary source, use get_recent_logs for those. Use it for 'everything since I enabled', 'the whole session', or 'what did I miss': the get_recent_* tools read the device rings, which drop the oldest events on a long session, while the spool keeps the full session record. A list read (no id) returns SUMMARY fields for network/RTM/trace and full clog records inline. An id read returns the FULL record: for network/RTM/trace the body (request/response, headers, full payload) is pulled from the device on first open and cached in the spool, so a later id read of the same record is served from the file with no device round-trip; clog is already the full record inline. If the body cannot be pulled (device offline, aged out of the ring, or a single body over the byte budget) the id read falls back to the summary with a note. Frames come back newest-first; when truncated:true the result carries an opaque cursor STRING token, which you pass back verbatim as cursor to page strictly older across all sources. Do not build or change the token; it is not an id and not a count. A token from before a spool rotation or a re-enable is rejected with a note (bounded; a normal session never rotates), and you start paging from the newest page again. Pass id (optionally with source) to fetch one whole record by id regardless of age. Ids are per-source, so an unsourced id can match more than one ring; pass source to disambiguate, and the returned frame type tells you which ring answered. A list read scoped to a plain source (source network) returns summaries; to list cached full bodies for a source, scope to its detail tag (source network_detail). An unscoped list read and an id read already prefer the cached full body. When id is set, all filters except source are ignored. Returns an empty result with a note when no spool exists (radar not enabled).",
488
+ inputSchema: {
489
+ type: "object",
490
+ properties: {
491
+ source: { type: "string", description: "Opaque frame type tag to match, e.g. network, rtm, clog, trace (or a future source). Matched exactly, not enumerated." },
492
+ id: { type: "number", description: "Return the single whole frame with this id, bypassing the newest-first byte budget. Optionally scope with source. id addresses one record and is unrelated to cursor; there is no older-than-id filter, page with cursor instead." },
493
+ cursor: { type: "string", description: "An OPAQUE paging token returned by a prior truncated result; pass it back verbatim to get the next older page across all sources. It is NOT an event id and NOT a count; do not construct or modify it. A token from before a spool rotation or a re-enable is rejected with a note, and you start paging from the newest page again." },
494
+ url_filter: { type: "string", description: "Keep frames whose url contains this substring." },
495
+ status_code: { type: "number", description: "Keep frames with this exact HTTP status code." },
496
+ event_type: { type: "string", description: "Keep frames with this exact event_type (RTM)." },
497
+ channel: { type: "string", description: "Keep frames with this exact channel id (RTM)." },
498
+ name: { type: "string", description: "Keep frames whose name contains this substring (clog/trace)." },
499
+ text: { type: "string", description: "Keep frames whose serialized JSON contains this substring anywhere (searches the whole frame, including clog detail)." },
500
+ limit: { type: "number", description: "Max frames to return; newest kept first." },
501
+ byte_cap: { type: "number", description: "Max serialized output bytes. Defaults to and is hard-capped at the response budget; a larger value is clamped down." },
502
+ },
503
+ },
504
+ annotations: { readOnlyHint: true },
505
+ },
484
506
  ];
485
507
  /**
486
508
  * Check whether a tool call should be blocked by the session gate.
@@ -39,3 +39,11 @@ export declare const WEB_PORT_DEFAULT = 8100;
39
39
  * on a debuggable build. Defined once here so the two readers cannot drift.
40
40
  */
41
41
  export declare const SLACK_DEBUG_PKG = "com.Slack.internal.debug";
42
+ /**
43
+ * Host-side ceiling on a single response we forward into the model context. A
44
+ * proxied device response (large trace/clog payloads) or a session-spool query
45
+ * could otherwise flood it. Point queries live under this; bulk exports go
46
+ * through capture_snapshot. Defined once here so the device-response path and
47
+ * the session-query byte cap share one ceiling and cannot drift.
48
+ */
49
+ export declare const MAX_RADAR_RESPONSE_BYTES: number;
@@ -48,3 +48,11 @@ export const WEB_PORT_DEFAULT = 8100;
48
48
  * on a debuggable build. Defined once here so the two readers cannot drift.
49
49
  */
50
50
  export const SLACK_DEBUG_PKG = "com.Slack.internal.debug";
51
+ /**
52
+ * Host-side ceiling on a single response we forward into the model context. A
53
+ * proxied device response (large trace/clog payloads) or a session-spool query
54
+ * could otherwise flood it. Point queries live under this; bulk exports go
55
+ * through capture_snapshot. Defined once here so the device-response path and
56
+ * the session-query byte cap share one ceiling and cannot drift.
57
+ */
58
+ export const MAX_RADAR_RESPONSE_BYTES = 1024 * 1024;
@@ -77,6 +77,19 @@ export declare function pullDatabase(name: string, user?: string | null, force?:
77
77
  * (1) and (2), not from the open mode.
78
78
  */
79
79
  export declare function assertReadOnlyQuery(sql: string): void;
80
+ /**
81
+ * True for EXPLAIN / EXPLAIN QUERY PLAN, which sqlite3 prints as a text table (ignoring -json).
82
+ * Leading-token match; assertReadOnlyQuery gates the same leading keyword, so both reject on
83
+ * comment-prefixed queries before this routing matters.
84
+ */
85
+ export declare function isExplainQuery(sql: string): boolean;
86
+ /**
87
+ * sqlite3 ignores -json for EXPLAIN (prints text table instead of JSON).
88
+ * Shape it into row objects so the web grid renders the plan readably.
89
+ */
90
+ export declare function parseExplainOutput(out: string): {
91
+ plan: string;
92
+ }[];
80
93
  /**
81
94
  * Run a read-only query against a (lazily pulled) local copy. The query is gated by
82
95
  * assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's