@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
package/src/ipc.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { connect } from 'node:net';
2
+
3
+ /** Hook event delivered over the daemon's unix socket (newline-delimited JSON). */
4
+ export interface HookEvent {
5
+ event: string;
6
+ session_id: string;
7
+ transcript_path?: string;
8
+ cwd?: string;
9
+ }
10
+
11
+ export interface HookAck {
12
+ ok: boolean;
13
+ newTurns?: number;
14
+ flushed?: boolean;
15
+ error?: string;
16
+ }
17
+
18
+ const LIFECYCLE_END = new Set(['Stop', 'SubagentStop', 'SessionEnd']);
19
+
20
+ export function isSessionEndEvent(event: string): boolean {
21
+ return LIFECYCLE_END.has(event);
22
+ }
23
+
24
+ /** Normalize a raw payload (from a socket line or a Claude hook stdin doc) into a HookEvent. */
25
+ export function parseHookEvent(raw: unknown): HookEvent | null {
26
+ if (!raw || typeof raw !== 'object') return null;
27
+ const r = raw as Record<string, unknown>;
28
+ const event = (r.event ?? r.hook_event_name ?? r.hookEventName) as string | undefined;
29
+ const session_id = (r.session_id ?? r.sessionId) as string | undefined;
30
+ if (!event || !session_id) return null;
31
+ const out: HookEvent = { event, session_id };
32
+ const tp = (r.transcript_path ?? r.transcriptPath) as string | undefined;
33
+ if (typeof tp === 'string') out.transcript_path = tp;
34
+ if (typeof r.cwd === 'string') out.cwd = r.cwd;
35
+ return out;
36
+ }
37
+
38
+ /** Connect to the daemon socket, send one event, resolve with its ack. */
39
+ export function sendEvent(
40
+ socketPath: string,
41
+ event: HookEvent,
42
+ timeoutMs = 4000,
43
+ ): Promise<HookAck> {
44
+ return new Promise((resolve) => {
45
+ const sock = connect(socketPath);
46
+ let buf = '';
47
+ let settled = false;
48
+ const done = (ack: HookAck) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ sock.destroy();
52
+ resolve(ack);
53
+ };
54
+ sock.setTimeout(timeoutMs);
55
+ sock.on('connect', () => sock.write(`${JSON.stringify(event)}\n`));
56
+ sock.on('data', (d) => {
57
+ buf += d.toString('utf8');
58
+ const nl = buf.indexOf('\n');
59
+ if (nl >= 0) {
60
+ try {
61
+ done(JSON.parse(buf.slice(0, nl)) as HookAck);
62
+ } catch {
63
+ done({ ok: false, error: 'bad ack' });
64
+ }
65
+ }
66
+ });
67
+ sock.on('timeout', () => done({ ok: false, error: 'timeout' }));
68
+ sock.on('error', (e) => done({ ok: false, error: e.message }));
69
+ });
70
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { estimateCostUsd, priceFor } from './pricing';
3
+
4
+ describe('pricing', () => {
5
+ it('selects a price table by fuzzy model match', () => {
6
+ expect(priceFor('claude-opus-4-8').output).toBe(75);
7
+ expect(priceFor('claude-haiku-4-5-20251001').output).toBe(5);
8
+ expect(priceFor('something-sonnet-ish').output).toBe(15);
9
+ expect(priceFor(undefined).output).toBe(15); // default = sonnet
10
+ });
11
+
12
+ it('computes cost as sum(tokens * $/M) / 1e6', () => {
13
+ const cost = estimateCostUsd(
14
+ { input_tokens: 1_000_000, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
15
+ 'claude-opus-4-8',
16
+ );
17
+ expect(cost).toBe(15);
18
+ });
19
+
20
+ it('returns 0 for empty usage', () => {
21
+ expect(
22
+ estimateCostUsd(
23
+ { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
24
+ 'claude-opus-4-8',
25
+ ),
26
+ ).toBe(0);
27
+ });
28
+ });
package/src/pricing.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { Usage } from '@unpolarize/code-sessions-schema';
2
+
3
+ /** Per-million-token list prices (USD). Approximate; override per real tier. */
4
+ export interface ModelPrice {
5
+ input: number;
6
+ output: number;
7
+ cacheRead: number;
8
+ cacheWrite: number;
9
+ }
10
+
11
+ const PRICES: Record<string, ModelPrice> = {
12
+ 'claude-opus-4-8': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
13
+ 'claude-opus': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
14
+ 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
15
+ 'claude-sonnet': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
16
+ 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
17
+ 'claude-haiku': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
18
+ };
19
+
20
+ const DEFAULT_PRICE: ModelPrice = PRICES['claude-sonnet']!;
21
+
22
+ export function priceFor(model: string | undefined): ModelPrice {
23
+ if (!model) return DEFAULT_PRICE;
24
+ const lower = model.toLowerCase();
25
+ for (const key of Object.keys(PRICES)) {
26
+ if (lower.includes(key)) return PRICES[key]!;
27
+ }
28
+ if (lower.includes('opus')) return PRICES['claude-opus']!;
29
+ if (lower.includes('sonnet')) return PRICES['claude-sonnet']!;
30
+ if (lower.includes('haiku')) return PRICES['claude-haiku']!;
31
+ return DEFAULT_PRICE;
32
+ }
33
+
34
+ /** Estimate USD cost of one turn's usage. cost = sum(tokens * $/M) / 1e6. */
35
+ export function estimateCostUsd(usage: Usage, model?: string): number {
36
+ const p = priceFor(model);
37
+ const dollars =
38
+ (usage.input_tokens * p.input +
39
+ usage.output_tokens * p.output +
40
+ usage.cache_read_tokens * p.cacheRead +
41
+ usage.cache_write_tokens * p.cacheWrite) /
42
+ 1_000_000;
43
+ // round to 6 dp to avoid float noise
44
+ return Math.round(dollars * 1e6) / 1e6;
45
+ }
@@ -0,0 +1,46 @@
1
+ import { existsSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { StateStore } from './state';
5
+ import { withTempDir } from './test/tmp';
6
+
7
+ describe('StateStore', () => {
8
+ it('initializes and persists a session', () => {
9
+ withTempDir((dir) => {
10
+ const path = join(dir, 'state.json');
11
+ const store = new StateStore(path);
12
+ const s = store.ensure('sess-1', '/tmp/sess-1.jsonl');
13
+ expect(s.offset).toBe(0);
14
+ expect(s.nextTurnIndex).toBe(0);
15
+ expect(existsSync(path)).toBe(true);
16
+ });
17
+ });
18
+
19
+ it('survives reload (restart-safe)', () => {
20
+ withTempDir((dir) => {
21
+ const path = join(dir, 'state.json');
22
+ const a = new StateStore(path);
23
+ a.ensure('sess-1', '/tmp/sess-1.jsonl');
24
+ a.update('sess-1', { offset: 128, nextTurnIndex: 4, month: '2026-06' });
25
+
26
+ const b = new StateStore(path);
27
+ const s = b.get('sess-1');
28
+ expect(s).toBeDefined();
29
+ expect(s!.offset).toBe(128);
30
+ expect(s!.nextTurnIndex).toBe(4);
31
+ expect(s!.month).toBe('2026-06');
32
+ });
33
+ });
34
+
35
+ it('recovers from a corrupt state file', () => {
36
+ withTempDir((dir) => {
37
+ const path = join(dir, 'state.json');
38
+ writeFileSync(path, 'not json at all');
39
+ const store = new StateStore(path);
40
+ expect(store.all()).toEqual({});
41
+ // still usable
42
+ store.ensure('x', '/tmp/x.jsonl');
43
+ expect(store.get('x')).toBeDefined();
44
+ });
45
+ });
46
+ });
package/src/state.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ /**
5
+ * Daemon bookkeeping — NOT the store. Tracks, per native session, how far we
6
+ * have consumed its JSONL and the next canonical turn index. Rebuildable by
7
+ * re-scanning if lost.
8
+ */
9
+
10
+ export interface SessionState {
11
+ /** native transcript path being tailed */
12
+ transcriptPath: string;
13
+ /** bytes of the transcript already consumed */
14
+ offset: number;
15
+ /** next canonical turn_index to assign */
16
+ nextTurnIndex: number;
17
+ /** YYYY-MM shard the session was filed under (from its first turn) */
18
+ month?: string;
19
+ startedAt?: string;
20
+ lastTs?: string;
21
+ endedAt?: string;
22
+ }
23
+
24
+ interface StateFile {
25
+ version: 1;
26
+ sessions: Record<string, SessionState>;
27
+ }
28
+
29
+ export class StateStore {
30
+ private data: StateFile;
31
+
32
+ constructor(private readonly path: string) {
33
+ this.data = this.read();
34
+ }
35
+
36
+ private read(): StateFile {
37
+ if (existsSync(this.path)) {
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(this.path, 'utf8')) as StateFile;
40
+ if (parsed && parsed.version === 1 && parsed.sessions) return parsed;
41
+ } catch {
42
+ // fall through to fresh state
43
+ }
44
+ }
45
+ return { version: 1, sessions: {} };
46
+ }
47
+
48
+ /** Atomic write: tmp file + rename. */
49
+ private flush(): void {
50
+ mkdirSync(dirname(this.path), { recursive: true });
51
+ const tmp = `${this.path}.tmp`;
52
+ writeFileSync(tmp, JSON.stringify(this.data, null, 2));
53
+ renameSync(tmp, this.path);
54
+ }
55
+
56
+ get(sessionId: string): SessionState | undefined {
57
+ return this.data.sessions[sessionId];
58
+ }
59
+
60
+ /** Get existing state or initialize a fresh one for this transcript. */
61
+ ensure(sessionId: string, transcriptPath: string): SessionState {
62
+ let s = this.data.sessions[sessionId];
63
+ if (!s) {
64
+ s = { transcriptPath, offset: 0, nextTurnIndex: 0 };
65
+ this.data.sessions[sessionId] = s;
66
+ this.flush();
67
+ } else if (transcriptPath && s.transcriptPath !== transcriptPath) {
68
+ s.transcriptPath = transcriptPath;
69
+ this.flush();
70
+ }
71
+ return s;
72
+ }
73
+
74
+ update(sessionId: string, patch: Partial<SessionState>): SessionState {
75
+ const s = this.data.sessions[sessionId] ?? {
76
+ transcriptPath: '',
77
+ offset: 0,
78
+ nextTurnIndex: 0,
79
+ };
80
+ const next = { ...s, ...patch };
81
+ this.data.sessions[sessionId] = next;
82
+ this.flush();
83
+ return next;
84
+ }
85
+
86
+ all(): Record<string, SessionState> {
87
+ return this.data.sessions;
88
+ }
89
+ }
@@ -0,0 +1,99 @@
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 } from 'vitest';
5
+ import { withTempDir } from '../test/tmp';
6
+ import { GitStore } from './git';
7
+
8
+ function git(cwd: string, ...args: string[]): string {
9
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
10
+ if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
11
+ return (r.stdout ?? '').trim();
12
+ }
13
+
14
+ function writeFile(root: string, rel: string, content: string): void {
15
+ const p = join(root, rel);
16
+ mkdirSync(join(p, '..'), { recursive: true });
17
+ writeFileSync(p, content);
18
+ }
19
+
20
+ describe('GitStore basics', () => {
21
+ it('initializes a repo with scaffolding (idempotent)', () => {
22
+ withTempDir((dir) => {
23
+ const store = new GitStore(dir);
24
+ store.init();
25
+ expect(store.isRepo()).toBe(true);
26
+ expect(existsSync(join(dir, '.gitignore'))).toBe(true);
27
+ expect(existsSync(join(dir, '.gitattributes'))).toBe(true);
28
+ expect(() => store.init()).not.toThrow(); // idempotent
29
+ });
30
+ });
31
+
32
+ it('commits content and reports clean trees', () => {
33
+ withTempDir((dir) => {
34
+ const store = new GitStore(dir);
35
+ store.init();
36
+ expect(store.commit('init').committed).toBe(true); // .gitignore/.gitattributes
37
+ expect(store.commit('noop').committed).toBe(false); // clean now
38
+
39
+ writeFile(dir, 'hosts/A/2026-06/s1/turns/000000.json', '{"a":1}');
40
+ const c = store.commit('add turn');
41
+ expect(c.committed).toBe(true);
42
+ expect(c.sha).toMatch(/^[0-9a-f]{7,40}$/);
43
+ });
44
+ });
45
+
46
+ it('ignores runtime (.daemon) and raw/ blobs', () => {
47
+ withTempDir((dir) => {
48
+ const store = new GitStore(dir);
49
+ store.init();
50
+ store.commit('init');
51
+ writeFile(dir, '.daemon/state.json', '{}');
52
+ writeFile(dir, 'hosts/A/2026-06/s1/raw/deadbeef', 'huge');
53
+ expect(store.hasChanges()).toBe(false); // both ignored
54
+ });
55
+ });
56
+ });
57
+
58
+ describe('GitStore two-host conflict-free sync', () => {
59
+ it('merges host-keyed writes from two machines with no conflict', () => {
60
+ withTempDir((root) => {
61
+ const remote = join(root, 'remote.git');
62
+ mkdirSync(remote, { recursive: true });
63
+ git(remote, 'init', '--bare', '-b', 'main');
64
+
65
+ // Host A: init, write, push
66
+ const dirA = join(root, 'hostA');
67
+ mkdirSync(dirA, { recursive: true });
68
+ const a = new GitStore(dirA, { remote, autoPush: true });
69
+ a.init();
70
+ writeFile(dirA, 'hosts/A/2026-06/sa/turns/000000.json', '{"host":"A"}');
71
+ const ra = a.sync('A: turn 0');
72
+ expect(ra.commit.committed).toBe(true);
73
+ expect(ra.pushed).toBe(true);
74
+
75
+ // Host B: clone, write a DIFFERENT host path, sync (pull --rebase + push)
76
+ const dirB = join(root, 'hostB');
77
+ git(root, 'clone', remote, dirB);
78
+ const b = new GitStore(dirB, { remote, autoPush: true });
79
+ b.init();
80
+ writeFile(dirB, 'hosts/B/2026-06/sb/turns/000000.json', '{"host":"B"}');
81
+ const rb = b.sync('B: turn 0');
82
+ expect(rb.commit.committed).toBe(true);
83
+ expect(rb.pushed).toBe(true);
84
+
85
+ // Host A: new write, must pull B's commit (rebase) then push — still clean
86
+ writeFile(dirA, 'hosts/A/2026-06/sa/turns/000001.json', '{"host":"A2"}');
87
+ const ra2 = a.sync('A: turn 1');
88
+ expect(ra2.commit.committed).toBe(true);
89
+ expect(ra2.pushed).toBe(true);
90
+
91
+ // Fresh clone sees ALL three files — nothing was lost or conflicted
92
+ const verify = join(root, 'verify');
93
+ git(root, 'clone', remote, verify);
94
+ expect(existsSync(join(verify, 'hosts/A/2026-06/sa/turns/000000.json'))).toBe(true);
95
+ expect(existsSync(join(verify, 'hosts/A/2026-06/sa/turns/000001.json'))).toBe(true);
96
+ expect(existsSync(join(verify, 'hosts/B/2026-06/sb/turns/000000.json'))).toBe(true);
97
+ });
98
+ });
99
+ });
@@ -0,0 +1,138 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * Git-backed store operations. Append-only + host-keyed paths mean writers never
7
+ * target the same file, so commits/merges are conflict-free by construction.
8
+ * `.gitattributes` adds merge=union for the few append-only manifests.
9
+ */
10
+
11
+ export interface GitResult {
12
+ ok: boolean;
13
+ stdout: string;
14
+ stderr: string;
15
+ code: number;
16
+ }
17
+
18
+ export interface CommitResult {
19
+ committed: boolean;
20
+ sha?: string;
21
+ reason?: string;
22
+ }
23
+
24
+ const FALLBACK_IDENTITY = ['-c', 'user.name=code-sessions', '-c', 'user.email=agent@code-sessions'];
25
+
26
+ const GITIGNORE = `# code-sessions store — runtime + local-only artifacts
27
+ .daemon/
28
+ *.tmp
29
+ # large externalized blobs stay local in MVP-1 (LFS/object-store in MVP-2)
30
+ raw/
31
+ `;
32
+
33
+ const GITATTRIBUTES = `# append-only manifests merge by union so concurrent appends both survive
34
+ *.jsonl merge=union
35
+ telemetry/*.jsonl merge=union
36
+ `;
37
+
38
+ export class GitStore {
39
+ constructor(
40
+ private readonly dir: string,
41
+ private readonly opts: { remote?: string; autoPush?: boolean } = {},
42
+ ) {}
43
+
44
+ private run(args: string[], extraEnv?: Record<string, string>): GitResult {
45
+ const res = spawnSync('git', ['-C', this.dir, ...args], {
46
+ encoding: 'utf8',
47
+ env: { ...process.env, ...extraEnv },
48
+ });
49
+ return {
50
+ ok: res.status === 0,
51
+ stdout: (res.stdout ?? '').trim(),
52
+ stderr: (res.stderr ?? '').trim(),
53
+ code: res.status ?? -1,
54
+ };
55
+ }
56
+
57
+ isRepo(): boolean {
58
+ return existsSync(join(this.dir, '.git'));
59
+ }
60
+
61
+ /** Initialize the store repo (idempotent): git init, scaffolding files, remote. */
62
+ init(): void {
63
+ if (!this.isRepo()) {
64
+ const r = spawnSync('git', ['init', '-b', 'main', this.dir], { encoding: 'utf8' });
65
+ if (r.status !== 0) throw new Error(`git init failed: ${r.stderr}`);
66
+ }
67
+ const giPath = join(this.dir, '.gitignore');
68
+ if (!existsSync(giPath)) writeFileSync(giPath, GITIGNORE);
69
+ const gaPath = join(this.dir, '.gitattributes');
70
+ if (!existsSync(gaPath)) writeFileSync(gaPath, GITATTRIBUTES);
71
+ if (this.opts.remote) this.ensureRemote(this.opts.remote);
72
+ }
73
+
74
+ ensureRemote(remote: string): void {
75
+ const existing = this.run(['remote', 'get-url', 'origin']);
76
+ if (existing.ok) {
77
+ if (existing.stdout !== remote) this.run(['remote', 'set-url', 'origin', remote]);
78
+ } else {
79
+ this.run(['remote', 'add', 'origin', remote]);
80
+ }
81
+ }
82
+
83
+ hasChanges(): boolean {
84
+ const r = this.run(['status', '--porcelain']);
85
+ return r.stdout.length > 0;
86
+ }
87
+
88
+ /** Stage everything and commit. Returns committed:false when the tree is clean. */
89
+ commit(message: string): CommitResult {
90
+ this.run(['add', '-A']);
91
+ if (!this.hasChanges() && !this.hasStaged()) {
92
+ return { committed: false, reason: 'nothing to commit' };
93
+ }
94
+ let r = this.run(['commit', '-m', message]);
95
+ if (!r.ok && /Please tell me who you are|user\.email|empty ident/i.test(r.stderr)) {
96
+ r = this.run([...FALLBACK_IDENTITY, 'commit', '-m', message]);
97
+ }
98
+ if (!r.ok) {
99
+ // "nothing to commit" race
100
+ if (/nothing to commit/i.test(r.stdout + r.stderr)) {
101
+ return { committed: false, reason: 'nothing to commit' };
102
+ }
103
+ return { committed: false, reason: r.stderr || r.stdout };
104
+ }
105
+ const sha = this.run(['rev-parse', 'HEAD']).stdout;
106
+ return { committed: true, sha };
107
+ }
108
+
109
+ private hasStaged(): boolean {
110
+ // diff --cached exits 1 when there are staged changes
111
+ const r = this.run(['diff', '--cached', '--quiet']);
112
+ return r.code === 1;
113
+ }
114
+
115
+ currentBranch(): string {
116
+ return this.run(['rev-parse', '--abbrev-ref', 'HEAD']).stdout || 'main';
117
+ }
118
+
119
+ /** Rebase local commits on top of the remote (clean by construction for host-keyed paths). */
120
+ pull(): GitResult {
121
+ return this.run(['pull', '--rebase', '--autostash', 'origin', this.currentBranch()]);
122
+ }
123
+
124
+ push(): GitResult {
125
+ return this.run(['push', '-u', 'origin', this.currentBranch()]);
126
+ }
127
+
128
+ /** Convenience: commit, and when a remote+autoPush are configured, pull --rebase then push. */
129
+ sync(message: string): { commit: CommitResult; pushed: boolean; pushError?: string } {
130
+ const commit = this.commit(message);
131
+ if (!commit.committed) return { commit, pushed: false };
132
+ if (!this.opts.remote || !this.opts.autoPush) return { commit, pushed: false };
133
+ // best-effort: integrate remote first, then push
134
+ this.pull();
135
+ const p = this.push();
136
+ return { commit, pushed: p.ok, ...(p.ok ? {} : { pushError: p.stderr }) };
137
+ }
138
+ }
@@ -0,0 +1,45 @@
1
+ import { join } from 'node:path';
2
+
3
+ /**
4
+ * Store layout — sharded host/month/session so no directory holds too many
5
+ * entries and retention is a `rm -rf <old-month>`.
6
+ *
7
+ * hosts/<host>/<YYYY-MM>/<session-uuid>/
8
+ * session.json
9
+ * turns/000007.json
10
+ * telemetry/otel.jsonl
11
+ * insights/labels.json
12
+ * raw/<sha256>
13
+ */
14
+
15
+ export function monthOf(ts: string | undefined): string {
16
+ if (ts) {
17
+ const m = /^(\d{4})-(\d{2})/.exec(ts);
18
+ if (m) return `${m[1]}-${m[2]}`;
19
+ }
20
+ return 'unknown';
21
+ }
22
+
23
+ export function sessionDir(storeDir: string, host: string, month: string, sessionId: string): string {
24
+ return join(storeDir, 'hosts', host, month, sessionId);
25
+ }
26
+
27
+ export function turnFile(dir: string, index: number): string {
28
+ return join(dir, 'turns', `${String(index).padStart(6, '0')}.json`);
29
+ }
30
+
31
+ export function envelopeFile(dir: string): string {
32
+ return join(dir, 'session.json');
33
+ }
34
+
35
+ export function insightsFile(dir: string): string {
36
+ return join(dir, 'insights', 'labels.json');
37
+ }
38
+
39
+ export function telemetryFile(dir: string): string {
40
+ return join(dir, 'telemetry', 'otel.jsonl');
41
+ }
42
+
43
+ export function rawBlobFile(dir: string, sha: string): string {
44
+ return join(dir, 'raw', sha);
45
+ }
@@ -0,0 +1,39 @@
1
+ import { readdirSync, type Dirent } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export interface SessionRef {
5
+ host: string;
6
+ month: string;
7
+ sessionId: string;
8
+ dir: string;
9
+ }
10
+
11
+ /** readdir(withFileTypes) that never throws and returns [] for a missing dir. */
12
+ export function readEntries(dir: string): Dirent[] {
13
+ try {
14
+ return readdirSync(dir, { withFileTypes: true }) as Dirent[];
15
+ } catch {
16
+ return [];
17
+ }
18
+ }
19
+
20
+ function subdirs(dir: string): string[] {
21
+ return readEntries(dir)
22
+ .filter((e) => e.isDirectory())
23
+ .map((e) => String(e.name));
24
+ }
25
+
26
+ /** Enumerate every session in the store: hosts/<host>/<month>/<sessionId>/. */
27
+ export function listSessionDirs(storeDir: string, opts: { sinceMonth?: string } = {}): SessionRef[] {
28
+ const hostsRoot = join(storeDir, 'hosts');
29
+ const out: SessionRef[] = [];
30
+ for (const host of subdirs(hostsRoot)) {
31
+ for (const month of subdirs(join(hostsRoot, host))) {
32
+ if (opts.sinceMonth && month < opts.sinceMonth) continue;
33
+ for (const sessionId of subdirs(join(hostsRoot, host, month))) {
34
+ out.push({ host, month, sessionId, dir: join(hostsRoot, host, month, sessionId) });
35
+ }
36
+ }
37
+ }
38
+ return out;
39
+ }