sandoichi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { estimateTokens } from './core.mjs';
4
+ import { redact, hasSecret } from './secret-redaction.mjs';
5
+
6
+ export const SEMANTIC_SUMMARY_SCHEMA = 'sando-semantic-summary/v1';
7
+
8
+ const DEFAULT_POLICY = Object.freeze({
9
+ minInputTokens: 8000,
10
+ maxSummaryRatio: 0.2,
11
+ timeoutMs: 1500,
12
+ });
13
+
14
+ function sha256(value) {
15
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
16
+ }
17
+
18
+ function facts(value) {
19
+ if (value === undefined) return [];
20
+ if (!Array.isArray(value) || value.some((fact) => typeof fact !== 'string' || fact.length === 0)) {
21
+ throw new TypeError('requiredFacts must be non-empty strings');
22
+ }
23
+ return [...new Set(value)];
24
+ }
25
+
26
+ function parseResponse(value) {
27
+ if (typeof value === 'string') {
28
+ try { return JSON.parse(value); } catch { return null; }
29
+ }
30
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
31
+ }
32
+
33
+ function usageCounter(value) {
34
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
35
+ }
36
+
37
+ function validatePolicy(policy) {
38
+ const result = { ...DEFAULT_POLICY, ...(policy ?? {}) };
39
+ if (!Number.isInteger(result.minInputTokens) || result.minInputTokens < 1
40
+ || !Number.isFinite(result.maxSummaryRatio) || result.maxSummaryRatio <= 0 || result.maxSummaryRatio >= 1
41
+ || !Number.isInteger(result.timeoutMs) || result.timeoutMs < 1) {
42
+ throw new TypeError('invalid semantic compactor policy');
43
+ }
44
+ return result;
45
+ }
46
+
47
+ export function buildSemanticPrompt({ provider, model, toolName, text, requiredFacts = [] } = {}) {
48
+ const required = facts(requiredFacts);
49
+ return [
50
+ `Schema: ${SEMANTIC_SUMMARY_SCHEMA}`,
51
+ 'Summarize the historical tool result for a coding agent.',
52
+ 'Keep exact paths, identifiers, errors, numbers, negations, and every required fact.',
53
+ 'Preserved facts must be copied verbatim from the tool result or required-facts list; do not invent or estimate counts.',
54
+ 'Return JSON only with schema, summary, and preservedFacts fields.',
55
+ `Provider: ${provider ?? 'unknown'}`,
56
+ `Model: ${model ?? 'unknown'}`,
57
+ `Tool: ${toolName ?? 'unknown'}`,
58
+ `Required facts: ${required.length ? required.join(', ') : '(none)'}`,
59
+ `<tool_result>\n${text}\n</tool_result>`,
60
+ ].join('\n');
61
+ }
62
+
63
+ export function validateSemanticSummary({ originalText, summary, requiredFacts = [], maxSummaryRatio = DEFAULT_POLICY.maxSummaryRatio } = {}) {
64
+ if (typeof originalText !== 'string' || typeof summary !== 'string') {
65
+ return { valid: false, reason: 'invalid-text' };
66
+ }
67
+ if (!Number.isFinite(maxSummaryRatio) || maxSummaryRatio <= 0 || maxSummaryRatio >= 1) {
68
+ return { valid: false, reason: 'invalid-ratio' };
69
+ }
70
+ const required = facts(requiredFacts);
71
+ const inputTokens = estimateTokens(originalText);
72
+ const outputTokens = estimateTokens(summary);
73
+ if (!summary.trim()) return { valid: false, reason: 'empty-summary', inputTokens, outputTokens };
74
+ if (outputTokens >= inputTokens) return { valid: false, reason: 'not-smaller', inputTokens, outputTokens };
75
+ if (outputTokens / Math.max(1, inputTokens) > maxSummaryRatio) {
76
+ return { valid: false, reason: 'summary-too-large', inputTokens, outputTokens };
77
+ }
78
+ const missing = required.find((fact) => !summary.includes(fact));
79
+ if (missing) return { valid: false, reason: 'missing-required-fact', missing, inputTokens, outputTokens };
80
+ if (hasSecret(summary)) return { valid: false, reason: 'secret-detected', inputTokens, outputTokens };
81
+ return { valid: true, inputTokens, outputTokens };
82
+ }
83
+
84
+ function cacheKey({ provider, model, toolName, text, requiredFacts }) {
85
+ return sha256(JSON.stringify({
86
+ schema: SEMANTIC_SUMMARY_SCHEMA,
87
+ provider: provider ?? null,
88
+ model: model ?? null,
89
+ toolName: toolName ?? null,
90
+ text,
91
+ requiredFacts,
92
+ }));
93
+ }
94
+
95
+ async function withTimeout(complete, request, timeoutMs) {
96
+ const controller = new AbortController();
97
+ let timer;
98
+ const timeout = new Promise((_, reject) => {
99
+ timer = setTimeout(() => {
100
+ controller.abort();
101
+ const error = new Error('semantic compactor timeout');
102
+ error.code = 'SEMANTIC_TIMEOUT';
103
+ reject(error);
104
+ }, timeoutMs);
105
+ });
106
+ const operation = Promise.resolve().then(() => complete({ ...request, signal: controller.signal }));
107
+ try {
108
+ return await Promise.race([operation, timeout]);
109
+ } finally {
110
+ clearTimeout(timer);
111
+ }
112
+ }
113
+
114
+ export function createSemanticCompactor({ complete, cache = new Map(), policy } = {}) {
115
+ if (typeof complete !== 'function') throw new TypeError('semantic compactor complete callback is required');
116
+ if (!cache || typeof cache.get !== 'function' || typeof cache.set !== 'function') {
117
+ throw new TypeError('semantic compactor cache must implement get and set');
118
+ }
119
+ const options = validatePolicy(policy);
120
+
121
+ return async function compact({ provider, model, toolName, text, historical = true, isError = false, requiredFacts = [] } = {}) {
122
+ if (typeof text !== 'string') throw new TypeError('semantic compactor text must be a string');
123
+ const required = facts(requiredFacts);
124
+ const inputTokens = estimateTokens(text);
125
+ const base = {
126
+ mode: 'shadow',
127
+ status: 'fallback',
128
+ fallbackText: text,
129
+ inputTokens,
130
+ outputTokens: inputTokens,
131
+ grossSavedTokens: 0,
132
+ netSavedTokens: 0,
133
+ cacheHit: false,
134
+ };
135
+ if (!historical) return { ...base, status: 'skipped', reason: 'current-result' };
136
+ if (isError) return { ...base, status: 'skipped', reason: 'error-result' };
137
+ if (inputTokens < options.minInputTokens) return { ...base, status: 'skipped', reason: 'below-threshold' };
138
+
139
+ const safe = redact(text);
140
+ const safeFacts = required.map((fact) => redact(fact).text);
141
+ const prompt = buildSemanticPrompt({ provider, model, toolName, text: safe.text, requiredFacts: safeFacts });
142
+ const key = cacheKey({ provider, model, toolName, text: safe.text, requiredFacts: safeFacts });
143
+ const started = Date.now();
144
+ let cached;
145
+ try { cached = cache.get(key); } catch { cached = undefined; }
146
+ if (cached?.summary) {
147
+ const validation = validateSemanticSummary({
148
+ originalText: text, summary: cached.summary, requiredFacts: required,
149
+ maxSummaryRatio: options.maxSummaryRatio,
150
+ });
151
+ if (validation.valid) {
152
+ const grossSavedTokens = inputTokens - validation.outputTokens;
153
+ return {
154
+ ...base,
155
+ status: 'candidate',
156
+ summary: cached.summary,
157
+ outputTokens: validation.outputTokens,
158
+ grossSavedTokens,
159
+ netSavedTokens: grossSavedTokens,
160
+ cacheHit: true,
161
+ providerUsage: null,
162
+ latencyMs: Date.now() - started,
163
+ redactions: safe.count,
164
+ };
165
+ }
166
+ try { cache.delete?.(key); } catch { /* fail open */ }
167
+ }
168
+
169
+ let raw;
170
+ try {
171
+ raw = await withTimeout(complete, { provider, model, toolName, prompt, text: safe.text, requiredFacts: safeFacts }, options.timeoutMs);
172
+ } catch (error) {
173
+ return { ...base, reason: error?.code === 'SEMANTIC_TIMEOUT' ? 'timeout' : 'compactor-error', latencyMs: Date.now() - started };
174
+ }
175
+ const response = parseResponse(raw);
176
+ if (response?.schema !== SEMANTIC_SUMMARY_SCHEMA || typeof response.summary !== 'string'
177
+ || !Array.isArray(response.preservedFacts)
178
+ || response.preservedFacts.some((fact) => typeof fact !== 'string')) {
179
+ return { ...base, reason: 'invalid-response', latencyMs: Date.now() - started };
180
+ }
181
+ if (response.preservedFacts.some((fact) => !safe.text.includes(fact))) {
182
+ return { ...base, reason: 'response-ungrounded-fact', latencyMs: Date.now() - started };
183
+ }
184
+ if (required.some((fact) => !response.preservedFacts.includes(fact))) {
185
+ return { ...base, reason: 'response-missing-fact', latencyMs: Date.now() - started };
186
+ }
187
+ const summary = required.reduce(
188
+ (value, fact) => value.includes(fact) ? value : `${value}\n${fact}`,
189
+ response.summary,
190
+ );
191
+ const validation = validateSemanticSummary({
192
+ originalText: text, summary, requiredFacts: required,
193
+ maxSummaryRatio: options.maxSummaryRatio,
194
+ });
195
+ if (!validation.valid) return { ...base, reason: validation.reason, latencyMs: Date.now() - started };
196
+
197
+ const usage = response.usage && typeof response.usage === 'object' ? response.usage : {};
198
+ const compactorInputTokens = usageCounter(usage.inputTokens) ?? estimateTokens(prompt);
199
+ const compactorOutputTokens = usageCounter(usage.outputTokens) ?? estimateTokens(JSON.stringify(response));
200
+ const grossSavedTokens = inputTokens - validation.outputTokens;
201
+ const netSavedTokens = grossSavedTokens - compactorInputTokens - compactorOutputTokens;
202
+ try { cache.set(key, { summary }); } catch { /* fail open */ }
203
+ return {
204
+ ...base,
205
+ status: 'candidate',
206
+ summary,
207
+ outputTokens: validation.outputTokens,
208
+ grossSavedTokens,
209
+ netSavedTokens,
210
+ cacheHit: false,
211
+ providerUsage: usage,
212
+ compactorInputTokens,
213
+ compactorOutputTokens,
214
+ latencyMs: Date.now() - started,
215
+ redactions: safe.count,
216
+ };
217
+ };
218
+ }
@@ -0,0 +1,100 @@
1
+ import {
2
+ buildMetricsReport,
3
+ defaultMetricsPath,
4
+ readMetrics,
5
+ } from './metrics.mjs';
6
+ import {
7
+ buildProviderUsageReport,
8
+ defaultProviderUsagePath,
9
+ readProviderUsage,
10
+ } from './provider-usage.mjs';
11
+
12
+ export const STATUSLINE_MAX_AGE_MS = 5 * 60 * 1000;
13
+
14
+ function latestAt(records) {
15
+ const timestamp = records.reduce((latest, item) => {
16
+ const value = Date.parse(item.at);
17
+ return Number.isFinite(value) && value > latest ? value : latest;
18
+ }, Number.NEGATIVE_INFINITY);
19
+ return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined;
20
+ }
21
+
22
+ function scopedRecords(records, { host, sessionId } = {}) {
23
+ return records.filter((item) => {
24
+ if (host !== undefined && item.host !== host) return false;
25
+ if (sessionId !== undefined && item.sessionId !== sessionId) return false;
26
+ return true;
27
+ });
28
+ }
29
+
30
+ function readMetricsSnapshot(metricsPath, { host, sessionId, model } = {}) {
31
+ try {
32
+ const state = readMetrics(metricsPath);
33
+ const records = scopedRecords(state.records, { host, sessionId });
34
+ if (!records.length) return undefined;
35
+ const report = buildMetricsReport({ ...state, records }, { sessionId });
36
+ const hasProvider = records.some((item) => item.providerReportedSavingsTokens !== null);
37
+ const hasEstimate = records.some((item) => item.providerReportedSavingsTokens === null);
38
+ const providerSavings = report.cumulative.providerReportedSavingsTokens;
39
+ const source = hasProvider && !hasEstimate && providerSavings !== null
40
+ ? 'provider-reported' : 'estimate';
41
+ const latest = [...records].sort((left, right) => left.at.localeCompare(right.at)).at(-1);
42
+ return {
43
+ updatedAt: latestAt(records), source,
44
+ model: model ?? latest?.model,
45
+ savedTokens: source === 'provider-reported'
46
+ ? providerSavings : report.cumulative.estimatedTransformSavingsTokens,
47
+ };
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ function readProviderSnapshot(providerUsagePath, { host, sessionId } = {}) {
54
+ try {
55
+ const state = readProviderUsage(providerUsagePath);
56
+ const records = scopedRecords(state.records, { host, sessionId });
57
+ if (!records.length) return undefined;
58
+ return { ...buildProviderUsageReport({ ...state, records }), updatedAt: latestAt(records) };
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ export function readStatusSnapshot({
65
+ metricsPath = defaultMetricsPath(),
66
+ providerUsagePath = defaultProviderUsagePath(),
67
+ host,
68
+ sessionId,
69
+ model,
70
+ } = {}) {
71
+ return {
72
+ metrics: readMetricsSnapshot(metricsPath, { host, sessionId, model }),
73
+ providerUsage: readProviderSnapshot(providerUsagePath, { host, sessionId }),
74
+ };
75
+ }
76
+
77
+ function compactTokens(value) {
78
+ if (value < 1_000) return String(value);
79
+ if (value < 1_000_000) return `${Number((value / 1_000).toFixed(1))}k`;
80
+ return `${Number((value / 1_000_000).toFixed(2))}M`;
81
+ }
82
+
83
+ // The rate is the session's own blended $/token (totalCostUsd / totalTokens),
84
+ // so cache reads (0.1x) and cache writes (1.25x/2x) are already folded in:
85
+ // it's the harness's real billed rate, not a reconstructed list price.
86
+ function compactCost(tokens, effectiveRate) {
87
+ return `$${(tokens * effectiveRate).toFixed(2)}`;
88
+ }
89
+
90
+ export function renderStatusLine({ metrics, providerUsage, totalCostUsd } = {}, _now = Date.now()) {
91
+ if (!metrics || !['estimate', 'provider-reported'].includes(metrics.source)
92
+ || !Number.isSafeInteger(metrics.savedTokens) || metrics.savedTokens <= 0) return '🥪 —';
93
+ const estimated = metrics.source === 'estimate';
94
+ const savings = `${estimated ? '~' : ''}${compactTokens(metrics.savedTokens)} token saved`;
95
+ const effectiveRate = Number.isFinite(totalCostUsd) && totalCostUsd > 0
96
+ && Number.isSafeInteger(providerUsage?.totalTokens) && providerUsage.totalTokens > 0
97
+ ? totalCostUsd / providerUsage.totalTokens : undefined;
98
+ const cost = effectiveRate === undefined ? undefined : compactCost(metrics.savedTokens, effectiveRate);
99
+ return `🥪 ${[savings, cost && `(-${cost})`].filter(Boolean).join(' ')}`;
100
+ }