@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,99 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { cmdBackfill, cmdDoctor, cmdInit, cmdInstallHooks, cmdReindex, cmdStatus, listClaudeTranscripts } from './commands';
|
|
5
|
+
import { insightsFile, sessionDir } from './store/paths';
|
|
6
|
+
import { makeConfig, withTempDir, withTempDirAsync } from './test/tmp';
|
|
7
|
+
|
|
8
|
+
const LINES = [
|
|
9
|
+
'{"type":"user","sessionId":"sess-1","cwd":"/proj","timestamp":"2026-06-20T08:00:00Z","message":{"role":"user","content":"Fix the bug in foo.ts"}}',
|
|
10
|
+
'{"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":100,"output_tokens":10}}}',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function seedProjects(root: string): string {
|
|
14
|
+
const projects = join(root, 'projects', 'encoded');
|
|
15
|
+
mkdirSync(projects, { recursive: true });
|
|
16
|
+
writeFileSync(join(projects, 'sess-1.jsonl'), LINES.map((l) => `${l}\n`).join(''));
|
|
17
|
+
return join(root, 'projects');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('listClaudeTranscripts', () => {
|
|
21
|
+
it('finds session files recursively', () => {
|
|
22
|
+
withTempDir((root) => {
|
|
23
|
+
const projectsDir = seedProjects(root);
|
|
24
|
+
const found = listClaudeTranscripts(projectsDir);
|
|
25
|
+
expect(found).toHaveLength(1);
|
|
26
|
+
expect(found[0]!.sessionId).toBe('sess-1');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('cmdInit', () => {
|
|
32
|
+
it('initializes the store repo and config', () => {
|
|
33
|
+
withTempDir((root) => {
|
|
34
|
+
const store = join(root, 'store');
|
|
35
|
+
const res = cmdInit(makeConfig(store));
|
|
36
|
+
expect(res.code).toBe(0);
|
|
37
|
+
expect(existsSync(join(store, '.git'))).toBe(true);
|
|
38
|
+
expect(existsSync(join(store, 'config.json'))).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('cmdBackfill', () => {
|
|
44
|
+
it('imports existing transcripts into the store', async () => {
|
|
45
|
+
await withTempDirAsync(async (root) => {
|
|
46
|
+
const store = join(root, 'store');
|
|
47
|
+
const projectsDir = seedProjects(root);
|
|
48
|
+
const res = await cmdBackfill(makeConfig(store), { projectsDir });
|
|
49
|
+
expect(res.code).toBe(0);
|
|
50
|
+
expect(res.output).toMatch(/Backfilled 1 session/);
|
|
51
|
+
const dir = sessionDir(store, 'test-host', '2026-06', 'sess-1');
|
|
52
|
+
expect(existsSync(dir)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('cmdReindex', () => {
|
|
58
|
+
it('derives insights for backfilled sessions', async () => {
|
|
59
|
+
await withTempDirAsync(async (root) => {
|
|
60
|
+
const store = join(root, 'store');
|
|
61
|
+
const projectsDir = seedProjects(root);
|
|
62
|
+
const cfg = makeConfig(store, { insights: { provider: 'fake' } });
|
|
63
|
+
await cmdBackfill(cfg, { projectsDir });
|
|
64
|
+
const res = await cmdReindex(cfg);
|
|
65
|
+
expect(res.output).toMatch(/Reindexed 1 session.*provider=fake/);
|
|
66
|
+
const dir = sessionDir(store, 'test-host', '2026-06', 'sess-1');
|
|
67
|
+
expect(existsSync(insightsFile(dir))).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('cmdInstallHooks', () => {
|
|
73
|
+
it('writes hooks into a target settings file', () => {
|
|
74
|
+
withTempDir((root) => {
|
|
75
|
+
const settingsPath = join(root, 'settings.json');
|
|
76
|
+
const res = cmdInstallHooks(makeConfig(join(root, 'store')), { settingsPath });
|
|
77
|
+
expect(res.code).toBe(0);
|
|
78
|
+
const written = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
79
|
+
expect(written.hooks.Stop[0].hooks[0].command).toBe('code-sessions hook');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('cmdStatus / cmdDoctor', () => {
|
|
85
|
+
it('status reports store + provider config', () => {
|
|
86
|
+
withTempDir((root) => {
|
|
87
|
+
const res = cmdStatus(makeConfig(join(root, 'store'), { insights: { provider: 'fake' } }));
|
|
88
|
+
expect(res.output).toMatch(/insights:\s+fake/);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('doctor returns non-zero when store is missing', () => {
|
|
93
|
+
withTempDir((root) => {
|
|
94
|
+
const res = cmdDoctor(makeConfig(join(root, 'absent')));
|
|
95
|
+
expect(res.code).toBe(1);
|
|
96
|
+
expect(res.output).toMatch(/✗ store dir exists/);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { CodeSessionsConfig } from './config';
|
|
4
|
+
import { CaptureEngine } from './capture';
|
|
5
|
+
import { Daemon } from './daemon';
|
|
6
|
+
import { FakeProvider } from './insights/provider';
|
|
7
|
+
import { labelSession, makeProvider, reindexStore } from './insights/labeler';
|
|
8
|
+
import { StateStore } from './state';
|
|
9
|
+
import { GitStore } from './store/git';
|
|
10
|
+
import { listSessionDirs, readEntries } from './store/scan';
|
|
11
|
+
import { installHooks } from './hooks/install';
|
|
12
|
+
import { exportSession, exportStore } from './telemetry/exporter';
|
|
13
|
+
import { discoverGrokSessions, parseGrokSession } from './adapters/grok';
|
|
14
|
+
import { discoverCodexSessions, parseCodexSession } from './adapters/codex';
|
|
15
|
+
import { writeImportedSession } from './adapters/import';
|
|
16
|
+
import { SessionIndex, type SessionIndexRow } from './index_store/db';
|
|
17
|
+
import { syncIndex } from './index_store/sync';
|
|
18
|
+
|
|
19
|
+
export interface CommandResult {
|
|
20
|
+
code: number;
|
|
21
|
+
output: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function gitStoreFor(cfg: CodeSessionsConfig): GitStore {
|
|
25
|
+
return new GitStore(cfg.storeDir, {
|
|
26
|
+
...(cfg.git.remote ? { remote: cfg.git.remote } : {}),
|
|
27
|
+
autoPush: cfg.git.autoPush,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Collect every Claude transcript (<sessionId>.jsonl) under a projects dir. */
|
|
32
|
+
export function listClaudeTranscripts(
|
|
33
|
+
projectsDir: string,
|
|
34
|
+
maxDepth = 3,
|
|
35
|
+
): { sessionId: string; path: string }[] {
|
|
36
|
+
const out: { sessionId: string; path: string }[] = [];
|
|
37
|
+
const walk = (dir: string, depth: number): void => {
|
|
38
|
+
if (depth > maxDepth || !existsSync(dir)) return;
|
|
39
|
+
for (const e of readEntries(dir)) {
|
|
40
|
+
const name = String(e.name);
|
|
41
|
+
if (e.isFile() && name.endsWith('.jsonl')) {
|
|
42
|
+
out.push({ sessionId: name.replace(/\.jsonl$/, ''), path: join(dir, name) });
|
|
43
|
+
} else if (e.isDirectory()) {
|
|
44
|
+
walk(join(dir, name), depth + 1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
walk(projectsDir, 0);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function cmdInit(cfg: CodeSessionsConfig): CommandResult {
|
|
53
|
+
const git = gitStoreFor(cfg);
|
|
54
|
+
git.init();
|
|
55
|
+
const configPath = join(cfg.storeDir, 'config.json');
|
|
56
|
+
if (!existsSync(configPath)) {
|
|
57
|
+
writeFileSync(
|
|
58
|
+
configPath,
|
|
59
|
+
`${JSON.stringify({ insights: cfg.insights, batch: cfg.batch, hygiene: cfg.hygiene }, null, 2)}\n`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
git.commit('init store');
|
|
63
|
+
return { code: 0, output: `Initialized store at ${cfg.storeDir}` };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function cmdStatus(cfg: CodeSessionsConfig): CommandResult {
|
|
67
|
+
const state = new StateStore(cfg.statePath);
|
|
68
|
+
const sessions = Object.keys(state.all());
|
|
69
|
+
const stored = listSessionDirs(cfg.storeDir);
|
|
70
|
+
const socketUp = existsSync(cfg.socketPath);
|
|
71
|
+
const lines = [
|
|
72
|
+
`store: ${cfg.storeDir}`,
|
|
73
|
+
`host: ${cfg.host}`,
|
|
74
|
+
`daemon: ${socketUp ? 'running (socket present)' : 'not running'}`,
|
|
75
|
+
`tracked: ${sessions.length} session(s) in state`,
|
|
76
|
+
`stored: ${stored.length} session(s) in store`,
|
|
77
|
+
`insights: ${cfg.insights.provider} / ${cfg.insights.mode}`,
|
|
78
|
+
`remote: ${cfg.git.remote ?? '(none)'} autoPush=${cfg.git.autoPush}`,
|
|
79
|
+
];
|
|
80
|
+
return { code: 0, output: lines.join('\n') };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type BackfillAgent = 'claude' | 'grok' | 'codex' | 'all';
|
|
84
|
+
|
|
85
|
+
export async function cmdBackfill(
|
|
86
|
+
cfg: CodeSessionsConfig,
|
|
87
|
+
opts: { projectsDir?: string; agent?: BackfillAgent } = {},
|
|
88
|
+
): Promise<CommandResult> {
|
|
89
|
+
const agent = opts.agent ?? 'claude';
|
|
90
|
+
const parts: string[] = [];
|
|
91
|
+
let sessions = 0;
|
|
92
|
+
let turns = 0;
|
|
93
|
+
|
|
94
|
+
if (agent === 'claude' || agent === 'all') {
|
|
95
|
+
const projectsDir = opts.projectsDir ?? cfg.claudeProjectsDir;
|
|
96
|
+
const transcripts = listClaudeTranscripts(projectsDir);
|
|
97
|
+
const engine = new CaptureEngine(cfg, new StateStore(cfg.statePath));
|
|
98
|
+
let t = 0;
|
|
99
|
+
for (const tr of transcripts) t += engine.captureSession(tr.sessionId, tr.path).newTurns;
|
|
100
|
+
sessions += transcripts.length;
|
|
101
|
+
turns += t;
|
|
102
|
+
parts.push(`claude: ${transcripts.length} sessions / ${t} turns`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (agent === 'grok' || agent === 'all') {
|
|
106
|
+
const found = discoverGrokSessions();
|
|
107
|
+
let n = 0;
|
|
108
|
+
let t = 0;
|
|
109
|
+
for (const info of found) {
|
|
110
|
+
const imported = parseGrokSession(info, cfg.host);
|
|
111
|
+
if (!imported) continue;
|
|
112
|
+
t += writeImportedSession(cfg, imported).turns;
|
|
113
|
+
n++;
|
|
114
|
+
}
|
|
115
|
+
sessions += n;
|
|
116
|
+
turns += t;
|
|
117
|
+
parts.push(`grok: ${n} sessions / ${t} turns`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (agent === 'codex' || agent === 'all') {
|
|
121
|
+
const found = discoverCodexSessions();
|
|
122
|
+
let n = 0;
|
|
123
|
+
let t = 0;
|
|
124
|
+
for (const info of found) {
|
|
125
|
+
const imported = parseCodexSession(info, cfg.host);
|
|
126
|
+
if (!imported) continue;
|
|
127
|
+
t += writeImportedSession(cfg, imported).turns;
|
|
128
|
+
n++;
|
|
129
|
+
}
|
|
130
|
+
sessions += n;
|
|
131
|
+
turns += t;
|
|
132
|
+
parts.push(`codex: ${n} sessions / ${t} turns`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const git = gitStoreFor(cfg);
|
|
136
|
+
git.init();
|
|
137
|
+
git.commit(`backfill (${agent}): ${sessions} sessions`);
|
|
138
|
+
return { code: 0, output: `Backfilled ${sessions} session(s), ${turns} turn(s) — ${parts.join(', ')}` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function cmdReindex(
|
|
142
|
+
cfg: CodeSessionsConfig,
|
|
143
|
+
opts: { since?: string } = {},
|
|
144
|
+
): Promise<CommandResult> {
|
|
145
|
+
const provider = makeProvider(cfg) ?? new FakeProvider();
|
|
146
|
+
const res = await reindexStore(cfg, provider, opts.since ? { sinceMonth: opts.since } : {});
|
|
147
|
+
const git = gitStoreFor(cfg);
|
|
148
|
+
if (git.isRepo()) git.sync(`insights reindex (${res.count})`);
|
|
149
|
+
return { code: 0, output: `Reindexed ${res.count} session(s) with provider=${provider.name}` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function cmdInstallHooks(
|
|
153
|
+
cfg: CodeSessionsConfig,
|
|
154
|
+
opts: { settingsPath?: string; command?: string } = {},
|
|
155
|
+
): CommandResult {
|
|
156
|
+
const home = cfg.claudeProjectsDir.replace(/\/projects\/?$/, '');
|
|
157
|
+
const settingsPath = opts.settingsPath ?? join(home, 'settings.json');
|
|
158
|
+
const command = opts.command ?? 'code-sessions hook';
|
|
159
|
+
const res = installHooks(settingsPath, command);
|
|
160
|
+
return {
|
|
161
|
+
code: 0,
|
|
162
|
+
output:
|
|
163
|
+
res.added.length > 0
|
|
164
|
+
? `Installed hooks (${res.added.join(', ')}) → ${settingsPath}`
|
|
165
|
+
: `Hooks already present → ${settingsPath}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function cmdDoctor(cfg: CodeSessionsConfig): CommandResult {
|
|
170
|
+
const checks: [string, boolean][] = [
|
|
171
|
+
['store dir exists', existsSync(cfg.storeDir)],
|
|
172
|
+
['store is git repo', existsSync(join(cfg.storeDir, '.git'))],
|
|
173
|
+
['daemon socket present', existsSync(cfg.socketPath)],
|
|
174
|
+
['claude projects dir', existsSync(cfg.claudeProjectsDir)],
|
|
175
|
+
];
|
|
176
|
+
const lines = checks.map(([name, ok]) => `${ok ? '✓' : '✗'} ${name}`);
|
|
177
|
+
const code = checks.every(([, ok]) => ok) ? 0 : 1;
|
|
178
|
+
return { code, output: lines.join('\n') };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function cmdExport(
|
|
182
|
+
cfg: CodeSessionsConfig,
|
|
183
|
+
opts: { since?: string } = {},
|
|
184
|
+
): Promise<CommandResult> {
|
|
185
|
+
if (!cfg.telemetry.enabled) {
|
|
186
|
+
return { code: 0, output: 'Telemetry export disabled (telemetry.enabled=false)' };
|
|
187
|
+
}
|
|
188
|
+
const res = await exportStore(cfg, opts.since ? { sinceMonth: opts.since } : {});
|
|
189
|
+
return {
|
|
190
|
+
code: 0,
|
|
191
|
+
output: `Exported ${res.exported}/${res.total} session(s) to ${cfg.telemetry.endpoint} (${res.failed} failed)`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function cmdIndex(cfg: CodeSessionsConfig): CommandResult {
|
|
196
|
+
const stats = syncIndex(cfg);
|
|
197
|
+
return {
|
|
198
|
+
code: 0,
|
|
199
|
+
output: `Indexed ${stats.indexed} new/changed, ${stats.unchanged} unchanged, ${stats.removed} removed → ${cfg.indexPath}`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function fmtRow(r: SessionIndexRow): string {
|
|
204
|
+
const date = r.started_at ? new Date(r.started_at).toISOString().slice(0, 16).replace('T', ' ') : '—'.padEnd(16);
|
|
205
|
+
const agent = (r.agent || '?').padEnd(11).slice(0, 11);
|
|
206
|
+
const tok = String(r.input_tokens + r.output_tokens).padStart(8);
|
|
207
|
+
const cost = `$${r.cost_usd.toFixed(2)}`.padStart(8);
|
|
208
|
+
const title = (r.topic || r.title || r.session_id).slice(0, 48);
|
|
209
|
+
return `${date} ${agent} ${tok} ${cost} ${title}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function cmdQuery(
|
|
213
|
+
cfg: CodeSessionsConfig,
|
|
214
|
+
opts: { limit?: number; agent?: string } = {},
|
|
215
|
+
): CommandResult {
|
|
216
|
+
const index = new SessionIndex(cfg.indexPath);
|
|
217
|
+
try {
|
|
218
|
+
const rows = index.listRecent(opts.limit ?? 25, opts.agent);
|
|
219
|
+
const s = index.stats();
|
|
220
|
+
const header = `# ${s.sessions} sessions, ${s.turns} turns, $${s.cost_usd.toFixed(2)} — ${Object.entries(s.byAgent).map(([a, n]) => `${a}:${n}`).join(' ')}`;
|
|
221
|
+
return { code: 0, output: [header, ...rows.map(fmtRow)].join('\n') };
|
|
222
|
+
} finally {
|
|
223
|
+
index.close();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function cmdSearch(
|
|
228
|
+
cfg: CodeSessionsConfig,
|
|
229
|
+
opts: { query: string; limit?: number },
|
|
230
|
+
): CommandResult {
|
|
231
|
+
if (!opts.query) return { code: 1, output: 'usage: code-sessions search <text> [--limit N]' };
|
|
232
|
+
const index = new SessionIndex(cfg.indexPath);
|
|
233
|
+
try {
|
|
234
|
+
const rows = index.searchTurns(opts.query, opts.limit ?? 25);
|
|
235
|
+
return {
|
|
236
|
+
code: 0,
|
|
237
|
+
output: rows.length ? [`# ${rows.length} match(es) for "${opts.query}"`, ...rows.map(fmtRow)].join('\n') : `No matches for "${opts.query}"`,
|
|
238
|
+
};
|
|
239
|
+
} finally {
|
|
240
|
+
index.close();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Long-running: start the daemon, wire insights + telemetry on session-end. */
|
|
245
|
+
export async function startDaemon(cfg: CodeSessionsConfig): Promise<Daemon> {
|
|
246
|
+
const provider = makeProvider(cfg);
|
|
247
|
+
const wantInsights = provider && cfg.insights.mode !== 'off';
|
|
248
|
+
const wantTelemetry = cfg.telemetry.enabled;
|
|
249
|
+
|
|
250
|
+
const deps =
|
|
251
|
+
wantInsights || wantTelemetry
|
|
252
|
+
? {
|
|
253
|
+
onSessionEnd: async (sessionId: string, sessionDir: string) => {
|
|
254
|
+
if (wantInsights && provider) {
|
|
255
|
+
await labelSession(sessionDir, { sessionId, host: cfg.host }, provider);
|
|
256
|
+
}
|
|
257
|
+
if (wantTelemetry) {
|
|
258
|
+
await exportSession(cfg, sessionDir);
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
: {};
|
|
263
|
+
const daemon = new Daemon(cfg, deps);
|
|
264
|
+
await daemon.start();
|
|
265
|
+
return daemon;
|
|
266
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { defaultConfig, resolveConfig } from './config';
|
|
3
|
+
|
|
4
|
+
describe('config', () => {
|
|
5
|
+
it('builds sane defaults under a home dir', () => {
|
|
6
|
+
const c = defaultConfig('/home/x', 'box');
|
|
7
|
+
expect(c.host).toBe('box');
|
|
8
|
+
expect(c.storeDir).toBe('/home/x/.sessions');
|
|
9
|
+
expect(c.runtimeDir).toBe('/home/x/.sessions/.daemon');
|
|
10
|
+
expect(c.socketPath).toBe('/home/x/.sessions/.daemon/daemon.sock');
|
|
11
|
+
expect(c.statePath).toBe('/home/x/.sessions/.daemon/state.json');
|
|
12
|
+
expect(c.claudeProjectsDir).toBe('/home/x/.claude/projects');
|
|
13
|
+
expect(c.insights.provider).toBe('none');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('deep-merges overrides without dropping sibling keys', () => {
|
|
17
|
+
const base = defaultConfig('/home/x', 'box');
|
|
18
|
+
const merged = resolveConfig(base, {
|
|
19
|
+
host: 'other',
|
|
20
|
+
batch: { maxTurns: 1 },
|
|
21
|
+
insights: { provider: 'ollama', model: 'llama3' },
|
|
22
|
+
});
|
|
23
|
+
expect(merged.host).toBe('other');
|
|
24
|
+
expect(merged.batch.maxTurns).toBe(1);
|
|
25
|
+
expect(merged.batch.maxIntervalMs).toBe(base.batch.maxIntervalMs); // sibling preserved
|
|
26
|
+
expect(merged.insights.provider).toBe('ollama');
|
|
27
|
+
expect(merged.insights.mode).toBe('off'); // sibling preserved
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('re-derives runtime paths when storeDir is overridden', () => {
|
|
31
|
+
const merged = resolveConfig(defaultConfig('/home/x', 'box'), { storeDir: '/tmp/store' });
|
|
32
|
+
expect(merged.runtimeDir).toBe('/tmp/store/.daemon');
|
|
33
|
+
expect(merged.socketPath).toBe('/tmp/store/.daemon/daemon.sock');
|
|
34
|
+
expect(merged.statePath).toBe('/tmp/store/.daemon/state.json');
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir, hostname } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { AgentKind } from '@unpolarize/code-sessions-schema';
|
|
5
|
+
|
|
6
|
+
export type InsightsProvider = 'none' | 'fake' | 'claude' | 'grok' | 'ollama';
|
|
7
|
+
export type InsightsMode = 'off' | 'on-stop' | 'per-turn';
|
|
8
|
+
|
|
9
|
+
export interface CodeSessionsConfig {
|
|
10
|
+
/** logical host id; keys store paths so two machines never collide */
|
|
11
|
+
host: string;
|
|
12
|
+
agent: AgentKind;
|
|
13
|
+
/** root of the git-backed store, e.g. ~/.sessions */
|
|
14
|
+
storeDir: string;
|
|
15
|
+
/** gitignored dir under storeDir for daemon runtime files (socket, state) */
|
|
16
|
+
runtimeDir: string;
|
|
17
|
+
/** unix socket the daemon listens on for hook events */
|
|
18
|
+
socketPath: string;
|
|
19
|
+
/** daemon bookkeeping state file */
|
|
20
|
+
statePath: string;
|
|
21
|
+
/** SQLite index (projection of the git store) for fast queries */
|
|
22
|
+
indexPath: string;
|
|
23
|
+
/** where Claude Code writes its native JSONL transcripts */
|
|
24
|
+
claudeProjectsDir: string;
|
|
25
|
+
batch: {
|
|
26
|
+
/** flush after this many buffered turns */
|
|
27
|
+
maxTurns: number;
|
|
28
|
+
/** flush at least this often (ms) */
|
|
29
|
+
maxIntervalMs: number;
|
|
30
|
+
};
|
|
31
|
+
hygiene: {
|
|
32
|
+
/** externalize a turn's text once it exceeds this many bytes */
|
|
33
|
+
maxTurnBytes: number;
|
|
34
|
+
scrubSecrets: boolean;
|
|
35
|
+
};
|
|
36
|
+
git: {
|
|
37
|
+
/** remote URL for the store; when set the daemon pushes after commit */
|
|
38
|
+
remote?: string;
|
|
39
|
+
autoCommit: boolean;
|
|
40
|
+
autoPush: boolean;
|
|
41
|
+
};
|
|
42
|
+
insights: {
|
|
43
|
+
provider: InsightsProvider;
|
|
44
|
+
mode: InsightsMode;
|
|
45
|
+
/** model/tag passed to the provider (e.g. ollama model name) */
|
|
46
|
+
model?: string;
|
|
47
|
+
};
|
|
48
|
+
telemetry: {
|
|
49
|
+
/** export captured sessions as OTLP to a collector */
|
|
50
|
+
enabled: boolean;
|
|
51
|
+
/** OTLP/HTTP base URL (paths /v1/traces, /v1/metrics are appended) */
|
|
52
|
+
endpoint: string;
|
|
53
|
+
serviceName: string;
|
|
54
|
+
timeoutMs: number;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function defaultConfig(home = homedir(), host = hostname()): CodeSessionsConfig {
|
|
59
|
+
const storeDir = join(home, '.sessions');
|
|
60
|
+
const runtimeDir = join(storeDir, '.daemon');
|
|
61
|
+
return {
|
|
62
|
+
host,
|
|
63
|
+
agent: 'claude-code',
|
|
64
|
+
storeDir,
|
|
65
|
+
runtimeDir,
|
|
66
|
+
socketPath: join(runtimeDir, 'daemon.sock'),
|
|
67
|
+
statePath: join(runtimeDir, 'state.json'),
|
|
68
|
+
indexPath: join(runtimeDir, 'index.db'),
|
|
69
|
+
claudeProjectsDir: join(home, '.claude', 'projects'),
|
|
70
|
+
batch: { maxTurns: 8, maxIntervalMs: 5000 },
|
|
71
|
+
hygiene: { maxTurnBytes: 64 * 1024, scrubSecrets: true },
|
|
72
|
+
git: { autoCommit: true, autoPush: false },
|
|
73
|
+
insights: { provider: 'none', mode: 'off' },
|
|
74
|
+
telemetry: {
|
|
75
|
+
enabled: true,
|
|
76
|
+
endpoint: 'http://localhost:4318',
|
|
77
|
+
serviceName: 'code-sessions',
|
|
78
|
+
timeoutMs: 2000,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Deep-merge a partial override onto a base config (pure; no IO). Runtime paths
|
|
85
|
+
* (runtimeDir/socketPath/statePath) are re-derived from storeDir unless the
|
|
86
|
+
* override sets them explicitly, so changing storeDir keeps them consistent.
|
|
87
|
+
*/
|
|
88
|
+
export function resolveConfig(
|
|
89
|
+
base: CodeSessionsConfig,
|
|
90
|
+
override: DeepPartial<CodeSessionsConfig> = {},
|
|
91
|
+
): CodeSessionsConfig {
|
|
92
|
+
const merged: CodeSessionsConfig = {
|
|
93
|
+
...base,
|
|
94
|
+
...stripUndefined(override),
|
|
95
|
+
batch: { ...base.batch, ...stripUndefined(override.batch) },
|
|
96
|
+
hygiene: { ...base.hygiene, ...stripUndefined(override.hygiene) },
|
|
97
|
+
git: { ...base.git, ...stripUndefined(override.git) },
|
|
98
|
+
insights: { ...base.insights, ...stripUndefined(override.insights) },
|
|
99
|
+
telemetry: { ...base.telemetry, ...stripUndefined(override.telemetry) },
|
|
100
|
+
};
|
|
101
|
+
if (override.storeDir && override.runtimeDir === undefined) {
|
|
102
|
+
merged.runtimeDir = join(merged.storeDir, '.daemon');
|
|
103
|
+
}
|
|
104
|
+
if (merged.socketPath === base.socketPath && override.socketPath === undefined) {
|
|
105
|
+
merged.socketPath = join(merged.runtimeDir, 'daemon.sock');
|
|
106
|
+
}
|
|
107
|
+
if (merged.statePath === base.statePath && override.statePath === undefined) {
|
|
108
|
+
merged.statePath = join(merged.runtimeDir, 'state.json');
|
|
109
|
+
}
|
|
110
|
+
if (merged.indexPath === base.indexPath && override.indexPath === undefined) {
|
|
111
|
+
merged.indexPath = join(merged.runtimeDir, 'index.db');
|
|
112
|
+
}
|
|
113
|
+
return merged;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Load config from defaults <- ~/.sessions/config.json <- env <- explicit overrides. */
|
|
117
|
+
export function loadConfig(override: DeepPartial<CodeSessionsConfig> = {}): CodeSessionsConfig {
|
|
118
|
+
let cfg = defaultConfig();
|
|
119
|
+
const configPath = join(cfg.storeDir, 'config.json');
|
|
120
|
+
if (existsSync(configPath)) {
|
|
121
|
+
try {
|
|
122
|
+
const fileCfg = JSON.parse(readFileSync(configPath, 'utf8')) as DeepPartial<CodeSessionsConfig>;
|
|
123
|
+
cfg = resolveConfig(cfg, fileCfg);
|
|
124
|
+
} catch {
|
|
125
|
+
// ignore malformed config; defaults win
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
cfg = resolveConfig(cfg, envOverrides());
|
|
129
|
+
cfg = resolveConfig(cfg, override);
|
|
130
|
+
return cfg;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function envOverrides(): DeepPartial<CodeSessionsConfig> {
|
|
134
|
+
const o: DeepPartial<CodeSessionsConfig> = {};
|
|
135
|
+
const env = process.env;
|
|
136
|
+
if (env.CODE_SESSIONS_STORE) o.storeDir = env.CODE_SESSIONS_STORE;
|
|
137
|
+
if (env.CODE_SESSIONS_HOST) o.host = env.CODE_SESSIONS_HOST;
|
|
138
|
+
if (env.CODE_SESSIONS_REMOTE) o.git = { remote: env.CODE_SESSIONS_REMOTE };
|
|
139
|
+
if (env.CODE_SESSIONS_INSIGHTS_PROVIDER)
|
|
140
|
+
o.insights = { provider: env.CODE_SESSIONS_INSIGHTS_PROVIDER as InsightsProvider };
|
|
141
|
+
if (env.OTEL_EXPORTER_OTLP_ENDPOINT) o.telemetry = { endpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT };
|
|
142
|
+
if (env.CODE_SESSIONS_TELEMETRY === '0' || env.CODE_SESSIONS_TELEMETRY === 'false')
|
|
143
|
+
o.telemetry = { ...(o.telemetry ?? {}), enabled: false };
|
|
144
|
+
return o;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function stripUndefined<T extends object>(obj: T | undefined): Partial<T> {
|
|
148
|
+
if (!obj) return {};
|
|
149
|
+
const out: Partial<T> = {};
|
|
150
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
151
|
+
if (v !== undefined) (out as Record<string, unknown>)[k] = v;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type DeepPartial<T> = {
|
|
157
|
+
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
|
|
158
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { Daemon, findTranscript } from './daemon';
|
|
6
|
+
import { sendEvent } from './ipc';
|
|
7
|
+
import { sessionDir, turnFile } from './store/paths';
|
|
8
|
+
import { makeConfig, withTempDir } from './test/tmp';
|
|
9
|
+
|
|
10
|
+
const LINES = [
|
|
11
|
+
'{"type":"user","sessionId":"sess-1","cwd":"/proj","gitBranch":"main","timestamp":"2026-06-20T08:00:00Z","message":{"role":"user","content":"hi"}}',
|
|
12
|
+
'{"type":"assistant","timestamp":"2026-06-20T08:00:05Z","message":{"role":"assistant","model":"claude-opus-4-8","content":[{"type":"text","text":"hello"}],"usage":{"input_tokens":1000,"output_tokens":20}}}',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function writeTranscript(dir: string): string {
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
const p = join(dir, 'sess-1.jsonl');
|
|
18
|
+
writeFileSync(p, LINES.map((l) => `${l}\n`).join(''));
|
|
19
|
+
return p;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function gitLogCount(repo: string): number {
|
|
23
|
+
const r = spawnSync('git', ['-C', repo, 'log', '--oneline'], { encoding: 'utf8' });
|
|
24
|
+
if (r.status !== 0) return 0;
|
|
25
|
+
return r.stdout.trim().split('\n').filter(Boolean).length;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('findTranscript', () => {
|
|
29
|
+
it('locates a session file by id under the projects dir', () => {
|
|
30
|
+
withTempDir((root) => {
|
|
31
|
+
const proj = join(root, 'projects', 'encoded-proj');
|
|
32
|
+
mkdirSync(proj, { recursive: true });
|
|
33
|
+
writeFileSync(join(proj, 'abc-123.jsonl'), '{}');
|
|
34
|
+
expect(findTranscript(join(root, 'projects'), 'abc-123')).toBe(join(proj, 'abc-123.jsonl'));
|
|
35
|
+
expect(findTranscript(join(root, 'projects'), 'missing')).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('Daemon', () => {
|
|
41
|
+
it('captures via handleEvent and commits on flush', async () => {
|
|
42
|
+
await withTempDirAsync(async (root) => {
|
|
43
|
+
const store = join(root, 'store');
|
|
44
|
+
const transcript = writeTranscript(join(root, 'src'));
|
|
45
|
+
const d = new Daemon(makeConfig(store, { batch: { maxTurns: 1 } }));
|
|
46
|
+
await d.start();
|
|
47
|
+
const ack = await d.handleEvent({
|
|
48
|
+
event: 'PostToolUse',
|
|
49
|
+
session_id: 'sess-1',
|
|
50
|
+
transcript_path: transcript,
|
|
51
|
+
});
|
|
52
|
+
expect(ack.ok).toBe(true);
|
|
53
|
+
expect(ack.newTurns).toBe(2);
|
|
54
|
+
expect(ack.flushed).toBe(true); // maxTurns=1 forces a flush
|
|
55
|
+
await d.stop();
|
|
56
|
+
|
|
57
|
+
const dir = sessionDir(store, 'test-host', '2026-06', 'sess-1');
|
|
58
|
+
expect(existsSync(turnFile(dir, 0))).toBe(true);
|
|
59
|
+
expect(gitLogCount(store)).toBeGreaterThanOrEqual(1);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('accepts events over the unix socket', async () => {
|
|
64
|
+
await withTempDirAsync(async (root) => {
|
|
65
|
+
const store = join(root, 'store');
|
|
66
|
+
const transcript = writeTranscript(join(root, 'src'));
|
|
67
|
+
const socketPath = join(root, 'd.sock');
|
|
68
|
+
const d = new Daemon(makeConfig(store, { socketPath, batch: { maxTurns: 1 } }));
|
|
69
|
+
await d.start();
|
|
70
|
+
try {
|
|
71
|
+
const ack = await sendEvent(socketPath, {
|
|
72
|
+
event: 'PostToolUse',
|
|
73
|
+
session_id: 'sess-1',
|
|
74
|
+
transcript_path: transcript,
|
|
75
|
+
});
|
|
76
|
+
expect(ack.ok).toBe(true);
|
|
77
|
+
expect(ack.newTurns).toBe(2);
|
|
78
|
+
} finally {
|
|
79
|
+
await d.stop();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('reports an error when the transcript cannot be found', async () => {
|
|
85
|
+
await withTempDirAsync(async (root) => {
|
|
86
|
+
const d = new Daemon(makeConfig(join(root, 'store')));
|
|
87
|
+
await d.start();
|
|
88
|
+
try {
|
|
89
|
+
const ack = await d.handleEvent({
|
|
90
|
+
event: 'PostToolUse',
|
|
91
|
+
session_id: 'nope',
|
|
92
|
+
transcript_path: '/does/not/exist.jsonl',
|
|
93
|
+
});
|
|
94
|
+
expect(ack.ok).toBe(false);
|
|
95
|
+
expect(ack.error).toMatch(/not found/);
|
|
96
|
+
} finally {
|
|
97
|
+
await d.stop();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('invokes the onSessionEnd hook on Stop', async () => {
|
|
103
|
+
await withTempDirAsync(async (root) => {
|
|
104
|
+
const store = join(root, 'store');
|
|
105
|
+
const transcript = writeTranscript(join(root, 'src'));
|
|
106
|
+
const onSessionEnd = vi.fn();
|
|
107
|
+
const d = new Daemon(makeConfig(store), { onSessionEnd });
|
|
108
|
+
await d.start();
|
|
109
|
+
await d.handleEvent({ event: 'Stop', session_id: 'sess-1', transcript_path: transcript });
|
|
110
|
+
await d.stop();
|
|
111
|
+
expect(onSessionEnd).toHaveBeenCalledOnce();
|
|
112
|
+
expect(onSessionEnd).toHaveBeenCalledWith(
|
|
113
|
+
'sess-1',
|
|
114
|
+
sessionDir(store, 'test-host', '2026-06', 'sess-1'),
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// async temp-dir helper (mirror of withTempDir but awaits fn)
|
|
121
|
+
async function withTempDirAsync<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
122
|
+
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
123
|
+
const { tmpdir } = await import('node:os');
|
|
124
|
+
const dir = mkdtempSync(join(tmpdir(), 'cs-d-'));
|
|
125
|
+
try {
|
|
126
|
+
return await fn(dir);
|
|
127
|
+
} finally {
|
|
128
|
+
rmSync(dir, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
}
|