@slack/radar-mcp 1.3.0 → 1.5.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/dist/mcp/tools.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { SOURCES, VIZ, SERIES_METRICS } from "../web/spec.js";
1
2
  export const SERVER_INSTRUCTIONS = [
2
3
  "Slack Radar provides on-device debugging tools for the Slack Android app via ADB.",
3
4
  "",
@@ -31,24 +32,70 @@ export const SERVER_INSTRUCTIONS = [
31
32
  "- RTM event types use subtypes when available (e.g. message_changed, not message).",
32
33
  "- Use wait_for_events with idle_timeout_ms instead of polling loops.",
33
34
  "- Use ping(timeout_minutes=N) to extend sessions. Monitor remaining_minutes proactively.",
35
+ "- ping reports which app/profile is bound (android_user_id, profile, package). The debug",
36
+ " build can run on more than one user (personal=0, plus a work profile or secondary user),",
37
+ " and only the first to bind the socket wins. If events seem to come from the wrong app, tell",
38
+ " the user which profile is bound and use list_profiles + activate_for_user to switch.",
34
39
  "- Screenshots default to 720px. Use full_res=true only when detail matters.",
35
40
  "- Setup: /slack-radar-setup",
36
41
  "- Manual enable (no skill): adb shell am broadcast -a slack.debug.ENABLE_RADAR --ei timeout_minutes 5 -p com.Slack.internal.debug",
37
42
  " Then: adb forward tcp:8099 localabstract:slack-radar",
38
43
  "",
39
44
  "WEB DASHBOARD (/radar-ui):",
40
- "A browser-based dashboard (http://localhost:8100) shows the same live device data in a",
41
- "visual UI. Launched via the /radar-ui skill, which wraps `npx -p @slack/radar-mcp slack-radar-web`.",
45
+ "A browser-based dashboard (http://localhost:8100) shows the same live device data as",
46
+ "tabs: Live (everything), Network, RTM, Clogs, and Logs, each with counters, a graph, a",
47
+ "filterable feed, and a payload inspector. Launched via the /radar-ui skill, which runs",
48
+ "`npx @slack/radar-mcp web` (a global install also exposes it as `slack-radar`).",
42
49
  "",
43
- "When to suggest /radar-ui (mention it ONCE per thread, do not auto-invoke):",
50
+ "PROACTIVELY offer the dashboard when it would help do not wait to be asked. The moment a",
51
+ "device session turns visual or multi-step, say so and offer to open it, e.g.: \"This is",
52
+ "easier to watch live — want me to open the Radar dashboard?\" Offer (once per thread) when:",
44
53
  '- User says "watch", "monitor", "let me see", "show me live" about device traffic',
45
54
  "- User wants to share traffic with a colleague, pair, or teammate",
46
55
  "- User is doing a multi-minute investigation and the transcript is getting noisy",
47
56
  '- User explicitly asks about a GUI, UI, browser view, or dashboard',
48
57
  "",
49
- "DO NOT invoke /radar-ui yourself. Opening a browser window is a user-visible action.",
50
- "DO NOT suggest /radar-ui for single-query debugging or when the user is happy reading",
51
- "results in the transcript. Suggest it when visual inspection adds real value.",
58
+ "OFFER, then open ONLY AFTER THE USER AGREES — opening a browser window is a user-visible",
59
+ "action; never pop one unbidden. On a yes, run the /radar-ui skill (or tell them to run",
60
+ "`npx @slack/radar-mcp web`) and hand over the URL. Nudge, do not auto-open.",
61
+ "DO NOT offer for single-query debugging or when the user is happy reading results in the",
62
+ "transcript. Offer when visual inspection adds real value.",
63
+ "",
64
+ "SCREEN OVERLAY (in the dashboard):",
65
+ "The dashboard has a '▣ Screen' button that mirrors the LIVE device screen in a side panel,",
66
+ "plus a full-resolution screenshot and screen recording. It is OFF by default and opens to a",
67
+ "consent notice first, because it shows real on-screen content (open DMs, messages). The live",
68
+ "mirror needs ffmpeg on the user's machine; the screenshot does not. If the user wants the live",
69
+ "screen and ffmpeg is missing, tell them plainly that live mirroring needs ffmpeg and give the",
70
+ "platform install command (mac: `brew install ffmpeg`; debian/ubuntu: `sudo apt install ffmpeg`;",
71
+ "windows: `winget install ffmpeg`), and note the screenshot works without it. NEVER run the",
72
+ "install for them. The dashboard's /health response reports screen.ffmpeg / screen.liveCast /",
73
+ "screen.installHint, so you can check before suggesting.",
74
+ "",
75
+ "CUSTOM DASHBOARD (open_radar_dashboard):",
76
+ "The pre-built tabs (Live/Network/RTM/Clogs/Logs) cover the common cases. When the user",
77
+ "describes a live view the tabs do not show, SUGGEST building it; invoke open_radar_dashboard",
78
+ "only after they agree or explicitly ask for a custom view/dashboard (same discipline as",
79
+ "/radar-ui). To build, translate their words into a spec and call the tool; it renders your",
80
+ "spec as a 'Custom' tab on the dashboard (default http://localhost:8100) and returns the URL",
81
+ "for you to share. Cases worth suggesting it for:",
82
+ '- "graph calls per second vs average response time and flag the slow ones"',
83
+ '- "count every DM I receive and beep when one arrives"',
84
+ '- "show only RTM events on channel C... and let me click each for its payload"',
85
+ '- "watch network calls whose body contains <substring>"',
86
+ "Authoring rules:",
87
+ "- source is one of network/rtm/clog/log/all ('all' = one unified timeline).",
88
+ "- match filters on SUMMARY fields only, by source: network=url/method/status_code,",
89
+ " rtm=event_type/direction/channel, clog=event_name, log=tag/level/message/pid/package.",
90
+ " NEVER invent a field; a match on a field the stream does not carry silently matches",
91
+ " nothing. To filter on body text use bodyContains (network/all only), which fetches detail.",
92
+ "- viz composes counter/rate/sparkline/feed/inspector/graph. graph needs a series array",
93
+ " (1-2 entries; metric ratePerSec/count/avg/errorRate; avg needs a field like duration_ms).",
94
+ "- highlight {field, op, value} flags rows breaching a numeric threshold; alert {sound,flash,",
95
+ " say, at} fires on each live match (or once at the Nth). Author an honest one-line `summary`.",
96
+ "Calling it again replaces/refines the live custom dashboard. The dashboard reads a connected",
97
+ "device, so Radar must be armed to show data (it has its own Reconnect button). Debug builds",
98
+ "only; local only. Does not open a browser window for you; surface the returned URL to the user.",
52
99
  "",
53
100
  "CAPTURE_SNAPSHOT OUTPUT:",
54
101
  "When capture_snapshot returns, tell the user:",
@@ -69,7 +116,7 @@ export const SERVER_INSTRUCTIONS = [
69
116
  export const TOOL_DEFINITIONS = [
70
117
  {
71
118
  name: "ping",
72
- description: "Check if Slack Radar is running. Returns uptime, buffer sizes, remaining session time, and stream status.",
119
+ description: "Check if Slack Radar is running. Returns uptime, buffer sizes, remaining session time, stream status, and which app/profile is bound: android_user_id (0 = personal, non-zero = a secondary user such as a work profile), profile ('personal'/'secondary'), and package.",
73
120
  inputSchema: {
74
121
  type: "object",
75
122
  properties: {
@@ -206,19 +253,19 @@ export const TOOL_DEFINITIONS = [
206
253
  },
207
254
  {
208
255
  name: "list_profiles",
209
- description: "List Android user profiles that have the Slack debug build running. Use this before activate_for_user when the user asks to switch profiles (personal vs work) or when logs/app state seem to point at the wrong profile. Returns each profile's userId (e.g. '0' for personal, '10' for work) and a human label.",
256
+ description: "List Android user profiles that have the Slack debug build running. Use this before activate_for_user when the user asks to switch profiles (personal vs a secondary user such as a work profile) or when logs/app state seem to point at the wrong profile. Returns each profile's userId (e.g. '0' for personal, '10' for a secondary user) and a human label.",
210
257
  inputSchema: { type: "object", properties: {} },
211
258
  annotations: { readOnlyHint: true },
212
259
  },
213
260
  {
214
261
  name: "activate_for_user",
215
- description: "Activate Slack Radar on a specific Android user profile. Use after list_profiles to switch between personal and work profile targets. Switching profiles restarts log capture with a new session-scoped file and reconnects the SSE event stream so events come from the newly activated profile; old captures remain on disk for up to 7 days. Call this when already connected and only the target profile needs to change.",
262
+ description: "Activate Slack Radar on a specific Android user profile. Use after list_profiles to switch between the personal user and a secondary user (such as a work profile). Switching profiles restarts log capture with a new session-scoped file and reconnects the SSE event stream so events come from the newly activated profile; old captures remain on disk for up to 7 days. Call this when already connected and only the target profile needs to change.",
216
263
  inputSchema: {
217
264
  type: "object",
218
265
  properties: {
219
266
  user_id: {
220
267
  type: "string",
221
- description: "Android user ID from list_profiles (e.g. '0' for personal, '10' for work).",
268
+ description: "Android user ID from list_profiles (e.g. '0' for personal, '10' for a secondary user).",
222
269
  },
223
270
  timeout_minutes: {
224
271
  type: "number",
@@ -298,6 +345,79 @@ export const TOOL_DEFINITIONS = [
298
345
  inputSchema: { type: "object", properties: {} },
299
346
  annotations: { readOnlyHint: false, idempotentHint: true },
300
347
  },
348
+ {
349
+ name: "open_radar_dashboard",
350
+ description: "Build or refine a custom live dashboard tab in the Slack Radar web UI from a spec YOU author. " +
351
+ "Suggest this when the user describes a live view the pre-built tabs (Live/Network/RTM/Clogs/Logs) do not cover " +
352
+ "(e.g. 'graph calls per second vs response time and flag slow ones', 'beep when I get a DM'); invoke it once they agree. " +
353
+ "Translate the request into the spec fields below and call this tool; it ensures the web dashboard is " +
354
+ "running (default http://localhost:8100) and renders your spec as a 'Custom' tab, then returns the URL to share. " +
355
+ "Calling again replaces/refines it. Does not open a browser window for the user. The dashboard reads a connected " +
356
+ "device, so Radar must be armed to show data (the dashboard has its own Reconnect button). Debug builds only, local only.",
357
+ inputSchema: {
358
+ type: "object",
359
+ properties: {
360
+ title: { type: "string", description: "Short 1-3 word dashboard title; also the Custom tab label." },
361
+ summary: {
362
+ type: "string",
363
+ description: "ONE honest plain-English line describing what this shows, including any active filter.",
364
+ },
365
+ source: {
366
+ type: "string",
367
+ enum: [...SOURCES],
368
+ description: "Which device stream to read. 'all' is a unified timeline mixing network, RTM, and clog.",
369
+ },
370
+ match: {
371
+ type: "object",
372
+ description: "Case-insensitive substring filter on SUMMARY fields, keyed by source. network: url/method/status_code; " +
373
+ "rtm: event_type/direction/channel; clog: event_name; log: tag/level/package. NEVER invent a field " +
374
+ "(a match on an absent field silently matches nothing). For body text use bodyContains instead.",
375
+ },
376
+ bodyContains: {
377
+ type: "string",
378
+ description: "network/all only: keep calls whose request/response BODY contains this substring (fetches each call's detail).",
379
+ },
380
+ viz: {
381
+ type: "array",
382
+ items: { type: "string", enum: [...VIZ] },
383
+ description: "Widgets to compose. 'graph' requires a series array.",
384
+ },
385
+ rateWindowSec: {
386
+ type: "number",
387
+ description: "Sliding window in seconds for the rate widget (default 10).",
388
+ },
389
+ series: {
390
+ type: "array",
391
+ description: "Required when viz includes 'graph'. 1-2 entries.",
392
+ items: {
393
+ type: "object",
394
+ properties: {
395
+ label: { type: "string" },
396
+ metric: { type: "string", enum: [...SERIES_METRICS] },
397
+ field: { type: "string", description: "Required for the 'avg' metric, e.g. duration_ms." },
398
+ unit: { type: "string" },
399
+ },
400
+ required: ["metric"],
401
+ },
402
+ },
403
+ extract: {
404
+ type: "object",
405
+ description: "Pull CONTENT from a record body into the feed: {parse:'response_body'|'request_body'|'data', field:'dot.path', label}.",
406
+ },
407
+ highlight: {
408
+ type: "object",
409
+ description: "Flag feed rows whose numeric field breaches a threshold: {field, op:'>'|'>='|'<'|'<='|'==', value, label}.",
410
+ },
411
+ alert: {
412
+ type: "object",
413
+ description: "Sentry mode: {sound, flash, say, at}. 'at':N fires once when the match count reaches N; omit to fire on every match.",
414
+ },
415
+ },
416
+ required: ["title", "summary", "source", "viz"],
417
+ },
418
+ // Not read-only: it starts a local web server and pushes a spec to it.
419
+ annotations: { readOnlyHint: false, idempotentHint: true },
420
+ },
301
421
  ];
302
422
  /**
303
423
  * Check whether a tool call should be blocked by the session gate.
@@ -312,6 +432,11 @@ export function sessionGate(toolName, sessionEnabled) {
312
432
  if (toolName === "ping") {
313
433
  return null;
314
434
  }
435
+ // open_radar_dashboard launches an independent local web process with its own
436
+ // device-enable (Reconnect) button, so it does not require the MCP session enabled.
437
+ if (toolName === "open_radar_dashboard") {
438
+ return null;
439
+ }
315
440
  if (!sessionEnabled) {
316
441
  return {
317
442
  content: [
@@ -13,8 +13,9 @@ export declare function resolveAdbPath(): string | null;
13
13
  /**
14
14
  * Parse the output of `ps -A -o USER,NAME` to extract the Android user ID
15
15
  * for the debug build process. When multiple profiles are running, prefers
16
- * the work profile (user ID > 0) since that's the typical debugging target.
17
- * Returns "0" for primary user, "10" for work profile, etc.
16
+ * a secondary user (user ID > 0) since that's the typical debugging target.
17
+ * Returns "0" for the primary user, or the first secondary user id (commonly
18
+ * "10", which is often a work profile, but the id alone cannot prove that).
18
19
  */
19
20
  export declare function parseUserFromPsOutput(psOutput: string): string;
20
21
  export declare class AndroidTransport implements DeviceTransport {
@@ -31,6 +32,14 @@ export declare class AndroidTransport implements DeviceTransport {
31
32
  isForwarded(): boolean;
32
33
  getTimeoutMinutes(): number;
33
34
  getActiveUserId(): string | null;
35
+ /**
36
+ * Resolve the Android user the debug build is actually running as: the already-detected
37
+ * user if known, else a `ps`-based detection (which prefers the non-zero/secondary profile
38
+ * the work build runs under). Unlike getActiveUserId() this never returns null, so callers
39
+ * that need a concrete target (the DB browser) default to the profile Radar is bound to
40
+ * rather than blindly to user 0.
41
+ */
42
+ resolveActiveUser(): string;
34
43
  getCaptureFilePath(): string | null;
35
44
  screenshot(fullRes: boolean): string;
36
45
  recordScreen(durationS: number): string;
@@ -61,8 +61,9 @@ const ADB = resolveAdbPath();
61
61
  /**
62
62
  * Parse the output of `ps -A -o USER,NAME` to extract the Android user ID
63
63
  * for the debug build process. When multiple profiles are running, prefers
64
- * the work profile (user ID > 0) since that's the typical debugging target.
65
- * Returns "0" for primary user, "10" for work profile, etc.
64
+ * a secondary user (user ID > 0) since that's the typical debugging target.
65
+ * Returns "0" for the primary user, or the first secondary user id (commonly
66
+ * "10", which is often a work profile, but the id alone cannot prove that).
66
67
  */
67
68
  export function parseUserFromPsOutput(psOutput) {
68
69
  if (!psOutput || !psOutput.trim())
@@ -77,9 +78,9 @@ export function parseUserFromPsOutput(psOutput) {
77
78
  }
78
79
  if (userIds.length === 0)
79
80
  return "0";
80
- // Prefer work profile (non-zero user ID) when multiple profiles are running
81
- const workProfile = userIds.find((id) => id !== "0");
82
- return workProfile ?? userIds[0];
81
+ // Prefer a secondary user (non-zero user ID) when multiple profiles are running
82
+ const secondaryUser = userIds.find((id) => id !== "0");
83
+ return secondaryUser ?? userIds[0];
83
84
  }
84
85
  export class AndroidTransport {
85
86
  platform = "android";
@@ -148,6 +149,16 @@ export class AndroidTransport {
148
149
  getActiveUserId() {
149
150
  return this.detectedUserId;
150
151
  }
152
+ /**
153
+ * Resolve the Android user the debug build is actually running as: the already-detected
154
+ * user if known, else a `ps`-based detection (which prefers the non-zero/secondary profile
155
+ * the work build runs under). Unlike getActiveUserId() this never returns null, so callers
156
+ * that need a concrete target (the DB browser) default to the profile Radar is bound to
157
+ * rather than blindly to user 0.
158
+ */
159
+ resolveActiveUser() {
160
+ return this.detectDebugBuildUser();
161
+ }
151
162
  getCaptureFilePath() {
152
163
  return getCaptureFilePath();
153
164
  }
@@ -228,7 +239,7 @@ export class AndroidTransport {
228
239
  const id = match[1];
229
240
  profiles.push({
230
241
  userId: id,
231
- label: id === "0" ? "Personal (user 0)" : `Work profile (user ${id})`,
242
+ label: id === "0" ? "Personal (user 0)" : `Secondary (user ${id}, e.g. a work profile)`,
232
243
  });
233
244
  }
234
245
  }
@@ -4,3 +4,23 @@ export declare const RADAR_PORT = 8099;
4
4
  export declare const RADAR_HOST = "127.0.0.1";
5
5
  /** Max events to retain in each local SSE buffer. */
6
6
  export declare const MAX_LOCAL_BUFFER = 500;
7
+ /**
8
+ * Max parsed logcat frames the web dashboard retains for the whole server
9
+ * session. The device does not buffer logcat host-side, so the dashboard keeps
10
+ * its own session ring and replays it to every newly connected stream — this is
11
+ * what lets a tab switch (which reopens the SSE stream) show the full history
12
+ * instead of only the lines that arrive after the reconnect. Bounded so a long,
13
+ * chatty session cannot grow without limit. logcat is captured for ALL apps (the
14
+ * dashboard filters by package client-side), so the depth is sized for an all-app
15
+ * stream: at typical line sizes this keeps the buffer well under ~10 MB (a
16
+ * stack-trace-heavy session runs larger per line, so treat this as a typical, not
17
+ * a hard, ceiling). Like Android Studio's logcat, retention is a bounded ring —
18
+ * the oldest lines age out once the cap is reached, not kept forever.
19
+ */
20
+ export declare const MAX_LOG_SESSION_BUFFER = 20000;
21
+ /**
22
+ * Default port for the web dashboard. Shared so the MCP `open_radar_dashboard`
23
+ * tool POSTs its spec to the same port the web server listens on; both honor the
24
+ * `SLACK_RADAR_WEB_PORT` env override.
25
+ */
26
+ export declare const WEB_PORT_DEFAULT = 8100;
@@ -4,3 +4,23 @@ export const RADAR_PORT = 8099;
4
4
  export const RADAR_HOST = "127.0.0.1";
5
5
  /** Max events to retain in each local SSE buffer. */
6
6
  export const MAX_LOCAL_BUFFER = 500;
7
+ /**
8
+ * Max parsed logcat frames the web dashboard retains for the whole server
9
+ * session. The device does not buffer logcat host-side, so the dashboard keeps
10
+ * its own session ring and replays it to every newly connected stream — this is
11
+ * what lets a tab switch (which reopens the SSE stream) show the full history
12
+ * instead of only the lines that arrive after the reconnect. Bounded so a long,
13
+ * chatty session cannot grow without limit. logcat is captured for ALL apps (the
14
+ * dashboard filters by package client-side), so the depth is sized for an all-app
15
+ * stream: at typical line sizes this keeps the buffer well under ~10 MB (a
16
+ * stack-trace-heavy session runs larger per line, so treat this as a typical, not
17
+ * a hard, ceiling). Like Android Studio's logcat, retention is a bounded ring —
18
+ * the oldest lines age out once the cap is reached, not kept forever.
19
+ */
20
+ export const MAX_LOG_SESSION_BUFFER = 20000;
21
+ /**
22
+ * Default port for the web dashboard. Shared so the MCP `open_radar_dashboard`
23
+ * tool POSTs its spec to the same port the web server listens on; both honor the
24
+ * `SLACK_RADAR_WEB_PORT` env override.
25
+ */
26
+ export const WEB_PORT_DEFAULT = 8100;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * On-device SQLite browser (host-side, read-only).
3
+ *
4
+ * Slack's on-device databases are plaintext SQLite (not SQLCipher) on a debug build,
5
+ * which is what makes host-side reading possible at all: `adb exec-out "run-as <pkg>
6
+ * cat databases/<db>"` streams the file to the host, where the platform-tools `sqlite3`
7
+ * binary runs read-only SELECT/PRAGMA against a local copy. Nothing is written on the
8
+ * device; no app rebuild is needed. If a future build encrypts these DBs, the magic-header
9
+ * check in `pullDatabase` fails fast with a clear message rather than producing garbage.
10
+ *
11
+ * SECURITY: a pulled DB is the full message store in plaintext (request bodies, message
12
+ * text, user/channel IDs). Pulled copies are written 0600 under a dedicated tmp dir and
13
+ * cleaned on server start/stop (see `cleanupPulledDatabases`). The DB names are learned
14
+ * live from the device (`listDatabases`); none are baked into this file.
15
+ */
16
+ /** Initialize DB_TMP with a port-scoped path. Call from the web server on startup. */
17
+ export declare function initializeDatabasePath(port: number): void;
18
+ export declare function availableUsers(): string[];
19
+ /**
20
+ * Reset cached device state. Clears the sqlite-binary path so a device swap
21
+ * re-detects it. The targeted Android user is passed explicitly to every call
22
+ * (sourced from the dashboard's bound profile) rather than guessed, so list / pull /
23
+ * query can never silently disagree on which profile's store they touch.
24
+ */
25
+ export declare function resetDbState(): void;
26
+ /**
27
+ * List the on-device databases for the given Android user (the dashboard's bound
28
+ * profile). Filters out WAL/SHM/journal sidecar files. Returns the resolved user and
29
+ * the full set of profiles that have the build, so the UI can show which profile this
30
+ * store belongs to and offer a switch when more than one exists.
31
+ */
32
+ export declare function listDatabases(user?: string | null): {
33
+ user: string;
34
+ availableUsers: string[];
35
+ names: string[];
36
+ };
37
+ /**
38
+ * Stream a database off the device to a local 0600 copy (scoped to the target user) and
39
+ * return its path. `run-as ... cp` into shared storage fails silently (sandbox), so the
40
+ * working path is `exec-out cat` piped to a host file. Validates the SQLite magic header
41
+ * so a missing DB (whose `cat` emits an error string, not file bytes) fails with a clear
42
+ * message instead of being written as a junk file that later trips an opaque parse error.
43
+ *
44
+ * WAL: Slack's on-device DBs are WAL-mode and the live app holds write connections open,
45
+ * so recently-committed rows live in the `-wal` sidecar, NOT yet in the main file. Pulling
46
+ * the main file alone (and reading it `immutable`) silently drops those recent rows — the
47
+ * exact data a debugger wants. So we also pull the `-wal` and `-shm` sidecars next to the
48
+ * main copy (best-effort; absent if the DB was checkpointed), and queryDatabase opens the
49
+ * copy NORMALLY (not immutable) so SQLite replays the WAL into the view.
50
+ */
51
+ export declare function pullDatabase(name: string, user?: string | null, force?: boolean): Promise<string>;
52
+ /**
53
+ * Run a read-only query against a (lazily pulled) local copy.
54
+ *
55
+ * Read-only is NOT just about blocking writes. `sqlite3 -readonly` forbids writes to the
56
+ * opened DB, but a single SELECT can still READ any host file the process can open, via
57
+ * `ATTACH '/path' AS x; SELECT * FROM x.t` or `SELECT readfile('/path')` — a confidentiality
58
+ * bypass (the other plaintext Slack DBs, any file on disk), reachable through the query box
59
+ * or a pushed db-spec's `sql`. Three layers close this:
60
+ * 1. `sqlite3 -safe` — engine-level: forbids ATTACH, readfile/writefile/edit, and
61
+ * load_extension while still allowing ordinary SELECT/PRAGMA. This is the load-bearing
62
+ * control; a keyword denylist alone is whack-a-mole.
63
+ * 2. the guard below: only a SINGLE SELECT/PRAGMA statement (no `;`-chaining), and an
64
+ * explicit reject of attach/readfile/writefile names as defense in depth.
65
+ * 3. `-readonly` still blocks writes to the opened DB.
66
+ * Surfaces sqlite's own error message on failure.
67
+ */
68
+ export declare function queryDatabase(name: string, sql: string, user?: string | null): Promise<unknown[]>;
69
+ /** Size in bytes of a pulled local copy for the given user, or 0 if not pulled. */
70
+ export declare function pulledSize(name: string, user?: string | null): number;
71
+ /**
72
+ * Remove all pulled database copies. Pulled DBs are plaintext message stores, so they
73
+ * must not linger; call on server start and stop. Best-effort.
74
+ */
75
+ export declare function cleanupPulledDatabases(): void;