reasonix 0.3.0-alpha.4 → 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
@@ -3,6 +3,49 @@
3
3
  // src/cli/index.ts
4
4
  import { Command } from "commander";
5
5
 
6
+ // src/config.ts
7
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs";
8
+ import { homedir } from "os";
9
+ import { dirname, join } from "path";
10
+ function defaultConfigPath() {
11
+ return join(homedir(), ".reasonix", "config.json");
12
+ }
13
+ function readConfig(path = defaultConfigPath()) {
14
+ try {
15
+ const raw = readFileSync(path, "utf8");
16
+ const parsed = JSON.parse(raw);
17
+ if (parsed && typeof parsed === "object") return parsed;
18
+ } catch {
19
+ }
20
+ return {};
21
+ }
22
+ function writeConfig(cfg, path = defaultConfigPath()) {
23
+ mkdirSync(dirname(path), { recursive: true });
24
+ writeFileSync(path, JSON.stringify(cfg, null, 2), "utf8");
25
+ try {
26
+ chmodSync(path, 384);
27
+ } catch {
28
+ }
29
+ }
30
+ function loadApiKey(path = defaultConfigPath()) {
31
+ if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
32
+ return readConfig(path).apiKey;
33
+ }
34
+ function saveApiKey(key, path = defaultConfigPath()) {
35
+ const cfg = readConfig(path);
36
+ cfg.apiKey = key.trim();
37
+ writeConfig(cfg, path);
38
+ }
39
+ function isPlausibleKey(key) {
40
+ const trimmed = key.trim();
41
+ return /^sk-[A-Za-z0-9_-]{16,}$/.test(trimmed);
42
+ }
43
+ function redactKey(key) {
44
+ if (!key) return "";
45
+ if (key.length <= 12) return "****";
46
+ return `${key.slice(0, 6)}\u2026${key.slice(-4)}`;
47
+ }
48
+
6
49
  // src/client.ts
7
50
  import { createParser } from "eventsource-parser";
8
51
 
@@ -409,6 +452,201 @@ function resolveTemperatures(budget, custom) {
409
452
  return out;
410
453
  }
411
454
 
455
+ // src/repair/flatten.ts
456
+ function analyzeSchema(schema) {
457
+ if (!schema) return { shouldFlatten: false, leafCount: 0, maxDepth: 0 };
458
+ let leafCount = 0;
459
+ let maxDepth = 0;
460
+ walk(schema, 0, (depth, isLeaf) => {
461
+ if (isLeaf) leafCount++;
462
+ if (depth > maxDepth) maxDepth = depth;
463
+ });
464
+ return {
465
+ shouldFlatten: leafCount > 10 || maxDepth > 2,
466
+ leafCount,
467
+ maxDepth
468
+ };
469
+ }
470
+ function flattenSchema(schema) {
471
+ const flatProps = {};
472
+ const required = [];
473
+ collect("", schema, flatProps, required, true);
474
+ return {
475
+ type: "object",
476
+ properties: flatProps,
477
+ required
478
+ };
479
+ }
480
+ function nestArguments(flatArgs) {
481
+ const out = {};
482
+ for (const [key, value] of Object.entries(flatArgs)) {
483
+ setByPath(out, key.split("."), value);
484
+ }
485
+ return out;
486
+ }
487
+ function walk(schema, depth, visit) {
488
+ if (schema.type === "object" && schema.properties) {
489
+ for (const child of Object.values(schema.properties)) {
490
+ walk(child, depth + 1, visit);
491
+ }
492
+ return;
493
+ }
494
+ if (schema.type === "array" && schema.items) {
495
+ walk(schema.items, depth + 1, visit);
496
+ return;
497
+ }
498
+ visit(depth, true);
499
+ }
500
+ function collect(prefix, schema, out, required, isRootRequired) {
501
+ if (schema.type === "object" && schema.properties) {
502
+ const requiredSet = new Set(schema.required ?? []);
503
+ for (const [key, child] of Object.entries(schema.properties)) {
504
+ const nextPrefix = prefix ? `${prefix}.${key}` : key;
505
+ const childRequired = isRootRequired && requiredSet.has(key);
506
+ collect(nextPrefix, child, out, required, childRequired);
507
+ }
508
+ return;
509
+ }
510
+ out[prefix] = schema;
511
+ if (isRootRequired) required.push(prefix);
512
+ }
513
+ function setByPath(target, path, value) {
514
+ let cur = target;
515
+ for (let i = 0; i < path.length - 1; i++) {
516
+ const key = path[i];
517
+ if (typeof cur[key] !== "object" || cur[key] === null) cur[key] = {};
518
+ cur = cur[key];
519
+ }
520
+ cur[path[path.length - 1]] = value;
521
+ }
522
+
523
+ // src/tools.ts
524
+ var ToolRegistry = class {
525
+ _tools = /* @__PURE__ */ new Map();
526
+ _autoFlatten;
527
+ constructor(opts = {}) {
528
+ this._autoFlatten = opts.autoFlatten !== false;
529
+ }
530
+ register(def) {
531
+ if (!def.name) throw new Error("tool requires a name");
532
+ const internal = { ...def };
533
+ if (this._autoFlatten && def.parameters) {
534
+ const decision = analyzeSchema(def.parameters);
535
+ if (decision.shouldFlatten) {
536
+ internal.flatSchema = flattenSchema(def.parameters);
537
+ }
538
+ }
539
+ this._tools.set(def.name, internal);
540
+ return this;
541
+ }
542
+ has(name) {
543
+ return this._tools.has(name);
544
+ }
545
+ get(name) {
546
+ return this._tools.get(name);
547
+ }
548
+ get size() {
549
+ return this._tools.size;
550
+ }
551
+ /** True if a registered tool's schema was flattened for the model. */
552
+ wasFlattened(name) {
553
+ return Boolean(this._tools.get(name)?.flatSchema);
554
+ }
555
+ specs() {
556
+ return [...this._tools.values()].map((t) => ({
557
+ type: "function",
558
+ function: {
559
+ name: t.name,
560
+ description: t.description ?? "",
561
+ parameters: t.flatSchema ?? t.parameters ?? { type: "object", properties: {} }
562
+ }
563
+ }));
564
+ }
565
+ async dispatch(name, argumentsRaw) {
566
+ const tool = this._tools.get(name);
567
+ if (!tool) {
568
+ return JSON.stringify({ error: `unknown tool: ${name}` });
569
+ }
570
+ let args;
571
+ try {
572
+ args = typeof argumentsRaw === "string" ? argumentsRaw.trim() ? JSON.parse(argumentsRaw) ?? {} : {} : argumentsRaw ?? {};
573
+ } catch (err) {
574
+ return JSON.stringify({
575
+ error: `invalid tool arguments JSON: ${err.message}`
576
+ });
577
+ }
578
+ if (tool.flatSchema && args && typeof args === "object" && hasDotKey(args)) {
579
+ args = nestArguments(args);
580
+ }
581
+ try {
582
+ const result = await tool.fn(args);
583
+ return typeof result === "string" ? result : JSON.stringify(result);
584
+ } catch (err) {
585
+ return JSON.stringify({
586
+ error: `${err.name}: ${err.message}`
587
+ });
588
+ }
589
+ }
590
+ };
591
+ function hasDotKey(obj) {
592
+ for (const k of Object.keys(obj)) {
593
+ if (k.includes(".")) return true;
594
+ }
595
+ return false;
596
+ }
597
+
598
+ // src/mcp/registry.ts
599
+ var DEFAULT_MAX_RESULT_CHARS = 32e3;
600
+ async function bridgeMcpTools(client, opts = {}) {
601
+ const registry = opts.registry ?? new ToolRegistry({ autoFlatten: opts.autoFlatten });
602
+ const prefix = opts.namePrefix ?? "";
603
+ const maxResultChars = opts.maxResultChars ?? DEFAULT_MAX_RESULT_CHARS;
604
+ const result = { registry, registeredNames: [], skipped: [] };
605
+ const listed = await client.listTools();
606
+ for (const mcpTool of listed.tools) {
607
+ if (!mcpTool.name) {
608
+ result.skipped.push({ name: "?", reason: "empty tool name" });
609
+ continue;
610
+ }
611
+ const registeredName = `${prefix}${mcpTool.name}`;
612
+ registry.register({
613
+ name: registeredName,
614
+ description: mcpTool.description ?? "",
615
+ parameters: mcpTool.inputSchema,
616
+ fn: async (args) => {
617
+ const toolResult = await client.callTool(mcpTool.name, args);
618
+ return flattenMcpResult(toolResult, { maxChars: maxResultChars });
619
+ }
620
+ });
621
+ result.registeredNames.push(registeredName);
622
+ }
623
+ return result;
624
+ }
625
+ function flattenMcpResult(result, opts = {}) {
626
+ const parts = result.content.map(blockToString);
627
+ const joined = parts.join("\n").trim();
628
+ const prefixed = result.isError ? `ERROR: ${joined || "(no error message from server)"}` : joined;
629
+ return opts.maxChars ? truncateForModel(prefixed, opts.maxChars) : prefixed;
630
+ }
631
+ function truncateForModel(s, maxChars) {
632
+ if (s.length <= maxChars) return s;
633
+ const tailBudget = Math.min(1024, Math.floor(maxChars * 0.1));
634
+ const headBudget = Math.max(0, maxChars - tailBudget);
635
+ const head = s.slice(0, headBudget);
636
+ const tail = s.slice(-tailBudget);
637
+ const dropped = s.length - head.length - tail.length;
638
+ return `${head}
639
+
640
+ [\u2026truncated ${dropped} chars \u2014 raise BridgeOptions.maxResultChars, or call the tool with a narrower scope (filter, head, pagination)\u2026]
641
+
642
+ ${tail}`;
643
+ }
644
+ function blockToString(block) {
645
+ if (block.type === "text") return block.text;
646
+ if (block.type === "image") return `[image ${block.mimeType}, ${block.data.length} chars base64]`;
647
+ return `[unknown block: ${JSON.stringify(block)}]`;
648
+ }
649
+
412
650
  // src/memory.ts
413
651
  import { createHash } from "crypto";
414
652
  var ImmutablePrefix = class {
@@ -446,6 +684,16 @@ var AppendOnlyLog = class {
446
684
  extend(messages) {
447
685
  for (const m of messages) this.append(m);
448
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
+ }
449
697
  get entries() {
450
698
  return this._entries;
451
699
  }
@@ -660,74 +908,6 @@ function repairTruncatedJson(input) {
660
908
  }
661
909
  }
662
910
 
663
- // src/repair/flatten.ts
664
- function analyzeSchema(schema) {
665
- if (!schema) return { shouldFlatten: false, leafCount: 0, maxDepth: 0 };
666
- let leafCount = 0;
667
- let maxDepth = 0;
668
- walk(schema, 0, (depth, isLeaf) => {
669
- if (isLeaf) leafCount++;
670
- if (depth > maxDepth) maxDepth = depth;
671
- });
672
- return {
673
- shouldFlatten: leafCount > 10 || maxDepth > 2,
674
- leafCount,
675
- maxDepth
676
- };
677
- }
678
- function flattenSchema(schema) {
679
- const flatProps = {};
680
- const required = [];
681
- collect("", schema, flatProps, required, true);
682
- return {
683
- type: "object",
684
- properties: flatProps,
685
- required
686
- };
687
- }
688
- function nestArguments(flatArgs) {
689
- const out = {};
690
- for (const [key, value] of Object.entries(flatArgs)) {
691
- setByPath(out, key.split("."), value);
692
- }
693
- return out;
694
- }
695
- function walk(schema, depth, visit) {
696
- if (schema.type === "object" && schema.properties) {
697
- for (const child of Object.values(schema.properties)) {
698
- walk(child, depth + 1, visit);
699
- }
700
- return;
701
- }
702
- if (schema.type === "array" && schema.items) {
703
- walk(schema.items, depth + 1, visit);
704
- return;
705
- }
706
- visit(depth, true);
707
- }
708
- function collect(prefix, schema, out, required, isRootRequired) {
709
- if (schema.type === "object" && schema.properties) {
710
- const requiredSet = new Set(schema.required ?? []);
711
- for (const [key, child] of Object.entries(schema.properties)) {
712
- const nextPrefix = prefix ? `${prefix}.${key}` : key;
713
- const childRequired = isRootRequired && requiredSet.has(key);
714
- collect(nextPrefix, child, out, required, childRequired);
715
- }
716
- return;
717
- }
718
- out[prefix] = schema;
719
- if (isRootRequired) required.push(prefix);
720
- }
721
- function setByPath(target, path, value) {
722
- let cur = target;
723
- for (let i = 0; i < path.length - 1; i++) {
724
- const key = path[i];
725
- if (typeof cur[key] !== "object" || cur[key] === null) cur[key] = {};
726
- cur = cur[key];
727
- }
728
- cur[path[path.length - 1]] = value;
729
- }
730
-
731
911
  // src/repair/index.ts
732
912
  var ToolCallRepair = class {
733
913
  storm;
@@ -786,21 +966,22 @@ function signature2(call) {
786
966
  // src/session.ts
787
967
  import {
788
968
  appendFileSync,
789
- chmodSync,
969
+ chmodSync as chmodSync2,
790
970
  existsSync,
791
- mkdirSync,
792
- readFileSync,
971
+ mkdirSync as mkdirSync2,
972
+ readFileSync as readFileSync2,
793
973
  readdirSync,
794
974
  statSync,
795
- unlinkSync
975
+ unlinkSync,
976
+ writeFileSync as writeFileSync2
796
977
  } from "fs";
797
- import { homedir } from "os";
798
- import { dirname, join } from "path";
978
+ import { homedir as homedir2 } from "os";
979
+ import { dirname as dirname2, join as join2 } from "path";
799
980
  function sessionsDir() {
800
- return join(homedir(), ".reasonix", "sessions");
981
+ return join2(homedir2(), ".reasonix", "sessions");
801
982
  }
802
983
  function sessionPath(name) {
803
- return join(sessionsDir(), `${sanitizeName(name)}.jsonl`);
984
+ return join2(sessionsDir(), `${sanitizeName(name)}.jsonl`);
804
985
  }
805
986
  function sanitizeName(name) {
806
987
  const cleaned = name.replace(/[^\w\-\u4e00-\u9fa5]/g, "_").slice(0, 64);
@@ -810,7 +991,7 @@ function loadSessionMessages(name) {
810
991
  const path = sessionPath(name);
811
992
  if (!existsSync(path)) return [];
812
993
  try {
813
- const raw = readFileSync(path, "utf8");
994
+ const raw = readFileSync2(path, "utf8");
814
995
  const out = [];
815
996
  for (const line of raw.split(/\r?\n/)) {
816
997
  const trimmed = line.trim();
@@ -828,11 +1009,11 @@ function loadSessionMessages(name) {
828
1009
  }
829
1010
  function appendSessionMessage(name, message) {
830
1011
  const path = sessionPath(name);
831
- mkdirSync(dirname(path), { recursive: true });
1012
+ mkdirSync2(dirname2(path), { recursive: true });
832
1013
  appendFileSync(path, `${JSON.stringify(message)}
833
1014
  `, "utf8");
834
1015
  try {
835
- chmodSync(path, 384);
1016
+ chmodSync2(path, 384);
836
1017
  } catch {
837
1018
  }
838
1019
  }
@@ -842,7 +1023,7 @@ function listSessions() {
842
1023
  try {
843
1024
  const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
844
1025
  return files.map((file) => {
845
- const path = join(dir, file);
1026
+ const path = join2(dir, file);
846
1027
  const stat = statSync(path);
847
1028
  const name = file.replace(/\.jsonl$/, "");
848
1029
  const messageCount = countLines(path);
@@ -861,9 +1042,20 @@ function deleteSession(name) {
861
1042
  return false;
862
1043
  }
863
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
+ }
864
1056
  function countLines(path) {
865
1057
  try {
866
- const raw = readFileSync(path, "utf8");
1058
+ const raw = readFileSync2(path, "utf8");
867
1059
  return raw.split(/\r?\n/).filter((l) => l.trim()).length;
868
1060
  } catch {
869
1061
  return 0;
@@ -876,6 +1068,11 @@ var DEEPSEEK_PRICING = {
876
1068
  "deepseek-reasoner": { inputCacheHit: 0.14, inputCacheMiss: 0.55, output: 2.19 }
877
1069
  };
878
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;
879
1076
  function costUsd(model, usage) {
880
1077
  const p = DEEPSEEK_PRICING[model];
881
1078
  if (!p) return 0;
@@ -919,12 +1116,14 @@ var SessionStats = class {
919
1116
  return denom > 0 ? hit / denom : 0;
920
1117
  }
921
1118
  summary() {
1119
+ const last = this.turns[this.turns.length - 1];
922
1120
  return {
923
1121
  turns: this.turns.length,
924
1122
  totalCostUsd: round(this.totalCost, 6),
925
1123
  claudeEquivalentUsd: round(this.totalClaudeEquivalent, 6),
926
1124
  savingsVsClaudePct: round(this.savingsVsClaude * 100, 2),
927
- cacheHitRatio: round(this.aggregateCacheHitRatio, 4)
1125
+ cacheHitRatio: round(this.aggregateCacheHitRatio, 4),
1126
+ lastPromptTokens: last?.usage.promptTokens ?? 0
928
1127
  };
929
1128
  }
930
1129
  };
@@ -933,81 +1132,6 @@ function round(n, digits) {
933
1132
  return Math.round(n * f) / f;
934
1133
  }
935
1134
 
936
- // src/tools.ts
937
- var ToolRegistry = class {
938
- _tools = /* @__PURE__ */ new Map();
939
- _autoFlatten;
940
- constructor(opts = {}) {
941
- this._autoFlatten = opts.autoFlatten !== false;
942
- }
943
- register(def) {
944
- if (!def.name) throw new Error("tool requires a name");
945
- const internal = { ...def };
946
- if (this._autoFlatten && def.parameters) {
947
- const decision = analyzeSchema(def.parameters);
948
- if (decision.shouldFlatten) {
949
- internal.flatSchema = flattenSchema(def.parameters);
950
- }
951
- }
952
- this._tools.set(def.name, internal);
953
- return this;
954
- }
955
- has(name) {
956
- return this._tools.has(name);
957
- }
958
- get(name) {
959
- return this._tools.get(name);
960
- }
961
- get size() {
962
- return this._tools.size;
963
- }
964
- /** True if a registered tool's schema was flattened for the model. */
965
- wasFlattened(name) {
966
- return Boolean(this._tools.get(name)?.flatSchema);
967
- }
968
- specs() {
969
- return [...this._tools.values()].map((t) => ({
970
- type: "function",
971
- function: {
972
- name: t.name,
973
- description: t.description ?? "",
974
- parameters: t.flatSchema ?? t.parameters ?? { type: "object", properties: {} }
975
- }
976
- }));
977
- }
978
- async dispatch(name, argumentsRaw) {
979
- const tool = this._tools.get(name);
980
- if (!tool) {
981
- return JSON.stringify({ error: `unknown tool: ${name}` });
982
- }
983
- let args;
984
- try {
985
- args = typeof argumentsRaw === "string" ? argumentsRaw.trim() ? JSON.parse(argumentsRaw) ?? {} : {} : argumentsRaw ?? {};
986
- } catch (err) {
987
- return JSON.stringify({
988
- error: `invalid tool arguments JSON: ${err.message}`
989
- });
990
- }
991
- if (tool.flatSchema && args && typeof args === "object" && hasDotKey(args)) {
992
- args = nestArguments(args);
993
- }
994
- try {
995
- const result = await tool.fn(args);
996
- return typeof result === "string" ? result : JSON.stringify(result);
997
- } catch (err) {
998
- return JSON.stringify({
999
- error: `${err.name}: ${err.message}`
1000
- });
1001
- }
1002
- }
1003
- };
1004
- function hasDotKey(obj) {
1005
- for (const k of Object.keys(obj)) {
1006
- if (k.includes(".")) return true;
1007
- }
1008
- return false;
1009
- }
1010
-
1011
1135
  // src/loop.ts
1012
1136
  var CacheFirstLoop = class {
1013
1137
  client;
@@ -1036,7 +1160,7 @@ var CacheFirstLoop = class {
1036
1160
  this.prefix = opts.prefix;
1037
1161
  this.tools = opts.tools ?? new ToolRegistry();
1038
1162
  this.model = opts.model ?? "deepseek-chat";
1039
- this.maxToolIters = opts.maxToolIters ?? 8;
1163
+ this.maxToolIters = opts.maxToolIters ?? 24;
1040
1164
  if (typeof opts.branch === "number") {
1041
1165
  this.branchOptions = { budget: opts.branch };
1042
1166
  } else if (opts.branch && typeof opts.branch === "object") {
@@ -1055,12 +1179,49 @@ var CacheFirstLoop = class {
1055
1179
  this.sessionName = opts.session ?? null;
1056
1180
  if (this.sessionName) {
1057
1181
  const prior = loadSessionMessages(this.sessionName);
1058
- for (const msg of prior) this.log.append(msg);
1059
- this.resumedMessageCount = prior.length;
1182
+ const { messages, healedCount, healedFrom } = healLoadedMessages(
1183
+ prior,
1184
+ DEFAULT_MAX_RESULT_CHARS
1185
+ );
1186
+ for (const msg of messages) this.log.append(msg);
1187
+ this.resumedMessageCount = messages.length;
1188
+ if (healedCount > 0) {
1189
+ process.stderr.write(
1190
+ `\u25B8 session "${this.sessionName}": healed ${healedCount} oversized tool result(s) (was ${healedFrom.toLocaleString()} chars total). Old payloads were truncated to fit DeepSeek's context window; the conversation is preserved.
1191
+ `
1192
+ );
1193
+ }
1060
1194
  } else {
1061
1195
  this.resumedMessageCount = 0;
1062
1196
  }
1063
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
+ }
1064
1225
  appendAndPersist(message) {
1065
1226
  this.log.append(message);
1066
1227
  if (this.sessionName) {
@@ -1250,7 +1411,7 @@ var CacheFirstLoop = class {
1250
1411
  turn: this._turn,
1251
1412
  role: "error",
1252
1413
  content: "",
1253
- error: err.message
1414
+ error: formatLoopError(err)
1254
1415
  };
1255
1416
  return;
1256
1417
  }
@@ -1298,7 +1459,38 @@ var CacheFirstLoop = class {
1298
1459
  };
1299
1460
  }
1300
1461
  }
1301
- 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
+ }
1302
1494
  }
1303
1495
  async run(userInput, onEvent) {
1304
1496
  let final = "";
@@ -1323,14 +1515,36 @@ function summarizeBranch(chosen, samples) {
1323
1515
  temperatures: samples.map((s) => s.temperature)
1324
1516
  };
1325
1517
  }
1518
+ function healLoadedMessages(messages, maxChars) {
1519
+ let healedCount = 0;
1520
+ let healedFrom = 0;
1521
+ const out = messages.map((msg) => {
1522
+ if (msg.role !== "tool") return msg;
1523
+ const content = typeof msg.content === "string" ? msg.content : "";
1524
+ if (content.length <= maxChars) return msg;
1525
+ healedCount += 1;
1526
+ healedFrom += content.length;
1527
+ return { ...msg, content: truncateForModel(content, maxChars) };
1528
+ });
1529
+ return { messages: out, healedCount, healedFrom };
1530
+ }
1531
+ function formatLoopError(err) {
1532
+ const msg = err.message ?? "";
1533
+ if (msg.includes("maximum context length")) {
1534
+ const reqMatch = msg.match(/requested\s+(\d+)\s+tokens/);
1535
+ const requested = reqMatch ? `${Number(reqMatch[1]).toLocaleString()} tokens` : "too many tokens";
1536
+ return `Context overflow (DeepSeek 400): session history is ${requested}, past the 131,072-token limit. Usually this means a single tool call returned a huge payload. v0.3.0-alpha.6+ caps new tool results at 32k chars, AND auto-heals oversized history on session load \u2014 restart Reasonix and this session should come back trimmed. If it still overflows, run /forget (delete the session) or /clear (drop the displayed history) to start fresh.`;
1537
+ }
1538
+ return msg;
1539
+ }
1326
1540
 
1327
1541
  // src/env.ts
1328
- import { readFileSync as readFileSync2 } from "fs";
1542
+ import { readFileSync as readFileSync3 } from "fs";
1329
1543
  import { resolve } from "path";
1330
1544
  function loadDotenv(path = ".env") {
1331
1545
  let raw;
1332
1546
  try {
1333
- raw = readFileSync2(resolve(process.cwd(), path), "utf8");
1547
+ raw = readFileSync3(resolve(process.cwd(), path), "utf8");
1334
1548
  } catch {
1335
1549
  return;
1336
1550
  }
@@ -1349,7 +1563,7 @@ function loadDotenv(path = ".env") {
1349
1563
  }
1350
1564
 
1351
1565
  // src/transcript.ts
1352
- import { createWriteStream, readFileSync as readFileSync3 } from "fs";
1566
+ import { createWriteStream, readFileSync as readFileSync4 } from "fs";
1353
1567
  function recordFromLoopEvent(ev, extra) {
1354
1568
  const rec = {
1355
1569
  ts: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1400,7 +1614,7 @@ function openTranscriptFile(path, meta) {
1400
1614
  return stream;
1401
1615
  }
1402
1616
  function readTranscript(path) {
1403
- const raw = readFileSync3(path, "utf8");
1617
+ const raw = readFileSync4(path, "utf8");
1404
1618
  return parseTranscript(raw);
1405
1619
  }
1406
1620
  function isPlanStateEmptyShape(s) {
@@ -1517,12 +1731,14 @@ function summarizeTurns(turns) {
1517
1731
  }
1518
1732
  const cacheHitRatio = hit + miss > 0 ? hit / (hit + miss) : 0;
1519
1733
  const savingsVsClaude = totalClaude > 0 ? 1 - totalCost / totalClaude : 0;
1734
+ const lastTurn = turns[turns.length - 1];
1520
1735
  return {
1521
1736
  turns: turns.length,
1522
1737
  totalCostUsd: round2(totalCost, 6),
1523
1738
  claudeEquivalentUsd: round2(totalClaude, 6),
1524
1739
  savingsVsClaudePct: round2(savingsVsClaude * 100, 2),
1525
- cacheHitRatio: round2(cacheHitRatio, 4)
1740
+ cacheHitRatio: round2(cacheHitRatio, 4),
1741
+ lastPromptTokens: lastTurn?.usage.promptTokens ?? 0
1526
1742
  };
1527
1743
  }
1528
1744
  function round2(n, digits) {
@@ -2242,45 +2458,6 @@ var SseTransport = class {
2242
2458
  }
2243
2459
  };
2244
2460
 
2245
- // src/mcp/registry.ts
2246
- async function bridgeMcpTools(client, opts = {}) {
2247
- const registry = opts.registry ?? new ToolRegistry({ autoFlatten: opts.autoFlatten });
2248
- const prefix = opts.namePrefix ?? "";
2249
- const result = { registry, registeredNames: [], skipped: [] };
2250
- const listed = await client.listTools();
2251
- for (const mcpTool of listed.tools) {
2252
- if (!mcpTool.name) {
2253
- result.skipped.push({ name: "?", reason: "empty tool name" });
2254
- continue;
2255
- }
2256
- const registeredName = `${prefix}${mcpTool.name}`;
2257
- registry.register({
2258
- name: registeredName,
2259
- description: mcpTool.description ?? "",
2260
- parameters: mcpTool.inputSchema,
2261
- fn: async (args) => {
2262
- const toolResult = await client.callTool(mcpTool.name, args);
2263
- return flattenMcpResult(toolResult);
2264
- }
2265
- });
2266
- result.registeredNames.push(registeredName);
2267
- }
2268
- return result;
2269
- }
2270
- function flattenMcpResult(result) {
2271
- const parts = result.content.map(blockToString);
2272
- const joined = parts.join("\n").trim();
2273
- if (result.isError) {
2274
- return `ERROR: ${joined || "(no error message from server)"}`;
2275
- }
2276
- return joined;
2277
- }
2278
- function blockToString(block) {
2279
- if (block.type === "text") return block.text;
2280
- if (block.type === "image") return `[image ${block.mimeType}, ${block.data.length} chars base64]`;
2281
- return `[unknown block: ${JSON.stringify(block)}]`;
2282
- }
2283
-
2284
2461
  // src/mcp/shell-split.ts
2285
2462
  function shellSplit(input) {
2286
2463
  const tokens = [];
@@ -2355,51 +2532,8 @@ function parseMcpSpec(input) {
2355
2532
  return { transport: "stdio", name, command, args };
2356
2533
  }
2357
2534
 
2358
- // src/config.ts
2359
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync } from "fs";
2360
- import { homedir as homedir2 } from "os";
2361
- import { dirname as dirname2, join as join2 } from "path";
2362
- function defaultConfigPath() {
2363
- return join2(homedir2(), ".reasonix", "config.json");
2364
- }
2365
- function readConfig(path = defaultConfigPath()) {
2366
- try {
2367
- const raw = readFileSync4(path, "utf8");
2368
- const parsed = JSON.parse(raw);
2369
- if (parsed && typeof parsed === "object") return parsed;
2370
- } catch {
2371
- }
2372
- return {};
2373
- }
2374
- function writeConfig(cfg, path = defaultConfigPath()) {
2375
- mkdirSync2(dirname2(path), { recursive: true });
2376
- writeFileSync(path, JSON.stringify(cfg, null, 2), "utf8");
2377
- try {
2378
- chmodSync2(path, 384);
2379
- } catch {
2380
- }
2381
- }
2382
- function loadApiKey(path = defaultConfigPath()) {
2383
- if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
2384
- return readConfig(path).apiKey;
2385
- }
2386
- function saveApiKey(key, path = defaultConfigPath()) {
2387
- const cfg = readConfig(path);
2388
- cfg.apiKey = key.trim();
2389
- writeConfig(cfg, path);
2390
- }
2391
- function isPlausibleKey(key) {
2392
- const trimmed = key.trim();
2393
- return /^sk-[A-Za-z0-9_-]{16,}$/.test(trimmed);
2394
- }
2395
- function redactKey(key) {
2396
- if (!key) return "";
2397
- if (key.length <= 12) return "****";
2398
- return `${key.slice(0, 6)}\u2026${key.slice(-4)}`;
2399
- }
2400
-
2401
2535
  // src/index.ts
2402
- var VERSION = "0.3.0-alpha.4";
2536
+ var VERSION = "0.3.1";
2403
2537
 
2404
2538
  // src/cli/commands/chat.tsx
2405
2539
  import { render } from "ink";
@@ -2727,7 +2861,15 @@ function StatsPanel({
2727
2861
  const hitPct = (summary.cacheHitRatio * 100).toFixed(1);
2728
2862
  const hitColor = summary.cacheHitRatio >= 0.7 ? "green" : summary.cacheHitRatio >= 0.4 ? "yellow" : "red";
2729
2863
  const branchOn = (branchBudget ?? 1) > 1;
2730
- 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`;
2731
2873
  }
2732
2874
 
2733
2875
  // src/cli/ui/slash.ts
@@ -2738,7 +2880,7 @@ function parseSlash(text) {
2738
2880
  if (!cmd) return null;
2739
2881
  return { cmd, args: parts.slice(1) };
2740
2882
  }
2741
- function handleSlash(cmd, args, loop) {
2883
+ function handleSlash(cmd, args, loop, ctx = {}) {
2742
2884
  switch (cmd) {
2743
2885
  case "exit":
2744
2886
  case "quit":
@@ -2756,6 +2898,9 @@ function handleSlash(cmd, args, loop) {
2756
2898
  " /model <id> deepseek-chat or deepseek-reasoner",
2757
2899
  " /harvest [on|off] Pillar 2: structured plan-state extraction",
2758
2900
  " /branch <N|off> run N parallel samples (N>=2), pick most confident",
2901
+ " /mcp list MCP servers + tools attached to this session",
2902
+ " /setup (exit + reconfigure) \u2192 run `reasonix setup`",
2903
+ " /compact [cap] shrink large tool results in history (default 4k/result)",
2759
2904
  " /sessions list saved sessions (current is marked with \u25B8)",
2760
2905
  " /forget delete the current session from disk",
2761
2906
  " /clear clear displayed history (log + session kept)",
@@ -2771,6 +2916,45 @@ function handleSlash(cmd, args, loop) {
2771
2916
  " reasonix chat --no-session disable persistence for this run"
2772
2917
  ].join("\n")
2773
2918
  };
2919
+ case "mcp": {
2920
+ const specs = ctx.mcpSpecs ?? [];
2921
+ const toolSpecs = loop.prefix.toolSpecs ?? [];
2922
+ if (specs.length === 0 && toolSpecs.length === 0) {
2923
+ return {
2924
+ info: 'no MCP servers attached. Run `reasonix setup` to pick some, or launch with --mcp "<spec>". `reasonix mcp list` shows the catalog.'
2925
+ };
2926
+ }
2927
+ const lines = [];
2928
+ if (specs.length > 0) {
2929
+ lines.push(`MCP servers (${specs.length}):`);
2930
+ for (const spec of specs) lines.push(` \xB7 ${spec}`);
2931
+ lines.push("");
2932
+ }
2933
+ if (toolSpecs.length > 0) {
2934
+ lines.push(`Tools in registry (${toolSpecs.length}):`);
2935
+ for (const t of toolSpecs) lines.push(` \xB7 ${t.function.name}`);
2936
+ }
2937
+ lines.push("");
2938
+ lines.push("To change this set, exit and run `reasonix setup`.");
2939
+ return { info: lines.join("\n") };
2940
+ }
2941
+ case "setup":
2942
+ return {
2943
+ info: "To reconfigure (preset, MCP servers, API key), exit this chat and run `reasonix setup`. Changes take effect on next launch."
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
+ }
2774
2958
  case "sessions": {
2775
2959
  const items = listSessions();
2776
2960
  if (items.length === 0) {
@@ -2860,7 +3044,16 @@ function handleSlash(cmd, args, loop) {
2860
3044
 
2861
3045
  // src/cli/ui/App.tsx
2862
3046
  var FLUSH_INTERVAL_MS = 60;
2863
- function App({ model, system, transcript, harvest: harvest2, branch, session, tools }) {
3047
+ function App({
3048
+ model,
3049
+ system,
3050
+ transcript,
3051
+ harvest: harvest2,
3052
+ branch,
3053
+ session,
3054
+ tools,
3055
+ mcpSpecs
3056
+ }) {
2864
3057
  const { exit } = useApp();
2865
3058
  const [historical, setHistorical] = useState2([]);
2866
3059
  const [streaming, setStreaming] = useState2(null);
@@ -2871,7 +3064,8 @@ function App({ model, system, transcript, harvest: harvest2, branch, session, to
2871
3064
  totalCostUsd: 0,
2872
3065
  claudeEquivalentUsd: 0,
2873
3066
  savingsVsClaudePct: 0,
2874
- cacheHitRatio: 0
3067
+ cacheHitRatio: 0,
3068
+ lastPromptTokens: 0
2875
3069
  });
2876
3070
  const transcriptRef = useRef(null);
2877
3071
  if (transcript && !transcriptRef.current) {
@@ -2948,7 +3142,7 @@ function App({ model, system, transcript, harvest: harvest2, branch, session, to
2948
3142
  setInput("");
2949
3143
  const slash = parseSlash(text);
2950
3144
  if (slash) {
2951
- const result = handleSlash(slash.cmd, slash.args, loop);
3145
+ const result = handleSlash(slash.cmd, slash.args, loop, { mcpSpecs });
2952
3146
  if (result.exit) {
2953
3147
  transcriptRef.current?.end();
2954
3148
  exit();
@@ -3059,7 +3253,7 @@ function App({ model, system, transcript, harvest: harvest2, branch, session, to
3059
3253
  setBusy(false);
3060
3254
  }
3061
3255
  },
3062
- [busy, exit, loop, writeTranscript]
3256
+ [busy, exit, loop, mcpSpecs, writeTranscript]
3063
3257
  );
3064
3258
  return /* @__PURE__ */ React6.createElement(Box6, { flexDirection: "column" }, /* @__PURE__ */ React6.createElement(
3065
3259
  StatsPanel,
@@ -3073,7 +3267,7 @@ function App({ model, system, transcript, harvest: harvest2, branch, session, to
3073
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));
3074
3268
  }
3075
3269
  function CommandStrip() {
3076
- return /* @__PURE__ */ React6.createElement(Box6, { paddingX: 2 }, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "/help \xB7 /preset ", "<fast|smart|max>", " \xB7 /sessions \xB7 /model \xB7 /harvest \xB7 /branch \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"));
3077
3271
  }
3078
3272
  function describeRepair(repair) {
3079
3273
  const parts = [];
@@ -3123,7 +3317,7 @@ function Setup({ onReady }) {
3123
3317
  }
3124
3318
 
3125
3319
  // src/cli/commands/chat.tsx
3126
- function Root({ initialKey, tools, ...appProps }) {
3320
+ function Root({ initialKey, tools, mcpSpecs, ...appProps }) {
3127
3321
  const [key, setKey] = useState4(initialKey);
3128
3322
  if (!key) {
3129
3323
  return /* @__PURE__ */ React8.createElement(
@@ -3146,22 +3340,25 @@ function Root({ initialKey, tools, ...appProps }) {
3146
3340
  harvest: appProps.harvest,
3147
3341
  branch: appProps.branch,
3148
3342
  session: appProps.session,
3149
- tools
3343
+ tools,
3344
+ mcpSpecs
3150
3345
  }
3151
3346
  );
3152
3347
  }
3153
3348
  async function chatCommand(opts) {
3154
3349
  loadDotenv();
3155
3350
  const initialKey = loadApiKey();
3156
- const mcpSpecs = opts.mcp ?? [];
3351
+ const requestedSpecs = opts.mcp ?? [];
3157
3352
  const clients = [];
3353
+ const successfulSpecs = [];
3354
+ const failedSpecs = [];
3158
3355
  let tools;
3159
- if (mcpSpecs.length > 0) {
3356
+ if (requestedSpecs.length > 0) {
3160
3357
  tools = new ToolRegistry();
3161
- for (const raw of mcpSpecs) {
3358
+ for (const raw of requestedSpecs) {
3162
3359
  try {
3163
3360
  const spec = parseMcpSpec(raw);
3164
- const prefix = spec.name ? `${spec.name}_` : mcpSpecs.length === 1 && opts.mcpPrefix ? opts.mcpPrefix : "";
3361
+ const prefix = spec.name ? `${spec.name}_` : requestedSpecs.length === 1 && opts.mcpPrefix ? opts.mcpPrefix : "";
3165
3362
  const transport = spec.transport === "sse" ? new SseTransport({ url: spec.url }) : new StdioTransport({ command: spec.command, args: spec.args });
3166
3363
  const mcp2 = new McpClient({ transport });
3167
3364
  await mcp2.initialize();
@@ -3173,17 +3370,26 @@ async function chatCommand(opts) {
3173
3370
  `
3174
3371
  );
3175
3372
  clients.push(mcp2);
3373
+ successfulSpecs.push(raw);
3176
3374
  } catch (err) {
3177
- process.stderr.write(`MCP setup failed for "${raw}": ${err.message}
3178
- `);
3179
- for (const c of clients) await c.close();
3180
- process.exit(1);
3375
+ const reason = err.message;
3376
+ failedSpecs.push({ spec: raw, reason });
3377
+ process.stderr.write(
3378
+ `\u25B8 MCP setup SKIPPED for "${raw}": ${reason}
3379
+ \u2192 this server will not be available this session. Run \`reasonix setup\` to remove it, or fix the underlying issue (missing npm package, network, etc.).
3380
+ `
3381
+ );
3181
3382
  }
3182
3383
  }
3384
+ if (successfulSpecs.length === 0) {
3385
+ tools = void 0;
3386
+ }
3183
3387
  }
3184
- const { waitUntilExit } = render(/* @__PURE__ */ React8.createElement(Root, { initialKey, tools, ...opts }), {
3185
- exitOnCtrlC: true
3186
- });
3388
+ const mcpSpecs = successfulSpecs;
3389
+ const { waitUntilExit } = render(
3390
+ /* @__PURE__ */ React8.createElement(Root, { initialKey, tools, mcpSpecs, ...opts }),
3391
+ { exitOnCtrlC: true }
3392
+ );
3187
3393
  try {
3188
3394
  await waitUntilExit();
3189
3395
  } finally {
@@ -3192,7 +3398,7 @@ async function chatCommand(opts) {
3192
3398
  }
3193
3399
 
3194
3400
  // src/cli/commands/diff.ts
3195
- import { writeFileSync as writeFileSync2 } from "fs";
3401
+ import { writeFileSync as writeFileSync3 } from "fs";
3196
3402
  import { basename } from "path";
3197
3403
  import { render as render2 } from "ink";
3198
3404
  import React11 from "react";
@@ -3338,7 +3544,7 @@ async function diffCommand(opts) {
3338
3544
  if (wantMarkdown) {
3339
3545
  console.log(renderSummaryTable(report));
3340
3546
  const md = renderMarkdown(report);
3341
- writeFileSync2(opts.mdPath, md, "utf8");
3547
+ writeFileSync3(opts.mdPath, md, "utf8");
3342
3548
  console.log(`
3343
3549
  markdown report written to ${opts.mdPath}`);
3344
3550
  return;
@@ -3362,11 +3568,6 @@ var MCP_CATALOG = [
3362
3568
  userArgs: "<dir>",
3363
3569
  note: "the directory is a hard sandbox \u2014 the server refuses access outside it"
3364
3570
  },
3365
- {
3366
- name: "fetch",
3367
- summary: "fetch URLs (markdown-friendly extraction, not a full browser)",
3368
- package: "@modelcontextprotocol/server-fetch"
3369
- },
3370
3571
  {
3371
3572
  name: "memory",
3372
3573
  summary: "persistent key-value memory across sessions",
@@ -3378,12 +3579,6 @@ var MCP_CATALOG = [
3378
3579
  package: "@modelcontextprotocol/server-github",
3379
3580
  note: "set GITHUB_PERSONAL_ACCESS_TOKEN in your env before spawning"
3380
3581
  },
3381
- {
3382
- name: "sqlite",
3383
- summary: "read/write a sqlite database file",
3384
- package: "@modelcontextprotocol/server-sqlite",
3385
- userArgs: "<db.sqlite>"
3386
- },
3387
3582
  {
3388
3583
  name: "puppeteer",
3389
3584
  summary: "browser automation \u2014 take screenshots, click, type",
@@ -3465,7 +3660,9 @@ function ReplayApp({ meta, pages }) {
3465
3660
  totalCostUsd: cumStats.totalCostUsd,
3466
3661
  claudeEquivalentUsd: cumStats.claudeEquivalentUsd,
3467
3662
  savingsVsClaudePct: cumStats.savingsVsClaudePct,
3468
- cacheHitRatio: cumStats.cacheHitRatio
3663
+ cacheHitRatio: cumStats.cacheHitRatio,
3664
+ // Replay is read-only — no live last-turn prompt tokens to show.
3665
+ lastPromptTokens: 0
3469
3666
  };
3470
3667
  const prefixHash = cumStats.prefixHashes.length === 1 ? cumStats.prefixHashes[0].slice(0, 16) : cumStats.prefixHashes.length === 0 ? "(untracked)" : `(churned \xD7${cumStats.prefixHashes.length})`;
3471
3668
  const currentPage = pages[idx];
@@ -3619,15 +3816,16 @@ async function runCommand(opts) {
3619
3816
  loadDotenv();
3620
3817
  const apiKey = await ensureApiKey();
3621
3818
  process.env.DEEPSEEK_API_KEY = apiKey;
3622
- const mcpSpecs = opts.mcp ?? [];
3819
+ const requestedSpecs = opts.mcp ?? [];
3623
3820
  const clients = [];
3624
3821
  let tools;
3625
- if (mcpSpecs.length > 0) {
3822
+ let successCount = 0;
3823
+ if (requestedSpecs.length > 0) {
3626
3824
  tools = new ToolRegistry();
3627
- for (const raw of mcpSpecs) {
3825
+ for (const raw of requestedSpecs) {
3628
3826
  try {
3629
3827
  const spec = parseMcpSpec(raw);
3630
- const prefix2 = spec.name ? `${spec.name}_` : mcpSpecs.length === 1 && opts.mcpPrefix ? opts.mcpPrefix : "";
3828
+ const prefix2 = spec.name ? `${spec.name}_` : requestedSpecs.length === 1 && opts.mcpPrefix ? opts.mcpPrefix : "";
3631
3829
  const transport = spec.transport === "sse" ? new SseTransport({ url: spec.url }) : new StdioTransport({ command: spec.command, args: spec.args });
3632
3830
  const mcp2 = new McpClient({ transport });
3633
3831
  await mcp2.initialize();
@@ -3638,13 +3836,16 @@ async function runCommand(opts) {
3638
3836
  `
3639
3837
  );
3640
3838
  clients.push(mcp2);
3839
+ successCount++;
3641
3840
  } catch (err) {
3642
- process.stderr.write(`MCP setup failed for "${raw}": ${err.message}
3643
- `);
3644
- for (const c of clients) await c.close();
3645
- process.exit(1);
3841
+ process.stderr.write(
3842
+ `\u25B8 MCP setup SKIPPED for "${raw}": ${err.message}
3843
+ \u2192 run \`reasonix setup\` to remove broken entries from your saved config.
3844
+ `
3845
+ );
3646
3846
  }
3647
3847
  }
3848
+ if (successCount === 0) tools = void 0;
3648
3849
  }
3649
3850
  const client = new DeepSeekClient();
3650
3851
  const prefix = new ImmutablePrefix({
@@ -3784,6 +3985,404 @@ function truncate4(s, max) {
3784
3985
  return s.length <= max ? s : `${s.slice(0, max)}\u2026`;
3785
3986
  }
3786
3987
 
3988
+ // src/cli/commands/setup.tsx
3989
+ import { render as render4 } from "ink";
3990
+ import React16 from "react";
3991
+
3992
+ // src/cli/ui/Wizard.tsx
3993
+ import { Box as Box12, Text as Text12, useApp as useApp5, useInput as useInput4 } from "ink";
3994
+ import TextInput3 from "ink-text-input";
3995
+ import React15, { useState as useState8 } from "react";
3996
+
3997
+ // src/cli/ui/Select.tsx
3998
+ import { Box as Box11, Text as Text11, useInput as useInput3 } from "ink";
3999
+ import React14, { useState as useState7 } from "react";
4000
+ function SingleSelect({
4001
+ items,
4002
+ initialValue,
4003
+ onSubmit,
4004
+ onCancel
4005
+ }) {
4006
+ const initialIndex = Math.max(
4007
+ 0,
4008
+ items.findIndex((i) => i.value === initialValue && !i.disabled)
4009
+ );
4010
+ const [index, setIndex] = useState7(initialIndex === -1 ? 0 : initialIndex);
4011
+ useInput3((_input, key) => {
4012
+ if (key.upArrow) {
4013
+ setIndex((i) => findNextEnabled(items, i, -1));
4014
+ } else if (key.downArrow) {
4015
+ setIndex((i) => findNextEnabled(items, i, 1));
4016
+ } else if (key.return) {
4017
+ const chosen = items[index];
4018
+ if (chosen && !chosen.disabled) onSubmit(chosen.value);
4019
+ } else if (key.escape && onCancel) {
4020
+ onCancel();
4021
+ }
4022
+ });
4023
+ return /* @__PURE__ */ React14.createElement(Box11, { flexDirection: "column" }, items.map((item, i) => /* @__PURE__ */ React14.createElement(
4024
+ SelectRow,
4025
+ {
4026
+ key: item.value,
4027
+ item,
4028
+ active: i === index,
4029
+ marker: i === index ? "\u25B8" : " "
4030
+ }
4031
+ )));
4032
+ }
4033
+ function MultiSelect({
4034
+ items,
4035
+ initialSelected = [],
4036
+ onSubmit,
4037
+ onCancel,
4038
+ footer
4039
+ }) {
4040
+ const [index, setIndex] = useState7(() => {
4041
+ const first = items.findIndex((i) => !i.disabled);
4042
+ return first === -1 ? 0 : first;
4043
+ });
4044
+ const [selected, setSelected] = useState7(new Set(initialSelected));
4045
+ useInput3((input, key) => {
4046
+ if (key.upArrow) {
4047
+ setIndex((i) => findNextEnabled(items, i, -1));
4048
+ } else if (key.downArrow) {
4049
+ setIndex((i) => findNextEnabled(items, i, 1));
4050
+ } else if (input === " ") {
4051
+ const item = items[index];
4052
+ if (!item || item.disabled) return;
4053
+ setSelected((prev) => {
4054
+ const next = new Set(prev);
4055
+ if (next.has(item.value)) next.delete(item.value);
4056
+ else next.add(item.value);
4057
+ return next;
4058
+ });
4059
+ } else if (key.return) {
4060
+ const ordered = items.filter((i) => selected.has(i.value)).map((i) => i.value);
4061
+ onSubmit(ordered);
4062
+ } else if (key.escape && onCancel) {
4063
+ onCancel();
4064
+ }
4065
+ });
4066
+ return /* @__PURE__ */ React14.createElement(Box11, { flexDirection: "column" }, items.map((item, i) => {
4067
+ const checked = selected.has(item.value);
4068
+ const marker = checked ? "[x]" : "[ ]";
4069
+ return /* @__PURE__ */ React14.createElement(
4070
+ SelectRow,
4071
+ {
4072
+ key: item.value,
4073
+ item,
4074
+ active: i === index,
4075
+ marker: `${i === index ? "\u25B8" : " "} ${marker}`
4076
+ }
4077
+ );
4078
+ }), footer ? /* @__PURE__ */ React14.createElement(Box11, { marginTop: 1 }, /* @__PURE__ */ React14.createElement(Text11, { dimColor: true }, footer)) : null);
4079
+ }
4080
+ function SelectRow({
4081
+ item,
4082
+ active,
4083
+ marker
4084
+ }) {
4085
+ const color = item.disabled ? "gray" : active ? "cyan" : void 0;
4086
+ return /* @__PURE__ */ React14.createElement(Box11, { flexDirection: "column" }, /* @__PURE__ */ React14.createElement(Box11, null, /* @__PURE__ */ React14.createElement(Text11, { color }, marker, " ", item.label)), item.hint ? /* @__PURE__ */ React14.createElement(Box11, { paddingLeft: marker.length + 1 }, /* @__PURE__ */ React14.createElement(Text11, { dimColor: true }, item.hint)) : null);
4087
+ }
4088
+ function findNextEnabled(items, from, step) {
4089
+ if (items.length === 0) return 0;
4090
+ let i = from;
4091
+ for (let tries = 0; tries < items.length; tries++) {
4092
+ i = (i + step + items.length) % items.length;
4093
+ if (!items[i]?.disabled) return i;
4094
+ }
4095
+ return from;
4096
+ }
4097
+
4098
+ // src/cli/ui/presets.ts
4099
+ var PRESETS = {
4100
+ fast: { model: "deepseek-chat", harvest: false, branch: 1 },
4101
+ smart: { model: "deepseek-reasoner", harvest: true, branch: 1 },
4102
+ max: { model: "deepseek-reasoner", harvest: true, branch: 3 }
4103
+ };
4104
+ var PRESET_DESCRIPTIONS = {
4105
+ fast: {
4106
+ headline: "deepseek-chat, no reasoning harvest, no branching",
4107
+ cost: "~1\xA2 per 100 turns \xB7 default"
4108
+ },
4109
+ smart: {
4110
+ headline: "deepseek-reasoner + Pillar 2 harvest",
4111
+ cost: "~10\xD7 cost vs fast \xB7 slower \xB7 better on multi-step tasks"
4112
+ },
4113
+ max: {
4114
+ headline: "reasoner + harvest + self-consistency (3 branches)",
4115
+ cost: "~30\xD7 cost vs fast \xB7 slowest \xB7 for hard single-shots"
4116
+ }
4117
+ };
4118
+
4119
+ // src/cli/ui/Wizard.tsx
4120
+ var CATALOG_BY_NAME = new Map(MCP_CATALOG.map((e) => [e.name, e]));
4121
+ function Wizard({ onComplete, onCancel, existingApiKey, initial }) {
4122
+ const { exit } = useApp5();
4123
+ const [step, setStep] = useState8(existingApiKey ? "preset" : "apiKey");
4124
+ const [data, setData] = useState8({
4125
+ apiKey: existingApiKey ?? "",
4126
+ preset: initial?.preset ?? "fast",
4127
+ selectedCatalog: deriveInitialCatalog(initial?.mcp ?? []),
4128
+ catalogArgs: {}
4129
+ });
4130
+ const [error, setError] = useState8(null);
4131
+ useInput4((_input, key) => {
4132
+ if (key.escape && step !== "saved" && onCancel) onCancel();
4133
+ });
4134
+ if (step === "apiKey") {
4135
+ return /* @__PURE__ */ React15.createElement(
4136
+ ApiKeyStep,
4137
+ {
4138
+ onSubmit: (key) => {
4139
+ setData((d) => ({ ...d, apiKey: key }));
4140
+ setError(null);
4141
+ setStep("preset");
4142
+ },
4143
+ error,
4144
+ onError: setError
4145
+ }
4146
+ );
4147
+ }
4148
+ if (step === "preset") {
4149
+ return /* @__PURE__ */ React15.createElement(StepFrame, { title: "Pick a preset", step: 1, total: 3 }, /* @__PURE__ */ React15.createElement(
4150
+ SingleSelect,
4151
+ {
4152
+ items: presetItems(),
4153
+ initialValue: data.preset,
4154
+ onSubmit: (preset) => {
4155
+ setData((d) => ({ ...d, preset }));
4156
+ setStep("mcp");
4157
+ }
4158
+ }
4159
+ ), /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, { dimColor: true }, "\u2191/\u2193 move \xB7 enter confirm \xB7 esc cancel")));
4160
+ }
4161
+ if (step === "mcp") {
4162
+ return /* @__PURE__ */ React15.createElement(StepFrame, { title: "Which MCP servers should Reasonix wire up for you?", step: 2, total: 3 }, /* @__PURE__ */ React15.createElement(
4163
+ MultiSelect,
4164
+ {
4165
+ items: mcpItems(),
4166
+ initialSelected: data.selectedCatalog,
4167
+ onSubmit: (selected) => {
4168
+ setData((d) => ({ ...d, selectedCatalog: selected }));
4169
+ const needsArgs = selected.some((name) => CATALOG_BY_NAME.get(name)?.userArgs);
4170
+ setStep(needsArgs ? "mcpArgs" : "review");
4171
+ },
4172
+ footer: "\u2191/\u2193 move \xB7 space toggle \xB7 enter confirm \xB7 esc cancel \xB7 leave empty to skip"
4173
+ }
4174
+ ));
4175
+ }
4176
+ if (step === "mcpArgs") {
4177
+ const pending = data.selectedCatalog.filter((name) => {
4178
+ const entry2 = CATALOG_BY_NAME.get(name);
4179
+ return entry2?.userArgs && !data.catalogArgs[name];
4180
+ });
4181
+ if (pending.length === 0) {
4182
+ setStep("review");
4183
+ return null;
4184
+ }
4185
+ const currentName = pending[0];
4186
+ const entry = CATALOG_BY_NAME.get(currentName);
4187
+ return /* @__PURE__ */ React15.createElement(
4188
+ McpArgsStep,
4189
+ {
4190
+ entry,
4191
+ error,
4192
+ onSubmit: (value) => {
4193
+ setData((d) => ({
4194
+ ...d,
4195
+ catalogArgs: { ...d.catalogArgs, [currentName]: value }
4196
+ }));
4197
+ setError(null);
4198
+ },
4199
+ onError: setError
4200
+ }
4201
+ );
4202
+ }
4203
+ if (step === "review") {
4204
+ const specs = data.selectedCatalog.map((name) => buildSpec(name, data.catalogArgs));
4205
+ return /* @__PURE__ */ React15.createElement(StepFrame, { title: "Ready to save", step: 3, total: 3 }, /* @__PURE__ */ React15.createElement(Box12, { flexDirection: "column" }, /* @__PURE__ */ React15.createElement(SummaryLine, { label: "API key", value: redactKey(data.apiKey) }), /* @__PURE__ */ React15.createElement(SummaryLine, { label: "Preset", value: data.preset }), /* @__PURE__ */ React15.createElement(
4206
+ SummaryLine,
4207
+ {
4208
+ label: "MCP",
4209
+ value: specs.length === 0 ? "(none)" : `${specs.length} server(s)`
4210
+ }
4211
+ ), specs.map((spec, i) => (
4212
+ // biome-ignore lint/suspicious/noArrayIndexKey: review-only render, order fixed
4213
+ /* @__PURE__ */ React15.createElement(Box12, { key: i, paddingLeft: 14 }, /* @__PURE__ */ React15.createElement(Text12, { dimColor: true }, "\xB7 ", spec))
4214
+ )), /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, null, "Saves to ", defaultConfigPath())), error ? /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, { color: "red" }, error)) : null, /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, { dimColor: true }, "enter save \xB7 esc cancel"))), /* @__PURE__ */ React15.createElement(
4215
+ ReviewConfirm,
4216
+ {
4217
+ onConfirm: () => {
4218
+ try {
4219
+ const specsNow = data.selectedCatalog.map(
4220
+ (name) => buildSpec(name, data.catalogArgs)
4221
+ );
4222
+ const prev = readConfig();
4223
+ const next = {
4224
+ ...prev,
4225
+ apiKey: data.apiKey,
4226
+ preset: data.preset,
4227
+ mcp: specsNow,
4228
+ setupCompleted: true
4229
+ };
4230
+ writeConfig(next);
4231
+ setStep("saved");
4232
+ onComplete(next);
4233
+ } catch (e) {
4234
+ setError(`Could not save config: ${e.message}`);
4235
+ }
4236
+ }
4237
+ }
4238
+ ));
4239
+ }
4240
+ return /* @__PURE__ */ React15.createElement(Box12, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1 }, /* @__PURE__ */ React15.createElement(Text12, { bold: true, color: "green" }, "\u25B8 Saved."), /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, null, "Run `reasonix` any time to start chatting \u2014 your settings are remembered.")), /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, { dimColor: true }, "Press enter to exit.")), /* @__PURE__ */ React15.createElement(ExitOnEnter, { onExit: exit }));
4241
+ }
4242
+ function ApiKeyStep({
4243
+ onSubmit,
4244
+ error,
4245
+ onError
4246
+ }) {
4247
+ const [value, setValue] = useState8("");
4248
+ return /* @__PURE__ */ React15.createElement(Box12, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1 }, /* @__PURE__ */ React15.createElement(Text12, { bold: true, color: "cyan" }, "Welcome to Reasonix."), /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, null, "Paste your DeepSeek API key to get started.")), /* @__PURE__ */ React15.createElement(Text12, { dimColor: true }, "Get one (free credit on signup): https://platform.deepseek.com/api_keys"), /* @__PURE__ */ React15.createElement(Text12, { dimColor: true }, "Saved locally to ", defaultConfigPath()), /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, { bold: true, color: "cyan" }, "key \u203A "), /* @__PURE__ */ React15.createElement(
4249
+ TextInput3,
4250
+ {
4251
+ value,
4252
+ onChange: setValue,
4253
+ onSubmit: (raw) => {
4254
+ const trimmed = raw.trim();
4255
+ if (!isPlausibleKey(trimmed)) {
4256
+ onError("Doesn't look like a DeepSeek key. They start with 'sk-' and are 30+ chars.");
4257
+ setValue("");
4258
+ return;
4259
+ }
4260
+ onSubmit(trimmed);
4261
+ },
4262
+ mask: "\u2022",
4263
+ placeholder: "sk-..."
4264
+ }
4265
+ )), error ? /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, { color: "red" }, error)) : value ? /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, { dimColor: true }, "preview: ", redactKey(value))) : null);
4266
+ }
4267
+ function McpArgsStep({
4268
+ entry,
4269
+ error,
4270
+ onSubmit,
4271
+ onError
4272
+ }) {
4273
+ const [value, setValue] = useState8("");
4274
+ return /* @__PURE__ */ React15.createElement(StepFrame, { title: `Configure ${entry.name}`, step: 2, total: 3 }, /* @__PURE__ */ React15.createElement(Box12, { flexDirection: "column" }, /* @__PURE__ */ React15.createElement(Text12, null, entry.summary), entry.note ? /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, { dimColor: true }, entry.note)) : null, /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, null, "Required parameter: "), /* @__PURE__ */ React15.createElement(Text12, { bold: true }, entry.userArgs)), /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, { bold: true, color: "cyan" }, entry.userArgs, " \u203A "), /* @__PURE__ */ React15.createElement(
4275
+ TextInput3,
4276
+ {
4277
+ value,
4278
+ onChange: setValue,
4279
+ onSubmit: (raw) => {
4280
+ const trimmed = raw.trim();
4281
+ if (!trimmed) {
4282
+ onError(`${entry.name} needs a value \u2014 got an empty string.`);
4283
+ return;
4284
+ }
4285
+ onSubmit(trimmed);
4286
+ setValue("");
4287
+ },
4288
+ placeholder: placeholderFor(entry)
4289
+ }
4290
+ )), error ? /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1 }, /* @__PURE__ */ React15.createElement(Text12, { color: "red" }, error)) : null));
4291
+ }
4292
+ function ReviewConfirm({ onConfirm }) {
4293
+ useInput4((_i, key) => {
4294
+ if (key.return) onConfirm();
4295
+ });
4296
+ return null;
4297
+ }
4298
+ function ExitOnEnter({ onExit }) {
4299
+ useInput4((_i, key) => {
4300
+ if (key.return) onExit();
4301
+ });
4302
+ return null;
4303
+ }
4304
+ function StepFrame({
4305
+ title,
4306
+ step,
4307
+ total,
4308
+ children
4309
+ }) {
4310
+ return /* @__PURE__ */ React15.createElement(Box12, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1 }, /* @__PURE__ */ React15.createElement(Box12, null, /* @__PURE__ */ React15.createElement(Text12, { dimColor: true }, "Step ", step, "/", total, " \xB7", " "), /* @__PURE__ */ React15.createElement(Text12, { bold: true, color: "cyan" }, title)), /* @__PURE__ */ React15.createElement(Box12, { marginTop: 1, flexDirection: "column" }, children));
4311
+ }
4312
+ function SummaryLine({ label, value }) {
4313
+ return /* @__PURE__ */ React15.createElement(Box12, null, /* @__PURE__ */ React15.createElement(Text12, null, label.padEnd(12)), /* @__PURE__ */ React15.createElement(Text12, { bold: true }, value));
4314
+ }
4315
+ function presetItems() {
4316
+ return ["fast", "smart", "max"].map((name) => ({
4317
+ value: name,
4318
+ label: `${name} \u2014 ${PRESET_DESCRIPTIONS[name].headline}`,
4319
+ hint: PRESET_DESCRIPTIONS[name].cost
4320
+ }));
4321
+ }
4322
+ function mcpItems() {
4323
+ return MCP_CATALOG.map((entry) => {
4324
+ const hintParts = [entry.summary];
4325
+ if (entry.userArgs) hintParts.push(`(you'll provide ${entry.userArgs})`);
4326
+ if (entry.note) hintParts.push(entry.note);
4327
+ return {
4328
+ value: entry.name,
4329
+ label: entry.name,
4330
+ hint: hintParts.join(" \xB7 ")
4331
+ };
4332
+ });
4333
+ }
4334
+ function placeholderFor(entry) {
4335
+ if (entry.name === "filesystem") return "e.g. /tmp/reasonix-sandbox";
4336
+ if (entry.name === "sqlite") return "e.g. ./notes.sqlite";
4337
+ return entry.userArgs ?? "";
4338
+ }
4339
+ function deriveInitialCatalog(existingSpecs) {
4340
+ const packageToName = new Map(MCP_CATALOG.map((e) => [e.package, e.name]));
4341
+ const out = [];
4342
+ for (const spec of existingSpecs) {
4343
+ for (const [pkg, name] of packageToName) {
4344
+ if (spec.includes(pkg)) {
4345
+ out.push(name);
4346
+ break;
4347
+ }
4348
+ }
4349
+ }
4350
+ return out;
4351
+ }
4352
+ function buildSpec(name, argsByName) {
4353
+ const entry = CATALOG_BY_NAME.get(name);
4354
+ if (!entry) return name;
4355
+ const userArg = entry.userArgs ? argsByName[name] : void 0;
4356
+ const tail = userArg ? ` ${quoteIfNeeded(userArg)}` : "";
4357
+ return `${entry.name}=npx -y ${entry.package}${tail}`;
4358
+ }
4359
+ function quoteIfNeeded(s) {
4360
+ return /\s|"/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
4361
+ }
4362
+
4363
+ // src/cli/commands/setup.tsx
4364
+ async function setupCommand(_opts = {}) {
4365
+ loadDotenv();
4366
+ const existingKey = loadApiKey();
4367
+ const existing = readConfig();
4368
+ const { waitUntilExit, unmount } = render4(
4369
+ /* @__PURE__ */ React16.createElement(
4370
+ Wizard,
4371
+ {
4372
+ existingApiKey: existingKey,
4373
+ initial: { preset: existing.preset, mcp: existing.mcp },
4374
+ onComplete: () => {
4375
+ },
4376
+ onCancel: () => {
4377
+ unmount();
4378
+ }
4379
+ }
4380
+ ),
4381
+ { exitOnCtrlC: true }
4382
+ );
4383
+ await waitUntilExit();
4384
+ }
4385
+
3787
4386
  // src/cli/commands/stats.ts
3788
4387
  import { existsSync as existsSync2, readFileSync as readFileSync5 } from "fs";
3789
4388
  function statsCommand(opts) {
@@ -3815,72 +4414,131 @@ function versionCommand() {
3815
4414
  console.log(`reasonix ${VERSION}`);
3816
4415
  }
3817
4416
 
4417
+ // src/cli/resolve.ts
4418
+ function resolveDefaults(flags) {
4419
+ const cfg = flags.noConfig ? {} : readConfig();
4420
+ const preset = pickPreset(flags.preset, cfg.preset);
4421
+ const presetSettings = PRESETS[preset];
4422
+ const model = flags.model ?? presetSettings.model;
4423
+ const harvest2 = flags.harvest === true ? true : presetSettings.harvest;
4424
+ const branchFromFlag = normalizeBranch(flags.branch);
4425
+ const branch = branchFromFlag ?? (presetSettings.branch > 1 ? presetSettings.branch : void 0);
4426
+ const mcp2 = flags.mcp && flags.mcp.length > 0 ? flags.mcp : cfg.mcp ?? [];
4427
+ const session = resolveSession(flags.session, cfg.session);
4428
+ return { model, harvest: harvest2, branch, mcp: mcp2, session };
4429
+ }
4430
+ function pickPreset(flagPreset, configPreset) {
4431
+ if (flagPreset && isPresetName(flagPreset)) return flagPreset;
4432
+ if (configPreset) return configPreset;
4433
+ return "fast";
4434
+ }
4435
+ function isPresetName(s) {
4436
+ return s === "fast" || s === "smart" || s === "max";
4437
+ }
4438
+ function normalizeBranch(raw) {
4439
+ if (raw === void 0) return void 0;
4440
+ if (!Number.isFinite(raw) || raw <= 1) return void 0;
4441
+ return Math.min(raw, 8);
4442
+ }
4443
+ function resolveSession(flag, configSession) {
4444
+ if (flag === false) return void 0;
4445
+ if (typeof flag === "string" && flag.length > 0) return flag;
4446
+ if (configSession === null) return void 0;
4447
+ if (typeof configSession === "string" && configSession.length > 0) return configSession;
4448
+ return "default";
4449
+ }
4450
+
3818
4451
  // src/cli/index.ts
3819
4452
  var DEFAULT_SYSTEM = "You are Reasonix, a helpful DeepSeek-powered assistant. Be concise and accurate. Use tools when available.";
3820
4453
  var program = new Command();
3821
4454
  program.name("reasonix").description("DeepSeek-native agent framework \u2014 built for cache hits and cheap tokens.").version(VERSION);
3822
- program.command("chat").description("Interactive Ink TUI with live cache/cost panel.").option("-m, --model <id>", "DeepSeek model id", "deepseek-chat").option("-s, --system <prompt>", "System prompt (pinned in the immutable prefix)", DEFAULT_SYSTEM).option("--transcript <path>", "Write a JSONL transcript to this path").option(
4455
+ program.action(async () => {
4456
+ const cfg = readConfig();
4457
+ if (!cfg.setupCompleted) {
4458
+ await setupCommand({});
4459
+ return;
4460
+ }
4461
+ const defaults = resolveDefaults({});
4462
+ await chatCommand({
4463
+ model: defaults.model,
4464
+ system: DEFAULT_SYSTEM,
4465
+ harvest: defaults.harvest,
4466
+ branch: defaults.branch,
4467
+ session: defaults.session,
4468
+ mcp: defaults.mcp
4469
+ });
4470
+ });
4471
+ program.command("setup").description("Interactive wizard \u2014 API key, preset, MCP servers. Re-run any time to reconfigure.").action(async () => {
4472
+ await setupCommand({});
4473
+ });
4474
+ 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(
4475
+ "--preset <name>",
4476
+ "Bundle of model + harvest + branch. One of: fast, smart, max. Overrides config.preset."
4477
+ ).option(
3823
4478
  "--harvest",
3824
- "Extract typed plan state from R1 reasoning (Pillar 2, adds a cheap V3 call per turn)"
4479
+ "Extract typed plan state from R1 reasoning (Pillar 2). Overrides preset's harvest setting."
3825
4480
  ).option(
3826
4481
  "--branch <n>",
3827
4482
  "Self-consistency: run N parallel samples per turn and pick the most confident (disables streaming; enables harvest)",
3828
4483
  (v) => Number.parseInt(v, 10)
3829
- ).option(
3830
- "--session <name>",
3831
- "Use a named session (default: 'default'). Resume the same session next time."
3832
- ).option("--no-session", "Disable session persistence for this run (ephemeral chat)").option(
4484
+ ).option("--session <name>", "Use a named session (default: from config, usually 'default').").option("--no-session", "Disable session persistence for this run (ephemeral chat)").option(
3833
4485
  "--mcp <spec>",
3834
- 'MCP server spec; repeatable. Forms: "name=cmd args..." (namespaced, tools get `name_` prefix) or "cmd args..." (anonymous). Example: --mcp "fs=npx -y @scope/fs /tmp" --mcp "gh=npx -y @scope/gh"',
4486
+ 'MCP server spec; repeatable. "name=cmd args...", "cmd args...", or a URL (http/https \u2192 SSE transport). Overrides config.mcp when provided.',
3835
4487
  (value, previous = []) => [...previous, value],
3836
4488
  []
3837
4489
  ).option(
3838
4490
  "--mcp-prefix <str>",
3839
4491
  "Global prefix applied to every MCP tool (only honored when no per-spec name is set; avoids collisions with a single anonymous server)"
3840
- ).action(async (opts) => {
3841
- let session;
3842
- if (opts.session === false) {
3843
- session = void 0;
3844
- } else if (typeof opts.session === "string" && opts.session.length > 0) {
3845
- session = opts.session;
3846
- } else {
3847
- session = "default";
3848
- }
3849
- await chatCommand({
4492
+ ).option("--no-config", "Ignore `~/.reasonix/config.json` \u2014 useful for CI or reproducing issues").action(async (opts) => {
4493
+ const defaults = resolveDefaults({
3850
4494
  model: opts.model,
4495
+ harvest: opts.harvest,
4496
+ branch: opts.branch,
4497
+ mcp: opts.mcp,
4498
+ session: opts.session,
4499
+ preset: opts.preset,
4500
+ noConfig: opts.config === false
4501
+ });
4502
+ await chatCommand({
4503
+ model: defaults.model,
3851
4504
  system: opts.system,
3852
4505
  transcript: opts.transcript,
3853
- harvest: !!opts.harvest,
3854
- branch: Number.isFinite(opts.branch) && opts.branch > 1 ? opts.branch : void 0,
3855
- session,
3856
- mcp: opts.mcp,
4506
+ harvest: defaults.harvest,
4507
+ branch: defaults.branch,
4508
+ session: defaults.session,
4509
+ mcp: defaults.mcp,
3857
4510
  mcpPrefix: opts.mcpPrefix
3858
4511
  });
3859
4512
  });
3860
- program.command("run <task>").description("Run a single task non-interactively, streaming output.").option("-m, --model <id>", "DeepSeek model id", "deepseek-chat").option("-s, --system <prompt>", "System prompt", DEFAULT_SYSTEM).option(
3861
- "--harvest",
3862
- "Extract typed plan state from R1 reasoning (Pillar 2, adds a cheap V3 call per turn)"
3863
- ).option(
4513
+ program.command("run <task>").description("Run a single task non-interactively, streaming output.").option("-m, --model <id>", "DeepSeek model id (overrides preset)").option("-s, --system <prompt>", "System prompt", DEFAULT_SYSTEM).option("--preset <name>", "Bundle of model + harvest + branch: fast | smart | max").option("--harvest", "Extract typed plan state from R1 reasoning (Pillar 2)").option(
3864
4514
  "--branch <n>",
3865
4515
  "Self-consistency: run N parallel samples per turn and pick the most confident",
3866
4516
  (v) => Number.parseInt(v, 10)
3867
4517
  ).option("--transcript <path>", "Write a JSONL transcript to this path for replay/diff").option(
3868
4518
  "--mcp <spec>",
3869
- 'MCP server spec; repeatable. "name=cmd args..." or "cmd args...".',
4519
+ 'MCP server spec; repeatable. "name=cmd args...", "cmd args...", or a URL (http/https \u2192 SSE).',
3870
4520
  (value, previous = []) => [...previous, value],
3871
4521
  []
3872
4522
  ).option(
3873
4523
  "--mcp-prefix <str>",
3874
4524
  "Global prefix (only honored when no per-spec name is set; for a single anonymous server)"
3875
- ).action(async (task, opts) => {
4525
+ ).option("--no-config", "Ignore `~/.reasonix/config.json` \u2014 useful for CI or reproducing issues").action(async (task, opts) => {
4526
+ const defaults = resolveDefaults({
4527
+ model: opts.model,
4528
+ harvest: opts.harvest,
4529
+ branch: opts.branch,
4530
+ mcp: opts.mcp,
4531
+ preset: opts.preset,
4532
+ noConfig: opts.config === false
4533
+ });
3876
4534
  await runCommand({
3877
4535
  task,
3878
- model: opts.model,
4536
+ model: defaults.model,
3879
4537
  system: opts.system,
3880
- harvest: !!opts.harvest,
3881
- branch: Number.isFinite(opts.branch) && opts.branch > 1 ? opts.branch : void 0,
4538
+ harvest: defaults.harvest,
4539
+ branch: defaults.branch,
3882
4540
  transcript: opts.transcript,
3883
- mcp: opts.mcp,
4541
+ mcp: defaults.mcp,
3884
4542
  mcpPrefix: opts.mcpPrefix
3885
4543
  });
3886
4544
  });