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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +54 -16
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +105 -10
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +168 -31
- package/config/evals/generation-quality-golden.json +95 -0
- package/config/evals/rag-answer-quality-golden.json +91 -0
- package/config/evals/retrieval-hybrid-ablation.json +66 -0
- package/config/evals/retrieval-ranking-golden.json +522 -0
- package/config/gates/claim-verifiers.example.json +42 -0
- package/config/gates/claim-verifiers.json +25 -0
- package/config/gates/default.json +217 -50
- package/config/mcp-allowlists.json +233 -206
- package/config/model-tiers.json +7 -2
- package/glama.json +6 -0
- package/hooks/hooks.json +1 -1
- package/package.json +69 -12
- package/public/assets/diagrams/before-after.svg +17 -16
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +19 -13
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/evaluations.html +1 -1
- package/public/index.html +142 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/agent-readiness.js +110 -0
- package/scripts/async-eval-observability.js +36 -11
- package/scripts/audit-trail.js +37 -1
- package/scripts/auto-promote-gates.js +149 -34
- package/scripts/auto-wire-hooks.js +20 -8
- package/scripts/cli-schema.js +14 -0
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +356 -126
- package/scripts/dashboard-chat.js +350 -17
- package/scripts/document-intake.js +283 -7
- package/scripts/eval-quality-suite.js +204 -0
- package/scripts/feedback-loop.js +115 -7
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/feedback-schema.js +3 -0
- package/scripts/file-ledger-lock.js +130 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/financial-control-plane.js +1514 -0
- package/scripts/gates-engine.js +202 -7
- package/scripts/gemini-embedding-policy.js +1 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +15 -3
- package/scripts/hook-stop-anti-claim.js +63 -3
- package/scripts/human-escalation.js +353 -41
- package/scripts/lesson-db.js +16 -5
- package/scripts/lesson-embedding-index.js +67 -20
- package/scripts/lesson-embedding-maintenance.js +177 -0
- package/scripts/lesson-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +305 -29
- package/scripts/lesson-search.js +22 -8
- package/scripts/llm-client.js +304 -15
- package/scripts/model-tier-router.js +593 -0
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/provider-action-normalizer.js +11 -4
- package/scripts/rag-document-pipeline.js +461 -0
- package/scripts/rag-structured-output.js +441 -0
- package/scripts/ragas-style-metrics.js +351 -0
- package/scripts/request-envelope.js +178 -0
- package/scripts/rerank-pipeline.js +370 -0
- package/scripts/rerank-quality-eval.js +155 -0
- package/scripts/retrieval-hybrid-ablation.js +120 -0
- package/scripts/retrieval-quality-tier.js +118 -0
- package/scripts/secret-scanner.js +395 -4
- package/scripts/self-distill-agent.js +7 -1
- package/scripts/self-healing-check.js +25 -0
- package/scripts/skill-packs.js +183 -0
- package/scripts/slow-loop.js +72 -0
- package/scripts/statusline-links.js +1 -1
- package/scripts/statusline.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-registry.js +141 -5
- package/scripts/universal-claim-evaluator.js +767 -0
- package/scripts/vector-store.js +154 -17
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/scripts/workflow-sentinel.js +77 -11
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- package/src/api/server.js +196 -13
|
@@ -13,8 +13,11 @@ const crypto = require('node:crypto');
|
|
|
13
13
|
const fs = require('node:fs');
|
|
14
14
|
const path = require('node:path');
|
|
15
15
|
const { getFeedbackPaths } = require('./feedback-paths');
|
|
16
|
+
const { withFileLedgerLock } = require('./file-ledger-lock');
|
|
16
17
|
|
|
17
18
|
const ESCALATIONS_FILE = 'human-escalations.jsonl';
|
|
19
|
+
const ESCALATIONS_HEAD_FILE = 'human-escalations.head.json';
|
|
20
|
+
const ESCALATIONS_JOURNAL_FILE = 'human-escalations.journal.json';
|
|
18
21
|
const MAX_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
19
22
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
20
23
|
const SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
|
|
@@ -24,6 +27,16 @@ function getEscalationsPath(options = {}) {
|
|
|
24
27
|
return path.join(getFeedbackPaths(options).FEEDBACK_DIR, ESCALATIONS_FILE);
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
function getEscalationsHeadPath(options = {}) {
|
|
31
|
+
if (options.inputPath) return `${path.resolve(options.inputPath)}.head.json`;
|
|
32
|
+
return path.join(getFeedbackPaths(options).FEEDBACK_DIR, ESCALATIONS_HEAD_FILE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getEscalationsJournalPath(options = {}) {
|
|
36
|
+
if (options.inputPath) return `${path.resolve(options.inputPath)}.journal.json`;
|
|
37
|
+
return path.join(getFeedbackPaths(options).FEEDBACK_DIR, ESCALATIONS_JOURNAL_FILE);
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
function requestEscalation(input = {}, options = {}) {
|
|
28
41
|
const now = options.now || new Date();
|
|
29
42
|
const taskId = requiredString(input.taskId, 'taskId');
|
|
@@ -35,8 +48,7 @@ function requestEscalation(input = {}, options = {}) {
|
|
|
35
48
|
if (!SEVERITIES.has(severity)) throw escalationError(`severity must be one of ${Array.from(SEVERITIES).join(', ')}`);
|
|
36
49
|
const ttlMs = Math.min(MAX_TTL_MS, Math.max(1, finiteNumber(input.ttlMs, DEFAULT_TTL_MS)));
|
|
37
50
|
const idempotencyKey = requiredString(input.idempotencyKey || taskId, 'idempotencyKey');
|
|
38
|
-
const
|
|
39
|
-
|
|
51
|
+
const approvalContextDigest = optionalDigest(input.approvalContextDigest, 'approvalContextDigest');
|
|
40
52
|
const request = {
|
|
41
53
|
escalationId: input.escalationId || `esc_${crypto.randomUUID()}`,
|
|
42
54
|
idempotencyKey,
|
|
@@ -50,19 +62,31 @@ function requestEscalation(input = {}, options = {}) {
|
|
|
50
62
|
status: 'pending',
|
|
51
63
|
eventType: 'requested',
|
|
52
64
|
};
|
|
53
|
-
request.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
if (approvalContextDigest) request.approvalContextDigest = approvalContextDigest;
|
|
66
|
+
return withEscalationLock(options, () => {
|
|
67
|
+
const ledger = readLedger(options);
|
|
68
|
+
assertLedgerHealthy(ledger);
|
|
69
|
+
// Compare retries with the immutable request event. A later decision event
|
|
70
|
+
// deliberately carries the reviewer's reason and status, so comparing the
|
|
71
|
+
// projected row would turn an already-approved request into a false
|
|
72
|
+
// idempotency conflict during cross-ledger recovery.
|
|
73
|
+
const existingRequest = ledger.events.find((event) => (
|
|
74
|
+
event.idempotencyKey === idempotencyKey
|
|
75
|
+
&& (!event.eventType || event.eventType === 'requested')
|
|
76
|
+
));
|
|
77
|
+
if (existingRequest) {
|
|
78
|
+
if (eventComparableHash(existingRequest) !== eventComparableHash(request)) {
|
|
79
|
+
const error = escalationError(`conflicting request for idempotency key '${idempotencyKey}'`);
|
|
80
|
+
error.code = 'THUMBGATE_IDEMPOTENCY_CONFLICT';
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
const current = projectEscalations(ledger.events, options)
|
|
84
|
+
.find((entry) => entry.escalationId === existingRequest.escalationId);
|
|
85
|
+
return { recorded: false, duplicate: true, escalation: current || existingRequest };
|
|
60
86
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
appendEvent(request, options);
|
|
65
|
-
return { recorded: true, duplicate: false, escalation: request };
|
|
87
|
+
const recorded = appendEventUnlocked(request, options, ledger.events);
|
|
88
|
+
return { recorded: true, duplicate: false, escalation: recorded };
|
|
89
|
+
});
|
|
66
90
|
}
|
|
67
91
|
|
|
68
92
|
function decideEscalation(input = {}, options = {}) {
|
|
@@ -75,29 +99,74 @@ function decideEscalation(input = {}, options = {}) {
|
|
|
75
99
|
const actor = requiredIdentity(options.authenticatedActor, 'authenticatedActor');
|
|
76
100
|
if (actor.kind !== 'human') throw escalationError('authenticatedActor.kind must be human');
|
|
77
101
|
const reason = requiredString(input.reason, 'reason');
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
102
|
+
return withEscalationLock(options, () => {
|
|
103
|
+
const ledger = readLedger(options);
|
|
104
|
+
assertLedgerHealthy(ledger);
|
|
105
|
+
const current = projectEscalations(ledger.events, options)
|
|
106
|
+
.find((entry) => entry.escalationId === escalationId);
|
|
107
|
+
if (!current) throw escalationError(`unknown escalation '${escalationId}'`);
|
|
108
|
+
if (current.status !== 'pending') throw escalationError(`escalation '${escalationId}' is already ${current.status}`);
|
|
109
|
+
if (sameIdentity(current.requester, actor)) throw escalationError('requester cannot decide their own escalation');
|
|
82
110
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
111
|
+
const now = options.now || new Date();
|
|
112
|
+
const event = {
|
|
113
|
+
escalationId,
|
|
114
|
+
taskId: current.taskId,
|
|
115
|
+
status: decision,
|
|
116
|
+
eventType: 'decided',
|
|
117
|
+
decision,
|
|
118
|
+
actor,
|
|
119
|
+
reason,
|
|
120
|
+
decidedAt: now.toISOString(),
|
|
121
|
+
};
|
|
122
|
+
if (current.approvalContextDigest) {
|
|
123
|
+
event.approvalContextDigest = current.approvalContextDigest;
|
|
124
|
+
}
|
|
125
|
+
const signingKey = optionalString(options.approvalSigningKey);
|
|
126
|
+
if (signingKey) event.approvalReceipt = signApprovalReceipt(event, signingKey);
|
|
127
|
+
const recorded = appendEventUnlocked(event, options, ledger.events);
|
|
128
|
+
return { recorded: true, escalation: { ...current, ...recorded } };
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Return an approval only when both the append-only history and the reviewer
|
|
134
|
+
* receipt authenticate. Merely appending an `approved` JSON row is not proof
|
|
135
|
+
* that the independently authenticated reviewer API produced it.
|
|
136
|
+
*/
|
|
137
|
+
function getVerifiedApproval(escalationId, options = {}) {
|
|
138
|
+
return withEscalationLock(options, () => getVerifiedApprovalUnlocked(escalationId, options));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getVerifiedApprovalUnlocked(escalationId, options = {}) {
|
|
142
|
+
const ledger = readLedger(options);
|
|
143
|
+
const integrity = validateEscalationLedger(ledger.events, ledger.malformedRows, ledger.head);
|
|
144
|
+
if (!integrity.ok) throw escalationError('escalation ledger integrity verification failed');
|
|
145
|
+
const events = ledger.events.filter((event) => event.escalationId === escalationId);
|
|
146
|
+
const requested = events.find((event) => event.eventType === 'requested');
|
|
147
|
+
const decided = events.findLast((event) => event.eventType === 'decided');
|
|
148
|
+
if (!requested || !decided || decided.status !== 'approved') return null;
|
|
149
|
+
if (decided.taskId !== requested.taskId) throw escalationError('approval task does not match its request');
|
|
150
|
+
if ((requested.approvalContextDigest || null) !== (decided.approvalContextDigest || null)) {
|
|
151
|
+
throw escalationError('approval context does not match its request');
|
|
152
|
+
}
|
|
153
|
+
if (decided.actor?.kind !== 'human' || sameIdentity(requested.requester, decided.actor)) {
|
|
154
|
+
throw escalationError('approval is not from an independent human actor');
|
|
155
|
+
}
|
|
156
|
+
const verificationKey = optionalString(
|
|
157
|
+
options.approvalVerificationKey || process.env.THUMBGATE_HUMAN_REVIEWER_KEY
|
|
158
|
+
);
|
|
159
|
+
if (!verificationKey || !verifyApprovalReceipt(decided, verificationKey)) {
|
|
160
|
+
throw escalationError('approval receipt is missing or unauthenticated');
|
|
161
|
+
}
|
|
162
|
+
return { ...requested, ...decided };
|
|
97
163
|
}
|
|
98
164
|
|
|
99
165
|
function listEscalations(options = {}) {
|
|
100
|
-
|
|
166
|
+
return projectEscalations(readEvents(options), options);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function projectEscalations(events, options = {}) {
|
|
101
170
|
const byId = new Map();
|
|
102
171
|
for (const event of events) {
|
|
103
172
|
const current = byId.get(event.escalationId) || {};
|
|
@@ -143,26 +212,254 @@ function calculateEscalationMetrics(escalations = [], now = new Date()) {
|
|
|
143
212
|
}
|
|
144
213
|
|
|
145
214
|
function readEvents(options = {}) {
|
|
215
|
+
return readLedger(options).events;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readLedger(options = {}) {
|
|
146
219
|
const inputPath = options.inputPath ? path.resolve(options.inputPath) : getEscalationsPath(options);
|
|
147
220
|
let raw = '';
|
|
148
221
|
try {
|
|
149
222
|
raw = fs.readFileSync(inputPath, 'utf8');
|
|
150
|
-
} catch {
|
|
151
|
-
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error.code !== 'ENOENT') throw error;
|
|
225
|
+
return { events: [], malformedRows: [], head: readLedgerHead(options) };
|
|
152
226
|
}
|
|
153
|
-
|
|
227
|
+
const events = [];
|
|
228
|
+
const malformedRows = [];
|
|
229
|
+
raw.split('\n').forEach((line, index) => {
|
|
230
|
+
if (!line.trim()) return;
|
|
154
231
|
try {
|
|
155
|
-
|
|
232
|
+
events.push(JSON.parse(line));
|
|
156
233
|
} catch {
|
|
157
|
-
|
|
234
|
+
malformedRows.push(index + 1);
|
|
158
235
|
}
|
|
159
236
|
});
|
|
237
|
+
return { events, malformedRows, head: readLedgerHead(options) };
|
|
160
238
|
}
|
|
161
239
|
|
|
162
|
-
function
|
|
240
|
+
function appendEventUnlocked(event, options, existingEvents) {
|
|
163
241
|
const outputPath = getEscalationsPath(options);
|
|
242
|
+
const previous = existingEvents.at(-1) || null;
|
|
243
|
+
const chained = {
|
|
244
|
+
...event,
|
|
245
|
+
schemaVersion: 'human-escalation-v2',
|
|
246
|
+
sequence: existingEvents.length + 1,
|
|
247
|
+
previousEventHash: previous?.eventHash || null,
|
|
248
|
+
};
|
|
249
|
+
chained.eventHash = eventHash(chained);
|
|
164
250
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
165
|
-
|
|
251
|
+
const journalPath = getEscalationsJournalPath(options);
|
|
252
|
+
writeAtomicJson(journalPath, {
|
|
253
|
+
schemaVersion: 'human-escalation-journal-v1',
|
|
254
|
+
previousHead: readLedgerHead(options),
|
|
255
|
+
event: chained,
|
|
256
|
+
});
|
|
257
|
+
const ledgerFd = fs.openSync(outputPath, 'a', 0o600);
|
|
258
|
+
try {
|
|
259
|
+
fs.writeSync(ledgerFd, `${JSON.stringify(chained)}\n`, null, 'utf8');
|
|
260
|
+
fs.fsyncSync(ledgerFd);
|
|
261
|
+
} finally {
|
|
262
|
+
fs.closeSync(ledgerFd);
|
|
263
|
+
}
|
|
264
|
+
fsyncDirectoryFor(outputPath);
|
|
265
|
+
writeLedgerHead(chained, options);
|
|
266
|
+
removeDurableFile(journalPath);
|
|
267
|
+
return chained;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function assertLedgerHealthy(ledger) {
|
|
271
|
+
if (!validateEscalationLedger(ledger.events, ledger.malformedRows, ledger.head).ok) {
|
|
272
|
+
throw escalationError('refusing to append to a damaged escalation ledger');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function withEscalationLock(options, callback) {
|
|
277
|
+
return withFileLedgerLock(`${getEscalationsPath(options)}.lock`, callback, {
|
|
278
|
+
now: options.now,
|
|
279
|
+
lockStaleMs: options.lockStaleMs,
|
|
280
|
+
errorFactory: (message) => escalationError(message),
|
|
281
|
+
beforeCallback: () => recoverEscalationTransaction(options),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readLedgerHead(options = {}) {
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(fs.readFileSync(getEscalationsHeadPath(options), 'utf8'));
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (error.code === 'ENOENT') return null;
|
|
290
|
+
return { malformed: true };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function writeLedgerHead(event, options = {}) {
|
|
295
|
+
const headPath = getEscalationsHeadPath(options);
|
|
296
|
+
const head = {
|
|
297
|
+
schemaVersion: 'human-escalation-head-v1',
|
|
298
|
+
sequence: event.sequence,
|
|
299
|
+
eventHash: event.eventHash,
|
|
300
|
+
};
|
|
301
|
+
writeAtomicJson(headPath, head);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function recoverEscalationTransaction(options = {}) {
|
|
305
|
+
const journalPath = getEscalationsJournalPath(options);
|
|
306
|
+
let journal;
|
|
307
|
+
try {
|
|
308
|
+
journal = JSON.parse(fs.readFileSync(journalPath, 'utf8'));
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (error.code === 'ENOENT') return;
|
|
311
|
+
throw escalationError(`cannot recover escalation journal: ${error.message}`);
|
|
312
|
+
}
|
|
313
|
+
if (journal?.schemaVersion !== 'human-escalation-journal-v1'
|
|
314
|
+
|| !journal.event
|
|
315
|
+
|| journal.event.eventHash !== eventHash(journal.event)) {
|
|
316
|
+
throw escalationError('escalation journal integrity verification failed');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const ledger = readLedger(options);
|
|
320
|
+
const event = journal.event;
|
|
321
|
+
const currentLast = ledger.events.at(-1) || null;
|
|
322
|
+
const eventHead = { sequence: event.sequence, eventHash: event.eventHash };
|
|
323
|
+
const eventAlreadyAppended = sameHead(currentLast, eventHead);
|
|
324
|
+
const headAtPrevious = sameHead(ledger.head, journal.previousHead);
|
|
325
|
+
const headAtEvent = sameHead(ledger.head, eventHead);
|
|
326
|
+
|
|
327
|
+
if (eventAlreadyAppended) {
|
|
328
|
+
const preceding = ledger.events.at(-2) || null;
|
|
329
|
+
const precedingMatches = event.sequence === ledger.events.length
|
|
330
|
+
&& event.previousEventHash === (preceding?.eventHash || null);
|
|
331
|
+
const syntheticHead = {
|
|
332
|
+
schemaVersion: 'human-escalation-head-v1',
|
|
333
|
+
...eventHead,
|
|
334
|
+
};
|
|
335
|
+
const integrity = validateEscalationLedger(ledger.events, ledger.malformedRows, syntheticHead);
|
|
336
|
+
if (!precedingMatches || !integrity.ok || (!headAtPrevious && !headAtEvent)) {
|
|
337
|
+
throw escalationError('escalation journal does not match the recoverable append');
|
|
338
|
+
}
|
|
339
|
+
if (!headAtEvent) writeLedgerHead(event, options);
|
|
340
|
+
removeDurableFile(journalPath);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const currentIntegrity = validateEscalationLedger(ledger.events, ledger.malformedRows, ledger.head);
|
|
345
|
+
if (currentIntegrity.ok && headAtPrevious && event.sequence === ledger.events.length + 1) {
|
|
346
|
+
// No append became durable, so the caller never received success. Discard
|
|
347
|
+
// the prepared transaction and let the original operation be retried.
|
|
348
|
+
removeDurableFile(journalPath);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
throw escalationError('escalation journal cannot be reconciled safely');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function sameHead(left, right) {
|
|
355
|
+
if (!left && !right) return true;
|
|
356
|
+
return left?.sequence === right?.sequence && left?.eventHash === right?.eventHash;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function writeAtomicJson(targetPath, value) {
|
|
360
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
361
|
+
const temporaryPath = `${targetPath}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
362
|
+
const fd = fs.openSync(temporaryPath, 'w', 0o600);
|
|
363
|
+
try {
|
|
364
|
+
fs.writeSync(fd, `${JSON.stringify(value)}\n`, null, 'utf8');
|
|
365
|
+
fs.fsyncSync(fd);
|
|
366
|
+
} finally {
|
|
367
|
+
fs.closeSync(fd);
|
|
368
|
+
}
|
|
369
|
+
fs.renameSync(temporaryPath, targetPath);
|
|
370
|
+
fsyncDirectoryFor(targetPath);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function removeDurableFile(targetPath) {
|
|
374
|
+
fs.unlinkSync(targetPath);
|
|
375
|
+
fsyncDirectoryFor(targetPath);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function fsyncDirectoryFor(targetPath) {
|
|
379
|
+
const directoryFd = fs.openSync(path.dirname(targetPath), 'r');
|
|
380
|
+
try {
|
|
381
|
+
fs.fsyncSync(directoryFd);
|
|
382
|
+
} finally {
|
|
383
|
+
fs.closeSync(directoryFd);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function validateEscalationLedger(events, malformedRows = [], head = null) {
|
|
388
|
+
const invalidEventHashes = [];
|
|
389
|
+
const invalidChainLinks = [];
|
|
390
|
+
let previousHash = null;
|
|
391
|
+
let chainedEvents = 0;
|
|
392
|
+
events.forEach((event, index) => {
|
|
393
|
+
const sequence = index + 1;
|
|
394
|
+
if (event.eventHash !== eventHash(event)) invalidEventHashes.push(sequence);
|
|
395
|
+
const isLegacy = !event.schemaVersion
|
|
396
|
+
&& event.sequence === undefined
|
|
397
|
+
&& event.previousEventHash === undefined;
|
|
398
|
+
if (!isLegacy) chainedEvents += 1;
|
|
399
|
+
// Legacy events predate the global chain. They may remain only as a
|
|
400
|
+
// contiguous prefix; the first new event seals their terminal hash into
|
|
401
|
+
// the v2 chain and creates the external head checkpoint.
|
|
402
|
+
if ((!isLegacy && (event.sequence !== sequence || event.previousEventHash !== previousHash))
|
|
403
|
+
|| (isLegacy && chainedEvents > 0)) {
|
|
404
|
+
invalidChainLinks.push(sequence);
|
|
405
|
+
}
|
|
406
|
+
previousHash = event.eventHash || null;
|
|
407
|
+
});
|
|
408
|
+
const expected = events.at(-1) || null;
|
|
409
|
+
const headValid = expected === null
|
|
410
|
+
? head === null
|
|
411
|
+
: chainedEvents === 0
|
|
412
|
+
? head === null
|
|
413
|
+
: head?.schemaVersion === 'human-escalation-head-v1'
|
|
414
|
+
&& head.sequence === expected.sequence
|
|
415
|
+
&& head.eventHash === expected.eventHash;
|
|
416
|
+
return {
|
|
417
|
+
ok: malformedRows.length === 0
|
|
418
|
+
&& invalidEventHashes.length === 0
|
|
419
|
+
&& invalidChainLinks.length === 0
|
|
420
|
+
&& headValid,
|
|
421
|
+
malformedRows,
|
|
422
|
+
invalidEventHashes,
|
|
423
|
+
invalidChainLinks,
|
|
424
|
+
headValid,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function signApprovalReceipt(event, signingKey) {
|
|
429
|
+
return {
|
|
430
|
+
algorithm: 'hmac-sha256',
|
|
431
|
+
keyId: crypto.createHash('sha256').update(signingKey).digest('hex').slice(0, 16),
|
|
432
|
+
signature: crypto.createHmac('sha256', signingKey).update(approvalPayload(event)).digest('hex'),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function verifyApprovalReceipt(event, verificationKey) {
|
|
437
|
+
const receipt = event.approvalReceipt;
|
|
438
|
+
if (!receipt || receipt.algorithm !== 'hmac-sha256') return false;
|
|
439
|
+
const expectedKeyId = crypto.createHash('sha256').update(verificationKey).digest('hex').slice(0, 16);
|
|
440
|
+
if (receipt.keyId !== expectedKeyId) return false;
|
|
441
|
+
const expected = crypto.createHmac('sha256', verificationKey).update(approvalPayload(event)).digest();
|
|
442
|
+
let actual;
|
|
443
|
+
try {
|
|
444
|
+
actual = Buffer.from(String(receipt.signature || ''), 'hex');
|
|
445
|
+
} catch {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function approvalPayload(event) {
|
|
452
|
+
const payload = {
|
|
453
|
+
escalationId: event.escalationId,
|
|
454
|
+
taskId: event.taskId,
|
|
455
|
+
status: event.status,
|
|
456
|
+
decision: event.decision,
|
|
457
|
+
actor: event.actor,
|
|
458
|
+
reason: event.reason,
|
|
459
|
+
decidedAt: event.decidedAt,
|
|
460
|
+
};
|
|
461
|
+
if (event.approvalContextDigest) payload.approvalContextDigest = event.approvalContextDigest;
|
|
462
|
+
return stableStringify(payload);
|
|
166
463
|
}
|
|
167
464
|
|
|
168
465
|
function requiredIdentity(value, field) {
|
|
@@ -187,6 +484,14 @@ function optionalString(value) {
|
|
|
187
484
|
return clean || undefined;
|
|
188
485
|
}
|
|
189
486
|
|
|
487
|
+
function optionalDigest(value, field) {
|
|
488
|
+
const digest = optionalString(value);
|
|
489
|
+
if (digest && !/^[a-f0-9]{64}$/i.test(digest)) {
|
|
490
|
+
throw escalationError(`${field} must be a SHA-256 hex digest`);
|
|
491
|
+
}
|
|
492
|
+
return digest?.toLowerCase();
|
|
493
|
+
}
|
|
494
|
+
|
|
190
495
|
function stringArray(value) {
|
|
191
496
|
return Array.isArray(value) ? value.map((entry) => String(entry).trim()).filter(Boolean) : [];
|
|
192
497
|
}
|
|
@@ -201,7 +506,9 @@ function sameIdentity(a, b) {
|
|
|
201
506
|
}
|
|
202
507
|
|
|
203
508
|
function eventHash(event) {
|
|
204
|
-
|
|
509
|
+
const copy = { ...event };
|
|
510
|
+
delete copy.eventHash;
|
|
511
|
+
return crypto.createHash('sha256').update(stableStringify(copy)).digest('hex');
|
|
205
512
|
}
|
|
206
513
|
|
|
207
514
|
function eventComparableHash(event) {
|
|
@@ -213,6 +520,7 @@ function eventComparableHash(event) {
|
|
|
213
520
|
requester: event.requester,
|
|
214
521
|
evidence: event.evidence,
|
|
215
522
|
};
|
|
523
|
+
if (event.approvalContextDigest) comparable.approvalContextDigest = event.approvalContextDigest;
|
|
216
524
|
return crypto.createHash('sha256').update(stableStringify(comparable)).digest('hex');
|
|
217
525
|
}
|
|
218
526
|
|
|
@@ -259,7 +567,11 @@ module.exports = {
|
|
|
259
567
|
calculateEscalationMetrics,
|
|
260
568
|
decideEscalation,
|
|
261
569
|
getEscalation,
|
|
570
|
+
getEscalationsHeadPath,
|
|
571
|
+
getEscalationsJournalPath,
|
|
262
572
|
getEscalationsPath,
|
|
573
|
+
getVerifiedApproval,
|
|
263
574
|
listEscalations,
|
|
264
575
|
requestEscalation,
|
|
576
|
+
validateEscalationLedger,
|
|
265
577
|
};
|
package/scripts/lesson-db.js
CHANGED
|
@@ -15,14 +15,16 @@
|
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
const fs = require('node:fs');
|
|
17
17
|
const { readJsonl } = require('./fs-utils');
|
|
18
|
+
const { resolveFeedbackDir } = require('./feedback-paths');
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
function resolveDefaultDbPath(options = {}) {
|
|
21
|
+
return path.join(resolveFeedbackDir(options), 'lessons.sqlite');
|
|
22
|
+
}
|
|
21
23
|
|
|
22
24
|
/** @returns {import('better-sqlite3').Database} */
|
|
23
25
|
function initDB(dbPath) {
|
|
24
26
|
const Database = require('better-sqlite3');
|
|
25
|
-
const resolvedPath = dbPath || process.env.LESSON_DB_PATH ||
|
|
27
|
+
const resolvedPath = dbPath || process.env.LESSON_DB_PATH || resolveDefaultDbPath();
|
|
26
28
|
|
|
27
29
|
// Ensure parent directory exists
|
|
28
30
|
const dir = path.dirname(resolvedPath);
|
|
@@ -643,7 +645,7 @@ function safeParseTags(tagsStr) {
|
|
|
643
645
|
}
|
|
644
646
|
}
|
|
645
647
|
|
|
646
|
-
|
|
648
|
+
const lessonDbApi = {
|
|
647
649
|
initDB,
|
|
648
650
|
upsertLesson,
|
|
649
651
|
upsertSession,
|
|
@@ -655,5 +657,14 @@ module.exports = {
|
|
|
655
657
|
getStats,
|
|
656
658
|
getStatsFromDB,
|
|
657
659
|
backfillFromJsonl,
|
|
658
|
-
|
|
660
|
+
resolveDefaultDbPath,
|
|
659
661
|
};
|
|
662
|
+
|
|
663
|
+
// Preserve the public property while resolving it at access time so callers
|
|
664
|
+
// cannot cache a package-checkout path before project selection is known.
|
|
665
|
+
Object.defineProperty(lessonDbApi, 'DEFAULT_DB_PATH', {
|
|
666
|
+
enumerable: true,
|
|
667
|
+
get: resolveDefaultDbPath,
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
module.exports = lessonDbApi;
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Design constraints:
|
|
14
14
|
* - Embedding the whole corpus on every tool call is too expensive. We cache
|
|
15
|
-
* document vectors keyed by `id +
|
|
15
|
+
* document vectors keyed by `id + sha256(text) + provider + dimension` in
|
|
16
|
+
* <feedbackDir>/lesson-embeddings.json.
|
|
16
17
|
* Only the query is embedded per call; only new/changed lessons re-embed.
|
|
17
18
|
* - The embedder is reused from vector-store.embed (Gemini -> local transformers
|
|
18
19
|
* -> stub). No new embedding dependency.
|
|
@@ -92,15 +93,12 @@ function cosineSimilarity(a, b) {
|
|
|
92
93
|
* WITHOUT loading a model. Returns true for the deterministic stub (test/CI safe).
|
|
93
94
|
*/
|
|
94
95
|
function isEmbedderAvailable() {
|
|
95
|
-
if (process.env.THUMBGATE_VECTOR_STUB_EMBED === 'true') return true;
|
|
96
|
-
// Managed Gemini path
|
|
97
96
|
try {
|
|
98
|
-
const {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return true;
|
|
97
|
+
const { hasSemanticEmbeddingProvider } = require('./vector-store');
|
|
98
|
+
return hasSemanticEmbeddingProvider();
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
104
102
|
}
|
|
105
103
|
|
|
106
104
|
function defaultEmbedder() {
|
|
@@ -122,12 +120,34 @@ function readCache(cachePath) {
|
|
|
122
120
|
function writeCache(cachePath, cache) {
|
|
123
121
|
try {
|
|
124
122
|
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
125
|
-
|
|
123
|
+
const tmpPath = `${cachePath}.${process.pid}.tmp`;
|
|
124
|
+
fs.writeFileSync(tmpPath, JSON.stringify(cache), { mode: 0o600 });
|
|
125
|
+
fs.renameSync(tmpPath, cachePath);
|
|
126
|
+
fs.chmodSync(cachePath, 0o600);
|
|
126
127
|
} catch {
|
|
127
128
|
/* cache is best-effort; never throw into the hot path */
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
function resolveProviderFingerprint(options, vector) {
|
|
133
|
+
if (options.embedder) {
|
|
134
|
+
return String(options.embedderId || options.embedder.providerId || 'injected-test');
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const { getLastEmbeddingProfile } = require('./vector-store');
|
|
138
|
+
const profile = getLastEmbeddingProfile();
|
|
139
|
+
const active = profile && profile.activeProfile;
|
|
140
|
+
return [
|
|
141
|
+
profile && profile.source,
|
|
142
|
+
active && active.id,
|
|
143
|
+
active && active.model,
|
|
144
|
+
Array.isArray(vector) ? vector.length : 0,
|
|
145
|
+
].filter(Boolean).join(':') || 'unknown';
|
|
146
|
+
} catch {
|
|
147
|
+
return 'unknown';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
131
151
|
/**
|
|
132
152
|
* Rank lessons by dense (embedding) similarity to the query.
|
|
133
153
|
* Returns [{ id, score }] sorted descending. Embeds the query once; reuses cached
|
|
@@ -136,10 +156,17 @@ function writeCache(cachePath, cache) {
|
|
|
136
156
|
* @returns {Promise<Array<{id:string, score:number}>>}
|
|
137
157
|
*/
|
|
138
158
|
async function semanticRank(queryText, lessons = [], options = {}) {
|
|
139
|
-
const {
|
|
159
|
+
const {
|
|
160
|
+
feedbackDir,
|
|
161
|
+
embedder = defaultEmbedder(),
|
|
162
|
+
persist = true,
|
|
163
|
+
pruneCache = true,
|
|
164
|
+
truncateDimension = null,
|
|
165
|
+
cacheFile = CACHE_FILE,
|
|
166
|
+
} = options;
|
|
140
167
|
if (!queryText || !Array.isArray(lessons) || lessons.length === 0) return [];
|
|
141
168
|
|
|
142
|
-
const cachePath =
|
|
169
|
+
const cachePath = path.join(resolveFeedbackDir(feedbackDir), path.basename(cacheFile));
|
|
143
170
|
const cache = readCache(cachePath);
|
|
144
171
|
let cacheDirty = false;
|
|
145
172
|
|
|
@@ -149,6 +176,11 @@ async function semanticRank(queryText, lessons = [], options = {}) {
|
|
|
149
176
|
if (truncateDimension) {
|
|
150
177
|
queryVector = truncateVector(queryVector, truncateDimension);
|
|
151
178
|
}
|
|
179
|
+
const provider = resolveProviderFingerprint(options, queryVector);
|
|
180
|
+
const dimension = queryVector.length;
|
|
181
|
+
if (!options.embedder && /(?:built-in|feature-hash)/i.test(provider)) {
|
|
182
|
+
throw new Error('Semantic embedding provider degraded to feature hashing');
|
|
183
|
+
}
|
|
152
184
|
|
|
153
185
|
const scored = [];
|
|
154
186
|
for (const lesson of lessons) {
|
|
@@ -158,14 +190,22 @@ async function semanticRank(queryText, lessons = [], options = {}) {
|
|
|
158
190
|
const hash = hashText(text);
|
|
159
191
|
|
|
160
192
|
let entry = cache[lesson.id];
|
|
161
|
-
if (
|
|
193
|
+
if (
|
|
194
|
+
!entry
|
|
195
|
+
|| entry.hash !== hash
|
|
196
|
+
|| entry.provider !== provider
|
|
197
|
+
|| entry.dimension !== dimension
|
|
198
|
+
|| !Array.isArray(entry.vector)
|
|
199
|
+
|| entry.vector.length !== dimension
|
|
200
|
+
) {
|
|
162
201
|
const vector = await embedder(text, {
|
|
163
202
|
kind: 'document',
|
|
164
203
|
task: 'code retrieval',
|
|
165
204
|
title: lesson.title || undefined,
|
|
166
205
|
});
|
|
167
206
|
if (!Array.isArray(vector) || vector.length === 0) continue;
|
|
168
|
-
|
|
207
|
+
if (vector.length !== dimension) continue;
|
|
208
|
+
entry = { hash, provider, dimension, vector };
|
|
169
209
|
cache[lesson.id] = entry;
|
|
170
210
|
cacheDirty = true;
|
|
171
211
|
}
|
|
@@ -174,12 +214,16 @@ async function semanticRank(queryText, lessons = [], options = {}) {
|
|
|
174
214
|
scored.push({ id: lesson.id, score: cosineSimilarity(queryVector, docVector) });
|
|
175
215
|
}
|
|
176
216
|
|
|
177
|
-
// Prune
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
217
|
+
// Prune only when the caller supplied the complete corpus. Metadata-filtered
|
|
218
|
+
// searches pass pruneCache=false so alternating filters cannot evict and
|
|
219
|
+
// regenerate one another's vectors.
|
|
220
|
+
if (pruneCache) {
|
|
221
|
+
const liveIds = new Set(lessons.map((l) => l && l.id).filter(Boolean));
|
|
222
|
+
for (const id of Object.keys(cache)) {
|
|
223
|
+
if (!liveIds.has(id)) {
|
|
224
|
+
delete cache[id];
|
|
225
|
+
cacheDirty = true;
|
|
226
|
+
}
|
|
183
227
|
}
|
|
184
228
|
}
|
|
185
229
|
|
|
@@ -196,4 +240,7 @@ module.exports = {
|
|
|
196
240
|
lessonText,
|
|
197
241
|
hashText,
|
|
198
242
|
getCachePath,
|
|
243
|
+
readCache,
|
|
244
|
+
writeCache,
|
|
245
|
+
resolveProviderFingerprint,
|
|
199
246
|
};
|