claude-code-infinite 1.0.32 → 1.0.34

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 (99) hide show
  1. package/README.md +34 -11
  2. package/dist/cli-args.d.ts +7 -0
  3. package/dist/cli-args.d.ts.map +1 -1
  4. package/dist/cli-args.js +19 -0
  5. package/dist/cli-args.js.map +1 -1
  6. package/dist/cli-main.js +28 -10
  7. package/dist/cli-main.js.map +1 -1
  8. package/dist/count-tokens.d.ts +7 -3
  9. package/dist/count-tokens.d.ts.map +1 -1
  10. package/dist/count-tokens.js +6 -2
  11. package/dist/count-tokens.js.map +1 -1
  12. package/dist/hooks.d.ts +21 -1
  13. package/dist/hooks.d.ts.map +1 -1
  14. package/dist/hooks.js +36 -6
  15. package/dist/hooks.js.map +1 -1
  16. package/dist/host-session.d.ts +12 -0
  17. package/dist/host-session.d.ts.map +1 -0
  18. package/dist/host-session.js +12 -0
  19. package/dist/host-session.js.map +1 -0
  20. package/dist/image-refs.d.ts +24 -2
  21. package/dist/image-refs.d.ts.map +1 -1
  22. package/dist/image-refs.js +67 -20
  23. package/dist/image-refs.js.map +1 -1
  24. package/dist/index-wait-413.d.ts +26 -0
  25. package/dist/index-wait-413.d.ts.map +1 -0
  26. package/dist/index-wait-413.js +113 -0
  27. package/dist/index-wait-413.js.map +1 -0
  28. package/dist/memtree-finder.d.ts +70 -6
  29. package/dist/memtree-finder.d.ts.map +1 -1
  30. package/dist/memtree-finder.js +150 -20
  31. package/dist/memtree-finder.js.map +1 -1
  32. package/dist/memtree-handover.d.ts +13 -0
  33. package/dist/memtree-handover.d.ts.map +1 -0
  34. package/dist/memtree-handover.js +231 -0
  35. package/dist/memtree-handover.js.map +1 -0
  36. package/dist/memtree-mcp-config.d.ts +2 -2
  37. package/dist/memtree-mcp-config.d.ts.map +1 -1
  38. package/dist/memtree-mcp-config.js +8 -5
  39. package/dist/memtree-mcp-config.js.map +1 -1
  40. package/dist/memtree-mcp.d.ts +182 -2
  41. package/dist/memtree-mcp.d.ts.map +1 -1
  42. package/dist/memtree-mcp.js +357 -38
  43. package/dist/memtree-mcp.js.map +1 -1
  44. package/dist/memtree-newest.d.ts +21 -3
  45. package/dist/memtree-newest.d.ts.map +1 -1
  46. package/dist/memtree-newest.js +43 -11
  47. package/dist/memtree-newest.js.map +1 -1
  48. package/dist/memtree-times.d.ts +33 -0
  49. package/dist/memtree-times.d.ts.map +1 -1
  50. package/dist/memtree-times.js +77 -0
  51. package/dist/memtree-times.js.map +1 -1
  52. package/dist/memtree-tools.d.ts +19 -5
  53. package/dist/memtree-tools.d.ts.map +1 -1
  54. package/dist/memtree-tools.js +97 -23
  55. package/dist/memtree-tools.js.map +1 -1
  56. package/dist/memtree.d.ts +109 -3
  57. package/dist/memtree.d.ts.map +1 -1
  58. package/dist/memtree.js +382 -21
  59. package/dist/memtree.js.map +1 -1
  60. package/dist/network-failure.d.ts +28 -0
  61. package/dist/network-failure.d.ts.map +1 -0
  62. package/dist/network-failure.js +67 -0
  63. package/dist/network-failure.js.map +1 -0
  64. package/dist/open-url.d.ts +49 -0
  65. package/dist/open-url.d.ts.map +1 -0
  66. package/dist/open-url.js +100 -0
  67. package/dist/open-url.js.map +1 -0
  68. package/dist/prefix-store.d.ts +4 -0
  69. package/dist/prefix-store.d.ts.map +1 -1
  70. package/dist/prefix-store.js +8 -4
  71. package/dist/prefix-store.js.map +1 -1
  72. package/dist/project-trees.d.ts +15 -7
  73. package/dist/project-trees.d.ts.map +1 -1
  74. package/dist/project-trees.js +20 -9
  75. package/dist/project-trees.js.map +1 -1
  76. package/dist/proxy.d.ts +125 -1
  77. package/dist/proxy.d.ts.map +1 -1
  78. package/dist/proxy.js +838 -74
  79. package/dist/proxy.js.map +1 -1
  80. package/dist/reqlog.d.ts +34 -0
  81. package/dist/reqlog.d.ts.map +1 -1
  82. package/dist/reqlog.js.map +1 -1
  83. package/dist/resume-guidance.d.ts +9 -3
  84. package/dist/resume-guidance.d.ts.map +1 -1
  85. package/dist/resume-guidance.js +9 -3
  86. package/dist/resume-guidance.js.map +1 -1
  87. package/dist/route-fallback.d.ts +77 -11
  88. package/dist/route-fallback.d.ts.map +1 -1
  89. package/dist/route-fallback.js +157 -33
  90. package/dist/route-fallback.js.map +1 -1
  91. package/dist/route-identity.d.ts +6 -2
  92. package/dist/route-identity.d.ts.map +1 -1
  93. package/dist/route-identity.js +209 -16
  94. package/dist/route-identity.js.map +1 -1
  95. package/dist/subagent-hint.d.ts +28 -0
  96. package/dist/subagent-hint.d.ts.map +1 -1
  97. package/dist/subagent-hint.js +37 -0
  98. package/dist/subagent-hint.js.map +1 -1
  99. package/package.json +1 -1
package/dist/memtree.js CHANGED
@@ -23,6 +23,8 @@ import { usageDashboardUrl } from "./usage-link.js";
23
23
  import { extendsHistory, mediaFreeMessageHash } from "./route-identity.js";
24
24
  import { withShortPaymentUrl } from "./payment-gate.js";
25
25
  import { isKeyRejected, keyPageUrl } from "./key-page.js";
26
+ import { indexWaitFrom413 } from "./index-wait-413.js";
27
+ import { networkFailureOf } from "./network-failure.js";
26
28
  import { IMAGE_REFS_FEATURE, ImageRefs, MISSING_IMAGES_STATUS, missingImageShas, refusesImageRefs, } from "./image-refs.js";
27
29
  import { planMemtreeBody } from "./memtree-body.js";
28
30
  import { stripCcSystemReminders, } from "./turns.js";
@@ -48,8 +50,12 @@ const DEFAULT_COMPRESS_TIMEOUT_MS = 15000;
48
50
  * body (20+ MB) takes seconds to send on a slow link before the server even
49
51
  * starts, so a flat budget times out on size alone (2026-10-23: every tool
50
52
  * turn's compress of a 21.5 MB body timed out at 15 s with MemTree healthy).
53
+ * Measured from ccc's request logs, 2026-10-23..30: compress calls of 21-27 MB
54
+ * answered in 10-53 s (median ~19 s); at 1 ms/KiB a 26 MB call had ~41 s and lost
55
+ * to the 53 s ones, refusing a session for ten minutes (2026-10-30). 2.5 ms/KiB
56
+ * gives a 26 MB call ~79 s, 1.5x the slowest seen.
51
57
  */
52
- export const COMPRESS_TIMEOUT_MS_PER_KIB = 1;
58
+ export const COMPRESS_TIMEOUT_MS_PER_KIB = 2.5;
53
59
  /**
54
60
  * The budget for a compress with nothing else to send: no compressed prefix to
55
61
  * ride and a raw request known not to fit the window (the first turn after
@@ -60,6 +66,13 @@ export const COMPRESS_TIMEOUT_MS_PER_KIB = 1;
60
66
  const DEFAULT_LONG_COMPRESS_TIMEOUT_MS = 120_000;
61
67
  /** Indexing runs off the response path; give it room. */
62
68
  const INDEX_TIMEOUT_MS = 120_000;
69
+ /**
70
+ * New text (characters) a conversation's history must gain over its last index upload
71
+ * before the next one is sent: about the server's 10k-token index minimum
72
+ * (polychat MIN_TOKENS_TO_INDEX) at ~3 characters a token. Under it the server only
73
+ * acknowledges the upload.
74
+ */
75
+ export const INDEX_MIN_NEW_CHARS = 30_000;
63
76
  const DEDUPE_CACHE_MAX = 64;
64
77
  /**
65
78
  * A main-lane Stop followed by this long without a new request is taken as the
@@ -774,6 +787,17 @@ export class MemtreeClient {
774
787
  indexLanesNext = new Map();
775
788
  /** Per conversation, the history the exit's final index carries (flushFinalIndexes). */
776
789
  finalIndexLanes = new Map();
790
+ indexMinNewChars;
791
+ compressTimeoutMsPerKiB;
792
+ /** Per conversation, the last history an index call was started for (INDEX_MIN_NEW_CHARS). */
793
+ indexLanesSent = new Map();
794
+ /**
795
+ * Per conversation, the newest history held back by INDEX_MIN_NEW_CHARS: sent before a
796
+ * call that does not carry it whole (a rewind, evicted media) replaces it.
797
+ */
798
+ indexLanesHeld = new Map();
799
+ /** Per conversation, the upload in flight: the baseline until it succeeds or fails. */
800
+ indexLanesPending = new Map();
777
801
  /** Once draining begins, no later request may create another log producer. */
778
802
  backgroundClosing = false;
779
803
  /** drainBackground ran out and is aborting what is left (exit_index_cut, not an error). */
@@ -797,10 +821,14 @@ export class MemtreeClient {
797
821
  lastAnswerKeyInvalid = false;
798
822
  /** Whether the latest such call succeeded; undefined before any answer. */
799
823
  lastAnswerOk;
824
+ /** The latest call's connection failure, until a later call gets any answer. */
825
+ lastUnreachable;
800
826
  /** Complete compression request key → whether its latest failure arms fuse. */
801
827
  compressFailureArming = new Map();
802
828
  /** HTTP status of each key's latest failed compress (absent: no response). */
803
829
  compressFailureStatus = new Map();
830
+ /** Seconds of indexing left, per compress call that MemTree answered with an indexing 413. */
831
+ compressIndexWaits = new WeakMap();
804
832
  /**
805
833
  * Non-null when the server last answered 402 (unpaid MemTree key): the
806
834
  * server's human-readable detail text. Set by both compression and
@@ -849,6 +877,13 @@ export class MemtreeClient {
849
877
  get answering() {
850
878
  return this.lastAnswerOk;
851
879
  }
880
+ /**
881
+ * The connection failure (DNS or connect) of the latest compress or prepare call that got
882
+ * no answer, while no later call got one; undefined once MemTree answers any call.
883
+ */
884
+ get unreachable() {
885
+ return this.lastUnreachable;
886
+ }
852
887
  /** Where to get a new key for this client's environment (its base URL). */
853
888
  get keyPageUrl() {
854
889
  return keyPageUrl(this.baseUrl);
@@ -867,7 +902,7 @@ export class MemtreeClient {
867
902
  }
868
903
  /** The blocking-compress budget for an upload of `bytes` (COMPRESS_TIMEOUT_MS_PER_KIB). */
869
904
  compressBudgetForBytes(bytes) {
870
- return scaledTimeout(this.compressBudgetMs, bytes);
905
+ return scaledTimeout(this.compressBudgetMs, bytes, this.compressTimeoutMsPerKiB);
871
906
  }
872
907
  /**
873
908
  * When a compress call (not an index-only one) last got a live successful
@@ -926,6 +961,8 @@ export class MemtreeClient {
926
961
  this.longCompressTimeoutMs =
927
962
  opts.longCompressTimeoutMs ??
928
963
  Number(process.env.CCC_COMPRESS_TIMEOUT_MS || DEFAULT_LONG_COMPRESS_TIMEOUT_MS);
964
+ this.indexMinNewChars = opts.indexMinNewChars ?? INDEX_MIN_NEW_CHARS;
965
+ this.compressTimeoutMsPerKiB = opts.compressTimeoutMsPerKiB ?? COMPRESS_TIMEOUT_MS_PER_KIB;
929
966
  this.debug = opts.debug ?? false;
930
967
  this.reqlog = opts.reqlog;
931
968
  this.memtreeTools = opts.memtreeTools || undefined;
@@ -974,6 +1011,14 @@ export class MemtreeClient {
974
1011
  lastCompressFailureStatus(hash, modelContextLimit, meta) {
975
1012
  return this.compressFailureStatus.get(this.compressKey(hash, modelContextLimit, meta));
976
1013
  }
1014
+ /**
1015
+ * When the compress call `call` (as compress() returned it, joined or not) failed with
1016
+ * MemTree's 413 for an index still catching up (index-wait-413.ts): the seconds of
1017
+ * indexing it said were left. Undefined for any other outcome, or before it settles.
1018
+ */
1019
+ compressIndexWait(call) {
1020
+ return this.compressIndexWaits.get(call);
1021
+ }
977
1022
  /**
978
1023
  * Blocking user-turn compression. Returns null on ANY failure (timeout,
979
1024
  * network, 4xx/5xx including 402) — the caller must degrade to passthrough.
@@ -1034,6 +1079,9 @@ export class MemtreeClient {
1034
1079
  if (first !== undefined)
1035
1080
  this.compressFailureStatus.delete(first);
1036
1081
  }
1082
+ if (typeof err?.indexWaitSeconds === "number") {
1083
+ this.compressIndexWaits.set(promise, err.indexWaitSeconds);
1084
+ }
1037
1085
  // Don't cache failures — drop the entry so retries (e.g. Claude Code's
1038
1086
  // automatic retry of an identical request) hit the server again.
1039
1087
  if (this.compressCache.get(cacheKey) === promise) {
@@ -1087,19 +1135,36 @@ export class MemtreeClient {
1087
1135
  */
1088
1136
  indexInBackground(hash, messages, modelContextLimit, sessionId, clientMeta,
1089
1137
  /** Times for retained original messages, in the positions actually sent. */
1090
- messageTimesFor) {
1138
+ messageTimesFor,
1139
+ /**
1140
+ * `force`: send even under INDEX_MIN_NEW_CHARS of new text. `finalIndex` (implies it):
1141
+ * flagged final_index, so the server indexes the tail under its own minimum too; the
1142
+ * server allows one per conversation per 30 minutes, so only at a subagent's end.
1143
+ */
1144
+ options = {}) {
1091
1145
  if (this.backgroundClosing)
1092
1146
  return;
1093
- const indexKey = JSON.stringify([sessionId ?? null, hash]);
1147
+ // A final call is its own entry: the same history sent per turn may have left a tail.
1148
+ const final = options.finalIndex === true;
1149
+ const indexKey = JSON.stringify([sessionId ?? null, hash, ...(final ? ["final"] : [])]);
1094
1150
  if (this.indexedHashes.has(indexKey))
1095
1151
  return;
1152
+ const lane = indexLaneKey(sessionId, messages);
1153
+ const job = { messages, modelContextLimit,
1154
+ opts: { sessionId, clientMeta, messageTimesFor, ...(final ? { finalIndex: true } : {}) } };
1155
+ this.releaseHeldIndex(lane, job);
1156
+ // Held back is not sent: a later (or forced) call of the same history still goes.
1157
+ if (!options.force && !final && this.tooLittleNewText(lane, job)) {
1158
+ this.holdIndex(lane, job);
1159
+ return;
1160
+ }
1096
1161
  this.indexedHashes.add(indexKey);
1097
1162
  if (this.indexedHashes.size > DEDUPE_CACHE_MAX) {
1098
1163
  const first = this.indexedHashes.values().next().value;
1099
1164
  if (first !== undefined)
1100
1165
  this.indexedHashes.delete(first);
1101
1166
  }
1102
- this.enqueueIndex(indexLaneKey(sessionId, messages), { messages, modelContextLimit, opts: { sessionId, clientMeta, messageTimesFor } });
1167
+ this.enqueueIndex(lane, job);
1103
1168
  }
1104
1169
  /**
1105
1170
  * At most one background index call per conversation. Each call carries the whole
@@ -1107,6 +1172,9 @@ export class MemtreeClient {
1107
1172
  * session's tool rounds otherwise stacked 25 MB uploads, up to 16 at once, and starved
1108
1173
  * the compress calls that share the link (long-session test, staging 2026-10-24). A
1109
1174
  * waiting call is replaced only by one whose history extends it; otherwise it goes now.
1175
+ * A call whose history extends the last one sent by less than INDEX_MIN_NEW_CHARS of
1176
+ * text is not sent at all: the server only acknowledges it (2026-10-30: 3 in 4 of a
1177
+ * 23 MB session's index uploads, ~1 GB per 40 minutes).
1110
1178
  */
1111
1179
  enqueueIndex(lane, job) {
1112
1180
  if (!this.indexLanesBusy.has(lane)) {
@@ -1114,14 +1182,85 @@ export class MemtreeClient {
1114
1182
  return;
1115
1183
  }
1116
1184
  const waiting = this.indexLanesNext.get(lane);
1117
- if (waiting && !extendsHistory(job.messages, waiting.messages)) {
1118
- void this.submitIndexOnly(waiting.messages, waiting.modelContextLimit, waiting.opts);
1185
+ // Not replaced when the new one does not carry it whole: another branch, media Claude
1186
+ // Code evicted since (never uploaded otherwise), or a final call.
1187
+ if (waiting && (!extendsHistory(job.messages, waiting.messages) ||
1188
+ losesMedia(waiting.messages, job.messages) ||
1189
+ (waiting.opts.finalIndex && !job.opts.finalIndex))) {
1190
+ void this.sendLaneIndex(lane, waiting);
1119
1191
  }
1120
1192
  this.indexLanesNext.set(lane, job);
1121
1193
  }
1194
+ /**
1195
+ * Send `job` as its conversation's newest upload: the INDEX_MIN_NEW_CHARS baseline while
1196
+ * it is in flight, and once it succeeded. A failed one is never a baseline, so the next
1197
+ * turn is not held back by an upload the server never got.
1198
+ */
1199
+ sendLaneIndex(lane, job) {
1200
+ const sent = { messages: job.messages, chars: messagesTextChars(job.messages) };
1201
+ this.indexLanesPending.set(lane, sent);
1202
+ return this.submitIndexOnly(job.messages, job.modelContextLimit, job.opts).then((ok) => {
1203
+ if (this.indexLanesPending.get(lane) === sent)
1204
+ this.indexLanesPending.delete(lane);
1205
+ if (ok)
1206
+ this.noteIndexSent(lane, sent);
1207
+ return ok;
1208
+ });
1209
+ }
1210
+ /**
1211
+ * Before `job` goes or is held: the lane's held-back history is sent if `job` does not
1212
+ * carry it whole (origin/main sent every turn, so a rewind must not lose a branch), and
1213
+ * dropped otherwise (`job` carries it).
1214
+ */
1215
+ releaseHeldIndex(lane, job) {
1216
+ const held = this.indexLanesHeld.get(lane);
1217
+ if (!held)
1218
+ return;
1219
+ this.indexLanesHeld.delete(lane);
1220
+ if (!extendsHistory(job.messages, held.messages) || losesMedia(held.messages, job.messages)) {
1221
+ this.enqueueIndex(lane, held);
1222
+ }
1223
+ }
1224
+ holdIndex(lane, job) {
1225
+ this.indexLanesHeld.set(lane, job);
1226
+ if (this.indexLanesHeld.size > DEDUPE_CACHE_MAX) {
1227
+ // Over capacity the oldest held history is sent, not dropped (origin/main sent it).
1228
+ const [first, evicted] = this.indexLanesHeld.entries().next().value;
1229
+ this.indexLanesHeld.delete(first);
1230
+ this.enqueueIndex(first, evicted);
1231
+ }
1232
+ }
1233
+ /**
1234
+ * Whether `job` adds under INDEX_MIN_NEW_CHARS of text to the history its conversation
1235
+ * last sent (or has waiting to send): the server's index minimum would only ack it.
1236
+ */
1237
+ tooLittleNewText(lane, job) {
1238
+ // A waiting call is replaced by this one anyway: it is never held back.
1239
+ if (this.indexLanesNext.has(lane))
1240
+ return false;
1241
+ const sent = this.indexLanesPending.get(lane) ?? this.indexLanesSent.get(lane);
1242
+ if (this.indexMinNewChars <= 0 || !sent || !extendsHistory(job.messages, sent.messages)) {
1243
+ return false;
1244
+ }
1245
+ // An image or document carries no text but may be evicted by Claude Code before a
1246
+ // later upload: never held back.
1247
+ if (carriesMedia(job.messages.slice(sent.messages.length)))
1248
+ return false;
1249
+ return messagesTextChars(job.messages) - sent.chars < this.indexMinNewChars;
1250
+ }
1251
+ /** Record the history an index call of `lane` delivered. */
1252
+ noteIndexSent(lane, sent) {
1253
+ this.indexLanesSent.delete(lane);
1254
+ this.indexLanesSent.set(lane, sent);
1255
+ if (this.indexLanesSent.size > DEDUPE_CACHE_MAX) {
1256
+ const first = this.indexLanesSent.keys().next().value;
1257
+ if (first !== undefined)
1258
+ this.indexLanesSent.delete(first);
1259
+ }
1260
+ }
1122
1261
  startLaneIndex(lane, job) {
1123
1262
  this.indexLanesBusy.add(lane);
1124
- void this.submitIndexOnly(job.messages, job.modelContextLimit, job.opts).finally(() => {
1263
+ void this.sendLaneIndex(lane, job).finally(() => {
1125
1264
  this.indexLanesBusy.delete(lane);
1126
1265
  const next = this.indexLanesNext.get(lane);
1127
1266
  this.indexLanesNext.delete(lane);
@@ -1135,9 +1274,20 @@ export class MemtreeClient {
1135
1274
  * the final index carries (a main session's, unless it was rewound since).
1136
1275
  */
1137
1276
  sendWaitingIndexes() {
1277
+ // A history held back by the text minimum goes too (origin/main sent every turn),
1278
+ // unless a waiting call or the final index carries it.
1279
+ // A carrier must hold the media too: extendsHistory tolerates media Claude Code evicted,
1280
+ // which no later upload would carry (Codex r1 P0, release 1.0.33).
1281
+ const carries = (carrier, messages) => carrier !== undefined && extendsHistory(carrier, messages) && !losesMedia(messages, carrier);
1282
+ for (const [lane, held] of this.indexLanesHeld) {
1283
+ const carriers = [this.indexLanesNext.get(lane)?.messages, this.finalIndexLanes.get(lane)];
1284
+ if (!carriers.some((messages) => carries(messages, held.messages))) {
1285
+ void this.submitIndexOnly(held.messages, held.modelContextLimit, held.opts);
1286
+ }
1287
+ }
1288
+ this.indexLanesHeld.clear();
1138
1289
  for (const [lane, job] of this.indexLanesNext) {
1139
- const final = this.finalIndexLanes.get(lane);
1140
- if (!final || !extendsHistory(final, job.messages)) {
1290
+ if (!carries(this.finalIndexLanes.get(lane), job.messages)) {
1141
1291
  void this.submitIndexOnly(job.messages, job.modelContextLimit, job.opts);
1142
1292
  }
1143
1293
  }
@@ -1263,8 +1413,9 @@ export class MemtreeClient {
1263
1413
  sessionId: opts.sessionId,
1264
1414
  clientMeta: opts.clientMeta,
1265
1415
  })
1266
- .then(() => undefined, (err) => {
1416
+ .then(() => true, (err) => {
1267
1417
  this.log(`background indexing failed (ignored): ${err?.message ?? err}`);
1418
+ return false;
1268
1419
  })
1269
1420
  .finally(() => {
1270
1421
  this.backgroundIndexes.delete(operation);
@@ -1455,7 +1606,13 @@ export class MemtreeClient {
1455
1606
  ? { value: opts.serializedMessages, refs: opts.serializedImageRefs ?? [] } : undefined,
1456
1607
  imageRefs: refs ? this.imageRefs : undefined,
1457
1608
  });
1458
- const planned = plan(opts.imageRefs !== false);
1609
+ let planned = plan(opts.imageRefs !== false);
1610
+ // A compress over the limit is not sent at all: before that, try it with images
1611
+ // the server has not listed as references too (a resumed session's first call).
1612
+ const unconfirmed = planned.payload === undefined && !opts.indexOnly &&
1613
+ opts.imageRefs !== false ? this.planUnconfirmed(body, messages) : undefined;
1614
+ if (unconfirmed)
1615
+ planned = unconfirmed;
1459
1616
  if (planned.payload === undefined)
1460
1617
  throw this.oversized(planned.limit, opts);
1461
1618
  const payload = planned.payload;
@@ -1473,15 +1630,24 @@ export class MemtreeClient {
1473
1630
  // A default compress budget grows with what it uploads; an index call's,
1474
1631
  // and any budget a caller set, is fixed.
1475
1632
  const timeoutMs = opts.scaleTimeout && !opts.indexOnly
1476
- ? scaledTimeout(opts.timeoutMs, Buffer.byteLength(payload, "utf-8")) : opts.timeoutMs;
1633
+ ? scaledTimeout(opts.timeoutMs, Buffer.byteLength(payload, "utf-8"), this.compressTimeoutMsPerKiB)
1634
+ : opts.timeoutMs;
1477
1635
  const timeout = setTimeout(abort, timeoutMs);
1478
1636
  timeout.unref();
1479
1637
  opts.signal?.addEventListener("abort", abort, { once: true });
1480
1638
  if (opts.signal?.aborted)
1481
1639
  abort();
1482
1640
  try {
1483
- const response = await this.postContextMemory(payload, opts, controller.signal);
1641
+ const response = await this.postRetryingDrop(payload, opts, controller.signal)
1642
+ .catch((err) => {
1643
+ // A background index call is no request's own evidence (review r1): it may clear
1644
+ // the state by an answer, never set it.
1645
+ if (!opts.indexOnly)
1646
+ this.noteUnreachable(err);
1647
+ throw err;
1648
+ });
1484
1649
  status = response.status;
1650
+ this.lastUnreachable = undefined;
1485
1651
  if (planned.refs.length && !response.ok) {
1486
1652
  refusalText = await readUnlessAborted(response, controller.signal);
1487
1653
  if (this.refusedRefs(response.status, refusalText, planned.refs)) {
@@ -1491,8 +1657,12 @@ export class MemtreeClient {
1491
1657
  // budget adds its allowance to what is left, however negative.
1492
1658
  resent = true;
1493
1659
  const budget = opts.timeoutMs;
1660
+ // Unconfirmed references the server named as missing: only those go as bytes
1661
+ // (all bytes would be over the limit again), once.
1662
+ const keepRefs = unconfirmed !== undefined && !opts.unconfirmedRetry &&
1663
+ namesMissingImages(response.status, refusalText);
1494
1664
  return await this.callContextMemory(messages, modelContextLimit, {
1495
- ...opts, imageRefs: false,
1665
+ ...opts, ...(keepRefs ? { unconfirmedRetry: true } : { imageRefs: false }),
1496
1666
  get timeoutMs() { return budget - (Date.now() - started); },
1497
1667
  });
1498
1668
  }
@@ -1507,9 +1677,15 @@ export class MemtreeClient {
1507
1677
  keyRefused = isKeyRejected(response.status, text);
1508
1678
  // The status rides on the error so compress() can classify the
1509
1679
  // failure for the fuse without parsing message text.
1510
- throw Object.assign(new Error(`context_memory ${response.status}: ${text.slice(0, 300)}`), { status: response.status });
1680
+ throw Object.assign(new Error(`context_memory ${response.status}: ${text.slice(0, 300)}`), { status: response.status,
1681
+ indexWaitSeconds: indexWaitFrom413(response.status, text, response.headers.get("retry-after"), Date.now(), response.headers.get("x-polychat-index-pending")) });
1511
1682
  }
1512
1683
  const json = (await response.json());
1684
+ if (unconfirmed && !ImageRefs.resolvedBy(json)) {
1685
+ // No proof the server put the images back: its result may hold references.
1686
+ this.imageRefs.disable();
1687
+ throw new Error("context_memory answer does not show it resolves image references");
1688
+ }
1513
1689
  this.imageRefs.noteAnswer(json);
1514
1690
  // An index-only ack is `{ messages: [], usage: {...zeros}, index_only:
1515
1691
  // true }` by contract: the server skips compression and returns no
@@ -1578,6 +1754,7 @@ export class MemtreeClient {
1578
1754
  status,
1579
1755
  requestBytes: Buffer.byteLength(payload),
1580
1756
  ...(planned.limit ? { bodyLimit: planned.limit } : {}),
1757
+ ...(unconfirmed ? { unconfirmedImageRefs: unconfirmed.refs.length } : {}),
1581
1758
  // Aborted by an exit drain that ran out: a known cut, not a server failure.
1582
1759
  ...(!ok && this.drainCutting && controller.signal.aborted
1583
1760
  ? { exitCut: true, finalIndex: opts.finalIndex === true }
@@ -1590,13 +1767,56 @@ export class MemtreeClient {
1590
1767
  });
1591
1768
  }
1592
1769
  }
1593
- /** One POST of a MemTree call's body. */
1594
- postContextMemory(payload, opts, signal) {
1770
+ /** Remember a call's connection failure (memtree.unreachable); other errors are no news. */
1771
+ noteUnreachable(err) {
1772
+ const failure = networkFailureOf(err, URL.canParse(this.baseUrl)
1773
+ ? new URL(this.baseUrl).host : this.baseUrl);
1774
+ if (failure)
1775
+ this.lastUnreachable = failure;
1776
+ }
1777
+ /**
1778
+ * A compress or prepare call's POST, sent once more (within the same budget) when its
1779
+ * connection dropped while the body was still uploading: a Wi-Fi switch cut a 24 MB
1780
+ * upload with EHOSTUNREACH after 89 s, ~1 MB sent (2026-10-28), and the turn went without
1781
+ * memory. Only before the whole body was handed over: after that MemTree may already be
1782
+ * compressing (and billing) it, and a second call would be a second compression.
1783
+ * Index-only calls are not retried: the next turn sends their history again.
1784
+ */
1785
+ async postRetryingDrop(payload, opts, signal) {
1786
+ const upload = { done: false };
1787
+ try {
1788
+ return await this.postContextMemory(payload, opts, signal, opts.indexOnly ? undefined : upload);
1789
+ }
1790
+ catch (err) {
1791
+ if (opts.indexOnly || signal.aborted)
1792
+ throw err;
1793
+ // A 401 to a streamed body: fetch reports a network error, not the status. A 401
1794
+ // compresses nothing (nothing billed): sent again whole, its status is seen.
1795
+ if (isStreamedBodyAuthChallenge(err)) {
1796
+ return await this.postContextMemory(payload, opts, signal);
1797
+ }
1798
+ if (upload.done || !isDroppedConnection(err))
1799
+ throw err;
1800
+ this.log(`context_memory upload dropped (${droppedCode(err)}); retrying once`);
1801
+ return await this.postContextMemory(payload, opts, signal);
1802
+ }
1803
+ }
1804
+ /**
1805
+ * One POST of a MemTree call's body. With `upload`, a body of STREAMED_UPLOAD_MIN_BYTES
1806
+ * or more goes as a stream and `upload.done` turns true once its last byte was handed
1807
+ * over (a smaller one counts as handed over at once).
1808
+ */
1809
+ postContextMemory(payload, opts, signal, upload) {
1810
+ const bytes = Buffer.from(payload, "utf-8");
1811
+ const streamed = upload !== undefined && bytes.length >= STREAMED_UPLOAD_MIN_BYTES;
1812
+ if (upload && !streamed)
1813
+ upload.done = true;
1595
1814
  return fetch(`${this.baseUrl}/v1/context_memory`, {
1596
1815
  method: "POST",
1597
1816
  headers: {
1598
1817
  authorization: `Bearer ${this.apiKey}`,
1599
1818
  "content-type": "application/json",
1819
+ ...(streamed ? { "content-length": String(bytes.length) } : {}),
1600
1820
  "x-client": CLIENT_NAME,
1601
1821
  "x-client-version": CLIENT_VERSION,
1602
1822
  "x-client-features": CLIENT_FEATURES,
@@ -1609,7 +1829,8 @@ export class MemtreeClient {
1609
1829
  ? { "x-memtree-tools": this.memtreeTools }
1610
1830
  : {}),
1611
1831
  },
1612
- body: payload,
1832
+ body: streamed ? uploadStream(bytes, upload) : payload,
1833
+ ...(streamed ? { duplex: "half" } : {}),
1613
1834
  signal,
1614
1835
  });
1615
1836
  }
@@ -1623,6 +1844,7 @@ export class MemtreeClient {
1623
1844
  const refused = refusesImageRefs(status, text);
1624
1845
  if (missing !== undefined) {
1625
1846
  this.imageRefs.forget(missing.length ? missing : refs);
1847
+ this.imageRefs.noteMissing(missing);
1626
1848
  }
1627
1849
  else if (refused === "never") {
1628
1850
  this.imageRefs.disable();
@@ -1636,6 +1858,19 @@ export class MemtreeClient {
1636
1858
  this.log(`context_memory ${status} on image references: sending their bytes`);
1637
1859
  return true;
1638
1860
  }
1861
+ /**
1862
+ * `body` with every image the server has not said it lacks as a reference, when
1863
+ * that fits the limit (image-refs.ts: referenceUnconfirmed); undefined otherwise.
1864
+ */
1865
+ planUnconfirmed(body, messages) {
1866
+ const referenced = this.imageRefs.referenceUnconfirmed(messages);
1867
+ const planned = referenced.refs.length
1868
+ ? planMemtreeBody({ body, messages: referenced.value, indexOnly: false }) : undefined;
1869
+ if (planned?.payload !== undefined)
1870
+ return { ...planned, refs: referenced.refs };
1871
+ this.imageRefs.retryMissing();
1872
+ return undefined;
1873
+ }
1639
1874
  /**
1640
1875
  * A body over the limit that nothing could shrink (memtree-body.ts): never sent.
1641
1876
  * Logged like any call, with `bodyLimit` (memtree_body_limit, not memtree_error); the
@@ -1654,6 +1889,10 @@ export class MemtreeClient {
1654
1889
  console.error(`[ccc proxy] ${msg}`);
1655
1890
  }
1656
1891
  }
1892
+ /** A 409 that names the references it could not serve (not one refusing them all). */
1893
+ function namesMissingImages(status, text) {
1894
+ return status === MISSING_IMAGES_STATUS && (missingImageShas(text)?.length ?? 0) > 0;
1895
+ }
1657
1896
  /** A failed answer's body; an abort while reading it is thrown, never read as "". */
1658
1897
  async function readUnlessAborted(response, signal) {
1659
1898
  const text = await response.text().catch(() => "");
@@ -1685,11 +1924,133 @@ export function withFinalReply(messages, reply) {
1685
1924
  function callerCut(status) {
1686
1925
  return status === undefined || (status >= 200 && status < 300);
1687
1926
  }
1927
+ /** Connection-level failures a compress call is retried once after (postRetryingDrop). */
1928
+ const DROPPED_CONNECTION_CODES = new Set([
1929
+ "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "ENETDOWN", "EPIPE", "ECONNABORTED",
1930
+ "ETIMEDOUT", "EAI_AGAIN", "UND_ERR_SOCKET", "ECONNREFUSED",
1931
+ ]);
1932
+ /** Bodies from this size are streamed, so a drop can be told to come before their end. */
1933
+ const STREAMED_UPLOAD_MIN_BYTES = 1024 * 1024;
1934
+ const UPLOAD_CHUNK_BYTES = 256 * 1024;
1935
+ /** undici's message for a 401/407 answer to a body it cannot send again. */
1936
+ const STREAMED_BODY_AUTH_ERROR = /non-null body source/;
1937
+ /** `bytes` as a stream that sets `upload.done` once its last chunk was taken. */
1938
+ function uploadStream(bytes, upload) {
1939
+ let offset = 0;
1940
+ return new ReadableStream({
1941
+ pull(controller) {
1942
+ if (offset >= bytes.length) {
1943
+ upload.done = true;
1944
+ controller.close();
1945
+ return;
1946
+ }
1947
+ const end = Math.min(bytes.length, offset + UPLOAD_CHUNK_BYTES);
1948
+ controller.enqueue(bytes.subarray(offset, end));
1949
+ offset = end;
1950
+ if (offset >= bytes.length)
1951
+ upload.done = true;
1952
+ },
1953
+ });
1954
+ }
1955
+ /** Whether a failed fetch lost its connection (the error's or its cause's code). */
1956
+ export function isDroppedConnection(err) {
1957
+ const code = droppedCode(err);
1958
+ return code !== undefined && DROPPED_CONNECTION_CODES.has(code);
1959
+ }
1960
+ /**
1961
+ * Whether fetch failed a streamed body on a 401 (or 407) answer: the fetch standard turns
1962
+ * those into a network error ("expected non-null body source") when the body cannot be
1963
+ * replayed, so a revoked key would otherwise read as a lost connection.
1964
+ */
1965
+ function isStreamedBodyAuthChallenge(err) {
1966
+ const cause = err?.cause;
1967
+ return cause?.code === undefined && typeof cause?.message === "string" &&
1968
+ STREAMED_BODY_AUTH_ERROR.test(cause.message);
1969
+ }
1970
+ function droppedCode(err) {
1971
+ const own = err?.code;
1972
+ if (typeof own === "string")
1973
+ return own;
1974
+ const cause = err?.cause?.code;
1975
+ return typeof cause === "string" ? cause : undefined;
1976
+ }
1977
+ /**
1978
+ * The text a history carries, in characters: strings and text blocks, tool inputs and
1979
+ * results; images and documents (base64) count nothing. For INDEX_MIN_NEW_CHARS only.
1980
+ */
1981
+ export function messagesTextChars(messages) {
1982
+ let chars = 0;
1983
+ for (const message of messages)
1984
+ chars += contentTextChars(message.content);
1985
+ return chars;
1986
+ }
1987
+ /** Whether `messages` hold an image or document block (at any depth). */
1988
+ function carriesMedia(value) {
1989
+ return mediaKeys(value).size > 0;
1990
+ }
1991
+ /**
1992
+ * Whether `older` holds an image or document `newer` does not (Claude Code evicted it):
1993
+ * replacing `older` by `newer` would leave it never uploaded.
1994
+ */
1995
+ export function losesMedia(older, newer) {
1996
+ const kept = mediaKeys(older);
1997
+ if (kept.size === 0)
1998
+ return false;
1999
+ const now = mediaKeys(newer);
2000
+ for (const key of kept)
2001
+ if (!now.has(key))
2002
+ return true;
2003
+ return false;
2004
+ }
2005
+ /** A digest of each image and document block's source in `value`, at any depth. */
2006
+ function mediaKeys(value, keys = new Set()) {
2007
+ if (Array.isArray(value)) {
2008
+ for (const item of value)
2009
+ mediaKeys(item, keys);
2010
+ return keys;
2011
+ }
2012
+ if (!value || typeof value !== "object")
2013
+ return keys;
2014
+ const block = value;
2015
+ if (block.type === "image" || block.type === "document") {
2016
+ keys.add(createHash("sha256").update(JSON.stringify(block.source ?? block)).digest("hex"));
2017
+ }
2018
+ else if (Array.isArray(block.content)) {
2019
+ mediaKeys(block.content, keys);
2020
+ }
2021
+ return keys;
2022
+ }
2023
+ function contentTextChars(value) {
2024
+ if (typeof value === "string")
2025
+ return value.length;
2026
+ if (Array.isArray(value)) {
2027
+ let chars = 0;
2028
+ for (const item of value)
2029
+ chars += contentTextChars(item);
2030
+ return chars;
2031
+ }
2032
+ if (!value || typeof value !== "object")
2033
+ return 0;
2034
+ const block = value;
2035
+ if (block.type === "image" || block.type === "document")
2036
+ return 0;
2037
+ if (typeof block.text === "string")
2038
+ return block.text.length;
2039
+ if (typeof block.thinking === "string")
2040
+ return block.thinking.length;
2041
+ if (block.content !== undefined)
2042
+ return contentTextChars(block.content);
2043
+ if (block.input !== undefined)
2044
+ return JSON.stringify(block.input)?.length ?? 0;
2045
+ return 0;
2046
+ }
1688
2047
  /** `baseMs` plus COMPRESS_TIMEOUT_MS_PER_KIB per KiB of `bytes`. */
1689
- function scaledTimeout(baseMs, bytes) {
2048
+ function scaledTimeout(baseMs, bytes, perKiB) {
1690
2049
  if (!Number.isFinite(baseMs))
1691
2050
  return baseMs;
1692
- return baseMs + Math.ceil(Math.max(0, bytes) / 1024) * COMPRESS_TIMEOUT_MS_PER_KIB;
2051
+ // Whole milliseconds: timers truncate a fractional delay, and a call that ran its whole
2052
+ // budget must not measure as shorter than it (the proxy's timedOut check).
2053
+ return baseMs + Math.ceil(Math.ceil(Math.max(0, bytes) / 1024) * perKiB);
1693
2054
  }
1694
2055
  /**
1695
2056
  * One conversation for background indexing: its session and its first message after the