reasonix 0.3.0-alpha.6 → 0.3.2

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/index.d.ts CHANGED
@@ -244,6 +244,14 @@ declare class AppendOnlyLog {
244
244
  private _entries;
245
245
  append(message: ChatMessage): void;
246
246
  extend(messages: ChatMessage[]): void;
247
+ /**
248
+ * Bulk-replace entries. Intentionally named to be hard to reach for —
249
+ * this is the one mutation path that breaks the log's append-only
250
+ * spirit, reserved for compaction flows (`/compact`) and recovery
251
+ * where the caller has consciously decided to drop old history. Any
252
+ * other use is almost certainly wrong; append() is what you want.
253
+ */
254
+ compactInPlace(replacement: ChatMessage[]): void;
247
255
  get entries(): readonly ChatMessage[];
248
256
  toMessages(): ChatMessage[];
249
257
  get length(): number;
@@ -379,6 +387,13 @@ interface SessionSummary {
379
387
  claudeEquivalentUsd: number;
380
388
  savingsVsClaudePct: number;
381
389
  cacheHitRatio: number;
390
+ /**
391
+ * Most recent turn's prompt-token count. Used by the TUI's context
392
+ * gauge: we can't know the next call's cost without making it, but
393
+ * the last turn's prompt tokens is the floor (next call is last
394
+ * prompt + user delta + any new tool outputs).
395
+ */
396
+ lastPromptTokens: number;
382
397
  }
383
398
  declare class SessionStats {
384
399
  readonly turns: TurnStats[];
@@ -418,7 +433,7 @@ declare class ToolRegistry {
418
433
  dispatch(name: string, argumentsRaw: string | Record<string, unknown>): Promise<string>;
419
434
  }
420
435
 
421
- type EventRole = "assistant_delta" | "assistant_final" | "tool" | "done" | "error" | "branch_start" | "branch_progress" | "branch_done";
436
+ type EventRole = "assistant_delta" | "assistant_final" | "tool" | "done" | "error" | "warning" | "branch_start" | "branch_progress" | "branch_done";
422
437
  interface BranchSummary {
423
438
  budget: number;
424
439
  chosenIndex: number;
@@ -513,7 +528,28 @@ declare class CacheFirstLoop {
513
528
  readonly resumedMessageCount: number;
514
529
  private _turn;
515
530
  private _streamPreference;
531
+ /**
532
+ * Set by {@link abort} to short-circuit the tool-call loop after the
533
+ * current iteration. Reset at the start of each `step()` so an Esc
534
+ * during one turn doesn't poison the next.
535
+ */
536
+ private _aborted;
516
537
  constructor(opts: CacheFirstLoopOptions);
538
+ /**
539
+ * Shrink the log by re-truncating oversized tool results to a tighter
540
+ * cap, and persist the result back to disk so the next launch doesn't
541
+ * re-inherit a fat session file. Returns a summary the TUI can
542
+ * display.
543
+ *
544
+ * Only tool-role messages are touched (same rationale as
545
+ * {@link healLoadedMessages}). User and assistant messages carry
546
+ * authored intent we can't mechanically shrink without losing
547
+ * meaning.
548
+ */
549
+ compact(tightCapChars?: number): {
550
+ healedCount: number;
551
+ charsSaved: number;
552
+ };
517
553
  private appendAndPersist;
518
554
  /**
519
555
  * Reconfigure model/harvest/branch/stream mid-session. The loop's log,
@@ -523,7 +559,16 @@ declare class CacheFirstLoop {
523
559
  */
524
560
  configure(opts: ReconfigurableOptions): void;
525
561
  private buildMessages;
562
+ /**
563
+ * Signal the currently-running {@link step} that the user wants to
564
+ * stop exploring. Takes effect at the next iteration boundary — if a
565
+ * tool call is mid-flight it will be allowed to finish, then the
566
+ * loop diverts to the forced-summary path so the user gets an
567
+ * answer instead of a cliff. Called by the TUI on Esc.
568
+ */
569
+ abort(): void;
526
570
  step(userInput: string): AsyncGenerator<LoopEvent>;
571
+ private forceSummaryAfterIterLimit;
527
572
  run(userInput: string, onEvent?: (ev: LoopEvent) => void): Promise<string>;
528
573
  private assistantMessage;
529
574
  }
@@ -1246,6 +1291,6 @@ declare function redactKey(key: string): string;
1246
1291
 
1247
1292
  /** Reasonix — DeepSeek-native agent framework. Library entry point. */
1248
1293
 
1249
- declare const VERSION = "0.3.0-alpha.6";
1294
+ declare const VERSION = "0.3.2";
1250
1295
 
1251
1296
  export { AppendOnlyLog, type BranchOptions, type BranchProgress, type BranchResult, type BranchSample, type BranchSelector, type BranchSummary, type BridgeOptions, type BridgeResult, CacheFirstLoop, type CacheFirstLoopOptions, type CallToolResult, type ChatMessage, type ChatResponse, DEFAULT_MAX_RESULT_CHARS, DeepSeekClient, type DeepSeekClientOptions, type RenderOptions as DiffRenderOptions, type DiffReport, type DiffSide, type EventRole, type FlattenDecision, type FlattenOptions, type HarvestOptions, ImmutablePrefix, type ImmutablePrefixOptions, type InitializeResult, type JSONSchema, type JsonRpcMessage, type JsonRpcRequest, type JsonRpcResponse, type ListToolsResult, type LoopEvent, MCP_PROTOCOL_VERSION, McpClient, type McpClientOptions, type McpContentBlock, type McpSpec, type McpTool, type McpToolSchema, type McpTransport, type ReadTranscriptResult, type ReasonixConfig, type ReconfigurableOptions, type RepairReport, type ReplayStats, type RetryInfo, type RetryOptions, type Role, type ScavengeOptions, type ScavengeResult, type SessionInfo, SessionStats, type SessionSummary, type SseMcpSpec, SseTransport, type SseTransportOptions, type StdioMcpSpec, StdioTransport, type StdioTransportOptions, StormBreaker, type StreamChunk, type ToolCall, ToolCallRepair, type ToolCallRepairOptions, type ToolDefinition, type ToolFunctionSpec, ToolRegistry, type ToolSpec, type TranscriptMeta, type TranscriptRecord, type TruncationRepairResult, type TurnPair, type TurnStats, type TypedPlanState, Usage, VERSION, VolatileScratch, aggregateBranchUsage, analyzeSchema, appendSessionMessage, bridgeMcpTools, claudeEquivalentCost, computeReplayStats, costUsd, defaultConfigPath, defaultSelector, deleteSession, diffTranscripts, emptyPlanState, fetchWithRetry, flattenMcpResult, flattenSchema, formatLoopError, harvest, healLoadedMessages, isJsonRpcError, isPlanStateEmpty, isPlausibleKey, listSessions, loadApiKey, loadDotenv, loadSessionMessages, nestArguments, openTranscriptFile, parseMcpSpec, parseTranscript, readConfig, readTranscript, recordFromLoopEvent, redactKey, renderMarkdown as renderDiffMarkdown, renderSummaryTable as renderDiffSummary, repairTruncatedJson, replayFromFile, runBranches, sanitizeName as sanitizeSessionName, saveApiKey, scavengeToolCalls, sessionPath, sessionsDir, similarity, truncateForModel, writeConfig, writeMeta, writeRecord };
package/dist/index.js CHANGED
@@ -636,6 +636,16 @@ var AppendOnlyLog = class {
636
636
  extend(messages) {
637
637
  for (const m of messages) this.append(m);
638
638
  }
639
+ /**
640
+ * Bulk-replace entries. Intentionally named to be hard to reach for —
641
+ * this is the one mutation path that breaks the log's append-only
642
+ * spirit, reserved for compaction flows (`/compact`) and recovery
643
+ * where the caller has consciously decided to drop old history. Any
644
+ * other use is almost certainly wrong; append() is what you want.
645
+ */
646
+ compactInPlace(replacement) {
647
+ this._entries = [...replacement];
648
+ }
639
649
  get entries() {
640
650
  return this._entries;
641
651
  }
@@ -914,7 +924,8 @@ import {
914
924
  readFileSync,
915
925
  readdirSync,
916
926
  statSync,
917
- unlinkSync
927
+ unlinkSync,
928
+ writeFileSync
918
929
  } from "fs";
919
930
  import { homedir } from "os";
920
931
  import { dirname, join } from "path";
@@ -983,6 +994,17 @@ function deleteSession(name) {
983
994
  return false;
984
995
  }
985
996
  }
997
+ function rewriteSession(name, messages) {
998
+ const path = sessionPath(name);
999
+ mkdirSync(dirname(path), { recursive: true });
1000
+ const body = messages.map((m) => JSON.stringify(m)).join("\n");
1001
+ writeFileSync(path, body ? `${body}
1002
+ ` : "", "utf8");
1003
+ try {
1004
+ chmodSync(path, 384);
1005
+ } catch {
1006
+ }
1007
+ }
986
1008
  function countLines(path) {
987
1009
  try {
988
1010
  const raw = readFileSync(path, "utf8");
@@ -1041,12 +1063,14 @@ var SessionStats = class {
1041
1063
  return denom > 0 ? hit / denom : 0;
1042
1064
  }
1043
1065
  summary() {
1066
+ const last = this.turns[this.turns.length - 1];
1044
1067
  return {
1045
1068
  turns: this.turns.length,
1046
1069
  totalCostUsd: round(this.totalCost, 6),
1047
1070
  claudeEquivalentUsd: round(this.totalClaudeEquivalent, 6),
1048
1071
  savingsVsClaudePct: round(this.savingsVsClaude * 100, 2),
1049
- cacheHitRatio: round(this.aggregateCacheHitRatio, 4)
1072
+ cacheHitRatio: round(this.aggregateCacheHitRatio, 4),
1073
+ lastPromptTokens: last?.usage.promptTokens ?? 0
1050
1074
  };
1051
1075
  }
1052
1076
  };
@@ -1078,12 +1102,18 @@ var CacheFirstLoop = class {
1078
1102
  resumedMessageCount;
1079
1103
  _turn = 0;
1080
1104
  _streamPreference;
1105
+ /**
1106
+ * Set by {@link abort} to short-circuit the tool-call loop after the
1107
+ * current iteration. Reset at the start of each `step()` so an Esc
1108
+ * during one turn doesn't poison the next.
1109
+ */
1110
+ _aborted = false;
1081
1111
  constructor(opts) {
1082
1112
  this.client = opts.client;
1083
1113
  this.prefix = opts.prefix;
1084
1114
  this.tools = opts.tools ?? new ToolRegistry();
1085
1115
  this.model = opts.model ?? "deepseek-chat";
1086
- this.maxToolIters = opts.maxToolIters ?? 8;
1116
+ this.maxToolIters = opts.maxToolIters ?? 24;
1087
1117
  if (typeof opts.branch === "number") {
1088
1118
  this.branchOptions = { budget: opts.branch };
1089
1119
  } else if (opts.branch && typeof opts.branch === "object") {
@@ -1118,6 +1148,33 @@ var CacheFirstLoop = class {
1118
1148
  this.resumedMessageCount = 0;
1119
1149
  }
1120
1150
  }
1151
+ /**
1152
+ * Shrink the log by re-truncating oversized tool results to a tighter
1153
+ * cap, and persist the result back to disk so the next launch doesn't
1154
+ * re-inherit a fat session file. Returns a summary the TUI can
1155
+ * display.
1156
+ *
1157
+ * Only tool-role messages are touched (same rationale as
1158
+ * {@link healLoadedMessages}). User and assistant messages carry
1159
+ * authored intent we can't mechanically shrink without losing
1160
+ * meaning.
1161
+ */
1162
+ compact(tightCapChars = 4e3) {
1163
+ const before = this.log.toMessages();
1164
+ const { messages, healedCount, healedFrom } = healLoadedMessages(before, tightCapChars);
1165
+ const afterBytes = messages.filter((m) => m.role === "tool").reduce((s, m) => s + (typeof m.content === "string" ? m.content.length : 0), 0);
1166
+ const charsSaved = healedFrom - afterBytes;
1167
+ if (healedCount > 0) {
1168
+ this.log.compactInPlace(messages);
1169
+ if (this.sessionName) {
1170
+ try {
1171
+ rewriteSession(this.sessionName, messages);
1172
+ } catch {
1173
+ }
1174
+ }
1175
+ }
1176
+ return { healedCount, charsSaved };
1177
+ }
1121
1178
  appendAndPersist(message) {
1122
1179
  this.log.append(message);
1123
1180
  if (this.sessionName) {
@@ -1162,12 +1219,42 @@ var CacheFirstLoop = class {
1162
1219
  if (pendingUser !== null) msgs.push({ role: "user", content: pendingUser });
1163
1220
  return msgs;
1164
1221
  }
1222
+ /**
1223
+ * Signal the currently-running {@link step} that the user wants to
1224
+ * stop exploring. Takes effect at the next iteration boundary — if a
1225
+ * tool call is mid-flight it will be allowed to finish, then the
1226
+ * loop diverts to the forced-summary path so the user gets an
1227
+ * answer instead of a cliff. Called by the TUI on Esc.
1228
+ */
1229
+ abort() {
1230
+ this._aborted = true;
1231
+ }
1165
1232
  async *step(userInput) {
1166
1233
  this._turn++;
1167
1234
  this.scratch.reset();
1235
+ this._aborted = false;
1168
1236
  let pendingUser = userInput;
1169
1237
  const toolSpecs = this.prefix.tools();
1238
+ const warnAt = Math.max(1, Math.floor(this.maxToolIters * 0.7));
1239
+ let warnedForIterBudget = false;
1170
1240
  for (let iter = 0; iter < this.maxToolIters; iter++) {
1241
+ if (this._aborted) {
1242
+ yield {
1243
+ turn: this._turn,
1244
+ role: "warning",
1245
+ content: `aborted at iter ${iter}/${this.maxToolIters} \u2014 forcing summary from what was gathered`
1246
+ };
1247
+ yield* this.forceSummaryAfterIterLimit({ reason: "aborted" });
1248
+ return;
1249
+ }
1250
+ if (!warnedForIterBudget && iter >= warnAt) {
1251
+ warnedForIterBudget = true;
1252
+ yield {
1253
+ turn: this._turn,
1254
+ role: "warning",
1255
+ content: `${iter}/${this.maxToolIters} tool calls used \u2014 approaching budget. Press Esc to force a summary now.`
1256
+ };
1257
+ }
1171
1258
  const messages = this.buildMessages(pendingUser);
1172
1259
  let assistantContent = "";
1173
1260
  let reasoningContent = "";
@@ -1355,7 +1442,40 @@ var CacheFirstLoop = class {
1355
1442
  };
1356
1443
  }
1357
1444
  }
1358
- yield { turn: this._turn, role: "done", content: "[max_tool_iters reached]" };
1445
+ yield* this.forceSummaryAfterIterLimit({ reason: "budget" });
1446
+ }
1447
+ async *forceSummaryAfterIterLimit(opts = { reason: "budget" }) {
1448
+ try {
1449
+ const messages = this.buildMessages(null);
1450
+ const resp = await this.client.chat({
1451
+ model: this.model,
1452
+ messages
1453
+ // no tools → model is forced to answer in text
1454
+ });
1455
+ const summary = resp.content?.trim() || "(model returned no text; try a narrower question or raise --max-tool-iters)";
1456
+ 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]`;
1457
+ const annotated = `${reasonPrefix}
1458
+
1459
+ ${summary}`;
1460
+ const summaryStats = this.stats.record(this._turn, this.model, resp.usage ?? new Usage());
1461
+ this.appendAndPersist({ role: "assistant", content: summary });
1462
+ yield {
1463
+ turn: this._turn,
1464
+ role: "assistant_final",
1465
+ content: annotated,
1466
+ stats: summaryStats
1467
+ };
1468
+ yield { turn: this._turn, role: "done", content: summary };
1469
+ } catch (err) {
1470
+ const label = opts.reason === "aborted" ? "aborted by user" : `tool-call budget (${this.maxToolIters}) reached`;
1471
+ yield {
1472
+ turn: this._turn,
1473
+ role: "error",
1474
+ content: "",
1475
+ error: `${label} and the fallback summary call failed: ${err.message}. Run /clear and retry with a narrower question, or raise --max-tool-iters.`
1476
+ };
1477
+ yield { turn: this._turn, role: "done", content: "" };
1478
+ }
1359
1479
  }
1360
1480
  async run(userInput, onEvent) {
1361
1481
  let final = "";
@@ -1578,12 +1698,14 @@ function summarizeTurns(turns) {
1578
1698
  }
1579
1699
  const cacheHitRatio = hit + miss > 0 ? hit / (hit + miss) : 0;
1580
1700
  const savingsVsClaude = totalClaude > 0 ? 1 - totalCost / totalClaude : 0;
1701
+ const lastTurn = turns[turns.length - 1];
1581
1702
  return {
1582
1703
  turns: turns.length,
1583
1704
  totalCostUsd: round2(totalCost, 6),
1584
1705
  claudeEquivalentUsd: round2(totalClaude, 6),
1585
1706
  savingsVsClaudePct: round2(savingsVsClaude * 100, 2),
1586
- cacheHitRatio: round2(cacheHitRatio, 4)
1707
+ cacheHitRatio: round2(cacheHitRatio, 4),
1708
+ lastPromptTokens: lastTurn?.usage.promptTokens ?? 0
1587
1709
  };
1588
1710
  }
1589
1711
  function round2(n, digits) {
@@ -2365,7 +2487,7 @@ function parseMcpSpec(input) {
2365
2487
  }
2366
2488
 
2367
2489
  // src/config.ts
2368
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync } from "fs";
2490
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
2369
2491
  import { homedir as homedir2 } from "os";
2370
2492
  import { dirname as dirname2, join as join2 } from "path";
2371
2493
  function defaultConfigPath() {
@@ -2382,7 +2504,7 @@ function readConfig(path = defaultConfigPath()) {
2382
2504
  }
2383
2505
  function writeConfig(cfg, path = defaultConfigPath()) {
2384
2506
  mkdirSync2(dirname2(path), { recursive: true });
2385
- writeFileSync(path, JSON.stringify(cfg, null, 2), "utf8");
2507
+ writeFileSync2(path, JSON.stringify(cfg, null, 2), "utf8");
2386
2508
  try {
2387
2509
  chmodSync2(path, 384);
2388
2510
  } catch {
@@ -2408,7 +2530,7 @@ function redactKey(key) {
2408
2530
  }
2409
2531
 
2410
2532
  // src/index.ts
2411
- var VERSION = "0.3.0-alpha.6";
2533
+ var VERSION = "0.3.2";
2412
2534
  export {
2413
2535
  AppendOnlyLog,
2414
2536
  CacheFirstLoop,