jevprune 0.1.0 → 0.1.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.
package/dist/cli.js CHANGED
@@ -1092,7 +1092,7 @@ var DEFAULT_CONFIG = {
1092
1092
  concurrency: 4,
1093
1093
  maxPruneBytes: 16777216,
1094
1094
  retention: { maxRuns: 200, maxBytes: 268435456 },
1095
- autoWrap: true,
1095
+ autoWrap: false,
1096
1096
  allowlist: DEFAULT_ALLOWLIST
1097
1097
  };
1098
1098
  function resolveHome(env = process.env) {
@@ -1216,1098 +1216,1011 @@ function describeValue(value) {
1216
1216
  // src/footer.ts
1217
1217
  import { homedir as homedir2 } from "os";
1218
1218
  import { sep } from "path";
1219
- var LF = 10;
1220
- function formatFooter(input) {
1221
- if (input.mode === "fast-path") return "";
1222
- const parts = [];
1223
- if (input.mode === "passthrough") {
1224
- if (input.exitCode !== void 0 && input.exitCode !== null) parts.push(`exit ${String(input.exitCode)}`);
1225
- const note = input.passthroughNote === void 0 ? "" : ` (${input.passthroughNote})`;
1226
- parts.push(`${formatCount(input.linesIn)} lines passed through${note}`);
1227
- } else {
1228
- if (input.mode === "fallback") parts.push(`fallback (no Jev: ${input.fallbackReason ?? "unknown"})`);
1229
- parts.push(`${formatCount(input.linesIn)} \u2192 ${formatCount(input.linesOut)} lines`);
1230
- if (input.exitCode !== void 0 && input.exitCode !== null) parts.push(`exit ${String(input.exitCode)}`);
1219
+
1220
+ // src/keeps.ts
1221
+ var SIGNATURE_CASE_SENSITIVE = /(^|\s)(FAIL|FAILED|ERROR|PANIC|FATAL)(\s|:|$)|\b[A-Z][A-Za-z]*(Error|Exception|Panic)\b|^\s*E\s{2,}\S|^npm ERR!|^\s*(Test Files|Tests|Test Suites)\s/;
1222
+ var SIGNATURE_CASE_INSENSITIVE = /^\s*(✗|✘|×|⨯|❌|❯)|^\s*(error|fatal)(\[E\d+\])?:|^traceback \(most recent call last\)|^\s*File ".*", line \d+|^\s+at .*:\d+:\d+\)?$|^\s*-->\s.*:\d+:\d+|^thread '.*' panicked|\b(exit code|exit status|exited with|command not found|no such file or directory|ENOENT|EACCES|ECONNREFUSED|segmentation fault|core dumped|killed)\b|\blevel[=:"\s]+"?(error|fatal|panic)\b|\[(error|fatal|panic)\]|^=+ .*(passed|failed|error).* =+$/i;
1223
+ function isSignatureLine(text) {
1224
+ return SIGNATURE_CASE_SENSITIVE.test(text) || SIGNATURE_CASE_INSENSITIVE.test(text);
1225
+ }
1226
+ function computeKeeps(lines, options) {
1227
+ const keeps = /* @__PURE__ */ new Map();
1228
+ const tailLines = Math.max(0, Math.trunc(options.tailLines));
1229
+ const contextLines = Math.max(0, Math.trunc(options.contextLines));
1230
+ const tailStart = lines.length - tailLines + 1;
1231
+ for (const line of lines) {
1232
+ if (line.n >= tailStart) keeps.set(line.n, "tail");
1231
1233
  }
1232
- if (input.storeFailureCode !== void 0) {
1233
- parts.push(`run store unavailable (${input.storeFailureCode})`);
1234
- } else if (input.logPath !== void 0) {
1235
- parts.push(`full output ${displayPath(input.logPath, input.home)}`);
1234
+ const seen = /* @__PURE__ */ new Set();
1235
+ const signatures = [];
1236
+ for (const line of lines) {
1237
+ if (!isSignatureLine(line.text)) continue;
1238
+ const key = line.text.trim();
1239
+ if (seen.has(key)) continue;
1240
+ seen.add(key);
1241
+ signatures.push(line.n);
1236
1242
  }
1237
- return `jevprune: ${parts.join(", ")}`;
1238
- }
1239
- function footerAfter(lastByte, footer) {
1240
- if (footer.length === 0) return "";
1241
- const separator = lastByte === void 0 || lastByte === LF ? "" : "\n";
1242
- return `${separator}${footer}
1243
- `;
1244
- }
1245
- function withFooter(kept, footer) {
1246
- if (footer.length === 0) return kept;
1247
- const separator = kept.length === 0 || kept.endsWith("\n") ? "" : "\n";
1248
- return `${kept}${separator}${footer}
1249
- `;
1250
- }
1251
- function formatCount(value) {
1252
- const digits = Math.trunc(Math.abs(value)).toString();
1253
- const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
1254
- return value < 0 ? `-${grouped}` : grouped;
1255
- }
1256
- function displayPath(path, home = homedir2()) {
1257
- if (path === home) return "~";
1258
- if (home.length > 0 && path.startsWith(home + sep)) return `~${path.slice(home.length)}`;
1259
- return path;
1243
+ for (const n of signatures) keeps.set(n, "signature");
1244
+ for (const n of signatures) {
1245
+ for (let offset = 1; offset <= contextLines; offset += 1) {
1246
+ for (const candidate of [n - offset, n + offset]) {
1247
+ if (candidate < 1 || candidate > lines.length) continue;
1248
+ if (!keeps.has(candidate)) keeps.set(candidate, "context");
1249
+ }
1250
+ }
1251
+ }
1252
+ return keeps;
1260
1253
  }
1261
1254
 
1262
- // src/store.ts
1263
- import { randomBytes } from "crypto";
1264
- import { createReadStream } from "fs";
1265
- import { appendFile, mkdir, open, readFile as readFile2, readdir, stat, unlink, writeFile } from "fs/promises";
1266
- import { join as join2 } from "path";
1267
- import { finished } from "stream/promises";
1268
-
1269
- // src/bytes.ts
1270
- var LF2 = 10;
1271
- var CR = 13;
1272
- function isValidUtf8(bytes) {
1273
- try {
1274
- new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1275
- return true;
1276
- } catch {
1277
- return false;
1255
+ // src/lines.ts
1256
+ function splitLines(text) {
1257
+ const lines = [];
1258
+ const pattern = /\r\n|\n|\r/g;
1259
+ let start = 0;
1260
+ let match = pattern.exec(text);
1261
+ while (match !== null) {
1262
+ const raw = match[0];
1263
+ const terminator = raw === "\r\n" ? "\r\n" : raw === "\r" ? "\r" : "\n";
1264
+ lines.push({ n: lines.length + 1, text: text.slice(start, match.index), terminator });
1265
+ start = match.index + raw.length;
1266
+ match = pattern.exec(text);
1278
1267
  }
1279
- }
1280
- function byteLineStarts(bytes) {
1281
- const starts = [0];
1282
- for (let index = 0; index < bytes.length; index += 1) {
1283
- const byte = bytes[index];
1284
- if (byte === CR && bytes[index + 1] === LF2) index += 1;
1285
- else if (byte !== CR && byte !== LF2) continue;
1286
- starts.push(index + 1);
1268
+ if (start < text.length) {
1269
+ lines.push({ n: lines.length + 1, text: text.slice(start), terminator: "" });
1287
1270
  }
1288
- if (starts[starts.length - 1] !== bytes.length) starts.push(bytes.length);
1289
- return starts;
1290
- }
1291
- function countByteLines(bytes) {
1292
- return byteLineStarts(bytes).length - 1;
1271
+ return lines;
1293
1272
  }
1294
1273
 
1295
- // src/store.ts
1296
- var RUN_ID_PATTERN = /^[a-z0-9]+-[a-f0-9]{4}$/;
1297
- var RUNS_DIR = "runs";
1298
- var GAIN_FILE = "gain.jsonl";
1299
- var DIR_MODE = 448;
1300
- var FILE_MODE = 384;
1301
- function newRunId() {
1302
- return `${Date.now().toString(36)}-${randomBytes(2).toString("hex")}`;
1274
+ // src/merge.ts
1275
+ function collapseMarker(range2, runId) {
1276
+ return `[jevprune: ${String(range2.count)} lines dropped, run ${runId}, lines ${String(range2.from)}-${String(range2.to)}]
1277
+ `;
1303
1278
  }
1304
- var FileRunWriter = class {
1305
- path;
1306
- #handle;
1307
- #stream;
1308
- #waiting = [];
1309
- #failure;
1310
- #closed = false;
1311
- constructor(path, handle, stream) {
1312
- this.path = path;
1313
- this.#handle = handle;
1314
- this.#stream = stream;
1315
- this.#stream.on("error", (error) => {
1316
- this.#fail(error);
1317
- this.#release();
1318
- });
1319
- this.#stream.on("drain", () => {
1320
- this.#release();
1321
- });
1322
- }
1323
- get failure() {
1324
- return this.#failure;
1325
- }
1326
- write(chunk) {
1327
- if (this.#closed || this.#failure !== void 0) return true;
1328
- try {
1329
- return this.#stream.write(chunk);
1330
- } catch (error) {
1331
- this.#fail(error);
1332
- return true;
1279
+ function mergeDecisions(lines, decisions, options) {
1280
+ const minCollapseLines = Math.max(1, Math.trunc(options.minCollapseLines));
1281
+ const dropped = [];
1282
+ let kept = "";
1283
+ let run = null;
1284
+ let expected = 1;
1285
+ const flush = () => {
1286
+ if (run === null) return;
1287
+ const count = run.to - run.from + 1;
1288
+ if (!run.missing && count < minCollapseLines) {
1289
+ for (const line of run.lines) {
1290
+ decisions.set(line.n, { keep: true, reason: "collapse-min" });
1291
+ kept += line.text + line.terminator;
1292
+ }
1293
+ } else {
1294
+ const range2 = { from: run.from, to: run.to, count };
1295
+ dropped.push(range2);
1296
+ kept += collapseMarker(range2, options.runId);
1333
1297
  }
1334
- }
1335
- onDrain(listener) {
1336
- if (this.#closed || this.#failure !== void 0) {
1337
- queueMicrotask(listener);
1338
- return;
1298
+ run = null;
1299
+ };
1300
+ const dropLine = (line) => {
1301
+ if (run === null) run = { from: line.n, to: line.n, lines: [line], missing: false };
1302
+ else {
1303
+ run.to = line.n;
1304
+ run.lines.push(line);
1339
1305
  }
1340
- this.#waiting.push(listener);
1341
- }
1342
- async close() {
1343
- if (this.#closed) return;
1344
- this.#closed = true;
1345
- try {
1346
- this.#stream.end();
1347
- await finished(this.#stream);
1348
- } catch (error) {
1349
- this.#fail(error);
1350
- await this.#handle.close().catch(() => void 0);
1306
+ };
1307
+ const dropMissing = (from, to) => {
1308
+ if (to < from) return;
1309
+ if (run === null) run = { from, to, lines: [], missing: true };
1310
+ else {
1311
+ run.to = to;
1312
+ run.missing = true;
1351
1313
  }
1352
- this.#release();
1314
+ };
1315
+ for (const line of lines) {
1316
+ dropMissing(expected, line.n - 1);
1317
+ expected = line.n + 1;
1318
+ if (decisions.get(line.n)?.keep === false) {
1319
+ dropLine(line);
1320
+ continue;
1321
+ }
1322
+ flush();
1323
+ kept += line.text + line.terminator;
1353
1324
  }
1354
- #fail(error) {
1355
- this.#failure ??= storeError(`run log ${this.path} could not be written`, error);
1325
+ if (options.totalLines !== void 0) dropMissing(expected, options.totalLines);
1326
+ flush();
1327
+ return { kept, dropped };
1328
+ }
1329
+
1330
+ // src/select.ts
1331
+ var UNAUTHORIZED_REASON = "unauthorized (401)";
1332
+ var NOT_UTF8_REASON = "not valid UTF-8";
1333
+ var NOT_UTF8_NOTE = "output is not valid UTF-8";
1334
+ var RUBRIC = "A line is needed when a developer acting on the task would want to read it: errors, failures, assertions, stack frames, diagnostics, timings or statuses that bear on the task, and the lines that give them meaning. Progress bars, download counters, repeated banners, unchanged status lines and routine success noise are not needed.";
1335
+ var ITEM_OVERHEAD_TOKENS = 12;
1336
+ function passthroughSelection(input) {
1337
+ return {
1338
+ mode: "passthrough",
1339
+ kept: "",
1340
+ dropped: [],
1341
+ linesIn: input.lines,
1342
+ linesOut: input.lines,
1343
+ bytesIn: input.bytes,
1344
+ bytesOut: input.bytes,
1345
+ windows: 0,
1346
+ jevRequests: 0,
1347
+ jevInputTokens: 0,
1348
+ ...input.reason !== void 0 ? { fallbackReason: input.reason } : {},
1349
+ decisions: /* @__PURE__ */ new Map()
1350
+ };
1351
+ }
1352
+ function questionFor(n) {
1353
+ return `Is line ${String(n)} needed for the task?`;
1354
+ }
1355
+ async function selectLines(input) {
1356
+ const lines = splitLines(input.text);
1357
+ const bytesIn = Buffer.byteLength(input.text);
1358
+ if (input.oversize !== void 0) {
1359
+ return oversizeSelection(input, input.oversize, lines, bytesIn);
1356
1360
  }
1357
- #release() {
1358
- const waiting = this.#waiting;
1359
- this.#waiting = [];
1360
- for (const listener of waiting) listener();
1361
+ if (input.exitCode !== void 0 && input.exitCode !== null && input.exitCode !== 0 || input.interrupted === true) {
1362
+ return everyLine(lines, "passthrough", input.text, bytesIn);
1361
1363
  }
1362
- };
1363
- var RunStore = class {
1364
- home;
1365
- #retention;
1366
- constructor(options) {
1367
- this.home = options.home;
1368
- this.#retention = options.retention ?? DEFAULT_CONFIG.retention;
1369
- }
1370
- get runsDir() {
1371
- return join2(this.home, RUNS_DIR);
1372
- }
1373
- get gainPath() {
1374
- return join2(this.home, GAIN_FILE);
1375
- }
1376
- logPath(id) {
1377
- return join2(this.runsDir, `${requireRunId(id)}.log`);
1378
- }
1379
- metaPath(id) {
1380
- return join2(this.runsDir, `${requireRunId(id)}.json`);
1381
- }
1382
- async openRun(run) {
1383
- const path = this.logPath(run.id);
1384
- await this.#ensureDir(this.runsDir);
1385
- let handle;
1386
- try {
1387
- handle = await open(path, "wx", FILE_MODE);
1388
- } catch (error) {
1389
- throw storeError(`run log ${path} could not be created`, error);
1390
- }
1391
- return new FileRunWriter(path, handle, handle.createWriteStream());
1392
- }
1393
- async finalizeRun(id, meta) {
1394
- const path = this.metaPath(id);
1395
- await this.#ensureDir(this.runsDir);
1396
- try {
1397
- await writeFile(path, `${JSON.stringify(meta)}
1398
- `, { mode: FILE_MODE });
1399
- } catch (error) {
1400
- throw storeError(`run meta ${path} could not be written`, error);
1401
- }
1402
- }
1403
- async readRunBytes(id) {
1404
- const path = this.logPath(id);
1405
- try {
1406
- return await readFile2(path);
1407
- } catch (error) {
1408
- if (errorCode(error) === "ENOENT") throw new RunNotFoundError(id, { cause: error });
1409
- throw storeError(`run log ${path} could not be read`, error);
1410
- }
1411
- }
1412
- async *readRunChunks(id) {
1413
- const path = this.logPath(id);
1414
- try {
1415
- for await (const chunk of createReadStream(path)) {
1416
- yield typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
1417
- }
1418
- } catch (error) {
1419
- if (errorCode(error) === "ENOENT") throw new RunNotFoundError(id, { cause: error });
1420
- throw storeError(`run log ${path} could not be read`, error);
1421
- }
1422
- }
1423
- async readRun(id) {
1424
- const bytes = await this.readRunBytes(id);
1425
- return { id, path: this.logPath(id), text: bytes.toString("utf8"), meta: await this.#readMeta(id) };
1364
+ if (lines.length <= input.config.fastPathLines) {
1365
+ return everyLine(lines, "fast-path", input.text, bytesIn);
1426
1366
  }
1427
- async readRunLineBytes(id, from = 1, to) {
1428
- const bytes = await this.readRunBytes(id);
1429
- const starts = byteLineStarts(bytes);
1430
- const lines = starts.length - 1;
1431
- const last = to ?? lines;
1432
- if (!Number.isInteger(from) || !Number.isInteger(last) || from < 1 || last < from) {
1433
- throw new LineRangeError(`line range ${String(from)}-${String(last)} is not a range`);
1434
- }
1435
- if (last > lines) {
1436
- throw new LineRangeError(
1437
- `line range ${String(from)}-${String(last)} is outside run ${id} (${String(lines)} lines)`
1438
- );
1367
+ const keeps = keepsOf(lines, input.config);
1368
+ const decisions = /* @__PURE__ */ new Map();
1369
+ const candidates = [];
1370
+ for (const line of lines) {
1371
+ const keep = keeps.get(line.n);
1372
+ if (keep !== void 0) {
1373
+ decisions.set(line.n, { keep: true, reason: keep });
1374
+ continue;
1439
1375
  }
1440
- return bytes.subarray(starts[from - 1] ?? 0, starts[last] ?? bytes.length);
1441
- }
1442
- async readRunLines(id, from = 1, to) {
1443
- return (await this.readRunLineBytes(id, from, to)).toString("utf8");
1444
- }
1445
- async appendGain(entry2) {
1446
- await this.#ensureDir(this.home);
1447
- try {
1448
- await appendFile(this.gainPath, `${JSON.stringify(entry2)}
1449
- `, { mode: FILE_MODE });
1450
- } catch (error) {
1451
- throw storeError(`gain ledger ${this.gainPath} could not be written`, error);
1376
+ if (line.text.trim().length === 0) {
1377
+ decisions.set(line.n, { keep: false, reason: "blank" });
1378
+ continue;
1452
1379
  }
1380
+ candidates.push({ id: `l${String(line.n)}`, n: line.n, text: line.text });
1453
1381
  }
1454
- async readGain() {
1455
- let raw;
1456
- try {
1457
- raw = await readFile2(this.gainPath, "utf8");
1458
- } catch (error) {
1459
- if (errorCode(error) === "ENOENT") return { runs: 0, linesIn: 0, linesOut: 0, bytesIn: 0, bytesOut: 0 };
1460
- throw storeError(`gain ledger ${this.gainPath} could not be read`, error);
1461
- }
1462
- let runs = 0;
1463
- let linesIn = 0;
1464
- let linesOut = 0;
1465
- let bytesIn = 0;
1466
- let bytesOut = 0;
1467
- for (const line of raw.split("\n")) {
1468
- if (line.trim().length === 0) continue;
1469
- const entry2 = parseGainEntry(line);
1470
- if (entry2 === null) continue;
1471
- runs += 1;
1472
- linesIn += entry2.linesIn;
1473
- linesOut += entry2.linesOut;
1474
- bytesIn += entry2.bytesIn;
1475
- bytesOut += entry2.bytesOut;
1476
- }
1477
- return { runs, linesIn, linesOut, bytesIn, bytesOut };
1382
+ if (input.client === null) {
1383
+ return fallbackSelection({
1384
+ lines,
1385
+ keeps,
1386
+ input,
1387
+ reason: { kind: "unavailable", detail: "API key not set" },
1388
+ bytesIn,
1389
+ linesIn: lines.length
1390
+ });
1478
1391
  }
1479
- async enforceRetention() {
1480
- let names;
1481
- try {
1482
- names = await readdir(this.runsDir);
1483
- } catch (error) {
1484
- if (errorCode(error) === "ENOENT") return;
1485
- throw storeError(`run directory ${this.runsDir} could not be read`, error);
1486
- }
1487
- const ids = [...new Set(names.filter((name) => name.endsWith(".log")).map((name) => name.slice(0, -4)))].filter((id) => RUN_ID_PATTERN.test(id)).sort();
1488
- const sizes = /* @__PURE__ */ new Map();
1489
- let total = 0;
1490
- for (const id of ids) {
1491
- const bytes = await this.#sizeOf(this.logPath(id)) + await this.#sizeOf(this.metaPath(id));
1492
- sizes.set(id, bytes);
1493
- total += bytes;
1494
- }
1495
- let count = ids.length;
1496
- for (const id of ids) {
1497
- if (count <= this.#retention.maxRuns && total <= this.#retention.maxBytes) break;
1498
- await this.discardRun(id);
1499
- total -= sizes.get(id) ?? 0;
1500
- count -= 1;
1501
- }
1392
+ let verdicts;
1393
+ try {
1394
+ verdicts = await askJev(candidates, input, input.client);
1395
+ } catch (error) {
1396
+ return fallbackSelection({
1397
+ lines,
1398
+ keeps,
1399
+ input,
1400
+ reason: unavailableReason(error),
1401
+ bytesIn,
1402
+ linesIn: lines.length
1403
+ });
1502
1404
  }
1503
- async discardRun(id) {
1504
- for (const path of [this.logPath(id), this.metaPath(id)]) {
1505
- try {
1506
- await unlink(path);
1507
- } catch (error) {
1508
- if (errorCode(error) === "ENOENT") continue;
1509
- throw storeError(`run file ${path} could not be deleted`, error);
1510
- }
1511
- }
1405
+ for (const n of verdicts.oversize) decisions.set(n, { keep: true, reason: "oversize" });
1406
+ for (const item of candidates) {
1407
+ if (decisions.has(item.n)) continue;
1408
+ const noul2 = verdicts.answers.get(item.n) ?? 0;
1409
+ decisions.set(item.n, { keep: noul2 >= input.config.threshold, reason: "jev", noul: noul2 });
1512
1410
  }
1513
- async #sizeOf(path) {
1514
- try {
1515
- return (await stat(path)).size;
1516
- } catch (error) {
1517
- if (errorCode(error) === "ENOENT") return 0;
1518
- throw storeError(`run file ${path} could not be inspected`, error);
1519
- }
1411
+ const { kept, dropped } = mergeDecisions(lines, decisions, {
1412
+ minCollapseLines: input.config.minCollapseLines,
1413
+ runId: input.runId
1414
+ });
1415
+ return {
1416
+ mode: "jev",
1417
+ kept,
1418
+ dropped,
1419
+ linesIn: lines.length,
1420
+ linesOut: splitLines(kept).length,
1421
+ bytesIn,
1422
+ bytesOut: Buffer.byteLength(kept),
1423
+ windows: verdicts.windows,
1424
+ jevRequests: verdicts.jevRequests,
1425
+ jevInputTokens: verdicts.jevInputTokens,
1426
+ decisions
1427
+ };
1428
+ }
1429
+ async function askJev(candidates, input, client) {
1430
+ const answers = /* @__PURE__ */ new Map();
1431
+ if (candidates.length === 0) {
1432
+ return { windows: 0, jevRequests: 0, jevInputTokens: 0, answers, oversize: [] };
1520
1433
  }
1521
- async #readMeta(id) {
1522
- const path = this.metaPath(id);
1523
- let raw;
1524
- try {
1525
- raw = await readFile2(path, "utf8");
1526
- } catch (error) {
1527
- if (errorCode(error) === "ENOENT") return null;
1528
- throw storeError(`run meta ${path} could not be read`, error);
1529
- }
1530
- let parsed;
1531
- try {
1532
- parsed = JSON.parse(raw);
1533
- } catch (error) {
1534
- throw storeError(`run meta ${path} is not valid JSON`, error);
1434
+ const lastLine = candidates[candidates.length - 1]?.n ?? 0;
1435
+ const plan = planWindows(candidates, {
1436
+ budgetTokens: input.config.windowTokens,
1437
+ overheadTokens: estimateJsonTokens(stateOf(input, [])),
1438
+ itemOverheadTokens: estimateTokens(questionFor(lastLine)) + ITEM_OVERHEAD_TOKENS,
1439
+ costTokens: (item) => estimateJsonTokens({ n: item.n, text: item.text })
1440
+ });
1441
+ const results = await runWindows(
1442
+ plan.windows,
1443
+ async (window, _index, options) => {
1444
+ const questions = {};
1445
+ for (const item of window) questions[item.id] = questionFor(item.n);
1446
+ return await client.noul({ state: stateOf(input, window), questions }, options);
1447
+ },
1448
+ {
1449
+ concurrency: input.config.concurrency,
1450
+ timeoutMs: input.config.windowTimeoutMs,
1451
+ ...input.signal !== void 0 ? { signal: input.signal } : {}
1535
1452
  }
1536
- return isRecord4(parsed) ? parsed : null;
1537
- }
1538
- async #ensureDir(path) {
1539
- try {
1540
- await mkdir(path, { recursive: true, mode: DIR_MODE });
1541
- } catch (error) {
1542
- throw storeError(`directory ${path} could not be created`, error);
1453
+ );
1454
+ let jevInputTokens = 0;
1455
+ for (const [index, result] of results.entries()) {
1456
+ jevInputTokens += result.usage.inputTokens;
1457
+ for (const item of plan.windows[index] ?? []) {
1458
+ const noul2 = result.answers[item.id];
1459
+ if (noul2 !== void 0) answers.set(item.n, noul2);
1543
1460
  }
1544
1461
  }
1545
- };
1546
- function requireRunId(id) {
1547
- if (!RUN_ID_PATTERN.test(id)) throw new RunStoreError(`"${id}" is not a run id`);
1548
- return id;
1462
+ return {
1463
+ windows: plan.windows.length,
1464
+ jevRequests: results.length,
1465
+ jevInputTokens,
1466
+ answers,
1467
+ oversize: plan.oversize.map((item) => item.n)
1468
+ };
1549
1469
  }
1550
- function parseGainEntry(line) {
1551
- let parsed;
1552
- try {
1553
- parsed = JSON.parse(line);
1554
- } catch {
1555
- return null;
1556
- }
1557
- if (!isRecord4(parsed)) return null;
1558
- const linesIn = parsed["linesIn"];
1559
- const linesOut = parsed["linesOut"];
1560
- const bytesIn = parsed["bytesIn"];
1561
- const bytesOut = parsed["bytesOut"];
1562
- if (typeof linesIn !== "number" || typeof linesOut !== "number" || typeof bytesIn !== "number" || typeof bytesOut !== "number") {
1563
- return null;
1470
+ function stateOf(input, window) {
1471
+ return {
1472
+ command: input.command,
1473
+ task: input.task,
1474
+ rubric: RUBRIC,
1475
+ lines: window.map((item) => ({ n: item.n, text: item.text }))
1476
+ };
1477
+ }
1478
+ function keepsOf(lines, config) {
1479
+ return computeKeeps(lines, { tailLines: config.tailLines, contextLines: config.contextLines });
1480
+ }
1481
+ function fallbackSelection(fallback) {
1482
+ const { lines, keeps, input } = fallback;
1483
+ return fallbackResult({
1484
+ lines,
1485
+ decisions: fallbackDecisions(lines, keeps, input.config.headLines),
1486
+ input,
1487
+ reason: fallback.reason,
1488
+ bytesIn: fallback.bytesIn,
1489
+ linesIn: fallback.linesIn
1490
+ });
1491
+ }
1492
+ function oversizeSelection(input, oversize, captured, bytesIn) {
1493
+ const headCount = Math.min(Math.max(0, Math.trunc(oversize.headSegmentLines)), captured.length);
1494
+ const totalLines = Math.max(Math.trunc(oversize.lines), captured.length);
1495
+ const tailFirst = totalLines - captured.length + headCount + 1;
1496
+ const head = captured.slice(0, headCount);
1497
+ const tail = renumber(captured.slice(headCount), tailFirst);
1498
+ const contextLines = input.config.contextLines;
1499
+ const keeps = computeKeeps(head, { tailLines: 0, contextLines });
1500
+ const tailKeeps = computeKeeps(renumber(tail, 1), { tailLines: input.config.tailLines, contextLines });
1501
+ for (const [n, reason] of tailKeeps) keeps.set(n + tailFirst - 1, reason);
1502
+ const lines = [...head, ...tail];
1503
+ return fallbackResult({
1504
+ lines,
1505
+ decisions: fallbackDecisions(lines, keeps, input.config.headLines),
1506
+ input,
1507
+ reason: { kind: "size-limit", maxBytes: input.config.maxPruneBytes },
1508
+ bytesIn,
1509
+ linesIn: totalLines,
1510
+ totalLines
1511
+ });
1512
+ }
1513
+ function fallbackDecisions(lines, keeps, headLines) {
1514
+ const head = Math.max(0, Math.trunc(headLines));
1515
+ const decisions = /* @__PURE__ */ new Map();
1516
+ for (const line of lines) {
1517
+ const keep = keeps.get(line.n);
1518
+ if (keep !== void 0) decisions.set(line.n, { keep: true, reason: keep });
1519
+ else if (line.n <= head) decisions.set(line.n, { keep: true, reason: "head" });
1520
+ else decisions.set(line.n, { keep: false, reason: "fallback" });
1564
1521
  }
1565
- return parsed;
1522
+ return decisions;
1566
1523
  }
1567
- function isRecord4(value) {
1568
- return typeof value === "object" && value !== null && !Array.isArray(value);
1524
+ function renumber(lines, from) {
1525
+ return lines.map((line, index) => ({ ...line, n: from + index }));
1569
1526
  }
1570
- function storeError(message, error) {
1571
- const code = errorCode(error);
1572
- return new RunStoreError(`${message}: ${errorMessage(error)}`, {
1573
- ...code !== void 0 ? { code } : {},
1574
- cause: error
1527
+ function fallbackResult(fallback) {
1528
+ const { kept, dropped } = mergeDecisions(fallback.lines, fallback.decisions, {
1529
+ minCollapseLines: fallback.input.config.minCollapseLines,
1530
+ runId: fallback.input.runId,
1531
+ ...fallback.totalLines !== void 0 ? { totalLines: fallback.totalLines } : {}
1575
1532
  });
1533
+ return {
1534
+ mode: "fallback",
1535
+ kept,
1536
+ dropped,
1537
+ linesIn: fallback.linesIn,
1538
+ linesOut: splitLines(kept).length,
1539
+ bytesIn: fallback.bytesIn,
1540
+ bytesOut: Buffer.byteLength(kept),
1541
+ windows: 0,
1542
+ jevRequests: 0,
1543
+ jevInputTokens: 0,
1544
+ fallbackReason: fallback.reason,
1545
+ decisions: fallback.decisions
1546
+ };
1576
1547
  }
1577
-
1578
- // src/commands/gain.ts
1579
- async function runGain(io) {
1580
- const config = await loadConfig(io.env);
1581
- const store = new RunStore({ home: config.home, retention: config.retention });
1582
- const totals = await store.readGain();
1583
- const tokens = Math.max(0, Math.floor((totals.bytesIn - totals.bytesOut) / CHARS_PER_TOKEN));
1584
- await io.write(
1585
- `jevprune: ${formatCount(totals.runs)} runs, ${formatCount(totals.linesIn)} \u2192 ${formatCount(totals.linesOut)} lines, ~${formatCount(tokens)} tokens saved (estimated at ${String(CHARS_PER_TOKEN)} chars per token)
1586
- `
1587
- );
1588
- return 0;
1548
+ function fallbackReasonText(reason) {
1549
+ switch (reason.kind) {
1550
+ case "size-limit":
1551
+ return `output over ${String(reason.maxBytes)} bytes`;
1552
+ case "not-utf8":
1553
+ return NOT_UTF8_REASON;
1554
+ case "unavailable":
1555
+ return reason.detail;
1556
+ }
1589
1557
  }
1590
-
1591
- // src/hook.ts
1592
- var STATE_CHANGING_TOKENS = [
1593
- "cd",
1594
- "export",
1595
- "source",
1596
- ".",
1597
- "unset",
1598
- "alias",
1599
- "set",
1600
- "eval",
1601
- "exec",
1602
- "pushd",
1603
- "popd"
1604
- ];
1605
- var ALWAYS_INTERACTIVE_COMMANDS = [
1606
- "vim",
1607
- "vi",
1608
- "nvim",
1609
- "nano",
1610
- "emacs",
1611
- "less",
1612
- "more",
1613
- "man",
1614
- "top",
1615
- "htop",
1616
- "ssh",
1617
- "telnet",
1618
- "tmux",
1619
- "screen",
1620
- "sudo",
1621
- "su",
1622
- "passwd",
1623
- "claude",
1624
- "watch"
1625
- ];
1626
- var INTERACTIVE_WHEN_BARE_COMMANDS = [
1627
- "python",
1628
- "python3",
1629
- "node",
1630
- "irb",
1631
- "psql",
1632
- "mysql",
1633
- "sqlite3",
1634
- "bash",
1635
- "sh",
1636
- "zsh",
1637
- "fish",
1638
- "gh"
1639
- ];
1640
- var SHELL_COMMANDS = ["bash", "sh", "zsh", "fish"];
1641
- var DOCKER_TTY_FLAG = /^-(?:i|t|it|ti)$|^--interactive$|^--tty$/;
1642
- var STATE_CHANGE_PATTERN = new RegExp(
1643
- `(?:^|;|\\||\\(|&&|\\n)\\s*(?:${STATE_CHANGING_TOKENS.map(escapeRegExp).join("|")})(?=\\s|$|[;|)&])`
1644
- );
1645
- var ENV_ASSIGNMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=/;
1646
- function quoteForShell(value) {
1647
- return `'${value.replaceAll("'", "'\\''")}'`;
1558
+ function unavailableReason(error) {
1559
+ return { kind: "unavailable", detail: unavailableDetail(error) };
1560
+ }
1561
+ function unavailableDetail(error) {
1562
+ if (error instanceof JevTimeoutError) return "timeout";
1563
+ if (error instanceof JevResponseError) return "invalid response";
1564
+ if (error instanceof JevRequestError) {
1565
+ switch (error.status) {
1566
+ case 429:
1567
+ return "rate limited (429)";
1568
+ case 529:
1569
+ return "overloaded (529)";
1570
+ case 401:
1571
+ return UNAUTHORIZED_REASON;
1572
+ case 400:
1573
+ return "bad request (400)";
1574
+ case void 0:
1575
+ return "network";
1576
+ default:
1577
+ return error.name;
1578
+ }
1579
+ }
1580
+ if (error instanceof Error) return error.name;
1581
+ return "unknown";
1648
1582
  }
1649
- function hasStateChange(command) {
1650
- return STATE_CHANGE_PATTERN.test(command);
1583
+ function everyLine(lines, mode, text, bytesIn) {
1584
+ const decisions = /* @__PURE__ */ new Map();
1585
+ for (const line of lines) decisions.set(line.n, { keep: true, reason: mode });
1586
+ return {
1587
+ mode,
1588
+ kept: text,
1589
+ dropped: [],
1590
+ linesIn: lines.length,
1591
+ linesOut: lines.length,
1592
+ bytesIn,
1593
+ bytesOut: bytesIn,
1594
+ windows: 0,
1595
+ jevRequests: 0,
1596
+ jevInputTokens: 0,
1597
+ decisions
1598
+ };
1651
1599
  }
1652
- function isInteractiveCommand(command) {
1653
- const words = command.split(/\s+/).filter((word2) => word2.length > 0);
1654
- let index = 0;
1655
- while (index < words.length && ENV_ASSIGNMENT_PATTERN.test(words[index] ?? "")) index += 1;
1656
- const word = words[index];
1657
- if (word === void 0) return true;
1658
- const rest = words.slice(index + 1);
1659
- if (ALWAYS_INTERACTIVE_COMMANDS.includes(word)) return true;
1660
- if (INTERACTIVE_WHEN_BARE_COMMANDS.includes(word)) {
1661
- if (rest.length === 0) return true;
1662
- if (SHELL_COMMANDS.includes(word) && rest.includes("-i")) return true;
1663
- return word === "gh" && rest[0] === "auth";
1600
+
1601
+ // src/footer.ts
1602
+ var LF = 10;
1603
+ function formatFooter(input) {
1604
+ if (input.mode === "fast-path") return "";
1605
+ const parts = [];
1606
+ if (input.mode === "passthrough") {
1607
+ if (input.exitCode !== void 0 && input.exitCode !== null) parts.push(`exit ${String(input.exitCode)}`);
1608
+ const note = input.passthroughNote === void 0 ? "" : ` (${input.passthroughNote})`;
1609
+ parts.push(`${formatCount(input.linesIn)} lines passed through${note}`);
1610
+ } else {
1611
+ if (input.mode === "fallback") parts.push(`fallback (${fallbackNote(input.fallbackReason)})`);
1612
+ parts.push(`${formatCount(input.linesIn)} \u2192 ${formatCount(input.linesOut)} lines`);
1613
+ if (input.exitCode !== void 0 && input.exitCode !== null) parts.push(`exit ${String(input.exitCode)}`);
1664
1614
  }
1665
- if (word === "tail") return rest[0] === "-f";
1666
- if (word === "docker" && (rest[0] === "exec" || rest[0] === "run")) {
1667
- return rest.some((flag) => DOCKER_TTY_FLAG.test(flag));
1615
+ if (input.storeFailureCode !== void 0) {
1616
+ parts.push(`full output was not saved (${input.storeFailureCode})`);
1617
+ } else if (input.logPath !== void 0) {
1618
+ parts.push(`full output ${displayPath(input.logPath, input.home)}`);
1668
1619
  }
1669
- return false;
1620
+ return `jevprune: ${parts.join(", ")}`;
1670
1621
  }
1671
- function isAllowlisted(command, allowlist) {
1672
- for (const prefix of allowlist) {
1673
- if (prefix.length === 0) continue;
1674
- if (command === prefix) return true;
1675
- const next = command.startsWith(prefix) ? command[prefix.length] : void 0;
1676
- if (next !== void 0 && /\s/.test(next)) return true;
1622
+ function fallbackNote(reason) {
1623
+ if (reason === void 0) return "Jev unavailable: unknown";
1624
+ switch (reason.kind) {
1625
+ case "size-limit":
1626
+ return `output over ${String(reason.maxBytes)} bytes`;
1627
+ case "not-utf8":
1628
+ return NOT_UTF8_NOTE;
1629
+ case "unavailable":
1630
+ return `Jev unavailable: ${reason.detail}`;
1677
1631
  }
1678
- return false;
1679
1632
  }
1680
- function planRewrite(input, config) {
1681
- if (input.tool_name !== "Bash") return null;
1682
- if (!config.autoWrap) return null;
1683
- const toolInput = input.tool_input;
1684
- if (toolInput === void 0) return null;
1685
- if (toolInput.run_in_background === true) return null;
1686
- const command = typeof toolInput.command === "string" ? toolInput.command.trim() : "";
1687
- if (command.length === 0) return null;
1688
- if (/\bjevprune\b/.test(command)) return null;
1689
- if (hasStateChange(command)) return null;
1690
- if (isInteractiveCommand(command)) return null;
1691
- if (command.endsWith("&") && !command.endsWith("&&")) return null;
1692
- if (isAllowlisted(command, config.allowlist)) return null;
1693
- const transcript = typeof input.transcript_path === "string" && input.transcript_path.length > 0 ? ` --transcript ${quoteForShell(input.transcript_path)}` : "";
1694
- return { command: `jevprune run --hook${transcript} -- bash -c ${quoteForShell(command)}` };
1633
+ function footerAfter(lastByte, footer) {
1634
+ if (footer.length === 0) return "";
1635
+ const separator = lastByte === void 0 || lastByte === LF ? "" : "\n";
1636
+ return `${separator}${footer}
1637
+ `;
1695
1638
  }
1696
- function parsePreToolUse(raw) {
1697
- let parsed;
1639
+ function withFooter(kept, footer) {
1640
+ if (footer.length === 0) return kept;
1641
+ const separator = kept.length === 0 || kept.endsWith("\n") ? "" : "\n";
1642
+ return `${kept}${separator}${footer}
1643
+ `;
1644
+ }
1645
+ function formatCount(value) {
1646
+ const digits = Math.trunc(Math.abs(value)).toString();
1647
+ const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
1648
+ return value < 0 ? `-${grouped}` : grouped;
1649
+ }
1650
+ function displayPath(path, home = homedir2()) {
1651
+ if (path === home) return "~";
1652
+ if (home.length > 0 && path.startsWith(home + sep)) return `~${path.slice(home.length)}`;
1653
+ return path;
1654
+ }
1655
+
1656
+ // src/store.ts
1657
+ import { randomBytes } from "crypto";
1658
+ import { createReadStream } from "fs";
1659
+ import { appendFile, mkdir, open, readFile as readFile2, readdir, stat, unlink, writeFile } from "fs/promises";
1660
+ import { join as join2 } from "path";
1661
+ import { finished } from "stream/promises";
1662
+
1663
+ // src/bytes.ts
1664
+ var LF2 = 10;
1665
+ var CR = 13;
1666
+ function isValidUtf8(bytes) {
1698
1667
  try {
1699
- parsed = JSON.parse(raw);
1668
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1669
+ return true;
1700
1670
  } catch {
1701
- return null;
1671
+ return false;
1702
1672
  }
1703
- if (!isRecord5(parsed)) return null;
1704
- const toolName = parsed["tool_name"];
1705
- const transcriptPath = parsed["transcript_path"];
1706
- const toolInput = parsed["tool_input"];
1707
- return {
1708
- ...typeof toolName === "string" ? { tool_name: toolName } : {},
1709
- ...typeof transcriptPath === "string" ? { transcript_path: transcriptPath } : {},
1710
- ...isRecord5(toolInput) ? { tool_input: readToolInput(toolInput) } : {}
1711
- };
1712
- }
1713
- function formatHookOutput(plan) {
1714
- return JSON.stringify({
1715
- hookSpecificOutput: {
1716
- hookEventName: "PreToolUse",
1717
- updatedInput: { command: plan.command }
1718
- }
1719
- });
1720
- }
1721
- function readToolInput(source) {
1722
- const command = source["command"];
1723
- const background = source["run_in_background"];
1724
- return {
1725
- ...typeof command === "string" ? { command } : {},
1726
- ...typeof background === "boolean" ? { run_in_background: background } : {}
1727
- };
1728
1673
  }
1729
- function isRecord5(value) {
1730
- return typeof value === "object" && value !== null && !Array.isArray(value);
1674
+ function byteLineStarts(bytes) {
1675
+ const starts = [0];
1676
+ for (let index = 0; index < bytes.length; index += 1) {
1677
+ const byte = bytes[index];
1678
+ if (byte === CR && bytes[index + 1] === LF2) index += 1;
1679
+ else if (byte !== CR && byte !== LF2) continue;
1680
+ starts.push(index + 1);
1681
+ }
1682
+ if (starts[starts.length - 1] !== bytes.length) starts.push(bytes.length);
1683
+ return starts;
1731
1684
  }
1732
- function escapeRegExp(value) {
1733
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1685
+ function countByteLines(bytes) {
1686
+ return byteLineStarts(bytes).length - 1;
1734
1687
  }
1735
1688
 
1736
- // src/io.ts
1737
- var CLOSED_PIPE_CODES = /* @__PURE__ */ new Set([
1738
- "EPIPE",
1739
- "EOF",
1740
- "ERR_STREAM_DESTROYED",
1741
- "ERR_STREAM_WRITE_AFTER_END"
1742
- ]);
1743
- function processIo() {
1744
- const write = openWriter(process.stdout);
1745
- return {
1746
- env: process.env,
1747
- cwd: process.cwd(),
1748
- stdin: process.stdin,
1749
- write: (text) => write(text),
1750
- writeBytes: (bytes) => write(bytes),
1751
- writeError: openWriter(process.stderr)
1752
- };
1689
+ // src/store.ts
1690
+ var RUN_ID_PATTERN = /^[a-z0-9]+-[a-f0-9]{4}$/;
1691
+ var RUNS_DIR = "runs";
1692
+ var GAIN_FILE = "gain.jsonl";
1693
+ var DIR_MODE = 448;
1694
+ var FILE_MODE = 384;
1695
+ function newRunId() {
1696
+ return `${Date.now().toString(36)}-${randomBytes(2).toString("hex")}`;
1753
1697
  }
1754
- async function readStreamBytes(stream) {
1755
- const chunks = [];
1756
- for await (const chunk of stream) {
1757
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk);
1698
+ var FileRunWriter = class {
1699
+ path;
1700
+ #handle;
1701
+ #stream;
1702
+ #waiting = [];
1703
+ #failure;
1704
+ #closed = false;
1705
+ constructor(path, handle, stream) {
1706
+ this.path = path;
1707
+ this.#handle = handle;
1708
+ this.#stream = stream;
1709
+ this.#stream.on("error", (error) => {
1710
+ this.#fail(error);
1711
+ this.#release();
1712
+ });
1713
+ this.#stream.on("drain", () => {
1714
+ this.#release();
1715
+ });
1716
+ }
1717
+ get failure() {
1718
+ return this.#failure;
1719
+ }
1720
+ write(chunk) {
1721
+ if (this.#closed || this.#failure !== void 0) return true;
1722
+ try {
1723
+ return this.#stream.write(chunk);
1724
+ } catch (error) {
1725
+ this.#fail(error);
1726
+ return true;
1727
+ }
1728
+ }
1729
+ onDrain(listener) {
1730
+ if (this.#closed || this.#failure !== void 0) {
1731
+ queueMicrotask(listener);
1732
+ return;
1733
+ }
1734
+ this.#waiting.push(listener);
1735
+ }
1736
+ async close() {
1737
+ if (this.#closed) return;
1738
+ this.#closed = true;
1739
+ try {
1740
+ this.#stream.end();
1741
+ await finished(this.#stream);
1742
+ } catch (error) {
1743
+ this.#fail(error);
1744
+ await this.#handle.close().catch(() => void 0);
1745
+ }
1746
+ this.#release();
1747
+ }
1748
+ #fail(error) {
1749
+ this.#failure ??= storeError(`run log ${this.path} could not be written`, error);
1750
+ }
1751
+ #release() {
1752
+ const waiting = this.#waiting;
1753
+ this.#waiting = [];
1754
+ for (const listener of waiting) listener();
1755
+ }
1756
+ };
1757
+ var RunStore = class {
1758
+ home;
1759
+ #retention;
1760
+ constructor(options) {
1761
+ this.home = options.home;
1762
+ this.#retention = options.retention ?? DEFAULT_CONFIG.retention;
1763
+ }
1764
+ get runsDir() {
1765
+ return join2(this.home, RUNS_DIR);
1766
+ }
1767
+ get gainPath() {
1768
+ return join2(this.home, GAIN_FILE);
1769
+ }
1770
+ logPath(id) {
1771
+ return join2(this.runsDir, `${requireRunId(id)}.log`);
1758
1772
  }
1759
- return Buffer.concat(chunks);
1760
- }
1761
- async function readStream(stream) {
1762
- return (await readStreamBytes(stream)).toString("utf8");
1763
- }
1764
- function isClosedPipe(error) {
1765
- const code = errorCode(error);
1766
- return code !== void 0 && CLOSED_PIPE_CODES.has(code);
1767
- }
1768
- function openWriter(stream) {
1769
- let closed = false;
1770
- stream.on("error", (error) => {
1771
- if (isClosedPipe(error)) closed = true;
1772
- });
1773
- return (chunk) => new Promise((resolve2, reject) => {
1774
- if (closed || chunk.length === 0) {
1775
- resolve2();
1776
- return;
1773
+ metaPath(id) {
1774
+ return join2(this.runsDir, `${requireRunId(id)}.json`);
1775
+ }
1776
+ async openRun(run) {
1777
+ const path = this.logPath(run.id);
1778
+ await this.#ensureDir(this.runsDir);
1779
+ let handle;
1780
+ try {
1781
+ handle = await open(path, "wx", FILE_MODE);
1782
+ } catch (error) {
1783
+ throw storeError(`run log ${path} could not be created`, error);
1777
1784
  }
1785
+ return new FileRunWriter(path, handle, handle.createWriteStream());
1786
+ }
1787
+ async finalizeRun(id, meta) {
1788
+ const path = this.metaPath(id);
1789
+ await this.#ensureDir(this.runsDir);
1778
1790
  try {
1779
- stream.write(chunk, (error) => {
1780
- if (error === void 0 || error === null) resolve2();
1781
- else if (isClosedPipe(error)) {
1782
- closed = true;
1783
- resolve2();
1784
- } else reject(error);
1785
- });
1791
+ await writeFile(path, `${JSON.stringify(meta)}
1792
+ `, { mode: FILE_MODE });
1786
1793
  } catch (error) {
1787
- if (!isClosedPipe(error)) {
1788
- reject(error instanceof Error ? error : new Error(String(error)));
1789
- return;
1790
- }
1791
- closed = true;
1792
- resolve2();
1794
+ throw storeError(`run meta ${path} could not be written`, error);
1793
1795
  }
1794
- });
1795
- }
1796
-
1797
- // src/commands/hook.ts
1798
- async function runHook(io) {
1799
- try {
1800
- const input = parsePreToolUse(await readStream(io.stdin));
1801
- if (input === null) return 0;
1802
- const plan = planRewrite(input, await loadConfig(io.env));
1803
- if (plan === null) return 0;
1804
- await io.write(`${formatHookOutput(plan)}
1805
- `);
1806
- } catch (error) {
1807
- await io.writeError(`jevprune: hook skipped: ${errorMessage(error)}
1808
- `).catch(() => void 0);
1809
1796
  }
1810
- return 0;
1811
- }
1812
-
1813
- // src/notices.ts
1814
- import { mkdir as mkdir2, open as open2 } from "fs/promises";
1815
- import { join as join3 } from "path";
1816
- var MISSING_KEY_MARKER = "missing-key-notice";
1817
- async function shouldAnnounceMissingKey(home) {
1818
- const path = join3(home, MISSING_KEY_MARKER);
1819
- try {
1820
- await mkdir2(home, { recursive: true, mode: 448 });
1821
- const handle = await open2(path, "wx", 384);
1822
- await handle.close();
1823
- return true;
1824
- } catch (error) {
1825
- return errorCode(error) !== "EEXIST";
1797
+ async readRunBytes(id) {
1798
+ const path = this.logPath(id);
1799
+ try {
1800
+ return await readFile2(path);
1801
+ } catch (error) {
1802
+ if (errorCode(error) === "ENOENT") throw new RunNotFoundError(id, { cause: error });
1803
+ throw storeError(`run log ${path} could not be read`, error);
1804
+ }
1826
1805
  }
1827
- }
1828
-
1829
- // src/keeps.ts
1830
- var SIGNATURE_CASE_SENSITIVE = /(^|\s)(FAIL|FAILED|ERROR|PANIC|FATAL)(\s|:|$)|\b[A-Z][A-Za-z]*(Error|Exception|Panic)\b|^\s*E\s{2,}\S|^npm ERR!|^\s*(Test Files|Tests|Test Suites)\s/;
1831
- var SIGNATURE_CASE_INSENSITIVE = /^\s*(✗|✘|×|⨯|❌|❯)|^\s*(error|fatal)(\[E\d+\])?:|^traceback \(most recent call last\)|^\s*File ".*", line \d+|^\s+at .*:\d+:\d+\)?$|^\s*-->\s.*:\d+:\d+|^thread '.*' panicked|\b(exit code|exit status|exited with|command not found|no such file or directory|ENOENT|EACCES|ECONNREFUSED|segmentation fault|core dumped|killed)\b|\blevel[=:"\s]+"?(error|fatal|panic)\b|\[(error|fatal|panic)\]|^=+ .*(passed|failed|error).* =+$/i;
1832
- function isSignatureLine(text) {
1833
- return SIGNATURE_CASE_SENSITIVE.test(text) || SIGNATURE_CASE_INSENSITIVE.test(text);
1834
- }
1835
- function computeKeeps(lines, options) {
1836
- const keeps = /* @__PURE__ */ new Map();
1837
- const tailLines = Math.max(0, Math.trunc(options.tailLines));
1838
- const contextLines = Math.max(0, Math.trunc(options.contextLines));
1839
- const tailStart = lines.length - tailLines + 1;
1840
- for (const line of lines) {
1841
- if (line.n >= tailStart) keeps.set(line.n, "tail");
1806
+ async *readRunChunks(id) {
1807
+ const path = this.logPath(id);
1808
+ try {
1809
+ for await (const chunk of createReadStream(path)) {
1810
+ yield typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
1811
+ }
1812
+ } catch (error) {
1813
+ if (errorCode(error) === "ENOENT") throw new RunNotFoundError(id, { cause: error });
1814
+ throw storeError(`run log ${path} could not be read`, error);
1815
+ }
1842
1816
  }
1843
- const seen = /* @__PURE__ */ new Set();
1844
- const signatures = [];
1845
- for (const line of lines) {
1846
- if (!isSignatureLine(line.text)) continue;
1847
- const key = line.text.trim();
1848
- if (seen.has(key)) continue;
1849
- seen.add(key);
1850
- signatures.push(line.n);
1817
+ async readRun(id) {
1818
+ const bytes = await this.readRunBytes(id);
1819
+ return { id, path: this.logPath(id), text: bytes.toString("utf8"), meta: await this.#readMeta(id) };
1851
1820
  }
1852
- for (const n of signatures) keeps.set(n, "signature");
1853
- for (const n of signatures) {
1854
- for (let offset = 1; offset <= contextLines; offset += 1) {
1855
- for (const candidate of [n - offset, n + offset]) {
1856
- if (candidate < 1 || candidate > lines.length) continue;
1857
- if (!keeps.has(candidate)) keeps.set(candidate, "context");
1858
- }
1821
+ async readRunLineBytes(id, from = 1, to) {
1822
+ const bytes = await this.readRunBytes(id);
1823
+ const starts = byteLineStarts(bytes);
1824
+ const lines = starts.length - 1;
1825
+ const last = to ?? lines;
1826
+ if (!Number.isInteger(from) || !Number.isInteger(last) || from < 1 || last < from) {
1827
+ throw new LineRangeError(`line range ${String(from)}-${String(last)} is not a range`);
1828
+ }
1829
+ if (last > lines) {
1830
+ throw new LineRangeError(
1831
+ `line range ${String(from)}-${String(last)} is outside run ${id} (${String(lines)} lines)`
1832
+ );
1859
1833
  }
1834
+ return bytes.subarray(starts[from - 1] ?? 0, starts[last] ?? bytes.length);
1860
1835
  }
1861
- return keeps;
1862
- }
1863
-
1864
- // src/lines.ts
1865
- function splitLines(text) {
1866
- const lines = [];
1867
- const pattern = /\r\n|\n|\r/g;
1868
- let start = 0;
1869
- let match = pattern.exec(text);
1870
- while (match !== null) {
1871
- const raw = match[0];
1872
- const terminator = raw === "\r\n" ? "\r\n" : raw === "\r" ? "\r" : "\n";
1873
- lines.push({ n: lines.length + 1, text: text.slice(start, match.index), terminator });
1874
- start = match.index + raw.length;
1875
- match = pattern.exec(text);
1836
+ async readRunLines(id, from = 1, to) {
1837
+ return (await this.readRunLineBytes(id, from, to)).toString("utf8");
1876
1838
  }
1877
- if (start < text.length) {
1878
- lines.push({ n: lines.length + 1, text: text.slice(start), terminator: "" });
1839
+ async appendGain(entry2) {
1840
+ await this.#ensureDir(this.home);
1841
+ try {
1842
+ await appendFile(this.gainPath, `${JSON.stringify(entry2)}
1843
+ `, { mode: FILE_MODE });
1844
+ } catch (error) {
1845
+ throw storeError(`gain ledger ${this.gainPath} could not be written`, error);
1846
+ }
1879
1847
  }
1880
- return lines;
1881
- }
1882
-
1883
- // src/merge.ts
1884
- function collapseMarker(range2, runId) {
1885
- return `[jevprune: ${String(range2.count)} lines dropped, run ${runId}, lines ${String(range2.from)}-${String(range2.to)}]
1886
- `;
1887
- }
1888
- function mergeDecisions(lines, decisions, options) {
1889
- const minCollapseLines = Math.max(1, Math.trunc(options.minCollapseLines));
1890
- const dropped = [];
1891
- let kept = "";
1892
- let run = null;
1893
- let expected = 1;
1894
- const flush = () => {
1895
- if (run === null) return;
1896
- const count = run.to - run.from + 1;
1897
- if (!run.missing && count < minCollapseLines) {
1898
- for (const line of run.lines) {
1899
- decisions.set(line.n, { keep: true, reason: "collapse-min" });
1900
- kept += line.text + line.terminator;
1901
- }
1902
- } else {
1903
- const range2 = { from: run.from, to: run.to, count };
1904
- dropped.push(range2);
1905
- kept += collapseMarker(range2, options.runId);
1848
+ async readGain() {
1849
+ let raw;
1850
+ try {
1851
+ raw = await readFile2(this.gainPath, "utf8");
1852
+ } catch (error) {
1853
+ if (errorCode(error) === "ENOENT") return { runs: 0, linesIn: 0, linesOut: 0, bytesIn: 0, bytesOut: 0 };
1854
+ throw storeError(`gain ledger ${this.gainPath} could not be read`, error);
1906
1855
  }
1907
- run = null;
1908
- };
1909
- const dropLine = (line) => {
1910
- if (run === null) run = { from: line.n, to: line.n, lines: [line], missing: false };
1911
- else {
1912
- run.to = line.n;
1913
- run.lines.push(line);
1856
+ let runs = 0;
1857
+ let linesIn = 0;
1858
+ let linesOut = 0;
1859
+ let bytesIn = 0;
1860
+ let bytesOut = 0;
1861
+ for (const line of raw.split("\n")) {
1862
+ if (line.trim().length === 0) continue;
1863
+ const entry2 = parseGainEntry(line);
1864
+ if (entry2 === null) continue;
1865
+ runs += 1;
1866
+ linesIn += entry2.linesIn;
1867
+ linesOut += entry2.linesOut;
1868
+ bytesIn += entry2.bytesIn;
1869
+ bytesOut += entry2.bytesOut;
1914
1870
  }
1915
- };
1916
- const dropMissing = (from, to) => {
1917
- if (to < from) return;
1918
- if (run === null) run = { from, to, lines: [], missing: true };
1919
- else {
1920
- run.to = to;
1921
- run.missing = true;
1871
+ return { runs, linesIn, linesOut, bytesIn, bytesOut };
1872
+ }
1873
+ async enforceRetention() {
1874
+ let names;
1875
+ try {
1876
+ names = await readdir(this.runsDir);
1877
+ } catch (error) {
1878
+ if (errorCode(error) === "ENOENT") return;
1879
+ throw storeError(`run directory ${this.runsDir} could not be read`, error);
1922
1880
  }
1923
- };
1924
- for (const line of lines) {
1925
- dropMissing(expected, line.n - 1);
1926
- expected = line.n + 1;
1927
- if (decisions.get(line.n)?.keep === false) {
1928
- dropLine(line);
1929
- continue;
1881
+ const ids = [...new Set(names.filter((name) => name.endsWith(".log")).map((name) => name.slice(0, -4)))].filter((id) => RUN_ID_PATTERN.test(id)).sort();
1882
+ const sizes = /* @__PURE__ */ new Map();
1883
+ let total = 0;
1884
+ for (const id of ids) {
1885
+ const bytes = await this.#sizeOf(this.logPath(id)) + await this.#sizeOf(this.metaPath(id));
1886
+ sizes.set(id, bytes);
1887
+ total += bytes;
1888
+ }
1889
+ let count = ids.length;
1890
+ for (const id of ids) {
1891
+ if (count <= this.#retention.maxRuns && total <= this.#retention.maxBytes) break;
1892
+ await this.discardRun(id);
1893
+ total -= sizes.get(id) ?? 0;
1894
+ count -= 1;
1930
1895
  }
1931
- flush();
1932
- kept += line.text + line.terminator;
1933
- }
1934
- if (options.totalLines !== void 0) dropMissing(expected, options.totalLines);
1935
- flush();
1936
- return { kept, dropped };
1937
- }
1938
-
1939
- // src/select.ts
1940
- var UNAUTHORIZED_REASON = "unauthorized (401)";
1941
- var NOT_UTF8_REASON = "not valid UTF-8";
1942
- var NOT_UTF8_NOTE = "output is not valid UTF-8";
1943
- var RUBRIC = "A line is needed when a developer acting on the task would want to read it: errors, failures, assertions, stack frames, diagnostics, timings or statuses that bear on the task, and the lines that give them meaning. Progress bars, download counters, repeated banners, unchanged status lines and routine success noise are not needed.";
1944
- var ITEM_OVERHEAD_TOKENS = 12;
1945
- function passthroughSelection(input) {
1946
- return {
1947
- mode: "passthrough",
1948
- kept: "",
1949
- dropped: [],
1950
- linesIn: input.lines,
1951
- linesOut: input.lines,
1952
- bytesIn: input.bytes,
1953
- bytesOut: input.bytes,
1954
- windows: 0,
1955
- jevRequests: 0,
1956
- jevInputTokens: 0,
1957
- ...input.reason !== void 0 ? { fallbackReason: input.reason } : {},
1958
- decisions: /* @__PURE__ */ new Map()
1959
- };
1960
- }
1961
- function questionFor(n) {
1962
- return `Is line ${String(n)} needed for the task?`;
1963
- }
1964
- async function selectLines(input) {
1965
- const lines = splitLines(input.text);
1966
- const bytesIn = Buffer.byteLength(input.text);
1967
- if (input.oversize !== void 0) {
1968
- return oversizeSelection(input, input.oversize, lines, bytesIn);
1969
1896
  }
1970
- if (input.exitCode !== void 0 && input.exitCode !== null && input.exitCode !== 0 || input.interrupted === true) {
1971
- return everyLine(lines, "passthrough", input.text, bytesIn);
1897
+ async discardRun(id) {
1898
+ for (const path of [this.logPath(id), this.metaPath(id)]) {
1899
+ try {
1900
+ await unlink(path);
1901
+ } catch (error) {
1902
+ if (errorCode(error) === "ENOENT") continue;
1903
+ throw storeError(`run file ${path} could not be deleted`, error);
1904
+ }
1905
+ }
1972
1906
  }
1973
- if (lines.length <= input.config.fastPathLines) {
1974
- return everyLine(lines, "fast-path", input.text, bytesIn);
1907
+ async #sizeOf(path) {
1908
+ try {
1909
+ return (await stat(path)).size;
1910
+ } catch (error) {
1911
+ if (errorCode(error) === "ENOENT") return 0;
1912
+ throw storeError(`run file ${path} could not be inspected`, error);
1913
+ }
1975
1914
  }
1976
- const keeps = keepsOf(lines, input.config);
1977
- const decisions = /* @__PURE__ */ new Map();
1978
- const candidates = [];
1979
- for (const line of lines) {
1980
- const keep = keeps.get(line.n);
1981
- if (keep !== void 0) {
1982
- decisions.set(line.n, { keep: true, reason: keep });
1983
- continue;
1915
+ async #readMeta(id) {
1916
+ const path = this.metaPath(id);
1917
+ let raw;
1918
+ try {
1919
+ raw = await readFile2(path, "utf8");
1920
+ } catch (error) {
1921
+ if (errorCode(error) === "ENOENT") return null;
1922
+ throw storeError(`run meta ${path} could not be read`, error);
1984
1923
  }
1985
- if (line.text.trim().length === 0) {
1986
- decisions.set(line.n, { keep: false, reason: "blank" });
1987
- continue;
1924
+ let parsed;
1925
+ try {
1926
+ parsed = JSON.parse(raw);
1927
+ } catch (error) {
1928
+ throw storeError(`run meta ${path} is not valid JSON`, error);
1988
1929
  }
1989
- candidates.push({ id: `l${String(line.n)}`, n: line.n, text: line.text });
1930
+ return isRecord4(parsed) ? parsed : null;
1990
1931
  }
1991
- if (input.client === null) {
1992
- return fallbackSelection({ lines, keeps, input, reason: "no api key", bytesIn, linesIn: lines.length });
1932
+ async #ensureDir(path) {
1933
+ try {
1934
+ await mkdir(path, { recursive: true, mode: DIR_MODE });
1935
+ } catch (error) {
1936
+ throw storeError(`directory ${path} could not be created`, error);
1937
+ }
1993
1938
  }
1994
- let verdicts;
1939
+ };
1940
+ function requireRunId(id) {
1941
+ if (!RUN_ID_PATTERN.test(id)) throw new RunStoreError(`"${id}" is not a run id`);
1942
+ return id;
1943
+ }
1944
+ function parseGainEntry(line) {
1945
+ let parsed;
1995
1946
  try {
1996
- verdicts = await askJev(candidates, input, input.client);
1997
- } catch (error) {
1998
- return fallbackSelection({
1999
- lines,
2000
- keeps,
2001
- input,
2002
- reason: fallbackReason(error),
2003
- bytesIn,
2004
- linesIn: lines.length
2005
- });
1947
+ parsed = JSON.parse(line);
1948
+ } catch {
1949
+ return null;
2006
1950
  }
2007
- for (const n of verdicts.oversize) decisions.set(n, { keep: true, reason: "oversize" });
2008
- for (const item of candidates) {
2009
- if (decisions.has(item.n)) continue;
2010
- const noul2 = verdicts.answers.get(item.n) ?? 0;
2011
- decisions.set(item.n, { keep: noul2 >= input.config.threshold, reason: "jev", noul: noul2 });
1951
+ if (!isRecord4(parsed)) return null;
1952
+ const linesIn = parsed["linesIn"];
1953
+ const linesOut = parsed["linesOut"];
1954
+ const bytesIn = parsed["bytesIn"];
1955
+ const bytesOut = parsed["bytesOut"];
1956
+ if (typeof linesIn !== "number" || typeof linesOut !== "number" || typeof bytesIn !== "number" || typeof bytesOut !== "number") {
1957
+ return null;
2012
1958
  }
2013
- const { kept, dropped } = mergeDecisions(lines, decisions, {
2014
- minCollapseLines: input.config.minCollapseLines,
2015
- runId: input.runId
2016
- });
2017
- return {
2018
- mode: "jev",
2019
- kept,
2020
- dropped,
2021
- linesIn: lines.length,
2022
- linesOut: splitLines(kept).length,
2023
- bytesIn,
2024
- bytesOut: Buffer.byteLength(kept),
2025
- windows: verdicts.windows,
2026
- jevRequests: verdicts.jevRequests,
2027
- jevInputTokens: verdicts.jevInputTokens,
2028
- decisions
2029
- };
1959
+ return parsed;
2030
1960
  }
2031
- async function askJev(candidates, input, client) {
2032
- const answers = /* @__PURE__ */ new Map();
2033
- if (candidates.length === 0) {
2034
- return { windows: 0, jevRequests: 0, jevInputTokens: 0, answers, oversize: [] };
2035
- }
2036
- const lastLine = candidates[candidates.length - 1]?.n ?? 0;
2037
- const plan = planWindows(candidates, {
2038
- budgetTokens: input.config.windowTokens,
2039
- overheadTokens: estimateJsonTokens(stateOf(input, [])),
2040
- itemOverheadTokens: estimateTokens(questionFor(lastLine)) + ITEM_OVERHEAD_TOKENS,
2041
- costTokens: (item) => estimateJsonTokens({ n: item.n, text: item.text })
1961
+ function isRecord4(value) {
1962
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1963
+ }
1964
+ function storeError(message, error) {
1965
+ const code = errorCode(error);
1966
+ return new RunStoreError(`${message}: ${errorMessage(error)}`, {
1967
+ ...code !== void 0 ? { code } : {},
1968
+ cause: error
2042
1969
  });
2043
- const results = await runWindows(
2044
- plan.windows,
2045
- async (window, _index, options) => {
2046
- const questions = {};
2047
- for (const item of window) questions[item.id] = questionFor(item.n);
2048
- return await client.noul({ state: stateOf(input, window), questions }, options);
2049
- },
2050
- {
2051
- concurrency: input.config.concurrency,
2052
- timeoutMs: input.config.windowTimeoutMs,
2053
- ...input.signal !== void 0 ? { signal: input.signal } : {}
2054
- }
2055
- );
2056
- let jevInputTokens = 0;
2057
- for (const [index, result] of results.entries()) {
2058
- jevInputTokens += result.usage.inputTokens;
2059
- for (const item of plan.windows[index] ?? []) {
2060
- const noul2 = result.answers[item.id];
2061
- if (noul2 !== void 0) answers.set(item.n, noul2);
2062
- }
2063
- }
2064
- return {
2065
- windows: plan.windows.length,
2066
- jevRequests: results.length,
2067
- jevInputTokens,
2068
- answers,
2069
- oversize: plan.oversize.map((item) => item.n)
2070
- };
2071
1970
  }
2072
- function stateOf(input, window) {
2073
- return {
2074
- command: input.command,
2075
- task: input.task,
2076
- rubric: RUBRIC,
2077
- lines: window.map((item) => ({ n: item.n, text: item.text }))
2078
- };
1971
+
1972
+ // src/commands/gain.ts
1973
+ async function runGain(io) {
1974
+ const config = await loadConfig(io.env);
1975
+ const store = new RunStore({ home: config.home, retention: config.retention });
1976
+ const totals = await store.readGain();
1977
+ const tokens = Math.max(0, Math.floor((totals.bytesIn - totals.bytesOut) / CHARS_PER_TOKEN));
1978
+ await io.write(
1979
+ `jevprune: ${formatCount(totals.runs)} runs, ${formatCount(totals.linesIn)} \u2192 ${formatCount(totals.linesOut)} lines, ~${formatCount(tokens)} output tokens removed (estimate: ${String(CHARS_PER_TOKEN)} bytes per token)
1980
+ `
1981
+ );
1982
+ return 0;
2079
1983
  }
2080
- function keepsOf(lines, config) {
2081
- return computeKeeps(lines, { tailLines: config.tailLines, contextLines: config.contextLines });
1984
+
1985
+ // src/hook.ts
1986
+ var STATE_CHANGING_TOKENS = [
1987
+ "cd",
1988
+ "export",
1989
+ "source",
1990
+ ".",
1991
+ "unset",
1992
+ "alias",
1993
+ "set",
1994
+ "eval",
1995
+ "exec",
1996
+ "pushd",
1997
+ "popd"
1998
+ ];
1999
+ var ALWAYS_INTERACTIVE_COMMANDS = [
2000
+ "vim",
2001
+ "vi",
2002
+ "nvim",
2003
+ "nano",
2004
+ "emacs",
2005
+ "less",
2006
+ "more",
2007
+ "man",
2008
+ "top",
2009
+ "htop",
2010
+ "ssh",
2011
+ "telnet",
2012
+ "tmux",
2013
+ "screen",
2014
+ "sudo",
2015
+ "su",
2016
+ "passwd",
2017
+ "claude",
2018
+ "watch"
2019
+ ];
2020
+ var INTERACTIVE_WHEN_BARE_COMMANDS = [
2021
+ "python",
2022
+ "python3",
2023
+ "node",
2024
+ "irb",
2025
+ "psql",
2026
+ "mysql",
2027
+ "sqlite3",
2028
+ "bash",
2029
+ "sh",
2030
+ "zsh",
2031
+ "fish",
2032
+ "gh"
2033
+ ];
2034
+ var SHELL_COMMANDS = ["bash", "sh", "zsh", "fish"];
2035
+ var DOCKER_TTY_FLAG = /^-(?:i|t|it|ti)$|^--interactive$|^--tty$/;
2036
+ var STATE_CHANGE_PATTERN = new RegExp(
2037
+ `(?:^|;|\\||\\(|&&|\\n)\\s*(?:${STATE_CHANGING_TOKENS.map(escapeRegExp).join("|")})(?=\\s|$|[;|)&])`
2038
+ );
2039
+ var ENV_ASSIGNMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=/;
2040
+ function quoteForShell(value) {
2041
+ return `'${value.replaceAll("'", "'\\''")}'`;
2082
2042
  }
2083
- function fallbackSelection(fallback) {
2084
- const { lines, keeps, input } = fallback;
2085
- return fallbackResult({
2086
- lines,
2087
- decisions: fallbackDecisions(lines, keeps, input.config.headLines),
2088
- input,
2089
- reason: fallback.reason,
2090
- bytesIn: fallback.bytesIn,
2091
- linesIn: fallback.linesIn
2092
- });
2043
+ function hasStateChange(command) {
2044
+ return STATE_CHANGE_PATTERN.test(command);
2093
2045
  }
2094
- function oversizeSelection(input, oversize, captured, bytesIn) {
2095
- const headCount = Math.min(Math.max(0, Math.trunc(oversize.headSegmentLines)), captured.length);
2096
- const totalLines = Math.max(Math.trunc(oversize.lines), captured.length);
2097
- const tailFirst = totalLines - captured.length + headCount + 1;
2098
- const head = captured.slice(0, headCount);
2099
- const tail = renumber(captured.slice(headCount), tailFirst);
2100
- const contextLines = input.config.contextLines;
2101
- const keeps = computeKeeps(head, { tailLines: 0, contextLines });
2102
- const tailKeeps = computeKeeps(renumber(tail, 1), { tailLines: input.config.tailLines, contextLines });
2103
- for (const [n, reason] of tailKeeps) keeps.set(n + tailFirst - 1, reason);
2104
- const lines = [...head, ...tail];
2105
- return fallbackResult({
2106
- lines,
2107
- decisions: fallbackDecisions(lines, keeps, input.config.headLines),
2108
- input,
2109
- reason: `output over ${String(input.config.maxPruneBytes)} bytes`,
2110
- bytesIn,
2111
- linesIn: totalLines,
2112
- totalLines
2113
- });
2046
+ function isInteractiveCommand(command) {
2047
+ const words = command.split(/\s+/).filter((word2) => word2.length > 0);
2048
+ let index = 0;
2049
+ while (index < words.length && ENV_ASSIGNMENT_PATTERN.test(words[index] ?? "")) index += 1;
2050
+ const word = words[index];
2051
+ if (word === void 0) return true;
2052
+ const rest = words.slice(index + 1);
2053
+ if (ALWAYS_INTERACTIVE_COMMANDS.includes(word)) return true;
2054
+ if (INTERACTIVE_WHEN_BARE_COMMANDS.includes(word)) {
2055
+ if (rest.length === 0) return true;
2056
+ if (SHELL_COMMANDS.includes(word) && rest.includes("-i")) return true;
2057
+ return word === "gh" && rest[0] === "auth";
2058
+ }
2059
+ if (word === "tail") return rest[0] === "-f";
2060
+ if (word === "docker" && (rest[0] === "exec" || rest[0] === "run")) {
2061
+ return rest.some((flag) => DOCKER_TTY_FLAG.test(flag));
2062
+ }
2063
+ return false;
2114
2064
  }
2115
- function fallbackDecisions(lines, keeps, headLines) {
2116
- const head = Math.max(0, Math.trunc(headLines));
2117
- const decisions = /* @__PURE__ */ new Map();
2118
- for (const line of lines) {
2119
- const keep = keeps.get(line.n);
2120
- if (keep !== void 0) decisions.set(line.n, { keep: true, reason: keep });
2121
- else if (line.n <= head) decisions.set(line.n, { keep: true, reason: "head" });
2122
- else decisions.set(line.n, { keep: false, reason: "fallback" });
2065
+ function isAllowlisted(command, allowlist) {
2066
+ for (const prefix of allowlist) {
2067
+ if (prefix.length === 0) continue;
2068
+ if (command === prefix) return true;
2069
+ const next = command.startsWith(prefix) ? command[prefix.length] : void 0;
2070
+ if (next !== void 0 && /\s/.test(next)) return true;
2123
2071
  }
2124
- return decisions;
2072
+ return false;
2125
2073
  }
2126
- function renumber(lines, from) {
2127
- return lines.map((line, index) => ({ ...line, n: from + index }));
2074
+ function planRewrite(input, config) {
2075
+ if (input.tool_name !== "Bash") return null;
2076
+ if (!config.autoWrap) return null;
2077
+ const toolInput = input.tool_input;
2078
+ if (toolInput === void 0) return null;
2079
+ if (toolInput.run_in_background === true) return null;
2080
+ const command = typeof toolInput.command === "string" ? toolInput.command.trim() : "";
2081
+ if (command.length === 0) return null;
2082
+ if (/\bjevprune\b/.test(command)) return null;
2083
+ if (hasStateChange(command)) return null;
2084
+ if (isInteractiveCommand(command)) return null;
2085
+ if (command.endsWith("&") && !command.endsWith("&&")) return null;
2086
+ if (isAllowlisted(command, config.allowlist)) return null;
2087
+ const transcript = typeof input.transcript_path === "string" && input.transcript_path.length > 0 ? ` --transcript ${quoteForShell(input.transcript_path)}` : "";
2088
+ return { command: `jevprune run --hook${transcript} -- bash -c ${quoteForShell(command)}` };
2128
2089
  }
2129
- function fallbackResult(fallback) {
2130
- const { kept, dropped } = mergeDecisions(fallback.lines, fallback.decisions, {
2131
- minCollapseLines: fallback.input.config.minCollapseLines,
2132
- runId: fallback.input.runId,
2133
- ...fallback.totalLines !== void 0 ? { totalLines: fallback.totalLines } : {}
2134
- });
2090
+ function parsePreToolUse(raw) {
2091
+ let parsed;
2092
+ try {
2093
+ parsed = JSON.parse(raw);
2094
+ } catch {
2095
+ return null;
2096
+ }
2097
+ if (!isRecord5(parsed)) return null;
2098
+ const toolName = parsed["tool_name"];
2099
+ const transcriptPath = parsed["transcript_path"];
2100
+ const toolInput = parsed["tool_input"];
2135
2101
  return {
2136
- mode: "fallback",
2137
- kept,
2138
- dropped,
2139
- linesIn: fallback.linesIn,
2140
- linesOut: splitLines(kept).length,
2141
- bytesIn: fallback.bytesIn,
2142
- bytesOut: Buffer.byteLength(kept),
2143
- windows: 0,
2144
- jevRequests: 0,
2145
- jevInputTokens: 0,
2146
- fallbackReason: fallback.reason,
2147
- decisions: fallback.decisions
2102
+ ...typeof toolName === "string" ? { tool_name: toolName } : {},
2103
+ ...typeof transcriptPath === "string" ? { transcript_path: transcriptPath } : {},
2104
+ ...isRecord5(toolInput) ? { tool_input: readToolInput(toolInput) } : {}
2148
2105
  };
2149
2106
  }
2150
- function fallbackReason(error) {
2151
- if (error instanceof JevTimeoutError) return "timeout";
2152
- if (error instanceof JevResponseError) return "invalid response";
2153
- if (error instanceof JevRequestError) {
2154
- switch (error.status) {
2155
- case 429:
2156
- return "rate limited (429)";
2157
- case 529:
2158
- return "overloaded (529)";
2159
- case 401:
2160
- return UNAUTHORIZED_REASON;
2161
- case 400:
2162
- return "bad request (400)";
2163
- case void 0:
2164
- return "network";
2165
- default:
2166
- return error.name;
2107
+ function formatHookOutput(plan) {
2108
+ return JSON.stringify({
2109
+ hookSpecificOutput: {
2110
+ hookEventName: "PreToolUse",
2111
+ updatedInput: { command: plan.command }
2167
2112
  }
2168
- }
2169
- if (error instanceof Error) return error.name;
2170
- return "unknown";
2113
+ });
2171
2114
  }
2172
- function everyLine(lines, mode, text, bytesIn) {
2173
- const decisions = /* @__PURE__ */ new Map();
2174
- for (const line of lines) decisions.set(line.n, { keep: true, reason: mode });
2115
+ function readToolInput(source) {
2116
+ const command = source["command"];
2117
+ const background = source["run_in_background"];
2175
2118
  return {
2176
- mode,
2177
- kept: text,
2178
- dropped: [],
2179
- linesIn: lines.length,
2180
- linesOut: lines.length,
2181
- bytesIn,
2182
- bytesOut: bytesIn,
2183
- windows: 0,
2184
- jevRequests: 0,
2185
- jevInputTokens: 0,
2186
- decisions
2119
+ ...typeof command === "string" ? { command } : {},
2120
+ ...typeof background === "boolean" ? { run_in_background: background } : {}
2187
2121
  };
2188
2122
  }
2123
+ function isRecord5(value) {
2124
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2125
+ }
2126
+ function escapeRegExp(value) {
2127
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2128
+ }
2189
2129
 
2190
- // src/prune.ts
2191
- async function pruneOutput(input) {
2192
- const env = input.env ?? process.env;
2193
- const config = mergeConfig(await loadConfig(env), input.config);
2194
- const client = input.client !== void 0 ? input.client : clientFromEnv(env);
2195
- const runId = newRunId();
2196
- const command = input.command ?? "";
2197
- const store = input.save === false ? null : new RunStore({ home: config.home, retention: config.retention });
2198
- const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2199
- const selection = await selectLines({
2200
- text: input.text,
2201
- task: input.task,
2202
- command,
2203
- exitCode: input.exitCode ?? null,
2204
- client,
2205
- config,
2206
- runId
2207
- });
2208
- const recorded = await recordRun({
2209
- store,
2210
- selection,
2211
- logText: input.text,
2212
- meta: {
2213
- id: runId,
2214
- command,
2215
- argv: [],
2216
- startedAt,
2217
- endedAt: (/* @__PURE__ */ new Date()).toISOString(),
2218
- exitCode: input.exitCode ?? null,
2219
- signal: null,
2220
- bytes: selection.bytesIn,
2221
- lines: selection.linesIn,
2222
- task: input.task
2223
- }
2224
- });
2130
+ // src/io.ts
2131
+ var CLOSED_PIPE_CODES = /* @__PURE__ */ new Set([
2132
+ "EPIPE",
2133
+ "EOF",
2134
+ "ERR_STREAM_DESTROYED",
2135
+ "ERR_STREAM_WRITE_AFTER_END"
2136
+ ]);
2137
+ function processIo() {
2138
+ const write = openWriter(process.stdout);
2225
2139
  return {
2226
- kept: selection.kept,
2227
- dropped: selection.dropped,
2228
- runId,
2229
- mode: selection.mode,
2230
- linesIn: selection.linesIn,
2231
- linesOut: selection.linesOut,
2232
- ...selection.fallbackReason !== void 0 ? { fallbackReason: selection.fallbackReason } : {},
2233
- ...recorded.logPath !== void 0 ? { logPath: recorded.logPath } : {},
2234
- footer: recorded.footer
2140
+ env: process.env,
2141
+ cwd: process.cwd(),
2142
+ stdin: process.stdin,
2143
+ write: (text) => write(text),
2144
+ writeBytes: (bytes) => write(bytes),
2145
+ writeError: openWriter(process.stderr)
2235
2146
  };
2236
2147
  }
2237
- async function recordRun(input) {
2238
- const { store, selection, meta } = input;
2239
- const fastPath = selection.mode === "fast-path";
2240
- const log = input.logBytes ?? (input.logText !== void 0 ? Buffer.from(input.logText, "utf8") : void 0);
2241
- let failureCode = input.storeFailureCode;
2242
- if (store !== null && failureCode === void 0) {
2148
+ async function readStreamBytes(stream) {
2149
+ const chunks = [];
2150
+ for await (const chunk of stream) {
2151
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk);
2152
+ }
2153
+ return Buffer.concat(chunks);
2154
+ }
2155
+ async function readStream(stream) {
2156
+ return (await readStreamBytes(stream)).toString("utf8");
2157
+ }
2158
+ function isClosedPipe(error) {
2159
+ const code = errorCode(error);
2160
+ return code !== void 0 && CLOSED_PIPE_CODES.has(code);
2161
+ }
2162
+ function openWriter(stream) {
2163
+ let closed = false;
2164
+ stream.on("error", (error) => {
2165
+ if (isClosedPipe(error)) closed = true;
2166
+ });
2167
+ return (chunk) => new Promise((resolve2, reject) => {
2168
+ if (closed || chunk.length === 0) {
2169
+ resolve2();
2170
+ return;
2171
+ }
2243
2172
  try {
2244
- if (fastPath) {
2245
- if (log === void 0) await store.discardRun(meta.id);
2246
- } else {
2247
- if (log !== void 0) await writeLog(store, meta.id, log);
2248
- await store.finalizeRun(meta.id, {
2249
- ...meta,
2250
- mode: selection.mode,
2251
- linesOut: selection.linesOut,
2252
- ...selection.fallbackReason !== void 0 ? { fallbackReason: selection.fallbackReason } : {}
2253
- });
2254
- }
2255
- await store.appendGain({
2256
- ts: (/* @__PURE__ */ new Date()).toISOString(),
2257
- id: meta.id,
2258
- mode: selection.mode,
2259
- linesIn: selection.linesIn,
2260
- linesOut: selection.linesOut,
2261
- bytesIn: selection.bytesIn,
2262
- bytesOut: selection.bytesOut,
2263
- ...selection.fallbackReason !== void 0 ? { reason: selection.fallbackReason } : {}
2173
+ stream.write(chunk, (error) => {
2174
+ if (error === void 0 || error === null) resolve2();
2175
+ else if (isClosedPipe(error)) {
2176
+ closed = true;
2177
+ resolve2();
2178
+ } else reject(error);
2264
2179
  });
2265
- await store.enforceRetention();
2266
2180
  } catch (error) {
2267
- if (!(error instanceof RunStoreError)) throw error;
2268
- failureCode = error.code ?? "failed";
2181
+ if (!isClosedPipe(error)) {
2182
+ reject(error instanceof Error ? error : new Error(String(error)));
2183
+ return;
2184
+ }
2185
+ closed = true;
2186
+ resolve2();
2269
2187
  }
2270
- }
2271
- const logPath = store !== null && !fastPath && failureCode === void 0 ? store.logPath(meta.id) : void 0;
2272
- const footer = formatFooter({
2273
- mode: selection.mode,
2274
- linesIn: selection.linesIn,
2275
- linesOut: selection.linesOut,
2276
- exitCode: meta.exitCode,
2277
- ...selection.fallbackReason !== void 0 ? { fallbackReason: selection.fallbackReason } : {},
2278
- ...input.passthroughNote !== void 0 ? { passthroughNote: input.passthroughNote } : {},
2279
- ...failureCode !== void 0 ? { storeFailureCode: failureCode } : {},
2280
- ...logPath !== void 0 ? { logPath } : {}
2281
2188
  });
2282
- return { footer, ...logPath !== void 0 ? { logPath } : {} };
2283
2189
  }
2284
- function clientFromEnv(env) {
2190
+
2191
+ // src/commands/hook.ts
2192
+ async function runHook(io) {
2285
2193
  try {
2286
- return createJevClientFromEnv(env);
2194
+ const input = parsePreToolUse(await readStream(io.stdin));
2195
+ if (input === null) return 0;
2196
+ const plan = planRewrite(input, await loadConfig(io.env));
2197
+ if (plan === null) return 0;
2198
+ await io.write(`${formatHookOutput(plan)}
2199
+ `);
2287
2200
  } catch (error) {
2288
- if (error instanceof JevConfigError) return null;
2289
- throw error;
2201
+ await io.writeError(`jevprune: hook skipped: ${errorMessage(error)}
2202
+ `).catch(() => void 0);
2290
2203
  }
2204
+ return 0;
2291
2205
  }
2292
- function mergeConfig(base, overrides) {
2293
- if (overrides === void 0) return base;
2294
- const defined = {};
2295
- for (const key of Object.keys(overrides)) {
2296
- if (overrides[key] !== void 0) Object.assign(defined, { [key]: overrides[key] });
2206
+
2207
+ // src/notices.ts
2208
+ import { mkdir as mkdir2, open as open2 } from "fs/promises";
2209
+ import { join as join3 } from "path";
2210
+ var MISSING_KEY_MARKER = "missing-key-notice";
2211
+ async function shouldAnnounceMissingKey(home) {
2212
+ const path = join3(home, MISSING_KEY_MARKER);
2213
+ try {
2214
+ await mkdir2(home, { recursive: true, mode: 448 });
2215
+ const handle = await open2(path, "wx", 384);
2216
+ await handle.close();
2217
+ return true;
2218
+ } catch (error) {
2219
+ return errorCode(error) !== "EEXIST";
2297
2220
  }
2298
- return { ...base, ...defined };
2299
- }
2300
- async function writeLog(store, id, bytes) {
2301
- const writer = await store.openRun({ id });
2302
- writer.write(bytes);
2303
- await writer.close();
2304
- if (writer.failure !== void 0) throw writer.failure;
2305
2221
  }
2306
2222
 
2307
- // src/runner.ts
2308
- import { spawn } from "child_process";
2309
- import { constants } from "os";
2310
- var FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
2223
+ // src/capture.ts
2311
2224
  var TAIL_RING_BYTES = 256 * 1024;
2312
2225
  var LF3 = 10;
2313
2226
  var CR2 = 13;
@@ -2384,7 +2297,10 @@ var CaptureBuffer = class {
2384
2297
  this.#trimRing();
2385
2298
  }
2386
2299
  bytes() {
2387
- if (!this.#oversize) return Buffer.concat(this.#head);
2300
+ if (!this.#oversize) {
2301
+ const single = this.#head.length === 1 ? this.#head[0] : void 0;
2302
+ return single ?? Buffer.concat(this.#head);
2303
+ }
2388
2304
  const head = trimToLastTerminator(Buffer.concat(this.#head));
2389
2305
  const tail = trimToFirstLine(Buffer.concat(this.#ring));
2390
2306
  return Buffer.concat([head, tail]);
@@ -2404,15 +2320,202 @@ var CaptureBuffer = class {
2404
2320
  }
2405
2321
  }
2406
2322
  };
2323
+ var OutputCapture = class {
2324
+ #buffer;
2325
+ #counter = new LineCounter();
2326
+ #bytes = 0;
2327
+ constructor(maxBytes) {
2328
+ this.#buffer = new CaptureBuffer(maxBytes);
2329
+ }
2330
+ push(chunk) {
2331
+ this.#bytes += chunk.length;
2332
+ this.#counter.push(chunk);
2333
+ this.#buffer.push(chunk);
2334
+ }
2335
+ result() {
2336
+ return {
2337
+ captured: this.#buffer.bytes(),
2338
+ bytes: this.#bytes,
2339
+ lines: this.#counter.lines,
2340
+ headSegmentLines: this.#buffer.headSegmentLines,
2341
+ oversize: this.#buffer.oversize
2342
+ };
2343
+ }
2344
+ };
2345
+ function captureBytes(bytes, maxBytes) {
2346
+ const capture = new OutputCapture(maxBytes);
2347
+ capture.push(bytes);
2348
+ return capture.result();
2349
+ }
2350
+ function trimToLastTerminator(buffer) {
2351
+ for (let index = buffer.length - 1; index >= 0; index -= 1) {
2352
+ const byte = buffer[index];
2353
+ if (byte === LF3 || byte === CR2) return buffer.subarray(0, index + 1);
2354
+ }
2355
+ return buffer.subarray(0, 0);
2356
+ }
2357
+ function trimToFirstLine(buffer) {
2358
+ for (let index = 0; index < buffer.length; index += 1) {
2359
+ const byte = buffer[index];
2360
+ if (byte === LF3) return buffer.subarray(index + 1);
2361
+ if (byte === CR2) {
2362
+ const next = buffer[index + 1];
2363
+ return buffer.subarray(next === LF3 ? index + 2 : index + 1);
2364
+ }
2365
+ }
2366
+ return Buffer.alloc(0);
2367
+ }
2368
+
2369
+ // src/prune.ts
2370
+ async function pruneOutput(input) {
2371
+ const prepared = await prepare(input);
2372
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2373
+ const bytes = Buffer.from(input.text, "utf8");
2374
+ const capture = captureBytes(bytes, prepared.config.maxPruneBytes);
2375
+ return await selectAndRecord({
2376
+ prepared,
2377
+ capture,
2378
+ text: capture.oversize ? capture.captured.toString("utf8") : input.text,
2379
+ task: input.task,
2380
+ exitCode: input.exitCode ?? null,
2381
+ startedAt,
2382
+ logBytes: bytes
2383
+ });
2384
+ }
2385
+ async function recordRun(input) {
2386
+ const { store, selection, meta } = input;
2387
+ const fastPath = selection.mode === "fast-path";
2388
+ const log = input.logBytes ?? (input.logText !== void 0 ? Buffer.from(input.logText, "utf8") : void 0);
2389
+ let failureCode = input.storeFailureCode;
2390
+ if (store !== null && failureCode === void 0) {
2391
+ try {
2392
+ if (fastPath) {
2393
+ if (log === void 0) await store.discardRun(meta.id);
2394
+ } else {
2395
+ if (log !== void 0) await writeLog(store, meta.id, log);
2396
+ await store.finalizeRun(meta.id, {
2397
+ ...meta,
2398
+ mode: selection.mode,
2399
+ linesOut: selection.linesOut,
2400
+ ...selection.fallbackReason !== void 0 ? { fallbackReason: fallbackReasonText(selection.fallbackReason) } : {}
2401
+ });
2402
+ }
2403
+ await store.appendGain({
2404
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2405
+ id: meta.id,
2406
+ mode: selection.mode,
2407
+ linesIn: selection.linesIn,
2408
+ linesOut: selection.linesOut,
2409
+ bytesIn: selection.bytesIn,
2410
+ bytesOut: selection.bytesOut,
2411
+ ...selection.fallbackReason !== void 0 ? { reason: fallbackReasonText(selection.fallbackReason) } : {}
2412
+ });
2413
+ await store.enforceRetention();
2414
+ } catch (error) {
2415
+ if (!(error instanceof RunStoreError)) throw error;
2416
+ failureCode = error.code ?? "failed";
2417
+ }
2418
+ }
2419
+ const logPath = store !== null && !fastPath && failureCode === void 0 ? store.logPath(meta.id) : void 0;
2420
+ const footer = formatFooter({
2421
+ mode: selection.mode,
2422
+ linesIn: selection.linesIn,
2423
+ linesOut: selection.linesOut,
2424
+ exitCode: meta.exitCode,
2425
+ ...selection.fallbackReason !== void 0 ? { fallbackReason: selection.fallbackReason } : {},
2426
+ ...input.passthroughNote !== void 0 ? { passthroughNote: input.passthroughNote } : {},
2427
+ ...failureCode !== void 0 ? { storeFailureCode: failureCode } : {},
2428
+ ...logPath !== void 0 ? { logPath } : {}
2429
+ });
2430
+ return { footer, ...logPath !== void 0 ? { logPath } : {} };
2431
+ }
2432
+ function clientFromEnv(env) {
2433
+ try {
2434
+ return createJevClientFromEnv(env);
2435
+ } catch (error) {
2436
+ if (error instanceof JevConfigError) return null;
2437
+ throw error;
2438
+ }
2439
+ }
2440
+ function mergeConfig(base, overrides) {
2441
+ if (overrides === void 0) return base;
2442
+ const defined = {};
2443
+ for (const key of Object.keys(overrides)) {
2444
+ if (overrides[key] !== void 0) Object.assign(defined, { [key]: overrides[key] });
2445
+ }
2446
+ return { ...base, ...defined };
2447
+ }
2448
+ async function prepare(input) {
2449
+ const env = input.env ?? process.env;
2450
+ const config = mergeConfig(await loadConfig(env), input.config);
2451
+ return {
2452
+ config,
2453
+ client: input.client !== void 0 ? input.client : clientFromEnv(env),
2454
+ store: input.save === false ? null : new RunStore({ home: config.home, retention: config.retention }),
2455
+ runId: newRunId(),
2456
+ command: input.command ?? ""
2457
+ };
2458
+ }
2459
+ async function selectAndRecord(input) {
2460
+ const { prepared, capture } = input;
2461
+ const selection = await selectLines({
2462
+ text: input.text,
2463
+ task: input.task,
2464
+ command: prepared.command,
2465
+ exitCode: input.exitCode,
2466
+ ...capture.oversize ? { oversize: { lines: capture.lines, headSegmentLines: capture.headSegmentLines } } : {},
2467
+ client: prepared.client,
2468
+ config: prepared.config,
2469
+ runId: prepared.runId
2470
+ });
2471
+ const recorded = await recordRun({
2472
+ store: prepared.store,
2473
+ selection,
2474
+ ...input.logBytes !== void 0 ? { logBytes: input.logBytes } : {},
2475
+ ...input.storeFailureCode !== void 0 ? { storeFailureCode: input.storeFailureCode } : {},
2476
+ meta: {
2477
+ id: prepared.runId,
2478
+ command: prepared.command,
2479
+ argv: [],
2480
+ startedAt: input.startedAt,
2481
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
2482
+ exitCode: input.exitCode,
2483
+ signal: null,
2484
+ bytes: capture.bytes,
2485
+ lines: selection.linesIn,
2486
+ task: input.task
2487
+ }
2488
+ });
2489
+ return {
2490
+ kept: selection.kept,
2491
+ dropped: selection.dropped,
2492
+ runId: prepared.runId,
2493
+ mode: selection.mode,
2494
+ linesIn: selection.linesIn,
2495
+ linesOut: selection.linesOut,
2496
+ ...selection.fallbackReason !== void 0 ? { fallbackReason: selection.fallbackReason } : {},
2497
+ ...recorded.logPath !== void 0 ? { logPath: recorded.logPath } : {},
2498
+ footer: recorded.footer
2499
+ };
2500
+ }
2501
+ async function writeLog(store, id, bytes) {
2502
+ const writer = await store.openRun({ id });
2503
+ writer.write(bytes);
2504
+ await writer.close();
2505
+ if (writer.failure !== void 0) throw writer.failure;
2506
+ }
2507
+
2508
+ // src/runner.ts
2509
+ import { spawn } from "child_process";
2510
+ import { constants } from "os";
2511
+ var FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
2407
2512
  async function runCommand(input) {
2408
2513
  const executable = input.argv[0];
2409
2514
  if (executable === void 0 || executable.length === 0) {
2410
2515
  throw new UsageError("run needs a command to execute");
2411
2516
  }
2412
2517
  const writer = await openWriter2(input);
2413
- const buffer = new CaptureBuffer(input.maxPruneBytes);
2414
- const counter = new LineCounter();
2415
- let bytes = 0;
2518
+ const capture = new OutputCapture(input.maxPruneBytes);
2416
2519
  let storeFailure = writer.failure;
2417
2520
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2418
2521
  const child = spawn(executable, input.argv.slice(1), {
@@ -2423,9 +2526,7 @@ async function runCommand(input) {
2423
2526
  });
2424
2527
  let paused = false;
2425
2528
  const onChunk = (chunk) => {
2426
- bytes += chunk.length;
2427
- counter.push(chunk);
2428
- buffer.push(chunk);
2529
+ capture.push(chunk);
2429
2530
  const ready = writer.write(chunk);
2430
2531
  if (ready || paused) return;
2431
2532
  paused = true;
@@ -2452,17 +2553,13 @@ async function runCommand(input) {
2452
2553
  await writer.close();
2453
2554
  }
2454
2555
  storeFailure = writer.failure ?? storeFailure;
2455
- const captured = buffer.bytes();
2556
+ const output = capture.result();
2456
2557
  return {
2457
- captured,
2458
- validUtf8: isValidUtf8(captured),
2459
- bytes,
2460
- lines: counter.lines,
2461
- headSegmentLines: buffer.headSegmentLines,
2558
+ ...output,
2559
+ validUtf8: isValidUtf8(output.captured),
2462
2560
  exitCode: exitCodeOf(exit),
2463
2561
  signal: exit.signal,
2464
2562
  interrupted: receivedSignal || exit.signal !== null,
2465
- oversize: buffer.oversize,
2466
2563
  startedAt,
2467
2564
  endedAt: (/* @__PURE__ */ new Date()).toISOString(),
2468
2565
  ...storeFailure !== void 0 ? { storeFailure } : {}
@@ -2525,24 +2622,6 @@ function exitCodeOf(exit) {
2525
2622
  if (exit.signal !== null) return 128 + (constants.signals[exit.signal] ?? 0);
2526
2623
  return 0;
2527
2624
  }
2528
- function trimToLastTerminator(buffer) {
2529
- for (let index = buffer.length - 1; index >= 0; index -= 1) {
2530
- const byte = buffer[index];
2531
- if (byte === LF3 || byte === CR2) return buffer.subarray(0, index + 1);
2532
- }
2533
- return buffer.subarray(0, 0);
2534
- }
2535
- function trimToFirstLine(buffer) {
2536
- for (let index = 0; index < buffer.length; index += 1) {
2537
- const byte = buffer[index];
2538
- if (byte === LF3) return buffer.subarray(index + 1);
2539
- if (byte === CR2) {
2540
- const next = buffer[index + 1];
2541
- return buffer.subarray(next === LF3 ? index + 2 : index + 1);
2542
- }
2543
- }
2544
- return Buffer.alloc(0);
2545
- }
2546
2625
 
2547
2626
  // src/task.ts
2548
2627
  import { readFile as readFile3 } from "fs/promises";
@@ -2665,7 +2744,8 @@ async function runRun(options, io) {
2665
2744
  config,
2666
2745
  runId
2667
2746
  });
2668
- if (options.hook === true && selection.fallbackReason === UNAUTHORIZED_REASON) {
2747
+ const reason = selection.fallbackReason;
2748
+ if (options.hook === true && reason?.kind === "unavailable" && reason.detail === UNAUTHORIZED_REASON) {
2669
2749
  await io.writeError(`jevprune: ${TYPESAFE_API_KEY_ENV} rejected (401), using fallback
2670
2750
  `).catch(() => void 0);
2671
2751
  }
@@ -2707,9 +2787,9 @@ async function passThrough(input, io) {
2707
2787
  lastByte = capture.captured.at(-1);
2708
2788
  complete = !capture.oversize;
2709
2789
  }
2710
- const oversizeReason = `output over ${String(input.maxPruneBytes)} bytes`;
2711
- const reason = capture.validUtf8 ? complete ? void 0 : oversizeReason : NOT_UTF8_REASON;
2712
- const note = capture.validUtf8 ? reason : NOT_UTF8_NOTE;
2790
+ const oversizeReason = { kind: "size-limit", maxBytes: input.maxPruneBytes };
2791
+ const reason = capture.validUtf8 ? complete ? void 0 : oversizeReason : { kind: "not-utf8" };
2792
+ const note = capture.validUtf8 ? reason === void 0 ? void 0 : fallbackReasonText(reason) : NOT_UTF8_NOTE;
2713
2793
  try {
2714
2794
  const { footer } = await recordRun({
2715
2795
  store: input.store,
@@ -2768,7 +2848,7 @@ async function passThrough2(input, io) {
2768
2848
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2769
2849
  const { footer } = await recordRun({
2770
2850
  store,
2771
- selection: passthroughSelection({ bytes: input.bytes.length, lines, reason: NOT_UTF8_REASON }),
2851
+ selection: passthroughSelection({ bytes: input.bytes.length, lines, reason: { kind: "not-utf8" } }),
2772
2852
  logBytes: input.bytes,
2773
2853
  passthroughNote: NOT_UTF8_NOTE,
2774
2854
  meta: {
@@ -2815,19 +2895,26 @@ async function runShow(options, io) {
2815
2895
  }
2816
2896
 
2817
2897
  // src/version.ts
2818
- var VERSION2 = "0.1.0";
2898
+ var VERSION2 = "0.1.1";
2819
2899
 
2820
2900
  // src/cli.ts
2821
2901
  var HELP = `usage: jevprune <command> [options]
2822
2902
 
2823
2903
  commands:
2824
2904
  run [--task <text>] [--threshold <n>] [--hook] [--transcript <path>] -- <command> [args...]
2905
+ runs the command, prints the kept lines and exits with the command's exit code
2825
2906
  select [--task <text>] [--threshold <n>] [--file <path>] [--command <text>]
2907
+ prunes a local file or stdin and reports only its own exit status
2826
2908
  show <id> [--lines A-B]
2909
+ prints a saved run, or one line range of it, exactly as it was captured
2827
2910
  gain
2911
+ totals the locally recorded runs and estimates the output tokens removed
2828
2912
  hook
2913
+ reads a plugin PreToolUse event on stdin and answers it
2829
2914
 
2830
2915
  options:
2916
+ --task <text> the task the kept lines have to serve
2917
+ --threshold <n> minimum Jev score to keep a line, 0 to 1
2831
2918
  --help
2832
2919
  --version
2833
2920
  `;