[claude] Context injection — pass conversation history to work model (#39) #78
@@ -145,13 +145,20 @@ Respond ONLY with valid JSON: {"accepted": true/false, "reason": "...", "confide
|
||||
};
|
||||
}
|
||||
|
||||
async executeWork(requestText: string): Promise<WorkResult> {
|
||||
async executeWork(
|
||||
requestText: string,
|
||||
conversationHistory: Array<{ role: "user" | "assistant"; content: string }> = [],
|
||||
): Promise<WorkResult> {
|
||||
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<WorkResult> {
|
||||
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) {
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
13
lib/db/migrations/0008_session_messages.sql
Normal file
13
lib/db/migrations/0008_session_messages.sql
Normal file
@@ -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);
|
||||
@@ -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<Array<{ role: "user" | "assistant"; content: string }>> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
18
lib/db/src/schema/session-messages.ts
Normal file
18
lib/db/src/schema/session-messages.ts
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user