import { randomUUID } from 'node:crypto'; import path from 'node:path'; import type { CodingConversationMetadata } from '../../shared/coding-project-contracts'; import type { ConversationModelState, ProductModelRef } from '../coding-runtime/contracts'; import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json'; import { normalizeProductModelRef } from './project-config'; export const CODING_CONVERSATIONS_PATH = '.makelore/conversations.json'; export interface CodingConversationV2 extends CodingConversationMetadata, ConversationModelState { piSessionId?: string; sessionKey?: string; } export interface CodingConversationFileV2 { schemaVersion: 2; conversations: CodingConversationV2[]; } export interface CreateCodingConversationInput { agentId: string; title: string; model: ProductModelRef | null; modelResolution: 'resolved' | 'required'; } export interface PiSessionBinding { piSessionId: string; sessionKey: string; } const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const SESSION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; function conversationFilePath(projectPath: string): string { return path.join(projectPath, CODING_CONVERSATIONS_PATH); } function cleanString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } function normalizeModelState(value: { model?: unknown; modelResolution?: unknown; }): ConversationModelState { if (value.modelResolution === 'required') { if (value.model !== null) throw new Error('Required Conversation model must be null'); return { model: null, modelResolution: 'required' }; } if (value.modelResolution !== 'resolved') throw new Error('Conversation model resolution is invalid'); return { model: normalizeProductModelRef(value.model), modelResolution: 'resolved' }; } export function validateSessionKey(value: unknown): string { const sessionKey = typeof value === 'string' ? value : ''; if (!SESSION_KEY_PATTERN.test(sessionKey) || path.isAbsolute(sessionKey)) { throw new Error('Pi session key must be an opaque relative key'); } return sessionKey; } function normalizeConversation(value: unknown): CodingConversationV2 { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Coding Conversation must be an object'); } const record = value as Partial; const id = cleanString(record.id); const agentId = cleanString(record.agentId); const title = cleanString(record.title); const createdAt = cleanString(record.createdAt); const updatedAt = cleanString(record.updatedAt); if (!UUID_PATTERN.test(id)) throw new Error('Conversation id must be a UUID'); if (!agentId) throw new Error('Conversation Agent id is required'); if (!title) throw new Error('Conversation title is required'); if (!createdAt || !updatedAt) throw new Error('Conversation timestamps are required'); const sessionKey = record.sessionKey === undefined ? undefined : validateSessionKey(record.sessionKey); const piSessionId = cleanString(record.piSessionId) || undefined; if ((sessionKey === undefined) !== (piSessionId === undefined)) { throw new Error('Pi session id and session key must be persisted together'); } return { id, agentId, title, ...normalizeModelState(record), ...(piSessionId ? { piSessionId } : {}), ...(sessionKey ? { sessionKey } : {}), archivedAt: cleanString(record.archivedAt) || null, unread: record.unread === true, createdAt, updatedAt, }; } export function createEmptyConversationFileV2(): CodingConversationFileV2 { return { schemaVersion: 2, conversations: [] }; } export function normalizeCodingConversationFileV2(value: unknown): CodingConversationFileV2 { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Coding Conversation file must be an object'); } const record = value as Partial; if (record.schemaVersion !== 2) throw new Error('Unsupported coding Conversation schema'); if (!Array.isArray(record.conversations)) throw new Error('Conversations must be an array'); const conversations = record.conversations.map(normalizeConversation); if (new Set(conversations.map((conversation) => conversation.id)).size !== conversations.length) { throw new Error('Duplicate Conversation id'); } return { schemaVersion: 2, conversations }; } export function createCodingConversationStore( projectPath: string, options: { createId?: () => string; now?: () => string; writer?: JsonFileWriter; } = {}, ) { const createId = options.createId ?? randomUUID; const now = options.now ?? (() => new Date().toISOString()); const writer = options.writer ?? atomicWriteJson; const bindingFlights = new Map>(); let mutationTail = Promise.resolve(); async function readNow(): Promise { try { return normalizeCodingConversationFileV2(await readJsonFile(conversationFilePath(projectPath))); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return createEmptyConversationFileV2(); throw error; } } function mutate(operation: (file: CodingConversationFileV2) => Promise<{ result: T; file: CodingConversationFileV2; }>): Promise { const execute = async () => { const change = await operation(await readNow()); const normalized = normalizeCodingConversationFileV2(change.file); await writer(conversationFilePath(projectPath), normalized); return change.result; }; const result = mutationTail.then(execute, execute); mutationTail = result.then(() => undefined, () => undefined); return result; } async function getConversation(conversationId: string): Promise { await mutationTail; return (await readNow()).conversations.find((item) => item.id === conversationId) ?? null; } return { async read(): Promise { await mutationTail; return await readNow(); }, async create(input: CreateCodingConversationInput): Promise { return await mutate(async (file) => { const timestamp = now(); const conversation = normalizeConversation({ id: createId(), agentId: input.agentId, title: input.title, model: input.model, modelResolution: input.modelResolution, archivedAt: null, unread: false, createdAt: timestamp, updatedAt: timestamp, }); return { result: conversation, file: { schemaVersion: 2, conversations: [conversation, ...file.conversations] }, }; }); }, get: getConversation, async patchMetadata( conversationId: string, patch: Partial>, ): Promise { return await mutate(async (file) => { const current = file.conversations.find((item) => item.id === conversationId); if (!current) throw new Error('Conversation does not exist'); const updated = normalizeConversation({ ...current, ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.archivedAt !== undefined ? { archivedAt: patch.archivedAt } : {}), ...(patch.unread !== undefined ? { unread: patch.unread } : {}), updatedAt: now(), }); return { result: updated, file: { schemaVersion: 2, conversations: file.conversations.map((item) => item.id === conversationId ? updated : item), }, }; }); }, async setModelState( conversationId: string, modelState: ConversationModelState, ): Promise { return await mutate(async (file) => { const current = file.conversations.find((item) => item.id === conversationId); if (!current) throw new Error('Conversation does not exist'); const updated = normalizeConversation({ ...current, ...modelState, updatedAt: now(), }); return { result: updated, file: { schemaVersion: 2, conversations: file.conversations.map((item) => item.id === conversationId ? updated : item), }, }; }); }, async delete(conversationId: string): Promise { await mutate(async (file) => { const current = file.conversations.find((item) => item.id === conversationId); if (!current) throw new Error('Conversation does not exist'); return { result: undefined, file: { schemaVersion: 2, conversations: file.conversations.filter((item) => item.id !== conversationId), }, }; }); }, async ensureSessionBinding( conversationId: string, createBinding: () => Promise, ): Promise { const inFlight = bindingFlights.get(conversationId); if (inFlight) return await inFlight; const flight = (async () => { const current = await getConversation(conversationId); if (!current) throw new Error('Conversation does not exist'); if (current.sessionKey && current.piSessionId) return current; const binding = await createBinding(); const sessionKey = validateSessionKey(binding.sessionKey); const piSessionId = cleanString(binding.piSessionId); if (!piSessionId) throw new Error('Pi session id is required'); return await mutate(async (file) => { const latest = file.conversations.find((item) => item.id === conversationId); if (!latest) throw new Error('Conversation does not exist'); if (latest.sessionKey && latest.piSessionId) return { result: latest, file }; const updated = normalizeConversation({ ...latest, sessionKey, piSessionId, updatedAt: now(), }); return { result: updated, file: { schemaVersion: 2, conversations: file.conversations.map((item) => item.id === conversationId ? updated : item), }, }; }); })(); bindingFlights.set(conversationId, flight); try { return await flight; } finally { bindingFlights.delete(conversationId); } }, }; }