thumbgate 1.5.4 → 1.6.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/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/CHANGELOG.md +198 -0
- package/README.md +7 -6
- package/adapters/README.md +1 -1
- package/adapters/chatgpt/openapi.yaml +25 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +4 -4
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +61 -5
- package/openapi/openapi.yaml +25 -0
- package/package.json +16 -3
- package/public/codex-plugin.html +277 -0
- package/public/dashboard.html +193 -13
- package/public/index.html +150 -48
- package/public/learn.html +13 -2
- package/public/lessons.html +5 -2
- package/public/pro.html +8 -1
- package/scripts/auto-wire-hooks.js +71 -6
- package/scripts/billing.js +503 -8
- package/scripts/contextfs.js +1 -1
- package/scripts/dashboard.js +249 -0
- package/scripts/gates-engine.js +153 -2
- package/scripts/hook-runtime.js +42 -0
- package/scripts/llm-client.js +25 -10
- package/scripts/mailer/index.js +13 -0
- package/scripts/mailer/resend-mailer.js +350 -0
- package/scripts/mcp-config.js +13 -0
- package/scripts/published-cli.js +8 -0
- package/scripts/seo-gsd.js +118 -4
- package/scripts/statusline.sh +8 -0
- package/scripts/vector-store.js +21 -7
- package/src/api/server.js +126 -23
package/scripts/dashboard.js
CHANGED
|
@@ -26,6 +26,7 @@ const { computeDecisionMetrics } = require('./decision-journal');
|
|
|
26
26
|
const PROJECT_ROOT = path.join(__dirname, '..');
|
|
27
27
|
const DEFAULT_GATES_PATH = path.join(PROJECT_ROOT, 'config', 'gates', 'default.json');
|
|
28
28
|
const LANDING_PAGE_PATH = path.join(PROJECT_ROOT, 'public', 'index.html');
|
|
29
|
+
const DASHBOARD_REVIEW_STATE_FILE = 'dashboard-review-state.json';
|
|
29
30
|
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
31
32
|
// Data readers
|
|
@@ -72,6 +73,225 @@ function toLocalDayKey(value) {
|
|
|
72
73
|
return `${year}-${month}-${day}`;
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
function getDashboardReviewStatePath(feedbackDir) {
|
|
77
|
+
return path.join(feedbackDir, DASHBOARD_REVIEW_STATE_FILE);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function inferProjectRootFromFeedbackDir(feedbackDir) {
|
|
81
|
+
if (!feedbackDir) return null;
|
|
82
|
+
const resolved = path.resolve(feedbackDir);
|
|
83
|
+
return path.basename(resolved) === '.thumbgate' ? path.dirname(resolved) : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveGitDir(projectRoot) {
|
|
87
|
+
const gitPath = path.join(projectRoot, '.git');
|
|
88
|
+
if (!fs.existsSync(gitPath)) return null;
|
|
89
|
+
const stat = fs.statSync(gitPath);
|
|
90
|
+
if (stat.isDirectory()) return gitPath;
|
|
91
|
+
// Worktree / submodule: .git is a file like "gitdir: /path/to/real/gitdir".
|
|
92
|
+
// Parse with startsWith+slice — no regex, so no ReDoS surface (S5852).
|
|
93
|
+
if (stat.isFile()) {
|
|
94
|
+
const contents = fs.readFileSync(gitPath, 'utf8').trim();
|
|
95
|
+
const prefix = 'gitdir:';
|
|
96
|
+
if (!contents.startsWith(prefix)) return null;
|
|
97
|
+
const target = contents.slice(prefix.length).trim();
|
|
98
|
+
if (!target) return null;
|
|
99
|
+
const resolved = path.isAbsolute(target)
|
|
100
|
+
? target
|
|
101
|
+
: path.resolve(projectRoot, target);
|
|
102
|
+
return fs.existsSync(resolved) ? resolved : null;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readGitHead(projectRoot) {
|
|
108
|
+
if (!projectRoot) return null;
|
|
109
|
+
const gitDir = resolveGitDir(projectRoot);
|
|
110
|
+
if (!gitDir) return null;
|
|
111
|
+
try {
|
|
112
|
+
// Read .git/HEAD directly instead of spawning `git rev-parse HEAD`.
|
|
113
|
+
// Avoids S4036 (PATH-resolved binary) and is faster: no subprocess.
|
|
114
|
+
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
|
|
115
|
+
if (!head) return null;
|
|
116
|
+
// Detached HEAD: the file contains the raw SHA.
|
|
117
|
+
if (/^[0-9a-f]{40}$/i.test(head)) return head;
|
|
118
|
+
// Symbolic ref: "ref: refs/heads/<branch>" — resolve the ref file.
|
|
119
|
+
// Parse with startsWith+slice — no regex, so no ReDoS surface (S5852).
|
|
120
|
+
const refPrefix = 'ref:';
|
|
121
|
+
if (!head.startsWith(refPrefix)) return null;
|
|
122
|
+
const refName = head.slice(refPrefix.length).trim();
|
|
123
|
+
if (!refName) return null;
|
|
124
|
+
// For worktrees, the commondir points to the main .git. Refs may live
|
|
125
|
+
// there rather than in the per-worktree gitdir.
|
|
126
|
+
const commonDirFile = path.join(gitDir, 'commondir');
|
|
127
|
+
let commonDir = gitDir;
|
|
128
|
+
if (fs.existsSync(commonDirFile)) {
|
|
129
|
+
const rel = fs.readFileSync(commonDirFile, 'utf8').trim();
|
|
130
|
+
commonDir = path.isAbsolute(rel) ? rel : path.resolve(gitDir, rel);
|
|
131
|
+
}
|
|
132
|
+
for (const base of [gitDir, commonDir]) {
|
|
133
|
+
const refPath = path.join(base, refName);
|
|
134
|
+
if (fs.existsSync(refPath)) {
|
|
135
|
+
const sha = fs.readFileSync(refPath, 'utf8').trim();
|
|
136
|
+
if (/^[0-9a-f]{40}$/i.test(sha)) return sha;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Packed refs fallback.
|
|
140
|
+
const packed = path.join(commonDir, 'packed-refs');
|
|
141
|
+
if (!fs.existsSync(packed)) return null;
|
|
142
|
+
const lines = fs.readFileSync(packed, 'utf8').split(/\r?\n/);
|
|
143
|
+
for (const line of lines) {
|
|
144
|
+
if (line.startsWith('#') || line.startsWith('^')) continue;
|
|
145
|
+
const [sha, ref] = line.split(/\s+/);
|
|
146
|
+
if (ref === refName && /^[0-9a-f]{40}$/i.test(sha)) return sha;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function findLatestTimestamp(entries) {
|
|
155
|
+
return entries.reduce((latest, entry) => {
|
|
156
|
+
const timestamp = normalizeText(entry && entry.timestamp);
|
|
157
|
+
if (!timestamp) return latest;
|
|
158
|
+
if (!latest) return timestamp;
|
|
159
|
+
return new Date(timestamp).getTime() > new Date(latest).getTime() ? timestamp : latest;
|
|
160
|
+
}, null);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function buildReviewSnapshot(feedbackDir, options = {}) {
|
|
164
|
+
const feedbackEntries = Array.isArray(options.feedbackEntries)
|
|
165
|
+
? options.feedbackEntries
|
|
166
|
+
: readJSONL(path.join(feedbackDir, 'feedback-log.jsonl'));
|
|
167
|
+
const memoryEntries = Array.isArray(options.memoryEntries)
|
|
168
|
+
? options.memoryEntries
|
|
169
|
+
: readJSONL(path.join(feedbackDir, 'memory-log.jsonl'));
|
|
170
|
+
const auditEntries = Array.isArray(options.auditEntries)
|
|
171
|
+
? options.auditEntries
|
|
172
|
+
: readJSONL(path.join(feedbackDir, AUDIT_LOG_FILENAME));
|
|
173
|
+
const projectRoot = options.projectRoot === undefined
|
|
174
|
+
? inferProjectRootFromFeedbackDir(feedbackDir)
|
|
175
|
+
: options.projectRoot;
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
reviewedAt: options.reviewedAt || new Date().toISOString(),
|
|
179
|
+
feedbackCount: feedbackEntries.length,
|
|
180
|
+
positiveCount: feedbackEntries.filter((entry) => entry.signal === 'positive').length,
|
|
181
|
+
negativeCount: feedbackEntries.filter((entry) => entry.signal === 'negative').length,
|
|
182
|
+
lessonCount: memoryEntries.length,
|
|
183
|
+
blockedCount: auditEntries.filter((entry) => entry && entry.decision === 'deny').length,
|
|
184
|
+
warnedCount: auditEntries.filter((entry) => entry && entry.decision === 'warn').length,
|
|
185
|
+
latestFeedbackAt: findLatestTimestamp(feedbackEntries),
|
|
186
|
+
latestLessonAt: findLatestTimestamp(memoryEntries),
|
|
187
|
+
gitHead: readGitHead(projectRoot),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function readDashboardReviewState(feedbackDir) {
|
|
192
|
+
return readJsonFile(getDashboardReviewStatePath(feedbackDir));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function writeDashboardReviewState(feedbackDir, snapshot) {
|
|
196
|
+
fs.mkdirSync(feedbackDir, { recursive: true });
|
|
197
|
+
fs.writeFileSync(getDashboardReviewStatePath(feedbackDir), `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
198
|
+
return snapshot;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function selectLatestRecord(entries, mapper) {
|
|
202
|
+
let latest = null;
|
|
203
|
+
let latestTime = -Infinity;
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
const timestamp = normalizeText(entry && entry.timestamp);
|
|
206
|
+
if (!timestamp) continue;
|
|
207
|
+
const currentTime = new Date(timestamp).getTime();
|
|
208
|
+
if (Number.isNaN(currentTime) || currentTime < latestTime) continue;
|
|
209
|
+
latest = mapper(entry);
|
|
210
|
+
latestTime = currentTime;
|
|
211
|
+
}
|
|
212
|
+
return latest;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function summarizeReviewDelta(feedbackEntries, memoryEntries, auditEntries, baseline, currentSnapshot) {
|
|
216
|
+
const noBaselineSummary = {
|
|
217
|
+
hasBaseline: false,
|
|
218
|
+
reviewedAt: null,
|
|
219
|
+
previousHead: null,
|
|
220
|
+
currentHead: currentSnapshot.gitHead || null,
|
|
221
|
+
feedbackAdded: feedbackEntries.length,
|
|
222
|
+
negativeAdded: feedbackEntries.filter((entry) => entry.signal === 'negative').length,
|
|
223
|
+
lessonsAdded: memoryEntries.length,
|
|
224
|
+
blocksAdded: auditEntries.filter((entry) => entry && entry.decision === 'deny').length,
|
|
225
|
+
warnsAdded: auditEntries.filter((entry) => entry && entry.decision === 'warn').length,
|
|
226
|
+
headline: 'No review checkpoint yet. Mark the current dashboard as reviewed to start seeing only new changes.',
|
|
227
|
+
latestFeedback: selectLatestRecord(feedbackEntries, (entry) => ({
|
|
228
|
+
title: pickFirstText(entry.title, entry.context, entry.whatWentWrong, entry.whatWorked) || 'Feedback event',
|
|
229
|
+
timestamp: entry.timestamp,
|
|
230
|
+
signal: entry.signal || null,
|
|
231
|
+
})),
|
|
232
|
+
latestLesson: selectLatestRecord(memoryEntries, (entry) => ({
|
|
233
|
+
title: pickFirstText(entry.title, entry.content) || 'Lesson event',
|
|
234
|
+
timestamp: entry.timestamp,
|
|
235
|
+
category: entry.category || null,
|
|
236
|
+
})),
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
if (!baseline || !baseline.reviewedAt) return noBaselineSummary;
|
|
240
|
+
|
|
241
|
+
const reviewedAtMs = new Date(baseline.reviewedAt).getTime();
|
|
242
|
+
if (!Number.isFinite(reviewedAtMs)) return noBaselineSummary;
|
|
243
|
+
|
|
244
|
+
const isAfterBaseline = (entry) => {
|
|
245
|
+
const timestamp = entry && entry.timestamp ? new Date(entry.timestamp).getTime() : NaN;
|
|
246
|
+
return Number.isFinite(timestamp) && timestamp > reviewedAtMs;
|
|
247
|
+
};
|
|
248
|
+
const newFeedback = feedbackEntries.filter(isAfterBaseline);
|
|
249
|
+
const newLessons = memoryEntries.filter(isAfterBaseline);
|
|
250
|
+
const newAudit = auditEntries.filter(isAfterBaseline);
|
|
251
|
+
const reviewFeedback = newFeedback.some((entry) => entry.signal === 'negative')
|
|
252
|
+
? newFeedback.filter((entry) => entry.signal === 'negative')
|
|
253
|
+
: newFeedback;
|
|
254
|
+
const feedbackAdded = newFeedback.length;
|
|
255
|
+
const negativeAdded = newFeedback.filter((entry) => entry.signal === 'negative').length;
|
|
256
|
+
const lessonsAdded = newLessons.length;
|
|
257
|
+
const blocksAdded = newAudit.filter((entry) => entry && entry.decision === 'deny').length;
|
|
258
|
+
const warnsAdded = newAudit.filter((entry) => entry && entry.decision === 'warn').length;
|
|
259
|
+
let headline = 'No new review activity since your last checkpoint.';
|
|
260
|
+
|
|
261
|
+
if (feedbackAdded || lessonsAdded || blocksAdded || warnsAdded) {
|
|
262
|
+
const parts = [];
|
|
263
|
+
if (feedbackAdded) parts.push(`${feedbackAdded} feedback event${feedbackAdded === 1 ? '' : 's'}`);
|
|
264
|
+
if (negativeAdded) parts.push(`${negativeAdded} negative`);
|
|
265
|
+
if (lessonsAdded) parts.push(`${lessonsAdded} lesson${lessonsAdded === 1 ? '' : 's'}`);
|
|
266
|
+
if (blocksAdded) parts.push(`${blocksAdded} gate block${blocksAdded === 1 ? '' : 's'}`);
|
|
267
|
+
if (warnsAdded) parts.push(`${warnsAdded} warning${warnsAdded === 1 ? '' : 's'}`);
|
|
268
|
+
headline = `Since your last review: ${parts.join(' · ')}.`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
hasBaseline: true,
|
|
273
|
+
reviewedAt: baseline.reviewedAt,
|
|
274
|
+
previousHead: baseline.gitHead || null,
|
|
275
|
+
currentHead: currentSnapshot.gitHead || null,
|
|
276
|
+
feedbackAdded,
|
|
277
|
+
negativeAdded,
|
|
278
|
+
lessonsAdded,
|
|
279
|
+
blocksAdded,
|
|
280
|
+
warnsAdded,
|
|
281
|
+
headline,
|
|
282
|
+
latestFeedback: selectLatestRecord(reviewFeedback, (entry) => ({
|
|
283
|
+
title: pickFirstText(entry.title, entry.context, entry.whatWentWrong, entry.whatWorked) || 'Feedback event',
|
|
284
|
+
timestamp: entry.timestamp,
|
|
285
|
+
signal: entry.signal || null,
|
|
286
|
+
})),
|
|
287
|
+
latestLesson: selectLatestRecord(newLessons, (entry) => ({
|
|
288
|
+
title: pickFirstText(entry.title, entry.content) || 'Lesson event',
|
|
289
|
+
timestamp: entry.timestamp,
|
|
290
|
+
category: entry.category || null,
|
|
291
|
+
})),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
75
295
|
// ---------------------------------------------------------------------------
|
|
76
296
|
// Approval rate + trend
|
|
77
297
|
// ---------------------------------------------------------------------------
|
|
@@ -904,6 +1124,17 @@ function generateDashboard(feedbackDir, options = {}) {
|
|
|
904
1124
|
const diagnosticLogPath = path.join(feedbackDir, 'diagnostic-log.jsonl');
|
|
905
1125
|
const entries = collectAllFeedbackEntries(feedbackDir);
|
|
906
1126
|
const diagnosticEntries = readJSONL(diagnosticLogPath);
|
|
1127
|
+
const memoryEntries = readJSONL(path.join(feedbackDir, 'memory-log.jsonl'));
|
|
1128
|
+
const auditEntries = readJSONL(path.join(feedbackDir, AUDIT_LOG_FILENAME));
|
|
1129
|
+
const reviewBaseline = options.reviewBaseline === undefined
|
|
1130
|
+
? readDashboardReviewState(feedbackDir)
|
|
1131
|
+
: options.reviewBaseline;
|
|
1132
|
+
const reviewSnapshot = buildReviewSnapshot(feedbackDir, {
|
|
1133
|
+
feedbackEntries: entries,
|
|
1134
|
+
memoryEntries,
|
|
1135
|
+
auditEntries,
|
|
1136
|
+
reviewedAt: options.now || new Date().toISOString(),
|
|
1137
|
+
});
|
|
907
1138
|
const billingSummary = options.billingSummary || getBillingSummary(analyticsWindow);
|
|
908
1139
|
|
|
909
1140
|
const approval = computeApprovalStats(entries);
|
|
@@ -971,6 +1202,18 @@ function generateDashboard(feedbackDir, options = {}) {
|
|
|
971
1202
|
const feedbackTimeSeries = computeFeedbackTimeSeries(entries, 30);
|
|
972
1203
|
const lessonPipeline = computeLessonPipeline(feedbackDir, entries, gateStats);
|
|
973
1204
|
|
|
1205
|
+
// Estimated token savings — computed from gate blocked counts using the
|
|
1206
|
+
// conservative methodology in scripts/token-savings.js. This is the ONLY
|
|
1207
|
+
// place "$ saved" appears that's backed by real gate-block data; the landing
|
|
1208
|
+
// page hero uses a hardcoded sample number disclosed as "Sample".
|
|
1209
|
+
let tokenSavings = null;
|
|
1210
|
+
try {
|
|
1211
|
+
const { computeTokenSavings } = require('./token-savings');
|
|
1212
|
+
tokenSavings = computeTokenSavings({
|
|
1213
|
+
blockedCalls: Number(gateStats.blocked) || 0,
|
|
1214
|
+
});
|
|
1215
|
+
} catch { /* module missing — skip */ }
|
|
1216
|
+
|
|
974
1217
|
// Merge lesson counts into feedbackTimeSeries days
|
|
975
1218
|
for (const day of feedbackTimeSeries.days) {
|
|
976
1219
|
day.lessons = lessonPipeline.lessonsByDay.get(day.dayKey) || 0;
|
|
@@ -988,6 +1231,7 @@ function generateDashboard(feedbackDir, options = {}) {
|
|
|
988
1231
|
gateStats,
|
|
989
1232
|
team,
|
|
990
1233
|
});
|
|
1234
|
+
const reviewDelta = summarizeReviewDelta(entries, memoryEntries, auditEntries, reviewBaseline, reviewSnapshot);
|
|
991
1235
|
|
|
992
1236
|
return {
|
|
993
1237
|
operational: {
|
|
@@ -1017,7 +1261,9 @@ function generateDashboard(feedbackDir, options = {}) {
|
|
|
1017
1261
|
templateLibrary,
|
|
1018
1262
|
liveMetrics,
|
|
1019
1263
|
predictive,
|
|
1264
|
+
reviewDelta,
|
|
1020
1265
|
feedbackTimeSeries,
|
|
1266
|
+
tokenSavings,
|
|
1021
1267
|
lessonPipeline: {
|
|
1022
1268
|
stages: lessonPipeline.stages,
|
|
1023
1269
|
rates: lessonPipeline.rates,
|
|
@@ -1287,6 +1533,9 @@ function printDashboard(data) {
|
|
|
1287
1533
|
|
|
1288
1534
|
module.exports = {
|
|
1289
1535
|
generateDashboard,
|
|
1536
|
+
buildReviewSnapshot,
|
|
1537
|
+
readDashboardReviewState,
|
|
1538
|
+
writeDashboardReviewState,
|
|
1290
1539
|
printDashboard,
|
|
1291
1540
|
computeApprovalStats,
|
|
1292
1541
|
computeDecisionMetrics,
|
package/scripts/gates-engine.js
CHANGED
|
@@ -1518,6 +1518,146 @@ function buildBehavioralContext() {
|
|
|
1518
1518
|
}
|
|
1519
1519
|
}
|
|
1520
1520
|
|
|
1521
|
+
/**
|
|
1522
|
+
* Build "recent mistakes" context by reading the tail of memory-log.jsonl.
|
|
1523
|
+
* Surfaces the 3 most recent negative-signal memories (captured via
|
|
1524
|
+
* capture_feedback) as a reminder on EVERY tool call — even when semantic
|
|
1525
|
+
* retrieval returns nothing and there are no recurring patterns yet.
|
|
1526
|
+
*
|
|
1527
|
+
* This plugs the cold-start gap: a mistake captured seconds ago should
|
|
1528
|
+
* surface on the very next tool call, not wait for the recurring-pattern
|
|
1529
|
+
* threshold (≥2 occurrences) that buildBehavioralContext requires.
|
|
1530
|
+
*
|
|
1531
|
+
* @param {Object} [options]
|
|
1532
|
+
* @param {number} [options.maxAgeMs=86400000] - Only include memories from the last 24h by default
|
|
1533
|
+
* @param {number} [options.limit=3]
|
|
1534
|
+
* @returns {string|null}
|
|
1535
|
+
*/
|
|
1536
|
+
function buildRecentCorrectiveActionsContext(options = {}) {
|
|
1537
|
+
const maxAgeMs = typeof options.maxAgeMs === 'number' ? options.maxAgeMs : 24 * 60 * 60 * 1000;
|
|
1538
|
+
const limit = typeof options.limit === 'number' ? options.limit : 3;
|
|
1539
|
+
|
|
1540
|
+
let resolveFeedbackDir;
|
|
1541
|
+
try {
|
|
1542
|
+
({ resolveFeedbackDir } = require('./feedback-paths'));
|
|
1543
|
+
} catch {
|
|
1544
|
+
return null;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
let feedbackDir;
|
|
1548
|
+
try {
|
|
1549
|
+
feedbackDir = resolveFeedbackDir({});
|
|
1550
|
+
} catch {
|
|
1551
|
+
return null;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
const memoryLogPath = path.join(feedbackDir, 'memory-log.jsonl');
|
|
1555
|
+
if (!fs.existsSync(memoryLogPath)) return null;
|
|
1556
|
+
|
|
1557
|
+
let raw;
|
|
1558
|
+
try {
|
|
1559
|
+
raw = fs.readFileSync(memoryLogPath, 'utf8');
|
|
1560
|
+
} catch {
|
|
1561
|
+
return null;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
1565
|
+
if (lines.length === 0) return null;
|
|
1566
|
+
|
|
1567
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
1568
|
+
const recent = [];
|
|
1569
|
+
|
|
1570
|
+
// Walk from the tail backwards so we get the newest entries first
|
|
1571
|
+
for (let i = lines.length - 1; i >= 0 && recent.length < limit; i--) {
|
|
1572
|
+
try {
|
|
1573
|
+
const entry = JSON.parse(lines[i]);
|
|
1574
|
+
if (entry.category !== 'error' && entry.category !== 'learning') continue;
|
|
1575
|
+
const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
|
1576
|
+
if (!Number.isFinite(ts) || ts < cutoff) continue;
|
|
1577
|
+
recent.push(entry);
|
|
1578
|
+
} catch {
|
|
1579
|
+
// skip malformed line
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
if (recent.length === 0) return null;
|
|
1584
|
+
|
|
1585
|
+
const formatted = recent.map((m) => {
|
|
1586
|
+
const title = String(m.title || '').replace(/^MISTAKE:\s*/, '').slice(0, 140);
|
|
1587
|
+
const content = String(m.content || '');
|
|
1588
|
+
const avoidMatch = content.match(/How to avoid:\s*([^\n]+)/i);
|
|
1589
|
+
const advice = avoidMatch ? avoidMatch[1].trim().slice(0, 220) : null;
|
|
1590
|
+
return advice ? ` • ${title}\n → ${advice}` : ` • ${title}`;
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
return `[ThumbGate] Recent mistakes (last 24h) — do NOT repeat:\n${formatted.join('\n')}`;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* Build per-action lesson context: retrieve semantically-relevant lessons for this
|
|
1598
|
+
* specific tool call and inject the top negative ones into hook output so the agent
|
|
1599
|
+
* sees its past mistakes BEFORE executing the action (not after).
|
|
1600
|
+
*
|
|
1601
|
+
* This is the enforcement mechanism that turns ThumbGate from a passive log into an
|
|
1602
|
+
* active governor. Without this, lessons stay in the DB and never get surfaced at
|
|
1603
|
+
* decision time — so the agent repeats mistakes.
|
|
1604
|
+
*/
|
|
1605
|
+
function buildRelevantLessonContext(toolName, toolInput) {
|
|
1606
|
+
if (!toolName) return null;
|
|
1607
|
+
|
|
1608
|
+
let retrieveRelevantLessons;
|
|
1609
|
+
try {
|
|
1610
|
+
({ retrieveRelevantLessons } = require('./lesson-retrieval'));
|
|
1611
|
+
} catch {
|
|
1612
|
+
return null;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// Extract a searchable action context from the tool input
|
|
1616
|
+
const actionContext = extractActionContext(toolName, toolInput);
|
|
1617
|
+
if (!actionContext) return null;
|
|
1618
|
+
|
|
1619
|
+
try {
|
|
1620
|
+
const lessons = retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
|
|
1621
|
+
// retrieveRelevantLessons already filters at relevanceScore > 0.1 internally;
|
|
1622
|
+
// any negative lesson that survives retrieval is relevant enough to surface.
|
|
1623
|
+
const negative = lessons.filter((l) => l.signal === 'negative');
|
|
1624
|
+
if (negative.length === 0) return null;
|
|
1625
|
+
|
|
1626
|
+
const formatted = negative.map((l) => {
|
|
1627
|
+
const title = (l.title || '').replace(/^MISTAKE:\s*/, '').slice(0, 140);
|
|
1628
|
+
const advice = extractAvoidanceAdvice(l.content);
|
|
1629
|
+
return advice ? ` • ${title}\n → ${advice}` : ` • ${title}`;
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
return `[ThumbGate] Past mistakes relevant to this action — read before proceeding:\n${formatted.join('\n')}`;
|
|
1633
|
+
} catch {
|
|
1634
|
+
return null;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function extractActionContext(toolName, toolInput) {
|
|
1639
|
+
if (!toolInput) return toolName;
|
|
1640
|
+
const parts = [toolName];
|
|
1641
|
+
if (toolInput.command) parts.push(String(toolInput.command).slice(0, 400));
|
|
1642
|
+
if (toolInput.file_path) parts.push(String(toolInput.file_path));
|
|
1643
|
+
if (toolInput.description) parts.push(String(toolInput.description).slice(0, 200));
|
|
1644
|
+
if (toolInput.prompt) parts.push(String(toolInput.prompt).slice(0, 400));
|
|
1645
|
+
if (toolInput.pattern) parts.push(String(toolInput.pattern).slice(0, 200));
|
|
1646
|
+
return parts.filter(Boolean).join(' ');
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function extractAvoidanceAdvice(content) {
|
|
1650
|
+
if (!content) return null;
|
|
1651
|
+
// Extract the "How to avoid:" section if present
|
|
1652
|
+
const match = content.match(/How to avoid:\s*([^\n]+)/i);
|
|
1653
|
+
if (match) return match[1].trim().slice(0, 220);
|
|
1654
|
+
return null;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
function mergeContextStrings(...ctxs) {
|
|
1658
|
+
return ctxs.filter((c) => typeof c === 'string' && c.length > 0).join('\n\n') || null;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1521
1661
|
async function runAsync(input) {
|
|
1522
1662
|
const secretGuard = evaluateSecretGuard(input);
|
|
1523
1663
|
if (secretGuard) {
|
|
@@ -1545,7 +1685,10 @@ async function runAsync(input) {
|
|
|
1545
1685
|
}
|
|
1546
1686
|
|
|
1547
1687
|
const behavioralContext = buildBehavioralContext();
|
|
1548
|
-
|
|
1688
|
+
const lessonContext = buildRelevantLessonContext(toolName, toolInput);
|
|
1689
|
+
const recentContext = buildRecentCorrectiveActionsContext();
|
|
1690
|
+
const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
|
|
1691
|
+
return formatOutput(result, combinedContext);
|
|
1549
1692
|
}
|
|
1550
1693
|
|
|
1551
1694
|
function run(input) {
|
|
@@ -1575,7 +1718,10 @@ function run(input) {
|
|
|
1575
1718
|
}
|
|
1576
1719
|
|
|
1577
1720
|
const behavioralContext = buildBehavioralContext();
|
|
1578
|
-
|
|
1721
|
+
const lessonContext = buildRelevantLessonContext(toolName, toolInput);
|
|
1722
|
+
const recentContext = buildRecentCorrectiveActionsContext();
|
|
1723
|
+
const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
|
|
1724
|
+
return formatOutput(result, combinedContext);
|
|
1579
1725
|
}
|
|
1580
1726
|
|
|
1581
1727
|
// ---------------------------------------------------------------------------
|
|
@@ -1796,6 +1942,11 @@ module.exports = {
|
|
|
1796
1942
|
PROTECTED_APPROVAL_TTL_MS,
|
|
1797
1943
|
DEFAULT_PROTECTED_FILE_GLOBS,
|
|
1798
1944
|
buildBehavioralContext,
|
|
1945
|
+
buildRecentCorrectiveActionsContext,
|
|
1946
|
+
buildRelevantLessonContext,
|
|
1947
|
+
extractActionContext,
|
|
1948
|
+
extractAvoidanceAdvice,
|
|
1949
|
+
mergeContextStrings,
|
|
1799
1950
|
isHighRiskAction,
|
|
1800
1951
|
};
|
|
1801
1952
|
|
package/scripts/hook-runtime.js
CHANGED
|
@@ -44,10 +44,25 @@ function resolveCliBaseCommand() {
|
|
|
44
44
|
return publishedCliShellCommand(version);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function resolveCodexCliBaseCommand() {
|
|
48
|
+
const version = packageVersion();
|
|
49
|
+
if (publishedHookCommandsAvailable(version)) {
|
|
50
|
+
return publishedCliShellCommand('latest', [], { preferInstalled: false });
|
|
51
|
+
}
|
|
52
|
+
if (isSourceCheckout(PKG_ROOT)) {
|
|
53
|
+
return `node ${shellQuote(path.join(PKG_ROOT, 'bin', 'cli.js'))}`;
|
|
54
|
+
}
|
|
55
|
+
return publishedCliShellCommand('latest', [], { preferInstalled: false });
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
function buildPortableHookCommand(subcommand) {
|
|
48
59
|
return `${resolveCliBaseCommand()} ${subcommand}`;
|
|
49
60
|
}
|
|
50
61
|
|
|
62
|
+
function buildCodexPortableHookCommand(subcommand) {
|
|
63
|
+
return `${resolveCodexCliBaseCommand()} ${subcommand}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
function preToolHookCommand() {
|
|
52
67
|
return buildPortableHookCommand('gate-check');
|
|
53
68
|
}
|
|
@@ -68,12 +83,39 @@ function statuslineCommand() {
|
|
|
68
83
|
return buildPortableHookCommand('statusline-render');
|
|
69
84
|
}
|
|
70
85
|
|
|
86
|
+
function codexPreToolHookCommand() {
|
|
87
|
+
return buildCodexPortableHookCommand('gate-check');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function codexUserPromptHookCommand() {
|
|
91
|
+
return buildCodexPortableHookCommand('hook-auto-capture');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function codexSessionStartHookCommand() {
|
|
95
|
+
return buildCodexPortableHookCommand('session-start');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function codexCacheUpdateHookCommand() {
|
|
99
|
+
return buildCodexPortableHookCommand('cache-update');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function codexStatuslineCommand() {
|
|
103
|
+
return buildCodexPortableHookCommand('statusline-render');
|
|
104
|
+
}
|
|
105
|
+
|
|
71
106
|
module.exports = {
|
|
72
107
|
buildPortableHookCommand,
|
|
108
|
+
buildCodexPortableHookCommand,
|
|
73
109
|
cacheUpdateHookCommand,
|
|
110
|
+
codexCacheUpdateHookCommand,
|
|
111
|
+
codexPreToolHookCommand,
|
|
112
|
+
codexSessionStartHookCommand,
|
|
113
|
+
codexStatuslineCommand,
|
|
114
|
+
codexUserPromptHookCommand,
|
|
74
115
|
packageVersion,
|
|
75
116
|
publishedHookCommandsAvailable,
|
|
76
117
|
preToolHookCommand,
|
|
118
|
+
resolveCodexCliBaseCommand,
|
|
77
119
|
resolveCliBaseCommand,
|
|
78
120
|
sessionStartHookCommand,
|
|
79
121
|
statuslineCommand,
|
package/scripts/llm-client.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { runStep } = require('./durability/step');
|
|
5
|
+
|
|
4
6
|
const MODELS = {
|
|
5
7
|
FAST: 'claude-haiku-4-5-20251001',
|
|
6
8
|
SMART: 'claude-sonnet-4-6',
|
|
@@ -33,25 +35,38 @@ function stripCodeFences(text) {
|
|
|
33
35
|
return fenced ? fenced[1].trim() : text.trim();
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
// Anthropic SDK throws errors with a `.status` field for HTTP failures.
|
|
39
|
+
// Our defaultClassify already reads `.status`, so 429/5xx retry and 4xx
|
|
40
|
+
// (bad request / unauthorized / not-found) bail immediately — which is
|
|
41
|
+
// what we want: there is no point retrying a malformed prompt or a
|
|
42
|
+
// revoked API key.
|
|
36
43
|
async function callClaude({ systemPrompt, userPrompt, model, maxTokens } = {}) {
|
|
37
44
|
const client = getClient();
|
|
38
45
|
if (!client) return null;
|
|
39
46
|
|
|
40
47
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
const text = await runStep('llm.callClaude', {
|
|
49
|
+
retries: 2,
|
|
50
|
+
logger: (msg) => console.warn(msg),
|
|
51
|
+
}, async () => {
|
|
52
|
+
const response = await client.messages.create({
|
|
53
|
+
model: model || DEFAULT_MODEL,
|
|
54
|
+
max_tokens: maxTokens || DEFAULT_MAX_TOKENS,
|
|
55
|
+
system: systemPrompt || undefined,
|
|
56
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
57
|
+
});
|
|
47
58
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
return response.content
|
|
60
|
+
.filter((b) => b.type === 'text')
|
|
61
|
+
.map((b) => b.text)
|
|
62
|
+
.join('');
|
|
63
|
+
});
|
|
52
64
|
|
|
53
65
|
return stripCodeFences(text);
|
|
54
66
|
} catch {
|
|
67
|
+
// Preserve the original callClaude contract — callers expect `null` on
|
|
68
|
+
// failure, not an exception. runStep already logged retry attempts,
|
|
69
|
+
// so the permanent failure is visible in logs.
|
|
55
70
|
return null;
|
|
56
71
|
}
|
|
57
72
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* scripts/mailer/index.js — public entry point for the mailer module.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { sendEmail, sendTrialWelcomeEmail, renderTrialWelcomeBodies } = require('./resend-mailer');
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
sendEmail,
|
|
11
|
+
sendTrialWelcomeEmail,
|
|
12
|
+
renderTrialWelcomeBodies,
|
|
13
|
+
};
|