sandoichi 0.1.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.
@@ -0,0 +1,492 @@
1
+ import { estimateTokens } from './core.mjs';
2
+ import { dedupeHistory } from './history-dedupe.mjs';
3
+ import { selectHistoryCandidates, validateMaxHistoryTokens } from './history-budget.mjs';
4
+ import { shakeHistoricalResult } from './history-shake.mjs';
5
+ import { compactHistoricalStructure } from './history-structure.mjs';
6
+
7
+ const SUPERSEDED = '[sando superseded by newer read]';
8
+ const USELESS = '[sando elided useless success]';
9
+ const USELESS_SUCCESSES = new Set([
10
+ 'command completed successfully with no output.',
11
+ 'no output.',
12
+ '(no output)',
13
+ ]);
14
+
15
+ function object(value) {
16
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
17
+ }
18
+
19
+ function estimate(body) {
20
+ const json = JSON.stringify(body);
21
+ if (json === undefined) throw new TypeError('body must be JSON-serializable');
22
+ return estimateTokens(json);
23
+ }
24
+
25
+ function parseArguments(value) {
26
+ if (object(value)) return value;
27
+ if (typeof value !== 'string') return null;
28
+ try {
29
+ const parsed = JSON.parse(value);
30
+ return object(parsed) ? parsed : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function readIdentity(name, input) {
37
+ if (typeof name !== 'string' || name.toLowerCase() !== 'read' || !object(input)) return null;
38
+ const paths = ['file_path', 'path'].filter((key) => Object.hasOwn(input, key));
39
+ if (paths.length !== 1 || typeof input[paths[0]] !== 'string' || input[paths[0]].length === 0) return null;
40
+
41
+ let path = input[paths[0]];
42
+ let range = null;
43
+ const suffix = path.match(/^(.*):(\d+)-(\d+)$/);
44
+ const selectorKeys = ['offset', 'limit', 'line_start', 'line_end', 'start_line', 'end_line']
45
+ .filter((key) => Object.hasOwn(input, key));
46
+ if (suffix && selectorKeys.length === 0) {
47
+ path = suffix[1];
48
+ range = [Number(suffix[2]), Number(suffix[3])];
49
+ } else if (selectorKeys.length > 0) {
50
+ if (selectorKeys.length !== 2) return null;
51
+ if (selectorKeys.includes('offset') && selectorKeys.includes('limit')) {
52
+ const { offset, limit } = input;
53
+ if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1) return null;
54
+ range = [offset, offset + limit - 1];
55
+ } else {
56
+ const pair = selectorKeys.includes('line_start') ? ['line_start', 'line_end'] : ['start_line', 'end_line'];
57
+ if (!pair.every((key) => selectorKeys.includes(key))) return null;
58
+ const [start, end] = pair.map((key) => input[key]);
59
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) return null;
60
+ range = [start, end];
61
+ }
62
+ }
63
+ if (range && range[1] < range[0]) return null;
64
+ return { path, range };
65
+ }
66
+
67
+ function covers(newer, older) {
68
+ if (newer.path !== older.path) return false;
69
+ if (newer.range === null) return true;
70
+ return older.range !== null && newer.range[0] <= older.range[0] && newer.range[1] >= older.range[1];
71
+ }
72
+
73
+ function resultError(item, text) {
74
+ if (item.is_error === true || item.error !== undefined || ['error', 'failed'].includes(item.status)) return true;
75
+ return /^\s*(?:error|failed|failure)\b[:\s-]*/i.test(text);
76
+ }
77
+
78
+ function useless(text) {
79
+ return USELESS_SUCCESSES.has(text.trim().toLowerCase());
80
+ }
81
+
82
+ function resultText(value) {
83
+ if (typeof value === 'string') return value;
84
+ if (Array.isArray(value) && value.length > 0
85
+ && value.every((item) => ['text', 'input_text'].includes(item?.type) && typeof item.text === 'string')) {
86
+ return value.map((item) => item.text).join('\n');
87
+ }
88
+ return null;
89
+ }
90
+
91
+ // Collapsing a run of text blocks into one drops every block after index 0. Any
92
+ // `cache_control` breakpoint the host (e.g. Claude Code) placed on a later block
93
+ // would vanish silently, costing a cache read on every subsequent turn. Carry the
94
+ // deepest marker onto the surviving block: cache semantics cover everything up to
95
+ // and including a marked block, so the collapsed block inherits that boundary.
96
+ function collapseTextBlocks(blocks, value) {
97
+ const collapsed = { ...blocks[0], text: value };
98
+ const marker = blocks.findLast((block) => block?.cache_control !== undefined)?.cache_control;
99
+ if (marker !== undefined) collapsed.cache_control = marker;
100
+ return [collapsed];
101
+ }
102
+
103
+ function replaceResult(item, key, value) {
104
+ if (typeof item[key] === 'string' && typeof value === 'string') item[key] = value;
105
+ else if (Array.isArray(item[key]) && Array.isArray(value)) item[key] = value;
106
+ else if (Array.isArray(item[key]) && typeof value === 'string'
107
+ && item[key].every((block) => ['text', 'input_text'].includes(block?.type))) {
108
+ item[key] = collapseTextBlocks(item[key], value);
109
+ }
110
+ }
111
+
112
+ function collectAnthropic(body) {
113
+ if (!Array.isArray(body.messages)) return [];
114
+ const entries = [];
115
+ body.messages.forEach((message, position) => {
116
+ if (!Array.isArray(message?.content)) return;
117
+ message.content.forEach((item) => {
118
+ if (item?.type === 'tool_use') {
119
+ entries.push({ kind: 'call', id: item.id, name: item.name, input: object(item.input) ? item.input : null, position });
120
+ } else if (item?.type === 'tool_result') {
121
+ entries.push({
122
+ kind: 'result', id: item.tool_use_id, item, key: 'content', position,
123
+ current: position === body.messages.length - 1,
124
+ });
125
+ }
126
+ });
127
+ });
128
+ return entries;
129
+ }
130
+
131
+ function collectChat(body) {
132
+ if (!Array.isArray(body.messages)) return [];
133
+ const entries = [];
134
+ const lastNonToolPosition = body.messages.reduce(
135
+ (last, message, position) => message?.role === 'tool' ? last : position,
136
+ -1,
137
+ );
138
+ body.messages.forEach((message, position) => {
139
+ if (Array.isArray(message?.tool_calls)) {
140
+ message.tool_calls.forEach((item) => {
141
+ if (item?.type === 'function' && object(item.function)) {
142
+ entries.push({
143
+ kind: 'call', id: item.id, name: item.function.name,
144
+ input: parseArguments(item.function.arguments), position,
145
+ });
146
+ }
147
+ });
148
+ }
149
+ if (message?.role === 'tool') {
150
+ entries.push({
151
+ kind: 'result', id: message.tool_call_id, item: message, key: 'content', position,
152
+ current: position > lastNonToolPosition,
153
+ });
154
+ }
155
+ });
156
+ return entries;
157
+ }
158
+
159
+ function collectResponses(body) {
160
+ if (!Array.isArray(body.input)) return [];
161
+ const entries = [];
162
+ const lastCallPosition = body.input.reduce(
163
+ (last, item, position) => ['function_call', 'custom_tool_call'].includes(item?.type) ? position : last,
164
+ -1,
165
+ );
166
+ body.input.forEach((item, position) => {
167
+ if (['function_call', 'custom_tool_call'].includes(item?.type)) {
168
+ entries.push({
169
+ kind: 'call', id: item.call_id, name: item.name,
170
+ input: parseArguments(item.arguments ?? item.input), position,
171
+ });
172
+ } else if (['function_call_output', 'custom_tool_call_output'].includes(item?.type)) {
173
+ entries.push({
174
+ kind: 'result', id: item.call_id, item, key: 'output', position,
175
+ current: position > lastCallPosition,
176
+ });
177
+ }
178
+ });
179
+ return entries;
180
+ }
181
+
182
+ const COLLECTORS = {
183
+ anthropic: collectAnthropic,
184
+ 'openai-chat': collectChat,
185
+ 'openai-responses': collectResponses,
186
+ };
187
+
188
+ function historyRecords(entries, calls) {
189
+ return entries.flatMap((entry) => {
190
+ if (entry.kind !== 'result') return [];
191
+ const call = calls.get(entry.id);
192
+ const text = resultText(entry.item[entry.key]);
193
+ if (!call || text === null) return [];
194
+ const isError = resultError(entry.item, text);
195
+ return [{
196
+ id: entry.id,
197
+ toolName: call.name,
198
+ input: call.input,
199
+ output: entry.item[entry.key],
200
+ current: entry.current,
201
+ historical: !entry.current,
202
+ safe: !isError,
203
+ isError,
204
+ position: entry.position,
205
+ estimatedTokens: estimateTokens(text),
206
+ entry,
207
+ }];
208
+ });
209
+ }
210
+
211
+ function collectHistoryRecords(provider, body) {
212
+ const collector = COLLECTORS[provider];
213
+ if (!collector) return [];
214
+ const entries = collector(body);
215
+ const calls = new Map();
216
+ const results = new Map();
217
+ const ambiguous = new Set();
218
+ for (const entry of entries) {
219
+ const map = entry.kind === 'call' ? calls : results;
220
+ if (typeof entry.id !== 'string' || entry.id.length === 0 || map.has(entry.id)) ambiguous.add(entry.id);
221
+ else map.set(entry.id, entry);
222
+ }
223
+ for (const id of ambiguous) {
224
+ calls.delete(id);
225
+ results.delete(id);
226
+ }
227
+ return historyRecords(entries, calls);
228
+ }
229
+
230
+ export function detectProviderBody(body, headers = {}) {
231
+ if (!object(body)) return null;
232
+ const header = typeof headers.get === 'function'
233
+ ? headers.get('anthropic-version')
234
+ : headers['anthropic-version'] ?? headers['Anthropic-Version'];
235
+ if (header && Array.isArray(body.messages)) return 'anthropic';
236
+
237
+ const responses = Array.isArray(body.input) && body.input.some((item) =>
238
+ ['function_call', 'function_call_output', 'custom_tool_call', 'custom_tool_call_output'].includes(item?.type));
239
+ if (responses) return 'openai-responses';
240
+ if (!Array.isArray(body.messages)) return null;
241
+ const anthropic = body.messages.some((message) => Array.isArray(message?.content)
242
+ && message.content.some((item) => ['tool_use', 'tool_result'].includes(item?.type)));
243
+ const chat = body.messages.some((message) => Array.isArray(message?.tool_calls) || message?.role === 'tool');
244
+ if (anthropic === chat) return null;
245
+ return anthropic ? 'anthropic' : 'openai-chat';
246
+ }
247
+
248
+ export function listSemanticCandidates({ provider, body, model } = {}) {
249
+ const selectedProvider = provider ?? detectProviderBody(body);
250
+ const selectedModel = model ?? (typeof body?.model === 'string' ? body.model : null);
251
+ return collectHistoryRecords(selectedProvider, body)
252
+ .filter((record) => record.safe && record.historical)
253
+ .map((record) => ({
254
+ id: record.id,
255
+ model: selectedModel,
256
+ toolName: record.toolName,
257
+ text: resultText(record.output),
258
+ current: record.current,
259
+ historical: record.historical,
260
+ isError: record.isError,
261
+ estimatedTokens: record.estimatedTokens,
262
+ }));
263
+ }
264
+
265
+ function carriesBreakpoint(value) {
266
+ if (Array.isArray(value)) return value.some(carriesBreakpoint);
267
+ if (!object(value)) return false;
268
+ if (value.cache_control !== undefined) return true;
269
+ return Object.values(value).some(carriesBreakpoint);
270
+ }
271
+
272
+ /**
273
+ * Minimum fraction of the re-prefilled suffix a rewrite must reclaim to pay for itself.
274
+ *
275
+ * Derived from Anthropic's published multipliers (cache read 0.1x, 5-minute cache
276
+ * write 1.25x of base input). With `S` tokens reclaimed, `P` tokens of suffix forced
277
+ * back through a cache write, and `K` further turns to amortize over:
278
+ *
279
+ * rewrite: 1.25(P-S) + 0.10(P-S)K leave: 0.10P(K+1)
280
+ * rewrite wins <=> S/P > 1.15 / (1.25 + 0.10K)
281
+ *
282
+ * The threshold is a RATIO and is independent of P — an absolute token budget is the
283
+ * wrong parameterization. K=0 needs 92%, K=10 needs 51%, K=50 needs 18%. Sando cannot
284
+ * know K (how much longer the session runs), so this uses the K=10 point: a rewrite
285
+ * must reclaim over half the suffix it invalidates. Conservative for short sessions,
286
+ * slightly cautious for very long ones.
287
+ */
288
+ const DEFAULT_CACHE_REWRITE_RATIO = 0.51;
289
+
290
+ /**
291
+ * Below this idle time, the ratio guard above governs. At or beyond it, the host's
292
+ * prompt cache has already expired on its own — Anthropic's longest published ephemeral
293
+ * TTL is 1h (measured on a real Claude Code request: `{"type":"ephemeral","ttl":"1h"}`),
294
+ * so a request idle this long forces a full cache-write regardless of what Sando does.
295
+ * Rewriting costs nothing extra at that point, so the ratio guard is bypassed entirely.
296
+ * Set `policy.cacheIdleFlushMs: null` to disable (ratio guard always governs).
297
+ */
298
+ const DEFAULT_CACHE_IDLE_FLUSH_MS = 65 * 60_000;
299
+
300
+ /**
301
+ * Token counts of the suffix following each message index.
302
+ *
303
+ * Position relative to a breakpoint is the wrong metric here. A real Claude Code
304
+ * request (measured: 90 tools, 2 system markers, 1 message marker, all ttl 1h) marks
305
+ * the LAST message, moving the breakpoint forward every turn so the growing
306
+ * conversation stays one cached span — the same strategy Cline documents. Protecting
307
+ * everything at or before that marker would protect the entire conversation and
308
+ * disable the transform outright (measured: 17.1% mechanical saving -> 0%).
309
+ *
310
+ * What actually matters is how much has to be re-prefilled, which is the size of the
311
+ * suffix after the rewritten message.
312
+ */
313
+ function suffixTokensByPosition(body) {
314
+ const messages = Array.isArray(body?.messages) ? body.messages : [];
315
+ const suffix = new Array(messages.length).fill(0);
316
+ let running = 0;
317
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
318
+ suffix[index] = running;
319
+ running += estimateTokens(JSON.stringify(messages[index]) ?? '');
320
+ }
321
+ return suffix;
322
+ }
323
+
324
+ export function transformProviderRequest({ provider, body, policy, idleMs } = {}) {
325
+ const clone = structuredClone(body);
326
+ const estimatedInputTokens = estimate(body);
327
+ const selectedProvider = provider ?? detectProviderBody(body);
328
+ const collector = COLLECTORS[selectedProvider];
329
+ let supersededReads = 0;
330
+ let elidedUselessSuccesses = 0;
331
+ let deduplicatedResults = 0;
332
+ let compactedStructures = 0;
333
+ let shakenResults = 0;
334
+ const maxHistoryTokens = object(policy) && Object.hasOwn(policy, 'maxHistoryTokens')
335
+ ? validateMaxHistoryTokens(policy.maxHistoryTokens)
336
+ : null;
337
+ const budgetTriggered = maxHistoryTokens !== null
338
+ && BigInt(estimatedInputTokens) * 5n > BigInt(maxHistoryTokens) * 4n;
339
+ // Don't rewrite warm cached history unless the rewrite reclaims enough of the suffix
340
+ // it invalidates to pay the cache-write premium. Only applies when the host actually
341
+ // asked for caching — with no breakpoint there is no warm prefix to protect.
342
+ // Set `policy.cacheRewriteRatio: 0` to disable.
343
+ const cacheRewriteRatio = object(policy) && Object.hasOwn(policy, 'cacheRewriteRatio')
344
+ ? policy.cacheRewriteRatio
345
+ : DEFAULT_CACHE_REWRITE_RATIO;
346
+ if (typeof cacheRewriteRatio !== 'number' || !(cacheRewriteRatio >= 0) || cacheRewriteRatio > 1) {
347
+ throw new TypeError('cacheRewriteRatio must be a number between 0 and 1');
348
+ }
349
+ const cacheIdleFlushMs = object(policy) && Object.hasOwn(policy, 'cacheIdleFlushMs')
350
+ ? policy.cacheIdleFlushMs
351
+ : DEFAULT_CACHE_IDLE_FLUSH_MS;
352
+ if (cacheIdleFlushMs !== null && (typeof cacheIdleFlushMs !== 'number' || !(cacheIdleFlushMs >= 0))) {
353
+ throw new TypeError('cacheIdleFlushMs must be a non-negative number or null');
354
+ }
355
+ const cacheWarm = cacheRewriteRatio > 0 && carriesBreakpoint(clone);
356
+ const suffixTokens = cacheWarm ? suffixTokensByPosition(clone) : null;
357
+ // The host's own cache has already gone cold from inactivity: any rewrite here is
358
+ // free, since the provider will cache-write the whole prefix again regardless.
359
+ const idleCold = cacheIdleFlushMs !== null && typeof idleMs === 'number' && idleMs >= cacheIdleFlushMs;
360
+ // `reclaimed` is how many tokens this particular rewrite removes.
361
+ const cacheProtected = (entry, reclaimed) => {
362
+ if (!cacheWarm || idleCold) return false;
363
+ const suffix = suffixTokens[entry.position] ?? 0;
364
+ if (suffix === 0) return false;
365
+ return reclaimed / suffix < cacheRewriteRatio;
366
+ };
367
+ let cacheProtectedSkips = 0;
368
+ const reclaimedTokens = (before, after) =>
369
+ Math.max(0, estimateTokens(before) - estimateTokens(after));
370
+
371
+ if (collector) {
372
+ const entries = collector(clone);
373
+ const calls = new Map();
374
+ const results = new Map();
375
+ const ambiguous = new Set();
376
+ for (const entry of entries) {
377
+ const map = entry.kind === 'call' ? calls : results;
378
+ if (typeof entry.id !== 'string' || entry.id.length === 0 || map.has(entry.id)) ambiguous.add(entry.id);
379
+ else map.set(entry.id, entry);
380
+ }
381
+ for (const id of ambiguous) {
382
+ calls.delete(id);
383
+ results.delete(id);
384
+ }
385
+
386
+ const reads = [];
387
+ for (const call of calls.values()) {
388
+ const result = results.get(call.id);
389
+ const identity = readIdentity(call.name, call.input);
390
+ const text = result && resultText(result.item[result.key]);
391
+ if (!result || !identity || text === null) continue;
392
+ if (!resultError(result.item, text)) reads.push({ call, result, identity, text });
393
+ }
394
+ reads.sort((a, b) => a.call.position - b.call.position);
395
+ for (let index = 0; index < reads.length; index += 1) {
396
+ const old = reads[index];
397
+ if (old.result.current) continue;
398
+ const newer = reads.slice(index + 1).find((candidate) =>
399
+ covers(candidate.identity, old.identity) && !useless(candidate.text));
400
+ if (!newer) continue;
401
+ if (cacheProtected(old.result, reclaimedTokens(old.text, SUPERSEDED))) { cacheProtectedSkips += 1; continue; }
402
+ replaceResult(old.result.item, old.result.key, SUPERSEDED);
403
+ supersededReads += 1;
404
+ }
405
+
406
+ for (const [id, result] of results) {
407
+ if (!calls.has(id) || result.current) continue;
408
+ const text = resultText(result.item[result.key]);
409
+ if (text === null) continue;
410
+ if (text === SUPERSEDED || resultError(result.item, text) || !useless(text)) continue;
411
+ if (cacheProtected(result, reclaimedTokens(text, USELESS))) { cacheProtectedSkips += 1; continue; }
412
+ replaceResult(result.item, result.key, USELESS);
413
+ elidedUselessSuccesses += 1;
414
+ }
415
+
416
+ const records = historyRecords(entries, calls);
417
+ const candidates = maxHistoryTokens === null
418
+ ? records.filter((record) => record.safe && record.historical)
419
+ : selectHistoryCandidates({ bodyTokens: estimatedInputTokens, maxHistoryTokens, candidates: records });
420
+ const candidateIds = new Set(candidates.map((candidate) => candidate.id));
421
+ const reductions = dedupeHistory(records);
422
+ const recordsById = new Map(records.map((record) => [record.id, record]));
423
+ for (const reduced of reductions.entries) {
424
+ if (!candidateIds.has(reduced.id)) continue;
425
+ const original = recordsById.get(reduced.id);
426
+ if (!original || reduced.output === original.output) continue;
427
+ if (cacheProtected(original.entry, reclaimedTokens(
428
+ resultText(original.output) ?? '', resultText(reduced.output) ?? ''))) { cacheProtectedSkips += 1; continue; }
429
+ replaceResult(original.entry.item, original.entry.key, reduced.output);
430
+ deduplicatedResults += 1;
431
+ }
432
+
433
+ for (const record of records) {
434
+ if (!candidateIds.has(record.id)) continue;
435
+ const text = resultText(record.entry.item[record.entry.key]);
436
+ if (text === null) continue;
437
+ const compacted = compactHistoricalStructure({
438
+ toolName: record.toolName,
439
+ text,
440
+ historical: record.historical,
441
+ isError: record.isError,
442
+ });
443
+ if (compacted === text) continue;
444
+ if (cacheProtected(record.entry, reclaimedTokens(text, compacted))) { cacheProtectedSkips += 1; continue; }
445
+ replaceResult(record.entry.item, record.entry.key, compacted);
446
+ compactedStructures += 1;
447
+ }
448
+
449
+ if (maxHistoryTokens !== null && budgetTriggered) {
450
+ for (const record of records) {
451
+ if (!candidateIds.has(record.id)) continue;
452
+ const text = resultText(record.entry.item[record.entry.key]);
453
+ if (text === null) continue;
454
+ const shaken = shakeHistoricalResult({
455
+ toolName: record.toolName,
456
+ text,
457
+ historical: record.historical,
458
+ isError: record.isError,
459
+ });
460
+ if (!shaken.changed) continue;
461
+ if (cacheProtected(record.entry, reclaimedTokens(text, shaken.text))) { cacheProtectedSkips += 1; continue; }
462
+ replaceResult(record.entry.item, record.entry.key, shaken.text);
463
+ shakenResults += 1;
464
+ }
465
+ }
466
+ }
467
+
468
+ const reasons = [];
469
+ if (supersededReads > 0) reasons.push('superseded-read');
470
+ if (elidedUselessSuccesses > 0) reasons.push('useless-success');
471
+ if (deduplicatedResults > 0) reasons.push('duplicate-history');
472
+ if (compactedStructures > 0) reasons.push('repeated-lines');
473
+ if (shakenResults > 0) reasons.push('history-shake');
474
+ return {
475
+ body: clone,
476
+ changed: reasons.length > 0,
477
+ reasons,
478
+ stats: {
479
+ estimatedInputTokens,
480
+ estimatedOutputTokens: estimate(clone),
481
+ supersededReads,
482
+ elidedUselessSuccesses,
483
+ deduplicatedResults,
484
+ compactedStructures,
485
+ shakenResults,
486
+ budgetTriggered,
487
+ cacheProtectedSkips,
488
+ cacheRewriteRatio: cacheWarm ? cacheRewriteRatio : null,
489
+ cacheIdleFlushed: cacheWarm ? idleCold : false,
490
+ },
491
+ };
492
+ }