reasonix 0.12.16 → 0.12.20

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/index.js CHANGED
@@ -548,8 +548,8 @@ var defaultSelector = (samples) => {
548
548
  })[0];
549
549
  };
550
550
  async function runBranches(client, request, opts = {}) {
551
- const budget = Math.max(1, opts.budget ?? 1);
552
- const temperatures = resolveTemperatures(budget, opts.temperatures);
551
+ const budget2 = Math.max(1, opts.budget ?? 1);
552
+ const temperatures = resolveTemperatures(budget2, opts.temperatures);
553
553
  const selector = opts.selector ?? defaultSelector;
554
554
  const samples = await Promise.all(
555
555
  temperatures.map(async (temperature, index) => {
@@ -586,12 +586,12 @@ function aggregateBranchUsage(samples) {
586
586
  promptCacheMissTokens
587
587
  };
588
588
  }
589
- function resolveTemperatures(budget, custom) {
590
- if (custom && custom.length >= budget) return [...custom.slice(0, budget)];
591
- if (budget === 1) return [0];
589
+ function resolveTemperatures(budget2, custom) {
590
+ if (custom && custom.length >= budget2) return [...custom.slice(0, budget2)];
591
+ if (budget2 === 1) return [0];
592
592
  const out = [];
593
- for (let i = 0; i < budget; i++) {
594
- out.push(Number((i / (budget - 1)).toFixed(2)));
593
+ for (let i = 0; i < budget2; i++) {
594
+ out.push(Number((i / (budget2 - 1)).toFixed(2)));
595
595
  }
596
596
  return out;
597
597
  }
@@ -666,6 +666,7 @@ function matchesTool(hook, toolName) {
666
666
  return false;
667
667
  }
668
668
  }
669
+ var HOOK_OUTPUT_CAP_BYTES = 256 * 1024;
669
670
  function defaultSpawner(input) {
670
671
  return new Promise((resolve13) => {
671
672
  const child = spawn(input.command, {
@@ -673,8 +674,11 @@ function defaultSpawner(input) {
673
674
  shell: true,
674
675
  stdio: ["pipe", "pipe", "pipe"]
675
676
  });
676
- let stdout3 = "";
677
- let stderr = "";
677
+ const stdoutChunks = [];
678
+ const stderrChunks = [];
679
+ let stdoutBytes = 0;
680
+ let stderrBytes = 0;
681
+ let truncated = false;
678
682
  let timedOut = false;
679
683
  const timer = setTimeout(() => {
680
684
  timedOut = true;
@@ -686,29 +690,46 @@ function defaultSpawner(input) {
686
690
  }
687
691
  }, 500);
688
692
  }, input.timeoutMs);
689
- child.stdout.on("data", (chunk) => {
690
- stdout3 += chunk.toString("utf8");
691
- });
692
- child.stderr.on("data", (chunk) => {
693
- stderr += chunk.toString("utf8");
694
- });
693
+ const onChunk = (kind, chunk) => {
694
+ const target = kind === "stdout" ? stdoutChunks : stderrChunks;
695
+ const seen = kind === "stdout" ? stdoutBytes : stderrBytes;
696
+ if (seen >= HOOK_OUTPUT_CAP_BYTES) {
697
+ truncated = true;
698
+ return;
699
+ }
700
+ const remaining = HOOK_OUTPUT_CAP_BYTES - seen;
701
+ if (chunk.length > remaining) {
702
+ target.push(chunk.subarray(0, remaining));
703
+ if (kind === "stdout") stdoutBytes = HOOK_OUTPUT_CAP_BYTES;
704
+ else stderrBytes = HOOK_OUTPUT_CAP_BYTES;
705
+ truncated = true;
706
+ } else {
707
+ target.push(chunk);
708
+ if (kind === "stdout") stdoutBytes += chunk.length;
709
+ else stderrBytes += chunk.length;
710
+ }
711
+ };
712
+ child.stdout.on("data", (chunk) => onChunk("stdout", chunk));
713
+ child.stderr.on("data", (chunk) => onChunk("stderr", chunk));
695
714
  child.once("error", (err) => {
696
715
  clearTimeout(timer);
697
716
  resolve13({
698
717
  exitCode: null,
699
- stdout: stdout3,
700
- stderr,
718
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
719
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
701
720
  timedOut: false,
702
- spawnError: err
721
+ spawnError: err,
722
+ truncated: truncated || void 0
703
723
  });
704
724
  });
705
725
  child.once("close", (code) => {
706
726
  clearTimeout(timer);
707
727
  resolve13({
708
728
  exitCode: code,
709
- stdout: stdout3.trim(),
710
- stderr: stderr.trim(),
711
- timedOut
729
+ stdout: Buffer.concat(stdoutChunks).toString("utf8").trim(),
730
+ stderr: Buffer.concat(stderrChunks).toString("utf8").trim(),
731
+ timedOut,
732
+ truncated: truncated || void 0
712
733
  });
713
734
  });
714
735
  try {
@@ -723,7 +744,8 @@ function formatHookOutcomeMessage(outcome) {
723
744
  const detail = (outcome.stderr || outcome.stdout || "").trim();
724
745
  const tag = `${outcome.hook.scope}/${outcome.hook.event}`;
725
746
  const cmd = outcome.hook.command.length > 60 ? `${outcome.hook.command.slice(0, 60)}\u2026` : outcome.hook.command;
726
- const head = `hook ${tag} \`${cmd}\` ${outcome.decision}`;
747
+ const truncTag = outcome.truncated ? " (output truncated at 256KB)" : "";
748
+ const head = `hook ${tag} \`${cmd}\` ${outcome.decision}${truncTag}`;
727
749
  return detail ? `${head}: ${detail}` : head;
728
750
  }
729
751
  function decideOutcome(event, raw) {
@@ -754,7 +776,8 @@ async function runHooks(opts) {
754
776
  exitCode: raw.exitCode,
755
777
  stdout: raw.stdout,
756
778
  stderr: raw.stderr || (raw.spawnError ? raw.spawnError.message : "") || (raw.timedOut ? `hook timed out after ${timeoutMs}ms` : ""),
757
- durationMs: Date.now() - start
779
+ durationMs: Date.now() - start,
780
+ truncated: raw.truncated
758
781
  });
759
782
  if (decision === "block") {
760
783
  blocked = true;
@@ -1253,29 +1276,29 @@ function truncateForModelByTokens(s, maxTokens) {
1253
1276
 
1254
1277
  ${tail}`;
1255
1278
  }
1256
- function sizePrefixToTokens(s, budget) {
1257
- if (budget <= 0 || s.length === 0) return "";
1258
- let size = Math.min(s.length, budget * 4);
1279
+ function sizePrefixToTokens(s, budget2) {
1280
+ if (budget2 <= 0 || s.length === 0) return "";
1281
+ let size = Math.min(s.length, budget2 * 4);
1259
1282
  for (let iter = 0; iter < 6; iter++) {
1260
1283
  if (size <= 0) return "";
1261
1284
  const slice = s.slice(0, size);
1262
1285
  const count = countTokens(slice);
1263
- if (count <= budget) return slice;
1264
- const next = Math.floor(size * (budget / count) * 0.95);
1286
+ if (count <= budget2) return slice;
1287
+ const next = Math.floor(size * (budget2 / count) * 0.95);
1265
1288
  if (next >= size) return s.slice(0, Math.max(0, size - 1));
1266
1289
  size = next;
1267
1290
  }
1268
1291
  return s.slice(0, Math.max(0, size));
1269
1292
  }
1270
- function sizeSuffixToTokens(s, budget) {
1271
- if (budget <= 0 || s.length === 0) return "";
1272
- let size = Math.min(s.length, budget * 4);
1293
+ function sizeSuffixToTokens(s, budget2) {
1294
+ if (budget2 <= 0 || s.length === 0) return "";
1295
+ let size = Math.min(s.length, budget2 * 4);
1273
1296
  for (let iter = 0; iter < 6; iter++) {
1274
1297
  if (size <= 0) return "";
1275
1298
  const slice = s.slice(-size);
1276
1299
  const count = countTokens(slice);
1277
- if (count <= budget) return slice;
1278
- const next = Math.floor(size * (budget / count) * 0.95);
1300
+ if (count <= budget2) return slice;
1301
+ const next = Math.floor(size * (budget2 / count) * 0.95);
1279
1302
  if (next >= size) return s.slice(-Math.max(0, size - 1));
1280
1303
  size = next;
1281
1304
  }
@@ -1302,6 +1325,23 @@ var ImmutablePrefix = class {
1302
1325
  */
1303
1326
  _toolSpecs;
1304
1327
  fewShots;
1328
+ /**
1329
+ * Cached SHA-256 of the prefix payload. Computed lazily on first
1330
+ * `fingerprint` access, invalidated only by mutations that go
1331
+ * through `addTool` (the one legitimate post-construction mutation
1332
+ * path). The TUI reads `fingerprint` on every render — without the
1333
+ * cache, that means a fresh `JSON.stringify` + sha256 over the
1334
+ * full prefix (system prompt + tools list + few-shots, typically
1335
+ * 5-10KB) on every keystroke.
1336
+ *
1337
+ * The lazy-init also acts as a cheap drift guard: if some future
1338
+ * code path mutates `_toolSpecs` directly without going through
1339
+ * `addTool`, `fingerprint` will return the stale cached value
1340
+ * while the actual prefix sent to DeepSeek diverges — the cache
1341
+ * miss would be the first symptom. {@link verifyFingerprint}
1342
+ * lets dev / test code assert the cache matches reality.
1343
+ */
1344
+ _fingerprintCache = null;
1305
1345
  constructor(opts) {
1306
1346
  this.system = opts.system;
1307
1347
  this._toolSpecs = [...opts.toolSpecs ?? []];
@@ -1327,9 +1367,33 @@ var ImmutablePrefix = class {
1327
1367
  if (!name) return false;
1328
1368
  if (this._toolSpecs.some((t2) => t2.function?.name === name)) return false;
1329
1369
  this._toolSpecs.push(spec);
1370
+ this._fingerprintCache = null;
1330
1371
  return true;
1331
1372
  }
1332
1373
  get fingerprint() {
1374
+ if (this._fingerprintCache !== null) return this._fingerprintCache;
1375
+ this._fingerprintCache = this.computeFingerprint();
1376
+ return this._fingerprintCache;
1377
+ }
1378
+ /**
1379
+ * Recompute the fingerprint from scratch and assert it matches the
1380
+ * cached value. Returns the freshly-computed hash on success; throws
1381
+ * with a diff if the cache drifted, which always indicates a bug —
1382
+ * either a non-`addTool` mutation path was added, or `addTool`
1383
+ * forgot to invalidate the cache. Dev / test only; the live loop
1384
+ * doesn't call this on the hot path.
1385
+ */
1386
+ verifyFingerprint() {
1387
+ const fresh = this.computeFingerprint();
1388
+ if (this._fingerprintCache !== null && this._fingerprintCache !== fresh) {
1389
+ throw new Error(
1390
+ `ImmutablePrefix fingerprint drift: cached=${this._fingerprintCache}, fresh=${fresh}. A mutation path bypassed addTool's cache invalidation \u2014 DeepSeek will see prefix churn that the TUI / transcript log don't know about.`
1391
+ );
1392
+ }
1393
+ this._fingerprintCache = fresh;
1394
+ return fresh;
1395
+ }
1396
+ computeFingerprint() {
1333
1397
  const blob = JSON.stringify({
1334
1398
  system: this.system,
1335
1399
  tools: this._toolSpecs,
@@ -1748,15 +1812,25 @@ function listSessions() {
1748
1812
  const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
1749
1813
  return files.map((file) => {
1750
1814
  const path5 = join4(dir, file);
1751
- const stat = statSync(path5);
1815
+ const stat2 = statSync(path5);
1752
1816
  const name = file.replace(/\.jsonl$/, "");
1753
1817
  const messageCount = countLines(path5);
1754
- return { name, path: path5, size: stat.size, messageCount, mtime: stat.mtime };
1818
+ return { name, path: path5, size: stat2.size, messageCount, mtime: stat2.mtime };
1755
1819
  }).sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
1756
1820
  } catch {
1757
1821
  return [];
1758
1822
  }
1759
1823
  }
1824
+ function pruneStaleSessions(daysOld = 90) {
1825
+ const cutoff = Date.now() - daysOld * 24 * 60 * 60 * 1e3;
1826
+ const deleted = [];
1827
+ for (const s of listSessions()) {
1828
+ if (s.mtime.getTime() < cutoff) {
1829
+ if (deleteSession(s.name)) deleted.push(s.name);
1830
+ }
1831
+ }
1832
+ return deleted;
1833
+ }
1760
1834
  function deleteSession(name) {
1761
1835
  const path5 = sessionPath(name);
1762
1836
  try {
@@ -1925,6 +1999,19 @@ var CacheFirstLoop = class {
1925
1999
  * flip it live alongside `model`.
1926
2000
  */
1927
2001
  autoEscalate = true;
2002
+ /**
2003
+ * Soft USD budget — see {@link CacheFirstLoopOptions.budgetUsd}.
2004
+ * Mutable so `/budget` slash can set / change / clear it mid-session.
2005
+ * `null` (the default) disables all budget checks.
2006
+ */
2007
+ budgetUsd;
2008
+ /**
2009
+ * Set the first time a turn crosses 80% of the budget so the warning
2010
+ * doesn't repeat every turn afterwards. Cleared by `setBudget` (any
2011
+ * change re-arms the warning, including raising the cap above the
2012
+ * current spend).
2013
+ */
2014
+ _budgetWarned = false;
1928
2015
  sessionName;
1929
2016
  /**
1930
2017
  * Hook list, mutable so `/hooks reload` can swap it without
@@ -1990,6 +2077,7 @@ var CacheFirstLoop = class {
1990
2077
  this.model = opts.model ?? "deepseek-v4-flash";
1991
2078
  this.reasoningEffort = opts.reasoningEffort ?? "max";
1992
2079
  if (opts.autoEscalate !== void 0) this.autoEscalate = opts.autoEscalate;
2080
+ this.budgetUsd = typeof opts.budgetUsd === "number" && opts.budgetUsd > 0 ? opts.budgetUsd : null;
1993
2081
  this.maxToolIters = opts.maxToolIters ?? 64;
1994
2082
  this.hooks = opts.hooks ?? [];
1995
2083
  this.hookCwd = opts.hookCwd ?? process.cwd();
@@ -2229,6 +2317,16 @@ var CacheFirstLoop = class {
2229
2317
  }
2230
2318
  this.stream = this.branchEnabled ? false : this._streamPreference;
2231
2319
  }
2320
+ /**
2321
+ * Set / change / clear the soft USD budget. `null` (or any non-
2322
+ * positive number) disables the cap entirely. Re-arms the 80%
2323
+ * warning so a user who bumps the cap mid-session sees a fresh
2324
+ * threshold message at the new boundary.
2325
+ */
2326
+ setBudget(usd) {
2327
+ this.budgetUsd = typeof usd === "number" && usd > 0 ? usd : null;
2328
+ this._budgetWarned = false;
2329
+ }
2232
2330
  /**
2233
2331
  * Arm pro for the next turn (consumed at turn start). Called by
2234
2332
  * `/pro`. Idempotent — repeated calls stay armed, `disarmPro()`
@@ -2390,6 +2488,26 @@ var CacheFirstLoop = class {
2390
2488
  return userText;
2391
2489
  }
2392
2490
  async *step(userInput) {
2491
+ if (this.budgetUsd !== null) {
2492
+ const spent = this.stats.totalCost;
2493
+ if (spent >= this.budgetUsd) {
2494
+ yield {
2495
+ turn: this._turn,
2496
+ role: "error",
2497
+ content: "",
2498
+ error: `session budget exhausted \u2014 spent $${spent.toFixed(4)} \u2265 cap $${this.budgetUsd.toFixed(2)}. Bump the cap with /budget <usd>, clear it with /budget off, or end the session.`
2499
+ };
2500
+ return;
2501
+ }
2502
+ if (!this._budgetWarned && spent >= this.budgetUsd * 0.8) {
2503
+ this._budgetWarned = true;
2504
+ yield {
2505
+ turn: this._turn,
2506
+ role: "warning",
2507
+ content: `\u25B2 budget 80% used \u2014 $${spent.toFixed(4)} of $${this.budgetUsd.toFixed(2)}. Next turn or two likely trips the cap.`
2508
+ };
2509
+ }
2510
+ }
2393
2511
  this._turn++;
2394
2512
  this.scratch.reset();
2395
2513
  this.repair.resetStorm();
@@ -2486,14 +2604,14 @@ var CacheFirstLoop = class {
2486
2604
  let preHarvestedPlanState;
2487
2605
  try {
2488
2606
  if (this.branchEnabled) {
2489
- const budget = this.branchOptions.budget ?? 1;
2607
+ const budget2 = this.branchOptions.budget ?? 1;
2490
2608
  yield {
2491
2609
  turn: this._turn,
2492
2610
  role: "branch_start",
2493
2611
  content: "",
2494
2612
  branchProgress: {
2495
2613
  completed: 0,
2496
- total: budget,
2614
+ total: budget2,
2497
2615
  latestIndex: -1,
2498
2616
  latestTemperature: -1,
2499
2617
  latestUncertainties: -1
@@ -2527,7 +2645,7 @@ var CacheFirstLoop = class {
2527
2645
  onSampleDone
2528
2646
  }
2529
2647
  );
2530
- for (let k = 0; k < budget; k++) {
2648
+ for (let k = 0; k < budget2; k++) {
2531
2649
  const sample = queue.shift() ?? await new Promise((resolve13) => {
2532
2650
  waiter = resolve13;
2533
2651
  });
@@ -2537,7 +2655,7 @@ var CacheFirstLoop = class {
2537
2655
  content: "",
2538
2656
  branchProgress: {
2539
2657
  completed: k + 1,
2540
- total: budget,
2658
+ total: budget2,
2541
2659
  latestIndex: sample.index,
2542
2660
  latestTemperature: sample.temperature,
2543
2661
  latestUncertainties: sample.planState.uncertainties.length
@@ -3252,6 +3370,7 @@ function extractDeepSeekErrorMessage(body) {
3252
3370
 
3253
3371
  // src/at-mentions.ts
3254
3372
  import { existsSync as existsSync4, readFileSync as readFileSync5, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
3373
+ import { readdir, stat } from "fs/promises";
3255
3374
  import { isAbsolute, join as join5, relative, resolve } from "path";
3256
3375
  var DEFAULT_AT_MENTION_MAX_BYTES = 64 * 1024;
3257
3376
  var DEFAULT_PICKER_IGNORE_DIRS = [
@@ -3306,6 +3425,58 @@ function listFilesWithStatsSync(root, opts = {}) {
3306
3425
  walk3(rootAbs, "");
3307
3426
  return out;
3308
3427
  }
3428
+ async function listFilesWithStatsAsync(root, opts = {}) {
3429
+ const maxResults = Math.max(1, opts.maxResults ?? 500);
3430
+ const ignore = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
3431
+ const rootAbs = resolve(root);
3432
+ const out = [];
3433
+ const walk3 = async (dirAbs, dirRel) => {
3434
+ if (out.length >= maxResults) return;
3435
+ let entries;
3436
+ try {
3437
+ entries = await readdir(dirAbs, { withFileTypes: true });
3438
+ } catch {
3439
+ return;
3440
+ }
3441
+ entries.sort((a, b) => a.name.localeCompare(b.name));
3442
+ const fileEnts = [];
3443
+ for (const ent of entries) {
3444
+ if (out.length >= maxResults) break;
3445
+ if (ent.isDirectory()) {
3446
+ if (ent.name.startsWith(".") || ignore.has(ent.name)) continue;
3447
+ if (fileEnts.length > 0) {
3448
+ await statBatch(fileEnts, dirAbs, dirRel, out, maxResults);
3449
+ fileEnts.length = 0;
3450
+ if (out.length >= maxResults) return;
3451
+ }
3452
+ await walk3(join5(dirAbs, ent.name), dirRel ? `${dirRel}/${ent.name}` : ent.name);
3453
+ } else if (ent.isFile()) {
3454
+ fileEnts.push(ent);
3455
+ }
3456
+ }
3457
+ if (fileEnts.length > 0 && out.length < maxResults) {
3458
+ await statBatch(fileEnts, dirAbs, dirRel, out, maxResults);
3459
+ }
3460
+ };
3461
+ await walk3(rootAbs, "");
3462
+ return out;
3463
+ }
3464
+ async function statBatch(ents, dirAbs, dirRel, out, maxResults) {
3465
+ const remaining = Math.max(0, maxResults - out.length);
3466
+ const batch = ents.slice(0, remaining);
3467
+ const stats2 = await Promise.all(
3468
+ batch.map(
3469
+ (e) => stat(join5(dirAbs, e.name)).then((s) => s.mtimeMs).catch(() => 0)
3470
+ )
3471
+ );
3472
+ for (let i = 0; i < batch.length; i++) {
3473
+ const ent = batch[i];
3474
+ out.push({
3475
+ path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
3476
+ mtimeMs: stats2[i] ?? 0
3477
+ });
3478
+ }
3479
+ }
3309
3480
  var AT_PICKER_PREFIX = /(?:^|\s)@([a-zA-Z0-9_./\\-]*)$/;
3310
3481
  function detectAtPicker(input) {
3311
3482
  const m = AT_PICKER_PREFIX.exec(input);
@@ -3666,8 +3837,8 @@ When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_L
3666
3837
  },
3667
3838
  fn: async (args) => {
3668
3839
  const abs = safePath(args.path);
3669
- const stat = await fs.stat(abs);
3670
- if (stat.isDirectory()) {
3840
+ const stat2 = await fs.stat(abs);
3841
+ if (stat2.isDirectory()) {
3671
3842
  throw new Error(`not a file: ${args.path} (it's a directory)`);
3672
3843
  }
3673
3844
  const raw = await fs.readFile(abs);
@@ -3936,13 +4107,13 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
3936
4107
  if (nameFilter && !e.name.toLowerCase().includes(nameFilter)) continue;
3937
4108
  if (isLikelyBinaryByName(e.name)) continue;
3938
4109
  const full = pathMod.join(dir, e.name);
3939
- let stat;
4110
+ let stat2;
3940
4111
  try {
3941
- stat = await fs.stat(full);
4112
+ stat2 = await fs.stat(full);
3942
4113
  } catch {
3943
4114
  continue;
3944
4115
  }
3945
- if (stat.size > 2 * 1024 * 1024) continue;
4116
+ if (stat2.size > 2 * 1024 * 1024) continue;
3946
4117
  let raw;
3947
4118
  try {
3948
4119
  raw = await fs.readFile(full);
@@ -4923,6 +5094,8 @@ var JobRegistry = class {
4923
5094
  };
4924
5095
  this.jobs.set(id, job);
4925
5096
  let readyMatched = false;
5097
+ let recentForReady = "";
5098
+ const READY_WINDOW = 1024;
4926
5099
  const onData = (chunk) => {
4927
5100
  const s = chunk.toString();
4928
5101
  job.totalBytesWritten += s.length;
@@ -4935,8 +5108,9 @@ var JobRegistry = class {
4935
5108
  ${job.output.slice(start)}`;
4936
5109
  }
4937
5110
  if (!readyMatched) {
5111
+ recentForReady = (recentForReady + s).slice(-READY_WINDOW);
4938
5112
  for (const re of READY_SIGNALS) {
4939
- if (re.test(s) || re.test(job.output)) {
5113
+ if (re.test(recentForReady)) {
4940
5114
  readyMatched = true;
4941
5115
  job.signalReady();
4942
5116
  break;
@@ -5620,6 +5794,7 @@ ${r.output}` : header2;
5620
5794
  var DEFAULT_FETCH_MAX_CHARS = 32e3;
5621
5795
  var DEFAULT_FETCH_TIMEOUT_MS = 15e3;
5622
5796
  var DEFAULT_TOPK = 5;
5797
+ var FETCH_MAX_BYTES = 10 * 1024 * 1024;
5623
5798
  var USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
5624
5799
  var MOJEEK_ENDPOINT = "https://www.mojeek.com/search";
5625
5800
  async function webSearch(query, opts = {}) {
@@ -5699,7 +5874,13 @@ async function webFetch(url, opts = {}) {
5699
5874
  }
5700
5875
  if (!resp.ok) throw new Error(`web_fetch ${resp.status} for ${url}`);
5701
5876
  const contentType = resp.headers.get("content-type") ?? "";
5702
- const raw = await resp.text();
5877
+ const declaredLen = Number(resp.headers.get("content-length") ?? "");
5878
+ if (Number.isFinite(declaredLen) && declaredLen > FETCH_MAX_BYTES) {
5879
+ throw new Error(
5880
+ `web_fetch refused: content-length ${declaredLen} bytes exceeds ${FETCH_MAX_BYTES}-byte cap (${url})`
5881
+ );
5882
+ }
5883
+ const raw = await readBodyCapped(resp, FETCH_MAX_BYTES);
5703
5884
  const title = extractTitle(raw);
5704
5885
  const text = contentType.includes("text/html") ? htmlToText(raw) : raw;
5705
5886
  const truncated = text.length > maxChars;
@@ -5708,6 +5889,37 @@ async function webFetch(url, opts = {}) {
5708
5889
  [\u2026 truncated ${text.length - maxChars} chars \u2026]` : text;
5709
5890
  return { url, title, text: finalText, truncated };
5710
5891
  }
5892
+ async function readBodyCapped(resp, maxBytes) {
5893
+ if (!resp.body) return await resp.text();
5894
+ const reader = resp.body.getReader();
5895
+ const decoder = new TextDecoder("utf-8");
5896
+ let total = 0;
5897
+ let out = "";
5898
+ try {
5899
+ while (true) {
5900
+ const { value, done } = await reader.read();
5901
+ if (done) break;
5902
+ total += value.byteLength;
5903
+ if (total > maxBytes) {
5904
+ try {
5905
+ await reader.cancel();
5906
+ } catch {
5907
+ }
5908
+ throw new Error(
5909
+ `web_fetch refused: response body exceeded ${maxBytes}-byte cap (${total} bytes seen)`
5910
+ );
5911
+ }
5912
+ out += decoder.decode(value, { stream: true });
5913
+ }
5914
+ out += decoder.decode();
5915
+ } finally {
5916
+ try {
5917
+ reader.releaseLock();
5918
+ } catch {
5919
+ }
5920
+ }
5921
+ return out;
5922
+ }
5711
5923
  function htmlToText(html) {
5712
5924
  let s = html;
5713
5925
  s = s.replace(/<script[\s\S]*?<\/script>/gi, "");
@@ -6352,6 +6564,107 @@ function truncate(s, n) {
6352
6564
  return s.length > n ? `${s.slice(0, n)}\u2026` : s;
6353
6565
  }
6354
6566
 
6567
+ // src/version.ts
6568
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
6569
+ import { homedir as homedir4 } from "os";
6570
+ import { dirname as dirname5, join as join7 } from "path";
6571
+ import { fileURLToPath as fileURLToPath2 } from "url";
6572
+ var REGISTRY_URL = "https://registry.npmjs.org/reasonix/latest";
6573
+ var LATEST_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
6574
+ var LATEST_FETCH_TIMEOUT_MS = 2e3;
6575
+ function readPackageVersion() {
6576
+ try {
6577
+ let dir = dirname5(fileURLToPath2(import.meta.url));
6578
+ for (let i = 0; i < 6; i++) {
6579
+ const p = join7(dir, "package.json");
6580
+ if (existsSync6(p)) {
6581
+ const pkg = JSON.parse(readFileSync8(p, "utf8"));
6582
+ if (pkg?.name === "reasonix" && typeof pkg.version === "string") {
6583
+ return pkg.version;
6584
+ }
6585
+ }
6586
+ const parent = dirname5(dir);
6587
+ if (parent === dir) break;
6588
+ dir = parent;
6589
+ }
6590
+ } catch {
6591
+ }
6592
+ return "0.0.0-dev";
6593
+ }
6594
+ var VERSION = readPackageVersion();
6595
+ function cachePath(homeDirOverride) {
6596
+ return join7(homeDirOverride ?? homedir4(), ".reasonix", "version-cache.json");
6597
+ }
6598
+ function readCache(homeDirOverride) {
6599
+ try {
6600
+ const raw = readFileSync8(cachePath(homeDirOverride), "utf8");
6601
+ const parsed = JSON.parse(raw);
6602
+ if (parsed && typeof parsed.version === "string" && typeof parsed.checkedAt === "number") {
6603
+ return parsed;
6604
+ }
6605
+ } catch {
6606
+ }
6607
+ return null;
6608
+ }
6609
+ function writeCache(entry, homeDirOverride) {
6610
+ try {
6611
+ const p = cachePath(homeDirOverride);
6612
+ mkdirSync3(dirname5(p), { recursive: true });
6613
+ writeFileSync3(p, JSON.stringify(entry), "utf8");
6614
+ } catch {
6615
+ }
6616
+ }
6617
+ async function getLatestVersion(opts = {}) {
6618
+ const ttl = opts.ttlMs ?? LATEST_CACHE_TTL_MS;
6619
+ if (!opts.force) {
6620
+ const cached2 = readCache(opts.homeDir);
6621
+ if (cached2 && Date.now() - cached2.checkedAt < ttl) return cached2.version;
6622
+ }
6623
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
6624
+ if (!fetchImpl) return null;
6625
+ const url = opts.registryUrl ?? REGISTRY_URL;
6626
+ const timeout = opts.timeoutMs ?? LATEST_FETCH_TIMEOUT_MS;
6627
+ const controller = new AbortController();
6628
+ const timer = setTimeout(() => controller.abort(), timeout);
6629
+ try {
6630
+ const res = await fetchImpl(url, {
6631
+ signal: controller.signal,
6632
+ headers: { accept: "application/json" }
6633
+ });
6634
+ if (!res.ok) return null;
6635
+ const body = await res.json();
6636
+ if (typeof body.version !== "string") return null;
6637
+ writeCache({ version: body.version, checkedAt: Date.now() }, opts.homeDir);
6638
+ return body.version;
6639
+ } catch {
6640
+ return null;
6641
+ } finally {
6642
+ clearTimeout(timer);
6643
+ }
6644
+ }
6645
+ function compareVersions(a, b) {
6646
+ const [aCore = "0", aPre = ""] = a.split("-", 2);
6647
+ const [bCore = "0", bPre = ""] = b.split("-", 2);
6648
+ const aParts = aCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
6649
+ const bParts = bCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
6650
+ for (let i = 0; i < 3; i++) {
6651
+ const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0);
6652
+ if (diff !== 0) return diff;
6653
+ }
6654
+ if (!aPre && !bPre) return 0;
6655
+ if (!aPre) return 1;
6656
+ if (!bPre) return -1;
6657
+ return aPre < bPre ? -1 : aPre > bPre ? 1 : 0;
6658
+ }
6659
+ function isNpxInstall() {
6660
+ const bin = process.argv[1] ?? "";
6661
+ if (/[/\\]_npx[/\\]/.test(bin)) return true;
6662
+ if (/[/\\]\.pnpm[/\\]/.test(bin) && /dlx/i.test(bin)) return true;
6663
+ const ua = process.env.npm_config_user_agent ?? "";
6664
+ if (ua.includes("npx/")) return true;
6665
+ return false;
6666
+ }
6667
+
6355
6668
  // src/mcp/types.ts
6356
6669
  var MCP_PROTOCOL_VERSION = "2024-11-05";
6357
6670
  function isJsonRpcError(msg) {
@@ -6380,7 +6693,7 @@ var McpClient = class {
6380
6693
  nextProgressToken = 1;
6381
6694
  constructor(opts) {
6382
6695
  this.transport = opts.transport;
6383
- this.clientInfo = opts.clientInfo ?? { name: "reasonix", version: "0.3.0-dev" };
6696
+ this.clientInfo = opts.clientInfo ?? { name: "reasonix", version: VERSION };
6384
6697
  this.requestTimeoutMs = opts.requestTimeoutMs ?? 6e4;
6385
6698
  }
6386
6699
  /** Server's advertised capabilities, available after initialize(). */
@@ -7120,8 +7433,8 @@ async function trySection(load) {
7120
7433
  }
7121
7434
 
7122
7435
  // src/code/edit-blocks.ts
7123
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync8, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
7124
- import { dirname as dirname5, resolve as resolve6 } from "path";
7436
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync9, unlinkSync as unlinkSync2, writeFileSync as writeFileSync4 } from "fs";
7437
+ import { dirname as dirname6, resolve as resolve6 } from "path";
7125
7438
  var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
7126
7439
  function parseEditBlocks(text) {
7127
7440
  const out = [];
@@ -7149,7 +7462,7 @@ function applyEditBlock(block, rootDir) {
7149
7462
  };
7150
7463
  }
7151
7464
  const searchEmpty = block.search.length === 0;
7152
- const exists = existsSync6(absTarget);
7465
+ const exists = existsSync7(absTarget);
7153
7466
  try {
7154
7467
  if (!exists) {
7155
7468
  if (!searchEmpty) {
@@ -7159,11 +7472,11 @@ function applyEditBlock(block, rootDir) {
7159
7472
  message: "file does not exist; to create it, use an empty SEARCH block"
7160
7473
  };
7161
7474
  }
7162
- mkdirSync3(dirname5(absTarget), { recursive: true });
7163
- writeFileSync3(absTarget, block.replace, "utf8");
7475
+ mkdirSync4(dirname6(absTarget), { recursive: true });
7476
+ writeFileSync4(absTarget, block.replace, "utf8");
7164
7477
  return { path: block.path, status: "created" };
7165
7478
  }
7166
- const content = readFileSync8(absTarget, "utf8");
7479
+ const content = readFileSync9(absTarget, "utf8");
7167
7480
  if (searchEmpty) {
7168
7481
  return {
7169
7482
  path: block.path,
@@ -7180,7 +7493,7 @@ function applyEditBlock(block, rootDir) {
7180
7493
  };
7181
7494
  }
7182
7495
  const replaced = `${content.slice(0, idx)}${block.replace}${content.slice(idx + block.search.length)}`;
7183
- writeFileSync3(absTarget, replaced, "utf8");
7496
+ writeFileSync4(absTarget, replaced, "utf8");
7184
7497
  return { path: block.path, status: "applied" };
7185
7498
  } catch (err) {
7186
7499
  return { path: block.path, status: "error", message: err.message };
@@ -7192,9 +7505,9 @@ function applyEditBlocks(blocks, rootDir) {
7192
7505
  function toWholeFileEditBlock(path5, content, rootDir) {
7193
7506
  const abs = resolve6(rootDir, path5);
7194
7507
  let search = "";
7195
- if (existsSync6(abs)) {
7508
+ if (existsSync7(abs)) {
7196
7509
  try {
7197
- search = readFileSync8(abs, "utf8");
7510
+ search = readFileSync9(abs, "utf8");
7198
7511
  } catch {
7199
7512
  search = "";
7200
7513
  }
@@ -7209,12 +7522,12 @@ function snapshotBeforeEdits(blocks, rootDir) {
7209
7522
  if (seen.has(b.path)) continue;
7210
7523
  seen.add(b.path);
7211
7524
  const abs = resolve6(absRoot, b.path);
7212
- if (!existsSync6(abs)) {
7525
+ if (!existsSync7(abs)) {
7213
7526
  snapshots.push({ path: b.path, prevContent: null });
7214
7527
  continue;
7215
7528
  }
7216
7529
  try {
7217
- snapshots.push({ path: b.path, prevContent: readFileSync8(abs, "utf8") });
7530
+ snapshots.push({ path: b.path, prevContent: readFileSync9(abs, "utf8") });
7218
7531
  } catch {
7219
7532
  snapshots.push({ path: b.path, prevContent: null });
7220
7533
  }
@@ -7234,14 +7547,14 @@ function restoreSnapshots(snapshots, rootDir) {
7234
7547
  }
7235
7548
  try {
7236
7549
  if (snap.prevContent === null) {
7237
- if (existsSync6(abs)) unlinkSync2(abs);
7550
+ if (existsSync7(abs)) unlinkSync2(abs);
7238
7551
  return {
7239
7552
  path: snap.path,
7240
7553
  status: "applied",
7241
7554
  message: "removed (the edit had created it)"
7242
7555
  };
7243
7556
  }
7244
- writeFileSync3(abs, snap.prevContent, "utf8");
7557
+ writeFileSync4(abs, snap.prevContent, "utf8");
7245
7558
  return {
7246
7559
  path: snap.path,
7247
7560
  status: "applied",
@@ -7256,114 +7569,54 @@ function sep() {
7256
7569
  return process.platform === "win32" ? "\\" : "/";
7257
7570
  }
7258
7571
 
7259
- // src/version.ts
7260
- import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
7261
- import { homedir as homedir4 } from "os";
7262
- import { dirname as dirname6, join as join7 } from "path";
7263
- import { fileURLToPath as fileURLToPath2 } from "url";
7264
- var REGISTRY_URL = "https://registry.npmjs.org/reasonix/latest";
7265
- var LATEST_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
7266
- var LATEST_FETCH_TIMEOUT_MS = 2e3;
7267
- function readPackageVersion() {
7268
- try {
7269
- let dir = dirname6(fileURLToPath2(import.meta.url));
7270
- for (let i = 0; i < 6; i++) {
7271
- const p = join7(dir, "package.json");
7272
- if (existsSync7(p)) {
7273
- const pkg = JSON.parse(readFileSync9(p, "utf8"));
7274
- if (pkg?.name === "reasonix" && typeof pkg.version === "string") {
7275
- return pkg.version;
7276
- }
7277
- }
7278
- const parent = dirname6(dir);
7279
- if (parent === dir) break;
7280
- dir = parent;
7281
- }
7282
- } catch {
7283
- }
7284
- return "0.0.0-dev";
7285
- }
7286
- var VERSION = readPackageVersion();
7287
- function cachePath(homeDirOverride) {
7288
- return join7(homeDirOverride ?? homedir4(), ".reasonix", "version-cache.json");
7572
+ // src/usage.ts
7573
+ import {
7574
+ appendFileSync as appendFileSync2,
7575
+ existsSync as existsSync8,
7576
+ mkdirSync as mkdirSync5,
7577
+ readFileSync as readFileSync10,
7578
+ statSync as statSync4,
7579
+ writeFileSync as writeFileSync5
7580
+ } from "fs";
7581
+ import { homedir as homedir5 } from "os";
7582
+ import { dirname as dirname7, join as join8 } from "path";
7583
+ function defaultUsageLogPath(homeDirOverride) {
7584
+ return join8(homeDirOverride ?? homedir5(), ".reasonix", "usage.jsonl");
7289
7585
  }
7290
- function readCache(homeDirOverride) {
7586
+ var USAGE_COMPACTION_THRESHOLD_BYTES = 5 * 1024 * 1024;
7587
+ var USAGE_RETENTION_DAYS = 365;
7588
+ function compactUsageLogIfLarge(path5, now) {
7589
+ let size;
7291
7590
  try {
7292
- const raw = readFileSync9(cachePath(homeDirOverride), "utf8");
7293
- const parsed = JSON.parse(raw);
7294
- if (parsed && typeof parsed.version === "string" && typeof parsed.checkedAt === "number") {
7295
- return parsed;
7296
- }
7591
+ size = statSync4(path5).size;
7297
7592
  } catch {
7593
+ return;
7298
7594
  }
7299
- return null;
7300
- }
7301
- function writeCache(entry, homeDirOverride) {
7595
+ if (size < USAGE_COMPACTION_THRESHOLD_BYTES) return;
7596
+ const cutoff = now - USAGE_RETENTION_DAYS * 24 * 60 * 60 * 1e3;
7597
+ let raw;
7302
7598
  try {
7303
- const p = cachePath(homeDirOverride);
7304
- mkdirSync4(dirname6(p), { recursive: true });
7305
- writeFileSync4(p, JSON.stringify(entry), "utf8");
7599
+ raw = readFileSync10(path5, "utf8");
7306
7600
  } catch {
7601
+ return;
7307
7602
  }
7308
- }
7309
- async function getLatestVersion(opts = {}) {
7310
- const ttl = opts.ttlMs ?? LATEST_CACHE_TTL_MS;
7311
- if (!opts.force) {
7312
- const cached2 = readCache(opts.homeDir);
7313
- if (cached2 && Date.now() - cached2.checkedAt < ttl) return cached2.version;
7603
+ const lines = raw.split(/\r?\n/);
7604
+ const kept = [];
7605
+ for (const line of lines) {
7606
+ if (!line.trim()) continue;
7607
+ try {
7608
+ const rec = JSON.parse(line);
7609
+ if (isValidRecord(rec) && rec.ts >= cutoff) kept.push(line);
7610
+ } catch {
7611
+ }
7314
7612
  }
7315
- const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
7316
- if (!fetchImpl) return null;
7317
- const url = opts.registryUrl ?? REGISTRY_URL;
7318
- const timeout = opts.timeoutMs ?? LATEST_FETCH_TIMEOUT_MS;
7319
- const controller = new AbortController();
7320
- const timer = setTimeout(() => controller.abort(), timeout);
7613
+ if (kept.length === lines.filter((l) => l.trim()).length) return;
7321
7614
  try {
7322
- const res = await fetchImpl(url, {
7323
- signal: controller.signal,
7324
- headers: { accept: "application/json" }
7325
- });
7326
- if (!res.ok) return null;
7327
- const body = await res.json();
7328
- if (typeof body.version !== "string") return null;
7329
- writeCache({ version: body.version, checkedAt: Date.now() }, opts.homeDir);
7330
- return body.version;
7615
+ writeFileSync5(path5, kept.length > 0 ? `${kept.join("\n")}
7616
+ ` : "", "utf8");
7331
7617
  } catch {
7332
- return null;
7333
- } finally {
7334
- clearTimeout(timer);
7335
7618
  }
7336
7619
  }
7337
- function compareVersions(a, b) {
7338
- const [aCore = "0", aPre = ""] = a.split("-", 2);
7339
- const [bCore = "0", bPre = ""] = b.split("-", 2);
7340
- const aParts = aCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
7341
- const bParts = bCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
7342
- for (let i = 0; i < 3; i++) {
7343
- const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0);
7344
- if (diff !== 0) return diff;
7345
- }
7346
- if (!aPre && !bPre) return 0;
7347
- if (!aPre) return 1;
7348
- if (!bPre) return -1;
7349
- return aPre < bPre ? -1 : aPre > bPre ? 1 : 0;
7350
- }
7351
- function isNpxInstall() {
7352
- const bin = process.argv[1] ?? "";
7353
- if (/[/\\]_npx[/\\]/.test(bin)) return true;
7354
- if (/[/\\]\.pnpm[/\\]/.test(bin) && /dlx/i.test(bin)) return true;
7355
- const ua = process.env.npm_config_user_agent ?? "";
7356
- if (ua.includes("npx/")) return true;
7357
- return false;
7358
- }
7359
-
7360
- // src/usage.ts
7361
- import { appendFileSync as appendFileSync2, existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync10, statSync as statSync4 } from "fs";
7362
- import { homedir as homedir5 } from "os";
7363
- import { dirname as dirname7, join as join8 } from "path";
7364
- function defaultUsageLogPath(homeDirOverride) {
7365
- return join8(homeDirOverride ?? homedir5(), ".reasonix", "usage.jsonl");
7366
- }
7367
7620
  function appendUsage(input) {
7368
7621
  const record = {
7369
7622
  ts: input.now ?? Date.now(),
@@ -7383,6 +7636,7 @@ function appendUsage(input) {
7383
7636
  mkdirSync5(dirname7(path5), { recursive: true });
7384
7637
  appendFileSync2(path5, `${JSON.stringify(record)}
7385
7638
  `, "utf8");
7639
+ compactUsageLogIfLarge(path5, record.ts);
7386
7640
  } catch {
7387
7641
  }
7388
7642
  return record;
@@ -7521,7 +7775,7 @@ import { Box as Box22, Static, Text as Text20, useStdout as useStdout8 } from "i
7521
7775
  import React24, { useCallback as useCallback4, useEffect as useEffect6, useMemo as useMemo3, useRef as useRef6, useState as useState10 } from "react";
7522
7776
 
7523
7777
  // src/code/pending-edits.ts
7524
- import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync11, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5 } from "fs";
7778
+ import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync11, unlinkSync as unlinkSync3, writeFileSync as writeFileSync6 } from "fs";
7525
7779
  import { dirname as dirname8, join as join9 } from "path";
7526
7780
  function pendingEditsPath(sessionName) {
7527
7781
  return join9(sessionsDir(), `${sanitizeName(sessionName)}.pending.json`);
@@ -7535,7 +7789,7 @@ function savePendingEdits(sessionName, blocks) {
7535
7789
  return;
7536
7790
  }
7537
7791
  mkdirSync6(dirname8(path5), { recursive: true });
7538
- writeFileSync5(path5, JSON.stringify(blocks, null, 2), "utf8");
7792
+ writeFileSync6(path5, JSON.stringify(blocks, null, 2), "utf8");
7539
7793
  } catch {
7540
7794
  }
7541
7795
  }
@@ -7581,7 +7835,7 @@ import {
7581
7835
  renameSync,
7582
7836
  statSync as statSync5,
7583
7837
  unlinkSync as unlinkSync4,
7584
- writeFileSync as writeFileSync6
7838
+ writeFileSync as writeFileSync7
7585
7839
  } from "fs";
7586
7840
  import { dirname as dirname9, join as join10 } from "path";
7587
7841
  function planStatePath(sessionName) {
@@ -7638,7 +7892,7 @@ function savePlanState(sessionName, steps, completedStepIds, extras) {
7638
7892
  };
7639
7893
  if (extras?.body) state.body = extras.body;
7640
7894
  if (extras?.summary) state.summary = extras.summary;
7641
- writeFileSync6(path5, `${JSON.stringify(state, null, 2)}
7895
+ writeFileSync7(path5, `${JSON.stringify(state, null, 2)}
7642
7896
  `, "utf8");
7643
7897
  } catch (err) {
7644
7898
  process.stderr.write(
@@ -7906,7 +8160,7 @@ import {
7906
8160
  readFileSync as readFileSync14,
7907
8161
  readSync,
7908
8162
  statSync as statSync6,
7909
- writeFileSync as writeFileSync7
8163
+ writeFileSync as writeFileSync8
7910
8164
  } from "fs";
7911
8165
  import { dirname as dirname11, isAbsolute as isAbsolute4, resolve as resolve7, sep as sep2 } from "path";
7912
8166
  var MAX_BYTES = 4 * 1024 * 1024;
@@ -7986,14 +8240,14 @@ async function handleFile(method, rest, body, ctx) {
7986
8240
  if (!existsSync11(target)) {
7987
8241
  return { status: 404, body: { error: "file not found" } };
7988
8242
  }
7989
- const stat = statSync6(target);
7990
- if (stat.isDirectory()) {
8243
+ const stat2 = statSync6(target);
8244
+ if (stat2.isDirectory()) {
7991
8245
  return { status: 400, body: { error: "path is a directory" } };
7992
8246
  }
7993
- if (stat.size > MAX_BYTES) {
8247
+ if (stat2.size > MAX_BYTES) {
7994
8248
  return {
7995
8249
  status: 413,
7996
- body: { error: `file too large (${stat.size} bytes; cap ${MAX_BYTES})` }
8250
+ body: { error: `file too large (${stat2.size} bytes; cap ${MAX_BYTES})` }
7997
8251
  };
7998
8252
  }
7999
8253
  if (looksBinary(target)) {
@@ -8008,8 +8262,8 @@ async function handleFile(method, rest, body, ctx) {
8008
8262
  body: {
8009
8263
  path: requested,
8010
8264
  absolute: target,
8011
- size: stat.size,
8012
- mtime: stat.mtime.getTime(),
8265
+ size: stat2.size,
8266
+ mtime: stat2.mtime.getTime(),
8013
8267
  content
8014
8268
  }
8015
8269
  };
@@ -8029,20 +8283,20 @@ async function handleFile(method, rest, body, ctx) {
8029
8283
  if (!existsSync11(parent)) {
8030
8284
  mkdirSync8(parent, { recursive: true });
8031
8285
  }
8032
- writeFileSync7(target, content, "utf8");
8286
+ writeFileSync8(target, content, "utf8");
8033
8287
  ctx.audit?.({
8034
8288
  ts: Date.now(),
8035
8289
  action: "save-file",
8036
8290
  payload: { path: requested, bytes: Buffer.byteLength(content, "utf8") }
8037
8291
  });
8038
- const stat = statSync6(target);
8292
+ const stat2 = statSync6(target);
8039
8293
  return {
8040
8294
  status: 200,
8041
8295
  body: {
8042
8296
  saved: true,
8043
8297
  path: requested,
8044
- size: stat.size,
8045
- mtime: stat.mtime.getTime()
8298
+ size: stat2.size,
8299
+ mtime: stat2.mtime.getTime()
8046
8300
  }
8047
8301
  };
8048
8302
  }
@@ -8139,7 +8393,7 @@ async function handleHealth(method, _rest, _body, ctx) {
8139
8393
  }
8140
8394
 
8141
8395
  // src/server/api/hooks.ts
8142
- import { existsSync as existsSync13, mkdirSync as mkdirSync9, readFileSync as readFileSync15, writeFileSync as writeFileSync8 } from "fs";
8396
+ import { existsSync as existsSync13, mkdirSync as mkdirSync9, readFileSync as readFileSync15, writeFileSync as writeFileSync9 } from "fs";
8143
8397
  import { dirname as dirname12 } from "path";
8144
8398
  function parseBody3(raw) {
8145
8399
  if (!raw) return {};
@@ -8164,7 +8418,7 @@ function writeSettingsFile(path5, hooksBlock) {
8164
8418
  const existing = readSettingsFile2(path5);
8165
8419
  existing.hooks = hooksBlock;
8166
8420
  mkdirSync9(dirname12(path5), { recursive: true });
8167
- writeFileSync8(path5, `${JSON.stringify(existing, null, 2)}
8421
+ writeFileSync9(path5, `${JSON.stringify(existing, null, 2)}
8168
8422
  `, "utf8");
8169
8423
  }
8170
8424
  async function handleHooks(method, rest, body, ctx) {
@@ -8345,7 +8599,7 @@ import {
8345
8599
  readdirSync as readdirSync5,
8346
8600
  statSync as statSync8,
8347
8601
  unlinkSync as unlinkSync5,
8348
- writeFileSync as writeFileSync9
8602
+ writeFileSync as writeFileSync10
8349
8603
  } from "fs";
8350
8604
  import { homedir as homedir7 } from "os";
8351
8605
  import { dirname as dirname13, join as join13, resolve as resolvePath } from "path";
@@ -8372,11 +8626,11 @@ function listMemoryFiles(dir) {
8372
8626
  if (!existsSync14(dir)) return [];
8373
8627
  try {
8374
8628
  return readdirSync5(dir).filter((f) => f.endsWith(".md")).map((f) => {
8375
- const stat = statSync8(join13(dir, f));
8629
+ const stat2 = statSync8(join13(dir, f));
8376
8630
  return {
8377
8631
  name: f.replace(/\.md$/, ""),
8378
- size: stat.size,
8379
- mtime: stat.mtime.getTime()
8632
+ size: stat2.size,
8633
+ mtime: stat2.mtime.getTime()
8380
8634
  };
8381
8635
  }).sort((a, b) => b.mtime - a.mtime);
8382
8636
  } catch {
@@ -8436,7 +8690,7 @@ async function handleMemory(method, rest, body, ctx) {
8436
8690
  if (!cwd2) return { status: 503, body: { error: "no active project" } };
8437
8691
  const path5 = join13(cwd2, PROJECT_MEMORY_FILE);
8438
8692
  mkdirSync10(dirname13(path5), { recursive: true });
8439
- writeFileSync9(path5, contents, "utf8");
8693
+ writeFileSync10(path5, contents, "utf8");
8440
8694
  ctx.audit?.({ ts: Date.now(), action: "save-memory", payload: { scope, path: path5 } });
8441
8695
  return { status: 200, body: { saved: true, path: path5 } };
8442
8696
  }
@@ -8445,7 +8699,7 @@ async function handleMemory(method, rest, body, ctx) {
8445
8699
  if (!dir) return { status: 503, body: { error: "no project root for project-mem" } };
8446
8700
  mkdirSync10(dir, { recursive: true });
8447
8701
  const path5 = join13(dir, `${name}.md`);
8448
- writeFileSync9(path5, contents, "utf8");
8702
+ writeFileSync10(path5, contents, "utf8");
8449
8703
  ctx.audit?.({ ts: Date.now(), action: "save-memory", payload: { scope, name, path: path5 } });
8450
8704
  return { status: 200, body: { saved: true, path: path5 } };
8451
8705
  }
@@ -8783,13 +9037,13 @@ async function* walkChunks(root, opts = {}) {
8783
9037
  const ext = path.extname(name).toLowerCase();
8784
9038
  if (BINARY_EXTS.has(ext)) continue;
8785
9039
  const abs = path.join(dir, name);
8786
- let stat;
9040
+ let stat2;
8787
9041
  try {
8788
- stat = await fs2.stat(abs);
9042
+ stat2 = await fs2.stat(abs);
8789
9043
  } catch {
8790
9044
  continue;
8791
9045
  }
8792
- if (stat.size > maxFileBytes) continue;
9046
+ if (stat2.size > maxFileBytes) continue;
8793
9047
  let text;
8794
9048
  try {
8795
9049
  text = await fs2.readFile(abs, "utf8");
@@ -9165,8 +9419,8 @@ async function buildIndex(root, opts = {}) {
9165
9419
  const abs = path3.join(root, chunk.path);
9166
9420
  let mtimeMs = 0;
9167
9421
  try {
9168
- const stat = await fs4.stat(abs);
9169
- mtimeMs = stat.mtimeMs;
9422
+ const stat2 = await fs4.stat(abs);
9423
+ mtimeMs = stat2.mtimeMs;
9170
9424
  } catch {
9171
9425
  continue;
9172
9426
  }
@@ -9969,7 +10223,7 @@ import {
9969
10223
  readdirSync as readdirSync6,
9970
10224
  rmSync,
9971
10225
  statSync as statSync9,
9972
- writeFileSync as writeFileSync10
10226
+ writeFileSync as writeFileSync11
9973
10227
  } from "fs";
9974
10228
  import { homedir as homedir8 } from "os";
9975
10229
  import { dirname as dirname14, join as join14 } from "path";
@@ -10008,14 +10262,14 @@ function listSkills(dir, scope) {
10008
10262
  const skillPath = join14(dir, entry, SKILL_FILE);
10009
10263
  if (!existsSync16(skillPath)) continue;
10010
10264
  try {
10011
- const stat = statSync9(skillPath);
10265
+ const stat2 = statSync9(skillPath);
10012
10266
  const raw = readFileSync18(skillPath, "utf8");
10013
10267
  const item = {
10014
10268
  name: entry,
10015
10269
  scope,
10016
10270
  path: skillPath,
10017
- size: stat.size,
10018
- mtime: stat.mtime.getTime()
10271
+ size: stat2.size,
10272
+ mtime: stat2.mtime.getTime()
10019
10273
  };
10020
10274
  const desc = parseFrontmatterDescription(raw);
10021
10275
  if (desc) item.description = desc;
@@ -10084,7 +10338,7 @@ async function handleSkills(method, rest, body, ctx) {
10084
10338
  return { status: 400, body: { error: "body (string) required" } };
10085
10339
  }
10086
10340
  mkdirSync11(dirname14(skillPath), { recursive: true });
10087
- writeFileSync10(skillPath, contents, "utf8");
10341
+ writeFileSync11(skillPath, contents, "utf8");
10088
10342
  ctx.audit?.({
10089
10343
  ts: Date.now(),
10090
10344
  action: "save-skill",
@@ -11191,13 +11445,13 @@ var MIN_DIFF_ROWS = 8;
11191
11445
  function EditConfirm({ block, onChoose }) {
11192
11446
  const { stdout: stdout3 } = useStdout2();
11193
11447
  const rows = stdout3?.rows ?? 40;
11194
- const budget = Math.max(MIN_DIFF_ROWS, rows - MODAL_OVERHEAD_ROWS);
11448
+ const budget2 = Math.max(MIN_DIFF_ROWS, rows - MODAL_OVERHEAD_ROWS);
11195
11449
  const allLines = useMemo(
11196
11450
  () => formatEditBlockDiff(block, { contextLines: 2, maxLines: 1e5, indent: " " }),
11197
11451
  [block]
11198
11452
  );
11199
11453
  const [scroll, setScroll] = useState2(0);
11200
- const maxScroll = Math.max(0, allLines.length - budget);
11454
+ const maxScroll = Math.max(0, allLines.length - budget2);
11201
11455
  const effectiveScroll = Math.min(scroll, maxScroll);
11202
11456
  useKeystroke((ev) => {
11203
11457
  if (ev.paste) return;
@@ -11228,11 +11482,11 @@ function EditConfirm({ block, onChoose }) {
11228
11482
  return;
11229
11483
  }
11230
11484
  if (key.pageDown || input === " " || input === "f") {
11231
- setScroll((s) => Math.min(maxScroll, s + Math.max(1, budget - 2)));
11485
+ setScroll((s) => Math.min(maxScroll, s + Math.max(1, budget2 - 2)));
11232
11486
  return;
11233
11487
  }
11234
11488
  if (key.pageUp || input === "b") {
11235
- setScroll((s) => Math.max(0, s - Math.max(1, budget - 2)));
11489
+ setScroll((s) => Math.max(0, s - Math.max(1, budget2 - 2)));
11236
11490
  return;
11237
11491
  }
11238
11492
  if (input === "g") {
@@ -11248,9 +11502,9 @@ function EditConfirm({ block, onChoose }) {
11248
11502
  const removed = isNew ? 0 : (block.search.match(/\n/g)?.length ?? 0) + 1;
11249
11503
  const added = block.replace === "" ? 0 : (block.replace.match(/\n/g)?.length ?? 0) + 1;
11250
11504
  const tag = isNew ? "NEW" : "EDIT";
11251
- const visibleLines = allLines.slice(effectiveScroll, effectiveScroll + budget);
11505
+ const visibleLines = allLines.slice(effectiveScroll, effectiveScroll + budget2);
11252
11506
  const hiddenAbove = effectiveScroll;
11253
- const hiddenBelow = Math.max(0, allLines.length - effectiveScroll - budget);
11507
+ const hiddenBelow = Math.max(0, allLines.length - effectiveScroll - budget2);
11254
11508
  const totalLines = allLines.length;
11255
11509
  const showScrollHud = hiddenAbove + hiddenBelow > 0;
11256
11510
  const subtitleParts = [`-${removed} +${added} lines`];
@@ -11640,26 +11894,26 @@ function validateCitation(url, projectRoot) {
11640
11894
  ...siblings.map((ext) => baseFullPath.replace(/\.[^./\\]+$/, ext))
11641
11895
  ];
11642
11896
  let fullPath = baseFullPath;
11643
- let stat = null;
11897
+ let stat2 = null;
11644
11898
  for (const candidate of candidates) {
11645
11899
  try {
11646
- stat = statSync11(candidate);
11900
+ stat2 = statSync11(candidate);
11647
11901
  fullPath = candidate;
11648
11902
  break;
11649
11903
  } catch {
11650
11904
  }
11651
11905
  }
11652
- if (!stat) return { ok: false, reason: "file not found" };
11653
- if (!stat.isFile()) return { ok: false, reason: "not a file" };
11906
+ if (!stat2) return { ok: false, reason: "file not found" };
11907
+ if (!stat2.isFile()) return { ok: false, reason: "not a file" };
11654
11908
  if (parts.startLine === void 0) return { ok: true };
11655
- let lineCount = getCachedLineCount(fullPath, stat.mtimeMs);
11909
+ let lineCount = getCachedLineCount(fullPath, stat2.mtimeMs);
11656
11910
  if (lineCount === null) {
11657
11911
  try {
11658
11912
  lineCount = readFileSync19(fullPath, "utf8").split("\n").length;
11659
11913
  } catch {
11660
11914
  return { ok: false, reason: "unreadable" };
11661
11915
  }
11662
- setCachedLineCount(fullPath, stat.mtimeMs, lineCount);
11916
+ setCachedLineCount(fullPath, stat2.mtimeMs, lineCount);
11663
11917
  }
11664
11918
  if (parts.startLine < 1 || parts.startLine > lineCount) {
11665
11919
  return { ok: false, reason: `line ${parts.startLine} > ${lineCount}` };
@@ -12454,7 +12708,7 @@ var EventRow = React11.memo(function EventRow2({
12454
12708
  event.branch ? /* @__PURE__ */ React11.createElement(BranchBlock, { branch: event.branch }) : null,
12455
12709
  event.reasoning ? /* @__PURE__ */ React11.createElement(ReasoningBlock, { reasoning: event.reasoning }) : null,
12456
12710
  !isPlanStateEmpty(event.planState) ? /* @__PURE__ */ React11.createElement(PlanStateBlock, { planState: event.planState }) : null,
12457
- event.text ? /* @__PURE__ */ React11.createElement(Markdown, { text: event.text, projectRoot }) : /* @__PURE__ */ React11.createElement(Text8, { dimColor: true }, "(no content)"),
12711
+ event.text ? /* @__PURE__ */ React11.createElement(Markdown, { text: event.text, projectRoot }) : /* @__PURE__ */ React11.createElement(Text8, { dimColor: true }, "(empty body \u2014 likely tool-call only)"),
12458
12712
  event.stats ? /* @__PURE__ */ React11.createElement(StatsLine, { stats: event.stats }) : null,
12459
12713
  event.repair ? /* @__PURE__ */ React11.createElement(Text8, { color: COLOR.accent }, event.repair) : null
12460
12714
  ));
@@ -13337,13 +13591,13 @@ function buildViewport(line, cursorCol, visibleCells, pastes) {
13337
13591
  return clipAroundCursor(line, cursorCol, visibleCells, pastes);
13338
13592
  }
13339
13593
  function clipFromLeft(line, visibleCells, pastes) {
13340
- const budget = Math.max(1, visibleCells - 1);
13594
+ const budget2 = Math.max(1, visibleCells - 1);
13341
13595
  let used = 0;
13342
13596
  let end = 0;
13343
13597
  while (end < line.length) {
13344
13598
  const ch = line[end];
13345
13599
  const cw = charCellsAt(line, end, pastes);
13346
- if (used + cw > budget) break;
13600
+ if (used + cw > budget2) break;
13347
13601
  used += cw;
13348
13602
  end++;
13349
13603
  }
@@ -13351,10 +13605,10 @@ function clipFromLeft(line, visibleCells, pastes) {
13351
13605
  return { segments, cursorCell: null, hiddenLeft: false, hiddenRight: end < line.length };
13352
13606
  }
13353
13607
  function clipAroundCursor(line, cursorCol, visibleCells, pastes) {
13354
- let budget = visibleCells;
13608
+ let budget2 = visibleCells;
13355
13609
  const reservedForMarkers = 2;
13356
- budget = Math.max(1, budget - reservedForMarkers);
13357
- const halfBudget = Math.floor(budget / 2);
13610
+ budget2 = Math.max(1, budget2 - reservedForMarkers);
13611
+ const halfBudget = Math.floor(budget2 / 2);
13358
13612
  let start = cursorCol;
13359
13613
  let leftCells = 0;
13360
13614
  while (start > 0 && leftCells < halfBudget) {
@@ -13363,7 +13617,7 @@ function clipAroundCursor(line, cursorCol, visibleCells, pastes) {
13363
13617
  start--;
13364
13618
  leftCells += cw;
13365
13619
  }
13366
- const rightBudget = budget - leftCells;
13620
+ const rightBudget = budget2 - leftCells;
13367
13621
  let end = cursorCol;
13368
13622
  let rightCells = 0;
13369
13623
  const cursorChar = cursorCol < line.length ? charCellsAt(line, cursorCol, pastes) : 1;
@@ -13870,7 +14124,8 @@ function StatsPanel({
13870
14124
  busy,
13871
14125
  proArmed,
13872
14126
  escalated,
13873
- dashboardUrl
14127
+ dashboardUrl,
14128
+ budgetUsd
13874
14129
  }) {
13875
14130
  const branchOn = (branchBudget ?? 1) > 1;
13876
14131
  const ctxMax = DEEPSEEK_CONTEXT_TOKENS[model2] ?? DEFAULT_CONTEXT_TOKENS;
@@ -13916,7 +14171,12 @@ function StatsPanel({
13916
14171
  balance,
13917
14172
  coldStart
13918
14173
  }
13919
- ));
14174
+ ), budgetUsd !== null && budgetUsd !== void 0 ? /* @__PURE__ */ React21.createElement(BudgetRow, { spent: summary.totalCostUsd, cap: budgetUsd }) : null);
14175
+ }
14176
+ function BudgetRow({ spent, cap }) {
14177
+ const pct2 = Math.max(0, spent / cap * 100);
14178
+ const color = pct2 >= 100 ? "#f87171" : pct2 >= 80 ? "#fbbf24" : "#94a3b8";
14179
+ return /* @__PURE__ */ React21.createElement(Box19, null, /* @__PURE__ */ React21.createElement(Text17, { dimColor: true }, " budget "), /* @__PURE__ */ React21.createElement(Text17, { color }, `$${spent.toFixed(4)} / $${cap.toFixed(2)}`, /* @__PURE__ */ React21.createElement(Text17, { dimColor: true }, ` (${pct2.toFixed(0)}%)`)));
13920
14180
  }
13921
14181
  function Header({
13922
14182
  model: model2,
@@ -14195,7 +14455,7 @@ function describeRepair(repair) {
14195
14455
  }
14196
14456
 
14197
14457
  // src/cli/ui/hash-memory.ts
14198
- import { appendFileSync as appendFileSync3, existsSync as existsSync18, mkdirSync as mkdirSync12, readFileSync as readFileSync20, writeFileSync as writeFileSync11 } from "fs";
14458
+ import { appendFileSync as appendFileSync3, existsSync as existsSync18, mkdirSync as mkdirSync12, readFileSync as readFileSync20, writeFileSync as writeFileSync12 } from "fs";
14199
14459
  import { homedir as homedir9 } from "os";
14200
14460
  import { dirname as dirname15, join as join17 } from "path";
14201
14461
  var PROJECT_HEADER = `# Reasonix project memory
@@ -14246,7 +14506,7 @@ function appendBulletToFile(path5, note, newFileHeader) {
14246
14506
  `;
14247
14507
  if (!existsSync18(path5)) {
14248
14508
  mkdirSync12(dirname15(path5), { recursive: true });
14249
- writeFileSync11(path5, `${newFileHeader}${bullet}`, "utf8");
14509
+ writeFileSync12(path5, `${newFileHeader}${bullet}`, "utf8");
14250
14510
  return { path: path5, created: true };
14251
14511
  }
14252
14512
  let prefix = "";
@@ -14618,6 +14878,12 @@ var SLASH_COMMANDS = [
14618
14878
  summary: "arm v4-pro for the NEXT turn only (one-shot \xB7 auto-disarms after turn)",
14619
14879
  argCompleter: ["off"]
14620
14880
  },
14881
+ {
14882
+ cmd: "budget",
14883
+ argsHint: "[usd|off]",
14884
+ summary: "session USD cap \u2014 warns at 80%, refuses next turn at 100%. Off by default. /budget alone shows status",
14885
+ argCompleter: ["off", "1", "5", "10", "20", "50"]
14886
+ },
14621
14887
  { cmd: "mcp", summary: "list MCP servers + tools attached to this session" },
14622
14888
  {
14623
14889
  cmd: "resource",
@@ -14692,6 +14958,11 @@ var SLASH_COMMANDS = [
14692
14958
  },
14693
14959
  { cmd: "sessions", summary: "list saved sessions (current marked with \u25B8)" },
14694
14960
  { cmd: "forget", summary: "delete the current session from disk" },
14961
+ {
14962
+ cmd: "prune-sessions",
14963
+ summary: "delete sessions idle \u2265N days (default 90) \u2014 frees disk on long-time installs",
14964
+ argsHint: "[days]"
14965
+ },
14695
14966
  { cmd: "setup", summary: "reminds you to exit and run `reasonix setup`" },
14696
14967
  {
14697
14968
  cmd: "semantic",
@@ -16102,6 +16373,42 @@ var pro = (args, loop2, ctx) => {
16102
16373
  };
16103
16374
  };
16104
16375
  var ESCALATION_MODEL_ID = "deepseek-v4-pro";
16376
+ var budget = (args, loop2) => {
16377
+ const arg = args[0]?.trim() ?? "";
16378
+ if (arg === "") {
16379
+ if (loop2.budgetUsd === null) {
16380
+ return {
16381
+ info: "no session budget set \u2014 Reasonix will keep going until you stop it. Set one with: /budget <usd> (e.g. /budget 5)"
16382
+ };
16383
+ }
16384
+ const spent2 = loop2.stats.totalCost;
16385
+ const pct2 = spent2 / loop2.budgetUsd * 100;
16386
+ return {
16387
+ info: `budget: $${spent2.toFixed(4)} of $${loop2.budgetUsd.toFixed(2)} (${pct2.toFixed(1)}%) \xB7 /budget off to clear, /budget <usd> to change`
16388
+ };
16389
+ }
16390
+ if (arg === "off" || arg === "none" || arg === "0") {
16391
+ loop2.setBudget(null);
16392
+ return { info: "budget \u2192 off (no cap)" };
16393
+ }
16394
+ const cleaned = arg.replace(/^\$/, "");
16395
+ const usd = Number(cleaned);
16396
+ if (!Number.isFinite(usd) || usd <= 0) {
16397
+ return {
16398
+ info: `usage: /budget <usd> (got "${arg}" \u2014 must be a positive number, e.g. /budget 5 or /budget 12.50)`
16399
+ };
16400
+ }
16401
+ loop2.setBudget(usd);
16402
+ const spent = loop2.stats.totalCost;
16403
+ if (spent >= usd) {
16404
+ return {
16405
+ info: `\u25B2 budget \u2192 $${usd.toFixed(2)} but already spent $${spent.toFixed(4)}. Next turn will be refused \u2014 bump the cap higher to keep going, or end the session.`
16406
+ };
16407
+ }
16408
+ return {
16409
+ info: `budget \u2192 $${usd.toFixed(2)} (so far: $${spent.toFixed(4)} \xB7 warns at 80%, refuses next turn at 100% \xB7 /budget off to clear)`
16410
+ };
16411
+ };
16105
16412
  var handlers9 = {
16106
16413
  model,
16107
16414
  models,
@@ -16109,7 +16416,8 @@ var handlers9 = {
16109
16416
  preset,
16110
16417
  branch,
16111
16418
  effort,
16112
- pro
16419
+ pro,
16420
+ budget
16113
16421
  };
16114
16422
 
16115
16423
  // src/cli/ui/slash/handlers/observability.ts
@@ -16661,9 +16969,9 @@ async function indexFileExists(rootDir) {
16661
16969
  async function readIndexMeta(rootDir) {
16662
16970
  const dataPath = path4.join(rootDir, ".reasonix", "semantic", "index.jsonl");
16663
16971
  try {
16664
- const stat = await fs5.stat(dataPath);
16665
- if (stat.size > 10 * 1024 * 1024) {
16666
- return { chunks: Math.round(stat.size / 500), files: 0 };
16972
+ const stat2 = await fs5.stat(dataPath);
16973
+ if (stat2.size > 10 * 1024 * 1024) {
16974
+ return { chunks: Math.round(stat2.size / 500), files: 0 };
16667
16975
  }
16668
16976
  const raw = await fs5.readFile(dataPath, "utf8");
16669
16977
  const seenPaths = /* @__PURE__ */ new Set();
@@ -16687,6 +16995,7 @@ var handlers13 = {
16687
16995
  };
16688
16996
 
16689
16997
  // src/cli/ui/slash/handlers/sessions.ts
16998
+ var STALE_THRESHOLD_DAYS = 90;
16690
16999
  var sessions = (_args, loop2) => {
16691
17000
  const items = listSessions();
16692
17001
  if (items.length === 0) {
@@ -16694,17 +17003,28 @@ var sessions = (_args, loop2) => {
16694
17003
  info: "no saved sessions yet \u2014 chat normally and your messages will be saved automatically"
16695
17004
  };
16696
17005
  }
17006
+ const now = Date.now();
16697
17007
  const lines = ["Saved sessions:"];
17008
+ let staleCount = 0;
16698
17009
  for (const s of items) {
16699
17010
  const sizeKb = (s.size / 1024).toFixed(1);
16700
17011
  const when = s.mtime.toISOString().replace("T", " ").slice(0, 16);
16701
17012
  const marker = s.name === loop2.sessionName ? "\u25B8" : " ";
17013
+ const ageDays = Math.floor((now - s.mtime.getTime()) / (24 * 60 * 60 * 1e3));
17014
+ const isStale = ageDays >= STALE_THRESHOLD_DAYS;
17015
+ const ageTag = isStale ? ` (${ageDays}d \u2014 stale)` : "";
17016
+ if (isStale) staleCount++;
16702
17017
  lines.push(
16703
- ` ${marker} ${s.name.padEnd(22)} ${String(s.messageCount).padStart(5)} msgs ${sizeKb.padStart(7)} KB ${when}`
17018
+ ` ${marker} ${s.name.padEnd(22)} ${String(s.messageCount).padStart(5)} msgs ${sizeKb.padStart(7)} KB ${when}${ageTag}`
16704
17019
  );
16705
17020
  }
16706
17021
  lines.push("");
16707
17022
  lines.push("Resume with: reasonix chat --session <name>");
17023
+ if (staleCount > 0) {
17024
+ lines.push(
17025
+ `${staleCount} session${staleCount === 1 ? "" : "s"} idle \u2265${STALE_THRESHOLD_DAYS} days \u2014 /prune-sessions to remove`
17026
+ );
17027
+ }
16708
17028
  return { info: lines.join("\n") };
16709
17029
  };
16710
17030
  var forget = (_args, loop2) => {
@@ -16717,9 +17037,26 @@ var forget = (_args, loop2) => {
16717
17037
  info: ok ? `\u25B8 deleted session "${name}" \u2014 current screen still shows the conversation, but next launch starts fresh` : `could not delete session "${name}" (already gone?)`
16718
17038
  };
16719
17039
  };
17040
+ var pruneSessions = (args) => {
17041
+ const raw = args?.[0];
17042
+ const days = raw ? Number.parseInt(raw, 10) : STALE_THRESHOLD_DAYS;
17043
+ if (!Number.isFinite(days) || days < 1) {
17044
+ return {
17045
+ info: `\u25B8 usage: /prune-sessions [days] \u2014 defaults to ${STALE_THRESHOLD_DAYS}, must be \u22651`
17046
+ };
17047
+ }
17048
+ const removed = pruneStaleSessions(days);
17049
+ if (removed.length === 0) {
17050
+ return { info: `\u25B8 nothing to prune \u2014 no sessions idle \u2265${days} days` };
17051
+ }
17052
+ return {
17053
+ info: `\u25B8 pruned ${removed.length} session${removed.length === 1 ? "" : "s"} idle \u2265${days} days: ${removed.join(", ")}`
17054
+ };
17055
+ };
16720
17056
  var handlers14 = {
16721
17057
  sessions,
16722
- forget
17058
+ forget,
17059
+ "prune-sessions": pruneSessions
16723
17060
  };
16724
17061
 
16725
17062
  // src/cli/ui/slash/handlers/skill.ts
@@ -16845,13 +17182,21 @@ function useCompletionPickers({
16845
17182
  });
16846
17183
  }, [slashMatches]);
16847
17184
  const [atSelected, setAtSelected] = useState6(0);
16848
- const atFiles = useMemo2(() => {
16849
- if (!codeMode) return [];
16850
- try {
16851
- return listFilesWithStatsSync(rootDir, { maxResults: 500 });
16852
- } catch {
16853
- return [];
17185
+ const [atFiles, setAtFiles] = useState6([]);
17186
+ useEffect3(() => {
17187
+ if (!codeMode) {
17188
+ setAtFiles([]);
17189
+ return;
16854
17190
  }
17191
+ let cancelled = false;
17192
+ listFilesWithStatsAsync(rootDir, { maxResults: 500 }).then((files) => {
17193
+ if (!cancelled) setAtFiles(files);
17194
+ }).catch(() => {
17195
+ if (!cancelled) setAtFiles([]);
17196
+ });
17197
+ return () => {
17198
+ cancelled = true;
17199
+ };
16855
17200
  }, [codeMode, rootDir]);
16856
17201
  const recentFilesRef = useRef3([]);
16857
17202
  const recordRecentFile = useCallback((p) => {
@@ -17325,6 +17670,7 @@ function App({
17325
17670
  transcript,
17326
17671
  harvest: harvest3,
17327
17672
  branch: branch2,
17673
+ budgetUsd,
17328
17674
  session,
17329
17675
  tools,
17330
17676
  mcpSpecs,
@@ -17538,6 +17884,7 @@ function App({
17538
17884
  model: model2,
17539
17885
  harvest: harvest3,
17540
17886
  branch: branch2,
17887
+ budgetUsd,
17541
17888
  session,
17542
17889
  hooks: hookList,
17543
17890
  hookCwd: currentRootDir,
@@ -17548,7 +17895,7 @@ function App({
17548
17895
  });
17549
17896
  loopRef.current = l;
17550
17897
  return l;
17551
- }, [model2, system, harvest3, branch2, session, tools, codeMode]);
17898
+ }, [model2, system, harvest3, branch2, budgetUsd, session, tools, codeMode]);
17552
17899
  useEffect6(() => {
17553
17900
  loop2.hooks = hookList;
17554
17901
  }, [loop2, hookList]);
@@ -19793,7 +20140,8 @@ Continue executing from the next pending step. Call mark_step_complete after eac
19793
20140
  updateAvailable,
19794
20141
  proArmed,
19795
20142
  escalated: turnOnPro,
19796
- dashboardUrl
20143
+ dashboardUrl,
20144
+ budgetUsd: loop2.budgetUsd
19797
20145
  }
19798
20146
  ), /* @__PURE__ */ React24.createElement(Static, { items: historical }, (item) => /* @__PURE__ */ React24.createElement(EventRow, { key: item.id, event: item, projectRoot: currentRootDir })), !historical.some((e) => e.role === "user" || e.role === "assistant") && !busy && !streaming ? /* @__PURE__ */ React24.createElement(WelcomeBanner, { inCodeMode: !!codeMode }) : null, !PLAIN_UI && !pendingShell && !pendingWorkspace && !pendingPlan && !stagedInput && !pendingEditReview && !pendingCheckpoint && !stagedCheckpointRevise && streaming ? /* @__PURE__ */ React24.createElement(Box22, { marginY: 1 }, /* @__PURE__ */ React24.createElement(EventRow, { event: streaming, projectRoot: currentRootDir })) : null, !PLAIN_UI && !pendingShell && !pendingWorkspace && !pendingPlan && !stagedInput && !pendingEditReview && !pendingCheckpoint && !stagedCheckpointRevise && ongoingTool ? /* @__PURE__ */ React24.createElement(OngoingToolRow, { tool: ongoingTool, progress: toolProgress }) : null, !PLAIN_UI && !pendingShell && !pendingWorkspace && !pendingPlan && !stagedInput && !pendingEditReview && !pendingCheckpoint && !stagedCheckpointRevise && subagentActivity ? /* @__PURE__ */ React24.createElement(SubagentRow, { activity: subagentActivity }) : null, !PLAIN_UI && !pendingShell && !pendingWorkspace && !pendingPlan && !stagedInput && !pendingEditReview && !pendingCheckpoint && !stagedCheckpointRevise && !ongoingTool && statusLine ? /* @__PURE__ */ React24.createElement(StatusRow, { text: statusLine }) : null, !PLAIN_UI && undoBanner && !pendingShell && !pendingWorkspace && !pendingPlan && !stagedInput && !pendingEditReview && !pendingCheckpoint && !stagedCheckpointRevise && !pendingChoice && !stagedChoiceCustom && !pendingRevision ? /* @__PURE__ */ React24.createElement(UndoBanner, { banner: undoBanner }) : null, !PLAIN_UI && !pendingShell && !pendingWorkspace && !pendingPlan && !stagedInput && !pendingEditReview && !pendingCheckpoint && !stagedCheckpointRevise && busy && !streaming && !ongoingTool && !statusLine ? /* @__PURE__ */ React24.createElement(StatusRow, { text: "processing\u2026" }) : null, stagedInput ? /* @__PURE__ */ React24.createElement(
19799
20147
  PlanRefineInput,
@@ -20064,6 +20412,7 @@ function Root({
20064
20412
  transcript: appProps.transcript,
20065
20413
  harvest: appProps.harvest,
20066
20414
  branch: appProps.branch,
20415
+ budgetUsd: appProps.budgetUsd,
20067
20416
  session: appProps.session,
20068
20417
  tools,
20069
20418
  mcpSpecs,
@@ -20225,6 +20574,7 @@ async function codeCommand(opts = {}) {
20225
20574
  await chatCommand({
20226
20575
  model: opts.model ?? "deepseek-v4-flash",
20227
20576
  harvest: opts.harvest ?? false,
20577
+ budgetUsd: opts.budgetUsd,
20228
20578
  system: codeSystemPrompt2(rootDir, { hasSemanticSearch: semantic2.enabled }),
20229
20579
  transcript: opts.transcript,
20230
20580
  session,
@@ -20237,7 +20587,7 @@ async function codeCommand(opts = {}) {
20237
20587
  }
20238
20588
 
20239
20589
  // src/cli/commands/diff.ts
20240
- import { writeFileSync as writeFileSync12 } from "fs";
20590
+ import { writeFileSync as writeFileSync13 } from "fs";
20241
20591
  import { basename as basename3 } from "path";
20242
20592
  import { render as render2 } from "ink";
20243
20593
  import React30 from "react";
@@ -20384,7 +20734,7 @@ async function diffCommand(opts) {
20384
20734
  if (wantMarkdown) {
20385
20735
  console.log(renderSummaryTable(report));
20386
20736
  const md = renderMarkdown(report);
20387
- writeFileSync12(opts.mdPath, md, "utf8");
20737
+ writeFileSync13(opts.mdPath, md, "utf8");
20388
20738
  console.log(`
20389
20739
  markdown report written to ${opts.mdPath}`);
20390
20740
  return;
@@ -20961,7 +21311,8 @@ async function runCommand2(opts) {
20961
21311
  tools,
20962
21312
  model: opts.model,
20963
21313
  harvest: opts.harvest,
20964
- branch: opts.branch
21314
+ branch: opts.branch,
21315
+ budgetUsd: opts.budgetUsd
20965
21316
  });
20966
21317
  const prefixHash = prefix.fingerprint;
20967
21318
  let transcriptStream = null;
@@ -21517,6 +21868,17 @@ Your training data has a cutoff. When an answer's correctness depends on somethi
21517
21868
  The signal isn't a topic list \u2014 it's: "if I'm wrong about this, is it because reality moved on?". If yes, ground the answer in fresh evidence; if no (definitions, mechanisms, well-established APIs), answer from memory.
21518
21869
 
21519
21870
  ${ESCALATION_CONTRACT}`;
21871
+ function parseBudgetFlag(raw) {
21872
+ if (raw === void 0) return void 0;
21873
+ if (!Number.isFinite(raw) || raw <= 0) {
21874
+ process.stderr.write(
21875
+ `\u25B2 ignoring --budget=${raw} (must be a positive number) \u2014 running with no cap
21876
+ `
21877
+ );
21878
+ return void 0;
21879
+ }
21880
+ return raw;
21881
+ }
21520
21882
  var program = new Command();
21521
21883
  program.name("reasonix").description("DeepSeek-native agent framework \u2014 built for cache hits and cheap tokens.").version(VERSION).option(
21522
21884
  "-c, --continue",
@@ -21554,6 +21916,10 @@ program.command("code [dir]").description(
21554
21916
  ).option("-m, --model <id>", "Override default model (v4-flash)").option("--no-session", "Disable session persistence for this run").option("-r, --resume", "Skip the session picker \u2014 always continue prior messages").option("-n, --new", "Skip the session picker \u2014 always wipe prior messages and start fresh").option("--transcript <path>", "Write a JSONL transcript to this path").option(
21555
21917
  "--harvest",
21556
21918
  "Opt-in Pillar-2 plan-state extraction. Adds one flash call per turn; off by default (no preset enables it)."
21919
+ ).option(
21920
+ "--budget <usd>",
21921
+ "Soft USD cap on session spend. Off by default. Warns at 80%, refuses next turn at 100%. Mid-session: /budget <usd> or /budget off.",
21922
+ (v) => Number.parseFloat(v)
21557
21923
  ).option(
21558
21924
  "--no-dashboard",
21559
21925
  "Suppress the auto-launched embedded web dashboard. Default behavior boots it on TUI mount and shows the URL in the status bar (clickable in OSC-8-aware terminals)."
@@ -21566,6 +21932,7 @@ program.command("code [dir]").description(
21566
21932
  forceResume: !!opts.resume,
21567
21933
  forceNew: !!opts.new,
21568
21934
  harvest: !!opts.harvest,
21935
+ budgetUsd: parseBudgetFlag(opts.budget),
21569
21936
  noDashboard: opts.dashboard === false
21570
21937
  });
21571
21938
  });
@@ -21579,6 +21946,10 @@ program.command("chat").description("Interactive Ink TUI with live cache/cost pa
21579
21946
  "--branch <n>",
21580
21947
  "Self-consistency: run N parallel samples per turn (N\xD7 cost). Manual only \u2014 never auto-enabled.",
21581
21948
  (v) => Number.parseInt(v, 10)
21949
+ ).option(
21950
+ "--budget <usd>",
21951
+ "Soft USD cap on session spend. Off by default. Warns at 80%, refuses next turn at 100%. Mid-session: /budget <usd> or /budget off.",
21952
+ (v) => Number.parseFloat(v)
21582
21953
  ).option("--session <name>", "Use a named session (default: from config, usually 'default').").option("--no-session", "Disable session persistence for this run (ephemeral chat)").option("-r, --resume", "Skip the session picker \u2014 always continue prior messages").option(
21583
21954
  "-c, --continue",
21584
21955
  "Resume the most-recently-used session (any name) without showing the picker."
@@ -21616,6 +21987,7 @@ program.command("chat").description("Interactive Ink TUI with live cache/cost pa
21616
21987
  transcript: opts.transcript,
21617
21988
  harvest: defaults.harvest,
21618
21989
  branch: defaults.branch,
21990
+ budgetUsd: parseBudgetFlag(opts.budget),
21619
21991
  session: continueOpts.session,
21620
21992
  mcp: defaults.mcp,
21621
21993
  mcpPrefix: opts.mcpPrefix,
@@ -21628,6 +22000,10 @@ program.command("run <task>").description("Run a single task non-interactively,
21628
22000
  "--branch <n>",
21629
22001
  "Self-consistency: run N parallel samples per turn and pick the most confident",
21630
22002
  (v) => Number.parseInt(v, 10)
22003
+ ).option(
22004
+ "--budget <usd>",
22005
+ "Soft USD cap on session spend. Off by default. Refuses to start a new turn once cumulative cost \u2265 cap.",
22006
+ (v) => Number.parseFloat(v)
21631
22007
  ).option("--transcript <path>", "Write a JSONL transcript to this path for replay/diff").option(
21632
22008
  "--mcp <spec>",
21633
22009
  'MCP server spec; repeatable. "name=cmd args...", "cmd args...", or a URL (http/https \u2192 SSE).',
@@ -21651,6 +22027,7 @@ program.command("run <task>").description("Run a single task non-interactively,
21651
22027
  system: applyMemoryStack(opts.system, process.cwd()),
21652
22028
  harvest: defaults.harvest,
21653
22029
  branch: defaults.branch,
22030
+ budgetUsd: parseBudgetFlag(opts.budget),
21654
22031
  transcript: opts.transcript,
21655
22032
  mcp: defaults.mcp,
21656
22033
  mcpPrefix: opts.mcpPrefix