// src/services/chat-session.ts // Copyright (C) 2026 Robert Colbert // All Rights Reserved import env from "../config/env.ts"; import path from "node:path"; import fs from "node:fs"; import dayjs from "dayjs"; import { IChatSession, ChatSessionMode, GadgetId, IUser, IProject, ChatTurnStatus, ChatTurnDocument, ReasoningEffort, } from "@gadget/api"; import { IAiProvider } from "@gadget/api"; import Project from "../models/project.ts"; import ChatTurn from "../models/chat-turn.ts"; import ChatSession from "../models/chat-session.ts"; import AiProvider from "../models/ai-provider.ts"; import { DtpService } from "../lib/service.ts"; import { PopulateOptions } from "mongoose"; import { AiApi, createAiApi, IAiEnvironment, IAiProvider as IAiApiProvider, } from "@gadget/ai"; const aiEnv: IAiEnvironment = { NODE_ENV: env.NODE_ENV || "develop", services: { google: { cse: { apiKey: env.google.cse.apiKey, engineId: env.google.cse.engineId, }, }, }, }; class ChatSessionService extends DtpService { public populateChatSession: PopulateOptions[] = [ { path: "user", select: "-passwordSalt -password" }, { path: "project", populate: [ { path: "user", select: "-passwordSalt -password", }, ], }, { path: "provider", select: "+apiKey" }, ]; public populateChatTurn: PopulateOptions[] = [ { path: "user", select: "-passwordSalt -password", }, { path: "project", }, { path: "session", populate: this.populateChatSession, }, { path: "provider", select: "-models", }, ]; get name(): string { return "ChatSessionService"; } get slug(): string { return "svc:chat-session"; } async start(): Promise { this.log.info("started"); } async stop(): Promise { this.log.info("stopped"); } /** * Creates a new chat session. */ async create( userId: GadgetId, projectId: GadgetId, providerId: GadgetId, selectedModel: string, mode: ChatSessionMode = ChatSessionMode.Build, name?: string, ): Promise { // Validate project exists const project = await Project.findById(projectId); if (!project) { throw new Error(`Project not found: ${projectId}`); } // Validate provider exists const provider = await AiProvider.findById(providerId); if (!provider) { throw new Error(`AI Provider not found: ${providerId}`); } const session = new ChatSession({ createdAt: new Date(), user: userId, project: projectId, name: name || "New Chat Session", mode, provider: providerId, selectedModel, stats: { turnCount: 0, toolCallCount: 0, inputTokens: 0, outputTokens: 0, }, pins: [], }); await session.save(); this.log.info("chat session created", { sessionId: session._id, projectId, providerId, model: selectedModel, }); return session.populate(this.populateChatSession); } /** * Gets a chat session by ID. */ async getById(chatSessionId: GadgetId): Promise { const session = await ChatSession.findById(chatSessionId) .populate("user", "-passwordSalt -password") .populate("project") .populate("provider", "+apiKey") .lean(); if (!session) { throw new Error(`Chat session not found: ${chatSessionId}`); } return session as unknown as IChatSession; } /** * Gets all chat sessions for a project. */ async getByProject(projectId: GadgetId): Promise { const sessions = await ChatSession.find({ project: projectId }) .populate("user", "-passwordSalt -password") .populate("project") .populate("provider", "+apiKey") .sort({ createdAt: -1 }) .lean(); return sessions as unknown as IChatSession[]; } /** * Gets all chat sessions for a user. */ async getByUser(userId: GadgetId): Promise { const sessions = await ChatSession.find({ user: userId }) .populate("user", "-passwordSalt -password") .populate("project") .populate("provider", "+apiKey") .sort({ createdAt: -1 }) .lean(); return sessions as unknown as IChatSession[]; } /** * Updates a chat session. */ async update( chatSessionId: GadgetId, updates: Partial<{ name: string; provider: GadgetId; selectedModel: string; mode: ChatSessionMode; reasoningEffort: ReasoningEffort; numCtx: number; }>, ): Promise { const session = await ChatSession.findById(chatSessionId); if (!session) { throw new Error(`Chat session not found: ${chatSessionId}`); } // Validate provider if changing if (updates.provider) { const provider = await AiProvider.findById(updates.provider); if (!provider) { throw new Error(`AI Provider not found: ${updates.provider}`); } session.provider = updates.provider; } if (updates.name !== undefined) { session.name = updates.name; } if (updates.selectedModel !== undefined) { session.selectedModel = updates.selectedModel; } if (updates.mode !== undefined) { session.mode = updates.mode; } if (updates.reasoningEffort !== undefined) { session.reasoningEffort = updates.reasoningEffort; } if (updates.numCtx !== undefined) { session.numCtx = updates.numCtx; } await session.save(); this.log.info("chat session updated", { sessionId: chatSessionId, updates, }); return session.populate(this.populateChatSession); } /** * Deletes a chat session. */ async delete(chatSessionId: GadgetId): Promise { const session = await ChatSession.findByIdAndDelete(chatSessionId); if (!session) { throw new Error(`Chat session not found: ${chatSessionId}`); } // Delete all turns associated with this session await ChatTurn.deleteMany({ session: chatSessionId }); this.log.info("chat session deleted", { sessionId: chatSessionId, }); } async createTurn( session: IChatSession, prompt: string, ): Promise { const NOW = new Date(); const user: IUser = session.user as IUser; const project: IProject = session.project as IProject; const systemPrompt = await this.buildSystemPrompt(session); let turn = new ChatTurn({ createdAt: NOW, user: user._id, project: project._id, session: session._id, provider: session.provider, llm: session.selectedModel, reasoningEffort: (session as IChatSession).reasoningEffort || "off", numCtx: (session as IChatSession).numCtx, mode: session.mode, status: ChatTurnStatus.Processing, prompts: { system: systemPrompt, user: prompt, }, toolCalls: [], subagents: [], stats: { toolCallCount: 0, inputTokens: 0, thinkingTokenCount: 0, responseTokens: 0, durationMs: 0, durationLabel: "pending", }, }); await turn.save(); turn = await ChatTurn.populate(turn, this.populateChatTurn); return turn; } async buildSystemPrompt(session: IChatSession): Promise { const TIMESTAMP_FORMAT = "YYYY-MM-DD hh:mm a"; const user = session.user as IUser; const provider = session.provider as IAiProvider; const project = session.project as IProject; const commonDir = path.join(env.installDir, "data", "prompts", "common"); const promptsDir = path.join( env.installDir, "data", "prompts", "agent", session.mode, ); const common = { processManagementBlock: await fs.promises.readFile( path.join(commonDir, "process-management-block.md"), "utf-8", ), scopeBlock: await fs.promises.readFile( path.join(commonDir, "scope-block.md"), "utf-8", ), subagentsBlock: await fs.promises.readFile( path.join(commonDir, "subagents.md"), "utf-8", ), toolsBlock: await fs.promises.readFile( path.join(commonDir, "tools-block.md"), "utf-8", ), }; /* * User Persona */ const personaBlock = [ `USER ID: ${user._id}`, `NAME: ${user.displayName}`, `IS ADMIN: ${user.flags.isAdmin ? "YES" : "NO"}`, `---[BEGIN PERSONA]---`, user.persona || "I haven't set a persona. Encourage me to visit Settings and describe myself!", `---[END PERSONA]---`, ].join("\n"); /* * Session Information */ const sessionBlock = [ `Session ID: ${session._id}`, `Session Name: ${session.name}`, `Session Created At: ${dayjs(session.createdAt).format(TIMESTAMP_FORMAT)}`, `AI Provider: ${provider.name}`, `AI API: ${provider.apiType}`, `Large Language Model: ${session.selectedModel}`, `Project Name: ${project.name}`, `Project Created At: ${dayjs(project.createdAt).format(TIMESTAMP_FORMAT)}`, ].join("\n"); const templateFilename = path.join(promptsDir, "system.md"); const promptTemplate = await fs.promises.readFile( templateFilename, "utf-8", ); let prompt = promptTemplate .replace("{{process_management_block}}", common.processManagementBlock) .replace("{{scope_block}}", common.scopeBlock) .replace("{{tools}}", common.toolsBlock) .replace("{{subagent_section}}", common.subagentsBlock) .replace("{{session_block}}", sessionBlock) .replace("{{persona_block}}", personaBlock); /* * Project System Prompt — appended as an appendix when the project * has a `system` field configured. This allows project-specific * instructions to be injected without modifying mode templates. */ if (project.system && project.system.trim().length > 0) { prompt += `\n\n## PROJECT CONFIGURATION\n\n### System Instructions\n${project.system.trim()}`; } return prompt; } /** * Gets all turns for a chat session. */ async getTurns(chatSessionId: GadgetId): Promise { const turns = await ChatTurn.find({ session: chatSessionId, status: { $ne: ChatTurnStatus.Processing }, }) .populate("user", "-passwordSalt -password") .populate("project") .populate("provider", "-models") .sort({ createdAt: 1 }) .lean(); return turns; } /** * Generates a session name from the first prompt. */ async generateSessionNameFromPrompt( session: IChatSession, prompt: string, ): Promise { const dbProvider: IAiProvider = session.provider as IAiProvider; const provider: IAiApiProvider = this.mapDbProviderToConfig(dbProvider); if (!provider.apiKey) { const error = new Error( `Provider "${provider.name}" (${provider._id}) has no apiKey configured`, ); error.statusCode = 400; throw error; } this.log.info("calling provider to generate chat session title", { provider: { _id: provider._id, name: provider.name, }, selectedModel: session.selectedModel, }); const api: AiApi = createAiApi(aiEnv, provider, this.log); const systemPrompt = [ "ROLE:", "You are an assistant that creates titles for chat sessions by examining the first prompt submitted by the user.", "", "SCOPE:", "You return just the title of the session, no explanation or justifications. You return one title, not multiple choices.", "Select the best title for the chat session, and return that.", "", "CONSTRAINTS:", "- Chat session titles shouldn't be longer than 60 characters.", "- Chat session titles shouldn't contain vulgarity or degeneracy.", ].join("\n"); const response = await api.generate( { provider, modelId: session.selectedModel, params: { reasoning: false, temperature: 1.0, topK: 0.6, topP: 0.4, numPredict: -1, numCtx: 131072, maxCompletionTokens: 256, }, }, { systemPrompt, prompt: `Here is the first prompt submitted by the user:\n\n${prompt}`, }, ); const newSession = await ChatSession.findOneAndUpdate( { _id: session._id }, { $set: { name: response.response || "New Session" } }, { new: true, populate: this.populateChatSession }, ); if (!newSession) { const error = new Error("chat session has been removed"); error.statusCode = 404; throw error; } return newSession; } mapDbProviderToConfig(provider: IAiProvider | GadgetId): IAiApiProvider { if (typeof provider === "string") { throw new Error("Provider must be populated, not a GadgetId reference"); } return { _id: provider._id, name: provider.name, sdk: provider.apiType, // map apiType → sdk baseUrl: provider.baseUrl, apiKey: provider.apiKey, }; } } export default new ChatSessionService();