@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,114 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import {
|
|
3
|
+
safeParseInsights,
|
|
4
|
+
safeParseSession,
|
|
5
|
+
type Insights,
|
|
6
|
+
type SessionEnvelope,
|
|
7
|
+
} from '@unpolarize/code-sessions-schema';
|
|
8
|
+
import { envelopeFile, insightsFile } from '../store/paths';
|
|
9
|
+
import { listSessionDirs, type SessionRef } from '../store/scan';
|
|
10
|
+
|
|
11
|
+
export interface LoadedSession {
|
|
12
|
+
ref: SessionRef;
|
|
13
|
+
envelope?: SessionEnvelope;
|
|
14
|
+
insights?: Insights;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loadSession(ref: SessionRef): LoadedSession {
|
|
18
|
+
const out: LoadedSession = { ref };
|
|
19
|
+
const envPath = envelopeFile(ref.dir);
|
|
20
|
+
if (existsSync(envPath)) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = safeParseSession(JSON.parse(readFileSync(envPath, 'utf8')));
|
|
23
|
+
if (parsed.success) out.envelope = parsed.data;
|
|
24
|
+
} catch {
|
|
25
|
+
/* ignore */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const insPath = insightsFile(ref.dir);
|
|
29
|
+
if (existsSync(insPath)) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = safeParseInsights(JSON.parse(readFileSync(insPath, 'utf8')));
|
|
32
|
+
if (parsed.success) out.insights = parsed.data;
|
|
33
|
+
} catch {
|
|
34
|
+
/* ignore */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AnalyticsReport {
|
|
41
|
+
generated_at: string;
|
|
42
|
+
sessions: number;
|
|
43
|
+
hosts: Record<string, number>;
|
|
44
|
+
totals: { input_tokens: number; output_tokens: number; cost_usd: number };
|
|
45
|
+
byMonth: Record<string, { sessions: number; cost_usd: number }>;
|
|
46
|
+
topTopics: { topic: string; count: number }[];
|
|
47
|
+
topTags: { tag: string; count: number }[];
|
|
48
|
+
signalCounts: Record<string, number>;
|
|
49
|
+
similar: { tag: string; sessions: string[] }[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function topN(counts: Map<string, number>, n: number): { count: number; key: string }[] {
|
|
53
|
+
return [...counts.entries()]
|
|
54
|
+
.map(([key, count]) => ({ key, count }))
|
|
55
|
+
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
|
|
56
|
+
.slice(0, n);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Crunch all stored sessions into a single analytics report (the "backend brain", server-free). */
|
|
60
|
+
export function computeReport(storeDir: string, now: string): AnalyticsReport {
|
|
61
|
+
const refs = listSessionDirs(storeDir);
|
|
62
|
+
const loaded = refs.map(loadSession);
|
|
63
|
+
|
|
64
|
+
const hosts: Record<string, number> = {};
|
|
65
|
+
const byMonth: Record<string, { sessions: number; cost_usd: number }> = {};
|
|
66
|
+
const totals = { input_tokens: 0, output_tokens: 0, cost_usd: 0 };
|
|
67
|
+
const topicCounts = new Map<string, number>();
|
|
68
|
+
const tagCounts = new Map<string, number>();
|
|
69
|
+
const tagToSessions = new Map<string, string[]>();
|
|
70
|
+
const signalCounts: Record<string, number> = {};
|
|
71
|
+
|
|
72
|
+
for (const { ref, envelope, insights } of loaded) {
|
|
73
|
+
hosts[ref.host] = (hosts[ref.host] ?? 0) + 1;
|
|
74
|
+
const month = (byMonth[ref.month] ??= { sessions: 0, cost_usd: 0 });
|
|
75
|
+
month.sessions++;
|
|
76
|
+
if (envelope) {
|
|
77
|
+
totals.input_tokens += envelope.totals.input_tokens;
|
|
78
|
+
totals.output_tokens += envelope.totals.output_tokens;
|
|
79
|
+
totals.cost_usd += envelope.totals.cost_usd;
|
|
80
|
+
month.cost_usd += envelope.totals.cost_usd;
|
|
81
|
+
}
|
|
82
|
+
if (insights) {
|
|
83
|
+
if (insights.topic) topicCounts.set(insights.topic, (topicCounts.get(insights.topic) ?? 0) + 1);
|
|
84
|
+
for (const tag of insights.tags) {
|
|
85
|
+
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
|
|
86
|
+
(tagToSessions.get(tag) ?? tagToSessions.set(tag, []).get(tag)!).push(ref.sessionId);
|
|
87
|
+
}
|
|
88
|
+
for (const sig of insights.signals) {
|
|
89
|
+
signalCounts[sig.kind] = (signalCounts[sig.kind] ?? 0) + 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
totals.cost_usd = Math.round(totals.cost_usd * 1e6) / 1e6;
|
|
95
|
+
for (const m of Object.values(byMonth)) m.cost_usd = Math.round(m.cost_usd * 1e6) / 1e6;
|
|
96
|
+
|
|
97
|
+
const similar = [...tagToSessions.entries()]
|
|
98
|
+
.filter(([, s]) => s.length >= 2)
|
|
99
|
+
.map(([tag, sessions]) => ({ tag, sessions: [...new Set(sessions)] }))
|
|
100
|
+
.sort((a, b) => b.sessions.length - a.sessions.length)
|
|
101
|
+
.slice(0, 10);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
generated_at: now,
|
|
105
|
+
sessions: loaded.length,
|
|
106
|
+
hosts,
|
|
107
|
+
totals,
|
|
108
|
+
byMonth,
|
|
109
|
+
topTopics: topN(topicCounts, 10).map(({ key, count }) => ({ topic: key, count })),
|
|
110
|
+
topTags: topN(tagCounts, 15).map(({ key, count }) => ({ tag: key, count })),
|
|
111
|
+
signalCounts,
|
|
112
|
+
similar,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AnalyticsReport } from './rollup';
|
|
2
|
+
|
|
3
|
+
function esc(s: string): string {
|
|
4
|
+
return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Render a minimal, dependency-free static HTML dashboard from the report. */
|
|
8
|
+
export function renderSite(report: AnalyticsReport): string {
|
|
9
|
+
const rows = (pairs: [string, string | number][]): string =>
|
|
10
|
+
pairs.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(String(v))}</td></tr>`).join('');
|
|
11
|
+
|
|
12
|
+
const topics = report.topTopics.map((t) => `<li>${esc(t.topic)} — ${t.count}</li>`).join('');
|
|
13
|
+
const tags = report.topTags.map((t) => `<span class="tag">${esc(t.tag)} (${t.count})</span>`).join(' ');
|
|
14
|
+
const signals = Object.entries(report.signalCounts)
|
|
15
|
+
.map(([k, v]) => `<li>${esc(k)}: ${v}</li>`)
|
|
16
|
+
.join('');
|
|
17
|
+
|
|
18
|
+
return `<!doctype html>
|
|
19
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
20
|
+
<title>code-sessions — analytics</title>
|
|
21
|
+
<style>
|
|
22
|
+
body{font:14px/1.5 system-ui,sans-serif;margin:2rem auto;max-width:720px;color:#111}
|
|
23
|
+
h1{margin-bottom:.2rem} .muted{color:#666}
|
|
24
|
+
table{border-collapse:collapse;margin:1rem 0} td{padding:.2rem .8rem;border-bottom:1px solid #eee}
|
|
25
|
+
.tag{display:inline-block;background:#eef;border-radius:4px;padding:.1rem .4rem;margin:.1rem}
|
|
26
|
+
ul{margin:.3rem 0}
|
|
27
|
+
</style></head><body>
|
|
28
|
+
<h1>code-sessions</h1>
|
|
29
|
+
<div class="muted">analytics · generated ${esc(report.generated_at)}</div>
|
|
30
|
+
<table>${rows([
|
|
31
|
+
['Sessions', report.sessions],
|
|
32
|
+
['Input tokens', report.totals.input_tokens],
|
|
33
|
+
['Output tokens', report.totals.output_tokens],
|
|
34
|
+
['Estimated cost (USD)', report.totals.cost_usd.toFixed(2)],
|
|
35
|
+
])}</table>
|
|
36
|
+
<h2>Top topics</h2><ul>${topics || '<li class="muted">none</li>'}</ul>
|
|
37
|
+
<h2>Top tags</h2><div>${tags || '<span class="muted">none</span>'}</div>
|
|
38
|
+
<h2>Signals</h2><ul>${signals || '<li class="muted">none</li>'}</ul>
|
|
39
|
+
</body></html>
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { CaptureEngine } from './capture';
|
|
5
|
+
import { StateStore } from './state';
|
|
6
|
+
import { envelopeFile, sessionDir, turnFile } from './store/paths';
|
|
7
|
+
import { makeConfig, withTempDir } from './test/tmp';
|
|
8
|
+
|
|
9
|
+
const LINES = [
|
|
10
|
+
'{"type":"user","sessionId":"sess-1","cwd":"/proj","gitBranch":"main","timestamp":"2026-06-20T08:00:00Z","message":{"role":"user","content":"hi"}}',
|
|
11
|
+
'{"type":"assistant","timestamp":"2026-06-20T08:00:05Z","message":{"role":"assistant","model":"claude-opus-4-8","content":[{"type":"text","text":"hello"},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"a"}}],"usage":{"input_tokens":1000,"output_tokens":20}}}',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function writeTranscript(dir: string, lines: string[]): string {
|
|
15
|
+
const p = join(dir, 'sess-1.jsonl');
|
|
16
|
+
writeFileSync(p, lines.map((l) => `${l}\n`).join(''));
|
|
17
|
+
return p;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('CaptureEngine', () => {
|
|
21
|
+
it('captures turns into immutable per-turn files + envelope', () => {
|
|
22
|
+
withTempDir((root) => {
|
|
23
|
+
const store = join(root, 'store');
|
|
24
|
+
const src = join(root, 'src');
|
|
25
|
+
mkdirSync(src, { recursive: true });
|
|
26
|
+
const cfg = makeConfig(store);
|
|
27
|
+
const engine = new CaptureEngine(cfg, new StateStore(join(store, 'state.json')));
|
|
28
|
+
const transcript = writeTranscript(src, LINES);
|
|
29
|
+
|
|
30
|
+
const res = engine.captureSession('sess-1', transcript);
|
|
31
|
+
expect(res.newTurns).toBe(2);
|
|
32
|
+
expect(res.month).toBe('2026-06');
|
|
33
|
+
|
|
34
|
+
const dir = sessionDir(store, 'test-host', '2026-06', 'sess-1');
|
|
35
|
+
expect(existsSync(turnFile(dir, 0))).toBe(true);
|
|
36
|
+
expect(existsSync(turnFile(dir, 1))).toBe(true);
|
|
37
|
+
expect(existsSync(envelopeFile(dir))).toBe(true);
|
|
38
|
+
expect(res.envelope!.turn_count).toBe(2);
|
|
39
|
+
expect(res.envelope!.model).toBe('claude-opus-4-8');
|
|
40
|
+
expect(res.envelope!.tool_call_count).toBe(1);
|
|
41
|
+
expect(res.envelope!.totals.cost_usd).toBeGreaterThan(0);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('is incremental: a second call only captures appended turns', () => {
|
|
46
|
+
withTempDir((root) => {
|
|
47
|
+
const store = join(root, 'store');
|
|
48
|
+
const src = join(root, 'src');
|
|
49
|
+
mkdirSync(src, { recursive: true });
|
|
50
|
+
const cfg = makeConfig(store);
|
|
51
|
+
const state = new StateStore(join(store, 'state.json'));
|
|
52
|
+
const engine = new CaptureEngine(cfg, state);
|
|
53
|
+
const transcript = writeTranscript(src, LINES);
|
|
54
|
+
|
|
55
|
+
const r1 = engine.captureSession('sess-1', transcript);
|
|
56
|
+
expect(r1.newTurns).toBe(2);
|
|
57
|
+
|
|
58
|
+
// append one more assistant turn
|
|
59
|
+
writeFileSync(
|
|
60
|
+
transcript,
|
|
61
|
+
LINES.map((l) => `${l}\n`).join('') +
|
|
62
|
+
'{"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}}}\n',
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const r2 = engine.captureSession('sess-1', transcript);
|
|
66
|
+
expect(r2.newTurns).toBe(1);
|
|
67
|
+
const dir = sessionDir(store, 'test-host', '2026-06', 'sess-1');
|
|
68
|
+
expect(existsSync(turnFile(dir, 2))).toBe(true);
|
|
69
|
+
expect(r2.envelope!.turn_count).toBe(3);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('is restart-safe: a fresh engine resumes from persisted offset', () => {
|
|
74
|
+
withTempDir((root) => {
|
|
75
|
+
const store = join(root, 'store');
|
|
76
|
+
const src = join(root, 'src');
|
|
77
|
+
mkdirSync(src, { recursive: true });
|
|
78
|
+
const cfg = makeConfig(store);
|
|
79
|
+
const statePath = join(store, 'state.json');
|
|
80
|
+
const transcript = writeTranscript(src, LINES);
|
|
81
|
+
|
|
82
|
+
new CaptureEngine(cfg, new StateStore(statePath)).captureSession('sess-1', transcript);
|
|
83
|
+
// simulate restart with a brand-new engine + state loaded from disk
|
|
84
|
+
const r = new CaptureEngine(cfg, new StateStore(statePath)).captureSession('sess-1', transcript);
|
|
85
|
+
expect(r.newTurns).toBe(0); // nothing new; no duplicate turns
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('scrubs secrets during capture', () => {
|
|
90
|
+
withTempDir((root) => {
|
|
91
|
+
const store = join(root, 'store');
|
|
92
|
+
const src = join(root, 'src');
|
|
93
|
+
mkdirSync(src, { recursive: true });
|
|
94
|
+
const cfg = makeConfig(store);
|
|
95
|
+
const engine = new CaptureEngine(cfg, new StateStore(join(store, 'state.json')));
|
|
96
|
+
const line =
|
|
97
|
+
'{"type":"user","sessionId":"sess-2","timestamp":"2026-06-20T08:00:00Z","message":{"role":"user","content":"my key ghp_1234567890abcdefghijklmnopqrstuvwxyzABCD ok"}}';
|
|
98
|
+
const transcript = writeTranscript(src, [line]);
|
|
99
|
+
const res = engine.captureSession('sess-2', transcript);
|
|
100
|
+
expect(res.redactions).toBeGreaterThan(0);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildTurn,
|
|
3
|
+
extractClaudeSessionMeta,
|
|
4
|
+
normalizeClaudeEvent,
|
|
5
|
+
type ClaudeSessionMeta,
|
|
6
|
+
type SessionEnvelope,
|
|
7
|
+
type Turn,
|
|
8
|
+
} from '@unpolarize/code-sessions-schema';
|
|
9
|
+
import type { CodeSessionsConfig } from './config';
|
|
10
|
+
import { applyHygiene } from './hygiene';
|
|
11
|
+
import { estimateCostUsd } from './pricing';
|
|
12
|
+
import type { StateStore } from './state';
|
|
13
|
+
import { sessionDir } from './store/paths';
|
|
14
|
+
import { rebuildEnvelope, writeBlobFile, writeTurnFile } from './store/writer';
|
|
15
|
+
import { readNewLines } from './tail';
|
|
16
|
+
import { monthOf } from './store/paths';
|
|
17
|
+
|
|
18
|
+
export interface CaptureResult {
|
|
19
|
+
sessionId: string;
|
|
20
|
+
sessionDir: string;
|
|
21
|
+
month: string;
|
|
22
|
+
newTurns: number;
|
|
23
|
+
writtenPaths: string[];
|
|
24
|
+
redactions: number;
|
|
25
|
+
envelope?: SessionEnvelope;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The capture engine: given a session id + its native transcript path, consume
|
|
30
|
+
* newly-appended JSONL, normalize + apply hygiene + cost, write immutable
|
|
31
|
+
* per-turn files, and rebuild the derived envelope. Restart-safe via StateStore
|
|
32
|
+
* (byte offset + next turn index persisted).
|
|
33
|
+
*/
|
|
34
|
+
export class CaptureEngine {
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly config: CodeSessionsConfig,
|
|
37
|
+
private readonly state: StateStore,
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
captureSession(sessionId: string, transcriptPath: string): CaptureResult {
|
|
41
|
+
const st = this.state.ensure(sessionId, transcriptPath);
|
|
42
|
+
const tail = readNewLines(transcriptPath, st.offset);
|
|
43
|
+
|
|
44
|
+
const meta = extractClaudeSessionMeta(tail.records);
|
|
45
|
+
const month = st.month ?? monthOf(meta.started_at ?? firstTs(tail.records));
|
|
46
|
+
const dir = sessionDir(this.config.storeDir, this.config.host, month, sessionId);
|
|
47
|
+
|
|
48
|
+
const writtenPaths: string[] = [];
|
|
49
|
+
let nextIndex = st.nextTurnIndex;
|
|
50
|
+
let redactions = 0;
|
|
51
|
+
|
|
52
|
+
for (const rec of tail.records) {
|
|
53
|
+
const norm = normalizeClaudeEvent(rec);
|
|
54
|
+
if (!norm) continue;
|
|
55
|
+
let turn: Turn = buildTurn(norm, {
|
|
56
|
+
session_id: sessionId,
|
|
57
|
+
host: this.config.host,
|
|
58
|
+
agent: this.config.agent,
|
|
59
|
+
turn_index: nextIndex,
|
|
60
|
+
});
|
|
61
|
+
// attach cost telemetry for billable (assistant) turns
|
|
62
|
+
const cost = estimateCostUsd(turn.usage, meta.model);
|
|
63
|
+
if (cost > 0) turn = { ...turn, telemetry: { cost_usd: cost } };
|
|
64
|
+
|
|
65
|
+
const hy = applyHygiene(turn, this.config.hygiene);
|
|
66
|
+
redactions += hy.redactions.reduce((a, m) => a + m.count, 0);
|
|
67
|
+
if (hy.blob) writtenPaths.push(writeBlobFile(dir, hy.blob.sha, hy.blob.content));
|
|
68
|
+
|
|
69
|
+
const res = writeTurnFile(dir, hy.turn);
|
|
70
|
+
if (res.written) {
|
|
71
|
+
writtenPaths.push(res.path);
|
|
72
|
+
nextIndex++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// advance state regardless (offset moves past consumed bytes even if all metadata)
|
|
77
|
+
this.state.update(sessionId, {
|
|
78
|
+
transcriptPath,
|
|
79
|
+
offset: tail.newOffset,
|
|
80
|
+
nextTurnIndex: nextIndex,
|
|
81
|
+
month,
|
|
82
|
+
startedAt: st.startedAt ?? meta.started_at,
|
|
83
|
+
lastTs: meta.ended_at ?? st.lastTs,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const newTurns = nextIndex - st.nextTurnIndex;
|
|
87
|
+
const result: CaptureResult = {
|
|
88
|
+
sessionId,
|
|
89
|
+
sessionDir: dir,
|
|
90
|
+
month,
|
|
91
|
+
newTurns,
|
|
92
|
+
writtenPaths,
|
|
93
|
+
redactions,
|
|
94
|
+
};
|
|
95
|
+
if (newTurns > 0 || writtenPaths.length > 0) {
|
|
96
|
+
result.envelope = rebuildEnvelope(
|
|
97
|
+
this.config.storeDir,
|
|
98
|
+
this.config.host,
|
|
99
|
+
month,
|
|
100
|
+
sessionId,
|
|
101
|
+
meta,
|
|
102
|
+
{
|
|
103
|
+
session_id: sessionId,
|
|
104
|
+
host: this.config.host,
|
|
105
|
+
agent: this.config.agent,
|
|
106
|
+
native_uuid: sessionId,
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function firstTs(records: unknown[]): string | undefined {
|
|
115
|
+
for (const r of records) {
|
|
116
|
+
if (r && typeof r === 'object' && typeof (r as { timestamp?: unknown }).timestamp === 'string') {
|
|
117
|
+
return (r as { timestamp: string }).timestamp;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { loadConfig } from './config';
|
|
2
|
+
import { HELP, overridesFromFlags, parseFlags } from './cliargs';
|
|
3
|
+
import {
|
|
4
|
+
cmdBackfill,
|
|
5
|
+
cmdDoctor,
|
|
6
|
+
cmdExport,
|
|
7
|
+
cmdIndex,
|
|
8
|
+
cmdInit,
|
|
9
|
+
cmdInstallHooks,
|
|
10
|
+
cmdQuery,
|
|
11
|
+
cmdReindex,
|
|
12
|
+
cmdSearch,
|
|
13
|
+
cmdStatus,
|
|
14
|
+
startDaemon,
|
|
15
|
+
type CommandResult,
|
|
16
|
+
} from './commands';
|
|
17
|
+
import { cmdAnalytics } from './analytics/command';
|
|
18
|
+
import { handleHookInput, readStdin } from './hooks/shim';
|
|
19
|
+
|
|
20
|
+
function emit(res: CommandResult): never {
|
|
21
|
+
if (res.output) process.stdout.write(`${res.output}\n`);
|
|
22
|
+
process.exit(res.code);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function main(argv: string[]): Promise<void> {
|
|
26
|
+
const command = argv[0];
|
|
27
|
+
const flags = parseFlags(argv.slice(1));
|
|
28
|
+
const cfg = loadConfig(overridesFromFlags(flags));
|
|
29
|
+
|
|
30
|
+
switch (command) {
|
|
31
|
+
case 'init':
|
|
32
|
+
emit(cmdInit(cfg));
|
|
33
|
+
break;
|
|
34
|
+
case 'status':
|
|
35
|
+
emit(cmdStatus(cfg));
|
|
36
|
+
break;
|
|
37
|
+
case 'doctor':
|
|
38
|
+
emit(cmdDoctor(cfg));
|
|
39
|
+
break;
|
|
40
|
+
case 'install-hooks':
|
|
41
|
+
emit(
|
|
42
|
+
cmdInstallHooks(cfg, {
|
|
43
|
+
...(typeof flags.settings === 'string' ? { settingsPath: flags.settings } : {}),
|
|
44
|
+
...(typeof flags.command === 'string' ? { command: flags.command } : {}),
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
break;
|
|
48
|
+
case 'backfill':
|
|
49
|
+
emit(
|
|
50
|
+
await cmdBackfill(cfg, {
|
|
51
|
+
...(typeof flags.projects === 'string' ? { projectsDir: flags.projects } : {}),
|
|
52
|
+
...(typeof flags.agent === 'string' ? { agent: flags.agent as 'claude' | 'grok' | 'codex' | 'all' } : {}),
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
break;
|
|
56
|
+
case 'reindex':
|
|
57
|
+
emit(await cmdReindex(cfg, typeof flags.since === 'string' ? { since: flags.since } : {}));
|
|
58
|
+
break;
|
|
59
|
+
case 'analytics':
|
|
60
|
+
emit(await cmdAnalytics(cfg));
|
|
61
|
+
break;
|
|
62
|
+
case 'export':
|
|
63
|
+
emit(await cmdExport(cfg, typeof flags.since === 'string' ? { since: flags.since } : {}));
|
|
64
|
+
break;
|
|
65
|
+
case 'index':
|
|
66
|
+
emit(cmdIndex(cfg));
|
|
67
|
+
break;
|
|
68
|
+
case 'query':
|
|
69
|
+
emit(
|
|
70
|
+
cmdQuery(cfg, {
|
|
71
|
+
...(typeof flags.limit === 'string' ? { limit: Number(flags.limit) } : {}),
|
|
72
|
+
...(typeof flags.agent === 'string' ? { agent: flags.agent } : {}),
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
break;
|
|
76
|
+
case 'search': {
|
|
77
|
+
const q = argv.slice(1).find((a) => !a.startsWith('--')) ?? '';
|
|
78
|
+
emit(cmdSearch(cfg, { query: q, ...(typeof flags.limit === 'string' ? { limit: Number(flags.limit) } : {}) }));
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case 'hook': {
|
|
82
|
+
// Never fail the agent: swallow everything, always exit 0.
|
|
83
|
+
try {
|
|
84
|
+
const input = await readStdin();
|
|
85
|
+
await handleHookInput(cfg.socketPath, input);
|
|
86
|
+
} catch {
|
|
87
|
+
/* ignore */
|
|
88
|
+
}
|
|
89
|
+
process.exit(0);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case 'start': {
|
|
93
|
+
const daemon = await startDaemon(cfg);
|
|
94
|
+
process.stdout.write(`code-sessions daemon listening on ${cfg.socketPath}\n`);
|
|
95
|
+
const stop = async (): Promise<void> => {
|
|
96
|
+
await daemon.stop();
|
|
97
|
+
process.exit(0);
|
|
98
|
+
};
|
|
99
|
+
process.on('SIGINT', stop);
|
|
100
|
+
process.on('SIGTERM', stop);
|
|
101
|
+
break; // keep the event loop alive
|
|
102
|
+
}
|
|
103
|
+
case 'help':
|
|
104
|
+
case '--help':
|
|
105
|
+
case undefined:
|
|
106
|
+
process.stdout.write(HELP);
|
|
107
|
+
process.exit(command ? 0 : 1);
|
|
108
|
+
break;
|
|
109
|
+
default:
|
|
110
|
+
process.stderr.write(`Unknown command: ${command}\n\n${HELP}`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
116
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { overridesFromFlags, parseFlags } from './cliargs';
|
|
3
|
+
|
|
4
|
+
describe('parseFlags', () => {
|
|
5
|
+
it('parses --key value, --key=value, and boolean flags', () => {
|
|
6
|
+
const f = parseFlags(['--store', '/s', '--provider=ollama', '--push', '--since', '2026-06']);
|
|
7
|
+
expect(f).toEqual({ store: '/s', provider: 'ollama', push: true, since: '2026-06' });
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('overridesFromFlags', () => {
|
|
12
|
+
it('maps flags onto config overrides', () => {
|
|
13
|
+
const o = overridesFromFlags({
|
|
14
|
+
store: '/s',
|
|
15
|
+
host: 'box',
|
|
16
|
+
remote: 'git@x:y.git',
|
|
17
|
+
push: true,
|
|
18
|
+
provider: 'claude',
|
|
19
|
+
mode: 'on-stop',
|
|
20
|
+
model: 'opus',
|
|
21
|
+
});
|
|
22
|
+
expect(o.storeDir).toBe('/s');
|
|
23
|
+
expect(o.host).toBe('box');
|
|
24
|
+
expect(o.git).toEqual({ remote: 'git@x:y.git', autoPush: true });
|
|
25
|
+
expect(o.insights).toEqual({ provider: 'claude', mode: 'on-stop', model: 'opus' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('omits unset groups', () => {
|
|
29
|
+
expect(overridesFromFlags({ store: '/s' })).toEqual({ storeDir: '/s' });
|
|
30
|
+
});
|
|
31
|
+
});
|
package/src/cliargs.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { DeepPartial, CodeSessionsConfig, InsightsProvider, InsightsMode } from './config';
|
|
2
|
+
|
|
3
|
+
export type Flags = Record<string, string | boolean>;
|
|
4
|
+
|
|
5
|
+
/** Minimal flag parser: --key value, --key=value, and bare --flag booleans. */
|
|
6
|
+
export function parseFlags(args: string[]): Flags {
|
|
7
|
+
const flags: Flags = {};
|
|
8
|
+
for (let i = 0; i < args.length; i++) {
|
|
9
|
+
const a = args[i]!;
|
|
10
|
+
if (!a.startsWith('--')) continue;
|
|
11
|
+
const body = a.slice(2);
|
|
12
|
+
const eq = body.indexOf('=');
|
|
13
|
+
if (eq >= 0) {
|
|
14
|
+
flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
15
|
+
} else {
|
|
16
|
+
const next = args[i + 1];
|
|
17
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
18
|
+
flags[body] = next;
|
|
19
|
+
i++;
|
|
20
|
+
} else {
|
|
21
|
+
flags[body] = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return flags;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function overridesFromFlags(flags: Flags): DeepPartial<CodeSessionsConfig> {
|
|
29
|
+
const o: DeepPartial<CodeSessionsConfig> = {};
|
|
30
|
+
if (typeof flags.store === 'string') o.storeDir = flags.store;
|
|
31
|
+
if (typeof flags.host === 'string') o.host = flags.host;
|
|
32
|
+
if (typeof flags.remote === 'string') o.git = { remote: flags.remote };
|
|
33
|
+
if (flags.push === true) o.git = { ...(o.git ?? {}), autoPush: true };
|
|
34
|
+
const insights: DeepPartial<CodeSessionsConfig['insights']> = {};
|
|
35
|
+
if (typeof flags.provider === 'string') insights.provider = flags.provider as InsightsProvider;
|
|
36
|
+
if (typeof flags.mode === 'string') insights.mode = flags.mode as InsightsMode;
|
|
37
|
+
if (typeof flags.model === 'string') insights.model = flags.model;
|
|
38
|
+
if (Object.keys(insights).length > 0) o.insights = insights;
|
|
39
|
+
const telemetry: DeepPartial<CodeSessionsConfig['telemetry']> = {};
|
|
40
|
+
if (typeof flags.endpoint === 'string') telemetry.endpoint = flags.endpoint;
|
|
41
|
+
if (flags['no-telemetry'] === true || flags['telemetry'] === false) telemetry.enabled = false;
|
|
42
|
+
if (Object.keys(telemetry).length > 0) o.telemetry = telemetry;
|
|
43
|
+
return o;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const HELP = `code-sessions — headless cross-agent session capture
|
|
47
|
+
|
|
48
|
+
Usage: code-sessions <command> [flags]
|
|
49
|
+
|
|
50
|
+
Commands:
|
|
51
|
+
init Initialize the git-backed store (~/.sessions)
|
|
52
|
+
start Run the capture daemon (foreground)
|
|
53
|
+
install-hooks Install Claude Code hooks that feed the daemon
|
|
54
|
+
hook (internal) forward a hook payload from stdin to the daemon
|
|
55
|
+
backfill Import existing transcripts into the store [--agent claude|grok|codex|all]
|
|
56
|
+
reindex (Re)derive insights for stored sessions [--since YYYY-MM]
|
|
57
|
+
export Export stored sessions as OTLP to a collector [--since YYYY-MM]
|
|
58
|
+
index (Re)build the internal SQLite index from the git store
|
|
59
|
+
query List recent sessions from the index [--limit N] [--agent X]
|
|
60
|
+
search Full-text search session turns <text> [--limit N]
|
|
61
|
+
analytics Compute MVP-2 rollups + digest into analytics/
|
|
62
|
+
status Show daemon/store status
|
|
63
|
+
doctor Environment checks
|
|
64
|
+
|
|
65
|
+
Flags:
|
|
66
|
+
--store <dir> store dir (default ~/.sessions)
|
|
67
|
+
--host <name> logical host id
|
|
68
|
+
--remote <url> git remote for the store
|
|
69
|
+
--push push after commit
|
|
70
|
+
--provider <p> insights provider: none|fake|claude|grok|ollama
|
|
71
|
+
--mode <m> insights mode: off|on-stop|per-turn
|
|
72
|
+
--model <m> provider model
|
|
73
|
+
--since <YYYY-MM> reindex/export: only sessions since month
|
|
74
|
+
--endpoint <url> OTLP/HTTP collector base (default http://localhost:4318)
|
|
75
|
+
--no-telemetry disable OTLP export
|
|
76
|
+
--settings <path> install-hooks: target settings.json
|
|
77
|
+
`;
|