@mindstudio-ai/remy 0.1.328 → 0.1.330

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/headless.js CHANGED
@@ -100,6 +100,87 @@ function resolveConfig(flags) {
100
100
  return { apiKey, baseUrl: baseUrl2, appId };
101
101
  }
102
102
 
103
+ // src/loopGuard.ts
104
+ var PARAGRAPH_REPEAT_THRESHOLD = 6;
105
+ var MIN_PARAGRAPH_CHARS = 80;
106
+ var CHUNK_SIZE = 60;
107
+ var CHUNK_WINDOW = 5e3;
108
+ var CHUNK_REPEAT_THRESHOLD = 10;
109
+ var LONE_HEADER = /^\*{1,3}[^*].*\*{1,3}$/;
110
+ function normalizeParagraph(seg) {
111
+ const trimmed = seg.trim();
112
+ if (!trimmed || LONE_HEADER.test(trimmed)) {
113
+ return null;
114
+ }
115
+ const key = trimmed.toLowerCase().replace(/\s+/g, " ");
116
+ if (key.length < MIN_PARAGRAPH_CHARS) {
117
+ return null;
118
+ }
119
+ return key;
120
+ }
121
+ var RepetitionDetector = class {
122
+ // Paragraph detector: incomplete tail awaiting its closing blank line, plus
123
+ // occurrence counts of completed, normalized paragraphs.
124
+ paragraphBuffer = "";
125
+ paragraphCounts = /* @__PURE__ */ new Map();
126
+ // Rolling-chunk detector: the trailing window of raw fed text.
127
+ window = "";
128
+ fired = false;
129
+ /**
130
+ * Feed the next streamed text fragment. Returns a signal the first time a
131
+ * loop is recognized, then null forever after (the caller aborts on the
132
+ * first signal; further feeds are harmless no-ops).
133
+ */
134
+ feed(text) {
135
+ if (this.fired || !text) {
136
+ return null;
137
+ }
138
+ return this.feedParagraphs(text) ?? this.feedChunks(text);
139
+ }
140
+ feedParagraphs(text) {
141
+ this.paragraphBuffer += text;
142
+ const segments = this.paragraphBuffer.split(/\n\s*\n/);
143
+ this.paragraphBuffer = segments.pop() ?? "";
144
+ for (const seg of segments) {
145
+ const key = normalizeParagraph(seg);
146
+ if (!key) {
147
+ continue;
148
+ }
149
+ const count = (this.paragraphCounts.get(key) ?? 0) + 1;
150
+ this.paragraphCounts.set(key, count);
151
+ if (count >= PARAGRAPH_REPEAT_THRESHOLD) {
152
+ this.fired = true;
153
+ return {
154
+ kind: "paragraph",
155
+ repeats: count,
156
+ sample: seg.trim().slice(0, 160)
157
+ };
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+ feedChunks(text) {
163
+ this.window = (this.window + text).slice(-CHUNK_WINDOW);
164
+ if (this.window.length < CHUNK_SIZE * CHUNK_REPEAT_THRESHOLD) {
165
+ return null;
166
+ }
167
+ const tail = this.window.slice(-CHUNK_SIZE);
168
+ if (!tail.trim()) {
169
+ return null;
170
+ }
171
+ const occurrences = this.window.split(tail).length - 1;
172
+ if (occurrences >= CHUNK_REPEAT_THRESHOLD) {
173
+ this.fired = true;
174
+ return {
175
+ kind: "chunk",
176
+ repeats: occurrences,
177
+ sample: tail.replace(/\s+/g, " ").trim().slice(0, 160)
178
+ };
179
+ }
180
+ return null;
181
+ }
182
+ };
183
+
103
184
  // src/api.ts
104
185
  var log2 = createLogger("api");
105
186
  async function* streamChat(params) {
@@ -311,17 +392,51 @@ async function* streamChatWithRetry(params, options) {
311
392
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
312
393
  const buffer = [];
313
394
  let retryableFailure = false;
314
- for await (const event of streamChat(params)) {
315
- if (event.type === "error") {
316
- if (isRetryableError(event.error, event.code) && attempt < MAX_RETRIES - 1) {
317
- options?.onRetry?.(attempt, event.error);
318
- retryableFailure = true;
319
- break;
395
+ const detector = options?.detectRepetition ? new RepetitionDetector() : null;
396
+ const streamAbort = new AbortController();
397
+ const onCallerAbort = () => streamAbort.abort();
398
+ if (params.signal) {
399
+ if (params.signal.aborted) {
400
+ streamAbort.abort();
401
+ } else {
402
+ params.signal.addEventListener("abort", onCallerAbort, { once: true });
403
+ }
404
+ }
405
+ try {
406
+ for await (const event of streamChat({
407
+ ...params,
408
+ signal: streamAbort.signal
409
+ })) {
410
+ if (event.type === "error") {
411
+ if (isRetryableError(event.error, event.code) && attempt < MAX_RETRIES - 1) {
412
+ options?.onRetry?.(attempt, event.error);
413
+ retryableFailure = true;
414
+ break;
415
+ }
416
+ yield event;
417
+ return;
320
418
  }
321
- yield event;
322
- return;
419
+ if (detector && (event.type === "text" || event.type === "thinking")) {
420
+ const loop = detector.feed(event.text);
421
+ if (loop) {
422
+ log2.warn("Repetition loop detected \u2014 aborting call", {
423
+ requestId: params.requestId,
424
+ kind: loop.kind,
425
+ repeats: loop.repeats
426
+ });
427
+ streamAbort.abort();
428
+ yield {
429
+ type: "error",
430
+ error: "Response stopped: the model was repeating its reasoning without making progress.",
431
+ code: "repetition_loop"
432
+ };
433
+ return;
434
+ }
435
+ }
436
+ buffer.push(event);
323
437
  }
324
- buffer.push(event);
438
+ } finally {
439
+ params.signal?.removeEventListener("abort", onCallerAbort);
325
440
  }
326
441
  if (retryableFailure) {
327
442
  if (params.signal?.aborted) {
@@ -3031,6 +3146,13 @@ var queryDatabaseTool = {
3031
3146
  // src/usageLedger.ts
3032
3147
  import fs16 from "fs";
3033
3148
  var LEDGER_FILE = ".logs/usage.ndjson";
3149
+ function thinkingTokensFromBilling(billingEvents, outputTokens) {
3150
+ if (!billingEvents?.length) {
3151
+ return 0;
3152
+ }
3153
+ const billedResponseUnits = billingEvents.filter((e) => e.eventType.endsWith("-response")).reduce((sum, e) => sum + e.numUnits, 0);
3154
+ return Math.max(0, billedResponseUnits - outputTokens);
3155
+ }
3034
3156
  var fd = null;
3035
3157
  function nanoToDollars(nano) {
3036
3158
  return typeof nano === "number" ? nano / 1e9 : void 0;
@@ -8961,6 +9083,13 @@ function getActionChain(startName) {
8961
9083
  }
8962
9084
 
8963
9085
  // src/errors.ts
9086
+ var OVERFLOW_PATTERN = /input token count.*exceeds|exceeds the maximum number of tokens|prompt is too long|context[_ ]length[_ ]exceeded|maximum context length|HTTP 413|payload too large|request entity too large/i;
9087
+ function isContextOverflowError(message, code) {
9088
+ if (code === "context_overflow") {
9089
+ return true;
9090
+ }
9091
+ return OVERFLOW_PATTERN.test(message);
9092
+ }
8964
9093
  var patterns = [
8965
9094
  [
8966
9095
  /Network error/i,
@@ -8980,6 +9109,14 @@ var patterns = [
8980
9109
  "The AI service is temporarily unavailable. Please try again."
8981
9110
  ],
8982
9111
  [/Stream stalled/i, "The connection was interrupted. Please try again."],
9112
+ [
9113
+ // A too-large-context failure that survived the automatic compact-and-retry
9114
+ // (or came from an older server). Kept ahead of the generic fallback so a
9115
+ // raw provider string (e.g. Gemini's INVALID_ARGUMENT JSON) never reaches
9116
+ // the user.
9117
+ OVERFLOW_PATTERN,
9118
+ "This conversation outgrew the model's context window. Remy compacted it and tried again; if you keep seeing this, run /compact or start a new conversation."
9119
+ ],
8983
9120
  [
8984
9121
  /content filter|Output blocked/i,
8985
9122
  "The AI model's content moderation filter blocked this response. These are usually false positives, we apologize for the interruption. Rephrasing your request typically fixes this."
@@ -9284,8 +9421,33 @@ async function runTurn(params) {
9284
9421
  let lastCallInputTokens = 0;
9285
9422
  let lastCallCacheCreation = 0;
9286
9423
  let lastCallCacheRead = 0;
9287
- let abnormalStopRecoveries = 0;
9288
- const MAX_ABNORMAL_STOP_RECOVERIES = 2;
9424
+ let recoveries = 0;
9425
+ const MAX_RECOVERIES = 2;
9426
+ let midTurnCompactions = 0;
9427
+ const MAX_MID_TURN_COMPACTIONS = 2;
9428
+ let overflowRecovered = false;
9429
+ const compactNow = async (reason) => {
9430
+ log15.warn("Compacting mid-turn", {
9431
+ requestId,
9432
+ reason,
9433
+ lastCallInputTokens
9434
+ });
9435
+ onEvent({ type: "status", message: "Compacting the conversation\u2026" });
9436
+ try {
9437
+ await triggerCompaction(state, apiConfig, {
9438
+ blocking: true,
9439
+ requestId,
9440
+ model,
9441
+ origin: "gate"
9442
+ });
9443
+ applyPendingSummaries(state);
9444
+ } catch (err) {
9445
+ log15.error("Mid-turn compaction failed", {
9446
+ requestId,
9447
+ error: err?.message ?? String(err)
9448
+ });
9449
+ }
9450
+ };
9289
9451
  const statusWatcher = isFirstMessage ? { stop() {
9290
9452
  }, pause() {
9291
9453
  }, resume() {
@@ -9432,6 +9594,7 @@ async function runTurn(params) {
9432
9594
  onEvent({ type: "tool_input_delta", id, name, result: content });
9433
9595
  }
9434
9596
  }
9597
+ let streamError = null;
9435
9598
  try {
9436
9599
  for await (const event of streamChatWithRetry(
9437
9600
  {
@@ -9450,9 +9613,13 @@ async function runTurn(params) {
9450
9613
  onRetry: (attempt) => {
9451
9614
  onEvent({
9452
9615
  type: "status",
9453
- message: `Lost connection, retrying (attempt ${attempt + 2} of 3)`
9616
+ message: `Lost connection, retrying (attempt ${attempt + 2} of ${MAX_RETRIES})`
9454
9617
  });
9455
- }
9618
+ },
9619
+ // Watch the streamed reasoning/text for a runaway repetition loop
9620
+ // and abort the in-flight call early (RPT-1225). Surfaces as a
9621
+ // `repetition_loop` error handled below.
9622
+ detectRepetition: true
9456
9623
  }
9457
9624
  )) {
9458
9625
  if (signal?.aborted) {
@@ -9476,7 +9643,7 @@ async function runTurn(params) {
9476
9643
  emitTextBlockSnapshot2(false);
9477
9644
  break;
9478
9645
  }
9479
- case "thinking":
9646
+ case "thinking": {
9480
9647
  if (event.text === "") {
9481
9648
  thinkingBlockStartTimes.push(event.ts);
9482
9649
  if (textBlockOpen) {
@@ -9486,6 +9653,7 @@ async function runTurn(params) {
9486
9653
  }
9487
9654
  onEvent({ type: "thinking", text: event.text });
9488
9655
  break;
9656
+ }
9489
9657
  case "thinking_complete": {
9490
9658
  const startedAt = thinkingBlockStartTimes[thinkingCompleteCount] ?? event.ts;
9491
9659
  contentBlocks.push({
@@ -9583,6 +9751,10 @@ async function runTurn(params) {
9583
9751
  outputTokens: event.usage.outputTokens,
9584
9752
  cacheCreationTokens: event.usage.cacheCreationTokens,
9585
9753
  cacheReadTokens: event.usage.cacheReadTokens,
9754
+ thinkingTokens: thinkingTokensFromBilling(
9755
+ event.billingEvents,
9756
+ event.usage.outputTokens
9757
+ ) || void 0,
9586
9758
  cost: nanoToDollars(event.cost),
9587
9759
  billingEvents: event.billingEvents,
9588
9760
  durationMs: Date.now() - iterStart,
@@ -9592,13 +9764,11 @@ async function runTurn(params) {
9592
9764
  });
9593
9765
  break;
9594
9766
  case "error":
9595
- statusWatcher.stop();
9596
- onEvent({
9597
- type: "error",
9598
- error: friendlyError(event.error),
9599
- ...event.code ? { code: event.code } : {}
9600
- });
9601
- return;
9767
+ streamError = { error: event.error, code: event.code };
9768
+ break;
9769
+ }
9770
+ if (streamError) {
9771
+ break;
9602
9772
  }
9603
9773
  }
9604
9774
  } catch (err) {
@@ -9643,6 +9813,51 @@ async function runTurn(params) {
9643
9813
  saveSession(state);
9644
9814
  return;
9645
9815
  }
9816
+ if (streamError) {
9817
+ const { error, code } = streamError;
9818
+ if (code === "repetition_loop") {
9819
+ if (recoveries < MAX_RECOVERIES && !signal?.aborted) {
9820
+ recoveries++;
9821
+ log15.warn("Repetition loop \u2014 nudging model to continue", {
9822
+ requestId,
9823
+ attempt: recoveries
9824
+ });
9825
+ const nudge = "Your previous response was stopped because it kept repeating the same reasoning without making progress. Do not deliberate further \u2014 take the next concrete action now: make the tool call or give the answer.";
9826
+ state.messages.push({
9827
+ role: "user",
9828
+ content: nudge,
9829
+ hidden: true
9830
+ });
9831
+ onEvent({ type: "user_message", text: nudge, hidden: true });
9832
+ continue;
9833
+ }
9834
+ statusWatcher.stop();
9835
+ saveSession(state);
9836
+ log15.warn("Repetition loop over recovery cap \u2014 ending turn", {
9837
+ requestId
9838
+ });
9839
+ onEvent({
9840
+ type: "error",
9841
+ error: "The model kept repeating itself without making progress, so Remy stopped the turn to avoid runaway cost. Try again, or rephrase your request.",
9842
+ lastCallInputTokens
9843
+ });
9844
+ return;
9845
+ }
9846
+ if (isContextOverflowError(error, code) && !overflowRecovered && !signal?.aborted) {
9847
+ overflowRecovered = true;
9848
+ await compactNow("context overflow");
9849
+ continue;
9850
+ }
9851
+ statusWatcher.stop();
9852
+ saveSession(state);
9853
+ onEvent({
9854
+ type: "error",
9855
+ error: friendlyError(error),
9856
+ ...code ? { code } : {},
9857
+ lastCallInputTokens
9858
+ });
9859
+ return;
9860
+ }
9646
9861
  if (contentBlocks.length > 0) {
9647
9862
  state.messages.push({
9648
9863
  role: "assistant",
@@ -9662,14 +9877,14 @@ async function runTurn(params) {
9662
9877
  });
9663
9878
  }
9664
9879
  const toolCalls = getToolCalls(contentBlocks);
9665
- if (toolCalls.length === 0 && (stopReason === "repetition" || stopReason === "max_tokens") && abnormalStopRecoveries < MAX_ABNORMAL_STOP_RECOVERIES && !signal?.aborted) {
9666
- abnormalStopRecoveries++;
9880
+ if (toolCalls.length === 0 && (stopReason === "repetition" || stopReason === "max_tokens") && recoveries < MAX_RECOVERIES && !signal?.aborted) {
9881
+ recoveries++;
9667
9882
  log15.warn("Abnormal stop \u2014 nudging model to continue", {
9668
9883
  requestId,
9669
9884
  stopReason,
9670
- attempt: abnormalStopRecoveries
9885
+ attempt: recoveries
9671
9886
  });
9672
- const nudge = "Your previous response was cut off \u2014 it degenerated into repeated text or hit the output limit, and the repeated portion was removed. Reassess where you are in the task and continue from where you left off. Prefer a tool call over restating what you were about to do.";
9887
+ const nudge = "Your previous response was cut off before you finished \u2014 it hit the output limit (often from over-long reasoning) or degenerated into repeated text, and the unusable part was removed. Stop deliberating, reassess where you are, and continue with a concrete tool call rather than restating your plan.";
9673
9888
  state.messages.push({ role: "user", content: nudge, hidden: true });
9674
9889
  onEvent({ type: "user_message", text: nudge, hidden: true });
9675
9890
  continue;
@@ -9862,6 +10077,11 @@ async function runTurn(params) {
9862
10077
  isToolError: r.isError
9863
10078
  });
9864
10079
  }
10080
+ const { forceCompactAt } = getContextLimits(parentModel);
10081
+ if (lastCallInputTokens > forceCompactAt && midTurnCompactions < MAX_MID_TURN_COMPACTIONS && !signal?.aborted) {
10082
+ midTurnCompactions++;
10083
+ await compactNow(`context ${lastCallInputTokens} > ${forceCompactAt}`);
10084
+ }
9865
10085
  if (takeSteering && !signal?.aborted) {
9866
10086
  const injected = (await takeSteering()).filter(
9867
10087
  (e) => e.text.trim().length > 0 || (e.attachments?.length ?? 0) > 0
@@ -10911,6 +11131,10 @@ var HeadlessSession = class {
10911
11131
  );
10912
11132
  return;
10913
11133
  case "error":
11134
+ if (typeof e.lastCallInputTokens === "number") {
11135
+ this.sessionStats.lastContextSize = e.lastCallInputTokens;
11136
+ this.persistStats();
11137
+ }
10914
11138
  this.emit(
10915
11139
  "error",
10916
11140
  { error: e.error, ...e.code ? { code: e.code } : {} },
package/dist/index.js CHANGED
@@ -84,6 +84,93 @@ var init_logger = __esm({
84
84
  }
85
85
  });
86
86
 
87
+ // src/loopGuard.ts
88
+ function normalizeParagraph(seg) {
89
+ const trimmed = seg.trim();
90
+ if (!trimmed || LONE_HEADER.test(trimmed)) {
91
+ return null;
92
+ }
93
+ const key = trimmed.toLowerCase().replace(/\s+/g, " ");
94
+ if (key.length < MIN_PARAGRAPH_CHARS) {
95
+ return null;
96
+ }
97
+ return key;
98
+ }
99
+ var PARAGRAPH_REPEAT_THRESHOLD, MIN_PARAGRAPH_CHARS, CHUNK_SIZE, CHUNK_WINDOW, CHUNK_REPEAT_THRESHOLD, LONE_HEADER, RepetitionDetector;
100
+ var init_loopGuard = __esm({
101
+ "src/loopGuard.ts"() {
102
+ "use strict";
103
+ PARAGRAPH_REPEAT_THRESHOLD = 6;
104
+ MIN_PARAGRAPH_CHARS = 80;
105
+ CHUNK_SIZE = 60;
106
+ CHUNK_WINDOW = 5e3;
107
+ CHUNK_REPEAT_THRESHOLD = 10;
108
+ LONE_HEADER = /^\*{1,3}[^*].*\*{1,3}$/;
109
+ RepetitionDetector = class {
110
+ // Paragraph detector: incomplete tail awaiting its closing blank line, plus
111
+ // occurrence counts of completed, normalized paragraphs.
112
+ paragraphBuffer = "";
113
+ paragraphCounts = /* @__PURE__ */ new Map();
114
+ // Rolling-chunk detector: the trailing window of raw fed text.
115
+ window = "";
116
+ fired = false;
117
+ /**
118
+ * Feed the next streamed text fragment. Returns a signal the first time a
119
+ * loop is recognized, then null forever after (the caller aborts on the
120
+ * first signal; further feeds are harmless no-ops).
121
+ */
122
+ feed(text) {
123
+ if (this.fired || !text) {
124
+ return null;
125
+ }
126
+ return this.feedParagraphs(text) ?? this.feedChunks(text);
127
+ }
128
+ feedParagraphs(text) {
129
+ this.paragraphBuffer += text;
130
+ const segments = this.paragraphBuffer.split(/\n\s*\n/);
131
+ this.paragraphBuffer = segments.pop() ?? "";
132
+ for (const seg of segments) {
133
+ const key = normalizeParagraph(seg);
134
+ if (!key) {
135
+ continue;
136
+ }
137
+ const count = (this.paragraphCounts.get(key) ?? 0) + 1;
138
+ this.paragraphCounts.set(key, count);
139
+ if (count >= PARAGRAPH_REPEAT_THRESHOLD) {
140
+ this.fired = true;
141
+ return {
142
+ kind: "paragraph",
143
+ repeats: count,
144
+ sample: seg.trim().slice(0, 160)
145
+ };
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+ feedChunks(text) {
151
+ this.window = (this.window + text).slice(-CHUNK_WINDOW);
152
+ if (this.window.length < CHUNK_SIZE * CHUNK_REPEAT_THRESHOLD) {
153
+ return null;
154
+ }
155
+ const tail = this.window.slice(-CHUNK_SIZE);
156
+ if (!tail.trim()) {
157
+ return null;
158
+ }
159
+ const occurrences = this.window.split(tail).length - 1;
160
+ if (occurrences >= CHUNK_REPEAT_THRESHOLD) {
161
+ this.fired = true;
162
+ return {
163
+ kind: "chunk",
164
+ repeats: occurrences,
165
+ sample: tail.replace(/\s+/g, " ").trim().slice(0, 160)
166
+ };
167
+ }
168
+ return null;
169
+ }
170
+ };
171
+ }
172
+ });
173
+
87
174
  // src/api.ts
88
175
  async function* streamChat(params) {
89
176
  const { baseUrl: baseUrl2, apiKey, signal, requestId, model, ...rest } = params;
@@ -286,17 +373,51 @@ async function* streamChatWithRetry(params, options) {
286
373
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
287
374
  const buffer = [];
288
375
  let retryableFailure = false;
289
- for await (const event of streamChat(params)) {
290
- if (event.type === "error") {
291
- if (isRetryableError(event.error, event.code) && attempt < MAX_RETRIES - 1) {
292
- options?.onRetry?.(attempt, event.error);
293
- retryableFailure = true;
294
- break;
376
+ const detector = options?.detectRepetition ? new RepetitionDetector() : null;
377
+ const streamAbort = new AbortController();
378
+ const onCallerAbort = () => streamAbort.abort();
379
+ if (params.signal) {
380
+ if (params.signal.aborted) {
381
+ streamAbort.abort();
382
+ } else {
383
+ params.signal.addEventListener("abort", onCallerAbort, { once: true });
384
+ }
385
+ }
386
+ try {
387
+ for await (const event of streamChat({
388
+ ...params,
389
+ signal: streamAbort.signal
390
+ })) {
391
+ if (event.type === "error") {
392
+ if (isRetryableError(event.error, event.code) && attempt < MAX_RETRIES - 1) {
393
+ options?.onRetry?.(attempt, event.error);
394
+ retryableFailure = true;
395
+ break;
396
+ }
397
+ yield event;
398
+ return;
295
399
  }
296
- yield event;
297
- return;
400
+ if (detector && (event.type === "text" || event.type === "thinking")) {
401
+ const loop = detector.feed(event.text);
402
+ if (loop) {
403
+ log.warn("Repetition loop detected \u2014 aborting call", {
404
+ requestId: params.requestId,
405
+ kind: loop.kind,
406
+ repeats: loop.repeats
407
+ });
408
+ streamAbort.abort();
409
+ yield {
410
+ type: "error",
411
+ error: "Response stopped: the model was repeating its reasoning without making progress.",
412
+ code: "repetition_loop"
413
+ };
414
+ return;
415
+ }
416
+ }
417
+ buffer.push(event);
298
418
  }
299
- buffer.push(event);
419
+ } finally {
420
+ params.signal?.removeEventListener("abort", onCallerAbort);
300
421
  }
301
422
  if (retryableFailure) {
302
423
  if (params.signal?.aborted) {
@@ -375,6 +496,7 @@ var init_api = __esm({
375
496
  "src/api.ts"() {
376
497
  "use strict";
377
498
  init_logger();
499
+ init_loopGuard();
378
500
  log = createLogger("api");
379
501
  MAX_RETRIES = 5;
380
502
  INITIAL_BACKOFF_MS = 1e3;
@@ -1543,6 +1665,13 @@ var init_sentinel = __esm({
1543
1665
 
1544
1666
  // src/usageLedger.ts
1545
1667
  import fs9 from "fs";
1668
+ function thinkingTokensFromBilling(billingEvents, outputTokens) {
1669
+ if (!billingEvents?.length) {
1670
+ return 0;
1671
+ }
1672
+ const billedResponseUnits = billingEvents.filter((e) => e.eventType.endsWith("-response")).reduce((sum, e) => sum + e.numUnits, 0);
1673
+ return Math.max(0, billedResponseUnits - outputTokens);
1674
+ }
1546
1675
  function nanoToDollars(nano) {
1547
1676
  return typeof nano === "number" ? nano / 1e9 : void 0;
1548
1677
  }
@@ -9311,6 +9440,12 @@ var init_resolve = __esm({
9311
9440
  });
9312
9441
 
9313
9442
  // src/errors.ts
9443
+ function isContextOverflowError(message, code) {
9444
+ if (code === "context_overflow") {
9445
+ return true;
9446
+ }
9447
+ return OVERFLOW_PATTERN.test(message);
9448
+ }
9314
9449
  function friendlyError(raw) {
9315
9450
  for (const [pattern, message] of patterns) {
9316
9451
  if (pattern.test(raw)) {
@@ -9319,10 +9454,11 @@ function friendlyError(raw) {
9319
9454
  }
9320
9455
  return `Something went wrong: ${raw}`;
9321
9456
  }
9322
- var patterns;
9457
+ var OVERFLOW_PATTERN, patterns;
9323
9458
  var init_errors = __esm({
9324
9459
  "src/errors.ts"() {
9325
9460
  "use strict";
9461
+ OVERFLOW_PATTERN = /input token count.*exceeds|exceeds the maximum number of tokens|prompt is too long|context[_ ]length[_ ]exceeded|maximum context length|HTTP 413|payload too large|request entity too large/i;
9326
9462
  patterns = [
9327
9463
  [
9328
9464
  /Network error/i,
@@ -9342,6 +9478,14 @@ var init_errors = __esm({
9342
9478
  "The AI service is temporarily unavailable. Please try again."
9343
9479
  ],
9344
9480
  [/Stream stalled/i, "The connection was interrupted. Please try again."],
9481
+ [
9482
+ // A too-large-context failure that survived the automatic compact-and-retry
9483
+ // (or came from an older server). Kept ahead of the generic fallback so a
9484
+ // raw provider string (e.g. Gemini's INVALID_ARGUMENT JSON) never reaches
9485
+ // the user.
9486
+ OVERFLOW_PATTERN,
9487
+ "This conversation outgrew the model's context window. Remy compacted it and tried again; if you keep seeing this, run /compact or start a new conversation."
9488
+ ],
9345
9489
  [
9346
9490
  /content filter|Output blocked/i,
9347
9491
  "The AI model's content moderation filter blocked this response. These are usually false positives, we apologize for the interruption. Rephrasing your request typically fixes this."
@@ -9987,8 +10131,33 @@ async function runTurn(params) {
9987
10131
  let lastCallInputTokens = 0;
9988
10132
  let lastCallCacheCreation = 0;
9989
10133
  let lastCallCacheRead = 0;
9990
- let abnormalStopRecoveries = 0;
9991
- const MAX_ABNORMAL_STOP_RECOVERIES = 2;
10134
+ let recoveries = 0;
10135
+ const MAX_RECOVERIES = 2;
10136
+ let midTurnCompactions = 0;
10137
+ const MAX_MID_TURN_COMPACTIONS = 2;
10138
+ let overflowRecovered = false;
10139
+ const compactNow = async (reason) => {
10140
+ log14.warn("Compacting mid-turn", {
10141
+ requestId,
10142
+ reason,
10143
+ lastCallInputTokens
10144
+ });
10145
+ onEvent({ type: "status", message: "Compacting the conversation\u2026" });
10146
+ try {
10147
+ await triggerCompaction(state, apiConfig, {
10148
+ blocking: true,
10149
+ requestId,
10150
+ model,
10151
+ origin: "gate"
10152
+ });
10153
+ applyPendingSummaries(state);
10154
+ } catch (err) {
10155
+ log14.error("Mid-turn compaction failed", {
10156
+ requestId,
10157
+ error: err?.message ?? String(err)
10158
+ });
10159
+ }
10160
+ };
9992
10161
  const statusWatcher = isFirstMessage ? { stop() {
9993
10162
  }, pause() {
9994
10163
  }, resume() {
@@ -10135,6 +10304,7 @@ async function runTurn(params) {
10135
10304
  onEvent({ type: "tool_input_delta", id, name, result: content });
10136
10305
  }
10137
10306
  }
10307
+ let streamError = null;
10138
10308
  try {
10139
10309
  for await (const event of streamChatWithRetry(
10140
10310
  {
@@ -10153,9 +10323,13 @@ async function runTurn(params) {
10153
10323
  onRetry: (attempt) => {
10154
10324
  onEvent({
10155
10325
  type: "status",
10156
- message: `Lost connection, retrying (attempt ${attempt + 2} of 3)`
10326
+ message: `Lost connection, retrying (attempt ${attempt + 2} of ${MAX_RETRIES})`
10157
10327
  });
10158
- }
10328
+ },
10329
+ // Watch the streamed reasoning/text for a runaway repetition loop
10330
+ // and abort the in-flight call early (RPT-1225). Surfaces as a
10331
+ // `repetition_loop` error handled below.
10332
+ detectRepetition: true
10159
10333
  }
10160
10334
  )) {
10161
10335
  if (signal?.aborted) {
@@ -10179,7 +10353,7 @@ async function runTurn(params) {
10179
10353
  emitTextBlockSnapshot2(false);
10180
10354
  break;
10181
10355
  }
10182
- case "thinking":
10356
+ case "thinking": {
10183
10357
  if (event.text === "") {
10184
10358
  thinkingBlockStartTimes.push(event.ts);
10185
10359
  if (textBlockOpen) {
@@ -10189,6 +10363,7 @@ async function runTurn(params) {
10189
10363
  }
10190
10364
  onEvent({ type: "thinking", text: event.text });
10191
10365
  break;
10366
+ }
10192
10367
  case "thinking_complete": {
10193
10368
  const startedAt = thinkingBlockStartTimes[thinkingCompleteCount] ?? event.ts;
10194
10369
  contentBlocks.push({
@@ -10286,6 +10461,10 @@ async function runTurn(params) {
10286
10461
  outputTokens: event.usage.outputTokens,
10287
10462
  cacheCreationTokens: event.usage.cacheCreationTokens,
10288
10463
  cacheReadTokens: event.usage.cacheReadTokens,
10464
+ thinkingTokens: thinkingTokensFromBilling(
10465
+ event.billingEvents,
10466
+ event.usage.outputTokens
10467
+ ) || void 0,
10289
10468
  cost: nanoToDollars(event.cost),
10290
10469
  billingEvents: event.billingEvents,
10291
10470
  durationMs: Date.now() - iterStart,
@@ -10295,13 +10474,11 @@ async function runTurn(params) {
10295
10474
  });
10296
10475
  break;
10297
10476
  case "error":
10298
- statusWatcher.stop();
10299
- onEvent({
10300
- type: "error",
10301
- error: friendlyError(event.error),
10302
- ...event.code ? { code: event.code } : {}
10303
- });
10304
- return;
10477
+ streamError = { error: event.error, code: event.code };
10478
+ break;
10479
+ }
10480
+ if (streamError) {
10481
+ break;
10305
10482
  }
10306
10483
  }
10307
10484
  } catch (err) {
@@ -10346,6 +10523,51 @@ async function runTurn(params) {
10346
10523
  saveSession(state);
10347
10524
  return;
10348
10525
  }
10526
+ if (streamError) {
10527
+ const { error, code } = streamError;
10528
+ if (code === "repetition_loop") {
10529
+ if (recoveries < MAX_RECOVERIES && !signal?.aborted) {
10530
+ recoveries++;
10531
+ log14.warn("Repetition loop \u2014 nudging model to continue", {
10532
+ requestId,
10533
+ attempt: recoveries
10534
+ });
10535
+ const nudge = "Your previous response was stopped because it kept repeating the same reasoning without making progress. Do not deliberate further \u2014 take the next concrete action now: make the tool call or give the answer.";
10536
+ state.messages.push({
10537
+ role: "user",
10538
+ content: nudge,
10539
+ hidden: true
10540
+ });
10541
+ onEvent({ type: "user_message", text: nudge, hidden: true });
10542
+ continue;
10543
+ }
10544
+ statusWatcher.stop();
10545
+ saveSession(state);
10546
+ log14.warn("Repetition loop over recovery cap \u2014 ending turn", {
10547
+ requestId
10548
+ });
10549
+ onEvent({
10550
+ type: "error",
10551
+ error: "The model kept repeating itself without making progress, so Remy stopped the turn to avoid runaway cost. Try again, or rephrase your request.",
10552
+ lastCallInputTokens
10553
+ });
10554
+ return;
10555
+ }
10556
+ if (isContextOverflowError(error, code) && !overflowRecovered && !signal?.aborted) {
10557
+ overflowRecovered = true;
10558
+ await compactNow("context overflow");
10559
+ continue;
10560
+ }
10561
+ statusWatcher.stop();
10562
+ saveSession(state);
10563
+ onEvent({
10564
+ type: "error",
10565
+ error: friendlyError(error),
10566
+ ...code ? { code } : {},
10567
+ lastCallInputTokens
10568
+ });
10569
+ return;
10570
+ }
10349
10571
  if (contentBlocks.length > 0) {
10350
10572
  state.messages.push({
10351
10573
  role: "assistant",
@@ -10365,14 +10587,14 @@ async function runTurn(params) {
10365
10587
  });
10366
10588
  }
10367
10589
  const toolCalls = getToolCalls(contentBlocks);
10368
- if (toolCalls.length === 0 && (stopReason === "repetition" || stopReason === "max_tokens") && abnormalStopRecoveries < MAX_ABNORMAL_STOP_RECOVERIES && !signal?.aborted) {
10369
- abnormalStopRecoveries++;
10590
+ if (toolCalls.length === 0 && (stopReason === "repetition" || stopReason === "max_tokens") && recoveries < MAX_RECOVERIES && !signal?.aborted) {
10591
+ recoveries++;
10370
10592
  log14.warn("Abnormal stop \u2014 nudging model to continue", {
10371
10593
  requestId,
10372
10594
  stopReason,
10373
- attempt: abnormalStopRecoveries
10595
+ attempt: recoveries
10374
10596
  });
10375
- const nudge = "Your previous response was cut off \u2014 it degenerated into repeated text or hit the output limit, and the repeated portion was removed. Reassess where you are in the task and continue from where you left off. Prefer a tool call over restating what you were about to do.";
10597
+ const nudge = "Your previous response was cut off before you finished \u2014 it hit the output limit (often from over-long reasoning) or degenerated into repeated text, and the unusable part was removed. Stop deliberating, reassess where you are, and continue with a concrete tool call rather than restating your plan.";
10376
10598
  state.messages.push({ role: "user", content: nudge, hidden: true });
10377
10599
  onEvent({ type: "user_message", text: nudge, hidden: true });
10378
10600
  continue;
@@ -10565,6 +10787,11 @@ async function runTurn(params) {
10565
10787
  isToolError: r.isError
10566
10788
  });
10567
10789
  }
10790
+ const { forceCompactAt } = getContextLimits(parentModel);
10791
+ if (lastCallInputTokens > forceCompactAt && midTurnCompactions < MAX_MID_TURN_COMPACTIONS && !signal?.aborted) {
10792
+ midTurnCompactions++;
10793
+ await compactNow(`context ${lastCallInputTokens} > ${forceCompactAt}`);
10794
+ }
10568
10795
  if (takeSteering && !signal?.aborted) {
10569
10796
  const injected = (await takeSteering()).filter(
10570
10797
  (e) => e.text.trim().length > 0 || (e.attachments?.length ?? 0) > 0
@@ -10606,6 +10833,7 @@ var init_agent = __esm({
10606
10833
  init_sentinel();
10607
10834
  init_trigger2();
10608
10835
  init_surfaces();
10836
+ init_trigger();
10609
10837
  init_toolRegistry();
10610
10838
  init_historyLimits();
10611
10839
  log14 = createLogger("agent");
@@ -11958,6 +12186,10 @@ var init_headless = __esm({
11958
12186
  );
11959
12187
  return;
11960
12188
  case "error":
12189
+ if (typeof e.lastCallInputTokens === "number") {
12190
+ this.sessionStats.lastContextSize = e.lastCallInputTokens;
12191
+ this.persistStats();
12192
+ }
11961
12193
  this.emit(
11962
12194
  "error",
11963
12195
  { error: e.error, ...e.code ? { code: e.code } : {} },
@@ -42,7 +42,7 @@ result.$billingCost; // cost in credits (if applicable)
42
42
  |--------|-------------|-----------|------------|
43
43
  | `analyzeImage` | Vision model analysis | `prompt`, `imageUrl` | `analysis` |
44
44
  | `analyzeVideo` | Video analysis | `prompt`, `videoUrl` | `analysis` |
45
- | `transcribeAudio` | Audio to text | `audioUrl` | `transcription` |
45
+ | `transcribeAudio` | Audio to text | `audioUrl` | `text`, `segments` |
46
46
  | `extractText` | Extract text from documents/images | `url` | `text` |
47
47
  | `detectPII` | Find personal data | `text` | `entities` |
48
48
 
@@ -40,6 +40,8 @@ Created on first use, so searching a source the build hasn't populated returns n
40
40
 
41
41
  **Filtering** narrows a search before ranking, and every condition only narrows: `filter: { metadata: { department: 'legal', year: [2025, 2026], signedAt: { gte: 20250101 } }, filename, documentIds, pages: { min?, max? }, contains: 'all these words', phrase: 'exact adjacent sequence' }`. Metadata matches per key: scalar = equals, array = any-of, `{ gte?, lte? }` = numeric range — ranges are numeric only, so store dates as sortable integers at add time (YYYYMMDD or epoch seconds) to range on them. Metadata is tagged at add time (scalars only, ≤16 keys); re-adding the same bytes with different metadata updates the tags in place, free. Filters are the right tool for scoping retrieval (per-user, per-category, a date window); they are NOT a substitute for a `db` query over structured data.
42
42
 
43
+ **Counting matches.** `Policies.count(filter)` returns `{ chunks }`: how many passages match a filter, exactly, over the whole corpus, in the same grammar as `search`'s filter. `count({ contains: query })` is the honest number to show beside a search's hits: "8 of 3,891 passages mention these words". It is not a relevance count (none exists; similarity is a continuous score over every chunk) and it is not the set `search` returns (semantic hits need not contain the words, lexical hits may contain only some), so never label it "relevant results" or "matches for your query". Chunks, not documents. Counts share the 300/minute search limit; a count beside every search spends two. Same `index_warming` / `index_building` handling as search.
44
+
43
45
  **Modes**: `mode: 'hybrid'` (default) fuses semantic and keyword retrieval; `'semantic'` is the embedding alone; `'lexical'` is keyword-only with **no query embedding** — cheapest and fastest, right when the query is an identifier (an error code, a SKU, a name) rather than a meaning. `maxPerDocument: 2` stops one document monopolizing the results when the answer should draw on several. `highlight: true` adds `matches` (`{start, end}` offsets into `text`) for rendering highlighted excerpts.
44
46
 
45
47
  Search is deterministic for a fixed corpus and configuration, so eval sets and regression checks are meaningful — key them on `(documentId, chunkIndex)` rather than on chunk text.
@@ -206,6 +208,8 @@ remy-admin datasources remap --source archive --wait
206
208
 
207
209
  **A mapper is a pure transform: one object in, documents out, nothing else.** `remap` and `jobs replay` run it again over the raw copies, and a frame runs in the context of the release that compiled it, so anything a mapper writes on the side is written twice and possibly into the wrong data plane. The per-document facts an app needs later belong in `metadata`; an app that wants its own view of a big corpus (a timeline, counts by year, a table of ids) builds it after ingest by walking `Source.allDocuments()` in a background task, and keeps it current from what each sync adds.
208
210
 
211
+ On a job, mapping is its own stage. The mapper turns each object into documents; the platform then ingests those documents in parallel batches of fifty across its workers, whatever one object became. So the size of an object does not set the pace, and a bundle of a thousand records is fine; only the number of objects sets how wide the mapping stage itself runs (three huge files map on three workers, the plan says so as a warning). `jobs status` reads "mapping N of M objects" until that stage is through, then counts documents.
212
+
209
213
  A mapper runs on the platform, so the platform has to build it. Any push builds it, and a branch push is a private preview build, which is all a mapper needs. `map deploy` then makes that build's mapper the source's active one: jobs, syncs and `add()` run it from then on, whether or not the app has ever been published. Publishing activates the mapper the live release declares — which is the one you deployed, since publishing fast-forwards the default branch to your branch. So there is nothing extra to do at publish time, and nothing to merge by hand: publishing is the merge (see the publishing skill). `jobs start` refuses with `mapper_not_deployed` while the dev session declares a mapper that is not yet active, because the job would otherwise load the raw records as documents.
210
214
 
211
215
  `map test --dev` needs the dev session running (`npx mindstudio dev`); it runs the mapper from local source through the tunnel and prints every outcome with markdown previews. The plan of a mapped job records the mapper's outcome mix on its sample; a run whose skip share climbs past twice that pauses with `pauseReason: 'skips'` for a look at the quarantine. `remap` reads the platform's own raw copies — no origin traffic — skips unchanged markdown by hash, and supersedes changed documents, so a metadata tweak on a million-document source costs frames and little else. `externalId` is the identity everything replaces by; choose it deliberately (the record's stable id, never the key of a file that gets rewritten in place).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindstudio-ai/remy",
3
- "version": "0.1.328",
3
+ "version": "0.1.330",
4
4
  "description": "Remy coding agent",
5
5
  "repository": {
6
6
  "type": "git",