reasonix 0.3.0-alpha.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -684,6 +684,16 @@ var AppendOnlyLog = class {
684
684
  extend(messages) {
685
685
  for (const m of messages) this.append(m);
686
686
  }
687
+ /**
688
+ * Bulk-replace entries. Intentionally named to be hard to reach for —
689
+ * this is the one mutation path that breaks the log's append-only
690
+ * spirit, reserved for compaction flows (`/compact`) and recovery
691
+ * where the caller has consciously decided to drop old history. Any
692
+ * other use is almost certainly wrong; append() is what you want.
693
+ */
694
+ compactInPlace(replacement) {
695
+ this._entries = [...replacement];
696
+ }
687
697
  get entries() {
688
698
  return this._entries;
689
699
  }
@@ -962,7 +972,8 @@ import {
962
972
  readFileSync as readFileSync2,
963
973
  readdirSync,
964
974
  statSync,
965
- unlinkSync
975
+ unlinkSync,
976
+ writeFileSync as writeFileSync2
966
977
  } from "fs";
967
978
  import { homedir as homedir2 } from "os";
968
979
  import { dirname as dirname2, join as join2 } from "path";
@@ -1031,6 +1042,17 @@ function deleteSession(name) {
1031
1042
  return false;
1032
1043
  }
1033
1044
  }
1045
+ function rewriteSession(name, messages) {
1046
+ const path = sessionPath(name);
1047
+ mkdirSync2(dirname2(path), { recursive: true });
1048
+ const body = messages.map((m) => JSON.stringify(m)).join("\n");
1049
+ writeFileSync2(path, body ? `${body}
1050
+ ` : "", "utf8");
1051
+ try {
1052
+ chmodSync2(path, 384);
1053
+ } catch {
1054
+ }
1055
+ }
1034
1056
  function countLines(path) {
1035
1057
  try {
1036
1058
  const raw = readFileSync2(path, "utf8");
@@ -1046,6 +1068,11 @@ var DEEPSEEK_PRICING = {
1046
1068
  "deepseek-reasoner": { inputCacheHit: 0.14, inputCacheMiss: 0.55, output: 2.19 }
1047
1069
  };
1048
1070
  var CLAUDE_SONNET_PRICING = { input: 3, output: 15 };
1071
+ var DEEPSEEK_CONTEXT_TOKENS = {
1072
+ "deepseek-chat": 131072,
1073
+ "deepseek-reasoner": 131072
1074
+ };
1075
+ var DEFAULT_CONTEXT_TOKENS = 131072;
1049
1076
  function costUsd(model, usage) {
1050
1077
  const p = DEEPSEEK_PRICING[model];
1051
1078
  if (!p) return 0;
@@ -1089,12 +1116,14 @@ var SessionStats = class {
1089
1116
  return denom > 0 ? hit / denom : 0;
1090
1117
  }
1091
1118
  summary() {
1119
+ const last = this.turns[this.turns.length - 1];
1092
1120
  return {
1093
1121
  turns: this.turns.length,
1094
1122
  totalCostUsd: round(this.totalCost, 6),
1095
1123
  claudeEquivalentUsd: round(this.totalClaudeEquivalent, 6),
1096
1124
  savingsVsClaudePct: round(this.savingsVsClaude * 100, 2),
1097
- cacheHitRatio: round(this.aggregateCacheHitRatio, 4)
1125
+ cacheHitRatio: round(this.aggregateCacheHitRatio, 4),
1126
+ lastPromptTokens: last?.usage.promptTokens ?? 0
1098
1127
  };
1099
1128
  }
1100
1129
  };
@@ -1131,7 +1160,7 @@ var CacheFirstLoop = class {
1131
1160
  this.prefix = opts.prefix;
1132
1161
  this.tools = opts.tools ?? new ToolRegistry();
1133
1162
  this.model = opts.model ?? "deepseek-chat";
1134
- this.maxToolIters = opts.maxToolIters ?? 8;
1163
+ this.maxToolIters = opts.maxToolIters ?? 24;
1135
1164
  if (typeof opts.branch === "number") {
1136
1165
  this.branchOptions = { budget: opts.branch };
1137
1166
  } else if (opts.branch && typeof opts.branch === "object") {
@@ -1166,6 +1195,33 @@ var CacheFirstLoop = class {
1166
1195
  this.resumedMessageCount = 0;
1167
1196
  }
1168
1197
  }
1198
+ /**
1199
+ * Shrink the log by re-truncating oversized tool results to a tighter
1200
+ * cap, and persist the result back to disk so the next launch doesn't
1201
+ * re-inherit a fat session file. Returns a summary the TUI can
1202
+ * display.
1203
+ *
1204
+ * Only tool-role messages are touched (same rationale as
1205
+ * {@link healLoadedMessages}). User and assistant messages carry
1206
+ * authored intent we can't mechanically shrink without losing
1207
+ * meaning.
1208
+ */
1209
+ compact(tightCapChars = 4e3) {
1210
+ const before = this.log.toMessages();
1211
+ const { messages, healedCount, healedFrom } = healLoadedMessages(before, tightCapChars);
1212
+ const afterBytes = messages.filter((m) => m.role === "tool").reduce((s, m) => s + (typeof m.content === "string" ? m.content.length : 0), 0);
1213
+ const charsSaved = healedFrom - afterBytes;
1214
+ if (healedCount > 0) {
1215
+ this.log.compactInPlace(messages);
1216
+ if (this.sessionName) {
1217
+ try {
1218
+ rewriteSession(this.sessionName, messages);
1219
+ } catch {
1220
+ }
1221
+ }
1222
+ }
1223
+ return { healedCount, charsSaved };
1224
+ }
1169
1225
  appendAndPersist(message) {
1170
1226
  this.log.append(message);
1171
1227
  if (this.sessionName) {
@@ -1403,7 +1459,38 @@ var CacheFirstLoop = class {
1403
1459
  };
1404
1460
  }
1405
1461
  }
1406
- yield { turn: this._turn, role: "done", content: "[max_tool_iters reached]" };
1462
+ yield* this.forceSummaryAfterIterLimit();
1463
+ }
1464
+ async *forceSummaryAfterIterLimit() {
1465
+ try {
1466
+ const messages = this.buildMessages(null);
1467
+ const resp = await this.client.chat({
1468
+ model: this.model,
1469
+ messages
1470
+ // no tools → model is forced to answer in text
1471
+ });
1472
+ const summary = resp.content?.trim() || "(model returned no text; try a narrower question or raise --max-tool-iters)";
1473
+ const annotated = `[tool-call budget (${this.maxToolIters}) reached \u2014 forcing summary from what I found]
1474
+
1475
+ ${summary}`;
1476
+ const summaryStats = this.stats.record(this._turn, this.model, resp.usage ?? new Usage());
1477
+ this.appendAndPersist({ role: "assistant", content: summary });
1478
+ yield {
1479
+ turn: this._turn,
1480
+ role: "assistant_final",
1481
+ content: annotated,
1482
+ stats: summaryStats
1483
+ };
1484
+ yield { turn: this._turn, role: "done", content: summary };
1485
+ } catch (err) {
1486
+ yield {
1487
+ turn: this._turn,
1488
+ role: "error",
1489
+ content: "",
1490
+ error: `tool-call budget (${this.maxToolIters}) reached and the fallback summary call failed: ${err.message}. Run /clear and retry with a narrower question, or pass --max-tool-iters higher.`
1491
+ };
1492
+ yield { turn: this._turn, role: "done", content: "" };
1493
+ }
1407
1494
  }
1408
1495
  async run(userInput, onEvent) {
1409
1496
  let final = "";
@@ -1644,12 +1731,14 @@ function summarizeTurns(turns) {
1644
1731
  }
1645
1732
  const cacheHitRatio = hit + miss > 0 ? hit / (hit + miss) : 0;
1646
1733
  const savingsVsClaude = totalClaude > 0 ? 1 - totalCost / totalClaude : 0;
1734
+ const lastTurn = turns[turns.length - 1];
1647
1735
  return {
1648
1736
  turns: turns.length,
1649
1737
  totalCostUsd: round2(totalCost, 6),
1650
1738
  claudeEquivalentUsd: round2(totalClaude, 6),
1651
1739
  savingsVsClaudePct: round2(savingsVsClaude * 100, 2),
1652
- cacheHitRatio: round2(cacheHitRatio, 4)
1740
+ cacheHitRatio: round2(cacheHitRatio, 4),
1741
+ lastPromptTokens: lastTurn?.usage.promptTokens ?? 0
1653
1742
  };
1654
1743
  }
1655
1744
  function round2(n, digits) {
@@ -2444,7 +2533,7 @@ function parseMcpSpec(input) {
2444
2533
  }
2445
2534
 
2446
2535
  // src/index.ts
2447
- var VERSION = "0.3.0-alpha.6";
2536
+ var VERSION = "0.3.1";
2448
2537
 
2449
2538
  // src/cli/commands/chat.tsx
2450
2539
  import { render } from "ink";
@@ -2772,7 +2861,15 @@ function StatsPanel({
2772
2861
  const hitPct = (summary.cacheHitRatio * 100).toFixed(1);
2773
2862
  const hitColor = summary.cacheHitRatio >= 0.7 ? "green" : summary.cacheHitRatio >= 0.4 ? "yellow" : "red";
2774
2863
  const branchOn = (branchBudget ?? 1) > 1;
2775
- return /* @__PURE__ */ React5.createElement(Box5, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1 }, /* @__PURE__ */ React5.createElement(Box5, { justifyContent: "space-between" }, /* @__PURE__ */ React5.createElement(Text5, null, /* @__PURE__ */ React5.createElement(Text5, { color: "cyan", bold: true }, "Reasonix"), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " \xB7 model "), /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, model), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " \xB7 prefix "), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, prefixHash), harvestOn ? /* @__PURE__ */ React5.createElement(Text5, { color: "magenta" }, " \xB7 harvest") : null, branchOn ? /* @__PURE__ */ React5.createElement(Text5, { color: "blue" }, " \xB7 branch", branchBudget) : null), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "turns ", summary.turns, " \xB7 type /help")), /* @__PURE__ */ React5.createElement(Box5, { marginTop: 1, gap: 3 }, /* @__PURE__ */ React5.createElement(Text5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "cache hit "), /* @__PURE__ */ React5.createElement(Text5, { color: hitColor, bold: true }, hitPct, "%")), /* @__PURE__ */ React5.createElement(Text5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "cost "), /* @__PURE__ */ React5.createElement(Text5, { color: "green" }, "$", summary.totalCostUsd.toFixed(6))), /* @__PURE__ */ React5.createElement(Text5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "vs Claude "), /* @__PURE__ */ React5.createElement(Text5, null, "$", summary.claudeEquivalentUsd.toFixed(6))), /* @__PURE__ */ React5.createElement(Text5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "saving "), /* @__PURE__ */ React5.createElement(Text5, { color: "green", bold: true }, summary.savingsVsClaudePct.toFixed(1), "%"))));
2864
+ const ctxMax = DEEPSEEK_CONTEXT_TOKENS[model] ?? DEFAULT_CONTEXT_TOKENS;
2865
+ const ctxRatio = summary.lastPromptTokens / ctxMax;
2866
+ const ctxColor = ctxRatio >= 0.8 ? "red" : ctxRatio >= 0.5 ? "yellow" : void 0;
2867
+ return /* @__PURE__ */ React5.createElement(Box5, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1 }, /* @__PURE__ */ React5.createElement(Box5, { justifyContent: "space-between" }, /* @__PURE__ */ React5.createElement(Text5, null, /* @__PURE__ */ React5.createElement(Text5, { color: "cyan", bold: true }, "Reasonix"), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " \xB7 model "), /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, model), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " \xB7 prefix "), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, prefixHash), harvestOn ? /* @__PURE__ */ React5.createElement(Text5, { color: "magenta" }, " \xB7 harvest") : null, branchOn ? /* @__PURE__ */ React5.createElement(Text5, { color: "blue" }, " \xB7 branch", branchBudget) : null), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "turns ", summary.turns, " \xB7 type /help")), /* @__PURE__ */ React5.createElement(Box5, { marginTop: 1, gap: 3 }, /* @__PURE__ */ React5.createElement(Text5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "cache hit "), /* @__PURE__ */ React5.createElement(Text5, { color: hitColor, bold: true }, hitPct, "%")), /* @__PURE__ */ React5.createElement(Text5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "cost "), /* @__PURE__ */ React5.createElement(Text5, { color: "green" }, "$", summary.totalCostUsd.toFixed(6))), /* @__PURE__ */ React5.createElement(Text5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "vs Claude "), /* @__PURE__ */ React5.createElement(Text5, null, "$", summary.claudeEquivalentUsd.toFixed(6))), /* @__PURE__ */ React5.createElement(Text5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "saving "), /* @__PURE__ */ React5.createElement(Text5, { color: "green", bold: true }, summary.savingsVsClaudePct.toFixed(1), "%")), summary.lastPromptTokens > 0 ? /* @__PURE__ */ React5.createElement(Text5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "ctx "), /* @__PURE__ */ React5.createElement(Text5, { color: ctxColor, bold: ctxColor !== void 0 }, formatTokens(summary.lastPromptTokens), "/", formatTokens(ctxMax)), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " (", (ctxRatio * 100).toFixed(0), "%)"), ctxRatio >= 0.8 ? /* @__PURE__ */ React5.createElement(Text5, { color: "red", bold: true }, " ", "\xB7 /compact") : null) : null));
2868
+ }
2869
+ function formatTokens(n) {
2870
+ if (n < 1e3) return String(n);
2871
+ const k = n / 1e3;
2872
+ return k >= 100 ? `${k.toFixed(0)}k` : `${k.toFixed(1)}k`;
2776
2873
  }
2777
2874
 
2778
2875
  // src/cli/ui/slash.ts
@@ -2803,6 +2900,7 @@ function handleSlash(cmd, args, loop, ctx = {}) {
2803
2900
  " /branch <N|off> run N parallel samples (N>=2), pick most confident",
2804
2901
  " /mcp list MCP servers + tools attached to this session",
2805
2902
  " /setup (exit + reconfigure) \u2192 run `reasonix setup`",
2903
+ " /compact [cap] shrink large tool results in history (default 4k/result)",
2806
2904
  " /sessions list saved sessions (current is marked with \u25B8)",
2807
2905
  " /forget delete the current session from disk",
2808
2906
  " /clear clear displayed history (log + session kept)",
@@ -2844,6 +2942,19 @@ function handleSlash(cmd, args, loop, ctx = {}) {
2844
2942
  return {
2845
2943
  info: "To reconfigure (preset, MCP servers, API key), exit this chat and run `reasonix setup`. Changes take effect on next launch."
2846
2944
  };
2945
+ case "compact": {
2946
+ const tight = Number.parseInt(args[0] ?? "", 10);
2947
+ const cap = Number.isFinite(tight) && tight >= 500 ? tight : 4e3;
2948
+ const { healedCount, charsSaved } = loop.compact(cap);
2949
+ if (healedCount === 0) {
2950
+ return {
2951
+ info: `\u25B8 nothing to compact \u2014 no tool result in history exceeds ${cap.toLocaleString()} chars.`
2952
+ };
2953
+ }
2954
+ return {
2955
+ info: `\u25B8 compacted ${healedCount} tool result(s), saved ${charsSaved.toLocaleString()} chars (~${Math.round(charsSaved / 4).toLocaleString()} tokens). Session file rewritten.`
2956
+ };
2957
+ }
2847
2958
  case "sessions": {
2848
2959
  const items = listSessions();
2849
2960
  if (items.length === 0) {
@@ -2953,7 +3064,8 @@ function App({
2953
3064
  totalCostUsd: 0,
2954
3065
  claudeEquivalentUsd: 0,
2955
3066
  savingsVsClaudePct: 0,
2956
- cacheHitRatio: 0
3067
+ cacheHitRatio: 0,
3068
+ lastPromptTokens: 0
2957
3069
  });
2958
3070
  const transcriptRef = useRef(null);
2959
3071
  if (transcript && !transcriptRef.current) {
@@ -3155,7 +3267,7 @@ function App({
3155
3267
  ), /* @__PURE__ */ React6.createElement(Static, { items: historical }, (item) => /* @__PURE__ */ React6.createElement(EventRow, { key: item.id, event: item })), streaming ? /* @__PURE__ */ React6.createElement(Box6, { marginY: 1 }, /* @__PURE__ */ React6.createElement(EventRow, { event: streaming })) : null, /* @__PURE__ */ React6.createElement(PromptInput, { value: input, onChange: setInput, onSubmit: handleSubmit, disabled: busy }), /* @__PURE__ */ React6.createElement(CommandStrip, null));
3156
3268
  }
3157
3269
  function CommandStrip() {
3158
- return /* @__PURE__ */ React6.createElement(Box6, { paddingX: 2 }, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "/help \xB7 /preset ", "<fast|smart|max>", " \xB7 /mcp \xB7 /sessions \xB7 /setup \xB7 /clear \xB7 /exit"));
3270
+ return /* @__PURE__ */ React6.createElement(Box6, { paddingX: 2 }, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "/help \xB7 /preset ", "<fast|smart|max>", " \xB7 /mcp \xB7 /compact \xB7 /sessions \xB7 /setup \xB7 /clear \xB7 /exit"));
3159
3271
  }
3160
3272
  function describeRepair(repair) {
3161
3273
  const parts = [];
@@ -3286,7 +3398,7 @@ async function chatCommand(opts) {
3286
3398
  }
3287
3399
 
3288
3400
  // src/cli/commands/diff.ts
3289
- import { writeFileSync as writeFileSync2 } from "fs";
3401
+ import { writeFileSync as writeFileSync3 } from "fs";
3290
3402
  import { basename } from "path";
3291
3403
  import { render as render2 } from "ink";
3292
3404
  import React11 from "react";
@@ -3432,7 +3544,7 @@ async function diffCommand(opts) {
3432
3544
  if (wantMarkdown) {
3433
3545
  console.log(renderSummaryTable(report));
3434
3546
  const md = renderMarkdown(report);
3435
- writeFileSync2(opts.mdPath, md, "utf8");
3547
+ writeFileSync3(opts.mdPath, md, "utf8");
3436
3548
  console.log(`
3437
3549
  markdown report written to ${opts.mdPath}`);
3438
3550
  return;
@@ -3548,7 +3660,9 @@ function ReplayApp({ meta, pages }) {
3548
3660
  totalCostUsd: cumStats.totalCostUsd,
3549
3661
  claudeEquivalentUsd: cumStats.claudeEquivalentUsd,
3550
3662
  savingsVsClaudePct: cumStats.savingsVsClaudePct,
3551
- cacheHitRatio: cumStats.cacheHitRatio
3663
+ cacheHitRatio: cumStats.cacheHitRatio,
3664
+ // Replay is read-only — no live last-turn prompt tokens to show.
3665
+ lastPromptTokens: 0
3552
3666
  };
3553
3667
  const prefixHash = cumStats.prefixHashes.length === 1 ? cumStats.prefixHashes[0].slice(0, 16) : cumStats.prefixHashes.length === 0 ? "(untracked)" : `(churned \xD7${cumStats.prefixHashes.length})`;
3554
3668
  const currentPage = pages[idx];