sandoichi 0.4.2 → 0.6.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/README.md CHANGED
@@ -26,7 +26,11 @@ Built-ins stay enabled. Profiles are declarative and local to the current projec
26
26
 
27
27
  The library requires Node.js `>=22.22.0 <23` and has no runtime dependencies. Installing it does not install or enable the plugin. The plugin remains the supported host surface; this package exports the bounded output/disclosure runtime, context footprint audit, F1/F3/F4 evidence APIs, provider usage report, paired accounting, and explicit proxy API. Host hooks and MCP registration remain outside the package API.
28
28
 
29
- `computeWeightedUsage` and `summarizePairedSessions` keep mechanical reduction, weighted estimates, provider-reported cost, and paired-session evidence separate. The library does not install hooks, register MCP servers, or make routing/backoff decisions for a host.
29
+ The provider proxy is pass-through unless request transformation is explicitly enabled.
30
+
31
+ The recoverable-history strategy is opt-in and keeps eligible results inline when they are below 3,072 bytes by default. Provider-boundary replay measurements are diagnostic paired evidence; they do not predict end-to-end agent behavior or provider billing across workloads.
32
+
33
+ `computeWeightedUsage` and `summarizePairedSessions` keep mechanical reduction, weighted cost units, and paired-session evidence separate from the provider-usage report's reported cost provenance and coverage. Host-reported list estimates are not billed-cost records. The library does not install hooks, register MCP servers, or make routing/backoff decisions for a host.
30
34
 
31
35
  For plugin installation, see the [main project README](https://github.com/yuzushi-dev/Sando#readme).
32
36
 
package/index.mjs CHANGED
@@ -7,7 +7,13 @@ export {
7
7
  } from './src/core.mjs';
8
8
  export { createRedactionProfile } from './src/redaction-profile.mjs';
9
9
  export { loadProjectRedactionProfile } from './src/redaction-config.mjs';
10
- export { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './src/context-transform.mjs';
10
+ export {
11
+ detectProviderBody,
12
+ listSemanticCandidates,
13
+ listSemanticJudgmentCandidates,
14
+ restoreSemanticJudgmentCandidates,
15
+ transformProviderRequest,
16
+ } from './src/context-transform.mjs';
11
17
  export {
12
18
  DEFAULT_ACCOUNTING_WEIGHTS,
13
19
  PAIRED_ARMS,
@@ -24,6 +30,12 @@ export {
24
30
  SEMANTIC_SUMMARY_SCHEMA,
25
31
  validateSemanticSummary,
26
32
  } from './src/semantic-compactor.mjs';
33
+ export {
34
+ buildSemanticJudgeRequest,
35
+ createSemanticJudge,
36
+ SEMANTIC_JUDGMENT_SCHEMA,
37
+ } from './src/semantic-judge.mjs';
38
+ export { createSemanticGate } from './src/semantic-gate.mjs';
27
39
  export { createProviderProxy } from './src/proxy.mjs';
28
40
  export { shakeHistoricalResult } from './src/history-shake.mjs';
29
41
  export {
@@ -90,6 +102,11 @@ export {
90
102
  recoverArtifactFromWorkspace,
91
103
  } from './src/artifact-recovery.mjs';
92
104
  export { runArtifactCli } from './src/artifact-cli.mjs';
105
+ export {
106
+ DEFAULT_ARTIFACT_MAX_BYTES,
107
+ DEFAULT_ARTIFACT_TTL_MS,
108
+ cleanupArtifacts,
109
+ } from './src/artifact-lifecycle.mjs';
93
110
  export { buildF1TelemetryEvent, publishF1Telemetry } from './src/f1-telemetry.mjs';
94
111
  export {
95
112
  F4_EVENT_SCHEMA,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandoichi",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Bound repeated tool-output context in Claude Code and Codex with deterministic local routing and provider accounting.",
5
5
  "license": "MIT",
6
6
  "author": "yuzushi",
@@ -21,7 +21,7 @@ export function formatAccountingReport(report) {
21
21
  `reasoning: ${report.reasoningOutputTokens}`,
22
22
  `turns: ${report.turnCount}`,
23
23
  `weighted estimate: ${report.weightedCost.costUnits} cost units`,
24
- `provider cost: ${report.cost.status === 'provider-reported' ? `$${report.cost.totalCostUsd.toFixed(6)}` : report.cost.status}`,
24
+ `reported cost: ${report.cost.totalCostUsd === null ? report.cost.status : `$${report.cost.totalCostUsd.toFixed(6)} (${report.cost.status})`}`,
25
25
  ];
26
26
  if (report.cost.effectiveRateUsdPerMillionTokens !== null) {
27
27
  lines.push(`blended effective rate: $${report.cost.effectiveRateUsdPerMillionTokens.toFixed(2)}/M tokens`);
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const DEFAULT_ARTIFACT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
5
+ export const DEFAULT_ARTIFACT_MAX_BYTES = 64 * 1024 * 1024;
6
+
7
+ function validNumber(value, name) {
8
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} is invalid`);
9
+ return value;
10
+ }
11
+
12
+ function safeArtifact(directory, name) {
13
+ if (!/^[a-f0-9]{64}\.txt$/.test(name)) return null;
14
+ const target = path.join(directory, name);
15
+ let link;
16
+ try { link = fs.lstatSync(target); } catch { return null; }
17
+ if (!link.isFile() || link.isSymbolicLink()) return null;
18
+ let resolved;
19
+ try { resolved = fs.realpathSync(target); } catch { return null; }
20
+ if (resolved !== target) return null;
21
+ let stat;
22
+ try { stat = fs.statSync(target); } catch { return null; }
23
+ return { target, name, bytes: stat.size, mtimeMs: stat.mtimeMs };
24
+ }
25
+
26
+ export function cleanupArtifacts(
27
+ directory,
28
+ {
29
+ now = Date.now(), ttlMs = DEFAULT_ARTIFACT_TTL_MS,
30
+ maxBytes = DEFAULT_ARTIFACT_MAX_BYTES, preserveName = null,
31
+ } = {},
32
+ ) {
33
+ if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw new TypeError('artifact directory is invalid');
34
+ validNumber(now, 'now');
35
+ validNumber(ttlMs, 'ttlMs');
36
+ validNumber(maxBytes, 'maxBytes');
37
+ if (preserveName !== null && !/^[a-f0-9]{64}\.txt$/.test(preserveName)) {
38
+ throw new TypeError('preserveName is invalid');
39
+ }
40
+ const directoryStat = fs.lstatSync(directory, { throwIfNoEntry: false });
41
+ if (!directoryStat?.isDirectory() || directoryStat.isSymbolicLink()) throw new Error('artifact directory is unsafe');
42
+
43
+ const entries = fs.readdirSync(directory).map((name) => safeArtifact(directory, name)).filter(Boolean);
44
+ const expired = entries.filter((entry) => now - entry.mtimeMs >= ttlMs);
45
+ const keep = entries.filter((entry) => !expired.includes(entry));
46
+ let totalBytes = keep.reduce((total, entry) => total + entry.bytes, 0);
47
+ const removals = [...expired, ...keep.sort((left, right) => {
48
+ if (left.name === preserveName) return 1;
49
+ if (right.name === preserveName) return -1;
50
+ return left.mtimeMs - right.mtimeMs || left.name.localeCompare(right.name);
51
+ })];
52
+ let removed = 0;
53
+ let removedBytes = 0;
54
+ for (const entry of removals) {
55
+ if (expired.includes(entry) || totalBytes > maxBytes) {
56
+ try {
57
+ const current = safeArtifact(directory, entry.name);
58
+ if (!current || current.target !== entry.target) continue;
59
+ fs.rmSync(current.target);
60
+ removed += 1;
61
+ removedBytes += current.bytes;
62
+ if (!expired.includes(entry)) totalBytes -= current.bytes;
63
+ } catch { /* cleanup is best-effort and never follows unresolved targets */ }
64
+ }
65
+ }
66
+ return { removed, removedBytes, retainedBytes: Math.max(0, totalBytes) };
67
+ }
@@ -17,6 +17,11 @@ function handleDigest(ref) {
17
17
  return match[1];
18
18
  }
19
19
 
20
+ export function validateArtifactHandle(ref) {
21
+ handleDigest(ref);
22
+ return ref;
23
+ }
24
+
20
25
  function integer(value, name, { positive = false } = {}) {
21
26
  if (!Number.isSafeInteger(value) || value < (positive ? 1 : 0)) throw new TypeError(`${name} is invalid`);
22
27
  return value;
@@ -1,4 +1,4 @@
1
- import { recoverArtifactContent } from './artifact-recovery.mjs';
1
+ import { recoverArtifactContent, validateArtifactHandle } from './artifact-recovery.mjs';
2
2
 
3
3
  const MAX_ARTIFACTS = 128;
4
4
  const MAX_STORED_BYTES = 64 * 1024 * 1024;
@@ -28,6 +28,7 @@ export function rememberArtifact(artifact) {
28
28
  }
29
29
 
30
30
  export function recoverStoredArtifact(options = {}) {
31
+ validateArtifactHandle(options.ref);
31
32
  const entry = store.get(options.ref);
32
33
  if (!entry) throw new Error('artifact handle is unavailable in this MCP session');
33
34
  store.delete(options.ref);
@@ -45,6 +45,10 @@ function counter(value) {
45
45
  return Number.isSafeInteger(value) && value >= 0 ? value : 0;
46
46
  }
47
47
 
48
+ function validCounter(value) {
49
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
50
+ }
51
+
48
52
  /** Stable digest of a JSON-serializable value. Order-sensitive by design: a reordered
49
53
  * tool array is a different byte prefix to the provider even if the set is equal. */
50
54
  export function shapeDigest(value) {
@@ -88,7 +92,9 @@ export function hasBreakpoint(body) {
88
92
  *
89
93
  * `current` / `previous` are `{ at, usage, tools, system, messages, body }`, where
90
94
  * `usage` is `{ cachedInputTokens, cacheWriteInputTokens, inputTokens }` as recorded
91
- * in the provider ledger. `previous` is null on the first turn of a session.
95
+ * in the provider ledger. `inputTokens` is the complete prompt, including cache
96
+ * reads and writes; `promptTokens` is accepted as its clearer alias. `previous` is
97
+ * null on the first turn of a session.
92
98
  */
93
99
  export function attributeTurn({
94
100
  current,
@@ -101,8 +107,10 @@ export function attributeTurn({
101
107
  const usage = object(current.usage) ? current.usage : {};
102
108
  const cacheReadTokens = counter(usage.cachedInputTokens);
103
109
  const cacheWriteTokens = counter(usage.cacheWriteInputTokens);
104
- const freshInputTokens = counter(usage.inputTokens);
105
- const totalPromptTokens = cacheReadTokens + cacheWriteTokens + freshInputTokens;
110
+ const promptTokens = validCounter(usage.promptTokens) ?? counter(usage.inputTokens);
111
+ const totalPromptTokens = promptTokens;
112
+ const effectiveInputTokens = Math.max(0, promptTokens - cacheReadTokens);
113
+ const freshInputTokens = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens);
106
114
  const hit = cacheReadTokens > 0;
107
115
 
108
116
  const currentDigests = messageDigests(current.messages);
@@ -112,6 +120,8 @@ export function attributeTurn({
112
120
  const detail = {
113
121
  cacheReadTokens,
114
122
  cacheWriteTokens,
123
+ promptTokens,
124
+ effectiveInputTokens,
115
125
  freshInputTokens,
116
126
  totalPromptTokens,
117
127
  divergedAtMessage: divergedAt,
@@ -1,9 +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';
6
8
  import { buildHistoryDisclosure } from './history-disclosure.mjs';
9
+ import { prepareHistoryArtifact, persistHistoryArtifact } from './history-archive.mjs';
10
+ import { createRedactionProfile } from './redaction-profile.mjs';
7
11
 
8
12
  const SUPERSEDED = '[sando superseded by newer read]';
9
13
  const USELESS = '[sando elided useless success]';
@@ -12,6 +16,11 @@ const USELESS_SUCCESSES = new Set([
12
16
  'no output.',
13
17
  '(no output)',
14
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;
15
24
 
16
25
  function object(value) {
17
26
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -76,10 +85,35 @@ function resultError(item, text) {
76
85
  return /^\s*(?:error|failed|failure)\b[:\s-]*/i.test(text);
77
86
  }
78
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
+
79
109
  function useless(text) {
80
110
  return USELESS_SUCCESSES.has(text.trim().toLowerCase());
81
111
  }
82
112
 
113
+ function historyArchiveMarker(text) {
114
+ return /^\[sando archived result sando:sha256:[a-f0-9]{64}; /.test(text) && text.endsWith(']');
115
+ }
116
+
83
117
  function resultText(value) {
84
118
  if (typeof value === 'string') return value;
85
119
  if (Array.isArray(value) && value.length > 0
@@ -168,7 +202,7 @@ function collectResponses(body) {
168
202
  if (['function_call', 'custom_tool_call'].includes(item?.type)) {
169
203
  entries.push({
170
204
  kind: 'call', id: item.call_id, name: item.name,
171
- input: parseArguments(item.arguments ?? item.input), position,
205
+ input: parseArguments(item.arguments ?? item.input), position, providerType: item.type,
172
206
  });
173
207
  } else if (['function_call_output', 'custom_tool_call_output'].includes(item?.type)) {
174
208
  entries.push({
@@ -186,13 +220,14 @@ const COLLECTORS = {
186
220
  'openai-responses': collectResponses,
187
221
  };
188
222
 
189
- function historyRecords(entries, calls) {
223
+ function historyRecords(entries, calls, provider) {
190
224
  return entries.flatMap((entry) => {
191
225
  if (entry.kind !== 'result') return [];
192
226
  const call = calls.get(entry.id);
193
227
  const text = resultText(entry.item[entry.key]);
194
228
  if (!call || text === null) return [];
195
229
  const isError = resultError(entry.item, text);
230
+ const safe = resultSuccess(entry.item, provider, text);
196
231
  return [{
197
232
  id: entry.id,
198
233
  toolName: call.name,
@@ -200,7 +235,7 @@ function historyRecords(entries, calls) {
200
235
  output: entry.item[entry.key],
201
236
  current: entry.current,
202
237
  historical: !entry.current,
203
- safe: !isError,
238
+ safe,
204
239
  isError,
205
240
  position: entry.position,
206
241
  estimatedTokens: estimateTokens(text),
@@ -225,7 +260,29 @@ function collectHistoryRecords(provider, body) {
225
260
  calls.delete(id);
226
261
  results.delete(id);
227
262
  }
228
- 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;
229
286
  }
230
287
 
231
288
  export function detectProviderBody(body, headers = {}) {
@@ -263,6 +320,46 @@ export function listSemanticCandidates({ provider, body, model } = {}) {
263
320
  }));
264
321
  }
265
322
 
323
+ export function listSemanticJudgmentCandidates({ provider, originalBody, transformedBody, model } = {}) {
324
+ const originalRecords = collectHistoryRecords(provider, originalBody);
325
+ const originalById = new Map(originalRecords.map((record) => [record.id, record]));
326
+ return collectHistoryRecords(provider, transformedBody)
327
+ .filter((record) => record.safe && record.historical)
328
+ .flatMap((preview) => {
329
+ const original = originalById.get(preview.id);
330
+ const originalText = resultText(original?.output);
331
+ const previewText = resultText(preview.output);
332
+ if (!original || originalText === null || previewText === null || originalText === previewText) return [];
333
+ return [{
334
+ id: preview.id,
335
+ provider,
336
+ model: model ?? null,
337
+ toolName: preview.toolName,
338
+ originalText,
339
+ previewText,
340
+ historical: preview.historical,
341
+ isError: preview.isError,
342
+ recoverable: historyArchiveMarker(previewText),
343
+ estimatedTokens: original.estimatedTokens,
344
+ previewTokens: preview.estimatedTokens,
345
+ }];
346
+ });
347
+ }
348
+
349
+ export function restoreSemanticJudgmentCandidates({ provider, originalBody, transformedBody, ids = [] } = {}) {
350
+ const restoreIds = new Set(ids);
351
+ const body = structuredClone(transformedBody);
352
+ if (restoreIds.size === 0) return body;
353
+ const originalById = new Map(collectHistoryRecords(provider, originalBody).map((record) => [record.id, record]));
354
+ for (const preview of collectHistoryRecords(provider, body)) {
355
+ if (!restoreIds.has(preview.id) || !preview.historical || !preview.safe) continue;
356
+ const original = originalById.get(preview.id);
357
+ if (!original?.safe || resultText(original.output) === null) continue;
358
+ replaceResult(preview.entry.item, preview.entry.key, structuredClone(original.output));
359
+ }
360
+ return body;
361
+ }
362
+
266
363
  function carriesBreakpoint(value) {
267
364
  if (Array.isArray(value)) return value.some(carriesBreakpoint);
268
365
  if (!object(value)) return false;
@@ -322,16 +419,21 @@ function suffixTokensByPosition(body) {
322
419
  return suffix;
323
420
  }
324
421
 
325
- export function transformProviderRequest({ provider, body, policy, idleMs, redactionProfile } = {}) {
422
+ export function transformProviderRequest({ provider, body, policy, idleMs, redactionProfile, historyArchiveRoot } = {}) {
326
423
  const clone = structuredClone(body);
327
424
  const estimatedInputTokens = estimate(body);
328
425
  const selectedProvider = provider ?? detectProviderBody(body);
426
+ const strategies = strategyPolicy(policy);
329
427
  const collector = COLLECTORS[selectedProvider];
330
428
  let supersededReads = 0;
331
429
  let elidedUselessSuccesses = 0;
332
430
  let deduplicatedResults = 0;
333
431
  let compactedStructures = 0;
334
432
  let shakenResults = 0;
433
+ let archivedResults = 0;
434
+ let archiveRedactionSkips = 0;
435
+ let archiveNoSavingsSkips = 0;
436
+ let archiveSizeSkips = 0;
335
437
  const disclosures = [];
336
438
  const disclose = (record, reason, originalText, visibleText, recovery = 'rerun-tool') => {
337
439
  if (typeof originalText !== 'string' || typeof visibleText !== 'string') return;
@@ -342,6 +444,22 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
342
444
  const maxHistoryTokens = object(policy) && Object.hasOwn(policy, 'maxHistoryTokens')
343
445
  ? validateMaxHistoryTokens(policy.maxHistoryTokens)
344
446
  : null;
447
+ const historyArchiveRetainResults = object(policy) && Object.hasOwn(policy, 'historyArchiveRetainResults')
448
+ ? policy.historyArchiveRetainResults
449
+ : DEFAULT_HISTORY_ARCHIVE_RETAIN_RESULTS;
450
+ if (!Number.isSafeInteger(historyArchiveRetainResults) || historyArchiveRetainResults < 0) {
451
+ throw new TypeError('historyArchiveRetainResults must be a non-negative safe integer');
452
+ }
453
+ const historyArchiveMinBytes = object(policy) && Object.hasOwn(policy, 'historyArchiveMinBytes')
454
+ ? policy.historyArchiveMinBytes
455
+ : DEFAULT_HISTORY_ARCHIVE_MIN_BYTES;
456
+ if (!Number.isSafeInteger(historyArchiveMinBytes) || historyArchiveMinBytes < 0) {
457
+ throw new TypeError('historyArchiveMinBytes must be a non-negative safe integer');
458
+ }
459
+ if (strategies.recoverableArchive
460
+ && (typeof historyArchiveRoot !== 'string' || !path.isAbsolute(historyArchiveRoot))) {
461
+ throw new TypeError('historyArchiveRoot must be an absolute path');
462
+ }
345
463
  const budgetTriggered = maxHistoryTokens !== null
346
464
  && BigInt(estimatedInputTokens) * 5n > BigInt(maxHistoryTokens) * 4n;
347
465
  // Don't rewrite warm cached history unless the rewrite reclaims enough of the suffix
@@ -391,18 +509,46 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
391
509
  results.delete(id);
392
510
  }
393
511
 
512
+ const archiveRecords = historyRecords(entries, calls, selectedProvider);
513
+ const resultEntries = entries.filter((entry) => entry.kind === 'result');
514
+ const recentEntries = new Set(historyArchiveRetainResults === 0
515
+ ? []
516
+ : resultEntries.slice(-historyArchiveRetainResults));
517
+ const archivedIds = new Set();
518
+ if (strategies.recoverableArchive) {
519
+ const profile = redactionProfile ?? DEFAULT_REDACTION_PROFILE;
520
+ if (!profile || typeof profile.redact !== 'function') throw new TypeError('history archive redaction profile is invalid');
521
+ for (const record of archiveRecords) {
522
+ const nativeSafety = nativeCodexExecSafety(selectedProvider, calls.get(record.id), record);
523
+ const safe = nativeSafety ?? record.safe;
524
+ if (!safe || !record.historical || recentEntries.has(record.entry)) continue;
525
+ const text = resultText(record.output);
526
+ if (text === null || historyArchiveMarker(text)) continue;
527
+ if (Buffer.byteLength(text, 'utf8') < historyArchiveMinBytes) { archiveSizeSkips += 1; continue; }
528
+ if (profile.redact(text).text !== text) { archiveRedactionSkips += 1; continue; }
529
+ const artifact = prepareHistoryArtifact({ root: historyArchiveRoot, content: text });
530
+ const reclaimed = reclaimedTokens(text, artifact.marker);
531
+ if (reclaimed === 0) { archiveNoSavingsSkips += 1; continue; }
532
+ if (cacheProtected(record.entry, reclaimed)) { cacheProtectedSkips += 1; continue; }
533
+ persistHistoryArtifact(artifact);
534
+ replaceResult(record.entry.item, record.entry.key, artifact.marker);
535
+ archivedIds.add(record.id);
536
+ archivedResults += 1;
537
+ }
538
+ }
539
+
394
540
  const reads = [];
395
541
  for (const call of calls.values()) {
396
542
  const result = results.get(call.id);
397
543
  const identity = readIdentity(call.name, call.input);
398
544
  const text = result && resultText(result.item[result.key]);
399
545
  if (!result || !identity || text === null) continue;
400
- if (!resultError(result.item, text)) reads.push({ call, result, identity, text });
546
+ if (resultSuccess(result.item, selectedProvider, text)) reads.push({ call, result, identity, text });
401
547
  }
402
548
  reads.sort((a, b) => a.call.position - b.call.position);
403
- for (let index = 0; index < reads.length; index += 1) {
549
+ for (let index = 0; strategies.supersededRead && index < reads.length; index += 1) {
404
550
  const old = reads[index];
405
- if (old.result.current) continue;
551
+ if (old.result.current || archivedIds.has(old.call.id)) continue;
406
552
  const newer = reads.slice(index + 1).find((candidate) =>
407
553
  covers(candidate.identity, old.identity) && !useless(candidate.text));
408
554
  if (!newer) continue;
@@ -412,23 +558,24 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
412
558
  supersededReads += 1;
413
559
  }
414
560
 
415
- for (const [id, result] of results) {
416
- if (!calls.has(id) || result.current) continue;
561
+ for (const [id, result] of strategies.producerUseless ? results : []) {
562
+ if (!calls.has(id) || result.current || archivedIds.has(id)) continue;
417
563
  const text = resultText(result.item[result.key]);
418
564
  if (text === null) continue;
419
- if (text === SUPERSEDED || resultError(result.item, text) || !useless(text)) continue;
565
+ if (text === SUPERSEDED || !resultSuccess(result.item, selectedProvider, text) || !useless(text)) continue;
420
566
  if (cacheProtected(result, reclaimedTokens(text, USELESS))) { cacheProtectedSkips += 1; continue; }
421
567
  replaceResult(result.item, result.key, USELESS);
422
568
  disclose({ toolName: calls.get(id).name }, 'useless-success', text, USELESS);
423
569
  elidedUselessSuccesses += 1;
424
570
  }
425
571
 
426
- const records = historyRecords(entries, calls);
572
+ const records = historyRecords(entries, calls, selectedProvider);
427
573
  const candidates = maxHistoryTokens === null
428
- ? records.filter((record) => record.safe && record.historical)
429
- : selectHistoryCandidates({ bodyTokens: estimatedInputTokens, maxHistoryTokens, candidates: records });
574
+ ? records.filter((record) => record.safe && record.historical && !archivedIds.has(record.id))
575
+ : selectHistoryCandidates({ bodyTokens: estimatedInputTokens, maxHistoryTokens, candidates: records })
576
+ .filter((record) => !archivedIds.has(record.id));
430
577
  const candidateIds = new Set(candidates.map((candidate) => candidate.id));
431
- const reductions = dedupeHistory(records);
578
+ const reductions = strategies.exactDuplicate ? dedupeHistory(records) : { entries: [] };
432
579
  const recordsById = new Map(records.map((record) => [record.id, record]));
433
580
  for (const reduced of reductions.entries) {
434
581
  if (!candidateIds.has(reduced.id)) continue;
@@ -441,7 +588,7 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
441
588
  deduplicatedResults += 1;
442
589
  }
443
590
 
444
- for (const record of records) {
591
+ for (const record of strategies.repeatedLines ? records : []) {
445
592
  if (!candidateIds.has(record.id)) continue;
446
593
  const text = resultText(record.entry.item[record.entry.key]);
447
594
  if (text === null) continue;
@@ -458,7 +605,7 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
458
605
  compactedStructures += 1;
459
606
  }
460
607
 
461
- if (maxHistoryTokens !== null && budgetTriggered) {
608
+ if (strategies.historyShake && maxHistoryTokens !== null && budgetTriggered) {
462
609
  for (const record of records) {
463
610
  if (!candidateIds.has(record.id)) continue;
464
611
  const text = resultText(record.entry.item[record.entry.key]);
@@ -484,6 +631,7 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
484
631
  if (deduplicatedResults > 0) reasons.push('duplicate-history');
485
632
  if (compactedStructures > 0) reasons.push('repeated-lines');
486
633
  if (shakenResults > 0) reasons.push('history-shake');
634
+ if (archivedResults > 0) reasons.unshift('recoverable-archive');
487
635
  return {
488
636
  body: clone,
489
637
  changed: reasons.length > 0,
@@ -497,6 +645,10 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
497
645
  deduplicatedResults,
498
646
  compactedStructures,
499
647
  shakenResults,
648
+ archivedResults,
649
+ archiveRedactionSkips,
650
+ archiveNoSavingsSkips,
651
+ archiveSizeSkips,
500
652
  historyDisclosureCount: disclosures.length,
501
653
  historyDisclosureOriginalBytes: disclosures.reduce((total, item) => total + item.bytes.original, 0),
502
654
  historyDisclosureVisibleBytes: disclosures.reduce((total, item) => total + item.bytes.visible, 0),