thumbgate 1.4.1 → 1.4.3
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/README.md +45 -34
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +3 -3
- package/.well-known/llms.txt +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +26 -2
- package/adapters/README.md +4 -1
- package/adapters/chatgpt/INSTALL.md +39 -19
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +2 -2
- package/adapters/mcp/server-stdio.js +10 -4
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/perplexity/.mcp.json +36 -0
- package/adapters/perplexity/config.toml +16 -0
- package/adapters/perplexity/opencode.json +29 -0
- package/bin/cli.js +246 -90
- package/config/mcp-allowlists.json +11 -3
- package/package.json +28 -13
- package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-codex-bridge/.mcp.json +1 -1
- package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
- package/plugins/codex-profile/.mcp.json +1 -1
- package/plugins/codex-profile/INSTALL.md +1 -1
- package/plugins/codex-profile/README.md +1 -1
- package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
- package/plugins/opencode-profile/INSTALL.md +1 -1
- package/public/index.html +121 -24
- package/public/llm-context.md +17 -1
- package/scripts/ai-search-visibility.js +10 -36
- package/scripts/audit-trail.js +25 -15
- package/scripts/auto-wire-hooks.js +127 -0
- package/scripts/cli-demo.js +102 -0
- package/scripts/cli-schema.js +285 -0
- package/scripts/cli-status.js +166 -0
- package/scripts/cross-encoder-reranker.js +235 -0
- package/scripts/explore-subcommands.js +277 -0
- package/scripts/explore.js +569 -0
- package/scripts/feedback-loop.js +20 -6
- package/scripts/lesson-inference.js +27 -2
- package/scripts/lesson-reranker.js +263 -0
- package/scripts/lesson-retrieval.js +34 -17
- package/scripts/lesson-search.js +69 -0
- package/scripts/perplexity-client.js +210 -0
- package/scripts/perplexity-command-center.js +644 -0
- package/scripts/perplexity-marketing.js +17 -29
- package/scripts/prove-packaged-runtime.js +5 -4
- package/scripts/ralph-mode-ci.js +122 -19
- package/scripts/reflector-agent.js +2 -2
- package/scripts/session-analyzer.js +533 -0
- package/scripts/social-analytics/db/marketing-db.js +179 -0
- package/scripts/social-analytics/db/schema.sql +23 -0
- package/scripts/social-analytics/generate-instagram-card.js +31 -5
- package/scripts/social-analytics/generate-slides.js +268 -0
- package/scripts/social-analytics/post-video.js +316 -0
- package/scripts/social-analytics/publishers/zernio.js +52 -23
- package/scripts/statusline-local-stats.js +3 -1
- package/scripts/statusline.sh +15 -10
- package/scripts/thumbgate-bench.js +494 -0
- package/src/api/server.js +65 -1
- package/scripts/social-analytics/db/analytics.sqlite +0 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* thumbgate explore — interactive TUI explorer for lessons, gates, stats, and rules.
|
|
6
|
+
*
|
|
7
|
+
* Inspired by Cloudflare's Local Explorer concept: a zero-dependency, keyboard-driven
|
|
8
|
+
* interface for discovering what your ThumbGate instance has learned and enforces.
|
|
9
|
+
*
|
|
10
|
+
* Keys:
|
|
11
|
+
* 1-4 / Tab switch tabs
|
|
12
|
+
* ↑ / k move up
|
|
13
|
+
* ↓ / j move down
|
|
14
|
+
* / start search filter
|
|
15
|
+
* Enter view detail
|
|
16
|
+
* Esc / q go back / quit
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const readline = require('node:readline');
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// ANSI helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const A = {
|
|
27
|
+
clear: '\x1b[2J\x1b[H',
|
|
28
|
+
b: '\x1b[1m', // bold
|
|
29
|
+
d: '\x1b[2m', // dim
|
|
30
|
+
r: '\x1b[0m', // reset
|
|
31
|
+
inv: '\x1b[7m', // inverse (highlight)
|
|
32
|
+
ul: '\x1b[4m', // underline
|
|
33
|
+
cy: '\x1b[36m', // cyan
|
|
34
|
+
gn: '\x1b[32m', // green
|
|
35
|
+
rd: '\x1b[31m', // red
|
|
36
|
+
yl: '\x1b[33m', // yellow
|
|
37
|
+
mg: '\x1b[35m', // magenta
|
|
38
|
+
gy: '\x1b[90m', // gray
|
|
39
|
+
wh: '\x1b[97m', // bright white
|
|
40
|
+
bgCy: '\x1b[46m\x1b[30m', // cyan bg + black text
|
|
41
|
+
hideCursor:'\x1b[?25l',
|
|
42
|
+
showCursor:'\x1b[?25h',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function cols() { return process.stdout.columns || 80; }
|
|
46
|
+
function rows() { return process.stdout.rows || 24; }
|
|
47
|
+
function hr(ch = '─') { return ch.repeat(cols()); }
|
|
48
|
+
function pad(str, w) { const s = String(str || ''); return s.length >= w ? s.slice(0, w) : s + ' '.repeat(w - s.length); }
|
|
49
|
+
function trunc(str, max) {
|
|
50
|
+
const s = String(str || '');
|
|
51
|
+
if (max <= 0) return '';
|
|
52
|
+
if (max === 1) return s.length > 1 ? '…' : s;
|
|
53
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function relDate(ts) {
|
|
57
|
+
if (!ts) return '';
|
|
58
|
+
const time = new Date(ts).getTime();
|
|
59
|
+
if (!Number.isFinite(time)) return '';
|
|
60
|
+
const d = Math.floor((Date.now() - time) / 86400000);
|
|
61
|
+
if (d === 0) return 'today';
|
|
62
|
+
if (d === 1) return '1d ago';
|
|
63
|
+
return `${d}d ago`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function write(s) { process.stdout.write(s); }
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Data loaders
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
function loadLessons(feedbackDir) {
|
|
73
|
+
const p = path.join(feedbackDir, 'memory-log.jsonl');
|
|
74
|
+
if (!fs.existsSync(p)) return [];
|
|
75
|
+
return fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).map(l => {
|
|
76
|
+
try { return JSON.parse(l); } catch { return null; }
|
|
77
|
+
}).filter(Boolean).reverse(); // newest first
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function loadGates(pkgRoot) {
|
|
81
|
+
const gatesDir = path.join(pkgRoot, 'config', 'gates');
|
|
82
|
+
const gates = [];
|
|
83
|
+
if (!fs.existsSync(gatesDir)) return gates;
|
|
84
|
+
for (const f of fs.readdirSync(gatesDir).sort((a, b) => a.localeCompare(b))) {
|
|
85
|
+
if (!f.endsWith('.json') || f === 'custom.json') continue;
|
|
86
|
+
try {
|
|
87
|
+
const raw = JSON.parse(fs.readFileSync(path.join(gatesDir, f), 'utf8'));
|
|
88
|
+
const items = Array.isArray(raw) ? raw : (raw.gates || raw.rules || [raw]);
|
|
89
|
+
items.forEach(g => gates.push({ ...g, _file: f }));
|
|
90
|
+
} catch { /* skip malformed */ }
|
|
91
|
+
}
|
|
92
|
+
// Custom gates
|
|
93
|
+
const customPath = path.join(pkgRoot, 'config', 'gates', 'custom.json');
|
|
94
|
+
if (fs.existsSync(customPath)) {
|
|
95
|
+
try {
|
|
96
|
+
const custom = JSON.parse(fs.readFileSync(customPath, 'utf8'));
|
|
97
|
+
(Array.isArray(custom) ? custom : custom.gates || []).forEach(g =>
|
|
98
|
+
gates.push({ ...g, _file: 'custom.json', _custom: true })
|
|
99
|
+
);
|
|
100
|
+
} catch { /* ignore */ }
|
|
101
|
+
}
|
|
102
|
+
return gates;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function loadStats(feedbackDir) {
|
|
106
|
+
const p = path.join(feedbackDir, 'feedback-summary.json');
|
|
107
|
+
if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch {} }
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function loadRules(feedbackDir) {
|
|
112
|
+
const p = path.join(feedbackDir, 'prevention-rules.md');
|
|
113
|
+
if (!fs.existsSync(p)) return [];
|
|
114
|
+
return fs.readFileSync(p, 'utf8').split('\n')
|
|
115
|
+
.filter(l => l.trim())
|
|
116
|
+
.map((text, i) => ({ id: i, text }));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Render helpers
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
const TABS = ['Lessons', 'Gates', 'Stats', 'Rules'];
|
|
124
|
+
|
|
125
|
+
const KEY_MAP = new Map([
|
|
126
|
+
['\x1b[A', 'up'],
|
|
127
|
+
['\x1b[B', 'down'],
|
|
128
|
+
['\x1b[C', 'right'],
|
|
129
|
+
['\x1b[D', 'left'],
|
|
130
|
+
['\r', 'return'],
|
|
131
|
+
['\n', 'return'],
|
|
132
|
+
['\x1b', 'escape'],
|
|
133
|
+
['\x7f', 'backspace'],
|
|
134
|
+
['\t', 'tab'],
|
|
135
|
+
]);
|
|
136
|
+
const EXIT_KEY = '\x03';
|
|
137
|
+
|
|
138
|
+
function normalizeKeyData(input) {
|
|
139
|
+
if (Buffer.isBuffer(input)) return input.toString('utf8');
|
|
140
|
+
if (typeof input === 'string') return input;
|
|
141
|
+
return '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function listHeight(state) {
|
|
145
|
+
return Math.max(1, rows() - 8 - (state.query ? 2 : 0));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* c8 ignore start -- terminal drawing is integration-tested through CLI smoke tests. */
|
|
149
|
+
|
|
150
|
+
function renderHeader(state) {
|
|
151
|
+
const version = (() => { try { return require('../package.json').version; } catch { return '?'; } })();
|
|
152
|
+
const title = `${A.b}${A.cy}thumbgate explore${A.r} v${version}`;
|
|
153
|
+
const hint = `${A.gy}q quit / search ↑↓ navigate Enter detail${A.r}`;
|
|
154
|
+
const tabLine = TABS.map((t, i) => {
|
|
155
|
+
const active = i === state.tab;
|
|
156
|
+
const key = `${i + 1}`;
|
|
157
|
+
return active
|
|
158
|
+
? `${A.b}${A.bgCy} ${key}:${t} ${A.r}`
|
|
159
|
+
: `${A.gy} ${key}:${t} ${A.r}`;
|
|
160
|
+
}).join(' ');
|
|
161
|
+
|
|
162
|
+
write(A.clear);
|
|
163
|
+
write(`${title} ${A.gy}│${A.r} ${hint}\n`);
|
|
164
|
+
write(`${A.cy}${hr()}\n${A.r}`);
|
|
165
|
+
write(`${tabLine}\n`);
|
|
166
|
+
write(`${A.gy}${hr()}\n${A.r}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderSearchBar(state) {
|
|
170
|
+
if (state.mode === 'search') {
|
|
171
|
+
write(`${A.yl}/${A.r} ${state.query}${A.inv} ${A.r}\n`);
|
|
172
|
+
write(`${A.gy}${hr()}\n${A.r}`);
|
|
173
|
+
} else if (state.query) {
|
|
174
|
+
write(`${A.gy}filter: ${A.yl}${state.query}${A.r} (press / to edit, Esc to clear)\n`);
|
|
175
|
+
write(`${A.gy}${hr()}\n${A.r}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function renderLessons(state) {
|
|
180
|
+
const items = state.filtered;
|
|
181
|
+
const listH = listHeight(state);
|
|
182
|
+
const start = Math.max(0, state.cursor - Math.floor(listH / 2));
|
|
183
|
+
const visible = items.slice(start, start + listH);
|
|
184
|
+
|
|
185
|
+
if (items.length === 0) {
|
|
186
|
+
const suffix = state.query
|
|
187
|
+
? ` Matching "${state.query}".`
|
|
188
|
+
: ' Run: npx thumbgate capture';
|
|
189
|
+
write(`\n ${A.gy}No lessons found.${suffix}${A.r}\n`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
visible.forEach((m, i) => {
|
|
194
|
+
const idx = start + i;
|
|
195
|
+
const selected = idx === state.cursor;
|
|
196
|
+
const signal = (m.tags || []).includes('negative') ? `${A.rd}●${A.r}` : `${A.gn}●${A.r}`;
|
|
197
|
+
const age = `${A.gy}${pad(relDate(m.timestamp), 8)}${A.r}`;
|
|
198
|
+
const titleText = trunc(m.title || m.context || '(untitled)', cols() - 20);
|
|
199
|
+
const row = ` ${signal} ${pad(titleText, cols() - 20)} ${age}`;
|
|
200
|
+
write(selected ? `${A.inv}${row}${A.r}\n` : `${row}\n`);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const total = items.length;
|
|
204
|
+
write(`\n ${A.gy}${total} lesson${total !== 1 ? 's' : ''}${state.query ? ` matching "${state.query}"` : ''}${A.r}\n`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderGates(state) {
|
|
208
|
+
const items = state.filtered;
|
|
209
|
+
const listH = listHeight(state);
|
|
210
|
+
const start = Math.max(0, state.cursor - Math.floor(listH / 2));
|
|
211
|
+
const visible = items.slice(start, start + listH);
|
|
212
|
+
|
|
213
|
+
if (items.length === 0) {
|
|
214
|
+
write(`\n ${A.gy}No gates configured.${A.r}\n`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
visible.forEach((g, i) => {
|
|
219
|
+
const idx = start + i;
|
|
220
|
+
const selected = idx === state.cursor;
|
|
221
|
+
const custom = g._custom ? `${A.mg}[custom]${A.r} ` : '';
|
|
222
|
+
const src = `${A.gy}${g._file || ''}${A.r}`;
|
|
223
|
+
const name = g.pattern || g.name || g.id || '(unnamed)';
|
|
224
|
+
const row = ` ${custom}${trunc(name, cols() - 24)} ${src}`;
|
|
225
|
+
write(selected ? `${A.inv}${row}${A.r}\n` : `${row}\n`);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
write(`\n ${A.gy}${items.length} gate rule${items.length !== 1 ? 's' : ''}${A.r}\n`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function renderStats(state) {
|
|
232
|
+
const stats = state.data.stats;
|
|
233
|
+
if (!stats) {
|
|
234
|
+
write(`\n ${A.gy}No stats available. Run some feedback captures first.${A.r}\n`);
|
|
235
|
+
write(` ${A.d}npx thumbgate capture --feedback=down --context="..." --what-went-wrong="..."${A.r}\n`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
write('\n');
|
|
239
|
+
const kv = (label, val, color = A.wh) => {
|
|
240
|
+
write(` ${A.gy}${pad(label, 24)}${A.r}${color}${val}${A.r}\n`);
|
|
241
|
+
};
|
|
242
|
+
kv('Total captures', stats.total ?? 0, A.b + A.wh);
|
|
243
|
+
kv('Thumbs up (👍)', stats.positives ?? 0, A.gn);
|
|
244
|
+
kv('Thumbs down (👎)', stats.negatives ?? 0, A.rd);
|
|
245
|
+
kv('Lessons stored', state.data.lessons.length, A.cy);
|
|
246
|
+
kv('Gates active', state.data.gates.length, A.yl);
|
|
247
|
+
|
|
248
|
+
if (stats.topTags && stats.topTags.length > 0) {
|
|
249
|
+
write(`\n ${A.b}Top tags:${A.r}\n`);
|
|
250
|
+
stats.topTags.slice(0, 8).forEach(([tag, count]) => {
|
|
251
|
+
const bar = '█'.repeat(Math.min(count, 20));
|
|
252
|
+
write(` ${A.gy}${pad(tag, 20)}${A.r}${A.cy}${bar}${A.r} ${count}\n`);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (stats.recentActivity && stats.recentActivity.length > 0) {
|
|
257
|
+
write(`\n ${A.b}Recent activity:${A.r}\n`);
|
|
258
|
+
stats.recentActivity.slice(0, 5).forEach(a => {
|
|
259
|
+
const icon = a.signal === 'negative' ? `${A.rd}👎${A.r}` : `${A.gn}👍${A.r}`;
|
|
260
|
+
write(` ${icon} ${A.gy}${relDate(a.timestamp)}${A.r} ${trunc(a.context || '', cols() - 20)}\n`);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function renderRules(state) {
|
|
266
|
+
const items = state.filtered;
|
|
267
|
+
const listH = listHeight(state);
|
|
268
|
+
const start = Math.max(0, state.cursor - Math.floor(listH / 2));
|
|
269
|
+
const visible = items.slice(start, start + listH);
|
|
270
|
+
|
|
271
|
+
if (items.length === 0) {
|
|
272
|
+
write(`\n ${A.gy}No prevention rules yet.${A.r}\n`);
|
|
273
|
+
write(` ${A.d}Run: npx thumbgate feedback:rules${A.r}\n`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
visible.forEach((rule, i) => {
|
|
278
|
+
const idx = start + i;
|
|
279
|
+
const selected = idx === state.cursor;
|
|
280
|
+
const isHead = rule.text.match(/^#{1,3}\s/);
|
|
281
|
+
const color = isHead ? A.b + A.cy : A.r;
|
|
282
|
+
const row = ` ${color}${trunc(rule.text, cols() - 4)}${A.r}`;
|
|
283
|
+
write(selected ? `${A.inv}${row}${A.r}\n` : `${row}\n`);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
write(`\n ${A.gy}${items.length} rule line${items.length !== 1 ? 's' : ''}${A.r}\n`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function renderDetail(state) {
|
|
290
|
+
const item = state.filtered[state.cursor];
|
|
291
|
+
if (!item) return;
|
|
292
|
+
|
|
293
|
+
write(`${A.b}${A.cy}Detail View${A.r} ${A.gy}Esc to go back${A.r}\n`);
|
|
294
|
+
write(`${A.cy}${hr()}\n${A.r}`);
|
|
295
|
+
|
|
296
|
+
if (state.tab === 0) {
|
|
297
|
+
// Lesson detail
|
|
298
|
+
const signal = (item.tags || []).includes('negative') ? `${A.rd}negative${A.r}` : `${A.gn}positive${A.r}`;
|
|
299
|
+
const fields = [
|
|
300
|
+
['Signal', signal],
|
|
301
|
+
['Timestamp', item.timestamp ? new Date(item.timestamp).toLocaleString() : ''],
|
|
302
|
+
['Tags', (item.tags || []).join(', ')],
|
|
303
|
+
['Title', item.title || item.context || ''],
|
|
304
|
+
['What went wrong',item.lesson?.whatWentWrong || item.whatWentWrong || ''],
|
|
305
|
+
['What worked', item.lesson?.whatWorked || item.whatWorked || ''],
|
|
306
|
+
['How to avoid', item.lesson?.howToAvoid || item.howToAvoid || ''],
|
|
307
|
+
['Summary', item.lesson?.summary || item.summary || ''],
|
|
308
|
+
['Content', item.content || ''],
|
|
309
|
+
];
|
|
310
|
+
write('\n');
|
|
311
|
+
fields.forEach(([label, val]) => {
|
|
312
|
+
if (!val) return;
|
|
313
|
+
write(` ${A.b}${pad(label + ':', 18)}${A.r}`);
|
|
314
|
+
const lines = String(val).split('\n');
|
|
315
|
+
lines.forEach((l, i) => {
|
|
316
|
+
write((i === 0 ? '' : ' '.repeat(20)) + trunc(l, cols() - 22) + '\n');
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
} else if (state.tab === 1) {
|
|
320
|
+
// Gate detail
|
|
321
|
+
write('\n');
|
|
322
|
+
Object.entries(item).filter(([k]) => !k.startsWith('_')).forEach(([k, v]) => {
|
|
323
|
+
const val = typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v);
|
|
324
|
+
write(` ${A.b}${pad(k + ':', 18)}${A.r}${trunc(val, cols() - 22)}\n`);
|
|
325
|
+
});
|
|
326
|
+
} else if (state.tab === 3) {
|
|
327
|
+
write('\n');
|
|
328
|
+
write(` ${item.text}\n`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// State machine
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
function buildState(data) {
|
|
337
|
+
return {
|
|
338
|
+
tab: 0,
|
|
339
|
+
cursor: 0,
|
|
340
|
+
mode: 'list', // list | search | detail
|
|
341
|
+
query: '',
|
|
342
|
+
data,
|
|
343
|
+
filtered: data.lessons,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function getItems(state) {
|
|
348
|
+
const map = [state.data.lessons, state.data.gates, [], state.data.rules];
|
|
349
|
+
return map[state.tab] || [];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function applyFilter(state) {
|
|
353
|
+
const items = getItems(state);
|
|
354
|
+
if (!state.query) return items;
|
|
355
|
+
const q = state.query.toLowerCase();
|
|
356
|
+
return items.filter(item => {
|
|
357
|
+
const text = JSON.stringify(item).toLowerCase();
|
|
358
|
+
return text.includes(q);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function render(state) {
|
|
363
|
+
renderHeader(state);
|
|
364
|
+
renderSearchBar(state);
|
|
365
|
+
|
|
366
|
+
if (state.mode === 'detail') {
|
|
367
|
+
renderDetail(state);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (state.tab === 0) renderLessons(state);
|
|
372
|
+
else if (state.tab === 1) renderGates(state);
|
|
373
|
+
else if (state.tab === 2) renderStats(state);
|
|
374
|
+
else if (state.tab === 3) renderRules(state);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* c8 ignore stop */
|
|
378
|
+
|
|
379
|
+
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
|
|
380
|
+
|
|
381
|
+
function resetFiltered(state) {
|
|
382
|
+
state.filtered = applyFilter(state);
|
|
383
|
+
state.cursor = 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function handleSearchKey(state, key, data) {
|
|
387
|
+
if (key === 'escape' || key === 'return') {
|
|
388
|
+
state.mode = 'list';
|
|
389
|
+
resetFiltered(state);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (key === 'backspace') {
|
|
393
|
+
state.query = state.query.slice(0, -1);
|
|
394
|
+
resetFiltered(state);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (typeof data === 'string' && data.length === 1 && data >= ' ') {
|
|
398
|
+
state.query += data;
|
|
399
|
+
resetFiltered(state);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function handleDetailKey(state, key) {
|
|
404
|
+
if (key === 'escape' || key === 'q') {
|
|
405
|
+
state.mode = 'list';
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function handleListKey(state, key, data) {
|
|
410
|
+
switch (key) {
|
|
411
|
+
case 'q':
|
|
412
|
+
cleanup();
|
|
413
|
+
process.exit(0);
|
|
414
|
+
break;
|
|
415
|
+
case 'escape':
|
|
416
|
+
if (state.query) {
|
|
417
|
+
state.query = '';
|
|
418
|
+
resetFiltered(state);
|
|
419
|
+
}
|
|
420
|
+
break;
|
|
421
|
+
case '/':
|
|
422
|
+
state.mode = 'search';
|
|
423
|
+
break;
|
|
424
|
+
case 'return':
|
|
425
|
+
if (state.tab !== 2 && state.filtered.length > 0) {
|
|
426
|
+
state.mode = 'detail';
|
|
427
|
+
}
|
|
428
|
+
break;
|
|
429
|
+
case 'up':
|
|
430
|
+
case 'k':
|
|
431
|
+
state.cursor = clamp(state.cursor - 1, 0, Math.max(0, state.filtered.length - 1));
|
|
432
|
+
break;
|
|
433
|
+
case 'down':
|
|
434
|
+
case 'j':
|
|
435
|
+
state.cursor = clamp(state.cursor + 1, 0, Math.max(0, state.filtered.length - 1));
|
|
436
|
+
break;
|
|
437
|
+
case 'tab':
|
|
438
|
+
state.tab = (state.tab + 1) % TABS.length;
|
|
439
|
+
state.cursor = 0;
|
|
440
|
+
state.query = '';
|
|
441
|
+
state.mode = 'list';
|
|
442
|
+
resetFiltered(state);
|
|
443
|
+
break;
|
|
444
|
+
default:
|
|
445
|
+
if (typeof data === 'string' && data >= '1' && data <= '4') {
|
|
446
|
+
state.tab = Number.parseInt(data, 10) - 1;
|
|
447
|
+
state.cursor = 0;
|
|
448
|
+
state.query = '';
|
|
449
|
+
state.mode = 'list';
|
|
450
|
+
resetFiltered(state);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function handleKey(state, key, data) {
|
|
456
|
+
if (state.mode === 'search') {
|
|
457
|
+
handleSearchKey(state, key, data);
|
|
458
|
+
} else if (state.mode === 'detail') {
|
|
459
|
+
handleDetailKey(state, key);
|
|
460
|
+
} else {
|
|
461
|
+
handleListKey(state, key, data);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function decodeKey(ch) {
|
|
466
|
+
const data = normalizeKeyData(ch);
|
|
467
|
+
return KEY_MAP.get(data) || data;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function isExitKey(ch) {
|
|
471
|
+
return normalizeKeyData(ch) === EXIT_KEY;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// Entry point
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
function cleanup() {
|
|
479
|
+
write(A.showCursor);
|
|
480
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
481
|
+
readline.clearLine(process.stdout, 0);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* c8 ignore start -- run() owns raw TTY wiring and process lifecycle. */
|
|
485
|
+
|
|
486
|
+
function run(options = {}) {
|
|
487
|
+
if (!process.stdout.isTTY) {
|
|
488
|
+
console.error('thumbgate explore requires a TTY terminal.');
|
|
489
|
+
process.exit(1);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
493
|
+
let feedbackDir = options.feedbackDir;
|
|
494
|
+
if (!feedbackDir) {
|
|
495
|
+
try {
|
|
496
|
+
const { getFeedbackPaths } = require('./feedback-loop');
|
|
497
|
+
feedbackDir = getFeedbackPaths().FEEDBACK_DIR;
|
|
498
|
+
} catch {
|
|
499
|
+
feedbackDir = path.join(PKG_ROOT, '.claude', 'memory', 'feedback');
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const data = {
|
|
504
|
+
lessons: loadLessons(feedbackDir),
|
|
505
|
+
gates: loadGates(PKG_ROOT),
|
|
506
|
+
stats: loadStats(feedbackDir),
|
|
507
|
+
rules: loadRules(feedbackDir),
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const state = buildState(data);
|
|
511
|
+
state.filtered = getItems(state);
|
|
512
|
+
|
|
513
|
+
// Setup terminal
|
|
514
|
+
process.stdin.setRawMode(true);
|
|
515
|
+
process.stdin.resume();
|
|
516
|
+
process.stdin.setEncoding('utf8');
|
|
517
|
+
write(A.hideCursor);
|
|
518
|
+
|
|
519
|
+
process.on('exit', cleanup);
|
|
520
|
+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
521
|
+
|
|
522
|
+
// Handle resize
|
|
523
|
+
process.stdout.on('resize', () => render(state));
|
|
524
|
+
|
|
525
|
+
// Keypress handler
|
|
526
|
+
process.stdin.on('data', (ch) => {
|
|
527
|
+
const data = normalizeKeyData(ch);
|
|
528
|
+
if (isExitKey(data)) {
|
|
529
|
+
cleanup();
|
|
530
|
+
process.exit(0);
|
|
531
|
+
}
|
|
532
|
+
const key = decodeKey(data);
|
|
533
|
+
handleKey(state, key, data);
|
|
534
|
+
render(state);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
render(state);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/* c8 ignore stop */
|
|
541
|
+
|
|
542
|
+
function isDirectInvocation(moduleRef = module, mainRef = require.main) {
|
|
543
|
+
const moduleFile = moduleRef && moduleRef.filename;
|
|
544
|
+
const mainFile = mainRef && mainRef.filename;
|
|
545
|
+
return Boolean(moduleFile && mainFile && mainFile === moduleFile);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
module.exports = {
|
|
549
|
+
run,
|
|
550
|
+
_internals: {
|
|
551
|
+
applyFilter,
|
|
552
|
+
buildState,
|
|
553
|
+
decodeKey,
|
|
554
|
+
handleKey,
|
|
555
|
+
isDirectInvocation,
|
|
556
|
+
isExitKey,
|
|
557
|
+
loadGates,
|
|
558
|
+
loadLessons,
|
|
559
|
+
loadRules,
|
|
560
|
+
loadStats,
|
|
561
|
+
pad,
|
|
562
|
+
relDate,
|
|
563
|
+
trunc,
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
if (isDirectInvocation()) {
|
|
568
|
+
run();
|
|
569
|
+
}
|
package/scripts/feedback-loop.js
CHANGED
|
@@ -42,6 +42,12 @@ const {
|
|
|
42
42
|
getFeedbackPaths: resolveFeedbackPaths,
|
|
43
43
|
} = require('./feedback-paths');
|
|
44
44
|
|
|
45
|
+
const AUDIT_TRAIL_TAG = 'audit-trail';
|
|
46
|
+
|
|
47
|
+
function isAuditTrailEntry(entry = {}) {
|
|
48
|
+
return Array.isArray(entry.tags) && entry.tags.includes(AUDIT_TRAIL_TAG);
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
// Lesson DB — SQLite+FTS5 backing store (dual-write alongside JSONL)
|
|
46
52
|
let _lessonDB = null;
|
|
47
53
|
let _lessonDBPath = null;
|
|
@@ -1063,8 +1069,12 @@ function captureFeedback(params) {
|
|
|
1063
1069
|
const historyEntries = readJSONL(FEEDBACK_LOG_PATH).slice(-SEQUENCE_WINDOW);
|
|
1064
1070
|
|
|
1065
1071
|
const summary = loadSummary();
|
|
1066
|
-
summary
|
|
1067
|
-
|
|
1072
|
+
// Only count real user feedback in the summary, not audit-trail gate events
|
|
1073
|
+
const isAuditEntry = Array.isArray(tags) && tags.includes(AUDIT_TRAIL_TAG);
|
|
1074
|
+
if (!isAuditEntry) {
|
|
1075
|
+
summary.total += 1;
|
|
1076
|
+
summary[signal] += 1;
|
|
1077
|
+
}
|
|
1068
1078
|
|
|
1069
1079
|
if (action.type === 'no-action') {
|
|
1070
1080
|
const firewallBlocked = maybeBlockMemoryIngress({ feedbackEvent, summary, now });
|
|
@@ -1383,6 +1393,8 @@ function analyzeFeedback(logPath) {
|
|
|
1383
1393
|
let totalNegative = 0;
|
|
1384
1394
|
|
|
1385
1395
|
for (const entry of entries) {
|
|
1396
|
+
if (isAuditTrailEntry(entry)) continue;
|
|
1397
|
+
|
|
1386
1398
|
if (entry.signal === 'positive') totalPositive++;
|
|
1387
1399
|
if (entry.signal === 'negative') totalNegative++;
|
|
1388
1400
|
|
|
@@ -1416,7 +1428,8 @@ function analyzeFeedback(logPath) {
|
|
|
1416
1428
|
|
|
1417
1429
|
const total = totalPositive + totalNegative;
|
|
1418
1430
|
const approvalRate = total > 0 ? Math.round((totalPositive / total) * 1000) / 1000 : 0;
|
|
1419
|
-
const
|
|
1431
|
+
const realEntries = entries.filter((entry) => !isAuditTrailEntry(entry));
|
|
1432
|
+
const recent = realEntries.slice(-20);
|
|
1420
1433
|
const recentPos = recent.filter((e) => e.signal === 'positive').length;
|
|
1421
1434
|
const recentRate = recent.length > 0 ? Math.round((recentPos / recent.length) * 1000) / 1000 : 0;
|
|
1422
1435
|
|
|
@@ -1425,7 +1438,7 @@ function analyzeFeedback(logPath) {
|
|
|
1425
1438
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
1426
1439
|
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
1427
1440
|
const windowStats = { '7d': { total: 0, positive: 0 }, '30d': { total: 0, positive: 0 } };
|
|
1428
|
-
for (const entry of
|
|
1441
|
+
for (const entry of realEntries) {
|
|
1429
1442
|
const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
1430
1443
|
const age = now - ts;
|
|
1431
1444
|
if (age <= SEVEN_DAYS_MS) {
|
|
@@ -1688,11 +1701,12 @@ function writePreventionRules(filePath, minOccurrences = 2) {
|
|
|
1688
1701
|
function feedbackSummary(recentN = 20, options = {}) {
|
|
1689
1702
|
const { FEEDBACK_LOG_PATH } = getFeedbackPaths(options);
|
|
1690
1703
|
const entries = readJSONL(FEEDBACK_LOG_PATH);
|
|
1691
|
-
|
|
1704
|
+
const realEntries = entries.filter((entry) => !isAuditTrailEntry(entry));
|
|
1705
|
+
if (realEntries.length === 0) {
|
|
1692
1706
|
return '## Feedback Summary\nNo feedback recorded yet.';
|
|
1693
1707
|
}
|
|
1694
1708
|
|
|
1695
|
-
const recent =
|
|
1709
|
+
const recent = realEntries.slice(-recentN);
|
|
1696
1710
|
const positive = recent.filter((e) => e.signal === 'positive').length;
|
|
1697
1711
|
const negative = recent.filter((e) => e.signal === 'negative').length;
|
|
1698
1712
|
const pct = Math.round((positive / recent.length) * 100);
|
|
@@ -192,7 +192,13 @@ function stripLessonPrefix(lessonText = '') {
|
|
|
192
192
|
function formatLessonTimestamp(createdAt = '') {
|
|
193
193
|
const parsed = new Date(createdAt);
|
|
194
194
|
if (!Number.isFinite(parsed.getTime())) return '';
|
|
195
|
-
|
|
195
|
+
const mm = String(parsed.getUTCMonth() + 1).padStart(2, '0');
|
|
196
|
+
const dd = String(parsed.getUTCDate()).padStart(2, '0');
|
|
197
|
+
const yyyy = parsed.getUTCFullYear();
|
|
198
|
+
const HH = String(parsed.getUTCHours()).padStart(2, '0');
|
|
199
|
+
const MM = String(parsed.getUTCMinutes()).padStart(2, '0');
|
|
200
|
+
const ss = String(parsed.getUTCSeconds()).padStart(2, '0');
|
|
201
|
+
return `${mm}/${dd}/${yyyy} ${HH}:${MM}:${ss}`;
|
|
196
202
|
}
|
|
197
203
|
|
|
198
204
|
function buildStatusbarLessonLabel(lesson = {}) {
|
|
@@ -303,7 +309,26 @@ function getStatusbarLessonData() {
|
|
|
303
309
|
if (!recent) return { hasLesson: false, text: null, link: null };
|
|
304
310
|
|
|
305
311
|
const normalizedLesson = stripLessonPrefix(recent.lesson || '');
|
|
306
|
-
|
|
312
|
+
|
|
313
|
+
// Distill to actionable insight: prefer structured rule action, then
|
|
314
|
+
// whatToChange, then the lesson text itself. Raw user feedback
|
|
315
|
+
// ("are you sure?", "is this working?") is not useful in a statusbar.
|
|
316
|
+
let displayText = normalizedLesson;
|
|
317
|
+
if (recent.structuredRule && recent.structuredRule.action && recent.structuredRule.action.description) {
|
|
318
|
+
displayText = recent.structuredRule.action.description;
|
|
319
|
+
} else if (recent.whatToChange || recent.what_to_change) {
|
|
320
|
+
displayText = String(recent.whatToChange || recent.what_to_change);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Clean up: strip noise prefixes, collapse whitespace
|
|
324
|
+
displayText = displayText
|
|
325
|
+
.replace(/^CRITICAL ERROR - User frustrated:\s*/i, '')
|
|
326
|
+
.replace(/^thumbs?\s*(up|down)\s*:?\s*/i, '')
|
|
327
|
+
.replace(/\s+/g, ' ')
|
|
328
|
+
.trim();
|
|
329
|
+
|
|
330
|
+
// Truncate to 60 chars (enough to be readable, short enough for statusbar)
|
|
331
|
+
const truncated = displayText.length > 60 ? displayText.slice(0, 57) + '...' : displayText;
|
|
307
332
|
|
|
308
333
|
return {
|
|
309
334
|
hasLesson: true,
|