@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 +7 -2
- package/README.md +5 -2
- package/deepsql-phase1-lib.js +138 -1
- package/package.json +1 -1
- package/skills/SKILL_BODY.md +6 -2
- package/src/cli.js +25 -1
- package/src/cli.test.js +2 -1
- package/src/commands/_agent_intro.js +50 -1
- package/src/commands/_agent_status.js +93 -0
- package/src/commands/_connections.js +41 -17
- package/src/commands/agent.js +15 -2
- package/src/commands/brain.js +144 -0
- package/src/commands/connections.js +8 -2
- package/src/connections/cache.js +52 -0
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 **
|
|
37
|
-
0.18.x's 5 slow-query tools + 0.19.0's 20 CLI-parity additions.
|
|
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
|
-
**
|
|
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 (
|
|
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
|
|
package/deepsql-phase1-lib.js
CHANGED
|
@@ -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
|
|
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
package/skills/SKILL_BODY.md
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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]",
|
|
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(
|
|
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
|
|
24
|
+
let inProcess = null; // L1: within a single command invocation
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
const names =
|
|
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
|
);
|
package/src/commands/agent.js
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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 };
|