@yejiming/dsh-data-agent 0.0.2 → 0.0.5

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/lib/tool.js CHANGED
@@ -1,7 +1,169 @@
1
- import { i as DEFAULT_MAX_RESULT_CHARS, l as classifyStatement, o as DEFAULT_QUERY_TIMEOUT_MS, u as clientsSchema } from "./defaults-Dgu2B2Yq.js";
2
- import { t as runClientQuery } from "./query-vK9dr7Z6.js";
1
+ import { d as clientsSchema, f as enforceReadRowLimit, i as DEFAULT_MAX_RESULT_CHARS, o as DEFAULT_QUERY_TIMEOUT_MS, u as classifyStatement, y as assertSingleStatement } from "./defaults-Bac6QvNt.js";
2
+ import { t as runClientQuery } from "./query-CmhTFklw.js";
3
3
  import z from "schemastery";
4
4
  import { defineTool } from "@deepseek-ai/dsh-tools";
5
+ //#region src/structured.ts
6
+ function normalizeNewlines(text) {
7
+ return text.replace(/\r\n?/g, "\n");
8
+ }
9
+ function splitLine(line, delimiter) {
10
+ return line.split(delimiter);
11
+ }
12
+ /** Make column names valid unique JSON object keys. */
13
+ function uniqueColumns(columns) {
14
+ const used = /* @__PURE__ */ new Set();
15
+ return columns.map((raw, index) => {
16
+ let name = raw.trim();
17
+ if (name.length === 0) name = `column_${index + 1}`;
18
+ if (used.has(name)) {
19
+ let suffix = 2;
20
+ while (used.has(`${name}_${suffix}`)) suffix += 1;
21
+ name = `${name}_${suffix}`;
22
+ }
23
+ used.add(name);
24
+ return name;
25
+ });
26
+ }
27
+ function rowObject(columns, fields) {
28
+ const row = {};
29
+ for (let index = 0; index < columns.length; index += 1) row[columns[index]] = fields[index] ?? null;
30
+ return row;
31
+ }
32
+ function emptyOutput() {
33
+ return {
34
+ columns: [],
35
+ rows: [],
36
+ rowLimitExceeded: false
37
+ };
38
+ }
39
+ function skipLeadingBlank(lines) {
40
+ let index = 0;
41
+ while (index < lines.length && lines[index].trim().length === 0) index += 1;
42
+ return index;
43
+ }
44
+ /** PostgreSQL `-A` appends a `(N rows)` / `(N row)` footer after SELECT output. */
45
+ function isPostgresFooter(line) {
46
+ return /^\(\d+ rows?\)$/.test(line.trim());
47
+ }
48
+ function parseDelimited(stdout, delimiter, maxRows, skipFooter = false) {
49
+ const lines = normalizeNewlines(stdout).split("\n");
50
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
51
+ const headerIndex = skipLeadingBlank(lines);
52
+ if (headerIndex >= lines.length) return emptyOutput();
53
+ const columns = uniqueColumns(splitLine(lines[headerIndex], delimiter));
54
+ const rows = [];
55
+ let rowLimitExceeded = false;
56
+ for (let index = headerIndex + 1; index < lines.length; index += 1) {
57
+ const line = lines[index];
58
+ if (skipFooter && isPostgresFooter(line)) continue;
59
+ if (rows.length >= maxRows) {
60
+ rowLimitExceeded = true;
61
+ break;
62
+ }
63
+ rows.push(rowObject(columns, splitLine(line, delimiter)));
64
+ }
65
+ return {
66
+ columns,
67
+ rows,
68
+ rowLimitExceeded
69
+ };
70
+ }
71
+ /** Minimal RFC-4180-style parser for sqlite3 `-csv` output. */
72
+ function parseCsv(text) {
73
+ const records = [];
74
+ let record = [];
75
+ let field = "";
76
+ let quoted = false;
77
+ let index = 0;
78
+ const pushField = () => {
79
+ record.push(field);
80
+ field = "";
81
+ };
82
+ const pushRecord = () => {
83
+ pushField();
84
+ records.push(record);
85
+ record = [];
86
+ };
87
+ while (index < text.length) {
88
+ const char = text[index];
89
+ if (quoted) {
90
+ if (char === "\"") {
91
+ if (text[index + 1] === "\"") {
92
+ field += "\"";
93
+ index += 2;
94
+ continue;
95
+ }
96
+ quoted = false;
97
+ index += 1;
98
+ continue;
99
+ }
100
+ field += char;
101
+ index += 1;
102
+ continue;
103
+ }
104
+ if (char === "\"" && field.length === 0) {
105
+ quoted = true;
106
+ index += 1;
107
+ continue;
108
+ }
109
+ if (char === ",") {
110
+ pushField();
111
+ index += 1;
112
+ continue;
113
+ }
114
+ if (char === "\n") {
115
+ pushRecord();
116
+ index += 1;
117
+ continue;
118
+ }
119
+ if (char === "\r") {
120
+ if (text[index + 1] === "\n") index += 1;
121
+ pushRecord();
122
+ index += 1;
123
+ continue;
124
+ }
125
+ field += char;
126
+ index += 1;
127
+ }
128
+ if (field.length > 0 || record.length > 0) pushRecord();
129
+ return records;
130
+ }
131
+ function parseCsvOutput(stdout, maxRows) {
132
+ const records = parseCsv(normalizeNewlines(stdout)).filter((record) => !(record.length === 1 && record[0] === ""));
133
+ if (records.length === 0) return emptyOutput();
134
+ const columns = uniqueColumns(records[0]);
135
+ const rows = [];
136
+ let rowLimitExceeded = false;
137
+ for (let index = 1; index < records.length; index += 1) {
138
+ if (rows.length >= maxRows) {
139
+ rowLimitExceeded = true;
140
+ break;
141
+ }
142
+ rows.push(rowObject(columns, records[index]));
143
+ }
144
+ return {
145
+ columns,
146
+ rows,
147
+ rowLimitExceeded
148
+ };
149
+ }
150
+ /**
151
+ * Parse one database type's structured-query stdout. The matching template is
152
+ * `buildStructuredQueryTemplate`: mysql tab-separated with a header, postgres
153
+ * pipe-separated with a header and row-count footer, sqlite CSV with a header,
154
+ * oracle pipe-separated with heading on, hive/impala tsv with a header.
155
+ */
156
+ function parseStructuredQueryOutput(type, stdout, maxRows) {
157
+ switch (type) {
158
+ case "mysql": return parseDelimited(stdout, " ", maxRows);
159
+ case "postgres": return parseDelimited(stdout, "|", maxRows, true);
160
+ case "sqlite": return parseCsvOutput(stdout, maxRows);
161
+ case "oracle": return parseDelimited(stdout, "|", maxRows);
162
+ case "hive":
163
+ case "impala": return parseDelimited(stdout, " ", maxRows);
164
+ }
165
+ }
166
+ //#endregion
5
167
  //#region src/tool.ts
6
168
  /** Cordis plugin name (diagnostics only). */
7
169
  const name = "data-agent-tool";
@@ -19,12 +181,12 @@ const Config = z.object({
19
181
  readonly: z.boolean().default(false),
20
182
  clients: clientsSchema
21
183
  });
22
- /** One-line sqlcmd label for the terminal card (newlines collapsed). */
184
+ /** One-line tool-call label (newlines collapsed). */
23
185
  function oneLine(sql) {
24
186
  const line = sql.replace(/\s+/g, " ").trim();
25
187
  return line.length > 80 ? `${line.slice(0, 77)}...` : line;
26
188
  }
27
- /** Format the canonical result as a monospace text block. */
189
+ /** Format the raw terminal result. */
28
190
  function formatResult(value) {
29
191
  const parts = [];
30
192
  if (value.stdout.length > 0) parts.push(value.stdout);
@@ -33,8 +195,35 @@ function formatResult(value) {
33
195
  if (value.exitCode !== 0) parts.push(`[exit code: ${value.exitCode ?? "signal"}]`);
34
196
  return parts.join("\n");
35
197
  }
198
+ /** Format the structured result as JSON text (the canonical value stays JSON). */
199
+ function formatStructuredResult(value) {
200
+ return "```json\n" + JSON.stringify(value, null, 2) + "\n```";
201
+ }
202
+ /** Look up the session connection, failing with the same message for every tool. */
203
+ function requireToolConnection(ctx, exec, toolName) {
204
+ const sessionId = exec.agent?.id;
205
+ if (sessionId === void 0) throw new Error(`${toolName}: 缺少会话上下文(agent loop 未注入)`);
206
+ const connection = ctx.dataAgentConnections.getWithSecret(sessionId);
207
+ if (connection === void 0) throw new Error(`请先在「数据库」标签页连接数据库,再使用 ${toolName}(未找到当前会话的连接)`);
208
+ return connection;
209
+ }
210
+ /** Empty and multi-statement checks shared by all three tools. */
211
+ function validateSingleSql(sql, toolName) {
212
+ if (sql.trim().length === 0) throw new Error(`${toolName}: sql 不能为空`);
213
+ assertSingleStatement(sql, toolName);
214
+ }
215
+ /** Query runner options with the deployment overrides applied. */
216
+ function runnerOptions(resolved, mode) {
217
+ return {
218
+ clients: resolved.clients,
219
+ timeoutMs: resolved.queryTimeoutMs,
220
+ maxResultChars: resolved.maxResultChars,
221
+ ...mode !== void 0 ? { mode } : {}
222
+ };
223
+ }
36
224
  /**
37
- * Mount the sqlcmd tool: register it into the current agent's tool registry.
225
+ * Mount the data-agent database tools: `sql-query` (structured read-only),
226
+ * `sql-write` (explicit write semantics), and `sqlcmd` (raw compatibility).
38
227
  * @param ctx - the preset-scoped agent context.
39
228
  * @param config - validated loader configuration.
40
229
  */
@@ -46,13 +235,146 @@ function apply(ctx, config) {
46
235
  readonly: config.readonly,
47
236
  clients: config.clients
48
237
  };
238
+ ctx.tools.register(defineTool({
239
+ name: "sql-query",
240
+ description: `在已连接数据库上执行一条只读 SQL(SELECT/SHOW/DESCRIBE/EXPLAIN,SQLite 还含查询型 PRAGMA),返回结构化 JSON:{ columns, rows, affectedRows, elapsedMs, truncated }。SELECT 未写 LIMIT 时会自动限制为最多 ${resolved.maxRows} 行;所有结果最多返回 ${resolved.maxRows} 行。只执行单条语句;写操作请使用 sql-write,原始客户端输出请使用 sqlcmd。`,
241
+ parameters: { sql: {
242
+ type: "string",
243
+ required: true,
244
+ description: "一条只读 SQL,如 \"SELECT * FROM orders LIMIT 5;\"、\"SHOW TABLES;\"、\"DESCRIBE users;\""
245
+ } },
246
+ output: {
247
+ schema: {
248
+ type: "object",
249
+ properties: {
250
+ columns: {
251
+ type: "array",
252
+ items: { type: "string" },
253
+ required: true
254
+ },
255
+ rows: {
256
+ type: "array",
257
+ items: {
258
+ type: "object",
259
+ properties: {},
260
+ additionalProperties: true
261
+ },
262
+ required: true
263
+ },
264
+ affectedRows: {
265
+ type: "integer",
266
+ required: true
267
+ },
268
+ elapsedMs: {
269
+ type: "integer",
270
+ required: true
271
+ },
272
+ truncated: {
273
+ type: "boolean",
274
+ required: true
275
+ }
276
+ },
277
+ additionalProperties: false
278
+ },
279
+ render: (_args, value) => [{
280
+ type: "text",
281
+ text: formatStructuredResult(value)
282
+ }]
283
+ },
284
+ presentCall: (args) => ({
285
+ card: "generic",
286
+ kind: "read",
287
+ title: `sql-query ${oneLine(args.sql)}`,
288
+ rawInput: args.sql
289
+ }),
290
+ presentResult: (args, result) => ({
291
+ card: "generic",
292
+ title: `sql-query ${oneLine(args.sql)}`,
293
+ content: result.content
294
+ }),
295
+ async execute(args, exec) {
296
+ const connection = requireToolConnection(ctx, exec, "sql-query");
297
+ validateSingleSql(args.sql, "sql-query");
298
+ if (classifyStatement(args.sql, connection.type) !== "read") throw new Error("sql-query 只执行读语句(SELECT/SHOW/DESCRIBE/EXPLAIN,SQLite 还含查询型 PRAGMA);写语句请使用 sql-write");
299
+ const limitedSql = enforceReadRowLimit(args.sql, connection.type, resolved.maxRows);
300
+ const startedAt = Date.now();
301
+ const result = await runClientQuery(ctx, connection, limitedSql, runnerOptions(resolved, "structured"), exec.signal);
302
+ const elapsedMs = Date.now() - startedAt;
303
+ if (result.exitCode !== 0) {
304
+ const detail = result.stderr.trim() !== "" ? result.stderr.trim() : result.stdout.trim();
305
+ throw new Error(`sql-query 执行失败(exit ${result.exitCode}):${detail}`);
306
+ }
307
+ const parsed = parseStructuredQueryOutput(connection.type, result.stdout, resolved.maxRows);
308
+ return {
309
+ columns: parsed.columns,
310
+ rows: parsed.rows,
311
+ affectedRows: 0,
312
+ elapsedMs,
313
+ truncated: result.truncated || parsed.rowLimitExceeded
314
+ };
315
+ }
316
+ }));
317
+ ctx.tools.register(defineTool({
318
+ name: "sql-write",
319
+ description: "在已连接数据库上执行一条写/管理语句(INSERT/UPDATE/DELETE/DDL 等)。每次调用都是独立客户端进程并自动提交,只接受单条语句,不支持跨调用的多语句事务;如需原子性,请改用单条 SQL(如 INSERT ... SELECT)或数据库端脚本/存储过程。只读查询请使用 sql-query。",
320
+ parameters: { sql: {
321
+ type: "string",
322
+ required: true,
323
+ description: "一条写/管理 SQL,如 \"INSERT INTO t VALUES (1);\"、\"UPDATE t SET x=1;\"、\"CREATE INDEX idx_t_x ON t(x);\""
324
+ } },
325
+ output: {
326
+ schema: {
327
+ type: "object",
328
+ properties: {
329
+ exitCode: {
330
+ oneOf: [{ type: "integer" }, { type: "null" }],
331
+ required: true
332
+ },
333
+ stdout: {
334
+ type: "string",
335
+ required: true
336
+ },
337
+ stderr: {
338
+ type: "string",
339
+ required: true
340
+ },
341
+ truncated: {
342
+ type: "boolean",
343
+ required: true
344
+ }
345
+ },
346
+ additionalProperties: false
347
+ },
348
+ render: (_args, value) => [{
349
+ type: "text",
350
+ text: formatResult(value)
351
+ }]
352
+ },
353
+ presentCall: (args) => ({
354
+ card: "terminal",
355
+ title: `sql-write ${oneLine(args.sql)}`,
356
+ description: "执行一条写/管理 SQL(自动提交)"
357
+ }),
358
+ presentResult: (args, result) => ({
359
+ card: "terminal",
360
+ title: `sql-write ${oneLine(args.sql)}`,
361
+ content: result.content
362
+ }),
363
+ async execute(args, exec) {
364
+ const connection = requireToolConnection(ctx, exec, "sql-write");
365
+ validateSingleSql(args.sql, "sql-write");
366
+ if (classifyStatement(args.sql, connection.type) === "read") throw new Error("sql-write 只执行写/管理语句;只读查询请使用 sql-query");
367
+ if (connection.readonly ?? resolved.readonly) throw new Error("当前连接为只读模式,sql-write 拒绝执行写/管理语句(仅放行 SELECT/SHOW/DESCRIBE/EXPLAIN/查询型 PRAGMA 等)");
368
+ return runClientQuery(ctx, connection, args.sql, runnerOptions(resolved), exec.signal);
369
+ }
370
+ }));
49
371
  ctx.tools.register(defineTool({
50
372
  name: "sqlcmd",
51
- description: `在已连接的数据库上执行 SQL 或客户端命令(如 SHOW TABLES、DESCRIBE users、SELECT * FROM orders LIMIT ${resolved.maxRows})。需要先在「数据库」标签页连接数据库;SQL 经 stdin 传给客户端(mysql/psql/sqlite3),无 shell 层。结果包含 exitCode 与 stdout/stderr 文本。`,
373
+ description: `在已连接数据库上执行一条 SQL 或客户端命令(如 SHOW TABLES、DESCRIBE users),返回原始 exitCode/stdout/stderr 文本。新调用优先使用 sql-query(结构化只读结果)和 sql-write(明确写语义)。一次只执行一条语句;读 SELECT 会自动限制最多 ${resolved.maxRows} 行;每次调用为独立客户端进程并自动提交。`,
52
374
  parameters: { sql: {
53
375
  type: "string",
54
376
  required: true,
55
- description: "要执行的 SQL 文本(或客户端命令),如 \"SHOW TABLES;\"、\"DESCRIBE users;\"、\"SELECT * FROM orders LIMIT 5;\""
377
+ description: "一条 SQL 文本(或客户端命令),如 \"SHOW TABLES;\"、\"DESCRIBE users;\"、\"SELECT * FROM orders LIMIT 5;\""
56
378
  } },
57
379
  output: {
58
380
  schema: {
@@ -85,7 +407,7 @@ function apply(ctx, config) {
85
407
  presentCall: (args) => ({
86
408
  card: "terminal",
87
409
  title: `sqlcmd ${oneLine(args.sql)}`,
88
- description: "在数据库客户端执行 SQL"
410
+ description: "在数据库客户端执行一条 SQL"
89
411
  }),
90
412
  presentResult: (args, result) => ({
91
413
  card: "terminal",
@@ -93,16 +415,11 @@ function apply(ctx, config) {
93
415
  content: result.content
94
416
  }),
95
417
  async execute(args, exec) {
96
- const sessionId = exec.agent?.id;
97
- if (sessionId === void 0) throw new Error("sqlcmd: 缺少会话上下文(agent loop 未注入)");
98
- const connection = ctx.dataAgentConnections.getWithSecret(sessionId);
99
- if (connection === void 0) throw new Error("请先在「数据库」标签页连接数据库,再使用 sqlcmd(未找到当前会话的连接)");
100
- if ((connection.readonly ?? resolved.readonly) && classifyStatement(args.sql, connection.type) === "write") throw new Error("当前连接为只读模式,sqlcmd 拒绝执行非读语句(仅放行 SELECT/SHOW/DESCRIBE/EXPLAIN/PRAGMA 等)");
101
- return runClientQuery(ctx, connection, args.sql, {
102
- clients: resolved.clients,
103
- timeoutMs: resolved.queryTimeoutMs,
104
- maxResultChars: resolved.maxResultChars
105
- }, exec.signal);
418
+ const connection = requireToolConnection(ctx, exec, "sqlcmd");
419
+ validateSingleSql(args.sql, "sqlcmd");
420
+ if ((connection.readonly ?? resolved.readonly) && classifyStatement(args.sql, connection.type) === "write") throw new Error("当前连接为只读模式,sqlcmd 拒绝执行非读语句(仅放行 SELECT/SHOW/DESCRIBE/EXPLAIN/查询型 PRAGMA 等)");
421
+ const sql = classifyStatement(args.sql, connection.type) === "read" ? enforceReadRowLimit(args.sql, connection.type, resolved.maxRows) : args.sql;
422
+ return runClientQuery(ctx, connection, sql, runnerOptions(resolved), exec.signal);
106
423
  }
107
424
  }));
108
425
  }
@@ -4,6 +4,8 @@ export declare const NS = "data-agent";
4
4
  /** Simplified Chinese dictionary (the key-set source of truth). */
5
5
  export declare const zh: {
6
6
  'form.title': string;
7
+ 'form.hero.title': string;
8
+ 'form.hero.hint': string;
7
9
  'form.type': string;
8
10
  'type.mysql': string;
9
11
  'type.postgres': string;
@@ -47,7 +49,6 @@ export declare const zh: {
47
49
  'action.config': string;
48
50
  'action.browse': string;
49
51
  'action.close': string;
50
- 'action.collapse': string;
51
52
  'error.title': string;
52
53
  };
53
54
  /** The data-agent namespace key union. */
@@ -55,6 +56,8 @@ export type DataAgentKey = keyof typeof zh;
55
56
  /** English dictionary, checked complete against the zh key set. */
56
57
  export declare const en: {
57
58
  'form.title': string;
59
+ 'form.hero.title': string;
60
+ 'form.hero.hint': string;
58
61
  'form.type': string;
59
62
  'type.mysql': string;
60
63
  'type.postgres': string;
@@ -98,6 +101,5 @@ export declare const en: {
98
101
  'action.config': string;
99
102
  'action.browse': string;
100
103
  'action.close': string;
101
- 'action.collapse': string;
102
104
  'error.title': string;
103
105
  };
@@ -12,12 +12,26 @@
12
12
  * @module @yejiming/dsh-data-agent/clients
13
13
  */
14
14
  import type { DatabaseConnection, DatabaseType } from './connections.ts';
15
+ import { assertSingleStatement, hasTopLevelKeyword, stripTrailingTerminator } from './sql.ts';
16
+ export { assertSingleStatement, hasTopLevelKeyword, stripTrailingTerminator };
15
17
  /**
16
18
  * Classify a SQL text as a read or write statement by its FIRST effective
17
19
  * token (a conservative read whitelist, not a parser). `with` is read only
18
- * when its body's first token is `select`. `pragma` is read-only for SQLite.
20
+ * when its body's first token is `select`. SQLite `pragma` is read in its
21
+ * query form and write when a value is assigned.
19
22
  */
20
23
  export declare function classifyStatement(sql: string, type: DatabaseType): 'read' | 'write';
24
+ /**
25
+ * Enforce the configured `maxRows` on a read query instead of relying on the
26
+ * prompt. SELECT/CTE-read statements get a real top-level LIMIT (Oracle uses
27
+ * a ROWNUM wrapper because it has no LIMIT); SHOW/DESCRIBE/EXPLAIN/PRAGMA are
28
+ * left untouched here and are capped while parsing structured output.
29
+ *
30
+ * An existing numeric top-level LIMIT is rewritten when it is larger than
31
+ * `maxRows`; a smaller existing LIMIT is preserved, and a non-numeric or
32
+ * unparseable LIMIT is left for the client (structured tools still truncate).
33
+ */
34
+ export declare function enforceReadRowLimit(sql: string, type: DatabaseType, maxRows: number): string;
21
35
  /**
22
36
  * Validate and quote one schema/table identifier for a safe metadata query.
23
37
  * Identifiers are restricted to `[A-Za-z0-9_$]+` and then wrapped per type:
@@ -73,6 +87,12 @@ export interface ClientTemplate {
73
87
  export declare function buildClientTemplate(type: DatabaseType, connection: DatabaseConnection, override?: ClientConfig): ClientTemplate;
74
88
  /** Build one client invocation for metadata runs (machine-readable flags). */
75
89
  export declare function buildIntrospectTemplate(type: DatabaseType, connection: DatabaseConnection, override?: ClientConfig): ClientTemplate;
90
+ /**
91
+ * Build one client invocation for the structured `sql-query` tool: every
92
+ * supported client prints a header row followed by one row per line (mysql
93
+ * tab, postgres pipe, sqlite csv, oracle pipe, hive/impala tsv).
94
+ */
95
+ export declare function buildStructuredQueryTemplate(type: DatabaseType, connection: DatabaseConnection, override?: ClientConfig): ClientTemplate;
76
96
  /**
77
97
  * The table-listing SQL per type, run at /connect time to verify
78
98
  * connectivity: the connected database's own tables (mysql uses the
@@ -82,7 +102,7 @@ export declare function buildIntrospectTemplate(type: DatabaseType, connection:
82
102
  export declare function tableListingSql(type: DatabaseType, connection?: DatabaseConnection): string;
83
103
  /**
84
104
  * Metadata query per kind × type. `schema`/`table` are identifier whitelist
85
- * validated by the caller (`[A-Za-z0-9_$#.-]`) before they reach here.
105
+ * validated by the caller (`[A-Za-z0-9_$]`) before they reach here.
86
106
  */
87
107
  export declare function metadataQuery(kind: 'schemas' | 'tables' | 'describe', type: DatabaseType, schema?: string, table?: string): string;
88
108
  /**
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Package-wide defaults shared by the server half (`src/index.ts`) and the
3
- * sqlcmd tool half (`src/tool.ts`). Loader schemas carry these as their
3
+ * database tool half (`src/tool.ts`). Loader schemas carry these as their
4
4
  * defaults so a deployment may override every one of them in cordis.yml.
5
5
  * @module @yejiming/dsh-data-agent/defaults
6
6
  */
@@ -10,9 +10,9 @@ export declare const DEFAULT_PRESET_ID = "data-agent";
10
10
  export declare const DEFAULT_CONNECT_TIMEOUT_MS = 10000;
11
11
  /** Cap on the table list returned by `/connect` and `/status`. */
12
12
  export declare const DEFAULT_INTROSPECT_MAX_TABLES = 500;
13
- /** End-to-end deadline for one sqlcmd query, milliseconds. */
13
+ /** End-to-end deadline for one database-tool query, milliseconds. */
14
14
  export declare const DEFAULT_QUERY_TIMEOUT_MS = 30000;
15
- /** In-memory cap on sqlcmd captured output (stdout and stderr each). */
15
+ /** In-memory cap on database-tool captured output (stdout and stderr each). */
16
16
  export declare const DEFAULT_MAX_RESULT_CHARS = 20000;
17
17
  /** Cap on one /query SQL text length (abuse guard; the wire body stays small). */
18
18
  export declare const DEFAULT_MAX_QUERY_CHARS = 65536;
@@ -8,9 +8,9 @@
8
8
  *
9
9
  * The HTTP routes live in the separate `./routes` entry
10
10
  * (`@yejiming/dsh-data-agent/routes`, cordis row `data-agent-routes`) so
11
- * this row keeps working in headless profiles without a webserver; the sqlcmd
12
- * tool itself lives in the `./tool` entry and is mounted only by the
13
- * data-agent preset.
11
+ * this row keeps working in headless profiles without a webserver; the
12
+ * database tools themselves live in the `./tool` entry and are mounted only
13
+ * by the data-agent preset.
14
14
  * @module @yejiming/dsh-data-agent
15
15
  */
16
16
  import type { Context } from '@deepseek-ai/cordis';
@@ -57,11 +57,11 @@ export interface Config {
57
57
  connectTimeoutMs: number;
58
58
  /** Cap on the table list returned by /connect and /status. */
59
59
  introspectMaxTables: number;
60
- /** Deadline for one sqlcmd query, milliseconds. */
60
+ /** Deadline for one database-tool query, milliseconds. */
61
61
  queryTimeoutMs: number;
62
- /** In-memory cap on sqlcmd captured output. */
62
+ /** In-memory cap on database-tool captured output. */
63
63
  maxResultChars: number;
64
- /** Default read-only guard: true rejects write statements in sqlcmd//query. */
64
+ /** Default read-only guard: true rejects write statements in database tools and /query. */
65
65
  readonly: boolean;
66
66
  /** CLI client overrides keyed by database type. */
67
67
  clients: ClientsConfig;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The shared client-process runner used by both halves: the /connect
3
- * connectivity check (server half) and the sqlcmd tool (tool half). All
3
+ * connectivity check (server half) and the database tools (tool half). All
4
4
  * execution goes through `ctx.subprocess` — no shell layer, argv arrays only,
5
5
  * SQL on stdin, credentials in env entries — with a caller-owned timeout
6
6
  * (AbortController → process-tree terminate escalation) and bounded captured
@@ -15,7 +15,7 @@ export interface CapturedOutput {
15
15
  text: string;
16
16
  truncated: boolean;
17
17
  }
18
- /** The canonical sqlcmd / connectivity-check result. */
18
+ /** The canonical database-tool / connectivity-check result. */
19
19
  export interface QueryResult {
20
20
  /** Process exit code; null when the process died from a signal. */
21
21
  exitCode: number | null;
@@ -26,6 +26,8 @@ export interface QueryResult {
26
26
  /** True when either stream hit the maxResultChars cap. */
27
27
  truncated: boolean;
28
28
  }
29
+ /** Which CLI flag set to use for one run. */
30
+ export type QueryTemplateMode = 'query' | 'introspect' | 'structured';
29
31
  /** Runner options: client overrides, deadlines, output caps. */
30
32
  export interface QueryOptions {
31
33
  /** Deployment client overrides keyed by database type. */
@@ -36,6 +38,8 @@ export interface QueryOptions {
36
38
  maxResultChars: number;
37
39
  /** Grace period for the terminate escalation; defaults to 5s. */
38
40
  graceMs?: number;
41
+ /** CLI flag set; overrides the legacy `introspect` parameter when set. */
42
+ mode?: QueryTemplateMode;
39
43
  }
40
44
  /**
41
45
  * Run one SQL text through the type's CLI client. The SQL is written to the
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Lightweight SQL-text scanning helpers shared by the sqlcmd tool half and
3
+ * the /query route. This is intentionally NOT a SQL parser: the scanner only
4
+ * understands lexical boundaries (strings, quoted identifiers, comments and
5
+ * parenthesis depth) well enough to make the two agent-loop guarantees from
6
+ * docs/optimization-opportunities.md:
7
+ *
8
+ * - a single tool call carries at most ONE SQL statement;
9
+ * - `maxRows` can be enforced with a real top-level LIMIT, not just a prompt.
10
+ *
11
+ * @module @yejiming/dsh-data-agent/sql
12
+ */
13
+ /**
14
+ * Throw unless `sql` contains at most one statement. A single trailing
15
+ * semicolon (and any number of repeated trailing semicolons / comments) is
16
+ * accepted; a semicolon followed by real content is rejected.
17
+ */
18
+ export declare function assertSingleStatement(sql: string, label?: string): void;
19
+ /** Whether `keyword` appears at top level as a whole word in `sql`. */
20
+ export declare function hasTopLevelKeyword(sql: string, keyword: string): boolean;
21
+ /**
22
+ * Strip trailing whitespace, statement terminators and trailing comments so a
23
+ * limit clause can be appended to the actual statement text. Only comments
24
+ * that occupy the whole tail are removed; the preceding statement is kept.
25
+ */
26
+ export declare function stripTrailingTerminator(sql: string): string;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Structured result parsing for the `sql-query` tool half. Each supported
3
+ * database type has a machine-readable query template (see
4
+ * {@link buildStructuredQueryTemplate} in `src/clients.ts`); this module turns
5
+ * that captured stdout into the canonical `{ columns, rows }` shape and
6
+ * enforces the row cap a second time (first line of defense is the SQL-level
7
+ * LIMIT injection, second is truncation while parsing).
8
+ *
9
+ * The parsers are deliberately output-shape based, not grammar based. Values
10
+ * stay as strings because every client renders SQL values as text (NULL
11
+ * rendering differs per client), and duplicate column names are made unique
12
+ * so the row objects are valid lossless JSON maps.
13
+ * @module @yejiming/dsh-data-agent/structured
14
+ */
15
+ import type { DatabaseType } from './connections.ts';
16
+ /** Canonical parsed query output before elapsed/affected metadata is added. */
17
+ export interface ParsedQueryOutput {
18
+ columns: string[];
19
+ rows: Record<string, string | null>[];
20
+ /** True when the output contained more rows than `maxRows` and was capped. */
21
+ rowLimitExceeded: boolean;
22
+ }
23
+ /**
24
+ * Parse one database type's structured-query stdout. The matching template is
25
+ * `buildStructuredQueryTemplate`: mysql tab-separated with a header, postgres
26
+ * pipe-separated with a header and row-count footer, sqlite CSV with a header,
27
+ * oracle pipe-separated with heading on, hive/impala tsv with a header.
28
+ */
29
+ export declare function parseStructuredQueryOutput(type: DatabaseType, stdout: string, maxRows: number): ParsedQueryOutput;
@@ -1,10 +1,15 @@
1
1
  /**
2
- * The sqlcmd tool half (`@yejiming/dsh-data-agent/tool`): mounted ONLY by
2
+ * The data-agent tool half (`@yejiming/dsh-data-agent/tool`): mounted ONLY by
3
3
  * the data-agent agent preset (`preset/data-agent/agent.cordis.yml`), never
4
4
  * by the host composition. It consumes the host's `subprocess` service and
5
5
  * the host-provided `dataAgentConnections` connection store, so it needs no
6
6
  * realm and satisfies the preset guard (a preset row that only consumes).
7
7
  *
8
+ * Tool surface:
9
+ * - `sql-query`: read-only statements, structured `{ columns, rows, ... }`;
10
+ * - `sql-write`: one write/management statement per call, explicit autocommit;
11
+ * - `sqlcmd`: the original raw-terminal tool (kept for compatibility).
12
+ *
8
13
  * Execution model (see `src/query.ts`): the SQL text travels on the client's
9
14
  * stdin, argv carries flags only, credentials go through environment entries
10
15
  * (`MYSQL_PWD` / `PGPASSWORD`), and the caller's signal plus an internal
@@ -20,11 +25,11 @@ export declare const name = "data-agent-tool";
20
25
  export declare const inject: string[];
21
26
  /** Tool-half configuration (loader schema with the same defaults as the host). */
22
27
  export interface Config {
23
- /** Deadline for one sqlcmd query, milliseconds. */
28
+ /** Deadline for one sqlcmd / sql-query / sql-write query, milliseconds. */
24
29
  queryTimeoutMs: number;
25
30
  /** In-memory cap on captured output. */
26
31
  maxResultChars: number;
27
- /** Row-count guidance injected into the tool description. */
32
+ /** Enforced read-query row cap (LIMIT injection + structured truncation). */
28
33
  maxRows: number;
29
34
  /** Read-only guard: true rejects write statements. */
30
35
  readonly: boolean;
@@ -58,7 +63,8 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
58
63
  }>, string>>;
59
64
  }>>;
60
65
  /**
61
- * Mount the sqlcmd tool: register it into the current agent's tool registry.
66
+ * Mount the data-agent database tools: `sql-query` (structured read-only),
67
+ * `sql-write` (explicit write semantics), and `sqlcmd` (raw compatibility).
62
68
  * @param ctx - the preset-scoped agent context.
63
69
  * @param config - validated loader configuration.
64
70
  */