@slack/radar-mcp 1.8.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.
- package/README.md +14 -5
- package/dist/mcp/index.js +131 -9
- package/dist/mcp/session-backfill.d.ts +26 -0
- package/dist/mcp/session-backfill.js +156 -0
- package/dist/mcp/session-detail.d.ts +36 -0
- package/dist/mcp/session-detail.js +109 -0
- package/dist/mcp/session-query.d.ts +57 -0
- package/dist/mcp/session-query.js +327 -0
- package/dist/mcp/tools.js +22 -0
- package/dist/shared/constants.d.ts +8 -0
- package/dist/shared/constants.js +8 -0
- package/dist/shared/event-spool.d.ts +75 -0
- package/dist/shared/event-spool.js +366 -0
- package/dist/shared/stream.d.ts +5 -1
- package/dist/shared/stream.js +39 -0
- package/dist/web/log-session.js +6 -0
- package/package.json +3 -2
|
@@ -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
|
+
}
|
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
|
"",
|
|
@@ -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;
|
package/dist/shared/constants.js
CHANGED
|
@@ -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;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ParsedSSE } from "./stream.js";
|
|
2
|
+
/**
|
|
3
|
+
* Point the spool dir at a per-process-unique path. Call once at startup with
|
|
4
|
+
* process.pid. Mirrors db.ts initializeDatabasePath: every MCP shares one port,
|
|
5
|
+
* so a pid key is the collision-free scope.
|
|
6
|
+
*/
|
|
7
|
+
export declare function initializeSpoolDir(scopeKey: number): void;
|
|
8
|
+
/**
|
|
9
|
+
* Delete spool files older than GC_TTL_MS. Runs at session enable. Bounds disk
|
|
10
|
+
* usage without losing recent session records. dir + now injectable for tests.
|
|
11
|
+
* Mirrors gcOldCaptures (logcat-capture.ts). Returns the count removed.
|
|
12
|
+
*/
|
|
13
|
+
export declare function gcOldSpools(dir?: string, now?: number): number;
|
|
14
|
+
export declare function buildSpoolFilePath(now?: number): string;
|
|
15
|
+
export declare function epochFromSpoolPath(filePath: string | null): string;
|
|
16
|
+
export declare function isSpooling(): boolean;
|
|
17
|
+
export declare function getSpoolFilePath(): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Read the current session spool back into parsed frames, oldest first (append
|
|
20
|
+
* order). Separate from the write path; reads the live file by its own handle,
|
|
21
|
+
* never touching the writer's fd or state. Returns [] when no spool is open, the
|
|
22
|
+
* file is gone, or it cannot be read. Never throws into the caller: a query over
|
|
23
|
+
* a missing or damaged spool must degrade to empty, not fail the tool.
|
|
24
|
+
*
|
|
25
|
+
* Reads the whole file and lets the query shaper's byte cap bound the OUTPUT,
|
|
26
|
+
* rather than tailing here: a filter can match an old frame near the top, so a
|
|
27
|
+
* byte-tail read could drop a frame the caller asked for. Rotation already caps
|
|
28
|
+
* the live file at a normal-session size, so a whole read is safe.
|
|
29
|
+
*
|
|
30
|
+
* A torn final line (the process died mid-append, before the newline) is skipped
|
|
31
|
+
* so one bad tail line never sinks the whole read. Any line that will not parse
|
|
32
|
+
* is dropped the same way.
|
|
33
|
+
*/
|
|
34
|
+
export declare function readSpool(): ParsedSSE[];
|
|
35
|
+
/**
|
|
36
|
+
* Open a fresh spool file for append and set module state. Ensures the dir,
|
|
37
|
+
* runs gcOldSpools first, returns the path (or null on failure). Idempotent: a
|
|
38
|
+
* second call while a spool is open returns the current path without reopening.
|
|
39
|
+
*/
|
|
40
|
+
export declare function startSpool(now?: number): string | null;
|
|
41
|
+
/**
|
|
42
|
+
* Append one frame as a single JSONL line. On the hot path (every streamed
|
|
43
|
+
* event) and inside the wait_for_events notify path. Synchronous on purpose:
|
|
44
|
+
* a sync append keeps frames in arrival order with no interleave, which an async
|
|
45
|
+
* write queue would not guarantee. It is also cheap and never lets an error
|
|
46
|
+
* reach the caller, so a spool write failure can never break bufferEvent or
|
|
47
|
+
* waiter delivery. No-op if no spool is open. Errors are swallowed after one
|
|
48
|
+
* breadcrumb.
|
|
49
|
+
*/
|
|
50
|
+
export declare function writeFrame(frame: ParsedSSE): void;
|
|
51
|
+
/**
|
|
52
|
+
* Close the fd and clear state, but KEEP the file on disk. Called on explicit
|
|
53
|
+
* disable and on process exit. The file survives so a just-ended session is
|
|
54
|
+
* still readable; the GC reclaims it after the TTL. This matches logcat, which
|
|
55
|
+
* also keeps its capture file on disable and lets the GC reclaim it. Never
|
|
56
|
+
* called on the transient device-error path.
|
|
57
|
+
*/
|
|
58
|
+
export declare function stopSpool(): void;
|
|
59
|
+
/**
|
|
60
|
+
* If the open file exceeds ROTATION_BYTES, rename it `.rotated-<ts>` (readable
|
|
61
|
+
* until the GC reclaims it) and open a fresh file. Rotation is the one place
|
|
62
|
+
* capture-everything yields to bounded disk: a normal session never rotates,
|
|
63
|
+
* this is only the disk-safety backstop.
|
|
64
|
+
*
|
|
65
|
+
* Safe rename-not-truncate, mirroring rotateCaptureIfNeeded: truncating in
|
|
66
|
+
* place would leave the next append past EOF. No-op if no spool is open or the
|
|
67
|
+
* file is under cap. Returns the new path, or null when nothing rotated.
|
|
68
|
+
*/
|
|
69
|
+
export declare function rotateSpoolIfNeeded(now?: number, capBytes?: number): string | null;
|
|
70
|
+
export declare function registerCleanup(): void;
|
|
71
|
+
export declare function _setSpoolDirForTest(dir: string): void;
|
|
72
|
+
export declare function _setRotationBytesForTest(n: number): void;
|
|
73
|
+
export declare function _setWriteCountForTest(n: number): void;
|
|
74
|
+
export declare function _getRotationCheckEveryForTest(): number;
|
|
75
|
+
export declare function _closeFdLeavingStateForTest(): void;
|