@oh-my-pi/pi-coding-agent 14.2.1 → 14.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/package.json +19 -19
  3. package/src/cli/args.ts +10 -1
  4. package/src/cli/shell-cli.ts +15 -3
  5. package/src/config/settings-schema.ts +60 -1
  6. package/src/debug/system-info.ts +6 -2
  7. package/src/discovery/claude.ts +58 -36
  8. package/src/discovery/opencode.ts +20 -2
  9. package/src/edit/index.ts +2 -1
  10. package/src/edit/modes/chunk.ts +132 -56
  11. package/src/edit/modes/hashline.ts +36 -11
  12. package/src/edit/renderer.ts +98 -133
  13. package/src/edit/streaming.ts +351 -0
  14. package/src/exec/bash-executor.ts +60 -5
  15. package/src/internal-urls/docs-index.generated.ts +5 -5
  16. package/src/internal-urls/pi-protocol.ts +0 -2
  17. package/src/lsp/client.ts +8 -1
  18. package/src/lsp/defaults.json +2 -1
  19. package/src/modes/acp/acp-agent.ts +76 -2
  20. package/src/modes/components/assistant-message.ts +1 -34
  21. package/src/modes/components/hook-editor.ts +1 -1
  22. package/src/modes/components/tool-execution.ts +111 -101
  23. package/src/modes/controllers/input-controller.ts +1 -1
  24. package/src/modes/interactive-mode.ts +0 -2
  25. package/src/modes/theme/mermaid-cache.ts +13 -52
  26. package/src/modes/theme/theme.ts +2 -2
  27. package/src/prompts/system/system-prompt.md +1 -1
  28. package/src/prompts/tools/browser.md +1 -0
  29. package/src/prompts/tools/chunk-edit.md +25 -22
  30. package/src/prompts/tools/gh-pr-push.md +2 -1
  31. package/src/prompts/tools/grep.md +4 -3
  32. package/src/prompts/tools/lsp.md +6 -0
  33. package/src/prompts/tools/read-chunk.md +46 -7
  34. package/src/prompts/tools/read.md +7 -4
  35. package/src/sdk.ts +8 -5
  36. package/src/session/agent-session.ts +36 -20
  37. package/src/session/session-manager.ts +228 -57
  38. package/src/session/streaming-output.ts +11 -0
  39. package/src/system-prompt.ts +7 -2
  40. package/src/task/executor.ts +1 -0
  41. package/src/tools/bash.ts +13 -0
  42. package/src/tools/gh.ts +6 -16
  43. package/src/tools/sqlite-reader.ts +116 -3
  44. package/src/web/search/providers/codex.ts +129 -6
@@ -1264,74 +1264,245 @@ function extractTextFromContent(content: Message["content"]): string {
1264
1264
  .join(" ");
1265
1265
  }
1266
1266
 
1267
- async function collectSessionsFromFiles(files: string[], storage: SessionStorage): Promise<SessionInfo[]> {
1268
- const sessions: SessionInfo[] = [];
1267
+ const SESSION_LIST_PREFIX_BYTES = 1024;
1268
+ const SESSION_LIST_PARALLEL_THRESHOLD = 64;
1269
+ const SESSION_LIST_MAX_WORKERS = 16;
1270
+ const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false });
1269
1271
 
1270
- // Collect session info for all files in parallel
1271
- await Promise.all(
1272
- files.map(async file => {
1273
- try {
1274
- const content = await storage.readText(file);
1275
- const entries = parseJsonlLenient<Record<string, unknown>>(content);
1276
- if (entries.length === 0) return;
1277
-
1278
- // Check first entry for valid session header
1279
- type SessionHeaderShape = {
1280
- type: string;
1281
- id: string;
1282
- cwd?: string;
1283
- title?: string;
1284
- titleSource?: "auto" | "user";
1285
- timestamp: string;
1286
- };
1287
- const header = entries[0] as SessionHeaderShape;
1288
- if (header.type !== "session" || !header.id) return;
1272
+ async function readSessionListPrefix(file: string, storage: SessionStorage, buffer: Buffer): Promise<string> {
1273
+ if (!(storage instanceof FileSessionStorage)) {
1274
+ return storage.readTextPrefix(file, buffer.byteLength);
1275
+ }
1276
+
1277
+ const handle = await fs.promises.open(file, "r");
1278
+ try {
1279
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, 0);
1280
+ return sessionListPrefixDecoder.decode(buffer.subarray(0, bytesRead));
1281
+ } finally {
1282
+ await handle.close();
1283
+ }
1284
+ }
1285
+
1286
+ function decodeJsonStringFragment(value: string): string {
1287
+ const safeValue = value.endsWith("\\") ? value.slice(0, -1) : value;
1288
+ try {
1289
+ return JSON.parse(`"${safeValue}"`) as string;
1290
+ } catch {
1291
+ return safeValue
1292
+ .replace(/\\n/g, "\n")
1293
+ .replace(/\\r/g, "\r")
1294
+ .replace(/\\t/g, "\t")
1295
+ .replace(/\\"/g, '"')
1296
+ .replace(/\\\\/g, "\\");
1297
+ }
1298
+ }
1289
1299
 
1290
- let messageCount = 0;
1291
- let firstMessage = "";
1292
- const allMessages: string[] = [];
1293
- let shortSummary: string | undefined;
1300
+ function extractStringProperty(source: string, name: string, startIndex = 0): string | undefined {
1301
+ const propertyIndex = source.indexOf(`"${name}"`, startIndex);
1302
+ if (propertyIndex === -1) return undefined;
1294
1303
 
1295
- for (let i = 1; i < entries.length; i++) {
1296
- const entry = entries[i] as { type?: string; message?: Message; shortSummary?: string };
1304
+ const colonIndex = source.indexOf(":", propertyIndex + name.length + 2);
1305
+ if (colonIndex === -1) return undefined;
1297
1306
 
1298
- if (entry.type === "compaction" && typeof entry.shortSummary === "string") {
1299
- shortSummary = entry.shortSummary;
1300
- }
1307
+ let valueIndex = colonIndex + 1;
1308
+ while (valueIndex < source.length) {
1309
+ const char = source.charCodeAt(valueIndex);
1310
+ if (char !== 32 && char !== 9 && char !== 10 && char !== 13) break;
1311
+ valueIndex++;
1312
+ }
1313
+ if (source.charCodeAt(valueIndex) !== 34) return undefined;
1314
+
1315
+ const valueStart = valueIndex + 1;
1316
+ let escaped = false;
1317
+ for (let i = valueStart; i < source.length; i++) {
1318
+ const char = source.charCodeAt(i);
1319
+ if (escaped) {
1320
+ escaped = false;
1321
+ continue;
1322
+ }
1323
+ if (char === 92) {
1324
+ escaped = true;
1325
+ continue;
1326
+ }
1327
+ if (char === 34) {
1328
+ return decodeJsonStringFragment(source.slice(valueStart, i));
1329
+ }
1330
+ }
1331
+
1332
+ return decodeJsonStringFragment(source.slice(valueStart));
1333
+ }
1334
+
1335
+ function countMessageMarkers(content: string): number {
1336
+ let count = 0;
1337
+ let index = 0;
1338
+ while (index < content.length) {
1339
+ const typeIndex = content.indexOf('"type"', index);
1340
+ if (typeIndex === -1) break;
1341
+ const colonIndex = content.indexOf(":", typeIndex + 6);
1342
+ if (colonIndex === -1) break;
1343
+ const type = extractStringProperty(content, "type", typeIndex);
1344
+ if (type === "message") count++;
1345
+ index = colonIndex + 1;
1346
+ }
1347
+ return count;
1348
+ }
1349
+
1350
+ function extractFirstUserMessageFromPrefix(content: string): string | undefined {
1351
+ const roleIndex = content.indexOf('"role"');
1352
+ if (roleIndex === -1) return undefined;
1353
+
1354
+ let index = roleIndex;
1355
+ while (index !== -1) {
1356
+ const role = extractStringProperty(content, "role", index);
1357
+ if (role === "user") {
1358
+ return extractStringProperty(content, "content", index) ?? extractStringProperty(content, "text", index);
1359
+ }
1360
+ index = content.indexOf('"role"', index + 6);
1361
+ }
1362
+
1363
+ return undefined;
1364
+ }
1365
+
1366
+ interface SessionListHeader {
1367
+ type: "session";
1368
+ id: string;
1369
+ cwd?: string;
1370
+ title?: string;
1371
+ parentSession?: string;
1372
+ timestamp?: string;
1373
+ }
1301
1374
 
1302
- if (entry.type === "message" && entry.message) {
1303
- messageCount++;
1375
+ function parseSessionListHeader(
1376
+ content: string,
1377
+ entries: Array<Record<string, unknown>>,
1378
+ ): SessionListHeader | undefined {
1379
+ const parsedHeader = entries[0];
1380
+ if (parsedHeader?.type === "session" && typeof parsedHeader.id === "string") {
1381
+ return {
1382
+ type: "session",
1383
+ id: parsedHeader.id,
1384
+ cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined,
1385
+ title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined,
1386
+ parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined,
1387
+ timestamp: typeof parsedHeader.timestamp === "string" ? parsedHeader.timestamp : undefined,
1388
+ };
1389
+ }
1390
+
1391
+ const firstLineEnd = content.indexOf("\n");
1392
+ const firstLine = firstLineEnd === -1 ? content : content.slice(0, firstLineEnd);
1393
+ if (extractStringProperty(firstLine, "type") !== "session") return undefined;
1394
+
1395
+ const id = extractStringProperty(firstLine, "id");
1396
+ if (!id) return undefined;
1397
+
1398
+ return {
1399
+ type: "session",
1400
+ id,
1401
+ cwd: extractStringProperty(firstLine, "cwd"),
1402
+ title: extractStringProperty(firstLine, "title"),
1403
+ parentSession: extractStringProperty(firstLine, "parentSession"),
1404
+ timestamp: extractStringProperty(firstLine, "timestamp"),
1405
+ };
1406
+ }
1407
+
1408
+ function getSessionListWorkerCount(fileCount: number): number {
1409
+ if (fileCount <= SESSION_LIST_PARALLEL_THRESHOLD) return 1;
1410
+ return Math.min(
1411
+ SESSION_LIST_MAX_WORKERS,
1412
+ os.availableParallelism(),
1413
+ Math.ceil(fileCount / SESSION_LIST_PARALLEL_THRESHOLD),
1414
+ );
1415
+ }
1416
+
1417
+ async function collectSessionFromFile(
1418
+ file: string,
1419
+ storage: SessionStorage,
1420
+ buffer: Buffer,
1421
+ ): Promise<SessionInfo | undefined> {
1422
+ try {
1423
+ const content = await readSessionListPrefix(file, storage, buffer);
1424
+ const entries = parseJsonlLenient<Record<string, unknown>>(content);
1425
+ const header = parseSessionListHeader(content, entries);
1426
+ if (!header) return undefined;
1427
+
1428
+ let parsedMessageCount = 0;
1429
+ let firstMessage = "";
1430
+ const allMessages: string[] = [];
1431
+ let shortSummary: string | undefined;
1432
+
1433
+ for (let i = 1; i < entries.length; i++) {
1434
+ const entry = entries[i] as { type?: string; message?: Message; shortSummary?: string };
1435
+
1436
+ if (entry.type === "compaction" && typeof entry.shortSummary === "string") {
1437
+ shortSummary = entry.shortSummary;
1438
+ }
1304
1439
 
1305
- if (entry.message.role === "user" || entry.message.role === "assistant") {
1306
- const textContent = extractTextFromContent(entry.message.content);
1440
+ if (entry.type === "message" && entry.message) {
1441
+ parsedMessageCount++;
1307
1442
 
1308
- if (textContent) {
1309
- allMessages.push(textContent);
1443
+ if (entry.message.role === "user" || entry.message.role === "assistant") {
1444
+ const textContent = extractTextFromContent(entry.message.content);
1310
1445
 
1311
- if (!firstMessage && entry.message.role === "user") {
1312
- firstMessage = textContent;
1313
- }
1314
- }
1446
+ if (textContent) {
1447
+ allMessages.push(textContent);
1448
+
1449
+ if (!firstMessage && entry.message.role === "user") {
1450
+ firstMessage = textContent;
1315
1451
  }
1316
1452
  }
1317
1453
  }
1454
+ }
1455
+ }
1318
1456
 
1319
- const stats = storage.statSync(file);
1320
- sessions.push({
1321
- path: file,
1322
- id: header.id,
1323
- cwd: typeof header.cwd === "string" ? header.cwd : "",
1324
- title: header.title ?? shortSummary,
1325
- parentSessionPath: (header as SessionHeader).parentSession,
1326
- created: new Date(header.timestamp),
1327
- modified: stats.mtime,
1328
- messageCount,
1329
- firstMessage: firstMessage || "(no messages)",
1330
- allMessagesText: allMessages.join(" "),
1331
- });
1332
- } catch {}
1333
- }),
1334
- );
1457
+ firstMessage ||= extractFirstUserMessageFromPrefix(content) ?? "";
1458
+ const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
1459
+ const stats = storage.statSync(file);
1460
+ return {
1461
+ path: file,
1462
+ id: header.id,
1463
+ cwd: header.cwd ?? "",
1464
+ title: header.title ?? shortSummary,
1465
+ parentSessionPath: header.parentSession,
1466
+ created: new Date(header.timestamp ?? ""),
1467
+ modified: stats.mtime,
1468
+ messageCount,
1469
+ firstMessage: firstMessage || "(no messages)",
1470
+ allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
1471
+ };
1472
+ } catch {
1473
+ return undefined;
1474
+ }
1475
+ }
1476
+
1477
+ async function collectSessionsFromFileStride(
1478
+ files: string[],
1479
+ storage: SessionStorage,
1480
+ startIndex: number,
1481
+ stride: number,
1482
+ ): Promise<SessionInfo[]> {
1483
+ const sessions: SessionInfo[] = [];
1484
+ const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
1485
+
1486
+ for (let i = startIndex; i < files.length; i += stride) {
1487
+ const session = await collectSessionFromFile(files[i], storage, buffer);
1488
+ if (session) sessions.push(session);
1489
+ }
1490
+
1491
+ return sessions;
1492
+ }
1493
+
1494
+ async function collectSessionsFromFiles(files: string[], storage: SessionStorage): Promise<SessionInfo[]> {
1495
+ const workerCount = getSessionListWorkerCount(files.length);
1496
+ const sessions =
1497
+ workerCount === 1
1498
+ ? await collectSessionsFromFileStride(files, storage, 0, 1)
1499
+ : (
1500
+ await Promise.all(
1501
+ Array.from({ length: workerCount }, (_, workerIndex) =>
1502
+ collectSessionsFromFileStride(files, storage, workerIndex, workerCount),
1503
+ ),
1504
+ )
1505
+ ).flat();
1335
1506
 
1336
1507
  sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1337
1508
  return sessions;
@@ -2771,7 +2942,7 @@ export class SessionManager {
2771
2942
  static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
2772
2943
  const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
2773
2944
  try {
2774
- const files = Array.from(new Bun.Glob("**/*.jsonl").scanSync(sessionsRoot)).map(name =>
2945
+ const files = await Array.fromAsync(new Bun.Glob("*/*.jsonl").scan(sessionsRoot), name =>
2775
2946
  path.join(sessionsRoot, name),
2776
2947
  );
2777
2948
  return await collectSessionsFromFiles(files, storage);
@@ -680,6 +680,17 @@ export class OutputSink {
680
680
  });
681
681
  }
682
682
 
683
+ /**
684
+ * Replace the in-memory buffer with the given text while preserving the
685
+ * streaming counters (totalLines/totalBytes reflect the raw chunks that
686
+ * already reached the sink). Used when an upstream minimizer rewrites the
687
+ * captured output after the raw bytes have already been streamed.
688
+ */
689
+ replace(text: string): void {
690
+ this.#buffer = text;
691
+ this.#bufferBytes = Buffer.byteLength(text, "utf-8");
692
+ }
693
+
683
694
  async dump(notice?: string): Promise<OutputSummary> {
684
695
  const noticeLine = notice ? `[${notice}]\n` : "";
685
696
  const outputLines = this.#buffer.length > 0 ? countNewlines(this.#buffer) + 1 : 0;
@@ -275,13 +275,18 @@ async function getCachedGpu(): Promise<string | undefined> {
275
275
  }
276
276
  async function getEnvironmentInfo(): Promise<Array<{ label: string; value: string }>> {
277
277
  const gpu = await getCachedGpu();
278
- const cpus = os.cpus();
278
+ let cpuModel: string | undefined;
279
+ try {
280
+ cpuModel = os.cpus()[0]?.model;
281
+ } catch {
282
+ cpuModel = undefined;
283
+ }
279
284
  const entries: Array<{ label: string; value: string | undefined }> = [
280
285
  { label: "OS", value: `${os.platform()} ${os.release()}` },
281
286
  { label: "Distro", value: os.type() },
282
287
  { label: "Kernel", value: os.version() },
283
288
  { label: "Arch", value: os.arch() },
284
- { label: "CPU", value: `${cpus[0]?.model}` },
289
+ { label: "CPU", value: cpuModel },
285
290
  { label: "GPU", value: gpu },
286
291
  { label: "Terminal", value: getTerminalName() },
287
292
  ];
@@ -975,6 +975,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
975
975
  enableLsp: lspEnabled,
976
976
  skipPythonPreflight,
977
977
  enableMCP,
978
+ mcpManager: options.mcpManager,
978
979
  customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
979
980
  });
980
981
 
package/src/tools/bash.ts CHANGED
@@ -30,6 +30,17 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
30
30
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
31
31
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
32
32
 
33
+ async function saveBashOriginalArtifact(session: ToolSession, originalText: string): Promise<string | undefined> {
34
+ try {
35
+ const alloc = await session.allocateOutputArtifact?.("bash-original");
36
+ if (!alloc?.path || !alloc.id) return undefined;
37
+ await Bun.write(alloc.path, originalText);
38
+ return alloc.id;
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
33
44
  const bashSchemaBase = Type.Object({
34
45
  command: Type.String({ description: "Command to execute" }),
35
46
  env: Type.Optional(
@@ -390,6 +401,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
390
401
  latestText = tailBuffer.text();
391
402
  void reportProgress(latestText, { async: { state: "running", jobId, type: "bash" } });
392
403
  },
404
+ onMinimizedSave: originalText => saveBashOriginalArtifact(this.session, originalText),
393
405
  });
394
406
  const finalResult = this.#buildCompletedResult(
395
407
  result,
@@ -656,6 +668,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
656
668
  artifactPath,
657
669
  artifactId,
658
670
  onChunk: streamTailUpdates(tailBuffer, onUpdate),
671
+ onMinimizedSave: originalText => saveBashOriginalArtifact(this.session, originalText),
659
672
  });
660
673
  if (result.cancelled) {
661
674
  if (signal?.aborted) {
package/src/tools/gh.ts CHANGED
@@ -611,14 +611,6 @@ function toLocalBranchRef(value: string): string {
611
611
  return `refs/heads/${value}`;
612
612
  }
613
613
 
614
- function stripHeadsRef(value: string | undefined): string | undefined {
615
- if (!value) {
616
- return undefined;
617
- }
618
-
619
- return value.startsWith("refs/heads/") ? value.slice("refs/heads/".length) : value;
620
- }
621
-
622
614
  async function requireGitRepoRoot(cwd: string, signal?: AbortSignal): Promise<string> {
623
615
  const repoRoot = await git.repo.root(cwd, signal);
624
616
  if (!repoRoot) {
@@ -763,10 +755,13 @@ async function resolvePrBranchPushTarget(
763
755
  maintainerCanModify?: boolean;
764
756
  isCrossRepository: boolean;
765
757
  }> {
758
+ const headRef = await git.config.getBranch(repoRoot, localBranch, "ompPrHeadRef", signal);
759
+ if (!headRef) {
760
+ throw new ToolError(`branch ${localBranch} has no PR push metadata; check it out via gh_pr_checkout first`);
761
+ }
762
+
766
763
  const pushRemote = await git.config.getBranch(repoRoot, localBranch, "pushRemote", signal);
767
764
  const remote = await git.config.getBranch(repoRoot, localBranch, "remote", signal);
768
- const mergeRef = await git.config.getBranch(repoRoot, localBranch, "merge", signal);
769
- const headRef = await git.config.getBranch(repoRoot, localBranch, "ompPrHeadRef", signal);
770
765
  const prUrl = await git.config.getBranch(repoRoot, localBranch, "ompPrUrl", signal);
771
766
  const maintainerCanModifyValue = await git.config.getBranch(
772
767
  repoRoot,
@@ -781,14 +776,9 @@ async function resolvePrBranchPushTarget(
781
776
  throw new ToolError(`branch ${localBranch} has no configured push remote`);
782
777
  }
783
778
 
784
- const remoteBranch = headRef ?? stripHeadsRef(mergeRef);
785
- if (!remoteBranch) {
786
- throw new ToolError(`branch ${localBranch} has no tracked PR head ref`);
787
- }
788
-
789
779
  return {
790
780
  remoteName,
791
- remoteBranch,
781
+ remoteBranch: headRef,
792
782
  remoteUrl: await git.remote.url(repoRoot, remoteName, signal),
793
783
  prUrl,
794
784
  maintainerCanModify:
@@ -252,6 +252,112 @@ function resolveOrderClause(order: string | undefined, columns: string[]): strin
252
252
  return ` ORDER BY ${quoteSqliteIdentifier(column)} ${direction.toUpperCase()}`;
253
253
  }
254
254
 
255
+ const FORBIDDEN_WHERE_KEYWORDS = new Set([
256
+ "limit",
257
+ "offset",
258
+ "union",
259
+ "intersect",
260
+ "except",
261
+ "attach",
262
+ "detach",
263
+ "pragma",
264
+ ]);
265
+
266
+ const COMMENT_OR_TERMINATOR_ERROR =
267
+ "SQLite 'where' clause must not contain comments or statement terminators; use '?q=SELECT ...' for raw SQL";
268
+ const FORBIDDEN_KEYWORD_ERROR =
269
+ "SQLite 'where' clause must not contain LIMIT/OFFSET/UNION/INTERSECT/EXCEPT/ATTACH/DETACH/PRAGMA; use '?q=SELECT ...' for raw SQL";
270
+
271
+ /**
272
+ * Scans a `where=` clause character-by-character, tracking single- and double-quoted
273
+ * string literals, and rejects SQL control syntax that would otherwise let the
274
+ * structured helper path escape the bound `LIMIT ? OFFSET ?` pagination:
275
+ *
276
+ * - comments (`--`, `/* ... *\/`) and statement terminators (`;`) outside quotes
277
+ * - pagination / attach / pragma keywords outside quotes
278
+ *
279
+ * Raw SQL remains available through `?q=SELECT ...`.
280
+ */
281
+ function findWhereClauseViolation(sql: string): string | null {
282
+ let inSingleQuote = false;
283
+ let inDoubleQuote = false;
284
+ let tokenStart = -1;
285
+ let keywordViolation: string | null = null;
286
+
287
+ const flushToken = (end: number): void => {
288
+ if (tokenStart < 0 || keywordViolation) {
289
+ tokenStart = -1;
290
+ return;
291
+ }
292
+ const token = sql.slice(tokenStart, end).toLowerCase();
293
+ tokenStart = -1;
294
+ if (FORBIDDEN_WHERE_KEYWORDS.has(token)) {
295
+ keywordViolation = FORBIDDEN_KEYWORD_ERROR;
296
+ }
297
+ };
298
+
299
+ for (let index = 0; index <= sql.length; index++) {
300
+ const char = index < sql.length ? sql[index] : undefined;
301
+ const next = index + 1 < sql.length ? sql[index + 1] : undefined;
302
+
303
+ if (inSingleQuote) {
304
+ if (char === "'" && next === "'") {
305
+ index += 1;
306
+ continue;
307
+ }
308
+ if (char === "'") {
309
+ inSingleQuote = false;
310
+ }
311
+ continue;
312
+ }
313
+ if (inDoubleQuote) {
314
+ if (char === '"' && next === '"') {
315
+ index += 1;
316
+ continue;
317
+ }
318
+ if (char === '"') {
319
+ inDoubleQuote = false;
320
+ }
321
+ continue;
322
+ }
323
+
324
+ const isIdent = char !== undefined && /[A-Za-z0-9_]/.test(char);
325
+ if (isIdent) {
326
+ if (tokenStart < 0) tokenStart = index;
327
+ continue;
328
+ }
329
+
330
+ flushToken(index);
331
+
332
+ if (char === undefined) break;
333
+ if (char === "'") {
334
+ inSingleQuote = true;
335
+ continue;
336
+ }
337
+ if (char === '"') {
338
+ inDoubleQuote = true;
339
+ continue;
340
+ }
341
+ if (char === ";") return COMMENT_OR_TERMINATOR_ERROR;
342
+ if ((char === "-" && next === "-") || (char === "/" && next === "*") || (char === "*" && next === "/")) {
343
+ return COMMENT_OR_TERMINATOR_ERROR;
344
+ }
345
+ }
346
+
347
+ return keywordViolation;
348
+ }
349
+
350
+ function validateWhereClause(where: string | undefined): string | undefined {
351
+ if (!where) return undefined;
352
+ const trimmed = where.trim();
353
+ if (!trimmed) return undefined;
354
+ const violation = findWhereClauseViolation(trimmed);
355
+ if (violation) {
356
+ throw new ToolError(violation);
357
+ }
358
+ return trimmed;
359
+ }
360
+
255
361
  function normalizeWriteValue(value: unknown, column: string): SqliteBinding {
256
362
  if (value === null) return null;
257
363
  if (
@@ -360,7 +466,7 @@ export function parseSqliteSelector(subPath: string, queryString: string): Sqlit
360
466
  return { kind: "row", table, key };
361
467
  }
362
468
 
363
- const where = params.get("where")?.trim() || undefined;
469
+ const where = validateWhereClause(params.get("where") ?? undefined);
364
470
  const order = params.get("order")?.trim() || undefined;
365
471
  const hasQueryParams = params.has("limit") || params.has("offset") || order !== undefined || where !== undefined;
366
472
  if (hasQueryParams) {
@@ -448,12 +554,19 @@ export function queryRows(
448
554
  opts: { limit: number; offset: number; order?: string; where?: string },
449
555
  ): { columns: string[]; rows: Record<string, unknown>[]; totalCount: number } {
450
556
  const columns = getTableColumns(db, table);
451
- const whereClause = opts.where?.trim() ? ` WHERE ${opts.where.trim()}` : "";
557
+ const validatedWhere = validateWhereClause(opts.where);
558
+ const whereClause = validatedWhere ? ` WHERE ${validatedWhere}` : "";
452
559
  const orderClause = resolveOrderClause(opts.order, columns);
453
560
  const countSql = `SELECT COUNT(*) AS count FROM ${quoteSqliteIdentifier(table)}${whereClause}`;
454
561
  const selectSql = `SELECT * FROM ${quoteSqliteIdentifier(table)}${whereClause}${orderClause} LIMIT ? OFFSET ?`;
455
562
  const totalCount = db.prepare<SqliteCountRow, []>(countSql).get()?.count ?? 0;
456
- const rows = db.prepare<SqliteRow, SQLQueryBindings[]>(selectSql).all(opts.limit, opts.offset);
563
+ const statement = db.prepare<SqliteRow, SQLQueryBindings[]>(selectSql);
564
+ if (statement.paramsCount !== 2) {
565
+ throw new ToolError(
566
+ "SQLite where clause changed the expected pagination parameters; use q=SELECT ... for raw SQL",
567
+ );
568
+ }
569
+ const rows = statement.all(opts.limit, opts.offset);
457
570
  return { columns, rows, totalCount };
458
571
  }
459
572