claude-dependency-test 1.0.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.
- package/.claude-plugin/plugin.json +21 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/assets/card.js +696 -0
- package/assets/fonts/IBMPlexMono-400.woff2 +0 -0
- package/assets/fonts/OFL-IBMPlexMono.txt +93 -0
- package/assets/fonts/OFL-VT323.txt +93 -0
- package/assets/fonts/VT323-400.woff2 +0 -0
- package/assets/report.template.html +181 -0
- package/config/scoring.json +203 -0
- package/package.json +29 -0
- package/scripts/diagnose.mjs +168 -0
- package/scripts/lib/collect.mjs +206 -0
- package/scripts/lib/metrics.mjs +267 -0
- package/scripts/lib/publish.mjs +77 -0
- package/scripts/lib/report.mjs +277 -0
- package/scripts/lib/score.mjs +69 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude Dependency Test: computes a 0-100 dependency score from local Claude Code transcripts.
|
|
3
|
+
// The computation is deterministic, offline and dependency-free. Then, unless --no-publish, the
|
|
4
|
+
// aggregate report (numbers and enum values only) is published to the share site.
|
|
5
|
+
//
|
|
6
|
+
// Usage: node diagnose.mjs [--json] [--tz <IANA zone>] [--days <n>] [--open] [--no-html]
|
|
7
|
+
// [--no-publish] [--new-link] [--unpublish]
|
|
8
|
+
// [--out <dir>] [--config-dir <dir>] [--now <ISO date>]
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { collect } from './lib/collect.mjs';
|
|
15
|
+
import { computeMetrics } from './lib/metrics.mjs';
|
|
16
|
+
import { archetypeFor, scoreMetrics, stageFor } from './lib/score.mjs';
|
|
17
|
+
import { publish, unpublish } from './lib/publish.mjs';
|
|
18
|
+
import { buildResult, finalizeShare, renderHtml, renderShareFooter, toPublicReport } from './lib/report.mjs';
|
|
19
|
+
import { badgeUrl } from '../assets/card.js';
|
|
20
|
+
|
|
21
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, '.claude-plugin', 'plugin.json'), 'utf8')).version;
|
|
23
|
+
|
|
24
|
+
export function parseArgs(argv) {
|
|
25
|
+
const args = { json: false, open: false, html: true, publish: true, newLink: false, unpublish: false };
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const a = argv[i];
|
|
28
|
+
const next = () => {
|
|
29
|
+
if (i + 1 >= argv.length) throw new Error(`${a} needs a value`);
|
|
30
|
+
return argv[++i];
|
|
31
|
+
};
|
|
32
|
+
if (a === '--json') args.json = true;
|
|
33
|
+
else if (a === '--open') args.open = true;
|
|
34
|
+
else if (a === '--no-open') args.open = false;
|
|
35
|
+
else if (a === '--no-html') args.html = false;
|
|
36
|
+
else if (a === '--no-publish') args.publish = false;
|
|
37
|
+
else if (a === '--new-link') args.newLink = true;
|
|
38
|
+
else if (a === '--unpublish') args.unpublish = true;
|
|
39
|
+
else if (a === '--tz') args.tz = next();
|
|
40
|
+
else if (a === '--days') args.days = Number(next());
|
|
41
|
+
else if (a === '--out') args.out = next();
|
|
42
|
+
else if (a === '--config-dir') args.configDir = next();
|
|
43
|
+
else if (a === '--now') args.now = next();
|
|
44
|
+
else if (a === '--help' || a === '-h') args.help = true;
|
|
45
|
+
else throw new Error(`Unknown option: ${a}`);
|
|
46
|
+
}
|
|
47
|
+
if (args.days !== undefined && !(args.days >= 1)) throw new Error('--days must be a positive number');
|
|
48
|
+
if (!argv.includes('--no-open') && !args.json && process.stdout.isTTY) args.open = true; // npx in a terminal
|
|
49
|
+
if (args.tz) new Intl.DateTimeFormat('en-US', { timeZone: args.tz }); // throws on invalid zone
|
|
50
|
+
return args;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Reads only `cleanupPeriodDays` from settings.json. */
|
|
54
|
+
export function readCleanupPeriodDays(configDir, fallback) {
|
|
55
|
+
try {
|
|
56
|
+
const s = JSON.parse(fs.readFileSync(path.join(configDir, 'settings.json'), 'utf8'));
|
|
57
|
+
const v = Number(s && s.cleanupPeriodDays);
|
|
58
|
+
return Number.isFinite(v) && v > 0 ? v : fallback;
|
|
59
|
+
} catch {
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function openInBrowser(file) {
|
|
65
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
66
|
+
const cmdArgs = process.platform === 'win32' ? ['/c', 'start', '', file] : [file];
|
|
67
|
+
try {
|
|
68
|
+
spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore' }).on('error', () => {}).unref();
|
|
69
|
+
} catch {
|
|
70
|
+
/* opening the browser is best-effort */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function run(argv, env = process.env, { fetchImpl = globalThis.fetch } = {}) {
|
|
75
|
+
const args = parseArgs(argv);
|
|
76
|
+
if (args.help) {
|
|
77
|
+
return { text: 'Usage: diagnose.mjs [--json] [--tz Europe/Paris] [--days 30] [--open] [--no-html] [--no-publish] [--new-link] [--unpublish] [--out dir]' };
|
|
78
|
+
}
|
|
79
|
+
const outDir = args.out || path.join(os.homedir(), '.claude-dependency-test');
|
|
80
|
+
const stateFile = path.join(outDir, 'published.json');
|
|
81
|
+
|
|
82
|
+
if (args.unpublish) {
|
|
83
|
+
const r = await unpublish({ stateFile, fetchImpl });
|
|
84
|
+
const result = { tool: 'claude-dependency-test', version: VERSION, unpublished: r };
|
|
85
|
+
return { result, text: r.deleted ? `Deleted ${r.url}. It's gone from the database now; cached copies of the page, preview image and badge expire within 5 minutes.` : `Nothing to delete: ${r.reason}.` };
|
|
86
|
+
}
|
|
87
|
+
const config = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'scoring.json'), 'utf8'));
|
|
88
|
+
const configDir = args.configDir || env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
89
|
+
const projectsDir = path.join(configDir, 'projects');
|
|
90
|
+
const timeZone = args.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
91
|
+
const now = args.now ? Date.parse(args.now) : Date.now();
|
|
92
|
+
if (!Number.isFinite(now)) throw new Error('--now must be an ISO date');
|
|
93
|
+
const cleanupPeriodDays = readCleanupPeriodDays(configDir, config.defaultCleanupPeriodDays);
|
|
94
|
+
const baseUrl = env.CDT_SHARE_BASE_URL || config.share.baseUrl;
|
|
95
|
+
|
|
96
|
+
const collected = await collect(projectsDir);
|
|
97
|
+
const computed = computeMetrics(collected, { now, timeZone, cleanupPeriodDays, maxDays: args.days, config });
|
|
98
|
+
|
|
99
|
+
if (computed.empty) {
|
|
100
|
+
const result = {
|
|
101
|
+
tool: 'claude-dependency-test',
|
|
102
|
+
version: VERSION,
|
|
103
|
+
error: 'insufficient_data',
|
|
104
|
+
message: `No prompts found in the last ${args.days || cleanupPeriodDays} days of Claude Code transcripts. Use Claude Code a bit, then come back for your diagnosis.`,
|
|
105
|
+
dataQuality: collected.quality,
|
|
106
|
+
};
|
|
107
|
+
return { result, text: result.message };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const scored = scoreMetrics(computed.metrics, computed.window.days, config);
|
|
111
|
+
const stage = stageFor(scored.total, config);
|
|
112
|
+
const archetype = archetypeFor(scored.breakdown, config);
|
|
113
|
+
const result = buildResult({
|
|
114
|
+
computed,
|
|
115
|
+
scored,
|
|
116
|
+
stage,
|
|
117
|
+
archetype,
|
|
118
|
+
config,
|
|
119
|
+
baseUrl,
|
|
120
|
+
generatedAt: new Date(now).toISOString(),
|
|
121
|
+
version: VERSION,
|
|
122
|
+
});
|
|
123
|
+
result.dataQuality = collected.quality;
|
|
124
|
+
|
|
125
|
+
if (args.publish && !env.CDT_NO_PUBLISH) {
|
|
126
|
+
try {
|
|
127
|
+
const p = await publish({ baseUrl, report: toPublicReport(result), stateFile, forceNew: args.newLink, fetchImpl });
|
|
128
|
+
result.published = p;
|
|
129
|
+
result.share.url = p.url;
|
|
130
|
+
result.share.badge = badgeUrl(baseUrl, result.card, p.slug);
|
|
131
|
+
finalizeShare(result);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
result.publishError = `${err.message}. Falling back to a link that carries the card in its URL.`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (args.html) {
|
|
138
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
139
|
+
const file = path.join(outDir, 'report.html');
|
|
140
|
+
fs.writeFileSync(file, renderHtml(result));
|
|
141
|
+
result.reportPath = file;
|
|
142
|
+
}
|
|
143
|
+
if (args.open && !env.CDT_NO_OPEN) {
|
|
144
|
+
const target = result.published ? result.published.url : result.reportPath;
|
|
145
|
+
if (target) openInBrowser(target);
|
|
146
|
+
}
|
|
147
|
+
const lines = [result.bulletin, ''];
|
|
148
|
+
if (result.published) {
|
|
149
|
+
lines.push(` Online: ${result.published.url}${result.published.updated ? ' (updated)' : ''}`);
|
|
150
|
+
lines.push(' Only aggregate numbers were published. Delete it: --unpublish · Stay offline next time: --no-publish');
|
|
151
|
+
}
|
|
152
|
+
else if (result.publishError) lines.push(` Not published: ${result.publishError}`);
|
|
153
|
+
lines.push(` Card: ${result.reportPath || '(not written)'}`, renderShareFooter(result), '');
|
|
154
|
+
return { result, text: lines.join('\n') };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
158
|
+
if (isMain) {
|
|
159
|
+
run(process.argv.slice(2))
|
|
160
|
+
.then(({ result, text }) => {
|
|
161
|
+
const argv = process.argv.slice(2);
|
|
162
|
+
process.stdout.write(argv.includes('--json') && result ? `${JSON.stringify(result, null, 1)}\n` : `${text}\n`);
|
|
163
|
+
})
|
|
164
|
+
.catch((err) => {
|
|
165
|
+
process.stderr.write(`claude-dependency-test: ${err.message}\n`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Streams Claude Code transcripts and keeps only timestamps and a few enum-like fields.
|
|
2
|
+
// Message text, file paths, project names and identifiers are never stored or returned:
|
|
3
|
+
// uuids / message ids live only in in-memory Sets used for de-duplication.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
import { normalizeToolName } from '../../assets/card.js';
|
|
8
|
+
|
|
9
|
+
// Legacy transcripts (written before Claude Code tagged prompts with `origin`/`promptSource`)
|
|
10
|
+
// have no structured marker for "typed by a human". For those lines only, we compare the first
|
|
11
|
+
// characters of the text against this fixed list of tags that Claude Code itself injects.
|
|
12
|
+
// The text is never copied or kept.
|
|
13
|
+
const LEGACY_INJECTED_PREFIXES = ['<', '[Request interrupted'];
|
|
14
|
+
const HUMAN_PROMPT_SOURCES = new Set(['typed', 'queued', 'suggestion_accepted']);
|
|
15
|
+
|
|
16
|
+
export function listTranscriptFiles(projectsDir) {
|
|
17
|
+
const out = [];
|
|
18
|
+
const walk = (dir) => {
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
22
|
+
} catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
for (const e of entries) {
|
|
26
|
+
const p = path.join(dir, e.name);
|
|
27
|
+
if (e.isDirectory()) walk(p);
|
|
28
|
+
else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(p);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
walk(projectsDir);
|
|
32
|
+
return out.sort();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function leadingText(content) {
|
|
36
|
+
if (typeof content === 'string') return content;
|
|
37
|
+
if (Array.isArray(content)) {
|
|
38
|
+
const block = content.find((b) => b && b.type === 'text');
|
|
39
|
+
return block && typeof block.text === 'string' ? block.text : '';
|
|
40
|
+
}
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Returns 'human' | 'legacy-human' | null for a `type: "user"` line. */
|
|
45
|
+
export function classifyUserLine(o) {
|
|
46
|
+
if (o.isSidechain || o.isMeta || o.isCompactSummary || o.isVisibleInTranscriptOnly) return null;
|
|
47
|
+
const content = o.message && o.message.content;
|
|
48
|
+
if (Array.isArray(content)) {
|
|
49
|
+
if (content.some((b) => b && b.type === 'tool_result')) return null;
|
|
50
|
+
if (!content.some((b) => b && (b.type === 'text' || b.type === 'image'))) return null;
|
|
51
|
+
} else if (typeof content !== 'string') {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (o.origin && typeof o.origin === 'object') return o.origin.kind === 'human' ? 'human' : null;
|
|
55
|
+
if (typeof o.promptSource === 'string') return HUMAN_PROMPT_SOURCES.has(o.promptSource) ? 'human' : null;
|
|
56
|
+
const head = leadingText(content).slice(0, 32).trimStart();
|
|
57
|
+
if (!head) return Array.isArray(content) ? 'legacy-human' : null; // image-only prompt
|
|
58
|
+
if (LEGACY_INJECTED_PREFIXES.some((p) => head.startsWith(p))) return null;
|
|
59
|
+
return 'legacy-human';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Claude Code writes this marker (not a prompt) when you press Esc while Claude is working.
|
|
63
|
+
const INTERRUPTION_MARKER = '[Request interrupted';
|
|
64
|
+
function isInterruptionLine(o) {
|
|
65
|
+
if (o.isSidechain || o.origin || o.promptSource) return false;
|
|
66
|
+
const content = o.message && o.message.content;
|
|
67
|
+
const block = Array.isArray(content) && content.length === 1 && content[0] && content[0].type === 'text' ? content[0] : null;
|
|
68
|
+
return !!block && typeof block.text === 'string' && block.text.startsWith(INTERRUPTION_MARKER);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isRateLimitLine(o) {
|
|
72
|
+
if (o.error === 'rate_limit') return true;
|
|
73
|
+
return !!(o.quotaLimits && o.quotaLimits.status === 'rejected');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @returns {Promise<{
|
|
78
|
+
* activity: number[], prompts: number[], modelEvents: {ms:number, model:string}[],
|
|
79
|
+
* limitEvents: {ms:number, resetsAt:number|null}[], errorSignalsSeen: boolean,
|
|
80
|
+
* quality: object }>}
|
|
81
|
+
*/
|
|
82
|
+
export async function collect(projectsDir) {
|
|
83
|
+
const files = listTranscriptFiles(projectsDir);
|
|
84
|
+
const seenUuids = new Set();
|
|
85
|
+
const seenMessageIds = new Set();
|
|
86
|
+
const activity = [];
|
|
87
|
+
const prompts = [];
|
|
88
|
+
const modelEvents = [];
|
|
89
|
+
const limitEvents = [];
|
|
90
|
+
const toolEvents = []; // { ms, name } with name from a fixed list (see normalizeToolName)
|
|
91
|
+
const usageById = new Map(); // message.id -> { ms, out, total }: max over the lines of one response
|
|
92
|
+
const subagents = new Map(); // agentId -> first ms (ids stay in memory, only the count is output)
|
|
93
|
+
const compactions = [];
|
|
94
|
+
const turns = []; // { ms, durationMs } for main-thread turns
|
|
95
|
+
const interruptions = [];
|
|
96
|
+
const projectLastMs = new Map(); // transcript folder -> last activity (only the count is output)
|
|
97
|
+
let errorSignalsSeen = false;
|
|
98
|
+
const quality = {
|
|
99
|
+
files: files.length,
|
|
100
|
+
lines: 0,
|
|
101
|
+
unparseableLines: 0,
|
|
102
|
+
duplicateLinesSkipped: 0,
|
|
103
|
+
promptsFromOriginField: 0,
|
|
104
|
+
promptsFromLegacyHeuristic: 0,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
const project = path.relative(projectsDir, file).split(path.sep)[0];
|
|
109
|
+
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
|
110
|
+
for await (const line of rl) {
|
|
111
|
+
if (!line) continue;
|
|
112
|
+
quality.lines++;
|
|
113
|
+
let o;
|
|
114
|
+
try {
|
|
115
|
+
o = JSON.parse(line);
|
|
116
|
+
} catch {
|
|
117
|
+
quality.unparseableLines++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!o || (o.type !== 'user' && o.type !== 'assistant' && o.type !== 'system')) continue;
|
|
121
|
+
|
|
122
|
+
// Resumed / forked sessions copy earlier lines verbatim (same uuid) into a new file.
|
|
123
|
+
if (typeof o.uuid === 'string') {
|
|
124
|
+
if (seenUuids.has(o.uuid)) {
|
|
125
|
+
quality.duplicateLinesSkipped++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
seenUuids.add(o.uuid);
|
|
129
|
+
}
|
|
130
|
+
const ms = Date.parse(o.timestamp);
|
|
131
|
+
if (!Number.isFinite(ms)) continue;
|
|
132
|
+
|
|
133
|
+
if (o.type === 'system') {
|
|
134
|
+
if (o.isSidechain) continue;
|
|
135
|
+
if (o.subtype === 'compact_boundary') compactions.push(ms);
|
|
136
|
+
if (o.subtype === 'turn_duration' && Number.isFinite(o.durationMs) && o.durationMs > 0) turns.push({ ms, durationMs: o.durationMs });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (o.isSidechain && typeof o.agentId === 'string' && !subagents.has(o.agentId)) subagents.set(o.agentId, ms);
|
|
140
|
+
|
|
141
|
+
if (o.type === 'assistant') {
|
|
142
|
+
if (o.error !== undefined || o.quotaLimits || o.isApiErrorMessage) errorSignalsSeen = true;
|
|
143
|
+
if (isRateLimitLine(o)) {
|
|
144
|
+
const r = o.quotaLimits && Number(o.quotaLimits.resetsAt);
|
|
145
|
+
limitEvents.push({ ms, resetsAt: Number.isFinite(r) ? r : null });
|
|
146
|
+
}
|
|
147
|
+
// One API response is written as several lines sharing message.id: count it once.
|
|
148
|
+
const msg = o.message || {};
|
|
149
|
+
const model = typeof msg.model === 'string' ? msg.model : null;
|
|
150
|
+
const id = typeof msg.id === 'string' ? msg.id : null;
|
|
151
|
+
if (model && model !== '<synthetic>' && !o.isApiErrorMessage && (!id || !seenMessageIds.has(id))) {
|
|
152
|
+
if (id) seenMessageIds.add(id);
|
|
153
|
+
modelEvents.push({ ms, model });
|
|
154
|
+
}
|
|
155
|
+
if (Array.isArray(msg.content)) {
|
|
156
|
+
for (const b of msg.content) if (b && b.type === 'tool_use' && typeof b.name === 'string') toolEvents.push({ ms, name: normalizeToolName(b.name) });
|
|
157
|
+
}
|
|
158
|
+
const u = msg.usage;
|
|
159
|
+
if (id && u && !o.isApiErrorMessage) {
|
|
160
|
+
const n = (v) => (Number.isFinite(v) && v > 0 ? v : 0);
|
|
161
|
+
const out = n(u.output_tokens);
|
|
162
|
+
const total = out + n(u.input_tokens) + n(u.cache_read_input_tokens) + n(u.cache_creation_input_tokens);
|
|
163
|
+
const prev = usageById.get(id);
|
|
164
|
+
if (!prev) usageById.set(id, { ms, out, total });
|
|
165
|
+
else {
|
|
166
|
+
prev.out = Math.max(prev.out, out);
|
|
167
|
+
prev.total = Math.max(prev.total, total);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (o.isSidechain) continue; // subagent work counts for models, tools and tokens, not activity or prompts
|
|
173
|
+
activity.push(ms);
|
|
174
|
+
projectLastMs.set(project, Math.max(projectLastMs.get(project) || 0, ms));
|
|
175
|
+
if (o.type === 'user' && isInterruptionLine(o)) interruptions.push(ms);
|
|
176
|
+
|
|
177
|
+
if (o.type === 'user') {
|
|
178
|
+
const kind = classifyUserLine(o);
|
|
179
|
+
if (kind) {
|
|
180
|
+
prompts.push(ms);
|
|
181
|
+
if (kind === 'human') quality.promptsFromOriginField++;
|
|
182
|
+
else quality.promptsFromLegacyHeuristic++;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
activity.sort((a, b) => a - b);
|
|
189
|
+
prompts.sort((a, b) => a - b);
|
|
190
|
+
limitEvents.sort((a, b) => a.ms - b.ms);
|
|
191
|
+
return {
|
|
192
|
+
activity,
|
|
193
|
+
prompts,
|
|
194
|
+
modelEvents,
|
|
195
|
+
limitEvents,
|
|
196
|
+
errorSignalsSeen,
|
|
197
|
+
toolEvents,
|
|
198
|
+
usageEvents: [...usageById.values()],
|
|
199
|
+
subagentStarts: [...subagents.values()],
|
|
200
|
+
compactions,
|
|
201
|
+
turns,
|
|
202
|
+
interruptions,
|
|
203
|
+
projectLastActivity: [...projectLastMs.values()],
|
|
204
|
+
quality,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Turns collected timestamps into "symptoms". Pure and deterministic given (collected, options).
|
|
2
|
+
import { WORDS_PER_TOKEN } from '../../assets/card.js';
|
|
3
|
+
const WEEKDAYS = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
4
|
+
const DAY_MS = 86_400_000;
|
|
5
|
+
|
|
6
|
+
/** Maps a UTC instant to the local calendar day / hour / weekday of `timeZone`. */
|
|
7
|
+
export function makeClock(timeZone) {
|
|
8
|
+
const fmt = new Intl.DateTimeFormat('en-US', {
|
|
9
|
+
timeZone,
|
|
10
|
+
hourCycle: 'h23',
|
|
11
|
+
year: 'numeric',
|
|
12
|
+
month: '2-digit',
|
|
13
|
+
day: '2-digit',
|
|
14
|
+
hour: '2-digit',
|
|
15
|
+
minute: '2-digit',
|
|
16
|
+
weekday: 'short',
|
|
17
|
+
});
|
|
18
|
+
const cache = new Map();
|
|
19
|
+
return (ms) => {
|
|
20
|
+
const bucket = Math.floor(ms / 900_000); // 15 min buckets are safe for every real UTC offset
|
|
21
|
+
let v = cache.get(bucket);
|
|
22
|
+
if (!v) {
|
|
23
|
+
const p = {};
|
|
24
|
+
for (const part of fmt.formatToParts(ms)) p[part.type] = part.value;
|
|
25
|
+
v = { day: `${p.year}-${p.month}-${p.day}`, hour: Number(p.hour) % 24, dow: WEEKDAYS[p.weekday], bucketMinute: (Number(p.hour) % 24) * 60 + Number(p.minute) };
|
|
26
|
+
cache.set(bucket, v);
|
|
27
|
+
}
|
|
28
|
+
// Every real UTC offset is a multiple of 15 min, so the local minute moves with the UTC one.
|
|
29
|
+
return { ...v, minuteOfDay: v.bucketMinute + Math.floor((ms % 900_000) / 60_000) };
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const dayIndex = (day) => Math.round(Date.parse(`${day}T00:00:00Z`) / DAY_MS);
|
|
34
|
+
const indexToDay = (i) => new Date(i * DAY_MS).toISOString().slice(0, 10);
|
|
35
|
+
|
|
36
|
+
const metric = (value, extra = {}) => ({ available: true, value, ...extra });
|
|
37
|
+
const missing = (reason) => ({ available: false, value: null, reason });
|
|
38
|
+
|
|
39
|
+
export function modelFamily(model) {
|
|
40
|
+
const m = String(model).toLowerCase();
|
|
41
|
+
for (const f of ['opus', 'sonnet', 'haiku', 'fable']) if (m.includes(f)) return f;
|
|
42
|
+
return 'other';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param collected output of collect()
|
|
47
|
+
* @param opts { now:number, timeZone:string, cleanupPeriodDays:number, maxDays?:number, config }
|
|
48
|
+
*/
|
|
49
|
+
export function computeMetrics(collected, opts) {
|
|
50
|
+
const { config, timeZone } = opts;
|
|
51
|
+
const clock = makeClock(timeZone);
|
|
52
|
+
const gapMs = config.sessionGapMinutes * 60_000;
|
|
53
|
+
const min = config.minSamples;
|
|
54
|
+
|
|
55
|
+
// --- Observation window -------------------------------------------------
|
|
56
|
+
// Claude Code deletes transcripts older than cleanupPeriodDays, but a session resumed
|
|
57
|
+
// recently keeps its old lines. Clamp to the retention window so a few ancient lines
|
|
58
|
+
// don't stretch the period (and dilute "active days").
|
|
59
|
+
const endDay = clock(opts.now).day;
|
|
60
|
+
const endIdx = dayIndex(endDay);
|
|
61
|
+
const limitDays = Math.max(1, Math.floor(opts.maxDays || opts.cleanupPeriodDays));
|
|
62
|
+
const earliestAllowedIdx = endIdx - limitDays + 1;
|
|
63
|
+
const inWindow = (ms) => {
|
|
64
|
+
const i = dayIndex(clock(ms).day);
|
|
65
|
+
return i >= earliestAllowedIdx && i <= endIdx;
|
|
66
|
+
};
|
|
67
|
+
const activity = collected.activity.filter(inWindow);
|
|
68
|
+
const prompts = collected.prompts.filter(inWindow);
|
|
69
|
+
const limitEvents = collected.limitEvents.filter((e) => inWindow(e.ms));
|
|
70
|
+
|
|
71
|
+
if (activity.length === 0 || prompts.length === 0) {
|
|
72
|
+
return { empty: true, window: { timeZone, cleanupPeriodDays: opts.cleanupPeriodDays, endDay } };
|
|
73
|
+
}
|
|
74
|
+
const startIdx = Math.max(earliestAllowedIdx, dayIndex(clock(activity[0]).day));
|
|
75
|
+
const windowDays = endIdx - startIdx + 1;
|
|
76
|
+
const firstRawIdx = collected.activity.length ? dayIndex(clock(collected.activity[0]).day) : startIdx;
|
|
77
|
+
|
|
78
|
+
const window = {
|
|
79
|
+
startDay: indexToDay(startIdx),
|
|
80
|
+
endDay,
|
|
81
|
+
days: windowDays,
|
|
82
|
+
timeZone,
|
|
83
|
+
cleanupPeriodDays: opts.cleanupPeriodDays,
|
|
84
|
+
clampedToRetention: firstRawIdx < earliestAllowedIdx,
|
|
85
|
+
likelyTruncatedByCleanup: startIdx === earliestAllowedIdx,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// --- Sessions & active time -----------------------------------------------
|
|
89
|
+
let sessions = 0;
|
|
90
|
+
let activeMs = 0;
|
|
91
|
+
let weekendMs = 0;
|
|
92
|
+
let longestSessionMs = 0;
|
|
93
|
+
let sessionStart = activity[0];
|
|
94
|
+
let prev = activity[0];
|
|
95
|
+
sessions = 1;
|
|
96
|
+
for (let i = 1; i < activity.length; i++) {
|
|
97
|
+
const t = activity[i];
|
|
98
|
+
const gap = t - prev;
|
|
99
|
+
if (gap > gapMs) {
|
|
100
|
+
longestSessionMs = Math.max(longestSessionMs, prev - sessionStart);
|
|
101
|
+
sessions++;
|
|
102
|
+
sessionStart = t;
|
|
103
|
+
} else {
|
|
104
|
+
activeMs += gap;
|
|
105
|
+
const dow = clock(prev).dow;
|
|
106
|
+
if (dow === 0 || dow === 6) weekendMs += gap;
|
|
107
|
+
}
|
|
108
|
+
prev = t;
|
|
109
|
+
}
|
|
110
|
+
longestSessionMs = Math.max(longestSessionMs, prev - sessionStart);
|
|
111
|
+
|
|
112
|
+
// --- Prompt-based stats -----------------------------------------------------
|
|
113
|
+
const activeDaySet = new Set();
|
|
114
|
+
const daily = new Array(windowDays).fill(0);
|
|
115
|
+
const byHour = new Array(24).fill(0);
|
|
116
|
+
const byWeekday = new Array(7).fill(0);
|
|
117
|
+
let night = 0;
|
|
118
|
+
for (const t of prompts) {
|
|
119
|
+
const c = clock(t);
|
|
120
|
+
const di = dayIndex(c.day);
|
|
121
|
+
activeDaySet.add(di);
|
|
122
|
+
daily[di - startIdx]++;
|
|
123
|
+
byHour[c.hour]++;
|
|
124
|
+
byWeekday[c.dow]++;
|
|
125
|
+
if (c.hour >= config.nightHours.start && c.hour < config.nightHours.end) night++;
|
|
126
|
+
}
|
|
127
|
+
const activeDayIdx = [...activeDaySet].sort((a, b) => a - b);
|
|
128
|
+
const activeDays = activeDayIdx.length;
|
|
129
|
+
let longestStreak = 0;
|
|
130
|
+
let run = 0;
|
|
131
|
+
for (let i = 0; i < activeDayIdx.length; i++) {
|
|
132
|
+
run = i > 0 && activeDayIdx[i] === activeDayIdx[i - 1] + 1 ? run + 1 : 1;
|
|
133
|
+
longestStreak = Math.max(longestStreak, run);
|
|
134
|
+
}
|
|
135
|
+
let currentStreak = 0;
|
|
136
|
+
for (let d = activeDaySet.has(endIdx) ? endIdx : endIdx - 1; activeDaySet.has(d); d--) currentStreak++;
|
|
137
|
+
const peakHour = byHour.indexOf(Math.max(...byHour));
|
|
138
|
+
const peakWeekday = byWeekday.indexOf(Math.max(...byWeekday));
|
|
139
|
+
const busiest = daily.indexOf(Math.max(...daily));
|
|
140
|
+
|
|
141
|
+
// --- Models ---------------------------------------------------------------
|
|
142
|
+
const families = {};
|
|
143
|
+
let modelMessages = 0;
|
|
144
|
+
for (const e of collected.modelEvents) {
|
|
145
|
+
if (!inWindow(e.ms)) continue;
|
|
146
|
+
const f = modelFamily(e.model);
|
|
147
|
+
families[f] = (families[f] || 0) + 1;
|
|
148
|
+
modelMessages++;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Usage stats (fun facts, real numbers) -------------------------------
|
|
152
|
+
const toolCalls = {};
|
|
153
|
+
for (const e of collected.toolEvents) if (inWindow(e.ms)) toolCalls[e.name] = (toolCalls[e.name] || 0) + 1;
|
|
154
|
+
let outputTokens = 0;
|
|
155
|
+
let totalTokens = 0;
|
|
156
|
+
for (const u of collected.usageEvents) {
|
|
157
|
+
if (!inWindow(u.ms)) continue;
|
|
158
|
+
outputTokens += u.out;
|
|
159
|
+
totalTokens += u.total;
|
|
160
|
+
}
|
|
161
|
+
const turns = collected.turns.filter((t) => inWindow(t.ms));
|
|
162
|
+
// Latest prompt of the night: evening and night hours (18:00-05:59) ordered as one night.
|
|
163
|
+
let latestNight = null;
|
|
164
|
+
let earliestMorning = null;
|
|
165
|
+
for (const t of prompts) {
|
|
166
|
+
const m = clock(t).minuteOfDay;
|
|
167
|
+
if (m >= 18 * 60 || m < 6 * 60) {
|
|
168
|
+
const shifted = (m + 6 * 60) % (24 * 60);
|
|
169
|
+
if (latestNight === null || shifted > (latestNight + 6 * 60) % (24 * 60)) latestNight = m;
|
|
170
|
+
} else if (m < 12 * 60 && (earliestMorning === null || m < earliestMorning)) earliestMorning = m;
|
|
171
|
+
}
|
|
172
|
+
const usage = {
|
|
173
|
+
outputTokens,
|
|
174
|
+
totalTokens,
|
|
175
|
+
toolCalls,
|
|
176
|
+
subagents: collected.subagentStarts.filter(inWindow).length,
|
|
177
|
+
compactions: collected.compactions.filter(inWindow).length,
|
|
178
|
+
interruptions: collected.interruptions.filter(inWindow).length,
|
|
179
|
+
longestTurnMinutes: turns.reduce((mx, t) => Math.max(mx, t.durationMs), 0) / 60_000,
|
|
180
|
+
claudeHours: turns.reduce((sum, t) => sum + t.durationMs, 0) / 3_600_000,
|
|
181
|
+
projects: collected.projectLastActivity.filter(inWindow).length,
|
|
182
|
+
latestNightMinute: latestNight,
|
|
183
|
+
earliestMorningMinute: earliestMorning,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// --- Usage limits -----------------------------------------------------------
|
|
187
|
+
// One limit hit usually produces several error lines (retries, parallel sessions).
|
|
188
|
+
// Lines sharing the same reset time, or within an hour of each other, are one hit.
|
|
189
|
+
let limitHits = 0;
|
|
190
|
+
let lastMs = -Infinity;
|
|
191
|
+
const seenResets = new Set();
|
|
192
|
+
for (const e of limitEvents) {
|
|
193
|
+
if (e.resetsAt !== null) {
|
|
194
|
+
if (!seenResets.has(e.resetsAt)) limitHits++;
|
|
195
|
+
seenResets.add(e.resetsAt);
|
|
196
|
+
} else if (e.ms - lastMs > 3_600_000) {
|
|
197
|
+
limitHits++;
|
|
198
|
+
}
|
|
199
|
+
lastMs = e.ms;
|
|
200
|
+
}
|
|
201
|
+
const limitsDetectable = limitHits > 0 || collected.errorSignalsSeen;
|
|
202
|
+
|
|
203
|
+
const hours = activeMs / 3_600_000;
|
|
204
|
+
const enoughPrompts = prompts.length >= min.prompts;
|
|
205
|
+
const tooFewPrompts = `fewer than ${min.prompts} prompts in the observation window`;
|
|
206
|
+
|
|
207
|
+
const metrics = {
|
|
208
|
+
activeHoursPerActiveDay: metric(hours / activeDays, { totalActiveHours: hours, sessions, longestSessionHours: longestSessionMs / 3_600_000 }),
|
|
209
|
+
activeDaysShare:
|
|
210
|
+
windowDays >= min.windowDaysForRegularity
|
|
211
|
+
? metric(activeDays / windowDays, { activeDays, windowDays })
|
|
212
|
+
: missing(`observation window shorter than ${min.windowDaysForRegularity} days`),
|
|
213
|
+
longestStreakDays:
|
|
214
|
+
windowDays >= min.windowDaysForRegularity
|
|
215
|
+
? metric(longestStreak, { currentStreak })
|
|
216
|
+
: missing(`observation window shorter than ${min.windowDaysForRegularity} days`),
|
|
217
|
+
nightPromptShare: enoughPrompts ? metric(night / prompts.length, { nightPrompts: night }) : missing(tooFewPrompts),
|
|
218
|
+
promptsPerActiveDay: enoughPrompts ? metric(prompts.length / activeDays, { prompts: prompts.length, activeDays }) : missing(tooFewPrompts),
|
|
219
|
+
weekendActivityShare:
|
|
220
|
+
windowDays < min.windowDaysForWeekend
|
|
221
|
+
? missing(`observation window shorter than ${min.windowDaysForWeekend} days`)
|
|
222
|
+
: activeMs > 0
|
|
223
|
+
? metric(weekendMs / activeMs)
|
|
224
|
+
: missing('no measurable active time'),
|
|
225
|
+
limitHitsPer30Days: limitsDetectable
|
|
226
|
+
? metric((limitHits * 30) / windowDays, { limitHits })
|
|
227
|
+
: missing('no rate-limit or API-error markers found in your transcripts (older Claude Code versions do not record them)'),
|
|
228
|
+
longestSessionHours: metric(longestSessionMs / 3_600_000),
|
|
229
|
+
subagentsPerActiveDay: metric(usage.subagents / activeDays, { subagents: usage.subagents }),
|
|
230
|
+
wordsPerActiveDay: outputTokens > 0
|
|
231
|
+
? metric((outputTokens * WORDS_PER_TOKEN) / activeDays, { words: outputTokens * WORDS_PER_TOKEN })
|
|
232
|
+
: missing('no token usage recorded in your transcripts'),
|
|
233
|
+
opusShare:
|
|
234
|
+
modelMessages >= min.modelMessages
|
|
235
|
+
? metric((families.opus || 0) / modelMessages, { modelMessages })
|
|
236
|
+
: missing(`fewer than ${min.modelMessages} model responses with a known model`),
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const modelMix = Object.fromEntries(
|
|
240
|
+
Object.entries(families)
|
|
241
|
+
.sort((a, b) => b[1] - a[1])
|
|
242
|
+
.map(([f, n]) => [f, n / modelMessages]),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
empty: false,
|
|
247
|
+
window,
|
|
248
|
+
metrics,
|
|
249
|
+
usage,
|
|
250
|
+
extras: {
|
|
251
|
+
prompts: prompts.length,
|
|
252
|
+
activeDays,
|
|
253
|
+
sessions,
|
|
254
|
+
totalActiveHours: hours,
|
|
255
|
+
longestSessionHours: longestSessionMs / 3_600_000,
|
|
256
|
+
currentStreak,
|
|
257
|
+
peakHour,
|
|
258
|
+
peakWeekday,
|
|
259
|
+
busiestDay: { day: indexToDay(startIdx + busiest), prompts: daily[busiest] },
|
|
260
|
+
promptsByDay: daily,
|
|
261
|
+
promptsByHour: byHour,
|
|
262
|
+
promptsByWeekday: byWeekday,
|
|
263
|
+
modelMix,
|
|
264
|
+
limitHits: limitsDetectable ? limitHits : null,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|