onepass-proxy 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,340 @@
1
+ import * as http from "node:http";
2
+ import * as https from "node:https";
3
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { evictContextSegments, formatThousands } from "./evict.js";
6
+ import { createProxyLogWriter } from "./log.js";
7
+ import { classifyRebuild, describeRebuild, extractUsage, GAUGE_MIN_ESTIMATED_TOKENS, formatDuration, totalContextTokens, } from "./speed.js";
8
+ // Deliberately low (code averages ~3.2–3.5): over-estimating tokens before the first
9
+ // calibration sample trips eviction early rather than letting a session overshoot the cap.
10
+ export const FALLBACK_CHARS_PER_TOKEN = 3.2;
11
+ const CALIBRATION_MIN_TOKENS = 1000;
12
+ /** How many bodies this process has dumped, so that each name carries the order it was written in. */
13
+ let dumpSequence = 0;
14
+ const USAGE_SCAN_LIMIT_CHARS = 262_144;
15
+ const DROPPED_REQUEST_HEADERS = new Set([
16
+ "host",
17
+ "connection",
18
+ "keep-alive",
19
+ "proxy-authenticate",
20
+ "proxy-authorization",
21
+ "te",
22
+ "trailer",
23
+ "transfer-encoding",
24
+ "upgrade",
25
+ ]);
26
+ const DROPPED_RESPONSE_HEADERS = new Set(["connection", "keep-alive", "transfer-encoding", "te", "trailer", "upgrade"]);
27
+ function filterHeaders(headers, dropped) {
28
+ const filtered = {};
29
+ for (const [name, value] of Object.entries(headers)) {
30
+ if (value === undefined || dropped.has(name.toLowerCase()))
31
+ continue;
32
+ filtered[name] = value;
33
+ }
34
+ return filtered;
35
+ }
36
+ function readEntireBody(request) {
37
+ return new Promise((resolve, reject) => {
38
+ const chunks = [];
39
+ request.on("data", (chunk) => chunks.push(chunk));
40
+ request.on("end", () => resolve(Buffer.concat(chunks)));
41
+ request.on("error", reject);
42
+ });
43
+ }
44
+ function formatTokensShort(tokens) {
45
+ return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
46
+ }
47
+ /** The line the user watches while a session runs. Same numbers the JSONL log records. */
48
+ function formatLiveLine(entry) {
49
+ const parts = [`[onepass] ${entry.timestamp.slice(11, 19)} ${entry.method} ${entry.path} ${entry.status}`];
50
+ if (entry.proxyMs !== undefined)
51
+ parts.push(`proxy ${formatDuration(entry.proxyMs)}`);
52
+ if (entry.upstreamFirstByteMs !== undefined)
53
+ parts.push(`first-byte ${formatDuration(entry.upstreamFirstByteMs)}`);
54
+ parts.push(`total ${formatDuration(entry.durationMs)}`);
55
+ if (entry.cacheReadInputTokens !== undefined && entry.cacheCreationInputTokens !== undefined) {
56
+ parts.push(`cache read ${formatTokensShort(entry.cacheReadInputTokens)} / ` +
57
+ `new ${formatTokensShort(entry.cacheCreationInputTokens)}`);
58
+ }
59
+ if (entry.estimatedTokensBefore !== undefined && entry.estimatedTokensSent !== undefined) {
60
+ parts.push(`est ${formatTokensShort(entry.estimatedTokensBefore)} -> ${formatTokensShort(entry.estimatedTokensSent)} tok, ` +
61
+ `${entry.stubbedResultCount ?? 0} stubbed (${entry.newlyEvictedCount ?? 0} new)`);
62
+ // Over the line with nothing taken is the state worth reading off a live log: either the proxy
63
+ // found nothing it is allowed to evict, or what it found was too small to be worth a trip.
64
+ if (entry.overThreshold === true && (entry.newlyEvictedCount ?? 0) === 0) {
65
+ parts.push(entry.heldBackTokens === undefined
66
+ ? "over T, nothing eligible"
67
+ : `over T, batch of ${formatTokensShort(entry.heldBackTokens)} held back`);
68
+ }
69
+ if (entry.aboveAlarmLine === true)
70
+ parts.push("ABOVE ALARM LINE (T + 40k)");
71
+ }
72
+ const rebuildNote = entry.rebuild === undefined
73
+ ? ""
74
+ : entry.rebuild === "unexpected"
75
+ ? " <- REBUILD (unexpected)"
76
+ : ` <- rebuild (${describeRebuild(entry.rebuild)})`;
77
+ return parts.join(" | ") + rebuildNote;
78
+ }
79
+ export function createProxyServer(config) {
80
+ const upstream = new URL(config.upstreamUrl);
81
+ const upstreamIsHttps = upstream.protocol === "https:";
82
+ const requestModule = upstreamIsHttps ? https : http;
83
+ const upstreamPort = upstream.port !== "" ? Number(upstream.port) : upstreamIsHttps ? 443 : 80;
84
+ const agent = upstreamIsHttps ? new https.Agent({ keepAlive: true }) : new http.Agent({ keepAlive: true });
85
+ const logWriter = createProxyLogWriter(config.logFilePath);
86
+ const evictedSegmentIds = new Set();
87
+ // Live chars-per-token ratio, calibrated from the API's reported usage on each response so
88
+ // the trip threshold is denominated in real tokens rather than a fixed chars ÷ 4 guess.
89
+ let charsPerToken = FALLBACK_CHARS_PER_TOKEN;
90
+ // Speed-gauge bookkeeping. Only /v1/messages requests are classified, but a trip on a
91
+ // count_tokens request changes the prefix for the /v1/messages request that follows it.
92
+ let previousMessagesRequestAt = null;
93
+ let trippedSinceLastMessagesRequest = false;
94
+ function forward(clientRequest, clientResponse, options) {
95
+ const { bufferedBody, evictionMeta, receivedAt, readUsage, rebuildContext } = options;
96
+ const timestamp = new Date(receivedAt).toISOString();
97
+ const method = clientRequest.method ?? "GET";
98
+ const path = clientRequest.url ?? "/";
99
+ const headers = filterHeaders(clientRequest.headers, DROPPED_REQUEST_HEADERS);
100
+ if (bufferedBody !== null)
101
+ headers["content-length"] = bufferedBody.byteLength;
102
+ // The usage scan reads the response as plain text, so ask the upstream not to compress.
103
+ if (readUsage)
104
+ delete headers["accept-encoding"];
105
+ let requestBodyBytes = bufferedBody?.byteLength ?? 0;
106
+ let forwardedAt = null;
107
+ let firstByteAt = null;
108
+ let logged = false;
109
+ const logRequest = (status, usage) => {
110
+ if (logged)
111
+ return;
112
+ logged = true;
113
+ const rebuild = usage === null || rebuildContext === null
114
+ ? null
115
+ : classifyRebuild({
116
+ ...rebuildContext,
117
+ cacheCreationInputTokens: usage.cacheCreationInputTokens,
118
+ contextTotal: totalContextTokens(usage),
119
+ });
120
+ const entry = {
121
+ kind: "request",
122
+ timestamp,
123
+ method,
124
+ path,
125
+ status,
126
+ durationMs: Date.now() - receivedAt,
127
+ ...(forwardedAt !== null ? { proxyMs: forwardedAt - receivedAt } : {}),
128
+ ...(forwardedAt !== null && firstByteAt !== null ? { upstreamFirstByteMs: firstByteAt - forwardedAt } : {}),
129
+ requestBodyBytes,
130
+ sentBodyBytes: bufferedBody?.byteLength ?? requestBodyBytes,
131
+ ...(usage ?? {}),
132
+ ...(rebuild !== null ? { rebuild } : {}),
133
+ ...(evictionMeta ?? {}),
134
+ };
135
+ logWriter.append(entry);
136
+ if (config.quiet !== true)
137
+ console.log(formatLiveLine(entry));
138
+ };
139
+ const upstreamRequest = requestModule.request({
140
+ host: upstream.hostname,
141
+ port: upstreamPort,
142
+ path,
143
+ method,
144
+ headers,
145
+ agent,
146
+ });
147
+ upstreamRequest.setNoDelay(true);
148
+ upstreamRequest.on("response", (upstreamResponse) => {
149
+ const status = upstreamResponse.statusCode ?? 502;
150
+ const scanUsage = readUsage && status === 200;
151
+ let responseHead = "";
152
+ upstreamResponse.on("data", (chunk) => {
153
+ firstByteAt ??= Date.now();
154
+ if (scanUsage && responseHead.length <= USAGE_SCAN_LIMIT_CHARS)
155
+ responseHead += chunk.toString("utf8");
156
+ });
157
+ upstreamResponse.on("end", () => {
158
+ const usage = scanUsage ? extractUsage(responseHead) : null;
159
+ if (usage !== null && bufferedBody !== null) {
160
+ const realInputTokens = totalContextTokens(usage);
161
+ if (realInputTokens >= CALIBRATION_MIN_TOKENS) {
162
+ charsPerToken = Math.min(8, Math.max(2, bufferedBody.byteLength / realInputTokens));
163
+ }
164
+ }
165
+ logRequest(status, usage);
166
+ });
167
+ // A client that hangs up mid-stream never fires `end`, but `close` always fires.
168
+ upstreamResponse.on("close", () => logRequest(status, null));
169
+ clientResponse.writeHead(status, filterHeaders(upstreamResponse.headers, DROPPED_RESPONSE_HEADERS));
170
+ upstreamResponse.pipe(clientResponse);
171
+ });
172
+ upstreamRequest.on("error", (err) => {
173
+ logWriter.append({ kind: "proxy_error", timestamp: new Date().toISOString(), method, path, message: err.message });
174
+ if (!clientResponse.headersSent) {
175
+ clientResponse.writeHead(502, { "content-type": "application/json" });
176
+ clientResponse.end(JSON.stringify({
177
+ type: "error",
178
+ error: { type: "api_error", message: `onepass proxy: upstream request failed (${err.message})` },
179
+ }));
180
+ logRequest(502, null);
181
+ }
182
+ else {
183
+ clientResponse.destroy();
184
+ }
185
+ });
186
+ clientRequest.on("error", () => upstreamRequest.destroy());
187
+ clientResponse.on("close", () => {
188
+ if (!clientResponse.writableEnded)
189
+ upstreamRequest.destroy();
190
+ });
191
+ if (bufferedBody !== null) {
192
+ forwardedAt = Date.now();
193
+ upstreamRequest.end(bufferedBody);
194
+ }
195
+ else {
196
+ clientRequest.on("data", (chunk) => {
197
+ requestBodyBytes += chunk.length;
198
+ });
199
+ forwardedAt = Date.now();
200
+ clientRequest.pipe(upstreamRequest);
201
+ }
202
+ }
203
+ async function handle(clientRequest, clientResponse) {
204
+ const method = clientRequest.method ?? "GET";
205
+ const pathname = new URL(clientRequest.url ?? "/", "http://proxy.local").pathname;
206
+ // count_tokens carries the same messages array and must be evicted identically: the
207
+ // client's context bookkeeping may consume the count, and an un-evicted count describes
208
+ // a request that will never be sent.
209
+ const transformable = method === "POST" &&
210
+ (pathname === "/v1/messages" || pathname === "/v1/messages/count_tokens") &&
211
+ clientRequest.headers["content-encoding"] === undefined;
212
+ if (!transformable) {
213
+ forward(clientRequest, clientResponse, {
214
+ bufferedBody: null,
215
+ evictionMeta: null,
216
+ receivedAt: Date.now(),
217
+ readUsage: false,
218
+ rebuildContext: null,
219
+ });
220
+ return;
221
+ }
222
+ const rawBody = await readEntireBody(clientRequest);
223
+ const receivedAt = Date.now();
224
+ if (config.dumpDir !== undefined) {
225
+ try {
226
+ mkdirSync(config.dumpDir, { recursive: true });
227
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
228
+ const suffix = pathname.replace(/[^a-zA-Z0-9]/g, "_");
229
+ // The name is the clock and then the count, and the eval replays these in name order. The
230
+ // clock alone is not enough: a millisecond holds more than one request, and two requests
231
+ // sharing a name would leave the second erasing the first — a request missing from the
232
+ // middle of an ordered replay with nothing saying so. So every name carries the sequence
233
+ // this process wrote it in, zero-padded so it sorts as a number, which makes name order
234
+ // arrival order exactly rather than nearly. `existsSync` then covers the one case the
235
+ // counter cannot: a second proxy writing into the same directory, which nothing should do.
236
+ let name = `${stamp}_${String((dumpSequence += 1)).padStart(6, "0")}${suffix}.json`;
237
+ while (existsSync(join(config.dumpDir, name))) {
238
+ name = `${stamp}_${String((dumpSequence += 1)).padStart(6, "0")}${suffix}.json`;
239
+ }
240
+ writeFileSync(join(config.dumpDir, name), rawBody);
241
+ }
242
+ catch {
243
+ // Dumping is best-effort; never fail the request over it.
244
+ }
245
+ }
246
+ let forwardBody = rawBody;
247
+ let evictionMeta = null;
248
+ try {
249
+ const parsedBody = JSON.parse(rawBody.toString("utf8"));
250
+ const requestCharsPerToken = Math.round(charsPerToken * 100) / 100;
251
+ const outcome = evictContextSegments(parsedBody, evictedSegmentIds, {
252
+ evictAfterAssistantTurns: config.evictAfterAssistantTurns,
253
+ protectLastAssistantTurns: config.protectLastAssistantTurns,
254
+ minSavedChars: config.minSavedChars,
255
+ tripThresholdTokens: config.tripThresholdTokens,
256
+ batchMinTokens: config.batchMinTokens,
257
+ charsPerToken: requestCharsPerToken,
258
+ });
259
+ for (const id of outcome.newlyEvictedIds)
260
+ evictedSegmentIds.add(id);
261
+ if (outcome.newlyEvictedIds.length > 0) {
262
+ logWriter.append({
263
+ kind: "trip",
264
+ timestamp: new Date().toISOString(),
265
+ addedToolUseIds: outcome.newlyEvictedIds,
266
+ charsRemoved: outcome.newlyEvictedCharsRemoved,
267
+ estimatedTokensBefore: outcome.estimatedTokensBefore,
268
+ estimatedTokensSent: outcome.estimatedTokensSent,
269
+ ...(outcome.pressure ? { pressure: true } : {}),
270
+ });
271
+ if (config.quiet !== true) {
272
+ console.log(`[onepass] TRIP${outcome.pressure ? " (pressure)" : ""}: evicted ${outcome.newlyEvictedIds.length} segment(s), ` +
273
+ `${formatThousands(outcome.newlyEvictedCharsRemoved)} chars removed ` +
274
+ `(est ${formatTokensShort(outcome.estimatedTokensBefore)} -> ${formatTokensShort(outcome.estimatedTokensSent)} tok, ` +
275
+ `${evictedSegmentIds.size} evicted total)`);
276
+ }
277
+ }
278
+ if (outcome.bodyChanged)
279
+ forwardBody = Buffer.from(JSON.stringify(outcome.body), "utf8");
280
+ evictionMeta = {
281
+ estimatedTokensBefore: outcome.estimatedTokensBefore,
282
+ estimatedTokensSent: outcome.estimatedTokensSent,
283
+ overThreshold: outcome.tripped,
284
+ stubbedResultCount: outcome.stubbedIds.length,
285
+ newlyEvictedCount: outcome.newlyEvictedIds.length,
286
+ newlyEvictedCharsRemoved: outcome.newlyEvictedCharsRemoved,
287
+ ...(outcome.heldBackTokens !== undefined ? { heldBackTokens: outcome.heldBackTokens } : {}),
288
+ ...(outcome.aboveAlarmLine ? { aboveAlarmLine: true } : {}),
289
+ charsPerToken: requestCharsPerToken,
290
+ };
291
+ }
292
+ catch {
293
+ // Unparseable body: forward the original bytes untouched. Never fail a request.
294
+ }
295
+ if (evictionMeta !== null && evictionMeta.newlyEvictedCount > 0)
296
+ trippedSinceLastMessagesRequest = true;
297
+ let rebuildContext = null;
298
+ if (pathname === "/v1/messages" && (evictionMeta?.estimatedTokensSent ?? 0) >= GAUGE_MIN_ESTIMATED_TOKENS) {
299
+ rebuildContext = {
300
+ firstMessagesRequest: previousMessagesRequestAt === null,
301
+ tripped: trippedSinceLastMessagesRequest,
302
+ secondsSincePrevious: previousMessagesRequestAt === null ? null : (receivedAt - previousMessagesRequestAt) / 1000,
303
+ };
304
+ previousMessagesRequestAt = receivedAt;
305
+ trippedSinceLastMessagesRequest = false;
306
+ }
307
+ forward(clientRequest, clientResponse, {
308
+ bufferedBody: forwardBody,
309
+ evictionMeta,
310
+ receivedAt,
311
+ readUsage: evictionMeta !== null,
312
+ rebuildContext,
313
+ });
314
+ }
315
+ const server = http.createServer((clientRequest, clientResponse) => {
316
+ handle(clientRequest, clientResponse).catch((err) => {
317
+ const message = err instanceof Error ? err.message : String(err);
318
+ logWriter.append({
319
+ kind: "proxy_error",
320
+ timestamp: new Date().toISOString(),
321
+ method: clientRequest.method ?? "GET",
322
+ path: clientRequest.url ?? "/",
323
+ message,
324
+ });
325
+ if (!clientResponse.headersSent) {
326
+ clientResponse.writeHead(502, { "content-type": "application/json" });
327
+ clientResponse.end(JSON.stringify({ type: "error", error: { type: "api_error", message: `onepass proxy: ${message}` } }));
328
+ }
329
+ else {
330
+ clientResponse.destroy();
331
+ }
332
+ });
333
+ });
334
+ server.on("connection", (socket) => socket.setNoDelay(true));
335
+ server.on("close", () => {
336
+ agent.destroy();
337
+ logWriter.close();
338
+ });
339
+ return server;
340
+ }
@@ -0,0 +1,100 @@
1
+ // Reading what a session left behind: the Claude Code transcript (read-only, always) and the
2
+ // proxy's own JSONL log. Both `onepass-report` and `claudep`'s exit line are built from these,
3
+ // which is why they live here rather than inside the reporter.
4
+ import { createReadStream, readFileSync } from "node:fs";
5
+ import { createInterface } from "node:readline";
6
+ import { measureContentChars } from "./evict.js";
7
+ const RECALL_TOOL_NAME = /(^|__)recall_(search|get)$/;
8
+ function isRecord(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ export async function scanTranscript(path) {
12
+ const stats = {
13
+ entryCount: 0,
14
+ firstTimestamp: null,
15
+ lastTimestamp: null,
16
+ compactionCount: 0,
17
+ recallResultCount: 0,
18
+ recallChars: 0,
19
+ realUsagePeak: 0,
20
+ realUsageSamples: 0,
21
+ realUsageTurnsAbove150k: 0,
22
+ };
23
+ const recallToolUseIds = new Set();
24
+ const lines = createInterface({ input: createReadStream(path, "utf8"), crlfDelay: Infinity });
25
+ for await (const line of lines) {
26
+ if (line.trim() === "")
27
+ continue;
28
+ let entry;
29
+ try {
30
+ entry = JSON.parse(line);
31
+ }
32
+ catch {
33
+ continue;
34
+ }
35
+ if (!isRecord(entry))
36
+ continue;
37
+ stats.entryCount++;
38
+ if (typeof entry.timestamp === "string") {
39
+ stats.firstTimestamp ??= entry.timestamp;
40
+ stats.lastTimestamp = entry.timestamp;
41
+ }
42
+ if (entry.isCompactSummary === true || (entry.compactMetadata !== undefined && entry.compactMetadata !== null)) {
43
+ stats.compactionCount++;
44
+ }
45
+ const message = entry.message;
46
+ if (!isRecord(message))
47
+ continue;
48
+ if (entry.type === "assistant" && isRecord(message.usage)) {
49
+ const usage = message.usage;
50
+ const asNumber = (value) => (typeof value === "number" ? value : 0);
51
+ const realContext = asNumber(usage.input_tokens) +
52
+ asNumber(usage.cache_creation_input_tokens) +
53
+ asNumber(usage.cache_read_input_tokens);
54
+ if (realContext > 0) {
55
+ stats.realUsageSamples++;
56
+ if (realContext > stats.realUsagePeak)
57
+ stats.realUsagePeak = realContext;
58
+ if (realContext > 150_000)
59
+ stats.realUsageTurnsAbove150k++;
60
+ }
61
+ }
62
+ if (!Array.isArray(message.content))
63
+ continue;
64
+ for (const block of message.content) {
65
+ if (!isRecord(block))
66
+ continue;
67
+ if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
68
+ if (RECALL_TOOL_NAME.test(block.name))
69
+ recallToolUseIds.add(block.id);
70
+ }
71
+ else if (block.type === "tool_result" &&
72
+ typeof block.tool_use_id === "string" &&
73
+ recallToolUseIds.has(block.tool_use_id)) {
74
+ stats.recallResultCount++;
75
+ stats.recallChars += measureContentChars(block.content);
76
+ }
77
+ }
78
+ }
79
+ return stats;
80
+ }
81
+ export function parseProxyLog(path) {
82
+ const requests = [];
83
+ const trips = [];
84
+ for (const line of readFileSync(path, "utf8").split("\n")) {
85
+ if (line.trim() === "")
86
+ continue;
87
+ let entry;
88
+ try {
89
+ entry = JSON.parse(line);
90
+ }
91
+ catch {
92
+ continue;
93
+ }
94
+ if (entry.kind === "request" && entry.path.split("?")[0] === "/v1/messages")
95
+ requests.push(entry);
96
+ else if (entry.kind === "trip")
97
+ trips.push(entry);
98
+ }
99
+ return { requests, trips };
100
+ }
package/dist/speed.js ADDED
@@ -0,0 +1,77 @@
1
+ // The speed gauge: reads the API's reported usage off a response, and decides whether a
2
+ // request made Anthropic re-read the conversation (a "rebuild") instead of serving it from
3
+ // cache. A rebuild costs seconds on that one turn, so an unexplained one is a bug worth
4
+ // seeing. No I/O — the caller owns the per-session bookkeeping this reads.
5
+ /**
6
+ * Pull the usage numbers out of an Anthropic response — the first `usage` object in the body
7
+ * (message_start for SSE, top level for JSON). Brace-matched rather than regexed whole: usage
8
+ * contains nested objects (`cache_creation`, `server_tool_use`).
9
+ */
10
+ export function extractUsage(responseText) {
11
+ const keyIndex = responseText.indexOf('"usage"');
12
+ if (keyIndex === -1)
13
+ return null;
14
+ const openIndex = responseText.indexOf("{", keyIndex);
15
+ if (openIndex === -1)
16
+ return null;
17
+ let depth = 0;
18
+ let closeIndex = -1;
19
+ for (let i = openIndex; i < responseText.length; i++) {
20
+ const ch = responseText[i];
21
+ if (ch === "{")
22
+ depth++;
23
+ else if (ch === "}" && --depth === 0) {
24
+ closeIndex = i;
25
+ break;
26
+ }
27
+ }
28
+ if (closeIndex === -1)
29
+ return null;
30
+ const usageSlice = responseText.slice(openIndex, closeIndex + 1);
31
+ // The leading quote matters: without it `"input_tokens"` would also match the tail of
32
+ // `"cache_creation_input_tokens"` and of the nested `cache_creation` ephemeral counters.
33
+ const read = (wireName) => {
34
+ const match = new RegExp(`"${wireName}"\\s*:\\s*(\\d+)`).exec(usageSlice);
35
+ return match === null ? 0 : Number(match[1]);
36
+ };
37
+ const usage = {
38
+ inputTokens: read("input_tokens"),
39
+ cacheCreationInputTokens: read("cache_creation_input_tokens"),
40
+ cacheReadInputTokens: read("cache_read_input_tokens"),
41
+ };
42
+ return totalContextTokens(usage) > 0 ? usage : null;
43
+ }
44
+ /** Everything Anthropic read for this request, however it got there. */
45
+ export function totalContextTokens(usage) {
46
+ return usage.inputTokens + usage.cacheCreationInputTokens + usage.cacheReadInputTokens;
47
+ }
48
+ // Claude Code makes several kinds of /v1/messages call — the conversation itself, plus small
49
+ // side calls (title generation, warm-ups) that carry their own separate cache prefix. Only the
50
+ // conversation is gauged: mixing the side calls in makes the session's real first request look
51
+ // like an unexplained rebuild, and a rebuild this small costs no measurable time anyway.
52
+ export const GAUGE_MIN_ESTIMATED_TOKENS = 20_000;
53
+ // A turn always writes a little fresh cache (the new user message and tool results), so a
54
+ // rebuild is a share of the whole context, not any non-zero creation count.
55
+ const REBUILD_SHARE_OF_CONTEXT = 0.2;
56
+ // Anthropic's ephemeral cache entries expire after 5 minutes of not being read.
57
+ const CACHE_TTL_SECONDS = 300;
58
+ export function classifyRebuild(input) {
59
+ if (input.contextTotal <= 0)
60
+ return null;
61
+ if (input.cacheCreationInputTokens / input.contextTotal <= REBUILD_SHARE_OF_CONTEXT)
62
+ return null;
63
+ if (input.firstMessagesRequest)
64
+ return "first";
65
+ if (input.tripped)
66
+ return "after-trip";
67
+ if (input.secondsSincePrevious !== null && input.secondsSincePrevious > CACHE_TTL_SECONDS)
68
+ return "after-idle";
69
+ return "unexpected";
70
+ }
71
+ /** "after-trip" -> "after trip"; used in the stdout line and the report. */
72
+ export function describeRebuild(rebuild) {
73
+ return rebuild.replace("-", " ");
74
+ }
75
+ export function formatDuration(milliseconds) {
76
+ return milliseconds >= 1000 ? `${(milliseconds / 1000).toFixed(1)}s` : `${milliseconds}ms`;
77
+ }
@@ -0,0 +1,79 @@
1
+ // Which transcript on disk belongs to the session asking for it.
2
+ //
3
+ // Recall reads the session's own history back after the proxy has evicted it, so reading the
4
+ // wrong session's history is worse than reading none: it answers confidently out of a
5
+ // conversation the agent was never in. `claudep` names the session id it started, which is the
6
+ // only identifier that cannot be confused between two sessions in one directory. Without one —
7
+ // a proxy the user started by hand — the newest transcript in this directory is the best guess
8
+ // available, and that is what recall did everywhere before session ids existed.
9
+ import { existsSync, readdirSync, statSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ /** Where Claude Code keeps its sessions. `CLAUDE_CONFIG_DIR` moves the whole directory. */
13
+ export function claudeConfigDir(env = process.env) {
14
+ const configured = env.CLAUDE_CONFIG_DIR;
15
+ return configured !== undefined && configured !== "" ? configured : join(homedir(), ".claude");
16
+ }
17
+ /** Claude Code stores each session under a slug of the cwd with separators replaced by dashes. */
18
+ export function transcriptDir(cwd, env = process.env) {
19
+ return join(claudeConfigDir(env), "projects", cwd.replace(/[/.]/g, "-"));
20
+ }
21
+ /**
22
+ * A transcript by session id. Every project directory is searched rather than the one the cwd
23
+ * slugifies to: the slug rule is Claude Code's and can change, while a session id is unique.
24
+ */
25
+ export function findTranscript(sessionId, env = process.env) {
26
+ const projects = join(claudeConfigDir(env), "projects");
27
+ let directories;
28
+ try {
29
+ directories = readdirSync(projects);
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ for (const directory of directories) {
35
+ const path = join(projects, directory, `${sessionId}.jsonl`);
36
+ if (existsSync(path))
37
+ return path;
38
+ }
39
+ return null;
40
+ }
41
+ /** The most recently written transcript for this directory, whoever wrote it. */
42
+ export function newestTranscript(cwd, env = process.env) {
43
+ const dir = transcriptDir(cwd, env);
44
+ let names;
45
+ try {
46
+ names = readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ let newest = null;
52
+ for (const name of names) {
53
+ const path = join(dir, name);
54
+ const { mtimeMs } = statSync(path);
55
+ if (newest === null || mtimeMs > newest.mtimeMs)
56
+ newest = { path, mtimeMs };
57
+ }
58
+ return newest?.path ?? null;
59
+ }
60
+ /**
61
+ * The transcript this recall server should read. `ONEPASS_SESSION_ID` is set by `claudep`, one
62
+ * value per session, so two sessions in one directory never read each other's history.
63
+ */
64
+ export function transcriptForSession(env, cwd) {
65
+ const sessionId = env.ONEPASS_SESSION_ID;
66
+ if (sessionId !== undefined && sessionId !== "") {
67
+ const path = findTranscript(sessionId, env);
68
+ return path !== null
69
+ ? { path, reason: "" }
70
+ : {
71
+ path: null,
72
+ // Normal at the very start: the file appears once the session's first turn is written.
73
+ reason: `No transcript yet for session ${sessionId} under ${join(claudeConfigDir(env), "projects")} — ` +
74
+ `it is written as the session runs.`,
75
+ };
76
+ }
77
+ const path = newestTranscript(cwd, env);
78
+ return path !== null ? { path, reason: "" } : { path: null, reason: `No transcript found under ${transcriptDir(cwd, env)}` };
79
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "onepass-proxy",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "description": "Local HTTP proxy between Claude Code and the Anthropic API that evicts old tool results from outgoing requests, keeping long sessions clear of auto-compact.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Julian-Win-Stack/OnePass.git",
10
+ "directory": "proxy"
11
+ },
12
+ "keywords": [
13
+ "claude-code",
14
+ "claude",
15
+ "anthropic",
16
+ "context-window",
17
+ "proxy",
18
+ "compaction"
19
+ ],
20
+ "bin": {
21
+ "claudep": "dist/claudep.js",
22
+ "onepass-proxy": "dist/main.js",
23
+ "onepass-recall": "dist/recall.js",
24
+ "onepass-report": "dist/report.js"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "!dist/*.test.js"
29
+ ],
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc",
35
+ "start": "node dist/main.js",
36
+ "test": "npm run build && node --test dist/*.test.js",
37
+ "report": "npm run build && node dist/report.js",
38
+ "prepublishOnly": "npm test"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^24.0.0",
42
+ "typescript": "^5.7.0"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.30.0",
46
+ "zod": "^4.6.2"
47
+ }
48
+ }