feat(tools): added SQLite database operations with read/write support and query validation
- Added SQLite path parsing and candidate validation for `.sqlite`, `.db`, `.db3`, and `.sqlite3` targets in read/write flows. - Added SQLite read operations for table lists, schema views, row lookups, paginated queries, and raw SELECT mode. - Added SQLite write operations for insert, update-by-key, and delete-by-key using JSON5 row payloads. - Updated selector routing to validate SQLite headers and fall back to normal file reads/writes when not databases. - Secured read mode by enforcing query validation to block destructive SQL execution on SQLite inputs.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
||||
@@ -38,7 +39,6 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
||||
import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading";
|
||||
import { convertFileWithMarkit } from "../utils/markit";
|
||||
import { type ArchiveReader, openArchive, parseArchivePathCandidates } from "./archive-reader";
|
||||
|
||||
import {
|
||||
executeReadUrl,
|
||||
isReadableUrlPath,
|
||||
@@ -52,6 +52,22 @@ import { applyListLimit } from "./list-limit";
|
||||
import { formatFullOutputReference, formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
|
||||
import { expandPath, resolveReadPath } from "./path-utils";
|
||||
import { formatAge, formatBytes, shortenPath, wrapBrackets } from "./render-utils";
|
||||
import {
|
||||
executeReadQuery,
|
||||
getRowByKey,
|
||||
getRowByRowId,
|
||||
getTableSchema,
|
||||
isSqliteFile,
|
||||
listTables,
|
||||
parseSqlitePathCandidates,
|
||||
parseSqliteSelector,
|
||||
queryRows,
|
||||
renderRow,
|
||||
renderSchema,
|
||||
renderTable,
|
||||
renderTableList,
|
||||
resolveTableRowLookup,
|
||||
} from "./sqlite-reader";
|
||||
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
|
||||
import { toolResult } from "./tool-result";
|
||||
|
||||
@@ -420,6 +436,29 @@ interface ResolvedArchiveReadPath {
|
||||
suffixResolution?: { from: string; to: string };
|
||||
}
|
||||
|
||||
interface ResolvedSqliteReadPath {
|
||||
absolutePath: string;
|
||||
sqliteSubPath: string;
|
||||
queryString: string;
|
||||
suffixResolution?: { from: string; to: string };
|
||||
}
|
||||
|
||||
function parseSqliteSelectorInput(selector: string | undefined): { subPath: string; queryString: string } {
|
||||
if (!selector) {
|
||||
return { subPath: "", queryString: "" };
|
||||
}
|
||||
|
||||
const queryIndex = selector.indexOf("?");
|
||||
if (queryIndex === -1) {
|
||||
return { subPath: selector.replace(/^:+/, ""), queryString: "" };
|
||||
}
|
||||
|
||||
return {
|
||||
subPath: selector.slice(0, queryIndex).replace(/^:+/, ""),
|
||||
queryString: selector.slice(queryIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read tool implementation.
|
||||
*
|
||||
@@ -502,6 +541,53 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async #resolveSqliteReadPath(readPath: string, signal?: AbortSignal): Promise<ResolvedSqliteReadPath | null> {
|
||||
const candidates = parseSqlitePathCandidates(readPath);
|
||||
for (const candidate of candidates) {
|
||||
let absolutePath = resolveReadPath(candidate.sqlitePath, this.session.cwd);
|
||||
let suffixResolution: { from: string; to: string } | undefined;
|
||||
|
||||
try {
|
||||
const stat = await Bun.file(absolutePath).stat();
|
||||
if (stat.isDirectory()) continue;
|
||||
if (!(await isSqliteFile(absolutePath))) continue;
|
||||
|
||||
return {
|
||||
absolutePath,
|
||||
sqliteSubPath: candidate.subPath,
|
||||
queryString: candidate.queryString,
|
||||
suffixResolution,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error) || isRemoteMountPath(absolutePath)) continue;
|
||||
|
||||
const suffixMatch = await findUniqueSuffixMatch(candidate.sqlitePath, this.session.cwd, signal);
|
||||
if (!suffixMatch) continue;
|
||||
|
||||
try {
|
||||
const retryStat = await Bun.file(suffixMatch.absolutePath).stat();
|
||||
if (retryStat.isDirectory()) continue;
|
||||
if (!(await isSqliteFile(suffixMatch.absolutePath))) continue;
|
||||
|
||||
absolutePath = suffixMatch.absolutePath;
|
||||
suffixResolution = { from: candidate.sqlitePath, to: suffixMatch.displayPath };
|
||||
return {
|
||||
absolutePath,
|
||||
sqliteSubPath: candidate.subPath,
|
||||
queryString: candidate.queryString,
|
||||
suffixResolution,
|
||||
};
|
||||
} catch (retryError) {
|
||||
if (!isNotFoundError(retryError)) {
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#buildInMemoryTextResult(
|
||||
text: string,
|
||||
offset: number | undefined,
|
||||
@@ -709,6 +795,132 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
return result;
|
||||
}
|
||||
|
||||
async #readSqlite(
|
||||
sel: string | undefined,
|
||||
resolvedSqlitePath: ResolvedSqliteReadPath,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentToolResult<ReadToolDetails>> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
const selectorInput = sel
|
||||
? parseSqliteSelectorInput(sel)
|
||||
: { subPath: resolvedSqlitePath.sqliteSubPath, queryString: resolvedSqlitePath.queryString };
|
||||
const selector = parseSqliteSelector(selectorInput.subPath, selectorInput.queryString);
|
||||
const details: ReadToolDetails = {
|
||||
resolvedPath: resolvedSqlitePath.absolutePath,
|
||||
suffixResolution: resolvedSqlitePath.suffixResolution,
|
||||
};
|
||||
|
||||
let db: Database | null = null;
|
||||
try {
|
||||
db = new Database(resolvedSqlitePath.absolutePath, { readonly: true, strict: true });
|
||||
db.run("PRAGMA busy_timeout = 3000");
|
||||
throwIfAborted(signal);
|
||||
|
||||
switch (selector.kind) {
|
||||
case "list": {
|
||||
const listLimit = applyListLimit(listTables(db), { limit: 500 });
|
||||
const output = prependSuffixResolutionNotice(
|
||||
renderTableList(listLimit.items),
|
||||
resolvedSqlitePath.suffixResolution,
|
||||
);
|
||||
const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
details.truncation = truncation.truncated ? truncation : undefined;
|
||||
const resultBuilder = toolResult<ReadToolDetails>(details)
|
||||
.text(truncation.content)
|
||||
.sourcePath(resolvedSqlitePath.absolutePath)
|
||||
.limits({ resultLimit: listLimit.meta.resultLimit?.reached });
|
||||
if (truncation.truncated) {
|
||||
resultBuilder.truncation(truncation, { direction: "head" });
|
||||
}
|
||||
return resultBuilder.done();
|
||||
}
|
||||
case "schema": {
|
||||
const sampleRows = queryRows(db, selector.table, { limit: selector.sampleLimit, offset: 0 });
|
||||
let output = renderSchema(getTableSchema(db, selector.table), {
|
||||
columns: sampleRows.columns,
|
||||
rows: sampleRows.rows,
|
||||
});
|
||||
if (sampleRows.rows.length < sampleRows.totalCount) {
|
||||
const remaining = sampleRows.totalCount - sampleRows.rows.length;
|
||||
output += `\n[${remaining} more rows; use sel="${selector.table}?limit=20&offset=${sampleRows.rows.length}" to continue]`;
|
||||
}
|
||||
return toolResult<ReadToolDetails>(details)
|
||||
.text(prependSuffixResolutionNotice(output, resolvedSqlitePath.suffixResolution))
|
||||
.sourcePath(resolvedSqlitePath.absolutePath)
|
||||
.done();
|
||||
}
|
||||
case "row": {
|
||||
const lookup = resolveTableRowLookup(db, selector.table);
|
||||
const row =
|
||||
lookup.kind === "pk"
|
||||
? getRowByKey(db, selector.table, lookup, selector.key)
|
||||
: getRowByRowId(db, selector.table, selector.key);
|
||||
if (!row) {
|
||||
return toolResult<ReadToolDetails>(details)
|
||||
.text(
|
||||
prependSuffixResolutionNotice(
|
||||
`No row found in table '${selector.table}' for key '${selector.key}'.`,
|
||||
resolvedSqlitePath.suffixResolution,
|
||||
),
|
||||
)
|
||||
.sourcePath(resolvedSqlitePath.absolutePath)
|
||||
.done();
|
||||
}
|
||||
return toolResult<ReadToolDetails>(details)
|
||||
.text(prependSuffixResolutionNotice(renderRow(row), resolvedSqlitePath.suffixResolution))
|
||||
.sourcePath(resolvedSqlitePath.absolutePath)
|
||||
.done();
|
||||
}
|
||||
case "query": {
|
||||
const page = queryRows(db, selector.table, selector);
|
||||
return toolResult<ReadToolDetails>(details)
|
||||
.text(
|
||||
prependSuffixResolutionNotice(
|
||||
renderTable(page.columns, page.rows, {
|
||||
totalCount: page.totalCount,
|
||||
offset: selector.offset,
|
||||
limit: selector.limit,
|
||||
table: selector.table,
|
||||
dbPath: resolvedSqlitePath.absolutePath,
|
||||
}),
|
||||
resolvedSqlitePath.suffixResolution,
|
||||
),
|
||||
)
|
||||
.sourcePath(resolvedSqlitePath.absolutePath)
|
||||
.done();
|
||||
}
|
||||
case "raw": {
|
||||
const result = executeReadQuery(db, selector.sql);
|
||||
return toolResult<ReadToolDetails>(details)
|
||||
.text(
|
||||
prependSuffixResolutionNotice(
|
||||
renderTable(result.columns, result.rows, {
|
||||
totalCount: result.rows.length,
|
||||
offset: 0,
|
||||
limit: result.rows.length || DEFAULT_MAX_LINES,
|
||||
table: "query",
|
||||
dbPath: resolvedSqlitePath.absolutePath,
|
||||
}),
|
||||
resolvedSqlitePath.suffixResolution,
|
||||
),
|
||||
)
|
||||
.sourcePath(resolvedSqlitePath.absolutePath)
|
||||
.done();
|
||||
}
|
||||
}
|
||||
|
||||
throw new ToolError("Unsupported SQLite selector");
|
||||
} catch (error) {
|
||||
if (error instanceof ToolError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ToolError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: ReadParams,
|
||||
@@ -769,6 +981,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
return this.#readArchive(readPath, offset, limit, archivePath, signal);
|
||||
}
|
||||
|
||||
const sqlitePath = await this.#resolveSqliteReadPath(readPath, signal);
|
||||
if (sqlitePath) {
|
||||
return this.#readSqlite(sel, sqlitePath, signal);
|
||||
}
|
||||
|
||||
let absolutePath = resolveReadPath(localReadPath, this.session.cwd);
|
||||
let suffixResolution: { from: string; to: string } | undefined;
|
||||
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
import type { Database, SQLQueryBindings } from "bun:sqlite";
|
||||
import { formatBytes, replaceTabs, truncateToWidth } from "./render-utils";
|
||||
import { ToolError } from "./tool-errors";
|
||||
|
||||
const SQLITE_MAGIC = new Uint8Array([
|
||||
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00,
|
||||
]);
|
||||
const SQLITE_PATH_PATTERN = /\.(?:sqlite3?|db3?)(?=(?::|\?|$))/gi;
|
||||
const DEFAULT_QUERY_LIMIT = 20;
|
||||
const DEFAULT_SCHEMA_SAMPLE_LIMIT = 5;
|
||||
const MAX_QUERY_LIMIT = 500;
|
||||
const MAX_RENDER_WIDTH = 120;
|
||||
const MAX_COLUMN_WIDTH = 40;
|
||||
const MIN_COLUMN_WIDTH = 1;
|
||||
|
||||
type SqliteBinding = Exclude<SQLQueryBindings, Record<string, unknown>>;
|
||||
|
||||
type SqliteRow = Record<string, unknown>;
|
||||
|
||||
interface SqliteMasterRow {
|
||||
name: string;
|
||||
sql: string | null;
|
||||
}
|
||||
|
||||
interface SqliteCountRow {
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface SqliteTableInfoRow {
|
||||
cid: number;
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: unknown;
|
||||
pk: number;
|
||||
}
|
||||
|
||||
export interface SqlitePathCandidate {
|
||||
sqlitePath: string;
|
||||
subPath: string;
|
||||
queryString: string;
|
||||
}
|
||||
|
||||
export type SqliteSelector =
|
||||
| { kind: "list" }
|
||||
| { kind: "schema"; table: string; sampleLimit: number }
|
||||
| { kind: "row"; table: string; key: string }
|
||||
| { kind: "query"; table: string; limit: number; offset: number; order?: string; where?: string }
|
||||
| { kind: "raw"; sql: string };
|
||||
|
||||
export type SqliteRowLookup = { kind: "pk"; column: string; type: string } | { kind: "rowid" };
|
||||
|
||||
function splitSqliteRemainder(remainder: string): { subPath: string; queryString: string } {
|
||||
const queryIndex = remainder.indexOf("?");
|
||||
if (queryIndex === -1) {
|
||||
return {
|
||||
subPath: remainder.replace(/^:+/, ""),
|
||||
queryString: "",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
subPath: remainder.slice(0, queryIndex).replace(/^:+/, ""),
|
||||
queryString: remainder.slice(queryIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function quoteSqliteIdentifier(identifier: string): string {
|
||||
return `"${identifier.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function sanitizeCell(value: string): string {
|
||||
return replaceTabs(value).replaceAll(/\r?\n/g, "\\n");
|
||||
}
|
||||
|
||||
function stringifySqliteValue(value: unknown): string {
|
||||
if (value === null) return "NULL";
|
||||
if (value === undefined) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
if (value instanceof Uint8Array) {
|
||||
return `<BLOB ${formatBytes(value.byteLength)}>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.stringify(value);
|
||||
return json ?? String(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function padCell(value: string, width: number): string {
|
||||
const truncated = truncateToWidth(sanitizeCell(value), Math.max(width, MIN_COLUMN_WIDTH));
|
||||
const visibleWidth = Bun.stringWidth(truncated);
|
||||
if (visibleWidth >= width) {
|
||||
return truncated;
|
||||
}
|
||||
return `${truncated}${" ".repeat(width - visibleWidth)}`;
|
||||
}
|
||||
|
||||
function buildAsciiTable(columns: string[], rows: SqliteRow[]): string {
|
||||
if (columns.length === 0) {
|
||||
return rows.length === 0 ? "(no rows)" : "(rows returned without named columns)";
|
||||
}
|
||||
|
||||
const widths = columns.map(column =>
|
||||
Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, Bun.stringWidth(sanitizeCell(column)))),
|
||||
);
|
||||
for (const row of rows) {
|
||||
for (const [index, column] of columns.entries()) {
|
||||
const cellWidth = Bun.stringWidth(sanitizeCell(stringifySqliteValue(row[column])));
|
||||
widths[index] = Math.max(widths[index] ?? MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, cellWidth));
|
||||
}
|
||||
}
|
||||
|
||||
let totalWidth = widths.reduce((sum, width) => sum + width, 0) + columns.length * 3 + 1;
|
||||
while (totalWidth > MAX_RENDER_WIDTH) {
|
||||
let widestIndex = -1;
|
||||
let widestWidth = MIN_COLUMN_WIDTH;
|
||||
for (const [index, width] of widths.entries()) {
|
||||
if (width > widestWidth) {
|
||||
widestIndex = index;
|
||||
widestWidth = width;
|
||||
}
|
||||
}
|
||||
if (widestIndex === -1) break;
|
||||
widths[widestIndex] = Math.max(MIN_COLUMN_WIDTH, (widths[widestIndex] ?? MIN_COLUMN_WIDTH) - 1);
|
||||
totalWidth = widths.reduce((sum, width) => sum + width, 0) + columns.length * 3 + 1;
|
||||
}
|
||||
|
||||
const header = `| ${columns.map((column, index) => padCell(column, widths[index] ?? MIN_COLUMN_WIDTH)).join(" | ")} |`;
|
||||
const divider = `| ${widths.map(width => "-".repeat(Math.max(width, MIN_COLUMN_WIDTH))).join(" | ")} |`;
|
||||
const lines = [header, divider];
|
||||
|
||||
if (rows.length === 0) {
|
||||
lines.push("(no rows)");
|
||||
return lines.map(line => truncateToWidth(replaceTabs(line), MAX_RENDER_WIDTH)).join("\n");
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const cells = columns.map((column, index) =>
|
||||
padCell(stringifySqliteValue(row[column]), widths[index] ?? MIN_COLUMN_WIDTH),
|
||||
);
|
||||
lines.push(`| ${cells.join(" | ")} |`);
|
||||
}
|
||||
|
||||
return lines.map(line => truncateToWidth(replaceTabs(line), MAX_RENDER_WIDTH)).join("\n");
|
||||
}
|
||||
|
||||
function parseLimit(value: string | null, fallback: number): number {
|
||||
if (value === null || value.trim().length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
throw new ToolError(`SQLite limit must be a positive integer; got '${value}'`);
|
||||
}
|
||||
return Math.min(parsed, MAX_QUERY_LIMIT);
|
||||
}
|
||||
|
||||
function parseOffset(value: string | null): number {
|
||||
if (value === null || value.trim().length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new ToolError(`SQLite offset must be a non-negative integer; got '${value}'`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getTableMasterRow(db: Database, table: string): SqliteMasterRow {
|
||||
const row =
|
||||
db
|
||||
.prepare<SqliteMasterRow, [string]>(
|
||||
"SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name = ?",
|
||||
)
|
||||
.get(table) ?? null;
|
||||
if (!row) {
|
||||
throw new ToolError(`SQLite table '${table}' not found`);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function getTableInfoRows(db: Database, table: string): SqliteTableInfoRow[] {
|
||||
getTableMasterRow(db, table);
|
||||
return db.prepare<SqliteTableInfoRow, []>(`PRAGMA table_info(${quoteSqliteIdentifier(table)})`).all();
|
||||
}
|
||||
|
||||
function getTableColumns(db: Database, table: string): string[] {
|
||||
return getTableInfoRows(db, table).map(column => column.name);
|
||||
}
|
||||
|
||||
function getPrimaryKeyColumns(db: Database, table: string): SqliteTableInfoRow[] {
|
||||
return getTableInfoRows(db, table)
|
||||
.filter(column => column.pk > 0)
|
||||
.sort((left, right) => left.pk - right.pk);
|
||||
}
|
||||
|
||||
function coerceIntegerKey(key: string, label: string): number | bigint {
|
||||
const trimmed = key.trim();
|
||||
if (!/^-?\d+$/.test(trimmed)) {
|
||||
throw new ToolError(`${label} must be an integer; got '${key}'`);
|
||||
}
|
||||
|
||||
const asNumber = Number.parseInt(trimmed, 10);
|
||||
if (Number.isSafeInteger(asNumber)) {
|
||||
return asNumber;
|
||||
}
|
||||
return BigInt(trimmed);
|
||||
}
|
||||
|
||||
function coerceLookupValue(key: string, type: string): SqliteBinding {
|
||||
const normalizedType = type.trim().toUpperCase();
|
||||
if (normalizedType.includes("INT")) {
|
||||
return coerceIntegerKey(key, `Primary key '${key}'`);
|
||||
}
|
||||
if (normalizedType.includes("REAL") || normalizedType.includes("FLOA") || normalizedType.includes("DOUB")) {
|
||||
const parsed = Number(key);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function resolveOrderClause(order: string | undefined, columns: string[]): string {
|
||||
if (!order) return "";
|
||||
const trimmed = order.trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
const separatorIndex = trimmed.lastIndexOf(":");
|
||||
const column = separatorIndex === -1 ? trimmed : trimmed.slice(0, separatorIndex);
|
||||
const direction =
|
||||
separatorIndex === -1
|
||||
? "asc"
|
||||
: trimmed
|
||||
.slice(separatorIndex + 1)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!columns.includes(column)) {
|
||||
throw new ToolError(`SQLite order column '${column}' not found in table schema`);
|
||||
}
|
||||
if (direction !== "asc" && direction !== "desc") {
|
||||
throw new ToolError(`SQLite order direction must be 'asc' or 'desc'; got '${direction}'`);
|
||||
}
|
||||
return ` ORDER BY ${quoteSqliteIdentifier(column)} ${direction.toUpperCase()}`;
|
||||
}
|
||||
|
||||
function normalizeWriteValue(value: unknown, column: string): SqliteBinding {
|
||||
if (value === null) return null;
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "bigint"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
throw new ToolError(`SQLite column '${column}' only accepts JSON scalar values or null`);
|
||||
}
|
||||
|
||||
function validateWriteColumns(
|
||||
db: Database,
|
||||
table: string,
|
||||
data: Record<string, unknown>,
|
||||
): Array<[string, SqliteBinding]> {
|
||||
const columns = new Set(getTableColumns(db, table));
|
||||
return Object.entries(data).map(([column, value]) => {
|
||||
if (!columns.has(column)) {
|
||||
throw new ToolError(`SQLite table '${table}' has no column named '${column}'`);
|
||||
}
|
||||
return [column, normalizeWriteValue(value, column)];
|
||||
});
|
||||
}
|
||||
|
||||
export function parseSqlitePathCandidates(filePath: string): SqlitePathCandidate[] {
|
||||
const normalized = filePath.replace(/\\/g, "/");
|
||||
const seen = new Set<string>();
|
||||
const candidates: SqlitePathCandidate[] = [];
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while (true) {
|
||||
match = SQLITE_PATH_PATTERN.exec(normalized);
|
||||
if (match === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
const end = match.index + match[0].length;
|
||||
const sqlitePath = filePath.slice(0, end);
|
||||
const remainder = normalized.slice(end);
|
||||
const { subPath, queryString } = splitSqliteRemainder(remainder);
|
||||
const key = `${sqlitePath}\0${subPath}\0${queryString}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
candidates.push({ sqlitePath, subPath, queryString });
|
||||
}
|
||||
|
||||
return candidates.sort((left, right) => right.sqlitePath.length - left.sqlitePath.length);
|
||||
}
|
||||
|
||||
export async function isSqliteFile(absolutePath: string): Promise<boolean> {
|
||||
try {
|
||||
const bytes = await Bun.file(absolutePath).slice(0, SQLITE_MAGIC.byteLength).bytes();
|
||||
if (bytes.length !== SQLITE_MAGIC.byteLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [index, byte] of SQLITE_MAGIC.entries()) {
|
||||
if (bytes[index] !== byte) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSqliteSelector(subPath: string, queryString: string): SqliteSelector {
|
||||
const normalizedSubPath = subPath.replace(/^:+/, "").trim();
|
||||
const params = new URLSearchParams(queryString);
|
||||
const rawQuery = params.get("q");
|
||||
|
||||
if (rawQuery !== null) {
|
||||
const otherKeys = [...params.keys()].filter(key => key !== "q");
|
||||
if (normalizedSubPath || otherKeys.length > 0) {
|
||||
throw new ToolError("SQLite raw queries cannot be combined with table selectors or pagination");
|
||||
}
|
||||
if (!rawQuery.trim()) {
|
||||
throw new ToolError("SQLite query parameter 'q' cannot be empty");
|
||||
}
|
||||
return { kind: "raw", sql: rawQuery };
|
||||
}
|
||||
|
||||
if (!normalizedSubPath) {
|
||||
if (params.size > 0) {
|
||||
throw new ToolError("SQLite query parameters require a table selector or q=SELECT...");
|
||||
}
|
||||
return { kind: "list" };
|
||||
}
|
||||
|
||||
const separatorIndex = normalizedSubPath.indexOf(":");
|
||||
const table = separatorIndex === -1 ? normalizedSubPath : normalizedSubPath.slice(0, separatorIndex);
|
||||
const key = separatorIndex === -1 ? undefined : normalizedSubPath.slice(separatorIndex + 1);
|
||||
if (!table) {
|
||||
throw new ToolError("SQLite selectors must include a table name");
|
||||
}
|
||||
|
||||
if (key !== undefined && key.length > 0) {
|
||||
if (params.size > 0) {
|
||||
throw new ToolError("SQLite row lookups cannot be combined with query parameters");
|
||||
}
|
||||
return { kind: "row", table, key };
|
||||
}
|
||||
|
||||
const where = params.get("where")?.trim() || undefined;
|
||||
const order = params.get("order")?.trim() || undefined;
|
||||
const hasQueryParams = params.has("limit") || params.has("offset") || order !== undefined || where !== undefined;
|
||||
if (hasQueryParams) {
|
||||
const knownKeys = new Set(["limit", "offset", "order", "where"]);
|
||||
for (const keyName of params.keys()) {
|
||||
if (!knownKeys.has(keyName)) {
|
||||
throw new ToolError(`Unsupported SQLite query parameter '${keyName}'`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "query",
|
||||
table,
|
||||
limit: parseLimit(params.get("limit"), DEFAULT_QUERY_LIMIT),
|
||||
offset: parseOffset(params.get("offset")),
|
||||
order,
|
||||
where,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.size > 0) {
|
||||
for (const keyName of params.keys()) {
|
||||
throw new ToolError(`Unsupported SQLite query parameter '${keyName}'`);
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "schema", table, sampleLimit: DEFAULT_SCHEMA_SAMPLE_LIMIT };
|
||||
}
|
||||
|
||||
export function listTables(db: Database): { name: string; rowCount: number }[] {
|
||||
const names = db
|
||||
.prepare<Pick<SqliteMasterRow, "name">, []>(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name COLLATE NOCASE",
|
||||
)
|
||||
.all();
|
||||
|
||||
return names.map(({ name }) => {
|
||||
const countRow =
|
||||
db.prepare<SqliteCountRow, []>(`SELECT COUNT(*) AS count FROM ${quoteSqliteIdentifier(name)}`).get() ?? null;
|
||||
return {
|
||||
name,
|
||||
rowCount: countRow?.count ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getTableSchema(db: Database, table: string): string {
|
||||
const row = getTableMasterRow(db, table);
|
||||
if (!row.sql) {
|
||||
throw new ToolError(`SQLite schema for table '${table}' is unavailable`);
|
||||
}
|
||||
return row.sql;
|
||||
}
|
||||
|
||||
export function getTablePrimaryKey(db: Database, table: string): { column: string; type: string } | null {
|
||||
const primaryKeyColumns = getPrimaryKeyColumns(db, table);
|
||||
if (primaryKeyColumns.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const column = primaryKeyColumns[0]!;
|
||||
return { column: column.name, type: column.type };
|
||||
}
|
||||
|
||||
export function resolveTableRowLookup(db: Database, table: string): SqliteRowLookup {
|
||||
const primaryKeyColumns = getPrimaryKeyColumns(db, table);
|
||||
if (primaryKeyColumns.length === 1) {
|
||||
const column = primaryKeyColumns[0]!;
|
||||
return { kind: "pk", column: column.name, type: column.type };
|
||||
}
|
||||
if (primaryKeyColumns.length > 1) {
|
||||
throw new ToolError(`SQLite table '${table}' has a composite primary key; use '?where=' instead`);
|
||||
}
|
||||
|
||||
const schema = getTableSchema(db, table);
|
||||
if (/\bWITHOUT\s+ROWID\b/i.test(schema)) {
|
||||
throw new ToolError(`SQLite table '${table}' does not expose ROWID; use '?where=' instead`);
|
||||
}
|
||||
|
||||
return { kind: "rowid" };
|
||||
}
|
||||
|
||||
export function queryRows(
|
||||
db: Database,
|
||||
table: string,
|
||||
opts: { limit: number; offset: number; order?: string; where?: string },
|
||||
): { columns: string[]; rows: Record<string, unknown>[]; totalCount: number } {
|
||||
const columns = getTableColumns(db, table);
|
||||
const whereClause = opts.where?.trim() ? ` WHERE ${opts.where.trim()}` : "";
|
||||
const orderClause = resolveOrderClause(opts.order, columns);
|
||||
const countSql = `SELECT COUNT(*) AS count FROM ${quoteSqliteIdentifier(table)}${whereClause}`;
|
||||
const selectSql = `SELECT * FROM ${quoteSqliteIdentifier(table)}${whereClause}${orderClause} LIMIT ? OFFSET ?`;
|
||||
const totalCount = db.prepare<SqliteCountRow, []>(countSql).get()?.count ?? 0;
|
||||
const rows = db.prepare<SqliteRow, SQLQueryBindings[]>(selectSql).all(opts.limit, opts.offset);
|
||||
return { columns, rows, totalCount };
|
||||
}
|
||||
|
||||
export function getRowByKey(
|
||||
db: Database,
|
||||
table: string,
|
||||
pk: { column: string; type?: string },
|
||||
key: string,
|
||||
): Record<string, unknown> | null {
|
||||
getTableMasterRow(db, table);
|
||||
const sql = `SELECT * FROM ${quoteSqliteIdentifier(table)} WHERE ${quoteSqliteIdentifier(pk.column)} = ? LIMIT 1`;
|
||||
const binding = coerceLookupValue(key, pk.type ?? "");
|
||||
return db.prepare<SqliteRow, SQLQueryBindings[]>(sql).get(binding);
|
||||
}
|
||||
|
||||
export function getRowByRowId(db: Database, table: string, key: string): Record<string, unknown> | null {
|
||||
getTableMasterRow(db, table);
|
||||
const binding = coerceIntegerKey(key, "SQLite ROWID");
|
||||
return db
|
||||
.prepare<SqliteRow, SQLQueryBindings[]>(`SELECT * FROM ${quoteSqliteIdentifier(table)} WHERE rowid = ? LIMIT 1`)
|
||||
.get(binding);
|
||||
}
|
||||
|
||||
export function executeReadQuery(db: Database, sql: string): { columns: string[]; rows: Record<string, unknown>[] } {
|
||||
const statement = db.prepare<SqliteRow, []>(sql);
|
||||
if (statement.paramsCount > 0) {
|
||||
throw new ToolError("SQLite raw queries do not support bound parameters");
|
||||
}
|
||||
return {
|
||||
columns: [...statement.columnNames],
|
||||
rows: statement.all(),
|
||||
};
|
||||
}
|
||||
|
||||
export function insertRow(db: Database, table: string, data: Record<string, unknown>): void {
|
||||
getTableMasterRow(db, table);
|
||||
const entries = validateWriteColumns(db, table, data);
|
||||
if (entries.length === 0) {
|
||||
db.run(`INSERT INTO ${quoteSqliteIdentifier(table)} DEFAULT VALUES`);
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = entries.map(([column]) => quoteSqliteIdentifier(column)).join(", ");
|
||||
const placeholders = entries.map(() => "?").join(", ");
|
||||
const bindings = entries.map(([, value]) => value);
|
||||
const statement = db.prepare<SqliteRow, SQLQueryBindings[]>(
|
||||
`INSERT INTO ${quoteSqliteIdentifier(table)} (${columns}) VALUES (${placeholders})`,
|
||||
);
|
||||
statement.run(...bindings);
|
||||
}
|
||||
|
||||
export function updateRowByKey(
|
||||
db: Database,
|
||||
table: string,
|
||||
pk: { column: string; type?: string },
|
||||
key: string,
|
||||
data: Record<string, unknown>,
|
||||
): number {
|
||||
getTableMasterRow(db, table);
|
||||
const entries = validateWriteColumns(db, table, data);
|
||||
if (entries.length === 0) {
|
||||
throw new ToolError("SQLite updates require at least one column value");
|
||||
}
|
||||
|
||||
const assignments = entries.map(([column]) => `${quoteSqliteIdentifier(column)} = ?`).join(", ");
|
||||
const bindings = entries.map(([, value]) => value);
|
||||
bindings.push(coerceLookupValue(key, pk.type ?? ""));
|
||||
const statement = db.prepare<SqliteRow, SQLQueryBindings[]>(
|
||||
`UPDATE ${quoteSqliteIdentifier(table)} SET ${assignments} WHERE ${quoteSqliteIdentifier(pk.column)} = ?`,
|
||||
);
|
||||
return statement.run(...bindings).changes;
|
||||
}
|
||||
|
||||
export function updateRowByRowId(db: Database, table: string, key: string, data: Record<string, unknown>): number {
|
||||
getTableMasterRow(db, table);
|
||||
const entries = validateWriteColumns(db, table, data);
|
||||
if (entries.length === 0) {
|
||||
throw new ToolError("SQLite updates require at least one column value");
|
||||
}
|
||||
|
||||
const assignments = entries.map(([column]) => `${quoteSqliteIdentifier(column)} = ?`).join(", ");
|
||||
const bindings = entries.map(([, value]) => value);
|
||||
bindings.push(coerceIntegerKey(key, "SQLite ROWID"));
|
||||
const statement = db.prepare<SqliteRow, SQLQueryBindings[]>(
|
||||
`UPDATE ${quoteSqliteIdentifier(table)} SET ${assignments} WHERE rowid = ?`,
|
||||
);
|
||||
return statement.run(...bindings).changes;
|
||||
}
|
||||
|
||||
export function deleteRowByKey(
|
||||
db: Database,
|
||||
table: string,
|
||||
pk: { column: string; type?: string },
|
||||
key: string,
|
||||
): number {
|
||||
getTableMasterRow(db, table);
|
||||
const binding = coerceLookupValue(key, pk.type ?? "");
|
||||
const statement = db.prepare<SqliteRow, SQLQueryBindings[]>(
|
||||
`DELETE FROM ${quoteSqliteIdentifier(table)} WHERE ${quoteSqliteIdentifier(pk.column)} = ?`,
|
||||
);
|
||||
return statement.run(binding).changes;
|
||||
}
|
||||
|
||||
export function deleteRowByRowId(db: Database, table: string, key: string): number {
|
||||
getTableMasterRow(db, table);
|
||||
const binding = coerceIntegerKey(key, "SQLite ROWID");
|
||||
const statement = db.prepare<SqliteRow, SQLQueryBindings[]>(
|
||||
`DELETE FROM ${quoteSqliteIdentifier(table)} WHERE rowid = ?`,
|
||||
);
|
||||
return statement.run(binding).changes;
|
||||
}
|
||||
|
||||
export function renderTableList(tables: { name: string; rowCount: number }[]): string {
|
||||
if (tables.length === 0) {
|
||||
return "(no tables)";
|
||||
}
|
||||
|
||||
return tables
|
||||
.map(table => truncateToWidth(replaceTabs(`${table.name} (${table.rowCount} rows)`), MAX_RENDER_WIDTH))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function renderSchema(
|
||||
createSql: string,
|
||||
sampleRows: { columns: string[]; rows: Record<string, unknown>[] },
|
||||
): string {
|
||||
const schemaLines = replaceTabs(createSql)
|
||||
.split("\n")
|
||||
.map(line => truncateToWidth(line, MAX_RENDER_WIDTH));
|
||||
const parts = [schemaLines.join("\n"), "", "Sample rows:", buildAsciiTable(sampleRows.columns, sampleRows.rows)];
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export function renderRow(row: Record<string, unknown>): string {
|
||||
const entries = Object.entries(row);
|
||||
if (entries.length === 0) {
|
||||
return "(no columns)";
|
||||
}
|
||||
|
||||
return entries
|
||||
.map(([column, value]) =>
|
||||
truncateToWidth(replaceTabs(`${column}: ${stringifySqliteValue(value)}`), MAX_RENDER_WIDTH),
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function renderTable(
|
||||
columns: string[],
|
||||
rows: Record<string, unknown>[],
|
||||
meta: { totalCount: number; offset: number; limit: number; table: string; dbPath: string },
|
||||
): string {
|
||||
const parts = [buildAsciiTable(columns, rows)];
|
||||
const shown = Math.min(meta.totalCount, meta.offset + rows.length);
|
||||
if (shown < meta.totalCount) {
|
||||
const remaining = meta.totalCount - shown;
|
||||
const nextOffset = meta.offset + rows.length;
|
||||
parts.push(
|
||||
truncateToWidth(
|
||||
replaceTabs(
|
||||
`[${remaining} more rows; use sel="${meta.table}?limit=${meta.limit}&offset=${nextOffset}" to continue]`,
|
||||
),
|
||||
MAX_RENDER_WIDTH,
|
||||
),
|
||||
);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type {
|
||||
@@ -9,7 +10,7 @@ import type {
|
||||
} from "@oh-my-pi/pi-agent-core";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { unzipSync, zipSync } from "fflate";
|
||||
import { stripHashlinePrefixes } from "../edit";
|
||||
@@ -34,7 +35,18 @@ import {
|
||||
replaceTabs,
|
||||
shortenPath,
|
||||
} from "./render-utils";
|
||||
import {
|
||||
deleteRowByKey,
|
||||
deleteRowByRowId,
|
||||
insertRow,
|
||||
isSqliteFile,
|
||||
parseSqlitePathCandidates,
|
||||
resolveTableRowLookup,
|
||||
updateRowByKey,
|
||||
updateRowByRowId,
|
||||
} from "./sqlite-reader";
|
||||
import { ToolError } from "./tool-errors";
|
||||
import { toolResult } from "./tool-result";
|
||||
|
||||
const writeSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
|
||||
@@ -94,6 +106,14 @@ interface ResolvedArchiveWritePath {
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
interface ResolvedSqliteWritePath {
|
||||
absolutePath: string;
|
||||
sqlitePath: string;
|
||||
table: string;
|
||||
key?: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
function isArchivePathNotFound(error: unknown): boolean {
|
||||
if (isEnoent(error)) return true;
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOTDIR";
|
||||
@@ -125,6 +145,29 @@ function normalizeArchiveWriteSubPath(rawPath: string): string {
|
||||
return normalizedParts.join("/");
|
||||
}
|
||||
|
||||
function parseSqliteWriteTarget(subPath: string, queryString: string): { table: string; key?: string } {
|
||||
if (queryString.trim().length > 0) {
|
||||
throw new ToolError("SQLite write paths do not support query parameters");
|
||||
}
|
||||
|
||||
const normalized = subPath.replace(/^:+/, "").trim();
|
||||
if (!normalized) {
|
||||
throw new ToolError("SQLite write path must target a table");
|
||||
}
|
||||
|
||||
const separatorIndex = normalized.indexOf(":");
|
||||
const table = separatorIndex === -1 ? normalized : normalized.slice(0, separatorIndex);
|
||||
const key = separatorIndex === -1 ? undefined : normalized.slice(separatorIndex + 1);
|
||||
if (!table) {
|
||||
throw new ToolError("SQLite write path must target a table");
|
||||
}
|
||||
if (key !== undefined && key.length === 0) {
|
||||
throw new ToolError("SQLite row writes require a non-empty row key");
|
||||
}
|
||||
|
||||
return { table, key };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write tool implementation.
|
||||
*
|
||||
@@ -262,6 +305,132 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
||||
};
|
||||
}
|
||||
|
||||
async #resolveSqliteWritePath(writePath: string): Promise<ResolvedSqliteWritePath | null> {
|
||||
const candidates = parseSqlitePathCandidates(writePath).filter(candidate => candidate.sqlitePath !== writePath);
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackCandidate = candidates[candidates.length - 1]!;
|
||||
const fallbackTarget = parseSqliteWriteTarget(fallbackCandidate.subPath, fallbackCandidate.queryString);
|
||||
const fallback: ResolvedSqliteWritePath = {
|
||||
absolutePath: resolvePlanPath(this.session, fallbackCandidate.sqlitePath),
|
||||
sqlitePath: fallbackCandidate.sqlitePath,
|
||||
table: fallbackTarget.table,
|
||||
key: fallbackTarget.key,
|
||||
exists: false,
|
||||
};
|
||||
|
||||
let sawExistingNonSqlite = false;
|
||||
for (const candidate of candidates) {
|
||||
const target = parseSqliteWriteTarget(candidate.subPath, candidate.queryString);
|
||||
const absolutePath = resolvePlanPath(this.session, candidate.sqlitePath);
|
||||
try {
|
||||
const stat = await Bun.file(absolutePath).stat();
|
||||
if (stat.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (!(await isSqliteFile(absolutePath))) {
|
||||
sawExistingNonSqlite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath,
|
||||
sqlitePath: candidate.sqlitePath,
|
||||
table: target.table,
|
||||
key: target.key,
|
||||
exists: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isArchivePathNotFound(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sawExistingNonSqlite) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async #writeSqliteRow(
|
||||
displayPath: string,
|
||||
content: string,
|
||||
resolvedSqlitePath: ResolvedSqliteWritePath,
|
||||
): Promise<AgentToolResult<WriteToolDetails>> {
|
||||
let db: Database | null = null;
|
||||
try {
|
||||
if (!resolvedSqlitePath.exists) {
|
||||
throw new ToolError(`SQLite database '${displayPath}' not found`);
|
||||
}
|
||||
|
||||
db = new Database(resolvedSqlitePath.absolutePath, { create: false, strict: true });
|
||||
db.run("PRAGMA busy_timeout = 3000");
|
||||
|
||||
const trimmedContent = content.trim();
|
||||
let resultText: string;
|
||||
if (trimmedContent.length === 0) {
|
||||
if (!resolvedSqlitePath.key) {
|
||||
throw new ToolError("SQLite deletes require a row key in the path");
|
||||
}
|
||||
|
||||
const lookup = resolveTableRowLookup(db, resolvedSqlitePath.table);
|
||||
const deleted =
|
||||
lookup.kind === "pk"
|
||||
? deleteRowByKey(db, resolvedSqlitePath.table, lookup, resolvedSqlitePath.key)
|
||||
: deleteRowByRowId(db, resolvedSqlitePath.table, resolvedSqlitePath.key);
|
||||
resultText =
|
||||
deleted > 0
|
||||
? `Deleted row '${resolvedSqlitePath.key}' from ${resolvedSqlitePath.table}`
|
||||
: `No row deleted from ${resolvedSqlitePath.table} for key '${resolvedSqlitePath.key}'`;
|
||||
} else {
|
||||
let parsedContent: unknown;
|
||||
try {
|
||||
parsedContent = Bun.JSON5.parse(content);
|
||||
} catch (error) {
|
||||
throw new ToolError(
|
||||
`SQLite write content must be valid JSON5: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecord(parsedContent)) {
|
||||
throw new ToolError("SQLite write content must be a JSON object");
|
||||
}
|
||||
|
||||
if (resolvedSqlitePath.key) {
|
||||
const lookup = resolveTableRowLookup(db, resolvedSqlitePath.table);
|
||||
const updated =
|
||||
lookup.kind === "pk"
|
||||
? updateRowByKey(db, resolvedSqlitePath.table, lookup, resolvedSqlitePath.key, parsedContent)
|
||||
: updateRowByRowId(db, resolvedSqlitePath.table, resolvedSqlitePath.key, parsedContent);
|
||||
resultText =
|
||||
updated > 0
|
||||
? `Updated row '${resolvedSqlitePath.key}' in ${resolvedSqlitePath.table}`
|
||||
: `No row updated in ${resolvedSqlitePath.table} for key '${resolvedSqlitePath.key}'`;
|
||||
} else {
|
||||
insertRow(db, resolvedSqlitePath.table, parsedContent);
|
||||
resultText = `Inserted row into ${resolvedSqlitePath.table}`;
|
||||
}
|
||||
}
|
||||
|
||||
invalidateFsScanAfterWrite(resolvedSqlitePath.absolutePath);
|
||||
return toolResult<WriteToolDetails>({}).text(resultText).sourcePath(resolvedSqlitePath.absolutePath).done();
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) {
|
||||
throw new ToolError(`SQLite database '${displayPath}' not found`);
|
||||
}
|
||||
if (error instanceof ToolError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ToolError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
{ path, content }: WriteParams,
|
||||
@@ -291,6 +460,23 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
||||
return archiveResult;
|
||||
}
|
||||
|
||||
const resolvedSqlitePath = await this.#resolveSqliteWritePath(path);
|
||||
if (resolvedSqlitePath) {
|
||||
enforcePlanModeWrite(this.session, resolvedSqlitePath.sqlitePath, { op: "update" });
|
||||
|
||||
const sqliteResult = await this.#writeSqliteRow(path, cleanContent, resolvedSqlitePath);
|
||||
if (stripped) {
|
||||
const firstText = sqliteResult.content.find(
|
||||
(block): block is { type: "text"; text: string } =>
|
||||
block.type === "text" && typeof block.text === "string",
|
||||
);
|
||||
if (firstText) {
|
||||
firstText.text += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
|
||||
}
|
||||
}
|
||||
return sqliteResult;
|
||||
}
|
||||
|
||||
enforcePlanModeWrite(this.session, path, { op: "create" });
|
||||
const absolutePath = resolvePlanPath(this.session, path);
|
||||
const batchRequest = getLspBatchRequest(context?.toolCall);
|
||||
|
||||
Reference in New Issue
Block a user