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:
can1357
2026-04-11 08:57:58 +02:00
parent 90f1672b2b
commit 2cd8f52f4e
8 changed files with 1461 additions and 3 deletions
+218 -1
View File
@@ -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");
}
+187 -1
View File
@@ -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);