Skill Tracer
Skills › Anatomy › anatomy › session.mjs

session.mjs

107 lines · scripts/session.mjs at 1482e84

1import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2import { homedir } from "node:os";
3import { join } from "node:path";
4
5export const IDLE_CAP = 10 * 60 * 1000;
6
7export function parseMinutes(text) {
8 const value = String(text ?? "").trim().toLowerCase();
9 if (!value) return null;
10 if (/^\d+$/.test(value)) return Number(value);
11 const match = value.match(/^(?:(\d+(?:[.,]\d+)?)\s*(?:h|hr|hrs|hours?))?\s*(?:(\d+)\s*(?:m|min|mins|minutes?))?$/);
12 if (!match || (!match[1] && !match[2])) return undefined;
13 return Math.round(Number((match[1] ?? "0").replace(",", ".")) * 60 + Number(match[2] ?? 0)) || undefined;
16export function parseTokens(text) {
17 const value = String(text ?? "").trim().toLowerCase().replace(/[\s,_]/g, "").replace(/tokens?$/, "");
18 if (!value) return null;
19 const match = value.match(/^(\d+(?:\.\d+)?)([km]?)$/);
20 if (!match) return undefined;
21 const tokens = Math.round(Number(match[1]) * (match[2] === "m" ? 1e6 : match[2] === "k" ? 1e3 : 1));
22 return tokens >= 1 && tokens <= 1e10 ? tokens : undefined;
25function walk(dir, out = []) {
26 for (const name of existsSync(dir) ? readdirSync(dir) : []) {
27 const full = join(dir, name);
28 if (statSync(full).isDirectory()) walk(full, out);
29 else if (name.endsWith(".jsonl")) out.push(full);
30 }
31 return out;
34export function sessionFiles(sessionId, root = join(homedir(), ".claude", "projects")) {
35 if (!sessionId || !/^[0-9a-f-]{8,64}$/i.test(sessionId) || !existsSync(root)) return [];
36 for (const project of readdirSync(root)) {
37 const main = join(root, project, `${sessionId}.jsonl`);
38 if (existsSync(main)) return [main, ...walk(join(root, project, sessionId))];
39 }
40 return [];
43function entriesOf(file) {
44 const out = [];
45 for (const line of readFileSync(file, "utf8").split("\n")) {
46 if (!line.trim()) continue;
47 try {
48 const entry = JSON.parse(line);
49 if (entry && typeof entry.timestamp === "string") out.push(entry);
50 } catch {}
51 }
52 return out;
55const textOf = (entry) => {
56 const content = entry.message?.content;
57 if (typeof content === "string") return content;
58 if (Array.isArray(content)) return content.filter((part) => part?.type === "text").map((part) => part.text).join("\n");
59 return "";
60};
62const squash = (text) => text.toLowerCase().replace(/\s+/g, " ").trim();
64export function measure(files, { prompt = "" } = {}) {
65 if (!files.length) return null;
66 const [main, ...side] = files;
67 const mainEntries = entriesOf(main).sort((a, b) => a.timestamp.localeCompare(b.timestamp));
68 const asks = mainEntries.filter((entry) => entry.type === "user" && !entry.isSidechain && textOf(entry));
69 const head = squash(prompt).slice(0, 48);
70 const start =
71 (head && asks.find((entry) => squash(textOf(entry)).includes(head))) ??
72 asks.find((entry) => /\/anatomy\b|\banatomy\b/i.test(textOf(entry))) ??
73 mainEntries[0];
74 if (!start) return null;
75 const end = asks.find((entry) => entry.timestamp > start.timestamp && /\bpublish\b/i.test(textOf(entry)) && /\b(community|gallery)\b/i.test(textOf(entry)));
76 const from = start.timestamp;
77 const to = end?.timestamp ?? "￿";
78 const inWindow = (entry) => entry.timestamp >= from && entry.timestamp < to;
80 const all = [...mainEntries, ...side.flatMap(entriesOf)].filter(inWindow);
81 const times = [...new Set(all.map((entry) => Date.parse(entry.timestamp)).filter(Number.isFinite))].sort((a, b) => a - b);
82 let active = 0;
83 for (let i = 1; i < times.length; i++) active += Math.min(times[i] - times[i - 1], IDLE_CAP);
85 const perMessage = new Map();
86 for (const entry of all) {
87 const usage = entry.type === "assistant" ? entry.message?.usage : null;
88 if (!usage) continue;
89 const key = `${entry.sessionId ?? ""}:${entry.agentId ?? ""}:${entry.message?.id ?? entry.uuid}`;
90 const spent = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.output_tokens ?? 0);
91 perMessage.set(key, Math.max(perMessage.get(key) ?? 0, spent));
92 }
93 const tokens = [...perMessage.values()].reduce((sum, spent) => sum + spent, 0);
94 if (!tokens) return null;
95 const last = end?.timestamp ?? (times.length ? new Date(times.at(-1)).toISOString() : from);
96 return { minutes: Math.max(1, Math.round(active / 60000)), tokens, from, to: last };
99export function fromThisSession({ prompt, env = process.env, root } = {}) {
100 return measure(sessionFiles(env.CLAUDE_CODE_SESSION_ID, root), { prompt });
103export function describe({ minutes, tokens }) {
104 const time = minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ""}`;
105 const count = tokens >= 1e6 ? `${(tokens / 1e6).toFixed(1).replace(/\.0$/, "")}M` : tokens >= 1e3 ? `${Math.round(tokens / 1e3)}k` : String(tokens);
106 return `about ${time} and ${count} tokens`;