@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.
- package/bin/code-sessions.mjs +20 -0
- package/dist/chunk-ZJG2DWAK.js +2321 -0
- package/dist/cli.js +308 -0
- package/dist/index.js +162 -0
- package/package.json +21 -0
- package/src/adapters/adapters.test.ts +121 -0
- package/src/adapters/codex.ts +228 -0
- package/src/adapters/grok.ts +179 -0
- package/src/adapters/import.ts +79 -0
- package/src/adapters/index.ts +3 -0
- package/src/analytics/analytics.test.ts +94 -0
- package/src/analytics/command.ts +38 -0
- package/src/analytics/digest.ts +48 -0
- package/src/analytics/rollup.ts +114 -0
- package/src/analytics/site.ts +41 -0
- package/src/capture.test.ts +103 -0
- package/src/capture.ts +121 -0
- package/src/cli.ts +118 -0
- package/src/cliargs.test.ts +31 -0
- package/src/cliargs.ts +77 -0
- package/src/commands.test.ts +99 -0
- package/src/commands.ts +266 -0
- package/src/config.test.ts +36 -0
- package/src/config.ts +158 -0
- package/src/daemon.test.ts +130 -0
- package/src/daemon.ts +216 -0
- package/src/hooks/install.test.ts +47 -0
- package/src/hooks/install.ts +81 -0
- package/src/hooks/shim.test.ts +57 -0
- package/src/hooks/shim.ts +26 -0
- package/src/hygiene.test.ts +78 -0
- package/src/hygiene.ts +107 -0
- package/src/index.ts +21 -0
- package/src/index_store/db.test.ts +108 -0
- package/src/index_store/db.ts +289 -0
- package/src/index_store/index.ts +2 -0
- package/src/index_store/sync.test.ts +88 -0
- package/src/index_store/sync.ts +83 -0
- package/src/insights/heuristics.test.ts +71 -0
- package/src/insights/heuristics.ts +106 -0
- package/src/insights/index.ts +4 -0
- package/src/insights/labeler.test.ts +105 -0
- package/src/insights/labeler.ts +136 -0
- package/src/insights/llm.test.ts +77 -0
- package/src/insights/llm.ts +130 -0
- package/src/insights/provider.ts +37 -0
- package/src/ipc.test.ts +35 -0
- package/src/ipc.ts +70 -0
- package/src/pricing.test.ts +28 -0
- package/src/pricing.ts +45 -0
- package/src/state.test.ts +46 -0
- package/src/state.ts +89 -0
- package/src/store/git.test.ts +99 -0
- package/src/store/git.ts +138 -0
- package/src/store/paths.ts +45 -0
- package/src/store/scan.ts +39 -0
- package/src/store/writer.test.ts +93 -0
- package/src/store/writer.ts +135 -0
- package/src/tail.test.ts +50 -0
- package/src/tail.ts +47 -0
- package/src/telemetry/exporter.test.ts +104 -0
- package/src/telemetry/exporter.ts +64 -0
- package/src/telemetry/index.ts +2 -0
- package/src/telemetry/otlp.test.ts +123 -0
- package/src/telemetry/otlp.ts +215 -0
- package/src/test/e2e.test.ts +112 -0
- 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,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
|
+
}
|