@unpolarize/code-sessions 0.2.0 → 0.4.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,13 @@ 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]
144
+ graph Sessions \xD7 topics graph (nodes + edges) [--json]
143
145
  search Full-text search session turns <text> [--limit N]
144
146
  fork Fork a session at a turn ("git for sessions") <session-id> --at N [--id X]
145
147
  analytics Compute MVP-2 rollups + digest into analytics/
@@ -1761,8 +1763,144 @@ function parseCodexSession(info, host) {
1761
1763
  return { host, sessionId, agent: "codex", turns, meta };
1762
1764
  }
1763
1765
 
1766
+ // src/adapters/codebuild.ts
1767
+ import { existsSync as existsSync12, readdirSync as readdirSync4, readFileSync as readFileSync10, statSync as statSync2 } from "fs";
1768
+ import { homedir as homedir4 } from "os";
1769
+ import { join as join8 } from "path";
1770
+ import {
1771
+ SCHEMA_VERSIONS as SCHEMA_VERSIONS5
1772
+ } from "@unpolarize/code-sessions-schema";
1773
+ function codebuildSessionsRoot() {
1774
+ return join8(homedir4(), ".codebuild", "sessions");
1775
+ }
1776
+ function discoverCodebuildSessions(root = codebuildSessionsRoot()) {
1777
+ if (!existsSync12(root)) return [];
1778
+ let files;
1779
+ try {
1780
+ files = readdirSync4(root).filter((f) => f.endsWith(".jsonl"));
1781
+ } catch {
1782
+ return [];
1783
+ }
1784
+ return files.map((f) => ({ sessionId: f.replace(/\.jsonl$/, ""), path: join8(root, f) }));
1785
+ }
1786
+ function backendToAgent(backend) {
1787
+ if (backend === "claude") return "claude-code";
1788
+ if (backend === "grok") return "grok";
1789
+ if (backend === "codex") return "codex";
1790
+ return "unknown";
1791
+ }
1792
+ function parseCodebuildSession(info, host) {
1793
+ let raw = "";
1794
+ try {
1795
+ raw = readFileSync10(info.path, "utf8");
1796
+ } catch {
1797
+ return null;
1798
+ }
1799
+ const lines = raw.split("\n").filter((l) => l.trim().length > 0);
1800
+ const baseMs = statMtime2(info.path);
1801
+ let agent = "unknown";
1802
+ let sessionId = info.sessionId;
1803
+ let title;
1804
+ let cwd;
1805
+ const turns = [];
1806
+ let pending = null;
1807
+ let idx = 0;
1808
+ const flush = () => {
1809
+ if (!pending) return;
1810
+ const p = pending;
1811
+ pending = null;
1812
+ const turn = mkTurn2(sessionId, host, agent, idx++, baseMs, "assistant", p.text, p.tools);
1813
+ turn.usage = {
1814
+ input_tokens: p.input_tokens,
1815
+ output_tokens: p.output_tokens,
1816
+ cache_read_tokens: p.cache_read_tokens,
1817
+ cache_write_tokens: 0
1818
+ };
1819
+ if (p.cost_usd > 0) turn.telemetry = { cost_usd: p.cost_usd };
1820
+ turns.push(turn);
1821
+ };
1822
+ const ensurePending = () => {
1823
+ if (!pending) pending = { text: "", tools: [], input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cost_usd: 0 };
1824
+ return pending;
1825
+ };
1826
+ for (const line of lines) {
1827
+ let ev;
1828
+ try {
1829
+ ev = JSON.parse(line);
1830
+ } catch {
1831
+ continue;
1832
+ }
1833
+ if (ev.type === "meta" && ev.meta) {
1834
+ agent = backendToAgent(ev.meta.backend);
1835
+ if (typeof ev.meta.id === "string") sessionId = ev.meta.id;
1836
+ if (typeof ev.meta.title === "string") title = ev.meta.title;
1837
+ if (typeof ev.meta.cwd === "string") cwd = ev.meta.cwd;
1838
+ continue;
1839
+ }
1840
+ if (ev.type === "user" && typeof ev.text === "string") {
1841
+ flush();
1842
+ turns.push(mkTurn2(sessionId, host, agent, idx++, baseMs, "user", ev.text, []));
1843
+ continue;
1844
+ }
1845
+ if (ev.type === "update" && ev.update) {
1846
+ const u = ev.update;
1847
+ if (u.kind === "agent_message_chunk") {
1848
+ const t = u.content?.text;
1849
+ if (typeof t === "string") ensurePending().text += t;
1850
+ } else if (u.kind === "tool_call" && u.toolCall) {
1851
+ const p = ensurePending();
1852
+ p.tools.push({ name: String(u.toolCall.title ?? u.toolCall.kind ?? "tool"), input: u.toolCall.rawInput });
1853
+ } else if (u.kind === "usage" && u.usage) {
1854
+ const p = ensurePending();
1855
+ p.input_tokens += Number(u.usage.inputTokens) || 0;
1856
+ p.output_tokens += Number(u.usage.outputTokens) || 0;
1857
+ p.cache_read_tokens += Number(u.usage.cacheReadTokens) || 0;
1858
+ } else if (u.kind === "result" && u.usage) {
1859
+ const p = ensurePending();
1860
+ p.input_tokens += Number(u.usage.inputTokens) || 0;
1861
+ p.output_tokens += Number(u.usage.outputTokens) || 0;
1862
+ p.cost_usd += Number(u.usage.costUsd) || 0;
1863
+ flush();
1864
+ }
1865
+ }
1866
+ }
1867
+ flush();
1868
+ if (turns.length === 0) return null;
1869
+ const meta = {
1870
+ session_id: sessionId,
1871
+ started_at: turns[0].ts,
1872
+ ended_at: turns[turns.length - 1].ts
1873
+ };
1874
+ if (cwd) meta.project_path = cwd;
1875
+ if (title) meta.title = title;
1876
+ return { host, sessionId, agent, turns, meta };
1877
+ }
1878
+ function statMtime2(p) {
1879
+ try {
1880
+ return statSync2(p).mtimeMs;
1881
+ } catch {
1882
+ return Date.parse("2020-01-01T00:00:00Z");
1883
+ }
1884
+ }
1885
+ function mkTurn2(sessionId, host, agent, index, baseMs, role, text, tool_calls) {
1886
+ return {
1887
+ schema: SCHEMA_VERSIONS5.turn,
1888
+ session_id: sessionId,
1889
+ host,
1890
+ agent,
1891
+ turn_index: index,
1892
+ ts: new Date(baseMs + index * 1e3).toISOString(),
1893
+ role,
1894
+ text,
1895
+ tool_calls,
1896
+ usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
1897
+ scrubbed: false,
1898
+ raw_ref: null
1899
+ };
1900
+ }
1901
+
1764
1902
  // src/adapters/import.ts
1765
- import { existsSync as existsSync12, mkdirSync as mkdirSync6, readFileSync as readFileSync10, renameSync as renameSync4, writeFileSync as writeFileSync6 } from "fs";
1903
+ import { existsSync as existsSync13, mkdirSync as mkdirSync6, readFileSync as readFileSync11, renameSync as renameSync4, writeFileSync as writeFileSync6 } from "fs";
1766
1904
  import { dirname as dirname5 } from "path";
1767
1905
  var NATIVE_FORMAT = {
1768
1906
  "claude-code": "claude-jsonl",
@@ -1793,9 +1931,9 @@ function writeImportedSession(cfg, s) {
1793
1931
  });
1794
1932
  env.native_ref.format = NATIVE_FORMAT[s.agent] ?? "unknown";
1795
1933
  const envPath = envelopeFile(dir);
1796
- if (existsSync12(envPath)) {
1934
+ if (existsSync13(envPath)) {
1797
1935
  try {
1798
- const prev = JSON.parse(readFileSync10(envPath, "utf8"));
1936
+ const prev = JSON.parse(readFileSync11(envPath, "utf8"));
1799
1937
  if (prev.labels?.length) env.labels = prev.labels;
1800
1938
  } catch {
1801
1939
  }
@@ -1816,6 +1954,9 @@ function toMs(iso) {
1816
1954
  const v = Date.parse(iso);
1817
1955
  return Number.isNaN(v) ? null : v;
1818
1956
  }
1957
+ function round6(n) {
1958
+ return Math.round(n * 1e6) / 1e6;
1959
+ }
1819
1960
  var SessionIndex = class {
1820
1961
  db;
1821
1962
  constructor(path) {
@@ -2017,6 +2158,83 @@ var SessionIndex = class {
2017
2158
  ).all(like, like, limit);
2018
2159
  return rows.map((r) => this.rowToIndex(r));
2019
2160
  }
2161
+ /** Graph of sessions clustered by topic (+ fork lineage where known). */
2162
+ graphData() {
2163
+ const rows = this.db.prepare("SELECT session_id, agent, topic, intent, cost_usd, title FROM session ORDER BY started_at DESC").all();
2164
+ const nodes = [];
2165
+ const edges = [];
2166
+ const topicNodes = /* @__PURE__ */ new Map();
2167
+ const topicId = (t) => {
2168
+ let id = topicNodes.get(t);
2169
+ if (!id) {
2170
+ id = `topic:${topicNodes.size}`;
2171
+ topicNodes.set(t, id);
2172
+ nodes.push({ id, kind: "topic", label: t, sessions: 0, cost_usd: 0 });
2173
+ }
2174
+ return id;
2175
+ };
2176
+ for (const r of rows) {
2177
+ const sid = `s:${r.session_id}`;
2178
+ nodes.push({
2179
+ id: sid,
2180
+ kind: "session",
2181
+ label: (r.topic || r.title || r.session_id).slice(0, 48),
2182
+ agent: r.agent,
2183
+ intent: r.intent ?? null,
2184
+ cost_usd: r.cost_usd,
2185
+ sessions: 1
2186
+ });
2187
+ const topic = (r.topic || r.intent || "misc").toString();
2188
+ const tid = topicId(topic);
2189
+ edges.push({ from: sid, to: tid, kind: "has-topic" });
2190
+ const tn = nodes.find((n) => n.id === tid);
2191
+ tn.sessions += 1;
2192
+ tn.cost_usd += r.cost_usd;
2193
+ }
2194
+ return { nodes, edges };
2195
+ }
2196
+ /** Aggregated usage for a Usage panel: totals, by agent, by day, by project, top cost. */
2197
+ usageSummary(opts = {}) {
2198
+ const rows = this.db.prepare(
2199
+ "SELECT session_id, agent, started_at, input_tokens, output_tokens, cost_usd, projects_json, topic, title FROM session"
2200
+ ).all();
2201
+ const totals = { sessions: rows.length, input_tokens: 0, output_tokens: 0, cost_usd: 0 };
2202
+ const byAgent = {};
2203
+ const byDay = {};
2204
+ const byProject = {};
2205
+ const add = (m, key, r) => {
2206
+ const b = m[key] ??= { sessions: 0, input_tokens: 0, output_tokens: 0, cost_usd: 0 };
2207
+ b.sessions++;
2208
+ b.input_tokens += r.input_tokens;
2209
+ b.output_tokens += r.output_tokens;
2210
+ b.cost_usd += r.cost_usd;
2211
+ };
2212
+ for (const r of rows) {
2213
+ totals.input_tokens += r.input_tokens;
2214
+ totals.output_tokens += r.output_tokens;
2215
+ totals.cost_usd += r.cost_usd;
2216
+ add(byAgent, r.agent || "unknown", r);
2217
+ if (r.started_at) add(byDay, new Date(r.started_at).toISOString().slice(0, 10), r);
2218
+ for (const p of safeJson(r.projects_json)) add(byProject, p, r);
2219
+ }
2220
+ totals.cost_usd = round6(totals.cost_usd);
2221
+ for (const m of [byAgent, byDay, byProject]) for (const b of Object.values(m)) b.cost_usd = round6(b.cost_usd);
2222
+ const days = opts.days ?? 30;
2223
+ const recentDays = Object.entries(byDay).sort((a, b) => a[0] < b[0] ? 1 : -1).slice(0, days).map(([day, b]) => ({ day, ...b }));
2224
+ const topByCost = rows.map((r) => ({
2225
+ session_id: r.session_id,
2226
+ agent: r.agent,
2227
+ cost_usd: round6(r.cost_usd),
2228
+ label: r.topic || r.title || r.session_id
2229
+ })).sort((a, b) => b.cost_usd - a.cost_usd).slice(0, opts.topN ?? 10);
2230
+ return {
2231
+ totals,
2232
+ byAgent,
2233
+ byDay: recentDays,
2234
+ byProject,
2235
+ topByCost
2236
+ };
2237
+ }
2020
2238
  stats() {
2021
2239
  const s = this.db.prepare("SELECT COUNT(*) c, COALESCE(SUM(cost_usd),0) cost FROM session").get();
2022
2240
  const t = this.db.prepare("SELECT COUNT(*) c FROM turn").get();
@@ -2040,7 +2258,7 @@ function safeJson(s) {
2040
2258
  }
2041
2259
 
2042
2260
  // src/index_store/sync.ts
2043
- import { existsSync as existsSync13, readFileSync as readFileSync11, statSync as statSync2 } from "fs";
2261
+ import { existsSync as existsSync14, readFileSync as readFileSync12, statSync as statSync3 } from "fs";
2044
2262
  import { safeParseInsights, safeParseSession as safeParseSession2 } from "@unpolarize/code-sessions-schema";
2045
2263
  function syncIndex(cfg, opts = {}) {
2046
2264
  const index = opts.index ?? new SessionIndex(cfg.indexPath);
@@ -2054,8 +2272,8 @@ function syncIndex(cfg, opts = {}) {
2054
2272
  let unchanged = 0;
2055
2273
  for (const ref of refs) {
2056
2274
  const envPath = envelopeFile(ref.dir);
2057
- if (!existsSync13(envPath)) continue;
2058
- const st = statSync2(envPath);
2275
+ if (!existsSync14(envPath)) continue;
2276
+ const st = statSync3(envPath);
2059
2277
  const mtime_ms = Math.floor(st.mtimeMs);
2060
2278
  const size_bytes = st.size;
2061
2279
  seen.add(ref.sessionId);
@@ -2064,7 +2282,7 @@ function syncIndex(cfg, opts = {}) {
2064
2282
  unchanged++;
2065
2283
  continue;
2066
2284
  }
2067
- const parsed = safeParseSession2(JSON.parse(readFileSync11(envPath, "utf8")));
2285
+ const parsed = safeParseSession2(JSON.parse(readFileSync12(envPath, "utf8")));
2068
2286
  if (!parsed.success) continue;
2069
2287
  const env = parsed.data;
2070
2288
  let topic;
@@ -2072,8 +2290,8 @@ function syncIndex(cfg, opts = {}) {
2072
2290
  let projects = [];
2073
2291
  const insPath = insightsFile(ref.dir);
2074
2292
  let insights = void 0;
2075
- if (existsSync13(insPath)) {
2076
- const pi = safeParseInsights(JSON.parse(readFileSync11(insPath, "utf8")));
2293
+ if (existsSync14(insPath)) {
2294
+ const pi = safeParseInsights(JSON.parse(readFileSync12(insPath, "utf8")));
2077
2295
  if (pi.success) {
2078
2296
  insights = pi.data;
2079
2297
  topic = pi.data.topic;
@@ -2146,36 +2364,36 @@ ${buildLabelSkillBody()}
2146
2364
 
2147
2365
  // src/skills/install.ts
2148
2366
  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";
2367
+ import { homedir as homedir5 } from "os";
2368
+ import { dirname as dirname7, join as join9 } from "path";
2151
2369
  function targetsFor(agent, home) {
2152
2370
  const out = [];
2153
2371
  const want = (a) => agent === "all" || agent === a;
2154
2372
  if (want("claude")) {
2155
2373
  out.push({
2156
2374
  agent: "claude",
2157
- path: join8(home, ".claude", "skills", "cs-label-session", "SKILL.md"),
2375
+ path: join9(home, ".claude", "skills", "cs-label-session", "SKILL.md"),
2158
2376
  content: buildClaudeSkill()
2159
2377
  });
2160
2378
  }
2161
2379
  if (want("codex")) {
2162
2380
  out.push({
2163
2381
  agent: "codex",
2164
- path: join8(home, ".codex", "prompts", "cs-label-session.md"),
2382
+ path: join9(home, ".codex", "prompts", "cs-label-session.md"),
2165
2383
  content: buildPromptFile()
2166
2384
  });
2167
2385
  }
2168
2386
  if (want("grok")) {
2169
2387
  out.push({
2170
2388
  agent: "grok",
2171
- path: join8(home, ".grok", "prompts", "cs-label-session.md"),
2389
+ path: join9(home, ".grok", "prompts", "cs-label-session.md"),
2172
2390
  content: buildPromptFile()
2173
2391
  });
2174
2392
  }
2175
2393
  return out;
2176
2394
  }
2177
2395
  function installSkills(opts = {}) {
2178
- const home = opts.home ?? homedir4();
2396
+ const home = opts.home ?? homedir5();
2179
2397
  const installed = [];
2180
2398
  for (const t of targetsFor(opts.agent ?? "all", home)) {
2181
2399
  mkdirSync8(dirname7(t.path), { recursive: true });
@@ -2186,7 +2404,7 @@ function installSkills(opts = {}) {
2186
2404
  }
2187
2405
 
2188
2406
  // src/fork.ts
2189
- import { existsSync as existsSync14, readFileSync as readFileSync12 } from "fs";
2407
+ import { existsSync as existsSync15, readFileSync as readFileSync13 } from "fs";
2190
2408
  import { randomUUID } from "crypto";
2191
2409
  import { mkdirSync as mkdirSync9, renameSync as renameSync5, writeFileSync as writeFileSync8 } from "fs";
2192
2410
  import { dirname as dirname8 } from "path";
@@ -2199,8 +2417,8 @@ function locateSession(storeDir, sessionId) {
2199
2417
  }
2200
2418
  function loadEnvelope2(dir) {
2201
2419
  const p = envelopeFile(dir);
2202
- if (!existsSync14(p)) return void 0;
2203
- const parsed = safeParseSession3(JSON.parse(readFileSync12(p, "utf8")));
2420
+ if (!existsSync15(p)) return void 0;
2421
+ const parsed = safeParseSession3(JSON.parse(readFileSync13(p, "utf8")));
2204
2422
  return parsed.success ? parsed.data : void 0;
2205
2423
  }
2206
2424
  function writeJsonAtomic4(path, value) {
@@ -2249,8 +2467,8 @@ function forkSession(cfg, opts) {
2249
2467
  }
2250
2468
 
2251
2469
  // src/commands.ts
2252
- import { existsSync as existsSync15, writeFileSync as writeFileSync9 } from "fs";
2253
- import { join as join9 } from "path";
2470
+ import { existsSync as existsSync16, writeFileSync as writeFileSync9 } from "fs";
2471
+ import { join as join10 } from "path";
2254
2472
  function gitStoreFor(cfg) {
2255
2473
  return new GitStore(cfg.storeDir, {
2256
2474
  ...cfg.git.remote ? { remote: cfg.git.remote } : {},
@@ -2260,13 +2478,13 @@ function gitStoreFor(cfg) {
2260
2478
  function listClaudeTranscripts(projectsDir, maxDepth = 3) {
2261
2479
  const out = [];
2262
2480
  const walk = (dir, depth) => {
2263
- if (depth > maxDepth || !existsSync15(dir)) return;
2481
+ if (depth > maxDepth || !existsSync16(dir)) return;
2264
2482
  for (const e of readEntries(dir)) {
2265
2483
  const name = String(e.name);
2266
2484
  if (e.isFile() && name.endsWith(".jsonl")) {
2267
- out.push({ sessionId: name.replace(/\.jsonl$/, ""), path: join9(dir, name) });
2485
+ out.push({ sessionId: name.replace(/\.jsonl$/, ""), path: join10(dir, name) });
2268
2486
  } else if (e.isDirectory()) {
2269
- walk(join9(dir, name), depth + 1);
2487
+ walk(join10(dir, name), depth + 1);
2270
2488
  }
2271
2489
  }
2272
2490
  };
@@ -2276,8 +2494,8 @@ function listClaudeTranscripts(projectsDir, maxDepth = 3) {
2276
2494
  function cmdInit(cfg) {
2277
2495
  const git = gitStoreFor(cfg);
2278
2496
  git.init();
2279
- const configPath = join9(cfg.storeDir, "config.json");
2280
- if (!existsSync15(configPath)) {
2497
+ const configPath = join10(cfg.storeDir, "config.json");
2498
+ if (!existsSync16(configPath)) {
2281
2499
  writeFileSync9(
2282
2500
  configPath,
2283
2501
  `${JSON.stringify({ insights: cfg.insights, batch: cfg.batch, hygiene: cfg.hygiene }, null, 2)}
@@ -2291,7 +2509,7 @@ function cmdStatus(cfg) {
2291
2509
  const state = new StateStore(cfg.statePath);
2292
2510
  const sessions = Object.keys(state.all());
2293
2511
  const stored = listSessionDirs(cfg.storeDir);
2294
- const socketUp = existsSync15(cfg.socketPath);
2512
+ const socketUp = existsSync16(cfg.socketPath);
2295
2513
  const lines = [
2296
2514
  `store: ${cfg.storeDir}`,
2297
2515
  `host: ${cfg.host}`,
@@ -2346,6 +2564,20 @@ async function cmdBackfill(cfg, opts = {}) {
2346
2564
  turns += t;
2347
2565
  parts.push(`codex: ${n} sessions / ${t} turns`);
2348
2566
  }
2567
+ if (agent === "codebuild" || agent === "all") {
2568
+ const found = discoverCodebuildSessions();
2569
+ let n = 0;
2570
+ let t = 0;
2571
+ for (const info of found) {
2572
+ const imported = parseCodebuildSession(info, cfg.host);
2573
+ if (!imported) continue;
2574
+ t += writeImportedSession(cfg, imported).turns;
2575
+ n++;
2576
+ }
2577
+ sessions += n;
2578
+ turns += t;
2579
+ parts.push(`codebuild: ${n} sessions / ${t} turns`);
2580
+ }
2349
2581
  const git = gitStoreFor(cfg);
2350
2582
  git.init();
2351
2583
  git.commit(`backfill (${agent}): ${sessions} sessions`);
@@ -2360,7 +2592,7 @@ async function cmdReindex(cfg, opts = {}) {
2360
2592
  }
2361
2593
  function cmdInstallHooks(cfg, opts = {}) {
2362
2594
  const home = cfg.claudeProjectsDir.replace(/\/projects\/?$/, "");
2363
- const settingsPath = opts.settingsPath ?? join9(home, "settings.json");
2595
+ const settingsPath = opts.settingsPath ?? join10(home, "settings.json");
2364
2596
  const command = opts.command ?? "code-sessions hook";
2365
2597
  const res = installHooks(settingsPath, command);
2366
2598
  return {
@@ -2370,10 +2602,10 @@ function cmdInstallHooks(cfg, opts = {}) {
2370
2602
  }
2371
2603
  function cmdDoctor(cfg) {
2372
2604
  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)]
2605
+ ["store dir exists", existsSync16(cfg.storeDir)],
2606
+ ["store is git repo", existsSync16(join10(cfg.storeDir, ".git"))],
2607
+ ["daemon socket present", existsSync16(cfg.socketPath)],
2608
+ ["claude projects dir", existsSync16(cfg.claudeProjectsDir)]
2377
2609
  ];
2378
2610
  const lines = checks.map(([name, ok]) => `${ok ? "\u2713" : "\u2717"} ${name}`);
2379
2611
  const code = checks.every(([, ok]) => ok) ? 0 : 1;
@@ -2409,6 +2641,37 @@ function cmdFork(cfg, opts) {
2409
2641
  return { code: 1, output: `fork failed: ${e instanceof Error ? e.message : String(e)}` };
2410
2642
  }
2411
2643
  }
2644
+ function cmdGraph(cfg, opts = {}) {
2645
+ syncIndex(cfg);
2646
+ const index = new SessionIndex(cfg.indexPath);
2647
+ try {
2648
+ const g = index.graphData();
2649
+ if (opts.json) return { code: 0, output: JSON.stringify(g) };
2650
+ const topics = g.nodes.filter((n) => n.kind === "topic").length;
2651
+ const sessions = g.nodes.filter((n) => n.kind === "session").length;
2652
+ return { code: 0, output: `graph: ${sessions} sessions across ${topics} topics, ${g.edges.length} edges` };
2653
+ } finally {
2654
+ index.close();
2655
+ }
2656
+ }
2657
+ function cmdUsage(cfg, opts = {}) {
2658
+ syncIndex(cfg);
2659
+ const index = new SessionIndex(cfg.indexPath);
2660
+ try {
2661
+ const u = index.usageSummary();
2662
+ if (opts.json) return { code: 0, output: JSON.stringify(u) };
2663
+ const lines = [
2664
+ `# 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)}`,
2665
+ "by agent:",
2666
+ ...Object.entries(u.byAgent).map(([a, b]) => ` ${a.padEnd(12)} ${b.sessions} sess $${b.cost_usd.toFixed(2)}`),
2667
+ "top sessions by cost:",
2668
+ ...u.topByCost.slice(0, 5).map((t) => ` $${t.cost_usd.toFixed(2).padStart(8)} ${t.agent.padEnd(12)} ${t.label.slice(0, 50)}`)
2669
+ ];
2670
+ return { code: 0, output: lines.join("\n") };
2671
+ } finally {
2672
+ index.close();
2673
+ }
2674
+ }
2412
2675
  function cmdInstallSkills(opts = {}) {
2413
2676
  const res = installSkills(opts.agent ? { agent: opts.agent } : {});
2414
2677
  return { code: 0, output: `Installed cs-label-session skill:
@@ -2474,9 +2737,9 @@ async function startDaemon(cfg) {
2474
2737
  }
2475
2738
 
2476
2739
  // src/hooks/shim.ts
2477
- import { existsSync as existsSync16 } from "fs";
2740
+ import { existsSync as existsSync17 } from "fs";
2478
2741
  async function handleHookInput(socketPath, rawInput) {
2479
- if (!existsSync16(socketPath)) return { ok: false, error: "daemon not running" };
2742
+ if (!existsSync17(socketPath)) return { ok: false, error: "daemon not running" };
2480
2743
  let parsed;
2481
2744
  try {
2482
2745
  parsed = JSON.parse(rawInput);
@@ -2560,6 +2823,9 @@ export {
2560
2823
  codexSessionsRoot,
2561
2824
  discoverCodexSessions,
2562
2825
  parseCodexSession,
2826
+ codebuildSessionsRoot,
2827
+ discoverCodebuildSessions,
2828
+ parseCodebuildSession,
2563
2829
  writeImportedSession,
2564
2830
  SessionIndex,
2565
2831
  syncIndex,
@@ -2577,6 +2843,8 @@ export {
2577
2843
  cmdDoctor,
2578
2844
  cmdExport,
2579
2845
  cmdFork,
2846
+ cmdGraph,
2847
+ cmdUsage,
2580
2848
  cmdInstallSkills,
2581
2849
  cmdIndex,
2582
2850
  cmdQuery,
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  cmdDoctor,
6
6
  cmdExport,
7
7
  cmdFork,
8
+ cmdGraph,
8
9
  cmdIndex,
9
10
  cmdInit,
10
11
  cmdInstallHooks,
@@ -13,6 +14,7 @@ import {
13
14
  cmdReindex,
14
15
  cmdSearch,
15
16
  cmdStatus,
17
+ cmdUsage,
16
18
  envelopeFile,
17
19
  handleHookInput,
18
20
  insightsFile,
@@ -22,7 +24,7 @@ import {
22
24
  parseFlags,
23
25
  readStdin,
24
26
  startDaemon
25
- } from "./chunk-HV6FQJPS.js";
27
+ } from "./chunk-3VPXOUIE.js";
26
28
 
27
29
  // src/analytics/command.ts
28
30
  import { mkdirSync, writeFileSync } from "fs";
@@ -256,6 +258,12 @@ async function main(argv) {
256
258
  case "index":
257
259
  emit(cmdIndex(cfg));
258
260
  break;
261
+ case "usage":
262
+ emit(cmdUsage(cfg, { json: flags.json === true }));
263
+ break;
264
+ case "graph":
265
+ emit(cmdGraph(cfg, { json: flags.json === true }));
266
+ break;
259
267
  case "query":
260
268
  emit(
261
269
  cmdQuery(cfg, {
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  cmdDoctor,
21
21
  cmdExport,
22
22
  cmdFork,
23
+ cmdGraph,
23
24
  cmdIndex,
24
25
  cmdInit,
25
26
  cmdInstallHooks,
@@ -28,6 +29,8 @@ import {
28
29
  cmdReindex,
29
30
  cmdSearch,
30
31
  cmdStatus,
32
+ cmdUsage,
33
+ codebuildSessionsRoot,
31
34
  codexSessionsRoot,
32
35
  computeEnvelope,
33
36
  defaultConfig,
@@ -35,6 +38,7 @@ import {
35
38
  deriveProjects,
36
39
  deriveSignals,
37
40
  deriveTags,
41
+ discoverCodebuildSessions,
38
42
  discoverCodexSessions,
39
43
  discoverGrokSessions,
40
44
  envelopeFile,
@@ -61,6 +65,7 @@ import {
61
65
  monthOf,
62
66
  ollamaRunner,
63
67
  overridesFromFlags,
68
+ parseCodebuildSession,
64
69
  parseCodexSession,
65
70
  parseFlags,
66
71
  parseGrokSession,
@@ -88,7 +93,7 @@ import {
88
93
  writeBlobFile,
89
94
  writeImportedSession,
90
95
  writeTurnFile
91
- } from "./chunk-HV6FQJPS.js";
96
+ } from "./chunk-3VPXOUIE.js";
92
97
  export {
93
98
  CaptureEngine,
94
99
  DEFAULT_HOOK_EVENTS,
@@ -111,6 +116,7 @@ export {
111
116
  cmdDoctor,
112
117
  cmdExport,
113
118
  cmdFork,
119
+ cmdGraph,
114
120
  cmdIndex,
115
121
  cmdInit,
116
122
  cmdInstallHooks,
@@ -119,6 +125,8 @@ export {
119
125
  cmdReindex,
120
126
  cmdSearch,
121
127
  cmdStatus,
128
+ cmdUsage,
129
+ codebuildSessionsRoot,
122
130
  codexSessionsRoot,
123
131
  computeEnvelope,
124
132
  defaultConfig,
@@ -126,6 +134,7 @@ export {
126
134
  deriveProjects,
127
135
  deriveSignals,
128
136
  deriveTags,
137
+ discoverCodebuildSessions,
129
138
  discoverCodexSessions,
130
139
  discoverGrokSessions,
131
140
  envelopeFile,
@@ -152,6 +161,7 @@ export {
152
161
  monthOf,
153
162
  ollamaRunner,
154
163
  overridesFromFlags,
164
+ parseCodebuildSession,
155
165
  parseCodexSession,
156
166
  parseFlags,
157
167
  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.4.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.4.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
@@ -5,6 +5,7 @@ import {
5
5
  cmdDoctor,
6
6
  cmdExport,
7
7
  cmdFork,
8
+ cmdGraph,
8
9
  cmdIndex,
9
10
  cmdInit,
10
11
  cmdInstallHooks,
@@ -13,6 +14,7 @@ import {
13
14
  cmdReindex,
14
15
  cmdSearch,
15
16
  cmdStatus,
17
+ cmdUsage,
16
18
  startDaemon,
17
19
  type CommandResult,
18
20
  } from './commands';
@@ -54,7 +56,7 @@ export async function main(argv: string[]): Promise<void> {
54
56
  emit(
55
57
  await cmdBackfill(cfg, {
56
58
  ...(typeof flags.projects === 'string' ? { projectsDir: flags.projects } : {}),
57
- ...(typeof flags.agent === 'string' ? { agent: flags.agent as 'claude' | 'grok' | 'codex' | 'all' } : {}),
59
+ ...(typeof flags.agent === 'string' ? { agent: flags.agent as 'claude' | 'grok' | 'codex' | 'codebuild' | 'all' } : {}),
58
60
  }),
59
61
  );
60
62
  break;
@@ -70,6 +72,12 @@ export async function main(argv: string[]): Promise<void> {
70
72
  case 'index':
71
73
  emit(cmdIndex(cfg));
72
74
  break;
75
+ case 'usage':
76
+ emit(cmdUsage(cfg, { json: flags.json === true }));
77
+ break;
78
+ case 'graph':
79
+ emit(cmdGraph(cfg, { json: flags.json === true }));
80
+ break;
73
81
  case 'query':
74
82
  emit(
75
83
  cmdQuery(cfg, {
package/src/cliargs.ts CHANGED
@@ -53,11 +53,13 @@ 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]
62
+ graph Sessions × topics graph (nodes + edges) [--json]
61
63
  search Full-text search session turns <text> [--limit N]
62
64
  fork Fork a session at a turn ("git for sessions") <session-id> --at N [--id X]
63
65
  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,41 @@ export function cmdFork(
218
234
  }
219
235
  }
220
236
 
237
+ /** Sessions × topics graph (nodes + edges) from the CS index. JSON for the graph view. */
238
+ export function cmdGraph(cfg: CodeSessionsConfig, opts: { json?: boolean } = {}): CommandResult {
239
+ syncIndex(cfg);
240
+ const index = new SessionIndex(cfg.indexPath);
241
+ try {
242
+ const g = index.graphData();
243
+ if (opts.json) return { code: 0, output: JSON.stringify(g) };
244
+ const topics = g.nodes.filter((n) => n.kind === 'topic').length;
245
+ const sessions = g.nodes.filter((n) => n.kind === 'session').length;
246
+ return { code: 0, output: `graph: ${sessions} sessions across ${topics} topics, ${g.edges.length} edges` };
247
+ } finally {
248
+ index.close();
249
+ }
250
+ }
251
+
252
+ /** Aggregated usage from the CS index (totals/byAgent/byDay/byProject/topByCost). */
253
+ export function cmdUsage(cfg: CodeSessionsConfig, opts: { json?: boolean } = {}): CommandResult {
254
+ syncIndex(cfg); // ensure the index reflects the current store
255
+ const index = new SessionIndex(cfg.indexPath);
256
+ try {
257
+ const u = index.usageSummary();
258
+ if (opts.json) return { code: 0, output: JSON.stringify(u) };
259
+ const lines = [
260
+ `# usage — ${u.totals.sessions} sessions · ${u.totals.input_tokens.toLocaleString()} in / ${u.totals.output_tokens.toLocaleString()} out · $${u.totals.cost_usd.toFixed(2)}`,
261
+ 'by agent:',
262
+ ...Object.entries(u.byAgent).map(([a, b]) => ` ${a.padEnd(12)} ${b.sessions} sess $${b.cost_usd.toFixed(2)}`),
263
+ 'top sessions by cost:',
264
+ ...u.topByCost.slice(0, 5).map((t) => ` $${t.cost_usd.toFixed(2).padStart(8)} ${t.agent.padEnd(12)} ${t.label.slice(0, 50)}`),
265
+ ];
266
+ return { code: 0, output: lines.join('\n') };
267
+ } finally {
268
+ index.close();
269
+ }
270
+ }
271
+
221
272
  export function cmdInstallSkills(opts: { agent?: SkillAgent } = {}): CommandResult {
222
273
  const res = installSkills(opts.agent ? { agent: opts.agent } : {});
223
274
  return { code: 0, output: `Installed cs-label-session skill:\n ${res.installed.join('\n ')}` };
@@ -91,6 +91,50 @@ 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
+
119
+ it('builds a sessions×topics graph', () => {
120
+ const idx = new SessionIndex(':memory:');
121
+ try {
122
+ idx.upsertSession(env('a', 'claude-code'), { ...src, topic: 'fix parser' });
123
+ idx.upsertSession(env('b', 'grok'), { ...src, source_path: '/s/b.json', topic: 'fix parser' });
124
+ idx.upsertSession(env('c', 'codex'), { ...src, source_path: '/s/c.json', topic: 'add feature' });
125
+ const g = idx.graphData();
126
+ const topics = g.nodes.filter((n) => n.kind === 'topic');
127
+ const sessions = g.nodes.filter((n) => n.kind === 'session');
128
+ expect(sessions).toHaveLength(3);
129
+ expect(topics).toHaveLength(2); // "fix parser" + "add feature"
130
+ expect(g.edges).toHaveLength(3); // each session -> its topic
131
+ const fixParser = topics.find((t) => t.label === 'fix parser')!;
132
+ expect(fixParser.sessions).toBe(2);
133
+ } finally {
134
+ idx.close();
135
+ }
136
+ });
137
+
94
138
  it('filters by agent and deletes sessions (cascade turns)', () => {
95
139
  const idx = new SessionIndex(':memory:');
96
140
  try {
@@ -44,12 +44,50 @@ 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 GraphNode {
55
+ id: string;
56
+ kind: 'session' | 'topic';
57
+ label: string;
58
+ agent?: string;
59
+ intent?: string | null;
60
+ cost_usd: number;
61
+ sessions: number;
62
+ }
63
+ export interface GraphEdge {
64
+ from: string;
65
+ to: string;
66
+ kind: 'has-topic' | 'forked-from';
67
+ }
68
+ export interface GraphData {
69
+ nodes: GraphNode[];
70
+ edges: GraphEdge[];
71
+ }
72
+
73
+ export interface UsageSummary {
74
+ totals: { sessions: number; input_tokens: number; output_tokens: number; cost_usd: number };
75
+ byAgent: Record<string, UsageBucket>;
76
+ byDay: Array<{ day: string } & UsageBucket>;
77
+ byProject: Record<string, UsageBucket>;
78
+ topByCost: Array<{ session_id: string; agent: string; cost_usd: number; label: string }>;
79
+ }
80
+
47
81
  function toMs(iso: string | undefined): number | null {
48
82
  if (!iso) return null;
49
83
  const v = Date.parse(iso);
50
84
  return Number.isNaN(v) ? null : v;
51
85
  }
52
86
 
87
+ function round6(n: number): number {
88
+ return Math.round(n * 1e6) / 1e6;
89
+ }
90
+
53
91
  export class SessionIndex {
54
92
  readonly db: DatabaseSyncT;
55
93
 
@@ -289,6 +327,97 @@ export class SessionIndex {
289
327
  return (rows as any[]).map((r) => this.rowToIndex(r));
290
328
  }
291
329
 
330
+ /** Graph of sessions clustered by topic (+ fork lineage where known). */
331
+ graphData(): GraphData {
332
+ const rows = this.db
333
+ .prepare('SELECT session_id, agent, topic, intent, cost_usd, title FROM session ORDER BY started_at DESC')
334
+ .all() as any[];
335
+ const nodes: GraphNode[] = [];
336
+ const edges: GraphEdge[] = [];
337
+ const topicNodes = new Map<string, string>(); // topic -> node id
338
+ const topicId = (t: string) => {
339
+ let id = topicNodes.get(t);
340
+ if (!id) {
341
+ id = `topic:${topicNodes.size}`;
342
+ topicNodes.set(t, id);
343
+ nodes.push({ id, kind: 'topic', label: t, sessions: 0, cost_usd: 0 });
344
+ }
345
+ return id;
346
+ };
347
+ for (const r of rows) {
348
+ const sid = `s:${r.session_id}`;
349
+ nodes.push({
350
+ id: sid,
351
+ kind: 'session',
352
+ label: (r.topic || r.title || r.session_id).slice(0, 48),
353
+ agent: r.agent,
354
+ intent: r.intent ?? null,
355
+ cost_usd: r.cost_usd,
356
+ sessions: 1,
357
+ });
358
+ const topic = (r.topic || r.intent || 'misc').toString();
359
+ const tid = topicId(topic);
360
+ edges.push({ from: sid, to: tid, kind: 'has-topic' });
361
+ const tn = nodes.find((n) => n.id === tid)!;
362
+ tn.sessions += 1;
363
+ tn.cost_usd += r.cost_usd;
364
+ }
365
+ return { nodes, edges };
366
+ }
367
+
368
+ /** Aggregated usage for a Usage panel: totals, by agent, by day, by project, top cost. */
369
+ usageSummary(opts: { days?: number; topN?: number } = {}): UsageSummary {
370
+ const rows = this.db
371
+ .prepare(
372
+ 'SELECT session_id, agent, started_at, input_tokens, output_tokens, cost_usd, projects_json, topic, title FROM session',
373
+ )
374
+ .all() as any[];
375
+ const totals = { sessions: rows.length, input_tokens: 0, output_tokens: 0, cost_usd: 0 };
376
+ const byAgent: Record<string, UsageBucket> = {};
377
+ const byDay: Record<string, UsageBucket> = {};
378
+ const byProject: Record<string, UsageBucket> = {};
379
+ const add = (m: Record<string, UsageBucket>, key: string, r: any) => {
380
+ const b = (m[key] ??= { sessions: 0, input_tokens: 0, output_tokens: 0, cost_usd: 0 });
381
+ b.sessions++;
382
+ b.input_tokens += r.input_tokens;
383
+ b.output_tokens += r.output_tokens;
384
+ b.cost_usd += r.cost_usd;
385
+ };
386
+ for (const r of rows) {
387
+ totals.input_tokens += r.input_tokens;
388
+ totals.output_tokens += r.output_tokens;
389
+ totals.cost_usd += r.cost_usd;
390
+ add(byAgent, r.agent || 'unknown', r);
391
+ if (r.started_at) add(byDay, new Date(r.started_at).toISOString().slice(0, 10), r);
392
+ for (const p of safeJson(r.projects_json)) add(byProject, p, r);
393
+ }
394
+ totals.cost_usd = round6(totals.cost_usd);
395
+ for (const m of [byAgent, byDay, byProject]) for (const b of Object.values(m)) b.cost_usd = round6(b.cost_usd);
396
+
397
+ const days = opts.days ?? 30;
398
+ const recentDays = Object.entries(byDay)
399
+ .sort((a, b) => (a[0] < b[0] ? 1 : -1))
400
+ .slice(0, days)
401
+ .map(([day, b]) => ({ day, ...b }));
402
+ const topByCost = rows
403
+ .map((r) => ({
404
+ session_id: r.session_id,
405
+ agent: r.agent,
406
+ cost_usd: round6(r.cost_usd),
407
+ label: r.topic || r.title || r.session_id,
408
+ }))
409
+ .sort((a, b) => b.cost_usd - a.cost_usd)
410
+ .slice(0, opts.topN ?? 10);
411
+
412
+ return {
413
+ totals,
414
+ byAgent,
415
+ byDay: recentDays,
416
+ byProject,
417
+ topByCost,
418
+ };
419
+ }
420
+
292
421
  stats(): { sessions: number; turns: number; cost_usd: number; byAgent: Record<string, number> } {
293
422
  const s = this.db.prepare('SELECT COUNT(*) c, COALESCE(SUM(cost_usd),0) cost FROM session').get() as {
294
423
  c: number;