@itsautomata/prism 0.2.0 → 0.3.1

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 (41) hide show
  1. package/README.md +4 -1
  2. package/dist/cli.js +2147 -380
  3. package/dist/wasm/tree-sitter-bash.wasm +0 -0
  4. package/dist/wasm/tree-sitter-c-sharp.wasm +0 -0
  5. package/dist/wasm/tree-sitter-c.wasm +0 -0
  6. package/dist/wasm/tree-sitter-clojure.wasm +0 -0
  7. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  8. package/dist/wasm/tree-sitter-css.wasm +0 -0
  9. package/dist/wasm/tree-sitter-dart.wasm +0 -0
  10. package/dist/wasm/tree-sitter-dockerfile.wasm +0 -0
  11. package/dist/wasm/tree-sitter-elixir.wasm +0 -0
  12. package/dist/wasm/tree-sitter-erlang.wasm +0 -0
  13. package/dist/wasm/tree-sitter-go.wasm +0 -0
  14. package/dist/wasm/tree-sitter-graphql.wasm +0 -0
  15. package/dist/wasm/tree-sitter-haskell.wasm +0 -0
  16. package/dist/wasm/tree-sitter-html.wasm +0 -0
  17. package/dist/wasm/tree-sitter-java.wasm +0 -0
  18. package/dist/wasm/tree-sitter-javascript.wasm +0 -0
  19. package/dist/wasm/tree-sitter-json.wasm +0 -0
  20. package/dist/wasm/tree-sitter-julia.wasm +0 -0
  21. package/dist/wasm/tree-sitter-kotlin.wasm +0 -0
  22. package/dist/wasm/tree-sitter-lua.wasm +0 -0
  23. package/dist/wasm/tree-sitter-make.wasm +0 -0
  24. package/dist/wasm/tree-sitter-markdown.wasm +0 -0
  25. package/dist/wasm/tree-sitter-ocaml.wasm +0 -0
  26. package/dist/wasm/tree-sitter-php.wasm +0 -0
  27. package/dist/wasm/tree-sitter-python.wasm +0 -0
  28. package/dist/wasm/tree-sitter-r.wasm +0 -0
  29. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  30. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  31. package/dist/wasm/tree-sitter-scala.wasm +0 -0
  32. package/dist/wasm/tree-sitter-sql.wasm +0 -0
  33. package/dist/wasm/tree-sitter-svelte.wasm +0 -0
  34. package/dist/wasm/tree-sitter-swift.wasm +0 -0
  35. package/dist/wasm/tree-sitter-toml.wasm +0 -0
  36. package/dist/wasm/tree-sitter-tsx.wasm +0 -0
  37. package/dist/wasm/tree-sitter-typescript.wasm +0 -0
  38. package/dist/wasm/tree-sitter-yaml.wasm +0 -0
  39. package/dist/wasm/tree-sitter-zig.wasm +0 -0
  40. package/npm-shrinkwrap.json +4182 -0
  41. package/package.json +14 -3
package/dist/cli.js CHANGED
@@ -11,7 +11,7 @@ import React4 from "react";
11
11
  import { render } from "ink";
12
12
 
13
13
  // src/ui/App.tsx
14
- import { useState as useState3, useCallback as useCallback3, useMemo as useMemo2 } from "react";
14
+ import { useState as useState3, useCallback as useCallback3, useMemo as useMemo2, useEffect as useEffect3, useRef as useRef3 } from "react";
15
15
  import { Box as Box8, useApp, useInput as useInput3 } from "ink";
16
16
 
17
17
  // src/ui/Banner.tsx
@@ -40,8 +40,8 @@ var theme = {
40
40
  // amber for shell mode and warnings
41
41
  planMode: "#a78bfa",
42
42
  // soft violet for plan mode (cool, contemplative)
43
- error: "#ff4444",
44
- // red for errors
43
+ error: "#cf3d0c",
44
+ // burnt vermillion for errors
45
45
  success: "#00ff88",
46
46
  // same as primary
47
47
  // UI elements
@@ -54,11 +54,11 @@ var theme = {
54
54
  spinner: "#00ff88",
55
55
  // loading spinner
56
56
  // tool results
57
- toolName: "#00ddff",
58
- // tool name highlight
57
+ toolName: "#c29734",
58
+ // muted gold for tool name highlight
59
59
  toolOutput: "#aaaaaa",
60
60
  // tool output text
61
- toolError: "#ff4444",
61
+ toolError: "#cf3d0c",
62
62
  // tool error text
63
63
  // thinking
64
64
  thinking: "#666666"
@@ -258,8 +258,8 @@ function MessageBlock({ message }) {
258
258
  return /* @__PURE__ */ jsx3(Box3, { marginTop: 0, marginBottom: 0, marginLeft: 2, children: /* @__PURE__ */ jsx3(Markdown, { text: message.text }) });
259
259
  case "tool_call":
260
260
  return /* @__PURE__ */ jsxs3(Box3, { marginTop: 0, marginBottom: 0, marginLeft: 2, children: [
261
- /* @__PURE__ */ jsx3(Text3, { color: theme.accent, children: "\u26A1 " }),
262
- /* @__PURE__ */ jsx3(Text3, { color: theme.accent, bold: true, children: message.toolName })
261
+ /* @__PURE__ */ jsx3(Text3, { color: theme.toolName, children: "\u26A1 " }),
262
+ /* @__PURE__ */ jsx3(Text3, { color: theme.toolName, bold: true, children: message.toolName?.toLowerCase() })
263
263
  ] });
264
264
  case "tool_result":
265
265
  if (message.isError) {
@@ -282,6 +282,7 @@ import { Box as Box5, Text as Text5, useInput } from "ink";
282
282
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
283
283
  import { join } from "path";
284
284
  import { homedir as homedir2 } from "os";
285
+ import { createHash } from "crypto";
285
286
  var PROFILES_DIR = join(homedir2(), ".prism", "models");
286
287
  function ensureDir() {
287
288
  if (!existsSync(PROFILES_DIR)) {
@@ -290,7 +291,8 @@ function ensureDir() {
290
291
  }
291
292
  function profilePath(model) {
292
293
  const safe = model.replace(/[^a-zA-Z0-9._-]/g, "_");
293
- return join(PROFILES_DIR, `${safe}.json`);
294
+ const hash = createHash("sha256").update(model).digest("hex").slice(0, 8);
295
+ return join(PROFILES_DIR, `${safe}-${hash}.json`);
294
296
  }
295
297
  function loadProfile(model) {
296
298
  const path = profilePath(model);
@@ -358,9 +360,17 @@ Follow these rules exactly. They override general instructions when they conflic
358
360
  import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, copyFileSync } from "fs";
359
361
  import { join as join2 } from "path";
360
362
  import { homedir as homedir3 } from "os";
361
- import { createHash } from "crypto";
363
+ import { createHash as createHash2 } from "crypto";
362
364
  import { execSync } from "child_process";
363
365
  var PROJECTS_DIR = join2(homedir3(), ".prism", "projects");
366
+ function normalizeRemote(url) {
367
+ let s = url.trim().toLowerCase().replace(/\/+$/, "").replace(/\.git$/, "");
368
+ const scp = s.match(/^[^@]+@([^:]+):(.+)$/);
369
+ if (scp) return `${scp[1]}/${scp[2]}`;
370
+ const proto = s.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?(.+)$/);
371
+ if (proto) return proto[1];
372
+ return s;
373
+ }
364
374
  function getProjectId(cwd) {
365
375
  let key = cwd;
366
376
  try {
@@ -370,10 +380,10 @@ function getProjectId(cwd) {
370
380
  timeout: 1e3,
371
381
  stdio: ["pipe", "pipe", "pipe"]
372
382
  }).trim();
373
- if (remote) key = remote;
383
+ if (remote) key = normalizeRemote(remote);
374
384
  } catch {
375
385
  }
376
- return createHash("sha256").update(key).digest("hex").slice(0, 12);
386
+ return createHash2("sha256").update(key).digest("hex").slice(0, 12);
377
387
  }
378
388
  function memoDir(id) {
379
389
  return join2(PROJECTS_DIR, id);
@@ -832,13 +842,19 @@ function handleSlashCommand(input, model, profile, setProfile, setMessages, exit
832
842
  }
833
843
  return true;
834
844
  case "/forget": {
835
- const idx = parseInt(args) - 1;
836
- if (isNaN(idx)) {
837
- info("usage: /forget <number>");
845
+ const raw = args.trim();
846
+ const idx = parseInt(raw, 10) - 1;
847
+ if (!/^\d+$/.test(raw)) {
848
+ info("usage: /forget <number> (see /rules)");
838
849
  } else {
850
+ const before = loadProfile(model).rules.length;
839
851
  const updated = removeRule(model, idx);
840
- setProfile(updated);
841
- info("rule removed.");
852
+ if (updated.rules.length === before) {
853
+ info(`no rule #${raw}. you have ${before} rule${before === 1 ? "" : "s"} (see /rules).`);
854
+ } else {
855
+ setProfile(updated);
856
+ info("rule removed.");
857
+ }
842
858
  }
843
859
  return true;
844
860
  }
@@ -1177,27 +1193,744 @@ function SlashHints({ matches, selectedIdx }) {
1177
1193
  ] });
1178
1194
  }
1179
1195
 
1196
+ // src/ui/inputBuffer.ts
1197
+ import { randomUUID } from "crypto";
1198
+ var cp = (s) => Array.from(s);
1199
+ var clen = (s) => cp(s).length;
1200
+ var cslice = (s, a, b) => cp(s).slice(a, b).join("");
1201
+ function createBuffer() {
1202
+ return [];
1203
+ }
1204
+ function flatLength(buf) {
1205
+ let n = 0;
1206
+ for (const seg of buf) n += seg.kind === "text" ? clen(seg.chars) : 1;
1207
+ return n;
1208
+ }
1209
+ function locate(buf, pos) {
1210
+ const clamped = Math.max(0, Math.min(pos, flatLength(buf)));
1211
+ let consumed = 0;
1212
+ for (let i = 0; i < buf.length; i++) {
1213
+ const seg = buf[i];
1214
+ const len = seg.kind === "text" ? clen(seg.chars) : 1;
1215
+ if (clamped <= consumed + len) {
1216
+ return { segIdx: i, offset: clamped - consumed };
1217
+ }
1218
+ consumed += len;
1219
+ }
1220
+ return { segIdx: buf.length, offset: 0 };
1221
+ }
1222
+ function normalize(buf) {
1223
+ const out = [];
1224
+ for (const seg of buf) {
1225
+ if (seg.kind === "text" && seg.chars.length === 0) continue;
1226
+ const last = out[out.length - 1];
1227
+ if (last && last.kind === "text" && seg.kind === "text") {
1228
+ out[out.length - 1] = { kind: "text", chars: last.chars + seg.chars };
1229
+ } else {
1230
+ out.push(seg);
1231
+ }
1232
+ }
1233
+ return out;
1234
+ }
1235
+ function insertText(buf, pos, str) {
1236
+ if (!str) return buf;
1237
+ if (buf.length === 0) return [{ kind: "text", chars: str }];
1238
+ const { segIdx, offset } = locate(buf, pos);
1239
+ const next = [];
1240
+ for (let i = 0; i < buf.length; i++) {
1241
+ const seg = buf[i];
1242
+ if (i !== segIdx) {
1243
+ next.push(seg);
1244
+ continue;
1245
+ }
1246
+ if (seg.kind === "text") {
1247
+ next.push({ kind: "text", chars: cslice(seg.chars, 0, offset) + str + cslice(seg.chars, offset) });
1248
+ } else {
1249
+ if (offset === 0) {
1250
+ next.push({ kind: "text", chars: str }, seg);
1251
+ } else {
1252
+ next.push(seg, { kind: "text", chars: str });
1253
+ }
1254
+ }
1255
+ }
1256
+ if (segIdx === buf.length) {
1257
+ next.push({ kind: "text", chars: str });
1258
+ }
1259
+ return normalize(next);
1260
+ }
1261
+ function insertPill(buf, pos, content, store, idOverride) {
1262
+ const id = idOverride ?? randomUUID();
1263
+ store.set(id, content);
1264
+ const pill = { kind: "pill", id, size: content.length };
1265
+ if (buf.length === 0) return [pill];
1266
+ const { segIdx, offset } = locate(buf, pos);
1267
+ const next = [];
1268
+ for (let i = 0; i < buf.length; i++) {
1269
+ const seg = buf[i];
1270
+ if (i !== segIdx) {
1271
+ next.push(seg);
1272
+ continue;
1273
+ }
1274
+ if (seg.kind === "text") {
1275
+ next.push({ kind: "text", chars: cslice(seg.chars, 0, offset) }, pill, { kind: "text", chars: cslice(seg.chars, offset) });
1276
+ } else {
1277
+ if (offset === 0) next.push(pill, seg);
1278
+ else next.push(seg, pill);
1279
+ }
1280
+ }
1281
+ if (segIdx === buf.length) {
1282
+ next.push(pill);
1283
+ }
1284
+ return normalize(next);
1285
+ }
1286
+ function deleteBack(buf, pos) {
1287
+ if (pos <= 0 || buf.length === 0) return { buf, pos: 0 };
1288
+ const { segIdx, offset } = locate(buf, pos);
1289
+ const next = [];
1290
+ let removed = 0;
1291
+ for (let i = 0; i < buf.length; i++) {
1292
+ const seg = buf[i];
1293
+ if (i === segIdx) {
1294
+ if (seg.kind === "text") {
1295
+ if (offset === 0) {
1296
+ const prev = next.pop();
1297
+ if (!prev) {
1298
+ next.push(seg);
1299
+ break;
1300
+ }
1301
+ if (prev.kind === "text") {
1302
+ next.push({ kind: "text", chars: cslice(prev.chars, 0, -1) });
1303
+ }
1304
+ removed = 1;
1305
+ } else {
1306
+ next.push({ kind: "text", chars: cslice(seg.chars, 0, offset - 1) + cslice(seg.chars, offset) });
1307
+ removed = 1;
1308
+ }
1309
+ } else {
1310
+ if (offset === 0) {
1311
+ const prev = next.pop();
1312
+ if (!prev) {
1313
+ next.push(seg);
1314
+ break;
1315
+ }
1316
+ if (prev.kind === "text") {
1317
+ next.push({ kind: "text", chars: cslice(prev.chars, 0, -1) });
1318
+ }
1319
+ next.push(seg);
1320
+ removed = 1;
1321
+ } else {
1322
+ removed = 1;
1323
+ continue;
1324
+ }
1325
+ }
1326
+ } else {
1327
+ next.push(seg);
1328
+ }
1329
+ }
1330
+ return { buf: normalize(next), pos: pos - removed };
1331
+ }
1332
+ function deleteForward(buf, pos) {
1333
+ if (pos >= flatLength(buf) || buf.length === 0) return { buf, pos };
1334
+ const { segIdx, offset } = locate(buf, pos);
1335
+ const next = [];
1336
+ for (let i = 0; i < buf.length; i++) {
1337
+ const seg = buf[i];
1338
+ if (i !== segIdx) {
1339
+ next.push(seg);
1340
+ continue;
1341
+ }
1342
+ if (seg.kind === "text") {
1343
+ if (offset >= clen(seg.chars)) {
1344
+ next.push(seg);
1345
+ const target = buf[i + 1];
1346
+ if (target?.kind === "text") {
1347
+ next.push({ kind: "text", chars: cslice(target.chars, 1) });
1348
+ i++;
1349
+ } else if (target?.kind === "pill") {
1350
+ i++;
1351
+ }
1352
+ } else {
1353
+ next.push({ kind: "text", chars: cslice(seg.chars, 0, offset) + cslice(seg.chars, offset + 1) });
1354
+ }
1355
+ } else {
1356
+ if (offset === 1) {
1357
+ next.push(seg);
1358
+ const target = buf[i + 1];
1359
+ if (target?.kind === "text") {
1360
+ next.push({ kind: "text", chars: cslice(target.chars, 1) });
1361
+ i++;
1362
+ } else if (target?.kind === "pill") {
1363
+ i++;
1364
+ }
1365
+ } else {
1366
+ continue;
1367
+ }
1368
+ }
1369
+ }
1370
+ return { buf: normalize(next), pos };
1371
+ }
1372
+ function expand(buf, store) {
1373
+ let out = "";
1374
+ for (const seg of buf) {
1375
+ out += seg.kind === "text" ? seg.chars : store.get(seg.id) ?? "";
1376
+ }
1377
+ return out;
1378
+ }
1379
+ function wordBoundaryLeft(buf, pos) {
1380
+ if (pos <= 0) return 0;
1381
+ const { segIdx, offset } = locate(buf, pos);
1382
+ const seg = buf[segIdx];
1383
+ if (seg?.kind === "pill") {
1384
+ return pos - offset;
1385
+ }
1386
+ let i = segIdx;
1387
+ let off = offset;
1388
+ let cursor = pos;
1389
+ let inWord = false;
1390
+ while (cursor > 0) {
1391
+ const cur = buf[i];
1392
+ if (!cur) break;
1393
+ if (cur.kind === "pill") {
1394
+ return cursor;
1395
+ }
1396
+ if (off === 0) {
1397
+ i -= 1;
1398
+ if (i < 0) return 0;
1399
+ const prev = buf[i];
1400
+ off = prev.kind === "text" ? clen(prev.chars) : 1;
1401
+ continue;
1402
+ }
1403
+ const ch = cp(cur.chars)[off - 1];
1404
+ const isWordChar = /\w/.test(ch);
1405
+ if (isWordChar) inWord = true;
1406
+ else if (inWord) return cursor;
1407
+ cursor -= 1;
1408
+ off -= 1;
1409
+ }
1410
+ return cursor;
1411
+ }
1412
+ function wordBoundaryRight(buf, pos) {
1413
+ const total = flatLength(buf);
1414
+ if (pos >= total) return total;
1415
+ const { segIdx, offset } = locate(buf, pos);
1416
+ const seg = buf[segIdx];
1417
+ if (seg?.kind === "pill") {
1418
+ return pos + (1 - offset);
1419
+ }
1420
+ let i = segIdx;
1421
+ let off = offset;
1422
+ let cursor = pos;
1423
+ let inWord = false;
1424
+ while (cursor < total) {
1425
+ const cur = buf[i];
1426
+ if (!cur) break;
1427
+ if (cur.kind === "pill") {
1428
+ return cursor + 1;
1429
+ }
1430
+ if (off >= clen(cur.chars)) {
1431
+ i += 1;
1432
+ off = 0;
1433
+ continue;
1434
+ }
1435
+ const ch = cp(cur.chars)[off];
1436
+ const isWordChar = /\w/.test(ch);
1437
+ if (isWordChar) inWord = true;
1438
+ else if (inWord) return cursor;
1439
+ cursor += 1;
1440
+ off += 1;
1441
+ }
1442
+ return cursor;
1443
+ }
1444
+ function deleteWordBack(buf, pos) {
1445
+ const target = wordBoundaryLeft(buf, pos);
1446
+ if (target >= pos) return { buf, pos };
1447
+ let cur = pos;
1448
+ let working = buf;
1449
+ while (cur > target) {
1450
+ const step = deleteBack(working, cur);
1451
+ working = step.buf;
1452
+ cur = step.pos;
1453
+ }
1454
+ return { buf: working, pos: cur };
1455
+ }
1456
+ function killToEnd(buf, pos) {
1457
+ const total = flatLength(buf);
1458
+ if (pos >= total) return buf;
1459
+ let working = buf;
1460
+ let n = total - pos;
1461
+ while (n-- > 0) {
1462
+ const step = deleteForward(working, pos);
1463
+ working = step.buf;
1464
+ }
1465
+ return working;
1466
+ }
1467
+ function totalLines(buf) {
1468
+ let n = 1;
1469
+ for (const seg of buf) {
1470
+ if (seg.kind === "text") {
1471
+ for (const ch of cp(seg.chars)) {
1472
+ if (ch === "\n") n++;
1473
+ }
1474
+ }
1475
+ }
1476
+ return n;
1477
+ }
1478
+ function flatToLineCol(buf, pos) {
1479
+ let line = 0;
1480
+ let col = 0;
1481
+ let consumed = 0;
1482
+ for (const seg of buf) {
1483
+ if (consumed >= pos) return { line, col };
1484
+ if (seg.kind === "text") {
1485
+ for (const ch of cp(seg.chars)) {
1486
+ if (consumed >= pos) return { line, col };
1487
+ if (ch === "\n") {
1488
+ line++;
1489
+ col = 0;
1490
+ } else {
1491
+ col++;
1492
+ }
1493
+ consumed++;
1494
+ }
1495
+ } else {
1496
+ if (consumed >= pos) return { line, col };
1497
+ col++;
1498
+ consumed++;
1499
+ }
1500
+ }
1501
+ return { line, col };
1502
+ }
1503
+ function lineColToFlat(buf, targetLine, targetCol) {
1504
+ let line = 0;
1505
+ let col = 0;
1506
+ let pos = 0;
1507
+ for (const seg of buf) {
1508
+ if (seg.kind === "text") {
1509
+ for (const ch of cp(seg.chars)) {
1510
+ if (line === targetLine && col >= targetCol) return pos;
1511
+ if (line === targetLine && ch === "\n") return pos;
1512
+ if (line > targetLine) return pos;
1513
+ if (ch === "\n") {
1514
+ line++;
1515
+ col = 0;
1516
+ } else {
1517
+ col++;
1518
+ }
1519
+ pos++;
1520
+ }
1521
+ } else {
1522
+ if (line === targetLine && col >= targetCol) return pos;
1523
+ if (line > targetLine) return pos;
1524
+ col++;
1525
+ pos++;
1526
+ }
1527
+ }
1528
+ return pos;
1529
+ }
1530
+ function moveCursorUp(buf, pos) {
1531
+ const { line, col } = flatToLineCol(buf, pos);
1532
+ if (line === 0) return pos;
1533
+ return lineColToFlat(buf, line - 1, col);
1534
+ }
1535
+ function moveCursorDown(buf, pos) {
1536
+ const { line, col } = flatToLineCol(buf, pos);
1537
+ if (line >= totalLines(buf) - 1) return pos;
1538
+ return lineColToFlat(buf, line + 1, col);
1539
+ }
1540
+ function sliceToLines(buf, startLine, endLine, cursor) {
1541
+ const result = [];
1542
+ let line = 0;
1543
+ let consumed = 0;
1544
+ let cursorDropped = 0;
1545
+ for (const seg of buf) {
1546
+ if (line >= endLine) break;
1547
+ if (seg.kind === "text") {
1548
+ let chunk = "";
1549
+ for (const ch of cp(seg.chars)) {
1550
+ if (line < startLine) {
1551
+ if (consumed < cursor) cursorDropped++;
1552
+ } else if (line < endLine) {
1553
+ chunk += ch;
1554
+ }
1555
+ consumed++;
1556
+ if (ch === "\n") line++;
1557
+ if (line >= endLine) break;
1558
+ }
1559
+ if (chunk.endsWith("\n")) chunk = chunk.slice(0, -1);
1560
+ if (chunk) result.push({ kind: "text", chars: chunk });
1561
+ } else {
1562
+ if (line < startLine) {
1563
+ if (consumed < cursor) cursorDropped++;
1564
+ } else if (line < endLine) {
1565
+ result.push(seg);
1566
+ }
1567
+ consumed++;
1568
+ }
1569
+ }
1570
+ let cursorOut = Math.max(0, cursor - cursorDropped);
1571
+ const newLen = result.reduce((n, s) => n + (s.kind === "text" ? clen(s.chars) : 1), 0);
1572
+ if (cursorOut > newLen) cursorOut = newLen;
1573
+ return { buf: result, cursor: cursorOut };
1574
+ }
1575
+
1576
+ // src/config/config.ts
1577
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
1578
+ import { join as join5 } from "path";
1579
+ import { homedir as homedir6 } from "os";
1580
+ var PRISM_DIR = join5(homedir6(), ".prism");
1581
+ var CONFIG_PATH = join5(PRISM_DIR, "config.toml");
1582
+ var TUNING_DEFAULTS = {
1583
+ repomap_max_files: 500,
1584
+ repomap_max_lines: 500,
1585
+ repomap_max_symbols_per_file: 10,
1586
+ lens_max_bytes: 64 * 1024,
1587
+ bash_timeout_ms: 3e4,
1588
+ bash_max_output_bytes: 512 * 1024,
1589
+ compaction_threshold: 0.8,
1590
+ empty_turn_nudge_cap: 2,
1591
+ input_viewport_max_lines: 10
1592
+ };
1593
+ var DEFAULTS = {
1594
+ default_provider: "ollama",
1595
+ default_model: "deepseek-r1:14b",
1596
+ openrouter: { api_key: "" },
1597
+ anthropic: { api_key: "" },
1598
+ openai: { api_key: "" },
1599
+ google: { api_key: "" },
1600
+ ollama: { base_url: "http://localhost:11434" },
1601
+ tuning: { ...TUNING_DEFAULTS }
1602
+ };
1603
+ function loadConfig() {
1604
+ const config = structuredClone(DEFAULTS);
1605
+ if (existsSync5(CONFIG_PATH)) {
1606
+ try {
1607
+ const text = readFileSync5(CONFIG_PATH, "utf-8");
1608
+ const parsed = parseToml(text);
1609
+ if (parsed.default_provider) config.default_provider = parsed.default_provider;
1610
+ if (parsed.default_model) config.default_model = parsed.default_model;
1611
+ if (parsed.openrouter?.api_key) config.openrouter.api_key = parsed.openrouter.api_key;
1612
+ if (parsed.anthropic?.api_key) config.anthropic.api_key = parsed.anthropic.api_key;
1613
+ if (parsed.openai?.api_key) config.openai.api_key = parsed.openai.api_key;
1614
+ if (parsed.google?.api_key) config.google.api_key = parsed.google.api_key;
1615
+ if (parsed.ollama?.base_url) config.ollama.base_url = parsed.ollama.base_url;
1616
+ if (parsed.tuning) {
1617
+ for (const key of Object.keys(TUNING_DEFAULTS)) {
1618
+ const raw = parsed.tuning[key];
1619
+ if (raw === void 0) continue;
1620
+ const num = Number(raw);
1621
+ if (Number.isFinite(num)) config.tuning[key] = num;
1622
+ }
1623
+ }
1624
+ } catch {
1625
+ }
1626
+ }
1627
+ if (process.env.OPENROUTER_API_KEY) config.openrouter.api_key = process.env.OPENROUTER_API_KEY;
1628
+ if (process.env.ANTHROPIC_API_KEY) config.anthropic.api_key = process.env.ANTHROPIC_API_KEY;
1629
+ if (process.env.OPENAI_API_KEY) config.openai.api_key = process.env.OPENAI_API_KEY;
1630
+ if (process.env.GOOGLE_API_KEY) config.google.api_key = process.env.GOOGLE_API_KEY;
1631
+ if (process.env.OLLAMA_HOST) config.ollama.base_url = process.env.OLLAMA_HOST;
1632
+ return config;
1633
+ }
1634
+ var TUNING_BLOCK = `# global tuning knobs. every key is optional. missing entries use the defaults
1635
+ # shown here. CLI flags (--max-files, --max-lines, --no-repomap) override these
1636
+ # on a per-session basis.
1637
+ [tuning]
1638
+
1639
+ # \u2500\u2500 retrieval / repo-map \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1640
+ # the structural floor of the project that prism injects into every system
1641
+ # prompt: paths + symbols per file. bigger map = more context, more tokens.
1642
+
1643
+ # how many source files the walker visits per build. larger repos are sampled.
1644
+ # bumping this trades startup time for broader coverage.
1645
+ # override per-session: --max-files <n>
1646
+ repomap_max_files = 500
1647
+
1648
+ # how many lines the formatted block injects into the system prompt. excess
1649
+ # is truncated and replaced with a "...and N more files" footer the model can
1650
+ # act on with Read / Grep. higher = more structure visible, more tokens/turn.
1651
+ # override per-session: --max-lines <n>
1652
+ repomap_max_lines = 500
1653
+
1654
+ # symbols shown per file in the rendered map. higher = denser detail per file,
1655
+ # fewer files fit. lower = more files visible, less detail each.
1656
+ repomap_max_symbols_per_file = 10
1657
+
1658
+ # \u2500\u2500 memory layer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1659
+
1660
+ # size cap on lens.md (project-local rules, committed to the repo). only a
1661
+ # sanity bound against runaway files. normal lens.md is well under this.
1662
+ lens_max_bytes = 65536 # 64KB
1663
+
1664
+ # \u2500\u2500 shell escape (! prefix in the prompt) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1665
+
1666
+ # timeout for '!<cmd>' shell escapes typed in the prompt. caps runaway
1667
+ # commands. does not affect the model's own Bash tool calls (those carry
1668
+ # their own timeout argument).
1669
+ bash_timeout_ms = 30000 # 30s
1670
+
1671
+ # size cap on captured output from '!<cmd>' and the Bash tool. excess is
1672
+ # truncated with a marker. protects the UI from 'find /'-style floods.
1673
+ bash_max_output_bytes = 524288 # 512KB
1674
+
1675
+ # \u2500\u2500 engine self-management \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1676
+
1677
+ # fraction of the model's context window at which compaction fires
1678
+ # (trim \u2192 snip \u2192 summarize). 0.8 = "compact once you've used 80%". lower
1679
+ # = compact earlier (safer, more model calls). a secondary snip threshold
1680
+ # derives from this at 0.75\xD7 to give summarize room to breathe.
1681
+ compaction_threshold = 0.8
1682
+
1683
+ # how many consecutive empty model turns the engine will nudge before
1684
+ # giving up. higher = more patience with flaky models. lower = faster
1685
+ # failure on stuck loops.
1686
+ empty_turn_nudge_cap = 2
1687
+
1688
+ # \u2500\u2500 input UX \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1689
+
1690
+ # max visible lines in the input box. typing past this freezes the box
1691
+ # height and scrolls the viewport to keep the cursor visible. above and
1692
+ # below the box, dim indicators show how many lines are hidden.
1693
+ input_viewport_max_lines = 10
1694
+ `;
1695
+ var BASE_TEMPLATE = `# prism config
1696
+ # env vars override these values. CLI flags override env vars and config.
1697
+
1698
+ default_provider = "ollama"
1699
+ default_model = "deepseek-r1:14b"
1700
+
1701
+ [openrouter]
1702
+ api_key = ""
1703
+
1704
+ [anthropic]
1705
+ api_key = ""
1706
+
1707
+ [openai]
1708
+ api_key = ""
1709
+
1710
+ [google]
1711
+ api_key = ""
1712
+
1713
+ [ollama]
1714
+ base_url = "http://localhost:11434"
1715
+ `;
1716
+ var REQUIRED_TABLES = [
1717
+ { name: "tuning", block: TUNING_BLOCK }
1718
+ ];
1719
+ function initConfig() {
1720
+ if (existsSync5(CONFIG_PATH)) return;
1721
+ if (!existsSync5(PRISM_DIR)) {
1722
+ mkdirSync3(PRISM_DIR, { recursive: true });
1723
+ }
1724
+ const fresh = BASE_TEMPLATE + "\n" + TUNING_BLOCK;
1725
+ writeFileSync3(CONFIG_PATH, fresh, "utf-8");
1726
+ }
1727
+ function migrateConfig() {
1728
+ if (!existsSync5(CONFIG_PATH)) return [];
1729
+ let text;
1730
+ try {
1731
+ text = readFileSync5(CONFIG_PATH, "utf-8");
1732
+ } catch {
1733
+ return [];
1734
+ }
1735
+ let parsed;
1736
+ try {
1737
+ parsed = parseToml(text);
1738
+ } catch {
1739
+ return [];
1740
+ }
1741
+ const missing = REQUIRED_TABLES.filter((t) => !parsed[t.name]);
1742
+ if (missing.length === 0) return [];
1743
+ const head = text.replace(/\s+$/, "");
1744
+ const appended = missing.map((t) => t.block).join("\n");
1745
+ const next = head + "\n\n" + appended + "\n";
1746
+ try {
1747
+ writeFileSync3(CONFIG_PATH, next, "utf-8");
1748
+ return missing.map((t) => t.name);
1749
+ } catch {
1750
+ return [];
1751
+ }
1752
+ }
1753
+ function getConfigPath() {
1754
+ return CONFIG_PATH;
1755
+ }
1756
+ function parseToml(text) {
1757
+ const result = {};
1758
+ let currentSection = result;
1759
+ for (const line of text.split("\n")) {
1760
+ const trimmed = line.trim();
1761
+ if (!trimmed || trimmed.startsWith("#")) continue;
1762
+ const sectionMatch = trimmed.match(/^\[([^\]]+)\]\s*(?:#.*)?$/);
1763
+ if (sectionMatch) {
1764
+ const key = sectionMatch[1];
1765
+ result[key] = result[key] || {};
1766
+ currentSection = result[key];
1767
+ continue;
1768
+ }
1769
+ const kvMatch = trimmed.match(/^(\w+)\s*=\s*"([^"]*)"$/);
1770
+ if (kvMatch) {
1771
+ currentSection[kvMatch[1]] = kvMatch[2];
1772
+ continue;
1773
+ }
1774
+ const kvUnquoted = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
1775
+ if (kvUnquoted) {
1776
+ currentSection[kvUnquoted[1]] = kvUnquoted[2].replace(/\s+#.*$/, "").trim();
1777
+ }
1778
+ }
1779
+ return result;
1780
+ }
1781
+
1782
+ // src/ui/spinnerPhrases.ts
1783
+ var STUCK_THRESHOLD_SEC = 30;
1784
+ var PHRASES = {
1785
+ planMode: [
1786
+ "mapping it out",
1787
+ "drafting the approach",
1788
+ "tracing the seam",
1789
+ "weighing the options",
1790
+ "before committing",
1791
+ "sketching the shape"
1792
+ ],
1793
+ stuck: [
1794
+ "still working",
1795
+ "taking longer than usual",
1796
+ "pushing through",
1797
+ "still cooking",
1798
+ "this one is a puzzle",
1799
+ "sticking with it"
1800
+ ],
1801
+ thinking: [
1802
+ "thinkering",
1803
+ "considering",
1804
+ "thinking",
1805
+ "give me a moment",
1806
+ "hold on lemme think",
1807
+ "figuring it out",
1808
+ "reasoning through",
1809
+ "cooking",
1810
+ "consulting the vibes"
1811
+ ],
1812
+ Read: [
1813
+ "reading",
1814
+ "pulling up the file",
1815
+ "scanning the source",
1816
+ "peeking",
1817
+ "taking a look"
1818
+ ],
1819
+ Edit: [
1820
+ "editing",
1821
+ "applying the patch",
1822
+ "rewriting in place",
1823
+ "tweaking it",
1824
+ "making the change"
1825
+ ],
1826
+ Write: [
1827
+ "writing",
1828
+ "saving to disk",
1829
+ "putting it down",
1830
+ "committing the file"
1831
+ ],
1832
+ Bash: [
1833
+ "running the shell",
1834
+ "firing the command",
1835
+ "on the terminal",
1836
+ "invoking the command"
1837
+ ],
1838
+ Verify: [
1839
+ "running tests",
1840
+ "confirming the change",
1841
+ "asking the suite",
1842
+ "checking the work"
1843
+ ],
1844
+ Agent: [
1845
+ "briefing a subagent to help me out",
1846
+ "delegating the work",
1847
+ "splitting the work with a team mate",
1848
+ "passing it down to someone else"
1849
+ ],
1850
+ Glob: [
1851
+ "globbing",
1852
+ "finding files",
1853
+ "walking the tree",
1854
+ "looking around"
1855
+ ],
1856
+ Grep: [
1857
+ "grepping",
1858
+ "searching the source",
1859
+ "pattern matching"
1860
+ ],
1861
+ WebFetch: [
1862
+ "fetching",
1863
+ "reading the page"
1864
+ ],
1865
+ WebSearch: [
1866
+ "searching the web",
1867
+ "looking it up",
1868
+ "consulting the world wide web"
1869
+ ],
1870
+ useSkill: [
1871
+ "loading the skill",
1872
+ "invoking the routine"
1873
+ ],
1874
+ afterTool: [
1875
+ "piecing it together",
1876
+ "reading the result",
1877
+ "connecting it back",
1878
+ "making sense of it",
1879
+ "looking at what came back"
1880
+ ]
1881
+ };
1882
+ function chooseBucket(ctx) {
1883
+ if (ctx.inPlanMode && ctx.phase === "thinking") return "planMode";
1884
+ if (ctx.phase === "thinking" && ctx.elapsedSec >= STUCK_THRESHOLD_SEC) return "stuck";
1885
+ if (ctx.phase === "running" && ctx.tool && ctx.tool in PHRASES) return ctx.tool;
1886
+ if (ctx.phase === "after-tool") return "afterTool";
1887
+ return "thinking";
1888
+ }
1889
+ function pickPhrase(ctx, random = Math.random) {
1890
+ const bucket = chooseBucket(ctx);
1891
+ const pool = PHRASES[bucket] ?? PHRASES.thinking;
1892
+ const recent = ctx.recentPhrases;
1893
+ let candidates = pool;
1894
+ if (recent && recent.length > 0) {
1895
+ const filtered = pool.filter((p) => !recent.includes(p));
1896
+ if (filtered.length > 0) candidates = filtered;
1897
+ }
1898
+ const idx = Math.floor(random() * candidates.length);
1899
+ return candidates[idx] ?? candidates[0];
1900
+ }
1901
+
1180
1902
  // src/ui/PromptInput.tsx
1181
1903
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1182
- var PromptInput = memo(function PromptInput2({ onSubmit, isLoading, inPlanMode, invokeSkills = [] }) {
1183
- const bufferRef = useRef("");
1904
+ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1905
+ var SPINNER_INTERVAL_MS = 80;
1906
+ var PHRASE_ROTATE_MS = 8e3;
1907
+ var PHRASE_RECENT_WINDOW = 3;
1908
+ var PASTE_PILL_THRESHOLD = 1e3;
1909
+ var PromptInput = memo(function PromptInput2({ onSubmit, isLoading, inPlanMode, invokeSkills = [], activity, phase = "thinking", currentTool }) {
1910
+ const bufferRef = useRef(createBuffer());
1911
+ const pasteStoreRef = useRef(/* @__PURE__ */ new Map());
1184
1912
  const cursorRef = useRef(0);
1185
- const [display, setDisplay] = useState("");
1913
+ const [displayBuf, setDisplayBuf] = useState([]);
1186
1914
  const [cursorPos, setCursorPos] = useState(0);
1187
1915
  const [selectedHintIdx, setSelectedHintIdx] = useState(0);
1916
+ const [tick, setTick] = useState(0);
1917
+ const [startedAt, setStartedAt] = useState(null);
1918
+ const [phrase, setPhrase] = useState("thinking");
1919
+ const recentPhrasesRef = useRef([]);
1188
1920
  const timerRef = useRef(null);
1921
+ const escapeTimerRef = useRef(null);
1189
1922
  const flushNow = useCallback(() => {
1190
1923
  if (timerRef.current) {
1191
1924
  clearTimeout(timerRef.current);
1192
1925
  timerRef.current = null;
1193
1926
  }
1194
- setDisplay(bufferRef.current);
1927
+ setDisplayBuf(bufferRef.current);
1195
1928
  setCursorPos(cursorRef.current);
1196
1929
  }, []);
1197
1930
  const scheduleDisplayUpdate = useCallback(() => {
1198
1931
  if (timerRef.current) return;
1199
1932
  timerRef.current = setTimeout(() => {
1200
- setDisplay(bufferRef.current);
1933
+ setDisplayBuf(bufferRef.current);
1201
1934
  setCursorPos(cursorRef.current);
1202
1935
  timerRef.current = null;
1203
1936
  }, 16);
@@ -1205,13 +1938,82 @@ var PromptInput = memo(function PromptInput2({ onSubmit, isLoading, inPlanMode,
1205
1938
  useEffect(() => {
1206
1939
  return () => {
1207
1940
  if (timerRef.current) clearTimeout(timerRef.current);
1941
+ if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
1208
1942
  };
1209
1943
  }, []);
1210
- const parts = display.split(/\s+/);
1944
+ useEffect(() => {
1945
+ if (!isLoading) {
1946
+ setStartedAt(null);
1947
+ return;
1948
+ }
1949
+ setStartedAt(Date.now());
1950
+ setTick(0);
1951
+ const id = setInterval(() => setTick((t) => t + 1), SPINNER_INTERVAL_MS);
1952
+ return () => clearInterval(id);
1953
+ }, [isLoading]);
1954
+ const startedAtRef = useRef(null);
1955
+ useEffect(() => {
1956
+ startedAtRef.current = startedAt;
1957
+ }, [startedAt]);
1958
+ useEffect(() => {
1959
+ if (!isLoading) {
1960
+ recentPhrasesRef.current = [];
1961
+ setPhrase("thinking");
1962
+ return;
1963
+ }
1964
+ const repick = () => {
1965
+ const elapsed = startedAtRef.current ? Math.floor((Date.now() - startedAtRef.current) / 1e3) : 0;
1966
+ const next = pickPhrase({
1967
+ phase,
1968
+ tool: currentTool,
1969
+ inPlanMode: !!inPlanMode,
1970
+ elapsedSec: elapsed,
1971
+ recentPhrases: recentPhrasesRef.current
1972
+ });
1973
+ const updated = [...recentPhrasesRef.current, next];
1974
+ if (updated.length > PHRASE_RECENT_WINDOW) updated.shift();
1975
+ recentPhrasesRef.current = updated;
1976
+ setPhrase(next);
1977
+ };
1978
+ repick();
1979
+ const id = setInterval(repick, PHRASE_ROTATE_MS);
1980
+ return () => clearInterval(id);
1981
+ }, [isLoading, phase, currentTool, inPlanMode]);
1982
+ const clearBufferNow = useCallback(() => {
1983
+ bufferRef.current = createBuffer();
1984
+ pasteStoreRef.current = /* @__PURE__ */ new Map();
1985
+ cursorRef.current = 0;
1986
+ flushNow();
1987
+ }, [flushNow]);
1988
+ const ignoreNextReturnRef = useRef(false);
1989
+ useEffect(() => {
1990
+ if (isLoading) return;
1991
+ const stdin = process.stdin;
1992
+ if (!stdin || !stdin.isTTY) return;
1993
+ const handler = (chunk) => {
1994
+ const s = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
1995
+ const isNewlineEnter = s === "\x1B\r" || s === "\x1B\n" || /^\x1b\[13;\d+u$/.test(s) || /^\x1b\[27;\d+;13~$/.test(s);
1996
+ if (!isNewlineEnter) return;
1997
+ bufferRef.current = insertText(bufferRef.current, cursorRef.current, "\n");
1998
+ cursorRef.current += 1;
1999
+ scheduleDisplayUpdate();
2000
+ ignoreNextReturnRef.current = true;
2001
+ setTimeout(() => {
2002
+ ignoreNextReturnRef.current = false;
2003
+ }, 50);
2004
+ };
2005
+ stdin.prependListener("data", handler);
2006
+ return () => {
2007
+ stdin.off("data", handler);
2008
+ };
2009
+ }, [isLoading, scheduleDisplayUpdate]);
2010
+ const allText = displayBuf.every((s) => s.kind === "text");
2011
+ const displayText = allText ? displayBuf.map((s) => s.chars).join("") : "";
2012
+ const parts = displayText.split(/\s+/);
1211
2013
  const firstWord = parts[0] || "";
1212
- const isSkillCompletion = firstWord === "/run" && parts.length <= 2;
1213
- const isSectionCompletion = firstWord === "/run" && parts.length >= 3;
1214
- const isCmdCompletion = display.startsWith("/") && !display.includes(" ") && parts.length === 1 && !isSkillCompletion;
2014
+ const isSkillCompletion = allText && firstWord === "/run" && parts.length <= 2;
2015
+ const isSectionCompletion = allText && firstWord === "/run" && parts.length >= 3;
2016
+ const isCmdCompletion = allText && displayText.startsWith("/") && !displayText.includes(" ") && parts.length === 1 && !isSkillCompletion;
1215
2017
  const showHints = isCmdCompletion || isSkillCompletion || isSectionCompletion;
1216
2018
  const matches = useMemo(() => {
1217
2019
  if (!showHints) return [];
@@ -1233,8 +2035,45 @@ var PromptInput = memo(function PromptInput2({ onSubmit, isLoading, inPlanMode,
1233
2035
  useEffect(() => {
1234
2036
  setSelectedHintIdx(0);
1235
2037
  }, [firstWord, showHints]);
2038
+ const RAW_WORD_LEFT = "\x1B[1;3D";
2039
+ const RAW_WORD_RIGHT = "\x1B[1;3C";
2040
+ const RAW_WORD_LEFT_CTRL = "\x1B[1;5D";
2041
+ const RAW_WORD_RIGHT_CTRL = "\x1B[1;5C";
2042
+ const RAW_DEL_WORD = "\x1B\x7F";
2043
+ const RAW_DEL_WORD_ALT = "";
2044
+ const RAW_KILL_LINE = "\v";
2045
+ const RAW_NEWLINE_ESC_CR = "\x1B\r";
2046
+ const RAW_NEWLINE_ESC_LF = "\x1B\n";
1236
2047
  useInput((input, key) => {
1237
2048
  if (isLoading) return;
2049
+ if (key.return && ignoreNextReturnRef.current) {
2050
+ ignoreNextReturnRef.current = false;
2051
+ return;
2052
+ }
2053
+ const isNewlineChord = key.return && (key.shift || key.meta) || input === RAW_NEWLINE_ESC_CR || input === RAW_NEWLINE_ESC_LF;
2054
+ if (isNewlineChord) {
2055
+ if (escapeTimerRef.current) {
2056
+ clearTimeout(escapeTimerRef.current);
2057
+ escapeTimerRef.current = null;
2058
+ }
2059
+ bufferRef.current = insertText(bufferRef.current, cursorRef.current, "\n");
2060
+ cursorRef.current += 1;
2061
+ scheduleDisplayUpdate();
2062
+ return;
2063
+ }
2064
+ if (key.return && escapeTimerRef.current) {
2065
+ clearTimeout(escapeTimerRef.current);
2066
+ escapeTimerRef.current = null;
2067
+ bufferRef.current = insertText(bufferRef.current, cursorRef.current, "\n");
2068
+ cursorRef.current += 1;
2069
+ scheduleDisplayUpdate();
2070
+ return;
2071
+ }
2072
+ if (escapeTimerRef.current && !key.escape) {
2073
+ clearTimeout(escapeTimerRef.current);
2074
+ escapeTimerRef.current = null;
2075
+ clearBufferNow();
2076
+ }
1238
2077
  if (matches.length > 0) {
1239
2078
  if (key.upArrow) {
1240
2079
  setSelectedHintIdx((prev) => Math.max(0, prev - 1));
@@ -1245,12 +2084,12 @@ var PromptInput = memo(function PromptInput2({ onSubmit, isLoading, inPlanMode,
1245
2084
  return;
1246
2085
  }
1247
2086
  if (key.tab || key.return) {
1248
- const liveBuffer = bufferRef.current;
1249
- const liveParts = liveBuffer.split(/\s+/);
2087
+ const liveText = bufferRef.current.every((s) => s.kind === "text") ? bufferRef.current.map((s) => s.chars).join("") : "";
2088
+ const liveParts = liveText.split(/\s+/);
1250
2089
  const liveFirst = liveParts[0] ?? "";
1251
2090
  const liveIsSkillCompletion = liveFirst === "/run" && liveParts.length <= 2;
1252
2091
  const liveIsSectionCompletion = liveFirst === "/run" && liveParts.length >= 3;
1253
- const liveIsCmdCompletion = liveBuffer.startsWith("/") && !liveBuffer.includes(" ") && liveParts.length === 1 && !liveIsSkillCompletion;
2092
+ const liveIsCmdCompletion = liveText.startsWith("/") && !liveText.includes(" ") && liveParts.length === 1 && !liveIsSkillCompletion;
1254
2093
  const liveShowHints = liveIsCmdCompletion || liveIsSkillCompletion || liveIsSectionCompletion;
1255
2094
  let liveMatches;
1256
2095
  if (liveIsSectionCompletion) {
@@ -1272,9 +2111,9 @@ var PromptInput = memo(function PromptInput2({ onSubmit, isLoading, inPlanMode,
1272
2111
  const selected = liveMatches[selectedHintIdx] ?? liveMatches[0];
1273
2112
  if (selected) {
1274
2113
  const newText = liveIsSectionCompletion ? `/run ${liveParts[1]} ${selected.name} ` : liveIsSkillCompletion ? `/run ${selected.name} ` : selected.name + (selected.args ? " " : "");
1275
- if (liveBuffer !== newText) {
1276
- bufferRef.current = newText;
1277
- cursorRef.current = newText.length;
2114
+ if (liveText !== newText) {
2115
+ bufferRef.current = [{ kind: "text", chars: newText }];
2116
+ cursorRef.current = [...newText].length;
1278
2117
  flushNow();
1279
2118
  return;
1280
2119
  }
@@ -1283,28 +2122,55 @@ var PromptInput = memo(function PromptInput2({ onSubmit, isLoading, inPlanMode,
1283
2122
  }
1284
2123
  }
1285
2124
  if (key.return) {
1286
- const text = bufferRef.current.trim();
2125
+ const text = expand(bufferRef.current, pasteStoreRef.current).trim();
1287
2126
  if (text) {
1288
2127
  onSubmit(text);
1289
- bufferRef.current = "";
2128
+ bufferRef.current = createBuffer();
2129
+ pasteStoreRef.current = /* @__PURE__ */ new Map();
1290
2130
  cursorRef.current = 0;
1291
2131
  flushNow();
1292
2132
  }
1293
2133
  return;
1294
2134
  }
2135
+ if (input === RAW_DEL_WORD || input === RAW_DEL_WORD_ALT || key.ctrl && input === "w") {
2136
+ const { buf, pos } = deleteWordBack(bufferRef.current, cursorRef.current);
2137
+ bufferRef.current = buf;
2138
+ cursorRef.current = pos;
2139
+ scheduleDisplayUpdate();
2140
+ return;
2141
+ }
2142
+ if (input === RAW_KILL_LINE || key.ctrl && input === "k") {
2143
+ bufferRef.current = killToEnd(bufferRef.current, cursorRef.current);
2144
+ scheduleDisplayUpdate();
2145
+ return;
2146
+ }
2147
+ if (input === RAW_WORD_LEFT || input === RAW_WORD_LEFT_CTRL || key.meta && key.leftArrow) {
2148
+ cursorRef.current = wordBoundaryLeft(bufferRef.current, cursorRef.current);
2149
+ scheduleDisplayUpdate();
2150
+ return;
2151
+ }
2152
+ if (input === RAW_WORD_RIGHT || input === RAW_WORD_RIGHT_CTRL || key.meta && key.rightArrow) {
2153
+ cursorRef.current = wordBoundaryRight(bufferRef.current, cursorRef.current);
2154
+ scheduleDisplayUpdate();
2155
+ return;
2156
+ }
1295
2157
  if (key.backspace || key.delete) {
1296
- const c2 = cursorRef.current;
1297
- if (c2 > 0) {
1298
- bufferRef.current = bufferRef.current.slice(0, c2 - 1) + bufferRef.current.slice(c2);
1299
- cursorRef.current = c2 - 1;
1300
- scheduleDisplayUpdate();
1301
- }
2158
+ const { buf, pos } = deleteBack(bufferRef.current, cursorRef.current);
2159
+ bufferRef.current = buf;
2160
+ cursorRef.current = pos;
2161
+ scheduleDisplayUpdate();
1302
2162
  return;
1303
2163
  }
1304
- if (key.ctrl && input === "u" || key.escape) {
1305
- bufferRef.current = "";
1306
- cursorRef.current = 0;
1307
- flushNow();
2164
+ if (key.ctrl && input === "u") {
2165
+ clearBufferNow();
2166
+ return;
2167
+ }
2168
+ if (key.escape) {
2169
+ if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
2170
+ escapeTimerRef.current = setTimeout(() => {
2171
+ clearBufferNow();
2172
+ escapeTimerRef.current = null;
2173
+ }, 50);
1308
2174
  return;
1309
2175
  }
1310
2176
  if (key.leftArrow) {
@@ -1313,7 +2179,7 @@ var PromptInput = memo(function PromptInput2({ onSubmit, isLoading, inPlanMode,
1313
2179
  return;
1314
2180
  }
1315
2181
  if (key.rightArrow) {
1316
- cursorRef.current = Math.min(bufferRef.current.length, cursorRef.current + 1);
2182
+ cursorRef.current = Math.min(flatLength(bufferRef.current), cursorRef.current + 1);
1317
2183
  scheduleDisplayUpdate();
1318
2184
  return;
1319
2185
  }
@@ -1323,57 +2189,158 @@ var PromptInput = memo(function PromptInput2({ onSubmit, isLoading, inPlanMode,
1323
2189
  return;
1324
2190
  }
1325
2191
  if (key.ctrl && input === "e") {
1326
- cursorRef.current = bufferRef.current.length;
2192
+ cursorRef.current = flatLength(bufferRef.current);
2193
+ scheduleDisplayUpdate();
2194
+ return;
2195
+ }
2196
+ if (key.upArrow) {
2197
+ cursorRef.current = moveCursorUp(bufferRef.current, cursorRef.current);
2198
+ scheduleDisplayUpdate();
2199
+ return;
2200
+ }
2201
+ if (key.downArrow) {
2202
+ cursorRef.current = moveCursorDown(bufferRef.current, cursorRef.current);
1327
2203
  scheduleDisplayUpdate();
1328
2204
  return;
1329
2205
  }
1330
- if (key.ctrl || key.meta || key.upArrow || key.downArrow || key.tab) {
2206
+ if (key.ctrl || key.meta || key.tab) {
1331
2207
  return;
1332
2208
  }
1333
- const c = cursorRef.current;
1334
- bufferRef.current = bufferRef.current.slice(0, c) + input + bufferRef.current.slice(c);
1335
- cursorRef.current = c + input.length;
2209
+ if (input.length >= PASTE_PILL_THRESHOLD) {
2210
+ bufferRef.current = insertPill(bufferRef.current, cursorRef.current, input, pasteStoreRef.current);
2211
+ cursorRef.current += 1;
2212
+ flushNow();
2213
+ return;
2214
+ }
2215
+ bufferRef.current = insertText(bufferRef.current, cursorRef.current, input);
2216
+ cursorRef.current += [...input].length;
1336
2217
  scheduleDisplayUpdate();
1337
2218
  }, { isActive: !isLoading });
1338
2219
  if (isLoading) {
2220
+ const frame = SPINNER_FRAMES[tick % SPINNER_FRAMES.length];
2221
+ const label = activity ?? phrase;
2222
+ const elapsedSec = startedAt ? Math.floor((Date.now() - startedAt) / 1e3) : 0;
1339
2223
  return /* @__PURE__ */ jsxs5(Box5, { marginTop: 1, flexDirection: "column", children: [
1340
2224
  /* @__PURE__ */ jsxs5(Box5, { children: [
1341
- /* @__PURE__ */ jsx5(Text5, { color: theme.spinner, children: "\u25C7 " }),
1342
- /* @__PURE__ */ jsx5(Text5, { color: theme.textDim, children: "thinking..." })
2225
+ /* @__PURE__ */ jsxs5(Text5, { color: theme.spinner, children: [
2226
+ frame,
2227
+ " "
2228
+ ] }),
2229
+ /* @__PURE__ */ jsxs5(Text5, { color: theme.textDim, children: [
2230
+ label,
2231
+ "..."
2232
+ ] }),
2233
+ elapsedSec >= 1 && /* @__PURE__ */ jsxs5(Text5, { color: theme.textMuted, children: [
2234
+ " \xB7 ",
2235
+ elapsedSec,
2236
+ "s"
2237
+ ] })
1343
2238
  ] }),
1344
2239
  /* @__PURE__ */ jsx5(Box5, { children: /* @__PURE__ */ jsx5(Text5, { color: theme.textMuted, children: " esc to interrupt" }) })
1345
2240
  ] });
1346
2241
  }
1347
- const isShell = display.startsWith("!");
2242
+ const firstSeg = displayBuf[0];
2243
+ const isShell = firstSeg?.kind === "text" && firstSeg.chars.startsWith("!");
1348
2244
  const isPlanInput = inPlanMode && !isShell;
1349
2245
  const promptChar = isShell ? "$" : isPlanInput ? "\u25C7" : "\u25C6";
1350
2246
  const accent = isShell ? theme.warning : isPlanInput ? theme.planMode : theme.prompt;
1351
- const visible = isShell ? display.slice(1) : display;
1352
- const visibleCursor = isShell ? Math.max(0, cursorPos - 1) : cursorPos;
1353
- const before = visible.slice(0, visibleCursor);
1354
- const cursorChar = visible[visibleCursor] ?? " ";
1355
- const after = visible.slice(visibleCursor + 1);
2247
+ const visibleSegs = isShell ? [
2248
+ { kind: "text", chars: firstSeg.chars.slice(1) },
2249
+ ...displayBuf.slice(1)
2250
+ ] : displayBuf;
2251
+ const initialVisibleCursor = isShell ? Math.max(0, cursorPos - 1) : cursorPos;
2252
+ const maxLines = loadConfig().tuning.input_viewport_max_lines;
2253
+ const total = totalLines(visibleSegs);
2254
+ let renderSegs = visibleSegs;
2255
+ let renderCursor = initialVisibleCursor;
2256
+ let hiddenAbove = 0;
2257
+ let hiddenBelow = 0;
2258
+ if (total > maxLines) {
2259
+ const { line: curLine } = flatToLineCol(visibleSegs, initialVisibleCursor);
2260
+ const start = Math.max(0, Math.min(total - maxLines, curLine - Math.floor(maxLines / 2)));
2261
+ const end = start + maxLines;
2262
+ const sliced = sliceToLines(visibleSegs, start, end, initialVisibleCursor);
2263
+ renderSegs = sliced.buf;
2264
+ renderCursor = sliced.cursor;
2265
+ hiddenAbove = start;
2266
+ hiddenBelow = total - end;
2267
+ }
2268
+ const renderAtomLen = renderSegs.reduce(
2269
+ (acc, s) => acc + (s.kind === "text" ? [...s.chars].length : 1),
2270
+ 0
2271
+ );
2272
+ const cursorOnEnd = renderCursor >= renderAtomLen;
2273
+ const bufferEmpty = visibleSegs.length === 0;
1356
2274
  return /* @__PURE__ */ jsxs5(Box5, { marginTop: 1, flexDirection: "column", children: [
1357
2275
  isShell && /* @__PURE__ */ jsx5(Text5, { color: theme.textMuted, children: " shell mode (delete the `!` to exit, or esc to clear. output stays here, the model won't see it)" }),
1358
2276
  isPlanInput && /* @__PURE__ */ jsxs5(Text5, { color: theme.planMode, children: [
1359
2277
  " plan mode ",
1360
2278
  /* @__PURE__ */ jsx5(Text5, { color: theme.textMuted, children: "(type /exec-plan to execute, /cancel-plan to abandon, or push back to revise)" })
1361
2279
  ] }),
1362
- /* @__PURE__ */ jsxs5(Box5, { children: [
2280
+ hiddenAbove > 0 && /* @__PURE__ */ jsxs5(Text5, { color: theme.textMuted, children: [
2281
+ " \u2191 ",
2282
+ hiddenAbove,
2283
+ " more line",
2284
+ hiddenAbove === 1 ? "" : "s",
2285
+ " above"
2286
+ ] }),
2287
+ /* @__PURE__ */ jsx5(Box5, { borderStyle: "round", borderColor: accent, paddingX: 1, children: /* @__PURE__ */ jsxs5(Text5, { wrap: "wrap", color: isShell ? theme.warning : void 0, children: [
1363
2288
  /* @__PURE__ */ jsxs5(Text5, { color: accent, children: [
1364
2289
  promptChar,
1365
2290
  " "
1366
2291
  ] }),
1367
- /* @__PURE__ */ jsxs5(Text5, { wrap: "wrap", color: isShell ? theme.warning : void 0, children: [
1368
- before,
1369
- /* @__PURE__ */ jsx5(Text5, { inverse: true, children: cursorChar }),
1370
- after,
1371
- !display && /* @__PURE__ */ jsx5(Text5, { color: theme.textMuted, children: "ask anything..." })
2292
+ renderSegments(renderSegs, renderCursor),
2293
+ cursorOnEnd && /* @__PURE__ */ jsx5(Text5, { inverse: true, children: " " }),
2294
+ bufferEmpty && /* @__PURE__ */ jsxs5(Text5, { color: theme.textMuted, children: [
2295
+ "ask anything... ",
2296
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "(shift+enter for newline)" })
1372
2297
  ] })
2298
+ ] }) }),
2299
+ hiddenBelow > 0 && /* @__PURE__ */ jsxs5(Text5, { color: theme.textMuted, children: [
2300
+ " \u2193 ",
2301
+ hiddenBelow,
2302
+ " more line",
2303
+ hiddenBelow === 1 ? "" : "s",
2304
+ " below"
1373
2305
  ] }),
1374
2306
  /* @__PURE__ */ jsx5(SlashHints, { matches, selectedIdx: selectedHintIdx })
1375
2307
  ] });
1376
2308
  });
2309
+ function renderSegments(segs, cursor) {
2310
+ const nodes = [];
2311
+ let pos = 0;
2312
+ for (let i = 0; i < segs.length; i++) {
2313
+ const seg = segs[i];
2314
+ if (seg.kind === "text") {
2315
+ const len = seg.chars.length;
2316
+ if (cursor >= pos && cursor < pos + len) {
2317
+ const off = cursor - pos;
2318
+ const before = seg.chars.slice(0, off);
2319
+ const ch = seg.chars[off] ?? " ";
2320
+ const after = seg.chars.slice(off + 1);
2321
+ if (before) nodes.push(/* @__PURE__ */ jsx5(Text5, { children: before }, `t-${i}-pre`));
2322
+ if (ch === "\n") {
2323
+ nodes.push(/* @__PURE__ */ jsx5(Text5, { inverse: true, children: " " }, `t-${i}-cur`));
2324
+ nodes.push(/* @__PURE__ */ jsx5(Text5, { children: "\n" }, `t-${i}-nl`));
2325
+ } else {
2326
+ nodes.push(/* @__PURE__ */ jsx5(Text5, { inverse: true, children: ch }, `t-${i}-cur`));
2327
+ }
2328
+ if (after) nodes.push(/* @__PURE__ */ jsx5(Text5, { children: after }, `t-${i}-post`));
2329
+ } else {
2330
+ nodes.push(/* @__PURE__ */ jsx5(Text5, { children: seg.chars }, `t-${i}`));
2331
+ }
2332
+ pos += len;
2333
+ } else {
2334
+ const label = `[paste ${seg.size} chars]`;
2335
+ const onPill = cursor === pos;
2336
+ nodes.push(
2337
+ /* @__PURE__ */ jsx5(Text5, { color: theme.prompt, dimColor: true, inverse: onPill, children: label }, `p-${seg.id}`)
2338
+ );
2339
+ pos += 1;
2340
+ }
2341
+ }
2342
+ return nodes;
2343
+ }
1377
2344
 
1378
2345
  // src/ui/PermissionPrompt.tsx
1379
2346
  import { useState as useState2, useRef as useRef2, useEffect as useEffect2, useCallback as useCallback2 } from "react";
@@ -1541,8 +2508,7 @@ function isSessionAllowed(toolName) {
1541
2508
  function allowForSession(toolName) {
1542
2509
  sessionRules.add(toolName);
1543
2510
  }
1544
- function needsPermission(toolName, permissionResult, isReadOnly) {
1545
- if (isReadOnly) return false;
2511
+ function needsPermission(toolName, permissionResult) {
1546
2512
  if (isSessionAllowed(toolName)) return false;
1547
2513
  if (permissionResult.behavior === "allow") return false;
1548
2514
  if (permissionResult.behavior === "deny") return false;
@@ -1642,8 +2608,8 @@ function isObviousBadToolCall(block) {
1642
2608
  }
1643
2609
  if (/^[a-z]+$/.test(cmd) && cmd.length < 10 && !cmd.includes("/")) {
1644
2610
  try {
1645
- const { execSync: execSync6 } = __require("child_process");
1646
- execSync6(`which ${cmd}`, { stdio: "pipe" });
2611
+ const { execSync: execSync5 } = __require("child_process");
2612
+ execSync5(`which ${cmd}`, { stdio: "pipe" });
1647
2613
  } catch {
1648
2614
  return `"${cmd}" is not a recognized command. respond with text instead.`;
1649
2615
  }
@@ -1652,6 +2618,15 @@ function isObviousBadToolCall(block) {
1652
2618
  return null;
1653
2619
  }
1654
2620
  async function executeToolCall(block, tools, context, askPermission) {
2621
+ if (block.invalidArgs) {
2622
+ return {
2623
+ toolUseId: block.id,
2624
+ result: {
2625
+ content: `malformed tool arguments for ${block.name} \u2014 not executed. resend the call with valid JSON arguments.`,
2626
+ isError: true
2627
+ }
2628
+ };
2629
+ }
1655
2630
  const badCallReason = isObviousBadToolCall(block);
1656
2631
  if (badCallReason) {
1657
2632
  return {
@@ -1692,8 +2667,16 @@ async function executeToolCall(block, tools, context, askPermission) {
1692
2667
  }
1693
2668
  };
1694
2669
  }
1695
- const isReadOnly = tool.isReadOnly(parsed.data);
1696
- if (askPermission && needsPermission(tool.name, permission, isReadOnly)) {
2670
+ if (needsPermission(tool.name, permission)) {
2671
+ if (!askPermission) {
2672
+ return {
2673
+ toolUseId: block.id,
2674
+ result: {
2675
+ content: `permission required for ${tool.name} but no approval is available in this context`,
2676
+ isError: true
2677
+ }
2678
+ };
2679
+ }
1697
2680
  const description = permission.behavior === "ask" ? permission.message : `run ${tool.name}`;
1698
2681
  const choice = await askPermission(tool.name, description, block.id);
1699
2682
  if (choice === "deny") {
@@ -1787,7 +2770,7 @@ async function runAgent(agent, task) {
1787
2770
  const messages = [
1788
2771
  { role: "user", content: [{ type: "text", text: prompt }] }
1789
2772
  ];
1790
- const context = { cwd: process.cwd(), signal };
2773
+ const context = { cwd: task.cwd ?? process.cwd(), signal };
1791
2774
  let turnCount = 0;
1792
2775
  let finalOutput = "";
1793
2776
  while (turnCount < maxTurns) {
@@ -1830,7 +2813,9 @@ async function runAgent(agent, task) {
1830
2813
  if (toolBlock) {
1831
2814
  try {
1832
2815
  toolBlock.input = JSON.parse(event.inputJson);
2816
+ toolBlock.invalidArgs = false;
1833
2817
  } catch {
2818
+ if (event.inputJson.trim().length > 0) toolBlock.invalidArgs = true;
1834
2819
  }
1835
2820
  }
1836
2821
  break;
@@ -1904,11 +2889,23 @@ ${last}`;
1904
2889
  return { ...block, content: trimmed };
1905
2890
  }
1906
2891
 
2892
+ // src/compact/pairing.ts
2893
+ function isToolResultBearingUser(msg) {
2894
+ return msg.role === "user" && msg.content.some((b) => b.type === "tool_result");
2895
+ }
2896
+ function safeKeepStart(messages, desiredKeepCount) {
2897
+ let start = Math.max(0, messages.length - desiredKeepCount);
2898
+ while (start < messages.length && isToolResultBearingUser(messages[start])) {
2899
+ start++;
2900
+ }
2901
+ return start;
2902
+ }
2903
+
1907
2904
  // src/compact/snip.ts
1908
2905
  function snipOldTurns(messages) {
1909
2906
  if (messages.length <= 4) return messages;
1910
2907
  const keepCount = Math.ceil(messages.length / 2);
1911
- const snipped = messages.slice(-keepCount);
2908
+ const snipped = messages.slice(safeKeepStart(messages, keepCount));
1912
2909
  const marker = {
1913
2910
  role: "user",
1914
2911
  content: [{
@@ -1939,8 +2936,9 @@ async function summarizeOldTurns(messages, provider, model, keepRecent = 10) {
1939
2936
  if (messages.length <= keepRecent + 2) {
1940
2937
  return { ok: true, messages };
1941
2938
  }
1942
- const oldMessages = messages.slice(0, -keepRecent);
1943
- const recentMessages = messages.slice(-keepRecent);
2939
+ const start = safeKeepStart(messages, keepRecent);
2940
+ const oldMessages = messages.slice(0, start);
2941
+ const recentMessages = messages.slice(start);
1944
2942
  const conversationText = oldMessages.map((msg) => {
1945
2943
  const role = msg.role;
1946
2944
  const text = msg.content.map((b) => {
@@ -2006,6 +3004,10 @@ async function* query(options) {
2006
3004
  cwd: process.cwd(),
2007
3005
  signal
2008
3006
  };
3007
+ const { tuning } = loadConfig();
3008
+ const compactionThreshold = tuning.compaction_threshold;
3009
+ const snipThreshold = compactionThreshold * 0.75;
3010
+ const emptyTurnNudgeCap = tuning.empty_turn_nudge_cap;
2009
3011
  let turnCount = 0;
2010
3012
  let consecutiveErrors = 0;
2011
3013
  let consecutiveEmptyTurns = 0;
@@ -2022,13 +3024,16 @@ async function* query(options) {
2022
3024
  messages.splice(0, messages.length, ...trimOldToolResults(messages));
2023
3025
  const tokenCount = countConversationTokens(messages);
2024
3026
  yield { type: "token_update", used: tokenCount, max: capabilities.maxContextTokens, formatted: `${formatTokens(tokenCount)} / ${formatTokens(capabilities.maxContextTokens)}` };
2025
- if (tokenCount > capabilities.maxContextTokens * 0.8) {
3027
+ if (tokenCount > capabilities.maxContextTokens * compactionThreshold) {
2026
3028
  let compacted = false;
2027
3029
  if (!summarizeBlocked) {
2028
3030
  const result = await summarizeOldTurns(messages, provider, model);
2029
3031
  if (result.ok) {
2030
3032
  messages.splice(0, messages.length, ...result.messages);
2031
3033
  compacted = true;
3034
+ if (countConversationTokens(messages) > capabilities.maxContextTokens * compactionThreshold) {
3035
+ messages.splice(0, messages.length, ...snipOldTurns(messages));
3036
+ }
2032
3037
  } else {
2033
3038
  summarizeBlocked = true;
2034
3039
  yield { type: "error", error: `compaction degraded to snip: ${result.reason}` };
@@ -2038,7 +3043,7 @@ async function* query(options) {
2038
3043
  const snipped = snipOldTurns(messages);
2039
3044
  messages.splice(0, messages.length, ...snipped);
2040
3045
  }
2041
- } else if (tokenCount > capabilities.maxContextTokens * 0.6) {
3046
+ } else if (tokenCount > capabilities.maxContextTokens * snipThreshold) {
2042
3047
  const snipped = snipOldTurns(messages);
2043
3048
  messages.splice(0, messages.length, ...snipped);
2044
3049
  }
@@ -2088,7 +3093,7 @@ async function* query(options) {
2088
3093
  (b) => b.type === "text" && b.text.trim().length > 0
2089
3094
  );
2090
3095
  if (toolUseBlocks.length === 0 && !hasText) {
2091
- if (consecutiveEmptyTurns >= 2) {
3096
+ if (consecutiveEmptyTurns >= emptyTurnNudgeCap) {
2092
3097
  yield { type: "done", reason: "empty_turn_cap", turnCount };
2093
3098
  return;
2094
3099
  }
@@ -2218,7 +3223,9 @@ function collectContentBlock(event, content) {
2218
3223
  if (toolBlock) {
2219
3224
  try {
2220
3225
  toolBlock.input = JSON.parse(event.inputJson);
3226
+ toolBlock.invalidArgs = false;
2221
3227
  } catch {
3228
+ if (event.inputJson.trim().length > 0) toolBlock.invalidArgs = true;
2222
3229
  }
2223
3230
  }
2224
3231
  break;
@@ -2251,7 +3258,8 @@ check if relevant files/paths exist. then report:
2251
3258
  // runAgent filters Agent out internally so subagents cannot nest.
2252
3259
  // turn cap and permission policy come from RECOVERY_AGENT.
2253
3260
  tools: opts.tools,
2254
- signal: opts.signal
3261
+ signal: opts.signal,
3262
+ cwd: opts.cwd
2255
3263
  });
2256
3264
  return result.output || "recovery agent could not diagnose the error";
2257
3265
  }
@@ -2299,6 +3307,13 @@ function formatContext(ctx) {
2299
3307
  if (ctx.deps.count > 0) {
2300
3308
  lines.push(`deps: ${ctx.deps.count} (${ctx.deps.file})`);
2301
3309
  }
3310
+ const { testing } = ctx;
3311
+ if (testing.hasTests) {
3312
+ const parts = [`tests: ${testing.testFileCount}`];
3313
+ if (testing.framework) parts.push(`framework: ${testing.framework}`);
3314
+ if (testing.command) parts.push(`scripts.test: ${testing.command}`);
3315
+ lines.push(parts.join(" \xB7 "));
3316
+ }
2302
3317
  if (ctx.prism.learnedRules > 0) {
2303
3318
  lines.push(`learned rules: ${ctx.prism.learnedRules}`);
2304
3319
  }
@@ -2326,11 +3341,11 @@ function formatMemory(m) {
2326
3341
  }
2327
3342
 
2328
3343
  // src/context/lenses.ts
2329
- import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync as readdirSync3 } from "fs";
2330
- import { join as join5, basename as basename3 } from "path";
3344
+ import { existsSync as existsSync6, readFileSync as readFileSync6, readdirSync as readdirSync3 } from "fs";
3345
+ import { join as join6, basename as basename3 } from "path";
2331
3346
  function loadLenses(cwd) {
2332
- const dir = join5(cwd, ".prism");
2333
- if (!existsSync5(dir)) return [];
3347
+ const dir = join6(cwd, ".prism");
3348
+ if (!existsSync6(dir)) return [];
2334
3349
  let files;
2335
3350
  try {
2336
3351
  files = readdirSync3(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name);
@@ -2345,7 +3360,7 @@ function loadLenses(cwd) {
2345
3360
  const lenses = [];
2346
3361
  for (const file of files) {
2347
3362
  try {
2348
- const content = readFileSync5(join5(dir, file), "utf-8").trim();
3363
+ const content = readFileSync6(join6(dir, file), "utf-8").trim();
2349
3364
  if (content.length > 0) {
2350
3365
  lenses.push({ name: basename3(file, ".md"), content });
2351
3366
  }
@@ -2356,8 +3371,42 @@ function loadLenses(cwd) {
2356
3371
  }
2357
3372
 
2358
3373
  // src/prompts/system.ts
3374
+ var refIds = /* @__PURE__ */ new WeakMap();
3375
+ var nextRefId = 0;
3376
+ function refId(obj) {
3377
+ if (!obj) return -1;
3378
+ let id = refIds.get(obj);
3379
+ if (id === void 0) {
3380
+ id = ++nextRefId;
3381
+ refIds.set(obj, id);
3382
+ }
3383
+ return id;
3384
+ }
3385
+ var cachedStaticKey = null;
3386
+ var cachedStaticPrefix = null;
3387
+ function staticKey(options) {
3388
+ return [
3389
+ options.capabilities.maxTools,
3390
+ options.cwd,
3391
+ refId(options.tools),
3392
+ refId(options.projectContext),
3393
+ refId(options.memory),
3394
+ refId(options.profile)
3395
+ ].join("|");
3396
+ }
2359
3397
  function buildSystemPrompt(options) {
2360
- const { capabilities, tools, cwd, profile, projectContext, memory, inPlanMode, activeSkills } = options;
3398
+ const key = staticKey(options);
3399
+ if (key !== cachedStaticKey) {
3400
+ cachedStaticPrefix = composeStatic(options);
3401
+ cachedStaticKey = key;
3402
+ }
3403
+ const dynamic = composeDynamic(options);
3404
+ return dynamic ? `${cachedStaticPrefix}
3405
+
3406
+ ${dynamic}` : cachedStaticPrefix;
3407
+ }
3408
+ function composeStatic(options) {
3409
+ const { capabilities, tools, cwd, profile, projectContext, memory } = options;
2361
3410
  const sections = [
2362
3411
  getCore(),
2363
3412
  getTools(tools, capabilities),
@@ -2367,13 +3416,12 @@ function buildSystemPrompt(options) {
2367
3416
  if (agentsBlock) sections.push(agentsBlock);
2368
3417
  const invokeSkillsBlock = getInvokeSkills(cwd);
2369
3418
  if (invokeSkillsBlock) sections.push(invokeSkillsBlock);
2370
- const skillsBlock = getActiveSkills(cwd, activeSkills);
2371
- if (skillsBlock) sections.push(skillsBlock);
2372
3419
  if (projectContext) {
2373
3420
  sections.push(formatContext(projectContext));
2374
3421
  if (projectContext.git) {
2375
3422
  sections.push(getGitGuidance());
2376
3423
  }
3424
+ sections.push(getVerificationGuidance(projectContext.testing.hasTests));
2377
3425
  }
2378
3426
  const lensesBlock = getLenses(cwd);
2379
3427
  if (lensesBlock) sections.push(lensesBlock);
@@ -2385,6 +3433,14 @@ function buildSystemPrompt(options) {
2385
3433
  const learned = rulesToPrompt(profile);
2386
3434
  if (learned) sections.push(learned);
2387
3435
  }
3436
+ return sections.join("\n\n");
3437
+ }
3438
+ function composeDynamic(options) {
3439
+ const { cwd, activeSkills, repoMap, inPlanMode } = options;
3440
+ const sections = [];
3441
+ const skillsBlock = getActiveSkills(cwd, activeSkills);
3442
+ if (skillsBlock) sections.push(skillsBlock);
3443
+ if (repoMap && repoMap.length > 0) sections.push(repoMap);
2388
3444
  if (inPlanMode) {
2389
3445
  sections.push(getPlanModeAddendum());
2390
3446
  }
@@ -2508,8 +3564,9 @@ function getInvokeSkills(cwd) {
2508
3564
  }
2509
3565
  function getActiveSkills(cwd, names) {
2510
3566
  if (!names || names.size === 0) return null;
3567
+ const sortedNames = [...names].sort();
2511
3568
  const bodies = [];
2512
- for (const name of names) {
3569
+ for (const name of sortedNames) {
2513
3570
  try {
2514
3571
  const skill = loadSkill(name, cwd);
2515
3572
  bodies.push(skill.body);
@@ -2546,6 +3603,17 @@ function getGitGuidance() {
2546
3603
  - Before committing, always show the user what will be committed.
2547
3604
  - Never force-push or reset --hard without explicit permission.`;
2548
3605
  }
3606
+ function getVerificationGuidance(hasTests) {
3607
+ if (!hasTests) {
3608
+ return `# verification
3609
+ - This project has no test suite. After a non-trivial edit, confirm the change and stop. Do not propose writing tests unless the user explicitly asks.
3610
+ - Typo and comment-only edits: call out that the change is trivial.`;
3611
+ }
3612
+ return `# verification
3613
+ - After a non-trivial edit, call Verify with the project's test command. Derive it from \`# project scan\` (framework, scripts.test) and \`# repo map\` (test file structure).
3614
+ - Skip Verify for typo or comment-only edits; call out that the change is trivial.
3615
+ - If Verify fails, debug from its output. Do not claim done until it passes (or the user accepts the failing state explicitly).`;
3616
+ }
2549
3617
  function getPlanModeAddendum() {
2550
3618
  return `## plan mode
2551
3619
 
@@ -2578,10 +3646,10 @@ date: ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}`;
2578
3646
  }
2579
3647
 
2580
3648
  // src/context/scanner.ts
2581
- import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync6, statSync } from "fs";
3649
+ import { existsSync as existsSync7, readdirSync as readdirSync4, readFileSync as readFileSync7, statSync } from "fs";
2582
3650
  import { execSync as execSync2 } from "child_process";
2583
- import { join as join6, basename as basename4, extname } from "path";
2584
- import { homedir as homedir6 } from "os";
3651
+ import { join as join7, basename as basename4, extname } from "path";
3652
+ import { homedir as homedir7 } from "os";
2585
3653
  var LANG_MAP = {
2586
3654
  // scripting
2587
3655
  ".py": "python",
@@ -2886,7 +3954,8 @@ function scanProject(cwd) {
2886
3954
  git: detectGit(cwd),
2887
3955
  deps,
2888
3956
  prism: detectPrismState(cwd),
2889
- runtime: detectRuntime()
3957
+ runtime: detectRuntime(),
3958
+ testing: detectTesting(cwd, structure, deps)
2890
3959
  };
2891
3960
  }
2892
3961
  function detectLanguage(filesByType) {
@@ -2911,10 +3980,10 @@ function detectFramework(depNames) {
2911
3980
  return null;
2912
3981
  }
2913
3982
  function detectEntryPoint(cwd, language) {
2914
- const pyproject = join6(cwd, "pyproject.toml");
2915
- if (existsSync6(pyproject)) {
3983
+ const pyproject = join7(cwd, "pyproject.toml");
3984
+ if (existsSync7(pyproject)) {
2916
3985
  try {
2917
- const text = readFileSync6(pyproject, "utf-8");
3986
+ const text = readFileSync7(pyproject, "utf-8");
2918
3987
  const match = text.match(/\[project\.scripts\]\s*\n\w+\s*=\s*"([^"]+)"/);
2919
3988
  if (match) {
2920
3989
  return match[1].split(":")[0].replace(/\./g, "/") + ".py";
@@ -2922,10 +3991,10 @@ function detectEntryPoint(cwd, language) {
2922
3991
  } catch {
2923
3992
  }
2924
3993
  }
2925
- const pkgJson = join6(cwd, "package.json");
2926
- if (existsSync6(pkgJson)) {
3994
+ const pkgJson = join7(cwd, "package.json");
3995
+ if (existsSync7(pkgJson)) {
2927
3996
  try {
2928
- const data = JSON.parse(readFileSync6(pkgJson, "utf-8"));
3997
+ const data = JSON.parse(readFileSync7(pkgJson, "utf-8"));
2929
3998
  if (data.main) return data.main;
2930
3999
  } catch {
2931
4000
  }
@@ -2938,7 +4007,7 @@ function detectEntryPoint(cwd, language) {
2938
4007
  rust: ["src/main.rs"]
2939
4008
  };
2940
4009
  for (const candidate of candidates[language || ""] || []) {
2941
- if (existsSync6(join6(cwd, candidate))) return candidate;
4010
+ if (existsSync7(join7(cwd, candidate))) return candidate;
2942
4011
  }
2943
4012
  return null;
2944
4013
  }
@@ -2948,11 +4017,11 @@ function detectStructure(cwd) {
2948
4017
  const configFiles = [];
2949
4018
  let totalFiles = 0;
2950
4019
  for (const cf of CONFIG_FILES) {
2951
- if (existsSync6(join6(cwd, cf))) configFiles.push(cf);
4020
+ if (existsSync7(join7(cwd, cf))) configFiles.push(cf);
2952
4021
  }
2953
4022
  try {
2954
4023
  for (const entry of readdirSync4(cwd)) {
2955
- const path = join6(cwd, entry);
4024
+ const path = join7(cwd, entry);
2956
4025
  try {
2957
4026
  const stat = statSync(path);
2958
4027
  if (stat.isDirectory() && !entry.startsWith(".") && !IGNORE_DIRS.has(entry)) {
@@ -2974,7 +4043,7 @@ function countFiles(dir, counts, depth, maxDepth) {
2974
4043
  try {
2975
4044
  for (const entry of readdirSync4(dir)) {
2976
4045
  if (IGNORE_DIRS.has(entry) || entry.startsWith(".")) continue;
2977
- const path = join6(dir, entry);
4046
+ const path = join7(dir, entry);
2978
4047
  try {
2979
4048
  const stat = statSync(path);
2980
4049
  if (stat.isFile()) {
@@ -2992,7 +4061,7 @@ function countFiles(dir, counts, depth, maxDepth) {
2992
4061
  }
2993
4062
  }
2994
4063
  function detectGit(cwd) {
2995
- if (!existsSync6(join6(cwd, ".git"))) return null;
4064
+ if (!existsSync7(join7(cwd, ".git"))) return null;
2996
4065
  try {
2997
4066
  const branch = exec(cwd, "git branch --show-current").trim();
2998
4067
  const status = exec(cwd, "git status --porcelain");
@@ -3019,18 +4088,18 @@ function detectGit(cwd) {
3019
4088
  }
3020
4089
  }
3021
4090
  function detectDeps(cwd) {
3022
- const reqTxt = join6(cwd, "requirements.txt");
3023
- if (existsSync6(reqTxt)) {
4091
+ const reqTxt = join7(cwd, "requirements.txt");
4092
+ if (existsSync7(reqTxt)) {
3024
4093
  try {
3025
- const lines = readFileSync6(reqTxt, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#") && !l.startsWith("-")).map((l) => l.split(/[><=!~]/)[0].trim());
4094
+ const lines = readFileSync7(reqTxt, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#") && !l.startsWith("-")).map((l) => l.split(/[><=!~]/)[0].trim());
3026
4095
  return { file: "requirements.txt", count: lines.length, names: lines };
3027
4096
  } catch {
3028
4097
  }
3029
4098
  }
3030
- const pyproject = join6(cwd, "pyproject.toml");
3031
- if (existsSync6(pyproject)) {
4099
+ const pyproject = join7(cwd, "pyproject.toml");
4100
+ if (existsSync7(pyproject)) {
3032
4101
  try {
3033
- const text = readFileSync6(pyproject, "utf-8");
4102
+ const text = readFileSync7(pyproject, "utf-8");
3034
4103
  const names = [];
3035
4104
  let inDeps = false;
3036
4105
  for (const line of text.split("\n")) {
@@ -3048,10 +4117,10 @@ function detectDeps(cwd) {
3048
4117
  } catch {
3049
4118
  }
3050
4119
  }
3051
- const pkgJson = join6(cwd, "package.json");
3052
- if (existsSync6(pkgJson)) {
4120
+ const pkgJson = join7(cwd, "package.json");
4121
+ if (existsSync7(pkgJson)) {
3053
4122
  try {
3054
- const data = JSON.parse(readFileSync6(pkgJson, "utf-8"));
4123
+ const data = JSON.parse(readFileSync7(pkgJson, "utf-8"));
3055
4124
  const names = [
3056
4125
  ...Object.keys(data.dependencies || {}),
3057
4126
  ...Object.keys(data.devDependencies || {})
@@ -3065,11 +4134,11 @@ function detectDeps(cwd) {
3065
4134
  function detectPrismState(_cwd) {
3066
4135
  let learnedRules = 0;
3067
4136
  try {
3068
- const modelsDir = join6(homedir6(), ".prism", "models");
3069
- if (existsSync6(modelsDir)) {
4137
+ const modelsDir = join7(homedir7(), ".prism", "models");
4138
+ if (existsSync7(modelsDir)) {
3070
4139
  for (const file of readdirSync4(modelsDir)) {
3071
4140
  if (file.endsWith(".json")) {
3072
- const data = JSON.parse(readFileSync6(join6(modelsDir, file), "utf-8"));
4141
+ const data = JSON.parse(readFileSync7(join7(modelsDir, file), "utf-8"));
3073
4142
  learnedRules += (data.rules || []).length;
3074
4143
  }
3075
4144
  }
@@ -3095,24 +4164,106 @@ function tryVersion(cmd) {
3095
4164
  return null;
3096
4165
  }
3097
4166
  }
4167
+ function detectTesting(cwd, structure, deps) {
4168
+ const configs = new Set(structure.configFiles);
4169
+ const depNames = new Set(deps.names);
4170
+ const testFileCount = countTestFiles(cwd);
4171
+ let framework = null;
4172
+ if (depNames.has("vitest")) framework = "vitest";
4173
+ else if (depNames.has("jest")) framework = "jest";
4174
+ else if (depNames.has("mocha")) framework = "mocha";
4175
+ else if (depNames.has("@playwright/test") || depNames.has("playwright")) framework = "playwright";
4176
+ else if (depNames.has("pytest")) framework = "pytest";
4177
+ else if (configs.has("pyproject.toml") || configs.has("setup.py")) framework = "pytest";
4178
+ else if (configs.has("Cargo.toml")) framework = "cargo-test";
4179
+ else if (configs.has("go.mod")) framework = "go-test";
4180
+ let command = null;
4181
+ const pkgJsonPath = join7(cwd, "package.json");
4182
+ if (existsSync7(pkgJsonPath)) {
4183
+ try {
4184
+ const pkg = JSON.parse(readFileSync7(pkgJsonPath, "utf-8"));
4185
+ if (pkg.scripts && typeof pkg.scripts.test === "string") {
4186
+ command = pkg.scripts.test;
4187
+ }
4188
+ } catch {
4189
+ }
4190
+ }
4191
+ return { hasTests: testFileCount > 0, testFileCount, framework, command };
4192
+ }
4193
+ function countTestFiles(cwd) {
4194
+ const TEST_NAME = /(?:^|[._-])(?:test|spec)(?:[._-]|$)/i;
4195
+ const TEST_DIRS = /* @__PURE__ */ new Set(["tests", "test", "__tests__"]);
4196
+ const SKIP = /* @__PURE__ */ new Set([
4197
+ "node_modules",
4198
+ ".git",
4199
+ ".venv",
4200
+ "venv",
4201
+ "env",
4202
+ "__pycache__",
4203
+ "dist",
4204
+ "build",
4205
+ "out",
4206
+ "target",
4207
+ "_build",
4208
+ ".next",
4209
+ ".nuxt",
4210
+ ".cache",
4211
+ "coverage",
4212
+ ".nyc_output"
4213
+ ]);
4214
+ let count = 0;
4215
+ const stack = [cwd];
4216
+ let budget = 1500;
4217
+ while (stack.length > 0 && budget-- > 0) {
4218
+ const dir = stack.pop();
4219
+ let entries;
4220
+ try {
4221
+ entries = readdirSync4(dir);
4222
+ } catch {
4223
+ continue;
4224
+ }
4225
+ for (const name of entries) {
4226
+ if (SKIP.has(name) || name.startsWith(".")) continue;
4227
+ const full = join7(dir, name);
4228
+ let s;
4229
+ try {
4230
+ s = statSync(full);
4231
+ } catch {
4232
+ continue;
4233
+ }
4234
+ if (s.isDirectory()) {
4235
+ stack.push(full);
4236
+ continue;
4237
+ }
4238
+ if (TEST_NAME.test(name)) {
4239
+ count++;
4240
+ continue;
4241
+ }
4242
+ const parent = basename4(dir);
4243
+ if (TEST_DIRS.has(parent)) count++;
4244
+ }
4245
+ }
4246
+ return count;
4247
+ }
3098
4248
 
3099
4249
  // src/sessions/store.ts
3100
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync3, readdirSync as readdirSync5 } from "fs";
3101
- import { join as join7 } from "path";
3102
- import { homedir as homedir7 } from "os";
3103
- var SESSIONS_DIR = join7(homedir7(), ".prism", "sessions");
4250
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync4, readdirSync as readdirSync5 } from "fs";
4251
+ import { join as join8 } from "path";
4252
+ import { homedir as homedir8 } from "os";
4253
+ import { randomBytes } from "crypto";
4254
+ var SESSIONS_DIR = join8(homedir8(), ".prism", "sessions");
3104
4255
  function ensureDir3() {
3105
- if (!existsSync7(SESSIONS_DIR)) {
3106
- mkdirSync3(SESSIONS_DIR, { recursive: true });
4256
+ if (!existsSync8(SESSIONS_DIR)) {
4257
+ mkdirSync4(SESSIONS_DIR, { recursive: true });
3107
4258
  }
3108
4259
  }
3109
4260
  function sessionPath(id) {
3110
- return join7(SESSIONS_DIR, `${id}.json`);
4261
+ return join8(SESSIONS_DIR, `${id}.json`);
3111
4262
  }
3112
4263
  function createSession(model, provider, cwd) {
3113
4264
  ensureDir3();
3114
4265
  const now = /* @__PURE__ */ new Date();
3115
- const id = now.toISOString().replace(/[:.]/g, "-").slice(0, 23);
4266
+ const id = now.toISOString().replace(/[:.]/g, "-").slice(0, 23) + "-" + randomBytes(3).toString("hex");
3116
4267
  return {
3117
4268
  id,
3118
4269
  model,
@@ -3127,13 +4278,13 @@ function saveSession(session) {
3127
4278
  ensureDir3();
3128
4279
  session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3129
4280
  const path = sessionPath(session.id);
3130
- writeFileSync3(path, JSON.stringify(session, null, 2), "utf-8");
4281
+ writeFileSync4(path, JSON.stringify(session, null, 2), "utf-8");
3131
4282
  }
3132
4283
  function loadSession(id) {
3133
4284
  const path = sessionPath(id);
3134
- if (!existsSync7(path)) return null;
4285
+ if (!existsSync8(path)) return null;
3135
4286
  try {
3136
- return JSON.parse(readFileSync7(path, "utf-8"));
4287
+ return JSON.parse(readFileSync8(path, "utf-8"));
3137
4288
  } catch {
3138
4289
  return null;
3139
4290
  }
@@ -3144,7 +4295,7 @@ function loadAllSorted() {
3144
4295
  const sessions = [];
3145
4296
  for (const file of files) {
3146
4297
  try {
3147
- sessions.push(JSON.parse(readFileSync7(join7(SESSIONS_DIR, file), "utf-8")));
4298
+ sessions.push(JSON.parse(readFileSync8(join8(SESSIONS_DIR, file), "utf-8")));
3148
4299
  } catch {
3149
4300
  continue;
3150
4301
  }
@@ -3176,6 +4327,15 @@ var DESCRIPTION = "Spawn a subagent to handle a focused task. The subagent gets
3176
4327
  function createAgentTool(opts) {
3177
4328
  const subagentTools = opts.subagentTools.filter((t) => t.name !== "Agent");
3178
4329
  const boundCwd = opts.cwd ?? process.cwd();
4330
+ const resolveRequested = (input) => {
4331
+ try {
4332
+ const requested = input.agent?.trim();
4333
+ const agentName = requested && requested.length > 0 ? requested : void 0;
4334
+ return resolveAgent(agentName, boundCwd);
4335
+ } catch {
4336
+ return null;
4337
+ }
4338
+ };
3179
4339
  return buildTool({
3180
4340
  name: "Agent",
3181
4341
  description: DESCRIPTION,
@@ -3205,7 +4365,8 @@ function createAgentTool(opts) {
3205
4365
  model: opts.model,
3206
4366
  tools: subagentTools,
3207
4367
  signal: context.signal,
3208
- onProgress: opts.onProgress
4368
+ onProgress: opts.onProgress,
4369
+ cwd: context.cwd
3209
4370
  });
3210
4371
  if (!result.success) {
3211
4372
  return {
@@ -3223,20 +4384,18 @@ ${result.output}`
3223
4384
  // inherit can write through the parent's resolver; two inherit agents
3224
4385
  // in the same batch could race on shared files. serialize those.
3225
4386
  // unresolvable agents (unknown name, broken file) → assume unsafe.
3226
- isConcurrencySafe: (input) => {
3227
- try {
3228
- const requested = input.agent?.trim();
3229
- const agentName = requested && requested.length > 0 ? requested : void 0;
3230
- const agent = resolveAgent(agentName, boundCwd);
3231
- return agent.permissions === "deny-writes";
3232
- } catch {
3233
- return false;
3234
- }
3235
- },
3236
- isReadOnly: () => true,
3237
- // agents report back, parent decides what to do
3238
- checkPermissions: () => ({ behavior: "allow" })
3239
- // auto-allow agent spawning
4387
+ isConcurrencySafe: (input) => resolveRequested(input)?.permissions === "deny-writes",
4388
+ // a spawn is only read-only when the requested agent cannot write. inherit
4389
+ // agents write through the parent's resolver, so they are not read-only.
4390
+ isReadOnly: (input) => resolveRequested(input)?.permissions === "deny-writes",
4391
+ // deny-writes agents are sandboxed → auto-allow the spawn. inherit agents
4392
+ // can mutate state through the parent's resolver → prompt before spawning.
4393
+ checkPermissions: (input) => {
4394
+ const agent = resolveRequested(input);
4395
+ if (agent?.permissions === "deny-writes") return { behavior: "allow" };
4396
+ const label = agent?.name ?? input.agent ?? "agent";
4397
+ return { behavior: "ask", message: `spawn write-capable agent "${label}"` };
4398
+ }
3240
4399
  });
3241
4400
  }
3242
4401
 
@@ -3300,9 +4459,8 @@ follow the skill instructions.]` };
3300
4459
  isConcurrencySafe: () => false,
3301
4460
  // not read-only: the skill body lands in the conversation framed as
3302
4461
  // "follow these instructions," which drives downstream Edit/Write/Bash
3303
- // calls. claiming read-only here short-circuits needsPermission() in
3304
- // orchestration.ts:54, killing the `requirePermission` gate. flagging
3305
- // false honors the operator's `require-permission: true` frontmatter.
4462
+ // calls. isReadOnly is a concurrency/UI hint; the `require-permission`
4463
+ // gate is enforced by checkPermissions below.
3306
4464
  isReadOnly: () => false,
3307
4465
  checkPermissions: (input) => {
3308
4466
  try {
@@ -3319,21 +4477,20 @@ follow the skill instructions.]` };
3319
4477
 
3320
4478
  // src/ui/bash.ts
3321
4479
  import { execSync as execSync3 } from "child_process";
3322
- var MAX_OUTPUT = 512 * 1024;
3323
- var TIMEOUT_MS = 3e4;
3324
4480
  function handleBashCommand(input, setMessages) {
3325
4481
  if (!input.startsWith("!")) return false;
3326
4482
  const cmd = input.slice(1).trim();
3327
4483
  if (!cmd) return true;
3328
4484
  setMessages((prev) => [...prev, { role: "tool_call", text: "", toolName: `! ${cmd}` }]);
4485
+ const { tuning } = loadConfig();
3329
4486
  let output;
3330
4487
  let isError = false;
3331
4488
  try {
3332
4489
  output = execSync3(cmd, {
3333
4490
  cwd: process.cwd(),
3334
4491
  encoding: "utf-8",
3335
- timeout: TIMEOUT_MS,
3336
- maxBuffer: MAX_OUTPUT,
4492
+ timeout: tuning.bash_timeout_ms,
4493
+ maxBuffer: tuning.bash_max_output_bytes,
3337
4494
  stdio: ["pipe", "pipe", "pipe"]
3338
4495
  }).trim() || "(no output)";
3339
4496
  } catch (e) {
@@ -3613,14 +4770,17 @@ var OllamaProvider = class {
3613
4770
 
3614
4771
  // src/completion/spec.ts
3615
4772
  import { execSync as execSync4 } from "child_process";
3616
- import { existsSync as existsSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4 } from "fs";
3617
- import { join as join8 } from "path";
3618
- import { homedir as homedir8 } from "os";
4773
+ import { existsSync as existsSync9, readFileSync as readFileSync9, writeFileSync as writeFileSync5, mkdirSync as mkdirSync5 } from "fs";
4774
+ import { join as join9 } from "path";
4775
+ import { homedir as homedir9 } from "os";
3619
4776
  var FLAGS = [
3620
4777
  { flag: "--or", alias: "--openrouter", desc: "use OpenRouter provider", takesValue: "model-openrouter", positionalValue: true },
3621
4778
  { flag: "-c", alias: "--continue", desc: "resume last session in this directory" },
3622
4779
  { flag: "-r", alias: "--resume", desc: "resume a specific session by id", takesValue: "session-id" },
3623
4780
  { flag: "--max-tokens", desc: "max output tokens per response", takesValue: "number" },
4781
+ { flag: "--max-files", desc: "repo-map walk cap (overrides [tuning].repomap_max_files)", takesValue: "number" },
4782
+ { flag: "--max-lines", desc: "repo-map render cap (overrides [tuning].repomap_max_lines)", takesValue: "number" },
4783
+ { flag: "--no-repomap", desc: "skip the repo-map retrieval pass for this session" },
3624
4784
  { flag: "--config", desc: "show config file path" },
3625
4785
  { flag: "--sessions", desc: "list recent sessions" },
3626
4786
  { flag: "--no-scan", desc: "skip the live project scan at startup" },
@@ -3664,13 +4824,13 @@ var FALLBACK_OPENROUTER_MODELS = [
3664
4824
  { id: "anthropic/claude-haiku-4.5", context_length: 2e5, supported_parameters: ["tools", "tool_choice"] },
3665
4825
  { id: "anthropic/claude-sonnet-4", context_length: 2e5, supported_parameters: ["tools", "tool_choice"] }
3666
4826
  ];
3667
- var CACHE_DIR = join8(homedir8(), ".prism", "cache");
3668
- var OR_CACHE_PATH = join8(CACHE_DIR, "openrouter-models.json");
4827
+ var CACHE_DIR = join9(homedir9(), ".prism", "cache");
4828
+ var OR_CACHE_PATH = join9(CACHE_DIR, "openrouter-models.json");
3669
4829
  var TTL_MS = 24 * 60 * 60 * 1e3;
3670
4830
  function readCache() {
3671
- if (!existsSync8(OR_CACHE_PATH)) return null;
4831
+ if (!existsSync9(OR_CACHE_PATH)) return null;
3672
4832
  try {
3673
- const raw = JSON.parse(readFileSync8(OR_CACHE_PATH, "utf-8"));
4833
+ const raw = JSON.parse(readFileSync9(OR_CACHE_PATH, "utf-8"));
3674
4834
  if (!Array.isArray(raw.models) || raw.models.length === 0 || typeof raw.models[0] === "string") {
3675
4835
  return null;
3676
4836
  }
@@ -3681,8 +4841,8 @@ function readCache() {
3681
4841
  }
3682
4842
  function writeCache(models) {
3683
4843
  try {
3684
- if (!existsSync8(CACHE_DIR)) mkdirSync4(CACHE_DIR, { recursive: true });
3685
- writeFileSync4(OR_CACHE_PATH, JSON.stringify({ fetchedAt: Date.now(), models }), "utf-8");
4844
+ if (!existsSync9(CACHE_DIR)) mkdirSync5(CACHE_DIR, { recursive: true });
4845
+ writeFileSync5(OR_CACHE_PATH, JSON.stringify({ fetchedAt: Date.now(), models }), "utf-8");
3686
4846
  } catch {
3687
4847
  }
3688
4848
  }
@@ -3785,6 +4945,34 @@ function inferCapabilities(modelId, meta) {
3785
4945
  caps.parallelToolCalls = params.includes("tools") && params.includes("tool_choice");
3786
4946
  return caps;
3787
4947
  }
4948
+ function extractCachedTokens(u) {
4949
+ if (!u) return 0;
4950
+ return u.prompt_tokens_details?.cached_tokens ?? u.cache_read_input_tokens ?? u.prompt_cache_hit_tokens ?? 0;
4951
+ }
4952
+ function formatOpenRouterError(status, rawBody) {
4953
+ let message = rawBody;
4954
+ try {
4955
+ const parsed = JSON.parse(rawBody);
4956
+ if (parsed.error?.message) message = parsed.error.message;
4957
+ } catch {
4958
+ }
4959
+ if (status === 402) {
4960
+ const m = message.match(/requested up to (\d+) tokens, but can only afford (\d+)/);
4961
+ if (m) {
4962
+ return `openrouter: out of credits. requested ${m[1]} tokens, only ${m[2]} affordable.
4963
+ fix: lower max_tokens in config, or add credits at https://openrouter.ai/settings/credits`;
4964
+ }
4965
+ return "openrouter: out of credits. add credits at https://openrouter.ai/settings/credits, or lower max_tokens";
4966
+ }
4967
+ if (status === 401) {
4968
+ return "openrouter: invalid api key. check OPENROUTER_API_KEY or ~/.prism/config.toml";
4969
+ }
4970
+ if (status === 429) {
4971
+ return "openrouter: rate limited. try again in a moment";
4972
+ }
4973
+ const clamped = message.length > 240 ? message.slice(0, 240) + "..." : message;
4974
+ return `openrouter ${status}: ${clamped}`;
4975
+ }
3788
4976
  var OpenRouterProvider = class {
3789
4977
  name = "openrouter";
3790
4978
  apiKey = "";
@@ -3829,7 +5017,7 @@ var OpenRouterProvider = class {
3829
5017
  });
3830
5018
  if (!res.ok) {
3831
5019
  const errorText = await res.text().catch(() => res.statusText);
3832
- yield { type: "error", error: `openrouter error: ${res.status} ${errorText}` };
5020
+ yield { type: "error", error: formatOpenRouterError(res.status, errorText) };
3833
5021
  return;
3834
5022
  }
3835
5023
  const reader = res.body?.getReader();
@@ -3841,6 +5029,7 @@ var OpenRouterProvider = class {
3841
5029
  const messageId = crypto.randomUUID();
3842
5030
  let inputTokens = 0;
3843
5031
  let outputTokens = 0;
5032
+ let cachedInputTokens = 0;
3844
5033
  const toolCalls = /* @__PURE__ */ new Map();
3845
5034
  yield { type: "message_start", id: messageId };
3846
5035
  let buffer = "";
@@ -3867,16 +5056,16 @@ var OpenRouterProvider = class {
3867
5056
  }
3868
5057
  if (choice.delta.tool_calls) {
3869
5058
  for (const tc of choice.delta.tool_calls) {
3870
- const idx = tc.index ?? 0;
3871
- if (!toolCalls.has(idx)) {
5059
+ const key = tc.index ?? tc.id ?? 0;
5060
+ if (!toolCalls.has(key)) {
3872
5061
  const id = tc.id || crypto.randomUUID();
3873
5062
  const name = tc.function?.name || "";
3874
- toolCalls.set(idx, { id, name, args: "" });
5063
+ toolCalls.set(key, { id, name, args: "" });
3875
5064
  if (name) {
3876
5065
  yield { type: "tool_call_start", id, name };
3877
5066
  }
3878
5067
  }
3879
- const existing = toolCalls.get(idx);
5068
+ const existing = toolCalls.get(key);
3880
5069
  if (tc.function?.name && !existing.name) {
3881
5070
  existing.name = tc.function.name;
3882
5071
  yield { type: "tool_call_start", id: existing.id, name: existing.name };
@@ -3897,13 +5086,14 @@ var OpenRouterProvider = class {
3897
5086
  if (chunk.usage) {
3898
5087
  inputTokens = chunk.usage.prompt_tokens;
3899
5088
  outputTokens = chunk.usage.completion_tokens;
5089
+ cachedInputTokens = extractCachedTokens(chunk.usage);
3900
5090
  }
3901
5091
  }
3902
5092
  }
3903
5093
  const stopReason = toolCalls.size > 0 ? "tool_use" : "end_turn";
3904
5094
  yield {
3905
5095
  type: "message_end",
3906
- usage: { inputTokens, outputTokens },
5096
+ usage: { inputTokens, outputTokens, cachedInputTokens },
3907
5097
  stopReason
3908
5098
  };
3909
5099
  }
@@ -3922,7 +5112,7 @@ var OpenRouterProvider = class {
3922
5112
  });
3923
5113
  if (!res.ok) {
3924
5114
  const errorText = await res.text().catch(() => res.statusText);
3925
- throw new Error(`openrouter error: ${res.status} ${errorText}`);
5115
+ throw new Error(formatOpenRouterError(res.status, errorText));
3926
5116
  }
3927
5117
  const data = await res.json();
3928
5118
  const choice = data.choices?.[0];
@@ -3934,15 +5124,18 @@ var OpenRouterProvider = class {
3934
5124
  if (choice.message.tool_calls) {
3935
5125
  for (const tc of choice.message.tool_calls) {
3936
5126
  let input = {};
5127
+ let invalidArgs = false;
3937
5128
  try {
3938
- input = JSON.parse(tc.function.arguments);
5129
+ input = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
3939
5130
  } catch {
5131
+ invalidArgs = true;
3940
5132
  }
3941
5133
  content.push({
3942
5134
  type: "tool_use",
3943
- id: tc.id,
5135
+ id: tc.id || crypto.randomUUID(),
3944
5136
  name: tc.function.name,
3945
- input
5137
+ input,
5138
+ ...invalidArgs ? { invalidArgs: true } : {}
3946
5139
  });
3947
5140
  }
3948
5141
  }
@@ -3952,7 +5145,8 @@ var OpenRouterProvider = class {
3952
5145
  content,
3953
5146
  usage: {
3954
5147
  inputTokens: data.usage?.prompt_tokens || 0,
3955
- outputTokens: data.usage?.completion_tokens || 0
5148
+ outputTokens: data.usage?.completion_tokens || 0,
5149
+ cachedInputTokens: extractCachedTokens(data.usage)
3956
5150
  },
3957
5151
  stopReason: hasTools ? "tool_use" : "end_turn"
3958
5152
  };
@@ -4046,126 +5240,463 @@ var OpenRouterProvider = class {
4046
5240
  }
4047
5241
  }
4048
5242
  }
4049
- return result;
5243
+ return result;
5244
+ }
5245
+ };
5246
+
5247
+ // src/ui/useModelSwitch.ts
5248
+ async function switchModel(newModel, session, setProvider, setModel, setCaps, setDisplayMessages) {
5249
+ const config = loadConfig();
5250
+ const isOpenRouter = newModel.includes("/");
5251
+ let newProvider;
5252
+ if (isOpenRouter) {
5253
+ const or = new OpenRouterProvider();
5254
+ await or.connect({ model: newModel, apiKey: config.openrouter.api_key });
5255
+ newProvider = or;
5256
+ } else {
5257
+ const ollama = new OllamaProvider();
5258
+ await ollama.connect({ model: newModel, baseUrl: config.ollama.base_url });
5259
+ newProvider = ollama;
5260
+ }
5261
+ setProvider(newProvider);
5262
+ setModel(newModel);
5263
+ setCaps(newProvider.getCapabilities());
5264
+ session.model = newModel;
5265
+ session.provider = newProvider.name;
5266
+ saveSession(session);
5267
+ setDisplayMessages((prev) => [...prev, { role: "tool_result", text: `switched to ${newModel}`, isError: false }]);
5268
+ }
5269
+
5270
+ // src/retrieval/repomap.ts
5271
+ import { readdirSync as readdirSync7, statSync as statSync3, lstatSync, readFileSync as readFileSync12 } from "fs";
5272
+ import { join as join12, relative } from "path";
5273
+
5274
+ // src/retrieval/languages.ts
5275
+ import { extname as extname2, basename as basename5 } from "path";
5276
+ var EXT_MAP = {
5277
+ // typescript / javascript
5278
+ ".ts": "typescript",
5279
+ ".mts": "typescript",
5280
+ ".cts": "typescript",
5281
+ ".tsx": "tsx",
5282
+ ".js": "javascript",
5283
+ ".mjs": "javascript",
5284
+ ".cjs": "javascript",
5285
+ ".jsx": "javascript",
5286
+ // scripting
5287
+ ".py": "python",
5288
+ ".pyw": "python",
5289
+ ".pyx": "python",
5290
+ ".rb": "ruby",
5291
+ ".php": "php",
5292
+ ".lua": "lua",
5293
+ ".r": "r",
5294
+ ".R": "r",
5295
+ ".jl": "julia",
5296
+ ".sh": "bash",
5297
+ ".bash": "bash",
5298
+ ".zsh": "bash",
5299
+ // systems
5300
+ ".c": "c",
5301
+ ".h": "c",
5302
+ ".cpp": "cpp",
5303
+ ".cc": "cpp",
5304
+ ".cxx": "cpp",
5305
+ ".hpp": "cpp",
5306
+ ".hh": "cpp",
5307
+ ".rs": "rust",
5308
+ ".go": "go",
5309
+ ".zig": "zig",
5310
+ // jvm
5311
+ ".java": "java",
5312
+ ".kt": "kotlin",
5313
+ ".kts": "kotlin",
5314
+ ".scala": "scala",
5315
+ ".clj": "clojure",
5316
+ ".cljs": "clojure",
5317
+ ".cljc": "clojure",
5318
+ // dotnet
5319
+ ".cs": "c-sharp",
5320
+ // mobile
5321
+ ".swift": "swift",
5322
+ ".dart": "dart",
5323
+ // functional
5324
+ ".hs": "haskell",
5325
+ ".ex": "elixir",
5326
+ ".exs": "elixir",
5327
+ ".erl": "erlang",
5328
+ ".hrl": "erlang",
5329
+ ".ml": "ocaml",
5330
+ ".mli": "ocaml",
5331
+ // markup / data
5332
+ ".html": "html",
5333
+ ".htm": "html",
5334
+ ".css": "css",
5335
+ ".json": "json",
5336
+ ".toml": "toml",
5337
+ ".yaml": "yaml",
5338
+ ".yml": "yaml",
5339
+ ".md": "markdown",
5340
+ ".mdx": "markdown",
5341
+ ".sql": "sql",
5342
+ ".graphql": "graphql",
5343
+ ".gql": "graphql",
5344
+ ".svelte": "svelte"
5345
+ };
5346
+ var FILENAME_MAP = {
5347
+ "Dockerfile": "dockerfile",
5348
+ "Containerfile": "dockerfile",
5349
+ "Makefile": "make",
5350
+ "makefile": "make",
5351
+ "GNUmakefile": "make"
5352
+ };
5353
+ function detectLanguage2(filePath) {
5354
+ const base = basename5(filePath);
5355
+ if (FILENAME_MAP[base]) return FILENAME_MAP[base];
5356
+ const ext = extname2(filePath).toLowerCase();
5357
+ return EXT_MAP[ext] ?? null;
5358
+ }
5359
+ function knownLanguages() {
5360
+ return /* @__PURE__ */ new Set([...Object.values(EXT_MAP), ...Object.values(FILENAME_MAP)]);
5361
+ }
5362
+
5363
+ // src/retrieval/treesitter.ts
5364
+ import { readFileSync as readFileSync10, existsSync as existsSync10 } from "fs";
5365
+ import { dirname, join as join10 } from "path";
5366
+ import { fileURLToPath } from "url";
5367
+ import { Parser, Language } from "web-tree-sitter";
5368
+ var initPromise = null;
5369
+ var grammars = /* @__PURE__ */ new Map();
5370
+ var resolvedWasmDir = null;
5371
+ function wasmDir() {
5372
+ if (resolvedWasmDir) return resolvedWasmDir;
5373
+ const here = dirname(fileURLToPath(import.meta.url));
5374
+ const candidates = [
5375
+ join10(here, "wasm"),
5376
+ // dist/cli.js → dist/wasm/ (bundled)
5377
+ join10(here, "..", "wasm", "build"),
5378
+ // src/retrieval/foo.ts → wasm/build (dev, tsx)
5379
+ join10(here, "..", "..", "wasm", "build")
5380
+ // dist/cli.js fallback if dist/ is one level deeper
5381
+ ];
5382
+ for (const c of candidates) {
5383
+ if (existsSync10(c)) {
5384
+ resolvedWasmDir = c;
5385
+ return c;
5386
+ }
5387
+ }
5388
+ throw new Error(
5389
+ `grammar wasm directory not found. checked: ${candidates.join(", ")}. run \`npm run build:wasms\` to build them, or \`npm run cp:wasms\` to copy from wasm/build to dist/wasm.`
5390
+ );
5391
+ }
5392
+ async function ensureInit() {
5393
+ if (!initPromise) initPromise = Parser.init();
5394
+ return initPromise;
5395
+ }
5396
+ async function loadGrammar(language) {
5397
+ if (grammars.has(language)) return grammars.get(language);
5398
+ await ensureInit();
5399
+ const path = join10(wasmDir(), `tree-sitter-${language}.wasm`);
5400
+ if (!existsSync10(path)) {
5401
+ grammars.set(language, null);
5402
+ return null;
5403
+ }
5404
+ try {
5405
+ const lang = await Language.load(readFileSync10(path));
5406
+ grammars.set(language, lang);
5407
+ return lang;
5408
+ } catch {
5409
+ grammars.set(language, null);
5410
+ return null;
5411
+ }
5412
+ }
5413
+ async function extractSymbols(filePath, source) {
5414
+ const language = detectLanguage2(filePath);
5415
+ if (!language) return null;
5416
+ const grammar = await loadGrammar(language);
5417
+ if (!grammar) return null;
5418
+ const parser = new Parser();
5419
+ parser.setLanguage(grammar);
5420
+ const tree = parser.parse(source);
5421
+ if (!tree) {
5422
+ parser.delete();
5423
+ return null;
5424
+ }
5425
+ const symbols = [];
5426
+ const imports = [];
5427
+ const root = tree.rootNode;
5428
+ collectFromNode(root, symbols, imports);
5429
+ tree.delete();
5430
+ parser.delete();
5431
+ return { language, symbols, imports };
5432
+ }
5433
+ function collectFromNode(root, symbols, imports) {
5434
+ for (let i = 0; i < root.childCount; i++) {
5435
+ const child = root.child(i);
5436
+ if (!child) continue;
5437
+ pushSymbolIfNamed(child, symbols);
5438
+ pushImportIfImport(child, imports);
5439
+ if (child.type === "export_statement") {
5440
+ for (let j = 0; j < child.childCount; j++) {
5441
+ const inner = child.child(j);
5442
+ if (inner) pushSymbolIfNamed(inner, symbols);
5443
+ }
5444
+ }
5445
+ }
5446
+ }
5447
+ function pushSymbolIfNamed(node, symbols) {
5448
+ const nameNode = node.childForFieldName("name");
5449
+ if (!nameNode) return;
5450
+ const firstLine = node.text.split("\n")[0] ?? "";
5451
+ symbols.push({
5452
+ kind: node.type,
5453
+ name: nameNode.text,
5454
+ line: node.startPosition.row + 1,
5455
+ signature: firstLine.length > 100 ? firstLine.slice(0, 100) + "..." : firstLine
5456
+ });
5457
+ }
5458
+ function pushImportIfImport(node, imports) {
5459
+ if (!node.type.includes("import") && node.type !== "use_declaration") return;
5460
+ const src = findStringDescendant(node);
5461
+ if (src) imports.push(src);
5462
+ }
5463
+ function findStringDescendant(node) {
5464
+ for (let i = 0; i < node.childCount; i++) {
5465
+ const child = node.child(i);
5466
+ if (!child) continue;
5467
+ if (child.type === "string" || child.type === "string_literal") {
5468
+ return child.text.replace(/^["'`]|["'`]$/g, "");
5469
+ }
5470
+ const nested = findStringDescendant(child);
5471
+ if (nested) return nested;
5472
+ }
5473
+ return null;
5474
+ }
5475
+
5476
+ // src/retrieval/cache.ts
5477
+ import { existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync6, renameSync, readdirSync as readdirSync6, rmSync, statSync as statSync2 } from "fs";
5478
+ import { join as join11 } from "path";
5479
+ import { homedir as homedir10 } from "os";
5480
+ import { createHash as createHash3 } from "crypto";
5481
+ var ROOT = join11(homedir10(), ".prism", "cache", "trees");
5482
+ function cacheDir(projectId) {
5483
+ return join11(ROOT, projectId);
5484
+ }
5485
+ function hashPath(filePath) {
5486
+ return createHash3("sha256").update(filePath).digest("hex").slice(0, 16);
5487
+ }
5488
+ function entryFile(projectId, filePath) {
5489
+ return join11(cacheDir(projectId), `${hashPath(filePath)}.json`);
5490
+ }
5491
+ function getCached(projectId, filePath, mtime, size) {
5492
+ const path = entryFile(projectId, filePath);
5493
+ if (!existsSync11(path)) return null;
5494
+ try {
5495
+ const data = JSON.parse(readFileSync11(path, "utf-8"));
5496
+ if (data.path !== filePath) return null;
5497
+ if (data.mtime !== mtime) return null;
5498
+ if (data.size !== size) return null;
5499
+ return data;
5500
+ } catch {
5501
+ return null;
5502
+ }
5503
+ }
5504
+ function setCached(projectId, filePath, data) {
5505
+ const dir = cacheDir(projectId);
5506
+ if (!existsSync11(dir)) {
5507
+ try {
5508
+ mkdirSync6(dir, { recursive: true });
5509
+ } catch {
5510
+ return;
5511
+ }
4050
5512
  }
4051
- };
4052
-
4053
- // src/config/config.ts
4054
- import { existsSync as existsSync9, readFileSync as readFileSync9, writeFileSync as writeFileSync5, mkdirSync as mkdirSync5 } from "fs";
4055
- import { join as join9 } from "path";
4056
- import { homedir as homedir9 } from "os";
4057
- var PRISM_DIR = join9(homedir9(), ".prism");
4058
- var CONFIG_PATH = join9(PRISM_DIR, "config.toml");
4059
- var DEFAULTS = {
4060
- default_provider: "ollama",
4061
- default_model: "deepseek-r1:14b",
4062
- openrouter: { api_key: "" },
4063
- anthropic: { api_key: "" },
4064
- openai: { api_key: "" },
4065
- google: { api_key: "" },
4066
- ollama: { base_url: "http://localhost:11434" }
4067
- };
4068
- function loadConfig() {
4069
- const config = { ...DEFAULTS };
4070
- if (existsSync9(CONFIG_PATH)) {
5513
+ const entry = {
5514
+ path: filePath,
5515
+ mtime: data.mtime,
5516
+ size: data.size,
5517
+ language: data.language,
5518
+ symbols: data.symbols,
5519
+ imports: data.imports,
5520
+ cachedAt: (/* @__PURE__ */ new Date()).toISOString()
5521
+ };
5522
+ const final = entryFile(projectId, filePath);
5523
+ const tmp = `${final}.${process.pid}.tmp`;
5524
+ try {
5525
+ writeFileSync6(tmp, JSON.stringify(entry), "utf-8");
5526
+ renameSync(tmp, final);
5527
+ } catch {
4071
5528
  try {
4072
- const text = readFileSync9(CONFIG_PATH, "utf-8");
4073
- const parsed = parseToml(text);
4074
- if (parsed.default_provider) config.default_provider = parsed.default_provider;
4075
- if (parsed.default_model) config.default_model = parsed.default_model;
4076
- if (parsed.openrouter?.api_key) config.openrouter.api_key = parsed.openrouter.api_key;
4077
- if (parsed.anthropic?.api_key) config.anthropic.api_key = parsed.anthropic.api_key;
4078
- if (parsed.openai?.api_key) config.openai.api_key = parsed.openai.api_key;
4079
- if (parsed.google?.api_key) config.google.api_key = parsed.google.api_key;
4080
- if (parsed.ollama?.base_url) config.ollama.base_url = parsed.ollama.base_url;
5529
+ rmSync(tmp, { force: true });
4081
5530
  } catch {
4082
5531
  }
4083
5532
  }
4084
- if (process.env.OPENROUTER_API_KEY) config.openrouter.api_key = process.env.OPENROUTER_API_KEY;
4085
- if (process.env.ANTHROPIC_API_KEY) config.anthropic.api_key = process.env.ANTHROPIC_API_KEY;
4086
- if (process.env.OPENAI_API_KEY) config.openai.api_key = process.env.OPENAI_API_KEY;
4087
- if (process.env.GOOGLE_API_KEY) config.google.api_key = process.env.GOOGLE_API_KEY;
4088
- if (process.env.OLLAMA_HOST) config.ollama.base_url = process.env.OLLAMA_HOST;
4089
- return config;
4090
5533
  }
4091
- function initConfig() {
4092
- if (existsSync9(CONFIG_PATH)) return;
4093
- if (!existsSync9(PRISM_DIR)) {
4094
- mkdirSync5(PRISM_DIR, { recursive: true });
4095
- }
4096
- const template = `# prism config
4097
- # env vars override these values.
4098
-
4099
- default_provider = "ollama"
4100
- default_model = "deepseek-r1:14b"
4101
-
4102
- [openrouter]
4103
- api_key = ""
4104
-
4105
- [anthropic]
4106
- api_key = ""
4107
-
4108
- [openai]
4109
- api_key = ""
4110
-
4111
- [google]
4112
- api_key = ""
4113
5534
 
4114
- [ollama]
4115
- base_url = "http://localhost:11434"
4116
- `;
4117
- writeFileSync5(CONFIG_PATH, template, "utf-8");
4118
- }
4119
- function getConfigPath() {
4120
- return CONFIG_PATH;
4121
- }
4122
- function parseToml(text) {
4123
- const result = {};
4124
- let currentSection = result;
4125
- for (const line of text.split("\n")) {
4126
- const trimmed = line.trim();
4127
- if (!trimmed || trimmed.startsWith("#")) continue;
4128
- const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
4129
- if (sectionMatch) {
4130
- const key = sectionMatch[1];
4131
- result[key] = result[key] || {};
4132
- currentSection = result[key];
5535
+ // src/retrieval/repomap.ts
5536
+ var IGNORE_DIRS2 = /* @__PURE__ */ new Set([
5537
+ "node_modules",
5538
+ ".git",
5539
+ ".venv",
5540
+ "venv",
5541
+ "env",
5542
+ "__pycache__",
5543
+ ".mypy_cache",
5544
+ ".pytest_cache",
5545
+ ".ruff_cache",
5546
+ "dist",
5547
+ "build",
5548
+ "out",
5549
+ "target",
5550
+ "_build",
5551
+ ".next",
5552
+ ".nuxt",
5553
+ ".svelte-kit",
5554
+ ".astro",
5555
+ ".cache",
5556
+ ".parcel-cache",
5557
+ ".turbo",
5558
+ "vendor",
5559
+ "deps",
5560
+ "_deps",
5561
+ "coverage",
5562
+ ".nyc_output",
5563
+ ".idea",
5564
+ ".vscode",
5565
+ ".fleet",
5566
+ // prism build artifact: wasm/sources/ holds cloned grammar repos (zig,
5567
+ // python, etc.); wasm/build/ holds the compiled wasms. neither is "the
5568
+ // project," both confuse the repo-map with generated parser code.
5569
+ "wasm"
5570
+ ]);
5571
+ async function extractRepoMap(cwd, opts = {}) {
5572
+ const { tuning } = loadConfig();
5573
+ const maxFiles = opts.maxFiles ?? tuning.repomap_max_files;
5574
+ const maxSymbolsPerFile = opts.maxSymbolsPerFile ?? tuning.repomap_max_symbols_per_file;
5575
+ const ignore = /* @__PURE__ */ new Set([...IGNORE_DIRS2, ...opts.extraIgnore ?? []]);
5576
+ const supported = knownLanguages();
5577
+ const candidates = walkProject(cwd, ignore, supported, maxFiles);
5578
+ const projectId = getProjectId(cwd);
5579
+ const entries = [];
5580
+ let cacheHits = 0;
5581
+ let cacheMisses = 0;
5582
+ let parseFailures = 0;
5583
+ for (const filePath of candidates) {
5584
+ let mtime = 0;
5585
+ let size = 0;
5586
+ try {
5587
+ const st = statSync3(filePath);
5588
+ mtime = st.mtimeMs;
5589
+ size = st.size;
5590
+ } catch {
4133
5591
  continue;
4134
5592
  }
4135
- const kvMatch = trimmed.match(/^(\w+)\s*=\s*"([^"]*)"$/);
4136
- if (kvMatch) {
4137
- currentSection[kvMatch[1]] = kvMatch[2];
5593
+ const cached = getCached(projectId, filePath, mtime, size);
5594
+ if (cached) {
5595
+ cacheHits += 1;
5596
+ if (cached.symbols.length > 0) {
5597
+ entries.push({
5598
+ path: relative(cwd, filePath),
5599
+ language: cached.language,
5600
+ symbols: cached.symbols.slice(0, maxSymbolsPerFile)
5601
+ });
5602
+ }
4138
5603
  continue;
4139
5604
  }
4140
- const kvUnquoted = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
4141
- if (kvUnquoted) {
4142
- currentSection[kvUnquoted[1]] = kvUnquoted[2].trim();
5605
+ cacheMisses += 1;
5606
+ let source;
5607
+ try {
5608
+ source = readFileSync12(filePath, "utf-8");
5609
+ } catch {
5610
+ parseFailures += 1;
5611
+ continue;
5612
+ }
5613
+ const result = await extractSymbols(filePath, source);
5614
+ if (!result) {
5615
+ parseFailures += 1;
5616
+ continue;
5617
+ }
5618
+ setCached(projectId, filePath, {
5619
+ mtime,
5620
+ size,
5621
+ language: result.language,
5622
+ symbols: result.symbols,
5623
+ imports: result.imports
5624
+ });
5625
+ if (result.symbols.length > 0) {
5626
+ entries.push({
5627
+ path: relative(cwd, filePath),
5628
+ language: result.language,
5629
+ symbols: result.symbols.slice(0, maxSymbolsPerFile)
5630
+ });
4143
5631
  }
4144
5632
  }
4145
- return result;
5633
+ return {
5634
+ cwd,
5635
+ entries,
5636
+ filesWalked: candidates.length,
5637
+ cacheHits,
5638
+ cacheMisses,
5639
+ parseFailures
5640
+ };
4146
5641
  }
4147
-
4148
- // src/ui/useModelSwitch.ts
4149
- async function switchModel(newModel, session, setProvider, setModel, setCaps, setDisplayMessages) {
4150
- const config = loadConfig();
4151
- const isOpenRouter = newModel.includes("/");
4152
- let newProvider;
4153
- if (isOpenRouter) {
4154
- const or = new OpenRouterProvider();
4155
- await or.connect({ model: newModel, apiKey: config.openrouter.api_key });
4156
- newProvider = or;
4157
- } else {
4158
- const ollama = new OllamaProvider();
4159
- await ollama.connect({ model: newModel, baseUrl: config.ollama.base_url });
4160
- newProvider = ollama;
5642
+ function formatRepoMap(data, opts = {}) {
5643
+ const maxLines = opts.maxLines ?? loadConfig().tuning.repomap_max_lines;
5644
+ if (data.entries.length === 0) return "";
5645
+ const lines = ["# repo map", ""];
5646
+ let truncated = 0;
5647
+ for (let i = 0; i < data.entries.length; i++) {
5648
+ const entry = data.entries[i];
5649
+ const block = [entry.path];
5650
+ for (const sym of entry.symbols) {
5651
+ const kind = sym.kind.replace(/_declaration$|_definition$/, "");
5652
+ block.push(` ${kind} ${sym.name}`);
5653
+ }
5654
+ if (lines.length + block.length + 1 > maxLines) {
5655
+ truncated = data.entries.length - i;
5656
+ break;
5657
+ }
5658
+ lines.push(...block);
5659
+ lines.push("");
4161
5660
  }
4162
- setProvider(newProvider);
4163
- setModel(newModel);
4164
- setCaps(newProvider.getCapabilities());
4165
- session.model = newModel;
4166
- session.provider = newProvider.name;
4167
- saveSession(session);
4168
- setDisplayMessages((prev) => [...prev, { role: "tool_result", text: `switched to ${newModel}`, isError: false }]);
5661
+ if (truncated > 0) {
5662
+ lines.push(`...and ${truncated} more files (call Read or Grep to inspect)`);
5663
+ }
5664
+ return lines.join("\n").trimEnd();
5665
+ }
5666
+ function walkProject(cwd, ignore, supportedLangs, maxFiles) {
5667
+ const results = [];
5668
+ const stack = [cwd];
5669
+ while (stack.length > 0 && results.length < maxFiles) {
5670
+ const dir = stack.pop();
5671
+ let entries;
5672
+ try {
5673
+ entries = readdirSync7(dir);
5674
+ } catch {
5675
+ continue;
5676
+ }
5677
+ for (const name of entries) {
5678
+ if (ignore.has(name)) continue;
5679
+ if (name.startsWith(".")) continue;
5680
+ const path = join12(dir, name);
5681
+ let s;
5682
+ try {
5683
+ s = lstatSync(path);
5684
+ } catch {
5685
+ continue;
5686
+ }
5687
+ if (s.isSymbolicLink()) continue;
5688
+ if (s.isDirectory()) {
5689
+ stack.push(path);
5690
+ } else if (s.isFile()) {
5691
+ const lang = detectLanguage2(name);
5692
+ if (lang && supportedLangs.has(lang)) {
5693
+ results.push(path);
5694
+ if (results.length >= maxFiles) break;
5695
+ }
5696
+ }
5697
+ }
5698
+ }
5699
+ return results;
4169
5700
  }
4170
5701
 
4171
5702
  // src/ui/App.tsx
@@ -4197,20 +5728,24 @@ function rebuildDisplayMessages(messages) {
4197
5728
  }
4198
5729
  return display;
4199
5730
  }
4200
- function App({ provider: initProvider, model: initModel, tools: baseTools, capabilities: initCaps, session, initialMessages, projectContext: initProjectContext, memory }) {
5731
+ function App({ provider: initProvider, model: initModel, tools: baseTools, capabilities: initCaps, session, initialMessages, projectContext: initProjectContext, memory, repoMapOverride }) {
4201
5732
  const [provider, setProvider] = useState3(initProvider);
4202
5733
  const [model, setModel] = useState3(initModel);
4203
5734
  const [caps, setCaps] = useState3(initCaps);
4204
5735
  const { exit } = useApp();
4205
5736
  const [displayMessages, setDisplayMessages] = useState3(() => rebuildDisplayMessages(initialMessages));
4206
5737
  const [isLoading, setIsLoading] = useState3(false);
5738
+ const submittingRef = useRef3(false);
4207
5739
  const [turnCount, setTurnCount] = useState3(0);
4208
5740
  const [tokenInfo, setTokenInfo] = useState3("");
5741
+ const [spinnerPhase, setSpinnerPhase] = useState3("thinking");
5742
+ const [spinnerTool, setSpinnerTool] = useState3(void 0);
4209
5743
  const [profile, setProfile] = useState3(() => loadProfile(model));
4210
5744
  const [pendingPermission, setPendingPermission] = useState3(null);
4211
5745
  const [abortController, setAbortController] = useState3(null);
4212
5746
  const [inPlanMode, setInPlanMode] = useState3(false);
4213
5747
  const [activeSkills, setActiveSkills] = useState3(/* @__PURE__ */ new Set());
5748
+ const [repoMap, setRepoMap] = useState3("");
4214
5749
  const [projectContext] = useState3(() => initProjectContext ?? scanProject(process.cwd()));
4215
5750
  const [messages] = useState3(() => initialMessages ? [...initialMessages] : []);
4216
5751
  const [agentTool] = useState3(() => createAgentTool({
@@ -4235,6 +5770,25 @@ function App({ provider: initProvider, model: initModel, tools: baseTools, capab
4235
5770
  }
4236
5771
  }));
4237
5772
  const [skillTool] = useState3(() => createSkillTool(process.cwd()));
5773
+ useEffect3(() => {
5774
+ if (repoMapOverride?.skip) return;
5775
+ let cancelled = false;
5776
+ (async () => {
5777
+ try {
5778
+ const data = await extractRepoMap(process.cwd(), {
5779
+ ...repoMapOverride?.maxFiles ? { maxFiles: repoMapOverride.maxFiles } : {}
5780
+ });
5781
+ const formatted = formatRepoMap(data, {
5782
+ ...repoMapOverride?.maxLines ? { maxLines: repoMapOverride.maxLines } : {}
5783
+ });
5784
+ if (!cancelled) setRepoMap(formatted);
5785
+ } catch {
5786
+ }
5787
+ })();
5788
+ return () => {
5789
+ cancelled = true;
5790
+ };
5791
+ }, []);
4238
5792
  const tools = useMemo2(() => [...baseTools, agentTool, skillTool], [baseTools, agentTool, skillTool]);
4239
5793
  const toolSchemas = useMemo2(() => tools.map((t) => toolToSchema(t)), [tools]);
4240
5794
  const getSystemPrompt = useCallback3(() => {
@@ -4250,9 +5804,10 @@ function App({ provider: initProvider, model: initModel, tools: baseTools, capab
4250
5804
  projectContext,
4251
5805
  memory,
4252
5806
  inPlanMode,
4253
- activeSkills
5807
+ activeSkills,
5808
+ repoMap
4254
5809
  });
4255
- }, [caps, toolSchemas, profile, memory, inPlanMode, activeSkills]);
5810
+ }, [caps, toolSchemas, profile, memory, inPlanMode, activeSkills, repoMap]);
4256
5811
  useInput3((input, key) => {
4257
5812
  if (!isLoading && key.ctrl && input === "c") {
4258
5813
  exit();
@@ -4267,11 +5822,13 @@ function App({ provider: initProvider, model: initModel, tools: baseTools, capab
4267
5822
  const runModelLoop = useCallback3(async () => {
4268
5823
  setTurnCount((prev) => prev + 1);
4269
5824
  setIsLoading(true);
5825
+ setSpinnerPhase("thinking");
5826
+ setSpinnerTool(void 0);
4270
5827
  const controller = new AbortController();
4271
5828
  setAbortController(controller);
4272
5829
  const askPermission = (toolName, description, id) => {
4273
- return new Promise((resolve6) => {
4274
- setPendingPermission({ toolName, description, id, resolve: resolve6 });
5830
+ return new Promise((resolve7) => {
5831
+ setPendingPermission({ toolName, description, id, resolve: resolve7 });
4275
5832
  });
4276
5833
  };
4277
5834
  let currentText = "";
@@ -4301,9 +5858,13 @@ function App({ provider: initProvider, model: initModel, tools: baseTools, capab
4301
5858
  });
4302
5859
  break;
4303
5860
  case "tool_start":
5861
+ setSpinnerPhase("running");
5862
+ setSpinnerTool(event.name);
4304
5863
  setDisplayMessages((prev) => [...prev, { role: "tool_call", text: "", toolName: event.name }]);
4305
5864
  break;
4306
5865
  case "tool_end":
5866
+ setSpinnerPhase("after-tool");
5867
+ setSpinnerTool(event.name);
4307
5868
  setDisplayMessages((prev) => [...prev, { role: "tool_result", text: event.result, isError: event.isError }]);
4308
5869
  currentText = "";
4309
5870
  break;
@@ -4350,11 +5911,11 @@ function App({ provider: initProvider, model: initModel, tools: baseTools, capab
4350
5911
  messages.push({ role: "user", content: [{ type: "text", text: "the user interrupted the current operation. stop what you were doing and ask what they want instead." }] });
4351
5912
  }
4352
5913
  session.messages = messages;
4353
- saveSession(session);
4354
5914
  setTimeout(() => {
4355
5915
  setAbortController(null);
4356
5916
  setIsLoading(false);
4357
5917
  }, 0);
5918
+ setTimeout(() => saveSession(session), 0);
4358
5919
  }, [provider, model, tools, messages, getSystemPrompt, session]);
4359
5920
  const triggerSyntheticTurn = useCallback3((hiddenMsg) => {
4360
5921
  messages.push({ role: "user", content: [{ type: "text", text: hiddenMsg }] });
@@ -4382,9 +5943,15 @@ function App({ provider: initProvider, model: initModel, tools: baseTools, capab
4382
5943
  });
4383
5944
  if (handled) return;
4384
5945
  }
4385
- setDisplayMessages((prev) => [...prev, { role: "user", text: input }]);
4386
- messages.push({ role: "user", content: [{ type: "text", text: input }] });
4387
- await runModelLoop();
5946
+ if (submittingRef.current) return;
5947
+ submittingRef.current = true;
5948
+ try {
5949
+ setDisplayMessages((prev) => [...prev, { role: "user", text: input }]);
5950
+ messages.push({ role: "user", content: [{ type: "text", text: input }] });
5951
+ await runModelLoop();
5952
+ } finally {
5953
+ submittingRef.current = false;
5954
+ }
4388
5955
  }, [provider, model, tools, messages, getSystemPrompt, inPlanMode, triggerSyntheticTurn]);
4389
5956
  return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", padding: 1, children: [
4390
5957
  /* @__PURE__ */ jsx8(
@@ -4419,7 +5986,9 @@ function App({ provider: initProvider, model: initModel, tools: baseTools, capab
4419
5986
  onSubmit: handleSubmit,
4420
5987
  isLoading,
4421
5988
  inPlanMode,
4422
- invokeSkills: invokeSkillSpecs
5989
+ invokeSkills: invokeSkillSpecs,
5990
+ phase: spinnerPhase,
5991
+ currentTool: spinnerTool
4423
5992
  }
4424
5993
  )
4425
5994
  ] });
@@ -4427,8 +5996,49 @@ function App({ provider: initProvider, model: initModel, tools: baseTools, capab
4427
5996
 
4428
5997
  // src/tools/bash.ts
4429
5998
  import { z as z4 } from "zod";
4430
- import { execSync as execSync5 } from "child_process";
4431
- var MAX_OUTPUT2 = 512 * 1024;
5999
+ import { exec as exec2 } from "child_process";
6000
+ import { promisify } from "util";
6001
+
6002
+ // src/tools/sensitivePaths.ts
6003
+ import { resolve, relative as relative2, isAbsolute, basename as basename6, join as join13 } from "path";
6004
+ import { homedir as homedir11 } from "os";
6005
+ import { realpathSync } from "fs";
6006
+ var SECRET_NAME = /^(\.env(\..+)?|\.netrc|\.git-credentials|\.npmrc|id_(rsa|ed25519|ecdsa|dsa)|credentials)$/i;
6007
+ var SECRET_EXT = /\.(pem|key|p12|pfx|keystore)$/i;
6008
+ function expandHome(p) {
6009
+ if (p === "~") return homedir11();
6010
+ if (p.startsWith("~/")) return join13(homedir11(), p.slice(2));
6011
+ return p;
6012
+ }
6013
+ function realOrLexical(p) {
6014
+ try {
6015
+ return realpathSync.native(p);
6016
+ } catch {
6017
+ return p;
6018
+ }
6019
+ }
6020
+ function classifyRead(targetPath, cwd) {
6021
+ if (targetPath.startsWith("~") && targetPath !== "~" && !targetPath.startsWith("~/")) {
6022
+ return { allow: false, reason: "outside-project" };
6023
+ }
6024
+ const expanded = expandHome(targetPath);
6025
+ const abs = realOrLexical(isAbsolute(expanded) ? expanded : resolve(cwd, expanded));
6026
+ const root = realOrLexical(resolve(cwd));
6027
+ const rel = relative2(root, abs);
6028
+ const outside = rel.split(/[\\/]/)[0] === ".." || isAbsolute(rel);
6029
+ if (outside) return { allow: false, reason: "outside-project" };
6030
+ const name = basename6(abs);
6031
+ if (SECRET_NAME.test(name) || SECRET_EXT.test(name)) {
6032
+ return { allow: false, reason: "secret-name" };
6033
+ }
6034
+ return { allow: true, reason: "in-project" };
6035
+ }
6036
+ function readAskMessage(targetPath, reason) {
6037
+ return reason === "secret-name" ? `read possible secret file: ${targetPath}` : `read file outside the project: ${targetPath}`;
6038
+ }
6039
+
6040
+ // src/tools/bash.ts
6041
+ var execAsync = promisify(exec2);
4432
6042
  var SAFE_COMMANDS = /* @__PURE__ */ new Set([
4433
6043
  "ls",
4434
6044
  "cat",
@@ -4478,6 +6088,14 @@ var DANGEROUS_PATTERNS = [
4478
6088
  /\bmkfs\b/,
4479
6089
  /\bkill\s+-9\b/
4480
6090
  ];
6091
+ var SHELL_METACHARS = /[;&|<>$`\n()]/;
6092
+ function isSimpleSafeCommand(command) {
6093
+ const cmd = command.trim();
6094
+ if (SHELL_METACHARS.test(cmd)) return false;
6095
+ if (DANGEROUS_PATTERNS.some((p) => p.test(cmd))) return false;
6096
+ const firstToken = cmd.split(/\s+/)[0] || "";
6097
+ return SAFE_COMMANDS.has(firstToken) || SAFE_COMMANDS.has(cmd);
6098
+ }
4481
6099
  var inputSchema2 = z4.object({
4482
6100
  command: z4.string().describe("the shell command to execute"),
4483
6101
  description: z4.string().optional().describe("what this command does"),
@@ -4488,21 +6106,19 @@ var BashTool = buildTool({
4488
6106
  description: "Execute a shell command and return its output.",
4489
6107
  inputSchema: inputSchema2,
4490
6108
  async call(input, context) {
6109
+ const maxOutput = loadConfig().tuning.bash_max_output_bytes;
4491
6110
  const timeout = Math.min(input.timeout || 12e4, 6e5);
4492
6111
  try {
4493
- const output = execSync5(input.command, {
6112
+ const { stdout } = await execAsync(input.command, {
4494
6113
  cwd: context.cwd,
4495
6114
  timeout,
4496
- maxBuffer: MAX_OUTPUT2,
4497
- encoding: "utf-8",
4498
- stdio: ["pipe", "pipe", "pipe"],
6115
+ maxBuffer: maxOutput,
4499
6116
  env: { ...process.env }
4500
6117
  });
4501
- const stderr = "";
4502
- const result = output.trim();
4503
- if (result.length > MAX_OUTPUT2) {
6118
+ const result = stdout.trim();
6119
+ if (result.length > maxOutput) {
4504
6120
  return {
4505
- content: result.slice(0, MAX_OUTPUT2) + "\n\n[output truncated]"
6121
+ content: result.slice(0, maxOutput) + "\n\n[output truncated]"
4506
6122
  };
4507
6123
  }
4508
6124
  return { content: result || "(no output)" };
@@ -4510,7 +6126,7 @@ var BashTool = buildTool({
4510
6126
  const execError = error;
4511
6127
  const stdout = execError.stdout?.toString().trim() || "";
4512
6128
  const stderr = execError.stderr?.toString().trim() || "";
4513
- const exitCode = execError.status ?? 1;
6129
+ const exitCode = execError.code ?? 1;
4514
6130
  let content = "";
4515
6131
  if (stdout) content += stdout + "\n";
4516
6132
  if (stderr) content += stderr + "\n";
@@ -4520,14 +6136,12 @@ Exit code: ${exitCode}`;
4520
6136
  }
4521
6137
  },
4522
6138
  isConcurrencySafe(input) {
4523
- const cmd = input.command.trim().split(/\s+/)[0] || "";
4524
- return SAFE_COMMANDS.has(cmd) || SAFE_COMMANDS.has(input.command.trim());
6139
+ return isSimpleSafeCommand(input.command);
4525
6140
  },
4526
6141
  isReadOnly(input) {
4527
- const cmd = input.command.trim().split(/\s+/)[0] || "";
4528
- return SAFE_COMMANDS.has(cmd) || SAFE_COMMANDS.has(input.command.trim());
6142
+ return isSimpleSafeCommand(input.command);
4529
6143
  },
4530
- checkPermissions(input) {
6144
+ checkPermissions(input, context) {
4531
6145
  for (const pattern of DANGEROUS_PATTERNS) {
4532
6146
  if (pattern.test(input.command)) {
4533
6147
  return {
@@ -4536,7 +6150,14 @@ Exit code: ${exitCode}`;
4536
6150
  };
4537
6151
  }
4538
6152
  }
4539
- if (SAFE_COMMANDS.has(input.command.trim().split(/\s+/)[0] || "")) {
6153
+ if (isSimpleSafeCommand(input.command)) {
6154
+ const args = input.command.trim().split(/\s+/).slice(1);
6155
+ for (const arg of args) {
6156
+ if (arg.startsWith("-")) continue;
6157
+ if (!classifyRead(arg, context.cwd).allow) {
6158
+ return { behavior: "ask", message: `run: ${input.command}` };
6159
+ }
6160
+ }
4540
6161
  return { behavior: "allow" };
4541
6162
  }
4542
6163
  return { behavior: "ask", message: `run: ${input.command}` };
@@ -4545,11 +6166,11 @@ Exit code: ${exitCode}`;
4545
6166
 
4546
6167
  // src/tools/read.ts
4547
6168
  import { z as z5 } from "zod";
4548
- import { readFileSync as readFileSync13, statSync as statSync2 } from "fs";
4549
- import { resolve, isAbsolute, extname as extname3 } from "path";
6169
+ import { readFileSync as readFileSync16, statSync as statSync4 } from "fs";
6170
+ import { resolve as resolve2, isAbsolute as isAbsolute2, extname as extname4 } from "path";
4550
6171
 
4551
6172
  // src/parsers/pdf.ts
4552
- import { execFileSync as execFileSync2 } from "child_process";
6173
+ import { execFileSync } from "child_process";
4553
6174
  function parsePdf(filePath, pages) {
4554
6175
  const args = ["-layout"];
4555
6176
  if (pages) {
@@ -4558,7 +6179,7 @@ function parsePdf(filePath, pages) {
4558
6179
  }
4559
6180
  args.push(filePath, "-");
4560
6181
  try {
4561
- const output = execFileSync2("pdftotext", args, {
6182
+ const output = execFileSync("pdftotext", args, {
4562
6183
  encoding: "utf-8",
4563
6184
  timeout: 3e4,
4564
6185
  maxBuffer: 5 * 1024 * 1024
@@ -4582,18 +6203,23 @@ function parsePageRange(range) {
4582
6203
  }
4583
6204
 
4584
6205
  // src/parsers/docx.ts
4585
- import { readFileSync as readFileSync10 } from "fs";
6206
+ import { readFileSync as readFileSync13 } from "fs";
4586
6207
  async function parseDocx(filePath) {
4587
6208
  const mammoth = await import("mammoth");
4588
- const buffer = readFileSync10(filePath);
6209
+ const buffer = readFileSync13(filePath);
4589
6210
  const result = await mammoth.extractRawText({ buffer });
4590
6211
  return result.value.trim() || "(no text content in document)";
4591
6212
  }
4592
6213
 
4593
6214
  // src/parsers/notebook.ts
4594
- import { readFileSync as readFileSync11 } from "fs";
6215
+ import { readFileSync as readFileSync14 } from "fs";
6216
+ function joinSource(s) {
6217
+ if (Array.isArray(s)) return s.join("");
6218
+ if (typeof s === "string") return s;
6219
+ return "";
6220
+ }
4595
6221
  function parseNotebook(filePath) {
4596
- const raw = readFileSync11(filePath, "utf-8");
6222
+ const raw = readFileSync14(filePath, "utf-8");
4597
6223
  const notebook = JSON.parse(raw);
4598
6224
  if (!notebook.cells || notebook.cells.length === 0) {
4599
6225
  return "(empty notebook)";
@@ -4601,7 +6227,7 @@ function parseNotebook(filePath) {
4601
6227
  const parts = [];
4602
6228
  for (let i = 0; i < notebook.cells.length; i++) {
4603
6229
  const cell = notebook.cells[i];
4604
- const source = cell.source.join("");
6230
+ const source = joinSource(cell.source);
4605
6231
  if (cell.cell_type === "markdown") {
4606
6232
  parts.push(`[cell ${i + 1} markdown]
4607
6233
  ${source}`);
@@ -4612,13 +6238,13 @@ ${source}`);
4612
6238
  for (const out of cell.outputs) {
4613
6239
  if (out.text) {
4614
6240
  parts.push(`[output]
4615
- ${out.text.join("")}`);
6241
+ ${joinSource(out.text)}`);
4616
6242
  }
4617
6243
  if (out.data) {
4618
6244
  for (const [mime, content] of Object.entries(out.data)) {
4619
6245
  if (mime.startsWith("text/")) {
4620
6246
  parts.push(`[output ${mime}]
4621
- ${content.join("")}`);
6247
+ ${joinSource(content)}`);
4622
6248
  } else {
4623
6249
  parts.push(`[output ${mime}] (binary content)`);
4624
6250
  }
@@ -4635,8 +6261,8 @@ ${source}`);
4635
6261
  }
4636
6262
 
4637
6263
  // src/parsers/image.ts
4638
- import { readFileSync as readFileSync12 } from "fs";
4639
- import { extname as extname2 } from "path";
6264
+ import { readFileSync as readFileSync15 } from "fs";
6265
+ import { extname as extname3 } from "path";
4640
6266
  var MIME_TYPES = {
4641
6267
  ".png": "image/png",
4642
6268
  ".jpg": "image/jpeg",
@@ -4647,9 +6273,9 @@ var MIME_TYPES = {
4647
6273
  ".bmp": "image/bmp"
4648
6274
  };
4649
6275
  function parseImage(filePath) {
4650
- const ext = extname2(filePath).toLowerCase();
6276
+ const ext = extname3(filePath).toLowerCase();
4651
6277
  const mediaType = MIME_TYPES[ext] || "image/png";
4652
- const buffer = readFileSync12(filePath);
6278
+ const buffer = readFileSync15(filePath);
4653
6279
  const base64 = buffer.toString("base64");
4654
6280
  const sizeKB = Math.round(buffer.length / 1024);
4655
6281
  return {
@@ -4659,7 +6285,7 @@ function parseImage(filePath) {
4659
6285
  };
4660
6286
  }
4661
6287
  function isImageFile(filePath) {
4662
- const ext = extname2(filePath).toLowerCase();
6288
+ const ext = extname3(filePath).toLowerCase();
4663
6289
  return ext in MIME_TYPES;
4664
6290
  }
4665
6291
 
@@ -4676,16 +6302,16 @@ var ReadTool = buildTool({
4676
6302
  description: "Read a file from the filesystem. Supports text, PDF, Word (.docx), Jupyter notebooks (.ipynb), and images.",
4677
6303
  inputSchema: inputSchema3,
4678
6304
  async call(input, context) {
4679
- const filePath = isAbsolute(input.file_path) ? input.file_path : resolve(context.cwd, input.file_path);
6305
+ const filePath = isAbsolute2(input.file_path) ? input.file_path : resolve2(context.cwd, input.file_path);
4680
6306
  try {
4681
- const stat = statSync2(filePath);
6307
+ const stat = statSync4(filePath);
4682
6308
  if (stat.isDirectory()) {
4683
6309
  return { content: `error: "${filePath}" is a directory, not a file. use Bash with ls to list directory contents.`, isError: true };
4684
6310
  }
4685
6311
  } catch {
4686
6312
  return { content: `error: file not found: ${filePath}`, isError: true };
4687
6313
  }
4688
- const ext = extname3(filePath).toLowerCase();
6314
+ const ext = extname4(filePath).toLowerCase();
4689
6315
  try {
4690
6316
  switch (ext) {
4691
6317
  case ".pdf":
@@ -4710,10 +6336,16 @@ var ReadTool = buildTool({
4710
6336
  },
4711
6337
  isConcurrencySafe: () => true,
4712
6338
  isReadOnly: () => true,
4713
- checkPermissions: () => ({ behavior: "allow" })
6339
+ // reads inside the project auto-allow; reads outside it (home dotfiles,
6340
+ // ~/.ssh, /etc) and in-project secret files prompt, so an injected agent
6341
+ // can't silently pull credentials into context.
6342
+ checkPermissions: (input, context) => {
6343
+ const c = classifyRead(input.file_path, context.cwd);
6344
+ return c.allow ? { behavior: "allow" } : { behavior: "ask", message: readAskMessage(input.file_path, c.reason) };
6345
+ }
4714
6346
  });
4715
6347
  function readTextFile(filePath, offset, limit) {
4716
- const content = readFileSync13(filePath, "utf-8");
6348
+ const content = readFileSync16(filePath, "utf-8");
4717
6349
  const allLines = content.split("\n");
4718
6350
  const start = (offset ?? 1) - 1;
4719
6351
  const count = limit ?? MAX_LINES;
@@ -4730,8 +6362,8 @@ function readTextFile(filePath, offset, limit) {
4730
6362
 
4731
6363
  // src/tools/edit.ts
4732
6364
  import { z as z6 } from "zod";
4733
- import { readFileSync as readFileSync14, writeFileSync as writeFileSync6 } from "fs";
4734
- import { resolve as resolve2, isAbsolute as isAbsolute2 } from "path";
6365
+ import { readFile, writeFile } from "fs/promises";
6366
+ import { resolve as resolve3, isAbsolute as isAbsolute3 } from "path";
4735
6367
  var inputSchema4 = z6.object({
4736
6368
  file_path: z6.string().describe("absolute path to the file to edit"),
4737
6369
  old_string: z6.string().describe("the exact text to find and replace"),
@@ -4743,10 +6375,10 @@ var EditTool = buildTool({
4743
6375
  description: "Replace exact string matches in a file. old_string must match exactly including whitespace.",
4744
6376
  inputSchema: inputSchema4,
4745
6377
  async call(input, context) {
4746
- const filePath = isAbsolute2(input.file_path) ? input.file_path : resolve2(context.cwd, input.file_path);
6378
+ const filePath = isAbsolute3(input.file_path) ? input.file_path : resolve3(context.cwd, input.file_path);
4747
6379
  let content;
4748
6380
  try {
4749
- content = readFileSync14(filePath, "utf-8");
6381
+ content = await readFile(filePath, "utf-8");
4750
6382
  } catch {
4751
6383
  return { content: `error: file not found: ${filePath}`, isError: true };
4752
6384
  }
@@ -4770,7 +6402,7 @@ var EditTool = buildTool({
4770
6402
  }
4771
6403
  const updated = input.replace_all ? content.split(input.old_string).join(input.new_string) : content.replace(input.old_string, input.new_string);
4772
6404
  try {
4773
- writeFileSync6(filePath, updated, "utf-8");
6405
+ await writeFile(filePath, updated, "utf-8");
4774
6406
  const replacements = input.replace_all ? content.split(input.old_string).length - 1 : 1;
4775
6407
  return { content: `edited ${filePath} (${replacements} replacement${replacements > 1 ? "s" : ""})` };
4776
6408
  } catch (error) {
@@ -4787,8 +6419,8 @@ var EditTool = buildTool({
4787
6419
 
4788
6420
  // src/tools/write.ts
4789
6421
  import { z as z7 } from "zod";
4790
- import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync6, existsSync as existsSync10 } from "fs";
4791
- import { resolve as resolve3, isAbsolute as isAbsolute3, dirname } from "path";
6422
+ import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync7, existsSync as existsSync13 } from "fs";
6423
+ import { resolve as resolve4, isAbsolute as isAbsolute4, dirname as dirname2 } from "path";
4792
6424
  var inputSchema5 = z7.object({
4793
6425
  file_path: z7.string().describe("absolute path to the file to write"),
4794
6426
  content: z7.string().describe("the content to write to the file")
@@ -4798,11 +6430,11 @@ var WriteTool = buildTool({
4798
6430
  description: "Write content to a file. Creates the file if it does not exist. Overwrites if it does.",
4799
6431
  inputSchema: inputSchema5,
4800
6432
  async call(input, context) {
4801
- const filePath = isAbsolute3(input.file_path) ? input.file_path : resolve3(context.cwd, input.file_path);
6433
+ const filePath = isAbsolute4(input.file_path) ? input.file_path : resolve4(context.cwd, input.file_path);
4802
6434
  try {
4803
- const dir = dirname(filePath);
4804
- if (!existsSync10(dir)) {
4805
- mkdirSync6(dir, { recursive: true });
6435
+ const dir = dirname2(filePath);
6436
+ if (!existsSync13(dir)) {
6437
+ mkdirSync7(dir, { recursive: true });
4806
6438
  }
4807
6439
  writeFileSync7(filePath, input.content, "utf-8");
4808
6440
  const lineCount = input.content.split("\n").length;
@@ -4821,8 +6453,8 @@ var WriteTool = buildTool({
4821
6453
 
4822
6454
  // src/tools/glob.ts
4823
6455
  import { z as z8 } from "zod";
4824
- import { execFileSync as execFileSync3 } from "child_process";
4825
- import { resolve as resolve4, isAbsolute as isAbsolute4 } from "path";
6456
+ import { execFileSync as execFileSync2 } from "child_process";
6457
+ import { resolve as resolve5, isAbsolute as isAbsolute5 } from "path";
4826
6458
  var inputSchema6 = z8.object({
4827
6459
  pattern: z8.string().describe('glob pattern to match files (e.g. "**/*.ts", "src/**/*.py")'),
4828
6460
  path: z8.string().optional().describe("directory to search in (default: cwd)")
@@ -4832,9 +6464,9 @@ var GlobTool = buildTool({
4832
6464
  description: "Find files matching a glob pattern. Returns file paths sorted by modification time.",
4833
6465
  inputSchema: inputSchema6,
4834
6466
  async call(input, context) {
4835
- const searchPath = input.path ? isAbsolute4(input.path) ? input.path : resolve4(context.cwd, input.path) : context.cwd;
6467
+ const searchPath = input.path ? isAbsolute5(input.path) ? input.path : resolve5(context.cwd, input.path) : context.cwd;
4836
6468
  try {
4837
- const pattern = input.pattern.replace(/\*\*\//g, "");
6469
+ const matchArgs = input.pattern.includes("/") ? ["-path", "*" + input.pattern.replace(/\*\*\//g, "").replace(/\*\*/g, "*")] : ["-name", input.pattern.replace(/\*\*\//g, "")];
4838
6470
  const excludeDirs = [
4839
6471
  "node_modules",
4840
6472
  ".git",
@@ -4855,9 +6487,9 @@ var GlobTool = buildTool({
4855
6487
  for (const d of excludeDirs) {
4856
6488
  excludeArgs.push("-not", "-path", `*/${d}/*`);
4857
6489
  }
4858
- const output = execFileSync3(
6490
+ const output = execFileSync2(
4859
6491
  "find",
4860
- [searchPath, "-type", "f", "-name", pattern, ...excludeArgs],
6492
+ [searchPath, "-type", "f", ...matchArgs, ...excludeArgs],
4861
6493
  {
4862
6494
  cwd: searchPath,
4863
6495
  encoding: "utf-8",
@@ -4886,13 +6518,18 @@ var GlobTool = buildTool({
4886
6518
  },
4887
6519
  isConcurrencySafe: () => true,
4888
6520
  isReadOnly: () => true,
4889
- checkPermissions: () => ({ behavior: "allow" })
6521
+ // enumerating a directory outside the project prompts: listing ~/.ssh or
6522
+ // ~/.aws leaks the location of credentials even without reading them.
6523
+ checkPermissions: (input, context) => {
6524
+ const c = classifyRead(input.path ?? context.cwd, context.cwd);
6525
+ return c.allow ? { behavior: "allow" } : { behavior: "ask", message: readAskMessage(input.path ?? context.cwd, c.reason) };
6526
+ }
4890
6527
  });
4891
6528
 
4892
6529
  // src/tools/grep.ts
4893
6530
  import { z as z9 } from "zod";
4894
- import { execFileSync as execFileSync4 } from "child_process";
4895
- import { resolve as resolve5, isAbsolute as isAbsolute5 } from "path";
6531
+ import { execFileSync as execFileSync3 } from "child_process";
6532
+ import { resolve as resolve6, isAbsolute as isAbsolute6 } from "path";
4896
6533
  var inputSchema7 = z9.object({
4897
6534
  pattern: z9.string().describe("regex pattern to search for"),
4898
6535
  path: z9.string().optional().describe("file or directory to search in (default: cwd)"),
@@ -4904,7 +6541,7 @@ var useRipgrep = null;
4904
6541
  function hasRipgrep() {
4905
6542
  if (useRipgrep !== null) return useRipgrep;
4906
6543
  try {
4907
- execFileSync4("which", ["rg"], { stdio: "pipe" });
6544
+ execFileSync3("which", ["rg"], { stdio: "pipe" });
4908
6545
  useRipgrep = true;
4909
6546
  } catch {
4910
6547
  useRipgrep = false;
@@ -4916,13 +6553,13 @@ var GrepTool = buildTool({
4916
6553
  description: "Search file contents for a regex pattern. Uses ripgrep if available.",
4917
6554
  inputSchema: inputSchema7,
4918
6555
  async call(input, context) {
4919
- const searchPath = input.path ? isAbsolute5(input.path) ? input.path : resolve5(context.cwd, input.path) : context.cwd;
6556
+ const searchPath = input.path ? isAbsolute6(input.path) ? input.path : resolve6(context.cwd, input.path) : context.cwd;
4920
6557
  const mode = input.output_mode ?? "files_with_matches";
4921
6558
  try {
4922
6559
  const useRg = hasRipgrep();
4923
6560
  const bin = useRg ? "rg" : "grep";
4924
6561
  const args = useRg ? buildRgArgs(input.pattern, searchPath, mode, input.glob, input.context) : buildGrepArgs(input.pattern, searchPath, mode, input.glob, input.context);
4925
- const output = execFileSync4(bin, args, {
6562
+ const output = execFileSync3(bin, args, {
4926
6563
  cwd: context.cwd,
4927
6564
  encoding: "utf-8",
4928
6565
  timeout: 3e4,
@@ -4953,7 +6590,12 @@ var GrepTool = buildTool({
4953
6590
  },
4954
6591
  isConcurrencySafe: () => true,
4955
6592
  isReadOnly: () => true,
4956
- checkPermissions: () => ({ behavior: "allow" })
6593
+ // searching outside the project (or a secret file) prompts: grep content mode
6594
+ // returns file contents, so an unbounded search is a secret-read channel.
6595
+ checkPermissions: (input, context) => {
6596
+ const c = classifyRead(input.path ?? context.cwd, context.cwd);
6597
+ return c.allow ? { behavior: "allow" } : { behavior: "ask", message: readAskMessage(input.path ?? context.cwd, c.reason) };
6598
+ }
4957
6599
  });
4958
6600
  function buildRgArgs(pattern, path, mode, glob, ctx) {
4959
6601
  const args = [];
@@ -5002,6 +6644,7 @@ import TurndownService from "turndown";
5002
6644
  // src/net/safeFetch.ts
5003
6645
  import got from "got";
5004
6646
  import { lookup as dnsLookup } from "dns";
6647
+ import { isIP } from "net";
5005
6648
 
5006
6649
  // src/net/validate.ts
5007
6650
  import ipaddr from "ipaddr.js";
@@ -5132,9 +6775,17 @@ var strictPolicy = {
5132
6775
 
5133
6776
  // src/net/safeFetch.ts
5134
6777
  async function safeFetch(rawUrl, policy) {
5135
- const parsed = new URL(rawUrl);
5136
- validateScheme(rawUrl, parsed.protocol, policy.allowedSchemes);
5137
- validatePort(rawUrl, parsed.port, policy.allowedPorts);
6778
+ const stripBrackets = (h) => h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
6779
+ const assertUrlAllowed = (urlStr) => {
6780
+ const u = new URL(urlStr);
6781
+ validateScheme(urlStr, u.protocol, policy.allowedSchemes);
6782
+ validatePort(urlStr, u.port, policy.allowedPorts);
6783
+ const host = stripBrackets(u.hostname);
6784
+ if (isIP(host) !== 0) {
6785
+ validateIp(urlStr, host, policy.blockedIpRanges);
6786
+ }
6787
+ };
6788
+ assertUrlAllowed(rawUrl);
5138
6789
  let lastResolvedIp = "";
5139
6790
  const pinningLookup = (hostname, options, cb) => {
5140
6791
  const optsArg = typeof options === "function" ? {} : options || {};
@@ -5167,7 +6818,18 @@ async function safeFetch(rawUrl, policy) {
5167
6818
  followRedirect: policy.maxRedirects > 0,
5168
6819
  dnsLookup: pinningLookup,
5169
6820
  headers: { "user-agent": policy.userAgent },
5170
- throwHttpErrors: true
6821
+ throwHttpErrors: true,
6822
+ hooks: {
6823
+ // re-validate scheme, port, and any literal-IP target on every redirect
6824
+ // hop. without this, a 302 to http://127.0.0.1:9/ or a forbidden port
6825
+ // would be followed (got's internal redirect doesn't re-run these checks,
6826
+ // and node skips the pinning lookup for IP literals).
6827
+ beforeRedirect: [
6828
+ (options) => {
6829
+ assertUrlAllowed(options.url.toString());
6830
+ }
6831
+ ]
6832
+ }
5171
6833
  });
5172
6834
  } catch (err) {
5173
6835
  if (err instanceof ForbiddenIpError || err instanceof ForbiddenSchemeError || err instanceof ForbiddenPortError) {
@@ -5209,20 +6871,20 @@ var turndown = new TurndownService({
5209
6871
  headingStyle: "atx",
5210
6872
  codeBlockStyle: "fenced"
5211
6873
  });
5212
- var MAX_OUTPUT3 = 4e4;
6874
+ var MAX_OUTPUT = 4e4;
5213
6875
  function extractMarkdown(html) {
5214
6876
  const $ = cheerio.load(html);
5215
6877
  $("script, style, noscript, iframe").remove();
5216
6878
  const $container = $("article").first().length ? $("article").first() : $("main").first().length ? $("main").first() : $("body");
5217
6879
  $container.find("nav, footer, aside, [role=navigation], [role=banner], [role=contentinfo]").remove();
5218
6880
  let md = turndown.turndown($container.html() || "");
5219
- if (md.length > MAX_OUTPUT3) {
5220
- md = md.slice(0, MAX_OUTPUT3) + "\n\n[... content truncated ...]";
6881
+ if (md.length > MAX_OUTPUT) {
6882
+ md = md.slice(0, MAX_OUTPUT) + "\n\n[... content truncated ...]";
5221
6883
  }
5222
6884
  return md;
5223
6885
  }
5224
6886
  function truncate(s) {
5225
- return s.length > MAX_OUTPUT3 ? s.slice(0, MAX_OUTPUT3) + "\n\n[... content truncated ...]" : s;
6887
+ return s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) + "\n\n[... content truncated ...]" : s;
5226
6888
  }
5227
6889
  var WebFetchTool = buildTool({
5228
6890
  name: "WebFetch",
@@ -5329,8 +6991,68 @@ var WebSearchTool = buildTool({
5329
6991
  checkPermissions: () => ({ behavior: "allow" })
5330
6992
  });
5331
6993
 
6994
+ // src/tools/verify.ts
6995
+ import { z as z12 } from "zod";
6996
+ import { exec as exec3 } from "child_process";
6997
+ import { promisify as promisify2 } from "util";
6998
+ var execAsync2 = promisify2(exec3);
6999
+ var inputSchema9 = z12.object({
7000
+ command: z12.string().describe("the verification command to run (e.g. `npx vitest run`, `pytest`, `cargo test`)"),
7001
+ description: z12.string().optional().describe("one-line description of what is being verified"),
7002
+ timeout: z12.number().optional().describe("timeout in milliseconds (default 60s, max 600s)")
7003
+ });
7004
+ var VerifyTool = buildTool({
7005
+ name: "Verify",
7006
+ description: "Run a project verification command (tests, typecheck, lint) and return its output. Derive the command from project scan and repo map. Use before claiming done after a non-trivial edit.",
7007
+ inputSchema: inputSchema9,
7008
+ async call(input, context) {
7009
+ const maxOutput = loadConfig().tuning.bash_max_output_bytes;
7010
+ const timeout = Math.min(input.timeout || 6e4, 6e5);
7011
+ try {
7012
+ const { stdout } = await execAsync2(input.command, {
7013
+ cwd: context.cwd,
7014
+ timeout,
7015
+ maxBuffer: maxOutput,
7016
+ env: { ...process.env }
7017
+ });
7018
+ const result = stdout.trim();
7019
+ if (result.length > maxOutput) {
7020
+ return { content: result.slice(0, maxOutput) + "\n\n[output truncated]" };
7021
+ }
7022
+ return { content: `verified: ${input.command}
7023
+
7024
+ ${result || "(no output)"}` };
7025
+ } catch (error) {
7026
+ const execError = error;
7027
+ const stdout = execError.stdout?.toString().trim() || "";
7028
+ const stderr = execError.stderr?.toString().trim() || "";
7029
+ const exitCode = execError.code ?? 1;
7030
+ const parts = [
7031
+ `verification failed: ${input.command}`,
7032
+ "",
7033
+ stdout && stdout,
7034
+ stderr && stderr,
7035
+ `exit code: ${exitCode}`
7036
+ ].filter(Boolean);
7037
+ return { content: parts.join("\n"), isError: true };
7038
+ }
7039
+ },
7040
+ // verification commands are observation, not mutation of the working tree.
7041
+ // they may have incidental side effects (cache writes, coverage reports)
7042
+ // but do not change source files the operator is authoring.
7043
+ isReadOnly() {
7044
+ return true;
7045
+ },
7046
+ isConcurrencySafe() {
7047
+ return false;
7048
+ },
7049
+ checkPermissions(input) {
7050
+ return { behavior: "ask", message: `verify: ${input.command}` };
7051
+ }
7052
+ });
7053
+
5332
7054
  // src/cli.ts
5333
- import { homedir as homedir11 } from "os";
7055
+ import { homedir as homedir13 } from "os";
5334
7056
 
5335
7057
  // src/completion/bash.ts
5336
7058
  function emitBash() {
@@ -5435,14 +7157,14 @@ compdef _prism prism
5435
7157
  }
5436
7158
 
5437
7159
  // src/completion/install.ts
5438
- import { existsSync as existsSync11, readFileSync as readFileSync15, appendFileSync, writeFileSync as writeFileSync8, mkdirSync as mkdirSync7 } from "fs";
5439
- import { join as join10 } from "path";
5440
- import { homedir as homedir10, platform } from "os";
5441
- import { basename as basename5 } from "path";
5442
- var FIRST_RUN_FLAG = join10(homedir10(), ".prism", ".completion-installed");
7160
+ import { existsSync as existsSync14, readFileSync as readFileSync17, appendFileSync, writeFileSync as writeFileSync8, mkdirSync as mkdirSync8 } from "fs";
7161
+ import { join as join14 } from "path";
7162
+ import { homedir as homedir12, platform } from "os";
7163
+ import { basename as basename7 } from "path";
7164
+ var FIRST_RUN_FLAG = join14(homedir12(), ".prism", ".completion-installed");
5443
7165
  function detectShell() {
5444
7166
  const sh = process.env.SHELL || "";
5445
- const name = basename5(sh);
7167
+ const name = basename7(sh);
5446
7168
  if (name === "zsh") return "zsh";
5447
7169
  if (name === "bash") return "bash";
5448
7170
  return null;
@@ -5450,13 +7172,13 @@ function detectShell() {
5450
7172
  function rcPathFor(shell) {
5451
7173
  if (shell === "zsh") {
5452
7174
  const zdotdir = process.env.ZDOTDIR;
5453
- return join10(zdotdir || homedir10(), ".zshrc");
7175
+ return join14(zdotdir || homedir12(), ".zshrc");
5454
7176
  }
5455
7177
  if (platform() === "darwin") {
5456
- const profile = join10(homedir10(), ".bash_profile");
5457
- if (existsSync11(profile)) return profile;
7178
+ const profile = join14(homedir12(), ".bash_profile");
7179
+ if (existsSync14(profile)) return profile;
5458
7180
  }
5459
- return join10(homedir10(), ".bashrc");
7181
+ return join14(homedir12(), ".bashrc");
5460
7182
  }
5461
7183
  var MARKER = "# prism shell completion";
5462
7184
  function evalLineFor(shell) {
@@ -5469,8 +7191,8 @@ function installCompletion(requested) {
5469
7191
  }
5470
7192
  const rcPath = rcPathFor(shell);
5471
7193
  const evalLine = evalLineFor(shell);
5472
- if (existsSync11(rcPath)) {
5473
- const contents = readFileSync15(rcPath, "utf-8");
7194
+ if (existsSync14(rcPath)) {
7195
+ const contents = readFileSync17(rcPath, "utf-8");
5474
7196
  if (contents.includes(evalLine)) {
5475
7197
  return { shell, rcPath, status: "already-installed" };
5476
7198
  }
@@ -5484,7 +7206,7 @@ ${evalLine}
5484
7206
  }
5485
7207
  function maybeAutoInstall() {
5486
7208
  if (process.env.PRISM_NO_AUTO_COMPLETION) return null;
5487
- if (existsSync11(FIRST_RUN_FLAG)) return null;
7209
+ if (existsSync14(FIRST_RUN_FLAG)) return null;
5488
7210
  const shell = detectShell();
5489
7211
  if (!shell) {
5490
7212
  markFirstRunDone();
@@ -5500,24 +7222,26 @@ function maybeAutoInstall() {
5500
7222
  }
5501
7223
  function markFirstRunDone() {
5502
7224
  try {
5503
- const dir = join10(homedir10(), ".prism");
5504
- if (!existsSync11(dir)) mkdirSync7(dir, { recursive: true });
7225
+ const dir = join14(homedir12(), ".prism");
7226
+ if (!existsSync14(dir)) mkdirSync8(dir, { recursive: true });
5505
7227
  writeFileSync8(FIRST_RUN_FLAG, (/* @__PURE__ */ new Date()).toISOString(), "utf-8");
5506
7228
  } catch {
5507
7229
  }
5508
7230
  }
5509
7231
 
5510
7232
  // src/memory/lens.ts
5511
- import { existsSync as existsSync12, readFileSync as readFileSync16 } from "fs";
5512
- import { join as join11 } from "path";
5513
- var MAX_LENS_BYTES = 64 * 1024;
7233
+ import { existsSync as existsSync15, readFileSync as readFileSync18 } from "fs";
7234
+ import { join as join15 } from "path";
5514
7235
  function loadLens(cwd) {
5515
- const path = join11(cwd, "lens.md");
5516
- if (!existsSync12(path)) return null;
7236
+ const path = join15(cwd, "lens.md");
7237
+ if (!existsSync15(path)) return null;
5517
7238
  try {
5518
- const content = readFileSync16(path, "utf-8");
5519
- if (content.length > MAX_LENS_BYTES) {
5520
- return content.slice(0, MAX_LENS_BYTES) + "\n\n[truncated: lens.md exceeds 64KB cap]";
7239
+ const content = readFileSync18(path, "utf-8");
7240
+ const maxBytes = loadConfig().tuning.lens_max_bytes;
7241
+ if (content.length > maxBytes) {
7242
+ return content.slice(0, maxBytes) + `
7243
+
7244
+ [truncated: lens.md exceeds ${maxBytes} bytes]`;
5521
7245
  }
5522
7246
  return content.trim() || null;
5523
7247
  } catch {
@@ -5527,7 +7251,7 @@ function loadLens(cwd) {
5527
7251
 
5528
7252
  // src/cli.ts
5529
7253
  function shortenPath2(cwd) {
5530
- const home = homedir11();
7254
+ const home = homedir13();
5531
7255
  let path = cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
5532
7256
  if (path.length > 50) {
5533
7257
  const parts = path.split("/").filter(Boolean);
@@ -5582,6 +7306,10 @@ async function main() {
5582
7306
  process.exit(0);
5583
7307
  }
5584
7308
  initConfig();
7309
+ const migrated = migrateConfig();
7310
+ if (migrated.length > 0) {
7311
+ console.log(`\x1B[2mconfig migrated: added [${migrated.join("], [")}] to ${getConfigPath()}\x1B[0m`);
7312
+ }
5585
7313
  const config = loadConfig();
5586
7314
  const KNOWN_FLAGS = /* @__PURE__ */ new Set([...allFlagTokens(), "--completion", "--complete", "--install-completion"]);
5587
7315
  if (args.includes("--help") || args.includes("-h")) {
@@ -5600,6 +7328,11 @@ async function main() {
5600
7328
  -c, --continue resume last session in this directory
5601
7329
  -r, --resume <n|id> resume the nth recent session, or by full id (see --sessions)
5602
7330
  --max-tokens <n> max output tokens per response (default: 10000)
7331
+ --max-files <n> repo-map walk cap (override [tuning].repomap_max_files)
7332
+ --max-lines <n> repo-map render cap (override [tuning].repomap_max_lines)
7333
+ --no-repomap skip the repo-map retrieval pass entirely
7334
+ --no-scan skip the live project scan
7335
+ --no-memory skip lens.md + persistent memo
5603
7336
  --config show config file path
5604
7337
  --sessions list recent sessions
5605
7338
  -h, --help show this help`);
@@ -5620,6 +7353,24 @@ async function main() {
5620
7353
  }
5621
7354
  maxTokens = parsed;
5622
7355
  }
7356
+ const parseFlagNumber = (flag, label) => {
7357
+ const idx = args.indexOf(flag);
7358
+ if (idx === -1) return void 0;
7359
+ const raw = args[idx + 1];
7360
+ if (!raw || raw.startsWith("-")) {
7361
+ console.error(`\x1B[31m${flag} requires a number (e.g. ${flag} ${label})\x1B[0m`);
7362
+ process.exit(1);
7363
+ }
7364
+ const parsed = parseInt(raw, 10);
7365
+ if (isNaN(parsed) || parsed <= 0) {
7366
+ console.error(`\x1B[31m${flag} must be a positive number, got: ${raw}\x1B[0m`);
7367
+ process.exit(1);
7368
+ }
7369
+ return parsed;
7370
+ };
7371
+ const maxFiles = parseFlagNumber("--max-files", "500");
7372
+ const maxLines = parseFlagNumber("--max-lines", "200");
7373
+ const skipRepoMap = args.includes("--no-repomap");
5623
7374
  const valueFlags = valueTakingFlagTokens();
5624
7375
  const isFlagValue = (i) => i > 0 && valueFlags.has(args[i - 1] || "");
5625
7376
  const unknownFlags = args.filter((a, i) => a.startsWith("-") && !KNOWN_FLAGS.has(a) && !isFlagValue(i));
@@ -5737,7 +7488,7 @@ async function main() {
5737
7488
  session = createSession(model, provider.name, cwd);
5738
7489
  }
5739
7490
  const capabilities = provider.getCapabilities();
5740
- const tools = [BashTool, ReadTool, EditTool, WriteTool, GlobTool, GrepTool, WebFetchTool, WebSearchTool];
7491
+ const tools = [BashTool, ReadTool, EditTool, WriteTool, GlobTool, GrepTool, WebFetchTool, WebSearchTool, VerifyTool];
5741
7492
  const skipScan = args.includes("--no-scan");
5742
7493
  const skipMemory = args.includes("--no-memory");
5743
7494
  let projectContext;
@@ -5769,6 +7520,21 @@ async function main() {
5769
7520
  if (autoInstall) {
5770
7521
  console.log(`\x1B[2mshell completion installed to ${autoInstall.rcPath}. restart your shell or run \`exec ${autoInstall.shell}\` to enable tab completion.\x1B[0m`);
5771
7522
  }
7523
+ if (process.stdout.isTTY) {
7524
+ process.stdout.write("\x1B[>1u");
7525
+ const popKeyboardMode = () => {
7526
+ if (process.stdout.isTTY) process.stdout.write("\x1B[<u");
7527
+ };
7528
+ process.on("exit", popKeyboardMode);
7529
+ process.on("SIGINT", () => {
7530
+ popKeyboardMode();
7531
+ process.exit(130);
7532
+ });
7533
+ process.on("SIGTERM", () => {
7534
+ popKeyboardMode();
7535
+ process.exit(143);
7536
+ });
7537
+ }
5772
7538
  const { waitUntilExit } = render(
5773
7539
  React4.createElement(App, {
5774
7540
  provider,
@@ -5778,7 +7544,8 @@ async function main() {
5778
7544
  session,
5779
7545
  initialMessages,
5780
7546
  projectContext,
5781
- memory
7547
+ memory,
7548
+ repoMapOverride: { maxFiles, maxLines, skip: skipRepoMap }
5782
7549
  })
5783
7550
  );
5784
7551
  await waitUntilExit();