From 43c948552aad4151a5bbfcf073bb812fbf4fb29c Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Sun, 22 Mar 2026 21:50:55 -0400 Subject: [PATCH] feat: inject conversation history into session work model - Add session_messages table and migration for storing conversation turns - Add getSessionHistory() helper to load recent history with token budget - Pass conversation history to executeWork() and executeWorkStreaming() - Persist user/assistant exchanges after completed requests - Rejected/failed requests do not pollute history Fixes #39 Co-Authored-By: Claude Opus 4.6 (1M context) --- artifacts/api-server/src/lib/agent.ts | 18 +++++++-- artifacts/api-server/src/routes/sessions.ts | 15 ++++++- lib/db/migrations/0008_session_messages.sql | 13 ++++++ lib/db/src/index.ts | 44 +++++++++++++++++++++ lib/db/src/schema/index.ts | 1 + lib/db/src/schema/session-messages.ts | 18 +++++++++ 6 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 lib/db/migrations/0008_session_messages.sql create mode 100644 lib/db/src/schema/session-messages.ts diff --git a/artifacts/api-server/src/lib/agent.ts b/artifacts/api-server/src/lib/agent.ts index 5f2a27e..13939bb 100644 --- a/artifacts/api-server/src/lib/agent.ts +++ b/artifacts/api-server/src/lib/agent.ts @@ -145,13 +145,20 @@ Respond ONLY with valid JSON: {"accepted": true/false, "reason": "...", "confide }; } - async executeWork(requestText: string): Promise { + async executeWork( + requestText: string, + conversationHistory: Array<{ role: "user" | "assistant"; content: string }> = [], + ): Promise { if (STUB_MODE) { await new Promise((r) => setTimeout(r, 500)); return { result: STUB_RESULT, inputTokens: 0, outputTokens: 0 }; } const client = await getClient(); + const messages = [ + ...conversationHistory, + { role: "user" as const, content: requestText }, + ]; const message = await client.messages.create({ model: this.workModel, max_tokens: 8192, @@ -164,7 +171,7 @@ If the user asks how to run their own Timmy or self-host this service, enthusias - Core env vars: AI_INTEGRATIONS_ANTHROPIC_API_KEY, AI_INTEGRATIONS_ANTHROPIC_BASE_URL, DATABASE_URL, LNBITS_URL, LNBITS_API_KEY, NOSTR_PRIVATE_KEY. - Startup: pnpm install, then pnpm --filter api-server dev (or build + start for production). - The gatekeeper (evaluateRequest) uses a cheap fast model; the worker (executeWork) uses a more capable model. Both are swappable via EVAL_MODEL and WORK_MODEL env vars.`, - messages: [{ role: "user", content: requestText }], + messages, }); const block = message.content[0]; @@ -187,6 +194,7 @@ If the user asks how to run their own Timmy or self-host this service, enthusias async executeWorkStreaming( requestText: string, onChunk: (delta: string) => void, + conversationHistory: Array<{ role: "user" | "assistant"; content: string }> = [], ): Promise { if (STUB_MODE) { const words = STUB_RESULT.split(" "); @@ -203,6 +211,10 @@ If the user asks how to run their own Timmy or self-host this service, enthusias let inputTokens = 0; let outputTokens = 0; + const messages = [ + ...conversationHistory, + { role: "user" as const, content: requestText }, + ]; const stream = client.messages.stream({ model: this.workModel, max_tokens: 8192, @@ -215,7 +227,7 @@ If the user asks how to run their own Timmy or self-host this service, enthusias - Core env vars: AI_INTEGRATIONS_ANTHROPIC_API_KEY, AI_INTEGRATIONS_ANTHROPIC_BASE_URL, DATABASE_URL, LNBITS_URL, LNBITS_API_KEY, NOSTR_PRIVATE_KEY. - Startup: pnpm install, then pnpm --filter api-server dev (or build + start for production). - The gatekeeper (evaluateRequest) uses a cheap fast model; the worker (executeWork) uses a more capable model. Both are swappable via EVAL_MODEL and WORK_MODEL env vars.`, - messages: [{ role: "user", content: requestText }], + messages, }); for await (const event of stream) { diff --git a/artifacts/api-server/src/routes/sessions.ts b/artifacts/api-server/src/routes/sessions.ts index e7e9ffd..479c8b9 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, getSessionHistory, type Session } from "@workspace/db"; import { eq, and } from "drizzle-orm"; import { lnbitsService } from "../lib/lnbits.js"; import { sessionsLimiter } from "../lib/rate-limiter.js"; @@ -312,6 +312,9 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => { const requestId = randomUUID(); const btcPriceUsd = await getBtcPriceUsd(); + // Load conversation history for context injection + const history = await getSessionHistory(id, 8, 4000); + // Eval phase const evalResult = await agentService.evaluateRequest(requestText); const evalCostUsd = pricingService.calculateActualCostUsd( @@ -343,7 +346,7 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => { if (evalResult.accepted) { try { - const workResult = await agentService.executeWork(requestText); + const workResult = await agentService.executeWork(requestText, history); workInputTokens = workResult.inputTokens; workOutputTokens = workResult.outputTokens; workCostUsd = pricingService.calculateActualCostUsd( @@ -452,6 +455,14 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => { updatedAt: new Date(), }) .where(eq(sessions.id, id)); + + // Persist conversation history only for completed requests + if (finalState === "complete") { + await tx.insert(sessionMessages).values([ + { sessionId: id, role: "user" as const, content: requestText, tokenCount: Math.ceil(requestText.length / 4) }, + { sessionId: id, role: "assistant" as const, content: result ?? "", tokenCount: Math.ceil((result ?? "").length / 4) }, + ]); + } }); // ── Trust scoring ──────────────────────────────────────────────────────── diff --git a/lib/db/migrations/0008_session_messages.sql b/lib/db/migrations/0008_session_messages.sql new file mode 100644 index 0000000..667c360 --- /dev/null +++ b/lib/db/migrations/0008_session_messages.sql @@ -0,0 +1,13 @@ +-- Migration: Session conversation history (#38/#39) +-- Stores user/assistant message pairs for context injection into the work model. + +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, + token_count INTEGER, + 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/index.ts b/lib/db/src/index.ts index 50cbf48..37bf93e 100644 --- a/lib/db/src/index.ts +++ b/lib/db/src/index.ts @@ -1,4 +1,5 @@ import { drizzle } from "drizzle-orm/node-postgres"; +import { eq, asc } from "drizzle-orm"; import pg from "pg"; import * as schema from "./schema"; @@ -14,3 +15,46 @@ export const pool = new Pool({ connectionString: process.env.DATABASE_URL }); export const db = drizzle(pool, { schema }); export * from "./schema"; + +// ── Session history helper ────────────────────────────────────────────────── + +/** + * Load the most recent conversation history for a session, capped by turn + * count and approximate token budget. + * + * @param sessionId Session to load history for + * @param maxTurns Maximum number of messages to return (default 8) + * @param maxTokens Approximate token budget — stops including older messages + * once cumulative token_count exceeds this (default 4000) + * @returns Array of { role, content } objects in chronological order + */ +export async function getSessionHistory( + sessionId: string, + maxTurns = 8, + maxTokens = 4000, +): Promise> { + const rows = await db + .select({ + role: schema.sessionMessages.role, + content: schema.sessionMessages.content, + tokenCount: schema.sessionMessages.tokenCount, + }) + .from(schema.sessionMessages) + .where(eq(schema.sessionMessages.sessionId, sessionId)) + .orderBy(asc(schema.sessionMessages.id)); + + // Take the most recent messages that fit within budget + const result: Array<{ role: "user" | "assistant"; content: string }> = []; + let totalTokens = 0; + + // Walk from newest to oldest, then reverse + for (let i = rows.length - 1; i >= 0 && result.length < maxTurns; i--) { + const row = rows[i]!; + const tokens = row.tokenCount ?? Math.ceil(row.content.length / 4); + if (totalTokens + tokens > maxTokens && result.length > 0) break; + totalTokens += tokens; + result.push({ role: row.role as "user" | "assistant", content: row.content }); + } + + return result.reverse(); +} diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index a1ad8a8..e62c9ee 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -13,3 +13,4 @@ export * from "./nostr-trust-vouches"; export * from "./relay-accounts"; export * from "./relay-event-queue"; export * from "./job-debates"; +export * from "./session-messages"; diff --git a/lib/db/src/schema/session-messages.ts b/lib/db/src/schema/session-messages.ts new file mode 100644 index 0000000..b5fadde --- /dev/null +++ b/lib/db/src/schema/session-messages.ts @@ -0,0 +1,18 @@ +import { pgTable, text, timestamp, integer, serial } from "drizzle-orm/pg-core"; +import { sessions } from "./sessions"; + +// ── session_messages ──────────────────────────────────────────────────────── +// Stores conversation history for context injection into the work model. + +export const sessionMessages = pgTable("session_messages", { + id: serial("id").primaryKey(), + sessionId: text("session_id") + .notNull() + .references(() => sessions.id), + role: text("role").$type<"user" | "assistant">().notNull(), + content: text("content").notNull(), + tokenCount: integer("token_count"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export type SessionMessage = typeof sessionMessages.$inferSelect; -- 2.43.0