@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,215 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { SessionEnvelope, Turn } from '@unpolarize/code-sessions-schema';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal OTLP/HTTP JSON exporter — emits standard OpenTelemetry traces +
|
|
6
|
+
* metrics for captured sessions to any OTLP collector, with no SDK dependency.
|
|
7
|
+
* One trace per session (root span + a child span per turn) + token/cost metrics.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type AnyValue =
|
|
11
|
+
| { stringValue: string }
|
|
12
|
+
| { intValue: number }
|
|
13
|
+
| { doubleValue: number }
|
|
14
|
+
| { boolValue: boolean };
|
|
15
|
+
|
|
16
|
+
interface KeyValue {
|
|
17
|
+
key: string;
|
|
18
|
+
value: AnyValue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function attr(key: string, value: string | number | boolean): KeyValue {
|
|
22
|
+
if (typeof value === 'boolean') return { key, value: { boolValue: value } };
|
|
23
|
+
if (typeof value === 'string') return { key, value: { stringValue: value } };
|
|
24
|
+
return Number.isInteger(value)
|
|
25
|
+
? { key, value: { intValue: value } }
|
|
26
|
+
: { key, value: { doubleValue: value } };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hexId(input: string, bytes: number): string {
|
|
30
|
+
return createHash('sha256').update(input).digest('hex').slice(0, bytes * 2);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** ISO-8601 → unix nanoseconds as a string (avoids float precision loss). */
|
|
34
|
+
export function isoNano(ts: string | undefined): string {
|
|
35
|
+
if (!ts) return '0';
|
|
36
|
+
const ms = Date.parse(ts);
|
|
37
|
+
return Number.isNaN(ms) ? '0' : `${ms}000000`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SCOPE = { name: 'code-sessions', version: '0.1.0' };
|
|
41
|
+
|
|
42
|
+
function resource(serviceName: string, host: string): { attributes: KeyValue[] } {
|
|
43
|
+
return {
|
|
44
|
+
attributes: [
|
|
45
|
+
attr('service.name', serviceName),
|
|
46
|
+
attr('host.name', host),
|
|
47
|
+
attr('telemetry.sdk.name', 'code-sessions'),
|
|
48
|
+
attr('telemetry.sdk.language', 'nodejs'),
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sumUsage(turns: Turn[]) {
|
|
54
|
+
const u = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
55
|
+
for (const t of turns) {
|
|
56
|
+
u.input += t.usage.input_tokens;
|
|
57
|
+
u.output += t.usage.output_tokens;
|
|
58
|
+
u.cacheRead += t.usage.cache_read_tokens;
|
|
59
|
+
u.cacheWrite += t.usage.cache_write_tokens;
|
|
60
|
+
u.cost += t.telemetry?.cost_usd ?? 0;
|
|
61
|
+
}
|
|
62
|
+
return u;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildTracePayload(
|
|
66
|
+
session: SessionEnvelope,
|
|
67
|
+
turns: Turn[],
|
|
68
|
+
serviceName: string,
|
|
69
|
+
): unknown {
|
|
70
|
+
const traceId = hexId(session.session_id, 16);
|
|
71
|
+
const rootId = hexId(`${session.session_id}:root`, 8);
|
|
72
|
+
const totals = sumUsage(turns);
|
|
73
|
+
|
|
74
|
+
const rootSpan = {
|
|
75
|
+
traceId,
|
|
76
|
+
spanId: rootId,
|
|
77
|
+
name: `session ${session.title ?? session.session_id}`,
|
|
78
|
+
kind: 1,
|
|
79
|
+
startTimeUnixNano: isoNano(session.started_at),
|
|
80
|
+
endTimeUnixNano: isoNano(session.ended_at ?? session.started_at),
|
|
81
|
+
attributes: [
|
|
82
|
+
attr('session.id', session.session_id),
|
|
83
|
+
attr('gen_ai.system', session.agent),
|
|
84
|
+
...(session.model ? [attr('gen_ai.request.model', session.model)] : []),
|
|
85
|
+
attr('session.turn_count', session.turn_count),
|
|
86
|
+
attr('gen_ai.usage.input_tokens', totals.input),
|
|
87
|
+
attr('gen_ai.usage.output_tokens', totals.output),
|
|
88
|
+
attr('code_sessions.cost_usd', Math.round(totals.cost * 1e6) / 1e6),
|
|
89
|
+
...(session.project_path ? [attr('project.path', session.project_path)] : []),
|
|
90
|
+
],
|
|
91
|
+
status: {},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const turnSpans = turns.map((t) => ({
|
|
95
|
+
traceId,
|
|
96
|
+
spanId: hexId(`${session.session_id}:${t.turn_index}`, 8),
|
|
97
|
+
parentSpanId: rootId,
|
|
98
|
+
name: `turn ${t.turn_index} ${t.role}`,
|
|
99
|
+
kind: 1,
|
|
100
|
+
startTimeUnixNano: isoNano(t.ts),
|
|
101
|
+
endTimeUnixNano: isoNano(t.ts),
|
|
102
|
+
attributes: [
|
|
103
|
+
attr('turn.index', t.turn_index),
|
|
104
|
+
attr('gen_ai.role', t.role),
|
|
105
|
+
attr('gen_ai.usage.input_tokens', t.usage.input_tokens),
|
|
106
|
+
attr('gen_ai.usage.output_tokens', t.usage.output_tokens),
|
|
107
|
+
attr('code_sessions.tool_count', t.tool_calls.length),
|
|
108
|
+
attr('code_sessions.cost_usd', t.telemetry?.cost_usd ?? 0),
|
|
109
|
+
],
|
|
110
|
+
status: {},
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
resourceSpans: [
|
|
115
|
+
{
|
|
116
|
+
resource: resource(serviceName, session.host),
|
|
117
|
+
scopeSpans: [{ scope: SCOPE, spans: [rootSpan, ...turnSpans] }],
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildMetricPayload(
|
|
124
|
+
session: SessionEnvelope,
|
|
125
|
+
turns: Turn[],
|
|
126
|
+
serviceName: string,
|
|
127
|
+
): unknown {
|
|
128
|
+
const totals = sumUsage(turns);
|
|
129
|
+
const time = isoNano(session.ended_at ?? session.started_at);
|
|
130
|
+
const base = [attr('session.id', session.session_id), attr('gen_ai.system', session.agent)];
|
|
131
|
+
|
|
132
|
+
const tokenPoint = (type: string, value: number) => ({
|
|
133
|
+
asInt: value,
|
|
134
|
+
timeUnixNano: time,
|
|
135
|
+
attributes: [...base, attr('gen_ai.token.type', type)],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
resourceMetrics: [
|
|
140
|
+
{
|
|
141
|
+
resource: resource(serviceName, session.host),
|
|
142
|
+
scopeMetrics: [
|
|
143
|
+
{
|
|
144
|
+
scope: SCOPE,
|
|
145
|
+
metrics: [
|
|
146
|
+
{
|
|
147
|
+
name: 'code_sessions.tokens',
|
|
148
|
+
unit: '{token}',
|
|
149
|
+
sum: {
|
|
150
|
+
aggregationTemporality: 2,
|
|
151
|
+
isMonotonic: true,
|
|
152
|
+
dataPoints: [
|
|
153
|
+
tokenPoint('input', totals.input),
|
|
154
|
+
tokenPoint('output', totals.output),
|
|
155
|
+
tokenPoint('cache_read', totals.cacheRead),
|
|
156
|
+
tokenPoint('cache_write', totals.cacheWrite),
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'code_sessions.cost_usd',
|
|
162
|
+
unit: 'USD',
|
|
163
|
+
gauge: {
|
|
164
|
+
dataPoints: [
|
|
165
|
+
{ asDouble: Math.round(totals.cost * 1e6) / 1e6, timeUnixNano: time, attributes: base },
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'code_sessions.turns',
|
|
171
|
+
unit: '{turn}',
|
|
172
|
+
sum: {
|
|
173
|
+
aggregationTemporality: 2,
|
|
174
|
+
isMonotonic: true,
|
|
175
|
+
dataPoints: [{ asInt: turns.length, timeUnixNano: time, attributes: base }],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface PostResult {
|
|
187
|
+
ok: boolean;
|
|
188
|
+
status?: number;
|
|
189
|
+
error?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** POST an OTLP/HTTP JSON payload; resilient — never throws, returns a result. */
|
|
193
|
+
export async function postOtlp(
|
|
194
|
+
endpoint: string,
|
|
195
|
+
signalPath: '/v1/traces' | '/v1/metrics',
|
|
196
|
+
payload: unknown,
|
|
197
|
+
timeoutMs: number,
|
|
198
|
+
): Promise<PostResult> {
|
|
199
|
+
const url = `${endpoint.replace(/\/$/, '')}${signalPath}`;
|
|
200
|
+
const controller = new AbortController();
|
|
201
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
202
|
+
try {
|
|
203
|
+
const res = await fetch(url, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: { 'content-type': 'application/json' },
|
|
206
|
+
body: JSON.stringify(payload),
|
|
207
|
+
signal: controller.signal,
|
|
208
|
+
});
|
|
209
|
+
return { ok: res.ok, status: res.status };
|
|
210
|
+
} catch (e) {
|
|
211
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
212
|
+
} finally {
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { startDaemon } from '../commands';
|
|
7
|
+
import { sendEvent } from '../ipc';
|
|
8
|
+
import { envelopeFile, insightsFile, sessionDir, turnFile } from '../store/paths';
|
|
9
|
+
import { makeConfig, withTempDirAsync } from './tmp';
|
|
10
|
+
|
|
11
|
+
const BIN = fileURLToPath(new URL('../../bin/code-sessions.mjs', import.meta.url));
|
|
12
|
+
|
|
13
|
+
const USER =
|
|
14
|
+
'{"type":"user","sessionId":"e2e","cwd":"/p","timestamp":"2026-06-20T08:00:00Z","message":{"role":"user","content":"Fix the bug in foo.ts"}}';
|
|
15
|
+
const ASSISTANT =
|
|
16
|
+
'{"type":"assistant","timestamp":"2026-06-20T08:00:05Z","message":{"role":"assistant","model":"claude-opus-4-8","content":[{"type":"text","text":"ok"},{"type":"tool_use","id":"t","name":"Edit","input":{}}],"usage":{"input_tokens":1000,"output_tokens":20}}}';
|
|
17
|
+
const DONE =
|
|
18
|
+
'{"type":"assistant","timestamp":"2026-06-20T08:01:00Z","message":{"role":"assistant","model":"claude-opus-4-8","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":50,"output_tokens":5}}}';
|
|
19
|
+
|
|
20
|
+
function gitLogCount(repo: string): number {
|
|
21
|
+
const r = spawnSync('git', ['-C', repo, 'log', '--oneline'], { encoding: 'utf8' });
|
|
22
|
+
return r.status === 0 ? r.stdout.trim().split('\n').filter(Boolean).length : 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('e2e: live capture pipeline (socket + insights on Stop)', () => {
|
|
26
|
+
it('captures turns, derives insights, and commits — across hook events', async () => {
|
|
27
|
+
await withTempDirAsync(async (root) => {
|
|
28
|
+
const store = join(root, 'store');
|
|
29
|
+
const src = join(root, 'src');
|
|
30
|
+
mkdirSync(src, { recursive: true });
|
|
31
|
+
const transcript = join(src, 'e2e.jsonl');
|
|
32
|
+
writeFileSync(transcript, `${USER}\n${ASSISTANT}\n`);
|
|
33
|
+
|
|
34
|
+
const cfg = makeConfig(store, {
|
|
35
|
+
socketPath: join(root, 'd.sock'),
|
|
36
|
+
insights: { provider: 'fake', mode: 'on-stop' },
|
|
37
|
+
});
|
|
38
|
+
const daemon = await startDaemon(cfg);
|
|
39
|
+
try {
|
|
40
|
+
const a1 = await sendEvent(cfg.socketPath, {
|
|
41
|
+
event: 'PostToolUse',
|
|
42
|
+
session_id: 'e2e',
|
|
43
|
+
transcript_path: transcript,
|
|
44
|
+
});
|
|
45
|
+
expect(a1.ok).toBe(true);
|
|
46
|
+
expect(a1.newTurns).toBe(2);
|
|
47
|
+
|
|
48
|
+
appendFileSync(transcript, `${DONE}\n`);
|
|
49
|
+
const a2 = await sendEvent(cfg.socketPath, {
|
|
50
|
+
event: 'Stop',
|
|
51
|
+
session_id: 'e2e',
|
|
52
|
+
transcript_path: transcript,
|
|
53
|
+
});
|
|
54
|
+
expect(a2.ok).toBe(true);
|
|
55
|
+
expect(a2.newTurns).toBe(1);
|
|
56
|
+
expect(a2.flushed).toBe(true);
|
|
57
|
+
} finally {
|
|
58
|
+
await daemon.stop();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const dir = sessionDir(store, cfg.host, '2026-06', 'e2e');
|
|
62
|
+
expect(existsSync(turnFile(dir, 0))).toBe(true);
|
|
63
|
+
expect(existsSync(turnFile(dir, 2))).toBe(true);
|
|
64
|
+
|
|
65
|
+
const env = JSON.parse(readFileSync(envelopeFile(dir), 'utf8'));
|
|
66
|
+
expect(env.turn_count).toBe(3);
|
|
67
|
+
expect(env.model).toBe('claude-opus-4-8');
|
|
68
|
+
|
|
69
|
+
const insights = JSON.parse(readFileSync(insightsFile(dir), 'utf8'));
|
|
70
|
+
expect(insights.provider).toBe('fake');
|
|
71
|
+
expect(insights.topic).toContain('Fix the bug');
|
|
72
|
+
expect(insights.tags).toContain('Edit');
|
|
73
|
+
|
|
74
|
+
expect(gitLogCount(store)).toBeGreaterThanOrEqual(1);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('e2e: CLI binary (backfill -> reindex -> analytics -> status)', () => {
|
|
80
|
+
it('runs the real bin against a fixture projects dir', async () => {
|
|
81
|
+
await withTempDirAsync(async (root) => {
|
|
82
|
+
const store = join(root, 'store');
|
|
83
|
+
const projects = join(root, 'projects', 'enc');
|
|
84
|
+
mkdirSync(projects, { recursive: true });
|
|
85
|
+
writeFileSync(join(projects, 'cli-1.jsonl'), `${USER}\n${ASSISTANT}\n${DONE}\n`);
|
|
86
|
+
|
|
87
|
+
const run = (...args: string[]) =>
|
|
88
|
+
spawnSync(process.execPath, [BIN, ...args, '--store', store, '--host', 'cli-host'], {
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const backfill = run('backfill', '--projects', join(root, 'projects'));
|
|
93
|
+
expect(backfill.status).toBe(0);
|
|
94
|
+
expect(backfill.stdout).toMatch(/Backfilled 1 session/);
|
|
95
|
+
|
|
96
|
+
const reindex = run('reindex', '--provider', 'fake');
|
|
97
|
+
expect(reindex.status).toBe(0);
|
|
98
|
+
expect(reindex.stdout).toMatch(/Reindexed 1 session/);
|
|
99
|
+
|
|
100
|
+
const analytics = run('analytics');
|
|
101
|
+
expect(analytics.status).toBe(0);
|
|
102
|
+
|
|
103
|
+
const status = run('status');
|
|
104
|
+
expect(status.status).toBe(0);
|
|
105
|
+
expect(status.stdout).toMatch(/stored:\s+1 session/);
|
|
106
|
+
|
|
107
|
+
const dir = sessionDir(store, 'cli-host', '2026-06', 'cli-1');
|
|
108
|
+
expect(existsSync(insightsFile(dir))).toBe(true);
|
|
109
|
+
expect(existsSync(join(store, 'analytics', 'report.json'))).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
}, 30000);
|
|
112
|
+
});
|
package/src/test/tmp.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { defaultConfig, resolveConfig, type CodeSessionsConfig, type DeepPartial } from '../config';
|
|
5
|
+
|
|
6
|
+
/** Create a throwaway temp dir, run fn, always clean up. */
|
|
7
|
+
export function withTempDir<T>(fn: (dir: string) => T): T {
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), 'cs-test-'));
|
|
9
|
+
try {
|
|
10
|
+
return fn(dir);
|
|
11
|
+
} finally {
|
|
12
|
+
rmSync(dir, { recursive: true, force: true });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Async variant: awaits fn before cleaning up the temp dir. */
|
|
17
|
+
export async function withTempDirAsync<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), 'cs-test-'));
|
|
19
|
+
try {
|
|
20
|
+
return await fn(dir);
|
|
21
|
+
} finally {
|
|
22
|
+
rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function makeConfig(
|
|
27
|
+
storeDir: string,
|
|
28
|
+
override: DeepPartial<CodeSessionsConfig> = {},
|
|
29
|
+
): CodeSessionsConfig {
|
|
30
|
+
// telemetry off by default in tests so we never hit a real collector / time out
|
|
31
|
+
const base = resolveConfig(defaultConfig('/home/test', 'test-host'), {
|
|
32
|
+
storeDir,
|
|
33
|
+
telemetry: { enabled: false },
|
|
34
|
+
});
|
|
35
|
+
return resolveConfig(base, override);
|
|
36
|
+
}
|