diff --git a/artifacts/api-server/src/routes/sessions.ts b/artifacts/api-server/src/routes/sessions.ts index e7e9ffd..30aea1d 100644 --- a/artifacts/api-server/src/routes/sessions.ts +++ b/artifacts/api-server/src/routes/sessions.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from "express"; import { randomBytes, randomUUID, createHash } from "crypto"; -import { db, sessions, sessionRequests, type Session } from "@workspace/db"; +import { db, sessions, sessionRequests, sessionMessages, type Session } from "@workspace/db"; import { eq, and } from "drizzle-orm"; import { lnbitsService } from "../lib/lnbits.js"; import { sessionsLimiter } from "../lib/rate-limiter.js"; @@ -251,6 +251,42 @@ router.get("/sessions/:id", async (req: Request, res: Response) => { } }); +// ── GET /sessions/:id/messages ──────────────────────────────────────────────── + +router.get("/sessions/:id/messages", async (req: Request, res: Response) => { + const id = req.params.id as string; + const macaroon = extractMacaroon(req); + + try { + const session = await getSessionById(id); + if (!session) { res.status(404).json({ error: "Session not found" }); return; } + + if (!macaroon || macaroon !== session.macaroon) { + res.status(401).json({ error: "Invalid or missing macaroon" }); + return; + } + + const rows = await db + .select() + .from(sessionMessages) + .where(eq(sessionMessages.sessionId, id)) + .orderBy(sessionMessages.id); + + res.json({ + sessionId: id, + messages: rows.map((m) => ({ + id: m.id, + role: m.role, + content: m.content, + sessionRequestId: m.sessionRequestId, + createdAt: m.createdAt.toISOString(), + })), + }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch messages" }); + } +}); + // ── POST /sessions/:id/request ──────────────────────────────────────────────── router.post("/sessions/:id/request", async (req: Request, res: Response) => { @@ -424,7 +460,7 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => { const newSessionState = newBalance < MIN_BALANCE_SATS ? "paused" : "active"; const expiresAt = new Date(Date.now() + EXPIRY_MS); - // Persist session request + update session balance atomically + // Persist session request + messages + update session balance atomically await db.transaction(async (tx) => { await tx.insert(sessionRequests).values({ id: requestId, @@ -443,6 +479,24 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => { btcPriceUsd, }); + // Store conversation messages + await tx.insert(sessionMessages).values({ + sessionId: id, + role: "user", + content: requestText, + sessionRequestId: requestId, + }); + + const assistantContent = result ?? reason ?? errorMessage ?? ""; + if (assistantContent) { + await tx.insert(sessionMessages).values({ + sessionId: id, + role: "assistant", + content: assistantContent, + sessionRequestId: requestId, + }); + } + await tx .update(sessions) .set({ diff --git a/lib/db/migrations/0008_session_messages.sql b/lib/db/migrations/0008_session_messages.sql new file mode 100644 index 0000000..4ccc6c0 --- /dev/null +++ b/lib/db/migrations/0008_session_messages.sql @@ -0,0 +1,13 @@ +-- Migration: Session messages for conversation history +-- Stores user/assistant message pairs produced during session requests. + +CREATE TABLE IF NOT EXISTS session_messages ( + id SERIAL PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + role TEXT NOT NULL, + content TEXT NOT NULL, + session_request_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_session_messages_session_id ON session_messages(session_id); diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index a1ad8a8..e1f5d2e 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -5,6 +5,7 @@ export * from "./messages"; export * from "./bootstrap-jobs"; export * from "./world-events"; export * from "./sessions"; +export * from "./session-messages"; export * from "./nostr-identities"; export * from "./timmy-config"; export * from "./free-tier-grants"; diff --git a/lib/db/src/schema/session-messages.ts b/lib/db/src/schema/session-messages.ts new file mode 100644 index 0000000..2e4f4a1 --- /dev/null +++ b/lib/db/src/schema/session-messages.ts @@ -0,0 +1,35 @@ +import { pgTable, text, timestamp, serial } from "drizzle-orm/pg-core"; +import { createInsertSchema } from "drizzle-zod"; +import { z } from "zod/v4"; +import { sessions } from "./sessions"; + +// ── session_messages ───────────────────────────────────────────────────────── +// Stores the full conversation history within a session. +// Each session request produces a "user" message (the request text) +// and an "assistant" message (the AI response), linked to the session +// and optionally to the session_request that produced them. + +export const SESSION_MESSAGE_ROLES = ["user", "assistant"] as const; +export type SessionMessageRole = (typeof SESSION_MESSAGE_ROLES)[number]; + +export const sessionMessages = pgTable("session_messages", { + id: serial("id").primaryKey(), + sessionId: text("session_id") + .notNull() + .references(() => sessions.id), + role: text("role").$type().notNull(), + content: text("content").notNull(), + + // Links back to the session_request that produced this message pair (nullable for flexibility) + sessionRequestId: text("session_request_id"), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export const insertSessionMessageSchema = createInsertSchema(sessionMessages).omit({ + id: true, + createdAt: true, +}); + +export type SessionMessage = typeof sessionMessages.$inferSelect; +export type InsertSessionMessage = z.infer;