import type { Agent, Connection } from "agents"; import * as Effect from "effect/Effect"; import type { ConversationSummary } from "../shared/conversations"; import { AgentFailure, agentCall, agentValidation } from "./agent-io"; import { conversationIdFromPath } from "./runtime-path"; import { OperationFailure } from "./operation-result"; export interface ConversationMetadata { id: string; name: string; created_at: string; updated_at: string; status: "creating" | "active" | "deleting"; } export function validateConversationId(id: unknown): asserts id is string { if ( typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id) ) throw new Error("Invalid conversation ID"); } function validateConversationName(name: unknown): string { if ( typeof name !== "string" || name.length > 120 || /[\u0000-\u001f\u007f-\u009f]/.test(name) || !name.trim() ) throw new Error( "Conversation name must be 1–120 characters without control characters", ); return name.trim(); } function conversationSummary(row: ConversationMetadata): ConversationSummary { return { id: row.id, name: row.name, createdAt: row.created_at, updatedAt: row.updated_at, }; } interface ConversationFacets { list(): { name: string; createdAt: number }[]; has(id: string): boolean; create(id: string): Effect.Effect; remove(id: string): Effect.Effect; } interface ConversationHost { facets: ConversationFacets; broadcast(message: string): void; connections(): Iterable; clear(id: string): void; cleanup(id: string): Effect.Effect; finishCleanup(id: string): void; generateTitle(message: string): Effect.Effect; waitUntil(work: Promise): void; } export class PersonalConversations { private readonly deletions = new Map>(); constructor( private readonly sql: Agent["sql"], private readonly host: ConversationHost, ) {} initialize() { const existing = this.sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'flarebot_conversations'`; this.sql`CREATE TABLE IF NOT EXISTS flarebot_conversations ( id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('creating', 'active', 'deleting')) )`; // Only newly created, unnamed conversations opt in. Existing conversations // must not acquire titles from a later message after an upgrade. this.sql`CREATE TABLE IF NOT EXISTS flarebot_conversation_naming ( id TEXT PRIMARY KEY, attempted INTEGER NOT NULL DEFAULT 0 )`; // Adopt facets created by the earlier internal harness once, at migration. // Never backfill on subsequent wakes: missing metadata must deny routing. if (!existing.length) { for (const facet of this.host.facets.list()) { const createdAt = new Date(facet.createdAt).toISOString(); this.sql`INSERT INTO flarebot_conversations VALUES (${facet.name}, 'New conversation', ${createdAt}, ${createdAt}, 'active')`; } } } recover() { return Effect.suspend(() => Effect.forEach( this .sql`SELECT * FROM flarebot_conversations WHERE status != 'active'`, (row) => this.finishDeletion(row.id), { discard: true }, ), ); } listConversations(): ConversationSummary[] { const registered = new Set( this.host.facets.list().map((facet) => facet.name), ); return this.sql`SELECT * FROM flarebot_conversations WHERE status = 'active' ORDER BY updated_at DESC, id ASC` .filter((row) => registered.has(row.id)) .map(conversationSummary); } renameConversation(id: unknown, name: unknown): ConversationSummary { validateConversationId(id); const displayName = validateConversationName(name); this.requireConversation(id); this.sql`DELETE FROM flarebot_conversation_naming WHERE id = ${id}`; this.sql`UPDATE flarebot_conversations SET name = ${displayName}, updated_at = ${new Date().toISOString()} WHERE id = ${id}`; this.host.broadcast(JSON.stringify({ type: "conversations-changed" })); return this.requireConversation(id); } activeConversation(id: string): ConversationMetadata | undefined { const [row] = this .sql`SELECT * FROM flarebot_conversations WHERE id = ${id} AND status = 'active'`; return row && this.host.facets.has(id) ? row : undefined; } requireConversation(id: string): ConversationSummary { const row = this.activeConversation(id); if (!row) throw new OperationFailure("conversation_missing"); return conversationSummary(row); } create(name?: unknown) { return Effect.gen({ self: this }, function* () { const displayName = yield* agentValidation(() => validateConversationName( name === undefined ? "New conversation" : name, ), ); const id = crypto.randomUUID(), now = new Date().toISOString(); this .sql`INSERT INTO flarebot_conversations VALUES (${id}, ${displayName}, ${now}, ${now}, 'creating')`; if (name === undefined) this.sql`INSERT INTO flarebot_conversation_naming (id) VALUES (${id})`; yield* this.host.facets.create(id).pipe( Effect.catchTag("AgentFailure", () => this.finishDeletion(id).pipe( Effect.andThen( Effect.fail( new AgentFailure({ message: "Conversation could not be created", }), ), ), ), ), ); this .sql`UPDATE flarebot_conversations SET status='active' WHERE id=${id}`; return this.requireConversation(id); }); } nameFromFirstMessage(id: string, message: string) { return Effect.gen({ self: this }, function* () { if (!this.activeConversation(id)) return; const claimed = this .sql`UPDATE flarebot_conversation_naming SET attempted = 1 WHERE id = ${id} AND attempted = 0 RETURNING id`; if (!claimed.length) return; // Do not send transcript context, system prompts, tool results or known // secret-bearing syntax to the title model at all. const safeMessage = message .slice(0, 4000) .replace(/```[\s\S]*?(?:```|$)|`[^`]*`/g, " ") .replace(/https?:\/\/\S+|\b\S+@\S+\b/gi, " ") .replace( /\b(?:password|secret|token|api[_ -]?key|authorization|credential)\b[^\n]*/gi, " ", ) .replace( /\b(?:[\w-]{20,}|sk-[\w-]+|gh[pousr]_\w+|github_pat_\w+)\b/gi, " ", ); if (!safeMessage.trim()) return; const title = validateConversationName( yield* this.host.generateTitle(safeMessage), ); if ( title === "New conversation" || !/^[\p{L}][\p{L}\p{M} '-]*$/u.test(title) || title.split(/\s+/).length < 2 || title.split(/\s+/).length > 12 || /\b(password|secret|token|credential|authorization|api.?key)\b/i.test( title, ) || /\b(?:sk-|bearer\b)/i.test(title) || title.split(/\s+/).some((word) => word.length >= 20) ) return; const updated = this.sql`UPDATE flarebot_conversations SET name = ${title}, updated_at = ${new Date().toISOString()} WHERE id = ${id} AND status = 'active' AND name = 'New conversation' AND EXISTS (SELECT 1 FROM flarebot_conversation_naming WHERE id = ${id} AND attempted = 1) RETURNING id`; if (updated.length) this.host.broadcast(JSON.stringify({ type: "conversations-changed" })); }).pipe(Effect.catchCause(() => Effect.void)); } delete(id: unknown) { return Effect.gen({ self: this }, function* () { const validId = yield* agentValidation(() => { validateConversationId(id); return id; }); if ( this.sql`SELECT id FROM flarebot_conversations WHERE id = ${validId}` .length ) yield* this.finishDeletion(validId); }); } finishDeletion(id: string): Effect.Effect { return Effect.suspend(() => { const existing = this.deletions.get(id); if (existing) return agentCall(() => existing, "Conversation deletion unavailable"); // Gate access and close sockets before the first asynchronous cleanup. this .sql`UPDATE flarebot_conversations SET status='deleting' WHERE id=${id}`; this.sql`DELETE FROM flarebot_conversation_naming WHERE id=${id}`; this.host.clear(id); for (const connection of this.host.connections()) if ( connection.uri && conversationIdFromPath(new URL(connection.uri).pathname) === id ) connection.close(4004, "Conversation deleted"); const deletion = Effect.runPromise( Effect.gen({ self: this }, function* () { yield* this.host.cleanup(id); yield* this.host.facets.remove(id); this.host.finishCleanup(id); this.sql`DELETE FROM flarebot_conversations WHERE id=${id}`; }).pipe(Effect.ensuring(Effect.sync(() => this.deletions.delete(id)))), ); this.deletions.set(id, deletion); this.host.waitUntil(deletion.catch(() => {})); return agentCall(() => deletion, "Conversation deletion unavailable"); }); } prepare(id: string) { return Effect.gen({ self: this }, function* () { if (!this.activeConversation(id)) return false; yield* this.host.facets.create(id); return Boolean(this.activeConversation(id)); }); } }