oh-my-hi 0.4.0 → 0.4.2
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/marketplace.json +1 -1
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/scripts/generate-dashboard.mjs +149 -53
- package/scripts/parsers/usage.mjs +19 -12
- package/test/cache.test.mjs +96 -7
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"name": "oh-my-hi",
|
|
12
12
|
"description": "Visual dashboard for Claude Code harness — usage/token analysis of skills, agents, plugins, hooks, memory, MCP servers, rules, and principles",
|
|
13
|
-
"version": "0.4.
|
|
13
|
+
"version": "0.4.2",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "Jae Sung Park",
|
|
16
16
|
"email": "alberto.park@gmail.com"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.2] - 2026-04-03
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- Progress output improved: step-numbered messages (`[1/3]`, `[1/4]`) replace generic lines; first-run vs normal-run messaging differentiated
|
|
7
|
+
- In-place progress bar (`█░` style) rendered during file collection
|
|
8
|
+
- `collectAllScopes` accepts `progress` flag to enable/disable bar rendering
|
|
9
|
+
- Update check now queries **GitHub tags** first (primary distribution channel), with npm registry as fallback
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Progress bar newline flushed after collection completes (no broken terminal output)
|
|
13
|
+
- Update check now compares versions numerically; pre-publish local versions no longer trigger spurious "downgrade" attempts
|
|
14
|
+
- `--update` now runs `git fetch --tags` on the marketplace cache before calling `claude plugin update`, so stale local caches no longer report "already at latest" when a newer version exists on GitHub
|
|
15
|
+
- Test: replaced flaky mtime comparison with output-based assertion (macOS APFS sub-ms timestamp precision artifact)
|
|
16
|
+
|
|
3
17
|
## [0.4.0] - 2026-04-02
|
|
4
18
|
|
|
5
19
|
### Added
|
package/package.json
CHANGED
|
@@ -64,43 +64,82 @@ async function runUpdate() {
|
|
|
64
64
|
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
|
|
65
65
|
console.log(`oh-my-hi: current version v${pkg.version}`);
|
|
66
66
|
console.log('oh-my-hi: checking for updates...');
|
|
67
|
+
|
|
68
|
+
// Detect marketplace name and cache dir
|
|
69
|
+
const pluginCacheDir = path.join(CLAUDE_CONFIG_DIR, 'plugins', 'cache');
|
|
70
|
+
let marketplace = 'oh-my-hi';
|
|
71
|
+
if (fs.existsSync(pluginCacheDir)) {
|
|
72
|
+
for (const entry of fs.readdirSync(pluginCacheDir, { withFileTypes: true })) {
|
|
73
|
+
if (!entry.isDirectory() || entry.name.startsWith('temp_')) continue;
|
|
74
|
+
if (fs.existsSync(path.join(pluginCacheDir, entry.name, pkg.name))) {
|
|
75
|
+
marketplace = entry.name;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Refresh marketplace cache so claude plugin update sees latest tags
|
|
82
|
+
const marketplaceCacheDir = path.join(CLAUDE_CONFIG_DIR, 'plugins', 'marketplaces', marketplace);
|
|
83
|
+
if (fs.existsSync(marketplaceCacheDir)) {
|
|
84
|
+
try {
|
|
85
|
+
execSync('git fetch --tags --quiet', { cwd: marketplaceCacheDir, stdio: 'pipe', timeout: 10000 });
|
|
86
|
+
} catch { /* best effort — offline or not a git repo */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
try {
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
90
|
+
// Check latest version via GitHub tags API (git-based distribution)
|
|
91
|
+
const repoUrl = pkg.repository?.url || '';
|
|
92
|
+
const ghMatch = repoUrl.match(/github\.com[/:]([^/]+\/[^/.]+)/);
|
|
93
|
+
let latest = null;
|
|
94
|
+
|
|
95
|
+
if (ghMatch) {
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
98
|
+
const res = await fetch(`https://api.github.com/repos/${ghMatch[1]}/tags`, {
|
|
99
|
+
signal: controller.signal, headers: { 'Accept': 'application/vnd.github+json' },
|
|
100
|
+
});
|
|
101
|
+
clearTimeout(timeout);
|
|
102
|
+
if (res.ok) {
|
|
103
|
+
const tags = await res.json();
|
|
104
|
+
// Find highest semver tag
|
|
105
|
+
for (const tag of tags) {
|
|
106
|
+
const v = tag.name.replace(/^v/, '');
|
|
107
|
+
if (/^\d+\.\d+\.\d+$/.test(v) && (!latest || semverGt(v, latest))) latest = v;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Fallback to npm registry if GitHub API unavailable
|
|
113
|
+
if (!latest) {
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
116
|
+
const res = await fetch(`https://registry.npmjs.org/${pkg.name}/latest`, {
|
|
117
|
+
signal: controller.signal, headers: { 'Accept': 'application/json' },
|
|
118
|
+
});
|
|
119
|
+
clearTimeout(timeout);
|
|
120
|
+
if (res.ok) {
|
|
121
|
+
const data = await res.json();
|
|
122
|
+
latest = data.version;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!latest) throw new Error('could not determine latest version');
|
|
76
127
|
|
|
77
128
|
// Cache check result
|
|
78
129
|
const UPDATE_CHECK_FILE = path.join(OUTPUT, 'cache', '.update-check');
|
|
79
130
|
try {
|
|
80
131
|
fs.mkdirSync(path.dirname(UPDATE_CHECK_FILE), { recursive: true });
|
|
81
|
-
fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ timestamp: Date.now(), current: pkg.version, latest
|
|
132
|
+
fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ timestamp: Date.now(), current: pkg.version, latest }), 'utf8');
|
|
82
133
|
} catch { /* best effort */ }
|
|
83
134
|
|
|
84
|
-
if (
|
|
135
|
+
if (!semverGt(latest, pkg.version)) {
|
|
85
136
|
console.log('oh-my-hi: ✅ already up to date');
|
|
86
137
|
return;
|
|
87
138
|
}
|
|
88
|
-
console.log(`oh-my-hi: v${
|
|
89
|
-
|
|
90
|
-
const pluginCacheDir = path.join(CLAUDE_CONFIG_DIR, 'plugins', 'cache');
|
|
91
|
-
let marketplace = 'oh-my-hi';
|
|
92
|
-
if (fs.existsSync(pluginCacheDir)) {
|
|
93
|
-
for (const entry of fs.readdirSync(pluginCacheDir, { withFileTypes: true })) {
|
|
94
|
-
if (!entry.isDirectory() || entry.name.startsWith('temp_')) continue;
|
|
95
|
-
if (fs.existsSync(path.join(pluginCacheDir, entry.name, pkg.name))) {
|
|
96
|
-
marketplace = entry.name;
|
|
97
|
-
break;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
console.log(`oh-my-hi: updating v${pkg.version} → v${data.version}...`);
|
|
139
|
+
console.log(`oh-my-hi: v${latest} available`);
|
|
140
|
+
console.log(`oh-my-hi: updating v${pkg.version} → v${latest}...`);
|
|
102
141
|
execSync(`claude plugin update ${pkg.name}@${marketplace}`, { stdio: 'inherit' });
|
|
103
|
-
console.log(`oh-my-hi: ✅ updated to v${
|
|
142
|
+
console.log(`oh-my-hi: ✅ updated to v${latest}`);
|
|
104
143
|
} catch (e) {
|
|
105
144
|
if (e.name === 'AbortError') console.log('oh-my-hi: ❌ network timeout');
|
|
106
145
|
else console.log('oh-my-hi: ❌ update failed —', e.message);
|
|
@@ -112,6 +151,17 @@ async function runUpdate() {
|
|
|
112
151
|
const SETTINGS_PATH = path.join(CLAUDE_CONFIG_DIR, 'settings.json');
|
|
113
152
|
const AUTO_HOOK_CMD = `node "${path.join(ROOT, 'scripts', 'generate-dashboard.mjs')}" --data-only`;
|
|
114
153
|
|
|
154
|
+
/** Returns true if version a is greater than version b (semver, numeric comparison) */
|
|
155
|
+
function semverGt(a, b) {
|
|
156
|
+
const pa = a.split('.').map(Number);
|
|
157
|
+
const pb = b.split('.').map(Number);
|
|
158
|
+
for (let i = 0; i < 3; i++) {
|
|
159
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
160
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
115
165
|
function readSettings() {
|
|
116
166
|
if (!fs.existsSync(SETTINGS_PATH)) return {};
|
|
117
167
|
try { return JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')); } catch { return {}; }
|
|
@@ -267,11 +317,7 @@ async function main() {
|
|
|
267
317
|
}
|
|
268
318
|
|
|
269
319
|
// ── Full mode (user-initiated /omh) ──
|
|
270
|
-
console.log('oh-my-hi: collecting data...');
|
|
271
|
-
|
|
272
320
|
const scopes = detectScopes(CLAUDE_CONFIG_DIR, extraPaths);
|
|
273
|
-
console.log(` scopes: ${scopes.length} detected`);
|
|
274
|
-
|
|
275
321
|
const systemLocale = detectSystemLocale();
|
|
276
322
|
|
|
277
323
|
// Check cache state to decide progressive mode
|
|
@@ -285,30 +331,37 @@ async function main() {
|
|
|
285
331
|
}
|
|
286
332
|
|
|
287
333
|
if (!cacheExists && !pendingExists) {
|
|
288
|
-
// Progressive mode: no cache at all
|
|
289
|
-
console.log('oh-my-hi:
|
|
334
|
+
// Progressive mode: no cache at all (first run)
|
|
335
|
+
console.log('oh-my-hi: first run — generating dashboard from scratch...');
|
|
336
|
+
console.log(` [1/4] scanning ${scopes.length} workspace(s)...`);
|
|
290
337
|
|
|
291
338
|
const cache = {};
|
|
292
|
-
|
|
339
|
+
console.log(' [2/4] building 7-day preview...');
|
|
340
|
+
const phase1ScopeData = await collectAllScopes(scopes, { days: 7, cache, progress: true });
|
|
293
341
|
const phase1Data = buildDataObject(scopes, phase1ScopeData, systemLocale, { _partial: true });
|
|
294
342
|
writeDataJs(phase1Data, dataPath);
|
|
343
|
+
|
|
344
|
+
console.log(' [3/4] opening browser with preview...');
|
|
295
345
|
openOrRefreshBrowser(indexPath);
|
|
296
|
-
console.log('oh-my-hi: preview ready — loading full data in background...');
|
|
297
346
|
|
|
298
|
-
|
|
347
|
+
console.log(' [4/4] loading full history (this may take a moment)...');
|
|
348
|
+
const phase2ScopeData = await collectAllScopes(scopes, { days: 0, cache, cachePath: CACHE_PATH, progress: true });
|
|
299
349
|
const phase2Data = buildDataObject(scopes, phase2ScopeData, systemLocale);
|
|
300
350
|
writeDataJs(phase2Data, dataPath);
|
|
301
351
|
openOrRefreshBrowser(indexPath);
|
|
302
|
-
console.log('oh-my-hi: full data loaded — browser refreshed');
|
|
303
352
|
} else {
|
|
304
353
|
// Normal mode: load cache + merge pending + full data
|
|
305
|
-
|
|
354
|
+
console.log('oh-my-hi: collecting data...');
|
|
355
|
+
console.log(` [1/3] scanning ${scopes.length} workspace(s)...`);
|
|
356
|
+
const scopeData = await collectAllScopes(scopes, { days: 0, cachePath: CACHE_PATH, progress: true });
|
|
357
|
+
console.log(' [2/3] building dashboard...');
|
|
306
358
|
const data = buildDataObject(scopes, scopeData, systemLocale);
|
|
307
359
|
writeDataJs(data, dataPath);
|
|
360
|
+
console.log(' [3/3] opening browser...');
|
|
308
361
|
openOrRefreshBrowser(indexPath);
|
|
309
362
|
}
|
|
310
363
|
|
|
311
|
-
console.log('oh-my-hi: done');
|
|
364
|
+
console.log('oh-my-hi: ✅ done');
|
|
312
365
|
|
|
313
366
|
// Auto-refresh status notice
|
|
314
367
|
const settings = readSettings();
|
|
@@ -323,7 +376,7 @@ async function main() {
|
|
|
323
376
|
await checkForUpdate();
|
|
324
377
|
}
|
|
325
378
|
|
|
326
|
-
/** Check
|
|
379
|
+
/** Check GitHub tags for newer version (non-blocking, cached for 24h) */
|
|
327
380
|
async function checkForUpdate() {
|
|
328
381
|
const UPDATE_CHECK_FILE = path.join(OUTPUT, 'cache', '.update-check');
|
|
329
382
|
try {
|
|
@@ -331,7 +384,7 @@ async function checkForUpdate() {
|
|
|
331
384
|
if (fs.existsSync(UPDATE_CHECK_FILE)) {
|
|
332
385
|
const lastCheck = JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, 'utf8'));
|
|
333
386
|
if (Date.now() - lastCheck.timestamp < 24 * 60 * 60 * 1000) {
|
|
334
|
-
if (lastCheck.latest && lastCheck.latest
|
|
387
|
+
if (lastCheck.latest && semverGt(lastCheck.latest, lastCheck.current)) {
|
|
335
388
|
console.log(`\noh-my-hi: ✨ v${lastCheck.latest} available (current: v${lastCheck.current})`);
|
|
336
389
|
console.log(' → Update: /omh --update');
|
|
337
390
|
}
|
|
@@ -341,25 +394,48 @@ async function checkForUpdate() {
|
|
|
341
394
|
|
|
342
395
|
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
|
|
343
396
|
const current = pkg.version;
|
|
397
|
+
let latest = null;
|
|
398
|
+
|
|
399
|
+
// Check GitHub tags first (primary distribution channel)
|
|
400
|
+
const repoUrl = pkg.repository?.url || '';
|
|
401
|
+
const ghMatch = repoUrl.match(/github\.com[/:]([^/]+\/[^/.]+)/);
|
|
402
|
+
if (ghMatch) {
|
|
403
|
+
const controller = new AbortController();
|
|
404
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
405
|
+
const res = await fetch(`https://api.github.com/repos/${ghMatch[1]}/tags`, {
|
|
406
|
+
signal: controller.signal, headers: { 'Accept': 'application/vnd.github+json' },
|
|
407
|
+
});
|
|
408
|
+
clearTimeout(timeout);
|
|
409
|
+
if (res.ok) {
|
|
410
|
+
const tags = await res.json();
|
|
411
|
+
for (const tag of tags) {
|
|
412
|
+
const v = tag.name.replace(/^v/, '');
|
|
413
|
+
if (/^\d+\.\d+\.\d+$/.test(v) && (!latest || semverGt(v, latest))) latest = v;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
344
417
|
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
418
|
+
// Fallback to npm registry
|
|
419
|
+
if (!latest) {
|
|
420
|
+
const controller = new AbortController();
|
|
421
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
422
|
+
const res = await fetch(`https://registry.npmjs.org/${pkg.name}/latest`, {
|
|
423
|
+
signal: controller.signal, headers: { 'Accept': 'application/json' },
|
|
424
|
+
});
|
|
425
|
+
clearTimeout(timeout);
|
|
426
|
+
if (res.ok) {
|
|
427
|
+
const data = await res.json();
|
|
428
|
+
latest = data.version;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
353
431
|
|
|
354
|
-
if (!
|
|
355
|
-
const data = await res.json();
|
|
356
|
-
const latest = data.version;
|
|
432
|
+
if (!latest) return;
|
|
357
433
|
|
|
358
434
|
// Cache the result
|
|
359
435
|
fs.mkdirSync(path.dirname(UPDATE_CHECK_FILE), { recursive: true });
|
|
360
436
|
fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ timestamp: Date.now(), current, latest }), 'utf8');
|
|
361
437
|
|
|
362
|
-
if (latest
|
|
438
|
+
if (semverGt(latest, current)) {
|
|
363
439
|
console.log(`\noh-my-hi: ✨ v${latest} available (current: v${current})`);
|
|
364
440
|
console.log(' → Update: /omh --update');
|
|
365
441
|
}
|
|
@@ -385,8 +461,18 @@ function detectSystemLocale() {
|
|
|
385
461
|
return systemLocale;
|
|
386
462
|
}
|
|
387
463
|
|
|
464
|
+
/** Render an in-place progress bar to stdout */
|
|
465
|
+
function renderProgressBar(processed, total) {
|
|
466
|
+
if (!total) return;
|
|
467
|
+
const pct = Math.round((processed / total) * 100);
|
|
468
|
+
const width = 25;
|
|
469
|
+
const filled = Math.round((processed / total) * width);
|
|
470
|
+
const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
471
|
+
process.stdout.write(`\r [${bar}] ${pct}% (${processed}/${total} files)`);
|
|
472
|
+
}
|
|
473
|
+
|
|
388
474
|
/** Collect all scopes (global + projects) with given options */
|
|
389
|
-
async function collectAllScopes(scopes, { days = 0, cache, cachePath } = {}) {
|
|
475
|
+
async function collectAllScopes(scopes, { days = 0, cache, cachePath, progress = false } = {}) {
|
|
390
476
|
const projectScopes = scopes.filter(s => s.type !== 'global');
|
|
391
477
|
// Load or reuse cache; reset parse counter for this collection round
|
|
392
478
|
const sharedCache = cache || (cachePath ? loadTranscriptCache(cachePath) : {});
|
|
@@ -398,6 +484,12 @@ async function collectAllScopes(scopes, { days = 0, cache, cachePath } = {}) {
|
|
|
398
484
|
}
|
|
399
485
|
|
|
400
486
|
sharedCache._parsed = 0;
|
|
487
|
+
sharedCache._processed = 0;
|
|
488
|
+
sharedCache._total = 0;
|
|
489
|
+
sharedCache._onProgress = progress
|
|
490
|
+
? () => renderProgressBar(sharedCache._processed, sharedCache._total)
|
|
491
|
+
: undefined;
|
|
492
|
+
|
|
401
493
|
const usageOpts = { days, cache: sharedCache, cachePath };
|
|
402
494
|
|
|
403
495
|
const [globalData, ...projectResults] = await Promise.all([
|
|
@@ -416,6 +508,8 @@ async function collectAllScopes(scopes, { days = 0, cache, cachePath } = {}) {
|
|
|
416
508
|
// Update mtime index for lightweight mode
|
|
417
509
|
if (cachePath) saveMtimeIndex(cachePath, sharedCache);
|
|
418
510
|
|
|
511
|
+
if (progress) process.stdout.write('\n');
|
|
512
|
+
|
|
419
513
|
const totalFiles = Object.keys(sharedCache).filter(k => !k.startsWith('_')).length;
|
|
420
514
|
const parsed = Math.min(sharedCache._parsed || 0, totalFiles);
|
|
421
515
|
console.log(` transcripts: ${totalFiles} files (${parsed} parsed, ${totalFiles - parsed} cached)`);
|
|
@@ -441,7 +535,9 @@ function buildDataObject(scopes, scopeData, systemLocale, extra = {}) {
|
|
|
441
535
|
}
|
|
442
536
|
}
|
|
443
537
|
const taskCategories = buildTaskCategories(cleanScopeData);
|
|
444
|
-
|
|
538
|
+
// 스크립트가 ~/.claude 하위(플러그인)에서 실행되면 배포본, 그 외는 dev
|
|
539
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
540
|
+
const isDevBuild = !scriptDir.startsWith(CLAUDE_CONFIG_DIR);
|
|
445
541
|
return {
|
|
446
542
|
scopes,
|
|
447
543
|
scopeData: cleanScopeData,
|
|
@@ -405,19 +405,24 @@ export function saveMtimeIndex(cachePath, cache) {
|
|
|
405
405
|
* Always parses without cutoff (full data) so cache is reusable across time ranges.
|
|
406
406
|
*/
|
|
407
407
|
async function cachedParseTranscriptFile(fp, cache) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
408
|
+
try {
|
|
409
|
+
let stat;
|
|
410
|
+
try { stat = fs.statSync(fp); } catch { return null; }
|
|
411
|
+
const cached = cache[fp];
|
|
412
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
413
|
+
// Full cache hit (has result) or mtime-only stub (skip — already processed)
|
|
414
|
+
if (cached.size === stat.size) return cached.result;
|
|
415
|
+
// Mtime match but size=0 means mtime-only stub from lightweight mode — skip
|
|
416
|
+
if (cached.size === 0) return null;
|
|
417
|
+
}
|
|
418
|
+
const result = await parseTranscriptFile(fp, 0);
|
|
419
|
+
cache[fp] = { mtimeMs: stat.mtimeMs, size: stat.size, result, _new: true };
|
|
420
|
+
cache._parsed = (cache._parsed || 0) + 1;
|
|
421
|
+
return result;
|
|
422
|
+
} finally {
|
|
423
|
+
cache._processed = (cache._processed || 0) + 1;
|
|
424
|
+
cache._onProgress?.();
|
|
416
425
|
}
|
|
417
|
-
const result = await parseTranscriptFile(fp, 0);
|
|
418
|
-
cache[fp] = { mtimeMs: stat.mtimeMs, size: stat.size, result, _new: true };
|
|
419
|
-
cache._parsed = (cache._parsed || 0) + 1;
|
|
420
|
-
return result;
|
|
421
426
|
}
|
|
422
427
|
|
|
423
428
|
/**
|
|
@@ -671,6 +676,7 @@ async function parseTranscripts(configDir, cutoffMs, cache = {}) {
|
|
|
671
676
|
}
|
|
672
677
|
|
|
673
678
|
// Phase 2: Parse all files in parallel (with cache)
|
|
679
|
+
cache._total = (cache._total || 0) + filePaths.length;
|
|
674
680
|
const results = await parallelMap(filePaths, fp => cachedParseTranscriptFile(fp, cache));
|
|
675
681
|
|
|
676
682
|
return applyCutoff(mergeTranscriptResults(results.filter(Boolean)), cutoffMs);
|
|
@@ -784,6 +790,7 @@ async function parseProjectTranscripts(projDirPath, cutoffMs, cache = {}) {
|
|
|
784
790
|
}
|
|
785
791
|
|
|
786
792
|
// Phase 2: Parse all files in parallel (with cache)
|
|
793
|
+
cache._total = (cache._total || 0) + filePaths.length;
|
|
787
794
|
const results = await parallelMap(filePaths, fp => cachedParseTranscriptFile(fp, cache));
|
|
788
795
|
|
|
789
796
|
return applyCutoff(mergeTranscriptResults(results.filter(Boolean)), cutoffMs);
|
package/test/cache.test.mjs
CHANGED
|
@@ -208,13 +208,11 @@ describe('Conditional HTML Rebuild', () => {
|
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
it('should not rebuild index.html when version matches', () => {
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const statAfter = fs.statSync(path.join(OUTPUT, 'index.html'));
|
|
217
|
-
assert.equal(statBefore.mtimeMs, statAfter.mtimeMs, 'index.html should not be rewritten');
|
|
211
|
+
// Check output rather than mtime: mtime comparison is unreliable on macOS APFS
|
|
212
|
+
// due to sub-millisecond timestamp precision artifacts.
|
|
213
|
+
const output = run('--data-only');
|
|
214
|
+
assert.ok(!output.includes('rebuilding'), 'should not print rebuilding message');
|
|
215
|
+
assert.ok(!output.includes('data structure changed'), 'should not trigger migration rebuild');
|
|
218
216
|
});
|
|
219
217
|
});
|
|
220
218
|
|
|
@@ -323,6 +321,97 @@ describe('Progressive Loading', () => {
|
|
|
323
321
|
});
|
|
324
322
|
});
|
|
325
323
|
|
|
324
|
+
// ── Progress Tracking ──
|
|
325
|
+
|
|
326
|
+
describe('Progress Tracking', () => {
|
|
327
|
+
it('_onProgress is called for every file (cache hit and miss)', async () => {
|
|
328
|
+
const tmpDir = fs.mkdtempSync('/tmp/omh-progress-');
|
|
329
|
+
try {
|
|
330
|
+
// Write two minimal JSONL transcript files
|
|
331
|
+
fs.writeFileSync(path.join(tmpDir, 'a.jsonl'), '');
|
|
332
|
+
fs.writeFileSync(path.join(tmpDir, 'b.jsonl'), '');
|
|
333
|
+
|
|
334
|
+
const cache = {};
|
|
335
|
+
let callCount = 0;
|
|
336
|
+
cache._onProgress = () => { callCount++; };
|
|
337
|
+
|
|
338
|
+
await parseUsage(CLAUDE_CONFIG_DIR, 0, tmpDir, { cache });
|
|
339
|
+
assert.ok(callCount >= 2, `_onProgress should be called at least once per file, got ${callCount}`);
|
|
340
|
+
} finally {
|
|
341
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('_processed equals number of files processed', async () => {
|
|
346
|
+
const tmpDir = fs.mkdtempSync('/tmp/omh-progress-');
|
|
347
|
+
try {
|
|
348
|
+
fs.writeFileSync(path.join(tmpDir, 'a.jsonl'), '');
|
|
349
|
+
fs.writeFileSync(path.join(tmpDir, 'b.jsonl'), '');
|
|
350
|
+
fs.writeFileSync(path.join(tmpDir, 'c.jsonl'), '');
|
|
351
|
+
|
|
352
|
+
const cache = {};
|
|
353
|
+
cache._onProgress = () => {};
|
|
354
|
+
await parseUsage(CLAUDE_CONFIG_DIR, 0, tmpDir, { cache });
|
|
355
|
+
|
|
356
|
+
assert.equal(cache._processed, 3, '_processed should equal file count');
|
|
357
|
+
} finally {
|
|
358
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('_total is set before files are processed', async () => {
|
|
363
|
+
const tmpDir = fs.mkdtempSync('/tmp/omh-progress-');
|
|
364
|
+
try {
|
|
365
|
+
fs.writeFileSync(path.join(tmpDir, 'a.jsonl'), '');
|
|
366
|
+
fs.writeFileSync(path.join(tmpDir, 'b.jsonl'), '');
|
|
367
|
+
|
|
368
|
+
const cache = {};
|
|
369
|
+
let totalAtFirstCall = null;
|
|
370
|
+
cache._onProgress = () => {
|
|
371
|
+
if (totalAtFirstCall === null) totalAtFirstCall = cache._total;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
await parseUsage(CLAUDE_CONFIG_DIR, 0, tmpDir, { cache });
|
|
375
|
+
assert.ok(totalAtFirstCall >= 2, `_total should be set before first _onProgress, got ${totalAtFirstCall}`);
|
|
376
|
+
} finally {
|
|
377
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('_processed increments for cache hits too', async () => {
|
|
382
|
+
const tmpDir = fs.mkdtempSync('/tmp/omh-progress-');
|
|
383
|
+
try {
|
|
384
|
+
fs.writeFileSync(path.join(tmpDir, 'a.jsonl'), '');
|
|
385
|
+
|
|
386
|
+
// First run — cache miss
|
|
387
|
+
const cache = {};
|
|
388
|
+
cache._onProgress = () => {};
|
|
389
|
+
await parseUsage(CLAUDE_CONFIG_DIR, 0, tmpDir, { cache });
|
|
390
|
+
const parsedFirst = cache._parsed;
|
|
391
|
+
|
|
392
|
+
// Second run — cache hit
|
|
393
|
+
cache._processed = 0;
|
|
394
|
+
cache._parsed = 0;
|
|
395
|
+
cache._onProgress = () => {};
|
|
396
|
+
await parseUsage(CLAUDE_CONFIG_DIR, 0, tmpDir, { cache });
|
|
397
|
+
|
|
398
|
+
assert.equal(cache._processed, 1, '_processed should increment even on cache hit');
|
|
399
|
+
assert.equal(cache._parsed, 0, '_parsed should not increment on cache hit');
|
|
400
|
+
assert.equal(parsedFirst, 1, 'first run should have parsed the file');
|
|
401
|
+
} finally {
|
|
402
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('normal mode output contains progress bar characters', () => {
|
|
407
|
+
// Progress bar is only shown in full (non --data-only) mode
|
|
408
|
+
// Use captured stdout which includes \r-separated progress updates
|
|
409
|
+
const output = run();
|
|
410
|
+
const hasProgressBar = output.includes('[') && output.includes('%') && output.includes('files)');
|
|
411
|
+
assert.ok(hasProgressBar, 'output should contain progress bar ([ ... % ... files))');
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
326
415
|
// ── findSkillFiles Exclusions ──
|
|
327
416
|
|
|
328
417
|
describe('findSkillFiles exclusions', () => {
|