@unpolarize/code-sessions 0.1.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.
Files changed (67) hide show
  1. package/bin/code-sessions.mjs +20 -0
  2. package/dist/chunk-ZJG2DWAK.js +2321 -0
  3. package/dist/cli.js +308 -0
  4. package/dist/index.js +162 -0
  5. package/package.json +21 -0
  6. package/src/adapters/adapters.test.ts +121 -0
  7. package/src/adapters/codex.ts +228 -0
  8. package/src/adapters/grok.ts +179 -0
  9. package/src/adapters/import.ts +79 -0
  10. package/src/adapters/index.ts +3 -0
  11. package/src/analytics/analytics.test.ts +94 -0
  12. package/src/analytics/command.ts +38 -0
  13. package/src/analytics/digest.ts +48 -0
  14. package/src/analytics/rollup.ts +114 -0
  15. package/src/analytics/site.ts +41 -0
  16. package/src/capture.test.ts +103 -0
  17. package/src/capture.ts +121 -0
  18. package/src/cli.ts +118 -0
  19. package/src/cliargs.test.ts +31 -0
  20. package/src/cliargs.ts +77 -0
  21. package/src/commands.test.ts +99 -0
  22. package/src/commands.ts +266 -0
  23. package/src/config.test.ts +36 -0
  24. package/src/config.ts +158 -0
  25. package/src/daemon.test.ts +130 -0
  26. package/src/daemon.ts +216 -0
  27. package/src/hooks/install.test.ts +47 -0
  28. package/src/hooks/install.ts +81 -0
  29. package/src/hooks/shim.test.ts +57 -0
  30. package/src/hooks/shim.ts +26 -0
  31. package/src/hygiene.test.ts +78 -0
  32. package/src/hygiene.ts +107 -0
  33. package/src/index.ts +21 -0
  34. package/src/index_store/db.test.ts +108 -0
  35. package/src/index_store/db.ts +289 -0
  36. package/src/index_store/index.ts +2 -0
  37. package/src/index_store/sync.test.ts +88 -0
  38. package/src/index_store/sync.ts +83 -0
  39. package/src/insights/heuristics.test.ts +71 -0
  40. package/src/insights/heuristics.ts +106 -0
  41. package/src/insights/index.ts +4 -0
  42. package/src/insights/labeler.test.ts +105 -0
  43. package/src/insights/labeler.ts +136 -0
  44. package/src/insights/llm.test.ts +77 -0
  45. package/src/insights/llm.ts +130 -0
  46. package/src/insights/provider.ts +37 -0
  47. package/src/ipc.test.ts +35 -0
  48. package/src/ipc.ts +70 -0
  49. package/src/pricing.test.ts +28 -0
  50. package/src/pricing.ts +45 -0
  51. package/src/state.test.ts +46 -0
  52. package/src/state.ts +89 -0
  53. package/src/store/git.test.ts +99 -0
  54. package/src/store/git.ts +138 -0
  55. package/src/store/paths.ts +45 -0
  56. package/src/store/scan.ts +39 -0
  57. package/src/store/writer.test.ts +93 -0
  58. package/src/store/writer.ts +135 -0
  59. package/src/tail.test.ts +50 -0
  60. package/src/tail.ts +47 -0
  61. package/src/telemetry/exporter.test.ts +104 -0
  62. package/src/telemetry/exporter.ts +64 -0
  63. package/src/telemetry/index.ts +2 -0
  64. package/src/telemetry/otlp.test.ts +123 -0
  65. package/src/telemetry/otlp.ts +215 -0
  66. package/src/test/e2e.test.ts +112 -0
  67. package/src/test/tmp.ts +36 -0
@@ -0,0 +1,228 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import {
5
+ SCHEMA_VERSIONS,
6
+ type ClaudeSessionMeta,
7
+ type ToolCall,
8
+ type Turn,
9
+ } from '@unpolarize/code-sessions-schema';
10
+ import { readEntries } from '../store/scan';
11
+ import type { ImportedSession } from './import';
12
+
13
+ /**
14
+ * Codex CLI adapter. Codex stores rollouts at
15
+ * ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl — a header/meta line
16
+ * plus event lines. The exact event schema has shifted across codex releases,
17
+ * so this parser is intentionally defensive: it extracts user/assistant
18
+ * messages + function (tool) calls from several known shapes and ignores the
19
+ * rest. Validated against fixtures; confirm against your real rollouts after
20
+ * `codex login`.
21
+ */
22
+
23
+ const UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
24
+
25
+ export function codexSessionsRoot(): string {
26
+ return join(homedir(), '.codex', 'sessions');
27
+ }
28
+
29
+ export interface CodexSessionInfo {
30
+ sessionId: string;
31
+ path: string;
32
+ }
33
+
34
+ export function discoverCodexSessions(root = codexSessionsRoot()): CodexSessionInfo[] {
35
+ if (!existsSync(root)) return [];
36
+ const out: CodexSessionInfo[] = [];
37
+ const walk = (dir: string, depth: number): void => {
38
+ if (depth > 6) return;
39
+ for (const e of readEntries(dir)) {
40
+ const name = String(e.name);
41
+ const full = join(dir, name);
42
+ if (e.isDirectory()) walk(full, depth + 1);
43
+ else if (e.isFile() && name.endsWith('.jsonl')) {
44
+ const m = UUID_RE.exec(name);
45
+ out.push({ sessionId: m ? m[1]! : name.replace(/\.jsonl$/, ''), path: full });
46
+ }
47
+ }
48
+ };
49
+ walk(root, 0);
50
+ return out;
51
+ }
52
+
53
+ function textFromContent(content: unknown): string {
54
+ if (typeof content === 'string') return content;
55
+ if (Array.isArray(content)) {
56
+ return content
57
+ .map((b) => {
58
+ if (typeof b === 'string') return b;
59
+ if (b && typeof b === 'object') {
60
+ const o = b as any;
61
+ if (typeof o.text === 'string') return o.text;
62
+ }
63
+ return '';
64
+ })
65
+ .filter(Boolean)
66
+ .join('\n');
67
+ }
68
+ if (content && typeof content === 'object' && typeof (content as any).text === 'string') {
69
+ return (content as any).text;
70
+ }
71
+ return '';
72
+ }
73
+
74
+ interface NormalizedCodex {
75
+ role: Turn['role'];
76
+ text: string;
77
+ tool_calls: ToolCall[];
78
+ }
79
+
80
+ /**
81
+ * Normalize one codex event line. Codex (0.14x) emits the conversation as
82
+ * `event_msg/{user_message,agent_message}` (payload.message is a string) and
83
+ * tool calls as `response_item/function_call`. `response_item/message` carries
84
+ * developer/permission scaffolding + reasoning, which we skip to avoid noise.
85
+ */
86
+ function normalizeCodexLine(ev: any): NormalizedCodex | null {
87
+ const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : ev;
88
+ const ptype = p?.type;
89
+
90
+ // primary conversation channel
91
+ if (ev?.type === 'event_msg') {
92
+ if (ptype === 'user_message' && typeof p.message === 'string') {
93
+ return { role: 'user', text: p.message, tool_calls: [] };
94
+ }
95
+ if (ptype === 'agent_message' && typeof p.message === 'string') {
96
+ return { role: 'assistant', text: p.message, tool_calls: [] };
97
+ }
98
+ return null; // task_started/complete/token_count/etc handled elsewhere
99
+ }
100
+
101
+ // tool calls live on response_item
102
+ if (ev?.type === 'response_item') {
103
+ if (ptype === 'function_call' || ptype === 'local_shell_call' || ptype === 'tool_call') {
104
+ const name = typeof p.name === 'string' ? p.name : ptype === 'local_shell_call' ? 'shell' : 'tool';
105
+ let input: unknown = p.arguments ?? p.input ?? p.action;
106
+ if (typeof input === 'string') {
107
+ try {
108
+ input = JSON.parse(input);
109
+ } catch {
110
+ /* keep string */
111
+ }
112
+ }
113
+ return { role: 'assistant', text: '', tool_calls: [{ name, input }] };
114
+ }
115
+ if (ptype === 'function_call_output' || ptype === 'tool_result') {
116
+ return { role: 'tool', text: textFromContent(p.output ?? p.content), tool_calls: [] };
117
+ }
118
+ // message / reasoning: scaffolding — skip
119
+ return null;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ interface CodexUsage {
125
+ input_tokens: number;
126
+ output_tokens: number;
127
+ cache_read_tokens: number;
128
+ }
129
+
130
+ function readTokenCount(ev: any): CodexUsage | null {
131
+ if (ev?.type !== 'event_msg' || ev?.payload?.type !== 'token_count') return null;
132
+ const u = ev.payload.info?.total_token_usage ?? ev.payload.info;
133
+ if (!u || typeof u !== 'object') return null;
134
+ return {
135
+ input_tokens: Number(u.input_tokens) || 0,
136
+ output_tokens: Number(u.output_tokens) || 0,
137
+ cache_read_tokens: Number(u.cached_input_tokens ?? u.cache_read_tokens) || 0,
138
+ };
139
+ }
140
+
141
+ function lineTs(ev: any, fallback: string): string {
142
+ const t = ev?.timestamp ?? ev?.ts;
143
+ if (typeof t === 'string' && !Number.isNaN(Date.parse(t))) return t;
144
+ return fallback;
145
+ }
146
+
147
+ export function parseCodexSession(info: CodexSessionInfo, host: string): ImportedSession | null {
148
+ const lines = readFileSync(info.path, 'utf8').split('\n').filter((l) => l.trim().length > 0);
149
+ if (lines.length === 0) return null;
150
+
151
+ let model: string | undefined;
152
+ let cwd: string | undefined;
153
+ let sessionId = info.sessionId;
154
+ let baseTs = '2020-01-01T00:00:00Z';
155
+ let latestUsage: CodexUsage | null = null;
156
+ let lastAssistantIdx = -1;
157
+
158
+ const turns: Turn[] = [];
159
+ let idx = 0;
160
+ for (const line of lines) {
161
+ let ev: any;
162
+ try {
163
+ ev = JSON.parse(line);
164
+ } catch {
165
+ continue;
166
+ }
167
+
168
+ // session_meta: metadata lives under payload (id, cwd, model, timestamp)
169
+ if (ev?.type === 'session_meta') {
170
+ const src = ev.payload && typeof ev.payload === 'object' ? ev.payload : ev;
171
+ if (typeof src.model === 'string') model = src.model;
172
+ if (typeof src.cwd === 'string') cwd = src.cwd;
173
+ if (typeof src.id === 'string') sessionId = UUID_RE.exec(src.id)?.[1] ?? sessionId;
174
+ const t = src.timestamp ?? ev.timestamp;
175
+ if (typeof t === 'string' && !Number.isNaN(Date.parse(t))) baseTs = t;
176
+ continue;
177
+ }
178
+ if (ev?.type === 'turn_context' && typeof ev.payload?.cwd === 'string' && !cwd) {
179
+ cwd = ev.payload.cwd;
180
+ }
181
+
182
+ const usage = readTokenCount(ev);
183
+ if (usage) {
184
+ latestUsage = usage; // cumulative; latest wins
185
+ continue;
186
+ }
187
+
188
+ const norm = normalizeCodexLine(ev);
189
+ if (!norm) continue;
190
+ const ts = lineTs(ev, new Date(Date.parse(baseTs) + idx * 1000).toISOString());
191
+ if (norm.role === 'assistant') lastAssistantIdx = turns.length;
192
+ turns.push({
193
+ schema: SCHEMA_VERSIONS.turn,
194
+ session_id: sessionId,
195
+ host,
196
+ agent: 'codex',
197
+ turn_index: idx++,
198
+ ts,
199
+ role: norm.role,
200
+ text: norm.text,
201
+ tool_calls: norm.tool_calls,
202
+ usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
203
+ scrubbed: false,
204
+ raw_ref: null,
205
+ });
206
+ }
207
+
208
+ if (turns.length === 0) return null;
209
+
210
+ // Codex reports cumulative usage via token_count; attribute it to the final
211
+ // assistant turn so the session envelope totals are non-zero.
212
+ if (latestUsage && lastAssistantIdx >= 0) {
213
+ turns[lastAssistantIdx]!.usage = {
214
+ input_tokens: latestUsage.input_tokens,
215
+ output_tokens: latestUsage.output_tokens,
216
+ cache_read_tokens: latestUsage.cache_read_tokens,
217
+ cache_write_tokens: 0,
218
+ };
219
+ }
220
+ const meta: ClaudeSessionMeta = {
221
+ session_id: sessionId,
222
+ started_at: turns[0]!.ts,
223
+ ended_at: turns[turns.length - 1]!.ts,
224
+ };
225
+ if (model) meta.model = model;
226
+ if (cwd) meta.project_path = cwd;
227
+ return { host, sessionId, agent: 'codex', turns, meta };
228
+ }
@@ -0,0 +1,179 @@
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 ClaudeSessionMeta,
7
+ type ToolCall,
8
+ type Turn,
9
+ } from '@unpolarize/code-sessions-schema';
10
+ import type { ImportedSession } from './import';
11
+
12
+ /**
13
+ * Grok Build CLI adapter. Grok stores each session as a directory under
14
+ * ~/.grok/sessions/<url-encoded-cwd>/<uuid>/ with chat_history.jsonl (event
15
+ * stream) + summary.json (metadata). Events carry no per-event timestamp, so
16
+ * we synthesize them from summary.created_at + line ordinal (ordering only).
17
+ */
18
+
19
+ export function grokSessionsRoot(): string {
20
+ return join(homedir(), '.grok', 'sessions');
21
+ }
22
+
23
+ export interface GrokSessionInfo {
24
+ sessionId: string;
25
+ chatPath: string;
26
+ summaryPath: string;
27
+ cwd: string;
28
+ }
29
+
30
+ function safeDirs(dir: string): string[] {
31
+ try {
32
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
38
+ export function discoverGrokSessions(root = grokSessionsRoot()): GrokSessionInfo[] {
39
+ if (!existsSync(root)) return [];
40
+ const out: GrokSessionInfo[] = [];
41
+ for (const enc of safeDirs(root)) {
42
+ let cwd = enc;
43
+ try {
44
+ cwd = decodeURIComponent(enc);
45
+ } catch {
46
+ /* keep raw */
47
+ }
48
+ for (const uuid of safeDirs(join(root, enc))) {
49
+ const dir = join(root, enc, uuid);
50
+ const chatPath = join(dir, 'chat_history.jsonl');
51
+ const summaryPath = join(dir, 'summary.json');
52
+ if (existsSync(chatPath)) out.push({ sessionId: uuid, chatPath, summaryPath, cwd });
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function extractText(content: unknown): string {
59
+ if (typeof content === 'string') return content;
60
+ if (Array.isArray(content)) {
61
+ return content
62
+ .map((b) => (typeof b === 'string' ? b : b && typeof b === 'object' && (b as any).type === 'text' ? String((b as any).text ?? '') : ''))
63
+ .filter(Boolean)
64
+ .join('\n\n');
65
+ }
66
+ return '';
67
+ }
68
+
69
+ function parseArgs(s: unknown): unknown {
70
+ if (typeof s !== 'string') return s;
71
+ try {
72
+ return JSON.parse(s);
73
+ } catch {
74
+ return s;
75
+ }
76
+ }
77
+
78
+ interface GrokSummary {
79
+ created_at?: string;
80
+ updated_at?: string;
81
+ last_active_at?: string;
82
+ generated_title?: string;
83
+ session_summary?: string;
84
+ current_model_id?: string;
85
+ session_kind?: string;
86
+ info?: { cwd?: string };
87
+ }
88
+
89
+ /** Parse one grok session into a canonical ImportedSession (or null to skip). */
90
+ export function parseGrokSession(info: GrokSessionInfo, host: string): ImportedSession | null {
91
+ let summary: GrokSummary = {};
92
+ if (existsSync(info.summaryPath)) {
93
+ try {
94
+ summary = JSON.parse(readFileSync(info.summaryPath, 'utf8')) as GrokSummary;
95
+ } catch {
96
+ /* defaults */
97
+ }
98
+ }
99
+ if (summary.session_kind === 'claude_import') return null; // claude indexer is authoritative
100
+
101
+ const baseMs = Date.parse(summary.created_at ?? '') || statMtime(info.chatPath);
102
+ const lines = readFileSync(info.chatPath, 'utf8').split('\n').filter((l) => l.trim().length > 0);
103
+
104
+ const turns: Turn[] = [];
105
+ let idx = 0;
106
+ let model = summary.current_model_id;
107
+ for (const line of lines) {
108
+ let ev: any;
109
+ try {
110
+ ev = JSON.parse(line);
111
+ } catch {
112
+ continue;
113
+ }
114
+ const ts = new Date(baseMs + idx * 1000).toISOString();
115
+ if (ev.type === 'user') {
116
+ turns.push(mkTurn(info.sessionId, host, idx++, ts, 'user', extractText(ev.content), []));
117
+ } else if (ev.type === 'assistant') {
118
+ if (typeof ev.model_id === 'string') model = ev.model_id;
119
+ const tools: ToolCall[] = Array.isArray(ev.tool_calls)
120
+ ? ev.tool_calls
121
+ .filter((t: any) => t && typeof t.name === 'string')
122
+ .map((t: any) => ({ name: t.name, input: parseArgs(t.arguments), ...(t.id ? { id: t.id } : {}) }))
123
+ : [];
124
+ turns.push(mkTurn(info.sessionId, host, idx++, ts, 'assistant', extractText(ev.content), tools));
125
+ } else if (ev.type === 'tool_result') {
126
+ turns.push(mkTurn(info.sessionId, host, idx++, ts, 'tool', extractText(ev.content), []));
127
+ }
128
+ // system / reasoning: skipped
129
+ }
130
+
131
+ if (turns.length === 0) return null;
132
+
133
+ const startedAt = new Date(baseMs).toISOString();
134
+ const endedAt = turns[turns.length - 1]!.ts;
135
+ const meta: ClaudeSessionMeta = {
136
+ session_id: info.sessionId,
137
+ project_path: summary.info?.cwd ?? info.cwd,
138
+ started_at: startedAt,
139
+ ended_at: endedAt,
140
+ };
141
+ if (model) meta.model = model;
142
+ const title = summary.generated_title?.trim() || summary.session_summary?.trim();
143
+ if (title) meta.title = title;
144
+
145
+ return { host, sessionId: info.sessionId, agent: 'grok', turns, meta };
146
+ }
147
+
148
+ function statMtime(p: string): number {
149
+ try {
150
+ return statSync(p).mtimeMs;
151
+ } catch {
152
+ return Date.parse('2020-01-01T00:00:00Z');
153
+ }
154
+ }
155
+
156
+ function mkTurn(
157
+ sessionId: string,
158
+ host: string,
159
+ index: number,
160
+ ts: string,
161
+ role: Turn['role'],
162
+ text: string,
163
+ tool_calls: ToolCall[],
164
+ ): Turn {
165
+ return {
166
+ schema: SCHEMA_VERSIONS.turn,
167
+ session_id: sessionId,
168
+ host,
169
+ agent: 'grok',
170
+ turn_index: index,
171
+ ts,
172
+ role,
173
+ text,
174
+ tool_calls,
175
+ usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
176
+ scrubbed: false,
177
+ raw_ref: null,
178
+ };
179
+ }
@@ -0,0 +1,79 @@
1
+ import type {
2
+ AgentKind,
3
+ ClaudeSessionMeta,
4
+ SessionEnvelope,
5
+ Turn,
6
+ } from '@unpolarize/code-sessions-schema';
7
+ import type { CodeSessionsConfig } from '../config';
8
+ import { applyHygiene } from '../hygiene';
9
+ import { monthOf, sessionDir } from '../store/paths';
10
+ import { computeEnvelope, writeBlobFile, writeTurnFile } from '../store/writer';
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+ import { envelopeFile } from '../store/paths';
14
+
15
+ const NATIVE_FORMAT: Record<string, string> = {
16
+ 'claude-code': 'claude-jsonl',
17
+ grok: 'grok-jsonl',
18
+ codex: 'codex-rollout',
19
+ unknown: 'unknown',
20
+ };
21
+
22
+ export interface ImportedSession {
23
+ host: string;
24
+ sessionId: string;
25
+ agent: AgentKind;
26
+ turns: Turn[];
27
+ meta: ClaudeSessionMeta;
28
+ }
29
+
30
+ export interface ImportResult {
31
+ sessionId: string;
32
+ sessionDir: string;
33
+ turns: number;
34
+ envelope: SessionEnvelope;
35
+ }
36
+
37
+ function writeJsonAtomic(path: string, value: unknown): void {
38
+ mkdirSync(dirname(path), { recursive: true });
39
+ const tmp = `${path}.tmp`;
40
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
41
+ renameSync(tmp, path);
42
+ }
43
+
44
+ /**
45
+ * Persist an imported (non-claude) session into the store: hygiene each turn,
46
+ * write immutable per-turn files, derive + write the envelope. Reuses the same
47
+ * writer the live claude capture path uses, so all agents land in one store.
48
+ */
49
+ export function writeImportedSession(cfg: CodeSessionsConfig, s: ImportedSession): ImportResult {
50
+ const month = monthOf(s.meta.started_at ?? s.turns[0]?.ts);
51
+ const dir = sessionDir(cfg.storeDir, s.host, month, s.sessionId);
52
+
53
+ for (const turn of s.turns) {
54
+ const hy = applyHygiene(turn, cfg.hygiene);
55
+ if (hy.blob) writeBlobFile(dir, hy.blob.sha, hy.blob.content);
56
+ writeTurnFile(dir, hy.turn);
57
+ }
58
+
59
+ const env = computeEnvelope(s.turns, s.meta, {
60
+ session_id: s.sessionId,
61
+ host: s.host,
62
+ agent: s.agent,
63
+ native_uuid: s.sessionId,
64
+ });
65
+ env.native_ref.format = NATIVE_FORMAT[s.agent] ?? 'unknown';
66
+ // preserve labels if a prior envelope exists
67
+ const envPath = envelopeFile(dir);
68
+ if (existsSync(envPath)) {
69
+ try {
70
+ const prev = JSON.parse(readFileSync(envPath, 'utf8')) as SessionEnvelope;
71
+ if (prev.labels?.length) env.labels = prev.labels;
72
+ } catch {
73
+ /* ignore */
74
+ }
75
+ }
76
+ writeJsonAtomic(envPath, env);
77
+
78
+ return { sessionId: s.sessionId, sessionDir: dir, turns: s.turns.length, envelope: env };
79
+ }
@@ -0,0 +1,3 @@
1
+ export * from './import';
2
+ export * from './grok';
3
+ export * from './codex';
@@ -0,0 +1,94 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { Turn } from '@unpolarize/code-sessions-schema';
5
+ import { FakeProvider } from '../insights/provider';
6
+ import { labelSession } from '../insights/labeler';
7
+ import { sessionDir } from '../store/paths';
8
+ import { rebuildEnvelope, writeTurnFile } from '../store/writer';
9
+ import { makeConfig, withTempDirAsync } from '../test/tmp';
10
+ import { cmdAnalytics } from './command';
11
+ import { renderDigest } from './digest';
12
+ import { computeReport } from './rollup';
13
+ import { renderSite } from './site';
14
+
15
+ function turn(i: number, over: Partial<Turn> = {}): Turn {
16
+ return {
17
+ schema: 'session-store/turn@1',
18
+ session_id: 's',
19
+ host: 'h',
20
+ agent: 'claude-code',
21
+ turn_index: i,
22
+ ts: `2026-06-20T08:0${i}:00Z`,
23
+ role: 'assistant',
24
+ text: '',
25
+ tool_calls: [],
26
+ usage: { input_tokens: 100, output_tokens: 10, cache_read_tokens: 0, cache_write_tokens: 0 },
27
+ scrubbed: false,
28
+ raw_ref: null,
29
+ ...over,
30
+ };
31
+ }
32
+
33
+ async function seed(store: string, sessionId: string): Promise<void> {
34
+ const dir = sessionDir(store, 'h', '2026-06', sessionId);
35
+ writeTurnFile(dir, turn(0, { role: 'user', text: 'Fix the bug in foo.ts' }));
36
+ writeTurnFile(dir, turn(1, { tool_calls: [{ name: 'Edit' }], telemetry: { cost_usd: 0.9 } }));
37
+ rebuildEnvelope(store, 'h', '2026-06', sessionId, { model: 'claude-opus-4-8' }, {
38
+ session_id: sessionId,
39
+ host: 'h',
40
+ agent: 'claude-code',
41
+ native_uuid: sessionId,
42
+ });
43
+ await labelSession(dir, { sessionId, host: 'h' }, new FakeProvider(), { now: '2026-06-20T09:00:00Z' });
44
+ }
45
+
46
+ describe('computeReport', () => {
47
+ it('aggregates totals, tags, signals, and similar sessions', async () => {
48
+ await withTempDirAsync(async (store) => {
49
+ await seed(store, 's1');
50
+ await seed(store, 's2');
51
+ const report = computeReport(store, '2026-06-20T10:00:00Z');
52
+ expect(report.sessions).toBe(2);
53
+ expect(report.hosts.h).toBe(2);
54
+ expect(report.totals.input_tokens).toBe(400);
55
+ expect(report.topTags.find((t) => t.tag === 'Edit')?.count).toBe(2);
56
+ expect(report.signalCounts['high-cost-turn']).toBe(2);
57
+ // both sessions share the 'Edit' tag -> similar
58
+ const sim = report.similar.find((s) => s.tag === 'Edit');
59
+ expect(sim?.sessions.sort()).toEqual(['s1', 's2']);
60
+ expect(report.byMonth['2026-06']?.sessions).toBe(2);
61
+ });
62
+ });
63
+ });
64
+
65
+ describe('renderDigest / renderSite', () => {
66
+ it('produces markdown + html from a report', async () => {
67
+ await withTempDirAsync(async (store) => {
68
+ await seed(store, 's1');
69
+ const report = computeReport(store, '2026-06-20T10:00:00Z');
70
+ const md = renderDigest(report);
71
+ expect(md).toContain('# Session digest');
72
+ expect(md).toContain('Estimated cost');
73
+ const html = renderSite(report);
74
+ expect(html).toContain('<!doctype html>');
75
+ expect(html).toContain('code-sessions');
76
+ });
77
+ });
78
+ });
79
+
80
+ describe('cmdAnalytics', () => {
81
+ it('writes report.json, digest.md, and index.html into the store', async () => {
82
+ await withTempDirAsync(async (store) => {
83
+ await seed(store, 's1');
84
+ const res = await cmdAnalytics(makeConfig(store), { now: '2026-06-20T10:00:00Z' });
85
+ expect(res.code).toBe(0);
86
+ const dir = join(store, 'analytics');
87
+ expect(existsSync(join(dir, 'report.json'))).toBe(true);
88
+ expect(existsSync(join(dir, 'digest.md'))).toBe(true);
89
+ expect(existsSync(join(dir, 'index.html'))).toBe(true);
90
+ const report = JSON.parse(readFileSync(join(dir, 'report.json'), 'utf8'));
91
+ expect(report.sessions).toBe(1);
92
+ });
93
+ });
94
+ });
@@ -0,0 +1,38 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { CodeSessionsConfig } from '../config';
4
+ import type { CommandResult } from '../commands';
5
+ import { GitStore } from '../store/git';
6
+ import { renderDigest } from './digest';
7
+ import { computeReport } from './rollup';
8
+ import { renderSite } from './site';
9
+
10
+ export interface AnalyticsOptions {
11
+ now?: string;
12
+ }
13
+
14
+ /** Compute MVP-2 rollups and write report.json + digest.md + index.html under analytics/. */
15
+ export async function cmdAnalytics(
16
+ cfg: CodeSessionsConfig,
17
+ opts: AnalyticsOptions = {},
18
+ ): Promise<CommandResult> {
19
+ const now = opts.now ?? new Date().toISOString();
20
+ const report = computeReport(cfg.storeDir, now);
21
+
22
+ const dir = join(cfg.storeDir, 'analytics');
23
+ mkdirSync(dir, { recursive: true });
24
+ writeFileSync(join(dir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`);
25
+ writeFileSync(join(dir, 'digest.md'), renderDigest(report));
26
+ writeFileSync(join(dir, 'index.html'), renderSite(report));
27
+
28
+ const git = new GitStore(cfg.storeDir, {
29
+ ...(cfg.git.remote ? { remote: cfg.git.remote } : {}),
30
+ autoPush: cfg.git.autoPush,
31
+ });
32
+ if (git.isRepo()) git.sync(`analytics rollup (${report.sessions} sessions)`);
33
+
34
+ return {
35
+ code: 0,
36
+ output: `Analytics written for ${report.sessions} session(s) → ${dir}`,
37
+ };
38
+ }
@@ -0,0 +1,48 @@
1
+ import type { AnalyticsReport } from './rollup';
2
+
3
+ /** Render a human-readable Markdown digest from an analytics report. */
4
+ export function renderDigest(report: AnalyticsReport): string {
5
+ const lines: string[] = [];
6
+ lines.push('# Session digest');
7
+ lines.push('');
8
+ lines.push(`_Generated ${report.generated_at}_`);
9
+ lines.push('');
10
+ lines.push(`- **Sessions:** ${report.sessions}`);
11
+ lines.push(
12
+ `- **Tokens:** ${report.totals.input_tokens.toLocaleString()} in / ${report.totals.output_tokens.toLocaleString()} out`,
13
+ );
14
+ lines.push(`- **Estimated cost:** $${report.totals.cost_usd.toFixed(2)}`);
15
+ lines.push(`- **Hosts:** ${Object.entries(report.hosts).map(([h, n]) => `${h} (${n})`).join(', ') || '—'}`);
16
+ lines.push('');
17
+
18
+ if (report.topTopics.length) {
19
+ lines.push('## Top topics');
20
+ for (const t of report.topTopics) lines.push(`- ${t.topic} — ${t.count}`);
21
+ lines.push('');
22
+ }
23
+ if (report.topTags.length) {
24
+ lines.push('## Top tags');
25
+ lines.push(report.topTags.map((t) => `\`${t.tag}\` (${t.count})`).join(' · '));
26
+ lines.push('');
27
+ }
28
+ if (Object.keys(report.signalCounts).length) {
29
+ lines.push('## Signals');
30
+ for (const [kind, count] of Object.entries(report.signalCounts).sort((a, b) => b[1] - a[1])) {
31
+ lines.push(`- ${kind}: ${count}`);
32
+ }
33
+ lines.push('');
34
+ }
35
+ if (report.similar.length) {
36
+ lines.push('## Related sessions (shared tags)');
37
+ for (const s of report.similar) lines.push(`- \`${s.tag}\`: ${s.sessions.length} sessions`);
38
+ lines.push('');
39
+ }
40
+ if (Object.keys(report.byMonth).length) {
41
+ lines.push('## By month');
42
+ for (const [month, m] of Object.entries(report.byMonth).sort()) {
43
+ lines.push(`- ${month}: ${m.sessions} sessions, $${m.cost_usd.toFixed(2)}`);
44
+ }
45
+ lines.push('');
46
+ }
47
+ return lines.join('\n');
48
+ }