thumbgate 1.29.1 → 1.30.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/commands/dashboard.md +11 -1
- package/.claude/commands/thumbgate-dashboard.md +23 -8
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +61 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +164 -7
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +7 -5
- package/commands/dashboard.md +11 -1
- package/commands/thumbgate-dashboard.md +23 -8
- package/config/agent-outcome-monitor-thresholds.json +63 -0
- package/config/evals/agent-outcomes-baseline.json +17 -0
- package/config/evals/agent-outcomes-golden.json +412 -0
- package/config/evals/prompt-eval-baseline.json +23 -0
- package/config/mcp-allowlists.json +26 -2
- package/config/post-deploy-marketing-pages.json +26 -1
- package/config/schemas/task-outcome-receipt.schema.json +296 -0
- package/openapi/openapi.yaml +235 -0
- package/package.json +55 -11
- package/public/architecture.html +130 -0
- package/public/assets/diagrams/agent-integration.png +0 -0
- package/public/assets/diagrams/before-after.svg +21 -0
- package/public/assets/diagrams/decision.svg +36 -0
- package/public/assets/diagrams/feedback-pipeline.png +0 -0
- package/public/assets/diagrams/loop.svg +34 -0
- package/public/assets/diagrams/plugin-topology.png +0 -0
- package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
- package/public/assets/diagrams/stack.svg +18 -0
- package/public/assets/diagrams/thumbgate-architecture.png +0 -0
- package/public/case-studies.html +151 -0
- package/public/eval-scorecard.html +195 -0
- package/public/eval-scorecard.json +18 -0
- package/public/evaluations.html +168 -0
- package/public/index.html +6 -3
- package/public/numbers.html +2 -2
- package/public/whitepaper.html +189 -0
- package/scripts/activation-quickstart.js +1 -0
- package/scripts/agent-outcome-eval.js +130 -0
- package/scripts/agent-outcome-monitor.js +331 -0
- package/scripts/agent-reasoning-traces.js +8 -9
- package/scripts/async-job-runner.js +107 -13
- package/scripts/billing.js +3 -1
- package/scripts/claude-feedback-sync.js +3 -2
- package/scripts/cli-feedback.js +13 -7
- package/scripts/cross-encoder-reranker.js +3 -0
- package/scripts/durability/step.js +121 -12
- package/scripts/feedback-aggregate.js +5 -2
- package/scripts/feedback-loop.js +244 -182
- package/scripts/gates-engine.js +512 -22
- package/scripts/generate-case-study-outreach.js +253 -0
- package/scripts/generate-eval-scorecard.js +276 -0
- package/scripts/growth-campaigns.js +183 -0
- package/scripts/human-escalation.js +265 -0
- package/scripts/hybrid-feedback-context.js +93 -50
- package/scripts/jsonl-watcher.js +1 -0
- package/scripts/judge-reward-function.js +30 -18
- package/scripts/lesson-inference.js +23 -4
- package/scripts/lesson-retrieval.js +71 -4
- package/scripts/lesson-search.js +26 -3
- package/scripts/mcp-config.js +26 -5
- package/scripts/mcp-oauth.js +37 -2
- package/scripts/model-eval.js +308 -0
- package/scripts/parallel-workflow-orchestrator.js +86 -22
- package/scripts/prompt-eval.js +81 -4
- package/scripts/published-cli.js +11 -1
- package/scripts/refresh-proof-pack.js +261 -0
- package/scripts/risk-scorer.js +144 -15
- package/scripts/schedule-manager.js +249 -0
- package/scripts/statusline-local-stats.js +1 -1
- package/scripts/task-outcomes.js +425 -0
- package/scripts/thumbgate-bench.js +13 -0
- package/scripts/tool-contract-validator.js +287 -59
- package/scripts/tool-kpi-tracker.js +124 -0
- package/scripts/tool-registry.js +192 -1
- package/src/api/server.js +355 -89
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
const fs = require('node:fs');
|
|
14
14
|
const path = require('node:path');
|
|
15
|
+
const { validateStructuredOutput } = require('./tool-contract-validator');
|
|
15
16
|
|
|
16
17
|
const DEFAULT_CRITERIA = [
|
|
17
18
|
{
|
|
@@ -117,15 +118,19 @@ function buildCompositeReward(sample = {}, options = {}) {
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
const judge = runJudgeSafely(sample, options.judge);
|
|
120
|
-
const
|
|
121
|
-
|
|
121
|
+
const score = judge.ok
|
|
122
|
+
? round((deterministic.score * 0.65) + (judge.score * 0.35))
|
|
123
|
+
: deterministic.score;
|
|
122
124
|
return {
|
|
123
125
|
score,
|
|
124
|
-
label: rewardLabel(score),
|
|
126
|
+
label: judge.ok ? rewardLabel(score) : 'deterministic_only',
|
|
125
127
|
deterministic,
|
|
126
128
|
judge,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
scoringMode: judge.ok ? 'deterministic_plus_llm_judge' : 'deterministic_only',
|
|
130
|
+
failureMode: judge.ok ? [] : [judge.available ? 'judge_error' : 'judge_unavailable'],
|
|
131
|
+
recommendation: judge.ok
|
|
132
|
+
? rewardRecommendation(score)
|
|
133
|
+
: 'Use deterministic results only; do not represent unavailable judge evidence as a neutral judgment.',
|
|
129
134
|
};
|
|
130
135
|
}
|
|
131
136
|
|
|
@@ -148,7 +153,8 @@ function buildPreferenceJudgment(a, b, options = {}) {
|
|
|
148
153
|
function buildJudgeReadinessReport(samples = [], options = {}) {
|
|
149
154
|
const rewards = samples.map((sample) => buildCompositeReward(sample, options));
|
|
150
155
|
const blocked = rewards.filter((reward) => reward.label === 'deterministic_block');
|
|
151
|
-
const neutralFallbacks = rewards.filter((reward) =>
|
|
156
|
+
const neutralFallbacks = rewards.filter((reward) =>
|
|
157
|
+
reward.failureMode.includes('judge_error') || reward.failureMode.includes('judge_unavailable'));
|
|
152
158
|
return {
|
|
153
159
|
generatedAt: new Date().toISOString(),
|
|
154
160
|
samples: samples.length,
|
|
@@ -186,7 +192,7 @@ function measureJudgeConsistency(samples = [], judge = null, options = {}) {
|
|
|
186
192
|
|
|
187
193
|
function evaluateCriterion(id, prediction, sample, { requiresJson }) {
|
|
188
194
|
if (id === 'schema_valid') {
|
|
189
|
-
return evaluateSchemaCriterion(prediction, requiresJson);
|
|
195
|
+
return evaluateSchemaCriterion(prediction, requiresJson, sample.outputSchema);
|
|
190
196
|
}
|
|
191
197
|
if (id === 'grounded_evidence') {
|
|
192
198
|
return hasGroundedEvidence(prediction);
|
|
@@ -203,14 +209,17 @@ function evaluateCriterion(id, prediction, sample, { requiresJson }) {
|
|
|
203
209
|
return true;
|
|
204
210
|
}
|
|
205
211
|
|
|
206
|
-
function evaluateSchemaCriterion(prediction, requiresJson) {
|
|
212
|
+
function evaluateSchemaCriterion(prediction, requiresJson, outputSchema) {
|
|
207
213
|
if (!requiresJson) return true;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
214
|
+
if (!outputSchema) {
|
|
215
|
+
try {
|
|
216
|
+
JSON.parse(prediction);
|
|
217
|
+
return true;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
213
221
|
}
|
|
222
|
+
return validateStructuredOutput(prediction, outputSchema).valid;
|
|
214
223
|
}
|
|
215
224
|
|
|
216
225
|
function hasGroundedEvidence(prediction) {
|
|
@@ -253,9 +262,10 @@ function buildCriterionReason(id, pass) {
|
|
|
253
262
|
function runJudgeSafely(sample, judge) {
|
|
254
263
|
if (typeof judge !== 'function') {
|
|
255
264
|
return {
|
|
256
|
-
ok:
|
|
257
|
-
|
|
258
|
-
|
|
265
|
+
ok: false,
|
|
266
|
+
available: false,
|
|
267
|
+
score: null,
|
|
268
|
+
rationale: 'No external judge configured; only deterministic checks are evidence.',
|
|
259
269
|
raw: null,
|
|
260
270
|
};
|
|
261
271
|
}
|
|
@@ -264,6 +274,7 @@ function runJudgeSafely(sample, judge) {
|
|
|
264
274
|
const score = clamp(Number(result.score ?? result), 0, 1);
|
|
265
275
|
return {
|
|
266
276
|
ok: true,
|
|
277
|
+
available: true,
|
|
267
278
|
score,
|
|
268
279
|
rationale: result.rationale || 'Judge returned a bounded score.',
|
|
269
280
|
raw: result,
|
|
@@ -271,8 +282,9 @@ function runJudgeSafely(sample, judge) {
|
|
|
271
282
|
} catch (err) {
|
|
272
283
|
return {
|
|
273
284
|
ok: false,
|
|
274
|
-
|
|
275
|
-
|
|
285
|
+
available: true,
|
|
286
|
+
score: null,
|
|
287
|
+
rationale: `Judge failed; deterministic checks remain authoritative. ${err.message}`,
|
|
276
288
|
raw: null,
|
|
277
289
|
};
|
|
278
290
|
}
|
|
@@ -170,6 +170,20 @@ function isPositiveSignal(signal) {
|
|
|
170
170
|
return signal === 'positive' || signal === 'up';
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
function isHumanReviewedLesson(lesson = {}) {
|
|
174
|
+
return (lesson.metadata?.reviewOrigin || lesson.reviewOrigin) === 'human';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function dedupeHumanReviewedLessons(lessons = []) {
|
|
178
|
+
const byFeedback = new Map();
|
|
179
|
+
for (const lesson of lessons) {
|
|
180
|
+
if (isHumanReviewedLesson(lesson) && lesson.feedbackId) {
|
|
181
|
+
byFeedback.set(lesson.feedbackId, lesson);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return [...byFeedback.values()];
|
|
185
|
+
}
|
|
186
|
+
|
|
173
187
|
function selectStatusbarLesson() {
|
|
174
188
|
const lessons = readJsonl(getLessonsPath())
|
|
175
189
|
.slice()
|
|
@@ -242,10 +256,14 @@ function searchLessons({ query = '', limit = 10, signal } = {}) {
|
|
|
242
256
|
*/
|
|
243
257
|
function getLessonStats() {
|
|
244
258
|
const lessons = readJsonl(getLessonsPath());
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
|
|
259
|
+
const humanReviewed = dedupeHumanReviewedLessons(lessons);
|
|
260
|
+
const positive = humanReviewed.filter((lesson) => isPositiveSignal(lesson.signal)).length;
|
|
261
|
+
const negative = humanReviewed.filter((lesson) => isNegativeSignal(lesson.signal)).length;
|
|
262
|
+
const avgConfidence = humanReviewed.length > 0
|
|
263
|
+
? Math.round(humanReviewed.reduce((sum, lesson) => sum + (lesson.confidence || 0), 0) / humanReviewed.length)
|
|
264
|
+
: 0;
|
|
265
|
+
return { total: positive + negative, positive, negative, avgConfidence,
|
|
266
|
+
rawTotal: lessons.length, excludedTotal: lessons.length - humanReviewed.length };
|
|
249
267
|
}
|
|
250
268
|
|
|
251
269
|
// ---------------------------------------------------------------------------
|
|
@@ -650,6 +668,7 @@ async function inferStructuredLessonLLM(conversationWindow, signal, context) {
|
|
|
650
668
|
module.exports = {
|
|
651
669
|
inferFromSurroundingMessages, createLesson, getRecentLesson,
|
|
652
670
|
searchLessons, getLessonStats, getStatusbarLessonData, getAllLessonsForContext,
|
|
671
|
+
isHumanReviewedLesson,
|
|
653
672
|
getLessonsPath, getRecentLessonPath,
|
|
654
673
|
selectStatusbarLesson, getLessonKind, stripLessonPrefix,
|
|
655
674
|
formatLessonTimestamp, buildStatusbarLessonLabel,
|
|
@@ -14,6 +14,63 @@
|
|
|
14
14
|
|
|
15
15
|
const RECENCY_DECAY_DAYS = 30;
|
|
16
16
|
const RERANK_CANDIDATE_POOL = 50; // bi-encoder retrieves this many; reranker picks topK
|
|
17
|
+
const MAX_RETRIEVAL_MEMORY_CHARS = 20000;
|
|
18
|
+
|
|
19
|
+
// Line cap for reading the memory log during retrieval.
|
|
20
|
+
//
|
|
21
|
+
// This was 200, which quietly made relevance irrelevant. Retrieval scores memories and keeps
|
|
22
|
+
// anything over 0.1, but it only ever SAW the newest 200 entries — so once 200 newer lessons
|
|
23
|
+
// existed, the single most relevant lesson in the corpus became unreachable no matter how well
|
|
24
|
+
// it matched. Measured on a synthetic corpus where the best-scoring lesson (0.183, threshold
|
|
25
|
+
// 0.1) is the oldest entry:
|
|
26
|
+
//
|
|
27
|
+
// corpus 150 -> found
|
|
28
|
+
// corpus 201 -> NOT found <- cliff, purely from recency
|
|
29
|
+
// corpus 2,000 -> NOT found
|
|
30
|
+
//
|
|
31
|
+
// A firewall that forgets its oldest lessons forgets the ones it learned the hard way.
|
|
32
|
+
//
|
|
33
|
+
// The cap exists for cost, so it is set from measurement rather than taste. Worst case (every
|
|
34
|
+
// entry scoring above threshold, so nothing filters out early):
|
|
35
|
+
//
|
|
36
|
+
// 200 entries 2.6 ms/call | 5,000 entries 2.6 ms/call | 20,000 entries 4.2 ms/call
|
|
37
|
+
//
|
|
38
|
+
// 5,000 therefore costs nothing measurable against the old 200 while covering realistic
|
|
39
|
+
// corpora with wide headroom. Override with THUMBGATE_RETRIEVAL_MAX_LINES if a machine ever
|
|
40
|
+
// grows past it.
|
|
41
|
+
const MAX_RETRIEVAL_MEMORY_LINES = Math.max(
|
|
42
|
+
1,
|
|
43
|
+
Number(process.env.THUMBGATE_RETRIEVAL_MAX_LINES) || 5000,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
function isRetrievableMemory(memory, options = {}) {
|
|
47
|
+
if (!memory || typeof memory !== 'object') return false;
|
|
48
|
+
const { looksLikeTransportBlob } = require('./feedback-sanitizer');
|
|
49
|
+
const title = String(memory.title || '');
|
|
50
|
+
const content = String(memory.content || '');
|
|
51
|
+
const combined = `${title}\n${content}`.trim();
|
|
52
|
+
const maxChars = Number.isFinite(options.maxMemoryChars)
|
|
53
|
+
? Math.max(1, options.maxMemoryChars)
|
|
54
|
+
: MAX_RETRIEVAL_MEMORY_CHARS;
|
|
55
|
+
if (!combined || combined.length > maxChars) return false;
|
|
56
|
+
return !looksLikeTransportBlob(title)
|
|
57
|
+
&& !looksLikeTransportBlob(content)
|
|
58
|
+
&& !looksLikeTransportBlob(combined);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function selectRetrievalMemories(memories = [], options = {}) {
|
|
62
|
+
let selected = memories.filter((memory) => isRetrievableMemory(memory, options));
|
|
63
|
+
if (options.requireScope && !options.scope) {
|
|
64
|
+
throw new Error('Scoped lesson retrieval requires scope');
|
|
65
|
+
}
|
|
66
|
+
if (options.scope) {
|
|
67
|
+
const { selectRecordsForScope } = require('./memory-scope-readiness');
|
|
68
|
+
selected = selectRecordsForScope(selected, options.scope, {
|
|
69
|
+
includeShared: options.includeShared !== false,
|
|
70
|
+
}).allowed;
|
|
71
|
+
}
|
|
72
|
+
return selected;
|
|
73
|
+
}
|
|
17
74
|
|
|
18
75
|
function retrieveRelevantLessons(toolName, actionContext, options = {}) {
|
|
19
76
|
const { maxResults = 5, feedbackDir } = options;
|
|
@@ -24,7 +81,10 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
|
|
|
24
81
|
? { MEMORY_LOG_PATH: pathMod.join(feedbackDir, 'memory-log.jsonl') }
|
|
25
82
|
: getFeedbackPaths();
|
|
26
83
|
|
|
27
|
-
const memories =
|
|
84
|
+
const memories = selectRetrievalMemories(
|
|
85
|
+
readJSONL(paths.MEMORY_LOG_PATH, { maxLines: MAX_RETRIEVAL_MEMORY_LINES }),
|
|
86
|
+
options,
|
|
87
|
+
);
|
|
28
88
|
if (memories.length === 0) return [];
|
|
29
89
|
|
|
30
90
|
const actionSig = buildActionSignature(toolName, actionContext);
|
|
@@ -92,13 +152,16 @@ function reciprocalRankFusion(rankedLists = [], options = {}) {
|
|
|
92
152
|
.sort((a, b) => b.score - a.score);
|
|
93
153
|
}
|
|
94
154
|
|
|
95
|
-
function loadMemories(feedbackDir) {
|
|
155
|
+
function loadMemories(feedbackDir, options = {}) {
|
|
96
156
|
const { getFeedbackPaths, readJSONL } = require('./feedback-loop');
|
|
97
157
|
const pathMod = require('path');
|
|
98
158
|
const paths = feedbackDir
|
|
99
159
|
? { MEMORY_LOG_PATH: pathMod.join(feedbackDir, 'memory-log.jsonl') }
|
|
100
160
|
: getFeedbackPaths();
|
|
101
|
-
return
|
|
161
|
+
return selectRetrievalMemories(
|
|
162
|
+
readJSONL(paths.MEMORY_LOG_PATH, { maxLines: MAX_RETRIEVAL_MEMORY_LINES }),
|
|
163
|
+
options,
|
|
164
|
+
);
|
|
102
165
|
}
|
|
103
166
|
|
|
104
167
|
function shapeLesson(m) {
|
|
@@ -140,7 +203,7 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
|
|
|
140
203
|
return retrieveRelevantLessons(toolName, actionContext, options);
|
|
141
204
|
}
|
|
142
205
|
|
|
143
|
-
const memories = loadMemories(feedbackDir);
|
|
206
|
+
const memories = loadMemories(feedbackDir, options);
|
|
144
207
|
if (memories.length === 0) return [];
|
|
145
208
|
|
|
146
209
|
const actionSig = buildActionSignature(toolName, actionContext);
|
|
@@ -482,4 +545,8 @@ module.exports = {
|
|
|
482
545
|
filterTopP,
|
|
483
546
|
resolveTopP,
|
|
484
547
|
dedupeSupersededLessons,
|
|
548
|
+
isRetrievableMemory,
|
|
549
|
+
selectRetrievalMemories,
|
|
550
|
+
MAX_RETRIEVAL_MEMORY_CHARS,
|
|
551
|
+
MAX_RETRIEVAL_MEMORY_LINES,
|
|
485
552
|
};
|
package/scripts/lesson-search.js
CHANGED
|
@@ -4,6 +4,7 @@ const path = require('node:path');
|
|
|
4
4
|
const { readJSONL, getFeedbackPaths } = require('./feedback-loop');
|
|
5
5
|
const { buildMemoryLifecycleView, scoreHybridMemoryMatch } = require('./agent-memory-lifecycle');
|
|
6
6
|
const { loadOptionalModule } = require('./private-core-boundary');
|
|
7
|
+
const { selectRetrievalMemories } = require('./lesson-retrieval');
|
|
7
8
|
|
|
8
9
|
const HIGH_RISK_TAGS = new Set([
|
|
9
10
|
'billing',
|
|
@@ -481,7 +482,8 @@ function searchLessons(query = '', options = {}) {
|
|
|
481
482
|
const sqliteResults = tryFts5Search(query, options);
|
|
482
483
|
if (sqliteResults) return sqliteResults;
|
|
483
484
|
|
|
484
|
-
const
|
|
485
|
+
const allMemories = readJSONL(MEMORY_LOG_PATH);
|
|
486
|
+
const memories = selectRetrievalMemories(allMemories, options);
|
|
485
487
|
const feedbackEntries = readJSONL(FEEDBACK_LOG_PATH);
|
|
486
488
|
const feedbackById = new Map(feedbackEntries.map((entry) => [entry.id, entry]));
|
|
487
489
|
const parsedLimit = Number(options.limit || 10);
|
|
@@ -534,9 +536,12 @@ function searchLessons(query = '', options = {}) {
|
|
|
534
536
|
filters: {
|
|
535
537
|
category: category || null,
|
|
536
538
|
tags: requiredTags,
|
|
539
|
+
scope: options.scope || null,
|
|
540
|
+
requireScope: options.requireScope === true,
|
|
537
541
|
},
|
|
538
542
|
feedbackDir: FEEDBACK_DIR,
|
|
539
543
|
totalLessons: memories.length,
|
|
544
|
+
excludedLessons: allMemories.length - memories.length,
|
|
540
545
|
returned: Math.min(limit, results.length),
|
|
541
546
|
results: results.slice(0, limit),
|
|
542
547
|
backend: 'jsonl-jaccard',
|
|
@@ -548,6 +553,10 @@ function searchLessons(query = '', options = {}) {
|
|
|
548
553
|
* or not opted in. Set LESSON_DB_SEARCH=1 to enable FTS5 as primary backend.
|
|
549
554
|
*/
|
|
550
555
|
function tryFts5Search(query, options) {
|
|
556
|
+
// The SQLite index does not currently carry the complete four-field scope
|
|
557
|
+
// contract. Fall back to JSONL whenever isolation is requested rather than
|
|
558
|
+
// silently searching across tenants or sessions.
|
|
559
|
+
if (options.scope || options.requireScope) return null;
|
|
551
560
|
if (!process.env.LESSON_DB_SEARCH && !options.useFts5) return null;
|
|
552
561
|
try {
|
|
553
562
|
const { initDB, searchLessons: fts5Search, getStats } = require('./lesson-db');
|
|
@@ -567,11 +576,24 @@ function tryFts5Search(query, options) {
|
|
|
567
576
|
.map((tag) => tag.trim())
|
|
568
577
|
.filter(Boolean);
|
|
569
578
|
|
|
570
|
-
const
|
|
571
|
-
limit,
|
|
579
|
+
const candidateRows = fts5Search(db, query || '', {
|
|
580
|
+
limit: Math.max(limit * 5, 50),
|
|
572
581
|
signal,
|
|
573
582
|
tags: requiredTags.length > 0 ? requiredTags : undefined,
|
|
574
583
|
});
|
|
584
|
+
const retrievableRows = selectRetrievalMemories(
|
|
585
|
+
candidateRows.map((row) => ({
|
|
586
|
+
...row,
|
|
587
|
+
title: row.context || '',
|
|
588
|
+
content: [
|
|
589
|
+
row.whatWentWrong,
|
|
590
|
+
row.whatToChange,
|
|
591
|
+
row.whatWorked,
|
|
592
|
+
].filter(Boolean).join('\n'),
|
|
593
|
+
})),
|
|
594
|
+
options,
|
|
595
|
+
);
|
|
596
|
+
const rows = retrievableRows.slice(0, limit);
|
|
575
597
|
|
|
576
598
|
return {
|
|
577
599
|
query: String(query || ''),
|
|
@@ -581,6 +603,7 @@ function tryFts5Search(query, options) {
|
|
|
581
603
|
tags: requiredTags,
|
|
582
604
|
},
|
|
583
605
|
totalLessons: stats.total,
|
|
606
|
+
excludedLessons: candidateRows.length - retrievableRows.length,
|
|
584
607
|
returned: rows.length,
|
|
585
608
|
results: rows.map((row) => ({
|
|
586
609
|
id: row.id,
|
package/scripts/mcp-config.js
CHANGED
|
@@ -193,17 +193,37 @@ function publishedCliAvailable(pkgVersion) {
|
|
|
193
193
|
return cliAvailabilityCache.get(pkgVersion);
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Project-scope entries land in COMMITTED, SHARED config (.mcp.json / .cursor/mcp.json —
|
|
198
|
+
* init's own banner says the file serves every agent on the repo). A machine-absolute path
|
|
199
|
+
* there is a bug by construction: run init on machine A (or a Cowork sandbox with a home
|
|
200
|
+
* like /Users/busy-clever-newton) and the committed config breaks for every other machine,
|
|
201
|
+
* teammate, and CI runner. Observed for real on 2026-07-29.
|
|
202
|
+
*
|
|
203
|
+
* So: absolute paths may only ever go to HOME-scope config (machine-local by definition).
|
|
204
|
+
* Project scope gets a repo-relative path when the project IS the ThumbGate checkout
|
|
205
|
+
* (dogfooding unpublished source still works — project MCP servers launch with cwd at the
|
|
206
|
+
* project root), and the portable npx launcher otherwise.
|
|
207
|
+
*/
|
|
208
|
+
function relativeLocalMcpEntry(pkgRoot, targetDir) {
|
|
209
|
+
const rel = path.relative(targetDir, resolveLocalServerPath(pkgRoot, 'project'));
|
|
210
|
+
// Committed config must be separator-portable too.
|
|
211
|
+
return { command: 'node', args: [rel.split(path.sep).join('/')] };
|
|
212
|
+
}
|
|
213
|
+
|
|
196
214
|
function resolveMcpEntry({ pkgRoot, pkgVersion, scope = 'project', targetDir = pkgRoot }) {
|
|
197
215
|
if (!isSourceCheckout(pkgRoot)) {
|
|
198
216
|
return codexAutoUpdateMcpEntry();
|
|
199
217
|
}
|
|
200
|
-
if (scope === 'home'
|
|
201
|
-
return codexAutoUpdateMcpEntry();
|
|
218
|
+
if (scope === 'home') {
|
|
219
|
+
if (publishedCliAvailable(pkgVersion)) return codexAutoUpdateMcpEntry();
|
|
220
|
+
return localMcpEntry(pkgRoot, scope);
|
|
202
221
|
}
|
|
203
|
-
|
|
204
|
-
|
|
222
|
+
// scope === 'project': this is going into shared, committed config.
|
|
223
|
+
if (isSameCheckoutFamily(pkgRoot, targetDir)) {
|
|
224
|
+
return relativeLocalMcpEntry(pkgRoot, targetDir);
|
|
205
225
|
}
|
|
206
|
-
return
|
|
226
|
+
return codexAutoUpdateMcpEntry();
|
|
207
227
|
}
|
|
208
228
|
|
|
209
229
|
module.exports = {
|
|
@@ -214,6 +234,7 @@ module.exports = {
|
|
|
214
234
|
localMcpEntry,
|
|
215
235
|
parseWorktreePaths,
|
|
216
236
|
portableMcpEntry,
|
|
237
|
+
relativeLocalMcpEntry,
|
|
217
238
|
resolveGitCommonDir,
|
|
218
239
|
resolveLocalServerPath,
|
|
219
240
|
resolveMcpEntry,
|
package/scripts/mcp-oauth.js
CHANGED
|
@@ -29,6 +29,7 @@ const crypto = require('crypto');
|
|
|
29
29
|
const AUTH_CODE_TTL_MS = 60 * 1000; // 1 minute
|
|
30
30
|
const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
31
31
|
const DEFAULT_SCOPE = 'mcp:read mcp:write';
|
|
32
|
+
const SUPPORTED_SCOPES = Object.freeze(['mcp:read', 'mcp:write']);
|
|
32
33
|
|
|
33
34
|
// Upper bounds on the in-memory store. The registration and authorization
|
|
34
35
|
// endpoints are reachable pre-auth, so without a cap a malicious caller could
|
|
@@ -160,6 +161,27 @@ function getClient(store, clientId) {
|
|
|
160
161
|
return store.clients.get(clientId) || null;
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
function normalizeScopes(scope = DEFAULT_SCOPE, allowedScopes = SUPPORTED_SCOPES) {
|
|
165
|
+
const requested = [...new Set(String(scope || DEFAULT_SCOPE).split(/\s+/).filter(Boolean))];
|
|
166
|
+
const supported = new Set(SUPPORTED_SCOPES);
|
|
167
|
+
const allowed = new Set(allowedScopes || SUPPORTED_SCOPES);
|
|
168
|
+
const invalid = requested.filter((candidate) => !supported.has(candidate));
|
|
169
|
+
const disallowed = requested.filter((candidate) => supported.has(candidate) && !allowed.has(candidate));
|
|
170
|
+
return {
|
|
171
|
+
valid: requested.length > 0 && invalid.length === 0 && disallowed.length === 0,
|
|
172
|
+
scopes: requested,
|
|
173
|
+
scope: requested.join(' '),
|
|
174
|
+
invalid,
|
|
175
|
+
disallowed,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function scopeAllows(session, requiredScope) {
|
|
180
|
+
if (!session || !requiredScope) return false;
|
|
181
|
+
const normalized = normalizeScopes(session.scope, SUPPORTED_SCOPES);
|
|
182
|
+
return normalized.valid && normalized.scopes.includes(requiredScope);
|
|
183
|
+
}
|
|
184
|
+
|
|
163
185
|
// ---------------------------------------------------------------------------
|
|
164
186
|
// Authorization code (PKCE S256)
|
|
165
187
|
// ---------------------------------------------------------------------------
|
|
@@ -169,20 +191,30 @@ function getClient(store, clientId) {
|
|
|
169
191
|
* token will act as (resolved by the authorize step once the user consents).
|
|
170
192
|
*/
|
|
171
193
|
function createAuthorizationCode(store, {
|
|
172
|
-
clientId, redirectUri, codeChallenge, codeChallengeMethod, scope, boundKey, state, resource,
|
|
194
|
+
clientId, redirectUri, codeChallenge, codeChallengeMethod, scope, allowedScopes, boundKey, state, resource,
|
|
173
195
|
} = {}) {
|
|
174
196
|
const client = getClient(store, clientId);
|
|
175
197
|
if (!client) return { error: 'invalid_client' };
|
|
176
198
|
if (!client.redirect_uris.includes(redirectUri)) return { error: 'invalid_request', error_description: 'redirect_uri mismatch' };
|
|
177
199
|
if (codeChallengeMethod !== 'S256') return { error: 'invalid_request', error_description: 'code_challenge_method must be S256' };
|
|
178
200
|
if (!codeChallenge || String(codeChallenge).length < 16) return { error: 'invalid_request', error_description: 'code_challenge required' };
|
|
201
|
+
const normalizedScopes = normalizeScopes(scope, allowedScopes || SUPPORTED_SCOPES);
|
|
202
|
+
if (!normalizedScopes.valid) {
|
|
203
|
+
return {
|
|
204
|
+
error: 'invalid_scope',
|
|
205
|
+
error_description: [
|
|
206
|
+
normalizedScopes.invalid.length > 0 ? `unsupported: ${normalizedScopes.invalid.join(', ')}` : '',
|
|
207
|
+
normalizedScopes.disallowed.length > 0 ? `not permitted: ${normalizedScopes.disallowed.join(', ')}` : '',
|
|
208
|
+
].filter(Boolean).join('; ') || 'scope is required',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
179
211
|
|
|
180
212
|
const code = randomToken(24);
|
|
181
213
|
capInsert(store.codes, code, {
|
|
182
214
|
clientId,
|
|
183
215
|
redirectUri,
|
|
184
216
|
codeChallenge,
|
|
185
|
-
scope: scope
|
|
217
|
+
scope: normalizedScopes.scope,
|
|
186
218
|
boundKey: boundKey || '',
|
|
187
219
|
resource: resource || '', // RFC 8707 resource indicator (the MCP server URL)
|
|
188
220
|
expiresAt: now() + AUTH_CODE_TTL_MS,
|
|
@@ -287,6 +319,9 @@ module.exports = {
|
|
|
287
319
|
AUTH_CODE_TTL_MS,
|
|
288
320
|
ACCESS_TOKEN_TTL_MS,
|
|
289
321
|
DEFAULT_SCOPE,
|
|
322
|
+
SUPPORTED_SCOPES,
|
|
323
|
+
normalizeScopes,
|
|
324
|
+
scopeAllows,
|
|
290
325
|
MAX_CLIENTS,
|
|
291
326
|
MAX_CODES,
|
|
292
327
|
MAX_TOKENS,
|