claude-code-infinite 1.0.11 → 1.0.14
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 +214 -47
- package/dist/cc-request.d.ts +94 -0
- package/dist/cc-request.d.ts.map +1 -0
- package/dist/cc-request.js +194 -0
- package/dist/cc-request.js.map +1 -0
- package/dist/claude-env.d.ts +30 -0
- package/dist/claude-env.d.ts.map +1 -0
- package/dist/claude-env.js +55 -0
- package/dist/claude-env.js.map +1 -0
- package/dist/cli-args.d.ts +32 -0
- package/dist/cli-args.d.ts.map +1 -0
- package/dist/cli-args.js +72 -0
- package/dist/cli-args.js.map +1 -0
- package/dist/cli-lifecycle.d.ts +17 -0
- package/dist/cli-lifecycle.d.ts.map +1 -0
- package/dist/cli-lifecycle.js +30 -0
- package/dist/cli-lifecycle.js.map +1 -0
- package/dist/cli-main.d.ts +15 -0
- package/dist/cli-main.d.ts.map +1 -0
- package/dist/cli-main.js +464 -0
- package/dist/cli-main.js.map +1 -0
- package/dist/cli.js +13 -178
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +29 -3
- package/dist/config.js.map +1 -1
- package/dist/count-tokens.d.ts +78 -0
- package/dist/count-tokens.d.ts.map +1 -0
- package/dist/count-tokens.js +277 -0
- package/dist/count-tokens.js.map +1 -0
- package/dist/final-index.d.ts +10 -0
- package/dist/final-index.d.ts.map +1 -0
- package/dist/final-index.js +37 -0
- package/dist/final-index.js.map +1 -0
- package/dist/hooks.d.ts +239 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +618 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/memtree-fetch.d.ts +24 -0
- package/dist/memtree-fetch.d.ts.map +1 -0
- package/dist/memtree-fetch.js +121 -0
- package/dist/memtree-fetch.js.map +1 -0
- package/dist/memtree-finder.d.ts +99 -0
- package/dist/memtree-finder.d.ts.map +1 -0
- package/dist/memtree-finder.js +235 -0
- package/dist/memtree-finder.js.map +1 -0
- package/dist/memtree-links.d.ts +16 -0
- package/dist/memtree-links.d.ts.map +1 -0
- package/dist/memtree-links.js +66 -0
- package/dist/memtree-links.js.map +1 -0
- package/dist/memtree-mcp-config.d.ts +28 -0
- package/dist/memtree-mcp-config.d.ts.map +1 -0
- package/dist/memtree-mcp-config.js +121 -0
- package/dist/memtree-mcp-config.js.map +1 -0
- package/dist/memtree-mcp.d.ts +265 -0
- package/dist/memtree-mcp.d.ts.map +1 -0
- package/dist/memtree-mcp.js +667 -0
- package/dist/memtree-mcp.js.map +1 -0
- package/dist/memtree-newest.d.ts +74 -0
- package/dist/memtree-newest.d.ts.map +1 -0
- package/dist/memtree-newest.js +217 -0
- package/dist/memtree-newest.js.map +1 -0
- package/dist/memtree-tools.d.ts +105 -0
- package/dist/memtree-tools.d.ts.map +1 -0
- package/dist/memtree-tools.js +408 -0
- package/dist/memtree-tools.js.map +1 -0
- package/dist/memtree.d.ts +398 -0
- package/dist/memtree.d.ts.map +1 -0
- package/dist/memtree.js +1231 -0
- package/dist/memtree.js.map +1 -0
- package/dist/notices.d.ts +177 -0
- package/dist/notices.d.ts.map +1 -0
- package/dist/notices.js +469 -0
- package/dist/notices.js.map +1 -0
- package/dist/payment-gate.d.ts +53 -0
- package/dist/payment-gate.d.ts.map +1 -0
- package/dist/payment-gate.js +97 -0
- package/dist/payment-gate.js.map +1 -0
- package/dist/project-meta.d.ts +15 -0
- package/dist/project-meta.d.ts.map +1 -0
- package/dist/project-meta.js +80 -0
- package/dist/project-meta.js.map +1 -0
- package/dist/prompt-accounting.d.ts +12 -0
- package/dist/prompt-accounting.d.ts.map +1 -0
- package/dist/prompt-accounting.js +70 -0
- package/dist/prompt-accounting.js.map +1 -0
- package/dist/proxy.d.ts +197 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +4247 -0
- package/dist/proxy.js.map +1 -0
- package/dist/reqlog.d.ts +419 -0
- package/dist/reqlog.d.ts.map +1 -0
- package/dist/reqlog.js +146 -0
- package/dist/reqlog.js.map +1 -0
- package/dist/request-sizing.d.ts +52 -0
- package/dist/request-sizing.d.ts.map +1 -0
- package/dist/request-sizing.js +92 -0
- package/dist/request-sizing.js.map +1 -0
- package/dist/resume-repair-file.d.ts +17 -0
- package/dist/resume-repair-file.d.ts.map +1 -0
- package/dist/resume-repair-file.js +100 -0
- package/dist/resume-repair-file.js.map +1 -0
- package/dist/resume-repair.d.ts +27 -0
- package/dist/resume-repair.d.ts.map +1 -0
- package/dist/resume-repair.js +198 -0
- package/dist/resume-repair.js.map +1 -0
- package/dist/route-cache.d.ts +5 -0
- package/dist/route-cache.d.ts.map +1 -0
- package/dist/route-cache.js +63 -0
- package/dist/route-cache.js.map +1 -0
- package/dist/route-fallback.d.ts +29 -0
- package/dist/route-fallback.d.ts.map +1 -0
- package/dist/route-fallback.js +49 -0
- package/dist/route-fallback.js.map +1 -0
- package/dist/route-identity.d.ts +6 -0
- package/dist/route-identity.d.ts.map +1 -0
- package/dist/route-identity.js +100 -0
- package/dist/route-identity.js.map +1 -0
- package/dist/transcript-usage.d.ts +57 -0
- package/dist/transcript-usage.d.ts.map +1 -0
- package/dist/transcript-usage.js +570 -0
- package/dist/transcript-usage.js.map +1 -0
- package/dist/turns.d.ts +73 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +298 -0
- package/dist/turns.js.map +1 -0
- package/dist/update-check.d.ts +38 -0
- package/dist/update-check.d.ts.map +1 -0
- package/dist/update-check.js +67 -0
- package/dist/update-check.js.map +1 -0
- package/package.json +6 -2
package/dist/memtree.js
ADDED
|
@@ -0,0 +1,1231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the polychat.co MemTree API (/v1/context_memory).
|
|
3
|
+
*
|
|
4
|
+
* Two modes, mirroring the server's lazy-compression path (cc_api.py):
|
|
5
|
+
* - Tool turns: fire-and-forget indexing POST, off the response path.
|
|
6
|
+
* - User turns: blocking compression call with a hard timeout; the caller
|
|
7
|
+
* degrades to transparent pass-through on any failure (including 402 —
|
|
8
|
+
* compression is the paid feature, the user's own Anthropic call is never
|
|
9
|
+
* gated on it). A 402 from either mode additionally records
|
|
10
|
+
* payment-required state (paymentRequiredDetail) so the proxy can tell the
|
|
11
|
+
* user WHY MemTree is off instead of implying a transient outage; any later
|
|
12
|
+
* successful call clears it (the user paid mid-session).
|
|
13
|
+
*
|
|
14
|
+
* Compression work is deduped per complete request key, while background
|
|
15
|
+
* indexing is deduped per messages hash. Claude Code retries therefore cannot
|
|
16
|
+
* amplify identical HTTP calls, and budget-changing inputs never reuse a
|
|
17
|
+
* stale compression result.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { prepareFinalMessages, readFinalReply } from "./final-index.js";
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
import { stripCcSystemReminders, } from "./turns.js";
|
|
23
|
+
export const CLIENT_NAME = "cc-infinite";
|
|
24
|
+
// Sent on every MemTree call so the server can detect/warn stale clients
|
|
25
|
+
// (plan: "API versioning").
|
|
26
|
+
export const CLIENT_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
27
|
+
// Hard budget for the blocking user-turn compression call. The abort clock covers
|
|
28
|
+
// the ENTIRE fetch — uploading the full conversation (multi-MB on long sessions) plus
|
|
29
|
+
// server-side compression — so this is a circuit breaker for a hung server, not a
|
|
30
|
+
// latency target. Override via CCC_COMPRESS_TIMEOUT_MS (read below).
|
|
31
|
+
const DEFAULT_COMPRESS_TIMEOUT_MS = 15000;
|
|
32
|
+
/** Indexing runs off the response path; give it room. */
|
|
33
|
+
const INDEX_TIMEOUT_MS = 120_000;
|
|
34
|
+
const DEDUPE_CACHE_MAX = 64;
|
|
35
|
+
/**
|
|
36
|
+
* A main-lane Stop followed by this long without a new request is taken as the
|
|
37
|
+
* session's end, and its final index goes out (exit sends it at once). Shorter
|
|
38
|
+
* pauses spend the server's once-per-30-minutes final index mid-session.
|
|
39
|
+
*/
|
|
40
|
+
export const FINAL_INDEX_IDLE_MS = 10 * 60_000;
|
|
41
|
+
/** Sessions finalized at exit at most (newest first), inside the 2 s drain. */
|
|
42
|
+
export const FINAL_INDEX_EXIT_SESSIONS = 3;
|
|
43
|
+
/**
|
|
44
|
+
* The server-flattened single user message of a compressed result, or null
|
|
45
|
+
* when the server did not provide a valid one (a pre-flatten server, or a
|
|
46
|
+
* malformed field). The flatten format lives server-side ONLY — the client
|
|
47
|
+
* must never re-derive or post-process it — so a null here means the result
|
|
48
|
+
* cannot be forwarded in compressed form and the caller degrades to the
|
|
49
|
+
* original history. Returns a fresh single-message array so callers may
|
|
50
|
+
* embed it in a request body without sharing structure with the result.
|
|
51
|
+
*/
|
|
52
|
+
export function serverFlattenedMessages(result) {
|
|
53
|
+
const flattened = result.flattened_messages;
|
|
54
|
+
if (!Array.isArray(flattened) || flattened.length !== 1)
|
|
55
|
+
return null;
|
|
56
|
+
const only = flattened[0];
|
|
57
|
+
if (!only || typeof only !== "object" || only.role !== "user")
|
|
58
|
+
return null;
|
|
59
|
+
if (typeof only.content !== "string" || only.content.length === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return [{ role: "user", content: only.content }];
|
|
63
|
+
}
|
|
64
|
+
function usageRecord(result) {
|
|
65
|
+
return result.usage && typeof result.usage === "object"
|
|
66
|
+
? result.usage
|
|
67
|
+
: null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether the server actually rewrote the conversation. A successful
|
|
71
|
+
* context_memory response is not enough: the endpoint returns the messages
|
|
72
|
+
* as-is while an index is still warming AND whenever the request already fits
|
|
73
|
+
* the model budget. The server's explicit `compressed` verdict is
|
|
74
|
+
* authoritative. The cached_tokens fallback only covers servers that predate
|
|
75
|
+
* it: cached_tokens is a billing prediction, not a compression signal, and is
|
|
76
|
+
* > 0 on most under-budget passthroughs — so on such servers those turns are
|
|
77
|
+
* flattened and Anthropic's prefix cache misses every human turn.
|
|
78
|
+
*/
|
|
79
|
+
export function didMemtreeCompress(result) {
|
|
80
|
+
if (typeof result.compressed === "boolean")
|
|
81
|
+
return result.compressed;
|
|
82
|
+
return (cachedPromptTokenCount(result) ?? 0) > 0;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Number of original prompt tokens covered by the index MemTree selected.
|
|
86
|
+
*
|
|
87
|
+
* Prefers `usage.indexed_tokens`: since 2026-10-04 the server bills a
|
|
88
|
+
* passthrough's covered input at nothing, so `cached_tokens` is 0 there even
|
|
89
|
+
* when a tree covers the prompt. Older servers only send `cached_tokens`.
|
|
90
|
+
*/
|
|
91
|
+
export function cachedPromptTokenCount(result) {
|
|
92
|
+
const usage = usageRecord(result);
|
|
93
|
+
const indexed = nonNegativeCount(usage?.indexed_tokens);
|
|
94
|
+
if (indexed !== undefined)
|
|
95
|
+
return indexed;
|
|
96
|
+
const details = usage?.prompt_tokens_details;
|
|
97
|
+
if (!details || typeof details !== "object")
|
|
98
|
+
return undefined;
|
|
99
|
+
return nonNegativeCount(details.cached_tokens);
|
|
100
|
+
}
|
|
101
|
+
function nonNegativeCount(value) {
|
|
102
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
103
|
+
? value
|
|
104
|
+
: undefined;
|
|
105
|
+
}
|
|
106
|
+
/** The server-reported model budget, when present and sane. */
|
|
107
|
+
export function modelBudgetTokens(result) {
|
|
108
|
+
const value = result.model_budget_tokens;
|
|
109
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
110
|
+
? value
|
|
111
|
+
: undefined;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* MemTree's informational estimate of the original, pre-consolidation prompt.
|
|
115
|
+
* Newer servers include images as visual-token estimates and deliberately keep
|
|
116
|
+
* this value separate from billable Context Memory usage.
|
|
117
|
+
*/
|
|
118
|
+
export function rawPromptTokenCount(result) {
|
|
119
|
+
const rawPromptTokens = usageRecord(result)?.raw_prompt_tokens;
|
|
120
|
+
return typeof rawPromptTokens === "number" &&
|
|
121
|
+
Number.isFinite(rawPromptTokens) &&
|
|
122
|
+
rawPromptTokens > 0
|
|
123
|
+
? rawPromptTokens
|
|
124
|
+
: undefined;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Minimum conversation content, beyond any verbatim echo of the current turn,
|
|
128
|
+
* that a compressed response must carry before we will send it to Anthropic in
|
|
129
|
+
* place of the real history.
|
|
130
|
+
*
|
|
131
|
+
* Sized well below any useful memory (the server's own semantic floor is 15k
|
|
132
|
+
* chars) and well above an empty answer, so this fires only on genuine context
|
|
133
|
+
* loss and never on a legitimately aggressive compression.
|
|
134
|
+
*/
|
|
135
|
+
export const MIN_RETAINED_HISTORY_CHARS = 2_000;
|
|
136
|
+
/** Characters of text/JSON content in an Anthropic content field. */
|
|
137
|
+
function contentChars(content) {
|
|
138
|
+
if (content == null)
|
|
139
|
+
return 0;
|
|
140
|
+
if (typeof content === "string")
|
|
141
|
+
return content.length;
|
|
142
|
+
if (Array.isArray(content)) {
|
|
143
|
+
let total = 0;
|
|
144
|
+
for (const part of content)
|
|
145
|
+
total += contentChars(part);
|
|
146
|
+
return total;
|
|
147
|
+
}
|
|
148
|
+
if (typeof content !== "object")
|
|
149
|
+
return String(content).length;
|
|
150
|
+
const block = content;
|
|
151
|
+
// Count what the server flatten actually puts in front of the model:
|
|
152
|
+
// thinking blocks contribute only their thinking text (the opaque base64
|
|
153
|
+
// `signature` is dropped) and redacted_thinking blocks are skipped entirely,
|
|
154
|
+
// so neither may count as retained conversation: an echoed thinking block's
|
|
155
|
+
// signature alone must not satisfy the retained floor.
|
|
156
|
+
if (block.type === "thinking") {
|
|
157
|
+
return typeof block.thinking === "string" ? block.thinking.length : 0;
|
|
158
|
+
}
|
|
159
|
+
if (block.type === "redacted_thinking")
|
|
160
|
+
return 0;
|
|
161
|
+
if (typeof block.text === "string")
|
|
162
|
+
return block.text.length;
|
|
163
|
+
try {
|
|
164
|
+
return JSON.stringify(block)?.length ?? 0;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Conversation characters in `messages`, ignoring any system entry. */
|
|
171
|
+
function conversationChars(messages) {
|
|
172
|
+
let chars = 0;
|
|
173
|
+
for (const message of messages ?? []) {
|
|
174
|
+
if (message.role === "system")
|
|
175
|
+
continue;
|
|
176
|
+
chars += contentChars(message.content);
|
|
177
|
+
}
|
|
178
|
+
return chars;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The rendered text segments of a content field, mirroring contentChars:
|
|
182
|
+
* plain strings, `text` blocks, thinking text (signatures dropped), no
|
|
183
|
+
* redacted_thinking, and JSON serialization for anything else. Used to locate
|
|
184
|
+
* verbatim echoes of the sent current turn inside a compressed result.
|
|
185
|
+
*/
|
|
186
|
+
function contentTextSegments(content, out) {
|
|
187
|
+
if (content == null)
|
|
188
|
+
return;
|
|
189
|
+
if (typeof content === "string") {
|
|
190
|
+
if (content)
|
|
191
|
+
out.push({ text: content, joinable: true });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (Array.isArray(content)) {
|
|
195
|
+
for (const part of content)
|
|
196
|
+
contentTextSegments(part, out);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (typeof content !== "object") {
|
|
200
|
+
out.push({ text: String(content), joinable: true });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const block = content;
|
|
204
|
+
if (block.type === "thinking") {
|
|
205
|
+
if (typeof block.thinking === "string" && block.thinking) {
|
|
206
|
+
out.push({ text: block.thinking, joinable: false });
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (block.type === "redacted_thinking")
|
|
211
|
+
return;
|
|
212
|
+
if (typeof block.text === "string") {
|
|
213
|
+
if (block.text)
|
|
214
|
+
out.push({ text: block.text, joinable: true });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const json = JSON.stringify(block);
|
|
219
|
+
if (json)
|
|
220
|
+
out.push({ text: json, joinable: false });
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// unserializable block contributes nothing, matching contentChars
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** The rendered text pieces of a content field, in order. */
|
|
227
|
+
function contentTextPieces(content, out) {
|
|
228
|
+
const segments = [];
|
|
229
|
+
contentTextSegments(content, segments);
|
|
230
|
+
for (const segment of segments)
|
|
231
|
+
out.push(segment.text);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The text pieces of one message's content, grouped into runs of ADJACENT
|
|
235
|
+
* joinable pieces: consecutive plain-text pieces with nothing rendered
|
|
236
|
+
* between them form one run, while thinking text and JSON-serialized blocks
|
|
237
|
+
* each stand alone and break adjacency on both sides. A run's pieces
|
|
238
|
+
* concatenate to the contiguous passage the model would read, which is where
|
|
239
|
+
* an echo fragmented across text-block boundaries becomes visible again.
|
|
240
|
+
*/
|
|
241
|
+
function contentTextRuns(content) {
|
|
242
|
+
const segments = [];
|
|
243
|
+
contentTextSegments(content, segments);
|
|
244
|
+
const runs = [];
|
|
245
|
+
let current = null;
|
|
246
|
+
for (const segment of segments) {
|
|
247
|
+
if (segment.joinable) {
|
|
248
|
+
if (!current) {
|
|
249
|
+
current = [];
|
|
250
|
+
runs.push(current);
|
|
251
|
+
}
|
|
252
|
+
current.push(segment.text);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
current = null;
|
|
256
|
+
runs.push([segment.text]);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return runs;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Below this length a match between result text and the current turn is
|
|
263
|
+
* treated as coincidence, not echo. Small fragments legitimately recur in both
|
|
264
|
+
* directions — the current turn quoting a prior message back, memory quoting a
|
|
265
|
+
* phrase the turn repeats — and counting them as echo would sink genuine
|
|
266
|
+
* retained history on overlap-heavy turns. Sized well below the shortest
|
|
267
|
+
* echo worth catching (an empty-memory replay of a >= ~2k-char turn) and above
|
|
268
|
+
* incidental shared phrases. Also the length of the head/tail probes in the
|
|
269
|
+
* echo scan, which is what enforces the floor there: a probe IS a 32-char
|
|
270
|
+
* verbatim match, so nothing shorter can ever be charged.
|
|
271
|
+
*/
|
|
272
|
+
const ECHO_MIN_PIECE_CHARS = 32;
|
|
273
|
+
/**
|
|
274
|
+
* Index of the first charged span with start >= pos. The charged list is kept
|
|
275
|
+
* sorted by start and its spans are disjoint, so starts and ends are both
|
|
276
|
+
* strictly increasing and one binary search answers every interval question
|
|
277
|
+
* in O(log spans) — a probe-dense run text (repeated characters produce a hit
|
|
278
|
+
* at nearly every offset) used to linear-scan the growing list per hit, which
|
|
279
|
+
* made the whole scan quadratic in the run length.
|
|
280
|
+
*/
|
|
281
|
+
function echoSpanLowerBound(spans, pos) {
|
|
282
|
+
let lo = 0;
|
|
283
|
+
let hi = spans.length;
|
|
284
|
+
while (lo < hi) {
|
|
285
|
+
const mid = (lo + hi) >> 1;
|
|
286
|
+
if (spans[mid].start < pos)
|
|
287
|
+
lo = mid + 1;
|
|
288
|
+
else
|
|
289
|
+
hi = mid;
|
|
290
|
+
}
|
|
291
|
+
return lo;
|
|
292
|
+
}
|
|
293
|
+
/** Whether [start, end) intersects any charged span. O(log spans). */
|
|
294
|
+
function overlapsEchoSpan(spans, start, end) {
|
|
295
|
+
const i = echoSpanLowerBound(spans, start);
|
|
296
|
+
if (i < spans.length && spans[i].start < end)
|
|
297
|
+
return true;
|
|
298
|
+
return i > 0 && spans[i - 1].end > start;
|
|
299
|
+
}
|
|
300
|
+
/** Record [start, end) as charged, keeping the list sorted by start. */
|
|
301
|
+
function chargeEchoSpan(spans, start, end) {
|
|
302
|
+
spans.splice(echoSpanLowerBound(spans, start), 0, { start, end });
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Echoed characters within one run text (the coalesced passage of adjacent
|
|
306
|
+
* plain-text pieces the model would read). Two mechanisms, both anchored to
|
|
307
|
+
* the sent current turn:
|
|
308
|
+
*
|
|
309
|
+
* - Whole-text containment: a run text that is itself a verbatim slice of the
|
|
310
|
+
* turn — whole, truncated at either end, or cut from the middle — is echo
|
|
311
|
+
* in full. Nothing the probe scan could find exceeds that, so it returns
|
|
312
|
+
* directly.
|
|
313
|
+
* - Probe-and-extend masking scan: for every framed or truncated copy, a
|
|
314
|
+
* 32-char probe from each END of each turn piece (head and tail) is
|
|
315
|
+
* searched for in the run text; every hit is extended greedily through the
|
|
316
|
+
* piece in the anchored direction (forward from a head hit, backward from a
|
|
317
|
+
* tail hit), the extended span is charged and masked so no later probe can
|
|
318
|
+
* re-charge it, and scanning continues past the span. Matches are therefore
|
|
319
|
+
* non-overlapping, each verbatim copy is charged once, and DISTINCT copies
|
|
320
|
+
* — the turn embedded twice in one blob, or reassembled from fragments next
|
|
321
|
+
* to a second truncated block — are each charged.
|
|
322
|
+
*
|
|
323
|
+
* Pieces are scanned LONGEST FIRST: when one turn piece is a verbatim
|
|
324
|
+
* substring of a larger one, the larger piece's copy in the run must be
|
|
325
|
+
* charged whole before the substring's probes can pre-mask its interior — a
|
|
326
|
+
* substring is never longer than its superset, so descending length order
|
|
327
|
+
* guarantees it. Interior masks that still arise (shared content between
|
|
328
|
+
* pieces neither of which contains the other) do not truncate a legitimate
|
|
329
|
+
* extension either: when the piece keeps matching across an already-charged
|
|
330
|
+
* span at the aligned offset, the extension steps both cursors past the span
|
|
331
|
+
* without re-charging it and continues on the far side. Both rules exist
|
|
332
|
+
* because an interior mask used to cap head extension and floor tail
|
|
333
|
+
* extension, leaving the region BETWEEN two interior masks uncharged — an
|
|
334
|
+
* under-count, which is the failure direction this scan must never take.
|
|
335
|
+
* Skips are attempted only after a segment that charged NEW characters, so a
|
|
336
|
+
* chain of adjacent masks with nothing new between them ends the extension —
|
|
337
|
+
* still strictly more coverage than not skipping at all — instead of
|
|
338
|
+
* re-walking charged territory on every probe hit of a probe-dense run.
|
|
339
|
+
*
|
|
340
|
+
* The scan catches whole, truncated, framed, and framed+truncated echoes. Two
|
|
341
|
+
* shapes it concedes: a copy truncated at BOTH ends and framed (neither probe
|
|
342
|
+
* present), and a copy whose head probe lands inside territory an earlier
|
|
343
|
+
* piece's extension already charged while its tail is truncated out of the
|
|
344
|
+
* run (the overlap check rejects every hit). Both require remixed,
|
|
345
|
+
* mask-geometry-aligned echoes never observed from real servers; the trade
|
|
346
|
+
* buys a cheap scan with no regexes over untrusted
|
|
347
|
+
* text: O(runText x pieces) for the substring searches, plus O(log spans)
|
|
348
|
+
* per probe hit for the mask bookkeeping (the charged list is kept sorted by
|
|
349
|
+
* start and binary-searched; see echoSpanLowerBound), plus extension work
|
|
350
|
+
* bounded by the characters actually matched. Run texts and turn pieces
|
|
351
|
+
* shorter than ECHO_MIN_PIECE_CHARS never participate, and because every
|
|
352
|
+
* charged span contains a full 32-char probe, no match shorter than the
|
|
353
|
+
* floor ever counts.
|
|
354
|
+
*/
|
|
355
|
+
function echoedCharsInRunText(runText, turnText, turnPieces) {
|
|
356
|
+
if (runText.length < ECHO_MIN_PIECE_CHARS)
|
|
357
|
+
return 0;
|
|
358
|
+
if (turnText.includes(runText))
|
|
359
|
+
return runText.length;
|
|
360
|
+
const charged = [];
|
|
361
|
+
let echoed = 0;
|
|
362
|
+
// Longest pieces first, so a piece that is a substring of another can never
|
|
363
|
+
// pre-mask the interior of its superset's copy (see the function comment).
|
|
364
|
+
// Sort is stable, so equal-length pieces keep their original order.
|
|
365
|
+
const pieces = turnPieces
|
|
366
|
+
.filter((piece) => piece.length >= ECHO_MIN_PIECE_CHARS)
|
|
367
|
+
.sort((a, b) => b.length - a.length);
|
|
368
|
+
for (const piece of pieces) {
|
|
369
|
+
// Head probe: a hit aligns runText[hit] with piece[0]; extend forward.
|
|
370
|
+
// An already-charged span the piece still matches at the aligned offset
|
|
371
|
+
// is stepped over without re-charging, so an interior mask cannot leave
|
|
372
|
+
// the rest of a legitimate extension uncharged.
|
|
373
|
+
const headProbe = piece.slice(0, ECHO_MIN_PIECE_CHARS);
|
|
374
|
+
for (let from = 0;;) {
|
|
375
|
+
const hit = runText.indexOf(headProbe, from);
|
|
376
|
+
if (hit === -1)
|
|
377
|
+
break;
|
|
378
|
+
if (overlapsEchoSpan(charged, hit, hit + ECHO_MIN_PIECE_CHARS)) {
|
|
379
|
+
from = hit + 1;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
let end = hit + ECHO_MIN_PIECE_CHARS;
|
|
383
|
+
let p = ECHO_MIN_PIECE_CHARS;
|
|
384
|
+
let segStart = hit;
|
|
385
|
+
const segments = [];
|
|
386
|
+
for (;;) {
|
|
387
|
+
const next = echoSpanLowerBound(charged, end);
|
|
388
|
+
const cap = next < charged.length ? charged[next].start : runText.length;
|
|
389
|
+
while (end < cap &&
|
|
390
|
+
p < piece.length &&
|
|
391
|
+
runText.charCodeAt(end) === piece.charCodeAt(p)) {
|
|
392
|
+
end += 1;
|
|
393
|
+
p += 1;
|
|
394
|
+
}
|
|
395
|
+
const progressed = end > segStart;
|
|
396
|
+
if (progressed)
|
|
397
|
+
segments.push({ start: segStart, end });
|
|
398
|
+
if (end !== cap || next >= charged.length)
|
|
399
|
+
break;
|
|
400
|
+
// Reached an interior mask. If the piece keeps matching across the
|
|
401
|
+
// whole masked span, step both cursors past it (it is already
|
|
402
|
+
// charged); otherwise the extension genuinely ends here. A skip is
|
|
403
|
+
// attempted only when this segment charged NEW characters (the probe
|
|
404
|
+
// guarantees that for the first one): a fruitless skip would mean the
|
|
405
|
+
// extension is re-walking already-charged territory, which later
|
|
406
|
+
// probes cover anyway, and unbounded re-walking of the charged
|
|
407
|
+
// prefix is what would make probe-dense (repeated-character) runs
|
|
408
|
+
// quadratic again.
|
|
409
|
+
if (!progressed)
|
|
410
|
+
break;
|
|
411
|
+
const span = charged[next];
|
|
412
|
+
const len = span.end - span.start;
|
|
413
|
+
if (p + len > piece.length)
|
|
414
|
+
break;
|
|
415
|
+
let matches = true;
|
|
416
|
+
for (let k = 0; k < len; k += 1) {
|
|
417
|
+
if (runText.charCodeAt(span.start + k) !== piece.charCodeAt(p + k)) {
|
|
418
|
+
matches = false;
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (!matches)
|
|
423
|
+
break;
|
|
424
|
+
end = span.end;
|
|
425
|
+
p += len;
|
|
426
|
+
segStart = end;
|
|
427
|
+
}
|
|
428
|
+
for (const s of segments) {
|
|
429
|
+
echoed += s.end - s.start;
|
|
430
|
+
chargeEchoSpan(charged, s.start, s.end);
|
|
431
|
+
}
|
|
432
|
+
from = end;
|
|
433
|
+
}
|
|
434
|
+
// Tail probe: a hit aligns runText[hit + 32] with the piece's end; extend
|
|
435
|
+
// backward, stepping over aligned already-charged spans the same way.
|
|
436
|
+
// Catches front-truncated copies, whose head never appears.
|
|
437
|
+
const tailProbe = piece.slice(piece.length - ECHO_MIN_PIECE_CHARS);
|
|
438
|
+
for (let from = 0;;) {
|
|
439
|
+
const hit = runText.indexOf(tailProbe, from);
|
|
440
|
+
if (hit === -1)
|
|
441
|
+
break;
|
|
442
|
+
const end = hit + ECHO_MIN_PIECE_CHARS;
|
|
443
|
+
if (overlapsEchoSpan(charged, hit, end)) {
|
|
444
|
+
from = hit + 1;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
let start = hit;
|
|
448
|
+
let p = piece.length - ECHO_MIN_PIECE_CHARS;
|
|
449
|
+
let segEnd = end;
|
|
450
|
+
const segments = [];
|
|
451
|
+
for (;;) {
|
|
452
|
+
const prev = echoSpanLowerBound(charged, start) - 1;
|
|
453
|
+
const floor = prev >= 0 ? charged[prev].end : 0;
|
|
454
|
+
while (start > floor &&
|
|
455
|
+
p > 0 &&
|
|
456
|
+
runText.charCodeAt(start - 1) === piece.charCodeAt(p - 1)) {
|
|
457
|
+
start -= 1;
|
|
458
|
+
p -= 1;
|
|
459
|
+
}
|
|
460
|
+
const progressed = segEnd > start;
|
|
461
|
+
if (progressed)
|
|
462
|
+
segments.push({ start, end: segEnd });
|
|
463
|
+
if (start !== floor || prev < 0)
|
|
464
|
+
break;
|
|
465
|
+
// Same fruitless-skip guard as the forward direction, mirrored.
|
|
466
|
+
if (!progressed)
|
|
467
|
+
break;
|
|
468
|
+
const span = charged[prev];
|
|
469
|
+
const len = span.end - span.start;
|
|
470
|
+
if (p - len < 0)
|
|
471
|
+
break;
|
|
472
|
+
let matches = true;
|
|
473
|
+
for (let k = 0; k < len; k += 1) {
|
|
474
|
+
if (runText.charCodeAt(span.start + k) !== piece.charCodeAt(p - len + k)) {
|
|
475
|
+
matches = false;
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (!matches)
|
|
480
|
+
break;
|
|
481
|
+
start = span.start;
|
|
482
|
+
p -= len;
|
|
483
|
+
segEnd = start;
|
|
484
|
+
}
|
|
485
|
+
for (const s of segments) {
|
|
486
|
+
echoed += s.end - s.start;
|
|
487
|
+
chargeEchoSpan(charged, s.start, s.end);
|
|
488
|
+
}
|
|
489
|
+
from = end;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return echoed;
|
|
493
|
+
}
|
|
494
|
+
/** Append text nested inside tool_result blocks as rendered by a flattened response. */
|
|
495
|
+
function appendRenderedToolResultTextPieces(content, pieces) {
|
|
496
|
+
if (!Array.isArray(content))
|
|
497
|
+
return;
|
|
498
|
+
for (const part of content) {
|
|
499
|
+
if (!part || typeof part !== "object" || Array.isArray(part))
|
|
500
|
+
continue;
|
|
501
|
+
const block = part;
|
|
502
|
+
if (block.type === "tool_result") {
|
|
503
|
+
contentTextPieces(block.content, pieces);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Characters of the result body that are a VERBATIM echo of the sent current
|
|
509
|
+
* turn. Each message's text is scored per RUN of adjacent plain-text pieces
|
|
510
|
+
* (contentTextRuns), coalesced into the contiguous passage the model would
|
|
511
|
+
* read, with one probe-and-extend masking scan per run
|
|
512
|
+
* (echoedCharsInRunText). Coalescing means an echo fragmented across
|
|
513
|
+
* consecutive text blocks — sub-floor slivers that individually dodge the
|
|
514
|
+
* length gate but concatenate back into the turn — is scored as the passage
|
|
515
|
+
* it reassembles into, and the probes find a copy wherever it sits inside
|
|
516
|
+
* the run: whole, truncated at either end, framed by non-turn text, or both.
|
|
517
|
+
*
|
|
518
|
+
* EVERY verbatim copy counts — a result that embeds the turn twice inside
|
|
519
|
+
* one memory blob, or once in the memory block AND again as a replayed tail,
|
|
520
|
+
* or reassembled from fragments beside a second truncated block, is charged
|
|
521
|
+
* for every copy, so the total may exceed the turn's own length. Masking
|
|
522
|
+
* within a run keeps the charges non-overlapping, so a single copy is never
|
|
523
|
+
* charged twice, and a double echo can never net out to a single turn's
|
|
524
|
+
* worth of "retained" text and pass the empty-memory gate. Over-subtraction
|
|
525
|
+
* fails safe: a verbatim copy of current-turn content is by definition not
|
|
526
|
+
* prior conversation.
|
|
527
|
+
*
|
|
528
|
+
* Run texts and turn pieces shorter than ECHO_MIN_PIECE_CHARS never count,
|
|
529
|
+
* and no charged match is shorter than that floor: tiny fragments shared
|
|
530
|
+
* between the turn and the result (quoted-back prior messages, repeated
|
|
531
|
+
* pastes) are genuine retained history, not echo — and because quoted
|
|
532
|
+
* fragments in genuine memory are separated by surrounding prose, coalescing
|
|
533
|
+
* their run does not turn them into echo either. Content the server rewrote
|
|
534
|
+
* — e.g. a summary of a huge pasted log — deliberately does not count as
|
|
535
|
+
* echo: rewriting is compression work, and its output is retained context,
|
|
536
|
+
* not a replay of the input.
|
|
537
|
+
*/
|
|
538
|
+
function echoedCurrentTurnChars(messages, currentTurnContent) {
|
|
539
|
+
const turnPieces = [];
|
|
540
|
+
contentTextPieces(currentTurnContent, turnPieces);
|
|
541
|
+
if (turnPieces.length === 0)
|
|
542
|
+
return 0;
|
|
543
|
+
const turnText = turnPieces.join("");
|
|
544
|
+
appendRenderedToolResultTextPieces(currentTurnContent, turnPieces);
|
|
545
|
+
let echoed = 0;
|
|
546
|
+
for (const message of messages ?? []) {
|
|
547
|
+
if (message.role === "system")
|
|
548
|
+
continue;
|
|
549
|
+
for (const run of contentTextRuns(message.content)) {
|
|
550
|
+
echoed += echoedCharsInRunText(run.join(""), turnText, turnPieces);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return echoed;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Decide whether a compressed response still contains the conversation.
|
|
557
|
+
*
|
|
558
|
+
* This is deliberately NOT derived from `usage`. Every usage-derived signal —
|
|
559
|
+
* `cached_tokens` and the `raw - cached` tail — measures how much of the prompt
|
|
560
|
+
* the server's INDEX covered, not how much context it put in the response. A
|
|
561
|
+
* server that indexes every message and then allocates zero characters to
|
|
562
|
+
* memory (because fixed system+tool overhead exceeded its whole-request budget)
|
|
563
|
+
* reports perfect coverage while returning nothing at all, and the tail metric
|
|
564
|
+
* scores that empty answer as the best possible result. Only the body itself
|
|
565
|
+
* says what the model will actually see.
|
|
566
|
+
*
|
|
567
|
+
* "Retained prior conversation" is measured as the result's conversation
|
|
568
|
+
* characters minus only the part that verbatim-echoes the SENT current turn —
|
|
569
|
+
* not minus the current turn's full size. A turn the server itself shrank
|
|
570
|
+
* (e.g. a 100k-char pasted log returned as a 5k summary) leaves no verbatim
|
|
571
|
+
* echo, so its compressed rendering counts as retained context instead of
|
|
572
|
+
* sinking the score below zero and disabling compression on exactly the turns
|
|
573
|
+
* that need it. The failure the floor exists to catch — the server echoing the
|
|
574
|
+
* current turn back with essentially nothing of the prior conversation — still
|
|
575
|
+
* scores ~0, because every verbatim echo (whole, truncated, or embedded in a
|
|
576
|
+
* larger message) is fully subtracted, and a double echo — the turn embedded
|
|
577
|
+
* in the memory block AND replayed as the tail, or carried twice within one
|
|
578
|
+
* memory blob — is charged for every copy rather than netting out to a
|
|
579
|
+
* single turn's worth of "retained" text. An echo fragmented across
|
|
580
|
+
* consecutive text blocks is caught by re-checking each run of adjacent
|
|
581
|
+
* plain-text pieces as one coalesced passage. Fragments below a small length
|
|
582
|
+
* floor are never counted as echo, so a turn that quotes prior messages
|
|
583
|
+
* cannot sink the genuine memory that contains those same fragments. A rewritten-but-still-empty answer can
|
|
584
|
+
* in principle slip through; the trade is deliberate, since rewriting proves
|
|
585
|
+
* the server did compression work rather than dropping context.
|
|
586
|
+
*/
|
|
587
|
+
export function checkCompressedHistory(result, sentMessages, minRetainedChars = MIN_RETAINED_HISTORY_CHARS) {
|
|
588
|
+
const nonSystem = sentMessages.filter((m) => m.role !== "system");
|
|
589
|
+
const currentTurnContent = nonSystem.length
|
|
590
|
+
? nonSystem[nonSystem.length - 1].content
|
|
591
|
+
: undefined;
|
|
592
|
+
const currentTurn = contentChars(currentTurnContent);
|
|
593
|
+
const priorHistoryChars = Math.max(0, conversationChars(sentMessages) - currentTurn);
|
|
594
|
+
const flattened = serverFlattenedMessages(result);
|
|
595
|
+
const measuredMessages = flattened ?? result.messages;
|
|
596
|
+
const retainedChars = conversationChars(measuredMessages);
|
|
597
|
+
const echoedChars = echoedCurrentTurnChars(measuredMessages, currentTurnContent);
|
|
598
|
+
// Require the original structured check too: rendering can reformat current
|
|
599
|
+
// tool/text blocks enough to hide a verbatim echo in the flattened text.
|
|
600
|
+
const structuredHistoryChars = flattened
|
|
601
|
+
? conversationChars(result.messages) -
|
|
602
|
+
echoedCurrentTurnChars(result.messages, currentTurnContent)
|
|
603
|
+
: retainedChars - echoedChars;
|
|
604
|
+
// Nothing meaningful to lose: a short conversation legitimately compresses to
|
|
605
|
+
// roughly itself, and passing it through would be pointless churn.
|
|
606
|
+
const usable = priorHistoryChars < minRetainedChars ||
|
|
607
|
+
(structuredHistoryChars >= minRetainedChars &&
|
|
608
|
+
retainedChars - echoedChars >= minRetainedChars);
|
|
609
|
+
return { retainedChars, currentTurnChars: currentTurn, priorHistoryChars, usable };
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Remove model-specific reasoning blocks from the copy sent to MemTree.
|
|
613
|
+
*
|
|
614
|
+
* Claude Code does not reliably replay prior-turn thinking after a model
|
|
615
|
+
* change or process resume. Thinking text, redacted payloads, and signatures
|
|
616
|
+
* are required when present in the live Anthropic request, but none is stable
|
|
617
|
+
* conversation identity for indexing.
|
|
618
|
+
*
|
|
619
|
+
* Scope this narrowly to assistant thinking blocks. A field named `signature`
|
|
620
|
+
* inside tool input/result data can be real user data and must remain intact.
|
|
621
|
+
* The original request is never mutated and still goes to Anthropic verbatim.
|
|
622
|
+
*/
|
|
623
|
+
export function normalizeMessagesForMemtree(messages) {
|
|
624
|
+
const normalized = [];
|
|
625
|
+
for (const message of messages) {
|
|
626
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
627
|
+
normalized.push(message);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
let removedReasoning = false;
|
|
631
|
+
const content = [];
|
|
632
|
+
for (const part of message.content) {
|
|
633
|
+
if (!isReasoningBlock(part)) {
|
|
634
|
+
content.push(part);
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
removedReasoning = true;
|
|
638
|
+
}
|
|
639
|
+
if (removedReasoning && content.length === 0)
|
|
640
|
+
continue;
|
|
641
|
+
normalized.push(removedReasoning ? { ...message, content } : message);
|
|
642
|
+
}
|
|
643
|
+
return normalized;
|
|
644
|
+
}
|
|
645
|
+
function isReasoningBlock(value) {
|
|
646
|
+
return (!!value &&
|
|
647
|
+
typeof value === "object" &&
|
|
648
|
+
!Array.isArray(value) &&
|
|
649
|
+
(value.type === "thinking" ||
|
|
650
|
+
value.type === "redacted_thinking"));
|
|
651
|
+
}
|
|
652
|
+
function transmittedModel(model) {
|
|
653
|
+
return typeof model === "string" && model.length > 0 ? model : undefined;
|
|
654
|
+
}
|
|
655
|
+
function transmittedTools(tools) {
|
|
656
|
+
return Array.isArray(tools) && tools.length > 0 ? tools : undefined;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* With a known comparison budget, only crossing it changes the server's
|
|
660
|
+
* verdict, so same-side retries share a key. Before the server reports its
|
|
661
|
+
* budget, callers can omit both target and threshold: preserve the full hint
|
|
662
|
+
* then, since any differing value may cross the unknown model-policy budget.
|
|
663
|
+
* In particular, a newly counted retry must not reuse an unhinted passthrough.
|
|
664
|
+
*/
|
|
665
|
+
function clientOverThreshold(meta) {
|
|
666
|
+
const budget = meta?.compressionThresholdTokens ?? meta?.compressionTargetTokens;
|
|
667
|
+
if (meta?.clientInputTokens === undefined)
|
|
668
|
+
return null;
|
|
669
|
+
if (budget === undefined)
|
|
670
|
+
return meta.clientInputTokens;
|
|
671
|
+
return meta.clientInputTokens > budget ? true : null;
|
|
672
|
+
}
|
|
673
|
+
export class MemtreeClient {
|
|
674
|
+
baseUrl;
|
|
675
|
+
apiKey;
|
|
676
|
+
compressTimeoutMs;
|
|
677
|
+
debug;
|
|
678
|
+
reqlog;
|
|
679
|
+
memtreeTools;
|
|
680
|
+
/** Complete compression request key → in-flight/settled promise (retry dedupe). */
|
|
681
|
+
compressCache = new Map();
|
|
682
|
+
/** Message hashes already submitted for background indexing. */
|
|
683
|
+
indexedHashes = new Set();
|
|
684
|
+
/** In-flight index-only calls, tracked so shutdown cannot outrun their logs. */
|
|
685
|
+
backgroundIndexes = new Map();
|
|
686
|
+
/** Once draining begins, no later request may create another log producer. */
|
|
687
|
+
backgroundClosing = false;
|
|
688
|
+
/** Session → its main lane's latest conversation, for the final index. */
|
|
689
|
+
mainConversations = new Map();
|
|
690
|
+
/** Session → its final index waiting for the session to stay idle. */
|
|
691
|
+
pendingFinals = new Map();
|
|
692
|
+
/** Session → hash of the conversation its last final index sent. */
|
|
693
|
+
finalIndexed = new Map();
|
|
694
|
+
finalPreparing = new Map();
|
|
695
|
+
finalDrainDeadline;
|
|
696
|
+
/** FastAPI `detail` text from the most recent 402, or null while paid. */
|
|
697
|
+
unpaidDetail = null;
|
|
698
|
+
/** Complete compression request key → whether its latest failure arms fuse. */
|
|
699
|
+
compressFailureArming = new Map();
|
|
700
|
+
/**
|
|
701
|
+
* Non-null when the server last answered 402 (unpaid MemTree key): the
|
|
702
|
+
* server's human-readable detail text. Set by both compression and
|
|
703
|
+
* background-indexing calls; cleared by any subsequent success.
|
|
704
|
+
*/
|
|
705
|
+
get paymentRequiredDetail() {
|
|
706
|
+
return this.unpaidDetail;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* The blocking-compress abort budget. Exposed so the proxy's request log
|
|
710
|
+
* can label a failed compress that consumed (roughly) the whole budget as a
|
|
711
|
+
* timeout rather than a fast server error.
|
|
712
|
+
*/
|
|
713
|
+
get compressBudgetMs() {
|
|
714
|
+
return this.compressTimeoutMs;
|
|
715
|
+
}
|
|
716
|
+
/** Change the `x-memtree-tools` value for later calls (undefined: none). */
|
|
717
|
+
setMemtreeTools(value) {
|
|
718
|
+
this.memtreeTools = value || undefined;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* GET a MemTree view path (`/usage/memtree/<id>[.json][?share=…]`) on the
|
|
722
|
+
* polychat host with this client's key. Backs the loopback `/memtree/*`
|
|
723
|
+
* passthrough, so an agent inside a ccc session reads the user's own tree
|
|
724
|
+
* through ANTHROPIC_BASE_URL without ever handling the key.
|
|
725
|
+
*/
|
|
726
|
+
async fetchMemTree(pathAndQuery, accept = "application/json", signal, extraHeaders = {}) {
|
|
727
|
+
const response = await fetch(`${this.baseUrl}${pathAndQuery}`, {
|
|
728
|
+
signal,
|
|
729
|
+
headers: {
|
|
730
|
+
...extraHeaders,
|
|
731
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
732
|
+
accept,
|
|
733
|
+
"x-client": CLIENT_NAME,
|
|
734
|
+
"x-client-version": CLIENT_VERSION,
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
return {
|
|
738
|
+
status: response.status,
|
|
739
|
+
contentType: response.headers.get("content-type") ?? "application/octet-stream",
|
|
740
|
+
body: Buffer.from(await response.arrayBuffer()),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
constructor(opts) {
|
|
744
|
+
this.baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
745
|
+
this.apiKey = opts.apiKey;
|
|
746
|
+
this.compressTimeoutMs =
|
|
747
|
+
opts.compressTimeoutMs ??
|
|
748
|
+
Number(process.env.CCC_COMPRESS_TIMEOUT_MS || DEFAULT_COMPRESS_TIMEOUT_MS);
|
|
749
|
+
this.debug = opts.debug ?? false;
|
|
750
|
+
this.reqlog = opts.reqlog;
|
|
751
|
+
this.memtreeTools = opts.memtreeTools || undefined;
|
|
752
|
+
}
|
|
753
|
+
static hashMessages(messages) {
|
|
754
|
+
return createHash("sha256")
|
|
755
|
+
.update(JSON.stringify(messages))
|
|
756
|
+
.digest("hex");
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Whether compress(hash, limit, meta) would answer without contacting the server: an
|
|
760
|
+
* already-settled success, or a leg another caller has in flight. Callers
|
|
761
|
+
* that read a compress result as evidence about the SERVER's health must
|
|
762
|
+
* consult this first — a memoized answer proves only that the server was
|
|
763
|
+
* alive whenever that entry was created, which may be many minutes and one
|
|
764
|
+
* outage ago. In-flight entries count as cached here even though they are
|
|
765
|
+
* real round trips: conservatively discarding live evidence only forgoes an
|
|
766
|
+
* optimization, whereas trusting a stale one suppresses a real outage.
|
|
767
|
+
*/
|
|
768
|
+
hasCachedCompress(hash, modelContextLimit, meta) {
|
|
769
|
+
return this.compressCache.has(this.compressKey(hash, modelContextLimit, meta));
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Fuse classification for the most recent live compress failure of `hash`.
|
|
773
|
+
* The entry is recorded before the failed promise settles null, and a
|
|
774
|
+
* concurrent same-request retry MAY overwrite it with a different class — so
|
|
775
|
+
* callers must sample at their own leg's settle (a .then on the compress
|
|
776
|
+
* promise), not after awaiting other work; only that keeps the read paired
|
|
777
|
+
* with the failure it describes.
|
|
778
|
+
* Arming failures are evidence about the SERVER's health: no response at
|
|
779
|
+
* all (network error or the abort-budget timeout), any 5xx, and 402 —
|
|
780
|
+
* unpaid is global and persistent, so it must keep arming. Any other 4xx
|
|
781
|
+
* came from a responsive server rejecting this one request and must not
|
|
782
|
+
* open the shared fuse. Unclassified failures default to arming: muting
|
|
783
|
+
* the fuse needs positive evidence of a responsive server.
|
|
784
|
+
*/
|
|
785
|
+
lastCompressFailureArming(hash, modelContextLimit, meta) {
|
|
786
|
+
return (this.compressFailureArming.get(this.compressKey(hash, modelContextLimit, meta)) ?? true);
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Blocking user-turn compression. Returns null on ANY failure (timeout,
|
|
790
|
+
* network, 4xx/5xx including 402) — the caller must degrade to passthrough.
|
|
791
|
+
* On 402 the failure is additionally recorded in paymentRequiredDetail so
|
|
792
|
+
* the caller can distinguish "unpaid" from "outage".
|
|
793
|
+
*/
|
|
794
|
+
compress(hash, messages, modelContextLimit, signal, meta) {
|
|
795
|
+
const cacheKey = this.compressKey(hash, modelContextLimit, meta);
|
|
796
|
+
const cached = this.compressCache.get(cacheKey);
|
|
797
|
+
if (cached)
|
|
798
|
+
return cached;
|
|
799
|
+
const promise = this.callContextMemory(messages, modelContextLimit, {
|
|
800
|
+
timeoutMs: this.compressTimeoutMs,
|
|
801
|
+
signal,
|
|
802
|
+
model: meta?.model,
|
|
803
|
+
tools: meta?.tools,
|
|
804
|
+
compressionTargetTokens: meta?.compressionTargetTokens,
|
|
805
|
+
compressionThresholdTokens: meta?.compressionThresholdTokens,
|
|
806
|
+
clientInputTokens: meta?.clientInputTokens,
|
|
807
|
+
messageUsage: meta?.messageUsage,
|
|
808
|
+
messageTimes: meta?.messageTimes,
|
|
809
|
+
sessionId: meta?.sessionId,
|
|
810
|
+
clientMeta: meta?.clientMeta,
|
|
811
|
+
}).catch((err) => {
|
|
812
|
+
this.log(`compression failed: ${err?.message ?? err}`);
|
|
813
|
+
// `status` is absent when the call never got a response (network
|
|
814
|
+
// error/timeout) — the arming default covers it.
|
|
815
|
+
const status = err?.status;
|
|
816
|
+
// Delete-before-set so a re-failure moves to the back of the FIFO;
|
|
817
|
+
// Map.set on an existing key keeps its old insertion position, which
|
|
818
|
+
// would leave a fresh classification first in eviction order — under
|
|
819
|
+
// churn it could be evicted before the failing leg samples it and the
|
|
820
|
+
// sample would default to arming.
|
|
821
|
+
this.compressFailureArming.delete(cacheKey);
|
|
822
|
+
this.compressFailureArming.set(cacheKey, status === undefined || status >= 500 || status === 402);
|
|
823
|
+
if (this.compressFailureArming.size > DEDUPE_CACHE_MAX) {
|
|
824
|
+
const first = this.compressFailureArming.keys().next().value;
|
|
825
|
+
if (first !== undefined)
|
|
826
|
+
this.compressFailureArming.delete(first);
|
|
827
|
+
}
|
|
828
|
+
// Don't cache failures — drop the entry so retries (e.g. Claude Code's
|
|
829
|
+
// automatic retry of an identical request) hit the server again.
|
|
830
|
+
if (this.compressCache.get(cacheKey) === promise) {
|
|
831
|
+
this.compressCache.delete(cacheKey);
|
|
832
|
+
}
|
|
833
|
+
return null;
|
|
834
|
+
});
|
|
835
|
+
this.remember(cacheKey, promise);
|
|
836
|
+
return promise;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Fire-and-forget background indexing for tool and first-user turns. Keeps
|
|
840
|
+
* the server index fed during tool loops; adds zero latency to the response
|
|
841
|
+
* path. A tool turn whose route-miss recovery got any non-null compress()
|
|
842
|
+
* response skips this: that call already submitted the same history. A
|
|
843
|
+
* recovery that returned null skips it too when the client has already
|
|
844
|
+
* disconnected, or on shutdown/402 — only an ordinary failure with a live
|
|
845
|
+
* client keeps the longer-budget background retry.
|
|
846
|
+
*/
|
|
847
|
+
indexInBackground(hash, messages, modelContextLimit, sessionId, clientMeta,
|
|
848
|
+
/** Times for retained original messages, in the positions actually sent. */
|
|
849
|
+
messageTimesFor) {
|
|
850
|
+
if (this.backgroundClosing)
|
|
851
|
+
return;
|
|
852
|
+
const indexKey = JSON.stringify([sessionId ?? null, hash]);
|
|
853
|
+
if (this.indexedHashes.has(indexKey))
|
|
854
|
+
return;
|
|
855
|
+
this.indexedHashes.add(indexKey);
|
|
856
|
+
if (this.indexedHashes.size > DEDUPE_CACHE_MAX) {
|
|
857
|
+
const first = this.indexedHashes.values().next().value;
|
|
858
|
+
if (first !== undefined)
|
|
859
|
+
this.indexedHashes.delete(first);
|
|
860
|
+
}
|
|
861
|
+
this.submitIndexOnly(messages, modelContextLimit, { sessionId, clientMeta, messageTimesFor });
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* The main lane's latest conversation for a session, kept for its final
|
|
865
|
+
* index (scheduleFinalIndex). A new request also means the session did not
|
|
866
|
+
* end at the last Stop: its pending final index is cancelled.
|
|
867
|
+
*/
|
|
868
|
+
noteMainConversation(sessionId, conversation) {
|
|
869
|
+
if (sessionId === undefined)
|
|
870
|
+
return;
|
|
871
|
+
this.cancelFinalIndex(sessionId);
|
|
872
|
+
this.finalPreparing.get(sessionId)?.abort();
|
|
873
|
+
this.mainConversations.delete(sessionId);
|
|
874
|
+
this.mainConversations.set(sessionId, conversation);
|
|
875
|
+
if (this.mainConversations.size > DEDUPE_CACHE_MAX) {
|
|
876
|
+
const first = this.mainConversations.keys().next().value;
|
|
877
|
+
if (first !== undefined)
|
|
878
|
+
this.mainConversations.delete(first);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* After a main-lane Stop: once the session has been idle `delayMs` (or at
|
|
883
|
+
* shutdown, flushFinalIndexes), send its conversation plus the reply it
|
|
884
|
+
* ended with as one index-only call flagged final_index, so the server
|
|
885
|
+
* indexes the tail even below its 10k-token minimum (at most once per
|
|
886
|
+
* conversation per 30 minutes, server-side). Every per-request index call
|
|
887
|
+
* lacks that reply: it only appears in the next request. `readReply` reads
|
|
888
|
+
* it from the transcript when the call goes out, not inside the hook.
|
|
889
|
+
*/
|
|
890
|
+
scheduleFinalIndex(sessionId, readReply, delayMs = FINAL_INDEX_IDLE_MS) {
|
|
891
|
+
if (sessionId === undefined || this.backgroundClosing)
|
|
892
|
+
return;
|
|
893
|
+
if (!this.mainConversations.has(sessionId))
|
|
894
|
+
return;
|
|
895
|
+
this.cancelFinalIndex(sessionId);
|
|
896
|
+
const timer = setTimeout(() => this.fireFinalIndex(sessionId), delayMs);
|
|
897
|
+
timer.unref?.();
|
|
898
|
+
this.pendingFinals.set(sessionId, { timer, readReply });
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* At shutdown, before drainBackground: finalize at most the three newest
|
|
902
|
+
* sessions, including sessions that never had a Stop (`-p`
|
|
903
|
+
* and non-TTY runs install no hooks). Never throws: a failure here must not
|
|
904
|
+
* change ccc's exit code. The calls share drainBackground's bounded wait.
|
|
905
|
+
*/
|
|
906
|
+
flushFinalIndexes(timeoutMs = 2_000) {
|
|
907
|
+
if (this.backgroundClosing)
|
|
908
|
+
return;
|
|
909
|
+
this.finalDrainDeadline ??= Date.now() + Math.max(0, timeoutMs);
|
|
910
|
+
const recent = [...this.mainConversations.keys()].slice(-FINAL_INDEX_EXIT_SESSIONS).reverse();
|
|
911
|
+
for (const sessionId of recent)
|
|
912
|
+
this.fireFinalIndex(sessionId, true);
|
|
913
|
+
for (const sessionId of this.pendingFinals.keys())
|
|
914
|
+
this.cancelFinalIndex(sessionId);
|
|
915
|
+
}
|
|
916
|
+
cancelFinalIndex(sessionId) {
|
|
917
|
+
const pending = this.pendingFinals.get(sessionId);
|
|
918
|
+
if (pending)
|
|
919
|
+
clearTimeout(pending.timer);
|
|
920
|
+
this.pendingFinals.delete(sessionId);
|
|
921
|
+
}
|
|
922
|
+
fireFinalIndex(sessionId, atExit = false) {
|
|
923
|
+
const pending = this.pendingFinals.get(sessionId);
|
|
924
|
+
this.cancelFinalIndex(sessionId);
|
|
925
|
+
const conversation = this.mainConversations.get(sessionId);
|
|
926
|
+
if ((!pending && !atExit) || !conversation || this.backgroundClosing)
|
|
927
|
+
return;
|
|
928
|
+
if (this.finalPreparing.has(sessionId))
|
|
929
|
+
return;
|
|
930
|
+
const controller = new AbortController();
|
|
931
|
+
this.finalPreparing.set(sessionId, controller);
|
|
932
|
+
const operation = this.sendFinalIndex(sessionId, conversation, pending?.readReply ?? conversation.readReply, controller.signal)
|
|
933
|
+
.catch(err => this.log(`final index failed (ignored): ${err?.message ?? err}`))
|
|
934
|
+
.finally(() => {
|
|
935
|
+
this.backgroundIndexes.delete(operation);
|
|
936
|
+
if (this.finalPreparing.get(sessionId) === controller)
|
|
937
|
+
this.finalPreparing.delete(sessionId);
|
|
938
|
+
});
|
|
939
|
+
this.backgroundIndexes.set(operation, controller);
|
|
940
|
+
}
|
|
941
|
+
async sendFinalIndex(sessionId, conversation, reader, signal) {
|
|
942
|
+
const reply = await readFinalReply(reader, signal);
|
|
943
|
+
const prepared = await prepareFinalMessages(withFinalReply(conversation.messages, reply), signal);
|
|
944
|
+
if (this.mainConversations.get(sessionId) !== conversation ||
|
|
945
|
+
this.finalIndexed.get(sessionId) === prepared.key)
|
|
946
|
+
return;
|
|
947
|
+
signal.throwIfAborted();
|
|
948
|
+
this.finalIndexed.set(sessionId, prepared.key);
|
|
949
|
+
const messageTimes = conversation.messageTimesFor?.(prepared.retained);
|
|
950
|
+
await this.callContextMemory(prepared.messages, conversation.modelContextLimit, {
|
|
951
|
+
sessionId, clientMeta: conversation.clientMeta, messageTimes,
|
|
952
|
+
indexOnly: true, finalIndex: true, signal, timeoutMs: INDEX_TIMEOUT_MS,
|
|
953
|
+
serializedMessages: prepared.serialized,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
submitIndexOnly(messages, modelContextLimit, opts) {
|
|
957
|
+
const stripped = [];
|
|
958
|
+
const retained = [];
|
|
959
|
+
for (const message of messages) {
|
|
960
|
+
const [cleaned] = stripCcSystemReminders([message]);
|
|
961
|
+
if (cleaned) {
|
|
962
|
+
stripped.push(cleaned);
|
|
963
|
+
retained.push(message);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
const controller = new AbortController();
|
|
967
|
+
const operation = this.callContextMemory(stripped, modelContextLimit, {
|
|
968
|
+
timeoutMs: INDEX_TIMEOUT_MS,
|
|
969
|
+
indexOnly: true,
|
|
970
|
+
finalIndex: opts.finalIndex,
|
|
971
|
+
signal: controller.signal,
|
|
972
|
+
// Match original text: trimming each block can change joined assistant
|
|
973
|
+
// text. Omit dropped messages first so the times use the sent positions.
|
|
974
|
+
messageTimes: opts.messageTimesFor?.(retained),
|
|
975
|
+
sessionId: opts.sessionId,
|
|
976
|
+
clientMeta: opts.clientMeta,
|
|
977
|
+
})
|
|
978
|
+
.then(() => undefined, (err) => {
|
|
979
|
+
this.log(`background indexing failed (ignored): ${err?.message ?? err}`);
|
|
980
|
+
})
|
|
981
|
+
.finally(() => {
|
|
982
|
+
this.backgroundIndexes.delete(operation);
|
|
983
|
+
});
|
|
984
|
+
this.backgroundIndexes.set(operation, controller);
|
|
985
|
+
void operation;
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Stop accepting background indexes and wait boundedly for those already in
|
|
989
|
+
* flight. Calls still running after the grace period are aborted, and this
|
|
990
|
+
* method does not return until their final request-log records are produced.
|
|
991
|
+
* The boolean is true for a graceful drain and false when abort was needed.
|
|
992
|
+
*/
|
|
993
|
+
async drainBackground(timeoutMs = 2_000) {
|
|
994
|
+
this.backgroundClosing = true;
|
|
995
|
+
if (this.backgroundIndexes.size === 0)
|
|
996
|
+
return true;
|
|
997
|
+
const boundedMs = Number.isFinite(timeoutMs) && timeoutMs >= 0
|
|
998
|
+
? Math.floor(timeoutMs)
|
|
999
|
+
: 2_000;
|
|
1000
|
+
const remainingMs = this.finalDrainDeadline === undefined ? boundedMs
|
|
1001
|
+
: Math.min(boundedMs, Math.max(0, this.finalDrainDeadline - Date.now()));
|
|
1002
|
+
const pending = [...this.backgroundIndexes.keys()];
|
|
1003
|
+
let timer;
|
|
1004
|
+
const completed = await Promise.race([
|
|
1005
|
+
Promise.allSettled(pending).then(() => true),
|
|
1006
|
+
new Promise((resolve) => {
|
|
1007
|
+
timer = setTimeout(() => resolve(false), remainingMs);
|
|
1008
|
+
}),
|
|
1009
|
+
]);
|
|
1010
|
+
if (timer)
|
|
1011
|
+
clearTimeout(timer);
|
|
1012
|
+
if (completed)
|
|
1013
|
+
return true;
|
|
1014
|
+
for (const controller of this.backgroundIndexes.values()) {
|
|
1015
|
+
controller.abort();
|
|
1016
|
+
}
|
|
1017
|
+
await Promise.allSettled([...this.backgroundIndexes.keys()]);
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
compressKey(hash, modelContextLimit, meta) {
|
|
1021
|
+
const model = transmittedModel(meta?.model);
|
|
1022
|
+
const tools = transmittedTools(meta?.tools);
|
|
1023
|
+
const toolsJson = tools === undefined ? "" : JSON.stringify(tools);
|
|
1024
|
+
const toolsHash = createHash("sha256").update(toolsJson).digest("hex");
|
|
1025
|
+
return createHash("sha256")
|
|
1026
|
+
.update(JSON.stringify([
|
|
1027
|
+
hash,
|
|
1028
|
+
meta?.sessionId ?? null,
|
|
1029
|
+
model ?? null,
|
|
1030
|
+
modelContextLimit,
|
|
1031
|
+
toolsHash,
|
|
1032
|
+
meta?.compressionTargetTokens ?? null,
|
|
1033
|
+
meta?.compressionThresholdTokens ?? null,
|
|
1034
|
+
clientOverThreshold(meta),
|
|
1035
|
+
]))
|
|
1036
|
+
.digest("hex");
|
|
1037
|
+
}
|
|
1038
|
+
remember(cacheKey, promise) {
|
|
1039
|
+
this.compressCache.set(cacheKey, promise);
|
|
1040
|
+
if (this.compressCache.size > DEDUPE_CACHE_MAX) {
|
|
1041
|
+
const first = this.compressCache.keys().next().value;
|
|
1042
|
+
if (first !== undefined)
|
|
1043
|
+
this.compressCache.delete(first);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
async callContextMemory(messages, modelContextLimit, opts) {
|
|
1047
|
+
const body = {
|
|
1048
|
+
messages,
|
|
1049
|
+
model_context_limit: modelContextLimit,
|
|
1050
|
+
};
|
|
1051
|
+
// Server may ignore this until the index-only endpoint mode ships
|
|
1052
|
+
// (plan Phase 2.2); harmless extra field either way.
|
|
1053
|
+
if (opts.indexOnly)
|
|
1054
|
+
body.index_only = true;
|
|
1055
|
+
// Older servers ignore it: the call is then an ordinary index-only call.
|
|
1056
|
+
if (opts.indexOnly && opts.finalIndex)
|
|
1057
|
+
body.final_index = true;
|
|
1058
|
+
// Both call kinds: index-only calls build most of the tree, and the
|
|
1059
|
+
// server stamps each input block with its messages' time range.
|
|
1060
|
+
if (opts.messageTimes && Object.keys(opts.messageTimes).length) {
|
|
1061
|
+
body.message_times = opts.messageTimes;
|
|
1062
|
+
}
|
|
1063
|
+
// Model (and tools, whose serialized size feeds the same budget) let the
|
|
1064
|
+
// server resolve a model-based memory budget instead of its static 50k
|
|
1065
|
+
// fallback. Only meaningful on compression calls: the server's index_only
|
|
1066
|
+
// path returns before budget resolution, so indexing calls skip both and
|
|
1067
|
+
// save the upload bytes (tools schemas run tens of KB per call).
|
|
1068
|
+
if (!opts.indexOnly) {
|
|
1069
|
+
const model = transmittedModel(opts.model);
|
|
1070
|
+
const tools = transmittedTools(opts.tools);
|
|
1071
|
+
if (model !== undefined)
|
|
1072
|
+
body.model = model;
|
|
1073
|
+
if (tools !== undefined)
|
|
1074
|
+
body.tools = tools;
|
|
1075
|
+
if (opts.compressionTargetTokens !== undefined) {
|
|
1076
|
+
body.compression_target_tokens = opts.compressionTargetTokens;
|
|
1077
|
+
}
|
|
1078
|
+
if (opts.compressionThresholdTokens !== undefined) {
|
|
1079
|
+
body.compression_threshold_tokens = opts.compressionThresholdTokens;
|
|
1080
|
+
}
|
|
1081
|
+
if (opts.clientInputTokens !== undefined) {
|
|
1082
|
+
body.client_input_tokens = opts.clientInputTokens;
|
|
1083
|
+
}
|
|
1084
|
+
// Compress calls only: the server adds the thinking tokens (stripped
|
|
1085
|
+
// from `messages` above) to its budget, since a passthrough forwards
|
|
1086
|
+
// them, and archives the counts for the MemTree page's thinking share.
|
|
1087
|
+
if (opts.messageUsage && Object.keys(opts.messageUsage).length) {
|
|
1088
|
+
body.message_usage = opts.messageUsage;
|
|
1089
|
+
}
|
|
1090
|
+
// Ask the server for its canonical single-user-message flatten of the
|
|
1091
|
+
// compressed result. The flatten format (closed transcript container,
|
|
1092
|
+
// per-human-turn headers, live-tail framing, header escaping) lives
|
|
1093
|
+
// server-side only; the client forwards `flattened_messages` verbatim
|
|
1094
|
+
// and never re-derives it. Pre-flatten servers ignore the field and
|
|
1095
|
+
// omit `flattened_messages`, which degrades to passthrough upstream.
|
|
1096
|
+
body.flatten = true;
|
|
1097
|
+
}
|
|
1098
|
+
let payload;
|
|
1099
|
+
if (opts.serializedMessages !== undefined) {
|
|
1100
|
+
delete body.messages;
|
|
1101
|
+
payload = JSON.stringify(body).slice(0, -1) + ',"messages":' + opts.serializedMessages + "}";
|
|
1102
|
+
}
|
|
1103
|
+
else {
|
|
1104
|
+
payload = JSON.stringify(body);
|
|
1105
|
+
}
|
|
1106
|
+
opts.signal?.throwIfAborted();
|
|
1107
|
+
const started = Date.now();
|
|
1108
|
+
let status;
|
|
1109
|
+
let ok = false;
|
|
1110
|
+
let diagnostics = {};
|
|
1111
|
+
const controller = new AbortController();
|
|
1112
|
+
const abort = () => controller.abort();
|
|
1113
|
+
const timeout = setTimeout(abort, opts.timeoutMs);
|
|
1114
|
+
timeout.unref();
|
|
1115
|
+
opts.signal?.addEventListener("abort", abort, { once: true });
|
|
1116
|
+
if (opts.signal?.aborted)
|
|
1117
|
+
abort();
|
|
1118
|
+
try {
|
|
1119
|
+
const response = await fetch(`${this.baseUrl}/v1/context_memory`, {
|
|
1120
|
+
method: "POST",
|
|
1121
|
+
headers: {
|
|
1122
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
1123
|
+
"content-type": "application/json",
|
|
1124
|
+
"x-client": CLIENT_NAME,
|
|
1125
|
+
"x-client-version": CLIENT_VERSION,
|
|
1126
|
+
...(opts.sessionId ? { "x-claude-code-session-id": opts.sessionId } : {}),
|
|
1127
|
+
...(opts.clientMeta && Object.keys(opts.clientMeta).length
|
|
1128
|
+
? { "x-client-meta": JSON.stringify(opts.clientMeta) }
|
|
1129
|
+
: {}),
|
|
1130
|
+
// Compress calls only: an index-only call returns no memory.
|
|
1131
|
+
...(this.memtreeTools && !opts.indexOnly
|
|
1132
|
+
? { "x-memtree-tools": this.memtreeTools }
|
|
1133
|
+
: {}),
|
|
1134
|
+
},
|
|
1135
|
+
body: payload,
|
|
1136
|
+
signal: controller.signal,
|
|
1137
|
+
});
|
|
1138
|
+
status = response.status;
|
|
1139
|
+
if (!response.ok) {
|
|
1140
|
+
const text = await response.text().catch(() => "");
|
|
1141
|
+
if (response.status === 402) {
|
|
1142
|
+
this.unpaidDetail = extract402Detail(text);
|
|
1143
|
+
}
|
|
1144
|
+
// The status rides on the error so compress() can classify the
|
|
1145
|
+
// failure for the fuse without parsing message text.
|
|
1146
|
+
throw Object.assign(new Error(`context_memory ${response.status}: ${text.slice(0, 300)}`), { status: response.status });
|
|
1147
|
+
}
|
|
1148
|
+
const json = (await response.json());
|
|
1149
|
+
// An index-only ack is `{ messages: [], usage: {...zeros}, index_only:
|
|
1150
|
+
// true }` by contract: the server skips compression and returns no
|
|
1151
|
+
// messages. Only a compress call needs a non-empty conversation back.
|
|
1152
|
+
if (!opts.indexOnly &&
|
|
1153
|
+
(!Array.isArray(json.messages) || json.messages.length === 0)) {
|
|
1154
|
+
throw new Error("context_memory returned no messages");
|
|
1155
|
+
}
|
|
1156
|
+
const clientLatencyMs = Date.now() - started;
|
|
1157
|
+
// The per-request MemTree page, stamped by the server before the first
|
|
1158
|
+
// body byte; absent on servers that predate the view.
|
|
1159
|
+
const memtreeUrl = response.headers.get("x-polychat-memtree-url") ?? undefined;
|
|
1160
|
+
const memtreeIndex = response.headers.get("x-polychat-memtree-index") ?? undefined;
|
|
1161
|
+
this.unpaidDetail = null; // a success proves the key is paid (again)
|
|
1162
|
+
ok = true;
|
|
1163
|
+
diagnostics = {
|
|
1164
|
+
...(memtreeUrl ? { memtreeUrl } : {}),
|
|
1165
|
+
...(memtreeIndex ? { memtreeIndex } : {}),
|
|
1166
|
+
...(modelBudgetTokens(json) !== undefined
|
|
1167
|
+
? { modelBudgetTokens: modelBudgetTokens(json) }
|
|
1168
|
+
: {}),
|
|
1169
|
+
indexedTokens: cachedPromptTokenCount(json),
|
|
1170
|
+
rawPromptTokens: rawPromptTokenCount(json),
|
|
1171
|
+
// Explicit field when the server sends one, else the first non-system
|
|
1172
|
+
// processed message — the current server's memory layout.
|
|
1173
|
+
memoryChars: typeof json.unfolded_memory === "string"
|
|
1174
|
+
? json.unfolded_memory.length
|
|
1175
|
+
: contentChars(json.messages.find((message) => message?.role !== "system")
|
|
1176
|
+
?.content),
|
|
1177
|
+
};
|
|
1178
|
+
this.log(`context_memory ok in ${clientLatencyMs}ms ` +
|
|
1179
|
+
`(${messages.length} → ${json.messages.length} messages` +
|
|
1180
|
+
`${opts.indexOnly ? ", index-only" : ""})`);
|
|
1181
|
+
return {
|
|
1182
|
+
...json,
|
|
1183
|
+
clientLatencyMs,
|
|
1184
|
+
...(memtreeUrl ? { memtreeUrl } : {}),
|
|
1185
|
+
...(memtreeIndex ? { memtreeIndex } : {}),
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
finally {
|
|
1189
|
+
clearTimeout(timeout);
|
|
1190
|
+
opts.signal?.removeEventListener("abort", abort);
|
|
1191
|
+
// One JSONL line per MemTree call, success or failure; `status` stays
|
|
1192
|
+
// absent when the call never got a response (network error/timeout).
|
|
1193
|
+
this.reqlog?.log({
|
|
1194
|
+
kind: "memtree",
|
|
1195
|
+
indexOnly: opts.indexOnly === true,
|
|
1196
|
+
ms: Date.now() - started,
|
|
1197
|
+
ok,
|
|
1198
|
+
status,
|
|
1199
|
+
requestBytes: Buffer.byteLength(payload),
|
|
1200
|
+
// Present only when the request carried a model for server-side
|
|
1201
|
+
// budget resolution — the client-visible half of the server's
|
|
1202
|
+
// "/v1/context_memory budget" log line.
|
|
1203
|
+
...(opts.model && !opts.indexOnly ? { model: opts.model } : {}),
|
|
1204
|
+
...diagnostics,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
log(msg) {
|
|
1209
|
+
if (this.debug)
|
|
1210
|
+
console.error(`[ccc proxy] ${msg}`);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
/** 402 bodies are FastAPI JSON: {"detail": "<human-readable payment text>"}. */
|
|
1214
|
+
function extract402Detail(bodyText) {
|
|
1215
|
+
try {
|
|
1216
|
+
const detail = JSON.parse(bodyText)?.detail;
|
|
1217
|
+
if (typeof detail === "string" && detail.trim())
|
|
1218
|
+
return detail.trim();
|
|
1219
|
+
}
|
|
1220
|
+
catch {
|
|
1221
|
+
// non-JSON 402 body — fall through to the generic text
|
|
1222
|
+
}
|
|
1223
|
+
return "Payment required";
|
|
1224
|
+
}
|
|
1225
|
+
/** The conversation plus the reply that ended it, unless it already ends with a reply. */
|
|
1226
|
+
export function withFinalReply(messages, reply) {
|
|
1227
|
+
if (!reply || messages.at(-1)?.role === "assistant")
|
|
1228
|
+
return messages;
|
|
1229
|
+
return [...messages, reply];
|
|
1230
|
+
}
|
|
1231
|
+
//# sourceMappingURL=memtree.js.map
|