diff --git a/src/zero-sessions/index.ts b/src/zero-sessions/index.ts new file mode 100644 index 000000000..c50c996e2 --- /dev/null +++ b/src/zero-sessions/index.ts @@ -0,0 +1,12 @@ +export { ZeroSessionEventStore, defaultZeroSessionRoot } from './store'; +export type { + AppendZeroSessionEventInput, + CreateZeroSessionInput, + DefaultZeroSessionRootOptions, + ZeroSessionEvent, + ZeroSessionEventStoreOptions, + ZeroSessionEventType, + ZeroSessionMetadata, + ZeroSessionSearchHit, + ZeroSessionSearchOptions, +} from './types'; diff --git a/src/zero-sessions/store.ts b/src/zero-sessions/store.ts new file mode 100644 index 000000000..f91435c97 --- /dev/null +++ b/src/zero-sessions/store.ts @@ -0,0 +1,256 @@ +import { appendFile, mkdir, readFile, readdir, writeFile } from 'fs/promises'; +import { homedir } from 'os'; +import { join } from 'path'; +import type { + AppendZeroSessionEventInput, + CreateZeroSessionInput, + DefaultZeroSessionRootOptions, + ZeroSessionEvent, + ZeroSessionEventStoreOptions, + ZeroSessionMetadata, + ZeroSessionSearchHit, + ZeroSessionSearchOptions, +} from './types'; + +const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const METADATA_FILE = 'metadata.json'; +const EVENTS_FILE = 'events.jsonl'; + +export function defaultZeroSessionRoot(options: DefaultZeroSessionRootOptions = {}): string { + const env = options.env ?? process.env; + const dataHome = env.XDG_DATA_HOME?.trim(); + const home = env.HOME?.trim() || homedir(); + const baseDir = dataHome || join(home, '.local', 'share'); + + return join(baseDir, 'zero', 'sessions'); +} + +export class ZeroSessionEventStore { + readonly rootDir: string; + + private readonly now: () => Date; + + constructor(options: ZeroSessionEventStoreOptions = {}) { + this.rootDir = options.rootDir ?? defaultZeroSessionRoot(); + this.now = options.now ?? (() => new Date()); + } + + async createSession(input: CreateZeroSessionInput = {}): Promise { + const sessionId = input.sessionId ?? createZeroSessionId(this.now()); + assertValidSessionId(sessionId); + + const createdAt = this.timestamp(); + const session: ZeroSessionMetadata = { + sessionId, + title: input.title, + cwd: input.cwd, + modelId: input.modelId, + provider: input.provider, + createdAt, + updatedAt: createdAt, + eventCount: 0, + }; + + await mkdir(this.rootDir, { recursive: true }); + await mkdir(this.sessionPath(sessionId)).catch((err: unknown) => { + if (isFileExistsError(err)) { + throw new Error(`Zero session already exists: ${sessionId}`); + } + throw err; + }); + await this.writeMetadata(session); + await writeFile(this.eventsPath(sessionId), '', { flag: 'wx' }).catch((err: unknown) => { + if (isFileExistsError(err)) return; + throw err; + }); + + return session; + } + + async getSession(sessionId: string): Promise { + assertValidSessionId(sessionId); + + try { + return await this.readMetadata(sessionId); + } catch (err: unknown) { + if (isNotFoundError(err)) return undefined; + throw err; + } + } + + async listSessions(): Promise { + await mkdir(this.rootDir, { recursive: true }); + const entries = await readdir(this.rootDir, { withFileTypes: true }); + const sessions: ZeroSessionMetadata[] = []; + + for (const entry of entries) { + if (!entry.isDirectory() || !isValidSessionId(entry.name)) continue; + + const session = await this.getSession(entry.name); + if (session) sessions.push(session); + } + + return sessions.sort((left, right) => { + const updated = right.updatedAt.localeCompare(left.updatedAt); + return updated || left.sessionId.localeCompare(right.sessionId); + }); + } + + async appendEvent( + sessionId: string, + input: AppendZeroSessionEventInput + ): Promise { + assertValidSessionId(sessionId); + const session = await this.readMetadata(sessionId); + const sequence = session.eventCount + 1; + const createdAt = this.timestamp(); + const event: ZeroSessionEvent = { + id: `${sessionId}:${sequence}`, + sessionId, + sequence, + type: input.type, + createdAt, + payload: input.payload, + }; + + await appendFile(this.eventsPath(sessionId), `${JSON.stringify(event)}\n`, 'utf-8'); + await this.writeMetadata({ + ...session, + updatedAt: createdAt, + eventCount: sequence, + lastEventType: input.type, + }); + + return event; + } + + async readEvents(sessionId: string): Promise { + assertValidSessionId(sessionId); + + let content: string; + try { + content = await readFile(this.eventsPath(sessionId), 'utf-8'); + } catch (err: unknown) { + if (isNotFoundError(err)) return []; + throw err; + } + + const events: ZeroSessionEvent[] = []; + const lines = content.split('\n'); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]?.trim(); + if (!line) continue; + + try { + events.push(JSON.parse(line) as ZeroSessionEvent); + } catch { + throw new Error( + `Invalid JSON in Zero session ${sessionId} ${EVENTS_FILE} at line ${index + 1}` + ); + } + } + + return events; + } + + async searchEvents( + query: string, + options: ZeroSessionSearchOptions = {} + ): Promise { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return []; + + const contextChars = Math.max(0, Math.floor(options.contextChars ?? 80)); + const limit = Math.max(0, Math.floor(options.limit ?? Number.POSITIVE_INFINITY)); + const hits: ZeroSessionSearchHit[] = []; + + for (const session of await this.listSessions()) { + for (const event of await this.readEvents(session.sessionId)) { + const text = extractSearchText(event.payload); + const matchIndex = text.toLowerCase().indexOf(normalizedQuery); + if (matchIndex === -1) continue; + + hits.push({ + sessionId: session.sessionId, + eventId: event.id, + sequence: event.sequence, + type: event.type, + context: text.slice( + Math.max(0, matchIndex - contextChars), + Math.min(text.length, matchIndex + query.trim().length + contextChars) + ), + }); + + if (hits.length >= limit) return hits; + } + } + + return hits; + } + + private timestamp(): string { + return this.now().toISOString(); + } + + private sessionPath(sessionId: string): string { + return join(this.rootDir, sessionId); + } + + private metadataPath(sessionId: string): string { + return join(this.sessionPath(sessionId), METADATA_FILE); + } + + private eventsPath(sessionId: string): string { + return join(this.sessionPath(sessionId), EVENTS_FILE); + } + + private async readMetadata(sessionId: string): Promise { + const content = await readFile(this.metadataPath(sessionId), 'utf-8'); + return JSON.parse(content) as ZeroSessionMetadata; + } + + private async writeMetadata(session: ZeroSessionMetadata): Promise { + await writeFile(this.metadataPath(session.sessionId), `${JSON.stringify(session, null, 2)}\n`); + } +} + +function createZeroSessionId(now: Date): string { + const date = now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); + const random = Math.random().toString(36).slice(2, 10); + return `zero_${date}_${random}`; +} + +function assertValidSessionId(sessionId: string): void { + if (!isValidSessionId(sessionId)) { + throw new Error('Invalid Zero session id'); + } +} + +function isValidSessionId(sessionId: string): boolean { + return SESSION_ID_PATTERN.test(sessionId); +} + +function extractSearchText(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) { + return value.map(extractSearchText).filter(Boolean).join(' '); + } + if (typeof value === 'object' && value !== null) { + return Object.values(value).map(extractSearchText).filter(Boolean).join(' '); + } + return ''; +} + +function isFileExistsError(err: unknown): boolean { + return isNodeErrnoException(err) && err.code === 'EEXIST'; +} + +function isNotFoundError(err: unknown): boolean { + return isNodeErrnoException(err) && err.code === 'ENOENT'; +} + +function isNodeErrnoException(err: unknown): err is NodeJS.ErrnoException { + return err instanceof Error && 'code' in err; +} diff --git a/src/zero-sessions/types.ts b/src/zero-sessions/types.ts new file mode 100644 index 000000000..0422c2b7f --- /dev/null +++ b/src/zero-sessions/types.ts @@ -0,0 +1,63 @@ +export type ZeroSessionEventType = + | 'message' + | 'tool_call' + | 'tool_result' + | 'provider_usage' + | 'error' + | (string & {}); + +export interface ZeroSessionMetadata { + sessionId: string; + title?: string; + cwd?: string; + modelId?: string; + provider?: string; + createdAt: string; + updatedAt: string; + eventCount: number; + lastEventType?: ZeroSessionEventType; +} + +export interface CreateZeroSessionInput { + sessionId?: string; + title?: string; + cwd?: string; + modelId?: string; + provider?: string; +} + +export interface AppendZeroSessionEventInput { + type: ZeroSessionEventType; + payload?: unknown; +} + +export interface ZeroSessionEvent { + id: string; + sessionId: string; + sequence: number; + type: ZeroSessionEventType; + createdAt: string; + payload?: unknown; +} + +export interface ZeroSessionSearchOptions { + contextChars?: number; + limit?: number; +} + +export interface ZeroSessionSearchHit { + sessionId: string; + eventId: string; + sequence: number; + type: ZeroSessionEventType; + context: string; +} + +export interface ZeroSessionEventStoreOptions { + rootDir?: string; + now?: () => Date; +} + +export interface DefaultZeroSessionRootOptions { + env?: Record; +} diff --git a/tests/zero-session-events.test.ts b/tests/zero-session-events.test.ts new file mode 100644 index 000000000..9d5548f7a --- /dev/null +++ b/tests/zero-session-events.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { readFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + ZeroSessionEventStore, + defaultZeroSessionRoot, +} from '../src/zero-sessions'; + +function tempRoot(): string { + return mkdtempSync(join(tmpdir(), 'zero-sessions-')); +} + +describe('ZeroSessionEventStore', () => { + it('creates session metadata and appends ordered JSONL events', async () => { + const rootDir = tempRoot(); + try { + const store = new ZeroSessionEventStore({ + rootDir, + now: sequenceClock([ + '2026-06-03T00:00:00.000Z', + '2026-06-03T00:00:01.000Z', + '2026-06-03T00:00:02.000Z', + ]), + }); + + const session = await store.createSession({ + sessionId: 'session_abc123', + title: 'Implement event store', + cwd: '/repo/zero', + modelId: 'gpt-4.1', + provider: 'openai', + }); + expect(session).toEqual({ + sessionId: 'session_abc123', + title: 'Implement event store', + cwd: '/repo/zero', + modelId: 'gpt-4.1', + provider: 'openai', + createdAt: '2026-06-03T00:00:00.000Z', + updatedAt: '2026-06-03T00:00:00.000Z', + eventCount: 0, + }); + + const first = await store.appendEvent('session_abc123', { + type: 'message', + payload: { role: 'user', content: 'List files' }, + }); + const second = await store.appendEvent('session_abc123', { + type: 'provider_usage', + payload: { promptTokens: 12, completionTokens: 8 }, + }); + + expect(first.sequence).toBe(1); + expect(second.sequence).toBe(2); + expect(first.id).toBe('session_abc123:1'); + expect(second.id).toBe('session_abc123:2'); + + const events = await store.readEvents('session_abc123'); + expect(events).toEqual([first, second]); + + const eventsFile = await readFile( + join(rootDir, 'session_abc123', 'events.jsonl'), + 'utf-8' + ); + expect(eventsFile.trim().split('\n')).toHaveLength(2); + + const updated = await store.getSession('session_abc123'); + expect(updated?.updatedAt).toBe('2026-06-03T00:00:02.000Z'); + expect(updated?.eventCount).toBe(2); + expect(updated?.lastEventType).toBe('provider_usage'); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('lists sessions by most recently updated first', async () => { + const rootDir = tempRoot(); + try { + const store = new ZeroSessionEventStore({ + rootDir, + now: sequenceClock([ + '2026-06-03T00:00:00.000Z', + '2026-06-03T00:00:01.000Z', + '2026-06-03T00:00:02.000Z', + '2026-06-03T00:00:03.000Z', + ]), + }); + + await store.createSession({ sessionId: 'older', title: 'Older' }); + await store.createSession({ sessionId: 'newer', title: 'Newer' }); + await store.appendEvent('older', { + type: 'message', + payload: { role: 'assistant', content: 'updated later' }, + }); + + const sessions = await store.listSessions(); + expect(sessions.map((session) => session.sessionId)).toEqual([ + 'older', + 'newer', + ]); + expect(sessions[0]?.eventCount).toBe(1); + expect(sessions[1]?.eventCount).toBe(0); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('does not overwrite an existing session', async () => { + const rootDir = tempRoot(); + try { + const store = new ZeroSessionEventStore({ + rootDir, + now: sequenceClock([ + '2026-06-03T00:00:00.000Z', + '2026-06-03T00:00:01.000Z', + '2026-06-03T00:00:02.000Z', + ]), + }); + + await store.createSession({ sessionId: 'duplicate', title: 'Original' }); + const event = await store.appendEvent('duplicate', { + type: 'message', + payload: { role: 'user', content: 'keep this event' }, + }); + + await expect( + store.createSession({ sessionId: 'duplicate', title: 'Replacement' }) + ).rejects.toThrow('Zero session already exists: duplicate'); + + expect(await store.getSession('duplicate')).toMatchObject({ + title: 'Original', + eventCount: 1, + }); + expect(await store.readEvents('duplicate')).toEqual([event]); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('searches persisted event payload text with bounded context', async () => { + const rootDir = tempRoot(); + try { + const store = new ZeroSessionEventStore({ + rootDir, + now: sequenceClock([ + '2026-06-03T00:00:00.000Z', + '2026-06-03T00:00:01.000Z', + ]), + }); + + await store.createSession({ sessionId: 'searchable' }); + await store.appendEvent('searchable', { + type: 'tool_result', + payload: { + toolCallId: 'call_1', + result: 'The Zero event store writes append-only JSONL files.', + }, + }); + + const hits = await store.searchEvents('jsonl', { contextChars: 12 }); + expect(hits).toEqual([ + { + sessionId: 'searchable', + eventId: 'searchable:1', + sequence: 1, + type: 'tool_result', + context: 'append-only JSONL files.', + }, + ]); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('limits event search results for callers that only need the first matches', async () => { + const rootDir = tempRoot(); + try { + const store = new ZeroSessionEventStore({ + rootDir, + now: sequenceClock([ + '2026-06-03T00:00:00.000Z', + '2026-06-03T00:00:01.000Z', + '2026-06-03T00:00:02.000Z', + ]), + }); + + await store.createSession({ sessionId: 'limited' }); + await store.appendEvent('limited', { + type: 'message', + payload: { content: 'first matching zero event' }, + }); + await store.appendEvent('limited', { + type: 'message', + payload: { content: 'second matching zero event' }, + }); + + const hits = await store.searchEvents('matching', { limit: 1 }); + expect(hits).toHaveLength(1); + expect(hits[0]?.eventId).toBe('limited:1'); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('rejects invalid session ids and corrupted event lines', async () => { + const rootDir = tempRoot(); + try { + const store = new ZeroSessionEventStore({ rootDir }); + + await expect(store.createSession({ sessionId: '../bad' })).rejects.toThrow( + 'Invalid Zero session id' + ); + + await store.createSession({ sessionId: 'valid_session' }); + await Bun.write( + join(rootDir, 'valid_session', 'events.jsonl'), + '{"type":"message"}\nnot json\n' + ); + + await expect(store.readEvents('valid_session')).rejects.toThrow( + 'Invalid JSON in Zero session valid_session events.jsonl at line 2' + ); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); +}); + +describe('defaultZeroSessionRoot', () => { + it('uses XDG_DATA_HOME when present and falls back to ~/.local/share', () => { + expect(defaultZeroSessionRoot({ + env: { XDG_DATA_HOME: '/xdg/data', HOME: '/home/zero' }, + })).toBe(join('/xdg/data', 'zero', 'sessions')); + + expect(defaultZeroSessionRoot({ + env: { HOME: '/home/zero' }, + })).toBe(join('/home/zero', '.local', 'share', 'zero', 'sessions')); + }); +}); + +function sequenceClock(values: string[]): () => Date { + let index = 0; + return () => new Date(values[Math.min(index++, values.length - 1)]!); +}