sandoichi 0.4.1 → 0.5.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.
@@ -1,8 +1,13 @@
1
+ import path from 'node:path';
2
+
1
3
  import { estimateTokens } from './core.mjs';
2
4
  import { dedupeHistory } from './history-dedupe.mjs';
3
5
  import { selectHistoryCandidates, validateMaxHistoryTokens } from './history-budget.mjs';
4
6
  import { shakeHistoricalResult } from './history-shake.mjs';
5
7
  import { compactHistoricalStructure } from './history-structure.mjs';
8
+ import { buildHistoryDisclosure } from './history-disclosure.mjs';
9
+ import { prepareHistoryArtifact, persistHistoryArtifact } from './history-archive.mjs';
10
+ import { createRedactionProfile } from './redaction-profile.mjs';
6
11
 
7
12
  const SUPERSEDED = '[sando superseded by newer read]';
8
13
  const USELESS = '[sando elided useless success]';
@@ -11,6 +16,11 @@ const USELESS_SUCCESSES = new Set([
11
16
  'no output.',
12
17
  '(no output)',
13
18
  ]);
19
+ const DEFAULT_REDACTION_PROFILE = createRedactionProfile();
20
+ const DEFAULT_HISTORY_ARCHIVE_RETAIN_RESULTS = 3;
21
+ // A marker is useful only when the historical observation is materially larger
22
+ // than the recovery instructions it replaces. Small results stay inline.
23
+ const DEFAULT_HISTORY_ARCHIVE_MIN_BYTES = 3072;
14
24
 
15
25
  function object(value) {
16
26
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -75,10 +85,35 @@ function resultError(item, text) {
75
85
  return /^\s*(?:error|failed|failure)\b[:\s-]*/i.test(text);
76
86
  }
77
87
 
88
+ function resultSuccess(item, provider, text) {
89
+ if (resultError(item, text)) return false;
90
+ if (provider === 'anthropic') return item.is_error !== true;
91
+ return item.ok === true || ['completed', 'success', 'succeeded'].includes(item.status);
92
+ }
93
+
94
+ function nativeCodexExecSafety(provider, call, record) {
95
+ const item = record.entry.item;
96
+ const output = item[record.entry.key];
97
+ if (provider !== 'openai-responses' || call?.providerType !== 'function_call'
98
+ || call.name !== 'exec_command' || item.type !== 'function_call_output') return null;
99
+ if (typeof output !== 'string') return false;
100
+ if (item.error !== undefined || item.is_error === true || item.ok === false
101
+ || (item.status !== undefined && !['completed', 'success', 'succeeded'].includes(item.status))) return false;
102
+ const lines = output.split(/\r?\n/);
103
+ const outputLine = lines.indexOf('Output:');
104
+ if (outputLine < 0) return false;
105
+ const exits = lines.slice(0, outputLine).filter((line) => line.startsWith('Process exited with code '));
106
+ return exits.length === 1 && exits[0] === 'Process exited with code 0';
107
+ }
108
+
78
109
  function useless(text) {
79
110
  return USELESS_SUCCESSES.has(text.trim().toLowerCase());
80
111
  }
81
112
 
113
+ function historyArchiveMarker(text) {
114
+ return /^\[sando archived result sando:sha256:[a-f0-9]{64}; /.test(text) && text.endsWith(']');
115
+ }
116
+
82
117
  function resultText(value) {
83
118
  if (typeof value === 'string') return value;
84
119
  if (Array.isArray(value) && value.length > 0
@@ -167,7 +202,7 @@ function collectResponses(body) {
167
202
  if (['function_call', 'custom_tool_call'].includes(item?.type)) {
168
203
  entries.push({
169
204
  kind: 'call', id: item.call_id, name: item.name,
170
- input: parseArguments(item.arguments ?? item.input), position,
205
+ input: parseArguments(item.arguments ?? item.input), position, providerType: item.type,
171
206
  });
172
207
  } else if (['function_call_output', 'custom_tool_call_output'].includes(item?.type)) {
173
208
  entries.push({
@@ -185,13 +220,14 @@ const COLLECTORS = {
185
220
  'openai-responses': collectResponses,
186
221
  };
187
222
 
188
- function historyRecords(entries, calls) {
223
+ function historyRecords(entries, calls, provider) {
189
224
  return entries.flatMap((entry) => {
190
225
  if (entry.kind !== 'result') return [];
191
226
  const call = calls.get(entry.id);
192
227
  const text = resultText(entry.item[entry.key]);
193
228
  if (!call || text === null) return [];
194
229
  const isError = resultError(entry.item, text);
230
+ const safe = resultSuccess(entry.item, provider, text);
195
231
  return [{
196
232
  id: entry.id,
197
233
  toolName: call.name,
@@ -199,7 +235,7 @@ function historyRecords(entries, calls) {
199
235
  output: entry.item[entry.key],
200
236
  current: entry.current,
201
237
  historical: !entry.current,
202
- safe: !isError,
238
+ safe,
203
239
  isError,
204
240
  position: entry.position,
205
241
  estimatedTokens: estimateTokens(text),
@@ -224,7 +260,29 @@ function collectHistoryRecords(provider, body) {
224
260
  calls.delete(id);
225
261
  results.delete(id);
226
262
  }
227
- return historyRecords(entries, calls);
263
+ return historyRecords(entries, calls, provider);
264
+ }
265
+
266
+ const DEFAULT_STRATEGIES = Object.freeze({
267
+ supersededRead: true,
268
+ producerUseless: true,
269
+ exactDuplicate: true,
270
+ repeatedLines: true,
271
+ historyShake: true,
272
+ recoverableArchive: false,
273
+ });
274
+
275
+ function strategyPolicy(policy) {
276
+ if (!object(policy) || !Object.hasOwn(policy, 'strategies')) return DEFAULT_STRATEGIES;
277
+ if (!object(policy.strategies)) throw new TypeError('strategies must be an object');
278
+ const strategies = { ...DEFAULT_STRATEGIES };
279
+ for (const [name, enabled] of Object.entries(policy.strategies)) {
280
+ if (!Object.hasOwn(strategies, name) || typeof enabled !== 'boolean') {
281
+ throw new TypeError(`invalid context strategy: ${name}`);
282
+ }
283
+ strategies[name] = enabled;
284
+ }
285
+ return strategies;
228
286
  }
229
287
 
230
288
  export function detectProviderBody(body, headers = {}) {
@@ -321,19 +379,47 @@ function suffixTokensByPosition(body) {
321
379
  return suffix;
322
380
  }
323
381
 
324
- export function transformProviderRequest({ provider, body, policy, idleMs } = {}) {
382
+ export function transformProviderRequest({ provider, body, policy, idleMs, redactionProfile, historyArchiveRoot } = {}) {
325
383
  const clone = structuredClone(body);
326
384
  const estimatedInputTokens = estimate(body);
327
385
  const selectedProvider = provider ?? detectProviderBody(body);
386
+ const strategies = strategyPolicy(policy);
328
387
  const collector = COLLECTORS[selectedProvider];
329
388
  let supersededReads = 0;
330
389
  let elidedUselessSuccesses = 0;
331
390
  let deduplicatedResults = 0;
332
391
  let compactedStructures = 0;
333
392
  let shakenResults = 0;
393
+ let archivedResults = 0;
394
+ let archiveRedactionSkips = 0;
395
+ let archiveNoSavingsSkips = 0;
396
+ let archiveSizeSkips = 0;
397
+ const disclosures = [];
398
+ const disclose = (record, reason, originalText, visibleText, recovery = 'rerun-tool') => {
399
+ if (typeof originalText !== 'string' || typeof visibleText !== 'string') return;
400
+ disclosures.push(buildHistoryDisclosure({
401
+ toolName: record.toolName, reason, originalText, visibleText, recovery, redactionProfile,
402
+ }));
403
+ };
334
404
  const maxHistoryTokens = object(policy) && Object.hasOwn(policy, 'maxHistoryTokens')
335
405
  ? validateMaxHistoryTokens(policy.maxHistoryTokens)
336
406
  : null;
407
+ const historyArchiveRetainResults = object(policy) && Object.hasOwn(policy, 'historyArchiveRetainResults')
408
+ ? policy.historyArchiveRetainResults
409
+ : DEFAULT_HISTORY_ARCHIVE_RETAIN_RESULTS;
410
+ if (!Number.isSafeInteger(historyArchiveRetainResults) || historyArchiveRetainResults < 0) {
411
+ throw new TypeError('historyArchiveRetainResults must be a non-negative safe integer');
412
+ }
413
+ const historyArchiveMinBytes = object(policy) && Object.hasOwn(policy, 'historyArchiveMinBytes')
414
+ ? policy.historyArchiveMinBytes
415
+ : DEFAULT_HISTORY_ARCHIVE_MIN_BYTES;
416
+ if (!Number.isSafeInteger(historyArchiveMinBytes) || historyArchiveMinBytes < 0) {
417
+ throw new TypeError('historyArchiveMinBytes must be a non-negative safe integer');
418
+ }
419
+ if (strategies.recoverableArchive
420
+ && (typeof historyArchiveRoot !== 'string' || !path.isAbsolute(historyArchiveRoot))) {
421
+ throw new TypeError('historyArchiveRoot must be an absolute path');
422
+ }
337
423
  const budgetTriggered = maxHistoryTokens !== null
338
424
  && BigInt(estimatedInputTokens) * 5n > BigInt(maxHistoryTokens) * 4n;
339
425
  // Don't rewrite warm cached history unless the rewrite reclaims enough of the suffix
@@ -383,42 +469,73 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
383
469
  results.delete(id);
384
470
  }
385
471
 
472
+ const archiveRecords = historyRecords(entries, calls, selectedProvider);
473
+ const resultEntries = entries.filter((entry) => entry.kind === 'result');
474
+ const recentEntries = new Set(historyArchiveRetainResults === 0
475
+ ? []
476
+ : resultEntries.slice(-historyArchiveRetainResults));
477
+ const archivedIds = new Set();
478
+ if (strategies.recoverableArchive) {
479
+ const profile = redactionProfile ?? DEFAULT_REDACTION_PROFILE;
480
+ if (!profile || typeof profile.redact !== 'function') throw new TypeError('history archive redaction profile is invalid');
481
+ for (const record of archiveRecords) {
482
+ const nativeSafety = nativeCodexExecSafety(selectedProvider, calls.get(record.id), record);
483
+ const safe = nativeSafety ?? record.safe;
484
+ if (!safe || !record.historical || recentEntries.has(record.entry)) continue;
485
+ const text = resultText(record.output);
486
+ if (text === null || historyArchiveMarker(text)) continue;
487
+ if (Buffer.byteLength(text, 'utf8') < historyArchiveMinBytes) { archiveSizeSkips += 1; continue; }
488
+ if (profile.redact(text).text !== text) { archiveRedactionSkips += 1; continue; }
489
+ const artifact = prepareHistoryArtifact({ root: historyArchiveRoot, content: text });
490
+ const reclaimed = reclaimedTokens(text, artifact.marker);
491
+ if (reclaimed === 0) { archiveNoSavingsSkips += 1; continue; }
492
+ if (cacheProtected(record.entry, reclaimed)) { cacheProtectedSkips += 1; continue; }
493
+ persistHistoryArtifact(artifact);
494
+ replaceResult(record.entry.item, record.entry.key, artifact.marker);
495
+ archivedIds.add(record.id);
496
+ archivedResults += 1;
497
+ }
498
+ }
499
+
386
500
  const reads = [];
387
501
  for (const call of calls.values()) {
388
502
  const result = results.get(call.id);
389
503
  const identity = readIdentity(call.name, call.input);
390
504
  const text = result && resultText(result.item[result.key]);
391
505
  if (!result || !identity || text === null) continue;
392
- if (!resultError(result.item, text)) reads.push({ call, result, identity, text });
506
+ if (resultSuccess(result.item, selectedProvider, text)) reads.push({ call, result, identity, text });
393
507
  }
394
508
  reads.sort((a, b) => a.call.position - b.call.position);
395
- for (let index = 0; index < reads.length; index += 1) {
509
+ for (let index = 0; strategies.supersededRead && index < reads.length; index += 1) {
396
510
  const old = reads[index];
397
- if (old.result.current) continue;
511
+ if (old.result.current || archivedIds.has(old.call.id)) continue;
398
512
  const newer = reads.slice(index + 1).find((candidate) =>
399
513
  covers(candidate.identity, old.identity) && !useless(candidate.text));
400
514
  if (!newer) continue;
401
515
  if (cacheProtected(old.result, reclaimedTokens(old.text, SUPERSEDED))) { cacheProtectedSkips += 1; continue; }
402
516
  replaceResult(old.result.item, old.result.key, SUPERSEDED);
517
+ disclose({ toolName: old.call.name }, 'superseded-read', old.text, SUPERSEDED);
403
518
  supersededReads += 1;
404
519
  }
405
520
 
406
- for (const [id, result] of results) {
407
- if (!calls.has(id) || result.current) continue;
521
+ for (const [id, result] of strategies.producerUseless ? results : []) {
522
+ if (!calls.has(id) || result.current || archivedIds.has(id)) continue;
408
523
  const text = resultText(result.item[result.key]);
409
524
  if (text === null) continue;
410
- if (text === SUPERSEDED || resultError(result.item, text) || !useless(text)) continue;
525
+ if (text === SUPERSEDED || !resultSuccess(result.item, selectedProvider, text) || !useless(text)) continue;
411
526
  if (cacheProtected(result, reclaimedTokens(text, USELESS))) { cacheProtectedSkips += 1; continue; }
412
527
  replaceResult(result.item, result.key, USELESS);
528
+ disclose({ toolName: calls.get(id).name }, 'useless-success', text, USELESS);
413
529
  elidedUselessSuccesses += 1;
414
530
  }
415
531
 
416
- const records = historyRecords(entries, calls);
532
+ const records = historyRecords(entries, calls, selectedProvider);
417
533
  const candidates = maxHistoryTokens === null
418
- ? records.filter((record) => record.safe && record.historical)
419
- : selectHistoryCandidates({ bodyTokens: estimatedInputTokens, maxHistoryTokens, candidates: records });
534
+ ? records.filter((record) => record.safe && record.historical && !archivedIds.has(record.id))
535
+ : selectHistoryCandidates({ bodyTokens: estimatedInputTokens, maxHistoryTokens, candidates: records })
536
+ .filter((record) => !archivedIds.has(record.id));
420
537
  const candidateIds = new Set(candidates.map((candidate) => candidate.id));
421
- const reductions = dedupeHistory(records);
538
+ const reductions = strategies.exactDuplicate ? dedupeHistory(records) : { entries: [] };
422
539
  const recordsById = new Map(records.map((record) => [record.id, record]));
423
540
  for (const reduced of reductions.entries) {
424
541
  if (!candidateIds.has(reduced.id)) continue;
@@ -427,10 +544,11 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
427
544
  if (cacheProtected(original.entry, reclaimedTokens(
428
545
  resultText(original.output) ?? '', resultText(reduced.output) ?? ''))) { cacheProtectedSkips += 1; continue; }
429
546
  replaceResult(original.entry.item, original.entry.key, reduced.output);
547
+ disclose(original, 'duplicate-history', resultText(original.output) ?? '', resultText(reduced.output) ?? '', 'newer-result');
430
548
  deduplicatedResults += 1;
431
549
  }
432
550
 
433
- for (const record of records) {
551
+ for (const record of strategies.repeatedLines ? records : []) {
434
552
  if (!candidateIds.has(record.id)) continue;
435
553
  const text = resultText(record.entry.item[record.entry.key]);
436
554
  if (text === null) continue;
@@ -443,10 +561,11 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
443
561
  if (compacted === text) continue;
444
562
  if (cacheProtected(record.entry, reclaimedTokens(text, compacted))) { cacheProtectedSkips += 1; continue; }
445
563
  replaceResult(record.entry.item, record.entry.key, compacted);
564
+ disclose(record, 'repeated-lines', text, compacted);
446
565
  compactedStructures += 1;
447
566
  }
448
567
 
449
- if (maxHistoryTokens !== null && budgetTriggered) {
568
+ if (strategies.historyShake && maxHistoryTokens !== null && budgetTriggered) {
450
569
  for (const record of records) {
451
570
  if (!candidateIds.has(record.id)) continue;
452
571
  const text = resultText(record.entry.item[record.entry.key]);
@@ -460,6 +579,7 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
460
579
  if (!shaken.changed) continue;
461
580
  if (cacheProtected(record.entry, reclaimedTokens(text, shaken.text))) { cacheProtectedSkips += 1; continue; }
462
581
  replaceResult(record.entry.item, record.entry.key, shaken.text);
582
+ disclose(record, 'history-shake', text, shaken.text);
463
583
  shakenResults += 1;
464
584
  }
465
585
  }
@@ -471,10 +591,12 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
471
591
  if (deduplicatedResults > 0) reasons.push('duplicate-history');
472
592
  if (compactedStructures > 0) reasons.push('repeated-lines');
473
593
  if (shakenResults > 0) reasons.push('history-shake');
594
+ if (archivedResults > 0) reasons.unshift('recoverable-archive');
474
595
  return {
475
596
  body: clone,
476
597
  changed: reasons.length > 0,
477
598
  reasons,
599
+ disclosures,
478
600
  stats: {
479
601
  estimatedInputTokens,
480
602
  estimatedOutputTokens: estimate(clone),
@@ -483,6 +605,13 @@ export function transformProviderRequest({ provider, body, policy, idleMs } = {}
483
605
  deduplicatedResults,
484
606
  compactedStructures,
485
607
  shakenResults,
608
+ archivedResults,
609
+ archiveRedactionSkips,
610
+ archiveNoSavingsSkips,
611
+ archiveSizeSkips,
612
+ historyDisclosureCount: disclosures.length,
613
+ historyDisclosureOriginalBytes: disclosures.reduce((total, item) => total + item.bytes.original, 0),
614
+ historyDisclosureVisibleBytes: disclosures.reduce((total, item) => total + item.bytes.visible, 0),
486
615
  budgetTriggered,
487
616
  cacheProtectedSkips,
488
617
  cacheRewriteRatio: cacheWarm ? cacheRewriteRatio : null,