diff --git a/docs/SUPPORTED-CLIENTS.md b/docs/SUPPORTED-CLIENTS.md index 80f4ebe..fedd505 100644 --- a/docs/SUPPORTED-CLIENTS.md +++ b/docs/SUPPORTED-CLIENTS.md @@ -284,7 +284,7 @@ segmentation, as recorded below. | Mistral Vibe | Setup-managed `vibe-incognito` and `mistral-vibe-incognito` commands | No | Wrapper/native sessions eligible when non-headless; headless skipped | Wrapper/native sessions eligible when non-headless; headless skipped | `privacy-features` E2E positive fixture proof; setup/inventory and command-behavior coverage | | Grok Build | Setup-managed `grok-incognito` command and `trajectory-incognito` skill | No | Source-only sessions remain headless/unknown; explicit native mode may become eligible | Source-only sessions remain headless/unknown; explicit native mode may become eligible | Fixture/control-plane proof; authenticated native-hook and incognito UX pilot pending | | Codebuff | Setup-managed `codebuff-incognito` and `cb-incognito` commands | No | Wrapper/imported sessions eligible when non-headless; headless skipped | Wrapper/imported sessions eligible when non-headless; headless skipped | `privacy-features` E2E positive fixture proof; setup/inventory and command-behavior coverage | -| Pi | Native `trajectory_incognito` tool plus MCP | Yes | Non-headless extension sessions eligible; extension-supplied verdicts accepted; headless skipped | Non-headless extension sessions eligible; headless skipped | Live incognito UX; `privacy-features` E2E positive fixture proof; extension verdict tests | +| Pi | MCP request path | Yes | Non-headless extension sessions eligible; extension-supplied verdicts accepted; headless skipped | Non-headless extension sessions eligible; headless skipped | Live incognito UX; `privacy-features` E2E positive fixture proof; extension verdict tests | | Oh My Pi (`omp`) | MCP request path; no setup-managed slash command yet | Yes | Native extension marks headless state; non-headless sessions eligible, headless skipped | Non-headless sessions eligible; headless skipped | Setup and capture fixtures only; live incognito UX and positive privacy-feature proof pending | | OpenCode | Incognito skill | Yes | Non-headless plugin SDK sessions eligible; headless skipped | Non-headless plugin SDK sessions eligible; headless skipped | Live incognito UX; `privacy-features` E2E positive fixture proof | | Kilo Code | Incognito skill | Yes | Non-headless plugin SDK sessions eligible; headless skipped | Non-headless plugin SDK sessions eligible; headless skipped | `privacy-features` E2E positive fixture proof; setup/live capture coverage | @@ -1768,9 +1768,9 @@ cp -R /path/to/trajectory/plugin/trajectory-pi ~/.pi/agent/extensions/trajectory Then point `~/.pi/agent/mcp.json` at `~/.pi/agent/extensions/trajectory/bin/trajectory mcp`. -Setup writes `~/.pi/agent/extensions/trajectory/` with a `package.json` that declares `pi.extensions: ["./src/index.ts"]`, plus a root `index.ts` shim that re-exports `./src/index.ts`, and points `~/.pi/agent/mcp.json` at the extension-local `bin/trajectory mcp` command. Setup does not add Trajectory's extension entrypoint to `~/.pi/agent/settings.json`; Pi discovers the extension from its standard extensions directory. Pi uses a TypeScript extension API (`pi.on("event", handler)`) that subscribes to lifecycle events (session_start, agent_end, tool_call, tool_result, etc.) and queues every captured event through `trajectory capture-hook --client pi`; the helper safely retries retirement resets with one delivery ID. Native `turn_end` is one provider request/tool-loop step, so the extension retains that request usage on `AgentMessage`/`llm_call` but finalizes a Trajectory turn only at `agent_end`, using the complete native agent-run usage aggregate and a stable source event ID. The extension emits `PostCompact` and registers `trajectory_status`, `trajectory_flush`, `trajectory_incognito`, `trajectory_schema`, and `trajectory_query`; MCP exposes the shared cross-client tool surface in environments where Pi routes MCP tools. Pi supports multiple LLM providers - use any provider API key for testing. +Setup writes `~/.pi/agent/extensions/trajectory/` with a `package.json` that declares `pi.extensions: ["./src/index.ts"]`, plus a root `index.ts` shim that re-exports `./src/index.ts`, and points `~/.pi/agent/mcp.json` at the extension-local `bin/trajectory mcp` command. Setup does not add Trajectory's extension entrypoint to `~/.pi/agent/settings.json`; Pi discovers the extension from its standard extensions directory. Pi uses a TypeScript extension API (`pi.on("event", handler)`) that subscribes to lifecycle events (session_start, agent_end, tool_call, tool_result, etc.) and queues every captured event through `trajectory capture-hook --client pi`; the helper safely retries retirement resets with one delivery ID. Native `turn_end` is one provider request/tool-loop step, so the extension retains that request usage on `AgentMessage`/`llm_call` but finalizes a Trajectory turn only at `agent_end`, using the complete native agent-run usage aggregate and a stable source event ID. The extension emits `PostCompact` without registering native Pi tools, keeping Trajectory tool schemas out of the model context by default. MCP exposes the shared cross-client tool surface in environments where Pi routes MCP tools. Pi supports multiple LLM providers - use any provider API key for testing. -Pi does not currently consume the Codex/Claude-style `skills/` plugin directory. The Trajectory Pi extension vends incognito through its native `trajectory_incognito` tool; environments that expose MCP can also use the shared `trajectory_incognito` MCP tool. +Pi does not currently consume the Codex/Claude-style `skills/` plugin directory. Environments that expose MCP can use the shared `trajectory_incognito` MCP tool. Current Pi reports fork and new-session transitions through `session_start`. The extension preserves the exact provider session IDs and records a canonical diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index bb92ccd..c94f583 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -504,10 +504,10 @@ trajectory user-guide cursor-cost # Cursor token formula, rate provenance, a The current OSS binary does not expose a general-purpose `trajectory query` CLI. Use `trajectory status`, `trajectory local-ui`, `get_session_trajectory`, and -the `trajectory_schema` / `trajectory_query` tools for local inspection. Pi -registers those as native extension tools; setup-managed MCP clients get the -same schema-first workflow through `trajectory mcp`. The embedded query guide -documents schema-first inspection and `TRAJECTORY_CACHE_DB` handling. +the `trajectory_schema` / `trajectory_query` MCP tools for local inspection. +The Pi extension does not register native tools, keeping their schemas out of +the model context by default. The embedded query guide documents schema-first +inspection and `TRAJECTORY_CACHE_DB` handling. Use `trajectory cost` for local cost tracking. It reads the local SQLite cache, automatically repairs obsolete Codex token/cost projections when needed, shows @@ -871,7 +871,7 @@ sensitivity classification and segmentation. | Cursor Desktop | Incognito skill, using Claude skill when available or native Cursor fallback; setup also installs `cursor-agent-incognito` | Yes | Non-headless GUI sessions eligible; headless skipped | Non-headless GUI sessions eligible; headless skipped | Punted for positive privacy-feature proof: GUI/transcript watcher path has no stable credential-free non-headless hook stream | | cursor-agent CLI | Setup-managed `cursor-agent-incognito` command when the Cursor integration is installed; watcher has no native slash surface | No | Passive history is local-only and replay-ineligible; native hook sessions use their proven surface | Passive history is local-only and replay-ineligible; native hook sessions use their proven surface | Protected `cursor-agent --print` native/passive identity gate; shared passive store remains surface-unknown | | Factory Droid | Incognito skill in the local marketplace plugin | Yes | Non-headless plugin sessions eligible; headless skipped | Non-headless plugin sessions eligible; headless skipped | `privacy-features` E2E positive fixture proof; no live Droid incognito UX gate yet | -| Pi | Native `trajectory_incognito` tool plus MCP | Yes | Non-headless extension sessions eligible; extension-supplied verdicts accepted; headless skipped | Non-headless extension sessions eligible; headless skipped | Live incognito UX; `privacy-features` E2E positive fixture proof; extension verdict tests | +| Pi | MCP request path | Yes | Non-headless extension sessions eligible; extension-supplied verdicts accepted; headless skipped | Non-headless extension sessions eligible; headless skipped | Live incognito UX; `privacy-features` E2E positive fixture proof; extension verdict tests | | Oh My Pi (`omp`) | MCP request path; no setup-managed slash command yet | Yes | Native extension marks headless state; non-headless eligible, headless skipped | Non-headless eligible; headless skipped | Sanitized v16.5.2 setup/capture/backfill fixtures; real executable and positive privacy proof pending | | Hermes Agent | Incognito skill | Yes | Non-headless observer sessions eligible; headless skipped | Non-headless observer sessions eligible; headless skipped | `privacy-features` E2E positive fixture proof; protected live capture coverage; no live incognito UX gate yet | | Amp Code | Setup-managed `amp-incognito` command plus MCP request path | Yes | Non-headless Amp plugin sessions eligible; headless skipped | Non-headless Amp plugin sessions eligible; headless skipped | `privacy-features` E2E positive fixture proof until a usable `AMP_API_KEY` exists | diff --git a/plugin/trajectory-pi/README.md b/plugin/trajectory-pi/README.md index 6ef98ef..ab7789f 100644 --- a/plugin/trajectory-pi/README.md +++ b/plugin/trajectory-pi/README.md @@ -44,4 +44,4 @@ extension is not an OhMyPi live-capture package. ## Tools -The extension registers `trajectory_status`, `trajectory_flush`, and `trajectory_incognito`. `trajectory_incognito` toggles publish suppression for the current Pi session while local JSONL capture continues. +The extension does not register native Pi tools, keeping Trajectory tool schemas out of the model context by default. Tool access remains available through the setup-managed MCP configuration when explicitly used. diff --git a/plugin/trajectory-pi/package-lock.json b/plugin/trajectory-pi/package-lock.json index 65860c0..4ae9b15 100644 --- a/plugin/trajectory-pi/package-lock.json +++ b/plugin/trajectory-pi/package-lock.json @@ -12,13 +12,11 @@ "@earendil-works/pi-ai": "0.83.0", "@earendil-works/pi-coding-agent": "0.83.0", "@types/node": "22.20.1", - "typebox": "1.3.10", "typescript": "7.0.2" }, "peerDependencies": { "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*", - "typebox": "*" + "@earendil-works/pi-coding-agent": "*" } }, "node_modules/@anthropic-ai/sdk": { @@ -3556,13 +3554,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/typebox": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.10.tgz", - "integrity": "sha512-L0MT00X96q0P30f1NrGULgaSbmu8hfTjWbLULeVoM+j5u0jR5wyefoDquX2NmRa39f0KiNIBo1wWSaVvHJTZlA==", - "dev": true, - "license": "MIT" - }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", diff --git a/plugin/trajectory-pi/package.json b/plugin/trajectory-pi/package.json index 2053282..2f0bbcc 100644 --- a/plugin/trajectory-pi/package.json +++ b/plugin/trajectory-pi/package.json @@ -17,13 +17,11 @@ "@earendil-works/pi-ai": "0.83.0", "@earendil-works/pi-coding-agent": "0.83.0", "@types/node": "22.20.1", - "typebox": "1.3.10", "typescript": "7.0.2" }, "peerDependencies": { "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*", - "typebox": "*" + "@earendil-works/pi-coding-agent": "*" }, "license": "Apache-2.0", "repository": { diff --git a/plugin/trajectory-pi/src/index.ts b/plugin/trajectory-pi/src/index.ts index ed41c7e..9feec2c 100644 --- a/plugin/trajectory-pi/src/index.ts +++ b/plugin/trajectory-pi/src/index.ts @@ -10,18 +10,15 @@ */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { Type } from "typebox"; import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { BoundedSerialQueue } from "./async-queue.js"; import { aggregatePiAgentRun, PiAgentRunTracker } from "./agent-run.js"; -import { buildTrajectorySchema, runTrajectoryQuery } from "./query-tools.js"; import { piSessionIdentityFields, readPiSessionHeaderId } from "./session-identity.js"; import { ensureTrajectoryServe as requestTrajectoryServe } from "./serve-ensure.js"; const DEFAULT_PORT = 19222; -const POST_TIMEOUT_MS = 2000; const CAPTURE_HELPER_TIMEOUT_MS = 5000; const CAPTURE_QUEUE_CAPACITY = 256; const LIFECYCLE_QUEUE_CAPACITY = 64; @@ -72,7 +69,6 @@ function withPluginProvenance(body: Record): Record; - limit?: number; - row_limit?: number; - }); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - details: { result }, - isError: !result.ok, - }; - }, - }); - // -- CLI fallback for session lifecycle events ------------------- // Session lifecycle events (start/end) use CLI fallback to write directly // to JSONL, independent of serve availability. Matches CC plugin pattern. diff --git a/plugin/trajectory-pi/src/query-tools.ts b/plugin/trajectory-pi/src/query-tools.ts deleted file mode 100644 index c43ae5e..0000000 --- a/plugin/trajectory-pi/src/query-tools.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { createHash } from "node:crypto"; -import { homedir } from "node:os"; -import { join } from "node:path"; - -const DEFAULT_DB_RELATIVE_PATH = [".trajectory", "trajectories", "cache", "trajectory.db"]; -const DEFAULT_QUERY_LIMIT = 100; -const MAX_QUERY_LIMIT = 1000; -const SQLITE_TIMEOUT_MS = 5000; -const SQLITE_MAX_BUFFER = 10 * 1024 * 1024; - -export type QueryParams = Record; - -export interface QueryToolInput { - query: string; - params?: QueryParams; - limit?: number; - row_limit?: number; -} - -export interface QueryResult { - ok: boolean; - db_path: string; - query: string; - rows: Array>; - meta: { - allowed_keyword: string; - limit: number; - returned: number; - truncated: boolean; - duration_ms: number; - }; - error?: string; - generated_at: string; -} - -export interface SchemaResult { - ok: boolean; - db_path: string; - generated_at: string; - schema_hash?: string; - tables: Array<{ - name: string; - columns: Array>; - row_count?: number; - }>; - indexes: Array>; - error?: string; -} - -export function resolveTrajectoryDbPath(env: Record = process.env): string { - if (env.TRAJECTORY_CACHE_DB?.trim()) return env.TRAJECTORY_CACHE_DB.trim(); - return join(homedir(), ...DEFAULT_DB_RELATIVE_PATH); -} - -export function buildTrajectorySchema( - includeRowCounts = false, - env: Record = process.env, -): SchemaResult { - const dbPath = resolveTrajectoryDbPath(env); - const result: SchemaResult = { - ok: false, - db_path: dbPath, - generated_at: new Date().toISOString(), - tables: [], - indexes: [], - }; - - try { - assertReadableDb(dbPath); - const tableRows = runSQLiteJSON<{ name: string }>( - dbPath, - "SELECT name FROM sqlite_schema WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name", - ); - const tables = tableRows.map((row) => { - const columns = runSQLiteJSON>(dbPath, `PRAGMA table_xinfo(${quoteSQLiteString(row.name)})`); - const table: SchemaResult["tables"][number] = { - name: row.name, - columns: columns.map((column) => ({ - name: column.name, - type: column.type, - notnull: Boolean(column.notnull), - default_value: column.dflt_value ?? null, - pk: Number(column.pk ?? 0), - hidden: Number(column.hidden ?? 0), - })), - }; - if (includeRowCounts) { - const countRows = runSQLiteJSON<{ count: number }>( - dbPath, - `SELECT COUNT(*) AS count FROM ${quoteSQLiteIdentifier(row.name)}`, - ); - table.row_count = Number(countRows[0]?.count ?? 0); - } - return table; - }); - - const indexes = runSQLiteJSON>( - dbPath, - "SELECT name, tbl_name AS 'table', sql FROM sqlite_schema WHERE type = 'index' ORDER BY tbl_name, name", - ); - result.ok = true; - result.tables = tables; - result.indexes = indexes; - result.schema_hash = createHash("sha256").update(JSON.stringify({ tables, indexes })).digest("hex"); - } catch (err) { - result.error = err instanceof Error ? err.message : String(err); - } - - return result; -} - -export function runTrajectoryQuery( - input: QueryToolInput, - env: Record = process.env, -): QueryResult { - const dbPath = resolveTrajectoryDbPath(env); - const query = String(input.query ?? "").trim(); - const limit = clampQueryLimit(input.limit ?? input.row_limit); - const startedAt = Date.now(); - const baseMeta = { - allowed_keyword: "", - limit, - returned: 0, - truncated: false, - duration_ms: 0, - }; - - try { - assertReadableDb(dbPath); - const keyword = firstSQLKeyword(query); - baseMeta.allowed_keyword = keyword; - if (!isAllowedKeyword(keyword)) { - throw new Error(`SQL keyword ${JSON.stringify(keyword)} is not allowed; use SELECT, WITH, or PRAGMA`); - } - assertSingleStatement(query); - - const substituted = substituteNamedParams(stripTrailingSemicolon(query), input.params ?? {}); - const bounded = keyword === "SELECT" || keyword === "WITH" - ? `SELECT * FROM (${substituted}) LIMIT ${limit + 1}` - : substituted; - const rawRows = runSQLiteJSON>(dbPath, bounded); - const rows = rawRows.slice(0, limit); - const truncated = rawRows.length > limit; - return { - ok: true, - db_path: dbPath, - query, - rows, - meta: { - allowed_keyword: keyword, - limit, - returned: rows.length, - truncated, - duration_ms: Date.now() - startedAt, - }, - generated_at: new Date().toISOString(), - }; - } catch (err) { - return { - ok: false, - db_path: dbPath, - query, - rows: [], - meta: { - ...baseMeta, - duration_ms: Date.now() - startedAt, - }, - error: err instanceof Error ? err.message : String(err), - generated_at: new Date().toISOString(), - }; - } -} - -export function firstSQLKeyword(query: string): string { - const stripped = stripSQLLeadingComments(query).trim(); - if (!stripped) throw new Error("query is empty"); - const match = /^[A-Za-z]+/.exec(stripped); - if (!match) throw new Error("query must start with a SQL keyword"); - return match[0].toUpperCase(); -} - -function isAllowedKeyword(keyword: string): boolean { - return keyword === "SELECT" || keyword === "WITH" || keyword === "PRAGMA"; -} - -function assertReadableDb(dbPath: string): void { - if (!dbPath.trim()) throw new Error("database path is empty"); - if (!existsSync(dbPath)) throw new Error(`database not found: ${dbPath}`); -} - -function runSQLiteJSON(dbPath: string, sql: string): T[] { - const stdout = execFileSync("sqlite3", ["-readonly", "-json", dbPath], { - input: `PRAGMA query_only=ON;\n${sql};\n`, - encoding: "utf8", - timeout: SQLITE_TIMEOUT_MS, - maxBuffer: SQLITE_MAX_BUFFER, - }); - const trimmed = stdout.trim(); - if (!trimmed) return []; - return JSON.parse(trimmed) as T[]; -} - -function stripSQLLeadingComments(query: string): string { - let s = query.trimStart(); - for (;;) { - if (s.startsWith("--")) { - const newline = s.indexOf("\n"); - if (newline < 0) return ""; - s = s.slice(newline + 1).trimStart(); - continue; - } - if (s.startsWith("/*")) { - const end = s.indexOf("*/", 2); - if (end < 0) throw new Error("unterminated block comment before SQL keyword"); - s = s.slice(end + 2).trimStart(); - continue; - } - return s; - } -} - -function assertSingleStatement(query: string): void { - const stripped = stripTrailingSemicolon(query); - if (stripped.includes(";")) { - throw new Error("multiple SQL statements are not allowed"); - } -} - -function stripTrailingSemicolon(query: string): string { - let stripped = query.trim(); - if (stripped.endsWith(";")) stripped = stripped.slice(0, -1).trimEnd(); - return stripped; -} - -function substituteNamedParams(query: string, params: QueryParams): string { - let out = query; - for (const [key, value] of Object.entries(params)) { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { - throw new Error(`invalid SQL parameter name: ${key}`); - } - const pattern = new RegExp(`:${key}\\b`, "g"); - out = out.replace(pattern, sqlLiteral(value)); - } - if (/:([A-Za-z_][A-Za-z0-9_]*)\b/.test(out)) { - throw new Error("query contains an unbound named parameter"); - } - return out; -} - -function sqlLiteral(value: string | number | boolean | null): string { - if (value === null) return "NULL"; - if (typeof value === "number") { - if (!Number.isFinite(value)) throw new Error("numeric SQL parameters must be finite"); - return String(value); - } - if (typeof value === "boolean") return value ? "1" : "0"; - return quoteSQLiteString(value); -} - -function clampQueryLimit(raw: unknown): number { - const value = Number(raw ?? DEFAULT_QUERY_LIMIT); - if (!Number.isFinite(value) || value <= 0) return DEFAULT_QUERY_LIMIT; - return Math.min(Math.floor(value), MAX_QUERY_LIMIT); -} - -function quoteSQLiteString(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - -function quoteSQLiteIdentifier(value: string): string { - return `"${value.replaceAll('"', '""')}"`; -} diff --git a/plugin/trajectory-pi/test/query-tools.test.ts b/plugin/trajectory-pi/test/query-tools.test.ts deleted file mode 100644 index 2b3da0a..0000000 --- a/plugin/trajectory-pi/test/query-tools.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; - -import { buildTrajectorySchema, firstSQLKeyword, runTrajectoryQuery } from "../src/query-tools.ts"; - -test("buildTrajectorySchema returns tables, columns, row counts, and schema hash", () => { - const dir = mkdtempSync(join(tmpdir(), "trajectory-pi-query-")); - const dbPath = join(dir, "trajectory.db"); - try { - createFixtureDb(dbPath); - const result = buildTrajectorySchema(true, { TRAJECTORY_CACHE_DB: dbPath }); - - assert.equal(result.ok, true, result.error); - assert.equal(result.db_path, dbPath); - assert.ok(result.schema_hash); - const sessions = result.tables.find((table) => table.name === "sessions"); - assert.ok(sessions); - assert.equal(sessions.row_count, 3); - assert.ok(sessions.columns.some((column) => column.name === "session_id")); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("runTrajectoryQuery returns bounded rows and supports named params", () => { - const dir = mkdtempSync(join(tmpdir(), "trajectory-pi-query-")); - const dbPath = join(dir, "trajectory.db"); - try { - createFixtureDb(dbPath); - const result = runTrajectoryQuery( - { - query: "SELECT session_id, cost_usd FROM sessions WHERE project_dir = :project_dir ORDER BY cost_usd DESC", - params: { project_dir: "/workspace/alpha" }, - limit: 1, - }, - { TRAJECTORY_CACHE_DB: dbPath }, - ); - - assert.equal(result.ok, true, result.error); - assert.equal(result.meta.allowed_keyword, "SELECT"); - assert.equal(result.meta.returned, 1); - assert.equal(result.meta.truncated, true); - assert.deepEqual(result.rows[0], { session_id: "s-alpha-2", cost_usd: 0.4 }); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("runTrajectoryQuery rejects writes and multiple statements", () => { - const dir = mkdtempSync(join(tmpdir(), "trajectory-pi-query-")); - const dbPath = join(dir, "trajectory.db"); - try { - createFixtureDb(dbPath); - - const update = runTrajectoryQuery( - { query: "UPDATE sessions SET cost_usd = 0" }, - { TRAJECTORY_CACHE_DB: dbPath }, - ); - assert.equal(update.ok, false); - assert.match(update.error ?? "", /not allowed/); - - const multi = runTrajectoryQuery( - { query: "SELECT session_id FROM sessions; SELECT cost_usd FROM sessions" }, - { TRAJECTORY_CACHE_DB: dbPath }, - ); - assert.equal(multi.ok, false); - assert.match(multi.error ?? "", /multiple SQL statements/); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("firstSQLKeyword strips leading comments", () => { - assert.equal(firstSQLKeyword("-- comment\nSELECT 1"), "SELECT"); - assert.equal(firstSQLKeyword("/* comment */\nWITH rows AS (SELECT 1) SELECT * FROM rows"), "WITH"); - assert.throws(() => firstSQLKeyword("/* unterminated"), /unterminated/); -}); - -function createFixtureDb(dbPath: string): void { - const sql = ` -CREATE TABLE sessions ( - session_id TEXT PRIMARY KEY, - project_dir TEXT, - cost_usd REAL -); - -INSERT INTO sessions (session_id, project_dir, cost_usd) VALUES - ('s-alpha-1', '/workspace/alpha', 0.25), - ('s-alpha-2', '/workspace/alpha', 0.40), - ('s-beta-1', '/workspace/beta', 0.10); -`; - const result = spawnSync("sqlite3", [dbPath], { input: sql, encoding: "utf8" }); - if (result.error) throw result.error; - if (result.status !== 0) throw new Error(result.stderr); -}