@deepsql/mcp 0.5.0 → 0.8.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.
@@ -66,29 +66,104 @@ const TOOL_DEFINITIONS = [
66
66
  },
67
67
  },
68
68
  {
69
- name: "answer_question",
70
- description: "Ask DeepSQL a natural-language database question using the full chat pipeline.",
69
+ name: "get_brain_context",
70
+ description:
71
+ "Retrieve DeepSQL's brain context for a question: relevant tables, columns, FKs, training docs, business rules, anti-patterns, and embedding-ranked snippets. Use this to give your own coding agent the same retrieval context the DeepSQL agent uses, then have your agent generate the SQL/answer.",
71
72
  inputSchema: {
72
73
  type: "object",
73
74
  properties: {
74
- connectionId: {
75
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
76
+ question: {
75
77
  type: "string",
76
- description: "DeepSQL connection ID.",
78
+ description: "Natural-language question used for retrieval ranking.",
79
+ },
80
+ topK: {
81
+ type: "integer",
82
+ minimum: 1,
83
+ maximum: 100,
84
+ description:
85
+ "Optional retrieval breadth. When provided, returns ranked diagnostic results from /training/retrieve; otherwise returns the rich /training/context payload.",
77
86
  },
87
+ },
88
+ required: ["connectionId", "question"],
89
+ additionalProperties: false,
90
+ },
91
+ },
92
+ {
93
+ name: "list_business_rules",
94
+ description:
95
+ "List active business rules and SQL guardrails for a connection. Optional `question` filters to rules applicable to that question.",
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: {
99
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
78
100
  question: {
79
101
  type: "string",
80
- description: "Natural-language database question.",
102
+ description: "Optional natural-language question to scope rules.",
81
103
  },
82
- chatId: {
104
+ },
105
+ required: ["connectionId"],
106
+ additionalProperties: false,
107
+ },
108
+ },
109
+ {
110
+ name: "get_relationships",
111
+ description:
112
+ "Get inferred and validated foreign-key relationships for a connection (source/target table+column with confidence and inference method).",
113
+ inputSchema: {
114
+ type: "object",
115
+ properties: {
116
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
117
+ },
118
+ required: ["connectionId"],
119
+ additionalProperties: false,
120
+ },
121
+ },
122
+ {
123
+ name: "get_anti_patterns",
124
+ description:
125
+ "Get DeepSQL-detected anti-patterns. `kind=table` returns table/schema-level anti-patterns; `kind=query` returns query-level anti-patterns (with optional limit).",
126
+ inputSchema: {
127
+ type: "object",
128
+ properties: {
129
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
130
+ kind: {
83
131
  type: "string",
84
- description: "Optional DeepSQL chat ID for conversation continuity.",
132
+ enum: ["table", "query"],
133
+ description: "Which anti-pattern catalog to fetch. Defaults to 'table'.",
85
134
  },
86
- userId: {
87
- type: "string",
88
- description: "Optional user ID for attribution in feedback/history.",
135
+ limit: {
136
+ type: "integer",
137
+ minimum: 1,
138
+ maximum: 500,
139
+ description: "Optional row limit (only used for kind='query').",
89
140
  },
90
141
  },
91
- required: ["connectionId", "question"],
142
+ required: ["connectionId"],
143
+ additionalProperties: false,
144
+ },
145
+ },
146
+ {
147
+ name: "analyze_slow_queries",
148
+ description:
149
+ "Analyze recent slow queries for a connection over the last 24 hours, returning fingerprints, durations, and example statements.",
150
+ inputSchema: {
151
+ type: "object",
152
+ properties: {
153
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
154
+ thresholdMs: {
155
+ type: "number",
156
+ minimum: 1,
157
+ description: "Minimum query duration in milliseconds. Defaults to 100.",
158
+ },
159
+ limit: {
160
+ type: "integer",
161
+ minimum: 1,
162
+ maximum: 500,
163
+ description: "Maximum queries to return. Defaults to 10.",
164
+ },
165
+ },
166
+ required: ["connectionId"],
92
167
  additionalProperties: false,
93
168
  },
94
169
  },
@@ -374,18 +449,64 @@ function summarizeObjects(payload) {
374
449
  : "No database objects were returned.";
375
450
  }
376
451
 
377
- function summarizeChat(payload) {
378
- const lines = [];
379
- if (payload?.chatId) {
380
- lines.push(`chatId: ${payload.chatId}`);
452
+ function summarizeBrainContext(payload) {
453
+ if (!payload || typeof payload !== "object") {
454
+ return "Brain context unavailable.";
381
455
  }
382
- if (payload?.sql) {
383
- lines.push(`sql: ${compactWhitespace(payload.sql)}`);
456
+ // /training/retrieve diagnostic shape
457
+ if (Array.isArray(payload?.results) || payload?.totalResults != null) {
458
+ const total =
459
+ payload.totalResults ?? (Array.isArray(payload.results) ? payload.results.length : 0);
460
+ const tableCount = Array.isArray(payload.tablesCovered) ? payload.tablesCovered.length : 0;
461
+ return `Retrieved ${total} ranked snippet(s) covering ${tableCount} table(s).`;
384
462
  }
385
- if (payload?.message) {
386
- lines.push(`answer: ${payload.message}`);
463
+ // /training/context rich shape (RetrievedContextResult)
464
+ const tables = Array.isArray(payload.ragTableNames)
465
+ ? payload.ragTableNames.length
466
+ : payload.ragTableNames
467
+ ? Object.keys(payload.ragTableNames).length
468
+ : 0;
469
+ const types = payload.typeCounts
470
+ ? Object.entries(payload.typeCounts).map(([k, v]) => `${k}=${v}`).join(", ")
471
+ : "";
472
+ const intent = payload.retrievalIntent || "n/a";
473
+ const skipped = payload.skipped ? ` (skipped: ${payload.skipReason || "?"})` : "";
474
+ return `Brain context: intent=${intent}, topK=${payload.retrievalTopK ?? "?"}, results=${payload.resultCount ?? 0}, tables=${tables}${types ? `, types[${types}]` : ""}${skipped}.`;
475
+ }
476
+
477
+ function summarizeBusinessRules(payload) {
478
+ const active = payload?.activeRuleCount ?? (payload?.activeRules?.length ?? 0);
479
+ const guards = payload?.applicableGuardrailCount ?? (payload?.applicableGuardrails?.length ?? 0);
480
+ return `Business rules: ${active} active, ${guards} applicable guardrail(s).`;
481
+ }
482
+
483
+ function summarizeRelationships(payload) {
484
+ const list = Array.isArray(payload) ? payload : payload?.relationships || [];
485
+ const high = list.filter((r) => (r.confidence ?? 0) >= 0.8).length;
486
+ return `${list.length} relationship(s) (${high} high-confidence).`;
487
+ }
488
+
489
+ function summarizeAntiPatterns(payload, kind) {
490
+ if (kind === "table") {
491
+ const tables = payload && typeof payload === "object" ? Object.keys(payload).length : 0;
492
+ return `Anti-patterns across ${tables} table(s).`;
387
493
  }
388
- return lines.join("\n");
494
+ const list = Array.isArray(payload) ? payload : payload?.patterns || [];
495
+ const sev = list.reduce((acc, p) => {
496
+ const s = p.severity || "UNKNOWN";
497
+ acc[s] = (acc[s] || 0) + 1;
498
+ return acc;
499
+ }, {});
500
+ const sevStr = Object.entries(sev).map(([k, v]) => `${k}=${v}`).join(", ");
501
+ return `${list.length} query anti-pattern(s)${sevStr ? ` (${sevStr})` : ""}.`;
502
+ }
503
+
504
+ function summarizeSlowQueries(payload) {
505
+ const list = Array.isArray(payload?.queries) ? payload.queries : [];
506
+ const total = payload?.totalCount ?? list.length;
507
+ const avg = payload?.avgDurationMs;
508
+ const max = payload?.maxDurationMs;
509
+ return `${total} slow query/queries${avg != null ? `, avg=${avg}ms` : ""}${max != null ? `, max=${max}ms` : ""}.`;
389
510
  }
390
511
 
391
512
  function summarizeQueryResult(payload) {
@@ -405,7 +526,7 @@ function summarizeExplain(payload) {
405
526
  return `EXPLAIN completed for ${planType}.`;
406
527
  }
407
528
 
408
- function buildToolResult(name, payload) {
529
+ function buildToolResult(name, payload, extra = {}) {
409
530
  let summary;
410
531
 
411
532
  switch (name) {
@@ -418,8 +539,20 @@ function buildToolResult(name, payload) {
418
539
  case "get_database_objects":
419
540
  summary = summarizeObjects(payload);
420
541
  break;
421
- case "answer_question":
422
- summary = summarizeChat(payload);
542
+ case "get_brain_context":
543
+ summary = summarizeBrainContext(payload);
544
+ break;
545
+ case "list_business_rules":
546
+ summary = summarizeBusinessRules(payload);
547
+ break;
548
+ case "get_relationships":
549
+ summary = summarizeRelationships(payload);
550
+ break;
551
+ case "get_anti_patterns":
552
+ summary = summarizeAntiPatterns(payload, extra.kind || "table");
553
+ break;
554
+ case "analyze_slow_queries":
555
+ summary = summarizeSlowQueries(payload);
423
556
  break;
424
557
  case "execute_readonly_sql":
425
558
  summary = summarizeQueryResult(payload);
@@ -484,23 +617,84 @@ async function handleToolCall(config, name, args = {}) {
484
617
  return buildToolResult(name, payload);
485
618
  }
486
619
 
487
- case "answer_question": {
620
+ case "get_brain_context": {
488
621
  const connectionId = String(args.connectionId || "").trim();
489
622
  const question = String(args.question || "").trim();
490
- const chatId = args.chatId ? String(args.chatId) : null;
491
- const userId = args.userId ? String(args.userId) : config.defaultUserId;
623
+ if (!connectionId) return buildToolError("connectionId is required.");
624
+ if (!question) return buildToolError("question is required.");
625
+
626
+ // Route based on whether the caller wants ranked diagnostics (topK) or
627
+ // the rich training-context payload.
628
+ let payload;
629
+ if (args.topK != null) {
630
+ const topK = clampInteger(args.topK, 1, 100, 20);
631
+ const path =
632
+ `/training/retrieve/${encodeURIComponent(connectionId)}` +
633
+ `?q=${encodeURIComponent(question)}&topK=${topK}`;
634
+ payload = await callDeepSqlApi(config, path);
635
+ } else {
636
+ payload = await callDeepSqlApi(
637
+ config,
638
+ `/training/context/${encodeURIComponent(connectionId)}`,
639
+ { method: "POST", json: { question } },
640
+ );
641
+ }
642
+ return buildToolResult(name, payload);
643
+ }
492
644
 
493
- const payload = await callDeepSqlApi(config, "/chat", {
494
- method: "POST",
495
- json: {
496
- connectionId,
497
- message: question,
498
- chatId,
499
- userId,
500
- projectId: config.defaultProjectId,
501
- },
502
- });
645
+ case "list_business_rules": {
646
+ const connectionId = String(args.connectionId || "").trim();
647
+ if (!connectionId) return buildToolError("connectionId is required.");
648
+ let path = `/business-rules/connection/${encodeURIComponent(connectionId)}`;
649
+ if (args.question) {
650
+ path += `?question=${encodeURIComponent(String(args.question))}`;
651
+ }
652
+ const payload = await callDeepSqlApi(config, path);
653
+ return buildToolResult(name, payload);
654
+ }
503
655
 
656
+ case "get_relationships": {
657
+ const connectionId = String(args.connectionId || "").trim();
658
+ if (!connectionId) return buildToolError("connectionId is required.");
659
+ const payload = await callDeepSqlApi(
660
+ config,
661
+ `/brain/inferred-relationships/${encodeURIComponent(connectionId)}`,
662
+ );
663
+ return buildToolResult(name, payload);
664
+ }
665
+
666
+ case "get_anti_patterns": {
667
+ const connectionId = String(args.connectionId || "").trim();
668
+ if (!connectionId) return buildToolError("connectionId is required.");
669
+ const kind = args.kind === "query" ? "query" : "table";
670
+ let path;
671
+ if (kind === "query") {
672
+ path = `/brain/query-anti-patterns/${encodeURIComponent(connectionId)}`;
673
+ if (args.limit != null) {
674
+ path += `?limit=${clampInteger(args.limit, 1, 500, 50)}`;
675
+ }
676
+ } else {
677
+ path = `/brain/table-anti-patterns/${encodeURIComponent(connectionId)}`;
678
+ }
679
+ const payload = await callDeepSqlApi(config, path);
680
+ return buildToolResult(name, payload, { kind });
681
+ }
682
+
683
+ case "analyze_slow_queries": {
684
+ const connectionId = String(args.connectionId || "").trim();
685
+ if (!connectionId) return buildToolError("connectionId is required.");
686
+ const params = [];
687
+ if (args.thresholdMs != null) {
688
+ params.push(`threshold=${Number(args.thresholdMs)}`);
689
+ }
690
+ if (args.limit != null) {
691
+ params.push(`limit=${clampInteger(args.limit, 1, 500, 10)}`);
692
+ }
693
+ const qs = params.length ? `?${params.join("&")}` : "";
694
+ const payload = await callDeepSqlApi(
695
+ config,
696
+ `/slow-queries/analyze/${encodeURIComponent(connectionId)}${qs}`,
697
+ );
504
698
  return buildToolResult(name, payload);
505
699
  }
506
700
 
@@ -193,7 +193,7 @@ class DeepSqlPhase1McpServer {
193
193
  },
194
194
  serverInfo: SERVER_INFO,
195
195
  instructions:
196
- "DeepSQL phase 1 MCP exposes read-only database access through DeepSQL. Prefer answer_question for high-level tasks. execute_readonly_sql and explain_readonly_sql reject mutating SQL.",
196
+ "DeepSQL phase 1 MCP exposes read-only database access plus DeepSQL's brain (retrieved context, business rules, inferred relationships, anti-patterns, slow-query analysis). Prefer get_brain_context to ground SQL/answer generation in retrieved schema knowledge, then call execute_readonly_sql / explain_readonly_sql to validate. Mutating SQL is rejected by both client and backend.",
197
197
  });
198
198
  return;
199
199
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepsql/mcp",
3
- "version": "0.5.0",
3
+ "version": "0.8.0",
4
4
  "description": "DeepSQL CLI and stdio MCP server for self-hosted deployments",
5
5
  "bin": {
6
6
  "deepsql": "./bin/deepsql.js",
@@ -22,5 +22,8 @@
22
22
  "engines": {
23
23
  "node": ">=20"
24
24
  },
25
- "license": "UNLICENSED"
25
+ "license": "UNLICENSED",
26
+ "dependencies": {
27
+ "@inquirer/prompts": "^8.4.2"
28
+ }
26
29
  }
package/src/api/client.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * needs:
9
9
  * - profile-based base URL resolution (not env-only)
10
10
  * - to talk to the unauthenticated /auth/cli endpoints
11
- * - to stream responses for `ask`
11
+ * - per-call query string composition for brain endpoints
12
12
  */
13
13
 
14
14
  class ApiError extends Error {
package/src/cli.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * - boolean flags: --json, --device, --browser, --no-browser
10
10
  * - value flags: --url <url>, --token <t>, --connection <name>, --limit 50
11
11
  * - subcommands: deepsql connections list, deepsql config show
12
- * - positional: deepsql ask "what tables exist?"
12
+ * - positional: deepsql brain-context "which tables hold orders?"
13
13
  */
14
14
 
15
15
  const COMMANDS = {
@@ -19,14 +19,28 @@ const COMMANDS = {
19
19
  config: () => require("./commands/config"),
20
20
  mcp: () => require("./commands/mcp"),
21
21
  connections: () => require("./commands/connections"),
22
- ask: () => require("./commands/ask"),
23
22
  query: () => require("./commands/query"),
24
23
  explain: () => require("./commands/explain"),
25
24
  schema: () => require("./commands/schema"),
25
+ // Brain tools — give a coding agent the same retrieval context the chat
26
+ // pipeline uses, then let the agent generate SQL/answers itself.
27
+ "brain-context": () => require("./commands/brain-context"),
28
+ "business-rules": () => require("./commands/business-rules"),
29
+ relationships: () => require("./commands/relationships"),
30
+ "anti-patterns": () => require("./commands/anti-patterns"),
26
31
  digest: () => require("./commands/digest"),
32
+ users: () => require("./commands/users"),
33
+ access: () => require("./commands/access"),
34
+ permissions: () => require("./commands/permissions"),
35
+ "slow-queries": () => require("./commands/slow-queries"),
36
+ setup: () => require("./commands/setup"),
27
37
  };
28
38
 
29
- const HELP = `deepsql — DeepSQL CLI
39
+ const HELP = `deepsql — ask the database what's wrong. It already knows.
40
+
41
+ Self-hosted AI for database performance: profile slow queries, stream
42
+ live optimization plans, audit access, and run day-to-day admin ops —
43
+ all from the terminal, without leaving your VPC.
30
44
 
31
45
  Usage:
32
46
  deepsql <command> [options]
@@ -47,14 +61,16 @@ Commands:
47
61
  config path Print the auth file path.
48
62
  mcp Run the stdio MCP server using the saved token.
49
63
  connections list [--json] List database connections.
50
- ask "<question>" --connection <name> [--chat <id>] [--json]
51
- Ask DeepSQL a question.
52
64
  query "<sql>" --connection <name> [--limit <n>] [--timeout-seconds <n>] [--file <path>] [--json]
53
- Run a read-only SQL query.
65
+ Run a read-only SQL statement. Enforced
66
+ read-only at the backend (parser-level) and
67
+ ACL-checked per connection.
54
68
  explain "<sql>" --connection <name> [--file <path>] [--json]
55
- Get an EXPLAIN plan.
69
+ Get an EXPLAIN plan (no ANALYZE — also
70
+ read-only enforced).
56
71
  schema [tables|objects] --connection <name>
57
- Dump schema or database objects as JSON.
72
+ Dump connection schema or database objects
73
+ as JSON.
58
74
  digest [N] [--connection <name>] [--json]
59
75
  Show the latest DeepSQL digest, or pass a
60
76
  number to list the last N (e.g. digest 5).
@@ -63,6 +79,53 @@ Commands:
63
79
  digest show <id> [--connection <name>] [--json]
64
80
  Show one digest by id.
65
81
 
82
+ Brain commands (give a coding agent DeepSQL's retrieved context — agentless V1):
83
+ brain-context "<question>" --connection <name> [--top-k <n>] [--json]
84
+ Retrieve embedding-ranked tables/columns/FKs,
85
+ training docs, and business rules for a
86
+ question. With --top-k, returns ranked
87
+ diagnostic snippets; otherwise returns the
88
+ rich training-context payload.
89
+ business-rules --connection <name> [--question "..."] [--json]
90
+ List active business rules and SQL guardrails
91
+ for a connection (optionally scoped by
92
+ question).
93
+ relationships --connection <name> [--json]
94
+ Inferred and validated foreign-key
95
+ relationships with confidence scores.
96
+ anti-patterns --connection <name> [--kind table|query] [--limit <n>] [--json]
97
+ Schema-level (table) or query-level
98
+ anti-patterns detected by the brain.
99
+
100
+ Admin commands (require ADMIN role on the calling token):
101
+ users list | get <ref> | add [<email>] [--role <r>] [--name <n>] [--password-stdin]
102
+ | set-role <ref> <role> | lock|unlock|disable <ref>
103
+ | resend-invite <ref> | reset-password <ref> [--password-stdin]
104
+ | delete <ref> [--yes]
105
+ Manage workspace users.
106
+ access list --user <ref> | --connection <name>
107
+ | grant --user <ref> --connection <name> --level read|write|admin
108
+ | revoke --user <ref> --connection <name>
109
+ | policy <user> <connection> (opens $EDITOR)
110
+ Per-connection access grants and chat policies.
111
+ permissions list [--role <ROLE>] [--json]
112
+ | override --role <ROLE> --permission <PERM> --grant|--revoke [--reason "..."]
113
+ | reset --role <ROLE> --permission <PERM>
114
+ Role-based permission overrides.
115
+ slow-queries latest --connection <name>
116
+ | history --connection <name> [N]
117
+ | analyze --connection <name> [--time-range LAST_24_HOURS|LAST_HOUR]
118
+ [--threshold-ms <n>] [--limit <n>]
119
+ | optimize --connection <name> --query-id <id>
120
+ (streams AI optimization steps to stderr; result to stdout)
121
+ | delete (--history-id <id> | --connection <name>) [--yes]
122
+ Read, trigger, and clean up slow-query analyses.
123
+ setup [--skip-email] [--skip-slack] [--skip-complete]
124
+ Post-install wizard: SMTP/email, Slack
125
+ (digests + bot), then mark setup complete.
126
+ Org and LLM config are set at install time
127
+ and are NOT touched by this wizard.
128
+
66
129
  Global options:
67
130
  --url <url> Override the DeepSQL base URL.
68
131
  --token <tok> Override the auth token (also: DEEPSQL_AUTH_TOKEN).
@@ -118,21 +181,54 @@ function buildOpts(parsed) {
118
181
  url: f.url || null,
119
182
  token: f.token || null,
120
183
  json: !!f.json,
184
+ // Login-flow selectors
121
185
  device: !!f.device,
122
186
  browser: !!f.browser,
123
187
  noBrowser: !!f.noBrowser,
124
- password: !!f.password,
188
+ // password may be `true` (login flow flag) OR a string value (`users add
189
+ // --password secret`, `users add --password=secret`). Each command
190
+ // interprets whichever shape it expects.
191
+ password: f.password ?? null,
125
192
  passwordStdin: !!f.passwordStdin,
126
193
  email: f.email || null,
127
194
  label: f.label || null,
195
+ // Connection / users / chat
128
196
  connection: f.connection || f.c || null,
129
197
  chat: f.chat || null,
130
198
  user: f.user || null,
131
199
  project: f.project || null,
200
+ name: f.name || null,
201
+ username: f.username || null,
202
+ role: f.role || null,
203
+ // List / pagination
132
204
  limit: f.limit,
133
205
  count: f.count || f.n || null,
134
206
  timeoutSeconds: f.timeoutSeconds,
135
207
  file: f.file || null,
208
+ // RBAC / access
209
+ level: f.level || null,
210
+ permission: f.permission || null,
211
+ grant: !!f.grant,
212
+ revoke: !!f.revoke,
213
+ reason: f.reason || null,
214
+ // Brain tools
215
+ topK: f.topK ?? null,
216
+ kind: f.kind || null,
217
+ question: f.question || null,
218
+ // Slow queries
219
+ timeRange: f.timeRange || null,
220
+ thresholdMs: f.thresholdMs || null,
221
+ queryId: f.queryId || null,
222
+ queryText: f.queryText || null,
223
+ sampleQuery: f.sampleQuery || null,
224
+ historyId: f.historyId || null,
225
+ // Setup wizard
226
+ force: !!f.force,
227
+ skipEmail: !!f.skipEmail,
228
+ skipSlack: !!f.skipSlack,
229
+ skipComplete: !!f.skipComplete,
230
+ // Confirmations
231
+ yes: !!f.yes || !!f.y,
136
232
  };
137
233
  }
138
234
 
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Resolve a user reference (numeric id, email, or username) to a {id, email,
5
+ * username, role, ...} record.
6
+ *
7
+ * Backend `GET /admin/users` returns the full list, so we fetch once per
8
+ * invocation and match locally. Cheap for typical org sizes.
9
+ */
10
+
11
+ const { request } = require("../api/client");
12
+
13
+ let cachedUsers = null;
14
+
15
+ async function listUsers(session) {
16
+ if (cachedUsers) return cachedUsers;
17
+ cachedUsers = await request(session.baseUrl, "/admin/users", { token: session.token });
18
+ if (!Array.isArray(cachedUsers)) cachedUsers = [];
19
+ return cachedUsers;
20
+ }
21
+
22
+ function clearUserCache() {
23
+ cachedUsers = null;
24
+ }
25
+
26
+ async function resolveUser(session, ref) {
27
+ if (ref == null || String(ref).trim() === "") {
28
+ throw new Error("Pass a user email, username, or numeric id.");
29
+ }
30
+ const trimmed = String(ref).trim();
31
+
32
+ // Numeric id — short-circuit if list isn't already cached.
33
+ if (/^\d+$/.test(trimmed)) {
34
+ const users = await listUsers(session);
35
+ const hit = users.find((u) => String(u.id) === trimmed);
36
+ if (hit) return hit;
37
+ throw new Error(`User id ${trimmed} not found.`);
38
+ }
39
+
40
+ const users = await listUsers(session);
41
+ const lower = trimmed.toLowerCase();
42
+ const exactEmail = users.find((u) => (u.email || "").toLowerCase() === lower);
43
+ if (exactEmail) return exactEmail;
44
+ const exactUsername = users.find((u) => (u.username || "").toLowerCase() === lower);
45
+ if (exactUsername) return exactUsername;
46
+
47
+ const available = users
48
+ .map((u) => u.email || u.username)
49
+ .filter(Boolean)
50
+ .slice(0, 20);
51
+ const hint = available.length ? ` Available: ${available.join(", ")}.` : "";
52
+ throw new Error(`User "${trimmed}" not found.${hint}`);
53
+ }
54
+
55
+ module.exports = { listUsers, resolveUser, clearUserCache };