reasonix 0.3.2 → 0.4.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
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import "./chunk-2P2MZLCE.js";
2
3
 
3
4
  // src/cli/index.ts
4
5
  import { Command } from "commander";
@@ -95,8 +96,8 @@ function computeWait(attempt, initial, cap, retryAfter) {
95
96
  }
96
97
  function sleep(ms, signal) {
97
98
  if (ms <= 0) return Promise.resolve();
98
- return new Promise((resolve2, reject) => {
99
- const timer = setTimeout(resolve2, ms);
99
+ return new Promise((resolve4, reject) => {
100
+ const timer = setTimeout(resolve4, ms);
100
101
  if (signal) {
101
102
  const onAbort = () => {
102
103
  clearTimeout(timer);
@@ -1166,7 +1167,7 @@ var CacheFirstLoop = class {
1166
1167
  this.prefix = opts.prefix;
1167
1168
  this.tools = opts.tools ?? new ToolRegistry();
1168
1169
  this.model = opts.model ?? "deepseek-chat";
1169
- this.maxToolIters = opts.maxToolIters ?? 24;
1170
+ this.maxToolIters = opts.maxToolIters ?? 64;
1170
1171
  if (typeof opts.branch === "number") {
1171
1172
  this.branchOptions = { budget: opts.branch };
1172
1173
  } else if (opts.branch && typeof opts.branch === "object") {
@@ -1355,8 +1356,8 @@ var CacheFirstLoop = class {
1355
1356
  }
1356
1357
  );
1357
1358
  for (let k = 0; k < budget; k++) {
1358
- const sample = queue.shift() ?? await new Promise((resolve2) => {
1359
- waiter = resolve2;
1359
+ const sample = queue.shift() ?? await new Promise((resolve4) => {
1360
+ waiter = resolve4;
1360
1361
  });
1361
1362
  yield {
1362
1363
  turn: this._turn,
@@ -1476,9 +1477,28 @@ var CacheFirstLoop = class {
1476
1477
  yield { turn: this._turn, role: "done", content: assistantContent };
1477
1478
  return;
1478
1479
  }
1480
+ const ctxMax = DEEPSEEK_CONTEXT_TOKENS[this.model] ?? DEFAULT_CONTEXT_TOKENS;
1481
+ if (usage && usage.promptTokens / ctxMax > 0.8) {
1482
+ yield {
1483
+ turn: this._turn,
1484
+ role: "warning",
1485
+ content: `context ${usage.promptTokens}/${ctxMax} (${Math.round(
1486
+ usage.promptTokens / ctxMax * 100
1487
+ )}%) \u2014 more tools would overflow. Forcing summary from what was gathered.`
1488
+ };
1489
+ yield* this.forceSummaryAfterIterLimit({ reason: "context-guard" });
1490
+ return;
1491
+ }
1479
1492
  for (const call of repairedCalls) {
1480
1493
  const name = call.function?.name ?? "";
1481
1494
  const args = call.function?.arguments ?? "{}";
1495
+ yield {
1496
+ turn: this._turn,
1497
+ role: "tool_start",
1498
+ content: "",
1499
+ toolName: name,
1500
+ toolArgs: args
1501
+ };
1482
1502
  const result = await this.tools.dispatch(name, args);
1483
1503
  this.appendAndPersist({
1484
1504
  role: "tool",
@@ -1506,7 +1526,7 @@ var CacheFirstLoop = class {
1506
1526
  // no tools → model is forced to answer in text
1507
1527
  });
1508
1528
  const summary = resp.content?.trim() || "(model returned no text; try a narrower question or raise --max-tool-iters)";
1509
- const reasonPrefix = opts.reason === "aborted" ? "[aborted by user (Esc) \u2014 summarizing what I found so far]" : `[tool-call budget (${this.maxToolIters}) reached \u2014 forcing summary from what I found]`;
1529
+ const reasonPrefix = reasonPrefixFor(opts.reason, this.maxToolIters);
1510
1530
  const annotated = `${reasonPrefix}
1511
1531
 
1512
1532
  ${summary}`;
@@ -1516,11 +1536,12 @@ ${summary}`;
1516
1536
  turn: this._turn,
1517
1537
  role: "assistant_final",
1518
1538
  content: annotated,
1519
- stats: summaryStats
1539
+ stats: summaryStats,
1540
+ forcedSummary: true
1520
1541
  };
1521
1542
  yield { turn: this._turn, role: "done", content: summary };
1522
1543
  } catch (err) {
1523
- const label = opts.reason === "aborted" ? "aborted by user" : `tool-call budget (${this.maxToolIters}) reached`;
1544
+ const label = errorLabelFor(opts.reason, this.maxToolIters);
1524
1545
  yield {
1525
1546
  turn: this._turn,
1526
1547
  role: "error",
@@ -1545,6 +1566,18 @@ ${summary}`;
1545
1566
  return msg;
1546
1567
  }
1547
1568
  };
1569
+ function reasonPrefixFor(reason, iterCap) {
1570
+ if (reason === "aborted") return "[aborted by user (Esc) \u2014 summarizing what I found so far]";
1571
+ if (reason === "context-guard") {
1572
+ return "[context budget running low \u2014 summarizing before the next call would overflow]";
1573
+ }
1574
+ return `[tool-call budget (${iterCap}) reached \u2014 forcing summary from what I found]`;
1575
+ }
1576
+ function errorLabelFor(reason, iterCap) {
1577
+ if (reason === "aborted") return "aborted by user";
1578
+ if (reason === "context-guard") return "context-guard triggered (prompt > 80% of window)";
1579
+ return `tool-call budget (${iterCap}) reached`;
1580
+ }
1548
1581
  function summarizeBranch(chosen, samples) {
1549
1582
  return {
1550
1583
  budget: samples.length,
@@ -2198,7 +2231,7 @@ var McpClient = class {
2198
2231
  async request(method, params) {
2199
2232
  const id = this.nextId++;
2200
2233
  const frame = { jsonrpc: "2.0", id, method, params };
2201
- const promise = new Promise((resolve2, reject) => {
2234
+ const promise = new Promise((resolve4, reject) => {
2202
2235
  const timeout = setTimeout(() => {
2203
2236
  this.pending.delete(id);
2204
2237
  reject(
@@ -2206,7 +2239,7 @@ var McpClient = class {
2206
2239
  );
2207
2240
  }, this.requestTimeoutMs);
2208
2241
  this.pending.set(id, {
2209
- resolve: resolve2,
2242
+ resolve: resolve4,
2210
2243
  reject,
2211
2244
  timeout
2212
2245
  });
@@ -2290,12 +2323,12 @@ var StdioTransport = class {
2290
2323
  }
2291
2324
  async send(message) {
2292
2325
  if (this.closed) throw new Error("MCP transport is closed");
2293
- return new Promise((resolve2, reject) => {
2326
+ return new Promise((resolve4, reject) => {
2294
2327
  const line = `${JSON.stringify(message)}
2295
2328
  `;
2296
2329
  this.child.stdin.write(line, "utf8", (err) => {
2297
2330
  if (err) reject(err);
2298
- else resolve2();
2331
+ else resolve4();
2299
2332
  });
2300
2333
  });
2301
2334
  }
@@ -2306,8 +2339,8 @@ var StdioTransport = class {
2306
2339
  continue;
2307
2340
  }
2308
2341
  if (this.closed) return;
2309
- const next = await new Promise((resolve2) => {
2310
- this.waiters.push(resolve2);
2342
+ const next = await new Promise((resolve4) => {
2343
+ this.waiters.push(resolve4);
2311
2344
  });
2312
2345
  if (next === null) return;
2313
2346
  yield next;
@@ -2373,8 +2406,8 @@ var SseTransport = class {
2373
2406
  constructor(opts) {
2374
2407
  this.url = opts.url;
2375
2408
  this.headers = opts.headers ?? {};
2376
- this.endpointReady = new Promise((resolve2, reject) => {
2377
- this.resolveEndpoint = resolve2;
2409
+ this.endpointReady = new Promise((resolve4, reject) => {
2410
+ this.resolveEndpoint = resolve4;
2378
2411
  this.rejectEndpoint = reject;
2379
2412
  });
2380
2413
  this.endpointReady.catch(() => void 0);
@@ -2401,8 +2434,8 @@ var SseTransport = class {
2401
2434
  continue;
2402
2435
  }
2403
2436
  if (this.closed) return;
2404
- const next = await new Promise((resolve2) => {
2405
- this.waiters.push(resolve2);
2437
+ const next = await new Promise((resolve4) => {
2438
+ this.waiters.push(resolve4);
2406
2439
  });
2407
2440
  if (next === null) return;
2408
2441
  yield next;
@@ -2570,8 +2603,133 @@ function parseMcpSpec(input) {
2570
2603
  return { transport: "stdio", name, command, args };
2571
2604
  }
2572
2605
 
2606
+ // src/code/edit-blocks.ts
2607
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync5, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
2608
+ import { dirname as dirname3, resolve as resolve2 } from "path";
2609
+ var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
2610
+ function parseEditBlocks(text) {
2611
+ const out = [];
2612
+ BLOCK_RE.lastIndex = 0;
2613
+ let m = BLOCK_RE.exec(text);
2614
+ while (m !== null) {
2615
+ out.push({
2616
+ path: m[1].trim(),
2617
+ search: m[2],
2618
+ replace: m[3],
2619
+ offset: m.index
2620
+ });
2621
+ m = BLOCK_RE.exec(text);
2622
+ }
2623
+ return out;
2624
+ }
2625
+ function applyEditBlock(block, rootDir) {
2626
+ const absRoot = resolve2(rootDir);
2627
+ const absTarget = resolve2(absRoot, block.path);
2628
+ if (absTarget !== absRoot && !absTarget.startsWith(`${absRoot}${sep()}`)) {
2629
+ return {
2630
+ path: block.path,
2631
+ status: "path-escape",
2632
+ message: `resolved path ${absTarget} is outside rootDir ${absRoot}`
2633
+ };
2634
+ }
2635
+ const searchEmpty = block.search.length === 0;
2636
+ const exists = existsSync2(absTarget);
2637
+ try {
2638
+ if (!exists) {
2639
+ if (!searchEmpty) {
2640
+ return {
2641
+ path: block.path,
2642
+ status: "file-missing",
2643
+ message: "file does not exist; to create it, use an empty SEARCH block"
2644
+ };
2645
+ }
2646
+ mkdirSync3(dirname3(absTarget), { recursive: true });
2647
+ writeFileSync3(absTarget, block.replace, "utf8");
2648
+ return { path: block.path, status: "created" };
2649
+ }
2650
+ const content = readFileSync5(absTarget, "utf8");
2651
+ if (searchEmpty) {
2652
+ return {
2653
+ path: block.path,
2654
+ status: "not-found",
2655
+ message: "empty SEARCH only creates new files \u2014 this file already exists"
2656
+ };
2657
+ }
2658
+ const idx = content.indexOf(block.search);
2659
+ if (idx === -1) {
2660
+ return {
2661
+ path: block.path,
2662
+ status: "not-found",
2663
+ message: "SEARCH text does not match the current file content exactly"
2664
+ };
2665
+ }
2666
+ const replaced = `${content.slice(0, idx)}${block.replace}${content.slice(idx + block.search.length)}`;
2667
+ writeFileSync3(absTarget, replaced, "utf8");
2668
+ return { path: block.path, status: "applied" };
2669
+ } catch (err) {
2670
+ return { path: block.path, status: "error", message: err.message };
2671
+ }
2672
+ }
2673
+ function applyEditBlocks(blocks, rootDir) {
2674
+ return blocks.map((b) => applyEditBlock(b, rootDir));
2675
+ }
2676
+ function snapshotBeforeEdits(blocks, rootDir) {
2677
+ const absRoot = resolve2(rootDir);
2678
+ const seen = /* @__PURE__ */ new Set();
2679
+ const snapshots = [];
2680
+ for (const b of blocks) {
2681
+ if (seen.has(b.path)) continue;
2682
+ seen.add(b.path);
2683
+ const abs = resolve2(absRoot, b.path);
2684
+ if (!existsSync2(abs)) {
2685
+ snapshots.push({ path: b.path, prevContent: null });
2686
+ continue;
2687
+ }
2688
+ try {
2689
+ snapshots.push({ path: b.path, prevContent: readFileSync5(abs, "utf8") });
2690
+ } catch {
2691
+ snapshots.push({ path: b.path, prevContent: null });
2692
+ }
2693
+ }
2694
+ return snapshots;
2695
+ }
2696
+ function restoreSnapshots(snapshots, rootDir) {
2697
+ const absRoot = resolve2(rootDir);
2698
+ return snapshots.map((snap) => {
2699
+ const abs = resolve2(absRoot, snap.path);
2700
+ if (abs !== absRoot && !abs.startsWith(`${absRoot}${sep()}`)) {
2701
+ return {
2702
+ path: snap.path,
2703
+ status: "path-escape",
2704
+ message: "snapshot path escapes rootDir \u2014 refusing to restore"
2705
+ };
2706
+ }
2707
+ try {
2708
+ if (snap.prevContent === null) {
2709
+ if (existsSync2(abs)) unlinkSync2(abs);
2710
+ return {
2711
+ path: snap.path,
2712
+ status: "applied",
2713
+ message: "removed (the edit had created it)"
2714
+ };
2715
+ }
2716
+ writeFileSync3(abs, snap.prevContent, "utf8");
2717
+ return {
2718
+ path: snap.path,
2719
+ status: "applied",
2720
+ message: "restored to pre-edit content"
2721
+ };
2722
+ } catch (err) {
2723
+ return { path: snap.path, status: "error", message: err.message };
2724
+ }
2725
+ });
2726
+ }
2727
+ function sep() {
2728
+ return process.platform === "win32" ? "\\" : "/";
2729
+ }
2730
+
2573
2731
  // src/index.ts
2574
- var VERSION = "0.3.2";
2732
+ var VERSION = "0.4.1";
2575
2733
 
2576
2734
  // src/cli/commands/chat.tsx
2577
2735
  import { render } from "ink";
@@ -2702,8 +2860,39 @@ function parseBlocks(raw) {
2702
2860
  listBuf = null;
2703
2861
  }
2704
2862
  };
2705
- for (const rawLine of lines) {
2863
+ for (let i = 0; i < lines.length; i++) {
2864
+ const rawLine = lines[i];
2706
2865
  const line = rawLine.replace(/\s+$/g, "");
2866
+ if (!inCode && /^<{7} SEARCH\s*$/.test(line)) {
2867
+ const filename = para.pop()?.trim();
2868
+ if (filename) {
2869
+ flushPara();
2870
+ flushList();
2871
+ let j = i + 1;
2872
+ const searchLines = [];
2873
+ while (j < lines.length && !/^={7}\s*$/.test(lines[j])) {
2874
+ searchLines.push(lines[j]);
2875
+ j++;
2876
+ }
2877
+ const replaceLines = [];
2878
+ let k = j + 1;
2879
+ while (k < lines.length && !/^>{7} REPLACE\s*$/.test(lines[k])) {
2880
+ replaceLines.push(lines[k]);
2881
+ k++;
2882
+ }
2883
+ if (j < lines.length && k < lines.length) {
2884
+ out.push({
2885
+ kind: "edit-block",
2886
+ filename,
2887
+ search: searchLines.join("\n"),
2888
+ replace: replaceLines.join("\n")
2889
+ });
2890
+ i = k;
2891
+ continue;
2892
+ }
2893
+ para.push(filename);
2894
+ }
2895
+ }
2707
2896
  const fence = line.match(/^```(\w*)/);
2708
2897
  if (fence) {
2709
2898
  if (inCode) {
@@ -2781,10 +2970,18 @@ function BlockView({ block }) {
2781
2970
  return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column" }, block.items.map((item, i) => /* @__PURE__ */ React2.createElement(Box2, { key: `${i}-${item.slice(0, 24)}` }, /* @__PURE__ */ React2.createElement(Text2, { color: "cyan" }, block.ordered ? ` ${block.start + i}. ` : " \u2022 "), /* @__PURE__ */ React2.createElement(InlineMd, { text: item }))));
2782
2971
  case "code":
2783
2972
  return /* @__PURE__ */ React2.createElement(Box2, { borderStyle: "single", borderColor: "gray", paddingX: 1 }, /* @__PURE__ */ React2.createElement(Text2, { color: "yellow" }, block.text));
2973
+ case "edit-block":
2974
+ return /* @__PURE__ */ React2.createElement(EditBlockRow, { block });
2784
2975
  case "hr":
2785
2976
  return /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
2786
2977
  }
2787
2978
  }
2979
+ function EditBlockRow({ block }) {
2980
+ const isNewFile = block.search.length === 0;
2981
+ const searchLines = block.search.split("\n");
2982
+ const replaceLines = block.replace.split("\n");
2983
+ return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1 }, /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "cyan" }, block.filename), isNewFile ? /* @__PURE__ */ React2.createElement(Text2, { color: "green", bold: true }, " (new file)") : null), isNewFile ? null : /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column", marginTop: 1 }, searchLines.map((line, i) => /* @__PURE__ */ React2.createElement(Text2, { key: `s-${i}-${line.length}`, color: "red" }, `- ${line}`))), /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column", marginTop: isNewFile ? 1 : 0 }, replaceLines.map((line, i) => /* @__PURE__ */ React2.createElement(Text2, { key: `r-${i}-${line.length}`, color: "green" }, `+ ${line}`))));
2984
+ }
2788
2985
  function Markdown({ text }) {
2789
2986
  const cleaned = stripMath(text);
2790
2987
  const blocks = React2.useMemo(() => parseBlocks(cleaned), [cleaned]);
@@ -2850,7 +3047,17 @@ function StreamingAssistant({ event }) {
2850
3047
  }
2851
3048
  const tail = lastLine(event.text, 140);
2852
3049
  const reasoningTail = event.reasoning ? lastLine(event.reasoning, 120) : "";
2853
- return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Text3, { bold: true, color: "green" }, "assistant", " "), /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "(streaming \xB7 ", event.text.length, event.reasoning ? ` + think ${event.reasoning.length}` : "", " chars)", " "), /* @__PURE__ */ React3.createElement(Elapsed, null)), reasoningTail ? /* @__PURE__ */ React3.createElement(Text3, { dimColor: true, italic: true }, "\u21B3 thinking: ", reasoningTail) : null, tail ? /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "\u25B8 ", tail) : /* @__PURE__ */ React3.createElement(Text3, { dimColor: true, italic: true }, " (waiting for first token\u2026)"));
3050
+ const reasoningOnly = !event.text && !!event.reasoning;
3051
+ return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Text3, { bold: true, color: "green" }, "assistant", " "), /* @__PURE__ */ React3.createElement(Pulse, null), /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, " ", "(", reasoningOnly ? "reasoning" : "streaming", " \xB7 ", event.text.length, event.reasoning ? ` + think ${event.reasoning.length}` : "", " chars)", " "), /* @__PURE__ */ React3.createElement(Elapsed, null)), reasoningTail ? /* @__PURE__ */ React3.createElement(Text3, { dimColor: true, italic: true }, "\u21B3 thinking: ", reasoningTail) : null, tail ? /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "\u25B8 ", tail) : reasoningOnly ? /* @__PURE__ */ React3.createElement(Text3, { color: "yellow", dimColor: true }, " R1 is thinking before it speaks \u2014 body text starts when reasoning completes (typically 20-90s).") : /* @__PURE__ */ React3.createElement(Text3, { dimColor: true, italic: true }, " (waiting for first byte \u2014 connection is open)"));
3052
+ }
3053
+ function Pulse() {
3054
+ const [tick, setTick] = useState(0);
3055
+ useEffect(() => {
3056
+ const id = setInterval(() => setTick((t) => t + 1), 500);
3057
+ return () => clearInterval(id);
3058
+ }, []);
3059
+ const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
3060
+ return /* @__PURE__ */ React3.createElement(Text3, { color: "cyan" }, frames[tick % frames.length]);
2854
3061
  }
2855
3062
  function lastLine(s, maxChars) {
2856
3063
  const flat = s.replace(/\s+/g, " ").trim();
@@ -2914,6 +3121,7 @@ function formatTokens(n) {
2914
3121
  }
2915
3122
 
2916
3123
  // src/cli/ui/slash.ts
3124
+ import { spawnSync } from "child_process";
2917
3125
  function parseSlash(text) {
2918
3126
  if (!text.startsWith("/")) return null;
2919
3127
  const parts = text.slice(1).trim().split(/\s+/);
@@ -2942,6 +3150,10 @@ function handleSlash(cmd, args, loop, ctx = {}) {
2942
3150
  " /mcp list MCP servers + tools attached to this session",
2943
3151
  " /setup (exit + reconfigure) \u2192 run `reasonix setup`",
2944
3152
  " /compact [cap] shrink large tool results in history (default 4k/result)",
3153
+ " /apply (code mode) commit the pending edit blocks to disk",
3154
+ " /discard (code mode) drop pending edits without writing",
3155
+ " /undo (code mode) roll back the last applied edit batch",
3156
+ ' /commit "msg" (code mode) git add -A && git commit -m "msg"',
2945
3157
  " /sessions list saved sessions (current is marked with \u25B8)",
2946
3158
  " /forget delete the current session from disk",
2947
3159
  " /clear clear displayed history (log + session kept)",
@@ -2983,6 +3195,45 @@ function handleSlash(cmd, args, loop, ctx = {}) {
2983
3195
  return {
2984
3196
  info: "To reconfigure (preset, MCP servers, API key), exit this chat and run `reasonix setup`. Changes take effect on next launch."
2985
3197
  };
3198
+ case "undo": {
3199
+ if (!ctx.codeUndo) {
3200
+ return {
3201
+ info: "/undo is only available inside `reasonix code` \u2014 chat mode doesn't apply edits."
3202
+ };
3203
+ }
3204
+ return { info: ctx.codeUndo() };
3205
+ }
3206
+ case "apply": {
3207
+ if (!ctx.codeApply) {
3208
+ return {
3209
+ info: "/apply is only available inside `reasonix code` (nothing to apply here)."
3210
+ };
3211
+ }
3212
+ return { info: ctx.codeApply() };
3213
+ }
3214
+ case "discard": {
3215
+ if (!ctx.codeDiscard) {
3216
+ return {
3217
+ info: "/discard is only available inside `reasonix code`."
3218
+ };
3219
+ }
3220
+ return { info: ctx.codeDiscard() };
3221
+ }
3222
+ case "commit": {
3223
+ if (!ctx.codeRoot) {
3224
+ return {
3225
+ info: "/commit is only available inside `reasonix code` (needs a rooted git repo)."
3226
+ };
3227
+ }
3228
+ const raw = args.join(" ").trim();
3229
+ const message = stripOuterQuotes(raw);
3230
+ if (!message) {
3231
+ return {
3232
+ info: `usage: /commit "your commit message" \u2014 runs \`git add -A && git commit -m "\u2026"\` in ${ctx.codeRoot}`
3233
+ };
3234
+ }
3235
+ return runGitCommit(ctx.codeRoot, message);
3236
+ }
2986
3237
  case "compact": {
2987
3238
  const tight = Number.parseInt(args[0] ?? "", 10);
2988
3239
  const cap = Number.isFinite(tight) && tight >= 500 ? tight : 4e3;
@@ -3082,6 +3333,38 @@ function handleSlash(cmd, args, loop, ctx = {}) {
3082
3333
  return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
3083
3334
  }
3084
3335
  }
3336
+ function stripOuterQuotes(s) {
3337
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
3338
+ return s.slice(1, -1);
3339
+ }
3340
+ return s;
3341
+ }
3342
+ function runGitCommit(rootDir, message) {
3343
+ const add = spawnSync("git", ["add", "-A"], { cwd: rootDir, encoding: "utf8" });
3344
+ if (add.error || add.status !== 0) {
3345
+ return { info: `git add failed (${add.status ?? "?"}):
3346
+ ${gitTail(add)}` };
3347
+ }
3348
+ const commit = spawnSync("git", ["commit", "-m", message], {
3349
+ cwd: rootDir,
3350
+ encoding: "utf8"
3351
+ });
3352
+ if (commit.error || commit.status !== 0) {
3353
+ return { info: `git commit failed (${commit.status ?? "?"}):
3354
+ ${gitTail(commit)}` };
3355
+ }
3356
+ const firstLine = (commit.stdout || "").split(/\r?\n/)[0] ?? "";
3357
+ return { info: `\u25B8 committed: ${message}${firstLine ? `
3358
+ ${firstLine}` : ""}` };
3359
+ }
3360
+ function gitTail(res) {
3361
+ const stderr = res.stderr ?? "";
3362
+ const stdout2 = res.stdout ?? "";
3363
+ const body = stderr.trim() || stdout2.trim();
3364
+ if (body) return body;
3365
+ if (res.error) return res.error.message;
3366
+ return "(no output from git)";
3367
+ }
3085
3368
 
3086
3369
  // src/cli/ui/App.tsx
3087
3370
  var FLUSH_INTERVAL_MS = 60;
@@ -3093,7 +3376,8 @@ function App({
3093
3376
  branch,
3094
3377
  session,
3095
3378
  tools,
3096
- mcpSpecs
3379
+ mcpSpecs,
3380
+ codeMode
3097
3381
  }) {
3098
3382
  const { exit } = useApp();
3099
3383
  const [historical, setHistorical] = useState2([]);
@@ -3101,6 +3385,9 @@ function App({
3101
3385
  const [input, setInput] = useState2("");
3102
3386
  const [busy, setBusy] = useState2(false);
3103
3387
  const abortedThisTurn = useRef(false);
3388
+ const [ongoingTool, setOngoingTool] = useState2(null);
3389
+ const lastEditSnapshots = useRef(null);
3390
+ const pendingEdits = useRef([]);
3104
3391
  const [summary, setSummary] = useState2({
3105
3392
  turns: 0,
3106
3393
  totalCostUsd: 0,
@@ -3175,6 +3462,35 @@ function App({
3175
3462
  abortedThisTurn.current = true;
3176
3463
  loop.abort();
3177
3464
  });
3465
+ const codeUndo = useCallback(() => {
3466
+ if (!codeMode) return "not in code mode";
3467
+ const snaps = lastEditSnapshots.current;
3468
+ if (!snaps || snaps.length === 0) {
3469
+ return "nothing to undo \u2014 no recent edit batch to restore";
3470
+ }
3471
+ const results = restoreSnapshots(snaps, codeMode.rootDir);
3472
+ lastEditSnapshots.current = null;
3473
+ return formatUndoResults(results);
3474
+ }, [codeMode]);
3475
+ const codeApply = useCallback(() => {
3476
+ if (!codeMode) return "not in code mode";
3477
+ const blocks = pendingEdits.current;
3478
+ if (blocks.length === 0) {
3479
+ return "nothing pending \u2014 the assistant hasn't proposed edits since the last /apply or /discard.";
3480
+ }
3481
+ const snaps = snapshotBeforeEdits(blocks, codeMode.rootDir);
3482
+ const results = applyEditBlocks(blocks, codeMode.rootDir);
3483
+ const anyApplied = results.some((r) => r.status === "applied" || r.status === "created");
3484
+ if (anyApplied) lastEditSnapshots.current = snaps;
3485
+ pendingEdits.current = [];
3486
+ return formatEditResults(results);
3487
+ }, [codeMode]);
3488
+ const codeDiscard = useCallback(() => {
3489
+ const count = pendingEdits.current.length;
3490
+ if (count === 0) return "nothing pending to discard.";
3491
+ pendingEdits.current = [];
3492
+ return `\u25B8 discarded ${count} pending edit block(s). Nothing was written to disk.`;
3493
+ }, []);
3178
3494
  const prefixHash = loop.prefix.fingerprint;
3179
3495
  const writeTranscript = useCallback(
3180
3496
  (ev) => {
@@ -3191,7 +3507,13 @@ function App({
3191
3507
  setInput("");
3192
3508
  const slash = parseSlash(text);
3193
3509
  if (slash) {
3194
- const result = handleSlash(slash.cmd, slash.args, loop, { mcpSpecs });
3510
+ const result = handleSlash(slash.cmd, slash.args, loop, {
3511
+ mcpSpecs,
3512
+ codeUndo: codeMode ? codeUndo : void 0,
3513
+ codeApply: codeMode ? codeApply : void 0,
3514
+ codeDiscard: codeMode ? codeDiscard : void 0,
3515
+ codeRoot: codeMode?.rootDir
3516
+ });
3195
3517
  if (result.exit) {
3196
3518
  transcriptRef.current?.end();
3197
3519
  exit();
@@ -3263,12 +3585,13 @@ function App({
3263
3585
  flush();
3264
3586
  const repairNote = ev.repair ? describeRepair(ev.repair) : "";
3265
3587
  setStreaming(null);
3588
+ const finalText = ev.content || streamRef.text;
3266
3589
  setHistorical((prev) => [
3267
3590
  ...prev,
3268
3591
  {
3269
3592
  id: assistantId,
3270
3593
  role: "assistant",
3271
- text: ev.content || streamRef.text,
3594
+ text: finalText,
3272
3595
  reasoning: streamRef.reasoning || void 0,
3273
3596
  planState: ev.planState,
3274
3597
  branch: ev.branch,
@@ -3277,8 +3600,25 @@ function App({
3277
3600
  streaming: false
3278
3601
  }
3279
3602
  ]);
3603
+ if (codeMode && finalText && !ev.forcedSummary) {
3604
+ const blocks = parseEditBlocks(finalText);
3605
+ if (blocks.length > 0) {
3606
+ pendingEdits.current = blocks;
3607
+ setHistorical((prev) => [
3608
+ ...prev,
3609
+ {
3610
+ id: `pending-${Date.now()}`,
3611
+ role: "info",
3612
+ text: formatPendingPreview(blocks)
3613
+ }
3614
+ ]);
3615
+ }
3616
+ }
3617
+ } else if (ev.role === "tool_start") {
3618
+ setOngoingTool({ name: ev.toolName ?? "?", args: ev.toolArgs });
3280
3619
  } else if (ev.role === "tool") {
3281
3620
  flush();
3621
+ setOngoingTool(null);
3282
3622
  setHistorical((prev) => [
3283
3623
  ...prev,
3284
3624
  {
@@ -3304,11 +3644,12 @@ function App({
3304
3644
  } finally {
3305
3645
  clearInterval(timer);
3306
3646
  setStreaming(null);
3647
+ setOngoingTool(null);
3307
3648
  setSummary(loop.stats.summary());
3308
3649
  setBusy(false);
3309
3650
  }
3310
3651
  },
3311
- [busy, exit, loop, mcpSpecs, writeTranscript]
3652
+ [busy, codeApply, codeDiscard, codeMode, codeUndo, exit, loop, mcpSpecs, writeTranscript]
3312
3653
  );
3313
3654
  return /* @__PURE__ */ React6.createElement(Box6, { flexDirection: "column" }, /* @__PURE__ */ React6.createElement(
3314
3655
  StatsPanel,
@@ -3319,10 +3660,53 @@ function App({
3319
3660
  harvestOn: loop.harvestEnabled,
3320
3661
  branchBudget: loop.branchOptions.budget
3321
3662
  }
3322
- ), /* @__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));
3663
+ ), /* @__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, ongoingTool ? /* @__PURE__ */ React6.createElement(OngoingToolRow, { tool: ongoingTool }) : null, /* @__PURE__ */ React6.createElement(PromptInput, { value: input, onChange: setInput, onSubmit: handleSubmit, disabled: busy }), /* @__PURE__ */ React6.createElement(CommandStrip, { codeMode: !!codeMode }));
3323
3664
  }
3324
- function CommandStrip() {
3325
- return /* @__PURE__ */ React6.createElement(Box6, { paddingX: 2, flexDirection: "column" }, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "/help \xB7 /preset ", "<fast|smart|max>", " \xB7 /mcp \xB7 /compact \xB7 /sessions \xB7 /setup \xB7 /clear \xB7 /exit"), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "Esc (while thinking) \u2014 abort & summarize what was found so far"));
3665
+ function OngoingToolRow({ tool }) {
3666
+ const [tick, setTick] = useState2(0);
3667
+ useEffect2(() => {
3668
+ const id = setInterval(() => setTick((t) => t + 1), 120);
3669
+ return () => clearInterval(id);
3670
+ }, []);
3671
+ const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
3672
+ const argsPreview = tool.args && tool.args.length > 0 && tool.args !== "{}" ? ` ${tool.args.length > 60 ? `${tool.args.slice(0, 60)}\u2026` : tool.args}` : "";
3673
+ return /* @__PURE__ */ React6.createElement(Box6, { marginY: 1 }, /* @__PURE__ */ React6.createElement(Text6, { color: "cyan" }, frames[tick % frames.length]), /* @__PURE__ */ React6.createElement(Text6, { color: "yellow" }, ` tool<${tool.name}> running\u2026`), argsPreview ? /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, argsPreview) : null);
3674
+ }
3675
+ function CommandStrip({ codeMode }) {
3676
+ return /* @__PURE__ */ React6.createElement(Box6, { paddingX: 2, flexDirection: "column" }, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "/help \xB7 /preset ", "<fast|smart|max>", " \xB7 /mcp \xB7 /compact \xB7 /sessions \xB7 /setup \xB7 /clear \xB7 /exit"), codeMode ? /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, 'code mode: /apply \xB7 /discard \xB7 /undo \xB7 /commit "msg" \u2014 edits NEVER write without /apply') : null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "Esc (while thinking) \u2014 abort & summarize what was found so far"));
3677
+ }
3678
+ function formatEditResults(results) {
3679
+ const lines = results.map((r) => {
3680
+ const mark = r.status === "applied" || r.status === "created" ? "\u2713" : "\u2717";
3681
+ const detail = r.message ? ` (${r.message})` : "";
3682
+ return ` ${mark} ${r.status.padEnd(11)} ${r.path}${detail}`;
3683
+ });
3684
+ const ok = results.filter((r) => r.status === "applied" || r.status === "created").length;
3685
+ const total = results.length;
3686
+ const header = `\u25B8 edit blocks: ${ok}/${total} applied \u2014 /undo to roll back, or \`git diff\` to review`;
3687
+ return [header, ...lines].join("\n");
3688
+ }
3689
+ function formatPendingPreview(blocks) {
3690
+ const lines = blocks.map((b) => {
3691
+ const removed = b.search === "" ? 0 : countLines2(b.search);
3692
+ const added = countLines2(b.replace);
3693
+ const tag = b.search === "" ? "NEW " : " ";
3694
+ return ` ${tag}${b.path} (-${removed} +${added} lines)`;
3695
+ });
3696
+ const header = `\u25B8 ${blocks.length} pending edit block(s) \u2014 /apply to commit to disk, /discard to drop`;
3697
+ return [header, ...lines].join("\n");
3698
+ }
3699
+ function countLines2(s) {
3700
+ if (s.length === 0) return 0;
3701
+ return (s.match(/\n/g)?.length ?? 0) + 1;
3702
+ }
3703
+ function formatUndoResults(results) {
3704
+ const lines = results.map((r) => {
3705
+ const mark = r.status === "applied" ? "\u2713" : "\u2717";
3706
+ const detail = r.message ? ` (${r.message})` : "";
3707
+ return ` ${mark} ${r.path}${detail}`;
3708
+ });
3709
+ return [`\u25B8 undo: restored ${results.length} file(s) to pre-edit state`, ...lines].join("\n");
3326
3710
  }
3327
3711
  function describeRepair(repair) {
3328
3712
  const parts = [];
@@ -3396,7 +3780,8 @@ function Root({ initialKey, tools, mcpSpecs, ...appProps }) {
3396
3780
  branch: appProps.branch,
3397
3781
  session: appProps.session,
3398
3782
  tools,
3399
- mcpSpecs
3783
+ mcpSpecs,
3784
+ codeMode: appProps.codeMode
3400
3785
  }
3401
3786
  );
3402
3787
  }
@@ -3452,9 +3837,35 @@ async function chatCommand(opts) {
3452
3837
  }
3453
3838
  }
3454
3839
 
3840
+ // src/cli/commands/code.tsx
3841
+ import { basename, resolve as resolve3 } from "path";
3842
+ async function codeCommand(opts = {}) {
3843
+ const { codeSystemPrompt: codeSystemPrompt2 } = await import("./prompt-MMANQ36Z.js");
3844
+ const rootDir = resolve3(opts.dir ?? process.cwd());
3845
+ const session = opts.noSession ? void 0 : `code-${sanitizeName(basename(rootDir))}`;
3846
+ const fsSpec = `filesystem=npx -y @modelcontextprotocol/server-filesystem ${quoteIfNeeded(rootDir)}`;
3847
+ process.stderr.write(
3848
+ `\u25B8 reasonix code: rooted at ${rootDir}, session "${session ?? "(ephemeral)"}"
3849
+ `
3850
+ );
3851
+ await chatCommand({
3852
+ model: opts.model ?? "deepseek-reasoner",
3853
+ harvest: true,
3854
+ // smart preset's harvest setting, always on for code
3855
+ system: codeSystemPrompt2(rootDir),
3856
+ transcript: opts.transcript,
3857
+ session,
3858
+ mcp: [fsSpec],
3859
+ codeMode: { rootDir }
3860
+ });
3861
+ }
3862
+ function quoteIfNeeded(s) {
3863
+ return /\s|"/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
3864
+ }
3865
+
3455
3866
  // src/cli/commands/diff.ts
3456
- import { writeFileSync as writeFileSync3 } from "fs";
3457
- import { basename } from "path";
3867
+ import { writeFileSync as writeFileSync4 } from "fs";
3868
+ import { basename as basename2 } from "path";
3458
3869
  import { render as render2 } from "ink";
3459
3870
  import React11 from "react";
3460
3871
 
@@ -3590,8 +4001,8 @@ async function diffCommand(opts) {
3590
4001
  const aParsed = readTranscript(opts.a);
3591
4002
  const bParsed = readTranscript(opts.b);
3592
4003
  const report = diffTranscripts(
3593
- { label: opts.labelA ?? basename(opts.a), parsed: aParsed },
3594
- { label: opts.labelB ?? basename(opts.b), parsed: bParsed }
4004
+ { label: opts.labelA ?? basename2(opts.a), parsed: aParsed },
4005
+ { label: opts.labelB ?? basename2(opts.b), parsed: bParsed }
3595
4006
  );
3596
4007
  const wantMarkdown = !!opts.mdPath;
3597
4008
  const wantPrint = opts.print || !process.stdout.isTTY;
@@ -3599,7 +4010,7 @@ async function diffCommand(opts) {
3599
4010
  if (wantMarkdown) {
3600
4011
  console.log(renderSummaryTable(report));
3601
4012
  const md = renderMarkdown(report);
3602
- writeFileSync3(opts.mdPath, md, "utf8");
4013
+ writeFileSync4(opts.mdPath, md, "utf8");
3603
4014
  console.log(`
3604
4015
  markdown report written to ${opts.mdPath}`);
3605
4016
  return;
@@ -4408,10 +4819,10 @@ function buildSpec(name, argsByName) {
4408
4819
  const entry = CATALOG_BY_NAME.get(name);
4409
4820
  if (!entry) return name;
4410
4821
  const userArg = entry.userArgs ? argsByName[name] : void 0;
4411
- const tail = userArg ? ` ${quoteIfNeeded(userArg)}` : "";
4822
+ const tail = userArg ? ` ${quoteIfNeeded2(userArg)}` : "";
4412
4823
  return `${entry.name}=npx -y ${entry.package}${tail}`;
4413
4824
  }
4414
- function quoteIfNeeded(s) {
4825
+ function quoteIfNeeded2(s) {
4415
4826
  return /\s|"/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
4416
4827
  }
4417
4828
 
@@ -4439,13 +4850,13 @@ async function setupCommand(_opts = {}) {
4439
4850
  }
4440
4851
 
4441
4852
  // src/cli/commands/stats.ts
4442
- import { existsSync as existsSync2, readFileSync as readFileSync5 } from "fs";
4853
+ import { existsSync as existsSync3, readFileSync as readFileSync6 } from "fs";
4443
4854
  function statsCommand(opts) {
4444
- if (!existsSync2(opts.transcript)) {
4855
+ if (!existsSync3(opts.transcript)) {
4445
4856
  console.error(`no such transcript: ${opts.transcript}`);
4446
4857
  process.exit(1);
4447
4858
  }
4448
- const lines = readFileSync5(opts.transcript, "utf8").split(/\r?\n/).filter(Boolean);
4859
+ const lines = readFileSync6(opts.transcript, "utf8").split(/\r?\n/).filter(Boolean);
4449
4860
  let assistantTurns = 0;
4450
4861
  let toolCalls = 0;
4451
4862
  let lastTurn = 0;
@@ -4526,6 +4937,16 @@ program.action(async () => {
4526
4937
  program.command("setup").description("Interactive wizard \u2014 API key, preset, MCP servers. Re-run any time to reconfigure.").action(async () => {
4527
4938
  await setupCommand({});
4528
4939
  });
4940
+ program.command("code [dir]").description(
4941
+ "Code-editing chat \u2014 filesystem MCP auto-bridged at <dir> (default: cwd), coding system prompt, smart preset. Model proposes SEARCH/REPLACE blocks; Reasonix applies them to disk."
4942
+ ).option("-m, --model <id>", "Override default reasoner model").option("--no-session", "Disable session persistence for this run").option("--transcript <path>", "Write a JSONL transcript to this path").action(async (dir, opts) => {
4943
+ await codeCommand({
4944
+ dir,
4945
+ model: opts.model,
4946
+ noSession: opts.session === false,
4947
+ transcript: opts.transcript
4948
+ });
4949
+ });
4529
4950
  program.command("chat").description("Interactive Ink TUI with live cache/cost panel.").option("-m, --model <id>", "DeepSeek model id (overrides preset)").option("-s, --system <prompt>", "System prompt (pinned in the immutable prefix)", DEFAULT_SYSTEM).option("--transcript <path>", "Write a JSONL transcript to this path").option(
4530
4951
  "--preset <name>",
4531
4952
  "Bundle of model + harvest + branch. One of: fast, smart, max. Overrides config.preset."