dsh-plugin-dbgraph-tool 0.0.0-stage → 0.1.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/lib/command.d.ts +86 -0
- package/lib/command.js +186 -0
- package/lib/index.d.ts +95 -0
- package/lib/index.js +586 -0
- package/lib/invariant.d.ts +16 -0
- package/lib/invariant.js +23 -0
- package/lib/prompt.d.ts +93 -0
- package/lib/prompt.js +337 -0
- package/lib/render.d.ts +34 -0
- package/lib/render.js +244 -0
- package/lib/schema.d.ts +782 -0
- package/lib/schema.js +315 -0
- package/package.json +78 -3
- package/README.md +0 -3
package/lib/render.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing text for each tool's canonical value.
|
|
3
|
+
*
|
|
4
|
+
* Two rules, both learned from what a model does with an answer: a capped list always says it is
|
|
5
|
+
* capped (a list that reads as complete is worse than a short one, because the model stops asking),
|
|
6
|
+
* and a row set is rendered as rows — never as JSON, which buries the same bytes in punctuation.
|
|
7
|
+
* @module dsh-plugin-dbgraph-tool/render
|
|
8
|
+
*/
|
|
9
|
+
import { assertNever } from 'dsh-plugin-dbgraph-service';
|
|
10
|
+
import { formatAge } from "./prompt.js";
|
|
11
|
+
/** Characters a single table cell may occupy before it is clipped. */
|
|
12
|
+
const MAX_CELL_CHARS = 80;
|
|
13
|
+
/** The `N shown of M` suffix a truncatable list carries. */
|
|
14
|
+
function counted(shown, total, truncated) {
|
|
15
|
+
return truncated ? ` (${shown} of ${total} shown)` : ` (${total})`;
|
|
16
|
+
}
|
|
17
|
+
/** A JSON-safe value as one table cell: quoted strings, `NULL`, clipped to {@link MAX_CELL_CHARS}. */
|
|
18
|
+
function cell(value) {
|
|
19
|
+
if (value === null || value === undefined)
|
|
20
|
+
return 'NULL';
|
|
21
|
+
// The canonical value was validated as lossless JSON before this ran, so `stringify` cannot
|
|
22
|
+
// return `undefined` here and no fallback branch is needed.
|
|
23
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
24
|
+
const flat = text.replaceAll('\r', ' ').replaceAll('\n', ' ');
|
|
25
|
+
const clipped = flat.length > MAX_CELL_CHARS ? `${flat.slice(0, MAX_CELL_CHARS - 1)}…` : flat;
|
|
26
|
+
return typeof value === 'string' ? `"${clipped}"` : clipped.replaceAll('|', '\\|');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A row set as an aligned pipe table, headed by the columns the caller actually received.
|
|
30
|
+
* @param headers - column names in result order.
|
|
31
|
+
* @param rows - the rows, values already JSON-safe.
|
|
32
|
+
* @param truncated - whether a row cap clipped the answer.
|
|
33
|
+
* @returns the rendered block, or a plain `(no rows)` note.
|
|
34
|
+
*/
|
|
35
|
+
function renderRows(headers, rows, truncated) {
|
|
36
|
+
if (rows.length === 0)
|
|
37
|
+
return '(no rows returned)';
|
|
38
|
+
const title = `${rows.length} row${rows.length === 1 ? '' : 's'}${truncated ? ' (truncated by the row cap)' : ''}:`;
|
|
39
|
+
const lines = [title, ` ${headers.join(' | ')}`];
|
|
40
|
+
for (const row of rows) {
|
|
41
|
+
const record = (typeof row === 'object' && row !== null ? row : {});
|
|
42
|
+
lines.push(` ${headers.map(header => cell(record[header])).join(' | ')}`);
|
|
43
|
+
}
|
|
44
|
+
return lines.join('\n');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* One connection's index state, phrased for a model deciding whether to reindex.
|
|
48
|
+
* @param index - the seam's index state.
|
|
49
|
+
* @returns a short clause, never empty.
|
|
50
|
+
*/
|
|
51
|
+
export function describeIndexState(index) {
|
|
52
|
+
const newest = index.l2_indexed_at ?? index.l1_indexed_at;
|
|
53
|
+
if (newest === undefined && index.objects === 0)
|
|
54
|
+
return 'not indexed';
|
|
55
|
+
const size = `${index.objects} 表 / ${index.columns} 列`;
|
|
56
|
+
if (newest === undefined)
|
|
57
|
+
return index.stale ? `${size}, 索引时间未记录` : size;
|
|
58
|
+
const age = formatAge(Date.now() - newest);
|
|
59
|
+
return index.stale ? `${size}, 索引 ${age}, 已过期` : `${size}, 索引 ${age}`;
|
|
60
|
+
}
|
|
61
|
+
/** Where a connection points, as one clause. */
|
|
62
|
+
function connectionTarget(connection) {
|
|
63
|
+
const parts = [];
|
|
64
|
+
if (connection.host !== undefined)
|
|
65
|
+
parts.push(connection.host);
|
|
66
|
+
if (connection.database !== undefined)
|
|
67
|
+
parts.push(connection.database);
|
|
68
|
+
return parts.length === 0 ? 'no target recorded' : parts.join(' / ');
|
|
69
|
+
}
|
|
70
|
+
/** One object line: name, comment, kind, size, row estimate. */
|
|
71
|
+
function objectLine(object) {
|
|
72
|
+
const comment = object.comment === undefined ? '' : ` ${object.comment}`;
|
|
73
|
+
const rows = object.row_estimate === undefined ? '' : ` 行数≈${object.row_estimate}`;
|
|
74
|
+
return `${object.object}${comment} [${object.kind}] ${object.column_count} 列${rows}`;
|
|
75
|
+
}
|
|
76
|
+
/** One edge line, direction-neutral: both `impact` and `refs` return the other end. */
|
|
77
|
+
function relationLine(entry) {
|
|
78
|
+
const column = entry.column === undefined ? '' : `.${entry.column}`;
|
|
79
|
+
const ref = entry.ref_column === undefined ? '' : ` → ${entry.ref_column}`;
|
|
80
|
+
const name = entry.name === undefined ? '' : ` [${entry.name}]`;
|
|
81
|
+
return `[${entry.kind}] ${entry.object}${column}${ref}${name}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Render one query-side result as the text the model reads.
|
|
85
|
+
* @param value - the canonical value the operation returned.
|
|
86
|
+
* @returns the rendered text.
|
|
87
|
+
*/
|
|
88
|
+
export function renderDbgraph(value) {
|
|
89
|
+
switch (value.operation) {
|
|
90
|
+
case 'connections': {
|
|
91
|
+
if (value.connections.length === 0) {
|
|
92
|
+
return 'No database connection is configured. Add one to connections.json under the dbgraph index root, then run dbgraph_index.';
|
|
93
|
+
}
|
|
94
|
+
const lines = [`Connections${counted(value.connections.length, value.connections.length, false)}:`];
|
|
95
|
+
for (const connection of value.connections) {
|
|
96
|
+
const label = connection.label === undefined ? '' : ` "${connection.label}"`;
|
|
97
|
+
// SQLite is a file, not a server: there is no account to authenticate with, so reporting a
|
|
98
|
+
// missing credential for it describes a requirement that does not exist.
|
|
99
|
+
const credential = connection.engine === 'sqlite'
|
|
100
|
+
? ''
|
|
101
|
+
: `, ${connection.credential_configured ? 'credential configured' : 'credential missing'}`;
|
|
102
|
+
lines.push(` ${connection.id}${label} (${connection.engine}) ${connectionTarget(connection)}`
|
|
103
|
+
+ ` — ${describeIndexState(connection.index)}${credential}`);
|
|
104
|
+
}
|
|
105
|
+
return lines.join('\n');
|
|
106
|
+
}
|
|
107
|
+
case 'schema': {
|
|
108
|
+
const engine = `${value.engine} / ${value.database}`;
|
|
109
|
+
if (value.objects.length === 0)
|
|
110
|
+
return `No object is indexed for ${value.connection} (${engine}). Run dbgraph_index to build the index.`;
|
|
111
|
+
const header = `Schema for ${value.connection} (${engine}): ${value.index.objects} 表 / ${value.index.columns} 列, `
|
|
112
|
+
+ `注释覆盖 ${Math.round(value.comment_coverage * 100)}%, ${describeIndexState(value.index)}`;
|
|
113
|
+
const lines = [header];
|
|
114
|
+
lines.push(...value.objects.map(object => ` ${objectLine(object)}`));
|
|
115
|
+
if (value.truncated)
|
|
116
|
+
lines.push(' (list truncated by the object cap)');
|
|
117
|
+
return lines.join('\n');
|
|
118
|
+
}
|
|
119
|
+
case 'objects': {
|
|
120
|
+
if (value.objects.length === 0)
|
|
121
|
+
return `No object in ${value.connection} matches.`;
|
|
122
|
+
const lines = [`Objects in ${value.connection}${counted(value.objects.length, value.objects.length, value.truncated)}:`];
|
|
123
|
+
lines.push(...value.objects.map(object => ` ${objectLine(object)}`));
|
|
124
|
+
return lines.join('\n');
|
|
125
|
+
}
|
|
126
|
+
case 'columns': {
|
|
127
|
+
const comment = value.comment === undefined ? '' : ` ${value.comment}`;
|
|
128
|
+
const header = `${value.object}${comment} [${value.kind}] ${value.columns.length} 列:`;
|
|
129
|
+
if (value.columns.length === 0)
|
|
130
|
+
return `${header} (no column is indexed)`;
|
|
131
|
+
const lines = [header];
|
|
132
|
+
for (const column of value.columns) {
|
|
133
|
+
const flags = [];
|
|
134
|
+
if (column.is_primary_key)
|
|
135
|
+
flags.push('PK');
|
|
136
|
+
if (!column.nullable)
|
|
137
|
+
flags.push('NOT NULL');
|
|
138
|
+
if (column.is_unique)
|
|
139
|
+
flags.push('UNIQUE');
|
|
140
|
+
const definition = column.default_value === undefined ? '' : ` = ${column.default_value}`;
|
|
141
|
+
const comment = column.comment === undefined ? '' : ` -- ${column.comment}`;
|
|
142
|
+
const sample = column.enum_values === undefined || column.enum_values.length === 0
|
|
143
|
+
? ''
|
|
144
|
+
: ` 例: ${column.enum_values.join('|')}`;
|
|
145
|
+
lines.push(` ${column.name} ${column.type}${definition}${flags.length === 0 ? '' : ` ${flags.join(' ')}`}${comment}${sample}`);
|
|
146
|
+
}
|
|
147
|
+
if (value.truncated)
|
|
148
|
+
lines.push(' (list truncated by the column cap)');
|
|
149
|
+
return lines.join('\n');
|
|
150
|
+
}
|
|
151
|
+
case 'find': {
|
|
152
|
+
const lines = [];
|
|
153
|
+
if (value.status === 'resolved') {
|
|
154
|
+
const target = value.resolved;
|
|
155
|
+
const name = target === undefined
|
|
156
|
+
? value.connection
|
|
157
|
+
: `${target.object}${target.comment === undefined ? '' : ` ${target.comment}`}`;
|
|
158
|
+
lines.push(`find → ${name} (confidence ${value.confidence.toFixed(2)}, ${value.elapsed_ms} ms)`);
|
|
159
|
+
if (target !== undefined && target.columns.length > 0) {
|
|
160
|
+
const matched = target.columns.map((column) => {
|
|
161
|
+
const comment = column.comment === undefined ? '' : ` ${column.comment}`;
|
|
162
|
+
return `${column.column}${comment} [${column.matched_by}]`;
|
|
163
|
+
});
|
|
164
|
+
lines.push(`Columns: ${matched.join(', ')}`);
|
|
165
|
+
}
|
|
166
|
+
if (value.sql !== undefined)
|
|
167
|
+
lines.push(`SQL: ${value.sql}`);
|
|
168
|
+
if (value.rows !== undefined) {
|
|
169
|
+
const headers = value.rows.length === 0 ? [] : Object.keys((value.rows[0] ?? {}));
|
|
170
|
+
lines.push(headers.length === 0 ? `(no rows) — ${value.row_count ?? 0} returned` : renderRows(headers, value.rows, value.truncated === true));
|
|
171
|
+
}
|
|
172
|
+
if (value.redacted !== undefined && value.redacted.length > 0) {
|
|
173
|
+
lines.push(`Masked: ${value.redacted.join(', ')} (matched the sensitive-column rule)`);
|
|
174
|
+
}
|
|
175
|
+
return lines.join('\n');
|
|
176
|
+
}
|
|
177
|
+
lines.push(`find did not execute: ${value.status} (${value.reason})`);
|
|
178
|
+
const candidates = value.candidates ?? (value.resolved === undefined ? [] : [value.resolved]);
|
|
179
|
+
if (candidates.length > 0) {
|
|
180
|
+
lines.push(`Candidates${counted(candidates.length, candidates.length, false)}:`);
|
|
181
|
+
for (const candidate of candidates) {
|
|
182
|
+
const comment = candidate.comment === undefined ? '' : ` ${candidate.comment}`;
|
|
183
|
+
const columns = candidate.columns.length === 0
|
|
184
|
+
? ''
|
|
185
|
+
: ` columns: ${candidate.columns.map(column => column.column).join(', ')}`;
|
|
186
|
+
lines.push(` ${candidate.object}${comment} (${candidate.score.toFixed(2)})${columns}`);
|
|
187
|
+
}
|
|
188
|
+
lines.push('Pick one with the object argument (and where for a filter), or ask the user which table they mean.');
|
|
189
|
+
}
|
|
190
|
+
if (value.status === 'missing_predicate') {
|
|
191
|
+
lines.push('Pass entity (a word for the table, e.g. 用户) or object (an exact table name), plus where when a filter is wanted.');
|
|
192
|
+
}
|
|
193
|
+
return lines.join('\n');
|
|
194
|
+
}
|
|
195
|
+
case 'sql': {
|
|
196
|
+
const lines = [`SQL on ${value.connection}: ${value.row_count} 行, ${value.elapsed_ms} ms`];
|
|
197
|
+
lines.push(renderRows(value.columns, value.rows, value.truncated));
|
|
198
|
+
if (value.affected_rows !== undefined)
|
|
199
|
+
lines.push(`Affected rows: ${value.affected_rows}`);
|
|
200
|
+
if (value.redacted.length > 0)
|
|
201
|
+
lines.push(`Masked: ${value.redacted.join(', ')} (matched the sensitive-column rule)`);
|
|
202
|
+
if (value.warnings !== undefined && value.warnings.length > 0) {
|
|
203
|
+
lines.push(...value.warnings.map(warning => `Warning: ${warning}`));
|
|
204
|
+
}
|
|
205
|
+
return lines.join('\n');
|
|
206
|
+
}
|
|
207
|
+
case 'impact':
|
|
208
|
+
case 'refs': {
|
|
209
|
+
const direction = value.operation === 'impact' ? 'A change to' : 'Objects referencing';
|
|
210
|
+
if (value.entries.length === 0)
|
|
211
|
+
return `${direction} ${value.object}: none recorded in the index.`;
|
|
212
|
+
const lines = [`${direction} ${value.object}${counted(value.entries.length, value.entries.length, value.truncated)}:`];
|
|
213
|
+
lines.push(...value.entries.map(entry => ` ${relationLine(entry)}`));
|
|
214
|
+
return lines.join('\n');
|
|
215
|
+
}
|
|
216
|
+
/* v8 ignore next -- exhaustive over the output schema's closed union; unreachable. */
|
|
217
|
+
default:
|
|
218
|
+
return assertNever(value, 'tool-dbgraph output');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Render one build-side result as the text the model reads.
|
|
223
|
+
* @param value - the canonical value the index tool returned.
|
|
224
|
+
* @returns the rendered text.
|
|
225
|
+
*/
|
|
226
|
+
export function renderDbgraphIndex(value) {
|
|
227
|
+
if (value.status === 'started') {
|
|
228
|
+
const target = value.connection ?? 'every configured connection';
|
|
229
|
+
return `Indexing ${target} (${value.mode}) in the background as job ${value.job_id}. The run outlives this call — `
|
|
230
|
+
+ `read its report with job_output ${value.job_id}, or stop it with job_kill.`;
|
|
231
|
+
}
|
|
232
|
+
if (value.reports.length === 0)
|
|
233
|
+
return 'No connection was configured to index.';
|
|
234
|
+
const lines = [];
|
|
235
|
+
for (const report of value.reports) {
|
|
236
|
+
lines.push(`Indexed ${report.connection} (${report.level}): ${report.objects} 对象 / ${report.columns} 列 / ${report.edges} 关系`
|
|
237
|
+
+ `, ${report.terms} 检索词, writer ${report.writer}, ${report.elapsed_ms} ms.`);
|
|
238
|
+
if (report.warnings !== undefined && report.warnings.length > 0) {
|
|
239
|
+
lines.push(...report.warnings.map(warning => `Warning: ${warning}`));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return lines.join('\n');
|
|
243
|
+
}
|
|
244
|
+
//# sourceMappingURL=render.js.map
|