thumbgate 1.30.0 → 1.34.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.
Files changed (92) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +105 -10
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +168 -31
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/claim-verifiers.example.json +42 -0
  15. package/config/gates/claim-verifiers.json +25 -0
  16. package/config/gates/default.json +217 -50
  17. package/config/mcp-allowlists.json +233 -206
  18. package/config/model-tiers.json +7 -2
  19. package/glama.json +6 -0
  20. package/hooks/hooks.json +1 -1
  21. package/package.json +69 -12
  22. package/public/assets/diagrams/before-after.svg +17 -16
  23. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  24. package/public/assets/diagrams/loop.svg +19 -13
  25. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  26. package/public/compare.html +1 -0
  27. package/public/dashboard.html +126 -28
  28. package/public/evaluations.html +1 -1
  29. package/public/index.html +142 -13
  30. package/public/numbers.html +3 -2
  31. package/public/pricing.html +143 -30
  32. package/scripts/a-plus-evidence-scorecard.js +303 -0
  33. package/scripts/agent-readiness.js +110 -0
  34. package/scripts/async-eval-observability.js +36 -11
  35. package/scripts/audit-trail.js +37 -1
  36. package/scripts/auto-promote-gates.js +149 -34
  37. package/scripts/auto-wire-hooks.js +20 -8
  38. package/scripts/cli-schema.js +14 -0
  39. package/scripts/colbert-style-maxsim.js +236 -0
  40. package/scripts/cross-encoder-reranker.js +356 -126
  41. package/scripts/dashboard-chat.js +350 -17
  42. package/scripts/document-intake.js +283 -7
  43. package/scripts/eval-quality-suite.js +204 -0
  44. package/scripts/feedback-loop.js +115 -7
  45. package/scripts/feedback-paths.js +32 -13
  46. package/scripts/feedback-quality.js +53 -0
  47. package/scripts/feedback-schema.js +3 -0
  48. package/scripts/file-ledger-lock.js +130 -0
  49. package/scripts/filesystem-search.js +17 -7
  50. package/scripts/financial-control-plane.js +1514 -0
  51. package/scripts/gates-engine.js +202 -7
  52. package/scripts/gemini-embedding-policy.js +1 -0
  53. package/scripts/harness-tool-names.js +70 -0
  54. package/scripts/hook-runtime.js +15 -3
  55. package/scripts/hook-stop-anti-claim.js +63 -3
  56. package/scripts/human-escalation.js +353 -41
  57. package/scripts/lesson-db.js +16 -5
  58. package/scripts/lesson-embedding-index.js +67 -20
  59. package/scripts/lesson-embedding-maintenance.js +177 -0
  60. package/scripts/lesson-reranker.js +55 -9
  61. package/scripts/lesson-retrieval.js +305 -29
  62. package/scripts/lesson-search.js +22 -8
  63. package/scripts/llm-client.js +304 -15
  64. package/scripts/model-tier-router.js +593 -0
  65. package/scripts/pragmatic-hybrid-search.js +379 -0
  66. package/scripts/provider-action-normalizer.js +11 -4
  67. package/scripts/rag-document-pipeline.js +461 -0
  68. package/scripts/rag-structured-output.js +441 -0
  69. package/scripts/ragas-style-metrics.js +351 -0
  70. package/scripts/request-envelope.js +178 -0
  71. package/scripts/rerank-pipeline.js +370 -0
  72. package/scripts/rerank-quality-eval.js +155 -0
  73. package/scripts/retrieval-hybrid-ablation.js +120 -0
  74. package/scripts/retrieval-quality-tier.js +118 -0
  75. package/scripts/secret-scanner.js +395 -4
  76. package/scripts/self-distill-agent.js +7 -1
  77. package/scripts/self-healing-check.js +25 -0
  78. package/scripts/skill-packs.js +183 -0
  79. package/scripts/slow-loop.js +72 -0
  80. package/scripts/statusline-links.js +1 -1
  81. package/scripts/statusline.sh +8 -1
  82. package/scripts/telemetry-analytics.js +13 -1
  83. package/scripts/thumbgate-search.js +98 -6
  84. package/scripts/tier-budget-guard.js +186 -0
  85. package/scripts/tool-registry.js +141 -5
  86. package/scripts/universal-claim-evaluator.js +767 -0
  87. package/scripts/vector-store.js +154 -17
  88. package/scripts/verify-marketing-pages-deployed.js +85 -3
  89. package/scripts/workflow-sentinel.js +77 -11
  90. package/server.json +44 -0
  91. package/smithery.yaml +17 -0
  92. package/src/api/server.js +196 -13
@@ -0,0 +1,351 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Offline Ragas-style generation metrics for ThumbGate.
5
+ *
6
+ * Metrics (deterministic, no API key required):
7
+ * - faithfulness: answer claims supported by context (no contradiction drift)
8
+ * - groundedness: answer content covered by retrieved context
9
+ * - answer_relevance: answer addresses the query (token / keyword overlap)
10
+ * - context_precision / context_recall: retrieval-side complements (optional inputs)
11
+ *
12
+ * Optional LLM path can refine scores when ANTHROPIC_API_KEY is present; never
13
+ * fabricates a pass when offline scores fail floors.
14
+ *
15
+ * Honesty: these are *lexical/claim* proxies of Ragas metrics, not the full
16
+ * neural Ragas library. They are stable in CI and comparable across commits.
17
+ */
18
+
19
+ const METRICS_VERSION = '2026-07-31.a-plus.1';
20
+
21
+ const STOP = new Set([
22
+ 'a', 'an', 'the', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'is', 'are', 'was',
23
+ 'be', 'as', 'at', 'by', 'with', 'from', 'that', 'this', 'it', 'we', 'you', 'our',
24
+ 'not', 'no', 'do', 'does', 'did', 'if', 'then', 'than', 'into', 'via',
25
+ ]);
26
+
27
+ function tokenize(text) {
28
+ return String(text || '')
29
+ .toLowerCase()
30
+ .replace(/[^\w\s.-]/g, ' ')
31
+ .split(/[\s_]+/)
32
+ .filter((t) => t.length >= 3 && !STOP.has(t));
33
+ }
34
+
35
+ function uniqueTokens(text) {
36
+ return [...new Set(tokenize(text))];
37
+ }
38
+
39
+ function jaccard(aTokens, bTokens) {
40
+ const a = new Set(aTokens);
41
+ const b = new Set(bTokens);
42
+ if (a.size === 0 && b.size === 0) return 1;
43
+ if (a.size === 0 || b.size === 0) return 0;
44
+ let inter = 0;
45
+ for (const t of a) if (b.has(t)) inter += 1;
46
+ return inter / (a.size + b.size - inter);
47
+ }
48
+
49
+ function coverage(answerTokens, contextTokens) {
50
+ const ctx = new Set(contextTokens);
51
+ if (answerTokens.length === 0) return 0;
52
+ let hits = 0;
53
+ for (const t of answerTokens) if (ctx.has(t)) hits += 1;
54
+ return hits / answerTokens.length;
55
+ }
56
+
57
+ function splitClaims(answer) {
58
+ return String(answer || '')
59
+ .split(/[.!?\n]+/)
60
+ .map((s) => s.trim())
61
+ .filter((s) => s.length >= 12);
62
+ }
63
+
64
+ /**
65
+ * Faithfulness: each answer claim must be supported by context (token coverage),
66
+ * and answer must not introduce contradiction tokens absent from context when
67
+ * expected constraint is known.
68
+ *
69
+ * @param {{ answer: string, context: string, expectedConstraint?: string }} input
70
+ * @returns {{ score: number, details: object }}
71
+ */
72
+ function faithfulness(input = {}) {
73
+ const answer = String(input.answer || '');
74
+ const context = String(input.context || '');
75
+ const expected = String(input.expectedConstraint || '');
76
+ const claims = splitClaims(answer);
77
+ const ctxTokens = uniqueTokens(context);
78
+ const ansTokens = uniqueTokens(answer);
79
+
80
+ let claimScores = [];
81
+ if (claims.length === 0) {
82
+ claimScores = [coverage(ansTokens, ctxTokens)];
83
+ } else {
84
+ claimScores = claims.map((c) => coverage(uniqueTokens(c), ctxTokens));
85
+ }
86
+ const meanClaim = claimScores.reduce((a, b) => a + b, 0) / claimScores.length;
87
+
88
+ // Expected constraint present in answer OR context when provided
89
+ let constraintHit = 1;
90
+ if (expected) {
91
+ const exp = expected.toLowerCase();
92
+ const hay = `${answer}\n${context}`.toLowerCase();
93
+ constraintHit = hay.includes(exp) || jaccard(uniqueTokens(expected), ansTokens) >= 0.35
94
+ ? 1
95
+ : (jaccard(uniqueTokens(expected), ctxTokens) >= 0.35 ? 0.7 : 0.2);
96
+ }
97
+
98
+ // Soft contradiction: answer has "always" where context says "never" for same stem
99
+ let contradictionPenalty = 0;
100
+ const ansLower = answer.toLowerCase();
101
+ const ctxLower = context.toLowerCase();
102
+ if (/\balways\b/.test(ansLower) && /\bnever\b/.test(ctxLower) && !/\bnever\b/.test(ansLower)) {
103
+ contradictionPenalty = 0.15;
104
+ }
105
+ if (/\bnever\b/.test(ansLower) && /\balways\b/.test(ctxLower) && !/\balways\b/.test(ansLower)) {
106
+ contradictionPenalty = Math.max(contradictionPenalty, 0.15);
107
+ }
108
+
109
+ const score = Math.max(0, Math.min(1, 0.65 * meanClaim + 0.35 * constraintHit - contradictionPenalty));
110
+ return {
111
+ score: Number(score.toFixed(4)),
112
+ details: {
113
+ meanClaimSupport: Number(meanClaim.toFixed(4)),
114
+ constraintHit,
115
+ contradictionPenalty,
116
+ claimCount: claims.length,
117
+ },
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Groundedness: how much of the answer is attributable to context.
123
+ * Short policy answers may paraphrase; credit partial stem/overlap with context.
124
+ * @param {{ answer: string, context: string }} input
125
+ */
126
+ function groundedness(input = {}) {
127
+ const ansTokens = uniqueTokens(input.answer);
128
+ const ctxTokens = uniqueTokens(input.context);
129
+ const cov = coverage(ansTokens, ctxTokens);
130
+ const jac = jaccard(ansTokens, ctxTokens);
131
+ // Soft stem: token prefix match (≥4 chars) counts as half a hit
132
+ let softHits = 0;
133
+ for (const a of ansTokens) {
134
+ if (ctxTokens.includes(a)) continue;
135
+ if (a.length >= 4 && ctxTokens.some((c) => c.startsWith(a.slice(0, 4)) || a.startsWith(c.slice(0, 4)))) {
136
+ softHits += 0.5;
137
+ }
138
+ }
139
+ const softCov = ansTokens.length
140
+ ? Math.min(1, (ansTokens.filter((t) => ctxTokens.includes(t)).length + softHits) / ansTokens.length)
141
+ : 0;
142
+ const score = Math.max(0, Math.min(1, 0.55 * softCov + 0.25 * cov + 0.2 * jac));
143
+ return {
144
+ score: Number(score.toFixed(4)),
145
+ details: {
146
+ coverage: Number(cov.toFixed(4)),
147
+ softCoverage: Number(softCov.toFixed(4)),
148
+ jaccard: Number(jac.toFixed(4)),
149
+ },
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Answer relevance: does the answer address the query?
155
+ * @param {{ query: string, answer: string, expectedKeywords?: string[] }} input
156
+ */
157
+ function answerRelevance(input = {}) {
158
+ const qTokens = uniqueTokens(input.query);
159
+ const aTokens = uniqueTokens(input.answer);
160
+ const base = jaccard(qTokens, aTokens);
161
+ const contentOverlap = coverage(qTokens, aTokens);
162
+ // Substring keyword hits in answer (handles multi-word expected keywords)
163
+ let keywordHit = 1;
164
+ const keywords = Array.isArray(input.expectedKeywords) ? input.expectedKeywords : [];
165
+ const hay = String(input.answer || '').toLowerCase();
166
+ if (keywords.length > 0) {
167
+ const hits = keywords.filter((k) => hay.includes(String(k).toLowerCase())).length;
168
+ keywordHit = hits / keywords.length;
169
+ }
170
+ // Query term substring presence in answer (e.g. "main" in both)
171
+ let qPresent = 0;
172
+ if (qTokens.length) {
173
+ for (const t of qTokens) {
174
+ if (hay.includes(t)) qPresent += 1;
175
+ }
176
+ qPresent /= qTokens.length;
177
+ }
178
+ const score = Math.max(
179
+ 0,
180
+ Math.min(1, 0.25 * base + 0.2 * contentOverlap + 0.35 * keywordHit + 0.2 * qPresent),
181
+ );
182
+ return {
183
+ score: Number(score.toFixed(4)),
184
+ details: {
185
+ jaccard: Number(base.toFixed(4)),
186
+ queryCoverage: Number(contentOverlap.toFixed(4)),
187
+ keywordHit: Number(keywordHit.toFixed(4)),
188
+ queryTermPresence: Number(qPresent.toFixed(4)),
189
+ },
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Context precision: fraction of context chunks relevant to query (binary token overlap).
195
+ * @param {{ query: string, contexts: string[] }} input
196
+ */
197
+ function contextPrecision(input = {}) {
198
+ const chunks = Array.isArray(input.contexts) ? input.contexts : [input.context].filter(Boolean);
199
+ if (chunks.length === 0) return { score: 0, details: { relevant: 0, total: 0 } };
200
+ const q = uniqueTokens(input.query);
201
+ let relevant = 0;
202
+ for (const c of chunks) {
203
+ if (jaccard(q, uniqueTokens(c)) >= 0.08 || coverage(q, uniqueTokens(c)) >= 0.2) {
204
+ relevant += 1;
205
+ }
206
+ }
207
+ return {
208
+ score: Number((relevant / chunks.length).toFixed(4)),
209
+ details: { relevant, total: chunks.length },
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Context recall: expected constraint / gold keywords found in context.
215
+ * @param {{ context: string, expectedConstraint?: string, goldKeywords?: string[] }} input
216
+ */
217
+ function contextRecall(input = {}) {
218
+ const ctx = String(input.context || '').toLowerCase();
219
+ const keys = [];
220
+ if (input.expectedConstraint) keys.push(...uniqueTokens(input.expectedConstraint));
221
+ if (Array.isArray(input.goldKeywords)) {
222
+ for (const k of input.goldKeywords) keys.push(...uniqueTokens(k));
223
+ }
224
+ const uniq = [...new Set(keys)];
225
+ if (uniq.length === 0) {
226
+ return { score: ctx.length > 0 ? 1 : 0, details: { hits: 0, total: 0 } };
227
+ }
228
+ let hits = 0;
229
+ for (const k of uniq) {
230
+ if (ctx.includes(k)) hits += 1;
231
+ }
232
+ return {
233
+ score: Number((hits / uniq.length).toFixed(4)),
234
+ details: { hits, total: uniq.length },
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Score a single generation case offline.
240
+ * @param {object} caseRow
241
+ * @returns {object}
242
+ */
243
+ function scoreGenerationCase(caseRow = {}) {
244
+ const context = caseRow.context
245
+ || (Array.isArray(caseRow.contexts) ? caseRow.contexts.join('\n') : '');
246
+ const answer = caseRow.answer || caseRow.generatedAnswer || '';
247
+ const query = caseRow.query || '';
248
+
249
+ const f = faithfulness({
250
+ answer,
251
+ context,
252
+ expectedConstraint: caseRow.expectedConstraint || caseRow.expectedRuleHit,
253
+ });
254
+ const g = groundedness({ answer, context });
255
+ const ar = answerRelevance({
256
+ query,
257
+ answer,
258
+ expectedKeywords: caseRow.expectedKeywords,
259
+ });
260
+ const cp = contextPrecision({
261
+ query,
262
+ contexts: caseRow.contexts || (context ? [context] : []),
263
+ });
264
+ const cr = contextRecall({
265
+ context,
266
+ expectedConstraint: caseRow.expectedConstraint || caseRow.expectedRuleHit,
267
+ goldKeywords: caseRow.goldKeywords || caseRow.expectedKeywords,
268
+ });
269
+
270
+ return {
271
+ id: caseRow.id || 'case',
272
+ faithfulness: f.score,
273
+ groundedness: g.score,
274
+ answer_relevance: ar.score,
275
+ context_precision: cp.score,
276
+ context_recall: cr.score,
277
+ details: { faithfulness: f.details, groundedness: g.details, answer_relevance: ar.details },
278
+ metricsVersion: METRICS_VERSION,
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Aggregate generation cases + apply floors.
284
+ */
285
+ function evaluateGenerationGolden(golden, options = {}) {
286
+ const cases = Array.isArray(golden?.cases) ? golden.cases : [];
287
+ const thresholds = {
288
+ minCases: 6,
289
+ minFaithfulness: 0.55,
290
+ minGroundedness: 0.5,
291
+ minAnswerRelevance: 0.45,
292
+ minContextRecall: 0.7,
293
+ ...(golden?.thresholds || {}),
294
+ ...(options.thresholds || {}),
295
+ };
296
+
297
+ const rows = cases.map((c) => scoreGenerationCase(c));
298
+ const mean = (key) => (rows.length
299
+ ? rows.reduce((s, r) => s + (Number(r[key]) || 0), 0) / rows.length
300
+ : 0);
301
+
302
+ const summary = {
303
+ metricsVersion: METRICS_VERSION,
304
+ cases: rows.length,
305
+ faithfulness: Number(mean('faithfulness').toFixed(4)),
306
+ groundedness: Number(mean('groundedness').toFixed(4)),
307
+ answer_relevance: Number(mean('answer_relevance').toFixed(4)),
308
+ context_precision: Number(mean('context_precision').toFixed(4)),
309
+ context_recall: Number(mean('context_recall').toFixed(4)),
310
+ };
311
+
312
+ const failures = [];
313
+ if (summary.cases < thresholds.minCases) {
314
+ failures.push(`cases ${summary.cases} < ${thresholds.minCases}`);
315
+ }
316
+ if (summary.faithfulness < thresholds.minFaithfulness) {
317
+ failures.push(`faithfulness ${summary.faithfulness} < ${thresholds.minFaithfulness}`);
318
+ }
319
+ if (summary.groundedness < thresholds.minGroundedness) {
320
+ failures.push(`groundedness ${summary.groundedness} < ${thresholds.minGroundedness}`);
321
+ }
322
+ if (summary.answer_relevance < thresholds.minAnswerRelevance) {
323
+ failures.push(`answer_relevance ${summary.answer_relevance} < ${thresholds.minAnswerRelevance}`);
324
+ }
325
+ if (summary.context_recall < thresholds.minContextRecall) {
326
+ failures.push(`context_recall ${summary.context_recall} < ${thresholds.minContextRecall}`);
327
+ }
328
+
329
+ return {
330
+ passed: failures.length === 0,
331
+ failures,
332
+ thresholds,
333
+ summary,
334
+ rows,
335
+ };
336
+ }
337
+
338
+ module.exports = {
339
+ METRICS_VERSION,
340
+ tokenize,
341
+ uniqueTokens,
342
+ jaccard,
343
+ coverage,
344
+ faithfulness,
345
+ groundedness,
346
+ answerRelevance,
347
+ contextPrecision,
348
+ contextRecall,
349
+ scoreGenerationCase,
350
+ evaluateGenerationGolden,
351
+ };
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Production request envelope — one schema for LLM + retrieval observability.
5
+ *
6
+ * Every dashboard chat / routed generation path should create an envelope at
7
+ * start and finalize it before return so latency, cost, retrieval, and
8
+ * structured-output status are greppable from one object.
9
+ *
10
+ * Privacy: never store full prompts/tool payloads here; use redacted previews only.
11
+ */
12
+
13
+ const crypto = require('node:crypto');
14
+ const {
15
+ buildAgentAuditSpan,
16
+ evaluateAgentAuditTrace,
17
+ } = require('./agent-audit-trace');
18
+
19
+ const ENVELOPE_VERSION = '2026-07-31.p0.1';
20
+
21
+ function newTraceId() {
22
+ if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
23
+ return `tr_${Date.now().toString(36)}_${crypto.randomBytes(6).toString('hex')}`;
24
+ }
25
+
26
+ function hashSensitiveText(text) {
27
+ return crypto.createHash('sha256').update(String(text || '')).digest('hex');
28
+ }
29
+
30
+ /**
31
+ * @param {object} [seed]
32
+ * @returns {object}
33
+ */
34
+ function createRequestEnvelope(seed = {}) {
35
+ const startedAt = Number.isFinite(seed.startedAt) ? seed.startedAt : Date.now();
36
+ const traceId = seed.traceId || newTraceId();
37
+ const auditTrace = seed.promptHash
38
+ ? {
39
+ runId: traceId,
40
+ spans: [buildAgentAuditSpan({
41
+ runId: traceId,
42
+ spanId: `${traceId}:input`,
43
+ stage: 'input',
44
+ promptHash: seed.promptHash,
45
+ model: seed.model || null,
46
+ })],
47
+ }
48
+ : null;
49
+ return {
50
+ envelopeVersion: ENVELOPE_VERSION,
51
+ traceId,
52
+ startedAt,
53
+ endedAt: null,
54
+ latencyMs: null,
55
+ surface: seed.surface || 'unknown',
56
+ model: seed.model || null,
57
+ tier: seed.tier || null,
58
+ provider: seed.provider || null,
59
+ inputTokens: seed.inputTokens ?? null,
60
+ outputTokens: seed.outputTokens ?? null,
61
+ estimatedCostCents: seed.estimatedCostCents ?? null,
62
+ budget: seed.budget || null,
63
+ retrieval: seed.retrieval || null,
64
+ structured: seed.structured || null,
65
+ qualityTier: seed.qualityTier || null,
66
+ outcome: seed.outcome || 'pending',
67
+ error: seed.error || null,
68
+ auditTrace,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Finalize timing + optional fields. Pure-ish: returns a new object.
74
+ * @param {object} envelope
75
+ * @param {object} [patch]
76
+ */
77
+ function finalizeRequestEnvelope(envelope, patch = {}) {
78
+ const endedAt = Number.isFinite(patch.endedAt) ? patch.endedAt : Date.now();
79
+ const startedAt = Number(envelope?.startedAt) || endedAt;
80
+ const finalized = {
81
+ ...envelope,
82
+ ...patch,
83
+ endedAt,
84
+ latencyMs: Math.max(0, endedAt - startedAt),
85
+ outcome: patch.outcome || envelope?.outcome || 'ok',
86
+ };
87
+
88
+ if (envelope?.auditTrace?.runId && Array.isArray(envelope.auditTrace.spans)) {
89
+ const evidenceIds = (patch.retrieval?.top || [])
90
+ .map((row) => row?.id)
91
+ .filter(Boolean);
92
+ const priorSpans = envelope.auditTrace.spans
93
+ .filter((span) => span?.stage !== 'decision');
94
+ const decisionSpan = buildAgentAuditSpan({
95
+ runId: envelope.auditTrace.runId,
96
+ spanId: `${envelope.auditTrace.runId}:decision`,
97
+ parentSpanId: priorSpans[0]?.spanId || null,
98
+ stage: 'decision',
99
+ model: finalized.model,
100
+ decision: finalized.outcome,
101
+ dataAccessed: evidenceIds.length ? ['retrieved_lessons'] : [],
102
+ evidenceIds,
103
+ safetyEvents: finalized.error ? [finalized.error] : [],
104
+ inputTokens: finalized.inputTokens,
105
+ outputTokens: finalized.outputTokens,
106
+ latencyMs: finalized.latencyMs,
107
+ });
108
+ const auditTrace = {
109
+ runId: envelope.auditTrace.runId,
110
+ spans: [...priorSpans, decisionSpan],
111
+ };
112
+ finalized.auditTrace = {
113
+ ...auditTrace,
114
+ evaluation: evaluateAgentAuditTrace(auditTrace),
115
+ };
116
+ }
117
+
118
+ return finalized;
119
+ }
120
+
121
+ /**
122
+ * Compact retrieval summary for the envelope (no lesson bodies).
123
+ * @param {Array<object>} rows
124
+ * @param {object} [meta]
125
+ */
126
+ function summarizeRetrieval(rows = [], meta = {}) {
127
+ const top = (rows || []).slice(0, 8).map((r, i) => {
128
+ const rawScore = r.rerankedScore ?? r.relevanceScore ?? r.score;
129
+ const numericScore = Number(rawScore);
130
+ return {
131
+ rank: i + 1,
132
+ id: r.id || r.memoryId || null,
133
+ score: rawScore == null || !Number.isFinite(numericScore) ? null : numericScore,
134
+ signal: r.signal || null,
135
+ };
136
+ });
137
+ return {
138
+ strategy: meta.strategy || meta.retrievalStrategy || null,
139
+ count: Array.isArray(rows) ? rows.length : 0,
140
+ qualityTier: meta.qualityTier || null,
141
+ degradedReasons: meta.degradedReasons || [],
142
+ top,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Estimate tokens from text length (rough, offline-safe).
148
+ * @param {string} text
149
+ */
150
+ function estimateTokensFromText(text) {
151
+ const s = String(text || '');
152
+ if (!s) return 0;
153
+ return Math.max(1, Math.ceil(s.length / 4));
154
+ }
155
+
156
+ /**
157
+ * Rough USD cents from token counts using Sonnet-ish defaults (conservative).
158
+ * @param {{ inputTokens?: number, outputTokens?: number, inputPerM?: number, outputPerM?: number }} opts
159
+ */
160
+ function estimateCostCents(opts = {}) {
161
+ const input = Number(opts.inputTokens) || 0;
162
+ const output = Number(opts.outputTokens) || 0;
163
+ const inputPerM = Number(opts.inputPerM) || 3;
164
+ const outputPerM = Number(opts.outputPerM) || 15;
165
+ const usd = (input / 1e6) * inputPerM + (output / 1e6) * outputPerM;
166
+ return Number((usd * 100).toFixed(4));
167
+ }
168
+
169
+ module.exports = {
170
+ ENVELOPE_VERSION,
171
+ newTraceId,
172
+ hashSensitiveText,
173
+ createRequestEnvelope,
174
+ finalizeRequestEnvelope,
175
+ summarizeRetrieval,
176
+ estimateTokensFromText,
177
+ estimateCostCents,
178
+ };