thumbgate 1.27.6 → 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 (96) hide show
  1. package/.claude/commands/thumbgate-blocked.md +27 -0
  2. package/.claude/commands/thumbgate-doctor.md +30 -0
  3. package/.claude/commands/thumbgate-guard.md +36 -0
  4. package/.claude/commands/thumbgate-protect.md +30 -0
  5. package/.claude/commands/thumbgate-rules.md +30 -0
  6. package/.claude-plugin/plugin.json +1 -1
  7. package/.well-known/llms.txt +6 -2
  8. package/.well-known/mcp/server-card.json +1 -1
  9. package/README.md +49 -5
  10. package/adapters/claude/.mcp.json +2 -2
  11. package/adapters/letta/README.md +41 -0
  12. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  13. package/adapters/mcp/server-stdio.js +16 -1
  14. package/adapters/opencode/opencode.json +1 -1
  15. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  16. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  17. package/bench/observability-eval-suite.json +26 -0
  18. package/bin/cli.js +180 -2
  19. package/bin/postinstall.js +1 -1
  20. package/config/gate-templates.json +84 -0
  21. package/config/gates/claim-verification.json +6 -0
  22. package/config/gates/default.json +20 -0
  23. package/config/github-about.json +1 -1
  24. package/config/model-candidates.json +50 -0
  25. package/package.json +65 -25
  26. package/public/agent-manager.html +41 -1
  27. package/public/agents-cost-savings.html +1 -1
  28. package/public/ai-malpractice-prevention.html +2 -1
  29. package/public/assets/brand/github-social-preview.png +0 -0
  30. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  31. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  32. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  33. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  34. package/public/assets/brand/thumbgate-mark-inline.svg +15 -0
  35. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  36. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  37. package/public/assets/brand/thumbgate-mark.svg +15 -0
  38. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  39. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  40. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  41. package/public/assets/legal-intake-control-flow.svg +66 -0
  42. package/public/blog.html +1 -1
  43. package/public/brand/thumbgate-mark.svg +15 -0
  44. package/public/brand/thumbgate-og.svg +16 -0
  45. package/public/codex-enterprise.html +1 -1
  46. package/public/codex-plugin.html +1 -1
  47. package/public/compare.html +23 -3
  48. package/public/dashboard.html +312 -30
  49. package/public/federal.html +1 -1
  50. package/public/guide.html +5 -4
  51. package/public/index.html +167 -49
  52. package/public/js/buyer-intent.js +672 -0
  53. package/public/learn.html +74 -7
  54. package/public/lessons.html +2 -1
  55. package/public/numbers.html +3 -3
  56. package/public/pricing.html +63 -15
  57. package/public/pro.html +7 -7
  58. package/scripts/activation-quickstart.js +187 -0
  59. package/scripts/agent-memory-lifecycle.js +211 -0
  60. package/scripts/async-eval-observability.js +236 -0
  61. package/scripts/auto-promote-gates.js +75 -4
  62. package/scripts/build-metadata.js +24 -3
  63. package/scripts/cli-schema.js +22 -0
  64. package/scripts/dashboard-chat.js +2 -1
  65. package/scripts/dashboard.js +8 -0
  66. package/scripts/export-databricks-bundle.js +5 -1
  67. package/scripts/export-dpo-pairs.js +7 -2
  68. package/scripts/feedback-aggregate.js +281 -0
  69. package/scripts/feedback-loop.js +34 -0
  70. package/scripts/filesystem-search.js +35 -10
  71. package/scripts/gates-engine.js +198 -6
  72. package/scripts/gemini-embedding-policy.js +2 -1
  73. package/scripts/hook-stop-anti-claim.js +227 -0
  74. package/scripts/hook-thumbgate-cache-updater.js +18 -2
  75. package/scripts/lesson-inference.js +8 -3
  76. package/scripts/lesson-search.js +17 -1
  77. package/scripts/operational-integrity.js +39 -5
  78. package/scripts/plausible-domain-config.js +4 -2
  79. package/scripts/rate-limiter.js +12 -6
  80. package/scripts/secret-redaction.js +166 -0
  81. package/scripts/security-scanner.js +100 -0
  82. package/scripts/self-distill-agent.js +3 -1
  83. package/scripts/self-harness-optimizer.js +141 -0
  84. package/scripts/seo-gsd.js +635 -0
  85. package/scripts/statusline-cache-path.js +17 -2
  86. package/scripts/statusline-cache-read.js +57 -0
  87. package/scripts/statusline-local-stats.js +9 -1
  88. package/scripts/statusline-meta.js +5 -2
  89. package/scripts/statusline.sh +13 -1
  90. package/scripts/sync-telemetry-from-prod.js +374 -0
  91. package/scripts/telemetry-analytics.js +9 -0
  92. package/scripts/thumbgate-search.js +85 -19
  93. package/scripts/tool-contract-validator.js +76 -0
  94. package/scripts/vector-store.js +44 -0
  95. package/scripts/workspace-evolver.js +62 -2
  96. package/src/api/server.js +715 -86
@@ -49,6 +49,10 @@ const {
49
49
  readDecisionLog,
50
50
  } = require('./decision-journal');
51
51
  const { analyzeFeedback } = require('./feedback-loop');
52
+ const {
53
+ collectAggregateLogEntries,
54
+ shouldAggregateFeedback,
55
+ } = require('./feedback-aggregate');
52
56
 
53
57
  const PROJECT_ROOT = path.join(__dirname, '..');
54
58
  const DEFAULT_GATES_PATH = path.join(PROJECT_ROOT, 'config', 'gates', 'default.json');
@@ -1545,6 +1549,10 @@ function resolveTeamWindowHours(analyticsWindow) {
1545
1549
  // ---------------------------------------------------------------------------
1546
1550
 
1547
1551
  function collectAllFeedbackEntries(feedbackDir) {
1552
+ if (shouldAggregateFeedback()) {
1553
+ return collectAggregateLogEntries('feedback-log.jsonl', { feedbackDir }).entries;
1554
+ }
1555
+
1548
1556
  const entries = [];
1549
1557
  const seen = new Set();
1550
1558
 
@@ -6,6 +6,7 @@ const path = require('path');
6
6
 
7
7
  const { getFeedbackPaths } = require('./feedback-loop');
8
8
  const { ensureDir } = require('./fs-utils');
9
+ const { redactSecretsDeep } = require('./secret-redaction');
9
10
 
10
11
  const PROJECT_ROOT = path.join(__dirname, '..');
11
12
  const DEFAULT_PROOF_DIR = process.env.THUMBGATE_PROOF_DIR
@@ -47,7 +48,10 @@ function readJSON(filePath) {
47
48
  }
48
49
 
49
50
  function writeJSONL(filePath, rows) {
50
- const content = rows.map((row) => JSON.stringify(row)).join('\n');
51
+ // Redact secrets from every bundle row this is the single choke point for all bundle tables
52
+ // (feedback_events, memory_records, sequences, attributions, proof_reports). A shared/published
53
+ // dataset must never ship a captured credential. See scripts/secret-redaction.js.
54
+ const content = rows.map((row) => JSON.stringify(redactSecretsDeep(row))).join('\n');
51
55
  fs.writeFileSync(filePath, content ? `${content}\n` : '');
52
56
  }
53
57
 
@@ -9,6 +9,7 @@ const fs = require('fs');
9
9
  const path = require('path');
10
10
  const { traceForDpoPair, aggregateTraces } = require('./code-reasoning');
11
11
  const { resolveFeedbackDir } = require('./feedback-paths');
12
+ const { redactSecretsDeep } = require('./secret-redaction');
12
13
 
13
14
  const DEFAULT_LOCAL_MEMORY_LOG = path.join(resolveFeedbackDir(), 'memory-log.jsonl');
14
15
 
@@ -201,14 +202,18 @@ function exportDpoFromMemories(memories) {
201
202
  },
202
203
  }));
203
204
 
205
+ // Redact secrets before the pairs leave this module — they are derived from memory content and
206
+ // are shipped to disk here AND consumed by export-hf-dataset.js. See scripts/secret-redaction.js.
207
+ const redactedPairs = pairsWithTraces.map((pair) => redactSecretsDeep(pair));
208
+
204
209
  return {
205
- pairs: pairsWithTraces,
210
+ pairs: redactedPairs,
206
211
  unpairedErrors: result.unpairedErrors,
207
212
  unpairedLearnings: result.unpairedLearnings,
208
213
  errors,
209
214
  learnings,
210
215
  reasoning,
211
- jsonl: toJSONL(pairsWithTraces),
216
+ jsonl: toJSONL(redactedPairs),
212
217
  };
213
218
  }
214
219
 
@@ -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
+ };
@@ -1499,10 +1499,44 @@ function captureFeedback(params) {
1499
1499
  totalGates: promoteResult.totalGates,
1500
1500
  });
1501
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
+ }
1502
1517
  }
1503
1518
  } catch { /* Gate promotion is non-critical */ }
1504
1519
  }
1505
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
+
1506
1540
  // --- Deferred side-effects (contextFs, RLAIF — non-critical, potentially slow) ---
1507
1541
  setImmediate(() => {
1508
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' })),