howamiprompting 0.0.0-stage → 0.1.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.
@@ -0,0 +1,112 @@
1
+ // VS Code–family editors: Cursor (Chat/Composer/Agent), VS Code Copilot Chat, Windsurf, Antigravity, Trae, Kiro, VSCodium…
2
+ // plus Cline / Roo Code / Kilo Code task histories stored in each editor's globalStorage.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { appSupport, find, isDir, readJson, projectName, exists } from '../util.js';
6
+ import { query } from '../sqlite.js';
7
+
8
+ export const id = 'vscode-family';
9
+ export const name = 'Editors';
10
+
11
+ const APPS = ['Cursor', 'Code', 'Code - Insiders', 'VSCodium', 'Windsurf', 'Windsurf - Next', 'Antigravity', 'Trae', 'Kiro', 'Void', 'Positron'];
12
+ const AGENT_EXT = { 'saoudrizwan.claude-dev': 'Cline', 'rooveterinaryinc.roo-cline': 'Roo Code', 'kilocode.kilo-code': 'Kilo Code' };
13
+
14
+ function workspaceProject(wsDir) {
15
+ try {
16
+ const j = JSON.parse(fs.readFileSync(path.join(wsDir, 'workspace.json'), 'utf8'));
17
+ return projectName(decodeURIComponent(String(j.folder || j.workspace || '').replace(/^file:\/\//, '')));
18
+ } catch { return null; }
19
+ }
20
+ const parse = s => { try { return typeof s === 'string' ? JSON.parse(s) : s; } catch { return null; } };
21
+
22
+ export async function collect(log = () => {}) {
23
+ const out = [];
24
+ for (const app of APPS) {
25
+ const user = path.join(appSupport(), app, 'User');
26
+ if (!isDir(user)) continue;
27
+ const tool = app === 'Code' || app === 'Code - Insiders' ? 'Copilot Chat' : app;
28
+
29
+ // 1) Copilot-style chat sessions (JSON files per workspace)
30
+ const ws = path.join(user, 'workspaceStorage');
31
+ if (isDir(ws)) {
32
+ for (const d of fs.readdirSync(ws)) {
33
+ const wsDir = path.join(ws, d);
34
+ const project = workspaceProject(wsDir);
35
+ const cs = path.join(wsDir, 'chatSessions');
36
+ if (isDir(cs)) {
37
+ for (const f of fs.readdirSync(cs).filter(f => f.endsWith('.json'))) {
38
+ const j = await readJson(path.join(cs, f));
39
+ let last = '';
40
+ for (const r of j?.requests || []) {
41
+ const text = r.message?.text || (r.message?.parts || []).map(p => p.text || '').join('');
42
+ if (text) out.push({ tool, session: j.sessionId || f, project, ts: r.timestamp || j.creationDate, text, context: last.slice(-400) });
43
+ last = (r.response || []).map(x => typeof x?.value === 'string' ? x.value : (x?.value?.value || '')).join('');
44
+ }
45
+ }
46
+ }
47
+ // 2) Cursor legacy prompt list in per-workspace state.vscdb
48
+ const db = path.join(wsDir, 'state.vscdb');
49
+ if (app === 'Cursor' && exists(db)) {
50
+ const rows = await query(db, "SELECT value FROM ItemTable WHERE key = 'aiService.prompts'");
51
+ for (const row of rows || []) for (const p of parse(row.value) || []) {
52
+ if (p?.text) out.push({ tool, session: 'ws-' + d, project, ts: null, text: p.text, context: '' });
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ // 3) Cursor Composer / Agent bubbles (global state.vscdb, table cursorDiskKV)
59
+ const gdb = path.join(user, 'globalStorage', 'state.vscdb');
60
+ if (exists(gdb) && (app === 'Cursor' || app === 'Windsurf' || app === 'Antigravity' || app === 'Trae')) {
61
+ const comps = await query(gdb, "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'");
62
+ if (comps) {
63
+ const bubbles = await query(gdb, "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'") || [];
64
+ const byComposer = new Map();
65
+ for (const b of bubbles) {
66
+ const [, cid, bid] = b.key.split(':');
67
+ const v = parse(b.value);
68
+ if (!v) continue;
69
+ if (!byComposer.has(cid)) byComposer.set(cid, new Map());
70
+ byComposer.get(cid).set(bid, v);
71
+ }
72
+ for (const c of comps) {
73
+ const cd = parse(c.value);
74
+ if (!cd) continue;
75
+ const cid = c.key.split(':')[1];
76
+ const ts = cd.createdAt || null;
77
+ let seq = [];
78
+ if (Array.isArray(cd.conversation) && cd.conversation.length) seq = cd.conversation; // older inline format
79
+ else if (Array.isArray(cd.fullConversationHeadersOnly)) seq = cd.fullConversationHeadersOnly.map(h => byComposer.get(cid)?.get(h.bubbleId) || { type: h.type });
80
+ let last = '';
81
+ for (const m of seq) {
82
+ const t = m.text || '';
83
+ if (m.type === 1 && t) out.push({ tool, session: cid, project: projectName(cd.context?.folderSelections?.[0]?.relativePath) || null, ts: m.createdAt || ts, text: t, context: last.slice(-400) });
84
+ else if (m.type === 2 && t) last = t;
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ // 4) Cline / Roo / Kilo task histories
91
+ for (const [ext, agent] of Object.entries(AGENT_EXT)) {
92
+ const tasks = path.join(user, 'globalStorage', ext, 'tasks');
93
+ if (!isDir(tasks)) continue;
94
+ const files = await find(tasks, (p, n) => n === 'api_conversation_history.json', { depth: 2 });
95
+ for (const f of files) {
96
+ const j = await readJson(f);
97
+ const taskId = path.basename(path.dirname(f));
98
+ let last = '';
99
+ for (const m of Array.isArray(j) ? j : []) {
100
+ const parts = typeof m.content === 'string' ? [m.content] : (m.content || []).filter(p => p.type === 'text').map(p => p.text);
101
+ const raw = parts.join('\n');
102
+ if (m.role === 'assistant') { if (raw) last = raw; continue; }
103
+ if (m.role !== 'user') continue;
104
+ const bits = [...raw.matchAll(/<(task|feedback|user_message|answer)>([\s\S]*?)<\/\1>/g)].map(x => x[2].trim());
105
+ const text = bits.join('\n').trim();
106
+ if (text) out.push({ tool: agent, session: taskId, project: null, ts: Number(taskId) || null, text, context: last.slice(-400) });
107
+ }
108
+ }
109
+ }
110
+ }
111
+ return out;
112
+ }
@@ -0,0 +1,119 @@
1
+ // Practice mode: 4 timed mini-games generated from YOUR weaknesses and graded by your own agent.
2
+ import fs from 'node:fs';
3
+ import { askJSON } from './agents.js';
4
+ import { ENGLISH_PATTERNS } from './stats.js';
5
+
6
+ const bank = JSON.parse(fs.readFileSync(new URL('./bank.json', import.meta.url), 'utf8'));
7
+ const pick = a => a[Math.floor(Math.random() * a.length)];
8
+ const shuffle = a => a.map(v => [Math.random(), v]).sort((x, y) => x[0] - y[0]).map(x => x[1]);
9
+
10
+ const COACH = 'You are a sharp, kind English-and-prompting coach for software developers (many are non-native English speakers). You design short, fresh exercises and grade honestly. Never reuse textbook examples.';
11
+
12
+ export async function generateTest({ agent, model, report, stats, worstPrompt }) {
13
+ const weak = (report?.weaknesses || []).map(w => `- ${w.title}: "${w.example || ''}"`).join('\n');
14
+ const eng = (report?.english?.errors || []).slice(0, 6).map(e => `- "${e.quote}" → "${e.fix}" (${e.rule})`).join('\n')
15
+ || (stats?.english?.patterns || []).slice(0, 5).map(p => `- ${p.label}: "${p.example || ''}"`).join('\n');
16
+ const projects = (stats?.projects || []).slice(0, 4).map(p => p.name).join(', ');
17
+ if (agent) {
18
+ try {
19
+ const t = await askJSON(agent, `${COACH}
20
+
21
+ TASK: generate_practice
22
+ Seed: ${Math.random().toString(36).slice(2, 8)}
23
+ Their projects: ${projects || 'web apps'}
24
+ Their prompting weaknesses:
25
+ ${weak || '- (unknown)'}
26
+ Their English mistakes:
27
+ ${eng || '- (none detected)'}
28
+ ${worstPrompt ? `One of their real messy prompts (for level 4): "${worstPrompt}"` : ''}
29
+
30
+ Return ONE JSON object:
31
+ {
32
+ "passage": {"text":"Level 1: a 4-5 sentence bug/incident report (65-95 words) from a world like theirs, with a clear cause → effect → consequence chain and one concrete number. Shown 45s then hidden.","questions":["what happened?","why?"],"model_answer":"ideal 2-3 sentences","key_facts":["3-4 facts"],"targets":"which weakness"},
33
+ "fixit": [{"broken":"NEW 12-24 word sentence in broken developer English that reproduces THEIR error patterns","reference":"clean rewrite","focus":"2-4 word hint","targets":"which weakness"}, x3],
34
+ "diagram": {"title":"a rule/flow from their world","audience":"who to explain it to","boxes":[{"icon":"emoji","label":"max 6 words"}, x3],"model_answer":"one paragraph, exactly 3 sentences, with linking words","targets":"short"},
35
+ "rewrite": {"original":"${worstPrompt ? 'their real prompt verbatim' : 'an invented messy prompt in their style'}","context":"what that prompt was trying to achieve","model_answer":"an excellent prompt: goal, context, constraints, acceptance criteria","targets":"short"}
36
+ }`, { model, validate: j => { if (!j.passage?.text || (j.fixit || []).length < 3 || (j.diagram?.boxes || []).length < 3) throw new Error('bad test'); } });
37
+ t.fixit = t.fixit.slice(0, 3); t.diagram.boxes = t.diagram.boxes.slice(0, 3);
38
+ if (worstPrompt) t.rewrite.original = worstPrompt;
39
+ t.source = agent;
40
+ return t;
41
+ } catch (e) { /* fall through to the offline bank */ }
42
+ }
43
+ const p = pick(bank.passages), d = pick(bank.diagrams);
44
+ return {
45
+ source: 'offline',
46
+ passage: { text: p.text, questions: p.questions, key_facts: p.key_facts.map(k => k.fact), model_answer: '', kw: p.key_facts },
47
+ fixit: shuffle(bank.fixit).slice(0, 3),
48
+ diagram: { ...d, model_answer: '' },
49
+ rewrite: { original: worstPrompt || pick(bank.rewrites), context: '', model_answer: '' },
50
+ };
51
+ }
52
+
53
+ export function publicTest(t) {
54
+ return {
55
+ source: t.source,
56
+ passage: { text: t.passage.text, questions: t.passage.questions, targets: t.passage.targets || '' },
57
+ fixit: t.fixit.map(f => ({ broken: f.broken, focus: f.focus || '', targets: f.targets || '' })),
58
+ diagram: { title: t.diagram.title, audience: t.diagram.audience || 'an end user', boxes: t.diagram.boxes, targets: t.diagram.targets || '' },
59
+ rewrite: { original: t.rewrite.original, context: t.rewrite.context || '', targets: t.rewrite.targets || '' },
60
+ };
61
+ }
62
+
63
+ export async function gradeTest({ agent, model, test, answers }) {
64
+ if (agent) {
65
+ try {
66
+ const r = await askJSON(agent, `${COACH}
67
+
68
+ TASK: grade_practice
69
+ Grade this timed written-English test (no paste, no spell-check). Empty answer = 0. Copying the passage verbatim loses comprehension marks. A "fix" that keeps the original errors scores low. Quote their exact words. 60 = B1, 75 = B2, 88+ = C1.
70
+
71
+ ${JSON.stringify({
72
+ level1: { passage: test.passage.text, questions: test.passage.questions, key_facts: test.passage.key_facts, model: test.passage.model_answer, answer: answers.passage },
73
+ level2: test.fixit.map((f, i) => ({ broken: f.broken, model: f.reference, answer: answers.fixit?.[i] || '' })),
74
+ level3: { flow: test.diagram.boxes.map(b => b.label), audience: test.diagram.audience, rule: 'one paragraph, exactly 3 sentences', model: test.diagram.model_answer, answer: answers.diagram },
75
+ level4: { original_prompt: test.rewrite.original, model: test.rewrite.model_answer, answer: answers.rewrite },
76
+ }, null, 1)}
77
+
78
+ Return ONE JSON object:
79
+ {"criteria":{"sentence_boundaries":{"score":0,"comment":""},"grammar_tense":{"score":0,"comment":""},"comprehension":{"score":0,"comment":""},"clarity":{"score":0,"comment":""}},
80
+ "levels":{"passage":{"score":0,"feedback":"","mistakes":[{"wrote":"","fix":"","why":""}]},"fixit":{"score":0,"feedback":"","items":[{"score":0,"note":""},{"score":0,"note":""},{"score":0,"note":""}]},"diagram":{"score":0,"feedback":"","mistakes":[]},"rewrite":{"score":0,"feedback":"","mistakes":[]}},
81
+ "cefr":"A2|B1|B2|C1|C2","verdict":"2 sentences","top_fixes":["3 specific habits"]}`, { model, validate: j => { if (!j.criteria?.grammar_tense) throw new Error('bad grade'); } });
82
+ return finalize(r, test, answers, agent);
83
+ } catch (e) { /* offline fallback below */ }
84
+ }
85
+ return finalize(offlineGrade(test, answers), test, answers, 'offline');
86
+ }
87
+
88
+ function finalize(r, test, answers, source) {
89
+ const c = r.criteria;
90
+ const n = k => Math.max(0, Math.min(100, Math.round(Number(c[k]?.score) || 0)));
91
+ const overall = Math.round(0.3 * n('sentence_boundaries') + 0.3 * n('grammar_tense') + 0.25 * n('comprehension') + 0.15 * n('clarity'));
92
+ return {
93
+ source, overall, cefr: r.cefr || (overall >= 88 ? 'C1' : overall >= 75 ? 'B2' : overall >= 60 ? 'B1' : 'A2'),
94
+ criteria: Object.fromEntries(['sentence_boundaries', 'grammar_tense', 'comprehension', 'clarity'].map(k => [k, { score: n(k), comment: c[k]?.comment || '' }])),
95
+ levels: r.levels || {}, verdict: r.verdict || '', top_fixes: r.top_fixes || [],
96
+ models: { passage: test.passage.model_answer, fixit: test.fixit.map(f => f.reference), diagram: test.diagram.model_answer, rewrite: test.rewrite.model_answer },
97
+ test: publicTest(test), answers,
98
+ };
99
+ }
100
+
101
+ function offlineGrade(test, a) {
102
+ const sents = t => (t.trim().match(/[^.!?]+[.!?]+|[^.!?]+$/g) || []).filter(s => s.trim().length > 1);
103
+ const errs = t => ENGLISH_PATTERNS.reduce((n, [, re]) => n + (re.test(t) ? 1 : 0), 0) + (sents(t).filter(s => /^\s*[a-z]/.test(s)).length);
104
+ const bound = t => !t.trim() ? 0 : Math.max(0, 100 - (/[.!?]\s*$/.test(t.trim()) ? 0 : 20) - sents(t).filter(s => s.split(/\s+/).length > 30).length * 18);
105
+ const gram = t => !t.trim() ? 0 : Math.max(0, 100 - errs(t) * 15);
106
+ const kw = test.passage.kw || [];
107
+ const lt = (a.passage || '').toLowerCase();
108
+ const comp = kw.length ? Math.round(100 * kw.filter(k => k.keywords.some(w => lt.includes(w))).length / kw.length) : 50;
109
+ const all = [a.passage, ...(a.fixit || []), a.diagram, a.rewrite].join(' ');
110
+ return {
111
+ criteria: {
112
+ sentence_boundaries: { score: Math.round((bound(a.passage || '') + bound(a.diagram || '') + bound(a.rewrite || '')) / 3), comment: '' },
113
+ grammar_tense: { score: gram(all), comment: '' },
114
+ comprehension: { score: comp, comment: '' },
115
+ clarity: { score: /\b(if|once|when|after|so|because|then|as a result)\b/i.test(a.diagram || '') ? 75 : 55, comment: '' },
116
+ },
117
+ levels: {}, verdict: 'Graded by the basic offline checker. Connect an AI agent for detailed feedback.', top_fixes: [],
118
+ };
119
+ }
package/src/server.js ADDED
@@ -0,0 +1,97 @@
1
+ // Tiny local web server for the report UI. Binds to 127.0.0.1 only and requires a per-run token.
2
+ import http from 'node:http';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { generateTest, gradeTest, publicTest } from './practice.js';
8
+ import { history } from './store.js';
9
+
10
+ const UI = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui');
11
+ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.json': 'application/json', '.ico': 'image/x-icon' };
12
+ const LEADERBOARD = process.env.HOWAMIPROMPTING_API || 'https://howamiprompting.com/api';
13
+
14
+ export function startServer({ bundle, prompts, agent, model, port = 0 }) {
15
+ const token = crypto.randomBytes(16).toString('hex');
16
+ const tests = new Map();
17
+
18
+ const send = (res, code, body, type = 'application/json') => {
19
+ res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
20
+ res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body));
21
+ };
22
+ const body = req => new Promise((ok, bad) => { let b = ''; req.on('data', d => { b += d; if (b.length > 2e6) req.destroy(); }); req.on('end', () => { try { ok(b ? JSON.parse(b) : {}); } catch (e) { bad(e); } }); });
23
+
24
+ const server = http.createServer(async (req, res) => {
25
+ try {
26
+ const host = (req.headers.host || '').split(':')[0];
27
+ if (!['127.0.0.1', 'localhost'].includes(host)) return send(res, 403, { error: 'forbidden' }); // DNS-rebinding guard
28
+ const url = new URL(req.url, 'http://127.0.0.1');
29
+ if (url.pathname.startsWith('/api/')) {
30
+ if (req.headers['x-hap-token'] !== token) return send(res, 401, { error: 'bad token — reopen the link printed in your terminal' });
31
+ if (url.pathname === '/api/report') return send(res, 200, { ...bundle, history: history() });
32
+ if (url.pathname === '/api/prompts') {
33
+ const q = (url.searchParams.get('q') || '').toLowerCase(), tool = url.searchParams.get('tool'), flag = url.searchParams.get('flag');
34
+ const off = +url.searchParams.get('offset') || 0;
35
+ let list = prompts;
36
+ if (tool) list = list.filter(p => p.tool === tool);
37
+ if (q) list = list.filter(p => p.text.toLowerCase().includes(q));
38
+ if (flag === 'short') list = list.filter(p => p.words <= 4);
39
+ if (flag === 'long') list = list.filter(p => p.words >= 80);
40
+ if (flag === 'paste') list = list.filter(p => p.logLines || p.codeLines);
41
+ const page = [...list].reverse().slice(off, off + 50).map(({ id, tool, project, ts, text, words }) => ({ id, tool, project, ts, text, words }));
42
+ return send(res, 200, { total: list.length, items: page });
43
+ }
44
+ if (url.pathname === '/api/practice/new' && req.method === 'POST') {
45
+ const worst = bundle.ai?.makeovers?.[Math.floor(Math.random() * (bundle.ai.makeovers.length || 1))]?.original || bundle.stats?.examples?.vague?.[0] || null;
46
+ const t = await generateTest({ agent, model, report: bundle.ai, stats: bundle.stats, worstPrompt: worst });
47
+ const id = crypto.randomBytes(6).toString('hex');
48
+ tests.set(id, t);
49
+ return send(res, 200, { id, ...publicTest(t) });
50
+ }
51
+ if (url.pathname === '/api/practice/submit' && req.method === 'POST') {
52
+ const b = await body(req);
53
+ const t = tests.get(b.id);
54
+ if (!t) return send(res, 404, { error: 'test expired — start a new one' });
55
+ return send(res, 200, await gradeTest({ agent, model, test: t, answers: b.answers || {} }));
56
+ }
57
+ if (url.pathname === '/api/card' && req.method === 'POST') {
58
+ // Upload ONLY the rendered share-card PNG (scores, no prompts) to get a public link with a preview image.
59
+ const b = await body(req);
60
+ const png = String(b.png || '');
61
+ if (!png.startsWith('data:image/png;base64,') || png.length > 2.5e6) return send(res, 400, { error: 'Bad image' });
62
+ const payload = { png, overall: bundle.scores.overall, persona: bundle.stats.persona.name, emoji: bundle.stats.persona.emoji, version: bundle.version };
63
+ const r = await fetch(LEADERBOARD + '/card.php', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(20000) });
64
+ const j = await r.json().catch(() => ({ error: 'Share service unavailable' }));
65
+ return send(res, r.ok ? 200 : 502, r.ok ? j : { error: j.error || 'Share service unavailable' });
66
+ }
67
+ if (url.pathname === '/api/publish' && req.method === 'POST') {
68
+ const b = await body(req);
69
+ const s = bundle.stats, sc = bundle.scores;
70
+ const payload = {
71
+ handle: String(b.handle || '').slice(0, 40), github: String(b.github || '').slice(0, 40), country: String(b.country || '').slice(0, 2),
72
+ overall: sc.overall, scores: sc, persona: s.persona.name, level: bundle.ai?.level || null, ai: !!bundle.ai,
73
+ prompts: s.totals.prompts, tools: s.tools.map(t => t.name), median_words: s.length.median, version: bundle.version,
74
+ };
75
+ const r = await fetch(LEADERBOARD + '/publish.php', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(15000) });
76
+ const j = await r.json().catch(() => ({ error: 'Leaderboard unavailable' }));
77
+ return send(res, r.ok ? 200 : 502, r.ok ? j : { error: j.error || 'Leaderboard unavailable' });
78
+ }
79
+ return send(res, 404, { error: 'not found' });
80
+ }
81
+ let p = url.pathname === '/' ? '/index.html' : url.pathname;
82
+ const file = path.join(UI, path.normalize(p).replace(/^(\.\.[\/\\])+/, ''));
83
+ if (!file.startsWith(UI) || !fs.existsSync(file)) return send(res, 404, 'not found', 'text/plain');
84
+ return send(res, 200, fs.readFileSync(file), MIME[path.extname(file)] || 'application/octet-stream');
85
+ } catch (e) {
86
+ return send(res, 500, { error: e.message });
87
+ }
88
+ });
89
+
90
+ return new Promise((resolve, reject) => {
91
+ server.on('error', reject);
92
+ server.listen(port, '127.0.0.1', () => {
93
+ const p = server.address().port;
94
+ resolve({ server, url: `http://127.0.0.1:${p}/#t=${token}`, port: p, token });
95
+ });
96
+ });
97
+ }
package/src/sqlite.js ADDED
@@ -0,0 +1,51 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { which } from './util.js';
6
+
7
+ let nodeSqlite;
8
+ async function loadNodeSqlite() {
9
+ if (nodeSqlite !== undefined) return nodeSqlite;
10
+ const orig = process.emitWarning;
11
+ process.emitWarning = (w, ...a) => { if (String(w).includes('SQLite')) return; return orig.call(process, w, ...a); };
12
+ try { nodeSqlite = await import('node:sqlite'); } catch { nodeSqlite = null; }
13
+ process.emitWarning = orig;
14
+ return nodeSqlite;
15
+ }
16
+
17
+ /** Copy the DB (+WAL) to a temp dir so we never lock or modify the user's live app DB. */
18
+ function snapshot(dbPath) {
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hap-'));
20
+ const dst = path.join(dir, 'db.sqlite');
21
+ fs.copyFileSync(dbPath, dst);
22
+ for (const ext of ['-wal', '-shm']) if (fs.existsSync(dbPath + ext)) fs.copyFileSync(dbPath + ext, dst + ext);
23
+ return { dst, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
24
+ }
25
+
26
+ /** Run a read-only query. Returns rows (array of objects) or null if SQLite is unavailable. */
27
+ export async function query(dbPath, sql) {
28
+ let snap;
29
+ try { snap = snapshot(dbPath); } catch { return null; }
30
+ try {
31
+ const mod = await loadNodeSqlite();
32
+ if (mod?.DatabaseSync) {
33
+ const db = new mod.DatabaseSync(snap.dst, { readOnly: true });
34
+ try { return db.prepare(sql).all(); } finally { db.close(); }
35
+ }
36
+ if (which('sqlite3')) {
37
+ const r = spawnSync('sqlite3', ['-readonly', '-json', snap.dst, sql], { encoding: 'utf8', maxBuffer: 1024 * 1024 * 1024 });
38
+ if (r.status !== 0) return null;
39
+ return r.stdout.trim() ? JSON.parse(r.stdout) : [];
40
+ }
41
+ return null;
42
+ } catch {
43
+ return null;
44
+ } finally {
45
+ snap.cleanup();
46
+ }
47
+ }
48
+
49
+ export async function sqliteAvailable() {
50
+ return !!((await loadNodeSqlite())?.DatabaseSync || which('sqlite3'));
51
+ }
package/src/stats.js ADDED
@@ -0,0 +1,208 @@
1
+ // Local, deterministic metrics — the "Wrapped" layer. No AI needed.
2
+ const STOP = new Set('a an the to of and or in on for with is are be it this that i you me my we our can could would will just please also so then now do does did not no yes ok okay if as at by from but all any some there here what how why when which should make need want like get use using code file files one more see into out up them they its it\'s im i\'m dont don\'t lets let\'s'.split(' '));
3
+
4
+ const RX = {
5
+ confirm: /^(y|ya|yes|yeah|yep|yup|ok|okay|k|sure|continue|go|go ahead|proceed|do it|next|yes please|ok do it|looks good|lgtm|perfect|great|nice|cool|thanks|thank you|ty|done|good|correct|right|approved?)[.! ]*$/i,
6
+ please: /\b(please|pls|plz|kindly)\b/i,
7
+ thanks: /\b(thanks|thank you|thx|ty|cheers|appreciate)\b/i,
8
+ sorry: /\b(sorry|my bad|apologies)\b/i,
9
+ swear: /\b(wtf|wth|ffs|fuck\w*|shit\w*|damn\w*|crap|bullshit|stupid|idiot|dumb|bloody|hell)\b/i,
10
+ frustr: /(\bstill (not|doesn'?t|isn'?t|broken|failing|the same)\b|\bagain\b.*\b(error|broken|fail|wrong)|\bwhy (is|are|does|did|isn'?t|doesn'?t|would|the hell)\b|\b(doesn'?t|does not|didn'?t|not) work(ing)?\b|\bsame (error|issue|problem|bug)\b|!!+|\?\?+|\bseriously\b|\bcome on\b|\bfor the \w+ time\b|\bi (told|said) you\b|\bwhat the\b)/i,
11
+ pushback: /^(no|nope|nah|wrong|stop|wait|undo|revert|don'?t|that'?s (not|wrong)|not what|you (broke|deleted|removed|changed)|why did you)\b/i,
12
+ context: /\b(because|so that|in order to|expected|expect|instead of|currently|right now|the goal|i want|should|must|constraint|requirement|acceptance|without (changing|breaking|touching)|don'?t (change|touch|modify|break)|keep (the|it|existing)|only (change|modify|touch)|for example|e\.g\.|such as|make sure|edge case)\b/i,
13
+ vagueVerb: /\b(fix( it| this| that)?|broken|doesn'?t work|not working|make it (better|nicer|work|good|look good)|improve (it|this)|clean (it|this) up|do (it|the rest|the same)|same (thing|for)|it'?s wrong|error)\b/i,
14
+ question: /\?\s*$|^(how|what|why|where|when|which|who|can|could|is|are|does|do|should|would|will)\b/i,
15
+ multi: /(\balso\b|\band then\b|\bplus\b|^\s*\d+[.)]\s|\n\s*[-*•]\s)/im,
16
+ canYou: /\b(can|could|would) you\b/i,
17
+ emoji: /\p{Extended_Pictographic}/gu,
18
+ };
19
+
20
+ export const ENGLISH_PATTERNS = [
21
+ ['why because', /\bwhy because\b/i, 'Use just "because".'],
22
+ ['can able to', /\bcan(not)? able to\b/i, 'Say "can" or "be able to", never both.'],
23
+ ['didn\'t + past tense', /\b(didn'?t|did not|doesn'?t|does not)\s+(?!need\b|feed\b|proceed\b|exceed\b|succeed\b|embed\b|seed\b|speed\b)(\w+ed|went|came|got|gave|did|made|wrote|took|saw|ran)\b/i, 'After did/does use the base verb: "didn\'t work".'],
24
+ ['have did / has went', /\b(has|have|had)\s+(went|came|gave|did|wrote|ran|took|saw|ate|broke)\b/i, 'Use the past participle: "have done", "has gone".'],
25
+ ['missing is/are', /\b(it|this|that|page|api|response|data|button|app|server|screen|value|error|output|code)\s+(not\s+)?(coming|showing|getting|giving|throwing|happening|loading|working|crashing|failing|returning)\b/i, 'Add the helping verb: "the page is showing".'],
26
+ ['subject–verb agreement', /(?<!\b(?:make|makes|made|making|let|lets|help|helps|see|saw|watch|have|has|had|does|did|do|can|could|will|would|should|must|might|may|to)\s)\b(it|he|she|this|that|user|the user|page|the page|api|button|server|app|code|function|screen)\s+(get|show|work|give|come|take|go|return|throw|crash|load|send|break|fail|run|need|look|seem|have|do)\b(?!['-])/i, 'Singular subjects take -s: "it gets", "the page shows".'],
27
+ ['a + vowel sound', /\ba\s+(error|api|issue|update|option|object|array|event|input|output|image|endpoint|example|empty|existing|extra|easy|important|hour|ui|id|url|svg|html|sql|ec2)\b/i, 'Use "an" before vowel sounds: "an error", "an API".'],
28
+ ['double comparative', /\b(more better|more faster|more easier|more simpler|most best|more cleaner)\b/i, '"better", not "more better".'],
29
+ ['since / for', /\b(since|from)\s+(\d+|two|three|four|five)\s+(mins?|minutes|hours?|days?|weeks?)\b/i, '"for" + duration, "since" + starting point.'],
30
+ ['redundant words', /\b(discuss about|revert back|return back|repeat again|reply back)\b/i, 'Drop the extra word.'],
31
+ ['texting shortcuts', /\b(u|ur|pls|plz|thx|bcoz|coz|bcz|wat|wht|r u)\b/i, 'Write words in full — the model does better with clear text.'],
32
+ ];
33
+
34
+ const median = a => { if (!a.length) return 0; const s = [...a].sort((x, y) => x - y); return s[Math.floor(s.length / 2)]; };
35
+ const pct = (n, d) => d ? Math.round(1000 * n / d) / 10 : 0;
36
+ const dayKey = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
37
+
38
+ export function computeStats(prompts, { interrupts = 0 } = {}) {
39
+ const n = prompts.length;
40
+ const tools = {}, projects = {}, sessions = new Map();
41
+ const hours = Array(24).fill(0), weekdays = Array(7).fill(0), heat = Array.from({ length: 7 }, () => Array(24).fill(0));
42
+ const days = new Set();
43
+ const words = [];
44
+ const c = { confirm: 0, please: 0, thanks: 0, sorry: 0, swear: 0, frustr: 0, pushback: 0, context: 0, vague: 0, question: 0, multi: 0, canYou: 0, emoji: 0, pastePray: 0, pastedCode: 0, pastedLog: 0, images: 0, lowerStart: 0, noEnd: 0, lowerI: 0, runOn: 0, shouty: 0 };
45
+ const english = Object.fromEntries(ENGLISH_PATTERNS.map(([k]) => [k, { count: 0, example: null }]));
46
+ const examples = { swear: [], frustr: [], vague: [], confirm: {}, longest: null, pastePray: [] };
47
+ const ngrams = new Map(), openers = new Map();
48
+ const sentenceSessions = new Map();
49
+ let night = 0, weekend = 0, timed = 0;
50
+
51
+ for (const p of prompts) {
52
+ const prose = p.prose || '';
53
+ const w = p.words;
54
+ words.push(w);
55
+ tools[p.tool] = (tools[p.tool] || 0) + 1;
56
+ if (p.project) projects[p.project] = (projects[p.project] || 0) + 1;
57
+ const sk = p.tool + '|' + p.session;
58
+ if (!sessions.has(sk)) sessions.set(sk, { n: 0, frustr: 0, tool: p.tool, project: p.project, first: p.ts });
59
+ sessions.get(sk).n++;
60
+
61
+ if (p.ts) {
62
+ const d = new Date(p.ts);
63
+ timed++;
64
+ hours[d.getHours()]++; weekdays[d.getDay()]++; heat[d.getDay()][d.getHours()]++;
65
+ days.add(dayKey(d));
66
+ if (d.getHours() < 5) night++;
67
+ if (d.getDay() === 0 || d.getDay() === 6) weekend++;
68
+ }
69
+ const t = prose.trim();
70
+ if (RX.confirm.test(t)) { c.confirm++; const k = t.toLowerCase().replace(/[.! ]+$/, ''); examples.confirm[k] = (examples.confirm[k] || 0) + 1; }
71
+ if (RX.please.test(t)) c.please++;
72
+ if (RX.thanks.test(t)) c.thanks++;
73
+ if (RX.sorry.test(t)) c.sorry++;
74
+ if (RX.swear.test(t)) { c.swear++; if (examples.swear.length < 5 && w < 40) examples.swear.push(t); }
75
+ if (RX.frustr.test(t)) { c.frustr++; sessions.get(sk).frustr++; if (examples.frustr.length < 8 && w < 40) examples.frustr.push(t); }
76
+ if (RX.pushback.test(t)) c.pushback++;
77
+ if (RX.context.test(t) && w >= 8) c.context++;
78
+ if (w <= 7 && RX.vagueVerb.test(t) && !p.codeLines && !p.logLines) { c.vague++; if (examples.vague.length < 8) examples.vague.push(t); }
79
+ if (RX.question.test(t)) c.question++;
80
+ if (RX.multi.test(p.text) && w > 15) c.multi++;
81
+ if (RX.canYou.test(t)) c.canYou++;
82
+ c.emoji += (t.match(RX.emoji) || []).length;
83
+ if (p.logLines && w < 8) { c.pastePray++; if (examples.pastePray.length < 5) examples.pastePray.push(t || '(just a pasted error)'); }
84
+ if (p.codeLines) c.pastedCode++;
85
+ if (p.logLines) c.pastedLog++;
86
+ c.images += p.images || 0;
87
+ if (w >= 3) {
88
+ if (/^[a-z]/.test(t)) c.lowerStart++;
89
+ if (!/[.!?)"'`:]$/.test(t)) c.noEnd++;
90
+ if (/(^|\s)i(\s|'m|'ll|'ve|'d)/.test(t)) c.lowerI++;
91
+ if (t.split(/[.!?;]\s/).some(s => s.split(/\s+/).length > 45)) c.runOn++;
92
+ const caps = t.match(/\b[A-Z]{3,}\b/g) || [];
93
+ if (caps.length >= 3 && caps.length / Math.max(1, w) > 0.3) c.shouty++;
94
+ for (const [k, re] of ENGLISH_PATTERNS) if (re.test(t)) { english[k].count++; if (!english[k].example && w < 60) english[k].example = t.slice(0, 160); }
95
+ }
96
+ if (!examples.longest || w > examples.longest.words) examples.longest = { words: w, text: t.slice(0, 280), tool: p.tool, ts: p.ts };
97
+
98
+ // phrases
99
+ const toks = t.toLowerCase().replace(/[^a-z0-9' ]+/g, ' ').split(/\s+/).filter(Boolean);
100
+ if (toks.length) { const op = toks.slice(0, 2).join(' '); openers.set(op, (openers.get(op) || 0) + 1); }
101
+ for (let i = 0; i + 1 < toks.length; i++) {
102
+ for (const len of [2, 3]) {
103
+ const g = toks.slice(i, i + len);
104
+ if (g.length < len || STOP.has(g[0]) || STOP.has(g[len - 1])) continue;
105
+ const k = g.join(' ');
106
+ ngrams.set(k, (ngrams.get(k) || 0) + 1);
107
+ }
108
+ }
109
+ // repeated instructions (candidates for CLAUDE.md / AGENTS.md / .cursorrules)
110
+ for (const s of t.split(/(?<=[.!?\n])\s+/)) {
111
+ const norm = s.toLowerCase().replace(/[^a-z0-9 ]+/g, '').replace(/\s+/g, ' ').trim();
112
+ const wc = norm.split(' ').length;
113
+ if (wc < 4 || wc > 22) continue;
114
+ if (!/\b(always|never|don'?t|do not|use|make sure|avoid|prefer|keep|only|must|should)\b/.test(norm) || RX.pushback.test(norm)) continue;
115
+ if (!sentenceSessions.has(norm)) sentenceSessions.set(norm, { text: s.trim(), sessions: new Set() });
116
+ sentenceSessions.get(norm).sessions.add(sk);
117
+ }
118
+ }
119
+
120
+ // streak
121
+ const sortedDays = [...days].sort();
122
+ let streak = 0, cur = 0, prev = null;
123
+ for (const d of sortedDays) {
124
+ const t = Date.parse(d);
125
+ cur = prev && t - prev <= 86400000 * 1.5 ? cur + 1 : 1;
126
+ streak = Math.max(streak, cur); prev = t;
127
+ }
128
+ const sess = [...sessions.values()];
129
+ const topN = (m, k = 12, min = 2) => [...m.entries()].filter(([, v]) => v >= min).sort((a, b) => b[1] - a[1]).slice(0, k).map(([text, count]) => ({ text, count }));
130
+ const ratio = k => pct(c[k], n);
131
+
132
+ const stats = {
133
+ totals: {
134
+ prompts: n, sessions: sessions.size, projects: Object.keys(projects).length, tools: Object.keys(tools).length,
135
+ words: words.reduce((a, b) => a + b, 0), interrupts,
136
+ first: prompts.find(p => p.ts)?.ts || null, last: [...prompts].reverse().find(p => p.ts)?.ts || null,
137
+ activeDays: days.size, longestStreak: streak, avgPerDay: days.size ? Math.round(10 * timed / days.size) / 10 : 0,
138
+ },
139
+ tools: Object.entries(tools).sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, count, avgWords: Math.round(prompts.filter(p => p.tool === name).reduce((a, p) => a + p.words, 0) / count) })),
140
+ projects: Object.entries(projects).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([name, count]) => ({ name, count })),
141
+ time: { hours, weekdays, heat, nightPct: pct(night, timed), weekendPct: pct(weekend, timed), busiestHour: hours.indexOf(Math.max(...hours)), busiestDay: weekdays.indexOf(Math.max(...weekdays)) },
142
+ length: { avg: n ? Math.round(10 * words.reduce((a, b) => a + b, 0) / n) / 10 : 0, median: median(words), buckets: [[1, 3], [4, 10], [11, 30], [31, 80], [81, 1e9]].map(([a, b]) => words.filter(x => x >= a && x <= b).length), longest: examples.longest },
143
+ habits: {
144
+ confirmPct: ratio('confirm'), pleasePct: ratio('please'), thanksPct: ratio('thanks'), sorry: c.sorry,
145
+ swears: c.swear, swearPct: ratio('swear'), frustrationPct: ratio('frustr'), pushbackPct: ratio('pushback'),
146
+ contextPct: ratio('context'), vaguePct: ratio('vague'), questionPct: ratio('question'), multiAskPct: ratio('multi'),
147
+ canYouPct: ratio('canYou'), emoji: c.emoji, pastePrayPct: ratio('pastePray'), pastedCodePct: ratio('pastedCode'), pastedLogPct: ratio('pastedLog'),
148
+ images: c.images, shoutyPct: ratio('shouty'),
149
+ topConfirms: Object.entries(examples.confirm).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([text, count]) => ({ text, count })),
150
+ },
151
+ english: {
152
+ lowerStartPct: ratio('lowerStart'), noEndPunctPct: ratio('noEnd'), lowerIPct: ratio('lowerI'), runOnPct: ratio('runOn'),
153
+ patterns: ENGLISH_PATTERNS.map(([k, , tip]) => ({ label: k, tip, ...english[k] })).filter(x => x.count).sort((a, b) => b.count - a.count),
154
+ },
155
+ phrases: dedupeGrams(topN(ngrams, 40, 3)).slice(0, 15),
156
+ openers: topN(openers, 8, 3),
157
+ repeated: [...sentenceSessions.values()].filter(x => x.sessions.size >= 3).sort((a, b) => b.sessions.size - a.sessions.size).slice(0, 12).map(x => ({ text: x.text, sessions: x.sessions.size })),
158
+ sessions: { avgPrompts: sess.length ? Math.round(10 * n / sess.length) / 10 : 0, longest: Math.max(0, ...sess.map(s => s.n)), rageSessions: sess.filter(s => s.frustr >= 3).length },
159
+ examples: { swear: examples.swear, frustration: examples.frustr, vague: examples.vague, pastePray: examples.pastePray },
160
+ };
161
+ stats.heuristic = heuristicScores(stats);
162
+ stats.persona = persona(stats);
163
+ return stats;
164
+ }
165
+
166
+ // drop n-grams contained in a longer, equally frequent n-gram ("dependency injection" vs "use dependency injection")
167
+ function dedupeGrams(list) {
168
+ return list.filter(a => !list.some(b => b !== a && b.text.includes(a.text) && b.count >= a.count * 0.8) && !list.some(b => b !== a && a.text.includes(b.text) && b.count > a.count * 1.2));
169
+ }
170
+
171
+ const clamp = v => Math.max(0, Math.min(100, Math.round(v)));
172
+ export function heuristicScores(s) {
173
+ const h = s.habits, e = s.english, L = s.length;
174
+ const lenScore = L.median < 4 ? 35 : L.median < 8 ? 55 : L.median < 15 ? 72 : L.median < 60 ? 85 : 75;
175
+ const clarity = clamp(lenScore - h.vaguePct * 1.2 - h.multiAskPct * 0.3 + 5);
176
+ const context = clamp(30 + h.contextPct * 1.4 + Math.min(15, h.pastedCodePct * 0.3) - h.pastePrayPct * 1.2);
177
+ const specificity = clamp(45 + h.contextPct * 0.8 - h.vaguePct * 1.5 - h.confirmPct * 0.3 + (L.median > 12 ? 10 : 0));
178
+ const efficiency = clamp(88 - h.frustrationPct * 1.6 - h.pushbackPct * 0.9 - h.swearPct * 0.8);
179
+ const structure = clamp(82 - h.multiAskPct * 0.5 - e.runOnPct * 0.7 - (L.avg > 120 ? 10 : 0) - (L.median < 5 ? 12 : 0));
180
+ const englishScore = clamp(92 - e.lowerStartPct * 0.12 - e.noEndPunctPct * 0.12 - e.lowerIPct * 0.4 - e.runOnPct * 0.8 - Math.min(30, e.patterns.reduce((a, p) => a + p.count, 0) / Math.max(1, s.totals.prompts) * 220));
181
+ const overall = clamp(0.2 * clarity + 0.22 * context + 0.18 * specificity + 0.12 * structure + 0.13 * efficiency + 0.15 * englishScore);
182
+ return { overall, clarity, context, specificity, structure, efficiency, english: englishScore };
183
+ }
184
+
185
+ const PERSONAS = [
186
+ { id: 'architect', name: 'The Architect', emoji: '📐', blurb: 'You brief your AI like a senior engineer: goals, constraints, context. It shows.', test: s => s.habits.contextPct / 35 * (s.length.median > 18 ? 1.2 : 0.8) },
187
+ { id: 'novelist', name: 'The Novelist', emoji: '📜', blurb: 'Your prompts are essays. Rich context — but the important ask sometimes gets buried.', test: s => s.length.avg / 70 },
188
+ { id: 'sniper', name: 'The Sniper', emoji: '🎯', blurb: 'Short, precise, effective. You say exactly what you need and move on.', test: s => (s.length.median >= 8 && s.length.median <= 25 ? 1 : 0.4) * (1 - s.habits.vaguePct / 30) * (1 - s.habits.frustrationPct / 30) },
189
+ { id: 'yes', name: 'The Yes-Machine', emoji: '👍', blurb: '"ok", "continue", "yes". You let the agent drive — sometimes straight into a wall.', test: s => s.habits.confirmPct / 22 },
190
+ { id: 'paste', name: 'The Paste-and-Pray', emoji: '🙏', blurb: 'Error in, fix out. You paste the stack trace and hope. Add one line of context and watch it work.', test: s => s.habits.pastePrayPct / 12 },
191
+ { id: 'rage', name: 'The Rage Prompter', emoji: '🌋', blurb: 'When the AI messes up, it hears about it. Your frustration is valid — but it costs you turns.', test: s => (s.habits.frustrationPct + s.habits.swearPct * 2) / 16 },
192
+ { id: 'diplomat', name: 'The Polite Diplomat', emoji: '🎩', blurb: '"Please" and "thank you" — your AI has never felt so respected.', test: s => (s.habits.pleasePct + s.habits.thanksPct) / 30 },
193
+ { id: 'speedrunner', name: 'The Speedrunner', emoji: '⚡', blurb: 'Three-word prompts at 200 WPM. Fast — but the AI is guessing half the time.', test: s => s.length.median < 7 ? (8 - s.length.median) / 4 : 0 },
194
+ { id: 'micromanager', name: 'The Micromanager', emoji: '🔍', blurb: '"No, not like that." You course-correct a lot. Front-load the details to skip the back-and-forth.', test: s => s.habits.pushbackPct / 14 },
195
+ ];
196
+ export function persona(s) {
197
+ const ranked = PERSONAS.map(p => ({ ...p, score: p.test(s) || 0 })).sort((a, b) => b.score - a.score);
198
+ const main = ranked[0];
199
+ const badges = [];
200
+ if (s.time.nightPct > 20) badges.push({ emoji: '🦉', label: 'Night Owl', detail: `${s.time.nightPct}% of prompts after midnight` });
201
+ if (s.time.weekendPct > 30) badges.push({ emoji: '🏖️', label: 'Weekend Warrior', detail: `${s.time.weekendPct}% on weekends` });
202
+ if (s.totals.longestStreak >= 14) badges.push({ emoji: '🔥', label: 'On Fire', detail: `${s.totals.longestStreak}-day streak` });
203
+ if (s.tools.length >= 3) badges.push({ emoji: '🧪', label: 'Tool Hopper', detail: `${s.tools.length} different AI tools` });
204
+ if (s.habits.pleasePct > 20) badges.push({ emoji: '🎩', label: 'Polite', detail: `"please" in ${s.habits.pleasePct}% of prompts` });
205
+ if (s.habits.swears > 10) badges.push({ emoji: '🤬', label: 'Potty Mouth', detail: `${s.habits.swears} swears` });
206
+ if (s.totals.prompts > 2000) badges.push({ emoji: '🏋️', label: 'Power User', detail: `${s.totals.prompts.toLocaleString()} prompts` });
207
+ return { id: main.id, name: main.name, emoji: main.emoji, blurb: main.blurb, runnerUp: ranked[1]?.name, badges };
208
+ }
package/src/store.js ADDED
@@ -0,0 +1,24 @@
1
+ // Local history: ~/.howamiprompting/reports/*.json — lets you track your score over time.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { dataDir } from './util.js';
5
+
6
+ const dir = () => { const d = path.join(dataDir(), 'reports'); fs.mkdirSync(d, { recursive: true }); return d; };
7
+
8
+ export function saveReport(bundle) {
9
+ const file = path.join(dir(), bundle.generatedAt.replace(/[:.]/g, '-') + '.json');
10
+ fs.writeFileSync(file, JSON.stringify(bundle));
11
+ return file;
12
+ }
13
+
14
+ export function listReports() {
15
+ return fs.readdirSync(dir()).filter(f => f.endsWith('.json')).sort().map(f => {
16
+ try { return JSON.parse(fs.readFileSync(path.join(dir(), f), 'utf8')); } catch { return null; }
17
+ }).filter(Boolean);
18
+ }
19
+
20
+ export function history() {
21
+ return listReports().map(r => ({ date: r.generatedAt, overall: r.scores?.overall, scores: r.scores, prompts: r.stats?.totals?.prompts, ai: !!r.ai }));
22
+ }
23
+
24
+ export const lastReport = () => listReports().pop() || null;