Recently Written · git

ideamine

An idea inbox for Claude Code: /idea saves ideas at zero tokens; Claude triages them and routes each to the cheapest model that can build it.

git clone https://github.com/equwal/ideamine

Log | Files | Refs


commit 0e1df306ee76ebb6f9b406af9887fbbc0fd72087
equwal <13551856+equwal@users.noreply.github.com>
2026-09-22 15:57:40 -0700

ideamine 0.10.0: tokens, models, and price for each project

Claude Code writes a transcript for each session, and each answer in it
carries the model and the token counts. src/usage.js reads the new bytes
of each transcript and adds the numbers up for each day, project, and
model. usage-scan.json keeps the size that it read, so a new scan reads
only what is new: the first scan of 203 transcripts takes 2.4 s, and the
next one takes 61 ms.

The price is the price of the API: a cache write costs 1.25 times the
input price with the 5-minute lifetime and 2 times with the 1-hour
lifetime, and each model has its own price for a cache read. PRICES in
src/usage.js holds the list.

Each machine sends its rows with the sync, at most every 10 minutes, and
the server keeps one set of rows for each machine. The dashboard gets a
Usage tab with one line for each project: the price, the models with
their share, the tokens, and the number of answers. `ideamine usage
[days]` prints the same table in a terminal.

 .claude-plugin/marketplace.json |   2 +-
 .claude-plugin/plugin.json      |   2 +-
 README.md                       |   5 +-
 bin/ideamine.js                 |  19 ++++
 dashboard/index.html            |  99 ++++++++++++++++++++-
 package.json                    |   2 +-
 src/serve.js                    |  64 ++++++++++++-
 src/sync.js                     |  33 ++++++-
 src/usage.js                    | 193 ++++++++++++++++++++++++++++++++++++++++
 tests/dashboard.test.js         |   2 +-
 tests/serve.test.js             |  18 ++++
 tests/usage.test.js             | 123 +++++++++++++++++++++++++
 12 files changed, 552 insertions(+), 10 deletions(-)
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 613dfbf..a668694 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -10,7 +10,7 @@
     {
       "name": "ideamine",
       "description": "An idea inbox for Claude Code. /idea saves an idea without a model call. Claude triages the ideas and picks the cheapest model that can build each one.",
-      "version": "0.9.1",
+      "version": "0.10.0",
       "author": {
         "name": "equwal"
       },
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index f262685..6595ecd 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
 {
   "name": "ideamine",
-  "version": "0.9.1",
+  "version": "0.10.0",
   "description": "An idea inbox for Claude Code. /idea saves an idea without a model call, so it works at any time, even at your usage limit. Claude triages the ideas and picks the cheapest model that can build each one.",
   "author": {
     "name": "equwal",
diff --git a/README.md b/README.md
index 37915e6..4f597a6 100644
--- a/README.md
+++ b/README.md
@@ -210,12 +210,15 @@ server {
 
 `serve_hosts` names the Host that nginx sends. Without it, the server refuses the request, so a DNS rebinding page cannot reach the archive. The server does not ask who you are: the network decides who can reach it. Put it only on a private network like WireGuard.
 
-### Prompts and Memory
+### Prompts, Memory, and Usage
 
 The dashboard can also show what your Claudes did:
 
 - **Prompts**: each prompt that you typed, on every machine, as a timeline of sessions. `ideamine config prompt_log on` sends each prompt to the ideamine server in the background. The full text goes, without the notes that Claude Code puts into a prompt; a paste longer than 100,000 characters is cut. `ideamine prompts import` sends the prompts of your older chats from the Claude Code transcripts.
 - **Memory**: the memories of a [memstate](https://github.com/map588/memstate) daemon: a timeline of the writes of each project, the latest writes, and each memory with its versions. Set `memstate_url` on the server, for example `http://127.0.0.1:8765`. The dashboard only reads memstate.
+- **Usage**: the tokens, the models, and the price for each project. Claude Code writes a transcript for each session, and each answer in it carries the model and the token counts. ideamine adds them up for each day, project, and model, and puts the API price on them. Each machine sends its numbers with the sync, at most every 10 minutes, and the dashboard shows every machine together. `ideamine usage [days]` prints the same table in a terminal.
+
+  The price is the price of the API (see [pricing](https://www.anthropic.com/pricing)): a cache write costs 1.25 times the input price with the 5-minute lifetime and 2 times with the 1-hour lifetime, and a cache read is much cheaper than input. **A Claude subscription does not bill for each token**, so with a subscription the price says what the same work would cost through the API. A model that the price list does not know shows 0. The price list is `PRICES` in `src/usage.js`.
 
 ## With the agent pipeline
 
diff --git a/bin/ideamine.js b/bin/ideamine.js
index 35c8b36..c3d4aa5 100644
--- a/bin/ideamine.js
+++ b/bin/ideamine.js
@@ -29,6 +29,7 @@ const HELP = `ideamine: an idea inbox for Claude Code
   ideamine serve [--port 4332]    the dashboard with a button for each command, at 127.0.0.1
   ideamine sync [url|off]         share the archive of every machine through an ideamine server
   ideamine prompts import         put the prompts of older Claude Code chats into the prompt log
+  ideamine usage [days]           tokens, models, and price for each project (0 days: all time)
   ideamine config [key [value]]   show or change a setting (an empty value restores the default)
   ideamine export [file.md]       Markdown export of the whole archive
   ideamine path                   where the archive lives (override with IDEAMINE_HOME)
@@ -267,6 +268,24 @@ async function main() {
       console.log(`Put ${sync.importPrompts()} prompts from ${sync.transcriptsDir()} into the prompt outbox. The next sync sends them.`);
       break;
     }
+    case 'usage': {
+      const usage = await import('../src/usage.js');
+      const days = Number(words[0]) || 0;
+      const { rows, files } = usage.scan();
+      const projects = usage.byProject(usage.since(rows, days));
+      const money = (n) => `$${n.toFixed(2)}`;
+      const tokens = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n));
+      const total = projects.reduce((n, p) => n + p.cost, 0);
+      console.log(`ideamine usage: ${money(total)} in ${days ? `the last ${days} days` : 'all time'}, from ${files} transcripts. The price is the price of the API.`);
+      for (const p of projects.slice(0, Number(process.env.IDEAMINE_USAGE_LIMIT) || 20)) {
+        console.log(`\n  ${money(p.cost).padStart(9)}  ${p.project}`);
+        console.log(`             in ${tokens(p.input)} · out ${tokens(p.output)} · cache write ${tokens(p.write5m + p.write1h)} · cache read ${tokens(p.read)} · ${p.messages} answers`);
+        for (const m of Object.values(p.models).sort((a, b) => b.cost - a.cost)) {
+          console.log(`             ${money(m.cost).padStart(9)}  ${m.model}${usage.priced(m.model) ? '' : ' (no price for this model)'}`);
+        }
+      }
+      break;
+    }
     case 'config': {
       const config = await import('../src/config.js');
       if (words.length >= 1 && rest.length >= 2) config.set(words[0], words.slice(1).join(' '));
diff --git a/dashboard/index.html b/dashboard/index.html
index c74c210..1599de4 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -218,6 +218,12 @@ main { padding: 12px; }
 .idea-table tbody tr:hover, .idea-table tbody tr:focus-visible { background: var(--surface-2); outline: none; }
 .sort-mark { font-size: 10px; margin-left: 3px; }
 
+/* usage view */
+.usage-total { margin: 0 0 8px; color: var(--text-dim); font-size: 13px; }
+.usage-path { color: var(--text-muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; max-width: 320px; }
+.usage-bar { display: block; height: 4px; border-radius: 2px; background: var(--accent); margin-top: 3px; }
+.usage-models { white-space: normal; max-width: 260px; color: var(--text-dim); font-size: 12px; }
+
 /* projects view */
 .projects-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; }
 .projects { display: grid; grid-auto-rows: minmax(48px, auto); width: max-content; }
@@ -336,6 +342,7 @@ button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-
       <button type="button" role="tab" data-view="groups" aria-controls="main">Groups</button>
       <button type="button" role="tab" data-view="prompts" aria-controls="main" hidden>Prompts</button>
       <button type="button" role="tab" data-view="memory" aria-controls="main" hidden>Memory</button>
+      <button type="button" role="tab" data-view="usage" aria-controls="main" hidden>Usage</button>
     </nav>
     <label class="parked-toggle">
       <input type="checkbox" id="parked-toggle"> Show parked
@@ -401,7 +408,7 @@ const SEARCH_DEBOUNCE_MS = 300;
 const EMBED_TIMEOUT_MS = 4000;
 const MODELS = ['haiku', 'sonnet', 'opus', 'fable'];
 const OPEN_LANES = ['doing', 'do', 'maybe', 'inbox'];
-const VIEWS = ['board', 'timeline', 'table', 'projects', 'flow', 'matrix', 'groups', 'prompts', 'memory'];
+const VIEWS = ['board', 'timeline', 'table', 'projects', 'flow', 'matrix', 'groups', 'prompts', 'memory', 'usage'];
 const FLOW_STACK = ['dropped', 'skip', 'done', 'doing', 'do', 'maybe', 'inbox']; // bottom to top
 const SIZE_ORDER = ['xs', 's', 'm', 'l', 'xl'];
 const PROMPT_RANGES = [[1, 'Last day'], [7, 'Last 7 days'], [30, 'Last 30 days'], [0, 'All']];
@@ -422,6 +429,7 @@ const state = {
   drag: null, // the card that the user drags on the board: { id, lane }
   prompts: { days: 1, list: null, error: null, filter: '' },
   memory: { overview: null, error: null, project: null, memories: new Map(), histories: new Map() },
+  usage: { days: 30, rows: null, error: null, loading: false, open: new Set() },
 };
 
 let searchTimer = null;
@@ -684,6 +692,7 @@ function renderTabsActive() {
     const view = btn.dataset.view;
     if (view === 'prompts') btn.hidden = !live.prompts;
     if (view === 'memory') btn.hidden = !live.memory;
+    if (view === 'usage') btn.hidden = !live.usage;
     const active = view === state.view;
     btn.classList.toggle('active', active);
     btn.setAttribute('aria-selected', String(active));
@@ -1244,6 +1253,90 @@ function renderRowsChart({ rows, label, count, span, marks, onRow, ariaLabel })
 
 /* ---- prompts tab: the prompt log, one row for each Claude Code session ---- */
 
+/* ---- usage view: tokens, models, and price for each project ---- */
+
+const USAGE_RANGES = [[7, 'Last 7 days'], [30, 'Last 30 days'], [90, 'Last 90 days'], [0, 'All']];
+
+async function loadUsage() {
+  const u = state.usage;
+  u.loading = true;
+  try {
+    u.rows = (await getJson('api/usage')).rows || [];
+    u.error = null;
+  } catch (err) {
+    u.rows = u.rows || [];
+    u.error = err.message;
+  }
+  u.loading = false;
+  render();
+}
+
+const money = (n) => '$' + (n >= 100 ? n.toFixed(0) : n.toFixed(2));
+const tokenCount = (n) => (n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'k' : String(n || 0));
+const shortModel = (m) => String(m).replace(/^claude-/, '');
+const projectLabel = (p) => String(p).split(/[\\/]/).filter(Boolean).pop() || String(p);
+
+/** One row for each project, with its models, the most expensive project first. */
+function usageByProject(rows) {
+  const out = new Map();
+  for (const r of rows) {
+    const p = out.get(r.project) || { project: r.project, cost: 0, input: 0, output: 0, read: 0, write: 0, messages: 0, models: new Map() };
+    p.cost += r.cost || 0;
+    p.input += r.input || 0;
+    p.output += r.output || 0;
+    p.read += r.read || 0;
+    p.write += (r.write5m || 0) + (r.write1h || 0);
+    p.messages += r.messages || 0;
+    const m = p.models.get(r.model) || { model: r.model, cost: 0, output: 0 };
+    m.cost += r.cost || 0;
+    m.output += r.output || 0;
+    p.models.set(r.model, m);
+    out.set(r.project, p);
+  }
+  return [...out.values()].sort((a, b) => b.cost - a.cost);
+}
+
+function renderUsage() {
+  $main.textContent = '';
+  const u = state.usage;
+  const range = el('select', { 'aria-label': 'Time range' }, USAGE_RANGES.map(([days, label]) => el('option', { value: String(days), selected: days === u.days }, label)));
+  range.addEventListener('change', () => { u.days = Number(range.value); render(); });
+  $main.append(el('div', { class: 'view-controls' }, range));
+  if (u.error) { $main.append(el('p', { class: 'load-error' }, u.error)); return; }
+  if (!u.rows) { $main.append(el('p', { class: 'empty-note' }, 'Loading the token use…')); return; }
+
+  const since = u.days ? new Date(Date.now() - u.days * 86400000).toISOString().slice(0, 10) : '';
+  const projects = usageByProject(u.rows.filter((r) => r.day >= since));
+  if (!projects.length) { $main.append(el('p', { class: 'empty-note' }, 'No token use in this time.')); return; }
+  const total = projects.reduce((n, p) => n + p.cost, 0);
+  const machines = new Set(u.rows.map((r) => r.host || r.machine).filter(Boolean));
+  const top = projects[0].cost || 1;
+
+  $main.append(el('p', { class: 'usage-total' },
+    money(total) + ' for ' + projects.length + ' projects' + (machines.size > 1 ? ' on ' + machines.size + ' machines' : '') +
+    '. The price is the price of the API: a Claude subscription does not bill for each token.'));
+
+  const body = el('tbody', {});
+  for (const p of projects) {
+    const models = [...p.models.values()].sort((a, b) => b.cost - a.cost);
+    body.append(el('tr', { title: p.project },
+      el('td', { class: 'col-title' }, projectLabel(p.project), el('div', { class: 'usage-path' }, p.project)),
+      el('td', {}, money(p.cost), el('span', { class: 'usage-bar', style: 'width:' + Math.max(2, Math.round((p.cost / top) * 100)) + '%' })),
+      el('td', { class: 'usage-models' }, models.map((m) => shortModel(m.model) + ' ' + money(m.cost)).join(' · ')),
+      el('td', {}, tokenCount(p.input)),
+      el('td', {}, tokenCount(p.output)),
+      el('td', {}, tokenCount(p.write)),
+      el('td', {}, tokenCount(p.read)),
+      el('td', {}, String(p.messages))));
+  }
+  $main.append(el('div', { class: 'table-wrap' },
+    el('table', { class: 'idea-table' },
+      el('thead', {}, el('tr', {},
+        el('th', {}, 'Project'), el('th', {}, 'Price'), el('th', {}, 'Models'),
+        el('th', {}, 'In'), el('th', {}, 'Out'), el('th', {}, 'Cache write'), el('th', {}, 'Cache read'), el('th', {}, 'Answers'))),
+      body)));
+}
+
 async function loadPrompts() {
   const p = state.prompts;
   p.loading = true;
@@ -1776,12 +1869,14 @@ function render() {
   else if (state.view === 'groups') renderGroups();
   else if (state.view === 'prompts') renderPrompts();
   else if (state.view === 'memory') renderMemory();
+  else if (state.view === 'usage') renderUsage();
   else renderBoard();
   renderDrawer();
-  // The prompt log and the memories load when a tab or a drawer needs them.
+  // The prompt log, the memories, and the token use load when a tab or a drawer needs them.
   const wantsPrompts = state.view === 'prompts' || String(state.openKey).startsWith('SESSION:');
   if (wantsPrompts && !state.prompts.list && !state.prompts.loading) loadPrompts();
   if (state.view === 'memory' && !state.memory.overview && !state.memory.loading) loadMemoryOverview();
+  if (state.view === 'usage' && !state.usage.rows && !state.usage.loading) loadUsage();
 }
 
 function applyHash() {
diff --git a/package.json b/package.json
index a7affc0..3245048 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "ideamine",
-  "version": "0.9.1",
+  "version": "0.10.0",
   "description": "An idea inbox for Claude Code. /idea saves an idea without calling the model; Claude later triages each idea and picks the cheapest model that can build it.",
   "type": "module",
   "bin": {
diff --git a/src/serve.js b/src/serve.js
index 55ddbbd..9044582 100644
--- a/src/serve.js
+++ b/src/serve.js
@@ -36,6 +36,7 @@ const APPLIED_KEPT = 1000; // results of recent changes, so that a change that c
 
 const logPath = () => path.join(store.home(), 'serve.log');
 const promptsPath = () => path.join(store.home(), 'prompts.jsonl');
+const usagePath = () => path.join(store.home(), 'usage.json');
 const appliedPath = () => path.join(store.home(), 'applied.json');
 const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
 
@@ -138,6 +139,7 @@ async function liveData() {
   const db = await archive.fresh();
   const { data, note } = await publish.build(db, { timeoutMs: EMBED_TIMEOUT_MS });
   const { hasClaude } = await import('./claude.js');
+  const { transcriptsDir } = await import('./usage.js');
   const w = watch.readState();
   const synced = sync.enabled();
   return {
@@ -150,6 +152,7 @@ async function liveData() {
       sync: synced ? { url: sync.serverUrl(), offline: archive.offlineReason(), waiting: sync.waiting() } : null,
       prompts: synced || fs.existsSync(promptsPath()),
       memory: synced || !!config.get('memstate_url'),
+      usage: synced || fs.existsSync(transcriptsDir()) || Object.keys(loadUsage().machines).length > 0,
     },
   };
 }
@@ -277,6 +280,57 @@ function listPrompts(days) {
   return loadPrompts().prompts.filter((p) => Date.parse(p.at) >= since).sort((a, b) => a.at.localeCompare(b.at));
 }
 
+/* ---- token use and price ---- */
+
+/** The rows that the machines sent, by machine id. */
+function loadUsage() {
+  try {
+    const data = JSON.parse(fs.readFileSync(usagePath(), 'utf8'));
+    if (data && data.version === 1 && data.machines) return data;
+  } catch {
+    // No machine sent rows yet.
+  }
+  return { version: 1, machines: {} };
+}
+
+/** Keep the rows of one machine. The new rows take the place of the rows from before. */
+function saveUsage({ machine, host, rows }) {
+  if (typeof machine !== 'string' || !machine || !Array.isArray(rows)) throw new Error('a machine id and its rows must come together');
+  const clean = rows
+    .filter((r) => r && /^\d{4}-\d{2}-\d{2}$/.test(String(r.day)) && typeof r.project === 'string' && typeof r.model === 'string')
+    .map((r) => ({
+      day: r.day,
+      project: String(r.project).slice(0, 300),
+      model: String(r.model).slice(0, 60),
+      messages: Number(r.messages) || 0,
+      input: Number(r.input) || 0,
+      output: Number(r.output) || 0,
+      read: Number(r.read) || 0,
+      write5m: Number(r.write5m) || 0,
+      write1h: Number(r.write1h) || 0,
+    }));
+  const data = loadUsage();
+  data.machines[machine.slice(0, 64)] = { host: field(host, 100), at: new Date().toISOString(), rows: clean };
+  store.withLock(() => store.writeAtomic(usagePath(), `${JSON.stringify(data)}\n`));
+  return { rows: clean.length, machines: Object.keys(data.machines).length };
+}
+
+/** Every row of every machine, each one with the machine that sent it. */
+function usageRows() {
+  const out = [];
+  for (const [machine, m] of Object.entries(loadUsage().machines)) {
+    for (const r of m.rows || []) out.push({ ...r, machine, host: m.host });
+  }
+  return out;
+}
+
+/** The rows of this machine, read from the transcripts of Claude Code. */
+async function localUsage() {
+  const usage = await import('./usage.js');
+  const { rows } = usage.scan();
+  return rows.map((r) => ({ ...r, machine: 'here', host: os.hostname() }));
+}
+
 /** A read from the memstated daemon at memstate_url. */
 async function memstate(route, body) {
   const base = config.get('memstate_url');
@@ -421,6 +475,14 @@ async function serverRoute(req, res, route, query) {
     const { ops } = await readJson(req);
     return send(res, 200, { ok: true, results: applyChanges(ops), db: store.load() });
   }
+  if (route === 'GET /api/usage') {
+    const usage = await import('./usage.js');
+    const sent = usageRows();
+    const rows = sent.length ? sent : await localUsage();
+    // The price comes from the server, so that one price list serves every page.
+    return send(res, 200, { ok: true, rows: rows.map((r) => ({ ...r, cost: usage.cost(r) })) });
+  }
+  if (route === 'POST /api/usage') return send(res, 200, { ok: true, ...saveUsage(await readJson(req)) });
   if (route === 'POST /api/prompts') {
     const { prompts } = await readJson(req);
     return send(res, 200, { ok: true, ...addPrompts(prompts) });
@@ -449,7 +511,7 @@ async function handle(req, res, ctx) {
     return;
   }
   if (sync.enabled()) {
-    if (route === 'GET /api/prompts' || route.startsWith('GET /api/memory/')) return forward(res, `${pathname.slice(1)}${search}`);
+    if (route === 'GET /api/prompts' || route === 'GET /api/usage' || route.startsWith('GET /api/memory/')) return forward(res, `${pathname.slice(1)}${search}`);
   } else {
     try {
       if ((await serverRoute(req, res, route, searchParams)) !== false) return;
diff --git a/src/sync.js b/src/sync.js
index 4dc2acb..fbe841d 100644
--- a/src/sync.js
+++ b/src/sync.js
@@ -22,6 +22,8 @@ const WAIT_AFTER_ERROR_MS = 2 * 60 * 1000; // after a failed sync, before kick()
 const LOCK_STALE_MS = 10 * 60 * 1000;
 const OPS_PER_REQUEST = 100;
 const PROMPT_BYTES_PER_REQUEST = 256 * 1024;
+const USAGE_EVERY_MS = 10 * 60 * 1000; // the token use goes to the server at most this often
+const USAGE_DAYS = 365; // the rows that go to the server, so that one request stays small
 const MAX_PROMPT_CHARS = 100 * 1000; // a longer paste is cut, so that each prompt fits in a request
 
 const outboxPath = () => path.join(home(), 'outbox.jsonl');
@@ -151,13 +153,40 @@ async function sendPrompts({ timeoutMs }) {
   await sendPart();
 }
 
+/** The id of this machine, so that the server keeps the token use of each machine apart. */
+export function machineId() {
+  const state = readState();
+  if (state.machine) return state.machine;
+  const machine = crypto.randomUUID();
+  writeState({ machine });
+  return machine;
+}
+
+/**
+ * Send the token use of this machine. The server keeps one set of rows for each machine, so the new
+ * rows take the place of the rows from before. The scan reads only the new bytes of the transcripts.
+ */
+async function sendUsage({ timeoutMs }) {
+  const state = readState();
+  if (Date.now() - Date.parse(state.usageAt || 0) < USAGE_EVERY_MS) return;
+  const usage = await import('./usage.js');
+  const rows = usage.since(usage.scan().rows, USAGE_DAYS);
+  if (!rows.length) return;
+  await request('api/usage', { body: { machine: machineId(), host: os.hostname(), rows }, timeoutMs });
+  writeState({ usageAt: new Date().toISOString() });
+}
+
 /**
  * Bring the copy here up to date: send the changes that wait, then take the archive of the server.
- * With `prompts`, send the prompts that wait too. Throws SyncError when the server does not answer.
+ * With `prompts`, send the prompts and the token use too. Throws SyncError when the server does not
+ * answer.
  */
 export async function pull({ timeoutMs = 10000, prompts = false } = {}) {
   let { db } = await sendOps({ timeoutMs });
-  if (prompts) await sendPrompts({ timeoutMs });
+  if (prompts) {
+    await sendPrompts({ timeoutMs });
+    await sendUsage({ timeoutMs });
+  }
   db ||= (await request('api/db', { timeoutMs })).db;
   keep(db);
 }
diff --git a/src/usage.js b/src/usage.js
new file mode 100644
index 0000000..7938436
--- /dev/null
+++ b/src/usage.js
@@ -0,0 +1,193 @@
+// Token use and price for each project. Claude Code writes one JSONL transcript for each session in
+// ~/.claude/projects. Each answer of the model carries its model name and its token counts, and each
+// line carries the folder of the session. This module adds those numbers up for each day, project,
+// and model, and it puts a price on them.
+//
+// The scan is incremental: usage-scan.json keeps the size of each transcript that it read, so a new
+// scan reads only the new bytes. `ideamine usage` prints the table, and the dashboard shows it.
+//
+// The price is the price of the API. A Claude subscription does not bill for each token, so for a
+// subscription the number is what the same work would cost through the API.
+
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import * as store from './store.js';
+
+/**
+ * Price in dollars for each million tokens (2026-09-22). `read` is the price of a cache read.
+ * A cache write costs 1.25 times the input price with the 5-minute lifetime, and 2 times with the
+ * 1-hour lifetime.
+ */
+export const PRICES = {
+  'claude-fable-5-1': { input: 10, output: 50, read: 0.25 },
+  'claude-mythos-5-1': { input: 10, output: 50, read: 0.25 },
+  'claude-fable-5': { input: 10, output: 50, read: 1 },
+  'claude-mythos-5': { input: 10, output: 50, read: 1 },
+  'claude-opus-5-5': { input: 4, output: 20, read: 0.2 },
+  'claude-opus-5': { input: 5, output: 25, read: 0.5 },
+  'claude-opus-4-8': { input: 5, output: 25, read: 0.5 },
+  'claude-opus-4-7': { input: 5, output: 25, read: 0.5 },
+  'claude-opus-4-6': { input: 5, output: 25, read: 0.5 },
+  'claude-sonnet-5': { input: 2, output: 10, read: 0.2 },
+  'claude-sonnet-4-6': { input: 3, output: 15, read: 0.3 },
+  'claude-haiku-4-5': { input: 1, output: 5, read: 0.1 },
+};
+
+const MILLION = 1e6;
+const WRITE_5M = 1.25; // times the input price
+const WRITE_1H = 2;
+
+const statePath = () => path.join(store.home(), 'usage-scan.json');
+
+/** The name of the model without the date at its end, for example claude-haiku-4-5-20251001. */
+export function modelName(raw) {
+  return String(raw || '')
+    .trim()
+    .replace(/-\d{8}$/, '');
+}
+
+/** The price of one row in dollars. An unknown model costs 0, and `priced` says so. */
+export function cost(row) {
+  const p = PRICES[modelName(row.model)];
+  if (!p) return 0;
+  const write = (row.write5m || 0) * p.input * WRITE_5M + (row.write1h || 0) * p.input * WRITE_1H;
+  return ((row.input || 0) * p.input + (row.output || 0) * p.output + (row.read || 0) * p.read + write) / MILLION;
+}
+
+/** True when the price list knows this model. */
+export const priced = (model) => Boolean(PRICES[modelName(model)]);
+
+const rowKey = (r) => `${r.day}|${r.project}|${r.model}`;
+const emptyRow = (day, project, model) => ({ day, project, model, messages: 0, input: 0, output: 0, read: 0, write5m: 0, write1h: 0 });
+
+/**
+ * Add the numbers of one transcript line to `rows`. A line without token counts, or from a model
+ * that answered nothing, changes nothing.
+ */
+export function addLine(rows, entry, seen) {
+  const usage = entry?.message?.usage;
+  if (!usage || entry.type !== 'assistant') return false;
+  const model = modelName(entry.message.model);
+  if (!model || model === '<synthetic>') return false;
+  const id = entry.message.id;
+  if (id && seen) {
+    if (seen.has(id)) return false; // the same answer twice in one file
+    seen.add(id);
+  }
+  const day = String(entry.timestamp || '').slice(0, 10);
+  if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return false;
+  const project = entry.cwd || 'unknown';
+  const key = `${day}|${project}|${model}`;
+  const row = rows.get(key) || emptyRow(day, project, model);
+  const created = usage.cache_creation || {};
+  row.messages += 1;
+  row.input += usage.input_tokens || 0;
+  row.output += usage.output_tokens || 0;
+  row.read += usage.cache_read_input_tokens || 0;
+  // Older transcripts have only the total of the cache writes. Those count as the 5-minute price.
+  const w1h = created.ephemeral_1h_input_tokens || 0;
+  const w5m = created.ephemeral_5m_input_tokens ?? Math.max(0, (usage.cache_creation_input_tokens || 0) - w1h);
+  row.write5m += w5m;
+  row.write1h += w1h;
+  rows.set(key, row);
+  return true;
+}
+
+/** Read one transcript from byte `from` to its end, and add its lines to `rows`. */
+function readFile(file, from, rows) {
+  const fd = fs.openSync(file, 'r');
+  try {
+    const size = fs.fstatSync(fd).size;
+    if (size <= from) return size;
+    const buf = Buffer.alloc(size - from);
+    fs.readSync(fd, buf, 0, buf.length, from);
+    const text = buf.toString('utf8');
+    // A read that starts inside a line drops that half line, and the next scan gets the whole file.
+    const seen = new Set();
+    for (const line of text.split('\n')) {
+      if (!line.startsWith('{')) continue;
+      try {
+        addLine(rows, JSON.parse(line), seen);
+      } catch {
+        // A line that a crash cut in half, or a line that is not JSON.
+      }
+    }
+    return size;
+  } finally {
+    fs.closeSync(fd);
+  }
+}
+
+function readState() {
+  try {
+    const state = JSON.parse(fs.readFileSync(statePath(), 'utf8'));
+    if (state && state.version === 1) return state;
+  } catch {
+    // No scan yet, or a file that another version wrote.
+  }
+  return { version: 1, files: {}, rows: [] };
+}
+
+function writeState(state) {
+  fs.mkdirSync(store.home(), { recursive: true });
+  store.writeAtomic(statePath(), `${JSON.stringify(state)}\n`);
+}
+
+/** The folder with the transcripts of Claude Code, the same one that the prompt log reads. */
+export const transcriptsDir = () => path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects');
+
+/**
+ * Read the new bytes of each transcript, add them to the numbers from before, and save the state.
+ * Resolves to { rows, files, read }: the rows for each day, project, and model, how many transcripts
+ * there are, and how many of them had new bytes.
+ */
+export function scan({ dir = transcriptsDir() } = {}) {
+  const state = readState();
+  const rows = new Map(state.rows.map((r) => [rowKey(r), r]));
+  let files = [];
+  try {
+    files = fs.readdirSync(dir, { recursive: true }).filter((f) => String(f).endsWith('.jsonl'));
+  } catch {
+    return { rows: [...rows.values()], files: 0, read: 0 };
+  }
+  let read = 0;
+  for (const name of files) {
+    const file = path.join(dir, String(name));
+    const from = state.files[String(name)] || 0;
+    let size;
+    try {
+      size = readFile(file, from, rows);
+    } catch {
+      continue; // a file that another process writes, or that is gone
+    }
+    if (size !== from) read += 1;
+    state.files[String(name)] = size;
+  }
+  state.rows = [...rows.values()];
+  writeState(state);
+  return { rows: state.rows, files: files.length, read };
+}
+
+/** The rows of the last `days` days, newest day first. 0 days means every day. */
+export function since(rows, days) {
+  if (!days) return rows;
+  const day = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
+  return rows.filter((r) => r.day >= day);
+}
+
+/** One row for each project: the tokens, the price, and the price for each model. */
+export function byProject(rows) {
+  const out = new Map();
+  for (const r of rows) {
+    const p = out.get(r.project) || { project: r.project, messages: 0, input: 0, output: 0, read: 0, write5m: 0, write1h: 0, cost: 0, models: {} };
+    for (const k of ['messages', 'input', 'output', 'read', 'write5m', 'write1h']) p[k] += r[k] || 0;
+    const m = p.models[r.model] || { model: r.model, messages: 0, input: 0, output: 0, read: 0, write5m: 0, write1h: 0, cost: 0 };
+    for (const k of ['messages', 'input', 'output', 'read', 'write5m', 'write1h']) m[k] += r[k] || 0;
+    m.cost += cost(r);
+    p.models[r.model] = m;
+    p.cost += cost(r);
+    out.set(r.project, p);
+  }
+  return [...out.values()].sort((a, b) => b.cost - a.cost);
+}
diff --git a/tests/dashboard.test.js b/tests/dashboard.test.js
index ba21f4f..62a05e1 100644
--- a/tests/dashboard.test.js
+++ b/tests/dashboard.test.js
@@ -11,7 +11,7 @@ test('the page script compiles, and the tabs match the views the script knows',
   const tabs = [...page.matchAll(/data-view="([a-z]+)"/g)].map((m) => m[1]);
   const views = JSON.parse(/const VIEWS = (\[[^\]]*\]);/.exec(script)[1].replace(/'/g, '"'));
   assert.deepEqual(tabs, views);
-  assert.deepEqual(tabs, ['board', 'timeline', 'table', 'projects', 'flow', 'matrix', 'groups', 'prompts', 'memory']);
+  assert.deepEqual(tabs, ['board', 'timeline', 'table', 'projects', 'flow', 'matrix', 'groups', 'prompts', 'memory', 'usage']);
   for (const view of views) assert.match(script, new RegExp(`state\\.view === '${view}'|else renderBoard`), `render() handles ${view}`);
 });
 
diff --git a/tests/serve.test.js b/tests/serve.test.js
index cbe86c7..ec74075 100644
--- a/tests/serve.test.js
+++ b/tests/serve.test.js
@@ -138,6 +138,7 @@ test('the page comes with live data, and no other page can frame it', async () =
     sync: null,
     prompts: false,
     memory: false,
+    usage: false, // no transcripts of Claude Code in the test home
   });
   assert.match(data.live.watch.status, /^ideamine watch: off/);
 });
@@ -373,3 +374,20 @@ test('a drag to the Do, Maybe, or Skip lane changes only the verdict', async ()
   assert.equal((await api('verdict', { id: 1, verdict: 'nope' })).error, 'verdict must be one of do/maybe/skip');
   assert.equal((await api('verdict', { id: 9, verdict: 'do' })).error, 'no idea #9');
 });
+
+test('the server keeps the token use of each machine and puts a price on it', async () => {
+  const row = { day: '2026-09-22', project: 'C:\work', model: 'claude-opus-5', messages: 1, input: 0, output: 1000000, read: 0, write5m: 0, write1h: 0 };
+  assert.equal((await api('usage', { machine: 'm1', host: 'pc1', rows: [row] })).rows, 1);
+  const first = await (await fetch(new URL('api/usage', url))).json();
+  assert.equal(first.rows.length, 1);
+  assert.equal(first.rows[0].cost, 25); // 1M output tokens on Opus 5
+  assert.equal(first.rows[0].host, 'pc1');
+
+  // The same machine sends again: the new rows take the place of the rows from before.
+  assert.equal((await api('usage', { machine: 'm1', host: 'pc1', rows: [row, { ...row, day: '2026-09-21' }] })).rows, 2);
+  assert.equal((await api('usage', { machine: 'm2', host: 'pc2', rows: [row] })).machines, 2);
+  assert.equal((await (await fetch(new URL('api/usage', url))).json()).rows.length, 3);
+
+  assert.equal((await api('usage', { rows: [] })).error, 'a machine id and its rows must come together');
+  assert.equal((await api('usage', { machine: 'm3', rows: [{ day: 'not a day' }] })).rows, 0); // a bad row is left out
+});
diff --git a/tests/usage.test.js b/tests/usage.test.js
new file mode 100644
index 0000000..e71eccb
--- /dev/null
+++ b/tests/usage.test.js
@@ -0,0 +1,123 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, test } from 'node:test';
+import fc from 'fast-check';
+import * as usage from '../src/usage.js';
+
+let dir; // the folder with the transcripts of the test
+
+beforeEach(() => {
+  process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-usage-'));
+  dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-transcripts-'));
+});
+
+afterEach(() => {
+  fs.rmSync(dir, { recursive: true, force: true });
+});
+
+/** One answer of the model, as Claude Code writes it into a transcript. */
+function answer({ id = 'msg_1', model = 'claude-opus-5', cwd = 'C:\\work', at = '2026-09-22T10:00:00.000Z', input = 0, output = 0, read = 0, w5m = 0, w1h = 0 } = {}) {
+  return JSON.stringify({
+    type: 'assistant',
+    cwd,
+    sessionId: 's1',
+    timestamp: at,
+    message: {
+      id,
+      model,
+      usage: {
+        input_tokens: input,
+        output_tokens: output,
+        cache_read_input_tokens: read,
+        cache_creation_input_tokens: w5m + w1h,
+        cache_creation: { ephemeral_5m_input_tokens: w5m, ephemeral_1h_input_tokens: w1h },
+      },
+    },
+  });
+}
+
+const write = (name, lines) => fs.writeFileSync(path.join(dir, name), `${lines.join('\n')}\n`);
+
+test('the price of a row follows the price list', () => {
+  // 1M input, 1M output, 1M cache read, 1M cache write with the 5-minute lifetime, on Opus 5.
+  const row = { model: 'claude-opus-5', input: 1e6, output: 1e6, read: 1e6, write5m: 1e6, write1h: 0 };
+  assert.equal(usage.cost(row), 5 + 25 + 0.5 + 5 * 1.25);
+  // The 1-hour lifetime costs 2 times the input price.
+  assert.equal(usage.cost({ model: 'claude-opus-5', write1h: 1e6 }), 10);
+  // A date at the end of the name names the same model.
+  assert.equal(usage.cost({ model: 'claude-haiku-4-5-20251001', output: 1e6 }), 5);
+  // An unknown model costs nothing, and the page can say so.
+  assert.equal(usage.cost({ model: 'gpt-9', output: 1e6 }), 0);
+  assert.equal(usage.priced('gpt-9'), false);
+  assert.equal(usage.priced('claude-opus-5'), true);
+});
+
+test('the price of a sum is the sum of the prices, for every model', () => {
+  const models = fc.constantFrom(...Object.keys(usage.PRICES));
+  const count = fc.integer({ min: 0, max: 5_000_000 });
+  fc.assert(
+    fc.property(models, count, count, count, count, count, (model, input, output, read, write5m, write1h) => {
+      const one = { model, input, output, read, write5m, write1h };
+      const parts = [
+        { model, input, output: 0, read: 0, write5m: 0, write1h: 0 },
+        { model, input: 0, output, read: 0, write5m: 0, write1h: 0 },
+        { model, input: 0, output: 0, read, write5m: 0, write1h: 0 },
+        { model, input: 0, output: 0, read: 0, write5m, write1h },
+      ];
+      const sum = parts.reduce((n, p) => n + usage.cost(p), 0);
+      assert.ok(Math.abs(usage.cost(one) - sum) < 1e-9);
+    }),
+  );
+});
+
+test('a scan adds the tokens up for each day, project, and model', () => {
+  write('a.jsonl', [
+    answer({ id: 'm1', output: 100, read: 1000 }),
+    answer({ id: 'm1', output: 100, read: 1000 }), // the same answer twice: it counts once
+    answer({ id: 'm2', model: 'claude-sonnet-5', output: 50 }),
+    answer({ id: 'm3', at: '2026-09-21T10:00:00.000Z', output: 7 }),
+    answer({ id: 'm4', cwd: 'C:\\other', output: 3 }),
+    '{"type":"user","message":{"content":"no tokens here"}}',
+    'half a line that a crash cut',
+  ]);
+  const { rows, files, read } = usage.scan({ dir });
+  assert.deepEqual([files, read], [1, 1]);
+  const find = (day, project, model) => rows.find((r) => r.day === day && r.project === project && r.model === model);
+  assert.deepEqual(
+    [find('2026-09-22', 'C:\\work', 'claude-opus-5').output, find('2026-09-22', 'C:\\work', 'claude-opus-5').messages],
+    [100, 1],
+  );
+  assert.equal(find('2026-09-22', 'C:\\work', 'claude-sonnet-5').output, 50);
+  assert.equal(find('2026-09-21', 'C:\\work', 'claude-opus-5').output, 7);
+  assert.equal(find('2026-09-22', 'C:\\other', 'claude-opus-5').output, 3);
+  assert.equal(rows.length, 4);
+});
+
+test('the next scan reads only the new lines', () => {
+  write('a.jsonl', [answer({ id: 'm1', output: 10 })]);
+  usage.scan({ dir });
+  assert.equal(usage.scan({ dir }).read, 0); // nothing new: no file is read again
+  fs.appendFileSync(path.join(dir, 'a.jsonl'), `${answer({ id: 'm2', output: 5 })}\n`);
+  const second = usage.scan({ dir });
+  assert.equal(second.read, 1);
+  const row = second.rows.find((r) => r.model === 'claude-opus-5');
+  assert.deepEqual([row.output, row.messages], [15, 2]); // the tokens of both scans
+});
+
+test('byProject and since group the rows the way the page shows them', () => {
+  const today = new Date().toISOString().slice(0, 10);
+  const old = new Date(Date.now() - 40 * 86400000).toISOString().slice(0, 10);
+  const rows = [
+    { day: today, project: 'p1', model: 'claude-opus-5', output: 1e6, messages: 2, input: 0, read: 0, write5m: 0, write1h: 0 },
+    { day: today, project: 'p1', model: 'claude-sonnet-5', output: 1e6, messages: 1, input: 0, read: 0, write5m: 0, write1h: 0 },
+    { day: old, project: 'p2', model: 'claude-opus-5', output: 2e6, messages: 5, input: 0, read: 0, write5m: 0, write1h: 0 },
+  ];
+  assert.equal(usage.since(rows, 30).length, 2);
+  assert.equal(usage.since(rows, 0).length, 3);
+  const [first, second] = usage.byProject(rows);
+  assert.deepEqual([first.project, first.cost], ['p2', 50]); // the most expensive project first
+  assert.deepEqual([second.project, second.cost, second.messages], ['p1', 35, 3]);
+  assert.deepEqual(Object.keys(second.models).sort(), ['claude-opus-5', 'claude-sonnet-5']);
+});