sandoichi 0.4.2 → 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.
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
@@ -90,6 +90,11 @@ export {
90
90
  recoverArtifactFromWorkspace,
91
91
  } from './src/artifact-recovery.mjs';
92
92
  export { runArtifactCli } from './src/artifact-cli.mjs';
93
+ export {
94
+ DEFAULT_ARTIFACT_MAX_BYTES,
95
+ DEFAULT_ARTIFACT_TTL_MS,
96
+ cleanupArtifacts,
97
+ } from './src/artifact-lifecycle.mjs';
93
98
  export { buildF1TelemetryEvent, publishF1Telemetry } from './src/f1-telemetry.mjs';
94
99
  export {
95
100
  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.5.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 = {}) {
@@ -322,16 +379,21 @@ function suffixTokensByPosition(body) {
322
379
  return suffix;
323
380
  }
324
381
 
325
- export function transformProviderRequest({ provider, body, policy, idleMs, redactionProfile } = {}) {
382
+ export function transformProviderRequest({ provider, body, policy, idleMs, redactionProfile, historyArchiveRoot } = {}) {
326
383
  const clone = structuredClone(body);
327
384
  const estimatedInputTokens = estimate(body);
328
385
  const selectedProvider = provider ?? detectProviderBody(body);
386
+ const strategies = strategyPolicy(policy);
329
387
  const collector = COLLECTORS[selectedProvider];
330
388
  let supersededReads = 0;
331
389
  let elidedUselessSuccesses = 0;
332
390
  let deduplicatedResults = 0;
333
391
  let compactedStructures = 0;
334
392
  let shakenResults = 0;
393
+ let archivedResults = 0;
394
+ let archiveRedactionSkips = 0;
395
+ let archiveNoSavingsSkips = 0;
396
+ let archiveSizeSkips = 0;
335
397
  const disclosures = [];
336
398
  const disclose = (record, reason, originalText, visibleText, recovery = 'rerun-tool') => {
337
399
  if (typeof originalText !== 'string' || typeof visibleText !== 'string') return;
@@ -342,6 +404,22 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
342
404
  const maxHistoryTokens = object(policy) && Object.hasOwn(policy, 'maxHistoryTokens')
343
405
  ? validateMaxHistoryTokens(policy.maxHistoryTokens)
344
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
+ }
345
423
  const budgetTriggered = maxHistoryTokens !== null
346
424
  && BigInt(estimatedInputTokens) * 5n > BigInt(maxHistoryTokens) * 4n;
347
425
  // Don't rewrite warm cached history unless the rewrite reclaims enough of the suffix
@@ -391,18 +469,46 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
391
469
  results.delete(id);
392
470
  }
393
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
+
394
500
  const reads = [];
395
501
  for (const call of calls.values()) {
396
502
  const result = results.get(call.id);
397
503
  const identity = readIdentity(call.name, call.input);
398
504
  const text = result && resultText(result.item[result.key]);
399
505
  if (!result || !identity || text === null) continue;
400
- if (!resultError(result.item, text)) reads.push({ call, result, identity, text });
506
+ if (resultSuccess(result.item, selectedProvider, text)) reads.push({ call, result, identity, text });
401
507
  }
402
508
  reads.sort((a, b) => a.call.position - b.call.position);
403
- for (let index = 0; index < reads.length; index += 1) {
509
+ for (let index = 0; strategies.supersededRead && index < reads.length; index += 1) {
404
510
  const old = reads[index];
405
- if (old.result.current) continue;
511
+ if (old.result.current || archivedIds.has(old.call.id)) continue;
406
512
  const newer = reads.slice(index + 1).find((candidate) =>
407
513
  covers(candidate.identity, old.identity) && !useless(candidate.text));
408
514
  if (!newer) continue;
@@ -412,23 +518,24 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
412
518
  supersededReads += 1;
413
519
  }
414
520
 
415
- for (const [id, result] of results) {
416
- 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;
417
523
  const text = resultText(result.item[result.key]);
418
524
  if (text === null) continue;
419
- if (text === SUPERSEDED || resultError(result.item, text) || !useless(text)) continue;
525
+ if (text === SUPERSEDED || !resultSuccess(result.item, selectedProvider, text) || !useless(text)) continue;
420
526
  if (cacheProtected(result, reclaimedTokens(text, USELESS))) { cacheProtectedSkips += 1; continue; }
421
527
  replaceResult(result.item, result.key, USELESS);
422
528
  disclose({ toolName: calls.get(id).name }, 'useless-success', text, USELESS);
423
529
  elidedUselessSuccesses += 1;
424
530
  }
425
531
 
426
- const records = historyRecords(entries, calls);
532
+ const records = historyRecords(entries, calls, selectedProvider);
427
533
  const candidates = maxHistoryTokens === null
428
- ? records.filter((record) => record.safe && record.historical)
429
- : 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));
430
537
  const candidateIds = new Set(candidates.map((candidate) => candidate.id));
431
- const reductions = dedupeHistory(records);
538
+ const reductions = strategies.exactDuplicate ? dedupeHistory(records) : { entries: [] };
432
539
  const recordsById = new Map(records.map((record) => [record.id, record]));
433
540
  for (const reduced of reductions.entries) {
434
541
  if (!candidateIds.has(reduced.id)) continue;
@@ -441,7 +548,7 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
441
548
  deduplicatedResults += 1;
442
549
  }
443
550
 
444
- for (const record of records) {
551
+ for (const record of strategies.repeatedLines ? records : []) {
445
552
  if (!candidateIds.has(record.id)) continue;
446
553
  const text = resultText(record.entry.item[record.entry.key]);
447
554
  if (text === null) continue;
@@ -458,7 +565,7 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
458
565
  compactedStructures += 1;
459
566
  }
460
567
 
461
- if (maxHistoryTokens !== null && budgetTriggered) {
568
+ if (strategies.historyShake && maxHistoryTokens !== null && budgetTriggered) {
462
569
  for (const record of records) {
463
570
  if (!candidateIds.has(record.id)) continue;
464
571
  const text = resultText(record.entry.item[record.entry.key]);
@@ -484,6 +591,7 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
484
591
  if (deduplicatedResults > 0) reasons.push('duplicate-history');
485
592
  if (compactedStructures > 0) reasons.push('repeated-lines');
486
593
  if (shakenResults > 0) reasons.push('history-shake');
594
+ if (archivedResults > 0) reasons.unshift('recoverable-archive');
487
595
  return {
488
596
  body: clone,
489
597
  changed: reasons.length > 0,
@@ -497,6 +605,10 @@ export function transformProviderRequest({ provider, body, policy, idleMs, redac
497
605
  deduplicatedResults,
498
606
  compactedStructures,
499
607
  shakenResults,
608
+ archivedResults,
609
+ archiveRedactionSkips,
610
+ archiveNoSavingsSkips,
611
+ archiveSizeSkips,
500
612
  historyDisclosureCount: disclosures.length,
501
613
  historyDisclosureOriginalBytes: disclosures.reduce((total, item) => total + item.bytes.original, 0),
502
614
  historyDisclosureVisibleBytes: disclosures.reduce((total, item) => total + item.bytes.visible, 0),