@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
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
|
+
});
|
package/src/store/git.ts
ADDED
|
@@ -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
|
+
}
|