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/evict.js ADDED
@@ -0,0 +1,416 @@
1
+ // Pure transform over an Anthropic POST /v1/messages request body: replaces old, large,
2
+ // recoverable context segments with short deterministic stubs. Four segment kinds by rule:
3
+ // - tool_result blocks (recover: re-run the tool / re-read the file, or recall)
4
+ // - tool_use inputs — the calls themselves (recover: the edit already landed on disk and the
5
+ // command already ran, so read the file or re-run it, or recall)
6
+ // - attached file content the harness injects as "<system-reminder>\nResult of calling the
7
+ // Read tool:" user text (recover: read the file from disk, or recall)
8
+ // - "<task-notification>" user text (recover: read the task's output file, or recall)
9
+ // The whitelist is the whole of it: the user's own text is never touched, and neither is
10
+ // anything else injected — CLAUDE.md instructions, skill and agent listings, compaction
11
+ // summaries — which stay protected by omission.
12
+ // No I/O — the caller owns the evicted-id set, the threshold state, and logging.
13
+ import { createHash } from "node:crypto";
14
+ /**
15
+ * The alarm line sits this far above T. Peaks already run ~30k over T and a held-back batch adds up
16
+ * to the batch minimum, so a request past it means the floor itself has grown too big. There is
17
+ * deliberately no line above which the minimum is waived: that would bring the tiny trips back in
18
+ * exactly the sessions where they cost most.
19
+ */
20
+ export const ALARM_LINE_MARGIN_TOKENS = 40_000;
21
+ export const STUB_PREFIX = "[onepass: evicted";
22
+ // Wire formats measured from real Claude Code requests (docs/findings.md §13). Prefix-matched
23
+ // exactly: any drift in the harness makes the proxy skip the segment, never mis-evict it.
24
+ const ATTACHED_FILE_PREFIX = "<system-reminder>\nResult of calling the Read tool:";
25
+ const TASK_NOTIFICATION_PREFIX = "<task-notification>";
26
+ const READ_INPUT_PREFIX = "<system-reminder>\nCalled the Read tool with the following input:";
27
+ export function isRecord(value) {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value);
29
+ }
30
+ /** For each message, how many assistant messages follow it — the age every gate here reads. */
31
+ function assistantTurnsAfterByMessage(messages) {
32
+ const turnsAfter = new Array(messages.length).fill(0);
33
+ let assistantsSeen = 0;
34
+ for (let i = messages.length - 1; i >= 0; i--) {
35
+ turnsAfter[i] = assistantsSeen;
36
+ const message = messages[i];
37
+ if (isRecord(message) && message.role === "assistant")
38
+ assistantsSeen++;
39
+ }
40
+ return turnsAfter;
41
+ }
42
+ export function formatThousands(n) {
43
+ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
44
+ }
45
+ export function estimateTokens(value, charsPerToken = 4) {
46
+ try {
47
+ const json = JSON.stringify(value);
48
+ return json === undefined ? 0 : Math.round(json.length / charsPerToken);
49
+ }
50
+ catch {
51
+ return 0;
52
+ }
53
+ }
54
+ export function measureContentChars(content) {
55
+ if (typeof content === "string")
56
+ return content.length;
57
+ if (content === undefined || content === null)
58
+ return 0;
59
+ try {
60
+ const json = JSON.stringify(content);
61
+ return json === undefined ? 0 : json.length;
62
+ }
63
+ catch {
64
+ return 0;
65
+ }
66
+ }
67
+ function sanitizeForStub(text) {
68
+ return text.replace(/\s+/g, " ").replace(/"/g, "'").trim();
69
+ }
70
+ // Deterministic on purpose: the same request must always produce the same bytes, or the
71
+ // stubs themselves would break the prompt-cache prefix they exist to protect.
72
+ //
73
+ // The stub names nothing on its own: the tool_use beside it still carries the call's `name`,
74
+ // and where that call was stubbed too its path is appended here by
75
+ // `nameEvictedCallsInResultStubs` — once, in the one block of the pair the model never writes.
76
+ // How to get any of it back is one sentence in the recall tool's description
77
+ // (spike/src/server.ts). Naming the target in every stub is what made stubs the largest thing
78
+ // in the request they exist to shrink: 12% of the measured peak (docs/findings.md §15).
79
+ function buildEvictedStub(originalChars) {
80
+ return `${STUB_PREFIX} ${formatThousands(originalChars)} chars]`;
81
+ }
82
+ // A stubbed call keeps nothing at all: the API rejects a tool_use whose `input` is not an
83
+ // object, so `{}` is the smallest legal stub.
84
+ //
85
+ // The shape this replaced was `{ file_path | command, evicted }`, and the model copied it into
86
+ // calls it meant to make — every Bash imitation truncating its own command at exactly the 80
87
+ // chars this file used to truncate at, on commands the session had never run. Whatever sits in
88
+ // this slot is written in the agent's own voice, which is why it gets copied; the identical
89
+ // marker text in 361 tool *results* was copied zero times (docs/findings.md §18).
90
+ //
91
+ // Emptiness is copied too — run 6 imitated `{}` three times in 557 turns, at a higher share of
92
+ // calls stubbed than run 5's 11. What it cannot do is become a valid call: every tool the agent
93
+ // uses has a required parameter, so an imitated `{}` is rejected on the spot and costs one turn.
94
+ // That is the property the old shape lacked, where a stubbed Bash call kept a truncated
95
+ // `{ command }` that would have run.
96
+ const EMPTY_CALL_INPUT_CHARS = measureContentChars({});
97
+ /** File path from a call's input, when it has one. Moves to the paired result's stub. */
98
+ function callPathFrom(input) {
99
+ if (typeof input.file_path === "string")
100
+ return input.file_path;
101
+ return typeof input.path === "string" ? input.path : undefined;
102
+ }
103
+ // Appended to the stub of a result whose own call was stubbed too, so the pair still says which
104
+ // file it was. Safe here and not in the call: the model never writes a tool_result block.
105
+ // A command is deliberately not carried over — a truncated one is no better a recall key than
106
+ // the file paths and error text already in the request, and re-teaching truncation is the
107
+ // failure this stub exists to avoid.
108
+ function buildEvictedCallSuffix(callPath) {
109
+ return callPath === undefined ? "; call evicted" : `; call evicted, ${callPath}`;
110
+ }
111
+ /**
112
+ * Chars a stubbed call costs: its emptied input, plus the suffix its result's stub gains for it.
113
+ * Charged whole even when the result stays live and never gains the suffix, which only ever
114
+ * makes the size floor stricter.
115
+ */
116
+ function evictedCallChars(callPath) {
117
+ return EMPTY_CALL_INPUT_CHARS + buildEvictedCallSuffix(callPath).length;
118
+ }
119
+ // Names no path either: the "Called the Read tool with the following input" marker beside the
120
+ // attachment carries it, and that marker is never evicted.
121
+ function buildAttachedFileStub(originalChars) {
122
+ return `${STUB_PREFIX} attached file, ${formatThousands(originalChars)} chars]`;
123
+ }
124
+ // The one stub that still names its target: nothing else in the request carries the task id or
125
+ // the path its output was written to.
126
+ function buildTaskNotificationStub(text, originalChars) {
127
+ const taskId = /<task-id>([^<]+)<\/task-id>/.exec(text)?.[1];
128
+ const outputFile = /<output-file>([^<]+)<\/output-file>/.exec(text)?.[1];
129
+ const idPart = taskId === undefined ? "" : ` ${sanitizeForStub(taskId)}`;
130
+ const outputPart = outputFile === undefined ? "" : `; output at ${sanitizeForStub(outputFile)}`;
131
+ return `${STUB_PREFIX} task notification${idPart}, ${formatThousands(originalChars)} chars${outputPart}]`;
132
+ }
133
+ /** Chars the segment costs once stubbed. */
134
+ function stubbedChars(segment) {
135
+ return segment.kind === "call" ? evictedCallChars(segment.callPath) : segment.stubText.length;
136
+ }
137
+ /** Segment id for a text block: a content hash, so it re-matches when the client resends it. */
138
+ export function textSegmentId(text) {
139
+ return `sha1:${createHash("sha1").update(text).digest("hex")}`;
140
+ }
141
+ /** Segment id for a tool_use input — distinct from the result's own `tool_use_id`. */
142
+ export function callSegmentId(toolUseId) {
143
+ return `call:${toolUseId}`;
144
+ }
145
+ function collectSegments(messages) {
146
+ const assistantTurnsAfterIndex = assistantTurnsAfterByMessage(messages);
147
+ const segments = [];
148
+ const classifyText = (text, messageIndex, blockIndex) => {
149
+ // Never a segment: this marker is what names the path for the attached-file stub next to
150
+ // it, so evicting it would take the attachment's only remaining pointer with it.
151
+ if (text.startsWith(READ_INPUT_PREFIX))
152
+ return;
153
+ const id = textSegmentId(text);
154
+ let stubText;
155
+ if (text.startsWith(ATTACHED_FILE_PREFIX)) {
156
+ stubText = buildAttachedFileStub(text.length);
157
+ }
158
+ else if (text.startsWith(TASK_NOTIFICATION_PREFIX)) {
159
+ stubText = buildTaskNotificationStub(text, text.length);
160
+ }
161
+ else {
162
+ // Anything else in a user message is the user's own text, or something injected that no
163
+ // rule owns. Not a segment at all: the whitelist above is the whole of what may be evicted.
164
+ return;
165
+ }
166
+ segments.push({
167
+ kind: "text",
168
+ id,
169
+ messageIndex,
170
+ blockIndex,
171
+ contentChars: text.length,
172
+ assistantTurnsAfter: assistantTurnsAfterIndex[messageIndex] ?? 0,
173
+ alreadyStubShaped: text.startsWith(STUB_PREFIX),
174
+ stubText,
175
+ });
176
+ };
177
+ messages.forEach((message, messageIndex) => {
178
+ if (!isRecord(message))
179
+ return;
180
+ if (message.role === "assistant") {
181
+ if (!Array.isArray(message.content))
182
+ return;
183
+ message.content.forEach((block, blockIndex) => {
184
+ if (!isRecord(block) || block.type !== "tool_use" || typeof block.id !== "string")
185
+ return;
186
+ const input = block.input;
187
+ if (!isRecord(input))
188
+ return;
189
+ segments.push({
190
+ kind: "call",
191
+ id: callSegmentId(block.id),
192
+ toolUseId: block.id,
193
+ messageIndex,
194
+ blockIndex,
195
+ contentChars: measureContentChars(input),
196
+ assistantTurnsAfter: assistantTurnsAfterIndex[messageIndex] ?? 0,
197
+ // An empty input is the stub. A real call with no arguments reads the same, and gets
198
+ // the same treatment either way: its stub would save nothing, so it is never a target.
199
+ alreadyStubShaped: Object.keys(input).length === 0,
200
+ callPath: callPathFrom(input),
201
+ });
202
+ });
203
+ return;
204
+ }
205
+ if (message.role !== "user")
206
+ return;
207
+ const content = message.content;
208
+ if (typeof content === "string") {
209
+ classifyText(content, messageIndex, null);
210
+ return;
211
+ }
212
+ if (!Array.isArray(content))
213
+ return;
214
+ content.forEach((block, blockIndex) => {
215
+ if (!isRecord(block))
216
+ return;
217
+ if (block.type === "tool_result" && typeof block.tool_use_id === "string") {
218
+ const contentChars = measureContentChars(block.content);
219
+ segments.push({
220
+ kind: "tool_result",
221
+ id: block.tool_use_id,
222
+ messageIndex,
223
+ blockIndex,
224
+ contentChars,
225
+ assistantTurnsAfter: assistantTurnsAfterIndex[messageIndex] ?? 0,
226
+ alreadyStubShaped: typeof block.content === "string" && block.content.startsWith(STUB_PREFIX),
227
+ stubText: buildEvictedStub(contentChars),
228
+ });
229
+ }
230
+ else if (block.type === "text" && typeof block.text === "string") {
231
+ classifyText(block.text, messageIndex, blockIndex);
232
+ }
233
+ });
234
+ });
235
+ return segments;
236
+ }
237
+ function applyStubs(messages, targets) {
238
+ if (targets.length === 0)
239
+ return { messages, charsRemoved: 0, stubbedIds: [] };
240
+ const targetsByMessage = new Map();
241
+ for (const target of targets) {
242
+ const forMessage = targetsByMessage.get(target.messageIndex);
243
+ if (forMessage === undefined)
244
+ targetsByMessage.set(target.messageIndex, [target]);
245
+ else
246
+ forMessage.push(target);
247
+ }
248
+ let charsRemoved = 0;
249
+ const stubbedIds = [];
250
+ const nextMessages = messages.map((message, messageIndex) => {
251
+ const forMessage = targetsByMessage.get(messageIndex);
252
+ if (forMessage === undefined || !isRecord(message))
253
+ return message;
254
+ // Only a text segment is ever a message's whole string content.
255
+ const wholeString = forMessage.find((target) => target.blockIndex === null);
256
+ if (wholeString !== undefined && wholeString.kind !== "call" && typeof message.content === "string") {
257
+ charsRemoved += wholeString.contentChars - stubbedChars(wholeString);
258
+ stubbedIds.push(wholeString.id);
259
+ return { ...message, content: wholeString.stubText };
260
+ }
261
+ if (!Array.isArray(message.content))
262
+ return message;
263
+ const targetByBlock = new Map();
264
+ for (const target of forMessage) {
265
+ if (target.blockIndex !== null)
266
+ targetByBlock.set(target.blockIndex, target);
267
+ }
268
+ const nextContent = message.content.map((block, blockIndex) => {
269
+ const target = targetByBlock.get(blockIndex);
270
+ if (target === undefined || !isRecord(block))
271
+ return block;
272
+ stubbedIds.push(target.id);
273
+ charsRemoved += target.contentChars - stubbedChars(target);
274
+ // A call stubs to a fresh empty input — never a shared one, since a body that stubs N
275
+ // calls to one aliased object is one stray write away from losing byte-determinism.
276
+ if (target.kind === "call")
277
+ return { ...block, input: {} };
278
+ return target.kind === "tool_result"
279
+ ? { ...block, content: target.stubText }
280
+ : { ...block, text: target.stubText };
281
+ });
282
+ return { ...message, content: nextContent };
283
+ });
284
+ return { messages: nextMessages, charsRemoved, stubbedIds };
285
+ }
286
+ /**
287
+ * Moves each stubbed call's path into the stub of its own result. Runs after the passes because
288
+ * only then is it known which calls were stubbed. Its cost is already charged to the call by
289
+ * `stubbedChars`, so there is nothing here to account for.
290
+ */
291
+ function nameEvictedCallsInResultStubs(messages,
292
+ /** tool_use_id -> suffix, for pairs where this pass stubbed the call *and* its result. */
293
+ suffixByToolUseId) {
294
+ if (suffixByToolUseId.size === 0)
295
+ return messages;
296
+ return messages.map((message) => {
297
+ if (!isRecord(message) || message.role !== "user" || !Array.isArray(message.content))
298
+ return message;
299
+ let changed = false;
300
+ const content = message.content.map((block) => {
301
+ if (!isRecord(block) || block.type !== "tool_result" || typeof block.tool_use_id !== "string")
302
+ return block;
303
+ const suffix = suffixByToolUseId.get(block.tool_use_id);
304
+ // An entry exists only where this pass stubbed this very result, so the content is a stub
305
+ // it just wrote and ends in the bracket being reopened. A live result has no entry.
306
+ if (suffix === undefined || typeof block.content !== "string")
307
+ return block;
308
+ changed = true;
309
+ return { ...block, content: `${block.content.slice(0, -1)}${suffix}]` };
310
+ });
311
+ return changed ? { ...message, content } : message;
312
+ });
313
+ }
314
+ export function evictContextSegments(body, alreadyEvictedIds, config) {
315
+ const estimatedTokensBefore = estimateTokens(body, config.charsPerToken);
316
+ const isAboveAlarmLine = (tokensSent) => tokensSent > config.tripThresholdTokens + ALARM_LINE_MARGIN_TOKENS;
317
+ const passthrough = {
318
+ body,
319
+ bodyChanged: false,
320
+ tripped: false,
321
+ newlyEvictedIds: [],
322
+ stubbedIds: [],
323
+ pressure: false,
324
+ charsRemoved: 0,
325
+ newlyEvictedCharsRemoved: 0,
326
+ estimatedTokensBefore,
327
+ estimatedTokensSent: estimatedTokensBefore,
328
+ aboveAlarmLine: isAboveAlarmLine(estimatedTokensBefore),
329
+ };
330
+ if (!isRecord(body) || !Array.isArray(body.messages))
331
+ return passthrough;
332
+ const messages = body.messages;
333
+ // A stub that saves too little is not worth the trip, and one no smaller than what it
334
+ // replaces would grow the request — with monotonic eviction re-paying that on every request
335
+ // after. A Read call is the case: emptying its input saves almost nothing, and most of that
336
+ // comes back as the path appended to its result's stub.
337
+ // Dropping the segment here keeps its id out of the evicted set entirely.
338
+ const candidates = collectSegments(messages).filter((segment) => !segment.alreadyStubShaped && segment.contentChars - stubbedChars(segment) >= config.minSavedChars);
339
+ // Monotonic: an id evicted on any earlier request is stubbed again on every request.
340
+ // The protected-window guard matters for text segments only: content re-attached after a
341
+ // fresh Read has the same hash, and stubbing the young copy would break the stub's own
342
+ // "read the file for current content" recovery path. (A tool_use_id never re-ages into
343
+ // the window, so the guard is a no-op for tool results.)
344
+ const existingTargets = candidates.filter((segment) => alreadyEvictedIds.has(segment.id) &&
345
+ segment.assistantTurnsAfter >= config.protectLastAssistantTurns);
346
+ const afterExisting = applyStubs(messages, existingTargets);
347
+ const estimatedTokensAfterExisting = afterExisting.stubbedIds.length > 0
348
+ ? estimateTokens({ ...body, messages: afterExisting.messages }, config.charsPerToken)
349
+ : estimatedTokensBefore;
350
+ const tripped = estimatedTokensAfterExisting > config.tripThresholdTokens;
351
+ const isNewTarget = (segment, minAge) => !alreadyEvictedIds.has(segment.id) &&
352
+ segment.assistantTurnsAfter >= minAge &&
353
+ segment.assistantTurnsAfter >= config.protectLastAssistantTurns;
354
+ const newTargets = tripped
355
+ ? candidates.filter((segment) => isNewTarget(segment, config.evictAfterAssistantTurns))
356
+ : [];
357
+ let afterNew = applyStubs(afterExisting.messages, newTargets);
358
+ // Pressure pass: a burst of fresh large results can leave the request above T with nothing
359
+ // aged past N yet. Rather than let the client cross its compaction threshold, relax the age
360
+ // gate down to K — the last K turns stay untouchable, everything older is fair game.
361
+ let pressure = false;
362
+ let afterPressure = { messages: afterNew.messages, charsRemoved: 0, stubbedIds: [] };
363
+ if (tripped) {
364
+ const stillOverThreshold = estimateTokens({ ...body, messages: afterNew.messages }, config.charsPerToken) > config.tripThresholdTokens;
365
+ if (stillOverThreshold) {
366
+ const alreadyTargeted = new Set(newTargets.map((target) => target.id));
367
+ const pressureTargets = candidates.filter((segment) => !alreadyTargeted.has(segment.id) && isNewTarget(segment, config.protectLastAssistantTurns));
368
+ if (pressureTargets.length > 0) {
369
+ pressure = true;
370
+ afterPressure = applyStubs(afterNew.messages, pressureTargets);
371
+ }
372
+ }
373
+ }
374
+ // The batch minimum, applied to the normal and pressure targets together: a trip rewrites the
375
+ // cached conversation from the first message it changes, so one that frees too little is held
376
+ // back whole. Nothing held back joins the evicted set, so all of it is a candidate again next
377
+ // request, when one more turn's worth may carry the batch over the line.
378
+ const batchChars = afterNew.charsRemoved + afterPressure.charsRemoved;
379
+ const heldBack = {};
380
+ if (batchChars > 0 && batchChars < config.batchMinTokens * config.charsPerToken) {
381
+ heldBack.heldBackTokens = Math.round(batchChars / config.charsPerToken);
382
+ pressure = false;
383
+ afterNew = { messages: afterExisting.messages, charsRemoved: 0, stubbedIds: [] };
384
+ afterPressure = afterNew;
385
+ }
386
+ const newlyEvictedIds = [...afterNew.stubbedIds, ...afterPressure.stubbedIds];
387
+ const stubbedIds = [...afterExisting.stubbedIds, ...newlyEvictedIds];
388
+ if (stubbedIds.length === 0)
389
+ return { ...passthrough, tripped, ...heldBack };
390
+ // A pair earns a suffix only when both halves were stubbed on this request: a live result
391
+ // still names its own file, and a live call still carries its own input.
392
+ const stubbed = new Set(stubbedIds);
393
+ const suffixByToolUseId = new Map();
394
+ for (const segment of candidates) {
395
+ if (segment.kind !== "call" || !stubbed.has(segment.id) || !stubbed.has(segment.toolUseId))
396
+ continue;
397
+ suffixByToolUseId.set(segment.toolUseId, buildEvictedCallSuffix(segment.callPath));
398
+ }
399
+ const namedMessages = nameEvictedCallsInResultStubs(afterPressure.messages, suffixByToolUseId);
400
+ const finalBody = { ...body, messages: namedMessages };
401
+ const estimatedTokensSent = estimateTokens(finalBody, config.charsPerToken);
402
+ return {
403
+ body: finalBody,
404
+ bodyChanged: true,
405
+ tripped,
406
+ newlyEvictedIds,
407
+ stubbedIds,
408
+ pressure,
409
+ charsRemoved: afterExisting.charsRemoved + afterNew.charsRemoved + afterPressure.charsRemoved,
410
+ newlyEvictedCharsRemoved: afterNew.charsRemoved + afterPressure.charsRemoved,
411
+ estimatedTokensBefore,
412
+ estimatedTokensSent,
413
+ ...heldBack,
414
+ aboveAlarmLine: isAboveAlarmLine(estimatedTokensSent),
415
+ };
416
+ }
package/dist/launch.js ADDED
@@ -0,0 +1,176 @@
1
+ // The decisions `claudep` makes before anything starts: what Claude Code is run with, which
2
+ // transcript belongs to the session that just ended, and what the one-line summary says.
3
+ //
4
+ // Separated from `claudep.ts` so every decision here is testable without spawning anything.
5
+ import { formatThousands } from "./evict.js";
6
+ /**
7
+ * Claude Code's own subcommands. `claudep mcp list` is a question about the installation, not a
8
+ * session: starting a proxy for it would be pointless, and `--session-id` would be rejected.
9
+ * A subcommand is always the first argument, so a prompt that happens to read "doctor" is safe.
10
+ */
11
+ const CLAUDE_SUBCOMMANDS = new Set([
12
+ "agents",
13
+ "attach",
14
+ "auth",
15
+ "auto-mode",
16
+ "doctor",
17
+ "gateway",
18
+ "import",
19
+ "install",
20
+ "kill",
21
+ "logs",
22
+ "mcp",
23
+ "plugin",
24
+ "plugins",
25
+ "project",
26
+ "respawn",
27
+ "rm",
28
+ "setup-token",
29
+ "stop",
30
+ "ultrareview",
31
+ "update",
32
+ "upgrade",
33
+ ]);
34
+ /** Flags that answer and exit. Same reasoning as a subcommand: no session, so no proxy. */
35
+ const INFO_FLAGS = new Set(["-v", "--version", "-h", "--help"]);
36
+ /** Flags that mean the conversation already exists, so `claudep` must not name a new one. */
37
+ const EXISTING_SESSION_FLAGS = new Set(["-c", "--continue", "-r", "--resume", "--session-id"]);
38
+ /** True when these arguments ask Claude Code something instead of starting a session. */
39
+ export function isPassthrough(args) {
40
+ const first = args[0];
41
+ if (first === undefined)
42
+ return false;
43
+ return CLAUDE_SUBCOMMANDS.has(first) || INFO_FLAGS.has(first);
44
+ }
45
+ /** True when the user is resuming: the session id is theirs to decide, not ours. */
46
+ export function reusesExistingSession(args) {
47
+ return args.some((arg) => EXISTING_SESSION_FLAGS.has(arg) || arg.startsWith("--resume=") || arg.startsWith("--session-id="));
48
+ }
49
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
50
+ /**
51
+ * The session id the user named, when they named one. `--resume` with no value opens a picker
52
+ * and `--continue` names nothing, so both leave this null and the exit line says what it can.
53
+ */
54
+ export function sessionIdFromArgs(args) {
55
+ for (let index = 0; index < args.length; index++) {
56
+ const arg = args[index];
57
+ for (const flag of ["--session-id", "--resume"]) {
58
+ if (arg === flag && UUID.test(args[index + 1] ?? ""))
59
+ return args[index + 1];
60
+ if (arg.startsWith(`${flag}=`)) {
61
+ const value = arg.slice(flag.length + 1);
62
+ if (UUID.test(value))
63
+ return value;
64
+ }
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+ /** Claude Code's arguments: ours first, then the user's, unchanged. */
70
+ export function claudeArgs(userArgs, sessionId) {
71
+ return sessionId === null ? [...userArgs] : ["--session-id", sessionId, ...userArgs];
72
+ }
73
+ /**
74
+ * Claude Code's environment. The base URL is the proxy child; the first-party flag keeps a
75
+ * natively-1M model at 1M, which a non-`api.anthropic.com` base URL otherwise caps at 200k
76
+ * (README, "Known Claude Code interactions"). Gzip is dropped rather than overridden: a
77
+ * compressed body is forwarded untouched, so leaving it set would silently disable eviction.
78
+ */
79
+ export function claudeEnv(base, port) {
80
+ const env = {
81
+ ...base,
82
+ ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
83
+ _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL: "1",
84
+ };
85
+ delete env.CLAUDE_CODE_GZIP_REQUEST_BODIES;
86
+ return env;
87
+ }
88
+ /**
89
+ * The proxy child's environment. `ONEPASS_*` settings pass through, so a user who wants a
90
+ * different threshold sets it in their shell as before. Two are pinned rather than passed:
91
+ * `ANTHROPIC_BASE_URL` is dropped, since in a shell that already has one, keeping it would chain
92
+ * this proxy through another one; and `ONEPASS_HOST` is forced to the loopback, because this
93
+ * child exists to serve the one session below it and nothing else should be able to reach it.
94
+ */
95
+ export function proxyEnv(base) {
96
+ const env = { ...base, ONEPASS_PORT: "0", ONEPASS_HOST: "127.0.0.1" };
97
+ delete env.ANTHROPIC_BASE_URL;
98
+ return env;
99
+ }
100
+ /** Where the proxy forwards unless `ONEPASS_UPSTREAM` says otherwise (see main.ts). */
101
+ const DEFAULT_UPSTREAM = "https://api.anthropic.com";
102
+ /**
103
+ * `claudep` overwrites `ANTHROPIC_BASE_URL`, so a shell that pointed Claude Code at a gateway
104
+ * would silently lose it. Warn rather than obey: the fix is `ONEPASS_UPSTREAM`, which is what
105
+ * decides where the proxy forwards. Nothing is lost when the variable names the default
106
+ * upstream — that is where the proxy was going to send it anyway — so that case stays quiet.
107
+ */
108
+ export function upstreamWarning(env) {
109
+ const base = env.ANTHROPIC_BASE_URL;
110
+ if (base === undefined || base === "")
111
+ return null;
112
+ if (env.ONEPASS_UPSTREAM !== undefined && env.ONEPASS_UPSTREAM !== "")
113
+ return null;
114
+ if (base.replace(/\/+$/, "") === DEFAULT_UPSTREAM)
115
+ return null;
116
+ return (`claudep: ignoring ANTHROPIC_BASE_URL=${base} — the proxy forwards to ${DEFAULT_UPSTREAM}. ` +
117
+ `Set ONEPASS_UPSTREAM to send it somewhere else.`);
118
+ }
119
+ /** The two lines of the child's banner `claudep` needs. Absent either, it is not ready yet. */
120
+ export function parseBanner(text) {
121
+ const port = /listening on http:\/\/([^\s:]+):(\d+)/.exec(text);
122
+ const log = /^\[onepass\] log: (.+)$/m.exec(text);
123
+ if (port === null || log === null)
124
+ return null;
125
+ return { port: Number(port[2]), logFilePath: log[1].trim() };
126
+ }
127
+ /**
128
+ * Adds the recall MCP server to Claude Code's arguments, carrying this session's id so it reads
129
+ * this session's transcript and no other. Recall is half of Onepass: eviction is only safe
130
+ * because what it removes can be fetched back verbatim.
131
+ *
132
+ * A `--mcp-config` the user passed is extended rather than replaced — our entry is spliced in
133
+ * as one more value of their own flag. The `--mcp-config=x` spelling takes a single value and
134
+ * cannot be extended that way, and a second flag would replace theirs, so that one is declined
135
+ * out loud instead.
136
+ */
137
+ export function withRecallMcp(args, recall) {
138
+ const config = JSON.stringify({
139
+ mcpServers: {
140
+ onepass: {
141
+ command: recall.node,
142
+ args: [recall.entry],
143
+ ...(recall.sessionId === null ? {} : { env: { ONEPASS_SESSION_ID: recall.sessionId } }),
144
+ },
145
+ },
146
+ });
147
+ const flagIndex = args.indexOf("--mcp-config");
148
+ if (flagIndex !== -1) {
149
+ return { args: [...args.slice(0, flagIndex + 1), config, ...args.slice(flagIndex + 1)], warning: null };
150
+ }
151
+ if (args.some((arg) => arg.startsWith("--mcp-config="))) {
152
+ return {
153
+ args: [...args],
154
+ warning: "claudep: --mcp-config=… keeps your servers but leaves out onepass recall, so evicted context " +
155
+ "cannot be fetched back. Use the spaced form (--mcp-config file.json) to get both.",
156
+ };
157
+ }
158
+ return { args: ["--mcp-config", config, ...args], warning: null };
159
+ }
160
+ /**
161
+ * The line printed after the session ends. One line, because it appears under a session the user
162
+ * has already finished reading; `onepass-report` is where the detail lives.
163
+ */
164
+ export function summaryLine(summary) {
165
+ const { transcript, segmentsEvicted, tokensEvicted, peakSentTokens } = summary;
166
+ const peak = transcript?.peakContextTokens ?? peakSentTokens;
167
+ if (segmentsEvicted === 0) {
168
+ return `onepass: no eviction (peak ~${formatThousands(peak)} tokens)`;
169
+ }
170
+ const evicted = `onepass: evicted ${segmentsEvicted} segments (~${formatThousands(tokensEvicted)} tokens)`;
171
+ if (transcript === null) {
172
+ return `${evicted} — no transcript found, so recalls and compactions are unknown`;
173
+ }
174
+ return (`${evicted}, recalled ${transcript.recallResults}, compactions ${transcript.compactions} ` +
175
+ `(peak ~${formatThousands(peak)} tokens)`);
176
+ }
package/dist/log.js ADDED
@@ -0,0 +1,53 @@
1
+ import { createWriteStream, mkdirSync, readdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ export const proxyLogDir = join(homedir(), ".onepass");
5
+ // One log file per proxy run, so a report never mixes metrics from unrelated runs.
6
+ // The ISO timestamp in the name sorts lexically, so "latest" is a plain string max.
7
+ const proxyLogFilePattern = /^proxy\.log\..+\.jsonl$/;
8
+ export function newProxyLogPath() {
9
+ const startedAt = new Date().toISOString().replace(/[:.]/g, "-");
10
+ return join(proxyLogDir, `proxy.log.${startedAt}.jsonl`);
11
+ }
12
+ export function latestProxyLogPath() {
13
+ let names;
14
+ try {
15
+ names = readdirSync(proxyLogDir);
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ const logNames = names.filter((name) => proxyLogFilePattern.test(name)).sort();
21
+ const newest = logNames.at(-1);
22
+ return newest === undefined ? null : join(proxyLogDir, newest);
23
+ }
24
+ export function createProxyLogWriter(filePath) {
25
+ let stream = null;
26
+ let warned = false;
27
+ const warnOnce = (err) => {
28
+ if (warned)
29
+ return;
30
+ warned = true;
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ console.error(`[onepass] cannot write proxy log at ${filePath}: ${message}`);
33
+ };
34
+ return {
35
+ append(entry) {
36
+ try {
37
+ if (stream === null) {
38
+ mkdirSync(dirname(filePath), { recursive: true });
39
+ stream = createWriteStream(filePath, { flags: "a" });
40
+ stream.on("error", warnOnce);
41
+ }
42
+ stream.write(`${JSON.stringify(entry)}\n`);
43
+ }
44
+ catch (err) {
45
+ warnOnce(err);
46
+ }
47
+ },
48
+ close() {
49
+ stream?.end();
50
+ stream = null;
51
+ },
52
+ };
53
+ }