@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,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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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
+ `;