cchubber 0.5.8 → 0.6.1
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/README.md +10 -0
- package/package.json +1 -1
- package/src/analyzers/cost-calculator.js +11 -0
- package/src/analyzers/drift-privacy.js +245 -0
- package/src/analyzers/drift-run.js +53 -0
- package/src/analyzers/drift.js +481 -0
- package/src/analyzers/reprice.js +154 -0
- package/src/cli/index.js +119 -11
- package/src/data/frontier-models.js +12 -0
- package/src/data/price-overrides.js +16 -0
- package/src/data/price-snapshot.json +49 -0
- package/src/readers/jsonl-reader.js +11 -14
- package/src/readers/plan.js +56 -0
- package/src/readers/stated-plan.js +187 -0
- package/src/readers/transcripts.js +259 -0
- package/src/renderers/drift-report.js +315 -0
- package/src/renderers/html-report.js +1307 -1280
- package/src/renderers/spent-well.js +171 -0
- package/src/renderers/terminal-summary.js +10 -0
- package/src/renderers/vs-report.js +439 -0
- package/src/renderers/vs-text.js +149 -0
- package/src/telemetry.js +13 -1
package/README.md
CHANGED
|
@@ -32,6 +32,16 @@ A single HTML report that tells you three things: what you spent, why you spent
|
|
|
32
32
|
- CLAUDE.md section-by-section analysis with per-message cost impact
|
|
33
33
|
- Cache break estimation even when diff files don't exist on your CC version
|
|
34
34
|
|
|
35
|
+
**Your week against your plan:**
|
|
36
|
+
Directly under the "Did you spend them well?" strip, the report shows how much of your last 7 days of Claude Code (and Codex) work went to what you said you'd do: one number, what it was measured against, where the rest went, and the day you drifted most. "What you said you'd do" is read from a `PLAN.md`, `plan.md` or `TODO.md` checklist in the project you work in (or a Mover OS Daily Note). With no written plan, or no work in the window, the section says what to add instead of showing a number. It runs on your machine and nothing is uploaded; detour names are your own words with paths, keys and links removed. No flag needed, it is part of `npx cchubber`.
|
|
37
|
+
|
|
38
|
+
**Your usage on other models:**
|
|
39
|
+
Right under the grade card, the report reprices your own tokens on every current frontier model's list price (Claude Fable 5.1, Opus 5.5, Sonnet 5.5, GPT-6 Astra and Sol, Gemini 4 Argon, DeepSeek V4 Pro, Kimi K3) and replays your real days as a six-second race. It also shows how many tokens Claude read for every token it wrote, what you paid (when your plan is detected), and, when you have a written plan, how much of your last week went to it (computed locally). Buttons download a 1200x675 card, copy the text, or open a pre-filled post on X. You click; the page never posts. No flag needed, it is part of `npx cchubber`. `--json` includes it as `reprice`.
|
|
40
|
+
|
|
41
|
+
- **Sources:** prices come live from [LiteLLM](https://github.com/BerriAI/litellm)'s `model_prices_and_context_window.json`, fetched on each run. A model LiteLLM does not list yet (today Gemini 4 Argon, priced from [blog.google](https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-4-argon/), checked 30 Sep 2026, introductory price with the later price shown too) comes from `src/data/price-overrides.js`, used only while LiteLLM has no entry, so live data wins the day it adds the model. Offline, bundled prices dated 30 Sep 2026 are used and the report says so.
|
|
42
|
+
- **Gaps:** where a provider lists no cache-read price, the input price is used (no discount assumed); where it lists no cache-write price, or zero, cache writes are billed as input. The report names which models that touched.
|
|
43
|
+
- **Fine print:** same tokens on each model's list price, cache included. Another model would use a different number of tokens, so this compares prices, not outcomes.
|
|
44
|
+
|
|
35
45
|
**The shareable card:**
|
|
36
46
|
An animated card with your grade, spend, cache ratio, and diagnosis line. Export as video. Post it. Let people see the numbers Anthropic won't show them.
|
|
37
47
|
|
package/package.json
CHANGED
|
@@ -13,6 +13,14 @@ const FALLBACK_PRICING = {
|
|
|
13
13
|
|
|
14
14
|
// Dynamic pricing cache (populated by fetchPricing)
|
|
15
15
|
let dynamicPricing = null;
|
|
16
|
+
// The whole LiteLLM JSON (every provider) from that same fetch, kept for --vs. Null when the fetch failed.
|
|
17
|
+
let rawLiteLLM = null;
|
|
18
|
+
let rawFetchedAt = null;
|
|
19
|
+
|
|
20
|
+
/** The raw LiteLLM table and when it was fetched, or null when offline. Call after fetchPricing(). */
|
|
21
|
+
export function getLiteLLMRaw() {
|
|
22
|
+
return rawLiteLLM ? { data: rawLiteLLM, fetchedAt: rawFetchedAt } : null;
|
|
23
|
+
}
|
|
16
24
|
|
|
17
25
|
/**
|
|
18
26
|
* Fetch latest pricing from LiteLLM. Returns map of model -> pricing.
|
|
@@ -36,6 +44,7 @@ export async function fetchPricing() {
|
|
|
36
44
|
|
|
37
45
|
// Parse LiteLLM format into our format
|
|
38
46
|
const pricing = {};
|
|
47
|
+
const fetchedAt = Date.now();
|
|
39
48
|
for (const [key, info] of Object.entries(data)) {
|
|
40
49
|
if (!key.startsWith('claude') && !key.includes('claude')) continue;
|
|
41
50
|
|
|
@@ -52,6 +61,8 @@ export async function fetchPricing() {
|
|
|
52
61
|
|
|
53
62
|
if (Object.keys(pricing).length > 0) {
|
|
54
63
|
dynamicPricing = pricing;
|
|
64
|
+
rawLiteLLM = data;
|
|
65
|
+
rawFetchedAt = fetchedAt;
|
|
55
66
|
return pricing;
|
|
56
67
|
}
|
|
57
68
|
} catch {
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the drift card is allowed to show of your prompts.
|
|
3
|
+
* Always on: keys, tokens, links, file paths, emails and long numbers are replaced before any words reach the card.
|
|
4
|
+
* With --redact: detour names become categories, so no prompt text is shown at all.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Shapes of the credentials people paste most. Anything key-like that no rule names is caught by the last two.
|
|
8
|
+
const SECRETS = [
|
|
9
|
+
/\bsk-(?:ant-|proj-|live-|test-|or-)?[A-Za-z0-9_-]{12,}/g,
|
|
10
|
+
/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{16,}/g,
|
|
11
|
+
/\bgithub_pat_[A-Za-z0-9_]{16,}/g,
|
|
12
|
+
/\bglpat-[A-Za-z0-9_-]{16,}/g,
|
|
13
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
|
|
14
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
15
|
+
/\bAIza[0-9A-Za-z_-]{30,}/g,
|
|
16
|
+
/\bnpm_[A-Za-z0-9]{30,}/g,
|
|
17
|
+
/\b(?:rk|pk|sk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
|
|
18
|
+
/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{3,}/g,
|
|
19
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
|
|
20
|
+
/\b(?:bearer|token|api[_-]?key|secret|password|passwd|pwd)\s*[:=]\s*\S+/gi,
|
|
21
|
+
/\b[A-Fa-f0-9]{32,}\b/g,
|
|
22
|
+
// 24+ characters mixing upper case, lower case and digits, with no spaces: almost never a word
|
|
23
|
+
/\b(?=[A-Za-z0-9+/_-]*\d)(?=[A-Za-z0-9+/_-]*[a-z])(?=[A-Za-z0-9+/_-]*[A-Z])[A-Za-z0-9+/_-]{24,}={0,2}/g,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const URL = /\b(?:https?:\/\/|www\.)\S+|\b[\w-]+\.(?:com|dev|io|ai|app|co|net|org|so|sh|me|gg|ly|uk|xyz)\/\S*/gi;
|
|
27
|
+
const EMAIL = /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g;
|
|
28
|
+
// Home paths, absolute paths with at least two parts (so "/goal" survives), Windows paths, and relative paths
|
|
29
|
+
// that end in a file extension
|
|
30
|
+
const PATH = /~\/[\w.@\/-]+|(?<![\w.])\/[\w.@-]+(?:\/[\w.@-]*)+|\b[A-Za-z]:\\[^\s"']+|\b(?:[\w.@-]+\/)+[\w@-]+\.[A-Za-z0-9]{1,6}\b/g;
|
|
31
|
+
// Nine or more digits (phone, card, account numbers); dates like 2026-09-29 have eight and are kept
|
|
32
|
+
const LONG_NUMBER = /\b\d(?:[ -]?\d){8,}\b/g;
|
|
33
|
+
|
|
34
|
+
export function scrub(text) {
|
|
35
|
+
let s = String(text || '');
|
|
36
|
+
for (const re of SECRETS) s = s.replace(re, ' [key] ');
|
|
37
|
+
return s
|
|
38
|
+
.replace(URL, ' [link] ')
|
|
39
|
+
.replace(EMAIL, ' [email] ')
|
|
40
|
+
.replace(PATH, ' [file] ')
|
|
41
|
+
.replace(LONG_NUMBER, ' [number] ')
|
|
42
|
+
.replace(/\s+/g, ' ')
|
|
43
|
+
.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Chat filler at the front of a prompt, dropped so the name starts at the point
|
|
47
|
+
const LEAD_FILLER = new Set(['yo', 'bro', 'bruh', 'broski', 'brudda', 'mate', 'man', 'ok', 'okay', 'alr', 'alright', 'so',
|
|
48
|
+
'and', 'also', 'btw', 'but', 'nah', 'yeah', 'yh', 'yes', 'hey', 'hi', 'oh', 'well', 'right', 'um', 'uh', 'ngl', 'tbh',
|
|
49
|
+
'lol', 'gng', 'wait', 'hmm', 'actually', 'basically', 'look', 'listen', 'now', 'then', 'please', 'pls',
|
|
50
|
+
'i', 'meant', 'mean', 'means', 'think', 'thought', 'saying', 'wondering', 'idk', 'just', 'like', 'the', 'thing', 'is',
|
|
51
|
+
'can', 'could', 'would', 'will', 'you', 'we', 'do', 'does', 'should', 'lets', 'let']);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A detour's name: the start of the user's own prompt, scrubbed, lower-cased and cut to a short phrase.
|
|
55
|
+
* Returns '' when nothing readable is left (a prompt that was only a link, say).
|
|
56
|
+
*/
|
|
57
|
+
export function shortName(text, { maxWords = 7, maxChars = 44 } = {}) {
|
|
58
|
+
let s = scrub(String(text || '').replace(/\[Image[^\]]*\]/gi, ' '))
|
|
59
|
+
// The placeholders scrub() leaves ([link], [file], ...) carry no meaning in a name, so drop them entirely
|
|
60
|
+
.replace(/\[(?:key|link|file|email|number)\]/g, ' ')
|
|
61
|
+
.replace(/[‘’]/g, "'").replace(/["“”`*_#>@]/g, '').replace(/\s+/g, ' ').trim();
|
|
62
|
+
// The first sentence that says something; "Why?" alone borrows the next one
|
|
63
|
+
const parts = s.split(/(?<=[.?!])\s+|\n+/).map(p => p.trim()).filter(Boolean);
|
|
64
|
+
let phrase = '';
|
|
65
|
+
for (const p of parts) {
|
|
66
|
+
phrase = phrase ? `${phrase} ${p}` : p;
|
|
67
|
+
if (phrase.split(' ').filter(w => !LEAD_FILLER.has(w.toLowerCase())).length >= 3) break;
|
|
68
|
+
}
|
|
69
|
+
let words = phrase.split(' ').filter(Boolean);
|
|
70
|
+
while (words.length && LEAD_FILLER.has(words[0].toLowerCase().replace(/[^a-z]/g, ''))) words.shift();
|
|
71
|
+
// Placeholders left by scrub() carry no meaning at the edges
|
|
72
|
+
while (words.length && /^\[\w+\]$/.test(words[words.length - 1])) words.pop();
|
|
73
|
+
if (!words.some(w => /[a-z]{2,}/i.test(w) && !/^\[\w+\]$/.test(w))) return '';
|
|
74
|
+
let out = '';
|
|
75
|
+
let cut = false;
|
|
76
|
+
for (const [i, w] of words.entries()) {
|
|
77
|
+
const next = out ? `${out} ${w}` : w;
|
|
78
|
+
if (i >= maxWords || next.length > maxChars) { cut = true; break; }
|
|
79
|
+
out = next;
|
|
80
|
+
}
|
|
81
|
+
out = out.replace(/[,;:\-]+$/, '').toLowerCase();
|
|
82
|
+
return cut ? `${out}…` : out.replace(/[.!]+$/, '');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// For --redact: the kind of detour, never its words. First matching rule wins.
|
|
86
|
+
const CATEGORIES = [
|
|
87
|
+
['food and health', /\b(food|eat|ate|eating|cook|cooking|cooked|recipes?|chicken|rice|protein|calories?|kcal|meals?|lunch|dinner|breakfast|snacks?|gym|workout|weights?|sleep|hrv|spo2|steps|doctor|ill|sick|cough|diet|macros?|brownies?)\b/],
|
|
88
|
+
['shopping', /\b(buy|bought|prices?|deals?|offers?|cheap|cheaper|discount|orders?|argos|amazon|ebay|marketplace|freezer|fridge|laptop|macbook|listing|shop|shopping|stock)\b|£/],
|
|
89
|
+
['paperwork', /\b(ticket|appeal|fine|parking|insurance|bank|tax|invoice|letter|form|council|visa|passport|contract|claim|refund)\b/],
|
|
90
|
+
['setup and tooling', /\b(install|setup|set up|config|settings|terminal|sessions?|account|accounts|login|log in|permissions?|mcp|remote|disk|storage|cache|space|update|upgrade|plugins?|extensions?|hooks?|cli|history)\b/],
|
|
91
|
+
['design', /\b(design|ui|ux|fonts?|colou?rs?|logo|brand|branding|mascots?|landing|layout|figma|icons?|animation|motion|screens?)\b/],
|
|
92
|
+
['content and marketing', /\b(posts?|tweets?|videos?|reels?|captions?|content|launch|viral|marketing|seo|newsletter|youtube|tiktok|instagram|x\.com|audience)\b/],
|
|
93
|
+
['research', /\b(research|analy[sz]e|analysis|compare|markets?|competitors?|trends?|bookmarks?|threads?|reddit|articles?|news|ipo|benchmarks?)\b/],
|
|
94
|
+
['money', /\b(money|revenue|pricing|business|income|profit|sales|sell|selling|customers?|startup)\b/],
|
|
95
|
+
['code', /\b(bug|fix|error|refactor|tests?|deploy|build|api|function|script|database|db|server|endpoint|repo|commit|branch|code)\b/],
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
export function categorize(text) {
|
|
99
|
+
const s = String(text || '').toLowerCase();
|
|
100
|
+
for (const [name, re] of CATEGORIES) if (re.test(s)) return name;
|
|
101
|
+
return 'something else';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
105
|
+
// Readable labels for the drift section. shortName() above keeps the raw start of a prompt; labelName() turns it into
|
|
106
|
+
// something a person recognises, or says what the detour touched, or names its kind. scrub() runs first, always.
|
|
107
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
// Openers that only lead into the point. Multi-word ones first so "question is" goes as one.
|
|
110
|
+
const OPENERS = [
|
|
111
|
+
'at the', 'on the', 'in the', 'question is', 'the question is', 'can you', 'could you', 'would you', 'will you', 'i want to', 'i want',
|
|
112
|
+
'i need to', 'i need', 'i would like to', 'make sure', 'lets', "let's", 'let us', 'ok so', 'okay so', 'so', 'also', 'ok', 'okay', 'and', 'but',
|
|
113
|
+
'i think that', 'i think', 'i feel like', 'i feel', 'i guess', 'i mean', 'i was thinking', 'i dont know', "i don't know", 'maybe', 'btw', 'yo', 'hey', 'please', 'pls', 'now', 'then', 'actually', 'basically', 'well', 'the', 'a', 'an', 'i',
|
|
114
|
+
];
|
|
115
|
+
// Words that carry no topic on their own, for counting how much a label really says
|
|
116
|
+
const THIN = new Set(`a an the and or but nor so if of to in on at by for with from as is am are was were be been being it its it's this that these those
|
|
117
|
+
here there i me my we our you your he she they them his her their do does did done doing have has had not no yes yeah
|
|
118
|
+
can could would should will shall may might must just also too very really quite much many more most some any all etc
|
|
119
|
+
what when where which who whom whose why how than then thus about into onto over under up down out off again still even
|
|
120
|
+
thing things stuff bit lot lots like kind sort one ones`.split(/\s+/).filter(Boolean));
|
|
121
|
+
// A label that ends on one of these was cut mid-thought
|
|
122
|
+
const DANGLING = new Set(['or', 'and', 'but', 'the', 'a', 'an', 'to', 'of', 'in', 'on', 'at', 'for', 'with', 'was', 'is', 'are', 'that', 'this',
|
|
123
|
+
'so', 'if', 'as', 'by', 'from', 'etc', 'how', 'what', 'my', 'your', 'our', 'their', 'it', 'its']);
|
|
124
|
+
|
|
125
|
+
const LABEL_MAX = 32;
|
|
126
|
+
|
|
127
|
+
/** The prompt with everything that reads as markup or decoration removed: tags, [Image #1], pasted-content ids, bullets. */
|
|
128
|
+
function tidy(text) {
|
|
129
|
+
return String(text || '')
|
|
130
|
+
.replace(/\[(?:Image|Pasted|Attached|File)[^\]]*\]/gi, ' ')
|
|
131
|
+
// <pastedcontent id=2b8c ...>, also when the closing > never came
|
|
132
|
+
.replace(/<\/?[a-z][\w:-]*(?:\s+[\w:-]+\s*=\s*[^\s>]+)*\s*>?/gi, ' ')
|
|
133
|
+
.replace(/<[^>\n]*>/g, ' ')
|
|
134
|
+
.replace(/[●•·▪◦○◉■□▶►→←↑↓✓✗✔✘★☆]/g, ' ')
|
|
135
|
+
.replace(/(?:^|\s)[-*>#|]+(?=\s|$)/g, ' ')
|
|
136
|
+
.replace(/\s+/g, ' ')
|
|
137
|
+
.trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const plain = (w) => w.toLowerCase().replace(/[^a-z0-9']/g, '');
|
|
141
|
+
const meaningful = (w) => { const p = plain(w); return p.length >= 2 && /[a-z]/.test(p) && !THIN.has(p) && !/^\d/.test(p); };
|
|
142
|
+
|
|
143
|
+
// "1/5 skill gates" -> "skill gates": numbering, fractions and lone digits at the front say nothing
|
|
144
|
+
const stripLeadNumbers = (s) => s.replace(/^(?:[\d#./:-]+\s+)+/, '');
|
|
145
|
+
|
|
146
|
+
function stripOpeners(s) {
|
|
147
|
+
let out = s;
|
|
148
|
+
for (let i = 0; i < 6; i++) {
|
|
149
|
+
const low = out.toLowerCase();
|
|
150
|
+
const hit = OPENERS.find(o => low === o || low.startsWith(o + ' '));
|
|
151
|
+
if (!hit) break;
|
|
152
|
+
out = out.slice(hit.length).trim();
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Cut to the cap at a word boundary, never ending on a dangling word
|
|
158
|
+
function capWords(words) {
|
|
159
|
+
let out = '';
|
|
160
|
+
for (const w of words) {
|
|
161
|
+
const next = out ? `${out} ${w}` : w;
|
|
162
|
+
if (next.length > LABEL_MAX) break;
|
|
163
|
+
out = next;
|
|
164
|
+
}
|
|
165
|
+
// A cut inside brackets leaves "skill gates (design and writing": drop the open bracket and what follows
|
|
166
|
+
out = out.replace(/\s*[(\[][^)\]]*$/, '');
|
|
167
|
+
const parts = out.split(' ');
|
|
168
|
+
while (parts.length > 1 && DANGLING.has(plain(parts[parts.length - 1]))) parts.pop();
|
|
169
|
+
return parts.join(' ');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The readable label a prompt earns on its own, or '' when it does not say enough to be one. */
|
|
173
|
+
export function promptLabel(text) {
|
|
174
|
+
const cleaned = tidy(scrub(tidy(text)).replace(/\[(?:key|link|file|email|number)\]/g, ' '));
|
|
175
|
+
const body = stripLeadNumbers(stripOpeners(stripLeadNumbers(cleaned)))
|
|
176
|
+
.replace(/[‘’]/g, "'").replace(/["“”`*_@]/g, '').replace(/(?:\.{2,}|…)+\s*$/, '').replace(/\s+/g, ' ').trim();
|
|
177
|
+
// The first sentence that says something
|
|
178
|
+
const parts = body.split(/(?<=[.?!])\s+|\n+/).map(p => p.trim()).filter(Boolean);
|
|
179
|
+
let phrase = '';
|
|
180
|
+
for (const p of parts) {
|
|
181
|
+
phrase = phrase ? `${phrase} ${p}` : p;
|
|
182
|
+
if (phrase.split(' ').filter(meaningful).length >= 3) break;
|
|
183
|
+
}
|
|
184
|
+
const words = phrase.replace(/[.?!,;:]+$/, '').split(' ').filter(Boolean);
|
|
185
|
+
if (words.filter(meaningful).length < 3) return '';
|
|
186
|
+
// Looks like a fragment: it was cut off, or it ends on a joining word
|
|
187
|
+
if (DANGLING.has(plain(words[words.length - 1])) && words.length <= 7) return '';
|
|
188
|
+
const label = capWords(words.slice(0, 8)).replace(/[,;:\-]+$/, '').toLowerCase();
|
|
189
|
+
return label.split(' ').filter(meaningful).length >= 3 ? label : '';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const FOLDER_SKIP = new Set(['users', 'user', 'home', 'documents', 'desktop', 'downloads', 'library', 'private', 'tmp', 'var', 'opt', 'node_modules', 'dist', 'build', '.git', '.claude']);
|
|
193
|
+
const EXT_NOUN = { md: 'notes', mdx: 'notes', txt: 'notes', html: 'pages', htm: 'pages', css: 'styles', scss: 'styles', js: 'code', mjs: 'code', ts: 'code', tsx: 'code', jsx: 'code', py: 'code', sh: 'scripts', json: 'data', yml: 'config', yaml: 'config', toml: 'config', png: 'images', jpg: 'images', jpeg: 'images', svg: 'images', pdf: 'documents' };
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* What a run of work touched: the folder its files mostly sit in ("site/compare pages"), else the project folder it ran in.
|
|
197
|
+
* `files` are absolute paths, `cwd` the folder the session ran in. Returns '' when there is nothing safe to say.
|
|
198
|
+
*/
|
|
199
|
+
export function touchedLabel(files = [], cwd = '', home = '') {
|
|
200
|
+
const tally = new Map();
|
|
201
|
+
const exts = new Map();
|
|
202
|
+
const cwdClean = String(cwd || '').replace(/[\\/]+$/, '');
|
|
203
|
+
for (const f of files) {
|
|
204
|
+
let p = String(f);
|
|
205
|
+
if (cwdClean && p.startsWith(cwdClean + '/')) p = p.slice(cwdClean.length + 1);
|
|
206
|
+
else p = p.split(/[\\/]/).slice(-3).join('/');
|
|
207
|
+
const segs = p.split(/[\\/]/).filter(Boolean);
|
|
208
|
+
const file = segs.pop() || '';
|
|
209
|
+
const homeBase = String(home || '').split(/[\\/]/).filter(Boolean).pop();
|
|
210
|
+
const dirs = segs.filter(s => !FOLDER_SKIP.has(s.toLowerCase()) && !s.startsWith('.') && s !== homeBase).slice(0, 2);
|
|
211
|
+
const ext = (file.split('.').pop() || '').toLowerCase();
|
|
212
|
+
if (dirs.length) {
|
|
213
|
+
const k = dirs.join('/');
|
|
214
|
+
tally.set(k, (tally.get(k) || 0) + 1);
|
|
215
|
+
if (EXT_NOUN[ext]) exts.set(k + '|' + EXT_NOUN[ext], (exts.get(k + '|' + EXT_NOUN[ext]) || 0) + 1);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const pretty = (s) => s.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase();
|
|
219
|
+
const best = [...tally].sort((a, b) => b[1] - a[1])[0];
|
|
220
|
+
if (best) {
|
|
221
|
+
const noun = [...exts].filter(([k]) => k.startsWith(best[0] + '|')).sort((a, b) => b[1] - a[1])[0];
|
|
222
|
+
const dir = pretty(best[0]);
|
|
223
|
+
const word = noun ? noun[0].split('|')[1] : '';
|
|
224
|
+
return word && !dir.endsWith(word) ? `${dir} ${word}` : dir;
|
|
225
|
+
}
|
|
226
|
+
// No folder to name: the project the work ran in, unless that is the home folder or plumbing
|
|
227
|
+
const base = cwdClean.split(/[\\/]/).filter(Boolean).pop() || '';
|
|
228
|
+
const homeBase = String(home || '').split(/[\\/]/).filter(Boolean).pop();
|
|
229
|
+
if (base && base !== homeBase && !FOLDER_SKIP.has(base.toLowerCase())) return pretty(base);
|
|
230
|
+
return '';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The name shown for a detour. Its own words when they make a readable label; else what it touched; else its kind.
|
|
235
|
+
* Everything that reaches the page has been through scrub() and is at most ~32 characters.
|
|
236
|
+
* `touched`: { files, cwd, home } for the work the detour covered.
|
|
237
|
+
*/
|
|
238
|
+
export function labelName(text, touched = {}, category = categorize(text)) {
|
|
239
|
+
const own = promptLabel(text);
|
|
240
|
+
if (own) return own;
|
|
241
|
+
const t = touchedLabel(touched.files, touched.cwd, touched.home);
|
|
242
|
+
const safe = t ? scrub(t).replace(/\[(?:key|link|file|email|number)\]/g, ' ').replace(/\s+/g, ' ').trim() : '';
|
|
243
|
+
if (safe && meaningful(safe.split(' ')[0]) && safe.length <= LABEL_MAX + 12) return capWords(safe.split(' '));
|
|
244
|
+
return category;
|
|
245
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readTranscripts, defaultCodexDir } from '../readers/transcripts.js';
|
|
2
|
+
import { readStatedPlans, moverVault } from '../readers/stated-plan.js';
|
|
3
|
+
import { analyzeDrift } from './drift.js';
|
|
4
|
+
|
|
5
|
+
const DAY = 86400000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The whole drift pipeline: read the last `days` of local sessions, find what you said you'd do, and label the work.
|
|
9
|
+
* Everything is local. Returns the analysis plus which sources were read, for the report's "how this was measured".
|
|
10
|
+
*/
|
|
11
|
+
export function runDrift(claudeDir, { days = 7, until = Date.now(), codexDir = defaultCodexDir() } = {}) {
|
|
12
|
+
const start = new Date(until);
|
|
13
|
+
start.setHours(0, 0, 0, 0);
|
|
14
|
+
start.setDate(start.getDate() - (days - 1));
|
|
15
|
+
const since = start.getTime();
|
|
16
|
+
|
|
17
|
+
const sessions = readTranscripts({ claudeDir, codexDir, since, until });
|
|
18
|
+
const codexSeen = sessions.some(s => s.agent === 'codex');
|
|
19
|
+
|
|
20
|
+
const vault = moverVault();
|
|
21
|
+
const plans = readStatedPlans({ vault });
|
|
22
|
+
const drift = analyzeDrift(sessions, { since, until, planFor: plans.planFor, planNotes: plans.notes() });
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
...drift,
|
|
26
|
+
windowDays: days, // the length of the window; drift.days is the day-by-day list and must not be overwritten
|
|
27
|
+
inputs: {
|
|
28
|
+
claude: sessions.filter(s => s.agent === 'claude').length,
|
|
29
|
+
codex: sessions.filter(s => s.agent === 'codex').length,
|
|
30
|
+
codexPresent: codexSeen,
|
|
31
|
+
mover: plans.mover,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Drift as the plain report needs it: never throws, and says which of four things happened so the page can be honest.
|
|
38
|
+
* ok a written plan was found and there was agent work: `drift` holds the analysis
|
|
39
|
+
* no-work nothing from Claude Code or Codex in the window
|
|
40
|
+
* no-plan work, but no written plan to measure it against (the first-ask fallback is not shown as a headline)
|
|
41
|
+
* error the pipeline failed on this machine
|
|
42
|
+
*/
|
|
43
|
+
export function driftForReport(claudeDir, opts = {}) {
|
|
44
|
+
const days = opts.days || 7;
|
|
45
|
+
try {
|
|
46
|
+
const drift = runDrift(claudeDir, { days });
|
|
47
|
+
if (!drift.available) return { state: 'no-work', days };
|
|
48
|
+
if (!drift.source?.planNames?.length) return { state: 'no-plan', days, drift };
|
|
49
|
+
return { state: 'ok', days, drift };
|
|
50
|
+
} catch {
|
|
51
|
+
return { state: 'error', days };
|
|
52
|
+
}
|
|
53
|
+
}
|