nexrall-code 0.5.2 → 0.5.12

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.
Files changed (2) hide show
  1. package/dist/index.js +314 -49
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -9810,6 +9810,7 @@ var require_client = __commonJS({
9810
9810
  };
9811
9811
  Object.defineProperty(exports2, "__esModule", { value: true });
9812
9812
  exports2.API_BASE = void 0;
9813
+ exports2.chooseFinalContent = chooseFinalContent;
9813
9814
  exports2.streamChat = streamChat;
9814
9815
  exports2.getBalance = getBalance3;
9815
9816
  exports2.exchangeVscodeCode = exchangeVscodeCode;
@@ -9818,6 +9819,12 @@ var require_client = __commonJS({
9818
9819
  var node_fetch_1 = __importDefault((init_src(), __toCommonJS(src_exports)));
9819
9820
  var index_1 = require_auth();
9820
9821
  exports2.API_BASE = "https://api.nexrall.com";
9822
+ function chooseFinalContent(rebuilt, rawContent) {
9823
+ if (!Array.isArray(rawContent))
9824
+ return rebuilt;
9825
+ const hasServerSideBlocks = rawContent.some((b) => b && b.type !== "text" && b.type !== "tool_use");
9826
+ return hasServerSideBlocks ? rawContent : rebuilt;
9827
+ }
9821
9828
  function authHeaders() {
9822
9829
  const token = (0, index_1.getToken)();
9823
9830
  if (!token) {
@@ -9828,11 +9835,17 @@ var require_client = __commonJS({
9828
9835
  Authorization: `Bearer ${token}`
9829
9836
  };
9830
9837
  }
9831
- var MAX_RETRIES = 3;
9838
+ var MAX_RETRIES = 5;
9832
9839
  var RETRY_BASE_MS = 1e3;
9840
+ var RETRY_MAX_MS = 3e4;
9841
+ function backoffMs(attempt) {
9842
+ const exp = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * Math.pow(2, attempt));
9843
+ return Math.round(exp / 2 + Math.random() * (exp / 2));
9844
+ }
9833
9845
  function sleep(ms) {
9834
9846
  return new Promise((r2) => setTimeout(r2, ms));
9835
9847
  }
9848
+ var MAX_TOTAL_ATTEMPTS = (MAX_RETRIES + 1) * (MAX_RETRIES + 1);
9836
9849
  async function streamChat(messages, options, onEvent) {
9837
9850
  const { model, env: env2, editorContext, nexrallMd, mode, effort, clientType, abortSignal, extraTools, agents } = options;
9838
9851
  const controller = new AbortController();
@@ -9855,6 +9868,19 @@ var require_client = __commonJS({
9855
9868
  }
9856
9869
  ];
9857
9870
  const isRetryableStreamMsg = (m2) => /overloaded|rate.?limit|temporarily|unavailable|try again|internal server error/i.test(String(m2 ?? ""));
9871
+ let didRetry = false;
9872
+ let totalAttemptsMade = 0;
9873
+ const reportRetry = (reason) => {
9874
+ totalAttemptsMade++;
9875
+ didRetry = true;
9876
+ onEvent({ type: "retry", attempt: totalAttemptsMade, maxAttempts: MAX_TOTAL_ATTEMPTS, reason });
9877
+ };
9878
+ const clearRetryIfNeeded = () => {
9879
+ if (didRetry) {
9880
+ didRetry = false;
9881
+ onEvent({ type: "retry_resolved" });
9882
+ }
9883
+ };
9858
9884
  async function runAttempt() {
9859
9885
  let response;
9860
9886
  let lastErr;
@@ -9862,12 +9888,14 @@ var require_client = __commonJS({
9862
9888
  try {
9863
9889
  response = await (0, node_fetch_1.default)(...fetchArgs);
9864
9890
  if (response.status === 429 && attempt < MAX_RETRIES) {
9891
+ reportRetry("Rate limited by the API \u2014 retrying");
9865
9892
  const retryAfter = parseInt(response.headers.get("retry-after") ?? "0", 10);
9866
- await sleep(retryAfter > 0 ? retryAfter * 1e3 : RETRY_BASE_MS * Math.pow(2, attempt));
9893
+ await sleep(retryAfter > 0 ? retryAfter * 1e3 : backoffMs(attempt));
9867
9894
  continue;
9868
9895
  }
9869
9896
  if (response.status >= 500 && response.status < 600 && attempt < MAX_RETRIES) {
9870
- await sleep(RETRY_BASE_MS * Math.pow(2, attempt));
9897
+ reportRetry(`Server error (${response.status}) \u2014 retrying`);
9898
+ await sleep(backoffMs(attempt));
9871
9899
  continue;
9872
9900
  }
9873
9901
  break;
@@ -9876,7 +9904,8 @@ var require_client = __commonJS({
9876
9904
  throw err;
9877
9905
  lastErr = err;
9878
9906
  if (attempt < MAX_RETRIES) {
9879
- await sleep(RETRY_BASE_MS * Math.pow(2, attempt));
9907
+ reportRetry("Connection lost \u2014 attempting to reconnect");
9908
+ await sleep(backoffMs(attempt));
9880
9909
  continue;
9881
9910
  }
9882
9911
  }
@@ -9886,14 +9915,17 @@ var require_client = __commonJS({
9886
9915
  if (!response.ok) {
9887
9916
  const errText = await response.text();
9888
9917
  let errMsg = `API error ${response.status}`;
9918
+ let balance;
9889
9919
  try {
9890
9920
  const parsed = JSON.parse(errText);
9891
9921
  if (parsed.error)
9892
9922
  errMsg = parsed.error;
9923
+ if (typeof parsed.balance === "number")
9924
+ balance = parsed.balance;
9893
9925
  } catch {
9894
9926
  errMsg = errText || errMsg;
9895
9927
  }
9896
- throw new Error(errMsg);
9928
+ throw Object.assign(new Error(errMsg), { status: response.status, balance });
9897
9929
  }
9898
9930
  if (!response.body) {
9899
9931
  throw new Error("Response body is null");
@@ -9916,7 +9948,8 @@ var require_client = __commonJS({
9916
9948
  if (now - lastDataAt > HEARTBEAT_TIMEOUT_MS) {
9917
9949
  clearInterval(heartbeatWatchdog);
9918
9950
  stream.destroy?.();
9919
- reject(new Error("Connection lost \u2014 no data received for 45 s. Retry your message."));
9951
+ const deadErr = new Error(emittedToCaller ? "Connection lost \u2014 no data received for 45 s. Retry your message." : "Connection lost \u2014 no data received for 45 s. Reconnecting\u2026");
9952
+ reject(emittedToCaller ? deadErr : Object.assign(deadErr, { retryable: true }));
9920
9953
  return;
9921
9954
  }
9922
9955
  const stallLimitMs = sawModelEvent ? PROGRESS_TIMEOUT_MS : FIRST_EVENT_TIMEOUT_MS;
@@ -9951,6 +9984,7 @@ var require_client = __commonJS({
9951
9984
  const evt = parsed;
9952
9985
  lastProgressAt = Date.now();
9953
9986
  sawModelEvent = true;
9987
+ clearRetryIfNeeded();
9954
9988
  switch (evt.type) {
9955
9989
  case "text": {
9956
9990
  const text = typeof evt.text === "string" ? evt.text : "";
@@ -9980,15 +10014,20 @@ var require_client = __commonJS({
9980
10014
  break;
9981
10015
  }
9982
10016
  case "message_complete": {
10017
+ const nestedMessage = evt.message;
10018
+ const stopReason = typeof nestedMessage?.stop_reason === "string" ? nestedMessage.stop_reason : null;
9983
10019
  const contentBlocks2 = [];
9984
10020
  const fullText2 = textParts.join("");
9985
10021
  if (fullText2) {
9986
10022
  contentBlocks2.push({ type: "text", text: fullText2 });
9987
10023
  }
9988
10024
  contentBlocks2.push(...toolUseBlocks);
10025
+ const rawContent = Array.isArray(nestedMessage?.content) ? nestedMessage.content : null;
10026
+ const finalContent = chooseFinalContent(contentBlocks2, rawContent);
9989
10027
  completedMessage = {
9990
10028
  role: "assistant",
9991
- content: contentBlocks2
10029
+ content: finalContent,
10030
+ stopReason
9992
10031
  };
9993
10032
  onEvent({ type: "message_complete", message: completedMessage });
9994
10033
  break;
@@ -10034,6 +10073,12 @@ var require_client = __commonJS({
10034
10073
  }
10035
10074
  break;
10036
10075
  }
10076
+ case "balance_status": {
10077
+ const balance = typeof evt.balance === "number" ? evt.balance : 0;
10078
+ const zero = !!evt.zero;
10079
+ onEvent({ type: "balance_status", balance, zero });
10080
+ break;
10081
+ }
10037
10082
  case "done": {
10038
10083
  onEvent({ type: "done" });
10039
10084
  resolve3();
@@ -10097,12 +10142,15 @@ var require_client = __commonJS({
10097
10142
  try {
10098
10143
  for (let sAttempt = 0; sAttempt <= MAX_RETRIES; sAttempt++) {
10099
10144
  try {
10100
- return await runAttempt();
10145
+ const result = await runAttempt();
10146
+ clearRetryIfNeeded();
10147
+ return result;
10101
10148
  } catch (err) {
10102
10149
  if (abortSignal?.aborted || controller.signal.aborted || err.name === "AbortError")
10103
10150
  throw err;
10104
10151
  if (err.retryable && sAttempt < MAX_RETRIES) {
10105
- await sleep(RETRY_BASE_MS * Math.pow(2, sAttempt));
10152
+ reportRetry(err.message || "Connection interrupted \u2014 reconnecting");
10153
+ await sleep(backoffMs(sAttempt));
10106
10154
  continue;
10107
10155
  }
10108
10156
  throw err;
@@ -12002,7 +12050,7 @@ Update these call-sites (or restore the symbol), then run a build/typecheck to c
12002
12050
  args.push("-m", "200", "--regexp", pattern, resolved);
12003
12051
  result = (0, child_process_1.spawnSync)("rg", args, spawnOpts);
12004
12052
  } else {
12005
- const args = ["-rn", "--binary-files=without-match", "--color=never"];
12053
+ const args = ["-rnE", "--binary-files=without-match", "--color=never"];
12006
12054
  if (ignoreCase)
12007
12055
  args.push("-i");
12008
12056
  if (contextLines > 0)
@@ -12031,8 +12079,10 @@ ${globalCapMatches(output)}` : "";
12031
12079
  }
12032
12080
  if (result.status === 1 && !output)
12033
12081
  return { output: "No matches found." };
12034
- if (result.status !== 0 && result.status !== 1)
12035
- return { error: stderr || "search failed" };
12082
+ if (result.status !== 0 && result.status !== 1) {
12083
+ const hint = /parenthes|bracket|brace|Unmatched|repetition-operator|invalid regex/i.test(stderr) ? ' \u2014 the pattern has invalid/unbalanced regex syntax. If you meant to match literal parentheses/brackets, escape them (e.g. "\\(", "\\)"), or simplify the pattern.' : "";
12084
+ return { error: (stderr || "search failed").trim() + hint };
12085
+ }
12036
12086
  output = globalCapMatches(output);
12037
12087
  return { output: output || "No matches found." };
12038
12088
  }
@@ -12373,6 +12423,14 @@ ${diff2}${xfile}` };
12373
12423
  return callback(null, address, family);
12374
12424
  });
12375
12425
  };
12426
+ let settled = false;
12427
+ let selfAborted = false;
12428
+ const finish = (result) => {
12429
+ if (settled)
12430
+ return;
12431
+ settled = true;
12432
+ resolve3(result);
12433
+ };
12376
12434
  const req = transport.get(url, {
12377
12435
  timeout: DEFAULT_TIMEOUT_MS,
12378
12436
  lookup: guardedLookup,
@@ -12385,15 +12443,16 @@ ${diff2}${xfile}` };
12385
12443
  }, (res) => {
12386
12444
  const status = res.statusCode ?? 0;
12387
12445
  if ((status === 301 || status === 302 || status === 307 || status === 308) && res.headers.location) {
12446
+ selfAborted = true;
12388
12447
  req.destroy();
12389
12448
  let nextUrl;
12390
12449
  try {
12391
12450
  nextUrl = new URL(res.headers.location, url).href;
12392
12451
  } catch {
12393
- resolve3({ error: `Invalid redirect location: ${res.headers.location}` });
12452
+ finish({ error: `Invalid redirect location: ${res.headers.location}` });
12394
12453
  return;
12395
12454
  }
12396
- fetchUrl({ url: nextUrl }, void 0, _redirectCount + 1).then(resolve3);
12455
+ fetchUrl({ url: nextUrl }, void 0, _redirectCount + 1).then(finish);
12397
12456
  return;
12398
12457
  }
12399
12458
  const contentType = res.headers["content-type"] ?? "";
@@ -12401,6 +12460,19 @@ ${diff2}${xfile}` };
12401
12460
  const chunks = [];
12402
12461
  let totalBytes = 0;
12403
12462
  let truncated = false;
12463
+ const emitBody = () => {
12464
+ let body = Buffer.concat(chunks).toString("utf-8");
12465
+ if (isHtml)
12466
+ body = stripHtml(body);
12467
+ const truncNote = truncated ? `
12468
+
12469
+ [Truncated at ${MAX_FETCH_BYTES / 1024}KB]` : "";
12470
+ finish({ output: `HTTP ${status}
12471
+
12472
+ <untrusted_web_content url="${url}">
12473
+ ${body}${truncNote}
12474
+ </untrusted_web_content>` });
12475
+ };
12404
12476
  res.on("data", (chunk) => {
12405
12477
  if (totalBytes + chunk.length > MAX_FETCH_BYTES) {
12406
12478
  const remaining = MAX_FETCH_BYTES - totalBytes;
@@ -12408,31 +12480,28 @@ ${diff2}${xfile}` };
12408
12480
  chunks.push(chunk.subarray(0, remaining));
12409
12481
  totalBytes = MAX_FETCH_BYTES;
12410
12482
  truncated = true;
12483
+ selfAborted = true;
12411
12484
  req.destroy();
12485
+ emitBody();
12412
12486
  } else {
12413
12487
  chunks.push(chunk);
12414
12488
  totalBytes += chunk.length;
12415
12489
  }
12416
12490
  });
12417
- res.on("end", () => {
12418
- let body = Buffer.concat(chunks).toString("utf-8");
12419
- if (isHtml)
12420
- body = stripHtml(body);
12421
- const truncNote = truncated ? `
12422
-
12423
- [Truncated at ${MAX_FETCH_BYTES / 1024}KB]` : "";
12424
- resolve3({ output: `HTTP ${status}
12425
-
12426
- <untrusted_web_content url="${url}">
12427
- ${body}${truncNote}
12428
- </untrusted_web_content>` });
12491
+ res.on("end", emitBody);
12492
+ res.on("error", (err) => {
12493
+ if (!selfAborted)
12494
+ finish({ error: err.message });
12429
12495
  });
12430
- res.on("error", (err) => resolve3({ error: err.message }));
12431
12496
  });
12432
- req.on("error", (err) => resolve3({ error: err.message }));
12497
+ req.on("error", (err) => {
12498
+ if (!selfAborted)
12499
+ finish({ error: err.message });
12500
+ });
12433
12501
  req.on("timeout", () => {
12502
+ selfAborted = true;
12434
12503
  req.destroy();
12435
- resolve3({ error: `Request timed out after ${DEFAULT_TIMEOUT_MS}ms` });
12504
+ finish({ error: `Request timed out after ${DEFAULT_TIMEOUT_MS}ms` });
12436
12505
  });
12437
12506
  });
12438
12507
  }
@@ -13706,6 +13775,8 @@ var require_loop = __commonJS({
13706
13775
  exports2.ledgerRecord = ledgerRecord;
13707
13776
  exports2.ledgerSummary = ledgerSummary;
13708
13777
  exports2.pruneOldToolResults = pruneOldToolResults;
13778
+ exports2.estimateTokensRough = estimateTokensRough;
13779
+ exports2.compactMessagesForResume = compactMessagesForResume2;
13709
13780
  exports2.runAgentLoop = runAgentLoop2;
13710
13781
  var client_1 = require_client();
13711
13782
  var executor_1 = require_executor();
@@ -14009,7 +14080,13 @@ ${tail}`;
14009
14080
  pro: 1e6,
14010
14081
  ultra: 1e6
14011
14082
  };
14012
- var AUTO_COMPACT_THRESHOLD = 0.8;
14083
+ function envFraction(name, fallback) {
14084
+ const v = Number(process.env[name]);
14085
+ return Number.isFinite(v) && v > 0 && v < 1 ? v : fallback;
14086
+ }
14087
+ var AUTO_PRUNE_THRESHOLD = envFraction("NEXRALL_PRUNE_THRESHOLD", 0.35);
14088
+ var AUTO_COMPACT_THRESHOLD = envFraction("NEXRALL_COMPACT_THRESHOLD", 0.8);
14089
+ var PRUNE_MIN_RECLAIM_BYTES = 256 * 1024;
14013
14090
  var COMPACT_KEEP_MIN = 6;
14014
14091
  var MAX_BODY_BYTES = 8 * 1024 * 1024;
14015
14092
  function estimateBodyBytes(messages) {
@@ -14160,11 +14237,12 @@ ${tail}`;
14160
14237
  var PRUNE_STUB_KEEP_CHARS = 400;
14161
14238
  var PRUNE_MARKER = "\n\n[\u2026 ";
14162
14239
  var PRUNE_MARKER_TAIL = " pruned to conserve context. Re-run the tool if you need the full result.]";
14163
- function pruneOldToolResults(messages) {
14240
+ function pruneOldToolResults(messages, minReclaimBytes = 0) {
14164
14241
  const cutoff = messages.length - PRUNE_KEEP_RECENT;
14165
14242
  if (cutoff <= 1)
14166
14243
  return 0;
14167
- let reclaimed = 0;
14244
+ const targets = [];
14245
+ let total = 0;
14168
14246
  for (let i2 = 0; i2 < cutoff; i2++) {
14169
14247
  const m2 = messages[i2];
14170
14248
  if (!Array.isArray(m2.content))
@@ -14179,10 +14257,17 @@ ${tail}`;
14179
14257
  continue;
14180
14258
  const head = text.slice(0, PRUNE_STUB_KEEP_CHARS);
14181
14259
  const omitted = text.length - head.length;
14182
- b.content = `${head}${PRUNE_MARKER}${omitted} chars of earlier tool output${PRUNE_MARKER_TAIL}`;
14183
- reclaimed += omitted;
14260
+ targets.push({ block: b, head, omitted });
14261
+ total += omitted;
14184
14262
  }
14185
14263
  }
14264
+ if (total < minReclaimBytes)
14265
+ return 0;
14266
+ let reclaimed = 0;
14267
+ for (const { block, head, omitted } of targets) {
14268
+ block.content = `${head}${PRUNE_MARKER}${omitted} chars of earlier tool output${PRUNE_MARKER_TAIL}`;
14269
+ reclaimed += omitted;
14270
+ }
14186
14271
  return reclaimed;
14187
14272
  }
14188
14273
  function originalTaskText(messages) {
@@ -14204,7 +14289,14 @@ ${tail}`;
14204
14289
  let summary = "";
14205
14290
  try {
14206
14291
  const reply = await (0, client_1.streamChat)([{ role: "user", content: [{ type: "text", text: summaryPrompt }] }], {
14207
- model: options.model ?? "turbo",
14292
+ // COST: the summariser is a mechanical "bullet-point this transcript" task —
14293
+ // its quality is indistinguishable across model tiers, so there is no reason
14294
+ // to run it on the user's (possibly expensive) tier. Force 'turbo' (Sonnet 5):
14295
+ // it is the cheapest 1M-context tier (in $2.0/1M vs Opus $2.5, Fable $3.0;
14296
+ // out $15/1M vs $25 / $50), so this is always ≤ the user's cost, and its 1M
14297
+ // window comfortably holds the transcript (capped at ~170K tokens by
14298
+ // transcriptOf) even on the largest sessions.
14299
+ model: "turbo",
14208
14300
  mode: "ask",
14209
14301
  // summariser must not call tools; ask-mode discourages action
14210
14302
  env: options.env,
@@ -14235,6 +14327,66 @@ ${summary}
14235
14327
  Continue the work from here.` }] });
14236
14328
  return true;
14237
14329
  }
14330
+ var RESUME_CHARS_PER_TOKEN = 4;
14331
+ function estimateTokensRough(messages) {
14332
+ return Math.ceil(estimateBodyBytes(messages) / RESUME_CHARS_PER_TOKEN);
14333
+ }
14334
+ async function compactMessagesForResume2(messages, opts) {
14335
+ if (messages.length <= COMPACT_KEEP_MIN + 2)
14336
+ return false;
14337
+ const settings = (0, rules_1.loadSettings)(opts.workDir);
14338
+ if (!resolveAutoCompact(void 0, settings.raw))
14339
+ return false;
14340
+ const contextWindow = MODEL_CONTEXT_TOKENS[opts.model ?? "turbo"] ?? 1e6;
14341
+ let bodyBytes = estimateBodyBytes(messages);
14342
+ let tokenGuess = estimateTokensRough(messages);
14343
+ const overPruneThreshold = () => tokenGuess > contextWindow * AUTO_PRUNE_THRESHOLD || bodyBytes > MAX_BODY_BYTES;
14344
+ const overCompactThreshold = () => tokenGuess > contextWindow * AUTO_COMPACT_THRESHOLD || bodyBytes > MAX_BODY_BYTES;
14345
+ if (!overPruneThreshold())
14346
+ return false;
14347
+ let compacted = false;
14348
+ if (messages.length > PRUNE_KEEP_RECENT + 2) {
14349
+ const reclaimed = pruneOldToolResults(messages, PRUNE_MIN_RECLAIM_BYTES);
14350
+ if (reclaimed > 0) {
14351
+ bodyBytes = estimateBodyBytes(messages);
14352
+ tokenGuess = estimateTokensRough(messages);
14353
+ compacted = true;
14354
+ opts.onNotice?.(`
14355
+ \u267B\uFE0F Trimmed ~${(reclaimed / (1024 * 1024)).toFixed(1)}MB of older tool output before resuming this chat.
14356
+ `);
14357
+ }
14358
+ }
14359
+ let guard = 0;
14360
+ while (overCompactThreshold() && messages.length > COMPACT_KEEP_MIN + 2 && guard < 5) {
14361
+ guard += 1;
14362
+ const did = await autoCompactMessages(messages, {
14363
+ workDir: opts.workDir,
14364
+ model: opts.model,
14365
+ clientType: opts.clientType,
14366
+ env: opts.env,
14367
+ onText: () => {
14368
+ },
14369
+ onToolUse: () => {
14370
+ },
14371
+ onToolResult: () => {
14372
+ },
14373
+ onUsage: () => {
14374
+ },
14375
+ requestPermission: async () => false
14376
+ });
14377
+ if (!did)
14378
+ break;
14379
+ compacted = true;
14380
+ bodyBytes = estimateBodyBytes(messages);
14381
+ tokenGuess = estimateTokensRough(messages);
14382
+ }
14383
+ if (compacted) {
14384
+ opts.onNotice?.(`
14385
+ \u267B\uFE0F Auto-compacted this chat's earlier history before resuming, to avoid resending it at full cost.
14386
+ `);
14387
+ }
14388
+ return compacted;
14389
+ }
14238
14390
  async function runAgentLoop2(initialMessages, options) {
14239
14391
  const messages = [...initialMessages];
14240
14392
  const model = options.model ?? "turbo";
@@ -14270,16 +14422,15 @@ Continue the work from here.` }] });
14270
14422
  if (options.abortSignal?.aborted)
14271
14423
  break;
14272
14424
  let bodyBytes = estimateBodyBytes(messages);
14425
+ const prunePressure = lastPromptTokens > contextWindow * AUTO_PRUNE_THRESHOLD;
14273
14426
  const tokenPressure = lastPromptTokens > contextWindow * AUTO_COMPACT_THRESHOLD;
14274
14427
  let bytePressure = bodyBytes > MAX_BODY_BYTES;
14275
- if (autoCompact && !compacting && bytePressure && messages.length > PRUNE_KEEP_RECENT + 2) {
14276
- const reclaimed = pruneOldToolResults(messages);
14428
+ if (autoCompact && !compacting && (prunePressure || bytePressure) && messages.length > PRUNE_KEEP_RECENT + 2) {
14429
+ const reclaimed = pruneOldToolResults(messages, PRUNE_MIN_RECLAIM_BYTES);
14277
14430
  if (reclaimed > 0) {
14278
14431
  bodyBytes = estimateBodyBytes(messages);
14279
14432
  bytePressure = bodyBytes > MAX_BODY_BYTES;
14280
- options.onText(`
14281
- \u267B\uFE0F Trimmed ~${(reclaimed / (1024 * 1024)).toFixed(1)}MB of older tool output to conserve context.
14282
- `);
14433
+ (options.onNotice ?? options.onText)(`\u267B\uFE0F Trimmed ~${(reclaimed / (1024 * 1024)).toFixed(1)}MB of already-processed tool output to keep this chat cheap to continue.`);
14283
14434
  }
14284
14435
  }
14285
14436
  if (autoCompact && !compacting && (tokenPressure || bytePressure) && messages.length > COMPACT_KEEP_MIN + 2) {
@@ -14289,9 +14440,7 @@ Continue the work from here.` }] });
14289
14440
  if (did) {
14290
14441
  lastPromptTokens = 0;
14291
14442
  const reason = bytePressure ? `body ~${(bodyBytes / (1024 * 1024)).toFixed(1)}MB` : "context window";
14292
- options.onText(`
14293
- \u267B\uFE0F Auto-compacted earlier conversation to stay within the ${reason}.
14294
- `);
14443
+ (options.onNotice ?? options.onText)(`\u267B\uFE0F Auto-compacted earlier conversation to stay within the ${reason}.`);
14295
14444
  }
14296
14445
  } finally {
14297
14446
  compacting = false;
@@ -14331,6 +14480,15 @@ Continue the work from here.` }] });
14331
14480
  break;
14332
14481
  case "message_complete":
14333
14482
  break;
14483
+ case "retry":
14484
+ options.onRetry?.(event.attempt, event.maxAttempts, event.reason);
14485
+ break;
14486
+ case "retry_resolved":
14487
+ options.onRetryResolved?.();
14488
+ break;
14489
+ case "balance_status":
14490
+ options.onBalanceStatus?.(event.balance, event.zero);
14491
+ break;
14334
14492
  case "done":
14335
14493
  case "error":
14336
14494
  break;
@@ -14353,6 +14511,12 @@ Continue the work from here.` }] });
14353
14511
  } catch (err) {
14354
14512
  if (options.abortSignal?.aborted || err.name === "AbortError")
14355
14513
  break;
14514
+ const status = err.status;
14515
+ if (status === 402) {
14516
+ const balance = err.balance ?? 0;
14517
+ options.onBalanceStatus?.(balance, true);
14518
+ break;
14519
+ }
14356
14520
  runSimpleHooks(hooks.OnError, options.workDir);
14357
14521
  throw new Error(`Stream failed: ${err.message}`);
14358
14522
  }
@@ -14371,9 +14535,12 @@ Continue the work from here.` }] });
14371
14535
  completedCleanly = true;
14372
14536
  break;
14373
14537
  }
14374
- messages.push(assistantMessage);
14538
+ const { stopReason: _stopReason, ...historyMessage } = assistantMessage;
14539
+ messages.push(historyMessage);
14375
14540
  const serverSideResultIds = new Set(assistantMessage.content.filter((b) => b.type === "tool_result").map((b) => b.tool_use_id).filter((id) => !!id));
14376
14541
  const toolUseBlocks = assistantMessage.content.filter((block) => block.type === "tool_use" && !serverSideResultIds.has(block.id));
14542
+ const lastBlock = assistantMessage.content[assistantMessage.content.length - 1];
14543
+ const truncatedToolUseId = assistantMessage.stopReason === "max_tokens" && lastBlock?.type === "tool_use" ? lastBlock.id : void 0;
14377
14544
  if (toolUseBlocks.length === 0) {
14378
14545
  const queued = options.takePendingInput?.() ?? [];
14379
14546
  if (queued.length) {
@@ -14441,8 +14608,15 @@ Continue the work from here.` }] });
14441
14608
  const toolResults = await Promise.all(toolUseBlocks.map(async (block) => {
14442
14609
  const { id, name, input } = block;
14443
14610
  options.onToolUse(name, input);
14444
- const description = humanDescription(name, input);
14445
14611
  let result;
14612
+ if (id === truncatedToolUseId) {
14613
+ result = {
14614
+ error: `This tool call (${name}) was CUT OFF because the response hit the model's output-token limit mid-generation (stop_reason: max_tokens) \u2014 its arguments may be incomplete or missing fields entirely, so it was NOT executed to avoid a silent partial edit. Retry with a SMALLER call: ` + (name === "multi_edit" || name === "edit_file" ? "split this into fewer edits per call (or call edit_file once per change instead of one large multi_edit), " : name === "write_file" ? "write the file in smaller chunks via write_file + edit_file follow-ups instead of one large write_file, " : "") + `so the full response fits comfortably under the per-turn output budget.`
14615
+ };
14616
+ options.onToolResult(name, result);
14617
+ return { block: { ...block, id }, result };
14618
+ }
14619
+ const description = humanDescription(name, input);
14446
14620
  let permitted;
14447
14621
  try {
14448
14622
  permitted = await options.requestPermission({ tool: name, input, description });
@@ -22883,11 +23057,15 @@ var import_code_core2 = __toESM(require_dist2());
22883
23057
  var autoApproved = /* @__PURE__ */ new Set();
22884
23058
  var _rules = { allow: [], ask: [], deny: [] };
22885
23059
  var _workDir = process.cwd();
23060
+ var _mode = "auto";
22886
23061
  function initPermissions(workDir) {
22887
23062
  _workDir = workDir;
22888
23063
  _rules = (0, import_code_core2.loadSettings)(workDir).permissions;
22889
23064
  return _rules;
22890
23065
  }
23066
+ function setMode(mode) {
23067
+ _mode = mode || "auto";
23068
+ }
22891
23069
  function setAutoApprove(category) {
22892
23070
  autoApproved.add(category);
22893
23071
  }
@@ -22955,6 +23133,10 @@ async function requestPermission(req) {
22955
23133
  ];
22956
23134
  if (readOnlyTools.includes(tool))
22957
23135
  return true;
23136
+ if (_mode === "plan") {
23137
+ console.error(source_default.yellow(` \u2298 Plan mode \u2014 refused ${tool} (read-only until you switch mode).`));
23138
+ return false;
23139
+ }
22958
23140
  if (tool === "write_file" || tool === "create_file") {
22959
23141
  if (isAutoApproved("write"))
22960
23142
  return true;
@@ -23356,6 +23538,23 @@ function tryExec(cmd, cwd) {
23356
23538
  return void 0;
23357
23539
  }
23358
23540
  }
23541
+ var BILLING_URL = "https://app.nexrall.com/?settings=billing";
23542
+ function hyperlink(label, url) {
23543
+ return `\x1B]8;;${url}\x1B\\${label}\x1B]8;;\x1B\\`;
23544
+ }
23545
+ var lastBalanceNoticeState = null;
23546
+ function printBalanceNotice(balance, zero) {
23547
+ const state = zero ? "zero" : "low";
23548
+ if (lastBalanceNoticeState === state)
23549
+ return;
23550
+ lastBalanceNoticeState = state;
23551
+ const title = zero ? "Out of balance" : "Balance running low";
23552
+ const detail = zero ? "You're out of funds \u2014 top up to keep the agent working." : `Your wallet is under $5${typeof balance === "number" ? ` ($${balance.toFixed(2)} left)` : ""}. Top up before it runs out.`;
23553
+ console.log();
23554
+ console.log(source_default.bgYellow.black(` ${title} `) + " " + source_default.dim(detail));
23555
+ console.log(" " + source_default.yellow(hyperlink("\u2192 Top up", BILLING_URL)) + source_default.dim(` (${BILLING_URL})`));
23556
+ console.log();
23557
+ }
23359
23558
  function collectEnv(workDir) {
23360
23559
  return {
23361
23560
  cwd: workDir,
@@ -23430,6 +23629,7 @@ async function runTurn(messages, modelAlias, workDir, abortSignal, env2, nexrall
23430
23629
  let toolStartTime = 0;
23431
23630
  let lastToolName = "";
23432
23631
  let thinkingTokens = 0;
23632
+ setMode(mode);
23433
23633
  process.stdout.write("\n");
23434
23634
  const result = await (0, import_code_core3.runAgentLoop)(messages, {
23435
23635
  workDir,
@@ -23456,6 +23656,18 @@ async function runTurn(messages, modelAlias, workDir, abortSignal, env2, nexrall
23456
23656
  spinner.stop();
23457
23657
  mdRender.feed(text);
23458
23658
  },
23659
+ // System notices (mid-run auto-prune/auto-compact housekeeping) are NOT part
23660
+ // of the model's own reply — used to go through onText, which spliced
23661
+ // "♻️ Trimmed ~0.3MB…" straight into the markdown stream renderer as if the
23662
+ // model itself had said it. Print it as its own dim line instead (same
23663
+ // treatment as the resume-time compaction notice below).
23664
+ onNotice: (text) => {
23665
+ if (abortSignal.aborted)
23666
+ return;
23667
+ mdRender.flush();
23668
+ spinner.stop();
23669
+ console.log(source_default.dim(` ${text}`));
23670
+ },
23459
23671
  onToolUse: (name, input) => {
23460
23672
  if (abortSignal.aborted)
23461
23673
  return;
@@ -23476,6 +23688,23 @@ async function runTurn(messages, modelAlias, workDir, abortSignal, env2, nexrall
23476
23688
  onUsage: (u) => {
23477
23689
  lastUsage = u;
23478
23690
  },
23691
+ // A transient disconnect (network drop, machine sleep/wake, overloaded upstream)
23692
+ // is retried transparently by the network layer — without this, that pause was
23693
+ // completely invisible: the CLI just appeared to freeze and then resume with no
23694
+ // explanation. Reuse the same spinner to show what's actually happening.
23695
+ onRetry: (attempt, _maxAttempts, reason) => {
23696
+ if (abortSignal.aborted)
23697
+ return;
23698
+ spinner.start(`${reason}\u2026 (attempt ${attempt})`);
23699
+ },
23700
+ onRetryResolved: () => {
23701
+ spinner.stop();
23702
+ },
23703
+ onBalanceStatus: (balance, zero) => {
23704
+ spinner.stop();
23705
+ mdRender.flush();
23706
+ printBalanceNotice(balance, zero);
23707
+ },
23479
23708
  requestPermission,
23480
23709
  checkpointManager,
23481
23710
  onProgress
@@ -23508,6 +23737,13 @@ async function runTurnHeadless(messages, modelAlias, workDir, _abortSignal, env2
23508
23737
  resultText += text;
23509
23738
  emit({ type: "text", text });
23510
23739
  },
23740
+ // System notice (mid-run auto-prune/auto-compact) — emit as its own event
23741
+ // type instead of falling through to onText, so a stream-json consumer
23742
+ // doesn't see compaction housekeeping text mixed into the model's `text`
23743
+ // events or accumulated into resultText.
23744
+ onNotice: (text) => {
23745
+ emit({ type: "notice", text });
23746
+ },
23511
23747
  onToolUse: (name, input) => {
23512
23748
  emit({ type: "tool_use", tool: name, input });
23513
23749
  },
@@ -23519,6 +23755,12 @@ async function runTurnHeadless(messages, modelAlias, workDir, _abortSignal, env2
23519
23755
  lastUsage = u;
23520
23756
  emit({ type: "usage", usage: u });
23521
23757
  },
23758
+ onRetry: (attempt, maxAttempts, reason) => {
23759
+ emit({ type: "retry", attempt, max_attempts: maxAttempts, reason });
23760
+ },
23761
+ onBalanceStatus: (balance, zero) => {
23762
+ emit({ type: "balance_status", balance, zero, billing_url: BILLING_URL });
23763
+ },
23522
23764
  // Headless → auto-approve (no TTY to ask on). Destructive/irreversible
23523
23765
  // commands (DB drops, force-push, terraform destroy…) fail CLOSED here: with
23524
23766
  // no human to confirm, they are denied unless NEXRALL_ALLOW_DESTRUCTIVE=1 is
@@ -23642,9 +23884,23 @@ async function startChatSession(options) {
23642
23884
  messages = stored.messages;
23643
23885
  sessionId = stored.id;
23644
23886
  sessionTitle = stored.title;
23645
- console.log(source_default.green(` Resumed session: ${source_default.bold(stored.title.slice(0, 60))}`));
23646
- console.log(source_default.dim(` ${messages.length} messages restored`));
23647
- console.log();
23887
+ try {
23888
+ const compacted = await (0, import_code_core3.compactMessagesForResume)(messages, {
23889
+ workDir,
23890
+ model: modelAlias,
23891
+ clientType: "cli",
23892
+ onNotice: (text) => {
23893
+ if (!headless)
23894
+ console.log(source_default.dim(text.trim()));
23895
+ }
23896
+ });
23897
+ } catch {
23898
+ }
23899
+ if (!headless) {
23900
+ console.log(source_default.green(` Resumed session: ${source_default.bold(stored.title.slice(0, 60))}`));
23901
+ console.log(source_default.dim(` ${messages.length} messages restored`));
23902
+ console.log();
23903
+ }
23648
23904
  }
23649
23905
  const checkpoints = new import_code_core3.CheckpointManager(workDir, sessionId);
23650
23906
  const updateTitle = () => {
@@ -23789,6 +24045,15 @@ ${text}` : "");
23789
24045
  messages = stored.messages;
23790
24046
  sessionId = stored.id;
23791
24047
  sessionTitle = stored.title;
24048
+ try {
24049
+ await (0, import_code_core3.compactMessagesForResume)(messages, {
24050
+ workDir,
24051
+ model: modelAlias,
24052
+ clientType: "cli",
24053
+ onNotice: (text) => console.log(source_default.dim(text.trim()))
24054
+ });
24055
+ } catch {
24056
+ }
23792
24057
  console.log(source_default.green(` Resumed: ${source_default.bold(stored.title.slice(0, 60))}`));
23793
24058
  console.log(source_default.dim(` ${messages.length} messages restored`));
23794
24059
  rl.prompt();
@@ -24335,7 +24600,7 @@ function pluginListCommand() {
24335
24600
 
24336
24601
  // src/index.ts
24337
24602
  var program2 = new Command();
24338
- program2.name("nex").description("Nexrall Code \u2014 AI coding assistant (powered by Nexrall)").version("0.5.2");
24603
+ program2.name("nex").description("Nexrall Code \u2014 AI coding assistant (powered by Nexrall)").version("0.5.12");
24339
24604
  program2.command("auth").description("Login to your Nexrall account").action(authCommand);
24340
24605
  program2.command("logout").description("Log out of your Nexrall account").action(logoutCommand);
24341
24606
  program2.command("update").description("Update nex to the latest version").option("-c, --check", "Check for updates without installing").option("-y, --yes", "Skip confirmation prompt").action(async (opts) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexrall-code",
3
- "version": "0.5.2",
3
+ "version": "0.5.12",
4
4
  "description": "Nexrall Code — AI coding assistant for your terminal (headless agent for scripts, CI and automation)",
5
5
  "keywords": [
6
6
  "ai",
@@ -37,7 +37,7 @@
37
37
  "ora": "^8.0.1",
38
38
  "prompts": "^2.4.2",
39
39
  "readline": "^1.3.0",
40
- "@nexrall/code-core": "1.4.2"
40
+ "@nexrall/code-core": "1.4.11"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@aws-sdk/client-s3": "^3.600.0",