claude-code-infinite 1.0.14 → 1.0.17

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 (43) hide show
  1. package/dist/cli-main.js +3 -1
  2. package/dist/cli-main.js.map +1 -1
  3. package/dist/hooks.d.ts +20 -4
  4. package/dist/hooks.d.ts.map +1 -1
  5. package/dist/hooks.js +35 -8
  6. package/dist/hooks.js.map +1 -1
  7. package/dist/memtree-mcp-config.d.ts.map +1 -1
  8. package/dist/memtree-mcp-config.js +7 -2
  9. package/dist/memtree-mcp-config.js.map +1 -1
  10. package/dist/memtree-mcp.d.ts +21 -0
  11. package/dist/memtree-mcp.d.ts.map +1 -1
  12. package/dist/memtree-mcp.js +8 -1
  13. package/dist/memtree-mcp.js.map +1 -1
  14. package/dist/memtree.d.ts +32 -0
  15. package/dist/memtree.d.ts.map +1 -1
  16. package/dist/memtree.js +58 -3
  17. package/dist/memtree.js.map +1 -1
  18. package/dist/notices.js +2 -2
  19. package/dist/notices.js.map +1 -1
  20. package/dist/payment-notice.d.ts +47 -0
  21. package/dist/payment-notice.d.ts.map +1 -0
  22. package/dist/payment-notice.js +72 -0
  23. package/dist/payment-notice.js.map +1 -0
  24. package/dist/proxy.d.ts +14 -0
  25. package/dist/proxy.d.ts.map +1 -1
  26. package/dist/proxy.js +180 -58
  27. package/dist/proxy.js.map +1 -1
  28. package/dist/reqlog.d.ts +10 -2
  29. package/dist/reqlog.d.ts.map +1 -1
  30. package/dist/reqlog.js.map +1 -1
  31. package/dist/request-sizing.d.ts +22 -0
  32. package/dist/request-sizing.d.ts.map +1 -1
  33. package/dist/request-sizing.js +31 -2
  34. package/dist/request-sizing.js.map +1 -1
  35. package/dist/route-fallback.d.ts +42 -4
  36. package/dist/route-fallback.d.ts.map +1 -1
  37. package/dist/route-fallback.js +101 -24
  38. package/dist/route-fallback.js.map +1 -1
  39. package/dist/turns.d.ts +4 -0
  40. package/dist/turns.d.ts.map +1 -1
  41. package/dist/turns.js +41 -9
  42. package/dist/turns.js.map +1 -1
  43. package/package.json +1 -1
package/dist/proxy.js CHANGED
@@ -40,9 +40,9 @@
40
40
  */
41
41
  import { capCacheBreakpoints } from "./route-cache.js";
42
42
  import { PromptAccounting } from "./prompt-accounting.js";
43
- import { RouteFallbackFailures } from "./route-fallback.js";
43
+ import { INDEX_WAIT_RETRY_AFTER_SECONDS, RouteFallbackFailures } from "./route-fallback.js";
44
44
  import { createCountTokensSession, CountTokensCooldowns, plausibleSample, } from "./count-tokens.js";
45
- import { RequestSizing, estimateRequestTokens, fitsNative } from "./request-sizing.js";
45
+ import { RequestSizing, estimateRequestTokens, fitsMeasured, fitsNative, outputReservation } from "./request-sizing.js";
46
46
  import { routeMessageHash, stablePrefixMessageHash } from "./route-identity.js";
47
47
  import http from "node:http";
48
48
  import https from "node:https";
@@ -50,11 +50,12 @@ import { createHash, randomBytes } from "node:crypto";
50
50
  import { brotliDecompressSync, createBrotliDecompress, createGunzip, createInflate, gunzipSync, inflateSync, } from "node:zlib";
51
51
  import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
52
52
  import { join } from "node:path";
53
- import { cachedPromptTokenCount, checkCompressedHistory, didMemtreeCompress, MemtreeClient, modelBudgetTokens, normalizeMessagesForMemtree, serverFlattenedMessages, rawPromptTokenCount, } from "./memtree.js";
53
+ import { cachedPromptTokenCount, checkCompressedHistory, didMemtreeCompress, indexLagEvidence, MemtreeClient, modelBudgetTokens, normalizeMessagesForMemtree, serverFlattenedMessages, rawPromptTokenCount, } from "./memtree.js";
54
54
  import { contextLimitForModel, hasEarlierNonToolUserMessage, isAwaySummaryUserMessage, isLocalBashCommandTurn, isNonToolUserMessage, isToolResultUserMessage, lastNonSystemMessage, messagesWithSystem, modelForMemtree, stripSystemReminderText, } from "./turns.js";
55
- import { COMPRESSED_NOTICE, compressedTotalsText, DEGRADED_NOTICE, NOT_COMPRESSED_NOTE, recapLinkText, PAYMENT_REQUIRED_NOTICE, SseNoticeRewriter, sanitizeNoticeDetail, stripNoticeBlocks, stripNoticeSystem, } from "./notices.js";
56
- import { NoticeDeliveryQueue, MEMTREE_COMPACT_COMMAND, MEMTREE_HELP_COMMAND, TRAILER_LABEL, LINK_LABEL, linkLines, terminalSupportsHyperlinks, isMemtreeViewCommand, sessionCommandArgs, parseNoticeHookInput, } from "./hooks.js";
55
+ import { COMPRESSED_NOTICE, compressedTotalsText, DEGRADED_NOTICE, NOT_COMPRESSED_NOTE, recapLinkText, SseNoticeRewriter, stripNoticeBlocks, stripNoticeSystem, } from "./notices.js";
56
+ import { NoticeDeliveryQueue, MEMTREE_COMPACT_COMMAND, MEMTREE_HELP_COMMAND, TRAILER_LABEL, LINK_LABEL, linkLines, urlLine, terminalSupportsHyperlinks, isMemtreeViewCommand, sessionCommandArgs, parseNoticeHookInput, } from "./hooks.js";
57
57
  import { hyperlink } from "./payment-gate.js";
58
+ import { PaymentNoticeThrottle, paymentNoticeText } from "./payment-notice.js";
58
59
  import { MEMTREE_LINKS_MAX_SESSIONS } from "./memtree-links.js";
59
60
  import { describeClaudeCodeRequest, inspectMonitorTranscript, isClaudeCodeSideRequest, memtreeClientMeta, sessionTag, } from "./cc-request.js";
60
61
  import { approxTokensFromBytes, mergeUsageFromJsonBody, mergeUsageFromSseEvent, } from "./reqlog.js";
@@ -135,6 +136,31 @@ function checkAwaitedIndex(opts, attempt, shutdownSignal) {
135
136
  waiting.checking = false;
136
137
  });
137
138
  }
139
+ /**
140
+ * Record lag evidence from `result` (indexLagEvidence): a refusal of this
141
+ * request then waits for the index, and the cached answer expires after the
142
+ * retry interval so a later retry asks MemTree again — at most one fresh
143
+ * compress per interval for the same request.
144
+ */
145
+ function noteIndexLag(opts, rec, result) {
146
+ if (!indexLagEvidence(result))
147
+ return false;
148
+ rec.indexBehind = true;
149
+ opts.memtree.expireCompressResult?.(result, opts.indexWaitRefreshMs ?? INDEX_WAIT_RETRY_AFTER_SECONDS * 1000);
150
+ return true;
151
+ }
152
+ /** Mark a lane as waiting for the tree behind `result`'s MemTree page. */
153
+ function awaitIndex(opts, attempt, record, result) {
154
+ const pageId = result.memtreeUrl ? memtreePageId(result.memtreeUrl) : undefined;
155
+ if (!pageId)
156
+ return;
157
+ attempt.awaitingIndex = {
158
+ pageId,
159
+ checking: false,
160
+ deadline: Date.now() + (opts.awaitedIndexWaitTimeoutMs ?? 60_000),
161
+ };
162
+ record.awaitingIndex = true;
163
+ }
138
164
  /** Growth (share of the budget) a lane waits for after an attempt that failed. */
139
165
  const TOOL_COMPACTION_RETRY_BUDGET_RATIO = 0.05;
140
166
  /**
@@ -235,12 +261,13 @@ export function startProxy(opts) {
235
261
  const acceptedRequests = new Set();
236
262
  const shutdownAbort = new AbortController();
237
263
  const state = {
238
- paymentNoticeShown: false,
264
+ paymentNotice: new PaymentNoticeThrottle(opts.paymentNoticeIntervalMs),
265
+ unpaidDetail: () => opts.memtree.paymentRequiredDetail,
239
266
  notices: new NoticeDeliveryQueue(),
240
267
  mainNoticePending: false,
241
268
  mainPromptGeneration: 0,
242
269
  promptAccounting: new PromptAccounting(),
243
- fallbackFailures: new RouteFallbackFailures(),
270
+ fallbackFailures: new RouteFallbackFailures(undefined, opts.indexWaitDeadlineMs),
244
271
  activeSubagents: new Set(),
245
272
  memoryRoutes: new Map(),
246
273
  mainRouteEpoch: 0,
@@ -252,6 +279,7 @@ export function startProxy(opts) {
252
279
  shutdownSignal: shutdownAbort.signal,
253
280
  routeInstallFault: opts.routeInstallFault,
254
281
  memtreePages: new Map(),
282
+ memorySourcePages: new Map(),
255
283
  memtreeCallSeq: 0,
256
284
  memtreeLinkStore: opts.memtreeLinkStore,
257
285
  newestTrees: opts.newestTrees,
@@ -522,7 +550,7 @@ async function handleMemTreeFinder(req, res, opts, upstreamPath, url) {
522
550
  */
523
551
  async function handleMemTreeCurrent(req, res, opts, state, url) {
524
552
  const sessionId = url.searchParams.get("session") || undefined;
525
- const page = currentMemtreePage(state, sessionId);
553
+ const page = currentMemorySourcePage(state, sessionId);
526
554
  const id = page ? memtreePageId(page.url) : undefined;
527
555
  if (!page || !id) {
528
556
  res.writeHead(404, { "content-type": "application/json" });
@@ -554,6 +582,12 @@ async function handleMemTreeCurrent(req, res, opts, state, url) {
554
582
  }
555
583
  }
556
584
  const MEMTREE_CURRENT_PATH = `${MEMTREE_PASSTHROUGH_PREFIX}current`;
585
+ /** The page whose memory is in the prompt, else (none adopted yet) the newest page. */
586
+ function currentMemorySourcePage(state, sessionId) {
587
+ if (!sessionId)
588
+ return undefined;
589
+ return state.memorySourcePages.get(sessionId) ?? currentMemtreePage(state, sessionId);
590
+ }
557
591
  function currentMemtreePage(state, sessionId) {
558
592
  if (!sessionId)
559
593
  return undefined;
@@ -615,7 +649,7 @@ onMainStop) {
615
649
  if (parsed.agent_id === undefined && parsed.source === "resume") {
616
650
  await state.newestTrees?.settle(parsed.session_id);
617
651
  }
618
- const line = resumeLinkLine(state, parsed);
652
+ const line = joinNoticeLines(resumeLinkLine(state, parsed), resumePaymentNotice(state, parsed));
619
653
  if (!line) {
620
654
  res.writeHead(204);
621
655
  res.end();
@@ -634,16 +668,16 @@ onMainStop) {
634
668
  // `/memtree-view`: answered here and blocked, so no model turn runs and
635
669
  // the prompt never enters the conversation. Not a human turn either, so
636
670
  // none of the turn state below is touched.
637
- if (parsed.agent_id === undefined) {
638
- // `/memtree-view` waits briefly for the newest tree; a prompt only warms it.
639
- if (isMemtreeViewCommand(parsed.prompt))
640
- await state.newestTrees?.settle(parsed.session_id);
641
- else
642
- state.newestTrees?.peek(parsed.session_id);
643
- }
671
+ // `/memtree-view` waits briefly for the newest tree; a prompt only warms it.
672
+ // In a subagent's view the command is answered too (with the main
673
+ // session's page): unanswered, the plugin's fallback body would run.
674
+ if (isMemtreeViewCommand(parsed.prompt))
675
+ await state.newestTrees?.settle(parsed.session_id);
676
+ else if (parsed.agent_id === undefined)
677
+ state.newestTrees?.peek(parsed.session_id);
644
678
  const commandReply = parsed.agent_id === undefined
645
679
  ? sessionCommandReply(state, parsed.session_id, parsed.prompt)
646
- : undefined;
680
+ : subagentCommandReply(state, parsed.session_id, parsed.prompt);
647
681
  if (commandReply !== undefined) {
648
682
  const body = Buffer.from(JSON.stringify({
649
683
  decision: "block",
@@ -919,7 +953,8 @@ async function handleMessages(req, res, opts, upstream, state) {
919
953
  const sizingLimits = () => ({
920
954
  budget: resolveBudget(opts, state, body.model, modelContextLimit).tokens,
921
955
  context: modelContextLimit,
922
- output: typeof body.max_tokens === "number" ? Math.max(0, body.max_tokens) : 0,
956
+ output: bodyOutputReservation(body),
957
+ recountOutput: outputReservation(undefined, body.max_tokens), // full max_tokens
923
958
  });
924
959
  const rides = state.memoryRoutes.has(requestRouteKey) ||
925
960
  (isMainRequest && requestSessionKey !== undefined && state.stablePrefixes.has(requestSessionKey));
@@ -983,7 +1018,7 @@ async function handleMessages(req, res, opts, upstream, state) {
983
1018
  const measured = await requestSizing.measure(candidate, sizingLimits());
984
1019
  // A validated ride is still eligible for bounded late recovery. Final
985
1020
  // selection considers it together with the original and any replacement.
986
- return candidate.kind === "ride" || fitsNative(measured.tokens, sizingLimits());
1021
+ return candidate.kind === "ride" || fitsMeasured(measured, sizingLimits());
987
1022
  },
988
1023
  recoverLate: async (buffer, tokens) => {
989
1024
  if (rec.compress || compactionMode(opts, state, requestSessionKey).mode === "off" ||
@@ -1014,6 +1049,8 @@ async function handleMessages(req, res, opts, upstream, state) {
1014
1049
  // Hook state controls notices only. Hidden requests cannot consume main notices.
1015
1050
  if (typeof body.model === "string")
1016
1051
  rec.model = body.model;
1052
+ if (typeof body.max_tokens === "number")
1053
+ rec.maxTokens = body.max_tokens;
1017
1054
  rec.stream = body.stream === true;
1018
1055
  // Observation only: which requests carry Claude Code's turn origin.
1019
1056
  if (clientInfo)
@@ -1045,7 +1082,7 @@ async function handleMessages(req, res, opts, upstream, state) {
1045
1082
  candidateSizeOwners.set(routedCandidate, owner);
1046
1083
  // Advisory window pressure can request recovery, but never removes a
1047
1084
  // structurally valid candidate before request-local counting/selection.
1048
- if (!fitsNative(requestSizing.plan(routedCandidate).tokens, sizingLimits())) {
1085
+ if (!fitsMeasured(plannedMeasurement(requestSizing, routedCandidate), sizingLimits())) {
1049
1086
  routeMiss = "rejected";
1050
1087
  regrantSmallerWindowRecovery(state, requestRouteKey, modelContextLimit);
1051
1088
  }
@@ -1319,6 +1356,9 @@ async function handleMessages(req, res, opts, upstream, state) {
1319
1356
  */
1320
1357
  const forwardCompressed = async (compressedBody, compressedRaw, turnType, stable, result) => {
1321
1358
  if (!(await requestSendPolicies.get(req).fitsNative(compressedRaw))) {
1359
+ // A lagging index left history verbatim: a refusal waits for indexing.
1360
+ if (result)
1361
+ noteIndexLag(opts, rec, result);
1322
1362
  releaseUncommittedRouteDecision();
1323
1363
  recordTurn(rec, "followup-degraded", rawBody);
1324
1364
  return logged(forwardRaw(req, res, rawBody, opts, upstream, state.shutdownSignal, rec));
@@ -1359,6 +1399,11 @@ async function handleMessages(req, res, opts, upstream, state) {
1359
1399
  return;
1360
1400
  }
1361
1401
  const installed = installMemoryRoute(state, requestRouteKey, req, body, messages, compressedBody, routeEpoch, routeDecisionGeneration, stable);
1402
+ // The answer's memory is now what future requests reuse; a ride keeps
1403
+ // the prior source, and a failed or stale forward adopts nothing.
1404
+ if (installed && result && linkSeq !== undefined) {
1405
+ noteMemorySource(state, linkSeq, sessionId, result);
1406
+ }
1362
1407
  commitRouteDecision();
1363
1408
  // Leave this false if installMemoryRoute unexpectedly throws: the
1364
1409
  // delivery-complete fallback then gets one safe retry.
@@ -1565,21 +1610,17 @@ async function handleMessages(req, res, opts, upstream, state) {
1565
1610
  // it. Degrade to passthrough and queue a display-only hook notice for a
1566
1611
  // visible turn. The hidden away-summary request deliberately stays quiet.
1567
1612
  // Unpaid key (402, from this compress OR an earlier background index) gets
1568
- // a payment-specific notice instead of the generic degraded one, at most
1569
- // once per proxy process after it has actually been delivered.
1613
+ // a payment-specific notice with the subscribe link instead of the generic
1614
+ // degraded one: on the first 402, then at most once per throttle interval
1615
+ // (counted from actual delivery). In between, unpaid turns stay quiet.
1570
1616
  recordTurn(rec, "followup-degraded", forwardBody);
1571
1617
  releaseForOriginalFallback();
1572
1618
  sendingWhole();
1573
1619
  capture(opts, "anthropic-request", forwardBody);
1574
1620
  const paymentDetail = opts.memtree.paymentRequiredDetail;
1575
1621
  const mayQueueNotice = displayForThisTurn && state.mainPromptGeneration === noticePromptGeneration;
1576
- if (mayQueueNotice && paymentDetail !== null && !state.paymentNoticeShown) {
1577
- const detailFirstLine = sanitizeNoticeDetail(paymentDetail.split(/[\r\n]/, 1)[0]);
1578
- state.notices.queueSuffix(detailFirstLine
1579
- ? `${PAYMENT_REQUIRED_NOTICE}\n${detailFirstLine}`
1580
- : PAYMENT_REQUIRED_NOTICE, () => {
1581
- state.paymentNoticeShown = true;
1582
- }, noticePromptId);
1622
+ if (mayQueueNotice && paymentDetail !== null && state.paymentNotice.due()) {
1623
+ state.notices.queueSuffix(paymentNoticeText(paymentDetail, recapLink(state, sessionId)?.link), () => state.paymentNotice.markShown(), noticePromptId);
1583
1624
  }
1584
1625
  else if (mayQueueNotice && paymentDetail === null) {
1585
1626
  state.notices.queueSuffix(DEGRADED_NOTICE, undefined, noticePromptId);
@@ -1598,6 +1639,10 @@ async function handleMessages(req, res, opts, upstream, state) {
1598
1639
  usable: historyCheck.usable,
1599
1640
  };
1600
1641
  if (!actuallyCompressed || !historyCheck.usable) {
1642
+ // A pass-through from a cold index: if the history cannot go out whole,
1643
+ // the refusal waits for the tree instead of spending its strikes.
1644
+ if (!actuallyCompressed)
1645
+ noteIndexLag(opts, rec, result);
1601
1646
  const kept = rideOldPrefix(!actuallyCompressed);
1602
1647
  if (kept)
1603
1648
  return kept;
@@ -1796,6 +1841,25 @@ function resumeLinkLine(state, input) {
1796
1841
  }
1797
1842
  return state.notices.resumeLine(link, sessionId);
1798
1843
  }
1844
+ /**
1845
+ * The unpaid-key notice for a resumed main session whose key the server has
1846
+ * already answered 402 for: a resume is a fresh look at the session, so it
1847
+ * says again why it runs uncompressed, regardless of the throttle. The link
1848
+ * line above it already names the session's MemTree page.
1849
+ */
1850
+ function resumePaymentNotice(state, input) {
1851
+ if (input.agent_id !== undefined || input.source !== "resume")
1852
+ return undefined;
1853
+ const detail = state.unpaidDetail();
1854
+ if (detail === null)
1855
+ return undefined;
1856
+ state.paymentNotice.markShown();
1857
+ return state.notices.warningText(paymentNoticeText(detail));
1858
+ }
1859
+ function joinNoticeLines(...parts) {
1860
+ const present = parts.filter((part) => !!part);
1861
+ return present.length ? present.join("\n") : undefined;
1862
+ }
1799
1863
  export const MEMTREE_COMPACT_MIN_TOKENS = 20_000;
1800
1864
  /** The reply to a ccc slash command, or undefined for an ordinary prompt. */
1801
1865
  function sessionCommandReply(state, sessionId, prompt) {
@@ -1837,6 +1901,22 @@ function sessionCommandReply(state, sessionId, prompt) {
1837
1901
  state.compactNow.add(sessionId);
1838
1902
  return `${TRAILER_LABEL} compacting: your next message is sent compressed to about ${Math.round(target / 1000)}k tokens, ${keep}`;
1839
1903
  }
1904
+ export const SUBAGENT_VIEW_NOTE = "(main session's page; subagent pages aren't linked yet)";
1905
+ export const SUBAGENT_COMPACT_REPLY = "MemTree compact applies to the main session only.";
1906
+ /**
1907
+ * The reply to a ccc slash command typed in a subagent's view. `sessionId` is
1908
+ * the parent session's: the view shows its page, and compaction (per session)
1909
+ * is refused rather than applied to the main conversation from here.
1910
+ */
1911
+ function subagentCommandReply(state, sessionId, prompt) {
1912
+ if (isMemtreeViewCommand(prompt)) {
1913
+ return `${memtreeViewLine(state, sessionId)}\n${SUBAGENT_VIEW_NOTE}`;
1914
+ }
1915
+ if (sessionCommandArgs(prompt, MEMTREE_COMPACT_COMMAND) !== undefined) {
1916
+ return `${TRAILER_LABEL} ${SUBAGENT_COMPACT_REPLY}`;
1917
+ }
1918
+ return sessionCommandReply(state, sessionId, prompt);
1919
+ }
1840
1920
  /** "50k", "50000", "1.5m" → tokens; undefined when not a positive count. */
1841
1921
  export function parseTokenCount(text) {
1842
1922
  const match = /^(\d+(?:\.\d+)?)\s*([km])?$/i.exec(text.trim());
@@ -1857,13 +1937,16 @@ function memtreeViewLine(state, sessionId) {
1857
1937
  return `${TRAILER_LABEL} no page yet: this session has not been indexed. The link appears once it has.`;
1858
1938
  }
1859
1939
  // Blocked-prompt reasons need an explicit hyperlink in styled terminals.
1860
- const url = terminalSupportsHyperlinks() ? hyperlink(link.link, link.link) : link.link;
1861
- const lines = linkLines(LINK_LABEL, url, link.note);
1940
+ const lines = linkLines(LINK_LABEL, viewUrl(link.link), link.note);
1862
1941
  // The memory the agent works from can be an older tree than the newest one.
1863
1942
  return page?.compressed && page.url !== link.link
1864
- ? `${lines}\n memory from ${page.url}`
1943
+ ? `${lines}\n${urlLine(viewUrl(page.url), "memory from ")}`
1865
1944
  : lines;
1866
1945
  }
1946
+ /** A `/memtree-view` URL: an explicit OSC 8 link where the terminal has them. */
1947
+ function viewUrl(url) {
1948
+ return terminalSupportsHyperlinks() ? hyperlink(url, url) : url;
1949
+ }
1867
1950
  /**
1868
1951
  * The link shown for a session: its newest completed tree when the server
1869
1952
  * named one (newestTrees), else `source`, the page its memory came from. The
@@ -1912,12 +1995,30 @@ function noteMemtreePage(state, seq, sessionId, result) {
1912
1995
  // A cached newest tree may predate this page; ask again.
1913
1996
  state.newestTrees?.invalidate(sessionId);
1914
1997
  }
1915
- /** Bound optional page state independently of how many sessions use the proxy. */
1998
+ /**
1999
+ * Record a compress answer's page as the source of the memory now forwarded
2000
+ * (memorySourcePages), unless a later-submitted call's memory already is.
2001
+ */
2002
+ function noteMemorySource(state, seq, sessionId, result) {
2003
+ const url = result.memtreeUrl;
2004
+ const index = result.memtreeIndex;
2005
+ if (!sessionId || !url || !index)
2006
+ return;
2007
+ const latest = state.memorySourcePages.get(sessionId);
2008
+ if (latest && latest.seq >= seq)
2009
+ return;
2010
+ const page = { sessionId, url, index, compressed: didMemtreeCompress(result), seq };
2011
+ rememberPage(state.memorySourcePages, sessionId, page);
2012
+ }
1916
2013
  function rememberMemtreePage(state, sessionId, page) {
1917
- state.memtreePages.delete(sessionId);
1918
- state.memtreePages.set(sessionId, page);
1919
- while (state.memtreePages.size > MEMTREE_LINKS_MAX_SESSIONS) {
1920
- state.memtreePages.delete(state.memtreePages.keys().next().value);
2014
+ rememberPage(state.memtreePages, sessionId, page);
2015
+ }
2016
+ /** Bound optional page state independently of how many sessions use the proxy. */
2017
+ function rememberPage(pages, sessionId, page) {
2018
+ pages.delete(sessionId);
2019
+ pages.set(sessionId, page);
2020
+ while (pages.size > MEMTREE_LINKS_MAX_SESSIONS) {
2021
+ pages.delete(pages.keys().next().value);
1921
2022
  }
1922
2023
  }
1923
2024
  /**
@@ -2225,8 +2326,8 @@ function laneCompaction(opts, state, sessionId, model, modelContextLimit) {
2225
2326
  }
2226
2327
  /** Calibrated input must leave room for the requested output as well. */
2227
2328
  function calibratedSizeExceedsWindow(record, body, modelContextLimit) {
2228
- const outputTokens = typeof body.max_tokens === "number" && Number.isFinite(body.max_tokens)
2229
- ? Math.max(0, body.max_tokens) : 0;
2329
+ // A planning estimate, so the full max_tokens (the floor needs an exact count).
2330
+ const outputTokens = bodyOutputReservation(body, false);
2230
2331
  return record.sizeSource === "reported" &&
2231
2332
  (record.estimatedTokens ?? 0) + outputTokens > modelContextLimit;
2232
2333
  }
@@ -2487,8 +2588,9 @@ function planToolCompaction(args) {
2487
2588
  }
2488
2589
  }
2489
2590
  if (!ride) {
2490
- const originalSize = args.sizing.plan(args.sizing.register(forwardBody, "original"));
2491
- overWindow ||= originalSize.tokens + Math.max(0, body.max_tokens ?? 0) > modelContextLimit;
2591
+ const original = args.sizing.register(forwardBody, "original");
2592
+ overWindow ||= args.sizing.plan(original).tokens +
2593
+ bodyOutputReservation(body, args.sizing.isExact(original)) > modelContextLimit;
2492
2594
  }
2493
2595
  const sample = ride
2494
2596
  ? ride.sizeHolder.lastSize
@@ -2503,7 +2605,7 @@ function planToolCompaction(args) {
2503
2605
  compaction.estimatedBytes = (ride?.raw ?? forwardBody).length;
2504
2606
  compaction.sizeSource = size.source;
2505
2607
  overWindow ||= !fitsNative(size.tokens, { budget: budget.tokens, context: modelContextLimit,
2506
- output: typeof body.max_tokens === "number" ? Math.max(0, body.max_tokens) : 0 });
2608
+ output: bodyOutputReservation(body, args.sizing.isExact(candidate)) });
2507
2609
  if (!args.canCompress) {
2508
2610
  if (overWindow)
2509
2611
  return { kind: "pass", original: true };
@@ -2567,12 +2669,21 @@ function memoryRoutedToolBody(body, messages, route, routeEpoch, sessionId) {
2567
2669
  }
2568
2670
  /** Size the assembled input (including system/tools) and reserve output room. */
2569
2671
  function routedBodyExceedsContext(body, routedBody, modelContextLimit) {
2570
- const outputTokens = typeof body.max_tokens === "number" &&
2571
- Number.isFinite(body.max_tokens) ? Math.max(0, body.max_tokens) : 0;
2672
+ const outputTokens = bodyOutputReservation(body, false);
2572
2673
  // This is the existing local byte estimate, not a tokenizer or a hard cap.
2573
2674
  // Final forwarding checks can replace this estimate with Count Tokens.
2574
2675
  return approxTokensFromBytes(routedBody.length) + outputTokens > modelContextLimit;
2575
2676
  }
2677
+ /** Output room a native-fit check reserves: the model's floor for an exact
2678
+ * count, else (an estimate) the full max_tokens. */
2679
+ function bodyOutputReservation(body, exact = true) {
2680
+ return outputReservation(exact && typeof body.model === "string" ? body.model : undefined, body.max_tokens);
2681
+ }
2682
+ /** A planned size as a measurement, exact only when Count Tokens measured it. */
2683
+ function plannedMeasurement(sizing, candidate) {
2684
+ const planned = sizing.plan(candidate);
2685
+ return { tokens: planned.tokens, source: sizing.isExact(candidate) ? "exact" : planned.source };
2686
+ }
2576
2687
  function regrantSmallerWindowRecovery(state, key, modelContextLimit) {
2577
2688
  const previous = state.toolRecoveryAttemptedLanes.get(key);
2578
2689
  if (previous && !previous.inFlight && modelContextLimit < previous.modelContextLimit) {
@@ -3031,9 +3142,10 @@ async function recoverToolRouteMiss(args) {
3031
3142
  * The attempt produced nothing: send what the turn would have sent without
3032
3143
  * it — the old ride (kept prefix) or the whole history — and back off.
3033
3144
  */
3034
- const forwardOriginal = () => {
3145
+ const forwardOriginal = (growthBackoff = true) => {
3035
3146
  requestSendPolicies.get(req).failedRebuild = true;
3036
- backOff();
3147
+ if (growthBackoff)
3148
+ backOff();
3037
3149
  if (fallback) {
3038
3150
  requestSendPolicies.get(req)?.allowed.add(fallback.raw);
3039
3151
  if (rec.compaction)
@@ -3146,17 +3258,11 @@ async function recoverToolRouteMiss(args) {
3146
3258
  };
3147
3259
  // No tree yet (nothing indexed): wait for it to be built rather than
3148
3260
  // retrying on growth, which on a fast-growing loop is every tool turn.
3149
- const pageId = result.memtreeUrl ? memtreePageId(result.memtreeUrl) : undefined;
3150
- if (!actuallyCompressed && !(cachedPromptTokenCount(result) ?? 0) && pageId) {
3151
- recoveryAttempt.awaitingIndex = {
3152
- pageId,
3153
- checking: false,
3154
- deadline: Date.now() + (opts.awaitedIndexWaitTimeoutMs ?? 60_000),
3155
- };
3156
- rec.routeRecovery.awaitingIndex = true;
3157
- }
3158
3261
  releaseOwnReservation();
3159
- return forwardOriginal();
3262
+ if (actuallyCompressed || !noteIndexLag(opts, rec, result))
3263
+ return forwardOriginal();
3264
+ awaitIndex(opts, recoveryAttempt, rec.routeRecovery, result);
3265
+ return forwardOriginal(false);
3160
3266
  }
3161
3267
  // Serializing the compressed body is the one synchronous step here that can
3162
3268
  // realistically throw: these are the multi-megabyte payloads that court
@@ -3194,7 +3300,14 @@ async function recoverToolRouteMiss(args) {
3194
3300
  if (!(await requestSendPolicies.get(req).fitsNative(compressedRaw))) {
3195
3301
  rec.routeRecovery = { conversationBytes, outcome: "unusable" };
3196
3302
  releaseOwnReservation();
3197
- return forwardOriginal();
3303
+ if (!noteIndexLag(opts, rec, result))
3304
+ return forwardOriginal();
3305
+ // The index trails the conversation, so this result kept most of it
3306
+ // verbatim. Growth cannot help; the next compress after indexing can.
3307
+ // Skip the growth backoff so retries compress again, and let a refusal
3308
+ // wait for the index rather than spend the strike budget.
3309
+ awaitIndex(opts, recoveryAttempt, rec.routeRecovery, result);
3310
+ return forwardOriginal(false);
3198
3311
  }
3199
3312
  if (compressedRaw.length >= (fallback?.raw ?? forwardBody).length) {
3200
3313
  // The final transformed body is the proof of payload recovery; a result
@@ -3228,6 +3341,10 @@ async function recoverToolRouteMiss(args) {
3228
3341
  activationAttempted = true;
3229
3342
  installedRoute = installed;
3230
3343
  installFate = installed ? "installed" : "stale";
3344
+ // Only an installed route makes this answer's memory the reused one.
3345
+ if (installed && linkSeq !== undefined) {
3346
+ noteMemorySource(state, linkSeq, requestSessionId(req), result);
3347
+ }
3231
3348
  // A tool continuation can arrive after message_stop while this HTTP
3232
3349
  // stream is still draining. Its route is already complete, so a smaller
3233
3350
  // window may buy recovery now. Mutate only this attempt's object: a late
@@ -3432,6 +3549,10 @@ async function recoverLateCount(args) {
3432
3549
  compaction: { target: compactionTarget(compactionMode(args.opts, args.state, requestSessionId(args.req)), budget) },
3433
3550
  });
3434
3551
  noteMemtreeHealth(args.state, outcome);
3552
+ // Like every compress path: a refusal after a lagging answer waits for
3553
+ // the index, and the cached answer expires so a retry asks again.
3554
+ if (outcome.result)
3555
+ noteIndexLag(args.opts, args.rec, outcome.result);
3435
3556
  if (!outcome.result || !didMemtreeCompress(outcome.result) ||
3436
3557
  !checkCompressedHistory(outcome.result, messages).usable)
3437
3558
  return undefined;
@@ -3490,12 +3611,13 @@ async function selectForwardCandidate(args) {
3490
3611
  }
3491
3612
  /** Write the bounded fallback refusal (route-fallback.ts); always false. */
3492
3613
  function refuseWholeRequest(args, inputTokens, budgetTokens) {
3493
- const failure = args.state.fallbackFailures.fail(args.routeKey);
3614
+ const failure = args.state.fallbackFailures.fail(args.routeKey, { awaitingIndex: args.rec.indexBehind === true });
3494
3615
  args.rec.forwardedBytes = 0;
3495
3616
  args.rec.approxInputTokens = 0;
3496
3617
  console.error("[ccc proxy] request exceeds native context window; recovery failed", {
3497
3618
  lane: args.routeKey, attempt: failure.attempt, status: failure.status,
3498
3619
  inputTokens, outputTokens: args.body.max_tokens ?? 0,
3620
+ outputReservation: bodyOutputReservation(args.body), indexBehind: args.rec.indexBehind === true,
3499
3621
  budgetTokens, modelContextLimit: args.modelContextLimit,
3500
3622
  });
3501
3623
  if (args.res.headersSent || args.res.destroyed)