commit 33dda8f12364caca3aa26d368953192901807cf9 Author: oiupoyt Date: Fri Oct 9 08:05:13 2026 +0530 feat: initial commit of dsh-plugin-token-heatmap diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b218957 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/\n.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..560a0ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 oiupoyt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ab5087 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# dsh-plugin-token-heatmap + +> GitHub-style token usage contribution heatmap and analytics for [DeepSeek Harness](https://github.com/deepseek-ai/dsh). + +Visualizes your daily token consumption, prompt intensity, cache hit efficiency, and streaks across all sessions in a 52-week contribution calendar. + +--- + +## Features + +- **52-Week GitHub Contribution Calendar:** 7 rows × 52 columns tracking daily token activity with 4-level color intensity. +- **Detailed Summary Cards:** + - **Total Tokens:** Complete volume of input, output, and cached tokens. + - **Cache Hit Ratio:** Percentage of tokens served from KV cache reads. + - **Daily Average & Active Days:** Real workload metrics. + - **Streak Tracker:** Current and longest active day streaks. + - **Peak Day Callout:** Date and token count for your heaviest day. +- **Dual Color Palette:** Switch between **GitHub Emerald** and **DeepSeek Indigo**. +- **Interactive Tooltips:** Hover over any day to see token breakdown (billed input/output vs. cache reads and step counts). +- **Session Token Meter Integration:** Integrates into session stats pills with a *"View 12-Month Activity Heatmap"* button that opens the interactive modal dialog anywhere. +- **High-Performance Parser:** Aggregates zstandard (`.jsonl.zstd`) session logs with timestamp and mtime caching for near-instant rendering. +- **DeepSeek Native Theme:** Automatically adapts to DSH dark and light themes using `--dsw-alias-*` design tokens. + +--- + +## Installation + +### Method 1: Using pnpm in your DSH Web profile + +Inside your DSH profile directory: + +```bash +cd ~/.dsh/profiles/web +pnpm add dsh-plugin-token-heatmap +``` + +Then add `"dsh-plugin-token-heatmap"` to `~/.dsh/profiles/web/package.json` under `dsh.profile.bundles`: + +```json +{ + "dsh": { + "profile": { + "bundles": [ + "@deepseek-ai/dsh-base", + "@deepseek-ai/dsh-web-app", + "dsh-plugin-token-heatmap" + ] + } + } +} +``` + +### Method 2: Local Development Link + +Clone the repository locally: + +```bash +git clone https://github.com/oiupoyt/dsh-plugin-token-heatmap.git ~/projects/dsh-plugin-token-heatmap +cd ~/projects/dsh-plugin-token-heatmap +npm run build + +cd ~/.dsh/profiles/web +pnpm add --link ~/projects/dsh-plugin-token-heatmap +``` + +--- + +## Usage + +1. Open the DeepSeek Harness Web GUI (`http://127.0.0.1:3080`). +2. Go to **Settings** (gear icon at the bottom left) → **Token Heatmap**. +3. Or during any session, click on the token stats pill at the bottom of the composer to view token details and tap **"View 12-Month Activity Heatmap →"**. + +--- + +## License + +MIT © [oiupoyt](https://github.com/oiupoyt) diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..6a5a11b --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,4 @@ +# dsh-plugin-token-heatmap bundle patch +- insert: + - id: token-heatmap + name: 'dsh-plugin-token-heatmap' diff --git a/lib/aggregator.js b/lib/aggregator.js new file mode 100644 index 0000000..2408200 --- /dev/null +++ b/lib/aggregator.js @@ -0,0 +1,230 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { spawn } from "node:child_process"; +const fileCache = /* @__PURE__ */ new Map(); +function findSessionFiles(dir) { + const results = []; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findSessionFiles(fullPath)); + } else if (entry.name.endsWith(".jsonl.zstd") || entry.name.endsWith(".jsonl")) { + results.push(fullPath); + } + } + } catch { + } + return results; +} +async function parseSessionFile(filePath) { + try { + const stat = fs.statSync(filePath); + const cached = fileCache.get(filePath); + if (cached && cached.mtimeMs === stat.mtimeMs) { + return cached.days; + } + const isZstd = filePath.endsWith(".zstd"); + const days = {}; + await new Promise((resolve) => { + let buffer = ""; + const handleLine = (line) => { + if (!line.includes('"usage"')) return; + try { + const obj = JSON.parse(line); + const time = obj.time || obj.data && obj.data.time; + const usage = obj.usage || obj.data && obj.data.usage; + if (time && usage) { + const d = new Date(time); + const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const input = Number(usage.inputTokens) || 0; + const output = Number(usage.outputTokens) || 0; + const cacheRead = Number(usage.cacheReadTokens) || 0; + const cacheWrite = Number(usage.cacheWriteTokens) || 0; + const total = input + output + cacheRead; + if (!days[dateKey]) { + days[dateKey] = { + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + turns: 0 + }; + } + days[dateKey].totalTokens += total; + days[dateKey].inputTokens += input; + days[dateKey].outputTokens += output; + days[dateKey].cacheReadTokens += cacheRead; + days[dateKey].cacheWriteTokens += cacheWrite; + days[dateKey].turns += 1; + } + } catch { + } + }; + if (isZstd) { + const proc = spawn("zstd", ["-dc", filePath], { stdio: ["ignore", "pipe", "ignore"] }); + proc.stdout.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + let idx; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + handleLine(line); + } + }); + proc.on("close", () => { + if (buffer.trim().length > 0) handleLine(buffer); + resolve(); + }); + proc.on("error", () => resolve()); + } else { + const stream = fs.createReadStream(filePath, { encoding: "utf8" }); + stream.on("data", (chunk) => { + buffer += chunk; + let idx; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + handleLine(line); + } + }); + stream.on("end", () => { + if (buffer.trim().length > 0) handleLine(buffer); + resolve(); + }); + stream.on("error", () => resolve()); + } + }); + fileCache.set(filePath, { mtimeMs: stat.mtimeMs, days }); + return days; + } catch { + return {}; + } +} +async function aggregateTokens(customSessionsRoot) { + const sessionsRoot = customSessionsRoot || path.join(os.homedir(), ".dsh", "sessions"); + const files = findSessionFiles(sessionsRoot); + const parsedResults = await Promise.all(files.map(parseSessionFile)); + const days = {}; + for (const fileDays of parsedResults) { + for (const [date, stats] of Object.entries(fileDays)) { + if (!days[date]) { + days[date] = { + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + turns: 0 + }; + } + days[date].totalTokens += stats.totalTokens; + days[date].inputTokens += stats.inputTokens; + days[date].outputTokens += stats.outputTokens; + days[date].cacheReadTokens += stats.cacheReadTokens; + days[date].cacheWriteTokens += stats.cacheWriteTokens; + days[date].turns += stats.turns; + } + } + const positiveValues = Object.values(days).map((d) => d.totalTokens).filter((t) => t > 0).sort((a, b) => a - b); + let q1 = 5e6; + let q2 = 25e6; + let q3 = 75e6; + let q4 = 15e7; + if (positiveValues.length >= 4) { + q1 = positiveValues[Math.floor(positiveValues.length * 0.25)]; + q2 = positiveValues[Math.floor(positiveValues.length * 0.5)]; + q3 = positiveValues[Math.floor(positiveValues.length * 0.75)]; + q4 = positiveValues[Math.floor(positiveValues.length * 0.9)]; + } + for (const day of Object.values(days)) { + if (day.totalTokens === 0) day.level = 0; + else if (day.totalTokens <= q1) day.level = 1; + else if (day.totalTokens <= q2) day.level = 2; + else if (day.totalTokens <= q3) day.level = 3; + else day.level = 4; + } + let totalTokens = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalTurns = 0; + let peakDay = null; + for (const [date, stats] of Object.entries(days)) { + totalTokens += stats.totalTokens; + totalInputTokens += stats.inputTokens; + totalOutputTokens += stats.outputTokens; + totalCacheReadTokens += stats.cacheReadTokens; + totalTurns += stats.turns; + if (!peakDay || stats.totalTokens > peakDay.tokens) { + peakDay = { date, tokens: stats.totalTokens, turns: stats.turns }; + } + } + const activeDays = Object.values(days).filter((d) => d.totalTokens > 0).length; + const dailyAverage = activeDays > 0 ? Math.round(totalTokens / activeDays) : 0; + const now = /* @__PURE__ */ new Date(); + const formatDate = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + let currentStreak = 0; + let cursor = new Date(now); + const todayKey = formatDate(cursor); + cursor.setDate(cursor.getDate() - 1); + const yesterdayKey = formatDate(cursor); + let streakDate = (days[todayKey]?.totalTokens ?? 0) > 0 ? new Date(now) : (days[yesterdayKey]?.totalTokens ?? 0) > 0 ? new Date(cursor) : null; + if (streakDate) { + while (true) { + const key = formatDate(streakDate); + if ((days[key]?.totalTokens ?? 0) > 0) { + currentStreak++; + streakDate.setDate(streakDate.getDate() - 1); + } else { + break; + } + } + } + const sortedDates = Object.keys(days).filter((k) => days[k].totalTokens > 0).sort(); + let longestStreak = 0; + let tempStreak = 0; + let prevDate = null; + for (const dateStr of sortedDates) { + const cur = /* @__PURE__ */ new Date(dateStr + "T00:00:00"); + if (!prevDate) { + tempStreak = 1; + } else { + const diffDays = Math.round((cur.getTime() - prevDate.getTime()) / (1e3 * 60 * 60 * 24)); + if (diffDays === 1) { + tempStreak++; + } else { + tempStreak = 1; + } + } + if (tempStreak > longestStreak) longestStreak = tempStreak; + prevDate = cur; + } + const endDate = formatDate(now); + const start = new Date(now); + start.setDate(start.getDate() - 364); + const startDate = formatDate(start); + return { + summary: { + totalTokens, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalTurns, + activeDays, + currentStreak, + longestStreak: Math.max(longestStreak, currentStreak), + peakDay, + dailyAverage + }, + days, + startDate, + endDate + }; +} +export { + aggregateTokens +}; diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 0000000..478d928 --- /dev/null +++ b/lib/client.js @@ -0,0 +1,643 @@ +// src/client/index.tsx +import React3, { useState as useState2, useEffect as useEffect2 } from "react"; +import { createRoot } from "react-dom/client"; + +// src/client/HeatmapView.tsx +import React, { useEffect, useState, useMemo } from "react"; +function formatNumber(num) { + if (num >= 1e9) return (num / 1e9).toFixed(2) + "B"; + if (num >= 1e6) return (num / 1e6).toFixed(1) + "M"; + if (num >= 1e3) return (num / 1e3).toFixed(1) + "k"; + return num.toLocaleString(); +} +function formatExact(num) { + return num.toLocaleString(); +} +var MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +var DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""]; +var COLOR_THEMES = { + green: { + name: "GitHub Emerald", + levels: [ + "var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.08))", + "#0e4429", + "#006d32", + "#26a641", + "#39d353" + ] + }, + deepseek: { + name: "DeepSeek Indigo", + levels: [ + "var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.08))", + "#1e1e4a", + "#2e3188", + "#434fc7", + "#6366f1" + ] + } +}; +function HeatmapView() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [themeKey, setThemeKey] = useState("green"); + const [hoveredDay, setHoveredDay] = useState(null); + const [tooltipPos, setTooltipPos] = useState(null); + const fetchStats = () => { + setLoading(true); + fetch("/api/dsh-token-heatmap/stats").then((r) => { + if (!r.ok) throw new Error(`HTTP error ${r.status}`); + return r.json(); + }).then((res) => { + setData(res); + setLoading(false); + }).catch((err) => { + setError(err.message); + setLoading(false); + }); + }; + useEffect(() => { + fetchStats(); + }, []); + const calendarGrid = useMemo(() => { + if (!data) return null; + const weeks = []; + const now = /* @__PURE__ */ new Date(); + const end = new Date(now); + const start = new Date(now); + start.setDate(start.getDate() - 364); + start.setDate(start.getDate() - start.getDay()); + let current = new Date(start); + let currentWeek = []; + while (current <= end || currentWeek.length > 0) { + const dateKey = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, "0")}-${String(current.getDate()).padStart(2, "0")}`; + const dayStats = data.days[dateKey] || { + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + turns: 0, + level: 0 + }; + if (current <= end) { + currentWeek.push({ date: dateKey, stats: dayStats }); + } else { + currentWeek.push(null); + } + if (currentWeek.length === 7) { + weeks.push(currentWeek); + currentWeek = []; + } + current.setDate(current.getDate() + 1); + if (current > end && currentWeek.length === 0) break; + } + return weeks; + }, [data]); + const monthHeaders = useMemo(() => { + if (!calendarGrid) return []; + const headers = []; + let lastMonth = -1; + calendarGrid.forEach((week, colIdx) => { + const firstValidDay = week.find((d) => d !== null); + if (firstValidDay) { + const d = /* @__PURE__ */ new Date(firstValidDay.date + "T00:00:00"); + const month = d.getMonth(); + if (month !== lastMonth) { + headers.push({ label: MONTH_NAMES[month], col: colIdx }); + lastMonth = month; + } + } + }); + return headers; + }, [calendarGrid]); + const activeTheme = COLOR_THEMES[themeKey]; + if (loading && !data) { + return /* @__PURE__ */ React.createElement("div", { style: { padding: "40px 20px", textAlign: "center", color: "var(--dsw-alias-label-secondary)" } }, "Loading token usage heatmap..."); + } + if (error) { + return /* @__PURE__ */ React.createElement("div", { style: { padding: "20px", color: "var(--dsw-alias-state-error-primary, #ef4444)" } }, "Failed to load heatmap: ", error, /* @__PURE__ */ React.createElement( + "button", + { + onClick: fetchStats, + style: { + marginLeft: 12, + padding: "4px 10px", + background: "var(--dsw-alias-bg-layer-2)", + border: "1px solid var(--dsw-alias-border-l2)", + color: "var(--dsw-alias-label-primary)", + borderRadius: 6, + cursor: "pointer" + } + }, + "Retry" + )); + } + if (!data) return null; + const { summary } = data; + const cacheHitPercent = summary.totalTokens > 0 ? Math.round(summary.totalCacheReadTokens / summary.totalTokens * 100) : 0; + return /* @__PURE__ */ React.createElement( + "div", + { + style: { + boxSizing: "border-box", + width: "100%", + maxWidth: "960px", + margin: "0 auto", + padding: "16px 0 40px", + fontFamily: "inherit", + color: "var(--dsw-alias-label-primary, #eeeeee)" + } + }, + /* @__PURE__ */ React.createElement( + "div", + { + style: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + flexWrap: "wrap", + gap: 12, + marginBottom: 20 + } + }, + /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement( + "h2", + { + style: { + margin: 0, + fontSize: 22, + fontWeight: 650, + letterSpacing: "-0.02em", + display: "flex", + alignItems: "center", + gap: 10 + } + }, + "Token Usage Activity", + /* @__PURE__ */ React.createElement( + "span", + { + style: { + fontSize: 12, + fontWeight: 500, + padding: "2px 8px", + borderRadius: 12, + background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.15))", + color: "var(--dsw-alias-label-secondary)" + } + }, + "Last 12 Months" + ) + ), /* @__PURE__ */ React.createElement( + "p", + { + style: { + margin: "6px 0 0", + fontSize: 13, + color: "var(--dsw-alias-label-tertiary, #a0a0a0)" + } + }, + "Daily token volume and request step intensity across all sessions" + )), + /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement( + "div", + { + style: { + display: "inline-flex", + background: "var(--dsw-alias-bg-layer-2, #2c2c2c)", + border: "1px solid var(--dsw-alias-border-l2, #494949)", + borderRadius: 8, + padding: 2 + } + }, + /* @__PURE__ */ React.createElement( + "button", + { + onClick: () => setThemeKey("green"), + style: { + border: 0, + background: themeKey === "green" ? "var(--dsw-alias-bg-layer-1, #1e1e1e)" : "transparent", + color: themeKey === "green" ? "var(--dsw-alias-label-primary)" : "var(--dsw-alias-label-tertiary)", + fontSize: 12, + padding: "4px 10px", + borderRadius: 6, + cursor: "pointer", + fontWeight: themeKey === "green" ? 600 : 400 + } + }, + "Emerald" + ), + /* @__PURE__ */ React.createElement( + "button", + { + onClick: () => setThemeKey("deepseek"), + style: { + border: 0, + background: themeKey === "deepseek" ? "var(--dsw-alias-bg-layer-1, #1e1e1e)" : "transparent", + color: themeKey === "deepseek" ? "var(--dsw-alias-label-primary)" : "var(--dsw-alias-label-tertiary)", + fontSize: 12, + padding: "4px 10px", + borderRadius: 6, + cursor: "pointer", + fontWeight: themeKey === "deepseek" ? 600 : 400 + } + }, + "Indigo" + ) + ), /* @__PURE__ */ React.createElement( + "button", + { + onClick: fetchStats, + title: "Refresh statistics", + style: { + display: "inline-flex", + alignItems: "center", + gap: 6, + background: "var(--dsw-alias-bg-layer-2, #2c2c2c)", + border: "1px solid var(--dsw-alias-border-l2, #494949)", + color: "var(--dsw-alias-label-primary)", + borderRadius: 8, + padding: "6px 12px", + fontSize: 12, + fontWeight: 500, + cursor: "pointer" + } + }, + "\u21BB Refresh" + )) + ), + /* @__PURE__ */ React.createElement( + "div", + { + style: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", + gap: 12, + marginBottom: 20 + } + }, + /* @__PURE__ */ React.createElement( + "div", + { + style: { + background: "var(--dsw-alias-bg-layer-2, #2c2c2c)", + border: "1px solid var(--dsw-alias-border-l2, #494949)", + borderRadius: 10, + padding: "14px 16px" + } + }, + /* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #a0a0a0)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 } }, "Total Tokens"), + /* @__PURE__ */ React.createElement("div", { style: { fontSize: 24, fontWeight: 700, margin: "6px 0 2px", color: "var(--dsw-alias-label-primary, #ffffff)" } }, formatNumber(summary.totalTokens)), + /* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-secondary, #b9b9b9)" } }, formatExact(summary.totalTokens), " total") + ), + /* @__PURE__ */ React.createElement( + "div", + { + style: { + background: "var(--dsw-alias-bg-layer-2, #2c2c2c)", + border: "1px solid var(--dsw-alias-border-l2, #494949)", + borderRadius: 10, + padding: "14px 16px" + } + }, + /* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #a0a0a0)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 } }, "Cache Hit Ratio"), + /* @__PURE__ */ React.createElement("div", { style: { fontSize: 24, fontWeight: 700, margin: "6px 0 2px", color: "#10b981" } }, cacheHitPercent, "%"), + /* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-secondary, #b9b9b9)" } }, formatNumber(summary.totalCacheReadTokens), " cached reads") + ), + /* @__PURE__ */ React.createElement( + "div", + { + style: { + background: "var(--dsw-alias-bg-layer-2, #2c2c2c)", + border: "1px solid var(--dsw-alias-border-l2, #494949)", + borderRadius: 10, + padding: "14px 16px" + } + }, + /* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #a0a0a0)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 } }, "Daily Average"), + /* @__PURE__ */ React.createElement("div", { style: { fontSize: 24, fontWeight: 700, margin: "6px 0 2px", color: "var(--dsw-alias-label-primary, #ffffff)" } }, formatNumber(summary.dailyAverage)), + /* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-secondary, #b9b9b9)" } }, "Across ", summary.activeDays, " active days") + ), + /* @__PURE__ */ React.createElement( + "div", + { + style: { + background: "var(--dsw-alias-bg-layer-2, #2c2c2c)", + border: "1px solid var(--dsw-alias-border-l2, #494949)", + borderRadius: 10, + padding: "14px 16px" + } + }, + /* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #a0a0a0)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 } }, "Active Streak"), + /* @__PURE__ */ React.createElement("div", { style: { fontSize: 24, fontWeight: 700, margin: "6px 0 2px", color: "var(--dsw-alias-label-primary, #ffffff)" } }, summary.currentStreak, " ", summary.currentStreak === 1 ? "day" : "days"), + /* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-secondary, #b9b9b9)" } }, "Longest streak: ", summary.longestStreak, " days") + ) + ), + /* @__PURE__ */ React.createElement( + "div", + { + style: { + background: "var(--dsw-alias-bg-layer-2, #2c2c2c)", + border: "1px solid var(--dsw-alias-border-l2, #494949)", + borderRadius: 12, + padding: "20px 22px", + position: "relative", + overflowX: "auto" + } + }, + /* @__PURE__ */ React.createElement( + "div", + { + style: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 14, + fontSize: 13, + fontWeight: 600 + } + }, + /* @__PURE__ */ React.createElement("span", null, "Contribution Calendar"), + summary.peakDay && /* @__PURE__ */ React.createElement("span", { style: { fontSize: 12, fontWeight: 400, color: "var(--dsw-alias-label-secondary)" } }, "Peak day: ", /* @__PURE__ */ React.createElement("strong", { style: { color: "var(--dsw-alias-label-primary)" } }, summary.peakDay.date), " (", formatNumber(summary.peakDay.tokens), " tokens)") + ), + calendarGrid && /* @__PURE__ */ React.createElement("div", { style: { display: "inline-block", minWidth: 780 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", marginLeft: 32, marginBottom: 4, height: 16 } }, monthHeaders.map((m, i) => /* @__PURE__ */ React.createElement( + "div", + { + key: i, + style: { + position: "relative", + left: m.col * 14, + width: 0, + whiteSpace: "nowrap", + fontSize: 10, + color: "var(--dsw-alias-label-tertiary, #a0a0a0)" + } + }, + m.label + ))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement( + "div", + { + style: { + display: "flex", + flexDirection: "column", + gap: 3, + width: 26, + fontSize: 9, + color: "var(--dsw-alias-label-tertiary, #a0a0a0)", + textAlign: "right", + paddingTop: 1 + } + }, + DAY_LABELS.map((lbl, idx) => /* @__PURE__ */ React.createElement("div", { key: idx, style: { height: 11, lineHeight: "11px" } }, lbl)) + ), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 3 } }, calendarGrid.map((week, colIdx) => /* @__PURE__ */ React.createElement("div", { key: colIdx, style: { display: "flex", flexDirection: "column", gap: 3 } }, week.map((day, rowIdx) => { + if (!day) { + return /* @__PURE__ */ React.createElement("div", { key: rowIdx, style: { width: 11, height: 11 } }); + } + const lvl = day.stats.level || 0; + const bg = activeTheme.levels[lvl]; + return /* @__PURE__ */ React.createElement( + "div", + { + key: rowIdx, + onMouseEnter: (e) => { + setHoveredDay({ date: day.date, stats: day.stats }); + const rect = e.currentTarget.getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top - 8 }); + }, + onMouseLeave: () => { + setHoveredDay(null); + setTooltipPos(null); + }, + style: { + width: 11, + height: 11, + borderRadius: 2, + backgroundColor: bg, + border: lvl === 0 ? "1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.06))" : "1px solid transparent", + cursor: "pointer", + transition: "transform 0.1s ease, filter 0.1s ease" + } + } + ); + }))))), /* @__PURE__ */ React.createElement( + "div", + { + style: { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: 6, + marginTop: 14, + fontSize: 11, + color: "var(--dsw-alias-label-tertiary, #a0a0a0)" + } + }, + /* @__PURE__ */ React.createElement("span", null, "Less"), + activeTheme.levels.map((c, i) => /* @__PURE__ */ React.createElement( + "div", + { + key: i, + style: { + width: 11, + height: 11, + borderRadius: 2, + backgroundColor: c, + border: i === 0 ? "1px solid var(--dsw-alias-border-l1)" : "none" + } + } + )), + /* @__PURE__ */ React.createElement("span", null, "More") + )) + ), + hoveredDay && tooltipPos && /* @__PURE__ */ React.createElement( + "div", + { + style: { + position: "fixed", + left: tooltipPos.x, + top: tooltipPos.y, + transform: "translate(-50%, -100%)", + background: "var(--dsw-alias-bg-layer-1, #1e1e1e)", + border: "1px solid var(--dsw-alias-border-l3, #555555)", + borderRadius: 8, + padding: "8px 12px", + fontSize: 12, + lineHeight: 1.4, + boxShadow: "0 6px 18px rgba(0,0,0,0.35)", + pointerEvents: "none", + zIndex: 9999, + whiteSpace: "nowrap" + } + }, + /* @__PURE__ */ React.createElement("div", { style: { fontWeight: 650, color: "var(--dsw-alias-label-primary, #ffffff)", marginBottom: 4 } }, (/* @__PURE__ */ new Date(hoveredDay.date + "T00:00:00")).toLocaleDateString(void 0, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric" + })), + hoveredDay.stats.totalTokens === 0 ? /* @__PURE__ */ React.createElement("div", { style: { color: "var(--dsw-alias-label-tertiary)" } }, "No activity recorded") : /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { style: { color: "#34d399", fontWeight: 600 } }, formatExact(hoveredDay.stats.totalTokens), " tokens"), /* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-secondary)", marginTop: 2 } }, hoveredDay.stats.turns, " requests / steps"), /* @__PURE__ */ React.createElement("div", { style: { fontSize: 10, color: "var(--dsw-alias-label-tertiary)", marginTop: 4 } }, "Billed: ", formatNumber(hoveredDay.stats.inputTokens + hoveredDay.stats.outputTokens), " \xB7 Cache: ", formatNumber(hoveredDay.stats.cacheReadTokens))) + ) + ); +} + +// src/client/HeatmapModal.tsx +import React2 from "react"; +function HeatmapModal({ open, onClose }) { + if (!open) return null; + return /* @__PURE__ */ React2.createElement( + "div", + { + role: "dialog", + "aria-modal": "true", + "aria-label": "Token Usage Heatmap", + onClick: onClose, + style: { + position: "fixed", + inset: 0, + zIndex: 1e4, + backgroundColor: "rgba(0, 0, 0, 0.65)", + backdropFilter: "blur(4px)", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "20px", + overflowY: "auto" + } + }, + /* @__PURE__ */ React2.createElement( + "div", + { + onClick: (e) => e.stopPropagation(), + style: { + position: "relative", + width: "100%", + maxWidth: "960px", + maxHeight: "90vh", + overflowY: "auto", + background: "var(--dsw-alias-bg-layer-1, #1e1e1e)", + border: "1px solid var(--dsw-alias-border-l2, #494949)", + borderRadius: "14px", + boxShadow: "0 16px 40px rgba(0, 0, 0, 0.5)", + padding: "24px 28px" + } + }, + /* @__PURE__ */ React2.createElement( + "button", + { + onClick: onClose, + "aria-label": "Close dialog", + style: { + position: "absolute", + top: "16px", + right: "18px", + background: "transparent", + border: "none", + color: "var(--dsw-alias-label-tertiary, #a0a0a0)", + fontSize: "20px", + cursor: "pointer", + width: "32px", + height: "32px", + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: "6px" + } + }, + "\u2715" + ), + /* @__PURE__ */ React2.createElement(HeatmapView, null) + ) + ); +} + +// src/client/index.tsx +var name = "dsh-plugin-token-heatmap-client"; +var inject = ["slots"]; +function apply(ctx) { + if (ctx.slots && typeof ctx.slots.inject === "function") { + ctx.slots.inject("settings.section", () => { + return ctx.slots.register( + { + name: "settings.section", + id: "token-heatmap", + order: 14, + label: () => "Token Heatmap", + icon: "chart" + }, + HeatmapView + ); + }); + } + if (typeof document !== "undefined") { + let ModalController = function() { + const [isOpen, setIsOpen] = useState2(false); + useEffect2(() => { + const handleOpen = () => setIsOpen(true); + const handleClose = () => setIsOpen(false); + window.addEventListener("dsh:open-token-heatmap", handleOpen); + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") handleClose(); + }); + return () => { + window.removeEventListener("dsh:open-token-heatmap", handleOpen); + }; + }, []); + return /* @__PURE__ */ React3.createElement(HeatmapModal, { open: isOpen, onClose: () => setIsOpen(false) }); + }; + const modalContainer = document.createElement("div"); + modalContainer.id = "dsh-token-heatmap-modal-root"; + document.body.appendChild(modalContainer); + const root = createRoot(modalContainer); + root.render(/* @__PURE__ */ React3.createElement(ModalController, null)); + const observer = new MutationObserver(() => { + const statDialogs = document.querySelectorAll( + 'div[role="dialog"][aria-label*="usage" i], div[role="dialog"][aria-label*="Token" i], [data-session-stats-usage]' + ); + statDialogs.forEach((dialog) => { + if (dialog.querySelector(".dsh-heatmap-trigger-btn")) return; + const button = document.createElement("button"); + button.className = "dsh-heatmap-trigger-btn"; + button.type = "button"; + button.innerHTML = "\u{1F4CA} View 12-Month Activity Heatmap \u2192"; + button.style.cssText = ` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + margin-top: 12px; + padding: 8px 12px; + background: var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.12)); + border: 1px solid var(--dsw-alias-border-l2, #494949); + border-radius: 8px; + color: var(--dsw-alias-brand-primary, #6366f1); + font-size: 12px; + font-weight: 550; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; + `; + button.onmouseenter = () => { + button.style.background = "var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.2))"; + button.style.borderColor = "var(--dsw-alias-brand-primary, #6366f1)"; + }; + button.onmouseleave = () => { + button.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.12))"; + button.style.borderColor = "var(--dsw-alias-border-l2, #494949)"; + }; + button.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + window.dispatchEvent(new CustomEvent("dsh:open-token-heatmap")); + }; + dialog.appendChild(button); + }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + } +} +export { + apply, + inject, + name +}; diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..e5317dc --- /dev/null +++ b/lib/index.js @@ -0,0 +1,44 @@ +import { aggregateTokens } from "./aggregator.js"; +const name = "dsh-plugin-token-heatmap"; +const inject = ["webServer"]; +function apply(ctx) { + ctx.effect( + () => ctx.webServer.register({ + kind: "prefix", + path: "/api/dsh-token-heatmap", + handler: async (req, res) => { + const url = new URL(req.url, "http://localhost"); + const pathname = url.pathname.replace(/\/+$/, ""); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + if (req.method === "GET" && (pathname === "/api/dsh-token-heatmap" || pathname === "/api/dsh-token-heatmap/stats")) { + try { + const data = await aggregateTokens(); + res.writeHead(200, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-cache" + }); + res.end(JSON.stringify(data)); + } catch (err) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err?.message || "Failed to aggregate token stats" })); + } + return; + } + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + } + }) + ); +} +export { + apply, + inject, + name +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..1c2dbdf --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "dsh-plugin-token-heatmap", + "version": "0.1.0", + "description": "GitHub-style token usage contribution heatmap and analytics for DeepSeek Harness", + "type": "module", + "main": "./lib/index.js", + "exports": { + ".": "./lib/index.js", + "./client": "./lib/client.js", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "cordis.patch.yml", + "README.md", + "LICENSE" + ], + "keywords": [ + "deepseek-harness", + "dsh", + "dsh-plugin", + "cordis", + "heatmap", + "token-usage", + "analytics" + ], + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "inject": [ + "@deepseek-ai/dsh-client-ui-slots" + ], + "platform": "web" + } + }, + "scripts": { + "build": "node scripts/build.mjs" + }, + "peerDependencies": { + "@deepseek-ai/cordis": ">=4.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "license": "MIT" +} diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..8add2c2 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,9 @@ +import { execSync } from "node:child_process"; + +console.log("Building server backend..."); +execSync("npx esbuild src/server/index.ts src/server/aggregator.ts --outdir=lib --platform=node --format=esm --target=node20 --sourcemap", { stdio: "inherit" }); + +console.log("Building client frontend bundle..."); +execSync("npx esbuild src/client/index.tsx --outfile=lib/client.js --platform=browser --format=esm --target=es2022 --bundle --external:react --external:react-dom --external:react-dom/client --sourcemap", { stdio: "inherit" }); + +console.log("Build complete!"); diff --git a/src/client/HeatmapModal.tsx b/src/client/HeatmapModal.tsx new file mode 100644 index 0000000..e45b305 --- /dev/null +++ b/src/client/HeatmapModal.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { HeatmapView } from "./HeatmapView.js"; + +interface HeatmapModalProps { + open: boolean; + onClose: () => void; +} + +export function HeatmapModal({ open, onClose }: HeatmapModalProps) { + if (!open) return null; + + return ( +
+
e.stopPropagation()} + style={{ + position: "relative", + width: "100%", + maxWidth: "960px", + maxHeight: "90vh", + overflowY: "auto", + background: "var(--dsw-alias-bg-layer-1, #1e1e1e)", + border: "1px solid var(--dsw-alias-border-l2, #494949)", + borderRadius: "14px", + boxShadow: "0 16px 40px rgba(0, 0, 0, 0.5)", + padding: "24px 28px", + }} + > + {/* Close button */} + + + +
+
+ ); +} diff --git a/src/client/HeatmapView.tsx b/src/client/HeatmapView.tsx new file mode 100644 index 0000000..8e348bb --- /dev/null +++ b/src/client/HeatmapView.tsx @@ -0,0 +1,617 @@ +import React, { useEffect, useState, useMemo } from "react"; + +export interface DayStats { + totalTokens: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + turns: number; + level?: number; +} + +export interface SummaryStats { + totalTokens: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalTurns: number; + activeDays: number; + currentStreak: number; + longestStreak: number; + peakDay: { date: string; tokens: number; turns: number } | null; + dailyAverage: number; +} + +export interface HeatmapResponse { + summary: SummaryStats; + days: Record; + startDate: string; + endDate: string; +} + +function formatNumber(num: number): string { + if (num >= 1_000_000_000) return (num / 1_000_000_000).toFixed(2) + "B"; + if (num >= 1_000_000) return (num / 1_000_000).toFixed(1) + "M"; + if (num >= 1_000) return (num / 1_000).toFixed(1) + "k"; + return num.toLocaleString(); +} + +function formatExact(num: number): string { + return num.toLocaleString(); +} + +const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""]; + +// Themes: Green (GitHub classic) and DeepSeek Blue/Indigo +const COLOR_THEMES = { + green: { + name: "GitHub Emerald", + levels: [ + "var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.08))", + "#0e4429", + "#006d32", + "#26a641", + "#39d353", + ], + }, + deepseek: { + name: "DeepSeek Indigo", + levels: [ + "var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.08))", + "#1e1e4a", + "#2e3188", + "#434fc7", + "#6366f1", + ], + }, +}; + +export function HeatmapView() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [themeKey, setThemeKey] = useState<"green" | "deepseek">("green"); + const [hoveredDay, setHoveredDay] = useState<{ date: string; stats: DayStats } | null>(null); + const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null); + + const fetchStats = () => { + setLoading(true); + fetch("/api/dsh-token-heatmap/stats") + .then((r) => { + if (!r.ok) throw new Error(`HTTP error ${r.status}`); + return r.json(); + }) + .then((res) => { + setData(res); + setLoading(false); + }) + .catch((err) => { + setError(err.message); + setLoading(false); + }); + }; + + useEffect(() => { + fetchStats(); + }, []); + + // Compute 52-week calendar grid: 7 rows x 53 columns + const calendarGrid = useMemo(() => { + if (!data) return null; + + const weeks: Array> = []; + const now = new Date(); + // End on current week Saturday + const end = new Date(now); + // Start 52 weeks back on Sunday + const start = new Date(now); + start.setDate(start.getDate() - 364); + // Align start to nearest previous Sunday + start.setDate(start.getDate() - start.getDay()); + + let current = new Date(start); + let currentWeek: Array<{ date: string; stats: DayStats } | null> = []; + + while (current <= end || currentWeek.length > 0) { + const dateKey = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, "0")}-${String(current.getDate()).padStart(2, "0")}`; + const dayStats = data.days[dateKey] || { + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + turns: 0, + level: 0, + }; + + if (current <= end) { + currentWeek.push({ date: dateKey, stats: dayStats }); + } else { + currentWeek.push(null); + } + + if (currentWeek.length === 7) { + weeks.push(currentWeek); + currentWeek = []; + } + current.setDate(current.getDate() + 1); + if (current > end && currentWeek.length === 0) break; + } + + return weeks; + }, [data]); + + // Compute month headers + const monthHeaders = useMemo(() => { + if (!calendarGrid) return []; + const headers: Array<{ label: string; col: number }> = []; + let lastMonth = -1; + + calendarGrid.forEach((week, colIdx) => { + const firstValidDay = week.find((d) => d !== null); + if (firstValidDay) { + const d = new Date(firstValidDay.date + "T00:00:00"); + const month = d.getMonth(); + if (month !== lastMonth) { + headers.push({ label: MONTH_NAMES[month], col: colIdx }); + lastMonth = month; + } + } + }); + + return headers; + }, [calendarGrid]); + + const activeTheme = COLOR_THEMES[themeKey]; + + if (loading && !data) { + return ( +
+ Loading token usage heatmap... +
+ ); + } + + if (error) { + return ( +
+ Failed to load heatmap: {error} + +
+ ); + } + + if (!data) return null; + + const { summary } = data; + const cacheHitPercent = + summary.totalTokens > 0 + ? Math.round((summary.totalCacheReadTokens / summary.totalTokens) * 100) + : 0; + + return ( +
+ {/* Title Bar */} +
+
+

+ Token Usage Activity + + Last 12 Months + +

+

+ Daily token volume and request step intensity across all sessions +

+
+ + {/* Action Controls */} +
+ {/* Theme Switcher */} +
+ + +
+ + +
+
+ + {/* Summary Cards */} +
+ {/* Card 1: Total Tokens */} +
+
+ Total Tokens +
+
+ {formatNumber(summary.totalTokens)} +
+
+ {formatExact(summary.totalTokens)} total +
+
+ + {/* Card 2: Cache Efficiency */} +
+
+ Cache Hit Ratio +
+
+ {cacheHitPercent}% +
+
+ {formatNumber(summary.totalCacheReadTokens)} cached reads +
+
+ + {/* Card 3: Daily Average */} +
+
+ Daily Average +
+
+ {formatNumber(summary.dailyAverage)} +
+
+ Across {summary.activeDays} active days +
+
+ + {/* Card 4: Streaks */} +
+
+ Active Streak +
+
+ {summary.currentStreak} {summary.currentStreak === 1 ? "day" : "days"} +
+
+ Longest streak: {summary.longestStreak} days +
+
+
+ + {/* Heatmap Card */} +
+
+ Contribution Calendar + {summary.peakDay && ( + + Peak day: {summary.peakDay.date} ({formatNumber(summary.peakDay.tokens)} tokens) + + )} +
+ + {/* SVG Heatmap */} + {calendarGrid && ( +
+ {/* Month labels */} +
+ {monthHeaders.map((m, i) => ( +
+ {m.label} +
+ ))} +
+ + {/* Grid with day labels */} +
+ {/* Day of week labels */} +
+ {DAY_LABELS.map((lbl, idx) => ( +
+ {lbl} +
+ ))} +
+ + {/* Columns of weeks */} +
+ {calendarGrid.map((week, colIdx) => ( +
+ {week.map((day, rowIdx) => { + if (!day) { + return
; + } + const lvl = day.stats.level || 0; + const bg = activeTheme.levels[lvl]; + + return ( +
{ + setHoveredDay({ date: day.date, stats: day.stats }); + const rect = e.currentTarget.getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top - 8 }); + }} + onMouseLeave={() => { + setHoveredDay(null); + setTooltipPos(null); + }} + style={{ + width: 11, + height: 11, + borderRadius: 2, + backgroundColor: bg, + border: + lvl === 0 + ? "1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.06))" + : "1px solid transparent", + cursor: "pointer", + transition: "transform 0.1s ease, filter 0.1s ease", + }} + /> + ); + })} +
+ ))} +
+
+ + {/* Legend & Footnote */} +
+ Less + {activeTheme.levels.map((c, i) => ( +
+ ))} + More +
+
+ )} +
+ + {/* Floating Hover Tooltip */} + {hoveredDay && tooltipPos && ( +
+
+ {new Date(hoveredDay.date + "T00:00:00").toLocaleDateString(undefined, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + })} +
+ {hoveredDay.stats.totalTokens === 0 ? ( +
No activity recorded
+ ) : ( +
+
+ {formatExact(hoveredDay.stats.totalTokens)} tokens +
+
+ {hoveredDay.stats.turns} requests / steps +
+
+ Billed: {formatNumber(hoveredDay.stats.inputTokens + hoveredDay.stats.outputTokens)} · Cache: {formatNumber(hoveredDay.stats.cacheReadTokens)} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/client/index.tsx b/src/client/index.tsx new file mode 100644 index 0000000..c1c9767 --- /dev/null +++ b/src/client/index.tsx @@ -0,0 +1,106 @@ +import React, { useState, useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { HeatmapView } from "./HeatmapView.js"; +import { HeatmapModal } from "./HeatmapModal.js"; + +export const name = "dsh-plugin-token-heatmap-client"; +export const inject = ["slots"]; + +export function apply(ctx: any) { + // 1. Register under Settings -> "Token Heatmap" + if (ctx.slots && typeof ctx.slots.inject === "function") { + ctx.slots.inject("settings.section", () => { + return ctx.slots.register( + { + name: "settings.section", + id: "token-heatmap", + order: 14, + label: () => "Token Heatmap", + icon: "chart", + }, + HeatmapView + ); + }); + } + + // 2. Global Modal Host & In-Session Token Meter Integration + if (typeof document !== "undefined") { + const modalContainer = document.createElement("div"); + modalContainer.id = "dsh-token-heatmap-modal-root"; + document.body.appendChild(modalContainer); + + const root = createRoot(modalContainer); + + function ModalController() { + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + const handleOpen = () => setIsOpen(true); + const handleClose = () => setIsOpen(false); + + window.addEventListener("dsh:open-token-heatmap", handleOpen); + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") handleClose(); + }); + + return () => { + window.removeEventListener("dsh:open-token-heatmap", handleOpen); + }; + }, []); + + return setIsOpen(false)} />; + } + + root.render(); + + // 3. Observe DOM for in-session token usage dialog to attach "View Heatmap" button + const observer = new MutationObserver(() => { + // Find the open session stats dialog + const statDialogs = document.querySelectorAll( + 'div[role="dialog"][aria-label*="usage" i], div[role="dialog"][aria-label*="Token" i], [data-session-stats-usage]' + ); + + statDialogs.forEach((dialog) => { + if (dialog.querySelector(".dsh-heatmap-trigger-btn")) return; + + const button = document.createElement("button"); + button.className = "dsh-heatmap-trigger-btn"; + button.type = "button"; + button.innerHTML = "📊 View 12-Month Activity Heatmap →"; + button.style.cssText = ` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + margin-top: 12px; + padding: 8px 12px; + background: var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.12)); + border: 1px solid var(--dsw-alias-border-l2, #494949); + border-radius: 8px; + color: var(--dsw-alias-brand-primary, #6366f1); + font-size: 12px; + font-weight: 550; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; + `; + button.onmouseenter = () => { + button.style.background = "var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.2))"; + button.style.borderColor = "var(--dsw-alias-brand-primary, #6366f1)"; + }; + button.onmouseleave = () => { + button.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.12))"; + button.style.borderColor = "var(--dsw-alias-border-l2, #494949)"; + }; + button.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + window.dispatchEvent(new CustomEvent("dsh:open-token-heatmap")); + }; + + dialog.appendChild(button); + }); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + } +} diff --git a/src/server/aggregator.ts b/src/server/aggregator.ts new file mode 100644 index 0000000..54a6e63 --- /dev/null +++ b/src/server/aggregator.ts @@ -0,0 +1,303 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { spawn } from "node:child_process"; + +export interface DayStats { + totalTokens: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + turns: number; + level?: number; // 0..4 +} + +export interface SummaryStats { + totalTokens: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalTurns: number; + activeDays: number; + currentStreak: number; + longestStreak: number; + peakDay: { date: string; tokens: number; turns: number } | null; + dailyAverage: number; +} + +export interface HeatmapResponse { + summary: SummaryStats; + days: Record; + startDate: string; + endDate: string; +} + +interface CacheEntry { + mtimeMs: number; + days: Record; +} + +const fileCache = new Map(); + +function findSessionFiles(dir: string): string[] { + const results: string[] = []; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findSessionFiles(fullPath)); + } else if (entry.name.endsWith(".jsonl.zstd") || entry.name.endsWith(".jsonl")) { + results.push(fullPath); + } + } + } catch { + // Ignore unreadable dirs + } + return results; +} + +async function parseSessionFile(filePath: string): Promise> { + try { + const stat = fs.statSync(filePath); + const cached = fileCache.get(filePath); + if (cached && cached.mtimeMs === stat.mtimeMs) { + return cached.days; + } + + const isZstd = filePath.endsWith(".zstd"); + const days: Record = {}; + + await new Promise((resolve) => { + let buffer = ""; + const handleLine = (line: string) => { + if (!line.includes('"usage"')) return; + try { + const obj = JSON.parse(line); + const time = obj.time || (obj.data && obj.data.time); + const usage = obj.usage || (obj.data && obj.data.usage); + if (time && usage) { + const d = new Date(time); + const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const input = Number(usage.inputTokens) || 0; + const output = Number(usage.outputTokens) || 0; + const cacheRead = Number(usage.cacheReadTokens) || 0; + const cacheWrite = Number(usage.cacheWriteTokens) || 0; + const total = input + output + cacheRead; + + if (!days[dateKey]) { + days[dateKey] = { + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + turns: 0, + }; + } + days[dateKey].totalTokens += total; + days[dateKey].inputTokens += input; + days[dateKey].outputTokens += output; + days[dateKey].cacheReadTokens += cacheRead; + days[dateKey].cacheWriteTokens += cacheWrite; + days[dateKey].turns += 1; + } + } catch { + // Ignore malformed lines + } + }; + + if (isZstd) { + const proc = spawn("zstd", ["-dc", filePath], { stdio: ["ignore", "pipe", "ignore"] }); + proc.stdout.on("data", (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + let idx: number; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + handleLine(line); + } + }); + proc.on("close", () => { + if (buffer.trim().length > 0) handleLine(buffer); + resolve(); + }); + proc.on("error", () => resolve()); + } else { + const stream = fs.createReadStream(filePath, { encoding: "utf8" }); + stream.on("data", (chunk: string) => { + buffer += chunk; + let idx: number; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + handleLine(line); + } + }); + stream.on("end", () => { + if (buffer.trim().length > 0) handleLine(buffer); + resolve(); + }); + stream.on("error", () => resolve()); + } + }); + + fileCache.set(filePath, { mtimeMs: stat.mtimeMs, days }); + return days; + } catch { + return {}; + } +} + +export async function aggregateTokens(customSessionsRoot?: string): Promise { + const sessionsRoot = customSessionsRoot || path.join(os.homedir(), ".dsh", "sessions"); + const files = findSessionFiles(sessionsRoot); + const parsedResults = await Promise.all(files.map(parseSessionFile)); + + const days: Record = {}; + + for (const fileDays of parsedResults) { + for (const [date, stats] of Object.entries(fileDays)) { + if (!days[date]) { + days[date] = { + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + turns: 0, + }; + } + days[date].totalTokens += stats.totalTokens; + days[date].inputTokens += stats.inputTokens; + days[date].outputTokens += stats.outputTokens; + days[date].cacheReadTokens += stats.cacheReadTokens; + days[date].cacheWriteTokens += stats.cacheWriteTokens; + days[date].turns += stats.turns; + } + } + + // Calculate Levels (GitHub 0..4 intensity scale based on positive token days) + const positiveValues = Object.values(days) + .map((d) => d.totalTokens) + .filter((t) => t > 0) + .sort((a, b) => a - b); + + let q1 = 5_000_000; + let q2 = 25_000_000; + let q3 = 75_000_000; + let q4 = 150_000_000; + + if (positiveValues.length >= 4) { + q1 = positiveValues[Math.floor(positiveValues.length * 0.25)]; + q2 = positiveValues[Math.floor(positiveValues.length * 0.50)]; + q3 = positiveValues[Math.floor(positiveValues.length * 0.75)]; + q4 = positiveValues[Math.floor(positiveValues.length * 0.90)]; + } + + for (const day of Object.values(days)) { + if (day.totalTokens === 0) day.level = 0; + else if (day.totalTokens <= q1) day.level = 1; + else if (day.totalTokens <= q2) day.level = 2; + else if (day.totalTokens <= q3) day.level = 3; + else day.level = 4; + } + + // Calculate Summary & Streaks + let totalTokens = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalTurns = 0; + let peakDay: { date: string; tokens: number; turns: number } | null = null; + + for (const [date, stats] of Object.entries(days)) { + totalTokens += stats.totalTokens; + totalInputTokens += stats.inputTokens; + totalOutputTokens += stats.outputTokens; + totalCacheReadTokens += stats.cacheReadTokens; + totalTurns += stats.turns; + + if (!peakDay || stats.totalTokens > peakDay.tokens) { + peakDay = { date, tokens: stats.totalTokens, turns: stats.turns }; + } + } + + const activeDays = Object.values(days).filter((d) => d.totalTokens > 0).length; + const dailyAverage = activeDays > 0 ? Math.round(totalTokens / activeDays) : 0; + + // Streak calculation (iterating backwards from today) + const now = new Date(); + const formatDate = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + + let currentStreak = 0; + let cursor = new Date(now); + const todayKey = formatDate(cursor); + cursor.setDate(cursor.getDate() - 1); + const yesterdayKey = formatDate(cursor); + + // If active today, start from today; else if active yesterday, start from yesterday + let streakDate = (days[todayKey]?.totalTokens ?? 0) > 0 ? new Date(now) : (days[yesterdayKey]?.totalTokens ?? 0) > 0 ? new Date(cursor) : null; + + if (streakDate) { + while (true) { + const key = formatDate(streakDate); + if ((days[key]?.totalTokens ?? 0) > 0) { + currentStreak++; + streakDate.setDate(streakDate.getDate() - 1); + } else { + break; + } + } + } + + // Longest streak + const sortedDates = Object.keys(days) + .filter((k) => days[k].totalTokens > 0) + .sort(); + + let longestStreak = 0; + let tempStreak = 0; + let prevDate: Date | null = null; + + for (const dateStr of sortedDates) { + const cur = new Date(dateStr + "T00:00:00"); + if (!prevDate) { + tempStreak = 1; + } else { + const diffDays = Math.round((cur.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)); + if (diffDays === 1) { + tempStreak++; + } else { + tempStreak = 1; + } + } + if (tempStreak > longestStreak) longestStreak = tempStreak; + prevDate = cur; + } + + const endDate = formatDate(now); + const start = new Date(now); + start.setDate(start.getDate() - 364); // 52 weeks = 364 days + const startDate = formatDate(start); + + return { + summary: { + totalTokens, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalTurns, + activeDays, + currentStreak, + longestStreak: Math.max(longestStreak, currentStreak), + peakDay, + dailyAverage, + }, + days, + startDate, + endDate, + }; +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..739f767 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,46 @@ +import { aggregateTokens } from "./aggregator.js"; + +export const name = "dsh-plugin-token-heatmap"; +export const inject = ["webServer"]; + +export function apply(ctx: any) { + ctx.effect(() => + ctx.webServer.register({ + kind: "prefix", + path: "/api/dsh-token-heatmap", + handler: async (req: any, res: any) => { + const url = new URL(req.url, "http://localhost"); + const pathname = url.pathname.replace(/\/+$/, ""); + + // Set common headers + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + if (req.method === "GET" && (pathname === "/api/dsh-token-heatmap" || pathname === "/api/dsh-token-heatmap/stats")) { + try { + const data = await aggregateTokens(); + res.writeHead(200, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-cache", + }); + res.end(JSON.stringify(data)); + } catch (err: any) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err?.message || "Failed to aggregate token stats" })); + } + return; + } + + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + }, + }) + ); +}