@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.
- package/dist/{chunk-HV6FQJPS.js → chunk-3VPXOUIE.js} +302 -34
- package/dist/cli.js +9 -1
- package/dist/index.js +11 -1
- package/package.json +2 -2
- package/src/adapters/adapters.test.ts +36 -0
- package/src/adapters/codebuild.ts +188 -0
- package/src/adapters/index.ts +1 -0
- package/src/cli.ts +9 -1
- package/src/cliargs.ts +3 -1
- package/src/commands.ts +52 -1
- package/src/index_store/db.test.ts +44 -0
- package/src/index_store/db.ts +129 -0
|
@@ -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
|
|
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 (
|
|
1934
|
+
if (existsSync13(envPath)) {
|
|
1797
1935
|
try {
|
|
1798
|
-
const prev = JSON.parse(
|
|
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
|
|
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 (!
|
|
2058
|
-
const st =
|
|
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(
|
|
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 (
|
|
2076
|
-
const pi = safeParseInsights(JSON.parse(
|
|
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
|
|
2150
|
-
import { dirname as dirname7, join as
|
|
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:
|
|
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:
|
|
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:
|
|
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 ??
|
|
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
|
|
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 (!
|
|
2203
|
-
const parsed = safeParseSession3(JSON.parse(
|
|
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
|
|
2253
|
-
import { join as
|
|
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 || !
|
|
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:
|
|
2485
|
+
out.push({ sessionId: name.replace(/\.jsonl$/, ""), path: join10(dir, name) });
|
|
2268
2486
|
} else if (e.isDirectory()) {
|
|
2269
|
-
walk(
|
|
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 =
|
|
2280
|
-
if (!
|
|
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 =
|
|
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 ??
|
|
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",
|
|
2374
|
-
["store is git repo",
|
|
2375
|
-
["daemon socket present",
|
|
2376
|
-
["claude projects dir",
|
|
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
|
|
2740
|
+
import { existsSync as existsSync17 } from "fs";
|
|
2478
2741
|
async function handleHookInput(socketPath, rawInput) {
|
|
2479
|
-
if (!
|
|
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-
|
|
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-
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/adapters/index.ts
CHANGED
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 {
|
package/src/index_store/db.ts
CHANGED
|
@@ -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;
|