thumbgate 1.27.4 → 1.27.7

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 (104) hide show
  1. package/.claude/commands/dashboard.md +15 -0
  2. package/.claude/commands/thumbgate-blocked.md +27 -0
  3. package/.claude/commands/thumbgate-dashboard.md +15 -0
  4. package/.claude/commands/thumbgate-doctor.md +30 -0
  5. package/.claude/commands/thumbgate-guard.md +36 -0
  6. package/.claude/commands/thumbgate-protect.md +30 -0
  7. package/.claude/commands/thumbgate-rules.md +30 -0
  8. package/.claude-plugin/plugin.json +2 -1
  9. package/.well-known/llms.txt +6 -2
  10. package/.well-known/mcp/server-card.json +1 -1
  11. package/README.md +49 -5
  12. package/adapters/claude/.mcp.json +2 -2
  13. package/adapters/letta/README.md +41 -0
  14. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  15. package/adapters/mcp/server-stdio.js +16 -1
  16. package/adapters/opencode/opencode.json +1 -1
  17. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  18. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  19. package/bench/observability-eval-suite.json +26 -0
  20. package/bin/cli.js +230 -6
  21. package/bin/postinstall.js +1 -1
  22. package/commands/dashboard.md +15 -0
  23. package/commands/thumbgate-dashboard.md +15 -0
  24. package/config/gate-templates.json +84 -0
  25. package/config/gates/claim-verification.json +12 -0
  26. package/config/gates/default.json +20 -0
  27. package/config/github-about.json +1 -1
  28. package/config/model-candidates.json +50 -0
  29. package/config/post-deploy-marketing-pages.json +5 -0
  30. package/package.json +67 -25
  31. package/public/agent-manager.html +41 -1
  32. package/public/agents-cost-savings.html +1 -1
  33. package/public/ai-malpractice-prevention.html +2 -1
  34. package/public/assets/brand/github-social-preview.png +0 -0
  35. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  36. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  37. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  38. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  39. package/public/assets/brand/thumbgate-mark-inline.svg +15 -0
  40. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  41. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  42. package/public/assets/brand/thumbgate-mark.svg +15 -0
  43. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  44. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  45. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  46. package/public/assets/legal-intake-control-flow.svg +66 -0
  47. package/public/blog.html +1 -1
  48. package/public/brand/thumbgate-mark.svg +15 -0
  49. package/public/brand/thumbgate-og.svg +16 -0
  50. package/public/codex-enterprise.html +1 -1
  51. package/public/codex-plugin.html +1 -1
  52. package/public/compare.html +23 -3
  53. package/public/dashboard.html +316 -30
  54. package/public/federal.html +1 -1
  55. package/public/guide.html +5 -4
  56. package/public/index.html +167 -49
  57. package/public/js/buyer-intent.js +672 -0
  58. package/public/learn.html +88 -7
  59. package/public/lessons.html +2 -1
  60. package/public/numbers.html +3 -3
  61. package/public/pricing.html +63 -15
  62. package/public/pro.html +7 -7
  63. package/scripts/activation-quickstart.js +187 -0
  64. package/scripts/agent-memory-lifecycle.js +211 -0
  65. package/scripts/async-eval-observability.js +236 -0
  66. package/scripts/auto-promote-gates.js +75 -4
  67. package/scripts/billing.js +12 -1
  68. package/scripts/build-metadata.js +24 -3
  69. package/scripts/cli-schema.js +42 -10
  70. package/scripts/dashboard-chat.js +53 -7
  71. package/scripts/dashboard.js +12 -17
  72. package/scripts/export-databricks-bundle.js +5 -1
  73. package/scripts/export-dpo-pairs.js +7 -2
  74. package/scripts/feedback-aggregate.js +281 -0
  75. package/scripts/feedback-loop.js +121 -0
  76. package/scripts/filesystem-search.js +35 -10
  77. package/scripts/gates-engine.js +234 -7
  78. package/scripts/gemini-embedding-policy.js +2 -1
  79. package/scripts/hook-stop-anti-claim.js +227 -0
  80. package/scripts/hook-thumbgate-cache-updater.js +18 -2
  81. package/scripts/hybrid-feedback-context.js +1 -0
  82. package/scripts/lesson-inference.js +8 -3
  83. package/scripts/lesson-search.js +17 -1
  84. package/scripts/operational-integrity.js +39 -5
  85. package/scripts/plausible-domain-config.js +15 -2
  86. package/scripts/plausible-server-events.js +4 -4
  87. package/scripts/rate-limiter.js +12 -6
  88. package/scripts/secret-redaction.js +166 -0
  89. package/scripts/security-scanner.js +100 -0
  90. package/scripts/self-distill-agent.js +3 -1
  91. package/scripts/self-harness-optimizer.js +141 -0
  92. package/scripts/seo-gsd.js +635 -0
  93. package/scripts/statusline-cache-path.js +17 -2
  94. package/scripts/statusline-cache-read.js +57 -0
  95. package/scripts/statusline-local-stats.js +9 -1
  96. package/scripts/statusline-meta.js +5 -2
  97. package/scripts/statusline.sh +13 -1
  98. package/scripts/sync-telemetry-from-prod.js +374 -0
  99. package/scripts/telemetry-analytics.js +9 -0
  100. package/scripts/thumbgate-search.js +85 -19
  101. package/scripts/tool-contract-validator.js +76 -0
  102. package/scripts/vector-store.js +44 -0
  103. package/scripts/workspace-evolver.js +62 -2
  104. package/src/api/server.js +862 -146
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const crypto = require('node:crypto');
8
+ const {
9
+ getFallbackFeedbackDir,
10
+ getGlobalFeedbackDir,
11
+ getHomeDir,
12
+ getLegacyFeedbackDir,
13
+ getThumbgateFeedbackDir,
14
+ resolveFeedbackDir,
15
+ resolveProjectDir,
16
+ } = require('./feedback-paths');
17
+ const { readJsonl } = require('./fs-utils');
18
+
19
+ const FEEDBACK_LOG = 'feedback-log.jsonl';
20
+ const MEMORY_LOG = 'memory-log.jsonl';
21
+ const STATUSLINE_CACHE = 'statusline_cache.json';
22
+
23
+ function truthyDisabled(value) {
24
+ return value === '0' || value === 'false' || value === 'local';
25
+ }
26
+
27
+ function shouldAggregateFeedback(options = {}) {
28
+ const env = options.env || process.env;
29
+ return !truthyDisabled(String(env.THUMBGATE_STATUSLINE_AGGREGATE || env.THUMBGATE_AGGREGATE_FEEDBACK || '1').toLowerCase());
30
+ }
31
+
32
+ function normalizePath(candidate) {
33
+ if (!candidate) return null;
34
+ try {
35
+ return path.resolve(String(candidate));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function uniquePaths(values = []) {
42
+ const seen = new Set();
43
+ const out = [];
44
+ for (const value of values) {
45
+ const resolved = normalizePath(value);
46
+ if (!resolved || seen.has(resolved)) continue;
47
+ seen.add(resolved);
48
+ out.push(resolved);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function safeExists(candidate) {
54
+ try {
55
+ return Boolean(candidate && fs.existsSync(candidate));
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function immediateChildDirs(parentDir) {
62
+ try {
63
+ return fs.readdirSync(parentDir, { withFileTypes: true })
64
+ .filter((entry) => entry.isDirectory())
65
+ .map((entry) => path.join(parentDir, entry.name));
66
+ } catch {
67
+ return [];
68
+ }
69
+ }
70
+
71
+ function ancestorProjectFeedbackDirs(projectDir, options = {}) {
72
+ const home = normalizePath(getHomeDir(options));
73
+ const start = normalizePath(projectDir);
74
+ if (!start) return [];
75
+
76
+ const dirs = [];
77
+ let cursor = start;
78
+ while (cursor && cursor !== path.dirname(cursor)) {
79
+ dirs.push(
80
+ path.join(cursor, '.thumbgate'),
81
+ path.join(cursor, '.thumbgate-compat'),
82
+ path.join(cursor, '.claude', 'memory', 'feedback')
83
+ );
84
+ if (home && cursor === home) break;
85
+ const next = path.dirname(cursor);
86
+ if (next === cursor) break;
87
+ cursor = next;
88
+ }
89
+ return dirs;
90
+ }
91
+
92
+ function listFeedbackStoreDirs(options = {}) {
93
+ const env = options.env || process.env;
94
+ const projectDir = resolveProjectDir({ cwd: options.cwd, env, projectDir: options.projectDir });
95
+ const home = getHomeDir({ env });
96
+ const homeThumbgate = path.join(home, '.thumbgate');
97
+ const projectsDir = path.join(homeThumbgate, 'projects');
98
+ const explicitRoots = String(env.THUMBGATE_AGGREGATE_ROOTS || '')
99
+ .split(path.delimiter)
100
+ .map((value) => value.trim())
101
+ .filter(Boolean);
102
+ const explicitFeedbackDir = options.feedbackDir || env.THUMBGATE_FEEDBACK_DIR;
103
+ const normalizedExplicitFeedbackDir = normalizePath(explicitFeedbackDir);
104
+
105
+ if (
106
+ normalizedExplicitFeedbackDir &&
107
+ normalizedExplicitFeedbackDir.startsWith(normalizePath(os.tmpdir()) + path.sep) &&
108
+ explicitRoots.length === 0
109
+ ) {
110
+ return uniquePaths([explicitFeedbackDir])
111
+ .filter((dir) => safeExists(path.join(dir, FEEDBACK_LOG)) || safeExists(path.join(dir, MEMORY_LOG)) || safeExists(path.join(dir, STATUSLINE_CACHE)));
112
+ }
113
+
114
+ return uniquePaths([
115
+ explicitFeedbackDir,
116
+ resolveFeedbackDir({ projectDir, env }),
117
+ getThumbgateFeedbackDir({ projectDir, env }),
118
+ getFallbackFeedbackDir({ projectDir, env }),
119
+ getLegacyFeedbackDir({ projectDir, env }),
120
+ getGlobalFeedbackDir({ projectDir, env }),
121
+ homeThumbgate,
122
+ ...immediateChildDirs(projectsDir),
123
+ ...ancestorProjectFeedbackDirs(projectDir, { env }),
124
+ ...explicitRoots,
125
+ ]).filter((dir) => safeExists(path.join(dir, FEEDBACK_LOG)) || safeExists(path.join(dir, MEMORY_LOG)) || safeExists(path.join(dir, STATUSLINE_CACHE)));
126
+ }
127
+
128
+ function normalizeSignal(signal) {
129
+ const raw = String(signal || '').toLowerCase();
130
+ if (raw === 'positive' || raw === 'up' || raw === 'thumbs_up') return 'positive';
131
+ if (raw === 'negative' || raw === 'down' || raw === 'thumbs_down') return 'negative';
132
+ return raw || null;
133
+ }
134
+
135
+ function stableEntryKey(entry = {}, source = {}) {
136
+ const id = entry.id || entry.feedbackId || entry.sourceFeedbackId;
137
+ if (id) return `id:${id}`;
138
+ const material = JSON.stringify({
139
+ sourcePath: source.logPath || '',
140
+ sourceIndex: Number.isFinite(source.index) ? source.index : -1,
141
+ entry,
142
+ });
143
+ return `sha:${crypto.createHash('sha256').update(material).digest('hex')}`;
144
+ }
145
+
146
+ function sortByTimestamp(entries) {
147
+ return entries.sort((a, b) => {
148
+ const at = a.timestamp ? Date.parse(a.timestamp) : 0;
149
+ const bt = b.timestamp ? Date.parse(b.timestamp) : 0;
150
+ return (Number.isFinite(at) ? at : 0) - (Number.isFinite(bt) ? bt : 0);
151
+ });
152
+ }
153
+
154
+ function collectAggregateLogEntries(fileName, options = {}) {
155
+ const stores = listFeedbackStoreDirs(options);
156
+ const seen = new Set();
157
+ const entries = [];
158
+
159
+ for (const dir of stores) {
160
+ const logPath = path.join(dir, fileName);
161
+ if (!safeExists(logPath)) continue;
162
+ const rows = readJsonl(logPath, { maxLines: 0 }) || [];
163
+ for (let index = 0; index < rows.length; index += 1) {
164
+ const rawEntry = rows[index];
165
+ const entry = { ...rawEntry };
166
+ if (entry.signal || entry.feedback) entry.signal = normalizeSignal(entry.signal || entry.feedback);
167
+ const key = stableEntryKey(entry, { logPath, index });
168
+ if (seen.has(key)) continue;
169
+ seen.add(key);
170
+ entries.push({ ...entry, sourceFeedbackDir: dir });
171
+ }
172
+ }
173
+
174
+ return {
175
+ entries: sortByTimestamp(entries),
176
+ stores,
177
+ };
178
+ }
179
+
180
+ function summarizeFeedbackEntries(entries) {
181
+ let totalPositive = 0;
182
+ let totalNegative = 0;
183
+ let rubricSamples = 0;
184
+
185
+ for (const entry of entries) {
186
+ if (entry.signal === 'positive') totalPositive += 1;
187
+ if (entry.signal === 'negative') totalNegative += 1;
188
+ if (entry.rubric && entry.rubric.weightedScore != null) rubricSamples += 1;
189
+ }
190
+
191
+ return { totalPositive, totalNegative, rubricSamples };
192
+ }
193
+
194
+ function createRateWindows(total, totalPositive, approvalRate) {
195
+ return {
196
+ '7d': { total: 0, positive: 0, rate: 0 },
197
+ '30d': { total: 0, positive: 0, rate: 0 },
198
+ lifetime: { total, positive: totalPositive, rate: approvalRate },
199
+ };
200
+ }
201
+
202
+ function applyEntryToRateWindow(window, entry) {
203
+ window.total += 1;
204
+ if (entry.signal === 'positive') window.positive += 1;
205
+ }
206
+
207
+ function updateRateWindows(windows, entries, now = Date.now()) {
208
+ const sevenDays = 7 * 24 * 60 * 60 * 1000;
209
+ const thirtyDays = 30 * 24 * 60 * 60 * 1000;
210
+
211
+ for (const entry of entries) {
212
+ const ts = entry.timestamp ? Date.parse(entry.timestamp) : Number.NaN;
213
+ if (!Number.isFinite(ts)) continue;
214
+ const age = now - ts;
215
+ if (age <= sevenDays) applyEntryToRateWindow(windows['7d'], entry);
216
+ if (age <= thirtyDays) applyEntryToRateWindow(windows['30d'], entry);
217
+ }
218
+ }
219
+
220
+ function finalizeRateWindows(windows) {
221
+ for (const key of ['7d', '30d']) {
222
+ const window = windows[key];
223
+ window.rate = 0;
224
+ if (window.total > 0) {
225
+ window.rate = Math.round((window.positive / window.total) * 1000) / 1000;
226
+ }
227
+ }
228
+ }
229
+
230
+ function trendFromRateWindows(windows) {
231
+ const hasTrendData = windows['7d'].total > 0 && windows['30d'].total > 0;
232
+ if (hasTrendData) {
233
+ if (windows['7d'].rate > windows['30d'].rate + 0.05) return 'improving';
234
+ if (windows['7d'].rate < windows['30d'].rate - 0.05) return 'degrading';
235
+ }
236
+ return 'stable';
237
+ }
238
+
239
+ function computeAggregateFeedbackStats(options = {}) {
240
+ const { entries, stores } = collectAggregateLogEntries(FEEDBACK_LOG, options);
241
+ const memory = collectAggregateLogEntries(MEMORY_LOG, options);
242
+ const { totalPositive, totalNegative, rubricSamples } = summarizeFeedbackEntries(entries);
243
+ const total = totalPositive + totalNegative;
244
+ const approvalRate = total > 0 ? Math.round((totalPositive / total) * 1000) / 1000 : 0;
245
+ const windows = createRateWindows(total, totalPositive, approvalRate);
246
+ updateRateWindows(windows, entries);
247
+ finalizeRateWindows(windows);
248
+ const trend = trendFromRateWindows(windows);
249
+
250
+ return {
251
+ total,
252
+ totalPositive,
253
+ totalNegative,
254
+ approvalRate,
255
+ recentRate: windows['7d'].rate || approvalRate,
256
+ trend,
257
+ windows,
258
+ rubric: { samples: rubricSamples || memory.entries.length, blockedPromotions: 0, failingCriteria: {} },
259
+ aggregate: {
260
+ enabled: true,
261
+ stores: stores.length,
262
+ feedbackLogPaths: stores.map((dir) => path.join(dir, FEEDBACK_LOG)),
263
+ memoryStores: memory.stores.length,
264
+ },
265
+ };
266
+ }
267
+
268
+ function getAggregateStatuslineCachePath(options = {}) {
269
+ const env = options.env || process.env;
270
+ if (env.THUMBGATE_STATUSLINE_CACHE) return path.resolve(env.THUMBGATE_STATUSLINE_CACHE);
271
+ return path.join(getHomeDir({ env }), '.thumbgate', STATUSLINE_CACHE);
272
+ }
273
+
274
+ module.exports = {
275
+ collectAggregateLogEntries,
276
+ computeAggregateFeedbackStats,
277
+ getAggregateStatuslineCachePath,
278
+ listFeedbackStoreDirs,
279
+ normalizeSignal,
280
+ shouldAggregateFeedback,
281
+ };
@@ -56,6 +56,90 @@ const {
56
56
 
57
57
  const AUDIT_TRAIL_TAG = 'audit-trail';
58
58
 
59
+ /**
60
+ * Anonymous fire-and-forget CLI feedback telemetry.
61
+ *
62
+ * Pings the hosted /v1/telemetry/ping endpoint exactly once per successful
63
+ * local feedback capture so the dashboard can measure CLI-side lesson volume.
64
+ *
65
+ * Hard contract (do NOT widen without explicit approval):
66
+ * - ONE event type: `feedback_captured`
67
+ * - Payload: { installId, signal: 'up'|'down', tier, ts } only.
68
+ * No context strings, tags, file paths, or content of any kind.
69
+ * - Opt-out: THUMBGATE_DISABLE_TELEMETRY=1 (or 'true') short-circuits
70
+ * immediately. Legacy THUMBGATE_NO_TELEMETRY=1 / DO_NOT_TRACK=1 are
71
+ * also honored for parity with cli-telemetry.js.
72
+ * - Fire-and-forget: NEVER await this call. Errors are swallowed.
73
+ * - 2-second timeout via AbortSignal.timeout.
74
+ */
75
+ function emitAnonymousFeedbackPing(signal) {
76
+ try {
77
+ const env = process.env || {};
78
+ if (
79
+ env.THUMBGATE_DISABLE_TELEMETRY === '1' ||
80
+ env.THUMBGATE_DISABLE_TELEMETRY === 'true' ||
81
+ env.THUMBGATE_NO_TELEMETRY === '1' ||
82
+ env.DO_NOT_TRACK === '1'
83
+ ) {
84
+ return;
85
+ }
86
+
87
+ const normalizedSignal = signal === 'positive' ? 'up' : signal === 'negative' ? 'down' : null;
88
+ if (!normalizedSignal) return;
89
+
90
+ // Reuse the canonical installId from cli-telemetry.js (persisted at
91
+ // ~/.thumbgate/install-id). Falls back to a fresh UUID if that module
92
+ // is unavailable — better to ship an event we can dedup on the server
93
+ // than to drop the ping entirely.
94
+ let installId = null;
95
+ try {
96
+ const { getInstallId } = require('./cli-telemetry');
97
+ installId = getInstallId();
98
+ } catch (_) { /* fall through */ }
99
+ if (!installId) {
100
+ try {
101
+ installId = require('crypto').randomUUID();
102
+ } catch (_) {
103
+ return; // no crypto, no install id → drop silently
104
+ }
105
+ }
106
+
107
+ let tier = 'free';
108
+ try {
109
+ const { getStatuslineMeta } = require('./statusline-meta');
110
+ const meta = getStatuslineMeta({ env });
111
+ const rawTier = String(meta && meta.tier ? meta.tier : 'free').toLowerCase();
112
+ if (rawTier === 'pro' || rawTier === 'enterprise' || rawTier === 'free') {
113
+ tier = rawTier;
114
+ }
115
+ } catch (_) { /* default to 'free' */ }
116
+
117
+ const base = env.THUMBGATE_PUBLIC_APP_ORIGIN
118
+ || env.THUMBGATE_API_URL
119
+ || 'https://thumbgate-production.up.railway.app';
120
+
121
+ const body = JSON.stringify({
122
+ eventType: 'feedback_captured',
123
+ clientType: 'cli',
124
+ installId,
125
+ signal: normalizedSignal,
126
+ tier,
127
+ ts: new Date().toISOString(),
128
+ });
129
+
130
+ // Fire-and-forget. No await. AbortSignal.timeout enforces the 2s cap.
131
+ if (typeof fetch !== 'function' || typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') {
132
+ return;
133
+ }
134
+ fetch(`${base.replace(/\/+$/, '')}/v1/telemetry/ping`, {
135
+ method: 'POST',
136
+ headers: { 'Content-Type': 'application/json' },
137
+ body,
138
+ signal: AbortSignal.timeout(2000),
139
+ }).catch(() => { /* fire-and-forget */ });
140
+ } catch (_) { /* telemetry must never disrupt CLI */ }
141
+ }
142
+
59
143
  function isAuditTrailEntry(entry = {}) {
60
144
  return Array.isArray(entry.tags) && entry.tags.includes(AUDIT_TRAIL_TAG);
61
145
  }
@@ -1113,6 +1197,7 @@ function captureFeedback(params) {
1113
1197
  summary.lastUpdated = now;
1114
1198
  saveSummary(summary);
1115
1199
  appendJSONL(FEEDBACK_LOG_PATH, feedbackEvent);
1200
+ emitAnonymousFeedbackPing(signal);
1116
1201
  try { appendRejectionLedger(feedbackEvent, action.reason); } catch { /* non-critical */ }
1117
1202
  try {
1118
1203
  appendSequence(historyEntries, feedbackEvent, getFeedbackPaths(), { accepted: false });
@@ -1154,6 +1239,7 @@ function captureFeedback(params) {
1154
1239
  ...feedbackEvent,
1155
1240
  validationIssues: prepared.issues,
1156
1241
  });
1242
+ emitAnonymousFeedbackPing(signal);
1157
1243
  try { appendRejectionLedger(feedbackEvent, `Schema validation failed: ${prepared.issues.join('; ')}`); } catch { /* non-critical */ }
1158
1244
  try {
1159
1245
  appendSequence(historyEntries, feedbackEvent, getFeedbackPaths(), { accepted: false });
@@ -1228,6 +1314,7 @@ function captureFeedback(params) {
1228
1314
  }
1229
1315
 
1230
1316
  appendJSONL(FEEDBACK_LOG_PATH, feedbackEvent);
1317
+ emitAnonymousFeedbackPing(signal);
1231
1318
 
1232
1319
  // Synthesis: merge similar lessons instead of creating duplicates
1233
1320
  let synthesisResult = null;
@@ -1412,10 +1499,44 @@ function captureFeedback(params) {
1412
1499
  totalGates: promoteResult.totalGates,
1413
1500
  });
1414
1501
  } catch { /* activation telemetry is non-critical */ }
1502
+
1503
+ // Trigger Self-Harness Optimizer to propagate the new rules to prompt files & validate
1504
+ try {
1505
+ const { fork } = require('child_process');
1506
+ const localOptimizerPath = path.join(process.cwd(), 'scripts', 'self-harness-optimizer.js');
1507
+ const packageOptimizerPath = path.join(__dirname, 'self-harness-optimizer.js');
1508
+
1509
+ if (fs.existsSync(localOptimizerPath)) {
1510
+ fork(localOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
1511
+ } else if (fs.existsSync(packageOptimizerPath)) {
1512
+ fork(packageOptimizerPath, [], { stdio: 'ignore', detached: true }).unref();
1513
+ }
1514
+ } catch (err) {
1515
+ console.error('Failed to trigger self-harness optimizer:', err);
1516
+ }
1415
1517
  }
1416
1518
  } catch { /* Gate promotion is non-critical */ }
1417
1519
  }
1418
1520
 
1521
+ // Auto-export to Obsidian if configured (deferred but tracked)
1522
+ if (process.env.THUMBGATE_OBSIDIAN_VAULT_PATH) {
1523
+ const exportPromise = new Promise((resolve) => {
1524
+ setImmediate(() => {
1525
+ try {
1526
+ const { exportAll } = require('./obsidian-export');
1527
+ exportAll({
1528
+ feedbackDir: FEEDBACK_DIR,
1529
+ outputDir: process.env.THUMBGATE_OBSIDIAN_VAULT_PATH,
1530
+ });
1531
+ } catch (_err) {
1532
+ // Non-critical, do not crash feedback loop
1533
+ }
1534
+ resolve();
1535
+ });
1536
+ });
1537
+ trackBackgroundSideEffect(exportPromise);
1538
+ }
1539
+
1419
1540
  // --- Deferred side-effects (contextFs, RLAIF — non-critical, potentially slow) ---
1420
1541
  setImmediate(() => {
1421
1542
  try {
@@ -53,10 +53,31 @@ function listJsonFiles(dirPath) {
53
53
  return results;
54
54
  }
55
55
 
56
+ const STOPWORDS = new Set([
57
+ 'a', 'about', 'above', 'after', 'again', 'against', 'all', 'am', 'an', 'and', 'any', 'are', 'arent', 'as', 'at',
58
+ 'be', 'because', 'been', 'before', 'being', 'below', 'between', 'both', 'but', 'by',
59
+ 'cant', 'cannot', 'could', 'couldnt', 'did', 'didnt', 'do', 'does', 'doesnt', 'doing', 'dont', 'down', 'during',
60
+ 'each', 'few', 'for', 'from', 'further', 'had', 'hadnt', 'has', 'hasnt', 'have', 'havent', 'having',
61
+ 'he', 'hed', 'hell', 'hes', 'her', 'here', 'heres', 'hers', 'herself', 'him', 'himself', 'his',
62
+ 'how', 'hows', 'i', 'id', 'ill', 'im', 'ive', 'if', 'in', 'into', 'is', 'isnt', 'it', 'its', 'itself',
63
+ 'lets', 'me', 'more', 'most', 'mustnt', 'my', 'myself',
64
+ 'no', 'nor', 'not', 'of', 'off', 'on', 'once', 'only', 'or', 'other', 'ought', 'our', 'ours', 'ourselves', 'out', 'over', 'own',
65
+ 'same', 'shant', 'she', 'shed', 'shell', 'shes', 'should', 'shouldnt', 'so', 'some', 'such',
66
+ 'than', 'that', 'thats', 'the', 'their', 'theirs', 'them', 'themselves', 'then', 'there', 'theres', 'these', 'they', 'theyd', 'theyll', 'theyre', 'theyve', 'this', 'those', 'through', 'to', 'too', 'under', 'until', 'up', 'very',
67
+ 'was', 'wasnt', 'we', 'wed', 'well', 'were', 'weve', 'werent', 'what', 'whats', 'when', 'whens', 'where', 'wheres', 'which', 'while', 'who', 'whos', 'whom', 'why', 'whys',
68
+ 'with', 'wont', 'would', 'wouldnt', 'you', 'youd', 'youll', 'youre', 'youve', 'your', 'yours', 'yourself', 'yourselves'
69
+ ]);
70
+
56
71
  function tokenize(text) {
57
72
  return String(text || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
58
73
  }
59
74
 
75
+ function getSearchTokens(queryText) {
76
+ const allTokens = tokenize(queryText);
77
+ const contentTokens = allTokens.filter(t => !STOPWORDS.has(t));
78
+ return contentTokens.length > 0 ? contentTokens : allTokens;
79
+ }
80
+
60
81
  function unique(arr) {
61
82
  return [...new Set(arr)];
62
83
  }
@@ -82,7 +103,7 @@ function substringBoost(query, text) {
82
103
  const q = query.toLowerCase();
83
104
  const t = text.toLowerCase();
84
105
  if (t.includes(q)) return 0.3;
85
- const words = q.split(/\s+/).filter((w) => w.length > 2);
106
+ const words = q.split(/\s+/).filter((w) => w.length > 2 && !STOPWORDS.has(w));
86
107
  const matched = words.filter((w) => t.includes(w)).length;
87
108
  return words.length > 0 ? (matched / words.length) * 0.2 : 0;
88
109
  }
@@ -117,8 +138,12 @@ function scoreRecord(queryTokens, queryText, record) {
117
138
  const recency = recencyScore(record.timestamp);
118
139
  const signalBoost = record.signal === 'down' ? 0.05 : 0;
119
140
 
141
+ const matchScore = jaccard + substr;
142
+ const isWildcard = !queryText || queryText === '*';
143
+ const score = isWildcard ? (recency + signalBoost || 0.01) : (matchScore > 0 ? matchScore + recency + signalBoost : 0);
144
+
120
145
  return {
121
- score: jaccard + substr + recency + signalBoost,
146
+ score: Number(score.toFixed(4)),
122
147
  record,
123
148
  matchedTokens: unique(queryTokens).filter((t) => new Set(recordTokens).has(t)),
124
149
  };
@@ -129,7 +154,7 @@ function scoreRecord(queryTokens, queryText, record) {
129
154
  // ---------------------------------------------------------------------------
130
155
 
131
156
  function searchFeedbackLog(queryText, limit = 5, options = {}) {
132
- const logPath = path.join(getFeedbackDir(), 'feedback-log.jsonl');
157
+ const logPath = path.join(options.feedbackDir || getFeedbackDir(), 'feedback-log.jsonl');
133
158
  let records = readJsonl(logPath);
134
159
 
135
160
  // SQLite fallback: if JSONL is empty/tiny, pull records from the lesson DB
@@ -156,7 +181,7 @@ function searchFeedbackLog(queryText, limit = 5, options = {}) {
156
181
 
157
182
  // Wildcard query: return all records sorted by recency
158
183
  const isWildcard = queryText === '*' || queryText === '';
159
- const queryTokens = isWildcard ? [] : tokenize(queryText);
184
+ const queryTokens = isWildcard ? [] : getSearchTokens(queryText);
160
185
 
161
186
  let scored = isWildcard
162
187
  ? records.map((r) => ({ score: recencyScore(r.timestamp) || 0.01, record: r, matchedTokens: [] }))
@@ -186,9 +211,9 @@ function searchFeedbackLog(queryText, limit = 5, options = {}) {
186
211
  }
187
212
 
188
213
  function searchContextFs(queryText, limit = 5, options = {}) {
189
- const contextDir = getContextFsDir();
214
+ const contextDir = options.contextDir || (options.feedbackDir ? path.join(options.feedbackDir, 'contextfs') : getContextFsDir());
190
215
  const namespaces = options.namespaces || ['memory/error', 'memory/learning', 'rules', 'raw_history'];
191
- const queryTokens = tokenize(queryText);
216
+ const queryTokens = getSearchTokens(queryText);
192
217
  const scored = [];
193
218
 
194
219
  for (const ns of namespaces) {
@@ -221,12 +246,12 @@ function searchContextFs(queryText, limit = 5, options = {}) {
221
246
  }));
222
247
  }
223
248
 
224
- function searchPreventionRules(queryText, limit = 5) {
225
- const rulesPath = path.join(getFeedbackDir(), 'prevention-rules.md');
249
+ function searchPreventionRules(queryText, limit = 5, options = {}) {
250
+ const rulesPath = path.join(options.feedbackDir || getFeedbackDir(), 'prevention-rules.md');
226
251
  if (!fs.existsSync(rulesPath)) return [];
227
252
 
228
253
  const content = fs.readFileSync(rulesPath, 'utf-8');
229
- const queryTokens = tokenize(queryText);
254
+ const queryTokens = getSearchTokens(queryText);
230
255
  const blocks = content.split(/^#{1,3}\s+/m).filter(Boolean);
231
256
 
232
257
  return blocks
@@ -252,7 +277,7 @@ function searchPreventionRules(queryText, limit = 5) {
252
277
  function searchAll(queryText, limit = 10, options = {}) {
253
278
  const feedbackResults = searchFeedbackLog(queryText, limit, options);
254
279
  const contextResults = searchContextFs(queryText, limit, options);
255
- const ruleResults = searchPreventionRules(queryText, limit);
280
+ const ruleResults = searchPreventionRules(queryText, limit, options);
256
281
 
257
282
  const merged = [
258
283
  ...feedbackResults.map((r) => ({ ...r, _source_type: 'feedback' })),