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
package/scripts/feedback-loop.js
CHANGED
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
buildClarificationMessage,
|
|
22
22
|
isGenericFeedbackText,
|
|
23
23
|
normalizeFeedbackText,
|
|
24
|
+
scoreFeedbackReward,
|
|
24
25
|
} = require('./feedback-quality');
|
|
25
26
|
const {
|
|
26
27
|
buildRubricEvaluation,
|
|
@@ -1613,6 +1614,14 @@ function captureFeedback(params) {
|
|
|
1613
1614
|
return firewallBlocked;
|
|
1614
1615
|
}
|
|
1615
1616
|
|
|
1617
|
+
// Grade the correction the operator actually wrote, and persist it with the
|
|
1618
|
+
// entry. assessFeedbackActionability already answered the binary question
|
|
1619
|
+
// (promotable at all); this is the graded one, and without computing it here
|
|
1620
|
+
// the scorer would be reachable-but-never-called — the same defect class it
|
|
1621
|
+
// was added to help detect.
|
|
1622
|
+
const rewardScore = scoreFeedbackReward(feedbackEvent);
|
|
1623
|
+
if (rewardScore) feedbackEvent.rewardScore = rewardScore;
|
|
1624
|
+
|
|
1616
1625
|
appendJSONL(FEEDBACK_LOG_PATH, feedbackEvent);
|
|
1617
1626
|
emitAnonymousFeedbackPing(signal);
|
|
1618
1627
|
|
|
@@ -2243,20 +2252,100 @@ function buildPreventionRules(minOccurrences = 2, options = {}) {
|
|
|
2243
2252
|
return Math.exp(-lambda * daysSince);
|
|
2244
2253
|
}
|
|
2245
2254
|
|
|
2255
|
+
// CEO contract tags beat generic richContext.domain=general so honesty/overclaim
|
|
2256
|
+
// whatToChange is not drowned by hook noise in the "general" bucket.
|
|
2257
|
+
const PRIORITY_DOMAIN_TAGS = [
|
|
2258
|
+
'honesty',
|
|
2259
|
+
'overclaim',
|
|
2260
|
+
'completion-claim',
|
|
2261
|
+
'production-truth',
|
|
2262
|
+
'false-completion',
|
|
2263
|
+
'pr-hygiene',
|
|
2264
|
+
'ceo-feedback',
|
|
2265
|
+
];
|
|
2266
|
+
const GENERIC_TAGS = new Set([
|
|
2267
|
+
'feedback',
|
|
2268
|
+
'negative',
|
|
2269
|
+
'positive',
|
|
2270
|
+
'entity:Customer',
|
|
2271
|
+
'thumbs-down',
|
|
2272
|
+
'thumbs-up',
|
|
2273
|
+
]);
|
|
2274
|
+
|
|
2275
|
+
function isNoiseTitle(title) {
|
|
2276
|
+
const t = String(title || '');
|
|
2277
|
+
return /hookEventName|user_prompt_submit|"sessionId"/i.test(t);
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
function extractAvoidLine(content) {
|
|
2281
|
+
return String(content || '')
|
|
2282
|
+
.split('\n')
|
|
2283
|
+
.find((l) => l.toLowerCase().startsWith('how to avoid:')) || null;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
function domainKeyForMemory(m) {
|
|
2287
|
+
const tags = Array.isArray(m.tags) ? m.tags : [];
|
|
2288
|
+
for (const tag of PRIORITY_DOMAIN_TAGS) {
|
|
2289
|
+
if (tags.includes(tag)) return tag;
|
|
2290
|
+
}
|
|
2291
|
+
const rcDomain = m.richContext && m.richContext.domain;
|
|
2292
|
+
if (rcDomain && rcDomain !== 'unknown' && rcDomain !== 'general') {
|
|
2293
|
+
return rcDomain;
|
|
2294
|
+
}
|
|
2295
|
+
return tags.find((t) => !GENERIC_TAGS.has(t)) || 'general';
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
function pickBestMemory(items) {
|
|
2299
|
+
let best = items[items.length - 1];
|
|
2300
|
+
let bestScore = -Infinity;
|
|
2301
|
+
items.forEach((m, index) => {
|
|
2302
|
+
let score = index;
|
|
2303
|
+
if (isNoiseTitle(m.title)) score -= 1000;
|
|
2304
|
+
const avoid = extractAvoidLine(m.content);
|
|
2305
|
+
if (avoid) score += 100;
|
|
2306
|
+
if (avoid && /never/i.test(avoid)) score += 50;
|
|
2307
|
+
if ((m.tags || []).some((t) => PRIORITY_DOMAIN_TAGS.includes(t))) score += 40;
|
|
2308
|
+
if ((m.occurrences || 1) > 1) score += 10;
|
|
2309
|
+
if (score >= bestScore) {
|
|
2310
|
+
bestScore = score;
|
|
2311
|
+
best = m;
|
|
2312
|
+
}
|
|
2313
|
+
});
|
|
2314
|
+
return best;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2246
2317
|
const buckets = {};
|
|
2247
2318
|
const rubricBuckets = {};
|
|
2248
2319
|
const diagnosisBuckets = {};
|
|
2249
2320
|
const repeatedViolationBuckets = {};
|
|
2321
|
+
const priorityContracts = [];
|
|
2250
2322
|
for (const m of memories) {
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2323
|
+
if (isNoiseTitle(m.title) && !extractAvoidLine(m.content)) {
|
|
2324
|
+
// Skip pure hook-noise shells with no actionable avoid line.
|
|
2325
|
+
continue;
|
|
2326
|
+
}
|
|
2327
|
+
const key = domainKeyForMemory(m);
|
|
2254
2328
|
if (!buckets[key]) buckets[key] = { items: [], weightedCount: 0 };
|
|
2255
2329
|
const w = decayWeight(m);
|
|
2256
2330
|
const occ = m.occurrences || 1;
|
|
2257
2331
|
buckets[key].items.push(m);
|
|
2258
2332
|
buckets[key].weightedCount += w * occ;
|
|
2259
2333
|
|
|
2334
|
+
const tags = Array.isArray(m.tags) ? m.tags : [];
|
|
2335
|
+
const avoid = extractAvoidLine(m.content);
|
|
2336
|
+
if (
|
|
2337
|
+
avoid
|
|
2338
|
+
&& tags.some((t) => PRIORITY_DOMAIN_TAGS.includes(t))
|
|
2339
|
+
) {
|
|
2340
|
+
priorityContracts.push({
|
|
2341
|
+
id: m.id,
|
|
2342
|
+
tags: tags.filter((t) => PRIORITY_DOMAIN_TAGS.includes(t)),
|
|
2343
|
+
rule: avoid.replace(/^How to avoid:\s*/i, ''),
|
|
2344
|
+
title: m.title,
|
|
2345
|
+
occurrences: occ,
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2260
2349
|
const failed = m.rubricSummary && Array.isArray(m.rubricSummary.failingCriteria)
|
|
2261
2350
|
? m.rubricSummary.failingCriteria
|
|
2262
2351
|
: [];
|
|
@@ -2294,18 +2383,37 @@ function buildPreventionRules(minOccurrences = 2, options = {}) {
|
|
|
2294
2383
|
|
|
2295
2384
|
const lines = ['# Prevention Rules', '', 'Generated from negative feedback memories (time-weighted, half-life: ' + decayHalfLifeDays + 'd).'];
|
|
2296
2385
|
|
|
2386
|
+
// High-priority CEO contracts: always emit actionable whatToChange (threshold 1).
|
|
2387
|
+
if (priorityContracts.length > 0) {
|
|
2388
|
+
lines.push('');
|
|
2389
|
+
lines.push('## High-Priority Contracts');
|
|
2390
|
+
const seen = new Set();
|
|
2391
|
+
priorityContracts
|
|
2392
|
+
.sort((a, b) => (b.occurrences || 1) - (a.occurrences || 1))
|
|
2393
|
+
.forEach((c) => {
|
|
2394
|
+
const dedupe = c.rule.slice(0, 120);
|
|
2395
|
+
if (seen.has(dedupe)) return;
|
|
2396
|
+
seen.add(dedupe);
|
|
2397
|
+
lines.push(`- **[${c.tags.join(', ')}]** ${c.rule}`);
|
|
2398
|
+
lines.push(` - Source: ${c.title}`);
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2297
2402
|
Object.entries(buckets)
|
|
2298
2403
|
.sort((a, b) => b[1].weightedCount - a[1].weightedCount)
|
|
2299
2404
|
.forEach(([domain, { items, weightedCount }]) => {
|
|
2300
2405
|
const effectiveOccurrences = Math.round(weightedCount);
|
|
2301
|
-
|
|
2302
|
-
const
|
|
2303
|
-
|
|
2406
|
+
// Priority domains promote with a single solid memory (CEO contracts).
|
|
2407
|
+
const threshold = PRIORITY_DOMAIN_TAGS.includes(domain) ? 1 : resolvedMinOccurrences;
|
|
2408
|
+
if (effectiveOccurrences < threshold) return;
|
|
2409
|
+
const best = pickBestMemory(items);
|
|
2410
|
+
const avoid = extractAvoidLine(best.content)
|
|
2411
|
+
|| 'How to avoid: Investigate and prevent recurrence';
|
|
2304
2412
|
lines.push('');
|
|
2305
2413
|
lines.push(`## ${domain}`);
|
|
2306
2414
|
lines.push(`- Recurrence count: ${items.length} (weighted: ${weightedCount.toFixed(1)})`);
|
|
2307
2415
|
lines.push(`- Rule: ${avoid.replace(/^How to avoid:\s*/i, '')}`);
|
|
2308
|
-
lines.push(`- Latest mistake: ${
|
|
2416
|
+
lines.push(`- Latest mistake: ${best.title}`);
|
|
2309
2417
|
});
|
|
2310
2418
|
|
|
2311
2419
|
const rubricEntries = Object.entries(rubricBuckets)
|
|
@@ -78,6 +78,15 @@ function isTransientProjectDir(dirPath, options = {}) {
|
|
|
78
78
|
if (!normalizedDir) return true;
|
|
79
79
|
if (!dirExists(normalizedDir)) return true;
|
|
80
80
|
|
|
81
|
+
// MCP hosts and desktop launchers commonly start global servers from `/` or
|
|
82
|
+
// the user's home directory. Those are launcher contexts, not projects. If
|
|
83
|
+
// they win resolution they create `projects/default` / `~/.thumbgate` split
|
|
84
|
+
// stores and make the same lesson corpus depend on which client started the
|
|
85
|
+
// process. A durable active-project state is a better signal.
|
|
86
|
+
if (normalizedDir === path.parse(normalizedDir).root) return true;
|
|
87
|
+
const homeDir = normalizeDir(getHomeDir(options));
|
|
88
|
+
if (homeDir && normalizedDir === homeDir) return true;
|
|
89
|
+
|
|
81
90
|
const runtimeDir = getRuntimeDir(options);
|
|
82
91
|
if (isWithinDir(normalizedDir, runtimeDir)) return true;
|
|
83
92
|
|
|
@@ -119,11 +128,12 @@ function writeActiveProjectState(projectDir, options = {}) {
|
|
|
119
128
|
function resolveProjectDir(options = {}) {
|
|
120
129
|
const env = options.env || process.env;
|
|
121
130
|
const stored = options.includeStored === false ? null : readActiveProjectState(options);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
// An injected cwd is authoritative for callers that resolve on behalf of a
|
|
132
|
+
// different process. Mixing in this Node process's cwd can silently route a
|
|
133
|
+
// global launcher back into ThumbGate's own checkout during diagnostics.
|
|
134
|
+
const cwdCandidates = uniquePaths(options.cwd
|
|
135
|
+
? [options.cwd]
|
|
136
|
+
: [env.PWD, process.cwd()]);
|
|
127
137
|
const isTransientExecution = cwdCandidates.length > 0
|
|
128
138
|
&& cwdCandidates.every((candidate) => isTransientProjectDir(candidate, options));
|
|
129
139
|
const candidates = uniquePaths([
|
|
@@ -167,15 +177,12 @@ function getExplicitFeedbackDir(options = {}) {
|
|
|
167
177
|
const env = options.env || process.env;
|
|
168
178
|
if (options.feedbackDir) return options.feedbackDir;
|
|
169
179
|
if (options.skipExplicitFeedbackDir) return null;
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
if (env.THUMBGATE_FEEDBACK_DIR
|
|
180
|
+
// An explicit storage root is the strongest storage instruction. Project
|
|
181
|
+
// metadata may still identify the project, but it must not redirect writes
|
|
182
|
+
// out of an isolated test/runtime directory.
|
|
183
|
+
if (env.THUMBGATE_FEEDBACK_DIR) {
|
|
174
184
|
return env.THUMBGATE_FEEDBACK_DIR;
|
|
175
185
|
}
|
|
176
|
-
if (hasDirectProjectScope(options)) {
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
179
186
|
if (env.RAILWAY_VOLUME_MOUNT_PATH) {
|
|
180
187
|
return path.join(env.RAILWAY_VOLUME_MOUNT_PATH, 'feedback');
|
|
181
188
|
}
|
|
@@ -221,7 +228,19 @@ function resolveFeedbackDir(options = {}) {
|
|
|
221
228
|
const localLegacy = getLegacyFeedbackDir(options);
|
|
222
229
|
if (dirExists(localLegacy)) return localLegacy;
|
|
223
230
|
|
|
224
|
-
|
|
231
|
+
// Existing installations may only have the pre-1.31 basename-scoped store.
|
|
232
|
+
// Preserve that corpus instead of silently switching to an empty directory.
|
|
233
|
+
// New projects never create this legacy global shape, so same-basename
|
|
234
|
+
// projects remain isolated going forward.
|
|
235
|
+
const globalLegacy = getGlobalFeedbackDir(options);
|
|
236
|
+
if (dirExists(globalLegacy)) return globalLegacy;
|
|
237
|
+
|
|
238
|
+
const projectDir = resolveProjectDir(options);
|
|
239
|
+
// New real projects select their collision-free local store before it is
|
|
240
|
+
// created, preventing the old first-write flip from global to local.
|
|
241
|
+
if (!isTransientProjectDir(projectDir, options)) return localThumbgate;
|
|
242
|
+
|
|
243
|
+
return globalLegacy;
|
|
225
244
|
}
|
|
226
245
|
|
|
227
246
|
function getFeedbackPaths(options = {}) {
|
|
@@ -154,6 +154,58 @@ function isGenericFeedbackText(value, signal) {
|
|
|
154
154
|
return rules.some((pattern) => pattern.test(normalized));
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Score a captured feedback entry with the reward rubric in judge-reward-function.
|
|
159
|
+
*
|
|
160
|
+
* `assessFeedbackActionability` answers a binary question — is this promotable at
|
|
161
|
+
* all. This answers the graded one: how good is the correction the operator wrote.
|
|
162
|
+
* A 👎 whose whatToChange is "be better" and a 👎 that says "run npm test before
|
|
163
|
+
* claiming green, see commit abc123" are both promotable; only one of them makes a
|
|
164
|
+
* useful prevention rule.
|
|
165
|
+
*
|
|
166
|
+
* Deterministic by construction. `buildCompositeReward` runs its Boolean rubric
|
|
167
|
+
* first and only consults an LLM judge if one is injected; we deliberately inject
|
|
168
|
+
* none, so this returns `scoringMode: 'deterministic_only'` and costs nothing. That
|
|
169
|
+
* matters: ANTHROPIC_API_KEY is frequently absent, and a scorer that silently
|
|
170
|
+
* degrades to nothing is worse than one that never claimed the LLM path.
|
|
171
|
+
*
|
|
172
|
+
* Reporting only — nothing here gates promotion. Wiring a fresh quality signal
|
|
173
|
+
* straight into enforcement would change which lessons become blocking rules, and
|
|
174
|
+
* that decision deserves its own change with its own evidence.
|
|
175
|
+
*
|
|
176
|
+
* @returns {{score: number, label: string, scoringMode: string, dimensions: object, passed: boolean}|null}
|
|
177
|
+
* null when there is no corrective text worth scoring.
|
|
178
|
+
*/
|
|
179
|
+
function scoreFeedbackReward(params = {}) {
|
|
180
|
+
const signal = normalizeFeedbackSignal(params.signal);
|
|
181
|
+
const prediction = normalizeFeedbackText(
|
|
182
|
+
signal === 'positive'
|
|
183
|
+
? (params.whatWorked || params.context)
|
|
184
|
+
: (params.whatToChange || params.whatWentWrong || params.context),
|
|
185
|
+
);
|
|
186
|
+
if (!prediction) return null;
|
|
187
|
+
|
|
188
|
+
// Lazy-require: judge-reward-function is a leaf, but feedback-quality is required
|
|
189
|
+
// by feedback-loop and three other modules — keep the import cost off that path
|
|
190
|
+
// until someone actually asks for a score.
|
|
191
|
+
let buildCompositeReward;
|
|
192
|
+
try {
|
|
193
|
+
({ buildCompositeReward } = require('./judge-reward-function'));
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
if (typeof buildCompositeReward !== 'function') return null;
|
|
198
|
+
|
|
199
|
+
const reward = buildCompositeReward({ prediction });
|
|
200
|
+
return {
|
|
201
|
+
score: reward.score,
|
|
202
|
+
label: reward.label,
|
|
203
|
+
scoringMode: reward.scoringMode || 'deterministic_only',
|
|
204
|
+
dimensions: reward.deterministic ? reward.deterministic.dimensions : {},
|
|
205
|
+
passed: Boolean(reward.deterministic && reward.deterministic.passed),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
157
209
|
function assessFeedbackActionability(params = {}) {
|
|
158
210
|
const signal = normalizeFeedbackSignal(params.signal);
|
|
159
211
|
const primaryFields = signal === 'positive'
|
|
@@ -222,4 +274,5 @@ module.exports = {
|
|
|
222
274
|
isGenericFeedbackText,
|
|
223
275
|
assessFeedbackActionability,
|
|
224
276
|
buildClarificationMessage,
|
|
277
|
+
scoreFeedbackReward,
|
|
225
278
|
};
|
|
@@ -31,6 +31,9 @@ const {
|
|
|
31
31
|
} = require('./feedback-quality');
|
|
32
32
|
|
|
33
33
|
const INFERRED_TAG_RULES = [
|
|
34
|
+
{ tag: 'claw-style', keywords: ['claw', 'enterprise-claw', 'openshell', 'dynamic-tool', 'screen-interaction', 'computer-use'] },
|
|
35
|
+
{ tag: 'hybrid-inference', keywords: ['hybrid', 'cloud-escalation', 'local-route', 'hybrid-route', 'perplexity-pc'] },
|
|
36
|
+
{ tag: 'agent-identity', keywords: ['agent identity', 'audit trail', 'identity separation', 'agent-credential'] },
|
|
34
37
|
{ tag: 'thumbgate', keywords: ['thumbgate', 'feedback-loop', 'statusline', 'dashboard', 'mcp'] },
|
|
35
38
|
{ tag: 'testing', keywords: ['test', 'testing', 'jest', 'coverage', 'verify', 'verification'] },
|
|
36
39
|
{ tag: 'security', keywords: ['security', 'secret', 'credential', 'token', 'auth'] },
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cross-process lock for append-only local ledgers.
|
|
6
|
+
*
|
|
7
|
+
* `mkdir` is the atomic acquisition primitive. An owner record prevents a
|
|
8
|
+
* crashed process from permanently wedging the control plane, while a nonce
|
|
9
|
+
* check prevents an old owner from deleting a replacement lock.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const crypto = require('node:crypto');
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
|
|
16
|
+
const DEFAULT_STALE_MS = 30 * 1000;
|
|
17
|
+
|
|
18
|
+
function withFileLedgerLock(lockPath, callback, options = {}) {
|
|
19
|
+
const resolvedLockPath = path.resolve(lockPath);
|
|
20
|
+
fs.mkdirSync(path.dirname(resolvedLockPath), { recursive: true });
|
|
21
|
+
const owner = acquireLock(resolvedLockPath, options);
|
|
22
|
+
try {
|
|
23
|
+
if (typeof options.beforeCallback === 'function') options.beforeCallback();
|
|
24
|
+
return callback();
|
|
25
|
+
} finally {
|
|
26
|
+
releaseOwnedLock(resolvedLockPath, owner);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function acquireLock(lockPath, options) {
|
|
31
|
+
const now = options.now || new Date();
|
|
32
|
+
const staleMs = positiveNumber(options.lockStaleMs, DEFAULT_STALE_MS);
|
|
33
|
+
const owner = {
|
|
34
|
+
schemaVersion: 'thumbgate-ledger-lock-v1',
|
|
35
|
+
pid: process.pid,
|
|
36
|
+
nonce: crypto.randomUUID(),
|
|
37
|
+
acquiredAt: now.toISOString(),
|
|
38
|
+
};
|
|
39
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
40
|
+
try {
|
|
41
|
+
fs.mkdirSync(lockPath);
|
|
42
|
+
writeOwner(lockPath, owner);
|
|
43
|
+
return owner;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code !== 'EEXIST') throw error;
|
|
46
|
+
if (!recoverStaleLock(lockPath, now, staleMs)) {
|
|
47
|
+
throw lockError(options, 'ledger is busy; deny and retry only after the active writer finishes');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
throw lockError(options, 'ledger lock could not be acquired after stale-lock recovery');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function recoverStaleLock(lockPath, now, staleMs) {
|
|
55
|
+
const observed = readOwner(lockPath);
|
|
56
|
+
const ageMs = lockAgeMs(lockPath, observed, now);
|
|
57
|
+
if (ageMs < staleMs || processIsAlive(observed?.pid)) return false;
|
|
58
|
+
|
|
59
|
+
// Rename is atomic. If another process already recovered or replaced the
|
|
60
|
+
// lock, this attempt loses harmlessly and acquisition is retried.
|
|
61
|
+
const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`;
|
|
62
|
+
try {
|
|
63
|
+
fs.renameSync(lockPath, quarantine);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (['ENOENT', 'EEXIST'].includes(error.code)) return true;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
fs.rmSync(quarantine, { recursive: true, force: true });
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeOwner(lockPath, owner) {
|
|
73
|
+
const target = path.join(lockPath, 'owner.json');
|
|
74
|
+
const temporary = `${target}.tmp-${process.pid}-${owner.nonce}`;
|
|
75
|
+
fs.writeFileSync(temporary, `${JSON.stringify(owner)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
76
|
+
fs.renameSync(temporary, target);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readOwner(lockPath) {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function lockAgeMs(lockPath, owner, now) {
|
|
88
|
+
const recorded = Date.parse(owner?.acquiredAt || '');
|
|
89
|
+
if (Number.isFinite(recorded)) return Math.max(0, now.getTime() - recorded);
|
|
90
|
+
try {
|
|
91
|
+
return Math.max(0, now.getTime() - fs.statSync(lockPath).mtimeMs);
|
|
92
|
+
} catch {
|
|
93
|
+
return Number.POSITIVE_INFINITY;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function processIsAlive(pid) {
|
|
98
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
99
|
+
try {
|
|
100
|
+
process.kill(pid, 0);
|
|
101
|
+
return true;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return error.code !== 'ESRCH';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function releaseOwnedLock(lockPath, owner) {
|
|
108
|
+
const recorded = readOwner(lockPath);
|
|
109
|
+
if (!recorded || recorded.nonce !== owner.nonce) return;
|
|
110
|
+
try { fs.unlinkSync(path.join(lockPath, 'owner.json')); } catch (error) {
|
|
111
|
+
if (error.code !== 'ENOENT') return;
|
|
112
|
+
}
|
|
113
|
+
try { fs.rmdirSync(lockPath); } catch { /* a replacement owner wins */ }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function positiveNumber(value, fallback) {
|
|
117
|
+
const number = Number(value);
|
|
118
|
+
return Number.isFinite(number) && number > 0 ? number : fallback;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function lockError(options, message) {
|
|
122
|
+
return typeof options.errorFactory === 'function'
|
|
123
|
+
? options.errorFactory(message)
|
|
124
|
+
: Object.assign(new Error(message), { code: 'THUMBGATE_LEDGER_BUSY' });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
DEFAULT_STALE_MS,
|
|
129
|
+
withFileLedgerLock,
|
|
130
|
+
};
|
|
@@ -154,14 +154,19 @@ function scoreRecord(queryTokens, queryText, record) {
|
|
|
154
154
|
// ---------------------------------------------------------------------------
|
|
155
155
|
|
|
156
156
|
function searchFeedbackLog(queryText, limit = 5, options = {}) {
|
|
157
|
-
const
|
|
157
|
+
const feedbackDir = options.feedbackDir || getFeedbackDir();
|
|
158
|
+
const logPath = path.join(feedbackDir, 'feedback-log.jsonl');
|
|
158
159
|
let records = readJsonl(logPath);
|
|
159
160
|
|
|
160
|
-
// SQLite fallback
|
|
161
|
-
|
|
161
|
+
// SQLite fallback is allowed only inside the exact selected feedback root.
|
|
162
|
+
// Falling back through lesson-db's ambient default can cross project/tenant
|
|
163
|
+
// boundaries when a sparse project has zero or one JSONL row.
|
|
164
|
+
const lessonDbPath = path.join(feedbackDir, 'lessons.sqlite');
|
|
165
|
+
if (records.length <= 1 && fs.existsSync(lessonDbPath)) {
|
|
166
|
+
let db = null;
|
|
162
167
|
try {
|
|
163
168
|
const { initDB } = require('./lesson-db');
|
|
164
|
-
|
|
169
|
+
db = initDB(lessonDbPath);
|
|
165
170
|
const rows = db.prepare('SELECT * FROM lessons ORDER BY timestamp DESC LIMIT 500').all();
|
|
166
171
|
if (rows.length > records.length) {
|
|
167
172
|
records = rows.map((r) => ({
|
|
@@ -171,12 +176,17 @@ function searchFeedbackLog(queryText, limit = 5, options = {}) {
|
|
|
171
176
|
title: r.title || r.context,
|
|
172
177
|
tags: r.tags ? JSON.parse(r.tags) : [],
|
|
173
178
|
timestamp: r.timestamp,
|
|
174
|
-
whatWentWrong: r.what_went_wrong,
|
|
175
|
-
whatWorked: r.what_worked,
|
|
176
|
-
whatToChange: r.what_to_change,
|
|
179
|
+
whatWentWrong: r.whatWentWrong ?? r.what_went_wrong,
|
|
180
|
+
whatWorked: r.whatWorked ?? r.what_worked,
|
|
181
|
+
whatToChange: r.whatToChange ?? r.what_to_change,
|
|
177
182
|
}));
|
|
178
183
|
}
|
|
179
184
|
} catch { /* lesson-db not available */ }
|
|
185
|
+
finally {
|
|
186
|
+
if (db) {
|
|
187
|
+
try { db.close(); } catch { /* best-effort close */ }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
180
190
|
}
|
|
181
191
|
|
|
182
192
|
// Wildcard query: return all records sorted by recency
|