Skip to content

Commit dcfa14e

Browse files
feat: web-axis redesign — behavior-based + whole-export coverage (iteration 4)
Verified against a real claude.ai export (8 conversations + 10 design_chats): - merge EVERY chat-bearing JSON in the export (conversations.json + design_chats/* + projects/*), handling both schemas (chat_messages/sender/text and messages/role/ nested-content) via a robust deep-text extractor — no longer just conversations.json - score axes from the USER's messages (intent), never from how much Claude wrote back (old code counted Claude's code fences as the user's CODER stat) - web now uses self-relative SHAPE scoring (scoreAxesShape): top axis = 100, others proportional, volume carried by level — a casual user gets a full varied radar instead of a flat low card - README scoring section updated to describe CLI fixed-anchor vs web shape model Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 40adf76 commit dcfa14e

3 files changed

Lines changed: 131 additions & 58 deletions

File tree

README.md

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,20 +66,23 @@ Each tool call (CLI) or message heuristic (web) is classified into one of 6 axes
6666
| WRITER | prose output volume | assistant text chars / 4000 |
6767
| PILOT | driving a real browser | chrome-devtools / playwright MCP tools |
6868

69-
Score per axis: `min(100, round(100 * sqrt(x / REF)))` — a square-root curve, so early activity climbs fast and the top end is hard to max.
69+
**CLI** uses fixed anchors so two Claude Code users' cards are directly comparable:
70+
`score = min(100, round(100 * sqrt(x / REF)))` — a square-root curve, so early activity climbs fast and the top end is hard to max.
7071

71-
REF anchors (what 100 means), v1:
72+
REF anchors (what 100 means on the CLI path), v1:
7273

73-
| axis | REF (CLI units) | REF (web units) |
74-
|---|---|---|
75-
| automator | 25000 | 1200 |
76-
| researcher | 15000 | 1500 |
77-
| coder | 15000 | 800 |
78-
| integrator | 8000 | 400 |
79-
| writer | 4000 | 2500 |
80-
| pilot | 4000 | 100 |
81-
82-
**Honest calibration note:** the v1 anchors are derived from a single heavy daily user's corpus (~426 sessions / ~57k tool calls over ~6 weeks, snapshot 2026-06-12). They represent "heavy daily user" as one data point, not a population study. Expect recalibration in later versions.
74+
| axis | REF (CLI units) |
75+
|---|---|
76+
| automator | 25000 |
77+
| researcher | 15000 |
78+
| coder | 15000 |
79+
| integrator | 8000 |
80+
| writer | 4000 |
81+
| pilot | 4000 |
82+
83+
**Web** uses **self-relative shape** scoring instead: your strongest axis is the reference (100) and the rest are proportional (`(x / max) ^ 0.7`). Volume is carried by the **level**, not the axis heights — so a casual user still gets a full, varied radar (like an RPG class whose stat *shape* is the same at level 5 and level 50), rather than a flat low card. Web axes are counted from **your own messages** (what you ask for), never from how much Claude wrote back. The web card is a rough estimate and is not comparable to a CLI card.
84+
85+
**Honest calibration note:** the CLI anchors are derived from a single heavy daily user's corpus (~426 sessions / ~57k tool calls, snapshot 2026-06-12) — one data point, not a population study. Expect recalibration in later versions.
8386

8487
Level: `floor(sqrt(effort / 25))`, where effort = total tool calls (CLI) or total messages × 4 (web). Minimum level 1.
8588

index.html

Lines changed: 92 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -500,60 +500,98 @@ <h2><span id="entry2Title">สาย claude.ai</span> <span class="badge">BETA</
500500

501501
/* ---------- claude.ai export parser (BETA, defensive) ---------- */
502502

503+
// Robust text extraction across claude.ai export schemas:
504+
// conversations.json -> array of {chat_messages:[{sender, text|content[]}]}
505+
// design_chats/*.json -> {messages:[{role, content:{...}}]} (content is a nested object)
506+
function deepText(v, depth) {
507+
if (depth > 6 || v == null) return '';
508+
if (typeof v === 'string') return v;
509+
if (Array.isArray(v)) return v.map(function (x) { return deepText(x, depth + 1); }).join('\n');
510+
if (typeof v === 'object') {
511+
var out = [];
512+
for (var k in v) {
513+
if (k === 'citations' || k === 'files' || k === 'id' || k === 'uuid') continue;
514+
out.push(deepText(v[k], depth + 1));
515+
}
516+
return out.join('\n');
517+
}
518+
return '';
519+
}
503520
function msgText(m) {
504521
if (!m || typeof m !== 'object') return '';
505522
if (typeof m.text === 'string' && m.text) return m.text;
506-
if (Array.isArray(m.content)) {
507-
return m.content.map(function (c) {
508-
return (c && typeof c.text === 'string') ? c.text : '';
509-
}).join('\n');
510-
}
511-
return '';
523+
return deepText(m.content, 0);
512524
}
513-
514-
var RX_RESEARCH = /\?|\b(search|why|how|find)\b||||https?:\/\//i;
515-
var RX_AUTO = /\b(script|automation|cron|bot|workflow)\b|/i;
516-
var RX_INTEG = /\b(api|integration|mcp|webhook)\b|/i;
517-
var RX_PILOT = /\b(browser|selenium|playwright)\b|/i;
518-
519-
function parseClaudeExport(json) {
520-
var convs = null;
525+
function roleOf(m) {
526+
var r = (m && (m.sender || m.role)) || '';
527+
return (r === 'human' || r === 'user') ? 'human' : (r === 'assistant' ? 'assistant' : 'other');
528+
}
529+
// Normalize any export JSON into a flat [{role, text}] list. Returns [] for non-chat JSON.
530+
function extractMessages(json) {
531+
var convs = [];
521532
if (Array.isArray(json)) convs = json;
522-
else if (json && typeof json === 'object' && Array.isArray(json.conversations)) convs = json.conversations;
523-
if (!convs) return null;
533+
else if (json && Array.isArray(json.conversations)) convs = json.conversations;
534+
else if (json && (Array.isArray(json.chat_messages) || Array.isArray(json.messages))) convs = [json];
535+
var msgs = [];
536+
convs.forEach(function (cv) {
537+
var ms = (cv && (cv.chat_messages || cv.messages)) || [];
538+
if (!Array.isArray(ms)) return;
539+
ms.forEach(function (m) { msgs.push({ role: roleOf(m), text: msgText(m) }); });
540+
});
541+
return msgs;
542+
}
524543

544+
// Behavior heuristics — scored from the USER's (human) messages, never from how much
545+
// Claude wrote back. Each axis = how many of your messages show that kind of intent.
546+
var RX = {
547+
researcher: /\?|\b(why|how|what|when|which|compare|explain|difference|search|find|research|vs)\b||||||||/i,
548+
writer: /\b(write|draft|rewrite|reword|email|essay|article|blog|story|poem|caption|translate|summari[sz]e|outline)\b||||||||/i,
549+
coder: /```|\b(code|coding|function|bug|debug|error|python|javascript|typescript|css|html|sql|react|component|npm|git|regex|refactor)\b||/i,
550+
automator: /\b(automate|automation|workflow|cron|schedule|batch|pipeline|bot|agent|step.by.step|steps|every day|do this for me)\b|||||/i,
551+
integrator: /\b(api|integrate|integration|webhook|connect|database|supabase|notion|slack|sheets?|zapier|upload|import|export|sync|oauth|endpoint)\b|||/i,
552+
pilot: /\b(browser|website|web ?page|url|link|scrape|crawl|navigate|screenshot|dom|selector)\b||||/i,
553+
};
554+
555+
// msgs: flat [{role,text}] list (already merged across all files in the export)
556+
function scoreMessages(msgs) {
557+
if (!msgs || !msgs.length) return null;
525558
var raw = { automator: 0, researcher: 0, coder: 0, integrator: 0, writer: 0, pilot: 0 };
526-
var msgCount = 0;
527-
var assistantChars = 0;
528-
529-
convs.forEach(function (conv) {
530-
var msgs = (conv && Array.isArray(conv.chat_messages)) ? conv.chat_messages : [];
531-
msgs.forEach(function (m) {
532-
var text = msgText(m);
533-
var sender = m && m.sender;
534-
msgCount++;
535-
if (sender === 'assistant') {
536-
assistantChars += text.length;
537-
var fences = (text.match(/```/g) || []).length;
538-
raw.coder += Math.floor(fences / 2);
539-
}
540-
if (sender === 'human' && RX_RESEARCH.test(text)) raw.researcher++;
541-
if (RX_AUTO.test(text)) raw.automator++;
542-
if (RX_INTEG.test(text)) raw.integrator++;
543-
if (RX_PILOT.test(text)) raw.pilot++;
544-
});
559+
var human = 0;
560+
msgs.forEach(function (m) {
561+
if (m.role !== 'human') return;
562+
human++;
563+
var t = m.text || '';
564+
for (var k in RX) { if (RX[k].test(t)) raw[k]++; }
545565
});
546-
547-
raw.writer = assistantChars / 4000;
566+
if (human === 0) return null;
548567
return {
549568
raw: raw,
550-
totals: { sessions: convs.length, toolCalls: 0, userMsgs: msgCount },
569+
totals: { sessions: 0, toolCalls: 0, userMsgs: msgs.length },
551570
};
552571
}
553572

573+
function countConvs(json) {
574+
if (Array.isArray(json)) return json.length;
575+
if (json && Array.isArray(json.conversations)) return json.conversations.length;
576+
if (json && (Array.isArray(json.chat_messages) || Array.isArray(json.messages))) return 1;
577+
return 0;
578+
}
579+
580+
function buildResult(msgs, convCount) {
581+
var r = scoreMessages(msgs);
582+
if (!r) return null;
583+
r.totals.sessions = convCount || 0;
584+
return r;
585+
}
586+
587+
// Single-JSON path (e.g. a lone conversations.json dropped in)
588+
function parseClaudeExport(json) {
589+
return buildResult(extractMessages(json), countConvs(json));
590+
}
591+
554592
function finishParse(parsed) {
555593
if (!parsed) { toast(t('badFormat'), true); return; }
556-
var stats = S.scoreAxes(parsed.raw, 'web');
594+
var stats = S.scoreAxesShape(parsed.raw);
557595
var encoded = S.encodeShare({ v: 1, src: 'web', stats: stats, totals: parsed.totals });
558596
window.location.hash = '#s=' + encoded;
559597
}
@@ -563,17 +601,25 @@ <h2><span id="entry2Title">สาย claude.ai</span> <span class="badge">BETA</
563601
}
564602

565603
// claude.ai exports arrive as a .zip (sometimes a .dms = renamed .zip). Accept them
566-
// directly so the user never has to unzip — find conversations.json inside, in-browser.
604+
// directly so the user never has to unzip. The export holds conversations.json PLUS
605+
// design_chats/* and projects/* (different schemas) — merge every chat-bearing JSON so
606+
// the card reflects the user's whole claude.ai life, not just regular chats.
567607
function handleZip(buf) {
568608
if (!window.fflate || !fflate.unzipSync) { toast(t('badFormat'), true); return; }
569609
var files;
570610
try { files = fflate.unzipSync(new Uint8Array(buf)); } catch (e) { toast(t('badFormat'), true); return; }
571-
var key = Object.keys(files).filter(function (n) { return /conversations\.json$/i.test(n); })[0]
572-
|| Object.keys(files).filter(function (n) { return /\.json$/i.test(n); })[0];
573-
if (!key) { toast(t('badFormat'), true); return; }
574-
var text;
575-
try { text = fflate.strFromU8(files[key]); } catch (e) { toast(t('badFormat'), true); return; }
576-
finishParse(parseJsonText(text));
611+
var msgs = [], convs = 0, sawJson = false;
612+
Object.keys(files).forEach(function (n) {
613+
if (!/\.json$/i.test(n)) return;
614+
if (/(^|\/)(users|memories)\.json$/i.test(n)) return; // not conversations
615+
sawJson = true;
616+
var json;
617+
try { json = JSON.parse(fflate.strFromU8(files[n])); } catch (e) { return; }
618+
msgs = msgs.concat(extractMessages(json));
619+
convs += countConvs(json);
620+
});
621+
if (!sawJson) { toast(t('badFormat'), true); return; }
622+
finishParse(buildResult(msgs, convs));
577623
}
578624

579625
function handleFile(file) {

lib/scoring.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@
6666
return out;
6767
}
6868

69+
// Self-relative "shape" scoring (used by the web path): the dominant axis is the
70+
// reference (100), the rest are proportional. Volume is carried by the level, not the
71+
// axis heights — so a casual user still gets a full, varied radar (like an RPG class
72+
// whose stat *shape* is the same at level 5 and 50). A small floor keeps present-but-
73+
// minor axes visible instead of collapsing to a degenerate spike.
74+
function scoreAxesShape(rawUnits) {
75+
var vals = [], max = 0;
76+
for (var i = 0; i < AXES.length; i++) {
77+
var x = rawUnits && typeof rawUnits[AXES[i]] === 'number' && isFinite(rawUnits[AXES[i]]) ? rawUnits[AXES[i]] : 0;
78+
if (x < 0) x = 0;
79+
vals.push(x);
80+
if (x > max) max = x;
81+
}
82+
var out = {};
83+
for (var j = 0; j < AXES.length; j++) {
84+
if (max <= 0) { out[AXES[j]] = 0; continue; }
85+
var s = Math.pow(vals[j] / max, 0.7) * 100;
86+
if (vals[j] > 0 && s < 12) s = 12;
87+
out[AXES[j]] = Math.round(Math.min(100, s));
88+
}
89+
return out;
90+
}
91+
6992
function level(effort) {
7093
var e = typeof effort === 'number' && isFinite(effort) && effort > 0 ? effort : 0;
7194
return Math.max(1, Math.floor(Math.sqrt(e / 25)));
@@ -142,6 +165,7 @@
142165
REF: REF,
143166
classifyTool: classifyTool,
144167
scoreAxes: scoreAxes,
168+
scoreAxesShape: scoreAxesShape,
145169
level: level,
146170
topAxis: topAxis,
147171
encodeShare: encodeShare,

0 commit comments

Comments
 (0)