vibeglish 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,141 @@
1
+ import { join } from 'node:path';
2
+ import { REVIEWED_DIR } from '../../constants.mjs';
3
+ import { readJSON, listFiles, formatDate, parseDate } from '../../utils.mjs';
4
+ import { checkAchievements } from '../../achievements.mjs';
5
+
6
+ export default async function report(args) {
7
+ const daysArg = args.indexOf('--days');
8
+ const days = daysArg !== -1 && args[daysArg + 1] ? parseInt(args[daysArg + 1]) : 7;
9
+
10
+ const today = parseDate(formatDate());
11
+ const startDate = new Date(today);
12
+ startDate.setDate(startDate.getDate() - days + 1);
13
+
14
+ const allFiles = listFiles(REVIEWED_DIR, '.json');
15
+ if (allFiles.length === 0) {
16
+ console.log('No reviewed data yet. Run "vibeglish review" first.');
17
+ return;
18
+ }
19
+
20
+ // Load all reviewed data
21
+ const allData = [];
22
+ const periodData = [];
23
+ const startStr = formatDate(startDate);
24
+
25
+ for (const f of allFiles) {
26
+ const data = readJSON(join(REVIEWED_DIR, f));
27
+ if (!data) continue;
28
+ allData.push(data);
29
+ if (data.date >= startStr) {
30
+ periodData.push(data);
31
+ }
32
+ }
33
+
34
+ if (periodData.length === 0) {
35
+ console.log(`No data in the last ${days} days.`);
36
+ return;
37
+ }
38
+
39
+ // Compute stats
40
+ const totalCaptures = periodData.reduce((s, d) => s + d.stats.total_captured, 0);
41
+ const totalReviewed = periodData.reduce((s, d) => s + d.stats.total_reviewed, 0);
42
+ const totalSkipped = periodData.reduce((s, d) => s + d.stats.skipped, 0);
43
+ const avgScore = periodData.reduce((s, d) => s + d.stats.avg_score, 0) / periodData.length;
44
+
45
+ // Previous period for comparison
46
+ const prevStart = new Date(startDate);
47
+ prevStart.setDate(prevStart.getDate() - days);
48
+ const prevStartStr = formatDate(prevStart);
49
+ const prevData = allData.filter(d => d.date >= prevStartStr && d.date < startStr);
50
+ const prevAvg = prevData.length > 0
51
+ ? prevData.reduce((s, d) => s + d.stats.avg_score, 0) / prevData.length
52
+ : null;
53
+ const scoreDiff = prevAvg !== null ? avgScore - prevAvg : null;
54
+
55
+ // Issue breakdown
56
+ const issueMap = {};
57
+ for (const day of periodData) {
58
+ for (const entry of day.entries) {
59
+ for (const issue of entry.issues || []) {
60
+ const key = issue.rule || issue.type;
61
+ issueMap[key] = (issueMap[key] || 0) + 1;
62
+ }
63
+ }
64
+ }
65
+ const topIssues = Object.entries(issueMap)
66
+ .sort((a, b) => b[1] - a[1])
67
+ .slice(0, 3);
68
+
69
+ // Daily scores
70
+ const dailyScores = periodData
71
+ .sort((a, b) => a.date.localeCompare(b.date))
72
+ .map(d => ({ date: d.date, score: d.stats.avg_score }));
73
+
74
+ // Clean prompts
75
+ const cleanCount = periodData.reduce((s, d) =>
76
+ s + d.entries.filter(e => e.is_clean).length, 0);
77
+
78
+ // Achievements
79
+ const achievements = checkAchievements(allData);
80
+ const recentUnlocked = achievements.filter(a => {
81
+ if (!a.unlockedAt) return false;
82
+ return a.unlockedAt >= startStr;
83
+ });
84
+
85
+ // Format dates
86
+ const from = periodData[0].date.slice(5);
87
+ const to = periodData[periodData.length - 1].date.slice(5);
88
+
89
+ // Output
90
+ const cols = process.stdout.columns || 50;
91
+ const barWidth = Math.min(10, Math.floor(cols / 6));
92
+
93
+ console.log(`
94
+ \u{1F4CA} VibeGlish Report (${from} ~ ${to})
95
+ ${'━'.repeat(Math.min(cols - 2, 45))}
96
+
97
+ Prompts captured: ${totalCaptures}
98
+ Prompts reviewed: ${totalReviewed} (${totalSkipped} skipped)
99
+ Average score: ${avgScore.toFixed(1)}${scoreDiff !== null ? ` ${scoreDiff >= 0 ? '\u25B2' : '\u25BC'} ${scoreDiff >= 0 ? '+' : ''}${scoreDiff.toFixed(1)} vs prev` : ''}`);
100
+
101
+ if (topIssues.length > 0) {
102
+ console.log(`
103
+ \u{1F525} Top ${topIssues.length} recurring mistakes:
104
+ ${'┌' + '─'.repeat(42) + '┐'}`);
105
+ const maxCount = topIssues[0][1];
106
+ for (let i = 0; i < topIssues.length; i++) {
107
+ const [rule, count] = topIssues[i];
108
+ const filled = Math.round((count / maxCount) * barWidth);
109
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
110
+ const label = rule.length > 20 ? rule.slice(0, 20) + '...' : rule.padEnd(23);
111
+ console.log(` │ ${i + 1}. ${label} ${String(count).padStart(3)} 次 ${bar} │`);
112
+ }
113
+ console.log(` ${'└' + '─'.repeat(42) + '┘'}`);
114
+ }
115
+
116
+ console.log(`
117
+ \u{1F48E} Clean prompts (score >= 95): ${cleanCount} / ${totalReviewed} (${totalReviewed > 0 ? ((cleanCount / totalReviewed) * 100).toFixed(1) : 0}%)`);
118
+
119
+ if (dailyScores.length > 0) {
120
+ console.log(`\n \u{1F4C8} Score trend:`);
121
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
122
+ const maxScore = Math.max(...dailyScores.map(d => d.score), 1);
123
+ const bestDay = dailyScores.reduce((best, d) => d.score > best.score ? d : best, dailyScores[0]);
124
+ for (const ds of dailyScores) {
125
+ const dayName = dayNames[parseDate(ds.date).getDay()];
126
+ const filled = Math.round((ds.score / 100) * barWidth);
127
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
128
+ const marker = ds.date === bestDay.date ? ' \u2190 best!' : '';
129
+ console.log(` ${dayName} ${bar} ${ds.score.toFixed(0)}${marker}`);
130
+ }
131
+ }
132
+
133
+ if (recentUnlocked.length > 0) {
134
+ console.log(`\n \u{1F3C6} Achievements unlocked:`);
135
+ for (const ach of recentUnlocked) {
136
+ console.log(` "${ach.name}" — ${ach.description}`);
137
+ }
138
+ }
139
+
140
+ console.log('');
141
+ }
@@ -0,0 +1,90 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync, copyFileSync } from 'node:fs';
3
+ import { RAW_DIR, REVIEWED_DIR } from '../../constants.mjs';
4
+ import { readJSONL, readJSON, writeJSON, listFiles, formatDate, dateRange, ensureDir } from '../../utils.mjs';
5
+ import { reviewBatch, computeStats } from '../../review/engine.mjs';
6
+
7
+ export default async function review(args) {
8
+ const force = args.includes('--force');
9
+ let dates = [];
10
+
11
+ const dateIdx = args.indexOf('--date');
12
+ const fromIdx = args.indexOf('--from');
13
+ const toIdx = args.indexOf('--to');
14
+
15
+ if (dateIdx !== -1 && args[dateIdx + 1]) {
16
+ dates = [args[dateIdx + 1]];
17
+ } else if (fromIdx !== -1 && toIdx !== -1 && args[fromIdx + 1] && args[toIdx + 1]) {
18
+ dates = dateRange(args[fromIdx + 1], args[toIdx + 1]);
19
+ } else {
20
+ // Default: all unreviewed dates
21
+ const rawFiles = listFiles(RAW_DIR, '.jsonl');
22
+ const reviewedFiles = new Set(listFiles(REVIEWED_DIR, '.json').map(f => f.replace('.json', '')));
23
+
24
+ dates = rawFiles
25
+ .map(f => f.replace('.jsonl', ''))
26
+ .filter(d => force || !reviewedFiles.has(d));
27
+
28
+ if (dates.length === 0) {
29
+ console.log('No pending prompts to review. Capture some prompts first!');
30
+ return;
31
+ }
32
+ }
33
+
34
+ console.log(`Reviewing ${dates.length} day(s)...`);
35
+
36
+ for (const date of dates) {
37
+ const rawFile = join(RAW_DIR, `${date}.jsonl`);
38
+ const reviewedFile = join(REVIEWED_DIR, `${date}.json`);
39
+
40
+ if (!existsSync(rawFile)) {
41
+ console.log(` ${date}: no raw data, skipping`);
42
+ continue;
43
+ }
44
+
45
+ if (existsSync(reviewedFile) && !force) {
46
+ console.log(` ${date}: already reviewed, skipping (use --force to re-review)`);
47
+ continue;
48
+ }
49
+
50
+ // Copy raw file to avoid read/write conflict
51
+ const tmpFile = join(RAW_DIR, `${date}.jsonl.tmp`);
52
+ copyFileSync(rawFile, tmpFile);
53
+
54
+ const entries = readJSONL(tmpFile);
55
+ if (entries.length === 0) {
56
+ console.log(` ${date}: empty file, skipping`);
57
+ continue;
58
+ }
59
+
60
+ console.log(` ${date}: ${entries.length} prompts`);
61
+
62
+ try {
63
+ const results = await reviewBatch(entries);
64
+ const stats = computeStats(results);
65
+
66
+ const output = {
67
+ date,
68
+ reviewed_at: new Date().toISOString(),
69
+ model: 'claude-sonnet-4-20250514',
70
+ stats,
71
+ entries: results.filter(e => !e.skipped),
72
+ };
73
+
74
+ ensureDir(REVIEWED_DIR);
75
+ writeJSON(reviewedFile, output);
76
+
77
+ console.log(` ${date}: done (avg score: ${stats.avg_score}, reviewed: ${stats.total_reviewed}, skipped: ${stats.skipped})`);
78
+ } catch (err) {
79
+ console.error(` ${date}: failed — ${err.message}`);
80
+ }
81
+
82
+ // Clean up temp file
83
+ try {
84
+ const { unlinkSync } = await import('node:fs');
85
+ unlinkSync(tmpFile);
86
+ } catch { /* ignore */ }
87
+ }
88
+
89
+ console.log('\nReview complete! Run "vibeglish serve" to view results.');
90
+ }
@@ -0,0 +1,102 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { REVIEWED_DIR, DASHBOARD_DIR, CONFIG_PATH, DEFAULT_CONFIG } from '../../constants.mjs';
5
+ import { readJSON, listFiles } from '../../utils.mjs';
6
+ import { checkAchievements } from '../../achievements.mjs';
7
+
8
+ export default async function serve(args) {
9
+ const portIdx = args.indexOf('--port');
10
+ const config = readJSON(CONFIG_PATH) || DEFAULT_CONFIG;
11
+ let port = portIdx !== -1 && args[portIdx + 1] ? parseInt(args[portIdx + 1]) : config.dashboard_port || 6188;
12
+
13
+ const dashboardFile = join(DASHBOARD_DIR, 'index.html');
14
+ if (!existsSync(dashboardFile)) {
15
+ console.error('Dashboard not found. Run "vibeglish init" first.');
16
+ process.exit(1);
17
+ }
18
+
19
+ const server = createServer((req, res) => {
20
+ const url = new URL(req.url, `http://localhost:${port}`);
21
+
22
+ if (url.pathname === '/api/data') {
23
+ handleDataAPI(url, res);
24
+ } else {
25
+ // Serve dashboard
26
+ try {
27
+ const html = readFileSync(dashboardFile, 'utf-8');
28
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
29
+ res.end(html);
30
+ } catch (err) {
31
+ res.writeHead(500);
32
+ res.end('Failed to load dashboard');
33
+ }
34
+ }
35
+ });
36
+
37
+ // Try port, increment if busy
38
+ let attempts = 0;
39
+ const tryListen = () => {
40
+ server.listen(port, '127.0.0.1', () => {
41
+ console.log(`VibeGlish Dashboard running at http://localhost:${port}`);
42
+ console.log('Press Ctrl+C to stop.\n');
43
+
44
+ // Try to open browser
45
+ import('node:child_process').then(({ exec }) => {
46
+ const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
47
+ exec(`${cmd} http://localhost:${port}`, () => {});
48
+ });
49
+ });
50
+
51
+ server.on('error', (err) => {
52
+ if (err.code === 'EADDRINUSE' && attempts < 10) {
53
+ attempts++;
54
+ port++;
55
+ tryListen();
56
+ } else {
57
+ console.error(`Cannot start server: ${err.message}`);
58
+ process.exit(1);
59
+ }
60
+ });
61
+ };
62
+
63
+ tryListen();
64
+ }
65
+
66
+ function handleDataAPI(url, res) {
67
+ try {
68
+ const dateParam = url.searchParams.get('date');
69
+ const fromParam = url.searchParams.get('from');
70
+ const toParam = url.searchParams.get('to');
71
+
72
+ const files = listFiles(REVIEWED_DIR, '.json');
73
+ let data = [];
74
+
75
+ for (const f of files) {
76
+ const date = f.replace('.json', '');
77
+ if (dateParam && date !== dateParam) continue;
78
+ if (fromParam && date < fromParam) continue;
79
+ if (toParam && date > toParam) continue;
80
+
81
+ const d = readJSON(join(REVIEWED_DIR, f));
82
+ if (d) data.push(d);
83
+ }
84
+
85
+ data.sort((a, b) => a.date.localeCompare(b.date));
86
+
87
+ // Check achievements
88
+ const allData = files.map(f => readJSON(join(REVIEWED_DIR, f))).filter(Boolean);
89
+ const achievements = checkAchievements(allData);
90
+
91
+ const result = { days: data, achievements };
92
+
93
+ res.writeHead(200, {
94
+ 'Content-Type': 'application/json',
95
+ 'Access-Control-Allow-Origin': '*',
96
+ });
97
+ res.end(JSON.stringify(result));
98
+ } catch (err) {
99
+ res.writeHead(500);
100
+ res.end(JSON.stringify({ error: err.message }));
101
+ }
102
+ }
@@ -0,0 +1,53 @@
1
+ import { join } from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+ import { RAW_DIR, REVIEWED_DIR } from '../../constants.mjs';
4
+ import { listFiles, formatDate } from '../../utils.mjs';
5
+
6
+ export default async function status() {
7
+ const today = formatDate();
8
+ const rawFiles = listFiles(RAW_DIR, '.jsonl');
9
+ const reviewedFiles = listFiles(REVIEWED_DIR, '.json');
10
+ const reviewedDates = new Set(reviewedFiles.map(f => f.replace('.json', '')));
11
+
12
+ // Today's captures
13
+ let todayCount = 0;
14
+ const todayFile = join(RAW_DIR, `${today}.jsonl`);
15
+ try {
16
+ todayCount = readFileSync(todayFile, 'utf-8').trim().split('\n').filter(Boolean).length;
17
+ } catch { /* no file */ }
18
+
19
+ // Total captures
20
+ let totalCaptures = 0;
21
+ for (const f of rawFiles) {
22
+ try {
23
+ totalCaptures += readFileSync(join(RAW_DIR, f), 'utf-8').trim().split('\n').filter(Boolean).length;
24
+ } catch { /* skip */ }
25
+ }
26
+
27
+ // Pending review
28
+ const pendingDates = rawFiles
29
+ .map(f => f.replace('.jsonl', ''))
30
+ .filter(d => !reviewedDates.has(d));
31
+
32
+ // Total reviewed
33
+ let totalReviewed = 0;
34
+ for (const f of reviewedFiles) {
35
+ try {
36
+ const data = JSON.parse(readFileSync(join(REVIEWED_DIR, f), 'utf-8'));
37
+ totalReviewed += data.stats?.total_reviewed || 0;
38
+ } catch { /* skip */ }
39
+ }
40
+
41
+ console.log(`
42
+ VibeGlish Status
43
+ ────────────────────────────────
44
+ Today (${today}): ${todayCount} prompts captured
45
+ Total captured: ${totalCaptures}
46
+ Total reviewed: ${totalReviewed}
47
+ Pending review: ${pendingDates.length} day(s)
48
+ ${pendingDates.length > 0 ? ` ${pendingDates.join(', ')}` : ''}
49
+ ────────────────────────────────
50
+ Raw data: ${RAW_DIR}
51
+ Reviewed: ${REVIEWED_DIR}
52
+ `);
53
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { CLAUDE_SETTINGS_PATH, VIBEGLISH_DIR } from '../../constants.mjs';
3
+ import { readJSON, writeJSON } from '../../utils.mjs';
4
+
5
+ export default async function uninstall() {
6
+ if (!existsSync(CLAUDE_SETTINGS_PATH)) {
7
+ console.log('No Claude settings file found — nothing to uninstall.');
8
+ return;
9
+ }
10
+
11
+ const settings = readJSON(CLAUDE_SETTINGS_PATH);
12
+ if (!settings?.hooks?.UserPromptSubmit) {
13
+ console.log('No UserPromptSubmit hook found — nothing to uninstall.');
14
+ return;
15
+ }
16
+
17
+ const before = settings.hooks.UserPromptSubmit.length;
18
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(entry =>
19
+ !entry.hooks?.some(h => h.command?.includes('vibeglish') || h.command?.includes('capture.sh'))
20
+ );
21
+ const after = settings.hooks.UserPromptSubmit.length;
22
+
23
+ if (before === after) {
24
+ console.log('VibeGlish hook not found in settings — nothing to uninstall.');
25
+ return;
26
+ }
27
+
28
+ // Clean up empty arrays
29
+ if (settings.hooks.UserPromptSubmit.length === 0) {
30
+ delete settings.hooks.UserPromptSubmit;
31
+ }
32
+ if (Object.keys(settings.hooks).length === 0) {
33
+ delete settings.hooks;
34
+ }
35
+
36
+ writeJSON(CLAUDE_SETTINGS_PATH, settings);
37
+
38
+ console.log(`VibeGlish hook removed from Claude Code settings.
39
+
40
+ Your data is preserved in: ${VIBEGLISH_DIR}
41
+ To delete all data: rm -rf ${VIBEGLISH_DIR}
42
+ `);
43
+ }
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+
3
+ const [command, ...args] = process.argv.slice(2);
4
+
5
+ const commands = {
6
+ init: () => import('./commands/init.mjs'),
7
+ status: () => import('./commands/status.mjs'),
8
+ review: () => import('./commands/review.mjs'),
9
+ report: () => import('./commands/report.mjs'),
10
+ serve: () => import('./commands/serve.mjs'),
11
+ export: () => import('./commands/export.mjs'),
12
+ 'hook-test': () => import('./commands/hook-test.mjs'),
13
+ uninstall: () => import('./commands/uninstall.mjs'),
14
+ };
15
+
16
+ function printHelp() {
17
+ console.log(`
18
+ vibeglish — Learn English naturally while vibe coding
19
+
20
+ Usage: vibeglish <command> [options]
21
+
22
+ Commands:
23
+ init Initialize VibeGlish and install the Claude Code hook
24
+ status Show capture/review statistics
25
+ review Run AI correction on captured prompts
26
+ report Print a weekly summary report in the terminal
27
+ serve Start the local Web Dashboard
28
+ export Export all reviewed data as JSON or CSV
29
+ hook-test Test if the capture hook is working
30
+ uninstall Remove the hook (keeps your data)
31
+
32
+ Options:
33
+ --help, -h Show this help message
34
+ --version Show version
35
+ `);
36
+ }
37
+
38
+ if (!command || command === '--help' || command === '-h') {
39
+ printHelp();
40
+ process.exit(0);
41
+ }
42
+
43
+ if (command === '--version') {
44
+ const { readFileSync } = await import('node:fs');
45
+ const { fileURLToPath } = await import('node:url');
46
+ const { join, dirname } = await import('node:path');
47
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../../package.json'), 'utf-8'));
48
+ console.log(`vibeglish v${pkg.version}`);
49
+ process.exit(0);
50
+ }
51
+
52
+ if (!commands[command]) {
53
+ console.error(`Unknown command: ${command}\nRun "vibeglish --help" for usage.`);
54
+ process.exit(1);
55
+ }
56
+
57
+ try {
58
+ const mod = await commands[command]();
59
+ await mod.default(args);
60
+ } catch (err) {
61
+ console.error(`Error: ${err.message}`);
62
+ process.exit(1);
63
+ }
@@ -0,0 +1,52 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ const HOME = homedir();
5
+
6
+ export const VIBEGLISH_DIR = join(HOME, '.vibeglish');
7
+ export const RAW_DIR = join(VIBEGLISH_DIR, 'raw');
8
+ export const REVIEWED_DIR = join(VIBEGLISH_DIR, 'reviewed');
9
+ export const HOOKS_DIR = join(VIBEGLISH_DIR, 'hooks');
10
+ export const DASHBOARD_DIR = join(VIBEGLISH_DIR, 'dashboard');
11
+ export const CONFIG_PATH = join(VIBEGLISH_DIR, 'config.json');
12
+ export const ERROR_LOG = join(VIBEGLISH_DIR, 'error.log');
13
+ export const ACHIEVEMENTS_PATH = join(VIBEGLISH_DIR, 'achievements.json');
14
+ export const CLAUDE_SETTINGS_PATH = join(HOME, '.claude', 'settings.json');
15
+
16
+ export const DEFAULT_CONFIG = {
17
+ locale: 'zh-CN',
18
+ min_word_count: 4,
19
+ max_code_ratio: 0.7,
20
+ batch_size: 20,
21
+ dashboard_port: 6188,
22
+ auto_review_cron: null,
23
+ score_explanation_language: 'zh-CN',
24
+ };
25
+
26
+ export const REVIEW_SYSTEM_PROMPT = `You are an English writing coach for non-native developers. You will receive
27
+ a batch of prompts that a Chinese developer wrote to an AI coding assistant.
28
+
29
+ For each prompt, provide:
30
+ 1. "corrected": The natural, idiomatic English version. Preserve technical
31
+ terms exactly. If the original mixes Chinese and English, only correct
32
+ the English parts and leave Chinese as-is.
33
+ 2. "issues": An array of specific corrections, each with:
34
+ - "type": one of "grammar", "vocabulary", "spelling", "punctuation",
35
+ "style", "word_order"
36
+ - "original": the problematic fragment (quote from original)
37
+ - "corrected": the fixed fragment
38
+ - "rule": a concise explanation in CHINESE (学习者的母语) so the
39
+ developer actually absorbs it. Use grammatical terminology sparingly.
40
+ 3. "score": 0-100 fluency score for the original. 100 = native-level.
41
+ Scoring rubric:
42
+ - 90-100: Minor style issues only
43
+ - 70-89: Understandable but has noticeable errors
44
+ - 50-69: Meaning is clear but grammar is systematically broken
45
+ - 30-49: Requires effort to understand
46
+ - 0-29: Largely incomprehensible
47
+ 4. "is_clean": boolean, true if no corrections needed (score >= 95)
48
+
49
+ If a prompt is just a code snippet, a file path, or not meaningfully English,
50
+ return {"skip": true} for that entry.
51
+
52
+ Respond with a JSON array matching the input order. No markdown fences.`;