cchubber 0.5.7 → 0.6.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/README.md +7 -0
- package/package.json +1 -1
- package/src/analyzers/cost-calculator.js +11 -0
- package/src/analyzers/drift-privacy.js +102 -0
- package/src/analyzers/drift-run.js +34 -0
- package/src/analyzers/drift.js +454 -0
- package/src/analyzers/reprice.js +154 -0
- package/src/cli/index.js +159 -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 +255 -0
- package/src/renderers/html-report.js +1301 -1272
- package/src/renderers/s1-report.js +166 -0
- 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 +19 -3
package/README.md
CHANGED
|
@@ -32,6 +32,13 @@ 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 usage on other models:**
|
|
36
|
+
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`.
|
|
37
|
+
|
|
38
|
+
- **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.
|
|
39
|
+
- **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.
|
|
40
|
+
- **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.
|
|
41
|
+
|
|
35
42
|
**The shareable card:**
|
|
36
43
|
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
44
|
|
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,102 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
days,
|
|
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
|
+
}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { shortName, categorize } from './drift-privacy.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Drift: how much of your agent time went to what you said you'd do.
|
|
5
|
+
*
|
|
6
|
+
* The method, in the order it runs. The report prints the same steps in plain words.
|
|
7
|
+
* 1. Turns. Each message you typed starts a turn; the agent's steps until your next message belong to it.
|
|
8
|
+
* Agent time is the time between the agent's own steps, and one wait longer than 20 minutes counts as 20.
|
|
9
|
+
* 2. Sittings. Coming back to a session after 2+ quiet hours with a new ask starts a new sitting ("continue" doesn't).
|
|
10
|
+
* 3. What you said. The written plan for that day or folder if there is one, otherwise your first real ask of the
|
|
11
|
+
* sitting. A /goal command replaces it for the rest of the sitting.
|
|
12
|
+
* 4. Threads. An ask that shares few words with the last few turns (your words, the agent's replies, the files it
|
|
13
|
+
* touched) starts a new thread. Short replies ("yes", "do it") stay on the thread they answer.
|
|
14
|
+
* 5. Labels. A thread whose opening ask shares enough words or files with what you said is on it. Anything else is
|
|
15
|
+
* a detour. Rare words count for more than common ones (weighted by how often they appear in your own week).
|
|
16
|
+
* No model is called. Everything here is word and file overlap, so it is rough, and the report says so.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const MIN = 60000;
|
|
20
|
+
const HOUR = 60 * MIN;
|
|
21
|
+
|
|
22
|
+
export const DRIFT = {
|
|
23
|
+
idleCapMs: 20 * MIN, // one step longer than this (a stuck prompt, a sleeping laptop) counts as this long
|
|
24
|
+
sittingGapMs: 2 * HOUR, // quiet this long, then a new ask, is a new sitting with its own "what you said"
|
|
25
|
+
continueAt: 0.34, // share of a new ask's word weight already in the last few turns that keeps it on the thread
|
|
26
|
+
onAt: 0.4, // share of a new ask's weight that one plan item (or the running focus) must cover for "on"
|
|
27
|
+
warmupMs: 15 * MIN, // people front-load a task's context in the first minutes; only then is an ask "still setup"
|
|
28
|
+
substantiveAt: 3, // total word weight an ask needs to be able to change the subject
|
|
29
|
+
replyTerms: 30, // the agent's rarest words per turn that join the thread's context
|
|
30
|
+
clusterAt: 0.5, // two detours sharing this much are the same detour, come back to
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Words that never say what a request is about: function words, chat filler, and verbs every request uses
|
|
34
|
+
const STOP = new Set(`a about above after again against all almost along already also although always am among an and
|
|
35
|
+
another any anybody anyone anything anyway anywhere are aren around as at away back be became because become been before
|
|
36
|
+
being below beside besides between beyond both but by came can cannot cant could couldnt did didnt do does doesnt doing
|
|
37
|
+
done dont down during each either else enough etc even ever every everybody everyone everything everywhere few for from
|
|
38
|
+
further get gets getting give given gives go goes going gone got gotten had hadnt has hasnt have havent having he her
|
|
39
|
+
here hers herself him himself his how however i if im in inside instead into is isnt it its itself ive just keep kept
|
|
40
|
+
last least less let lets like likely made make makes making many may maybe me might mine more most much must my myself
|
|
41
|
+
need needed needs neither never next no nobody none nor not nothing now of off often on once one only onto or other
|
|
42
|
+
others otherwise our ours ourselves out over own per perhaps please put quite rather really said same say says see seem
|
|
43
|
+
seems seen several shall she should shouldnt since so some somebody someone something sometimes somewhere soon still such
|
|
44
|
+
sure take taken takes taking tell than that thats the their theirs them themselves then there theres these they theyre
|
|
45
|
+
thing things think this those though through thus till to together too took toward towards try trying under unless
|
|
46
|
+
until up upon us use used uses using very via want wanted wants was wasnt way ways we well went were werent what whats
|
|
47
|
+
whatever when whenever where whereas wherever whether which while who whoever whole whom whose why will with within
|
|
48
|
+
without wont would wouldnt yet you youd youll your youre yours yourself yourselves youve
|
|
49
|
+
yo yh yeah yea yep yes ya nah nope ok okay alr alright bro bruh broski brudda bruv mate man gng innit lol lmao haha tbh
|
|
50
|
+
ngl icl idk yk ik lowk lowkey rn btw pls plz thx thanks thank cheers hey hi hello cool nice great good bad fine damn
|
|
51
|
+
wow oh uh um hmm huh actually basically genuinely literally honestly kinda kind sort sorta gonna wanna gotta ain aint
|
|
52
|
+
dunno cuz cause coz tho though anyways anyway also again already yall ye yup ofc imo imho fr frfr deadass bet
|
|
53
|
+
ask asked asking check checked checking continue continued create created doing find found fix fixed help know knew look
|
|
54
|
+
looked looking run running see show showed start started stop tell told thought work worked working works mean meant
|
|
55
|
+
means let give gave go went come came call called read wrote write writing added add update updated change changed
|
|
56
|
+
try tried seems seemed feel felt happen happened happening sure right wrong new old big small little lot lots bit
|
|
57
|
+
able best better way ways kind things stuff something anything everything nothing today tomorrow yesterday now later
|
|
58
|
+
time times day days week weeks first second third next last one two three four five also still even
|
|
59
|
+
claude codex agent ai model assistant chat session code file files folder message messages prompt reply answer`.split(/\s+/).filter(Boolean));
|
|
60
|
+
|
|
61
|
+
// Path parts that name the plumbing rather than the work
|
|
62
|
+
const PATH_STOP = new Set(`users user home documents desktop downloads library private tmp var opt src lib dist build
|
|
63
|
+
node modules index main app test tests spec html css js mjs cjs ts tsx jsx json md mdx txt yaml yml toml png jpg jpeg
|
|
64
|
+
svg gif webp pdf py sh log logs dev obsidian projects project readme claude codex`.split(/\s+/));
|
|
65
|
+
|
|
66
|
+
// Two-letter words worth keeping
|
|
67
|
+
const SHORT = new Set(['ui', 'ux', 'db', 'qa', 'ci', 'pr', 'js', 'ts', 'go', 'ml', 'os', 'uk', 'us', 'tv', 'pc', 'vr', 'ar']);
|
|
68
|
+
|
|
69
|
+
export function stem(w) {
|
|
70
|
+
if (w.length <= 3) return w;
|
|
71
|
+
let s = w;
|
|
72
|
+
if (s.endsWith('ies') && s.length > 4) s = s.slice(0, -3) + 'y';
|
|
73
|
+
else if (s.endsWith('sses')) s = s.slice(0, -2);
|
|
74
|
+
else if (s.endsWith('s') && !/(ss|us|is)$/.test(s)) s = s.slice(0, -1);
|
|
75
|
+
if (s.length > 5 && s.endsWith('ing')) s = s.slice(0, -3);
|
|
76
|
+
else if (s.length > 4 && s.endsWith('ed')) s = s.slice(0, -2);
|
|
77
|
+
if (/([b-df-hj-np-tv-z])\1$/.test(s) && !/(ll|ss|zz|ff)$/.test(s)) s = s.slice(0, -1);
|
|
78
|
+
if (s.length > 3 && s.endsWith('e')) s = s.slice(0, -1);
|
|
79
|
+
return s;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Content words of a text, stemmed. Links keep only their site name.
|
|
83
|
+
export function tokenize(text, stop = STOP) {
|
|
84
|
+
const out = [];
|
|
85
|
+
const s = String(text || '').toLowerCase()
|
|
86
|
+
.replace(/https?:\/\/(?:www\.)?([a-z0-9-]+)[^\s]*/g, ' $1 ')
|
|
87
|
+
.replace(/['’]/g, '');
|
|
88
|
+
for (const raw of s.split(/[^a-z0-9]+/)) {
|
|
89
|
+
if (!raw || (raw.length < 3 && !SHORT.has(raw))) continue;
|
|
90
|
+
if (/^\d+$/.test(raw) || raw.length > 24) continue;
|
|
91
|
+
if (/\d/.test(raw) && raw.length >= 8) continue; // ids and hashes
|
|
92
|
+
if (stop.has(raw)) continue;
|
|
93
|
+
const st = stem(raw);
|
|
94
|
+
if (st.length < 2 || stop.has(st)) continue;
|
|
95
|
+
out.push(st);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function pathTerms(file, cwd) {
|
|
101
|
+
let p = String(file);
|
|
102
|
+
if (cwd && p.startsWith(cwd)) p = p.slice(cwd.length);
|
|
103
|
+
else p = p.split(/[\\/]/).slice(-3).join('/');
|
|
104
|
+
return tokenize(p.replace(/([a-z])([A-Z])/g, '$1 $2'), new Set([...STOP, ...PATH_STOP]));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const CONTINUE = /\b(continue|carry on|keep going|keep at it|go on|go ahead|proceed|resume|where you left off|as you were|do it|go for it|do so|do that|finish (it|up|off))\b/i;
|
|
108
|
+
// A command or prefix that states the plan outright
|
|
109
|
+
const PLAN_COMMANDS = new Set(['/goal', '/ignite', '/plan', '/focus']);
|
|
110
|
+
const PLAN_PREFIX = /^\s*(goal|plan|focus|today'?s? (plan|goal|focus))\s*:/i;
|
|
111
|
+
|
|
112
|
+
export function dayKey(t) {
|
|
113
|
+
const d = new Date(t);
|
|
114
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Share of the weight of `terms` found in `bag` (at weight `min` or more): how much of an ask is already there
|
|
118
|
+
export function coverage(terms, bag, idf, min = 0.2) {
|
|
119
|
+
let all = 0, hit = 0;
|
|
120
|
+
for (const w of new Set(terms)) {
|
|
121
|
+
const x = idf(w);
|
|
122
|
+
all += x;
|
|
123
|
+
if ((bag.get(w) || 0) >= min) hit += x;
|
|
124
|
+
}
|
|
125
|
+
return all ? hit / all : 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The best a single plan item covers this ask. Scoring against one item at a time, not one merged bag of every
|
|
129
|
+
// item, is what stops a sprawling plan from marking everything on-plan: an ask is on the plan only when one thing
|
|
130
|
+
// you wrote is really about it, not when its words happen to be scattered across fifty unrelated tasks.
|
|
131
|
+
export function bestItemCoverage(terms, itemSets, idf) {
|
|
132
|
+
const uniq = [...new Set(terms)];
|
|
133
|
+
const total = uniq.reduce((a, w) => a + idf(w), 0);
|
|
134
|
+
if (!total) return 0;
|
|
135
|
+
let best = 0;
|
|
136
|
+
for (const set of itemSets) {
|
|
137
|
+
let hit = 0;
|
|
138
|
+
for (const w of uniq) if (set.has(w)) hit += idf(w);
|
|
139
|
+
if (hit > best) best = hit;
|
|
140
|
+
}
|
|
141
|
+
return best / total;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function addTerms(bag, terms, weight) {
|
|
145
|
+
for (const w of terms) bag.set(w, Math.max(bag.get(w) || 0, weight));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function fade(bag, f) {
|
|
149
|
+
for (const [k, v] of bag) {
|
|
150
|
+
if (v * f < 0.05) bag.delete(k);
|
|
151
|
+
else bag.set(k, v * f);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildTurns(session) {
|
|
156
|
+
const turns = [];
|
|
157
|
+
let cur = null;
|
|
158
|
+
let last = null;
|
|
159
|
+
for (const e of session.events) {
|
|
160
|
+
if (e.kind === 'prompt' || e.kind === 'auto') {
|
|
161
|
+
cur = {
|
|
162
|
+
human: e.kind === 'prompt', t: e.t, text: e.text || '', command: e.command || null,
|
|
163
|
+
cwd: e.cwd || session.cwd, idle: last === null ? Infinity : e.t - last, steps: [], session,
|
|
164
|
+
};
|
|
165
|
+
turns.push(cur);
|
|
166
|
+
} else if (cur) {
|
|
167
|
+
cur.steps.push(e);
|
|
168
|
+
}
|
|
169
|
+
last = e.t;
|
|
170
|
+
}
|
|
171
|
+
return turns;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Agent time inside the window, split by local day
|
|
175
|
+
function agentTime(turn, since, until) {
|
|
176
|
+
let prev = turn.t;
|
|
177
|
+
let ms = 0;
|
|
178
|
+
const byDay = new Map();
|
|
179
|
+
for (const s of turn.steps) {
|
|
180
|
+
const dt = s.t - prev;
|
|
181
|
+
prev = s.t;
|
|
182
|
+
if (dt <= 0 || s.t < since || s.t > until) continue;
|
|
183
|
+
const add = Math.min(dt, DRIFT.idleCapMs);
|
|
184
|
+
ms += add;
|
|
185
|
+
const k = dayKey(s.t);
|
|
186
|
+
byDay.set(k, (byDay.get(k) || 0) + add);
|
|
187
|
+
}
|
|
188
|
+
return { ms, byDay, end: prev };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* sessions: from readTranscripts(). planFor(cwd, dayKey): from readStatedPlans(), or null when there is no plan.
|
|
193
|
+
*/
|
|
194
|
+
export function analyzeDrift(sessions, { since, until = Date.now(), planFor = () => null, planNotes = [] } = {}) {
|
|
195
|
+
const perSession = sessions.map(s => buildTurns(s));
|
|
196
|
+
const turns = perSession.flat();
|
|
197
|
+
const human = turns.filter(t => t.human);
|
|
198
|
+
|
|
199
|
+
// Features per turn, then word weights across the whole span (a word in every turn tells you nothing)
|
|
200
|
+
const fileCount = new Map();
|
|
201
|
+
for (const t of human) {
|
|
202
|
+
t.words = tokenize(t.text.slice(0, 2000));
|
|
203
|
+
t.replyAll = tokenize(t.steps.map(s => s.words || '').join(' ').slice(0, 8000));
|
|
204
|
+
t.files = new Set(t.steps.flatMap(s => s.files || []));
|
|
205
|
+
t.fileWords = [...t.files].flatMap(f => pathTerms(f, t.cwd));
|
|
206
|
+
for (const f of t.files) fileCount.set(f, (fileCount.get(f) || 0) + 1);
|
|
207
|
+
}
|
|
208
|
+
const df = new Map();
|
|
209
|
+
for (const t of human) for (const w of new Set([...t.words, ...t.replyAll, ...t.fileWords])) df.set(w, (df.get(w) || 0) + 1);
|
|
210
|
+
const n = human.length;
|
|
211
|
+
const idf = (w) => Math.log((n + 1) / ((df.get(w) || 0) + 1)) + 1;
|
|
212
|
+
// Files most turns touch (logs, plan files, notes) say nothing about the topic
|
|
213
|
+
const everywhere = new Set([...fileCount].filter(([, c]) => c >= 5 && c > n * 0.2).map(([f]) => f));
|
|
214
|
+
for (const t of human) {
|
|
215
|
+
t.replyTop = [...new Set(t.replyAll)].sort((a, b) => idf(b) - idf(a)).slice(0, DRIFT.replyTerms);
|
|
216
|
+
t.topicFiles = new Set([...t.files].filter(f => !everywhere.has(f)));
|
|
217
|
+
const weight = [...new Set(t.words)].reduce((a, w) => a + idf(w), 0);
|
|
218
|
+
// How much a turn says about its topic, used to name a detour after its most descriptive turn, not its first
|
|
219
|
+
t.richness = weight * (shortName(t.text) ? 1 : 0);
|
|
220
|
+
t.substantive = weight >= DRIFT.substantiveAt && !(CONTINUE.test(t.text) && t.words.length <= 5);
|
|
221
|
+
t.states = t.substantive && (Boolean(t.command && PLAN_COMMANDS.has(t.command)) || PLAN_PREFIX.test(t.text));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const detours = [];
|
|
225
|
+
const sources = { plan: 0, goal: 0, ask: 0 };
|
|
226
|
+
const planNames = new Set();
|
|
227
|
+
const scopes = new Set();
|
|
228
|
+
let sittingId = 0;
|
|
229
|
+
|
|
230
|
+
// "What you said" for a turn: the best any single plan item covers it, OR the opening ask, OR the topics that
|
|
231
|
+
// earlier on-plan work has already established this sitting (so related follow-ups stay on)
|
|
232
|
+
const onScore = (t, intent) => {
|
|
233
|
+
const fromPlan = intent.items ? bestItemCoverage(t.words, intent.items, idf) : 0;
|
|
234
|
+
const fromFocus = coverage(t.words, intent.focus, idf);
|
|
235
|
+
const shared = [...t.topicFiles].filter(f => intent.files.has(f)).length;
|
|
236
|
+
const fromFiles = t.topicFiles.size ? shared / t.topicFiles.size : 0;
|
|
237
|
+
return Math.max(fromPlan, fromFocus, fromFiles);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const newThread = (t, lbl, score) => ({ label: lbl, bag: new Map(), head: t, best: t, ms: 0, score });
|
|
241
|
+
const join = (t, th, score) => {
|
|
242
|
+
t.label = th.label;
|
|
243
|
+
t.thread = th;
|
|
244
|
+
t.score = score;
|
|
245
|
+
th.ms += t.time.ms;
|
|
246
|
+
if (t.richness > th.best.richness) th.best = t; // name the thread after its most descriptive turn
|
|
247
|
+
fade(th.bag, 0.6);
|
|
248
|
+
addTerms(th.bag, t.words, 1);
|
|
249
|
+
addTerms(th.bag, t.replyTop, 0.7);
|
|
250
|
+
addTerms(th.bag, t.fileWords, 0.6);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// Walk each session in order: sittings, then threads, then labels
|
|
254
|
+
for (const list of perSession) {
|
|
255
|
+
let sitting = null;
|
|
256
|
+
let thread = null;
|
|
257
|
+
let pending = []; // "continue" and the like, before the sitting has said what it is for
|
|
258
|
+
for (const t of list) {
|
|
259
|
+
t.time = agentTime(t, since, until);
|
|
260
|
+
if (!t.human) { t.label = 'auto'; continue; }
|
|
261
|
+
|
|
262
|
+
if (!sitting || (t.idle >= DRIFT.sittingGapMs && t.substantive)) {
|
|
263
|
+
sitting = { id: ++sittingId, intent: null };
|
|
264
|
+
thread = null;
|
|
265
|
+
pending = [];
|
|
266
|
+
const plan = planFor(t.cwd, dayKey(t.t));
|
|
267
|
+
if (plan && plan.items.length) {
|
|
268
|
+
const items = plan.items.map(x => new Set(tokenize(x))).filter(s => s.size);
|
|
269
|
+
sitting.intent = { source: 'plan', items, focus: new Map(), files: new Set() };
|
|
270
|
+
plan.sources.forEach(x => planNames.add(x));
|
|
271
|
+
scopes.add(plan.scope);
|
|
272
|
+
sources.plan++;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
t.sitting = sitting.id;
|
|
276
|
+
|
|
277
|
+
// A stated goal always resets what you said; with nothing written down, so does the sitting's first real ask.
|
|
278
|
+
// The opening ask becomes the plan's one "item", so later work is matched against it the same way. When there
|
|
279
|
+
// is no written plan, the next couple of asks are treated as part of laying out the task (people front-load
|
|
280
|
+
// context), so a thin opening does not make the rest of the same task read as drift.
|
|
281
|
+
if (t.states || (!sitting.intent && t.substantive)) {
|
|
282
|
+
const focus = new Map();
|
|
283
|
+
addTerms(focus, t.words, 1);
|
|
284
|
+
addTerms(focus, t.replyTop, 0.5);
|
|
285
|
+
addTerms(focus, t.fileWords, 0.5);
|
|
286
|
+
sitting.intent = { source: t.states ? 'goal' : 'ask', items: [new Set(t.words)], focus, files: new Set(t.topicFiles), warm: 2, startT: t.t };
|
|
287
|
+
sources[sitting.intent.source]++;
|
|
288
|
+
thread = newThread(t, 'on', 1);
|
|
289
|
+
for (const p of [...pending, t]) join(p, thread, 1);
|
|
290
|
+
pending = [];
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (!sitting.intent) { pending.push(t); continue; }
|
|
294
|
+
|
|
295
|
+
// Warmup only counts while the task is still being laid out: substantive, budget left, and soon after it opened
|
|
296
|
+
const warming = sitting.intent.warm > 0 && t.substantive && (t.t - sitting.intent.startT) <= DRIFT.warmupMs;
|
|
297
|
+
// Non-substantive turns ("yes", "continue") ride the current thread; a real ask is re-judged against the plan
|
|
298
|
+
if (!warming && thread && (!t.substantive || coverage(t.words, thread.bag, idf) >= DRIFT.continueAt)) {
|
|
299
|
+
join(t, thread, thread.score);
|
|
300
|
+
} else {
|
|
301
|
+
const score = warming ? 1 : onScore(t, sitting.intent);
|
|
302
|
+
thread = newThread(t, warming || score >= DRIFT.onAt ? 'on' : 'detour', score);
|
|
303
|
+
if (thread.label === 'detour') detours.push(thread);
|
|
304
|
+
join(t, thread, score);
|
|
305
|
+
if (warming) { sitting.intent.items.push(new Set(t.words)); sitting.intent.warm--; }
|
|
306
|
+
}
|
|
307
|
+
// Work that is on what you said establishes its topic, so a related follow-up a few turns later is still on
|
|
308
|
+
if (t.label === 'on') {
|
|
309
|
+
fade(sitting.intent.focus, 0.85);
|
|
310
|
+
addTerms(sitting.intent.focus, t.words, 1);
|
|
311
|
+
addTerms(sitting.intent.focus, t.replyTop, 0.5);
|
|
312
|
+
for (const f of t.topicFiles) sitting.intent.files.add(f);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// A sitting that never said anything substantive counts as on whatever it was already doing
|
|
316
|
+
for (const p of pending) p.label = 'on';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return summarize({ turns, human, detours, since, until, idf, sources, planNames, scopes: [...scopes], planNotes });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function summarize({ turns, human, detours, since, until, idf, sources, planNames, scopes, planNotes }) {
|
|
323
|
+
const counted = turns.filter(t => t.time && t.time.ms > 0 && (t.label === 'on' || t.label === 'detour'));
|
|
324
|
+
const onMs = counted.filter(t => t.label === 'on').reduce((a, t) => a + t.time.ms, 0);
|
|
325
|
+
const detourMs = counted.filter(t => t.label === 'detour').reduce((a, t) => a + t.time.ms, 0);
|
|
326
|
+
const autoMs = turns.filter(t => t.label === 'auto' && t.time).reduce((a, t) => a + t.time.ms, 0);
|
|
327
|
+
const agentMs = onMs + detourMs;
|
|
328
|
+
|
|
329
|
+
// Days in the window, oldest first, including quiet ones
|
|
330
|
+
const days = [];
|
|
331
|
+
for (let d = new Date(since); d.getTime() <= until; d.setDate(d.getDate() + 1)) {
|
|
332
|
+
const k = dayKey(d.getTime());
|
|
333
|
+
if (!days.find(x => x.day === k)) days.push({ day: k, onMs: 0, detourMs: 0 });
|
|
334
|
+
}
|
|
335
|
+
const lastKey = dayKey(until);
|
|
336
|
+
if (!days.find(x => x.day === lastKey)) days.push({ day: lastKey, onMs: 0, detourMs: 0 });
|
|
337
|
+
for (const t of counted) {
|
|
338
|
+
for (const [k, ms] of t.time.byDay) {
|
|
339
|
+
const d = days.find(x => x.day === k);
|
|
340
|
+
if (d) d[t.label === 'on' ? 'onMs' : 'detourMs'] += ms;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const busiest = Math.max(0, ...days.map(d => d.onMs + d.detourMs));
|
|
344
|
+
const worst = days
|
|
345
|
+
.filter(d => d.onMs + d.detourMs >= Math.max(30 * MIN, busiest * 0.15) && d.detourMs > 0)
|
|
346
|
+
.map(d => ({ ...d, share: d.detourMs / (d.onMs + d.detourMs) }))
|
|
347
|
+
.sort((a, b) => b.share - a.share || b.detourMs - a.detourMs)[0] || null;
|
|
348
|
+
|
|
349
|
+
// A detour episode is a run of off-plan turns you did in one go before coming back. Grouping the contiguous run,
|
|
350
|
+
// then naming it after its most descriptive turn, keeps "bin emptied btw" inside the footage-move it belongs to.
|
|
351
|
+
const episodes = [];
|
|
352
|
+
const bySitting = new Map();
|
|
353
|
+
for (const t of human) {
|
|
354
|
+
if (!t.sitting) continue;
|
|
355
|
+
if (!bySitting.has(t.sitting)) bySitting.set(t.sitting, []);
|
|
356
|
+
bySitting.get(t.sitting).push(t);
|
|
357
|
+
}
|
|
358
|
+
for (const list of bySitting.values()) {
|
|
359
|
+
let run = null;
|
|
360
|
+
const close = () => { if (run && run.ms > 0) episodes.push(run); run = null; };
|
|
361
|
+
for (const t of list.sort((a, b) => a.t - b.t)) {
|
|
362
|
+
if (t.label === 'detour') {
|
|
363
|
+
if (!run) run = { ms: 0, start: t.t, end: t.t, turns: [] };
|
|
364
|
+
run.ms += t.time.ms;
|
|
365
|
+
run.end = Math.max(run.end, t.time.end);
|
|
366
|
+
run.turns.push(t);
|
|
367
|
+
} else if (t.label === 'on') {
|
|
368
|
+
close();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
close();
|
|
372
|
+
}
|
|
373
|
+
for (const e of episodes) e.best = e.turns.reduce((a, b) => (b.richness > a.richness ? b : a), e.turns[0]);
|
|
374
|
+
|
|
375
|
+
// The longest single episode is the day's deepest rabbit hole
|
|
376
|
+
const longest = episodes.slice().sort((a, b) => b.ms - a.ms)[0] || null;
|
|
377
|
+
const streak = longest ? {
|
|
378
|
+
ms: longest.ms,
|
|
379
|
+
wallMs: longest.end - longest.start,
|
|
380
|
+
start: longest.start,
|
|
381
|
+
detours: longest.turns.filter(t => t.thread && t.thread.head === t).length,
|
|
382
|
+
name: shortName(longest.best.text),
|
|
383
|
+
category: categorize(longest.turns.map(t => t.text).join(' ')),
|
|
384
|
+
} : null;
|
|
385
|
+
|
|
386
|
+
// Episodes on the same subject across the week are one named detour, counted each time it pulled you away
|
|
387
|
+
const clusters = [];
|
|
388
|
+
for (const e of episodes.filter(x => x.best.richness > 0).sort((a, b) => a.start - b.start)) {
|
|
389
|
+
const own = bagOf(e.best.words);
|
|
390
|
+
const c = clusters.find(x => coverage(e.best.words, x.bag, idf) >= DRIFT.clusterAt || coverage(x.best.words, own, idf) >= DRIFT.clusterAt);
|
|
391
|
+
if (c) {
|
|
392
|
+
c.ms += e.ms;
|
|
393
|
+
c.episodes.push(e);
|
|
394
|
+
if (e.best.richness > c.best.richness) c.best = e.best;
|
|
395
|
+
addTerms(c.bag, e.best.words, 1);
|
|
396
|
+
} else {
|
|
397
|
+
clusters.push({ best: e.best, bag: own, ms: e.ms, episodes: [e] });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const named = clusters.map(c => ({
|
|
401
|
+
name: shortName(c.best.text),
|
|
402
|
+
category: categorize(c.episodes.map(e => e.best.text).join(' ')),
|
|
403
|
+
ms: c.ms,
|
|
404
|
+
times: c.episodes.length,
|
|
405
|
+
first: Math.min(...c.episodes.map(e => e.start)),
|
|
406
|
+
})).filter(d => d.name).sort((a, b) => b.ms - a.ms);
|
|
407
|
+
|
|
408
|
+
const active = new Set(counted.map(t => t.session));
|
|
409
|
+
const inSpan = human.filter(t => t.t >= since && t.t <= until);
|
|
410
|
+
const agentsUsed = { claude: 0, codex: 0 };
|
|
411
|
+
for (const s of active) agentsUsed[s.agent] = (agentsUsed[s.agent] || 0) + 1;
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
available: agentMs > 0,
|
|
415
|
+
since, until,
|
|
416
|
+
share: agentMs ? onMs / agentMs : 0,
|
|
417
|
+
totals: {
|
|
418
|
+
agentMs, onMs, detourMs, autoMs,
|
|
419
|
+
prompts: inSpan.length,
|
|
420
|
+
sessions: active.size,
|
|
421
|
+
sittings: new Set(counted.map(t => t.sitting)).size,
|
|
422
|
+
},
|
|
423
|
+
agents: agentsUsed,
|
|
424
|
+
days,
|
|
425
|
+
worstDay: worst,
|
|
426
|
+
streak,
|
|
427
|
+
detours: named,
|
|
428
|
+
detourCount: named.length,
|
|
429
|
+
source: {
|
|
430
|
+
plan: sources.plan, goal: sources.goal, ask: sources.ask,
|
|
431
|
+
planNames: [...planNames],
|
|
432
|
+
scope: scopes.includes('list') ? 'list' : scopes.includes('project') ? 'project' : 'session',
|
|
433
|
+
notes: planNotes,
|
|
434
|
+
},
|
|
435
|
+
// Every labelled ask, for --json: when, how long, which label, and how the score came out
|
|
436
|
+
turns: human.filter(t => t.time && t.t >= since && t.t <= until).map(t => ({
|
|
437
|
+
t: new Date(t.t).toISOString(),
|
|
438
|
+
agent: t.session.agent,
|
|
439
|
+
session: String(t.session.id).slice(0, 8),
|
|
440
|
+
sitting: t.sitting,
|
|
441
|
+
label: t.label,
|
|
442
|
+
score: t.score == null ? null : Math.round(t.score * 100) / 100,
|
|
443
|
+
opens: Boolean(t.thread && t.thread.head === t),
|
|
444
|
+
minutes: Math.round(t.time.ms / MIN),
|
|
445
|
+
ask: shortName(t.text, { maxWords: 14, maxChars: 90 }),
|
|
446
|
+
})),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function bagOf(words) {
|
|
451
|
+
const bag = new Map();
|
|
452
|
+
addTerms(bag, words, 1);
|
|
453
|
+
return bag;
|
|
454
|
+
}
|