thumbgate 1.3.0 → 1.4.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 +32 -13
- package/.claude-plugin/plugin.json +15 -2
- package/.well-known/llms.txt +60 -0
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +109 -20
- package/adapters/README.md +1 -1
- package/adapters/chatgpt/openapi.yaml +168 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +2 -2
- package/adapters/mcp/server-stdio.js +84 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +200 -13
- package/bin/postinstall.js +8 -2
- package/config/budget.json +18 -0
- package/config/gates/code-edit.json +61 -0
- package/config/gates/db-write.json +61 -0
- package/config/gates/default.json +154 -3
- package/config/gates/deploy.json +61 -0
- package/config/github-about.json +2 -1
- package/config/merge-quality-checks.json +23 -0
- package/openapi/openapi.yaml +168 -0
- package/package.json +42 -10
- package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-codex-bridge/.mcp.json +1 -1
- package/plugins/claude-codex-bridge/scripts/codex-bridge.js +1 -3
- package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
- package/plugins/codex-profile/.mcp.json +1 -1
- package/plugins/codex-profile/INSTALL.md +27 -4
- package/plugins/codex-profile/README.md +33 -9
- package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
- package/plugins/opencode-profile/INSTALL.md +1 -1
- package/public/blog.html +73 -0
- package/public/compare/mem0.html +189 -0
- package/public/compare/speclock.html +180 -0
- package/public/compare.html +10 -2
- package/public/guide.html +2 -2
- package/public/guides/claude-code-prevent-repeated-mistakes.html +161 -0
- package/public/guides/codex-cli-guardrails.html +158 -0
- package/public/guides/cursor-prevent-repeated-mistakes.html +161 -0
- package/public/guides/pre-action-gates.html +162 -0
- package/public/guides/stop-repeated-ai-agent-mistakes.html +159 -0
- package/public/index.html +136 -50
- package/public/lessons.html +33 -24
- package/public/llm-context.md +140 -0
- package/public/pro.html +24 -22
- package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
- package/scripts/access-anomaly-detector.js +1 -1
- package/scripts/adk-consolidator.js +1 -5
- package/scripts/agent-security-hardening.js +4 -6
- package/scripts/agentic-data-pipeline.js +1 -3
- package/scripts/async-job-runner.js +1 -5
- package/scripts/audit-trail.js +1 -5
- package/scripts/background-agent-governance.js +2 -10
- package/scripts/billing.js +2 -16
- package/scripts/budget-enforcer.js +173 -0
- package/scripts/build-codex-plugin.js +152 -0
- package/scripts/check-congruence.js +132 -14
- package/scripts/commercial-offer.js +5 -7
- package/scripts/content-engine/linkedin-content-generator.js +154 -0
- package/scripts/content-engine/output/linkedin-memento-validation.md +17 -0
- package/scripts/content-engine/output/linkedin-posts-2026-04-09.md +175 -0
- package/scripts/content-engine/reddit-thread-finder.js +154 -0
- package/scripts/context-engine.js +21 -6
- package/scripts/contextfs.js +1 -21
- package/scripts/dashboard.js +20 -0
- package/scripts/decision-journal.js +341 -0
- package/scripts/delegation-runtime.js +1 -5
- package/scripts/distribution-surfaces.js +26 -0
- package/scripts/document-intake.js +927 -0
- package/scripts/ephemeral-agent-store.js +1 -8
- package/scripts/evolution-state.js +1 -5
- package/scripts/experiment-tracker.js +1 -5
- package/scripts/export-databricks-bundle.js +1 -5
- package/scripts/export-hf-dataset.js +1 -5
- package/scripts/export-training.js +1 -5
- package/scripts/feedback-attribution.js +1 -16
- package/scripts/feedback-history-distiller.js +1 -16
- package/scripts/feedback-loop.js +1 -5
- package/scripts/feedback-root-consolidator.js +2 -21
- package/scripts/feedback-session.js +49 -0
- package/scripts/feedback-to-rules.js +188 -28
- package/scripts/filesystem-search.js +1 -9
- package/scripts/fs-utils.js +104 -0
- package/scripts/gates-engine.js +149 -4
- package/scripts/github-about.js +32 -8
- package/scripts/gtm-revenue-loop.js +1 -5
- package/scripts/harness-selector.js +148 -0
- package/scripts/hosted-job-launcher.js +1 -5
- package/scripts/hybrid-feedback-context.js +7 -33
- package/scripts/intervention-policy.js +58 -1
- package/scripts/lesson-db.js +3 -18
- package/scripts/lesson-inference.js +194 -16
- package/scripts/lesson-retrieval.js +60 -24
- package/scripts/llm-client.js +59 -0
- package/scripts/managed-lesson-agent.js +183 -0
- package/scripts/marketing-experiment.js +8 -22
- package/scripts/meta-agent-loop.js +624 -0
- package/scripts/metered-billing.js +1 -1
- package/scripts/money-watcher.js +1 -4
- package/scripts/obsidian-export.js +1 -5
- package/scripts/operational-integrity.js +15 -3
- package/scripts/org-dashboard.js +6 -1
- package/scripts/per-step-scoring.js +2 -4
- package/scripts/pr-manager.js +201 -19
- package/scripts/pro-features.js +3 -2
- package/scripts/prompt-dlp.js +3 -3
- package/scripts/prove-adapters.js +1 -5
- package/scripts/prove-attribution.js +1 -5
- package/scripts/prove-automation.js +1 -3
- package/scripts/prove-cloudflare-sandbox.js +1 -3
- package/scripts/prove-data-pipeline.js +1 -3
- package/scripts/prove-intelligence.js +1 -3
- package/scripts/prove-lancedb.js +1 -5
- package/scripts/prove-local-intelligence.js +1 -3
- package/scripts/prove-packaged-runtime.js +75 -9
- package/scripts/prove-predictive-insights.js +1 -3
- package/scripts/prove-training-export.js +1 -3
- package/scripts/prove-workflow-contract.js +1 -5
- package/scripts/rate-limiter.js +3 -1
- package/scripts/reddit-dm-outreach.js +14 -4
- package/scripts/schedule-manager.js +3 -5
- package/scripts/security-scanner.js +448 -0
- package/scripts/self-distill-agent.js +579 -0
- package/scripts/semantic-dedup.js +115 -0
- package/scripts/skill-exporter.js +1 -3
- package/scripts/skill-generator.js +1 -5
- package/scripts/social-analytics/engagement-audit.js +1 -18
- package/scripts/social-analytics/pollers/linkedin.js +26 -16
- package/scripts/social-analytics/publishers/linkedin.js +1 -1
- package/scripts/social-analytics/publishers/zernio.js +51 -0
- package/scripts/social-pipeline.js +1 -3
- package/scripts/social-post-hourly.js +47 -4
- package/scripts/statusline-links.js +6 -5
- package/scripts/statusline.sh +29 -153
- package/scripts/sync-branch-protection.js +340 -0
- package/scripts/tessl-export.js +1 -3
- package/scripts/thumbgate-search.js +32 -1
- package/scripts/tool-kpi-tracker.js +1 -1
- package/scripts/tool-registry.js +106 -2
- package/scripts/vector-store.js +1 -5
- package/scripts/weekly-auto-post.js +1 -1
- package/scripts/workflow-sentinel.js +91 -0
- package/skills/thumbgate/SKILL.md +1 -1
- package/src/api/server.js +273 -4
- package/scripts/social-analytics/db/social-analytics.db-shm +0 -0
- /package/scripts/social-analytics/db/{social-analytics.db-wal → analytics.sqlite} +0 -0
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const path = require('path');
|
|
19
19
|
const { resolveFeedbackDir } = require('./feedback-paths');
|
|
20
|
+
const { readJsonl } = require('./fs-utils');
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
23
|
// Paths
|
|
@@ -62,39 +63,12 @@ const POS = new Set([
|
|
|
62
63
|
'success', 'pass', 'passed', 'great', 'excellent', 'perfect', 'works',
|
|
63
64
|
]);
|
|
64
65
|
|
|
66
|
+
const HYBRID_JSONL_READ_LIMIT = 400;
|
|
67
|
+
|
|
65
68
|
// ---------------------------------------------------------------------------
|
|
66
69
|
// Low-level helpers
|
|
67
70
|
// ---------------------------------------------------------------------------
|
|
68
71
|
|
|
69
|
-
/**
|
|
70
|
-
* Read last maxLines of a JSONL file in reverse, then re-reverse so oldest-first.
|
|
71
|
-
*/
|
|
72
|
-
function readJsonl(filePath, maxLines) {
|
|
73
|
-
const limit = maxLines !== undefined ? maxLines : 400;
|
|
74
|
-
if (!fs.existsSync(filePath)) return [];
|
|
75
|
-
let raw;
|
|
76
|
-
try {
|
|
77
|
-
raw = fs.readFileSync(filePath, 'utf8').trimEnd();
|
|
78
|
-
} catch (_) {
|
|
79
|
-
return [];
|
|
80
|
-
}
|
|
81
|
-
if (!raw) return [];
|
|
82
|
-
const lines = raw.split('\n');
|
|
83
|
-
const slice = lines.slice(-limit);
|
|
84
|
-
const parsed = [];
|
|
85
|
-
for (let i = slice.length - 1; i >= 0; i--) {
|
|
86
|
-
const line = slice[i].trim();
|
|
87
|
-
if (!line) continue;
|
|
88
|
-
try {
|
|
89
|
-
parsed.push(JSON.parse(line));
|
|
90
|
-
} catch (_) {
|
|
91
|
-
// skip malformed
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
parsed.reverse(); // back to chronological order
|
|
95
|
-
return parsed;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
72
|
/**
|
|
99
73
|
* Normalize text: strip /Users/ paths, port numbers, lowercase.
|
|
100
74
|
*/
|
|
@@ -208,10 +182,10 @@ function buildHybridState(opts) {
|
|
|
208
182
|
const pendingSyncPath = o.pendingSyncPath || process.env.THUMBGATE_PENDING_SYNC || paths.pendingSync;
|
|
209
183
|
const attributedFeedbackPath = o.attributedFeedbackPath || process.env.THUMBGATE_ATTRIBUTED_FEEDBACK || paths.attributedFeedback;
|
|
210
184
|
|
|
211
|
-
const feedbackEntries = readJsonl(feedbackLogPath);
|
|
212
|
-
const inboxEntries = readJsonl(inboxPath);
|
|
213
|
-
const pendingSyncEntries = readJsonl(pendingSyncPath);
|
|
214
|
-
const attributedEntries = readJsonl(attributedFeedbackPath);
|
|
185
|
+
const feedbackEntries = readJsonl(feedbackLogPath, HYBRID_JSONL_READ_LIMIT);
|
|
186
|
+
const inboxEntries = readJsonl(inboxPath, HYBRID_JSONL_READ_LIMIT);
|
|
187
|
+
const pendingSyncEntries = readJsonl(pendingSyncPath, HYBRID_JSONL_READ_LIMIT);
|
|
188
|
+
const attributedEntries = readJsonl(attributedFeedbackPath, HYBRID_JSONL_READ_LIMIT);
|
|
215
189
|
|
|
216
190
|
// Deduplicate by id across all sources
|
|
217
191
|
const seen = new Set();
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { resolveFeedbackDir } = require('./feedback-paths');
|
|
7
|
+
const { getDecisionLogPath, readDecisionLog, collapseDecisionTimeline } = require('./decision-journal');
|
|
7
8
|
|
|
8
9
|
const LABELS = ['allow', 'recall', 'verify', 'warn', 'deny'];
|
|
9
10
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
@@ -323,14 +324,64 @@ function buildDiagnosticExample(entry) {
|
|
|
323
324
|
};
|
|
324
325
|
}
|
|
325
326
|
|
|
327
|
+
function deriveLabelFromDecisionOutcome(outcome) {
|
|
328
|
+
const status = normalizeText(outcome && outcome.outcome);
|
|
329
|
+
const actualDecision = normalizeText(outcome && outcome.actualDecision);
|
|
330
|
+
if (status === 'blocked' || status === 'rolled_back' || actualDecision === 'deny') return 'deny';
|
|
331
|
+
if (status === 'warned' || status === 'overridden' || actualDecision === 'warn') return 'warn';
|
|
332
|
+
if (status === 'accepted' || status === 'completed') return 'allow';
|
|
333
|
+
if (status === 'aborted') return 'warn';
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildDecisionExample(action) {
|
|
338
|
+
const evaluation = action && action.evaluation ? action.evaluation : null;
|
|
339
|
+
const latestOutcome = action && Array.isArray(action.outcomes) && action.outcomes.length > 0
|
|
340
|
+
? action.outcomes[action.outcomes.length - 1]
|
|
341
|
+
: null;
|
|
342
|
+
const label = deriveLabelFromDecisionOutcome(latestOutcome);
|
|
343
|
+
if (!evaluation || !latestOutcome || !label) return null;
|
|
344
|
+
|
|
345
|
+
const recommendation = evaluation.recommendation || {};
|
|
346
|
+
const blastRadius = evaluation.blastRadius || {};
|
|
347
|
+
const toolInput = evaluation.toolInput && typeof evaluation.toolInput === 'object' ? evaluation.toolInput : {};
|
|
348
|
+
const changedFiles = Array.isArray(evaluation.changedFiles) ? evaluation.changedFiles : [];
|
|
349
|
+
const tokens = buildFeatureTokens([
|
|
350
|
+
'kind:decision',
|
|
351
|
+
`tool:${evaluation.toolName || latestOutcome.toolName || 'unknown'}`,
|
|
352
|
+
`decision:${recommendation.decision || 'allow'}`,
|
|
353
|
+
`execution:${recommendation.executionMode || 'auto_execute'}`,
|
|
354
|
+
`owner:${recommendation.decisionOwner || 'agent'}`,
|
|
355
|
+
`reversibility:${recommendation.reversibility || 'reviewable'}`,
|
|
356
|
+
recommendation.riskBand ? `risk:${recommendation.riskBand}` : null,
|
|
357
|
+
blastRadius.severity ? `blast:${blastRadius.severity}` : null,
|
|
358
|
+
latestOutcome.outcome ? `outcome:${latestOutcome.outcome}` : null,
|
|
359
|
+
latestOutcome.actor ? `actor:${latestOutcome.actor}` : null,
|
|
360
|
+
...extractCommandTokens(toolInput.command || ''),
|
|
361
|
+
...changedFiles.flatMap((filePath) => extractFileTokens(filePath)),
|
|
362
|
+
...tokenizeText([recommendation.summary, latestOutcome.notes].filter(Boolean).join(' '), 10).map((token) => `decisiontok:${token}`),
|
|
363
|
+
]);
|
|
364
|
+
|
|
365
|
+
if (!tokens.length) return null;
|
|
366
|
+
return {
|
|
367
|
+
id: latestOutcome.actionId || evaluation.actionId || null,
|
|
368
|
+
source: 'decision',
|
|
369
|
+
label,
|
|
370
|
+
timestamp: latestOutcome.timestamp || evaluation.timestamp || new Date().toISOString(),
|
|
371
|
+
tokens,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
326
375
|
function buildExamplesFromFeedbackDir(feedbackDir) {
|
|
327
376
|
const resolvedDir = resolveFeedbackDir({ feedbackDir });
|
|
328
377
|
const feedbackEntries = readJSONL(path.join(resolvedDir, 'feedback-log.jsonl'));
|
|
329
378
|
const auditEntries = readJSONL(path.join(resolvedDir, 'audit-trail.jsonl'));
|
|
330
379
|
const diagnosticEntries = readJSONL(path.join(resolvedDir, 'diagnostic-log.jsonl'));
|
|
380
|
+
const decisionEntries = readDecisionLog(getDecisionLogPath(resolvedDir));
|
|
381
|
+
const decisions = collapseDecisionTimeline(decisionEntries);
|
|
331
382
|
|
|
332
383
|
const examples = [];
|
|
333
|
-
const sourceCounts = { feedback: 0, audit: 0, diagnostic: 0 };
|
|
384
|
+
const sourceCounts = { feedback: 0, audit: 0, diagnostic: 0, decision: 0 };
|
|
334
385
|
|
|
335
386
|
for (const entry of feedbackEntries) {
|
|
336
387
|
const example = buildFeedbackExample(entry);
|
|
@@ -350,6 +401,12 @@ function buildExamplesFromFeedbackDir(feedbackDir) {
|
|
|
350
401
|
sourceCounts.diagnostic += 1;
|
|
351
402
|
examples.push(example);
|
|
352
403
|
}
|
|
404
|
+
for (const action of decisions) {
|
|
405
|
+
const example = buildDecisionExample(action);
|
|
406
|
+
if (!example) continue;
|
|
407
|
+
sourceCounts.decision += 1;
|
|
408
|
+
examples.push(example);
|
|
409
|
+
}
|
|
353
410
|
|
|
354
411
|
examples.sort((left, right) => {
|
|
355
412
|
return Date.parse(left.timestamp || 0) - Date.parse(right.timestamp || 0);
|
package/scripts/lesson-db.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
const fs = require('node:fs');
|
|
17
|
+
const { readJsonl } = require('./fs-utils');
|
|
17
18
|
|
|
18
19
|
const PROJECT_ROOT = path.join(__dirname, '..');
|
|
19
20
|
const DEFAULT_DB_PATH = path.join(PROJECT_ROOT, '.claude', 'memory', 'lessons.sqlite');
|
|
@@ -495,8 +496,8 @@ function backfillFromJsonl(db, feedbackDir) {
|
|
|
495
496
|
const feedbackLogPath = path.join(feedbackDir, 'feedback-log.jsonl');
|
|
496
497
|
const memoryLogPath = path.join(feedbackDir, 'memory-log.jsonl');
|
|
497
498
|
|
|
498
|
-
const feedbackEntries =
|
|
499
|
-
const memoryEntries =
|
|
499
|
+
const feedbackEntries = readJsonl(feedbackLogPath);
|
|
500
|
+
const memoryEntries = readJsonl(memoryLogPath);
|
|
500
501
|
|
|
501
502
|
// Index memories by sourceFeedbackId for joining
|
|
502
503
|
const memoryByFeedbackId = new Map();
|
|
@@ -581,22 +582,6 @@ function safeParseTags(tagsStr) {
|
|
|
581
582
|
}
|
|
582
583
|
}
|
|
583
584
|
|
|
584
|
-
function readJsonlSafe(filePath) {
|
|
585
|
-
if (!fs.existsSync(filePath)) return [];
|
|
586
|
-
const raw = fs.readFileSync(filePath, 'utf-8').trim();
|
|
587
|
-
if (!raw) return [];
|
|
588
|
-
return raw
|
|
589
|
-
.split('\n')
|
|
590
|
-
.map((line) => {
|
|
591
|
-
try {
|
|
592
|
-
return JSON.parse(line);
|
|
593
|
-
} catch {
|
|
594
|
-
return null;
|
|
595
|
-
}
|
|
596
|
-
})
|
|
597
|
-
.filter(Boolean);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
585
|
module.exports = {
|
|
601
586
|
initDB,
|
|
602
587
|
upsertLesson,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const path = require('path');
|
|
19
19
|
const { resolveFeedbackDir } = require('./feedback-paths');
|
|
20
|
+
const { ensureParentDir, readJsonl } = require('./fs-utils');
|
|
20
21
|
const {
|
|
21
22
|
buildStableId,
|
|
22
23
|
extractFilePaths,
|
|
@@ -39,15 +40,6 @@ function getLessonBaseUrl() {
|
|
|
39
40
|
function getLessonsPath() { return path.join(getFeedbackDir(), LESSONS_FILE); }
|
|
40
41
|
function getRecentLessonPath() { return path.join(getFeedbackDir(), RECENT_LESSON_FILE); }
|
|
41
42
|
|
|
42
|
-
function readJsonl(fp) {
|
|
43
|
-
if (!fs.existsSync(fp)) return [];
|
|
44
|
-
const raw = fs.readFileSync(fp, 'utf-8').trim();
|
|
45
|
-
if (!raw) return [];
|
|
46
|
-
return raw.split('\n').map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function ensureDir(p) { const d = path.dirname(p); if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); }
|
|
50
|
-
|
|
51
43
|
// ---------------------------------------------------------------------------
|
|
52
44
|
// 1. Surrounding Message Context Extraction
|
|
53
45
|
// ---------------------------------------------------------------------------
|
|
@@ -146,7 +138,7 @@ function createLesson({ feedbackId, signal, inferredLesson, triggerMessage, prio
|
|
|
146
138
|
lesson.link = `${getLessonBaseUrl()}/lessons#${lesson.id}`;
|
|
147
139
|
|
|
148
140
|
const lessonsPath = getLessonsPath();
|
|
149
|
-
|
|
141
|
+
ensureParentDir(lessonsPath);
|
|
150
142
|
fs.appendFileSync(lessonsPath, JSON.stringify(lesson) + '\n');
|
|
151
143
|
|
|
152
144
|
// Update recent lesson for statusbar
|
|
@@ -165,6 +157,55 @@ function getRecentLesson() {
|
|
|
165
157
|
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return null; }
|
|
166
158
|
}
|
|
167
159
|
|
|
160
|
+
function isNegativeSignal(signal) {
|
|
161
|
+
return signal === 'negative' || signal === 'down';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isPositiveSignal(signal) {
|
|
165
|
+
return signal === 'positive' || signal === 'up';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function selectStatusbarLesson() {
|
|
169
|
+
const lessons = readJsonl(getLessonsPath())
|
|
170
|
+
.slice()
|
|
171
|
+
.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
|
|
172
|
+
const latestNegative = lessons.find((lesson) => isNegativeSignal(lesson.signal));
|
|
173
|
+
if (latestNegative) return latestNegative;
|
|
174
|
+
const latestPositive = lessons.find((lesson) => isPositiveSignal(lesson.signal));
|
|
175
|
+
if (latestPositive) return latestPositive;
|
|
176
|
+
return getRecentLesson();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getLessonKind(lesson = {}) {
|
|
180
|
+
const normalizedTitle = String(lesson.lesson || '').trim();
|
|
181
|
+
if (isNegativeSignal(lesson.signal) || /^MISTAKE:/i.test(normalizedTitle)) return 'mistake';
|
|
182
|
+
if (isPositiveSignal(lesson.signal) || /^SUCCESS:/i.test(normalizedTitle)) return 'success';
|
|
183
|
+
if (/^LEARNING:/i.test(normalizedTitle)) return 'learning';
|
|
184
|
+
if (/^PREFERENCE:/i.test(normalizedTitle)) return 'preference';
|
|
185
|
+
return 'lesson';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function stripLessonPrefix(lessonText = '') {
|
|
189
|
+
return String(lessonText || '').replace(/^(MISTAKE|SUCCESS|LEARNING|PREFERENCE):\s*/i, '').trim();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function formatLessonTimestamp(createdAt = '') {
|
|
193
|
+
const parsed = new Date(createdAt);
|
|
194
|
+
if (!Number.isFinite(parsed.getTime())) return '';
|
|
195
|
+
return parsed.toISOString().slice(0, 16).replace('T', ' ') + 'Z';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function buildStatusbarLessonLabel(lesson = {}) {
|
|
199
|
+
const kind = getLessonKind(lesson);
|
|
200
|
+
const prefix = kind === 'mistake'
|
|
201
|
+
? 'Latest mistake'
|
|
202
|
+
: kind === 'success'
|
|
203
|
+
? 'Latest success'
|
|
204
|
+
: 'Latest lesson';
|
|
205
|
+
const timestamp = formatLessonTimestamp(lesson.createdAt);
|
|
206
|
+
return timestamp ? `${prefix} ${timestamp}` : prefix;
|
|
207
|
+
}
|
|
208
|
+
|
|
168
209
|
/**
|
|
169
210
|
* Search lessons by query text.
|
|
170
211
|
*/
|
|
@@ -196,6 +237,59 @@ function getLessonStats() {
|
|
|
196
237
|
return { total: lessons.length, positive, negative, avgConfidence };
|
|
197
238
|
}
|
|
198
239
|
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// 2b. Context Stuffing — dump all lessons for injection into agent context
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Returns ALL lessons condensed for context-window injection.
|
|
246
|
+
* Bypasses RAG/search — just stuff everything into context.
|
|
247
|
+
* For most projects (20-200 lessons), this is 1K-10K tokens.
|
|
248
|
+
* @param {object} opts
|
|
249
|
+
* @param {number} opts.maxTokenBudget - approximate token budget (default 10000)
|
|
250
|
+
* @param {string} opts.signal - filter by 'positive' or 'negative'
|
|
251
|
+
* @param {string} opts.format - 'compact' (default) or 'full'
|
|
252
|
+
* @returns {{ lessons: string, count: number, truncated: boolean }}
|
|
253
|
+
*/
|
|
254
|
+
function getAllLessonsForContext({ maxTokenBudget = 10000, signal, format = 'compact' } = {}) {
|
|
255
|
+
let lessons = readJsonl(getLessonsPath());
|
|
256
|
+
if (signal) lessons = lessons.filter((l) => l.signal === signal || (signal === 'negative' && l.signal === 'down') || (signal === 'positive' && l.signal === 'up'));
|
|
257
|
+
|
|
258
|
+
// Sort by confidence descending — most important lessons first
|
|
259
|
+
lessons.sort((a, b) => (b.confidence || 0) - (a.confidence || 0));
|
|
260
|
+
|
|
261
|
+
const lines = [];
|
|
262
|
+
let approxTokens = 0;
|
|
263
|
+
let truncated = false;
|
|
264
|
+
|
|
265
|
+
for (const l of lessons) {
|
|
266
|
+
let line;
|
|
267
|
+
if (format === 'compact') {
|
|
268
|
+
const sig = l.signal === 'positive' || l.signal === 'up' ? 'DO' : 'AVOID';
|
|
269
|
+
line = `[${sig}] ${l.lesson || l.inferredLesson || ''}`;
|
|
270
|
+
} else {
|
|
271
|
+
line = JSON.stringify({ signal: l.signal, lesson: l.lesson || l.inferredLesson, confidence: l.confidence, tags: l.tags, createdAt: l.createdAt });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const lineTokens = Math.ceil(line.length / 4); // rough token estimate
|
|
275
|
+
if (approxTokens + lineTokens > maxTokenBudget) {
|
|
276
|
+
truncated = true;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
lines.push(line);
|
|
281
|
+
approxTokens += lineTokens;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
lessons: lines.join('\n'),
|
|
286
|
+
count: lines.length,
|
|
287
|
+
totalAvailable: lessons.length,
|
|
288
|
+
truncated,
|
|
289
|
+
approxTokens,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
199
293
|
// ---------------------------------------------------------------------------
|
|
200
294
|
// 3. Statusbar Data Provider
|
|
201
295
|
// ---------------------------------------------------------------------------
|
|
@@ -205,19 +299,21 @@ function getLessonStats() {
|
|
|
205
299
|
* Returns the most recent lesson with link, formatted for display.
|
|
206
300
|
*/
|
|
207
301
|
function getStatusbarLessonData() {
|
|
208
|
-
const recent =
|
|
302
|
+
const recent = selectStatusbarLesson();
|
|
209
303
|
if (!recent) return { hasLesson: false, text: null, link: null };
|
|
210
304
|
|
|
211
|
-
const
|
|
212
|
-
const truncated =
|
|
305
|
+
const normalizedLesson = stripLessonPrefix(recent.lesson || '');
|
|
306
|
+
const truncated = normalizedLesson.length > 48 ? normalizedLesson.slice(0, 45) + '...' : normalizedLesson;
|
|
213
307
|
|
|
214
308
|
return {
|
|
215
309
|
hasLesson: true,
|
|
216
|
-
text:
|
|
310
|
+
text: truncated,
|
|
217
311
|
link: recent.link,
|
|
218
312
|
lessonId: recent.id,
|
|
219
313
|
confidence: recent.confidence,
|
|
220
314
|
createdAt: recent.createdAt,
|
|
315
|
+
label: buildStatusbarLessonLabel(recent),
|
|
316
|
+
kind: getLessonKind(recent),
|
|
221
317
|
};
|
|
222
318
|
}
|
|
223
319
|
|
|
@@ -306,10 +402,92 @@ function consumePhrase(lower, original, phrases) {
|
|
|
306
402
|
return null;
|
|
307
403
|
}
|
|
308
404
|
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// 6. LLM-Powered Structured Lesson Extraction
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
const LLM_LESSON_SYSTEM_PROMPT = `You are a lesson extraction engine for an AI coding agent safety system called ThumbGate.
|
|
410
|
+
|
|
411
|
+
Given a conversation window and a feedback signal (positive or negative), extract a structured lesson.
|
|
412
|
+
|
|
413
|
+
Return ONLY valid JSON matching this exact schema:
|
|
414
|
+
{
|
|
415
|
+
"trigger": { "condition": "<when this lesson applies>", "type": "<one of: debugging, implementation, question, error-report, constraint>" },
|
|
416
|
+
"action": { "type": "<do or avoid>", "description": "<specific action to take or avoid>" },
|
|
417
|
+
"confidence": <0.0 to 1.0>,
|
|
418
|
+
"scope": "<global, file-level, or project-level>",
|
|
419
|
+
"tags": ["<relevant tags>"]
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
Guidelines:
|
|
423
|
+
- Be specific and actionable. "Avoid: editing files without reading them first" is better than "Avoid: bad edits".
|
|
424
|
+
- confidence should reflect how clear the lesson is from the conversation context.
|
|
425
|
+
- tags should include tool names, file types, or domain areas mentioned.
|
|
426
|
+
- Do NOT include any text outside the JSON object.`;
|
|
427
|
+
|
|
428
|
+
async function inferStructuredLessonLLM(conversationWindow, signal, context) {
|
|
429
|
+
const { isAvailable, callClaude, MODELS } = require('./llm-client');
|
|
430
|
+
if (!isAvailable()) return null;
|
|
431
|
+
|
|
432
|
+
const normalizedWindow = Array.isArray(conversationWindow) ? conversationWindow : [];
|
|
433
|
+
if (normalizedWindow.length === 0 && !context) return null;
|
|
434
|
+
|
|
435
|
+
const windowText = normalizedWindow
|
|
436
|
+
.slice(-10)
|
|
437
|
+
.map((m) => `[${m.role}]: ${(m.content || '').slice(0, 400)}`)
|
|
438
|
+
.join('\n')
|
|
439
|
+
.slice(0, 4000);
|
|
440
|
+
|
|
441
|
+
const userPrompt = [
|
|
442
|
+
`Signal: ${signal === 'positive' || signal === 'up' ? 'positive (thumbs up — something worked well)' : 'negative (thumbs down — something went wrong)'}`,
|
|
443
|
+
context ? `User context: ${context}` : '',
|
|
444
|
+
`\nConversation:\n${windowText}`,
|
|
445
|
+
].filter(Boolean).join('\n');
|
|
446
|
+
|
|
447
|
+
const raw = await callClaude({
|
|
448
|
+
systemPrompt: LLM_LESSON_SYSTEM_PROMPT,
|
|
449
|
+
userPrompt,
|
|
450
|
+
model: MODELS.FAST,
|
|
451
|
+
maxTokens: 512,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
if (!raw) return null;
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
const parsed = JSON.parse(raw);
|
|
458
|
+
if (!parsed.trigger || !parsed.action) return null;
|
|
459
|
+
|
|
460
|
+
const filePaths = extractFilePaths(normalizedWindow);
|
|
461
|
+
const toolCalls = extractToolCalls(normalizedWindow);
|
|
462
|
+
const errorPatterns = extractErrors(normalizedWindow);
|
|
463
|
+
const userMessages = normalizedWindow.filter((m) => m.role === 'user');
|
|
464
|
+
const assistantMessages = normalizedWindow.filter((m) => m.role === 'assistant');
|
|
465
|
+
const lastUser = userMessages[userMessages.length - 1]?.content || '';
|
|
466
|
+
const lastAssistant = assistantMessages[assistantMessages.length - 1]?.content || '';
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
format: 'if-then-v1-llm',
|
|
470
|
+
trigger: parsed.trigger,
|
|
471
|
+
action: parsed.action,
|
|
472
|
+
signal: signal === 'positive' || signal === 'up' ? 'positive' : 'negative',
|
|
473
|
+
confidence: Math.max(0, Math.min(1, Number(parsed.confidence) || 0.5)),
|
|
474
|
+
scope: parsed.scope || inferScope(filePaths, toolCalls),
|
|
475
|
+
examples: [{ userIntent: lastUser.slice(0, 300), assistantAction: lastAssistant.slice(0, 300), outcome: signal === 'positive' || signal === 'up' ? 'approved' : 'rejected' }],
|
|
476
|
+
metadata: { toolsUsed: toolCalls, filesInvolved: filePaths.slice(0, 10), errorPatterns: errorPatterns.slice(0, 5), conversationLength: normalizedWindow.length, inferredAt: new Date().toISOString(), llmModel: MODELS.FAST },
|
|
477
|
+
tags: Array.isArray(parsed.tags) ? parsed.tags : [],
|
|
478
|
+
};
|
|
479
|
+
} catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
309
484
|
module.exports = {
|
|
310
485
|
inferFromSurroundingMessages, createLesson, getRecentLesson,
|
|
311
|
-
searchLessons, getLessonStats, getStatusbarLessonData,
|
|
486
|
+
searchLessons, getLessonStats, getStatusbarLessonData, getAllLessonsForContext,
|
|
312
487
|
getLessonsPath, getRecentLessonPath,
|
|
313
|
-
|
|
488
|
+
selectStatusbarLesson, getLessonKind, stripLessonPrefix,
|
|
489
|
+
formatLessonTimestamp, buildStatusbarLessonLabel,
|
|
490
|
+
inferStructuredLesson, inferStructuredLessonLLM,
|
|
491
|
+
extractTrigger, extractAction, extractToolCalls,
|
|
314
492
|
extractFilePaths, extractErrors, calculateConfidence, inferScope,
|
|
315
493
|
};
|
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Per-action lesson retrieval.
|
|
6
|
-
*
|
|
7
|
-
* using keyword matching + recency decay + signal weighting.
|
|
6
|
+
* v2: backward retrieval + bigram Jaccard fuzzy matching
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
|
-
const RECENCY_DECAY_DAYS = 30;
|
|
9
|
+
const RECENCY_DECAY_DAYS = 30;
|
|
11
10
|
|
|
12
11
|
function retrieveRelevantLessons(toolName, actionContext, options = {}) {
|
|
13
12
|
const { maxResults = 5, feedbackDir } = options;
|
|
@@ -20,13 +19,13 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
|
|
|
20
19
|
const memories = readJSONL(paths.MEMORY_LOG_PATH, { maxLines: 200 });
|
|
21
20
|
if (memories.length === 0) return [];
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
const actionSig = buildActionSignature(toolName, actionContext);
|
|
23
|
+
|
|
24
24
|
const scored = memories.map((mem) => ({
|
|
25
25
|
...mem,
|
|
26
|
-
relevanceScore: scoreRelevance(mem, toolName, actionContext),
|
|
26
|
+
relevanceScore: scoreRelevance(mem, toolName, actionContext, actionSig),
|
|
27
27
|
}));
|
|
28
28
|
|
|
29
|
-
// Sort by relevance, return top-K
|
|
30
29
|
return scored
|
|
31
30
|
.filter((m) => m.relevanceScore > 0.1)
|
|
32
31
|
.sort((a, b) => b.relevanceScore - a.relevanceScore)
|
|
@@ -42,43 +41,74 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
|
|
|
42
41
|
}));
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
function
|
|
44
|
+
function buildActionSignature(toolName, actionContext) {
|
|
45
|
+
const toolLower = (toolName || '').toLowerCase();
|
|
46
|
+
const contextLower = (actionContext || '').toLowerCase();
|
|
47
|
+
const sigPaths = extractPaths(actionContext);
|
|
48
|
+
const tokens = tokenize(contextLower);
|
|
49
|
+
const ngramSet = textBigrams(contextLower);
|
|
50
|
+
return { toolLower, contextLower, paths: sigPaths, tokens, ngramSet };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function textBigrams(text) {
|
|
54
|
+
const normalized = (text || '')
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
57
|
+
.replace(/\s+/g, ' ')
|
|
58
|
+
.trim();
|
|
59
|
+
const set = new Set();
|
|
60
|
+
for (let i = 0; i < normalized.length - 1; i++) {
|
|
61
|
+
set.add(normalized.slice(i, i + 2));
|
|
62
|
+
}
|
|
63
|
+
return set;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function bigramJaccard(setA, setB) {
|
|
67
|
+
if (setA.size === 0 && setB.size === 0) return 1;
|
|
68
|
+
if (setA.size === 0 || setB.size === 0) return 0;
|
|
69
|
+
let intersection = 0;
|
|
70
|
+
for (const item of setA) {
|
|
71
|
+
if (setB.has(item)) intersection++;
|
|
72
|
+
}
|
|
73
|
+
const union = setA.size + setB.size - intersection;
|
|
74
|
+
return union === 0 ? 0 : intersection / union;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function scoreRelevance(memory, toolName, actionContext, actionSig) {
|
|
78
|
+
const sig = actionSig || buildActionSignature(toolName, actionContext);
|
|
46
79
|
let score = 0;
|
|
47
80
|
|
|
48
|
-
const memText =
|
|
49
|
-
const contextLower = (actionContext || '').toLowerCase();
|
|
50
|
-
const toolLower = (toolName || '').toLowerCase();
|
|
81
|
+
const memText = ((memory.title || '') + ' ' + (memory.content || '') + ' ' + (memory.tags || []).join(' ')).toLowerCase();
|
|
51
82
|
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
if (memText.includes(toolLower)) score += 0.2;
|
|
83
|
+
if (memory.metadata?.toolsUsed?.some((t) => t.toLowerCase() === sig.toolLower)) score += 0.4;
|
|
84
|
+
if (memText.includes(sig.toolLower)) score += 0.2;
|
|
55
85
|
|
|
56
|
-
// 2. File path overlap
|
|
57
|
-
const contextPaths = extractPaths(actionContext);
|
|
58
86
|
const memPaths = memory.metadata?.filesInvolved || extractPaths(memText);
|
|
59
|
-
const pathOverlap =
|
|
87
|
+
const pathOverlap = sig.paths.filter((p) =>
|
|
60
88
|
memPaths.some((mp) => mp.includes(p) || p.includes(mp)),
|
|
61
89
|
);
|
|
62
90
|
if (pathOverlap.length > 0) score += 0.3;
|
|
63
91
|
|
|
64
|
-
// 3. Keyword overlap (TF-IDF-lite)
|
|
65
|
-
const contextTokens = tokenize(contextLower);
|
|
66
92
|
const memTokens = tokenize(memText);
|
|
67
|
-
const overlap =
|
|
93
|
+
const overlap = sig.tokens.filter((t) => memTokens.includes(t));
|
|
68
94
|
score += Math.min(overlap.length * 0.05, 0.3);
|
|
69
95
|
|
|
70
|
-
//
|
|
96
|
+
// Fuzzy n-gram matching (only when there is already signal)
|
|
97
|
+
if (score > 0) {
|
|
98
|
+
const memBigrams = textBigrams(memText);
|
|
99
|
+
const fuzzyScore = bigramJaccard(sig.ngramSet, memBigrams);
|
|
100
|
+
score += fuzzyScore * 0.2;
|
|
101
|
+
}
|
|
102
|
+
|
|
71
103
|
if (memory.tags?.includes('negative')) score += 0.1;
|
|
72
104
|
|
|
73
|
-
// 5. Recency decay
|
|
74
105
|
if (memory.timestamp) {
|
|
75
106
|
const ageMs = Date.now() - new Date(memory.timestamp).getTime();
|
|
76
107
|
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
77
108
|
const decay = Math.max(0, 1 - ageDays / RECENCY_DECAY_DAYS);
|
|
78
|
-
score *= 0.5 + 0.5 * decay;
|
|
109
|
+
score *= 0.5 + 0.5 * decay;
|
|
79
110
|
}
|
|
80
111
|
|
|
81
|
-
// 6. Structured rule bonus — IF/THEN rules are more actionable
|
|
82
112
|
if (memory.structuredRule) score += 0.15;
|
|
83
113
|
|
|
84
114
|
return score;
|
|
@@ -92,4 +122,10 @@ function tokenize(text) {
|
|
|
92
122
|
return (text || '').split(/[\s.,;:!?()\[\]{}"'`]+/).filter((t) => t.length > 3);
|
|
93
123
|
}
|
|
94
124
|
|
|
95
|
-
module.exports = {
|
|
125
|
+
module.exports = {
|
|
126
|
+
retrieveRelevantLessons,
|
|
127
|
+
scoreRelevance,
|
|
128
|
+
buildActionSignature,
|
|
129
|
+
textBigrams,
|
|
130
|
+
bigramJaccard,
|
|
131
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const MODELS = {
|
|
5
|
+
FAST: 'claude-haiku-4-5-20251001',
|
|
6
|
+
SMART: 'claude-sonnet-4-6',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MODEL = MODELS.FAST;
|
|
10
|
+
const DEFAULT_MAX_TOKENS = 1024;
|
|
11
|
+
|
|
12
|
+
let _client = null;
|
|
13
|
+
|
|
14
|
+
function isAvailable() {
|
|
15
|
+
return Boolean(process.env.ANTHROPIC_API_KEY);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getClient() {
|
|
19
|
+
if (_client) return _client;
|
|
20
|
+
if (!isAvailable()) return null;
|
|
21
|
+
try {
|
|
22
|
+
const Anthropic = require('@anthropic-ai/sdk');
|
|
23
|
+
_client = new Anthropic();
|
|
24
|
+
return _client;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function stripCodeFences(text) {
|
|
31
|
+
if (!text) return text;
|
|
32
|
+
const fenced = text.match(/^```(?:json)?\s*\n?([\s\S]*?)```\s*$/);
|
|
33
|
+
return fenced ? fenced[1].trim() : text.trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function callClaude({ systemPrompt, userPrompt, model, maxTokens } = {}) {
|
|
37
|
+
const client = getClient();
|
|
38
|
+
if (!client) return null;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const response = await client.messages.create({
|
|
42
|
+
model: model || DEFAULT_MODEL,
|
|
43
|
+
max_tokens: maxTokens || DEFAULT_MAX_TOKENS,
|
|
44
|
+
system: systemPrompt || undefined,
|
|
45
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const text = response.content
|
|
49
|
+
.filter((b) => b.type === 'text')
|
|
50
|
+
.map((b) => b.text)
|
|
51
|
+
.join('');
|
|
52
|
+
|
|
53
|
+
return stripCodeFences(text);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { isAvailable, callClaude, stripCodeFences, MODELS };
|