sandoichi 0.5.0 → 0.6.1

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/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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandoichi",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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",
@@ -4,6 +4,23 @@ import path from 'node:path';
4
4
  export const DEFAULT_ARTIFACT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
5
5
  export const DEFAULT_ARTIFACT_MAX_BYTES = 64 * 1024 * 1024;
6
6
 
7
+ export function reuseArtifact(destination, expectedContent) {
8
+ const { O_NOFOLLOW, O_NONBLOCK } = fs.constants;
9
+ if (!Number.isInteger(O_NOFOLLOW) || !Number.isInteger(O_NONBLOCK)) {
10
+ throw new Error('artifact storage requires no-follow open support');
11
+ }
12
+ const flags = fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK;
13
+ const handle = fs.openSync(destination, flags);
14
+ try {
15
+ const stat = fs.fstatSync(handle);
16
+ if (!stat.isFile() || stat.nlink !== 1) throw new Error('artifact file is unsafe');
17
+ if (fs.readFileSync(handle, 'utf8') !== expectedContent) throw new Error('artifact content differs');
18
+ fs.fchmodSync(handle, 0o600);
19
+ } finally {
20
+ fs.closeSync(handle);
21
+ }
22
+ }
23
+
7
24
  function validNumber(value, name) {
8
25
  if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} is invalid`);
9
26
  return value;
@@ -320,6 +320,46 @@ export function listSemanticCandidates({ provider, body, model } = {}) {
320
320
  }));
321
321
  }
322
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
+
323
363
  function carriesBreakpoint(value) {
324
364
  if (Array.isArray(value)) return value.some(carriesBreakpoint);
325
365
  if (!object(value)) return false;
@@ -2,6 +2,8 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
+ import { reuseArtifact } from './artifact-lifecycle.mjs';
6
+
5
7
  const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
6
8
  const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
7
9
 
@@ -70,11 +72,11 @@ export function persistHistoryArtifact(artifact) {
70
72
  fs.writeFileSync(temporary, artifact.content, { flag: 'wx', mode: 0o600 });
71
73
  try { fs.linkSync(temporary, destination); }
72
74
  catch (error) {
73
- if (error?.code !== 'EEXIST' || fs.readFileSync(destination, 'utf8') !== artifact.content) throw error;
75
+ if (error?.code !== 'EEXIST') throw error;
76
+ reuseArtifact(destination, artifact.content);
74
77
  }
75
78
  } finally {
76
79
  fs.rmSync(temporary, { force: true });
77
80
  }
78
- fs.chmodSync(destination, 0o600);
79
81
  return artifact;
80
82
  }
package/src/hook-cli.mjs CHANGED
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
3
3
  import path from 'node:path';
4
4
 
5
5
  import { createReceipt, normalizeEvent, normalizePolicy, optimizeToolOutput } from './core.mjs';
6
- import { cleanupArtifacts } from './artifact-lifecycle.mjs';
6
+ import { cleanupArtifacts, reuseArtifact } from './artifact-lifecycle.mjs';
7
7
  import { loadProjectRedactionProfile } from './redaction-config.mjs';
8
8
  import { defaultMetricsPath, recordMetrics } from './metrics.mjs';
9
9
  import {
@@ -86,12 +86,12 @@ function artifactPath(cwd, artifact) {
86
86
  fs.writeFileSync(temporary, artifact.content, { flag: 'wx', mode: 0o600 });
87
87
  try { fs.linkSync(temporary, destination); }
88
88
  catch (error) {
89
- if (error?.code !== 'EEXIST' || fs.readFileSync(destination, 'utf8') !== artifact.content) throw error;
89
+ if (error?.code !== 'EEXIST') throw error;
90
+ reuseArtifact(destination, artifact.content);
90
91
  }
91
92
  } finally {
92
93
  fs.rmSync(temporary, { force: true });
93
94
  }
94
- fs.chmodSync(destination, 0o600);
95
95
  cleanupArtifacts(directory, { preserveName: name });
96
96
  if (!artifactPresent(destination)) throw new Error('artifact storage limit removed the new artifact');
97
97
  return path.posix.join('.sando/sando', 'artifacts', name);
package/src/proxy.mjs CHANGED
@@ -6,7 +6,12 @@ import { brotliDecompressSync, gunzipSync, inflateSync, zstdDecompressSync } fro
6
6
 
7
7
  import { estimateTokens } from './core.mjs';
8
8
  import { buildContextCaptureRecord, recordContextCapture } from './context-capture.mjs';
9
- import { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './context-transform.mjs';
9
+ import {
10
+ detectProviderBody,
11
+ listSemanticCandidates,
12
+ listSemanticJudgmentCandidates,
13
+ transformProviderRequest,
14
+ } from './context-transform.mjs';
10
15
  import { publishF1Telemetry } from './f1-telemetry.mjs';
11
16
  import { recordProxyRequest } from './proxy-metrics.mjs';
12
17
  import {
@@ -407,9 +412,61 @@ async function observeSemanticCandidates({ provider, candidates, semanticCompact
407
412
  }
408
413
  }
409
414
 
415
+ function createSemanticJudgeStats(candidates) {
416
+ return {
417
+ candidates: candidates.length,
418
+ attempted: candidates.length,
419
+ judged: 0,
420
+ losses: 0,
421
+ preserved: 0,
422
+ fallbacks: 0,
423
+ skipped: 0,
424
+ pending: candidates.length,
425
+ };
426
+ }
427
+
428
+ function createPendingSemanticJudgeStats() {
429
+ const stats = createSemanticJudgeStats([]);
430
+ stats.pending = 1;
431
+ return stats;
432
+ }
433
+
434
+ function scheduleSemanticJudgments({ provider, originalBody, transformedBody, model, semanticJudge, stats }) {
435
+ setImmediate(() => {
436
+ let candidates;
437
+ try {
438
+ candidates = listSemanticJudgmentCandidates({ provider, originalBody, transformedBody, model });
439
+ } catch {
440
+ stats.fallbacks += 1;
441
+ stats.pending = 0;
442
+ return;
443
+ }
444
+ Object.assign(stats, createSemanticJudgeStats(candidates));
445
+ void observeSemanticJudgments({ candidates, semanticJudge, stats });
446
+ });
447
+ }
448
+
449
+ async function observeSemanticJudgments({ candidates, semanticJudge, stats }) {
450
+ for (const candidate of candidates) {
451
+ try {
452
+ const result = await semanticJudge(candidate);
453
+ if (result?.status === 'judged') {
454
+ stats.judged += 1;
455
+ if (result.verdict === 'loss') stats.losses += 1;
456
+ else if (result.verdict === 'preserved') stats.preserved += 1;
457
+ } else if (result?.status === 'skipped') stats.skipped += 1;
458
+ else stats.fallbacks += 1;
459
+ } catch {
460
+ stats.fallbacks += 1;
461
+ } finally {
462
+ stats.pending -= 1;
463
+ }
464
+ }
465
+ }
466
+
410
467
  export async function createProviderProxy({
411
468
  upstream, host = '127.0.0.1', port = 0, policy = {}, maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
412
- semanticCompactor, metricsPath, contextCapturePath, contextCaptureHost, contextSessionKey,
469
+ semanticCompactor, semanticJudge, metricsPath, contextCapturePath, contextCaptureHost, contextSessionKey,
413
470
  f1TelemetryPublisher = publishF1Telemetry, transformProviderRequests = false,
414
471
  historyArchiveRoot,
415
472
  env = process.env,
@@ -521,6 +578,18 @@ export async function createProviderProxy({
521
578
  lastStats.semantic = stats;
522
579
  setImmediate(() => observeSemanticCandidates({ provider, candidates, semanticCompactor, stats }));
523
580
  }
581
+ if (typeof semanticJudge === 'function' && transformed.changed) {
582
+ const stats = createPendingSemanticJudgeStats();
583
+ lastStats.semanticJudge = stats;
584
+ scheduleSemanticJudgments({
585
+ provider,
586
+ originalBody: parsed,
587
+ transformedBody: transformed.body,
588
+ model: recordModel,
589
+ semanticJudge,
590
+ stats,
591
+ });
592
+ }
524
593
  }
525
594
  } catch {
526
595
  recordProxyFailure({ env, provider, failureStage: 'optimization' });
@@ -2,10 +2,6 @@ import { createHash } from 'node:crypto';
2
2
 
3
3
  const BUILT_IN_MATCHERS = Object.freeze([
4
4
  [/-----BEGIN [A-Z ]+ KEY-----[\s\S]*?-----END [A-Z ]+ KEY-----/g, '[REDACTED PRIVATE KEY]'],
5
- [/(authorization\s*[:=]\s*(?!(?:bearer\s+)?\[REDACTED(?: (?:PRIVATE KEY|TOKEN))?\](?=$|[\s,"'}]))(?:bearer\s+)?)[^\s,"'}]+/gi,
6
- (_match, prefix) => `${prefix}[REDACTED]`],
7
- [/(["']?(?:api[_-]?key|access[_-]?token|password|secret|private[_-]?key)["']?\s*[:=]\s*["']?)(?!\[REDACTED(?: (?:PRIVATE KEY|TOKEN))?\](?=$|[\s,"'}]))[^\s,"'}]+/gi,
8
- (_match, prefix) => `${prefix}[REDACTED]`],
9
5
  [/\b(?:sk|rk)-[A-Za-z0-9_-]{12,}\b/g, '[REDACTED TOKEN]'],
10
6
  [/\bgh[pousr]_[A-Za-z0-9_-]{12,}\b/g, '[REDACTED TOKEN]'],
11
7
  [/\bgithub_pat_[A-Za-z0-9_-]{20,}\b/g, '[REDACTED TOKEN]'],
@@ -20,6 +16,55 @@ function escapeRegExp(value) {
20
16
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
17
  }
22
18
 
19
+ const REDACTION_WORD = 'REDACTED';
20
+ const REDACTION_PLACEHOLDER = `[${REDACTION_WORD}]`;
21
+ const REDACTION_VALUE = String.raw`\[${REDACTION_WORD}(?: (?:PRIVATE KEY|TOKEN))?\]`;
22
+ const ASSIGNMENT_VALUE = String.raw`${REDACTION_VALUE}(?=$|[\s,"'}])|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\\n])*(?=\n|$)|'(?:\\.|[^'\\\n])*(?=\n|$)|[^\s,"'}]+`;
23
+
24
+ function isRedactionPlaceholder(value) {
25
+ const unquoted = value.length >= 2 && ((value.startsWith('"') && value.endsWith('"'))
26
+ || (value.startsWith("'") && value.endsWith("'"))) ? value.slice(1, -1) : value;
27
+ return new RegExp(`^${REDACTION_VALUE}$`).test(unquoted);
28
+ }
29
+
30
+ function redactAssignment(prefix, value) {
31
+ if (isRedactionPlaceholder(value)) return `${prefix}${value}`;
32
+ const quote = value[0] === '"' || value[0] === "'" ? value[0] : '';
33
+ return `${prefix}${quote}${REDACTION_PLACEHOLDER}${quote}`;
34
+ }
35
+
36
+ function assignmentMatcher(keyPattern) {
37
+ return [
38
+ new RegExp(`(${keyPattern}\\s*[:=]\\s*)(${ASSIGNMENT_VALUE})`, 'gi'),
39
+ (_match, prefix, value) => redactAssignment(prefix, value),
40
+ ];
41
+ }
42
+
43
+ const AUTHORIZATION_MATCHER = [
44
+ new RegExp(
45
+ '(["\\\']?authorization["\\\']?\\s*[:=]\\s*(?:(?:bearer|basic)\\s+)?' + ')(' + ASSIGNMENT_VALUE + ')',
46
+ 'gi',
47
+ ),
48
+ (_match, prefix, value) => redactAssignment(prefix, value),
49
+ ];
50
+ const AUTHORIZATION_FIELD_MATCHER = [
51
+ new RegExp(
52
+ '(["\\\']?authorization["\\\']?\\s*[:=]\\s*)(?!(?:\\s*)(?:bearer|basic)\\s+)('
53
+ + '"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|[^\\r\\n]+)',
54
+ 'gi',
55
+ ),
56
+ (_match, prefix, value) => {
57
+ const leading = value.match(/^\s*/u)?.[0] ?? '';
58
+ const withoutLeading = value.slice(leading.length);
59
+ const trailing = withoutLeading.match(/\s*$/u)?.[0] ?? '';
60
+ const body = trailing ? withoutLeading.slice(0, -trailing.length) : withoutLeading;
61
+ return `${prefix}${leading}${redactAssignment('', body)}${trailing}`;
62
+ },
63
+ ];
64
+ const BUILT_IN_ASSIGNMENT_MATCHER = assignmentMatcher(
65
+ String.raw`["']?(?:api[_-]?key|access[_-]?token|password|secret|private[_-]?key)["']?`,
66
+ );
67
+
23
68
  function compareCanonical(left, right) {
24
69
  const a = JSON.stringify(left);
25
70
  const b = JSON.stringify(right);
@@ -79,11 +124,10 @@ function customMatchers(rules) {
79
124
  const tokens = [];
80
125
  for (const rule of rules) {
81
126
  if (rule.type === 'assignment-key') {
82
- const key = escapeRegExp(rule.key);
83
- assignments.push([
84
- new RegExp(`(?<![A-Za-z0-9_.-])(["']?${key}["']?\\s*[:=]\\s*["']?)(?!\\[REDACTED\\](?=$|[\\s,"'}]))[^\\s,"'}]+`, 'gi'),
85
- (_match, prefix) => `${prefix}[REDACTED]`,
86
- ]);
127
+ const key = escapeRegExp(rule.key);
128
+ assignments.push(assignmentMatcher(
129
+ String.raw`(?<![A-Za-z0-9_.-])["']?${key}["']?`,
130
+ ));
87
131
  } else {
88
132
  const maximum = rule.maxLength === null ? '' : rule.maxLength;
89
133
  tokens.push([
@@ -99,8 +143,9 @@ function applyMatchers(text, matchers) {
99
143
  let count = 0;
100
144
  for (const [pattern, replacement] of matchers) {
101
145
  text = text.replace(pattern, (...args) => {
102
- count += 1;
103
- return typeof replacement === 'function' ? replacement(...args) : replacement;
146
+ const next = typeof replacement === 'function' ? replacement(...args) : replacement;
147
+ if (next !== args[0]) count += 1;
148
+ return next;
104
149
  });
105
150
  }
106
151
  return { text, count };
@@ -110,9 +155,12 @@ export function createRedactionProfile(customRules = []) {
110
155
  const rules = normalizeRules(customRules);
111
156
  const custom = customMatchers(rules);
112
157
  const matchers = [
113
- ...BUILT_IN_MATCHERS.slice(0, 3),
158
+ BUILT_IN_MATCHERS[0],
159
+ AUTHORIZATION_FIELD_MATCHER,
160
+ AUTHORIZATION_MATCHER,
161
+ BUILT_IN_ASSIGNMENT_MATCHER,
114
162
  ...custom.assignments,
115
- ...BUILT_IN_MATCHERS.slice(3),
163
+ ...BUILT_IN_MATCHERS.slice(1),
116
164
  ...custom.tokens,
117
165
  ];
118
166
  const digest = `sha256:${createHash('sha256')
@@ -5,6 +5,8 @@ import { redact, hasSecret } from './secret-redaction.mjs';
5
5
 
6
6
  export const SEMANTIC_SUMMARY_SCHEMA = 'sando-semantic-summary/v1';
7
7
 
8
+ // Ordered extraction guarantees grounding in source text, not preservation of every omitted fact.
9
+
8
10
  const DEFAULT_POLICY = Object.freeze({
9
11
  minInputTokens: 8000,
10
12
  maxSummaryRatio: 0.2,
@@ -48,7 +50,7 @@ export function buildSemanticPrompt({ provider, model, toolName, text, requiredF
48
50
  const required = facts(requiredFacts);
49
51
  return [
50
52
  `Schema: ${SEMANTIC_SUMMARY_SCHEMA}`,
51
- 'Summarize the historical tool result for a coding agent.',
53
+ 'Return a concise extractive summary using only complete source lines in their original order; do not paraphrase or invent clauses.',
52
54
  'Keep exact paths, identifiers, errors, numbers, negations, and every required fact.',
53
55
  'Preserved facts must be copied verbatim from the tool result or required-facts list; do not invent or estimate counts.',
54
56
  'Return JSON only with schema, summary, and preservedFacts fields.',
@@ -82,6 +84,15 @@ export function validateSemanticSummary({
82
84
  if ((redactionProfile ? redactionProfile.hasSecret(summary) : hasSecret(summary))) {
83
85
  return { valid: false, reason: 'secret-detected', inputTokens, outputTokens };
84
86
  }
87
+ const redactedOriginal = redactionProfile ? redactionProfile.redact(originalText).text : redact(originalText).text;
88
+ const sourceLines = redactedOriginal.split(/\r?\n/);
89
+ let sourceIndex = 0;
90
+ for (const line of summary.split(/\r?\n/)) {
91
+ if (!line.trim()) continue;
92
+ const matchIndex = sourceLines.indexOf(line, sourceIndex);
93
+ if (matchIndex < 0) return { valid: false, reason: 'summary-not-extractive', inputTokens, outputTokens };
94
+ sourceIndex = matchIndex + 1;
95
+ }
85
96
  return { valid: true, inputTokens, outputTokens };
86
97
  }
87
98
 
@@ -0,0 +1,69 @@
1
+ import {
2
+ listSemanticJudgmentCandidates,
3
+ restoreSemanticJudgmentCandidates,
4
+ } from './context-transform.mjs';
5
+
6
+ const DEFAULT_LOSS_THRESHOLD = 0.7;
7
+
8
+ function lossThreshold(value) {
9
+ if (value === undefined) return DEFAULT_LOSS_THRESHOLD;
10
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
11
+ throw new TypeError('lossThreshold must be a number between 0 and 1');
12
+ }
13
+ return value;
14
+ }
15
+
16
+ export function createSemanticGate({ judge, lossThreshold: threshold } = {}) {
17
+ if (typeof judge !== 'function') throw new TypeError('judge must be a function');
18
+ const selectedThreshold = lossThreshold(threshold);
19
+
20
+ return async function gate({ provider, originalBody, transformedBody, model } = {}) {
21
+ const candidates = listSemanticJudgmentCandidates({
22
+ provider, originalBody, transformedBody, model,
23
+ });
24
+ const restoreIds = [];
25
+ const stats = {
26
+ candidates: candidates.length,
27
+ judged: 0,
28
+ losses: 0,
29
+ preserved: 0,
30
+ restored: 0,
31
+ fallbacks: 0,
32
+ skipped: 0,
33
+ lossThreshold: selectedThreshold,
34
+ };
35
+
36
+ for (const candidate of candidates) {
37
+ let result;
38
+ try {
39
+ result = await judge(candidate);
40
+ } catch {
41
+ stats.fallbacks += 1;
42
+ continue;
43
+ }
44
+ if (result?.status === 'judged') {
45
+ stats.judged += 1;
46
+ if (result.verdict === 'loss') {
47
+ stats.losses += 1;
48
+ if (Number.isFinite(result.lossProbability) && result.lossProbability >= selectedThreshold) {
49
+ restoreIds.push(candidate.id);
50
+ }
51
+ } else if (result.verdict === 'preserved') {
52
+ stats.preserved += 1;
53
+ } else {
54
+ stats.fallbacks += 1;
55
+ }
56
+ } else if (result?.status === 'skipped') {
57
+ stats.skipped += 1;
58
+ } else {
59
+ stats.fallbacks += 1;
60
+ }
61
+ }
62
+
63
+ const body = restoreSemanticJudgmentCandidates({
64
+ provider, originalBody, transformedBody, ids: restoreIds,
65
+ });
66
+ stats.restored = restoreIds.length;
67
+ return { body, stats };
68
+ };
69
+ }
@@ -0,0 +1,261 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { estimateTokens } from './core.mjs';
4
+ import { redact } from './secret-redaction.mjs';
5
+
6
+ export const SEMANTIC_JUDGMENT_SCHEMA = 'sando-semantic-judgment/v1';
7
+ const QUESTION_ID = 'preview_loses_diagnostic_evidence';
8
+
9
+ const DEFAULT_POLICY = Object.freeze({
10
+ minInputTokens: 8000,
11
+ maxTextChars: 6000,
12
+ timeoutMs: 1500,
13
+ maxRequests: 20,
14
+ lossThreshold: 0.7,
15
+ });
16
+
17
+ function object(value) {
18
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
19
+ }
20
+
21
+ function sha256(value) {
22
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
23
+ }
24
+
25
+ function validatePolicy(policy) {
26
+ const result = { ...DEFAULT_POLICY, ...(policy ?? {}) };
27
+ if (!Number.isInteger(result.minInputTokens) || result.minInputTokens < 1
28
+ || !Number.isInteger(result.maxTextChars) || result.maxTextChars < 64
29
+ || !Number.isInteger(result.timeoutMs) || result.timeoutMs < 1
30
+ || !Number.isInteger(result.maxRequests) || result.maxRequests < 1
31
+ || !Number.isFinite(result.lossThreshold) || result.lossThreshold < 0 || result.lossThreshold > 1) {
32
+ throw new TypeError('invalid semantic judge policy');
33
+ }
34
+ return result;
35
+ }
36
+
37
+ function boundedText(text, maxChars) {
38
+ if (text.length <= maxChars) return { text, sampled: false };
39
+ const marker = '\n[sando semantic judge middle elided]\n';
40
+ if (maxChars <= marker.length) return { text: text.slice(0, maxChars), sampled: true };
41
+ const available = maxChars - marker.length;
42
+ const headChars = Math.ceil(available / 2);
43
+ return {
44
+ text: `${text.slice(0, headChars)}${marker}${text.slice(-available + headChars)}`,
45
+ sampled: true,
46
+ };
47
+ }
48
+
49
+ function redactedBounded(text, maxChars, redactionProfile) {
50
+ const safe = redactionProfile ? redactionProfile.redact(text) : redact(text);
51
+ const bounded = boundedText(safe.text, maxChars);
52
+ return { ...bounded, count: safe.count };
53
+ }
54
+
55
+ function prepareSemanticJudgeRequest(options) {
56
+ const {
57
+ originalText, previewText, maxTextChars = DEFAULT_POLICY.maxTextChars, redactionProfile,
58
+ } = options;
59
+ requireText(originalText, 'originalText');
60
+ requireText(previewText, 'previewText');
61
+ if (!Number.isInteger(maxTextChars) || maxTextChars < 64) throw new TypeError('maxTextChars is invalid');
62
+ if (typeof options.recoverable !== 'boolean') throw new TypeError('recoverable must be a boolean');
63
+
64
+ const original = redactedBounded(originalText, maxTextChars, redactionProfile);
65
+ const preview = redactedBounded(previewText, maxTextChars, redactionProfile);
66
+ return {
67
+ request: {
68
+ state: {
69
+ provider: typeof options.provider === 'string' ? options.provider : 'unknown',
70
+ model: typeof options.model === 'string' ? options.model : 'unknown',
71
+ tool: typeof options.toolName === 'string' ? options.toolName : 'unknown',
72
+ original: original.text,
73
+ preview: preview.text,
74
+ originalSampled: original.sampled,
75
+ previewSampled: preview.sampled,
76
+ recoverable: options.recoverable,
77
+ },
78
+ questions: {
79
+ [QUESTION_ID]: {
80
+ type: 'noul',
81
+ instructions: 'Does `preview` omit diagnostic information from `original` that a coding agent would need to diagnose the result? If `recoverable` is true, treat intentionally omitted information as available through the Sando recovery artifact.',
82
+ criteria: 'Answer yes only for task-relevant diagnostic evidence that is absent from the preview and not recoverable; answer no when the preview preserves the evidence or the omission is recoverable.',
83
+ },
84
+ },
85
+ },
86
+ redactions: original.count + preview.count,
87
+ };
88
+ }
89
+
90
+ function requireText(value, name) {
91
+ if (typeof value !== 'string') throw new TypeError(`${name} must be a string`);
92
+ }
93
+
94
+ export function buildSemanticJudgeRequest({
95
+ provider, model, toolName, originalText, previewText, recoverable = false,
96
+ maxTextChars = DEFAULT_POLICY.maxTextChars, redactionProfile,
97
+ } = {}) {
98
+ return prepareSemanticJudgeRequest({
99
+ provider, model, toolName, originalText, previewText, recoverable, maxTextChars, redactionProfile,
100
+ }).request;
101
+ }
102
+
103
+ function cacheKey(candidate, request) {
104
+ return sha256(JSON.stringify({
105
+ schema: SEMANTIC_JUDGMENT_SCHEMA,
106
+ id: candidate.id ?? null,
107
+ provider: candidate.provider ?? null,
108
+ model: candidate.model ?? null,
109
+ toolName: candidate.toolName ?? null,
110
+ recoverable: candidate.recoverable === true,
111
+ state: request.state,
112
+ }));
113
+ }
114
+
115
+ function probability(value) {
116
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1 ? value : null;
117
+ }
118
+
119
+ function usageSummary(value) {
120
+ if (!object(value)) return null;
121
+ const result = {};
122
+ const fields = {
123
+ inputTokens: ['inputTokens', 'input_tokens'],
124
+ outputTokens: ['outputTokens', 'output_tokens'],
125
+ };
126
+ for (const [target, keys] of Object.entries(fields)) {
127
+ const tokenValue = keys.map((key) => value[key]).find(
128
+ (item) => Number.isSafeInteger(item) && item >= 0,
129
+ );
130
+ if (tokenValue !== undefined) result[target] = tokenValue;
131
+ }
132
+ return Object.keys(result).length ? result : null;
133
+ }
134
+
135
+ function resultBase(candidate, inputTokens, previewTokens) {
136
+ return {
137
+ schema: SEMANTIC_JUDGMENT_SCHEMA,
138
+ mode: 'shadow',
139
+ status: 'fallback',
140
+ id: typeof candidate.id === 'string' ? candidate.id : null,
141
+ inputTokens,
142
+ previewTokens,
143
+ omittedTokens: Math.max(0, inputTokens - previewTokens),
144
+ lossProbability: null,
145
+ cacheHit: false,
146
+ };
147
+ }
148
+
149
+ function validCandidate(candidate) {
150
+ return candidate && typeof candidate === 'object' && !Array.isArray(candidate);
151
+ }
152
+
153
+ async function evaluateWithTimeout(evaluate, request, timeoutMs) {
154
+ const controller = new AbortController();
155
+ let timer;
156
+ const timeout = new Promise((_, reject) => {
157
+ timer = setTimeout(() => {
158
+ controller.abort();
159
+ const error = new Error('semantic judge timeout');
160
+ error.code = 'SEMANTIC_JUDGE_TIMEOUT';
161
+ reject(error);
162
+ }, timeoutMs);
163
+ });
164
+ const operation = Promise.resolve().then(() => evaluate(request, { signal: controller.signal }));
165
+ try {
166
+ return await Promise.race([operation, timeout]);
167
+ } finally {
168
+ clearTimeout(timer);
169
+ }
170
+ }
171
+
172
+ function cachedResult(value, lossThreshold) {
173
+ const lossProbability = probability(value?.lossProbability);
174
+ if (lossProbability === null) return null;
175
+ const result = {
176
+ status: 'judged',
177
+ lossProbability,
178
+ verdict: lossProbability >= lossThreshold ? 'loss' : 'preserved',
179
+ cacheHit: true,
180
+ };
181
+ if (Number.isSafeInteger(value?.latencyMs) && value.latencyMs >= 0) result.latencyMs = value.latencyMs;
182
+ if (Number.isSafeInteger(value?.redactions) && value.redactions >= 0) result.redactions = value.redactions;
183
+ const usage = usageSummary(value?.usage);
184
+ if (usage) result.usage = usage;
185
+ return result;
186
+ }
187
+
188
+ export function createSemanticJudge({ evaluate, cache = new Map(), policy, redactionProfile } = {}) {
189
+ if (typeof evaluate !== 'function') throw new TypeError('semantic judge evaluate callback is required');
190
+ if (!cache || typeof cache.get !== 'function' || typeof cache.set !== 'function') {
191
+ throw new TypeError('semantic judge cache must implement get and set');
192
+ }
193
+ if (redactionProfile && typeof redactionProfile.redact !== 'function') {
194
+ throw new TypeError('redactionProfile is invalid');
195
+ }
196
+ const options = validatePolicy(policy);
197
+ const pending = new Map();
198
+ let requests = 0;
199
+
200
+ return async function judge(candidate = {}) {
201
+ if (!validCandidate(candidate)) throw new TypeError('semantic judge candidate must be an object');
202
+ requireText(candidate.originalText, 'originalText');
203
+ requireText(candidate.previewText, 'previewText');
204
+ const inputTokens = estimateTokens(candidate.originalText);
205
+ const previewTokens = estimateTokens(candidate.previewText);
206
+ const base = resultBase(candidate, inputTokens, previewTokens);
207
+ if (candidate.historical === false) return { ...base, status: 'skipped', reason: 'current-result' };
208
+ if (candidate.isError === true) return { ...base, status: 'skipped', reason: 'error-result' };
209
+ if (inputTokens < options.minInputTokens) return { ...base, status: 'skipped', reason: 'below-threshold' };
210
+
211
+ const prepared = prepareSemanticJudgeRequest({
212
+ provider: candidate.provider,
213
+ model: candidate.model,
214
+ toolName: candidate.toolName,
215
+ originalText: candidate.originalText,
216
+ previewText: candidate.previewText,
217
+ recoverable: candidate.recoverable === true,
218
+ maxTextChars: options.maxTextChars,
219
+ redactionProfile,
220
+ });
221
+ const request = prepared.request;
222
+ const key = cacheKey(candidate, request);
223
+ let cached;
224
+ try { cached = cachedResult(cache.get(key), options.lossThreshold); } catch { cached = null; }
225
+ if (cached) return { ...base, ...cached };
226
+ if (pending.has(key)) return { ...base, ...(await pending.get(key)), cacheHit: true };
227
+ if (requests >= options.maxRequests) return { ...base, reason: 'budget' };
228
+
229
+ requests += 1;
230
+ const started = Date.now();
231
+ const operation = (async () => {
232
+ let raw;
233
+ try {
234
+ raw = await evaluateWithTimeout(evaluate, request, options.timeoutMs);
235
+ } catch (error) {
236
+ return { ...base, reason: error?.code === 'SEMANTIC_JUDGE_TIMEOUT' ? 'timeout' : 'judge-error', latencyMs: Date.now() - started };
237
+ }
238
+ const lossProbability = probability(raw?.answers?.[QUESTION_ID]?.noul);
239
+ if (lossProbability === null) {
240
+ return { ...base, reason: 'invalid-response', latencyMs: Date.now() - started };
241
+ }
242
+ const judged = {
243
+ status: 'judged',
244
+ lossProbability,
245
+ verdict: lossProbability >= options.lossThreshold ? 'loss' : 'preserved',
246
+ cacheHit: false,
247
+ latencyMs: Number.isSafeInteger(raw?.elapsedMs) ? raw.elapsedMs : Date.now() - started,
248
+ usage: usageSummary(raw?.usage),
249
+ redactions: prepared.redactions,
250
+ };
251
+ try { cache.set(key, judged); } catch { /* cache is best-effort */ }
252
+ return { ...base, ...judged };
253
+ })();
254
+ pending.set(key, operation);
255
+ try {
256
+ return await operation;
257
+ } finally {
258
+ pending.delete(key);
259
+ }
260
+ };
261
+ }