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.
- package/README.md +34 -11
- package/dist/cli-args.d.ts +7 -0
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +19 -0
- package/dist/cli-args.js.map +1 -1
- package/dist/cli-main.js +28 -10
- package/dist/cli-main.js.map +1 -1
- package/dist/count-tokens.d.ts +7 -3
- package/dist/count-tokens.d.ts.map +1 -1
- package/dist/count-tokens.js +6 -2
- package/dist/count-tokens.js.map +1 -1
- package/dist/hooks.d.ts +21 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +36 -6
- package/dist/hooks.js.map +1 -1
- package/dist/host-session.d.ts +12 -0
- package/dist/host-session.d.ts.map +1 -0
- package/dist/host-session.js +12 -0
- package/dist/host-session.js.map +1 -0
- package/dist/image-refs.d.ts +24 -2
- package/dist/image-refs.d.ts.map +1 -1
- package/dist/image-refs.js +67 -20
- package/dist/image-refs.js.map +1 -1
- package/dist/index-wait-413.d.ts +26 -0
- package/dist/index-wait-413.d.ts.map +1 -0
- package/dist/index-wait-413.js +113 -0
- package/dist/index-wait-413.js.map +1 -0
- package/dist/memtree-finder.d.ts +70 -6
- package/dist/memtree-finder.d.ts.map +1 -1
- package/dist/memtree-finder.js +150 -20
- package/dist/memtree-finder.js.map +1 -1
- package/dist/memtree-handover.d.ts +13 -0
- package/dist/memtree-handover.d.ts.map +1 -0
- package/dist/memtree-handover.js +231 -0
- package/dist/memtree-handover.js.map +1 -0
- package/dist/memtree-mcp-config.d.ts +2 -2
- package/dist/memtree-mcp-config.d.ts.map +1 -1
- package/dist/memtree-mcp-config.js +8 -5
- package/dist/memtree-mcp-config.js.map +1 -1
- package/dist/memtree-mcp.d.ts +182 -2
- package/dist/memtree-mcp.d.ts.map +1 -1
- package/dist/memtree-mcp.js +357 -38
- package/dist/memtree-mcp.js.map +1 -1
- package/dist/memtree-newest.d.ts +21 -3
- package/dist/memtree-newest.d.ts.map +1 -1
- package/dist/memtree-newest.js +43 -11
- package/dist/memtree-newest.js.map +1 -1
- package/dist/memtree-times.d.ts +33 -0
- package/dist/memtree-times.d.ts.map +1 -1
- package/dist/memtree-times.js +77 -0
- package/dist/memtree-times.js.map +1 -1
- package/dist/memtree-tools.d.ts +19 -5
- package/dist/memtree-tools.d.ts.map +1 -1
- package/dist/memtree-tools.js +97 -23
- package/dist/memtree-tools.js.map +1 -1
- package/dist/memtree.d.ts +109 -3
- package/dist/memtree.d.ts.map +1 -1
- package/dist/memtree.js +382 -21
- package/dist/memtree.js.map +1 -1
- package/dist/network-failure.d.ts +28 -0
- package/dist/network-failure.d.ts.map +1 -0
- package/dist/network-failure.js +67 -0
- package/dist/network-failure.js.map +1 -0
- package/dist/open-url.d.ts +49 -0
- package/dist/open-url.d.ts.map +1 -0
- package/dist/open-url.js +100 -0
- package/dist/open-url.js.map +1 -0
- package/dist/prefix-store.d.ts +4 -0
- package/dist/prefix-store.d.ts.map +1 -1
- package/dist/prefix-store.js +8 -4
- package/dist/prefix-store.js.map +1 -1
- package/dist/project-trees.d.ts +15 -7
- package/dist/project-trees.d.ts.map +1 -1
- package/dist/project-trees.js +20 -9
- package/dist/project-trees.js.map +1 -1
- package/dist/proxy.d.ts +125 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +838 -74
- package/dist/proxy.js.map +1 -1
- package/dist/reqlog.d.ts +34 -0
- package/dist/reqlog.d.ts.map +1 -1
- package/dist/reqlog.js.map +1 -1
- package/dist/resume-guidance.d.ts +9 -3
- package/dist/resume-guidance.d.ts.map +1 -1
- package/dist/resume-guidance.js +9 -3
- package/dist/resume-guidance.js.map +1 -1
- package/dist/route-fallback.d.ts +77 -11
- package/dist/route-fallback.d.ts.map +1 -1
- package/dist/route-fallback.js +157 -33
- package/dist/route-fallback.js.map +1 -1
- package/dist/route-identity.d.ts +6 -2
- package/dist/route-identity.d.ts.map +1 -1
- package/dist/route-identity.js +209 -16
- package/dist/route-identity.js.map +1 -1
- package/dist/subagent-hint.d.ts +28 -0
- package/dist/subagent-hint.d.ts.map +1 -1
- package/dist/subagent-hint.js +37 -0
- package/dist/subagent-hint.js.map +1 -1
- 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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1118
|
-
|
|
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.
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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")
|
|
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.
|
|
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
|
-
/**
|
|
1594
|
-
|
|
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
|
-
|
|
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
|