@unpolarize/code-sessions 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -135,11 +135,12 @@ Commands:
135
135
  install-hooks Install Claude Code hooks that feed the daemon
136
136
  install-skills Install the cs-label-session skill into agents [--agent claude|codex|grok|all]
137
137
  hook (internal) forward a hook payload from stdin to the daemon
138
- backfill Import existing transcripts into the store [--agent claude|grok|codex|all]
138
+ backfill Import existing transcripts into the store [--agent claude|grok|codex|codebuild|all]
139
139
  reindex (Re)derive insights for stored sessions [--since YYYY-MM]
140
140
  export Export stored sessions as OTLP to a collector [--since YYYY-MM]
141
141
  index (Re)build the internal SQLite index from the git store
142
142
  query List recent sessions from the index [--limit N] [--agent X]
143
+ usage Aggregated token/cost usage (totals/by-agent/by-day) [--json]
143
144
  search Full-text search session turns <text> [--limit N]
144
145
  fork Fork a session at a turn ("git for sessions") <session-id> --at N [--id X]
145
146
  analytics Compute MVP-2 rollups + digest into analytics/
@@ -1761,8 +1762,144 @@ function parseCodexSession(info, host) {
1761
1762
  return { host, sessionId, agent: "codex", turns, meta };
1762
1763
  }
1763
1764
 
1765
+ // src/adapters/codebuild.ts
1766
+ import { existsSync as existsSync12, readdirSync as readdirSync4, readFileSync as readFileSync10, statSync as statSync2 } from "fs";
1767
+ import { homedir as homedir4 } from "os";
1768
+ import { join as join8 } from "path";
1769
+ import {
1770
+ SCHEMA_VERSIONS as SCHEMA_VERSIONS5
1771
+ } from "@unpolarize/code-sessions-schema";
1772
+ function codebuildSessionsRoot() {
1773
+ return join8(homedir4(), ".codebuild", "sessions");
1774
+ }
1775
+ function discoverCodebuildSessions(root = codebuildSessionsRoot()) {
1776
+ if (!existsSync12(root)) return [];
1777
+ let files;
1778
+ try {
1779
+ files = readdirSync4(root).filter((f) => f.endsWith(".jsonl"));
1780
+ } catch {
1781
+ return [];
1782
+ }
1783
+ return files.map((f) => ({ sessionId: f.replace(/\.jsonl$/, ""), path: join8(root, f) }));
1784
+ }
1785
+ function backendToAgent(backend) {
1786
+ if (backend === "claude") return "claude-code";
1787
+ if (backend === "grok") return "grok";
1788
+ if (backend === "codex") return "codex";
1789
+ return "unknown";
1790
+ }
1791
+ function parseCodebuildSession(info, host) {
1792
+ let raw = "";
1793
+ try {
1794
+ raw = readFileSync10(info.path, "utf8");
1795
+ } catch {
1796
+ return null;
1797
+ }
1798
+ const lines = raw.split("\n").filter((l) => l.trim().length > 0);
1799
+ const baseMs = statMtime2(info.path);
1800
+ let agent = "unknown";
1801
+ let sessionId = info.sessionId;
1802
+ let title;
1803
+ let cwd;
1804
+ const turns = [];
1805
+ let pending = null;
1806
+ let idx = 0;
1807
+ const flush = () => {
1808
+ if (!pending) return;
1809
+ const p = pending;
1810
+ pending = null;
1811
+ const turn = mkTurn2(sessionId, host, agent, idx++, baseMs, "assistant", p.text, p.tools);
1812
+ turn.usage = {
1813
+ input_tokens: p.input_tokens,
1814
+ output_tokens: p.output_tokens,
1815
+ cache_read_tokens: p.cache_read_tokens,
1816
+ cache_write_tokens: 0
1817
+ };
1818
+ if (p.cost_usd > 0) turn.telemetry = { cost_usd: p.cost_usd };
1819
+ turns.push(turn);
1820
+ };
1821
+ const ensurePending = () => {
1822
+ if (!pending) pending = { text: "", tools: [], input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cost_usd: 0 };
1823
+ return pending;
1824
+ };
1825
+ for (const line of lines) {
1826
+ let ev;
1827
+ try {
1828
+ ev = JSON.parse(line);
1829
+ } catch {
1830
+ continue;
1831
+ }
1832
+ if (ev.type === "meta" && ev.meta) {
1833
+ agent = backendToAgent(ev.meta.backend);
1834
+ if (typeof ev.meta.id === "string") sessionId = ev.meta.id;
1835
+ if (typeof ev.meta.title === "string") title = ev.meta.title;
1836
+ if (typeof ev.meta.cwd === "string") cwd = ev.meta.cwd;
1837
+ continue;
1838
+ }
1839
+ if (ev.type === "user" && typeof ev.text === "string") {
1840
+ flush();
1841
+ turns.push(mkTurn2(sessionId, host, agent, idx++, baseMs, "user", ev.text, []));
1842
+ continue;
1843
+ }
1844
+ if (ev.type === "update" && ev.update) {
1845
+ const u = ev.update;
1846
+ if (u.kind === "agent_message_chunk") {
1847
+ const t = u.content?.text;
1848
+ if (typeof t === "string") ensurePending().text += t;
1849
+ } else if (u.kind === "tool_call" && u.toolCall) {
1850
+ const p = ensurePending();
1851
+ p.tools.push({ name: String(u.toolCall.title ?? u.toolCall.kind ?? "tool"), input: u.toolCall.rawInput });
1852
+ } else if (u.kind === "usage" && u.usage) {
1853
+ const p = ensurePending();
1854
+ p.input_tokens += Number(u.usage.inputTokens) || 0;
1855
+ p.output_tokens += Number(u.usage.outputTokens) || 0;
1856
+ p.cache_read_tokens += Number(u.usage.cacheReadTokens) || 0;
1857
+ } else if (u.kind === "result" && u.usage) {
1858
+ const p = ensurePending();
1859
+ p.input_tokens += Number(u.usage.inputTokens) || 0;
1860
+ p.output_tokens += Number(u.usage.outputTokens) || 0;
1861
+ p.cost_usd += Number(u.usage.costUsd) || 0;
1862
+ flush();
1863
+ }
1864
+ }
1865
+ }
1866
+ flush();
1867
+ if (turns.length === 0) return null;
1868
+ const meta = {
1869
+ session_id: sessionId,
1870
+ started_at: turns[0].ts,
1871
+ ended_at: turns[turns.length - 1].ts
1872
+ };
1873
+ if (cwd) meta.project_path = cwd;
1874
+ if (title) meta.title = title;
1875
+ return { host, sessionId, agent, turns, meta };
1876
+ }
1877
+ function statMtime2(p) {
1878
+ try {
1879
+ return statSync2(p).mtimeMs;
1880
+ } catch {
1881
+ return Date.parse("2020-01-01T00:00:00Z");
1882
+ }
1883
+ }
1884
+ function mkTurn2(sessionId, host, agent, index, baseMs, role, text, tool_calls) {
1885
+ return {
1886
+ schema: SCHEMA_VERSIONS5.turn,
1887
+ session_id: sessionId,
1888
+ host,
1889
+ agent,
1890
+ turn_index: index,
1891
+ ts: new Date(baseMs + index * 1e3).toISOString(),
1892
+ role,
1893
+ text,
1894
+ tool_calls,
1895
+ usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
1896
+ scrubbed: false,
1897
+ raw_ref: null
1898
+ };
1899
+ }
1900
+
1764
1901
  // src/adapters/import.ts
1765
- import { existsSync as existsSync12, mkdirSync as mkdirSync6, readFileSync as readFileSync10, renameSync as renameSync4, writeFileSync as writeFileSync6 } from "fs";
1902
+ import { existsSync as existsSync13, mkdirSync as mkdirSync6, readFileSync as readFileSync11, renameSync as renameSync4, writeFileSync as writeFileSync6 } from "fs";
1766
1903
  import { dirname as dirname5 } from "path";
1767
1904
  var NATIVE_FORMAT = {
1768
1905
  "claude-code": "claude-jsonl",
@@ -1793,9 +1930,9 @@ function writeImportedSession(cfg, s) {
1793
1930
  });
1794
1931
  env.native_ref.format = NATIVE_FORMAT[s.agent] ?? "unknown";
1795
1932
  const envPath = envelopeFile(dir);
1796
- if (existsSync12(envPath)) {
1933
+ if (existsSync13(envPath)) {
1797
1934
  try {
1798
- const prev = JSON.parse(readFileSync10(envPath, "utf8"));
1935
+ const prev = JSON.parse(readFileSync11(envPath, "utf8"));
1799
1936
  if (prev.labels?.length) env.labels = prev.labels;
1800
1937
  } catch {
1801
1938
  }
@@ -1816,6 +1953,9 @@ function toMs(iso) {
1816
1953
  const v = Date.parse(iso);
1817
1954
  return Number.isNaN(v) ? null : v;
1818
1955
  }
1956
+ function round6(n) {
1957
+ return Math.round(n * 1e6) / 1e6;
1958
+ }
1819
1959
  var SessionIndex = class {
1820
1960
  db;
1821
1961
  constructor(path) {
@@ -2017,6 +2157,48 @@ var SessionIndex = class {
2017
2157
  ).all(like, like, limit);
2018
2158
  return rows.map((r) => this.rowToIndex(r));
2019
2159
  }
2160
+ /** Aggregated usage for a Usage panel: totals, by agent, by day, by project, top cost. */
2161
+ usageSummary(opts = {}) {
2162
+ const rows = this.db.prepare(
2163
+ "SELECT session_id, agent, started_at, input_tokens, output_tokens, cost_usd, projects_json, topic, title FROM session"
2164
+ ).all();
2165
+ const totals = { sessions: rows.length, input_tokens: 0, output_tokens: 0, cost_usd: 0 };
2166
+ const byAgent = {};
2167
+ const byDay = {};
2168
+ const byProject = {};
2169
+ const add = (m, key, r) => {
2170
+ const b = m[key] ??= { sessions: 0, input_tokens: 0, output_tokens: 0, cost_usd: 0 };
2171
+ b.sessions++;
2172
+ b.input_tokens += r.input_tokens;
2173
+ b.output_tokens += r.output_tokens;
2174
+ b.cost_usd += r.cost_usd;
2175
+ };
2176
+ for (const r of rows) {
2177
+ totals.input_tokens += r.input_tokens;
2178
+ totals.output_tokens += r.output_tokens;
2179
+ totals.cost_usd += r.cost_usd;
2180
+ add(byAgent, r.agent || "unknown", r);
2181
+ if (r.started_at) add(byDay, new Date(r.started_at).toISOString().slice(0, 10), r);
2182
+ for (const p of safeJson(r.projects_json)) add(byProject, p, r);
2183
+ }
2184
+ totals.cost_usd = round6(totals.cost_usd);
2185
+ for (const m of [byAgent, byDay, byProject]) for (const b of Object.values(m)) b.cost_usd = round6(b.cost_usd);
2186
+ const days = opts.days ?? 30;
2187
+ const recentDays = Object.entries(byDay).sort((a, b) => a[0] < b[0] ? 1 : -1).slice(0, days).map(([day, b]) => ({ day, ...b }));
2188
+ const topByCost = rows.map((r) => ({
2189
+ session_id: r.session_id,
2190
+ agent: r.agent,
2191
+ cost_usd: round6(r.cost_usd),
2192
+ label: r.topic || r.title || r.session_id
2193
+ })).sort((a, b) => b.cost_usd - a.cost_usd).slice(0, opts.topN ?? 10);
2194
+ return {
2195
+ totals,
2196
+ byAgent,
2197
+ byDay: recentDays,
2198
+ byProject,
2199
+ topByCost
2200
+ };
2201
+ }
2020
2202
  stats() {
2021
2203
  const s = this.db.prepare("SELECT COUNT(*) c, COALESCE(SUM(cost_usd),0) cost FROM session").get();
2022
2204
  const t = this.db.prepare("SELECT COUNT(*) c FROM turn").get();
@@ -2040,7 +2222,7 @@ function safeJson(s) {
2040
2222
  }
2041
2223
 
2042
2224
  // src/index_store/sync.ts
2043
- import { existsSync as existsSync13, readFileSync as readFileSync11, statSync as statSync2 } from "fs";
2225
+ import { existsSync as existsSync14, readFileSync as readFileSync12, statSync as statSync3 } from "fs";
2044
2226
  import { safeParseInsights, safeParseSession as safeParseSession2 } from "@unpolarize/code-sessions-schema";
2045
2227
  function syncIndex(cfg, opts = {}) {
2046
2228
  const index = opts.index ?? new SessionIndex(cfg.indexPath);
@@ -2054,8 +2236,8 @@ function syncIndex(cfg, opts = {}) {
2054
2236
  let unchanged = 0;
2055
2237
  for (const ref of refs) {
2056
2238
  const envPath = envelopeFile(ref.dir);
2057
- if (!existsSync13(envPath)) continue;
2058
- const st = statSync2(envPath);
2239
+ if (!existsSync14(envPath)) continue;
2240
+ const st = statSync3(envPath);
2059
2241
  const mtime_ms = Math.floor(st.mtimeMs);
2060
2242
  const size_bytes = st.size;
2061
2243
  seen.add(ref.sessionId);
@@ -2064,7 +2246,7 @@ function syncIndex(cfg, opts = {}) {
2064
2246
  unchanged++;
2065
2247
  continue;
2066
2248
  }
2067
- const parsed = safeParseSession2(JSON.parse(readFileSync11(envPath, "utf8")));
2249
+ const parsed = safeParseSession2(JSON.parse(readFileSync12(envPath, "utf8")));
2068
2250
  if (!parsed.success) continue;
2069
2251
  const env = parsed.data;
2070
2252
  let topic;
@@ -2072,8 +2254,8 @@ function syncIndex(cfg, opts = {}) {
2072
2254
  let projects = [];
2073
2255
  const insPath = insightsFile(ref.dir);
2074
2256
  let insights = void 0;
2075
- if (existsSync13(insPath)) {
2076
- const pi = safeParseInsights(JSON.parse(readFileSync11(insPath, "utf8")));
2257
+ if (existsSync14(insPath)) {
2258
+ const pi = safeParseInsights(JSON.parse(readFileSync12(insPath, "utf8")));
2077
2259
  if (pi.success) {
2078
2260
  insights = pi.data;
2079
2261
  topic = pi.data.topic;
@@ -2146,36 +2328,36 @@ ${buildLabelSkillBody()}
2146
2328
 
2147
2329
  // src/skills/install.ts
2148
2330
  import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync7 } from "fs";
2149
- import { homedir as homedir4 } from "os";
2150
- import { dirname as dirname7, join as join8 } from "path";
2331
+ import { homedir as homedir5 } from "os";
2332
+ import { dirname as dirname7, join as join9 } from "path";
2151
2333
  function targetsFor(agent, home) {
2152
2334
  const out = [];
2153
2335
  const want = (a) => agent === "all" || agent === a;
2154
2336
  if (want("claude")) {
2155
2337
  out.push({
2156
2338
  agent: "claude",
2157
- path: join8(home, ".claude", "skills", "cs-label-session", "SKILL.md"),
2339
+ path: join9(home, ".claude", "skills", "cs-label-session", "SKILL.md"),
2158
2340
  content: buildClaudeSkill()
2159
2341
  });
2160
2342
  }
2161
2343
  if (want("codex")) {
2162
2344
  out.push({
2163
2345
  agent: "codex",
2164
- path: join8(home, ".codex", "prompts", "cs-label-session.md"),
2346
+ path: join9(home, ".codex", "prompts", "cs-label-session.md"),
2165
2347
  content: buildPromptFile()
2166
2348
  });
2167
2349
  }
2168
2350
  if (want("grok")) {
2169
2351
  out.push({
2170
2352
  agent: "grok",
2171
- path: join8(home, ".grok", "prompts", "cs-label-session.md"),
2353
+ path: join9(home, ".grok", "prompts", "cs-label-session.md"),
2172
2354
  content: buildPromptFile()
2173
2355
  });
2174
2356
  }
2175
2357
  return out;
2176
2358
  }
2177
2359
  function installSkills(opts = {}) {
2178
- const home = opts.home ?? homedir4();
2360
+ const home = opts.home ?? homedir5();
2179
2361
  const installed = [];
2180
2362
  for (const t of targetsFor(opts.agent ?? "all", home)) {
2181
2363
  mkdirSync8(dirname7(t.path), { recursive: true });
@@ -2186,7 +2368,7 @@ function installSkills(opts = {}) {
2186
2368
  }
2187
2369
 
2188
2370
  // src/fork.ts
2189
- import { existsSync as existsSync14, readFileSync as readFileSync12 } from "fs";
2371
+ import { existsSync as existsSync15, readFileSync as readFileSync13 } from "fs";
2190
2372
  import { randomUUID } from "crypto";
2191
2373
  import { mkdirSync as mkdirSync9, renameSync as renameSync5, writeFileSync as writeFileSync8 } from "fs";
2192
2374
  import { dirname as dirname8 } from "path";
@@ -2199,8 +2381,8 @@ function locateSession(storeDir, sessionId) {
2199
2381
  }
2200
2382
  function loadEnvelope2(dir) {
2201
2383
  const p = envelopeFile(dir);
2202
- if (!existsSync14(p)) return void 0;
2203
- const parsed = safeParseSession3(JSON.parse(readFileSync12(p, "utf8")));
2384
+ if (!existsSync15(p)) return void 0;
2385
+ const parsed = safeParseSession3(JSON.parse(readFileSync13(p, "utf8")));
2204
2386
  return parsed.success ? parsed.data : void 0;
2205
2387
  }
2206
2388
  function writeJsonAtomic4(path, value) {
@@ -2249,8 +2431,8 @@ function forkSession(cfg, opts) {
2249
2431
  }
2250
2432
 
2251
2433
  // src/commands.ts
2252
- import { existsSync as existsSync15, writeFileSync as writeFileSync9 } from "fs";
2253
- import { join as join9 } from "path";
2434
+ import { existsSync as existsSync16, writeFileSync as writeFileSync9 } from "fs";
2435
+ import { join as join10 } from "path";
2254
2436
  function gitStoreFor(cfg) {
2255
2437
  return new GitStore(cfg.storeDir, {
2256
2438
  ...cfg.git.remote ? { remote: cfg.git.remote } : {},
@@ -2260,13 +2442,13 @@ function gitStoreFor(cfg) {
2260
2442
  function listClaudeTranscripts(projectsDir, maxDepth = 3) {
2261
2443
  const out = [];
2262
2444
  const walk = (dir, depth) => {
2263
- if (depth > maxDepth || !existsSync15(dir)) return;
2445
+ if (depth > maxDepth || !existsSync16(dir)) return;
2264
2446
  for (const e of readEntries(dir)) {
2265
2447
  const name = String(e.name);
2266
2448
  if (e.isFile() && name.endsWith(".jsonl")) {
2267
- out.push({ sessionId: name.replace(/\.jsonl$/, ""), path: join9(dir, name) });
2449
+ out.push({ sessionId: name.replace(/\.jsonl$/, ""), path: join10(dir, name) });
2268
2450
  } else if (e.isDirectory()) {
2269
- walk(join9(dir, name), depth + 1);
2451
+ walk(join10(dir, name), depth + 1);
2270
2452
  }
2271
2453
  }
2272
2454
  };
@@ -2276,8 +2458,8 @@ function listClaudeTranscripts(projectsDir, maxDepth = 3) {
2276
2458
  function cmdInit(cfg) {
2277
2459
  const git = gitStoreFor(cfg);
2278
2460
  git.init();
2279
- const configPath = join9(cfg.storeDir, "config.json");
2280
- if (!existsSync15(configPath)) {
2461
+ const configPath = join10(cfg.storeDir, "config.json");
2462
+ if (!existsSync16(configPath)) {
2281
2463
  writeFileSync9(
2282
2464
  configPath,
2283
2465
  `${JSON.stringify({ insights: cfg.insights, batch: cfg.batch, hygiene: cfg.hygiene }, null, 2)}
@@ -2291,7 +2473,7 @@ function cmdStatus(cfg) {
2291
2473
  const state = new StateStore(cfg.statePath);
2292
2474
  const sessions = Object.keys(state.all());
2293
2475
  const stored = listSessionDirs(cfg.storeDir);
2294
- const socketUp = existsSync15(cfg.socketPath);
2476
+ const socketUp = existsSync16(cfg.socketPath);
2295
2477
  const lines = [
2296
2478
  `store: ${cfg.storeDir}`,
2297
2479
  `host: ${cfg.host}`,
@@ -2346,6 +2528,20 @@ async function cmdBackfill(cfg, opts = {}) {
2346
2528
  turns += t;
2347
2529
  parts.push(`codex: ${n} sessions / ${t} turns`);
2348
2530
  }
2531
+ if (agent === "codebuild" || agent === "all") {
2532
+ const found = discoverCodebuildSessions();
2533
+ let n = 0;
2534
+ let t = 0;
2535
+ for (const info of found) {
2536
+ const imported = parseCodebuildSession(info, cfg.host);
2537
+ if (!imported) continue;
2538
+ t += writeImportedSession(cfg, imported).turns;
2539
+ n++;
2540
+ }
2541
+ sessions += n;
2542
+ turns += t;
2543
+ parts.push(`codebuild: ${n} sessions / ${t} turns`);
2544
+ }
2349
2545
  const git = gitStoreFor(cfg);
2350
2546
  git.init();
2351
2547
  git.commit(`backfill (${agent}): ${sessions} sessions`);
@@ -2360,7 +2556,7 @@ async function cmdReindex(cfg, opts = {}) {
2360
2556
  }
2361
2557
  function cmdInstallHooks(cfg, opts = {}) {
2362
2558
  const home = cfg.claudeProjectsDir.replace(/\/projects\/?$/, "");
2363
- const settingsPath = opts.settingsPath ?? join9(home, "settings.json");
2559
+ const settingsPath = opts.settingsPath ?? join10(home, "settings.json");
2364
2560
  const command = opts.command ?? "code-sessions hook";
2365
2561
  const res = installHooks(settingsPath, command);
2366
2562
  return {
@@ -2370,10 +2566,10 @@ function cmdInstallHooks(cfg, opts = {}) {
2370
2566
  }
2371
2567
  function cmdDoctor(cfg) {
2372
2568
  const checks = [
2373
- ["store dir exists", existsSync15(cfg.storeDir)],
2374
- ["store is git repo", existsSync15(join9(cfg.storeDir, ".git"))],
2375
- ["daemon socket present", existsSync15(cfg.socketPath)],
2376
- ["claude projects dir", existsSync15(cfg.claudeProjectsDir)]
2569
+ ["store dir exists", existsSync16(cfg.storeDir)],
2570
+ ["store is git repo", existsSync16(join10(cfg.storeDir, ".git"))],
2571
+ ["daemon socket present", existsSync16(cfg.socketPath)],
2572
+ ["claude projects dir", existsSync16(cfg.claudeProjectsDir)]
2377
2573
  ];
2378
2574
  const lines = checks.map(([name, ok]) => `${ok ? "\u2713" : "\u2717"} ${name}`);
2379
2575
  const code = checks.every(([, ok]) => ok) ? 0 : 1;
@@ -2409,6 +2605,24 @@ function cmdFork(cfg, opts) {
2409
2605
  return { code: 1, output: `fork failed: ${e instanceof Error ? e.message : String(e)}` };
2410
2606
  }
2411
2607
  }
2608
+ function cmdUsage(cfg, opts = {}) {
2609
+ syncIndex(cfg);
2610
+ const index = new SessionIndex(cfg.indexPath);
2611
+ try {
2612
+ const u = index.usageSummary();
2613
+ if (opts.json) return { code: 0, output: JSON.stringify(u) };
2614
+ const lines = [
2615
+ `# usage \u2014 ${u.totals.sessions} sessions \xB7 ${u.totals.input_tokens.toLocaleString()} in / ${u.totals.output_tokens.toLocaleString()} out \xB7 $${u.totals.cost_usd.toFixed(2)}`,
2616
+ "by agent:",
2617
+ ...Object.entries(u.byAgent).map(([a, b]) => ` ${a.padEnd(12)} ${b.sessions} sess $${b.cost_usd.toFixed(2)}`),
2618
+ "top sessions by cost:",
2619
+ ...u.topByCost.slice(0, 5).map((t) => ` $${t.cost_usd.toFixed(2).padStart(8)} ${t.agent.padEnd(12)} ${t.label.slice(0, 50)}`)
2620
+ ];
2621
+ return { code: 0, output: lines.join("\n") };
2622
+ } finally {
2623
+ index.close();
2624
+ }
2625
+ }
2412
2626
  function cmdInstallSkills(opts = {}) {
2413
2627
  const res = installSkills(opts.agent ? { agent: opts.agent } : {});
2414
2628
  return { code: 0, output: `Installed cs-label-session skill:
@@ -2474,9 +2688,9 @@ async function startDaemon(cfg) {
2474
2688
  }
2475
2689
 
2476
2690
  // src/hooks/shim.ts
2477
- import { existsSync as existsSync16 } from "fs";
2691
+ import { existsSync as existsSync17 } from "fs";
2478
2692
  async function handleHookInput(socketPath, rawInput) {
2479
- if (!existsSync16(socketPath)) return { ok: false, error: "daemon not running" };
2693
+ if (!existsSync17(socketPath)) return { ok: false, error: "daemon not running" };
2480
2694
  let parsed;
2481
2695
  try {
2482
2696
  parsed = JSON.parse(rawInput);
@@ -2560,6 +2774,9 @@ export {
2560
2774
  codexSessionsRoot,
2561
2775
  discoverCodexSessions,
2562
2776
  parseCodexSession,
2777
+ codebuildSessionsRoot,
2778
+ discoverCodebuildSessions,
2779
+ parseCodebuildSession,
2563
2780
  writeImportedSession,
2564
2781
  SessionIndex,
2565
2782
  syncIndex,
@@ -2577,6 +2794,7 @@ export {
2577
2794
  cmdDoctor,
2578
2795
  cmdExport,
2579
2796
  cmdFork,
2797
+ cmdUsage,
2580
2798
  cmdInstallSkills,
2581
2799
  cmdIndex,
2582
2800
  cmdQuery,
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  cmdReindex,
14
14
  cmdSearch,
15
15
  cmdStatus,
16
+ cmdUsage,
16
17
  envelopeFile,
17
18
  handleHookInput,
18
19
  insightsFile,
@@ -22,7 +23,7 @@ import {
22
23
  parseFlags,
23
24
  readStdin,
24
25
  startDaemon
25
- } from "./chunk-HV6FQJPS.js";
26
+ } from "./chunk-ON3CPW4C.js";
26
27
 
27
28
  // src/analytics/command.ts
28
29
  import { mkdirSync, writeFileSync } from "fs";
@@ -256,6 +257,9 @@ async function main(argv) {
256
257
  case "index":
257
258
  emit(cmdIndex(cfg));
258
259
  break;
260
+ case "usage":
261
+ emit(cmdUsage(cfg, { json: flags.json === true }));
262
+ break;
259
263
  case "query":
260
264
  emit(
261
265
  cmdQuery(cfg, {
package/dist/index.js CHANGED
@@ -28,6 +28,8 @@ import {
28
28
  cmdReindex,
29
29
  cmdSearch,
30
30
  cmdStatus,
31
+ cmdUsage,
32
+ codebuildSessionsRoot,
31
33
  codexSessionsRoot,
32
34
  computeEnvelope,
33
35
  defaultConfig,
@@ -35,6 +37,7 @@ import {
35
37
  deriveProjects,
36
38
  deriveSignals,
37
39
  deriveTags,
40
+ discoverCodebuildSessions,
38
41
  discoverCodexSessions,
39
42
  discoverGrokSessions,
40
43
  envelopeFile,
@@ -61,6 +64,7 @@ import {
61
64
  monthOf,
62
65
  ollamaRunner,
63
66
  overridesFromFlags,
67
+ parseCodebuildSession,
64
68
  parseCodexSession,
65
69
  parseFlags,
66
70
  parseGrokSession,
@@ -88,7 +92,7 @@ import {
88
92
  writeBlobFile,
89
93
  writeImportedSession,
90
94
  writeTurnFile
91
- } from "./chunk-HV6FQJPS.js";
95
+ } from "./chunk-ON3CPW4C.js";
92
96
  export {
93
97
  CaptureEngine,
94
98
  DEFAULT_HOOK_EVENTS,
@@ -119,6 +123,8 @@ export {
119
123
  cmdReindex,
120
124
  cmdSearch,
121
125
  cmdStatus,
126
+ cmdUsage,
127
+ codebuildSessionsRoot,
122
128
  codexSessionsRoot,
123
129
  computeEnvelope,
124
130
  defaultConfig,
@@ -126,6 +132,7 @@ export {
126
132
  deriveProjects,
127
133
  deriveSignals,
128
134
  deriveTags,
135
+ discoverCodebuildSessions,
129
136
  discoverCodexSessions,
130
137
  discoverGrokSessions,
131
138
  envelopeFile,
@@ -152,6 +159,7 @@ export {
152
159
  monthOf,
153
160
  ollamaRunner,
154
161
  overridesFromFlags,
162
+ parseCodebuildSession,
155
163
  parseCodexSession,
156
164
  parseFlags,
157
165
  parseGrokSession,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unpolarize/code-sessions",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Headless, event-driven cross-agent session capture agent (daemon + CLI)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,7 +25,7 @@
25
25
  "build": "tsup src/index.ts src/cli.ts --format esm --clean --out-dir dist"
26
26
  },
27
27
  "dependencies": {
28
- "@unpolarize/code-sessions-schema": "^0.2.0",
28
+ "@unpolarize/code-sessions-schema": "^0.3.0",
29
29
  "zod": "^3.23.8"
30
30
  }
31
31
  }
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
4
4
  import { sessionDir, turnFile, envelopeFile } from '../store/paths';
5
5
  import { makeConfig, withTempDir } from '../test/tmp';
6
6
  import { discoverCodexSessions, parseCodexSession } from './codex';
7
+ import { discoverCodebuildSessions, parseCodebuildSession } from './codebuild';
7
8
  import { discoverGrokSessions, parseGrokSession } from './grok';
8
9
  import { writeImportedSession } from './import';
9
10
 
@@ -98,6 +99,41 @@ describe('codex adapter', () => {
98
99
  });
99
100
  });
100
101
 
102
+ function seedCodebuild(root: string): void {
103
+ mkdirSync(root, { recursive: true });
104
+ writeFileSync(
105
+ join(root, 'cb-1.jsonl'),
106
+ [
107
+ '{"type":"meta","meta":{"id":"cb-1","backend":"claude","title":"Plan MVP","cwd":"/Users/x/projects/foo"}}',
108
+ '{"type":"update","update":{"kind":"system_init","backendSessionId":"native-1"}}',
109
+ '{"type":"user","text":"add a feature"}',
110
+ '{"type":"update","update":{"kind":"agent_message_chunk","content":{"type":"text","text":"sure, "}}}',
111
+ '{"type":"update","update":{"kind":"agent_message_chunk","content":{"type":"text","text":"editing"}}}',
112
+ '{"type":"update","update":{"kind":"tool_call","toolCall":{"toolCallId":"t1","title":"Edit","rawInput":{"file_path":"a.ts"}}}}',
113
+ '{"type":"update","update":{"kind":"result","stopReason":"success","usage":{"inputTokens":1000,"outputTokens":40,"costUsd":0.25}}}',
114
+ ].join('\n'),
115
+ );
116
+ }
117
+
118
+ describe('codebuild adapter', () => {
119
+ it('folds CB stream into user/assistant turns with usage', () => {
120
+ withTempDir((root) => {
121
+ seedCodebuild(root);
122
+ const found = discoverCodebuildSessions(root);
123
+ expect(found).toHaveLength(1);
124
+ const imported = parseCodebuildSession(found[0]!, 'test-host')!;
125
+ expect(imported.agent).toBe('claude-code'); // backend=claude -> claude-code
126
+ expect(imported.sessionId).toBe('cb-1');
127
+ expect(imported.meta.title).toBe('Plan MVP');
128
+ expect(imported.turns.map((t) => t.role)).toEqual(['user', 'assistant']);
129
+ expect(imported.turns[1]!.text).toBe('sure, editing'); // chunks concatenated
130
+ expect(imported.turns[1]!.tool_calls[0]).toMatchObject({ name: 'Edit' });
131
+ expect(imported.turns[1]!.usage.input_tokens).toBe(1000);
132
+ expect(imported.turns[1]!.telemetry?.cost_usd).toBe(0.25);
133
+ });
134
+ });
135
+ });
136
+
101
137
  describe('writeImportedSession', () => {
102
138
  it('writes per-turn files + envelope for an imported session', () => {
103
139
  withTempDir((store) => {
@@ -0,0 +1,188 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import {
5
+ SCHEMA_VERSIONS,
6
+ type AgentKind,
7
+ type ClaudeSessionMeta,
8
+ type ToolCall,
9
+ type Turn,
10
+ } from '@unpolarize/code-sessions-schema';
11
+ import type { ImportedSession } from './import';
12
+
13
+ /**
14
+ * Code Build VSCode (CB) adapter. CB stores each chat at
15
+ * ~/.codebuild/sessions/<uuid>.jsonl as a stream of:
16
+ * {type:"meta", meta:{id, backend, title, cwd}}
17
+ * {type:"user", text}
18
+ * {type:"update", update:{kind:"agent_message_chunk", content:{type:"text",text}}}
19
+ * {type:"update", update:{kind:"tool_call", toolCall:{title, rawInput}}}
20
+ * {type:"update", update:{kind:"usage"|"result", usage:{...}}}
21
+ * Events carry no per-line timestamp; synthesized from file mtime + ordinal.
22
+ *
23
+ * Importing CB sessions into the CS store is how "CB context management uses CS":
24
+ * every CB turn becomes discoverable in the shared sessions store + index, and a
25
+ * CB switch/fork can be expressed as a CS forkSession on the persisted session.
26
+ */
27
+
28
+ export function codebuildSessionsRoot(): string {
29
+ return join(homedir(), '.codebuild', 'sessions');
30
+ }
31
+
32
+ export interface CodebuildSessionInfo {
33
+ sessionId: string;
34
+ path: string;
35
+ }
36
+
37
+ export function discoverCodebuildSessions(root = codebuildSessionsRoot()): CodebuildSessionInfo[] {
38
+ if (!existsSync(root)) return [];
39
+ let files: string[];
40
+ try {
41
+ files = readdirSync(root).filter((f) => f.endsWith('.jsonl'));
42
+ } catch {
43
+ return [];
44
+ }
45
+ return files.map((f) => ({ sessionId: f.replace(/\.jsonl$/, ''), path: join(root, f) }));
46
+ }
47
+
48
+ function backendToAgent(backend: unknown): AgentKind {
49
+ if (backend === 'claude') return 'claude-code';
50
+ if (backend === 'grok') return 'grok';
51
+ if (backend === 'codex') return 'codex';
52
+ return 'unknown';
53
+ }
54
+
55
+ interface Pending {
56
+ text: string;
57
+ tools: ToolCall[];
58
+ input_tokens: number;
59
+ output_tokens: number;
60
+ cache_read_tokens: number;
61
+ cost_usd: number;
62
+ }
63
+
64
+ export function parseCodebuildSession(info: CodebuildSessionInfo, host: string): ImportedSession | null {
65
+ let raw = '';
66
+ try {
67
+ raw = readFileSync(info.path, 'utf8');
68
+ } catch {
69
+ return null;
70
+ }
71
+ const lines = raw.split('\n').filter((l) => l.trim().length > 0);
72
+ const baseMs = statMtime(info.path);
73
+
74
+ let agent: AgentKind = 'unknown';
75
+ let sessionId = info.sessionId;
76
+ let title: string | undefined;
77
+ let cwd: string | undefined;
78
+
79
+ const turns: Turn[] = [];
80
+ let pending: Pending | null = null;
81
+ let idx = 0;
82
+
83
+ const flush = (): void => {
84
+ if (!pending) return;
85
+ const p = pending;
86
+ pending = null;
87
+ const turn = mkTurn(sessionId, host, agent, idx++, baseMs, 'assistant', p.text, p.tools);
88
+ turn.usage = {
89
+ input_tokens: p.input_tokens,
90
+ output_tokens: p.output_tokens,
91
+ cache_read_tokens: p.cache_read_tokens,
92
+ cache_write_tokens: 0,
93
+ };
94
+ if (p.cost_usd > 0) turn.telemetry = { cost_usd: p.cost_usd };
95
+ turns.push(turn);
96
+ };
97
+ const ensurePending = (): Pending => {
98
+ if (!pending) pending = { text: '', tools: [], input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cost_usd: 0 };
99
+ return pending;
100
+ };
101
+
102
+ for (const line of lines) {
103
+ let ev: any;
104
+ try {
105
+ ev = JSON.parse(line);
106
+ } catch {
107
+ continue;
108
+ }
109
+ if (ev.type === 'meta' && ev.meta) {
110
+ agent = backendToAgent(ev.meta.backend);
111
+ if (typeof ev.meta.id === 'string') sessionId = ev.meta.id;
112
+ if (typeof ev.meta.title === 'string') title = ev.meta.title;
113
+ if (typeof ev.meta.cwd === 'string') cwd = ev.meta.cwd;
114
+ continue;
115
+ }
116
+ if (ev.type === 'user' && typeof ev.text === 'string') {
117
+ flush();
118
+ turns.push(mkTurn(sessionId, host, agent, idx++, baseMs, 'user', ev.text, []));
119
+ continue;
120
+ }
121
+ if (ev.type === 'update' && ev.update) {
122
+ const u = ev.update;
123
+ if (u.kind === 'agent_message_chunk') {
124
+ const t = u.content?.text;
125
+ if (typeof t === 'string') ensurePending().text += t;
126
+ } else if (u.kind === 'tool_call' && u.toolCall) {
127
+ const p = ensurePending();
128
+ p.tools.push({ name: String(u.toolCall.title ?? u.toolCall.kind ?? 'tool'), input: u.toolCall.rawInput });
129
+ } else if (u.kind === 'usage' && u.usage) {
130
+ const p = ensurePending();
131
+ p.input_tokens += Number(u.usage.inputTokens) || 0;
132
+ p.output_tokens += Number(u.usage.outputTokens) || 0;
133
+ p.cache_read_tokens += Number(u.usage.cacheReadTokens) || 0;
134
+ } else if (u.kind === 'result' && u.usage) {
135
+ const p = ensurePending();
136
+ p.input_tokens += Number(u.usage.inputTokens) || 0;
137
+ p.output_tokens += Number(u.usage.outputTokens) || 0;
138
+ p.cost_usd += Number(u.usage.costUsd) || 0;
139
+ flush();
140
+ }
141
+ }
142
+ }
143
+ flush();
144
+
145
+ if (turns.length === 0) return null;
146
+ const meta: ClaudeSessionMeta = {
147
+ session_id: sessionId,
148
+ started_at: turns[0]!.ts,
149
+ ended_at: turns[turns.length - 1]!.ts,
150
+ };
151
+ if (cwd) meta.project_path = cwd;
152
+ if (title) meta.title = title;
153
+ return { host, sessionId, agent, turns, meta };
154
+ }
155
+
156
+ function statMtime(p: string): number {
157
+ try {
158
+ return statSync(p).mtimeMs;
159
+ } catch {
160
+ return Date.parse('2020-01-01T00:00:00Z');
161
+ }
162
+ }
163
+
164
+ function mkTurn(
165
+ sessionId: string,
166
+ host: string,
167
+ agent: AgentKind,
168
+ index: number,
169
+ baseMs: number,
170
+ role: Turn['role'],
171
+ text: string,
172
+ tool_calls: ToolCall[],
173
+ ): Turn {
174
+ return {
175
+ schema: SCHEMA_VERSIONS.turn,
176
+ session_id: sessionId,
177
+ host,
178
+ agent,
179
+ turn_index: index,
180
+ ts: new Date(baseMs + index * 1000).toISOString(),
181
+ role,
182
+ text,
183
+ tool_calls,
184
+ usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
185
+ scrubbed: false,
186
+ raw_ref: null,
187
+ };
188
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './import';
2
2
  export * from './grok';
3
3
  export * from './codex';
4
+ export * from './codebuild';
package/src/cli.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  cmdReindex,
14
14
  cmdSearch,
15
15
  cmdStatus,
16
+ cmdUsage,
16
17
  startDaemon,
17
18
  type CommandResult,
18
19
  } from './commands';
@@ -54,7 +55,7 @@ export async function main(argv: string[]): Promise<void> {
54
55
  emit(
55
56
  await cmdBackfill(cfg, {
56
57
  ...(typeof flags.projects === 'string' ? { projectsDir: flags.projects } : {}),
57
- ...(typeof flags.agent === 'string' ? { agent: flags.agent as 'claude' | 'grok' | 'codex' | 'all' } : {}),
58
+ ...(typeof flags.agent === 'string' ? { agent: flags.agent as 'claude' | 'grok' | 'codex' | 'codebuild' | 'all' } : {}),
58
59
  }),
59
60
  );
60
61
  break;
@@ -70,6 +71,9 @@ export async function main(argv: string[]): Promise<void> {
70
71
  case 'index':
71
72
  emit(cmdIndex(cfg));
72
73
  break;
74
+ case 'usage':
75
+ emit(cmdUsage(cfg, { json: flags.json === true }));
76
+ break;
73
77
  case 'query':
74
78
  emit(
75
79
  cmdQuery(cfg, {
package/src/cliargs.ts CHANGED
@@ -53,11 +53,12 @@ Commands:
53
53
  install-hooks Install Claude Code hooks that feed the daemon
54
54
  install-skills Install the cs-label-session skill into agents [--agent claude|codex|grok|all]
55
55
  hook (internal) forward a hook payload from stdin to the daemon
56
- backfill Import existing transcripts into the store [--agent claude|grok|codex|all]
56
+ backfill Import existing transcripts into the store [--agent claude|grok|codex|codebuild|all]
57
57
  reindex (Re)derive insights for stored sessions [--since YYYY-MM]
58
58
  export Export stored sessions as OTLP to a collector [--since YYYY-MM]
59
59
  index (Re)build the internal SQLite index from the git store
60
60
  query List recent sessions from the index [--limit N] [--agent X]
61
+ usage Aggregated token/cost usage (totals/by-agent/by-day) [--json]
61
62
  search Full-text search session turns <text> [--limit N]
62
63
  fork Fork a session at a turn ("git for sessions") <session-id> --at N [--id X]
63
64
  analytics Compute MVP-2 rollups + digest into analytics/
package/src/commands.ts CHANGED
@@ -12,6 +12,7 @@ import { installHooks } from './hooks/install';
12
12
  import { exportSession, exportStore } from './telemetry/exporter';
13
13
  import { discoverGrokSessions, parseGrokSession } from './adapters/grok';
14
14
  import { discoverCodexSessions, parseCodexSession } from './adapters/codex';
15
+ import { discoverCodebuildSessions, parseCodebuildSession } from './adapters/codebuild';
15
16
  import { writeImportedSession } from './adapters/import';
16
17
  import { SessionIndex, type SessionIndexRow } from './index_store/db';
17
18
  import { syncIndex } from './index_store/sync';
@@ -82,7 +83,7 @@ export function cmdStatus(cfg: CodeSessionsConfig): CommandResult {
82
83
  return { code: 0, output: lines.join('\n') };
83
84
  }
84
85
 
85
- export type BackfillAgent = 'claude' | 'grok' | 'codex' | 'all';
86
+ export type BackfillAgent = 'claude' | 'grok' | 'codex' | 'codebuild' | 'all';
86
87
 
87
88
  export async function cmdBackfill(
88
89
  cfg: CodeSessionsConfig,
@@ -134,6 +135,21 @@ export async function cmdBackfill(
134
135
  parts.push(`codex: ${n} sessions / ${t} turns`);
135
136
  }
136
137
 
138
+ if (agent === 'codebuild' || agent === 'all') {
139
+ const found = discoverCodebuildSessions();
140
+ let n = 0;
141
+ let t = 0;
142
+ for (const info of found) {
143
+ const imported = parseCodebuildSession(info, cfg.host);
144
+ if (!imported) continue;
145
+ t += writeImportedSession(cfg, imported).turns;
146
+ n++;
147
+ }
148
+ sessions += n;
149
+ turns += t;
150
+ parts.push(`codebuild: ${n} sessions / ${t} turns`);
151
+ }
152
+
137
153
  const git = gitStoreFor(cfg);
138
154
  git.init();
139
155
  git.commit(`backfill (${agent}): ${sessions} sessions`);
@@ -218,6 +234,26 @@ export function cmdFork(
218
234
  }
219
235
  }
220
236
 
237
+ /** Aggregated usage from the CS index (totals/byAgent/byDay/byProject/topByCost). */
238
+ export function cmdUsage(cfg: CodeSessionsConfig, opts: { json?: boolean } = {}): CommandResult {
239
+ syncIndex(cfg); // ensure the index reflects the current store
240
+ const index = new SessionIndex(cfg.indexPath);
241
+ try {
242
+ const u = index.usageSummary();
243
+ if (opts.json) return { code: 0, output: JSON.stringify(u) };
244
+ const lines = [
245
+ `# usage — ${u.totals.sessions} sessions · ${u.totals.input_tokens.toLocaleString()} in / ${u.totals.output_tokens.toLocaleString()} out · $${u.totals.cost_usd.toFixed(2)}`,
246
+ 'by agent:',
247
+ ...Object.entries(u.byAgent).map(([a, b]) => ` ${a.padEnd(12)} ${b.sessions} sess $${b.cost_usd.toFixed(2)}`),
248
+ 'top sessions by cost:',
249
+ ...u.topByCost.slice(0, 5).map((t) => ` $${t.cost_usd.toFixed(2).padStart(8)} ${t.agent.padEnd(12)} ${t.label.slice(0, 50)}`),
250
+ ];
251
+ return { code: 0, output: lines.join('\n') };
252
+ } finally {
253
+ index.close();
254
+ }
255
+ }
256
+
221
257
  export function cmdInstallSkills(opts: { agent?: SkillAgent } = {}): CommandResult {
222
258
  const res = installSkills(opts.agent ? { agent: opts.agent } : {});
223
259
  return { code: 0, output: `Installed cs-label-session skill:\n ${res.installed.join('\n ')}` };
@@ -91,6 +91,31 @@ describe('SessionIndex', () => {
91
91
  }
92
92
  });
93
93
 
94
+ it('aggregates usage by agent, day, project, and top-cost', () => {
95
+ const idx = new SessionIndex(':memory:');
96
+ try {
97
+ idx.upsertSession(env('c1', 'claude-code', { totals: { input_tokens: 100, output_tokens: 20, cost_usd: 2 } }), {
98
+ ...src,
99
+ projects: ['foo'],
100
+ });
101
+ idx.upsertSession(env('g1', 'grok', { totals: { input_tokens: 50, output_tokens: 5, cost_usd: 0.5 } }), {
102
+ ...src,
103
+ source_path: '/s/g.json',
104
+ projects: ['foo', 'bar'],
105
+ });
106
+ const u = idx.usageSummary();
107
+ expect(u.totals.sessions).toBe(2);
108
+ expect(u.totals.cost_usd).toBe(2.5);
109
+ expect(u.byAgent['claude-code']!.cost_usd).toBe(2);
110
+ expect(u.byProject['foo']!.sessions).toBe(2);
111
+ expect(u.byProject['bar']!.sessions).toBe(1);
112
+ expect(u.byDay[0]!.day).toBe('2026-06-20');
113
+ expect(u.topByCost[0]!.session_id).toBe('c1'); // highest cost first
114
+ } finally {
115
+ idx.close();
116
+ }
117
+ });
118
+
94
119
  it('filters by agent and deletes sessions (cascade turns)', () => {
95
120
  const idx = new SessionIndex(':memory:');
96
121
  try {
@@ -44,12 +44,31 @@ export interface SessionIndexRow {
44
44
  source_path: string;
45
45
  }
46
46
 
47
+ export interface UsageBucket {
48
+ sessions: number;
49
+ input_tokens: number;
50
+ output_tokens: number;
51
+ cost_usd: number;
52
+ }
53
+
54
+ export interface UsageSummary {
55
+ totals: { sessions: number; input_tokens: number; output_tokens: number; cost_usd: number };
56
+ byAgent: Record<string, UsageBucket>;
57
+ byDay: Array<{ day: string } & UsageBucket>;
58
+ byProject: Record<string, UsageBucket>;
59
+ topByCost: Array<{ session_id: string; agent: string; cost_usd: number; label: string }>;
60
+ }
61
+
47
62
  function toMs(iso: string | undefined): number | null {
48
63
  if (!iso) return null;
49
64
  const v = Date.parse(iso);
50
65
  return Number.isNaN(v) ? null : v;
51
66
  }
52
67
 
68
+ function round6(n: number): number {
69
+ return Math.round(n * 1e6) / 1e6;
70
+ }
71
+
53
72
  export class SessionIndex {
54
73
  readonly db: DatabaseSyncT;
55
74
 
@@ -289,6 +308,59 @@ export class SessionIndex {
289
308
  return (rows as any[]).map((r) => this.rowToIndex(r));
290
309
  }
291
310
 
311
+ /** Aggregated usage for a Usage panel: totals, by agent, by day, by project, top cost. */
312
+ usageSummary(opts: { days?: number; topN?: number } = {}): UsageSummary {
313
+ const rows = this.db
314
+ .prepare(
315
+ 'SELECT session_id, agent, started_at, input_tokens, output_tokens, cost_usd, projects_json, topic, title FROM session',
316
+ )
317
+ .all() as any[];
318
+ const totals = { sessions: rows.length, input_tokens: 0, output_tokens: 0, cost_usd: 0 };
319
+ const byAgent: Record<string, UsageBucket> = {};
320
+ const byDay: Record<string, UsageBucket> = {};
321
+ const byProject: Record<string, UsageBucket> = {};
322
+ const add = (m: Record<string, UsageBucket>, key: string, r: any) => {
323
+ const b = (m[key] ??= { sessions: 0, input_tokens: 0, output_tokens: 0, cost_usd: 0 });
324
+ b.sessions++;
325
+ b.input_tokens += r.input_tokens;
326
+ b.output_tokens += r.output_tokens;
327
+ b.cost_usd += r.cost_usd;
328
+ };
329
+ for (const r of rows) {
330
+ totals.input_tokens += r.input_tokens;
331
+ totals.output_tokens += r.output_tokens;
332
+ totals.cost_usd += r.cost_usd;
333
+ add(byAgent, r.agent || 'unknown', r);
334
+ if (r.started_at) add(byDay, new Date(r.started_at).toISOString().slice(0, 10), r);
335
+ for (const p of safeJson(r.projects_json)) add(byProject, p, r);
336
+ }
337
+ totals.cost_usd = round6(totals.cost_usd);
338
+ for (const m of [byAgent, byDay, byProject]) for (const b of Object.values(m)) b.cost_usd = round6(b.cost_usd);
339
+
340
+ const days = opts.days ?? 30;
341
+ const recentDays = Object.entries(byDay)
342
+ .sort((a, b) => (a[0] < b[0] ? 1 : -1))
343
+ .slice(0, days)
344
+ .map(([day, b]) => ({ day, ...b }));
345
+ const topByCost = rows
346
+ .map((r) => ({
347
+ session_id: r.session_id,
348
+ agent: r.agent,
349
+ cost_usd: round6(r.cost_usd),
350
+ label: r.topic || r.title || r.session_id,
351
+ }))
352
+ .sort((a, b) => b.cost_usd - a.cost_usd)
353
+ .slice(0, opts.topN ?? 10);
354
+
355
+ return {
356
+ totals,
357
+ byAgent,
358
+ byDay: recentDays,
359
+ byProject,
360
+ topByCost,
361
+ };
362
+ }
363
+
292
364
  stats(): { sessions: number; turns: number; cost_usd: number; byAgent: Record<string, number> } {
293
365
  const s = this.db.prepare('SELECT COUNT(*) c, COALESCE(SUM(cost_usd),0) cost FROM session').get() as {
294
366
  c: number;