@oh-my-pi/pi-coding-agent 14.0.5 → 14.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/CHANGELOG.md +41 -0
- package/package.json +7 -7
- package/src/async/index.ts +1 -0
- package/src/async/support.ts +5 -0
- package/src/cli/list-models.ts +96 -57
- package/src/commit/model-selection.ts +16 -13
- package/src/config/model-equivalence.ts +674 -0
- package/src/config/model-registry.ts +179 -11
- package/src/config/model-resolver.ts +171 -50
- package/src/config/settings-schema.ts +23 -0
- package/src/export/html/template.css +82 -0
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +612 -97
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/internal-urls/jobs-protocol.ts +2 -1
- package/src/lsp/client.ts +1 -1
- package/src/main.ts +6 -1
- package/src/memories/index.ts +7 -6
- package/src/modes/components/model-selector.ts +221 -64
- package/src/modes/controllers/command-controller.ts +18 -0
- package/src/modes/controllers/selector-controller.ts +13 -5
- package/src/prompts/system/system-prompt.md +5 -1
- package/src/prompts/tools/bash.md +15 -0
- package/src/prompts/tools/cancel-job.md +1 -1
- package/src/prompts/tools/read-chunk.md +9 -0
- package/src/prompts/tools/read.md +9 -0
- package/src/prompts/tools/write.md +1 -0
- package/src/sdk.ts +7 -4
- package/src/session/agent-session.ts +23 -6
- package/src/task/executor.ts +5 -1
- package/src/tools/await-tool.ts +2 -1
- package/src/tools/bash.ts +221 -56
- package/src/tools/cancel-job.ts +2 -1
- package/src/tools/inspect-image.ts +1 -1
- package/src/tools/read.ts +218 -1
- package/src/tools/sqlite-reader.ts +623 -0
- package/src/tools/write.ts +187 -1
- package/src/utils/commit-message-generator.ts +1 -0
- package/src/utils/git.ts +24 -1
- package/src/utils/title-generator.ts +1 -1
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import type { Database, SQLQueryBindings } from "bun:sqlite";
|
|
2
|
+
import { formatBytes, replaceTabs, truncateToWidth } from "./render-utils";
|
|
3
|
+
import { ToolError } from "./tool-errors";
|
|
4
|
+
|
|
5
|
+
const SQLITE_MAGIC = new Uint8Array([
|
|
6
|
+
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00,
|
|
7
|
+
]);
|
|
8
|
+
const SQLITE_PATH_PATTERN = /\.(?:sqlite3?|db3?)(?=(?::|\?|$))/gi;
|
|
9
|
+
const DEFAULT_QUERY_LIMIT = 20;
|
|
10
|
+
const DEFAULT_SCHEMA_SAMPLE_LIMIT = 5;
|
|
11
|
+
const MAX_QUERY_LIMIT = 500;
|
|
12
|
+
const MAX_RENDER_WIDTH = 120;
|
|
13
|
+
const MAX_COLUMN_WIDTH = 40;
|
|
14
|
+
const MIN_COLUMN_WIDTH = 1;
|
|
15
|
+
|
|
16
|
+
type SqliteBinding = Exclude<SQLQueryBindings, Record<string, unknown>>;
|
|
17
|
+
|
|
18
|
+
type SqliteRow = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
interface SqliteMasterRow {
|
|
21
|
+
name: string;
|
|
22
|
+
sql: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SqliteCountRow {
|
|
26
|
+
count: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SqliteTableInfoRow {
|
|
30
|
+
cid: number;
|
|
31
|
+
name: string;
|
|
32
|
+
type: string;
|
|
33
|
+
notnull: number;
|
|
34
|
+
dflt_value: unknown;
|
|
35
|
+
pk: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SqlitePathCandidate {
|
|
39
|
+
sqlitePath: string;
|
|
40
|
+
subPath: string;
|
|
41
|
+
queryString: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type SqliteSelector =
|
|
45
|
+
| { kind: "list" }
|
|
46
|
+
| { kind: "schema"; table: string; sampleLimit: number }
|
|
47
|
+
| { kind: "row"; table: string; key: string }
|
|
48
|
+
| { kind: "query"; table: string; limit: number; offset: number; order?: string; where?: string }
|
|
49
|
+
| { kind: "raw"; sql: string };
|
|
50
|
+
|
|
51
|
+
export type SqliteRowLookup = { kind: "pk"; column: string; type: string } | { kind: "rowid" };
|
|
52
|
+
|
|
53
|
+
function splitSqliteRemainder(remainder: string): { subPath: string; queryString: string } {
|
|
54
|
+
const queryIndex = remainder.indexOf("?");
|
|
55
|
+
if (queryIndex === -1) {
|
|
56
|
+
return {
|
|
57
|
+
subPath: remainder.replace(/^:+/, ""),
|
|
58
|
+
queryString: "",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
subPath: remainder.slice(0, queryIndex).replace(/^:+/, ""),
|
|
64
|
+
queryString: remainder.slice(queryIndex + 1),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function quoteSqliteIdentifier(identifier: string): string {
|
|
69
|
+
return `"${identifier.replaceAll('"', '""')}"`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sanitizeCell(value: string): string {
|
|
73
|
+
return replaceTabs(value).replaceAll(/\r?\n/g, "\\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stringifySqliteValue(value: unknown): string {
|
|
77
|
+
if (value === null) return "NULL";
|
|
78
|
+
if (value === undefined) return "";
|
|
79
|
+
if (typeof value === "string") return value;
|
|
80
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
81
|
+
return String(value);
|
|
82
|
+
}
|
|
83
|
+
if (value instanceof Uint8Array) {
|
|
84
|
+
return `<BLOB ${formatBytes(value.byteLength)}>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const json = JSON.stringify(value);
|
|
89
|
+
return json ?? String(value);
|
|
90
|
+
} catch {
|
|
91
|
+
return String(value);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function padCell(value: string, width: number): string {
|
|
96
|
+
const truncated = truncateToWidth(sanitizeCell(value), Math.max(width, MIN_COLUMN_WIDTH));
|
|
97
|
+
const visibleWidth = Bun.stringWidth(truncated);
|
|
98
|
+
if (visibleWidth >= width) {
|
|
99
|
+
return truncated;
|
|
100
|
+
}
|
|
101
|
+
return `${truncated}${" ".repeat(width - visibleWidth)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildAsciiTable(columns: string[], rows: SqliteRow[]): string {
|
|
105
|
+
if (columns.length === 0) {
|
|
106
|
+
return rows.length === 0 ? "(no rows)" : "(rows returned without named columns)";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const widths = columns.map(column =>
|
|
110
|
+
Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, Bun.stringWidth(sanitizeCell(column)))),
|
|
111
|
+
);
|
|
112
|
+
for (const row of rows) {
|
|
113
|
+
for (const [index, column] of columns.entries()) {
|
|
114
|
+
const cellWidth = Bun.stringWidth(sanitizeCell(stringifySqliteValue(row[column])));
|
|
115
|
+
widths[index] = Math.max(widths[index] ?? MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, cellWidth));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let totalWidth = widths.reduce((sum, width) => sum + width, 0) + columns.length * 3 + 1;
|
|
120
|
+
while (totalWidth > MAX_RENDER_WIDTH) {
|
|
121
|
+
let widestIndex = -1;
|
|
122
|
+
let widestWidth = MIN_COLUMN_WIDTH;
|
|
123
|
+
for (const [index, width] of widths.entries()) {
|
|
124
|
+
if (width > widestWidth) {
|
|
125
|
+
widestIndex = index;
|
|
126
|
+
widestWidth = width;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (widestIndex === -1) break;
|
|
130
|
+
widths[widestIndex] = Math.max(MIN_COLUMN_WIDTH, (widths[widestIndex] ?? MIN_COLUMN_WIDTH) - 1);
|
|
131
|
+
totalWidth = widths.reduce((sum, width) => sum + width, 0) + columns.length * 3 + 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const header = `| ${columns.map((column, index) => padCell(column, widths[index] ?? MIN_COLUMN_WIDTH)).join(" | ")} |`;
|
|
135
|
+
const divider = `| ${widths.map(width => "-".repeat(Math.max(width, MIN_COLUMN_WIDTH))).join(" | ")} |`;
|
|
136
|
+
const lines = [header, divider];
|
|
137
|
+
|
|
138
|
+
if (rows.length === 0) {
|
|
139
|
+
lines.push("(no rows)");
|
|
140
|
+
return lines.map(line => truncateToWidth(replaceTabs(line), MAX_RENDER_WIDTH)).join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
const cells = columns.map((column, index) =>
|
|
145
|
+
padCell(stringifySqliteValue(row[column]), widths[index] ?? MIN_COLUMN_WIDTH),
|
|
146
|
+
);
|
|
147
|
+
lines.push(`| ${cells.join(" | ")} |`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return lines.map(line => truncateToWidth(replaceTabs(line), MAX_RENDER_WIDTH)).join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseLimit(value: string | null, fallback: number): number {
|
|
154
|
+
if (value === null || value.trim().length === 0) {
|
|
155
|
+
return fallback;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const parsed = Number.parseInt(value, 10);
|
|
159
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
160
|
+
throw new ToolError(`SQLite limit must be a positive integer; got '${value}'`);
|
|
161
|
+
}
|
|
162
|
+
return Math.min(parsed, MAX_QUERY_LIMIT);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseOffset(value: string | null): number {
|
|
166
|
+
if (value === null || value.trim().length === 0) {
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const parsed = Number.parseInt(value, 10);
|
|
171
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
172
|
+
throw new ToolError(`SQLite offset must be a non-negative integer; got '${value}'`);
|
|
173
|
+
}
|
|
174
|
+
return parsed;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function getTableMasterRow(db: Database, table: string): SqliteMasterRow {
|
|
178
|
+
const row =
|
|
179
|
+
db
|
|
180
|
+
.prepare<SqliteMasterRow, [string]>(
|
|
181
|
+
"SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name = ?",
|
|
182
|
+
)
|
|
183
|
+
.get(table) ?? null;
|
|
184
|
+
if (!row) {
|
|
185
|
+
throw new ToolError(`SQLite table '${table}' not found`);
|
|
186
|
+
}
|
|
187
|
+
return row;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function getTableInfoRows(db: Database, table: string): SqliteTableInfoRow[] {
|
|
191
|
+
getTableMasterRow(db, table);
|
|
192
|
+
return db.prepare<SqliteTableInfoRow, []>(`PRAGMA table_info(${quoteSqliteIdentifier(table)})`).all();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getTableColumns(db: Database, table: string): string[] {
|
|
196
|
+
return getTableInfoRows(db, table).map(column => column.name);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getPrimaryKeyColumns(db: Database, table: string): SqliteTableInfoRow[] {
|
|
200
|
+
return getTableInfoRows(db, table)
|
|
201
|
+
.filter(column => column.pk > 0)
|
|
202
|
+
.sort((left, right) => left.pk - right.pk);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function coerceIntegerKey(key: string, label: string): number | bigint {
|
|
206
|
+
const trimmed = key.trim();
|
|
207
|
+
if (!/^-?\d+$/.test(trimmed)) {
|
|
208
|
+
throw new ToolError(`${label} must be an integer; got '${key}'`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const asNumber = Number.parseInt(trimmed, 10);
|
|
212
|
+
if (Number.isSafeInteger(asNumber)) {
|
|
213
|
+
return asNumber;
|
|
214
|
+
}
|
|
215
|
+
return BigInt(trimmed);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function coerceLookupValue(key: string, type: string): SqliteBinding {
|
|
219
|
+
const normalizedType = type.trim().toUpperCase();
|
|
220
|
+
if (normalizedType.includes("INT")) {
|
|
221
|
+
return coerceIntegerKey(key, `Primary key '${key}'`);
|
|
222
|
+
}
|
|
223
|
+
if (normalizedType.includes("REAL") || normalizedType.includes("FLOA") || normalizedType.includes("DOUB")) {
|
|
224
|
+
const parsed = Number(key);
|
|
225
|
+
if (Number.isFinite(parsed)) {
|
|
226
|
+
return parsed;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return key;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function resolveOrderClause(order: string | undefined, columns: string[]): string {
|
|
233
|
+
if (!order) return "";
|
|
234
|
+
const trimmed = order.trim();
|
|
235
|
+
if (!trimmed) return "";
|
|
236
|
+
|
|
237
|
+
const separatorIndex = trimmed.lastIndexOf(":");
|
|
238
|
+
const column = separatorIndex === -1 ? trimmed : trimmed.slice(0, separatorIndex);
|
|
239
|
+
const direction =
|
|
240
|
+
separatorIndex === -1
|
|
241
|
+
? "asc"
|
|
242
|
+
: trimmed
|
|
243
|
+
.slice(separatorIndex + 1)
|
|
244
|
+
.trim()
|
|
245
|
+
.toLowerCase();
|
|
246
|
+
if (!columns.includes(column)) {
|
|
247
|
+
throw new ToolError(`SQLite order column '${column}' not found in table schema`);
|
|
248
|
+
}
|
|
249
|
+
if (direction !== "asc" && direction !== "desc") {
|
|
250
|
+
throw new ToolError(`SQLite order direction must be 'asc' or 'desc'; got '${direction}'`);
|
|
251
|
+
}
|
|
252
|
+
return ` ORDER BY ${quoteSqliteIdentifier(column)} ${direction.toUpperCase()}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function normalizeWriteValue(value: unknown, column: string): SqliteBinding {
|
|
256
|
+
if (value === null) return null;
|
|
257
|
+
if (
|
|
258
|
+
typeof value === "string" ||
|
|
259
|
+
typeof value === "number" ||
|
|
260
|
+
typeof value === "boolean" ||
|
|
261
|
+
typeof value === "bigint"
|
|
262
|
+
) {
|
|
263
|
+
return value;
|
|
264
|
+
}
|
|
265
|
+
throw new ToolError(`SQLite column '${column}' only accepts JSON scalar values or null`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function validateWriteColumns(
|
|
269
|
+
db: Database,
|
|
270
|
+
table: string,
|
|
271
|
+
data: Record<string, unknown>,
|
|
272
|
+
): Array<[string, SqliteBinding]> {
|
|
273
|
+
const columns = new Set(getTableColumns(db, table));
|
|
274
|
+
return Object.entries(data).map(([column, value]) => {
|
|
275
|
+
if (!columns.has(column)) {
|
|
276
|
+
throw new ToolError(`SQLite table '${table}' has no column named '${column}'`);
|
|
277
|
+
}
|
|
278
|
+
return [column, normalizeWriteValue(value, column)];
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function parseSqlitePathCandidates(filePath: string): SqlitePathCandidate[] {
|
|
283
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
284
|
+
const seen = new Set<string>();
|
|
285
|
+
const candidates: SqlitePathCandidate[] = [];
|
|
286
|
+
|
|
287
|
+
let match: RegExpExecArray | null;
|
|
288
|
+
while (true) {
|
|
289
|
+
match = SQLITE_PATH_PATTERN.exec(normalized);
|
|
290
|
+
if (match === null) {
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const end = match.index + match[0].length;
|
|
295
|
+
const sqlitePath = filePath.slice(0, end);
|
|
296
|
+
const remainder = normalized.slice(end);
|
|
297
|
+
const { subPath, queryString } = splitSqliteRemainder(remainder);
|
|
298
|
+
const key = `${sqlitePath}\0${subPath}\0${queryString}`;
|
|
299
|
+
if (seen.has(key)) continue;
|
|
300
|
+
seen.add(key);
|
|
301
|
+
candidates.push({ sqlitePath, subPath, queryString });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return candidates.sort((left, right) => right.sqlitePath.length - left.sqlitePath.length);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function isSqliteFile(absolutePath: string): Promise<boolean> {
|
|
308
|
+
try {
|
|
309
|
+
const bytes = await Bun.file(absolutePath).slice(0, SQLITE_MAGIC.byteLength).bytes();
|
|
310
|
+
if (bytes.length !== SQLITE_MAGIC.byteLength) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const [index, byte] of SQLITE_MAGIC.entries()) {
|
|
315
|
+
if (bytes[index] !== byte) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return true;
|
|
321
|
+
} catch {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function parseSqliteSelector(subPath: string, queryString: string): SqliteSelector {
|
|
327
|
+
const normalizedSubPath = subPath.replace(/^:+/, "").trim();
|
|
328
|
+
const params = new URLSearchParams(queryString);
|
|
329
|
+
const rawQuery = params.get("q");
|
|
330
|
+
|
|
331
|
+
if (rawQuery !== null) {
|
|
332
|
+
const otherKeys = [...params.keys()].filter(key => key !== "q");
|
|
333
|
+
if (normalizedSubPath || otherKeys.length > 0) {
|
|
334
|
+
throw new ToolError("SQLite raw queries cannot be combined with table selectors or pagination");
|
|
335
|
+
}
|
|
336
|
+
if (!rawQuery.trim()) {
|
|
337
|
+
throw new ToolError("SQLite query parameter 'q' cannot be empty");
|
|
338
|
+
}
|
|
339
|
+
return { kind: "raw", sql: rawQuery };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!normalizedSubPath) {
|
|
343
|
+
if (params.size > 0) {
|
|
344
|
+
throw new ToolError("SQLite query parameters require a table selector or q=SELECT...");
|
|
345
|
+
}
|
|
346
|
+
return { kind: "list" };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const separatorIndex = normalizedSubPath.indexOf(":");
|
|
350
|
+
const table = separatorIndex === -1 ? normalizedSubPath : normalizedSubPath.slice(0, separatorIndex);
|
|
351
|
+
const key = separatorIndex === -1 ? undefined : normalizedSubPath.slice(separatorIndex + 1);
|
|
352
|
+
if (!table) {
|
|
353
|
+
throw new ToolError("SQLite selectors must include a table name");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (key !== undefined && key.length > 0) {
|
|
357
|
+
if (params.size > 0) {
|
|
358
|
+
throw new ToolError("SQLite row lookups cannot be combined with query parameters");
|
|
359
|
+
}
|
|
360
|
+
return { kind: "row", table, key };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const where = params.get("where")?.trim() || undefined;
|
|
364
|
+
const order = params.get("order")?.trim() || undefined;
|
|
365
|
+
const hasQueryParams = params.has("limit") || params.has("offset") || order !== undefined || where !== undefined;
|
|
366
|
+
if (hasQueryParams) {
|
|
367
|
+
const knownKeys = new Set(["limit", "offset", "order", "where"]);
|
|
368
|
+
for (const keyName of params.keys()) {
|
|
369
|
+
if (!knownKeys.has(keyName)) {
|
|
370
|
+
throw new ToolError(`Unsupported SQLite query parameter '${keyName}'`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
kind: "query",
|
|
375
|
+
table,
|
|
376
|
+
limit: parseLimit(params.get("limit"), DEFAULT_QUERY_LIMIT),
|
|
377
|
+
offset: parseOffset(params.get("offset")),
|
|
378
|
+
order,
|
|
379
|
+
where,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (params.size > 0) {
|
|
384
|
+
for (const keyName of params.keys()) {
|
|
385
|
+
throw new ToolError(`Unsupported SQLite query parameter '${keyName}'`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return { kind: "schema", table, sampleLimit: DEFAULT_SCHEMA_SAMPLE_LIMIT };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function listTables(db: Database): { name: string; rowCount: number }[] {
|
|
393
|
+
const names = db
|
|
394
|
+
.prepare<Pick<SqliteMasterRow, "name">, []>(
|
|
395
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name COLLATE NOCASE",
|
|
396
|
+
)
|
|
397
|
+
.all();
|
|
398
|
+
|
|
399
|
+
return names.map(({ name }) => {
|
|
400
|
+
const countRow =
|
|
401
|
+
db.prepare<SqliteCountRow, []>(`SELECT COUNT(*) AS count FROM ${quoteSqliteIdentifier(name)}`).get() ?? null;
|
|
402
|
+
return {
|
|
403
|
+
name,
|
|
404
|
+
rowCount: countRow?.count ?? 0,
|
|
405
|
+
};
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function getTableSchema(db: Database, table: string): string {
|
|
410
|
+
const row = getTableMasterRow(db, table);
|
|
411
|
+
if (!row.sql) {
|
|
412
|
+
throw new ToolError(`SQLite schema for table '${table}' is unavailable`);
|
|
413
|
+
}
|
|
414
|
+
return row.sql;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function getTablePrimaryKey(db: Database, table: string): { column: string; type: string } | null {
|
|
418
|
+
const primaryKeyColumns = getPrimaryKeyColumns(db, table);
|
|
419
|
+
if (primaryKeyColumns.length !== 1) {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const column = primaryKeyColumns[0]!;
|
|
424
|
+
return { column: column.name, type: column.type };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function resolveTableRowLookup(db: Database, table: string): SqliteRowLookup {
|
|
428
|
+
const primaryKeyColumns = getPrimaryKeyColumns(db, table);
|
|
429
|
+
if (primaryKeyColumns.length === 1) {
|
|
430
|
+
const column = primaryKeyColumns[0]!;
|
|
431
|
+
return { kind: "pk", column: column.name, type: column.type };
|
|
432
|
+
}
|
|
433
|
+
if (primaryKeyColumns.length > 1) {
|
|
434
|
+
throw new ToolError(`SQLite table '${table}' has a composite primary key; use '?where=' instead`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const schema = getTableSchema(db, table);
|
|
438
|
+
if (/\bWITHOUT\s+ROWID\b/i.test(schema)) {
|
|
439
|
+
throw new ToolError(`SQLite table '${table}' does not expose ROWID; use '?where=' instead`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return { kind: "rowid" };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function queryRows(
|
|
446
|
+
db: Database,
|
|
447
|
+
table: string,
|
|
448
|
+
opts: { limit: number; offset: number; order?: string; where?: string },
|
|
449
|
+
): { columns: string[]; rows: Record<string, unknown>[]; totalCount: number } {
|
|
450
|
+
const columns = getTableColumns(db, table);
|
|
451
|
+
const whereClause = opts.where?.trim() ? ` WHERE ${opts.where.trim()}` : "";
|
|
452
|
+
const orderClause = resolveOrderClause(opts.order, columns);
|
|
453
|
+
const countSql = `SELECT COUNT(*) AS count FROM ${quoteSqliteIdentifier(table)}${whereClause}`;
|
|
454
|
+
const selectSql = `SELECT * FROM ${quoteSqliteIdentifier(table)}${whereClause}${orderClause} LIMIT ? OFFSET ?`;
|
|
455
|
+
const totalCount = db.prepare<SqliteCountRow, []>(countSql).get()?.count ?? 0;
|
|
456
|
+
const rows = db.prepare<SqliteRow, SQLQueryBindings[]>(selectSql).all(opts.limit, opts.offset);
|
|
457
|
+
return { columns, rows, totalCount };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function getRowByKey(
|
|
461
|
+
db: Database,
|
|
462
|
+
table: string,
|
|
463
|
+
pk: { column: string; type?: string },
|
|
464
|
+
key: string,
|
|
465
|
+
): Record<string, unknown> | null {
|
|
466
|
+
getTableMasterRow(db, table);
|
|
467
|
+
const sql = `SELECT * FROM ${quoteSqliteIdentifier(table)} WHERE ${quoteSqliteIdentifier(pk.column)} = ? LIMIT 1`;
|
|
468
|
+
const binding = coerceLookupValue(key, pk.type ?? "");
|
|
469
|
+
return db.prepare<SqliteRow, SQLQueryBindings[]>(sql).get(binding);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function getRowByRowId(db: Database, table: string, key: string): Record<string, unknown> | null {
|
|
473
|
+
getTableMasterRow(db, table);
|
|
474
|
+
const binding = coerceIntegerKey(key, "SQLite ROWID");
|
|
475
|
+
return db
|
|
476
|
+
.prepare<SqliteRow, SQLQueryBindings[]>(`SELECT * FROM ${quoteSqliteIdentifier(table)} WHERE rowid = ? LIMIT 1`)
|
|
477
|
+
.get(binding);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function executeReadQuery(db: Database, sql: string): { columns: string[]; rows: Record<string, unknown>[] } {
|
|
481
|
+
const statement = db.prepare<SqliteRow, []>(sql);
|
|
482
|
+
if (statement.paramsCount > 0) {
|
|
483
|
+
throw new ToolError("SQLite raw queries do not support bound parameters");
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
columns: [...statement.columnNames],
|
|
487
|
+
rows: statement.all(),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function insertRow(db: Database, table: string, data: Record<string, unknown>): void {
|
|
492
|
+
getTableMasterRow(db, table);
|
|
493
|
+
const entries = validateWriteColumns(db, table, data);
|
|
494
|
+
if (entries.length === 0) {
|
|
495
|
+
db.run(`INSERT INTO ${quoteSqliteIdentifier(table)} DEFAULT VALUES`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const columns = entries.map(([column]) => quoteSqliteIdentifier(column)).join(", ");
|
|
500
|
+
const placeholders = entries.map(() => "?").join(", ");
|
|
501
|
+
const bindings = entries.map(([, value]) => value);
|
|
502
|
+
const statement = db.prepare<SqliteRow, SQLQueryBindings[]>(
|
|
503
|
+
`INSERT INTO ${quoteSqliteIdentifier(table)} (${columns}) VALUES (${placeholders})`,
|
|
504
|
+
);
|
|
505
|
+
statement.run(...bindings);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function updateRowByKey(
|
|
509
|
+
db: Database,
|
|
510
|
+
table: string,
|
|
511
|
+
pk: { column: string; type?: string },
|
|
512
|
+
key: string,
|
|
513
|
+
data: Record<string, unknown>,
|
|
514
|
+
): number {
|
|
515
|
+
getTableMasterRow(db, table);
|
|
516
|
+
const entries = validateWriteColumns(db, table, data);
|
|
517
|
+
if (entries.length === 0) {
|
|
518
|
+
throw new ToolError("SQLite updates require at least one column value");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const assignments = entries.map(([column]) => `${quoteSqliteIdentifier(column)} = ?`).join(", ");
|
|
522
|
+
const bindings = entries.map(([, value]) => value);
|
|
523
|
+
bindings.push(coerceLookupValue(key, pk.type ?? ""));
|
|
524
|
+
const statement = db.prepare<SqliteRow, SQLQueryBindings[]>(
|
|
525
|
+
`UPDATE ${quoteSqliteIdentifier(table)} SET ${assignments} WHERE ${quoteSqliteIdentifier(pk.column)} = ?`,
|
|
526
|
+
);
|
|
527
|
+
return statement.run(...bindings).changes;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function updateRowByRowId(db: Database, table: string, key: string, data: Record<string, unknown>): number {
|
|
531
|
+
getTableMasterRow(db, table);
|
|
532
|
+
const entries = validateWriteColumns(db, table, data);
|
|
533
|
+
if (entries.length === 0) {
|
|
534
|
+
throw new ToolError("SQLite updates require at least one column value");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const assignments = entries.map(([column]) => `${quoteSqliteIdentifier(column)} = ?`).join(", ");
|
|
538
|
+
const bindings = entries.map(([, value]) => value);
|
|
539
|
+
bindings.push(coerceIntegerKey(key, "SQLite ROWID"));
|
|
540
|
+
const statement = db.prepare<SqliteRow, SQLQueryBindings[]>(
|
|
541
|
+
`UPDATE ${quoteSqliteIdentifier(table)} SET ${assignments} WHERE rowid = ?`,
|
|
542
|
+
);
|
|
543
|
+
return statement.run(...bindings).changes;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function deleteRowByKey(
|
|
547
|
+
db: Database,
|
|
548
|
+
table: string,
|
|
549
|
+
pk: { column: string; type?: string },
|
|
550
|
+
key: string,
|
|
551
|
+
): number {
|
|
552
|
+
getTableMasterRow(db, table);
|
|
553
|
+
const binding = coerceLookupValue(key, pk.type ?? "");
|
|
554
|
+
const statement = db.prepare<SqliteRow, SQLQueryBindings[]>(
|
|
555
|
+
`DELETE FROM ${quoteSqliteIdentifier(table)} WHERE ${quoteSqliteIdentifier(pk.column)} = ?`,
|
|
556
|
+
);
|
|
557
|
+
return statement.run(binding).changes;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function deleteRowByRowId(db: Database, table: string, key: string): number {
|
|
561
|
+
getTableMasterRow(db, table);
|
|
562
|
+
const binding = coerceIntegerKey(key, "SQLite ROWID");
|
|
563
|
+
const statement = db.prepare<SqliteRow, SQLQueryBindings[]>(
|
|
564
|
+
`DELETE FROM ${quoteSqliteIdentifier(table)} WHERE rowid = ?`,
|
|
565
|
+
);
|
|
566
|
+
return statement.run(binding).changes;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function renderTableList(tables: { name: string; rowCount: number }[]): string {
|
|
570
|
+
if (tables.length === 0) {
|
|
571
|
+
return "(no tables)";
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return tables
|
|
575
|
+
.map(table => truncateToWidth(replaceTabs(`${table.name} (${table.rowCount} rows)`), MAX_RENDER_WIDTH))
|
|
576
|
+
.join("\n");
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function renderSchema(
|
|
580
|
+
createSql: string,
|
|
581
|
+
sampleRows: { columns: string[]; rows: Record<string, unknown>[] },
|
|
582
|
+
): string {
|
|
583
|
+
const schemaLines = replaceTabs(createSql)
|
|
584
|
+
.split("\n")
|
|
585
|
+
.map(line => truncateToWidth(line, MAX_RENDER_WIDTH));
|
|
586
|
+
const parts = [schemaLines.join("\n"), "", "Sample rows:", buildAsciiTable(sampleRows.columns, sampleRows.rows)];
|
|
587
|
+
return parts.join("\n");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export function renderRow(row: Record<string, unknown>): string {
|
|
591
|
+
const entries = Object.entries(row);
|
|
592
|
+
if (entries.length === 0) {
|
|
593
|
+
return "(no columns)";
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return entries
|
|
597
|
+
.map(([column, value]) =>
|
|
598
|
+
truncateToWidth(replaceTabs(`${column}: ${stringifySqliteValue(value)}`), MAX_RENDER_WIDTH),
|
|
599
|
+
)
|
|
600
|
+
.join("\n");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function renderTable(
|
|
604
|
+
columns: string[],
|
|
605
|
+
rows: Record<string, unknown>[],
|
|
606
|
+
meta: { totalCount: number; offset: number; limit: number; table: string; dbPath: string },
|
|
607
|
+
): string {
|
|
608
|
+
const parts = [buildAsciiTable(columns, rows)];
|
|
609
|
+
const shown = Math.min(meta.totalCount, meta.offset + rows.length);
|
|
610
|
+
if (shown < meta.totalCount) {
|
|
611
|
+
const remaining = meta.totalCount - shown;
|
|
612
|
+
const nextOffset = meta.offset + rows.length;
|
|
613
|
+
parts.push(
|
|
614
|
+
truncateToWidth(
|
|
615
|
+
replaceTabs(
|
|
616
|
+
`[${remaining} more rows; use sel="${meta.table}?limit=${meta.limit}&offset=${nextOffset}" to continue]`,
|
|
617
|
+
),
|
|
618
|
+
MAX_RENDER_WIDTH,
|
|
619
|
+
),
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
return parts.join("\n");
|
|
623
|
+
}
|