@deepsql/mcp 0.22.0 → 0.26.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,93 @@
1
+ "use strict";
2
+
3
+ // Best-effort onboarding data for the agent intro: the connections this token
4
+ // can see, plus per-connection "needs attention" suggestions for connections the
5
+ // user can configure (admin-level). Uses EXISTING backend endpoints only:
6
+ // GET /connections (RBAC-scoped list, with canManageConfig)
7
+ // GET /connections/{id}/init-status (brain readiness: currentStage)
8
+ // GET /slow-log-source/{id} (slow-query log: enabled)
9
+ //
10
+ // Everything is wrapped in tight timeouts and swallows errors, so a slow or old
11
+ // backend (missing an endpoint) degrades to "show whatever we have" and never
12
+ // blocks the REPL.
13
+
14
+ const { request } = require("../api/client");
15
+ const { listConnections } = require("./_connections");
16
+
17
+ function withTimeout(p, ms) {
18
+ return Promise.race([
19
+ Promise.resolve(p),
20
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)),
21
+ ]);
22
+ }
23
+
24
+ // Derive config suggestions + a brain-recommendation count for one connection
25
+ // from its status endpoints. Returns { items: [...attention], recCount }.
26
+ async function checkConnection(session, c) {
27
+ const out = [];
28
+ const [init, slow, recs] = await Promise.all([
29
+ request(session.baseUrl, `/connections/${encodeURIComponent(c.id)}/init-status`, {
30
+ token: session.token,
31
+ }).catch(() => null),
32
+ request(session.baseUrl, `/slow-log-source/${encodeURIComponent(c.id)}`, {
33
+ token: session.token,
34
+ }).catch(() => null),
35
+ // totalCount tracks the limit, so request enough to give an accurate-ish
36
+ // "N to review" nudge (the brain command shows the full list).
37
+ request(session.baseUrl, `/brain/notes/suggestions/${encodeURIComponent(c.id)}?limit=25`, {
38
+ token: session.token,
39
+ }).catch(() => null),
40
+ ]);
41
+
42
+ const stage = init && init.currentStage;
43
+ const pct = (init && init.progressPercent) || 0;
44
+ if (stage === "FAILED") {
45
+ out.push({ conn: c.name, text: "brain initialization failed", fix: `deepsql connections init ${c.name}` });
46
+ } else if (stage && stage !== "READY" && pct < 100) {
47
+ // Skip near-done (100% but not yet flipped to READY) — don't nag.
48
+ out.push({ conn: c.name, text: `brain still initializing (${pct}%)`, fix: `deepsql connections init ${c.name}` });
49
+ }
50
+ if (slow && slow.enabled !== true) {
51
+ out.push({
52
+ conn: c.name,
53
+ text: "slow-query log not connected",
54
+ fix: "set it up in the web UI → Slow Query Log",
55
+ });
56
+ }
57
+ // Brain recommendations to review (only meaningful once the brain is trained).
58
+ const recCount = stage !== "FAILED" && recs && typeof recs.totalCount === "number" ? recs.totalCount : 0;
59
+ return { items: out, recCount };
60
+ }
61
+
62
+ // Returns { connections: [{name,dbType,canManage}], suggestions: [{conn,text,fix}] }.
63
+ async function loadIntroData(session, { timeoutMs = 2500 } = {}) {
64
+ const data = { connections: [], suggestions: [], recommendationCount: 0 };
65
+ try {
66
+ const list = await withTimeout(listConnections(session), timeoutMs);
67
+ data.connections = (list || []).map((c) => ({
68
+ id: c.id,
69
+ name: c.connectionName || c.name || c.id,
70
+ dbType: c.dbType || "",
71
+ canManage: !!c.canManageConfig,
72
+ }));
73
+ // Status/suggestions only for connections this user can configure
74
+ // (admin-level), capped so a workspace with many connections doesn't fan
75
+ // out at startup.
76
+ const manageable = data.connections.filter((c) => c.canManage).slice(0, 6);
77
+ if (manageable.length) {
78
+ const checks = await withTimeout(
79
+ Promise.all(
80
+ manageable.map((c) => checkConnection(session, c).catch(() => ({ items: [], recCount: 0 })))
81
+ ),
82
+ timeoutMs
83
+ );
84
+ data.suggestions = checks.flatMap((r) => r.items);
85
+ data.recommendationCount = checks.reduce((n, r) => n + (r.recCount || 0), 0);
86
+ }
87
+ } catch {
88
+ /* degrade gracefully — render whatever we have */
89
+ }
90
+ return data;
91
+ }
92
+
93
+ module.exports = { loadIntroData };
@@ -17,16 +17,40 @@
17
17
  */
18
18
 
19
19
  const { request } = require("../api/client");
20
+ const cache = require("../connections/cache");
20
21
 
21
22
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
23
 
23
- let cachedList = null;
24
+ let inProcess = null; // L1: within a single command invocation
24
25
 
25
- async function listConnections(session) {
26
- if (cachedList) return cachedList;
27
- cachedList = await request(session.baseUrl, "/connections", { token: session.token });
28
- if (!Array.isArray(cachedList)) cachedList = [];
29
- return cachedList;
26
+ // Connections list with two cache tiers: the in-process memo (L1) and a short-
27
+ // TTL on-disk cache (L2) shared across `deepsql` invocations. Pass refresh:true
28
+ // to bypass both and fetch live (then refresh the caches).
29
+ async function listConnections(session, { refresh = false } = {}) {
30
+ if (!refresh) {
31
+ if (inProcess) return inProcess;
32
+ const disk = cache.read(session.baseUrl);
33
+ if (disk) {
34
+ inProcess = disk;
35
+ return disk;
36
+ }
37
+ }
38
+ const fetched = await request(session.baseUrl, "/connections", { token: session.token });
39
+ const list = Array.isArray(fetched) ? fetched : [];
40
+ inProcess = list;
41
+ cache.write(session.baseUrl, list);
42
+ return list;
43
+ }
44
+
45
+ function matchConnection(connections, trimmed) {
46
+ const exact = connections.filter((c) => (c.connectionName || c.name) === trimmed);
47
+ if (exact.length === 1) return { id: exact[0].id || exact[0].connectionId };
48
+ const ci = connections.filter(
49
+ (c) => String(c.connectionName || c.name || "").toLowerCase() === trimmed.toLowerCase(),
50
+ );
51
+ if (ci.length === 1) return { id: ci[0].id || ci[0].connectionId };
52
+ if (ci.length > 1) return { ambiguous: ci };
53
+ return null;
30
54
  }
31
55
 
32
56
  /**
@@ -59,17 +83,17 @@ async function resolveConnectionId(session, input) {
59
83
  const trimmed = raw.trim();
60
84
  if (UUID_RE.test(trimmed)) return trimmed;
61
85
 
62
- const connections = await listConnections(session);
63
- const exact = connections.filter((c) => (c.connectionName || c.name) === trimmed);
64
- if (exact.length === 1) return exact[0].id || exact[0].connectionId;
65
-
66
- const ciMatches = connections.filter(
67
- (c) => String(c.connectionName || c.name || "").toLowerCase() === trimmed.toLowerCase(),
68
- );
69
- if (ciMatches.length === 1) return ciMatches[0].id || ciMatches[0].connectionId;
70
-
71
- if (ciMatches.length > 1) {
72
- const names = ciMatches.map((c) => `${c.connectionName} (${c.id})`).join(", ");
86
+ // Try the (possibly cached) list first; on a miss, refetch live once before
87
+ // failing so a connection added since the cache was written still resolves.
88
+ let connections = await listConnections(session);
89
+ let m = matchConnection(connections, trimmed);
90
+ if (!m) {
91
+ connections = await listConnections(session, { refresh: true });
92
+ m = matchConnection(connections, trimmed);
93
+ }
94
+ if (m && m.id) return m.id;
95
+ if (m && m.ambiguous) {
96
+ const names = m.ambiguous.map((c) => `${c.connectionName} (${c.id})`).join(", ");
73
97
  throw new Error(
74
98
  `Multiple connections match "${trimmed}" by case-insensitive name: ${names}. Pass the exact name or the id.`,
75
99
  );
@@ -15,6 +15,8 @@ const readline = require("node:readline");
15
15
  const { request } = require("../api/client");
16
16
  const { resolveSession } = require("./_session");
17
17
  const { resolveConnectionId } = require("./_connections");
18
+ const { renderIntro, getVersionLine, promptLabel } = require("./_agent_intro");
19
+ const { loadIntroData } = require("./_agent_status");
18
20
 
19
21
  // The brokered turn can run a full multi-tool agent loop server-side; allow well
20
22
  // past the backend's own per-turn ceiling so we don't abandon a valid answer.
@@ -29,6 +31,31 @@ async function runTurn(session, connectionId, message, conversationId) {
29
31
  });
30
32
  }
31
33
 
34
+ // A server-side turn can run a multi-tool agent loop for a minute or more with
35
+ // no streamed output, so show a live spinner + elapsed timer instead of a
36
+ // static "…thinking" that reads as frozen. No-op (returns the promise) when not
37
+ // a TTY, so piped/one-shot output stays clean.
38
+ function awaitWithSpinner(stdout, promise) {
39
+ if (!stdout.isTTY) return promise;
40
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
41
+ const start = Date.now();
42
+ let i = 0;
43
+ const tick = () => {
44
+ const s = Math.floor((Date.now() - start) / 1000);
45
+ stdout.write(`\r\x1b[2m${frames[i++ % frames.length]} thinking… ${s}s\x1b[0m\x1b[K`);
46
+ };
47
+ tick();
48
+ const timer = setInterval(tick, 120);
49
+ const clear = () => {
50
+ clearInterval(timer);
51
+ stdout.write("\r\x1b[K");
52
+ };
53
+ return promise.then(
54
+ (v) => { clear(); return v; },
55
+ (e) => { clear(); throw e; }
56
+ );
57
+ }
58
+
32
59
  function renderReply(stdout, resp) {
33
60
  if (resp && resp.ok && resp.answer && String(resp.answer).trim()) {
34
61
  stdout.write(`\n${String(resp.answer).trim()}\n`);
@@ -63,7 +90,7 @@ async function run(opts, io = {}) {
63
90
  // One-shot: a question on the command line.
64
91
  if (message) {
65
92
  try {
66
- const resp = await runTurn(session, connectionId, message, null);
93
+ const resp = await awaitWithSpinner(stdout, runTurn(session, connectionId, message, null));
67
94
  renderReply(stdout, resp);
68
95
  return resp && resp.ok ? 0 : 1;
69
96
  } catch (e) {
@@ -78,12 +105,35 @@ async function run(opts, io = {}) {
78
105
  return 2;
79
106
  }
80
107
 
81
- stdout.write("DeepSQL Agent connected to your server agent. Type a question; `exit` or Ctrl-C to quit.\n");
108
+ // Branded one-time intro (compact). Suppress with DEEPSQL_NO_BANNER=1; colors
109
+ // honor --no-color / NO_COLOR (this branch is already TTY-only).
110
+ const useColor = !opts.noColor && !process.env.NO_COLOR;
111
+ if (process.env.DEEPSQL_NO_BANNER) {
112
+ stdout.write("DeepSQL Agent — connected to your server agent. Type a question; `exit` or Ctrl-C to quit.\n");
113
+ } else {
114
+ // Only surface a connection when one was explicitly named; a default/absent
115
+ // connection shows nothing (the agent asks if a question needs one).
116
+ const connectionLabel = opts.connection || null;
117
+ // Version check + connections/suggestions run in parallel (both best-effort,
118
+ // tight timeouts) so startup waits on the slower one, not the sum.
119
+ const [versionLine, introData] = await Promise.all([
120
+ getVersionLine(useColor),
121
+ loadIntroData(session),
122
+ ]);
123
+ renderIntro(stdout, {
124
+ useColor,
125
+ connectionLabel,
126
+ versionLine,
127
+ connections: introData.connections,
128
+ suggestions: introData.suggestions,
129
+ recommendationCount: introData.recommendationCount,
130
+ });
131
+ }
82
132
  // conversationId stays null on the first turn so the server resumes your most
83
133
  // recent conversation for this connection (shared with the web Agent tab);
84
134
  // subsequent turns reuse the id it returns.
85
135
  let conversationId = null;
86
- const rl = readline.createInterface({ input: process.stdin, output: stdout, prompt: "\nyou › " });
136
+ const rl = readline.createInterface({ input: process.stdin, output: stdout, prompt: promptLabel(useColor) });
87
137
  rl.prompt();
88
138
 
89
139
  return await new Promise((resolve) => {
@@ -98,9 +148,8 @@ async function run(opts, io = {}) {
98
148
  return;
99
149
  }
100
150
  rl.pause();
101
- stdout.write("…thinking\n");
102
151
  try {
103
- const resp = await runTurn(session, connectionId, text, conversationId);
152
+ const resp = await awaitWithSpinner(stdout, runTurn(session, connectionId, text, conversationId));
104
153
  if (resp && resp.conversationId) conversationId = resp.conversationId;
105
154
  renderReply(stdout, resp);
106
155
  } catch (e) {
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+
3
+ // `deepsql brain` — review what DeepSQL has learned about a connection and teach
4
+ // it more. The "remember things as people use it" admin loop, on the CLI:
5
+ // recommendations AI-proposed things to document/investigate (the inbox)
6
+ // notes knowledge already saved to the brain (filterable)
7
+ // remember save a fact to the brain (admin: needs manage-content)
8
+ //
9
+ // Backed by existing endpoints — no new backend:
10
+ // GET /brain/notes/suggestions/{connectionId}?limit=N → { suggestions, totalCount }
11
+ // GET /brain/notes/{connectionId}[?tableName=&columnName=]
12
+ // POST /brain/notes { connectionId, scopeType, tableName, columnName, noteText }
13
+
14
+ const { ApiError, request } = require("../api/client");
15
+ const { resolveSession } = require("./_session");
16
+ const { resolveConnectionId } = require("./_connections");
17
+
18
+ // Canonical subcommands (the drift guard compares these keys to the help rows).
19
+ const SUBCOMMANDS = {
20
+ recommendations: cmdRecommendations,
21
+ notes: cmdNotes,
22
+ remember: cmdRemember,
23
+ };
24
+ // Convenience aliases resolved before dispatch (kept out of SUBCOMMANDS so the
25
+ // help-drift test stays clean).
26
+ const ALIASES = { recs: "recommendations", save: "remember" };
27
+
28
+ async function run(opts, io = {}) {
29
+ const raw = opts.positional[0];
30
+ if (!raw) throw new Error("Usage: deepsql brain <recommendations|notes|remember> [options]");
31
+ const sub = ALIASES[raw] || raw;
32
+ const handler = SUBCOMMANDS[sub];
33
+ if (!handler) throw new Error(`Unknown brain subcommand: ${raw}.`);
34
+ return wrap(handler)({ ...opts, positional: opts.positional.slice(1) }, io);
35
+ }
36
+
37
+ function wrap(handler) {
38
+ return async (opts, io) => {
39
+ try {
40
+ return await handler(opts, io);
41
+ } catch (err) {
42
+ if (err instanceof ApiError && err.status === 403) {
43
+ throw new Error("Access denied — this brain operation requires manage-content permission on the connection.");
44
+ }
45
+ throw err;
46
+ }
47
+ };
48
+ }
49
+
50
+ // ─── recommendations ─────────────────────────────────────────────────────────
51
+ async function cmdRecommendations(opts, { stdout = process.stdout } = {}) {
52
+ const session = resolveSession(opts);
53
+ const connectionId = await resolveConnectionId(session, opts.connection);
54
+ const limit = parseInt(opts.limit, 10) || 10;
55
+ const res = await request(
56
+ session.baseUrl,
57
+ `/brain/notes/suggestions/${encodeURIComponent(connectionId)}?limit=${limit}`,
58
+ { token: session.token }
59
+ );
60
+ const suggestions = (res && res.suggestions) || [];
61
+ if (opts.json) {
62
+ stdout.write(JSON.stringify(suggestions, null, 2) + "\n");
63
+ return 0;
64
+ }
65
+ if (!suggestions.length) {
66
+ stdout.write("No recommendations — the brain has nothing pending to review for this connection.\n");
67
+ return 0;
68
+ }
69
+ stdout.write(`\nBrain recommendations (${res.totalCount ?? suggestions.length}):\n\n`);
70
+ for (const s of suggestions) {
71
+ const target = s.columnName ? `${s.tableName}.${s.columnName}` : s.tableName;
72
+ stdout.write(` ${String(s.priority || "").padEnd(3)} ${target}\n`);
73
+ if (s.reason) stdout.write(` ${s.reason}\n`);
74
+ if (Array.isArray(s.indicators) && s.indicators.length) {
75
+ stdout.write(` ${s.indicators.join(" · ")}\n`);
76
+ }
77
+ if (s.suggestedPrompt) stdout.write(` explore: deepsql agent "${s.suggestedPrompt}"\n`);
78
+ stdout.write("\n");
79
+ }
80
+ stdout.write('Save a fact with: deepsql brain remember "<note>" --table <t> [--column <c>]\n');
81
+ return 0;
82
+ }
83
+
84
+ // ─── notes ───────────────────────────────────────────────────────────────────
85
+ async function cmdNotes(opts, { stdout = process.stdout } = {}) {
86
+ const session = resolveSession(opts);
87
+ const connectionId = await resolveConnectionId(session, opts.connection);
88
+ const qs = [];
89
+ if (opts.table) qs.push(`tableName=${encodeURIComponent(opts.table)}`);
90
+ if (opts.column) qs.push(`columnName=${encodeURIComponent(opts.column)}`);
91
+ const notes = await request(
92
+ session.baseUrl,
93
+ `/brain/notes/${encodeURIComponent(connectionId)}${qs.length ? `?${qs.join("&")}` : ""}`,
94
+ { token: session.token }
95
+ );
96
+ const list = Array.isArray(notes) ? notes : [];
97
+ if (opts.json) {
98
+ stdout.write(JSON.stringify(list, null, 2) + "\n");
99
+ return 0;
100
+ }
101
+ if (!list.length) {
102
+ stdout.write("No saved notes for that scope.\n");
103
+ return 0;
104
+ }
105
+ const limit = parseInt(opts.limit, 10) || 20;
106
+ stdout.write(`\nBrain notes (${list.length}${list.length > limit ? `, showing ${limit}` : ""}):\n\n`);
107
+ for (const n of list.slice(0, limit)) {
108
+ const target = n.columnName ? `${n.tableName}.${n.columnName}` : n.tableName;
109
+ const tags = `${n.source ? ` [${n.source}]` : ""}${n.stale ? " (stale)" : ""}`;
110
+ stdout.write(` ${target}${tags}\n ${n.noteText}\n\n`);
111
+ }
112
+ if (list.length > limit) {
113
+ stdout.write(`… +${list.length - limit} more — narrow with --table <t> [--column <c>], or --limit N\n`);
114
+ }
115
+ return 0;
116
+ }
117
+
118
+ // ─── remember ────────────────────────────────────────────────────────────────
119
+ async function cmdRemember(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
120
+ const session = resolveSession(opts);
121
+ const connectionId = await resolveConnectionId(session, opts.connection);
122
+ const noteText = (opts.positional || []).join(" ").trim();
123
+ if (!noteText) {
124
+ stderr.write('Usage: deepsql brain remember "<note text>" --table <t> [--column <c>]\n');
125
+ return 2;
126
+ }
127
+ if (!opts.table) {
128
+ stderr.write("A --table is required (add --column for a column-scoped note).\n");
129
+ return 2;
130
+ }
131
+ const body = {
132
+ connectionId,
133
+ scopeType: opts.column ? "COLUMN" : "TABLE",
134
+ tableName: opts.table,
135
+ columnName: opts.column || null,
136
+ noteText,
137
+ };
138
+ await request(session.baseUrl, "/brain/notes", { method: "POST", token: session.token, json: body });
139
+ const target = body.columnName ? `${body.tableName}.${body.columnName}` : body.tableName;
140
+ stdout.write(`✓ Saved to brain — ${target}: ${noteText}\n`);
141
+ return 0;
142
+ }
143
+
144
+ module.exports = { run, SUBCOMMANDS };
@@ -27,9 +27,10 @@ const fs = require("node:fs");
27
27
  const { ApiError, request } = require("../api/client");
28
28
  const store = require("../auth/store");
29
29
  const { resolveSession } = require("./_session");
30
- const { resolveConnectionId } = require("./_connections");
30
+ const { resolveConnectionId, listConnections } = require("./_connections");
31
31
  const { resolveSecrets, maskSecrets, SECRET_FIELDS } = require("../connections/secrets");
32
32
  const { SCHEMA, validate } = require("../connections/schema");
33
+ const cache = require("../connections/cache");
33
34
  const ui = require("../ui/prompts");
34
35
  const { promptPassword } = require("../auth/prompt");
35
36
 
@@ -59,7 +60,8 @@ async function run(opts, io = {}) {
59
60
 
60
61
  async function runList(opts, { stdout = process.stdout } = {}) {
61
62
  const session = resolveSession(opts);
62
- const data = await request(session.baseUrl, "/connections", { token: session.token });
63
+ // Cached for speed; --refresh (or --no-cache) forces a live fetch.
64
+ const data = await listConnections(session, { refresh: !!(opts.refresh || opts.noCache) });
63
65
  if (opts.json) {
64
66
  stdout.write(`${JSON.stringify(data, null, 2)}\n`);
65
67
  return;
@@ -228,6 +230,7 @@ async function runAdd(opts, { stdout = process.stdout, stderr = process.stderr }
228
230
  throw new Error(saved.message || "Connection save failed.");
229
231
  }
230
232
  const id = saved?.connectionId || saved?.id;
233
+ cache.invalidate(session.baseUrl);
231
234
  if (saved?.privileges) printPrivilegeReport(stderr, saved);
232
235
  stdout.write(`Saved "${cfg.connectionName}" (id ${id || "?"}).\n`);
233
236
 
@@ -277,6 +280,7 @@ async function runUpdate(opts, { stdout = process.stdout, stderr = process.stder
277
280
  if (saved?.success === false) {
278
281
  throw new Error(saved.message || "Connection update failed.");
279
282
  }
283
+ cache.invalidate(session.baseUrl);
280
284
  if (saved?.privileges) printPrivilegeReport(stderr, saved);
281
285
  stdout.write(`Updated "${target}".\n`);
282
286
  }
@@ -303,6 +307,7 @@ async function runRemove(opts, { stdout = process.stdout, stderr = process.stder
303
307
  method: "DELETE",
304
308
  token: session.token,
305
309
  });
310
+ cache.invalidate(session.baseUrl);
306
311
  if (session.defaultConnection === target) {
307
312
  store.setDefaultConnection(session.baseUrl, null);
308
313
  stderr.write(`[deepsql] Cleared the active-connection pin (was "${target}").\n`);
@@ -391,6 +396,7 @@ async function runInit(opts, { stdout = process.stdout, stderr = process.stderr
391
396
  token: session.token,
392
397
  json: { force: !!opts.force },
393
398
  });
399
+ cache.invalidate(session.baseUrl);
394
400
  stdout.write(`Brain reinit triggered for "${target}".\n`);
395
401
  if (opts.wait) await pollInitStatus(session, connectionId, stderr);
396
402
  }
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+
3
+ // Small on-disk cache for the connections list, keyed per DeepSQL instance.
4
+ // Each `deepsql` invocation is a fresh process, so without this every command
5
+ // re-fetches (and re-decrypts, server-side) the whole list just to resolve
6
+ // `--connection`. The list changes rarely, so a short TTL makes the common path
7
+ // (resolve a connection name → id) effectively free while staying fresh.
8
+ //
9
+ // Holds only the same non-secret connection summary the user already sees
10
+ // (name/host/port/etc.; secrets are masked server-side). File is 0600.
11
+
12
+ const fs = require("node:fs");
13
+ const path = require("node:path");
14
+ const crypto = require("node:crypto");
15
+ const { configDir } = require("../auth/store");
16
+
17
+ const DEFAULT_TTL_MS = 60000; // 60s
18
+
19
+ function cacheFile(baseUrl) {
20
+ const key = crypto.createHash("sha1").update(String(baseUrl || "")).digest("hex").slice(0, 16);
21
+ return path.join(configDir(), "cache", `connections-${key}.json`);
22
+ }
23
+
24
+ function read(baseUrl, ttlMs = DEFAULT_TTL_MS) {
25
+ try {
26
+ const obj = JSON.parse(fs.readFileSync(cacheFile(baseUrl), "utf8"));
27
+ if (!obj || !Array.isArray(obj.data)) return null;
28
+ if (Date.now() - (obj.ts || 0) > ttlMs) return null;
29
+ return obj.data;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function write(baseUrl, data) {
36
+ if (!Array.isArray(data)) return;
37
+ try {
38
+ const dir = path.join(configDir(), "cache");
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ const file = cacheFile(baseUrl);
41
+ fs.writeFileSync(file, JSON.stringify({ ts: Date.now(), data }), { mode: 0o600 });
42
+ try { fs.chmodSync(file, 0o600); } catch { /* best-effort */ }
43
+ } catch {
44
+ /* cache is best-effort — never fail a command over it */
45
+ }
46
+ }
47
+
48
+ function invalidate(baseUrl) {
49
+ try { fs.rmSync(cacheFile(baseUrl), { force: true }); } catch { /* ignore */ }
50
+ }
51
+
52
+ module.exports = { read, write, invalidate, DEFAULT_TTL_MS };
@@ -1,28 +0,0 @@
1
- You are **DeepSQL DBA**, an AI database performance assistant. You answer questions about the user's databases and help them build features against an existing schema. You operate exclusively through the **DeepSQL MCP tools** (server `deepsql`) — they are your source of truth, not your training data and not the user's codebase.
2
-
3
- Be direct, concrete, and grounded. Cite the tables and rules you used. Admit uncertainty instead of guessing. Prefer one correct, grounded answer over a verbose survey.
4
-
5
- ## Non-negotiable rules
6
-
7
- 1. **Connections are UUIDs.** Everything except `get_current_user` needs a `connectionId`. Get it from `list_connections` once and reuse it. Pass the UUID, never the human name.
8
-
9
- 2. **Ground before you generate.** ALWAYS call `get_brain_context(connectionId, question)` before writing any non-trivial SQL or proposing schema. The brain knows business rules, anti-patterns, and inferred foreign keys that the raw schema does not. Skipping it produces "technically valid, semantically wrong" SQL — the worst kind.
10
-
11
- 3. **Schema comes from `get_schema`, not `information_schema`.** It is cached, fast, and authoritative. Never trust column names inferred from the codebase — they drift.
12
-
13
- 4. **Table-qualify every column** in generated SQL (`table.column`). Honor business rules and anti-patterns silently — if a rule says `always_filter_cancelled`, your query includes the filter without asking permission to follow the user's own rule.
14
-
15
- 5. **Read-only by default.** Developers cannot mutate; admins can with a **two-step confirmation**. If `execute_sql` returns `requiresConfirmation: true`, surface the warnings verbatim, get explicit human approval, then re-call with `confirmMutation: true`. NEVER auto-confirm — that defeats the safety gate. Never try to work around a 403/`EDITOR_MUTATION_FORBIDDEN`; surface it.
16
-
17
- 6. **One execution tool, one analysis tool.** Use `execute_sql` to run SQL; use `analyze_query_plan` for plans. Don't hand-wrap `EXPLAIN` inside `execute_sql`, and don't run a query just to see its plan. `EXPLAIN`/`EXPLAIN ANALYZE` are read-only SQL when you do need them — but `analyze_query_plan` gives the AI-enriched summary.
18
-
19
- 7. **Row limits are real.** `execute_sql` defaults to 100 rows, max 1000. If you need a total, `SELECT COUNT(*)` — don't infer it from a truncated result.
20
-
21
- 8. **Consult before you commit schema.** When the user says "add a table / track X / write a migration," STOP and run the brain consult (`get_brain_context` → `get_schema` → `list_business_rules` → `get_relationships` → `get_anti_patterns`). There is almost always an existing table or column to extend instead of duplicate. Narrate what you found before proposing DDL.
22
-
23
- ## Skills
24
-
25
- Detailed procedures live in your skills. Load the matching one before acting:
26
- `bi-query` (answer a data question), `schema-exploration` (map/describe a database), `index-advisor` (what indexes to add/drop), `slow-query-optimize` (why is a query slow / rewrite it), `workload-analysis` (what's driving load, regressions, growth).
27
-
28
- Every tool call is logged with your identity and the statement you ran. Don't do anything you wouldn't defend in an audit.
@@ -1,41 +0,0 @@
1
- # Hermes profile distribution — DeepSQL DBA agent.
2
- # Enterprises install this per user: hermes profile install <git-url|dir> --name <profile>
3
- # `hermes profile update <profile>` refreshes SOUL.md + skills/ WITHOUT touching
4
- # the user's memory, sessions, .env, or config.yaml (their identity + history).
5
- name: deepsql-agent
6
- version: 0.1.0
7
- description: "DeepSQL DBA agent — grounded, read-only database assistant over the DeepSQL MCP tools (BI queries, schema exploration, index advice, slow-query optimization, workload analysis)."
8
- hermes_requires: ">=0.12.0"
9
- author: "DeepSQL"
10
- license: "proprietary"
11
-
12
- # Per-user runtime values. Secrets + identity live in each profile's .env
13
- # (user-owned, preserved on update); provision-profile.sh writes them.
14
- env_requires:
15
- - name: AZURE_OPENAI_KEY
16
- description: "Azure OpenAI API key for the chat model"
17
- required: true
18
- - name: AZURE_OPENAI_ENDPOINT
19
- description: "Azure OpenAI resource endpoint (…cognitiveservices/…openai.azure.com)"
20
- required: false
21
- default: "https://dba-agent-3-resource.openai.azure.com/"
22
- - name: DEEPSQL_API_BASE_URL
23
- description: "DeepSQL backend API base URL"
24
- required: false
25
- default: "http://localhost:8080/api/"
26
- - name: DEEPSQL_MCP_SERVER
27
- description: "Absolute path to mcp/deepsql-phase1-server.js"
28
- required: true
29
- - name: DEEPSQL_MCP_USER_ID
30
- description: "DeepSQL user id this profile acts as — backend audit + RBAC attribution"
31
- required: true
32
- - name: DEEPSQL_AUTH_TOKEN
33
- description: "DeepSQL bearer token for this user — server-enforced RBAC/connection-scope"
34
- required: false
35
-
36
- # The agent persona and skills are the distribution's product surface.
37
- # Runtime config (model, mcp_servers, approvals, toolset scoping) is applied
38
- # per-user by provision-profile.sh into the profile's preserved config.yaml.
39
- distribution_owned:
40
- - SOUL.md
41
- - skills/
@@ -1,40 +0,0 @@
1
- ---
2
- name: bi-query
3
- description: Answer a question about the data — write and run grounded, read-only SQL against a DeepSQL connection and report the result.
4
- version: 1.0.0
5
- platforms: [linux, macos, windows]
6
- metadata:
7
- hermes:
8
- tags: [sql, bi, analytics, query, count, report, deepsql, database]
9
- related_skills: [schema-exploration, slow-query-optimize]
10
- ---
11
-
12
- # BI Query
13
-
14
- Use when the user asks a question whose answer is **in the data** ("how many bookings last week?", "revenue by region", "top 10 customers"). The output is a number/table, not schema advice.
15
-
16
- ## Procedure
17
-
18
- 1. **Resolve the connection.** If you don't already have the UUID, call `list_connections` and match the user's named database. Pass the UUID to every later call.
19
-
20
- 2. **Ground.** Call `get_brain_context(connectionId, "<the user's question>")`. Read what it surfaces — relevant tables, columns, inferred FKs, business rules, anti-patterns. Do not skip this even if you think you know the table.
21
-
22
- 3. **Confirm exact columns** with `get_schema(connectionId)` only if the brain context didn't give you exact column names/types you need. Don't query `information_schema`.
23
-
24
- 4. **Write ONE statement.** Table-qualify every column. Apply every relevant business rule (e.g. cancelled/soft-delete filters) without being asked. Use a CTE (`WITH …`) instead of multiple statements — `execute_sql` rejects multi-statement input.
25
-
26
- 5. **Sanity-check the plan** with `analyze_query_plan(connectionId, sql)` (no `useAnalyze`) when the query has non-trivial joins or runs against an unfamiliar schema. It's cheap and catches bad joins before you show the user a wrong number.
27
-
28
- 6. **Run it** with `execute_sql(connectionId, sql, limit=…)`. Remember: default 100 rows, max 1000. For a total, `SELECT COUNT(*)` rather than counting a truncated result set.
29
-
30
- 7. **Report concisely:** the answer, the table(s) you used, and any business rule you applied ("excluded CANCELLED per `always_filter_cancelled`").
31
-
32
- ## Guardrails
33
-
34
- - Read-only only. If the question implies a write, switch to the mutation flow (surface warnings, get a human OK, `confirmMutation: true`) and only if the user is an admin.
35
- - If `get_brain_context` returns nothing useful, do **not** guess a table from memory. First try lightweight discovery against the live DB: `SHOW TABLES LIKE '%<keyword>%'`, then `DESCRIBE <candidate_table>`, then a narrowly-scoped verification query such as `SELECT COUNT(*) ...`. Ask the user only if multiple candidates remain plausible after those probes.
36
- - If schema/object metadata is too large or `get_database_objects` times out, prefer targeted read-only probes over broad catalog dumps.
37
- - When the user asks for a chart or ranking and brain context is thin, first identify the fact table and dimension join path with small probes, then run the final aggregation. Example pattern used successfully in `idb_database`: `USER_BOOKINGS.hotel_id -> HOTEL.id` for city-level booking rollups, and `PRICE_BREAKDOWN.booking_id -> USER_BOOKINGS.id -> HOTEL.id` for hotel-level room-price averages.
38
- - If the user says "active hotels" or similar status language, check brain context for status-source rules before using a status column. In `idb_database`, `HOTEL_PRICING.property_status` is the correct source and `HOTEL.onboarding_status` is specifically the wrong one.
39
- - For chart requests in chat-only environments, still run the real aggregation query first. If you cannot render a binary image with available tools, provide a clearly labeled text/Markdown chart from the real results rather than fabricating an image.
40
- - Never present a truncated (limit-capped) result as a total.