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.
Files changed (146) hide show
  1. package/.claude-plugin/marketplace.json +32 -13
  2. package/.claude-plugin/plugin.json +15 -2
  3. package/.well-known/llms.txt +60 -0
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +109 -20
  6. package/adapters/README.md +1 -1
  7. package/adapters/chatgpt/openapi.yaml +168 -0
  8. package/adapters/claude/.mcp.json +2 -2
  9. package/adapters/codex/config.toml +2 -2
  10. package/adapters/mcp/server-stdio.js +84 -1
  11. package/adapters/opencode/opencode.json +1 -1
  12. package/bin/cli.js +200 -13
  13. package/bin/postinstall.js +8 -2
  14. package/config/budget.json +18 -0
  15. package/config/gates/code-edit.json +61 -0
  16. package/config/gates/db-write.json +61 -0
  17. package/config/gates/default.json +154 -3
  18. package/config/gates/deploy.json +61 -0
  19. package/config/github-about.json +2 -1
  20. package/config/merge-quality-checks.json +23 -0
  21. package/openapi/openapi.yaml +168 -0
  22. package/package.json +42 -10
  23. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  24. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  25. package/plugins/claude-codex-bridge/scripts/codex-bridge.js +1 -3
  26. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  27. package/plugins/codex-profile/.mcp.json +1 -1
  28. package/plugins/codex-profile/INSTALL.md +27 -4
  29. package/plugins/codex-profile/README.md +33 -9
  30. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  31. package/plugins/opencode-profile/INSTALL.md +1 -1
  32. package/public/blog.html +73 -0
  33. package/public/compare/mem0.html +189 -0
  34. package/public/compare/speclock.html +180 -0
  35. package/public/compare.html +10 -2
  36. package/public/guide.html +2 -2
  37. package/public/guides/claude-code-prevent-repeated-mistakes.html +161 -0
  38. package/public/guides/codex-cli-guardrails.html +158 -0
  39. package/public/guides/cursor-prevent-repeated-mistakes.html +161 -0
  40. package/public/guides/pre-action-gates.html +162 -0
  41. package/public/guides/stop-repeated-ai-agent-mistakes.html +159 -0
  42. package/public/index.html +136 -50
  43. package/public/lessons.html +33 -24
  44. package/public/llm-context.md +140 -0
  45. package/public/pro.html +24 -22
  46. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  47. package/scripts/access-anomaly-detector.js +1 -1
  48. package/scripts/adk-consolidator.js +1 -5
  49. package/scripts/agent-security-hardening.js +4 -6
  50. package/scripts/agentic-data-pipeline.js +1 -3
  51. package/scripts/async-job-runner.js +1 -5
  52. package/scripts/audit-trail.js +1 -5
  53. package/scripts/background-agent-governance.js +2 -10
  54. package/scripts/billing.js +2 -16
  55. package/scripts/budget-enforcer.js +173 -0
  56. package/scripts/build-codex-plugin.js +152 -0
  57. package/scripts/check-congruence.js +132 -14
  58. package/scripts/commercial-offer.js +5 -7
  59. package/scripts/content-engine/linkedin-content-generator.js +154 -0
  60. package/scripts/content-engine/output/linkedin-memento-validation.md +17 -0
  61. package/scripts/content-engine/output/linkedin-posts-2026-04-09.md +175 -0
  62. package/scripts/content-engine/reddit-thread-finder.js +154 -0
  63. package/scripts/context-engine.js +21 -6
  64. package/scripts/contextfs.js +1 -21
  65. package/scripts/dashboard.js +20 -0
  66. package/scripts/decision-journal.js +341 -0
  67. package/scripts/delegation-runtime.js +1 -5
  68. package/scripts/distribution-surfaces.js +26 -0
  69. package/scripts/document-intake.js +927 -0
  70. package/scripts/ephemeral-agent-store.js +1 -8
  71. package/scripts/evolution-state.js +1 -5
  72. package/scripts/experiment-tracker.js +1 -5
  73. package/scripts/export-databricks-bundle.js +1 -5
  74. package/scripts/export-hf-dataset.js +1 -5
  75. package/scripts/export-training.js +1 -5
  76. package/scripts/feedback-attribution.js +1 -16
  77. package/scripts/feedback-history-distiller.js +1 -16
  78. package/scripts/feedback-loop.js +1 -5
  79. package/scripts/feedback-root-consolidator.js +2 -21
  80. package/scripts/feedback-session.js +49 -0
  81. package/scripts/feedback-to-rules.js +188 -28
  82. package/scripts/filesystem-search.js +1 -9
  83. package/scripts/fs-utils.js +104 -0
  84. package/scripts/gates-engine.js +149 -4
  85. package/scripts/github-about.js +32 -8
  86. package/scripts/gtm-revenue-loop.js +1 -5
  87. package/scripts/harness-selector.js +148 -0
  88. package/scripts/hosted-job-launcher.js +1 -5
  89. package/scripts/hybrid-feedback-context.js +7 -33
  90. package/scripts/intervention-policy.js +58 -1
  91. package/scripts/lesson-db.js +3 -18
  92. package/scripts/lesson-inference.js +194 -16
  93. package/scripts/lesson-retrieval.js +60 -24
  94. package/scripts/llm-client.js +59 -0
  95. package/scripts/managed-lesson-agent.js +183 -0
  96. package/scripts/marketing-experiment.js +8 -22
  97. package/scripts/meta-agent-loop.js +624 -0
  98. package/scripts/metered-billing.js +1 -1
  99. package/scripts/money-watcher.js +1 -4
  100. package/scripts/obsidian-export.js +1 -5
  101. package/scripts/operational-integrity.js +15 -3
  102. package/scripts/org-dashboard.js +6 -1
  103. package/scripts/per-step-scoring.js +2 -4
  104. package/scripts/pr-manager.js +201 -19
  105. package/scripts/pro-features.js +3 -2
  106. package/scripts/prompt-dlp.js +3 -3
  107. package/scripts/prove-adapters.js +1 -5
  108. package/scripts/prove-attribution.js +1 -5
  109. package/scripts/prove-automation.js +1 -3
  110. package/scripts/prove-cloudflare-sandbox.js +1 -3
  111. package/scripts/prove-data-pipeline.js +1 -3
  112. package/scripts/prove-intelligence.js +1 -3
  113. package/scripts/prove-lancedb.js +1 -5
  114. package/scripts/prove-local-intelligence.js +1 -3
  115. package/scripts/prove-packaged-runtime.js +75 -9
  116. package/scripts/prove-predictive-insights.js +1 -3
  117. package/scripts/prove-training-export.js +1 -3
  118. package/scripts/prove-workflow-contract.js +1 -5
  119. package/scripts/rate-limiter.js +3 -1
  120. package/scripts/reddit-dm-outreach.js +14 -4
  121. package/scripts/schedule-manager.js +3 -5
  122. package/scripts/security-scanner.js +448 -0
  123. package/scripts/self-distill-agent.js +579 -0
  124. package/scripts/semantic-dedup.js +115 -0
  125. package/scripts/skill-exporter.js +1 -3
  126. package/scripts/skill-generator.js +1 -5
  127. package/scripts/social-analytics/engagement-audit.js +1 -18
  128. package/scripts/social-analytics/pollers/linkedin.js +26 -16
  129. package/scripts/social-analytics/publishers/linkedin.js +1 -1
  130. package/scripts/social-analytics/publishers/zernio.js +51 -0
  131. package/scripts/social-pipeline.js +1 -3
  132. package/scripts/social-post-hourly.js +47 -4
  133. package/scripts/statusline-links.js +6 -5
  134. package/scripts/statusline.sh +29 -153
  135. package/scripts/sync-branch-protection.js +340 -0
  136. package/scripts/tessl-export.js +1 -3
  137. package/scripts/thumbgate-search.js +32 -1
  138. package/scripts/tool-kpi-tracker.js +1 -1
  139. package/scripts/tool-registry.js +106 -2
  140. package/scripts/vector-store.js +1 -5
  141. package/scripts/weekly-auto-post.js +1 -1
  142. package/scripts/workflow-sentinel.js +91 -0
  143. package/skills/thumbgate/SKILL.md +1 -1
  144. package/src/api/server.js +273 -4
  145. package/scripts/social-analytics/db/social-analytics.db-shm +0 -0
  146. /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);
@@ -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 = readJsonlSafe(feedbackLogPath);
499
- const memoryEntries = readJsonlSafe(memoryLogPath);
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
- ensureDir(lessonsPath);
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 = getRecentLesson();
302
+ const recent = selectStatusbarLesson();
209
303
  if (!recent) return { hasLesson: false, text: null, link: null };
210
304
 
211
- const emoji = (recent.signal === 'negative' || recent.signal === 'down') ? '👎' : '👍';
212
- const truncated = recent.lesson.length > 60 ? recent.lesson.slice(0, 57) + '...' : recent.lesson;
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: `${emoji} ${truncated}`,
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
- inferStructuredLesson, extractTrigger, extractAction, extractToolCalls,
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
- * Given a tool name + context, returns the top-K most relevant lessons
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; // lessons older than this get down-weighted
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
- // Score each memory against the current action
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 scoreRelevance(memory, toolName, actionContext) {
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 = `${memory.title || ''} ${memory.content || ''} ${(memory.tags || []).join(' ')}`.toLowerCase();
49
- const contextLower = (actionContext || '').toLowerCase();
50
- const toolLower = (toolName || '').toLowerCase();
81
+ const memText = ((memory.title || '') + ' ' + (memory.content || '') + ' ' + (memory.tags || []).join(' ')).toLowerCase();
51
82
 
52
- // 1. Tool name match (high weight)
53
- if (memory.metadata?.toolsUsed?.some((t) => t.toLowerCase() === toolLower)) score += 0.4;
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 = contextPaths.filter((p) =>
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 = contextTokens.filter((t) => memTokens.includes(t));
93
+ const overlap = sig.tokens.filter((t) => memTokens.includes(t));
68
94
  score += Math.min(overlap.length * 0.05, 0.3);
69
95
 
70
- // 4. Signal weighting negative lessons are more important to surface
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; // 50% base + 50% recency
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 = { retrieveRelevantLessons, scoreRelevance };
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 };