@deepsql/mcp 0.23.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.
package/CLAUDE.md CHANGED
@@ -33,8 +33,10 @@ server, and the statement you ran.** Don't be sloppy.
33
33
 
34
34
  ## The tools you have
35
35
 
36
- The MCP server exposes **41 tools** as of 0.19.0 — the original 16 +
37
- 0.18.x's 5 slow-query tools + 0.19.0's 20 CLI-parity additions. Most
36
+ The MCP server exposes **44 tools** as of 0.26.0 — the original 16 +
37
+ 0.18.x's 5 slow-query tools + 0.19.0's 20 CLI-parity additions + 0.26.0's
38
+ 3 brain-notes tools (`list_brain_recommendations`, `save_brain_note`,
39
+ `list_brain_notes`). Most
38
40
  take a `connectionId` (UUID returned by `list_connections`); a few
39
41
  take server-resolved row ids (`apply_index_recommendation` →
40
42
  `recommendationId`, `dismiss_index_recommendation` →
@@ -65,6 +67,9 @@ in this version; ask before mid-session admin work.
65
67
  | `list_business_rules` | Active business rules and SQL guardrails. Honor these — they encode domain semantics. |
66
68
  | `get_relationships` | Inferred + validated foreign keys with confidence scores. Many real DBs lack declared FKs; this fills the gap. |
67
69
  | `get_anti_patterns` | Schema-level (`kind=table`) or query-level (`kind=query`) anti-patterns. |
70
+ | `list_brain_recommendations` | The brain's AI-proposed notes to review for a connection (priority, reason, indicators, suggested prompt). The company-context review queue. |
71
+ | `save_brain_note` | **Accept/save a fact into the shared brain** — grounds every future answer for the connection. TABLE- or COLUMN-scoped. Admin (manage-content), audited. Personal preferences belong in a DeepSQL skill, not here. |
72
+ | `list_brain_notes` | Knowledge already saved to the brain (filter by table/column; can be thousands). |
68
73
  | `analyze_slow_queries` | Recent slow queries with fingerprints, durations, examples. Read-only; doesn't trigger new work. |
69
74
  | `get_slow_query_timeline` | Day-by-day timeline for one query from the 30-day analytics store — call count, mean/max time, regression factor per day. Identify the query by its fingerprint (the `queryId` from `analyze_slow_queries`). Answers "is this query getting slower". |
70
75
  | `get_query_regressions` | Slow queries that regressed (got slower) on the latest daily analysis run, ranked by slowdown factor. Read-only. |
package/README.md CHANGED
@@ -88,7 +88,7 @@ Restart the editor for the entry to load.
88
88
 
89
89
  ## What the MCP server exposes
90
90
 
91
- **41 tools** as of 0.19.0. Read-only at the schema/retrieval/diagnostics
91
+ **44 tools** as of 0.26.0. Read-only at the schema/retrieval/diagnostics
92
92
  layer; policy-gated at the SQL layer (only `execute_sql`,
93
93
  `analyze_query_plan` with `useAnalyze=true`, and
94
94
  `apply_index_recommendation` can write — the rest are reads or low-stakes
@@ -112,7 +112,7 @@ per-user admin ops (`users`/`access`/`permissions`).
112
112
  | `test_connection` | Run the privilege report (+ SSH check); reuses saved creds — no plaintext crosses the wire |
113
113
  | `reinit_connection_brain` | Trigger a fresh schema scan + brain re-embed |
114
114
 
115
- ### Schema + retrieval brain (5)
115
+ ### Schema + retrieval brain (8)
116
116
 
117
117
  | Tool | Purpose |
118
118
  |---|---|
@@ -121,6 +121,9 @@ per-user admin ops (`users`/`access`/`permissions`).
121
121
  | `get_brain_context` | Retrieval brain: tables/columns/FKs/training docs/rules for a question |
122
122
  | `list_business_rules` | Active business rules and SQL guardrails for a connection |
123
123
  | `get_relationships` | Inferred + validated foreign keys with confidence scores |
124
+ | `list_brain_recommendations` | The brain's AI-proposed notes to review (company-context queue) |
125
+ | `save_brain_note` | Accept/save a fact into the shared brain (admin; grounds future answers) |
126
+ | `list_brain_notes` | Knowledge already saved to the brain (filterable) |
124
127
 
125
128
  ### Anti-patterns + daily digest (4)
126
129
 
@@ -143,6 +143,51 @@ const TOOL_DEFINITIONS = [
143
143
  additionalProperties: false,
144
144
  },
145
145
  },
146
+ {
147
+ name: "list_brain_recommendations",
148
+ description:
149
+ "List the brain's AI-proposed recommendations for a connection — high-value tables/columns DeepSQL suggests documenting, each with a priority (P0/P1…), the reason, supporting indicators, and a suggested prompt to explore. This is the company-context review queue: an admin reviews these and accepts the good ones with save_brain_note. Returns { suggestions, totalCount } (totalCount reflects the requested limit, not an absolute total).",
150
+ inputSchema: {
151
+ type: "object",
152
+ properties: {
153
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
154
+ limit: { type: "integer", minimum: 1, maximum: 100, description: "Max recommendations to return (default 10)." },
155
+ },
156
+ required: ["connectionId"],
157
+ additionalProperties: false,
158
+ },
159
+ },
160
+ {
161
+ name: "save_brain_note",
162
+ description:
163
+ "Accept/save a fact into the connection's BRAIN — shared, company-level context that grounds EVERY future answer for this connection (not a per-user preference). Use this to accept a recommendation from list_brain_recommendations, or to record any durable fact about a table/column. Scope is TABLE (tableName only) or COLUMN (tableName + columnName). Requires manage-content permission on the connection (admin) — the backend enforces and audits it. NOTE: an individual's personal preference (how *they* like answers formatted, a private shortcut) belongs in a DeepSQL skill on their own profile, NOT here — this writes to the shared brain everyone sees.",
164
+ inputSchema: {
165
+ type: "object",
166
+ properties: {
167
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
168
+ tableName: { type: "string", description: "Table the note is about (required)." },
169
+ columnName: { type: "string", description: "Column the note is about; omit for a table-scoped note." },
170
+ noteText: { type: "string", description: "The fact/guidance to remember." },
171
+ },
172
+ required: ["connectionId", "tableName", "noteText"],
173
+ additionalProperties: false,
174
+ },
175
+ },
176
+ {
177
+ name: "list_brain_notes",
178
+ description:
179
+ "List knowledge already saved to the connection's brain (the notes that ground answers). Optionally filter by tableName and/or columnName — a trained connection can hold thousands of notes, so filtering is recommended. Use this to check whether a fact is already remembered before saving a new one with save_brain_note.",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: {
183
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
184
+ tableName: { type: "string", description: "Filter to one table." },
185
+ columnName: { type: "string", description: "Filter to one column (use with tableName)." },
186
+ },
187
+ required: ["connectionId"],
188
+ additionalProperties: false,
189
+ },
190
+ },
146
191
  {
147
192
  name: "apply_index_recommendation",
148
193
  description:
@@ -1149,6 +1194,25 @@ function summarizeRelationships(payload) {
1149
1194
  return `${list.length} relationship(s) (${high} high-confidence).`;
1150
1195
  }
1151
1196
 
1197
+ function summarizeBrainRecommendations(payload) {
1198
+ const list = (payload && payload.suggestions) || [];
1199
+ if (!list.length) return "No brain recommendations pending review for this connection.";
1200
+ const top = list
1201
+ .slice(0, 5)
1202
+ .map((s) => `${s.priority || ""} ${s.columnName ? `${s.tableName}.${s.columnName}` : s.tableName}`.trim());
1203
+ return `${payload.totalCount ?? list.length} recommendation(s) to review. Top: ${top.join("; ")}. Accept the good ones with save_brain_note.`;
1204
+ }
1205
+
1206
+ function summarizeBrainNoteSaved(payload) {
1207
+ const target = payload && (payload.columnName ? `${payload.tableName}.${payload.columnName}` : payload.tableName);
1208
+ return `Saved to brain${target ? ` — ${target}` : ""}. It now grounds future answers for this connection (shared, company-level).`;
1209
+ }
1210
+
1211
+ function summarizeBrainNotes(payload) {
1212
+ const list = Array.isArray(payload) ? payload : (payload && payload.notes) || [];
1213
+ return `${list.length} brain note(s) for the requested scope.`;
1214
+ }
1215
+
1152
1216
  function summarizeAntiPatterns(payload, kind) {
1153
1217
  if (kind === "table") {
1154
1218
  const tables = payload && typeof payload === "object" ? Object.keys(payload).length : 0;
@@ -1548,6 +1612,15 @@ function buildToolResult(name, payload, extra = {}) {
1548
1612
  case "get_growth_anomalies":
1549
1613
  summary = summarizeGrowthAnomalies(payload);
1550
1614
  break;
1615
+ case "list_brain_recommendations":
1616
+ summary = summarizeBrainRecommendations(payload);
1617
+ break;
1618
+ case "save_brain_note":
1619
+ summary = summarizeBrainNoteSaved(payload);
1620
+ break;
1621
+ case "list_brain_notes":
1622
+ summary = summarizeBrainNotes(payload);
1623
+ break;
1551
1624
  case "get_index_recommendations":
1552
1625
  summary = summarizeIndexRecommendations(payload);
1553
1626
  break;
@@ -1596,10 +1669,28 @@ function buildToolError(message, extra = {}) {
1596
1669
  };
1597
1670
  }
1598
1671
 
1672
+ // Short-TTL in-memory cache for the connections list. The MCP server is a
1673
+ // long-lived process and `list_connections` is the "call this first" tool, so an
1674
+ // agent hits it repeatedly in a session — caching makes those calls instant. The
1675
+ // list changes rarely; a 30s TTL keeps it fresh enough.
1676
+ const CONNECTIONS_CACHE_TTL_MS = 30000;
1677
+ let _connectionsCache = null; // { key, ts, payload }
1678
+
1679
+ async function fetchConnectionsCached(config) {
1680
+ const key = `${(config && config.baseUrl) || ""}|${(config && config.authToken) || ""}`;
1681
+ if (_connectionsCache && _connectionsCache.key === key
1682
+ && Date.now() - _connectionsCache.ts < CONNECTIONS_CACHE_TTL_MS) {
1683
+ return _connectionsCache.payload;
1684
+ }
1685
+ const payload = await callDeepSqlApi(config, "/connections");
1686
+ _connectionsCache = { key, ts: Date.now(), payload };
1687
+ return payload;
1688
+ }
1689
+
1599
1690
  async function handleToolCall(config, name, args = {}) {
1600
1691
  switch (name) {
1601
1692
  case "list_connections": {
1602
- const payload = await callDeepSqlApi(config, "/connections");
1693
+ const payload = await fetchConnectionsCached(config);
1603
1694
  return buildToolResult(name, payload);
1604
1695
  }
1605
1696
 
@@ -1684,6 +1775,52 @@ async function handleToolCall(config, name, args = {}) {
1684
1775
  return buildToolResult(name, payload, { kind });
1685
1776
  }
1686
1777
 
1778
+ case "list_brain_recommendations": {
1779
+ const connectionId = String(args.connectionId || "").trim();
1780
+ if (!connectionId) return buildToolError("connectionId is required.");
1781
+ const limit = clampInteger(args.limit, 1, 100, 10);
1782
+ const payload = await callDeepSqlApi(
1783
+ config,
1784
+ `/brain/notes/suggestions/${encodeURIComponent(connectionId)}?limit=${limit}`,
1785
+ );
1786
+ return buildToolResult(name, payload);
1787
+ }
1788
+
1789
+ case "save_brain_note": {
1790
+ const connectionId = String(args.connectionId || "").trim();
1791
+ const tableName = String(args.tableName || "").trim();
1792
+ const noteText = String(args.noteText || "").trim();
1793
+ if (!connectionId) return buildToolError("connectionId is required.");
1794
+ if (!tableName) return buildToolError("tableName is required.");
1795
+ if (!noteText) return buildToolError("noteText is required.");
1796
+ const columnName = args.columnName ? String(args.columnName).trim() : null;
1797
+ // Backend enforces manage-content permission (admin) + audits the write.
1798
+ const payload = await callDeepSqlApi(config, "/brain/notes", {
1799
+ method: "POST",
1800
+ json: {
1801
+ connectionId,
1802
+ scopeType: columnName ? "COLUMN" : "TABLE",
1803
+ tableName,
1804
+ columnName,
1805
+ noteText,
1806
+ },
1807
+ });
1808
+ return buildToolResult(name, payload);
1809
+ }
1810
+
1811
+ case "list_brain_notes": {
1812
+ const connectionId = String(args.connectionId || "").trim();
1813
+ if (!connectionId) return buildToolError("connectionId is required.");
1814
+ const qs = [];
1815
+ if (args.tableName) qs.push(`tableName=${encodeURIComponent(String(args.tableName))}`);
1816
+ if (args.columnName) qs.push(`columnName=${encodeURIComponent(String(args.columnName))}`);
1817
+ const payload = await callDeepSqlApi(
1818
+ config,
1819
+ `/brain/notes/${encodeURIComponent(connectionId)}${qs.length ? `?${qs.join("&")}` : ""}`,
1820
+ );
1821
+ return buildToolResult(name, payload);
1822
+ }
1823
+
1687
1824
  case "get_index_recommendations": {
1688
1825
  const connectionId = String(args.connectionId || "").trim();
1689
1826
  if (!connectionId) return buildToolError("connectionId is required.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepsql/mcp",
3
- "version": "0.23.0",
3
+ "version": "0.26.0",
4
4
  "description": "DeepSQL CLI, DBA Agent (thin client), and stdio MCP server for self-hosted deployments",
5
5
  "bin": {
6
6
  "deepsql": "bin/deepsql.js",
@@ -2,7 +2,7 @@
2
2
 
3
3
  You have two DeepSQL surfaces available:
4
4
 
5
- 1. **MCP tools** — JSON-RPC tools loaded into your session (41 of them
5
+ 1. **MCP tools** — JSON-RPC tools loaded into your session (44 of them
6
6
  as of 0.19.0). The MCP surface mirrors the CLI for almost every
7
7
  read/diagnostic operation, plus the two execute tools and the index-
8
8
  apply tool.
@@ -91,6 +91,9 @@ usually doesn't know about either; that's exactly why DeepSQL exists.
91
91
  | `list_business_rules(connectionId, question?)` | Rules the SQL must respect. |
92
92
  | `get_relationships(connectionId)` | Foreign keys (declared + inferred-with-confidence). |
93
93
  | `get_anti_patterns(connectionId, kind="table"\|"query")` | Patterns to avoid in this DB. |
94
+ | `list_brain_recommendations(connectionId, limit?)` | The brain's AI-proposed things to document (the review queue). Admin accepts good ones with `save_brain_note`. |
95
+ | `save_brain_note(connectionId, tableName, noteText, columnName?)` | **Accept/save a fact to the SHARED company brain** — grounds every future answer for this connection. Admin (manage-content). NOT for personal prefs — those go in a DeepSQL skill. |
96
+ | `list_brain_notes(connectionId, tableName?, columnName?)` | Knowledge already in the brain; filter before saving a duplicate. |
94
97
  | `analyze_slow_queries(connectionId, thresholdMs?, limit?)` | Snapshot of slow queries from live stats. |
95
98
  | `get_slow_query_timeline(connectionId, fingerprint)` | Day-by-day timeline for one fingerprint: call count, mean/max time, regression factor per day. Answers "is this query getting slower". |
96
99
  | `get_query_regressions(connectionId, minFactor?)` | Slow queries that got slower on the latest daily analysis run, ranked by slowdown factor. |
@@ -138,7 +141,7 @@ called by claude-code via deepsql CLI").
138
141
  deepsql query "SELECT 1" --connection prod-pg --caller-agent claude-code --json
139
142
  ```
140
143
 
141
- ### Command catalog (20 top-level commands)
144
+ ### Command catalog (21 top-level commands)
142
145
 
143
146
  | Command | What it does | MCP equivalent? |
144
147
  |---|---|---|
@@ -154,6 +157,7 @@ deepsql query "SELECT 1" --connection prod-pg --caller-agent claude-code --json
154
157
  | `deepsql analyze "<sql>" --connection <c>` | AI plan analysis (`--analyze` for EXPLAIN ANALYZE) | `analyze_query_plan` |
155
158
  | `deepsql schema [tables\|objects] --connection <c>` | Dump full schema as JSON | `get_schema` / `get_database_objects` |
156
159
  | `deepsql brain-context "<question>" --connection <c>` | Same retrieval as the MCP tool | `get_brain_context` |
160
+ | `deepsql brain recommendations\|notes\|remember` | Review what DeepSQL has learned (AI-proposed notes), list saved notes, and `remember "<note>" --table <t> [--column <c>]` to teach it (admin) | none — CLI/web surface for the brain-notes loop |
157
161
  | `deepsql business-rules --connection <c>` | List active business rules | `list_business_rules` |
158
162
  | `deepsql relationships --connection <c>` | Inferred + validated FKs | `get_relationships` |
159
163
  | `deepsql anti-patterns --connection <c> [--kind table\|query]` | Anti-patterns | `get_anti_patterns` |
package/src/cli.js CHANGED
@@ -28,6 +28,7 @@ const COMMANDS = {
28
28
  // Brain tools — give a coding agent the same retrieval context the chat
29
29
  // pipeline uses, then let the agent generate SQL/answers itself.
30
30
  "brain-context": () => require("./commands/brain-context"),
31
+ brain: () => require("./commands/brain"),
31
32
  "business-rules": () => require("./commands/business-rules"),
32
33
  relationships: () => require("./commands/relationships"),
33
34
  "anti-patterns": () => require("./commands/anti-patterns"),
@@ -60,6 +61,7 @@ const COMMAND_LIST = [
60
61
  ["digest", true, "Show DeepSQL daily digests"],
61
62
  ["growth", true, "Table growth analytics — trends, history, anomalies, alert config"],
62
63
  ["brain-context", false, "Retrieve embedding-ranked context for a question"],
64
+ ["brain", true, "Review brain recommendations and remember facts (admin)"],
63
65
  ["business-rules", false, "List active business rules and SQL guardrails"],
64
66
  ["relationships", false, "List inferred and validated FK relationships"],
65
67
  ["anti-patterns", false, "List schema- or query-level anti-patterns"],
@@ -161,7 +163,7 @@ const COMMAND_HELP = {
161
163
  description: "Manage database connections — list, pin, full CRUD.",
162
164
  usage: "deepsql connections <subcommand> [options]",
163
165
  subcommands: [
164
- ["list [--json]", "List database connections (active default marked with *)"],
166
+ ["list [--json] [--refresh]", "List database connections (cached ~60s; --refresh forces live)"],
165
167
  ["use <name>", "Pin <name> as the active default for this profile"],
166
168
  ["current", "Print the active default (exit 1 if none)"],
167
169
  ["unset", "Clear the active default for this profile"],
@@ -174,6 +176,7 @@ const COMMAND_HELP = {
174
176
  ["init <name> [--force] [--wait]", "Trigger brain re-initialization"],
175
177
  ],
176
178
  options: [
179
+ ["--refresh", "Bypass the connections cache and fetch live (list)"],
177
180
  ["--from-file <p>", "Read connection JSON from file"],
178
181
  ["--from-stdin", "Read connection JSON from stdin"],
179
182
  ["--upsert", "PUT instead of POST on add if name collision"],
@@ -261,6 +264,24 @@ const COMMAND_HELP = {
261
264
  notes: "`capture` and `config set` are the only mutations; both are admin-level but neither writes user data. Trends + anomalies are the agent-facing subcommands and are also exposed via the MCP tools `get_table_growth` and `get_growth_anomalies`.",
262
265
  },
263
266
 
267
+ brain: {
268
+ description: "Review what DeepSQL has learned about a connection and teach it more (admin).",
269
+ usage: "deepsql brain <subcommand> [options]",
270
+ subcommands: [
271
+ ["recommendations [--connection <name>] [--limit <n>]", "AI-proposed things to document/investigate (alias: recs)"],
272
+ ["notes [--connection <name>] [--table <t>] [--column <c>]", "Knowledge already saved to the brain (filterable)"],
273
+ ['remember "<note>" --table <t> [--column <c>]', "Save a fact to the brain (alias: save)"],
274
+ ],
275
+ options: [
276
+ ["--connection <name>", "Connection to act on (default: active connection)"],
277
+ ["--table <t>", "Scope notes/remember to a table"],
278
+ ["--column <c>", "Scope to a column (with --table)"],
279
+ ["--limit <n>", "Cap results"],
280
+ ["--json", "Raw JSON output"],
281
+ ],
282
+ notes: "`recommendations` reads /brain/notes/suggestions; `remember` writes a brain note (requires manage-content permission on the connection). The same recommendations surface backs the web UI.",
283
+ },
284
+
264
285
  "brain-context": {
265
286
  description: "Retrieve embedding-ranked tables/columns/FKs, training docs, and business rules for a question.",
266
287
  usage: 'deepsql brain-context "<question>" --connection <name> [options]',
@@ -581,6 +602,9 @@ function buildOpts(parsed) {
581
602
  label: f.label || null,
582
603
  // Connection / users / chat
583
604
  connection: f.connection || f.c || null,
605
+ // Brain notes scoping
606
+ table: f.table || null,
607
+ column: f.column || null,
584
608
  chat: f.chat || null,
585
609
  user: f.user || null,
586
610
  project: f.project || null,
package/src/cli.test.js CHANGED
@@ -75,7 +75,7 @@ test("every command in the catalog has a COMMAND_HELP entry", () => {
75
75
  const expected = [
76
76
  "agent",
77
77
  "login","logout","whoami","config","mcp","connections","query","analyze","schema",
78
- "digest","brain-context","business-rules","relationships","anti-patterns","indexes",
78
+ "digest","brain-context","brain","business-rules","relationships","anti-patterns","indexes",
79
79
  "users","access","permissions","slow-queries","setup",
80
80
  ];
81
81
  for (const name of expected) {
@@ -137,6 +137,7 @@ test("--no-color suppresses ANSI escapes in help output", async () => {
137
137
  // a `SUBCOMMANDS` object whose keys are the dispatchable subcommand names.
138
138
  const HELP_DRIFT_TARGETS = [
139
139
  { command: "slow-queries", modulePath: "./commands/slow-queries" },
140
+ { command: "brain", modulePath: "./commands/brain" },
140
141
  ];
141
142
 
142
143
  for (const { command, modulePath } of HELP_DRIFT_TARGETS) {
@@ -35,6 +35,7 @@ const LOGO = [
35
35
  const PURPLE_LIGHT = 141; // #af87ff
36
36
  const PURPLE = 99; // #875fff
37
37
  const PURPLE_DEEP = 98; // #875fd7
38
+ const AMBER = 178; // #d7af00 — "needs attention" marker, readable on light & dark
38
39
  const LOGO_ROWS = [PURPLE_LIGHT, PURPLE_LIGHT, PURPLE, PURPLE, PURPLE_DEEP];
39
40
 
40
41
  function c(n, s, on) {
@@ -75,7 +76,17 @@ function promptLabel(useColor) {
75
76
  return "\n" + c(PURPLE, "deepsql ›", useColor) + " ";
76
77
  }
77
78
 
78
- function renderIntro(stdout, { useColor = true, connectionLabel = null, versionLine = null } = {}) {
79
+ function renderIntro(
80
+ stdout,
81
+ {
82
+ useColor = true,
83
+ connectionLabel = null,
84
+ versionLine = null,
85
+ connections = [],
86
+ suggestions = [],
87
+ recommendationCount = 0,
88
+ } = {}
89
+ ) {
79
90
  const out = [""];
80
91
  LOGO.forEach((row, i) => out.push(" " + c(LOGO_ROWS[i], row, useColor)));
81
92
  out.push("");
@@ -88,6 +99,44 @@ function renderIntro(stdout, { useColor = true, connectionLabel = null, versionL
88
99
  const label = `${cat} prompt`;
89
100
  out.push(" " + c(PURPLE_LIGHT, label.padEnd(16), useColor) + `"${pick(PROMPTS[cat])}"`);
90
101
  }
102
+
103
+ // Connections the token can see (frictionless onboarding: guide to add one if none).
104
+ out.push("");
105
+ if (connections.length === 0) {
106
+ out.push(" " + c(PURPLE, "No databases connected yet", useColor));
107
+ out.push(" " + dim("→ ", useColor) + "deepsql connections add");
108
+ } else {
109
+ out.push(" " + c(PURPLE, `Connections (${connections.length})`, useColor));
110
+ const width = Math.min(24, Math.max(...connections.map((x) => x.name.length)));
111
+ for (const conn of connections.slice(0, 8)) {
112
+ out.push(" " + conn.name.padEnd(width + 2) + dim(conn.dbType, useColor));
113
+ }
114
+ if (connections.length > 8) out.push(" " + dim(`… +${connections.length - 8} more`, useColor));
115
+ }
116
+
117
+ // Admin "needs attention" — config suggestions for connections the user manages.
118
+ if (suggestions.length) {
119
+ out.push("");
120
+ out.push(" " + c(AMBER, "Needs attention", useColor));
121
+ for (const s of suggestions.slice(0, 6)) {
122
+ out.push(
123
+ " " + c(AMBER, "⚠ ", useColor) + s.conn + dim(" · ", useColor) + s.text
124
+ );
125
+ if (s.fix) out.push(" " + dim("→ ", useColor) + dim(s.fix, useColor));
126
+ }
127
+ if (suggestions.length > 6) out.push(" " + dim(`… +${suggestions.length - 6} more`, useColor));
128
+ }
129
+
130
+ // Brain learning loop: prompt admins to review what DeepSQL wants to remember.
131
+ if (recommendationCount > 0) {
132
+ out.push("");
133
+ out.push(
134
+ " " +
135
+ c(PURPLE, `${recommendationCount} brain recommendation${recommendationCount === 1 ? "" : "s"} to review`, useColor) +
136
+ dim(" → deepsql brain recs", useColor)
137
+ );
138
+ }
139
+
91
140
  out.push("");
92
141
  if (connectionLabel) {
93
142
  out.push(" " + dim("Grounded on ", useColor) + connectionLabel);
@@ -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
  );
@@ -16,6 +16,7 @@ const { request } = require("../api/client");
16
16
  const { resolveSession } = require("./_session");
17
17
  const { resolveConnectionId } = require("./_connections");
18
18
  const { renderIntro, getVersionLine, promptLabel } = require("./_agent_intro");
19
+ const { loadIntroData } = require("./_agent_status");
19
20
 
20
21
  // The brokered turn can run a full multi-tool agent loop server-side; allow well
21
22
  // past the backend's own per-turn ceiling so we don't abandon a valid answer.
@@ -113,8 +114,20 @@ async function run(opts, io = {}) {
113
114
  // Only surface a connection when one was explicitly named; a default/absent
114
115
  // connection shows nothing (the agent asks if a question needs one).
115
116
  const connectionLabel = opts.connection || null;
116
- const versionLine = await getVersionLine(useColor); // best-effort, tight timeout
117
- renderIntro(stdout, { useColor, connectionLabel, versionLine });
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
+ });
118
131
  }
119
132
  // conversationId stays null on the first turn so the server resumes your most
120
133
  // recent conversation for this connection (shared with the web Agent tab);
@@ -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 };