diff --git a/.freecode/sessions/session-1/memory.json b/.freecode/sessions/session-1/memory.json new file mode 100644 index 00000000..cee7d06d --- /dev/null +++ b/.freecode/sessions/session-1/memory.json @@ -0,0 +1,22 @@ +{ + "sessionId": "session-1", + "messages": [ + { + "id": "msg-1780338978792-mzkiymbovd8", + "role": "user", + "content": "hi", + "timestamp": 1780338978792, + "tokenCount": 1 + }, + { + "id": "msg-1780338978792-q9w51durvb", + "role": "assistant", + "content": "The user is just saying \"hi\" - a simple greeting. I should respond in a friendly, concise manner and offer to help with their project.\nHi! I'm FreeCode, your AI coding assistant.\n\nI see you're working on the **tui** project - a Terminal UI that drives ChatGPT via Playwright/CDP.\n\nHow can I help you today? I can:\n- Explore the codebase\n- Run commands\n- Read/edit files\n- Help with debugging or feature implementation\n\nJust let me know what you'd like to do!", + "timestamp": 1780338978792, + "tokenCount": 115 + } + ], + "summaries": [], + "tokenCount": 116, + "totalCompactions": 0 +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0d8e8ee0..c5ebb22d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ yarn-error.log* # Worktrees .worktrees + +# FreeCode runtime data +.freecode/ +apps/*/.freecode/ diff --git a/apps/core/src/agent/loop-session-store.test.ts b/apps/core/src/agent/loop-session-store.test.ts new file mode 100644 index 00000000..4f459473 --- /dev/null +++ b/apps/core/src/agent/loop-session-store.test.ts @@ -0,0 +1,123 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { rm } from "fs/promises" +import { createAgentLoop } from "./loop" +import { createSessionStore, type SessionStore } from "../session/store" + +test("AgentLoop accepts sessionStore in constructor config", async () => { + const testDir = "/tmp/freecode-test-loop-session-store" + await rm(testDir, { recursive: true, force: true }) + const store: SessionStore = await createSessionStore(testDir) + + const sessionId = await store.createSession({ + title: "Test", + projectPath: "/tmp/test", + provider: "mock", + }) + + const loop = createAgentLoop(sessionId, { sessionStore: store }) + + // Access private field via any cast for testing + assert.equal((loop as any).sessionStore, store) + + await rm(testDir, { recursive: true, force: true }) +}) + +test("AgentLoop sessionStore is optional", async () => { + const loop = createAgentLoop("test-session-id") + assert.equal((loop as any).sessionStore, undefined) +}) + +test("AgentLoop appendToolMessage creates correct serialized message structure", async () => { + const testDir = "/tmp/freecode-test-loop-session-store2" + await rm(testDir, { recursive: true, force: true }) + const store: SessionStore = await createSessionStore(testDir) + + const sessionId = await store.createSession({ + title: "Test", + projectPath: "/tmp/test", + provider: "mock", + }) + + const loop = createAgentLoop(sessionId, { sessionStore: store }) + + const toolCall = { + id: "tool-1", + tool: "read", + args: { path: "/tmp/test.txt" }, + execution: "sequential" as const, + } + const result = { + id: "result-1", + toolCallId: "tool-1", + tool: "read", + title: "Read file", + stdout: "file contents", + } + + await (loop as any).appendToolMessage(toolCall, result) + + const messages = await store.getMessages(sessionId) + assert.equal(messages.length, 1) + assert.equal(messages[0].role, "assistant") + assert.equal(messages[0].parts[0].type, "tool") + assert.equal(messages[0].parts[0].tool?.name, "read") + assert.equal(messages[0].parts[0].result, "file contents") + + await rm(testDir, { recursive: true, force: true }) +}) + +test("AgentLoop appendUserMessage creates correct serialized message structure", async () => { + const testDir = "/tmp/freecode-test-loop-session-store3" + await rm(testDir, { recursive: true, force: true }) + const store: SessionStore = await createSessionStore(testDir) + + const sessionId = await store.createSession({ + title: "Test", + projectPath: "/tmp/test", + provider: "mock", + }) + + const loop = createAgentLoop(sessionId, { sessionStore: store }) + + await (loop as any).appendUserMessage("Hello world") + + const messages = await store.getMessages(sessionId) + assert.equal(messages.length, 1) + assert.equal(messages[0].role, "user") + assert.equal(messages[0].parts[0].type, "text") + assert.equal(messages[0].parts[0].content, "Hello world") + + await rm(testDir, { recursive: true, force: true }) +}) + +test("AgentLoop appendAssistantMessage creates correct serialized message structure", async () => { + const testDir = "/tmp/freecode-test-loop-session-store4" + await rm(testDir, { recursive: true, force: true }) + const store: SessionStore = await createSessionStore(testDir) + + const sessionId = await store.createSession({ + title: "Test", + projectPath: "/tmp/test", + provider: "mock", + }) + + const loop = createAgentLoop(sessionId, { sessionStore: store }) + + await (loop as any).appendAssistantMessage("I can help with that") + + const messages = await store.getMessages(sessionId) + assert.equal(messages.length, 1) + assert.equal(messages[0].role, "assistant") + assert.equal(messages[0].parts[0].type, "text") + assert.equal(messages[0].parts[0].content, "I can help with that") + + await rm(testDir, { recursive: true, force: true }) +}) + +test("AgentLoop does not throw when sessionStore is undefined", async () => { + const loop = createAgentLoop("test-session-id") + // Should not throw + await (loop as any).appendUserMessage("Hello") + await (loop as any).appendAssistantMessage("Hi") +}) diff --git a/apps/core/src/agent/loop.ts b/apps/core/src/agent/loop.ts index 85875000..91197604 100644 --- a/apps/core/src/agent/loop.ts +++ b/apps/core/src/agent/loop.ts @@ -6,6 +6,9 @@ // FLOW: Build Prompt → Send to AI → Normalize → Parse → Execute Tool → Loop // ============================================================================= +import * as path from "path" +import * as os from "os" +import { randomUUID } from "crypto" import type { SessionState, ToolCall, @@ -18,6 +21,7 @@ import type { HookContext, } from "./types.js" import { createInitialSessionState, DEFAULT_LOOP_HEURISTICS } from "./types.js" +import type { StreamEvent } from "@freecode/shared" import { createToolOrchestrator, listTools, getTool } from "../tools/index.js" import { MemoryService, renderPromptMemoryContext } from "../memory/index.js" import { getProvider } from "../providers/index.js" @@ -26,6 +30,20 @@ import type { HookResult } from "../agent/types.js" import { bus, BusEvents } from "../bus/index.js" import { createRecorder, type RolloutRecorder } from "../rollout/recorder.js" import { loadProviderPrompt } from "../session/prompt.js" +import { createSessionStore, type SessionStore, type SerializedMessage } from "../session/store.js" +import { getInterruptHandler } from "../session/interrupt.js" + +const SESSION_DIR = ".freecode" + +let globalSessionStore: SessionStore | null = null + +async function getSessionStore(): Promise { + if (!globalSessionStore) { + const baseDir = path.join(os.homedir(), SESSION_DIR) + globalSessionStore = await createSessionStore(baseDir) + } + return globalSessionStore +} const orchestrator = createToolOrchestrator() @@ -44,14 +62,17 @@ export class AgentLoop { private memory: MemoryService private hooks: HookRuntime private recorder: RolloutRecorder + private sessionStore: SessionStore | undefined + private onToolEvent: ((event: StreamEvent) => void) | undefined + private lastThinking: string | undefined // Loop health tracking state private recentToolCalls: Array<{ tool: string; args: string }> = [] private recentReasoning: string[] = [] private lastFileStates: string[] = [] private fileStateHash: string = "" - constructor(sessionId: string, config?: { maxIterations?: number; heuristics?: Partial; hooks?: HookRuntime; recorder?: RolloutRecorder }) { - this.state = createInitialSessionState(sessionId) + constructor(sessionId: string, config?: { maxIterations?: number; heuristics?: Partial; hooks?: HookRuntime; recorder?: RolloutRecorder; sessionStore?: SessionStore }) { + this.state = createInitialSessionState(sessionId, "") // projectPath set in run() this.config = { maxIterations: config?.maxIterations ?? 100, heuristics: { ...DEFAULT_LOOP_HEURISTICS, ...config?.heuristics }, @@ -59,6 +80,7 @@ export class AgentLoop { this.memory = new MemoryService(sessionId) this.hooks = config?.hooks ?? createHookRuntime() this.recorder = config?.recorder ?? createRecorder(sessionId) + this.sessionStore = config?.sessionStore } // =========================================================================== @@ -66,10 +88,11 @@ export class AgentLoop { // Main execution entry point - runs the continuous loop until completion // =========================================================================== async run(input: UserInput): Promise { - this.state = { ...this.state, status: "starting" } + this.state = { ...this.state, status: "starting", projectPath: input.projectPath } try { this.state = { ...this.state, status: "running" } + this.onToolEvent = input.onToolEvent // Step 1: Collect project context (file tree, etc.) const contextResult = await this.collectContext(input.projectPath) @@ -85,6 +108,8 @@ export class AgentLoop { let prompt = input.prompt let previousToolResults: ToolResult[] | undefined + let totalInputTokens = 0 + let totalOutputTokens = 0 // ======================================================================= // CONTINUOUS LOOP - Core agent cycle @@ -113,9 +138,15 @@ export class AgentLoop { return this.fail("Turn execution failed", turnResult.error) } + // Accumulate usage across turns + if (turnResult.usage) { + totalInputTokens += turnResult.usage.inputTokens ?? 0 + totalOutputTokens += turnResult.usage.outputTokens ?? 0 + } + // No tool calls means we're done if (turnResult.toolResults.length === 0) { - return this.complete("Done", turnResult.responseText) + return this.complete("Done", turnResult.responseText, turnResult.thinking, { inputTokens: totalInputTokens, outputTokens: totalOutputTokens }) } // Build continuation prompt for next iteration @@ -148,7 +179,7 @@ export class AgentLoop { model: string | undefined, context: { name: string; projectPath: string; tree: string }, previousToolResults?: ToolResult[] - ): Promise<{ success: boolean; toolResults: ToolResult[]; responseText?: string; error?: string }> { + ): Promise<{ success: boolean; toolResults: ToolResult[]; responseText?: string; thinking?: string; error?: string; usage?: { inputTokens: number; outputTokens: number } }> { try { // TWO-PHASE CONTEXT COLLECTION // Phase 1: Ask model which files it needs to complete the task @@ -184,6 +215,12 @@ ${memoryContext ? `Session context:\n${memoryContext}\n\n` : ""}Task: ${prompt}` console.log("[AgentLoop] Sending prompt to provider...") const providerResult = await this.sendToProvider(modifiedPrompt, provider, model, previousToolResults) + // Emit thinking content if present (for UI to display as streaming reasoning) + if (providerResult.thinking) { + this.lastThinking = providerResult.thinking + this.onToolEvent?.({ type: "thinking", content: providerResult.thinking }) + } + // Record turn.started event this.recorder.recordTurnStarted(`turn-${this.state.turnCount}`) @@ -204,6 +241,9 @@ ${memoryContext ? `Session context:\n${memoryContext}\n\n` : ""}Task: ${prompt}` if (toolCalls.length === 0) { this.memory.addMessage("user", prompt) this.memory.addMessage("assistant", providerResult.content) + // Also append to session store + await this.appendUserMessage(prompt) + await this.appendAssistantMessage(providerResult.content) if (this.memory.shouldCompact(provider)) { // PreCompact Hook — can inspect/modify context before compaction const preHookCtx: HookContext = { sessionId: this.state.sessionId, turnCount: this.state.turnCount } @@ -219,7 +259,7 @@ ${memoryContext ? `Session context:\n${memoryContext}\n\n` : ""}Task: ${prompt}` } } } - return { success: true, toolResults: [], responseText: providerResult.content } + return { success: true, toolResults: [], responseText: providerResult.content, thinking: providerResult.thinking, usage: providerResult.usage } } // Execute each tool sequentially (as per spec: sequential tools run one at a time) @@ -229,11 +269,16 @@ ${memoryContext ? `Session context:\n${memoryContext}\n\n` : ""}Task: ${prompt}` toolResults.push(result) // Update loop health tracking after each tool execution this.updateLoopHealth(toolCall, result) + // Append tool result to session store + await this.appendToolMessage(toolCall, result) } // Record messages and check for compaction after tool execution this.memory.addMessage("user", prompt) this.memory.addMessage("assistant", providerResult.content) + // Also append to session store + await this.appendUserMessage(prompt) + await this.appendAssistantMessage(providerResult.content) if (this.memory.shouldCompact(provider)) { // PreCompact Hook — can inspect/modify context before compaction @@ -251,7 +296,7 @@ ${memoryContext ? `Session context:\n${memoryContext}\n\n` : ""}Task: ${prompt}` } } - return { success: true, toolResults, responseText: providerResult.content } + return { success: true, toolResults, responseText: providerResult.content, usage: providerResult.usage } } catch (error) { return { success: false, toolResults: [], error: String(error) } } @@ -379,7 +424,7 @@ Based on this task, which files do you need to read to understand the codebase a provider: string, model: string | undefined, toolResults?: ToolResult[] - ): Promise<{ content: string; toolCalls?: Array<{ name: string; args: Record; id: string }> }> { + ): Promise<{ content: string; thinking?: string; toolCalls?: Array<{ name: string; args: Record; id: string }>; usage?: { inputTokens: number; outputTokens: number } }> { const aiProvider = getProvider(provider as any) const tools = listTools().map(t => { const toolDef = getTool(t.id) @@ -400,7 +445,7 @@ Based on this task, which files do you need to read to understand the codebase a })), model, }) - return { content: result.content, toolCalls: result.toolCalls } + return { content: result.content, thinking: result.thinking, toolCalls: result.toolCalls, usage: result.usage } } // =========================================================================== @@ -547,6 +592,14 @@ Based on this task, which files do you need to read to understand the codebase a } } + // Emit tool_start event for streaming + this.onToolEvent?.({ + type: "tool_start", + toolCallId: toolCall.id, + toolName: toolCall.tool, + args: toolCall.args as Record, + }) + // Emit tool.called event before execution BusEvents.toolCalled(this.state.sessionId, toolCall.tool, toolCall.id, toolCall.args as Record) @@ -578,6 +631,20 @@ Based on this task, which files do you need to read to understand the codebase a return errorResult } + // Emit tool_output with last 5 lines of stdout + // Truncate each line to 200 chars to prevent terminal overflow + const MAX_LINE_LEN = 200 + const outputLines = (result.stdout || "") + .split("\n") + .filter(l => l.trim()) + .slice(-5) + .map(line => line.length > MAX_LINE_LEN ? line.slice(0, MAX_LINE_LEN) + "..." : line) + this.onToolEvent?.({ + type: "tool_output", + toolCallId: toolCall.id, + content: outputLines.join("\n"), + }) + // PostToolUse Hook — can modify result const postResult = await this.hooks.runPostToolUse(toolCall, result, hookContext) @@ -589,6 +656,16 @@ Based on this task, which files do you need to read to understand the codebase a const success = !result.error BusEvents.toolCompleted(this.state.sessionId, toolCall.tool, toolCall.id, success, duration_ms) + // Emit tool_complete event for streaming + this.onToolEvent?.({ + type: "tool_complete", + toolCallId: toolCall.id, + toolName: toolCall.tool, + result: result.stdout || result.error || "", + success, + duration_ms, + }) + return result } @@ -757,17 +834,65 @@ Based on this task, which files do you need to read to understand the codebase a } } - private complete(message: string, content?: string): LoopResult { + private complete(message: string, content?: string, thinking?: string, usage?: { inputTokens: number; outputTokens: number }): LoopResult { // Emit session.updated event BusEvents.sessionUpdated(this.state.sessionId) return { success: true, message, content, + thinking: thinking ?? this.lastThinking, turnCount: this.state.turnCount, iterationCount: this.state.iterationCount, finalState: this.state, + usage, + } + } + + // =========================================================================== + // PRIVATE: Session Store Helpers + // Append messages to session store for persistence + // =========================================================================== + + private async appendUserMessage(content: string): Promise { + if (!this.sessionStore) return + const message: SerializedMessage = { + id: randomUUID(), + role: "user", + parts: [{ type: "text", content }], + timestamp: Date.now(), + } + await this.sessionStore.appendMessage(this.state.sessionId, message, this.state.projectPath) + } + + private async appendAssistantMessage(content: string): Promise { + if (!this.sessionStore) return '' + const id = randomUUID() + const message: SerializedMessage = { + id, + role: "assistant", + parts: [{ type: "text", content }], + timestamp: Date.now(), + } + await this.sessionStore.appendMessage(this.state.sessionId, message, this.state.projectPath) + // Set this message as the interrupt target so Ctrl+C marks it + getInterruptHandler().setActive(this.state.sessionId, id) + return id + } + + private async appendToolMessage(toolCall: ToolCall, result: ToolResult): Promise { + if (!this.sessionStore) return + const message: SerializedMessage = { + id: randomUUID(), + role: "assistant", + parts: [{ + type: "tool", + tool: { name: toolCall.tool, args: toolCall.args as Record }, + result: result.stdout || result.error || "", + }], + timestamp: Date.now(), } + await this.sessionStore.appendMessage(this.state.sessionId, message, this.state.projectPath) } // =========================================================================== @@ -788,7 +913,7 @@ Based on this task, which files do you need to read to understand the codebase a // ============================================================================= export const createAgentLoop = ( sessionId: string, - config?: { maxIterations?: number; heuristics?: Partial; hooks?: HookRuntime; recorder?: RolloutRecorder } + config?: { maxIterations?: number; heuristics?: Partial; hooks?: HookRuntime; recorder?: RolloutRecorder; sessionStore?: SessionStore } ): AgentLoop => { return new AgentLoop(sessionId, config) } diff --git a/apps/core/src/agent/title-generator.ts b/apps/core/src/agent/title-generator.ts new file mode 100644 index 00000000..d9f9dd87 --- /dev/null +++ b/apps/core/src/agent/title-generator.ts @@ -0,0 +1,141 @@ +// ============================================================================= +// Session Title Generator +// PRIMARY: Generate a concise title from the first user prompt +// Uses a fast/small model (Haiku-tier) for title generation +// ============================================================================= + +import { getProvider } from "../providers/index.js" +import type { ProviderId } from "../providers/index.js" + +const TITLE_PROMPT_TEMPLATE = `Generate a concise title (3-5 words) that captures the main topic or goal of this coding session. + +Requirements: +- Use sentence-case (only first word capitalized) +- 3-5 words maximum +- Focus on the action or subject, not generic words like "session" or "coding" +- No quotes or special formatting + +Examples: +- "Add user authentication" (not "User Authentication" or "Add user authentication to the app") +- "Fix memory leak in agent loop" +- "Implement session resume feature" +- "Refactor provider adapter" + +Session prompt: +""" + +{prompt} +""" + +Title:` + +interface GenerateTitleOptions { + prompt: string + provider: string + model?: string +} + +/** + * Generate a session title from the first user prompt. + * Uses the provider's fastest model (Haiku-tier equivalent). + */ +export async function generateSessionTitle(opts: GenerateTitleOptions): Promise { + try { + const provider = getProvider(opts.provider as ProviderId) + if (!provider) { + return generateTitleFromPrompt(opts.prompt) + } + + // Use a fast, cheap model for title generation + const titleModel = opts.model ? extractSmallModel(opts.model) : undefined + + const result = await provider.execute({ + prompt: TITLE_PROMPT_TEMPLATE.replace("{prompt}", opts.prompt.slice(0, 500)), + system: "You are a helpful assistant that generates concise session titles.", + model: titleModel, + maxTokens: 20, + }) + + const title = result.content.trim() + if (title && title.length >= 3 && title.length <= 60) { + return normalizeTitle(title) + } + + return generateTitleFromPrompt(opts.prompt) + } catch (error) { + console.warn("[TitleGenerator] Failed to generate title:", error) + return generateTitleFromPrompt(opts.prompt) + } +} + +/** + * Extract a smaller/faster model name from a model string. + * For example, "MiniMax-M2" stays "MiniMax-M2" but could map to "MiniMax-Haiku" + */ +function extractSmallModel(model: string): string | undefined { + // If the model already indicates a small/fast variant, use it + if (model.toLowerCase().includes("haiku") || model.toLowerCase().includes("mini")) { + return model + } + // Otherwise, append -haiku or similar to indicate smaller model + // This is provider-specific and may need adjustment + return undefined // Let provider decide default fast model +} + +/** + * Normalize the generated title. + * - Trim whitespace + * - Remove quotes + * - Ensure sentence case + */ +function normalizeTitle(title: string): string { + let cleaned = title.trim() + // Remove surrounding quotes if present + if ((cleaned.startsWith('"') && cleaned.endsWith('"')) || + (cleaned.startsWith("'") && cleaned.endsWith("'"))) { + cleaned = cleaned.slice(1, -1) + } + // Remove any newlines or weird formatting + cleaned = cleaned.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() + // Ensure sentence case (first letter capital, rest lowercase) + if (cleaned.length > 0) { + cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1) + } + // Truncate if too long + if (cleaned.length > 50) { + cleaned = cleaned.slice(0, 47) + "..." + } + return cleaned +} + +/** + * Generate a fallback title from the prompt when LLM doesn't provide one. + * Filters out stop words and returns first 5 meaningful words. + */ +export function generateTitleFromPrompt(prompt: string): string { + const stopWords = new Set([ + "please", + "can", + "could", + "would", + "help", + "me", + "with", + "the", + "a", + "an", + "to", + "for", + "and", + "in", + "on", + ]) + + const words = prompt + .replace(/[^\w\s-]/g, "") + .split(/\s+/) + .filter(word => word.length > 2) + .filter(word => !stopWords.has(word.toLowerCase())) + + return words.slice(0, 5).join(" ") +} \ No newline at end of file diff --git a/apps/core/src/agent/types.ts b/apps/core/src/agent/types.ts index baca608d..a98dd2ad 100644 --- a/apps/core/src/agent/types.ts +++ b/apps/core/src/agent/types.ts @@ -128,6 +128,7 @@ export interface LoopAction { export interface SessionState { status: "idle" | "starting" | "running" | "error" | "stopped" sessionId: string + projectPath: string turnCount: number iterationCount: number loopHealth: LoopHealth @@ -135,10 +136,11 @@ export interface SessionState { activeToolChain?: string[] // For compaction awareness } -export function createInitialSessionState(sessionId: string): SessionState { +export function createInitialSessionState(sessionId: string, projectPath: string): SessionState { return { status: "idle", sessionId, + projectPath, turnCount: 0, iterationCount: 0, loopHealth: { @@ -171,21 +173,26 @@ export type MessagePart = // User Input / Loop Result - Main entry/exit types // ============================================================================= +import type { StreamEvent } from "@freecode/shared" + export interface UserInput { prompt: string sessionId: string provider: string model?: string projectPath: string + onToolEvent?: (event: StreamEvent) => void } export interface LoopResult { success: boolean message?: string content?: string + thinking?: string // Extended thinking content from provider turnCount: number iterationCount: number finalState: SessionState + usage?: { inputTokens: number; outputTokens: number } } // ============================================================================= diff --git a/apps/core/src/providers/anthropic.ts b/apps/core/src/providers/anthropic.ts index b99bd02c..b3cc1a6a 100644 --- a/apps/core/src/providers/anthropic.ts +++ b/apps/core/src/providers/anthropic.ts @@ -18,12 +18,23 @@ function createAnthropicProvider(_apiKey: string): AIProvider { async function execute(opts: ExecuteOptions): Promise { const model = opts.model || PROVIDER_INFO.defaultModel + const tools = opts.tools?.reduce((acc, t) => { + acc[t.name] = { + description: t.description, + inputSchema: t.parameters as Record, + } + return acc + }, {} as Record }>) + + // Cast to any to satisfy AI SDK's ToolSet type which expects FlexibleSchema + // The underlying implementation accepts plain JSON schema objects const result = await generateText({ model: anthropic(model), system: opts.system, prompt: opts.prompt, temperature: opts.temperature, maxOutputTokens: opts.maxTokens || 4096, + tools: tools as any, }) const toolCalls = result.toolCalls?.map((tc): { name: string; args: Record; id: string } => { @@ -40,6 +51,7 @@ function createAnthropicProvider(_apiKey: string): AIProvider { return { content, + thinking: undefined, // V3 SDK doesn't expose thinking blocks toolCalls: toolCalls?.length ? toolCalls : undefined, usage: result.usage ? { inputTokens: result.usage.inputTokens ?? 0, diff --git a/apps/core/src/providers/gemini.ts b/apps/core/src/providers/gemini.ts index 405d6b76..779765d3 100644 --- a/apps/core/src/providers/gemini.ts +++ b/apps/core/src/providers/gemini.ts @@ -18,12 +18,23 @@ function createGeminiProvider(_apiKey: string): AIProvider { async function execute(opts: ExecuteOptions): Promise { const model = opts.model || PROVIDER_INFO.defaultModel + const tools = opts.tools?.reduce((acc, t) => { + acc[t.name] = { + description: t.description, + inputSchema: t.parameters as Record, + } + return acc + }, {} as Record }>) + + // Cast to any to satisfy AI SDK's ToolSet type which expects FlexibleSchema + // The underlying implementation accepts plain JSON schema objects const result = await generateText({ model: gemini.languageModel(model), system: opts.system, prompt: opts.prompt, temperature: opts.temperature, maxOutputTokens: opts.maxTokens || 4096, + tools: tools as any, }) const toolCalls = result.toolCalls?.map((tc): { name: string; args: Record; id: string } => { @@ -37,6 +48,7 @@ function createGeminiProvider(_apiKey: string): AIProvider { return { content: result.text || "", + thinking: undefined, // Gemini doesn't expose thinking blocks toolCalls: toolCalls?.length ? toolCalls : undefined, usage: result.usage ? { inputTokens: result.usage.inputTokens ?? 0, diff --git a/apps/core/src/providers/minimax.ts b/apps/core/src/providers/minimax.ts index bf040bf5..566bf63d 100644 --- a/apps/core/src/providers/minimax.ts +++ b/apps/core/src/providers/minimax.ts @@ -95,9 +95,11 @@ function createMiniMaxProvider(_apiKey: string): AIProvider { stop_reason: string } - // Extract text content, filter out thinking blocks - const textParts = data.content?.filter(c => c.type === "text" || c.type === "thinking") - const content = textParts?.map(c => c.text || c.thinking || "").join("\n").trim() || "" + // Extract text content and thinking blocks separately + const textParts = data.content?.filter(c => c.type === "text") + const thinkingParts = data.content?.filter(c => c.type === "thinking") + const content = textParts?.map(c => c.text || "").join("\n").trim() || "" + const thinking = thinkingParts?.map(c => c.thinking || "").join("\n").trim() || undefined // Extract tool calls from tool_use content blocks const toolCalls = data.content @@ -119,6 +121,7 @@ function createMiniMaxProvider(_apiKey: string): AIProvider { return { content, + thinking, toolCalls: toolCalls.length > 0 ? toolCalls : undefined, usage: data.usage ? { inputTokens: data.usage.input_tokens, diff --git a/apps/core/src/providers/openai.ts b/apps/core/src/providers/openai.ts index 9eeab9b2..7f909f1a 100644 --- a/apps/core/src/providers/openai.ts +++ b/apps/core/src/providers/openai.ts @@ -18,12 +18,23 @@ function createOpenAIProvider(_apiKey: string): AIProvider { async function execute(opts: ExecuteOptions): Promise { const model = opts.model || PROVIDER_INFO.defaultModel + const tools = opts.tools?.reduce((acc, t) => { + acc[t.name] = { + description: t.description, + inputSchema: t.parameters as Record, + } + return acc + }, {} as Record }>) + + // Cast to any to satisfy AI SDK's ToolSet type which expects FlexibleSchema + // The underlying implementation accepts plain JSON schema objects const result = await generateText({ model: openai(model), system: opts.system, prompt: opts.prompt, temperature: opts.temperature, maxOutputTokens: opts.maxTokens || 4096, + tools: tools as any, }) const toolCalls = result.toolCalls?.map((tc): { name: string; args: Record; id: string } => { @@ -37,6 +48,7 @@ function createOpenAIProvider(_apiKey: string): AIProvider { return { content: result.text || "", + thinking: undefined, // OpenAI doesn't have extended thinking in same way toolCalls: toolCalls?.length ? toolCalls : undefined, usage: result.usage ? { inputTokens: result.usage.inputTokens ?? 0, diff --git a/apps/core/src/providers/types.ts b/apps/core/src/providers/types.ts index 4f6dd1db..cdf097f6 100644 --- a/apps/core/src/providers/types.ts +++ b/apps/core/src/providers/types.ts @@ -25,6 +25,7 @@ export interface ExecuteOptions { export interface ExecuteResult { content: string + thinking?: string // Extended thinking/reasoning content toolCalls?: Array<{ name: string; args: Record; id: string }> usage?: { inputTokens: number; outputTokens: number } stopReason: "stop" | "tool_use" | "max_tokens" | "unknown" diff --git a/apps/core/src/server.ts b/apps/core/src/server.ts index 8aad99b8..851216bc 100644 --- a/apps/core/src/server.ts +++ b/apps/core/src/server.ts @@ -10,12 +10,29 @@ import { getProviders, getProviderModels } from "./models-dev.js"; import { readConfig, writeConfig, setApiKey, setCurrentModel, hasApiKey, getCurrentModel, type ProviderId } from "./providers/config.js"; import { logger } from "./utils/logger.js"; import type { ToolContext } from "./tools/types.js"; -import type { JsonRpcRequest, JsonRpcResponse, SessionConfig } from "@freecode/shared"; +import type { JsonRpcRequest, JsonRpcResponse, SessionConfig, StreamEvent } from "@freecode/shared"; import { getMemoryStore, type MemoryEntry, type MemoryType } from "./memory/index.js"; import { findRelevantMemories } from "./memory/mem-query.js"; import { buildMemoryPrompt } from "./memory/mem-prompt.js"; import { getSessionManager, type SessionContext } from "./session/index.js"; +import { createSessionStore, type SessionStore } from "./session/store.js"; import { getRemoteSync, type ExportedSession, type RemoteSessionConfig } from "./store/index.js"; +import { getInterruptHandler } from "./session/interrupt.js"; +import { generateTitleFromPrompt } from "./agent/title-generator.js"; +import { homedir } from "os"; +import { randomUUID } from 'crypto' +import { join } from "path"; + +const SESSION_BASE_DIR = join(homedir(), ".freecode"); + +let sessionStore: SessionStore | null = null; + +async function getSessionStore(): Promise { + if (!sessionStore) { + sessionStore = await createSessionStore(SESSION_BASE_DIR); + } + return sessionStore; +} interface ToolListItem { id: string; @@ -40,10 +57,9 @@ interface SessionInfo { } const sessions: Map = new Map(); -let sessionCounter = 0; function createSession(config: SessionConfig): SessionInfo { - const id = `session-${++sessionCounter}`; + const id = randomUUID(); const session: SessionInfo = { id, projectPath: config.projectPath, @@ -96,6 +112,17 @@ const methodHandlers: Record< const config = params as unknown as SessionConfig; const session = createSession(config); logger.info("Session started", { sessionId: session.id, provider: session.provider }); + + // Persist session to ~/.freecode/sessions/ via SessionStore + const store = await getSessionStore(); + // Use the same session ID that was created in createSession() + await store.createSession({ + title: `Session ${session.id}`, + projectPath: session.projectPath, + provider: session.provider, + model: session.model, + }, session.id); + return { sessionId: session.id }; }, @@ -118,15 +145,34 @@ const methodHandlers: Record< logger.info("Session send", { sessionId, messageLength: message.length, model: session.model, provider: currentProvider }); - const loop = createAgentLoop(sessionId, { maxIterations: 100 }) + // Emit events to stdout immediately for streaming + const emitEvent = (event: StreamEvent) => { + process.stdout.write(JSON.stringify(event) + "\n"); + }; + + // Get session store for persisting messages + const store = await getSessionStore(); + + const loop = createAgentLoop(sessionId, { maxIterations: 100, sessionStore: store }) const result = await loop.run({ prompt: message, sessionId, provider: currentProvider, model: session.model, projectPath: session.projectPath, + onToolEvent: emitEvent, }) + // Emit done event + emitEvent({ type: "done", content: result.message || "Done" }); + + // Extract session title from first response (no extra API call) + if (result.success && result.turnCount > 0 && result.content) { + const titleMatch = result.content.match(/SESSION_TITLE:\s*(.+)/i) + const title = titleMatch ? titleMatch[1].trim() : generateTitleFromPrompt(message) + await store.updateMeta(sessionId, { title }, session.projectPath) + } + return result; }, @@ -232,9 +278,9 @@ const methodHandlers: Record< }, "session.fork": async (params: Record): Promise => { - const { sessionId, point } = params as { sessionId: string; point?: string } + const { sessionId } = params as { sessionId: string } const manager = await getSessionManager() - return manager.fork(sessionId, point) + return manager.fork(sessionId) }, "session.archive": async (params: Record): Promise => { @@ -249,6 +295,11 @@ const methodHandlers: Record< await manager.delete(sessionId) }, + "session.getInterrupted": async (): Promise<{ sessionId: string; messageId: string } | null> => { + const store = await getSessionStore() + return store.getInterruptedSession() + }, + // ========== Remote Sync Methods ========== "session.export": async (params: Record): Promise => { @@ -257,6 +308,13 @@ const methodHandlers: Record< return remoteSync.exportSession(sessionId) }, + "session.import": async (params: Record): Promise<{ sessionId: string }> => { + const { url } = params as { url: string } + const manager = await getSessionManager() + const sessionId = await manager.import(url) + return { sessionId } + }, + "session.upload": async (params: Record): Promise => { const { sessionId, endpoint, apiKey } = params as { sessionId: string; endpoint: string; apiKey?: string } const remoteSync = await getRemoteSync() @@ -287,6 +345,17 @@ async function handleRequest(request: JsonRpcRequest): Promise async function main() { await initProviders(); + // Set up Ctrl+C interrupt handler for session resumption + const handler = getInterruptHandler() + handler.setupSignalHandler(async (sessionId: string, messageId: string) => { + try { + const manager = await getSessionManager() + await manager.markInterrupted(sessionId, messageId) + } catch (e) { + // Ignore errors during interrupt handling + } + }) + let buffer = ""; process.stdin.setEncoding("utf-8"); @@ -327,4 +396,4 @@ async function main() { main().catch((e) => { process.stderr.write(`Server error: ${e}\n`); process.exit(1); -}); \ No newline at end of file +}); diff --git a/apps/core/src/session/interrupt.test.ts b/apps/core/src/session/interrupt.test.ts new file mode 100644 index 00000000..7faa7a55 --- /dev/null +++ b/apps/core/src/session/interrupt.test.ts @@ -0,0 +1,16 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { InterruptHandler } from './interrupt' + +test('InterruptHandler tracks active session/message', () => { + const handler = new InterruptHandler() + assert.strictEqual(handler.getState().pending, false) + + handler.setActive('session-1', 'msg-1') + assert.strictEqual(handler.getState().pending, true) + assert.strictEqual(handler.getState().sessionId, 'session-1') + assert.strictEqual(handler.getState().messageId, 'msg-1') + + handler.clear() + assert.strictEqual(handler.getState().pending, false) +}) \ No newline at end of file diff --git a/apps/core/src/session/interrupt.ts b/apps/core/src/session/interrupt.ts new file mode 100644 index 00000000..bed68c88 --- /dev/null +++ b/apps/core/src/session/interrupt.ts @@ -0,0 +1,57 @@ +// ============================================================================= +// Interrupt Handler - Ctrl+C signal handling for session interruption +// PRIMARY: Marks current message as interrupted on single Ctrl+C, force exits on double +// ============================================================================= + +export interface InterruptState { + sessionId: string | null + messageId: string | null + pending: boolean +} + +export class InterruptHandler { + private sessionId: string | null = null + private messageId: string | null = null + + setActive(sessionId: string, messageId: string): void { + this.sessionId = sessionId + this.messageId = messageId + } + + clear(): void { + this.sessionId = null + this.messageId = null + } + + getState(): InterruptState { + return { + sessionId: this.sessionId, + messageId: this.messageId, + pending: this.sessionId !== null, + } + } + + setupSignalHandler(onInterrupt: (sessionId: string, messageId: string) => void): void { + let lastSigInt = 0 + process.on('SIGINT', () => { + const now = Date.now() + if (now - lastSigInt < 1000) { + // Double Ctrl+C → force exit + process.exit(1) + } + lastSigInt = now + if (this.sessionId && this.messageId) { + onInterrupt(this.sessionId, this.messageId) + } + }) + } +} + +let globalHandler: InterruptHandler | null = null + +export function getInterruptHandler(): InterruptHandler { + if (!globalHandler) { + globalHandler = new InterruptHandler() + } + return globalHandler +} \ No newline at end of file diff --git a/apps/core/src/session/manager.test.ts b/apps/core/src/session/manager.test.ts new file mode 100644 index 00000000..c20b7170 --- /dev/null +++ b/apps/core/src/session/manager.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { rm } from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { SessionManager, createSessionManager } from './manager' +import { createSessionStore, type SessionStore } from './store' + +describe('SessionManager', () => { + const testDir = path.join(os.tmpdir(), 'freecode-test-session-manager') + let sessionStore: SessionStore + let manager: SessionManager + + beforeEach(async () => { + await rm(testDir, { recursive: true, force: true }) + sessionStore = await createSessionStore(testDir) + manager = createSessionManager(sessionStore) + }) + + describe('start', () => { + it('creates a new session and returns session id', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude', 'Test Session') + expect(sessionId).toBeDefined() + expect(typeof sessionId).toBe('string') + }) + + it('stores session metadata via sessionStore', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude', 'Test Session') + const meta = await sessionStore.getMeta(sessionId) + expect(meta).not.toBeNull() + expect(meta!.title).toBe('Test Session') + expect(meta!.projectPath).toBe('/tmp/test-project') + expect(meta!.provider).toBe('claude') + expect(meta!.status).toBe('active') + }) + }) + + describe('resume', () => { + it('loads session context with messages', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude', 'Test Session') + await manager.appendMessage(sessionId, { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello' }], + timestamp: Date.now(), + }) + + const ctx = await manager.resume(sessionId) + expect(ctx.id).toBe(sessionId) + expect(ctx.title).toBe('Test Session') + expect(ctx.messages).toHaveLength(1) + expect(ctx.messages[0].parts[0].content).toBe('Hello') + }) + + it('injects resume marker when session is interrupted', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude', 'Test Session') + const msgId = 'msg-interrupted' + await manager.appendMessage(sessionId, { + id: msgId, + role: 'assistant', + parts: [], + timestamp: Date.now(), + }) + await manager.markInterrupted(sessionId, msgId) + + const ctx = await manager.resume(sessionId) + // Should have original message + injected resume message + const lastMsg = ctx.messages[ctx.messages.length - 1] + expect(lastMsg.role).toBe('user') + expect(lastMsg.parts[0].content).toBe('Continue from where you left off.') + }) + }) + + describe('appendMessage', () => { + it('appends message to session via sessionStore', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude') + await manager.appendMessage(sessionId, { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Test' }], + timestamp: Date.now(), + }) + + const messages = await sessionStore.getMessages(sessionId) + expect(messages).toHaveLength(1) + expect(messages[0].parts[0].content).toBe('Test') + }) + }) + + describe('markInterrupted', () => { + it('marks last message as interrupted', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude') + const msgId = 'msg-1' + await manager.appendMessage(sessionId, { + id: msgId, + role: 'assistant', + parts: [], + timestamp: Date.now(), + }) + + await manager.markInterrupted(sessionId, msgId) + const messages = await sessionStore.getMessages(sessionId) + expect(messages[0].interrupted).toBe(true) + + const meta = await sessionStore.getMeta(sessionId) + expect(meta!.status).toBe('interrupted') + }) + }) + + describe('list', () => { + it('returns all sessions from sessionStore', async () => { + await manager.start('/tmp/p1', 'claude', 'Session 1') + await manager.start('/tmp/p2', 'claude', 'Session 2') + + const sessions = await manager.list() + expect(sessions).toHaveLength(2) + }) + + it('filters by projectPath', async () => { + await manager.start('/tmp/p1', 'claude', 'Session 1') + await manager.start('/tmp/p2', 'claude', 'Session 2') + + const sessions = await manager.list({ projectPath: '/tmp/p1' }) + expect(sessions).toHaveLength(1) + expect(sessions[0].title).toBe('Session 1') + }) + + it('filters by status', async () => { + const s1 = await manager.start('/tmp/p1', 'claude', 'Session 1') + await manager.start('/tmp/p2', 'claude', 'Session 2') + await manager.archive(s1) + + const active = await manager.list({ status: 'active' }) + expect(active).toHaveLength(1) + expect(active[0].title).toBe('Session 2') + }) + }) + + describe('archive', () => { + it('archives session via sessionStore', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude') + await manager.archive(sessionId) + + const meta = await sessionStore.getMeta(sessionId) + expect(meta!.status).toBe('archived') + }) + }) + + describe('delete', () => { + it('deletes session via sessionStore', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude') + await manager.delete(sessionId) + + // After delete, session should be marked as deleted in store + const meta = await sessionStore.getMeta(sessionId) + expect(meta!.status).toBe('deleted') + }) + }) + + describe('fork', () => { + it('forks session via sessionStore', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude', 'Parent') + await manager.appendMessage(sessionId, { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello' }], + timestamp: Date.now(), + }) + + const forkId = await manager.fork(sessionId) + expect(forkId).not.toBe(sessionId) + + const forkMeta = await sessionStore.getMeta(forkId) + expect(forkMeta!.parentId).toBe(sessionId) + expect(forkMeta!.title).toBe('Parent (fork)') + + const forkMessages = await sessionStore.getMessages(forkId) + expect(forkMessages).toHaveLength(1) + }) + }) + + describe('switch', () => { + it('sets current session without loading', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude') + await manager.switch(sessionId) + + const current = await manager.getCurrent() + expect(current).not.toBeNull() + expect(current!.id).toBe(sessionId) + }) + }) +}) \ No newline at end of file diff --git a/apps/core/src/session/manager.ts b/apps/core/src/session/manager.ts index 7773cee2..88a73c87 100644 --- a/apps/core/src/session/manager.ts +++ b/apps/core/src/session/manager.ts @@ -3,31 +3,15 @@ // PRIMARY: Start, resume, switch, fork, list sessions // ============================================================================= -import * as fs from "fs" import * as path from "path" import * as os from "os" +import * as fs from "fs" import { randomUUID } from "crypto" -import { getThreadStore, createThreadStoreService, type ThreadStoreService } from "../store/thread-store" -import type { StoredThread, ThreadFilter } from "../store/types" +import { createSessionStore, type SessionStore, type SessionMeta, type SerializedMessage } from "./store" +import { createThreadStoreService, type ThreadStoreService } from "../store/thread-store" +import { CONFIG_FILE } from "../providers/config.js" const SESSION_DIR = ".freecode" -const SESSIONS_DIR = "sessions" - -function getSessionsDir(): string { - return path.join(os.homedir(), SESSION_DIR, SESSIONS_DIR) -} - -function getSessionDir(sessionId: string): string { - return path.join(getSessionsDir(), sessionId) -} - -function getMessagesPath(sessionId: string): string { - return path.join(getSessionDir(sessionId), "messages.jsonl") -} - -function getMetadataPath(sessionId: string): string { - return path.join(getSessionDir(sessionId), "metadata.json") -} // ============================================================================= // Session Context @@ -38,7 +22,7 @@ export interface SessionContext { title: string projectPath: string provider: string - status: "active" | "archived" | "deleted" + status: "active" | "interrupted" | "archived" | "deleted" createdAt: number updatedAt: number lastTurnAt: number @@ -47,54 +31,35 @@ export interface SessionContext { messages: SerializedMessage[] } -export interface SerializedMessage { - id: string - role: "user" | "assistant" - content: string - timestamp: number -} - // ============================================================================= // SessionManager // ============================================================================= export class SessionManager { - private store: ThreadStoreService + private sessionStore: SessionStore + private threadStore: ThreadStoreService | undefined private currentSessionId: string | null = null - constructor(store: ThreadStoreService) { - this.store = store + constructor(sessionStore: SessionStore, threadStore?: ThreadStoreService) { + this.sessionStore = sessionStore + this.threadStore = threadStore } // Start a new session async start(projectPath: string, provider: string, title?: string): Promise { - const sessionId = randomUUID() const sessionTitle = title || `Session ${new Date().toLocaleDateString()}` - // Create session directory - const sessionDir = getSessionDir(sessionId) - fs.mkdirSync(sessionDir, { recursive: true }) - - // Create thread in store - const threadId = await this.store.create(sessionTitle, projectPath, provider) - - // Save session metadata - const metadata = { - id: sessionId, - threadId, + // Use SessionStore to create the session + const sessionId = await this.sessionStore.createSession({ title: sessionTitle, projectPath, provider, - status: "active" as const, - createdAt: Date.now(), - updatedAt: Date.now(), - lastTurnAt: Date.now(), - turnCount: 0, - } - fs.writeFileSync(getMetadataPath(sessionId), JSON.stringify(metadata, null, 2)) + }) - // Initialize empty messages file - fs.writeFileSync(getMessagesPath(sessionId), "", "utf-8") + // Also create thread in ThreadStore for structured queries (if provided) + if (this.threadStore) { + await this.threadStore.create(sessionTitle, projectPath, provider) + } this.currentSessionId = sessionId return sessionId @@ -102,172 +67,170 @@ export class SessionManager { // Resume an existing session async resume(sessionId: string): Promise { - const metadata = this.loadMetadata(sessionId) - if (!metadata) { + // List all sessions to find the one we want and get its projectPath + const allMetas = await this.sessionStore.list() + const meta = allMetas.find(m => m.id === sessionId) + if (!meta) { throw new Error(`Session not found: ${sessionId}`) } - const messages = this.loadMessages(sessionId) + // Use getMetaBySessionId with the formatted project dir (already stored in list results) + const formattedProjDir = this.sessionStore.list ? undefined : undefined // not needed - meta already has projectPath + let messages = await this.sessionStore.getMessages(sessionId, meta.projectPath) + + // Detect interrupted state → inject resume marker + if (meta.status === "interrupted") { + const lastMsg = messages[messages.length - 1] + if (lastMsg?.interrupted) { + const resumeMsg: SerializedMessage = { + id: randomUUID(), + role: "user", + parts: [{ type: "text", content: "Continue from where you left off." }], + timestamp: Date.now(), + } + messages = [...messages, resumeMsg] + } + } + this.currentSessionId = sessionId return { - id: sessionId, - title: metadata.title, - projectPath: metadata.projectPath, - provider: metadata.provider, - status: metadata.status, - createdAt: metadata.createdAt, - updatedAt: metadata.updatedAt, - lastTurnAt: metadata.lastTurnAt, - turnCount: metadata.turnCount, - parentId: metadata.parentId, + id: meta.id, + title: meta.title, + projectPath: meta.projectPath, + provider: meta.provider, + status: meta.status, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + lastTurnAt: meta.lastTurnAt, + turnCount: meta.turnCount, + parentId: meta.parentId, messages, } } // Switch to a different session (make it current) async switch(sessionId: string): Promise { - const metadata = this.loadMetadata(sessionId) - if (!metadata) { + const allMetas = await this.sessionStore.list() + const meta = allMetas.find(m => m.id === sessionId) + if (!meta) { throw new Error(`Session not found: ${sessionId}`) } this.currentSessionId = sessionId } // Fork session at current point - async fork(sessionId: string, _point?: string): Promise { - const parent = await this.resume(sessionId) - const newSessionId = randomUUID() - - // Create new session directory - const newDir = getSessionDir(newSessionId) - fs.mkdirSync(newDir, { recursive: true }) - - // Copy messages - const messages = this.loadMessages(sessionId) - fs.writeFileSync(getMessagesPath(newSessionId), messages.map((m) => JSON.stringify(m)).join("\n"), "utf-8") - - // Create new metadata with parent reference - const metadata = { - id: newSessionId, - threadId: parent.id, // Reuse thread for now - title: `${parent.title} (fork)`, - projectPath: parent.projectPath, - provider: parent.provider, - status: "active" as const, - createdAt: Date.now(), - updatedAt: Date.now(), - lastTurnAt: Date.now(), - turnCount: 0, - parentId: sessionId, + async fork(sessionId: string): Promise { + const allMetas = await this.sessionStore.list() + const meta = allMetas.find(m => m.id === sessionId) + if (!meta) { + throw new Error(`Session not found: ${sessionId}`) } - fs.writeFileSync(getMetadataPath(newSessionId), JSON.stringify(metadata, null, 2)) - - this.currentSessionId = newSessionId - return newSessionId + const forkId = await this.sessionStore.fork(sessionId, meta.projectPath) + this.currentSessionId = forkId + return forkId } // List sessions - async list(filter?: { projectPath?: string; status?: "active" | "archived" | "deleted" }): Promise { - const sessions: SessionContext[] = [] - const sessionsDir = getSessionsDir() - - if (!fs.existsSync(sessionsDir)) { - return sessions - } - - const entries = fs.readdirSync(sessionsDir) - for (const entry of entries) { - const sessionPath = path.join(sessionsDir, entry) - if (!fs.statSync(sessionPath).isDirectory()) continue - - const metadata = this.loadMetadata(entry) - if (!metadata) continue - - if (filter?.projectPath && metadata.projectPath !== filter.projectPath) continue - if (filter?.status && metadata.status !== filter.status) continue - - sessions.push({ - id: entry, - title: metadata.title, - projectPath: metadata.projectPath, - provider: metadata.provider, - status: metadata.status, - createdAt: metadata.createdAt, - updatedAt: metadata.updatedAt, - lastTurnAt: metadata.lastTurnAt, - turnCount: metadata.turnCount, - parentId: metadata.parentId, - messages: [], // Don't load messages for list - }) - } - - // Sort by lastTurnAt descending - sessions.sort((a, b) => b.lastTurnAt - a.lastTurnAt) - return sessions + async list(filter?: { projectPath?: string; status?: SessionMeta["status"] }): Promise { + const metas = await this.sessionStore.list(filter) + + return metas.map((meta) => ({ + id: meta.id, + title: meta.title, + projectPath: meta.projectPath, + provider: meta.provider, + status: meta.status, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + lastTurnAt: meta.lastTurnAt, + turnCount: meta.turnCount, + parentId: meta.parentId, + messages: [], // Don't load messages for list + })) } // Archive a session async archive(sessionId: string): Promise { - const metadata = this.loadMetadata(sessionId) - if (!metadata) return - - metadata.status = "archived" - metadata.updatedAt = Date.now() - fs.writeFileSync(getMetadataPath(sessionId), JSON.stringify(metadata, null, 2)) + await this.sessionStore.updateStatus(sessionId, "archived") } // Delete a session async delete(sessionId: string): Promise { - const sessionDir = getSessionDir(sessionId) - if (fs.existsSync(sessionDir)) { - fs.rmSync(sessionDir, { recursive: true, force: true }) - } + await this.sessionStore.deleteSession(sessionId) } // Get current session - getCurrent(): SessionContext | null { + async getCurrent(): Promise { if (!this.currentSessionId) return null try { - return this.resume(this.currentSessionId) as unknown as SessionContext + return await this.resume(this.currentSessionId) } catch { return null } } - // Add message to current session - addMessage(sessionId: string, role: "user" | "assistant", content: string): void { - const messagesPath = getMessagesPath(sessionId) - const message: SerializedMessage = { - id: randomUUID(), - role, - content, - timestamp: Date.now(), - } - fs.appendFileSync(messagesPath, JSON.stringify(message) + "\n", "utf-8") + // Append message to session + async appendMessage(sessionId: string, message: SerializedMessage): Promise { + await this.sessionStore.appendMessage(sessionId, message) + } + + // Mark message as interrupted + async markInterrupted(sessionId: string, messageId: string): Promise { + await this.sessionStore.markInterrupted(sessionId, messageId) } - // ============================================================================= - // Private helpers - // ============================================================================= + // Export session to remote sync endpoint + async export(sessionId: string): Promise<{ url: string; expiresAt: number }> { + const meta = await this.sessionStore.getMeta(sessionId) + if (!meta) throw new Error("Session not found") + const messages = await this.sessionStore.getMessages(sessionId) - private loadMetadata(sessionId: string): any { - const metadataPath = getMetadataPath(sessionId) - if (!fs.existsSync(metadataPath)) return null - return JSON.parse(fs.readFileSync(metadataPath, "utf-8")) + const payload = JSON.stringify({ meta, messages }) + const endpoint = this.getSyncEndpoint() + + const response = await fetch(`${endpoint}/upload`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload, + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) throw new Error("Export failed") + return response.json() as Promise<{ url: string; expiresAt: number }> } - private loadMessages(sessionId: string): SerializedMessage[] { - const messagesPath = getMessagesPath(sessionId) - if (!fs.existsSync(messagesPath)) return [] + // Import session from remote URL + async import(url: string): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(30000) }) + if (!response.ok) throw new Error("Import failed") + const { metadata: meta, messages } = await response.json() as { metadata: SessionMeta; messages: SerializedMessage[] } + + const newId = await this.sessionStore.createSession({ + title: meta.title + " (imported)", + projectPath: meta.projectPath, + provider: meta.provider, + }) + + for (const msg of messages) { + await this.sessionStore.appendMessage(newId, msg) + } - const content = fs.readFileSync(messagesPath, "utf-8") - if (!content.trim()) return [] + await this.sessionStore.updateMeta(newId, { status: "active" }) + return newId + } - return content - .split("\n") - .filter((line) => line.trim()) - .map((line) => JSON.parse(line)) + private getSyncEndpoint(): string { + try { + if (fs.existsSync(CONFIG_FILE)) { + const content = fs.readFileSync(CONFIG_FILE, "utf-8") + const config = JSON.parse(content) as { syncEndpoint?: string } + if (config.syncEndpoint) return config.syncEndpoint + } + } catch { + // ignore + } + return "https://sync.freecode.dev" } } @@ -279,8 +242,14 @@ let globalSessionManager: SessionManager | null = null export async function getSessionManager(): Promise { if (!globalSessionManager) { - const store = await createThreadStoreService() - globalSessionManager = new SessionManager(store) + const baseDir = path.join(os.homedir(), SESSION_DIR) + const sessionStore = await createSessionStore(baseDir) + const threadStore = await createThreadStoreService() + globalSessionManager = new SessionManager(sessionStore, threadStore) } return globalSessionManager } + +export function createSessionManager(sessionStore: SessionStore, threadStore?: ThreadStoreService): SessionManager { + return new SessionManager(sessionStore, threadStore) +} \ No newline at end of file diff --git a/apps/core/src/session/prompt/anthropic.txt b/apps/core/src/session/prompt/anthropic.txt index df8cc1f0..7a0f05e1 100644 --- a/apps/core/src/session/prompt/anthropic.txt +++ b/apps/core/src/session/prompt/anthropic.txt @@ -38,3 +38,14 @@ When referencing code, include `file_path:line_number` for navigation. - Tool results may include `` tags with useful reminders - System reminders are automatically added and relevant to the task - You can call multiple tools in parallel when independent + +## Response Formatting + +Format your responses for terminal display: +- Use **bold** for important concepts, key terms, and emphasis +- Use `inline code` for file paths (e.g., `src/index.ts`), function names, variables, and command names +- Use code blocks with ``` for multi-line code snippets +- Use # headings for section titles (keep it short for terminal) +- Use > blockquotes for important notes or warnings +- Use bullet lists (-) for sequential items or steps +- Color coding: blue for links and file references, cyan for headings, yellow for code diff --git a/apps/core/src/session/prompt/chatgpt.txt b/apps/core/src/session/prompt/chatgpt.txt index 24da75c3..dfc3c22b 100644 --- a/apps/core/src/session/prompt/chatgpt.txt +++ b/apps/core/src/session/prompt/chatgpt.txt @@ -20,3 +20,16 @@ Assist users with software engineering tasks efficiently through CLI automation. - Focus on the task at hand - Prefer editing existing files to creating new ones - Provide clear, actionable feedback + + +## Response Formatting + +Format your responses for terminal display: +- Use **bold** for important concepts, key terms, and emphasis +- Use `inline code` for file paths (e.g., `src/index.ts`), function names, variables, and command names +- Use code blocks with ``` for multi-line code snippets +- Use # headings for section titles (keep it short for terminal) +- Use > blockquotes for important notes or warnings +- Use bullet lists (-) for sequential items or steps +- Use 1. numbered lists for ordered steps +- Color coding: use blue for links and file references, cyan for headings, yellow for code diff --git a/apps/core/src/session/prompt/default.txt b/apps/core/src/session/prompt/default.txt index 1b32ba12..9a1690bb 100644 --- a/apps/core/src/session/prompt/default.txt +++ b/apps/core/src/session/prompt/default.txt @@ -25,3 +25,15 @@ When referencing specific functions or pieces of code, include the pattern `file - Be concise in responses - output is displayed on a command line interface - Prioritize technical accuracy over validation - Use GitHub-flavored markdown for formatting + +## Response Formatting + +Format your responses for terminal display: +- Use **bold** for important concepts, key terms, and emphasis +- Use `inline code` for file paths (e.g., `src/index.ts`), function names, variables, and command names +- Use code blocks with ``` for multi-line code snippets +- Use # headings for section titles (keep it short for terminal) +- Use > blockquotes for important notes or warnings +- Use bullet lists (-) for sequential items or steps +- Use 1. numbered lists for ordered steps +- Color coding: use blue for links and file references, cyan for headings, yellow for code diff --git a/apps/core/src/session/prompt/gemini.txt b/apps/core/src/session/prompt/gemini.txt index 5d3f1b71..0fec35bd 100644 --- a/apps/core/src/session/prompt/gemini.txt +++ b/apps/core/src/session/prompt/gemini.txt @@ -30,3 +30,16 @@ Leverage Gemini's strengths for coding assistance: - Make incremental changes - Always verify your work - Be clear about what you did and why + + +## Response Formatting + +Format your responses for terminal display: +- Use **bold** for important concepts, key terms, and emphasis +- Use `inline code` for file paths (e.g., `src/index.ts`), function names, variables, and command names +- Use code blocks with ``` for multi-line code snippets +- Use # headings for section titles (keep it short for terminal) +- Use > blockquotes for important notes or warnings +- Use bullet lists (-) for sequential items or steps +- Use 1. numbered lists for ordered steps +- Color coding: use blue for links and file references, cyan for headings, yellow for code diff --git a/apps/core/src/session/prompt/gpt.txt b/apps/core/src/session/prompt/gpt.txt index f3ff7980..36be89bf 100644 --- a/apps/core/src/session/prompt/gpt.txt +++ b/apps/core/src/session/prompt/gpt.txt @@ -30,3 +30,16 @@ You are FreeCode, powered by OpenAI's GPT models for coding assistance. - Follow existing project conventions - Add comments for non-obvious logic - Keep functions focused and small + + +## Response Formatting + +Format your responses for terminal display: +- Use **bold** for important concepts, key terms, and emphasis +- Use `inline code` for file paths (e.g., `src/index.ts`), function names, variables, and command names +- Use code blocks with ``` for multi-line code snippets +- Use # headings for section titles (keep it short for terminal) +- Use > blockquotes for important notes or warnings +- Use bullet lists (-) for sequential items or steps +- Use 1. numbered lists for ordered steps +- Color coding: use blue for links and file references, cyan for headings, yellow for code diff --git a/apps/core/src/session/prompt/openai.txt b/apps/core/src/session/prompt/openai.txt index f5dc8d5d..327c04b3 100644 --- a/apps/core/src/session/prompt/openai.txt +++ b/apps/core/src/session/prompt/openai.txt @@ -28,3 +28,16 @@ You have access to tools for reading, writing, searching, and editing code files - Use **Read** to examine file contents - Use **Bash** for shell operations - Use **edit** for targeted modifications + + +## Response Formatting + +Format your responses for terminal display: +- Use **bold** for important concepts, key terms, and emphasis +- Use `inline code` for file paths (e.g., `src/index.ts`), function names, variables, and command names +- Use code blocks with ``` for multi-line code snippets +- Use # headings for section titles (keep it short for terminal) +- Use > blockquotes for important notes or warnings +- Use bullet lists (-) for sequential items or steps +- Use 1. numbered lists for ordered steps +- Color coding: use blue for links and file references, cyan for headings, yellow for code diff --git a/apps/core/src/session/store.test.ts b/apps/core/src/session/store.test.ts new file mode 100644 index 00000000..9064ab79 --- /dev/null +++ b/apps/core/src/session/store.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { SessionStore, createSessionStore } from './store' +import { rm } from 'fs/promises' + +describe('SessionStore', () => { + const testDir = '/tmp/freecode-test-session-store' + let store: SessionStore + + beforeEach(async () => { + await rm(testDir, { recursive: true, force: true }) + store = await createSessionStore(testDir) + }) + + it('creates session directory with meta.json', async () => { + const sessionId = await store.createSession({ + title: 'Test Session', + projectPath: '/tmp/test', + provider: 'claude', + }) + const meta = await store.getMeta(sessionId) + expect(meta).not.toBeNull() + expect(meta!.id).toBe(sessionId) + expect(meta!.title).toBe('Test Session') + expect(meta!.status).toBe('active') + }) + + it('appends messages to messages.jsonl', async () => { + const sessionId = await store.createSession({ + title: 'Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + const msg = { + id: 'msg-1', + role: 'user' as const, + parts: [{ type: 'text' as const, content: 'hello' }], + timestamp: Date.now(), + } + await store.appendMessage(sessionId, msg) + const messages = await store.getMessages(sessionId) + expect(messages).toHaveLength(1) + expect(messages[0].parts[0]).toEqual({ type: 'text', content: 'hello' }) + }) + + it('marks message as interrupted', async () => { + const sessionId = await store.createSession({ + title: 'Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + const msg = { + id: 'msg-1', + role: 'assistant' as const, + parts: [], + timestamp: Date.now(), + } + await store.appendMessage(sessionId, msg) + await store.markInterrupted(sessionId, 'msg-1') + const msgs = await store.getMessages(sessionId) + expect(msgs[0].interrupted).toBe(true) + }) + + it('detects interrupted sessions', async () => { + const sessionId = await store.createSession({ + title: 'Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + const msg = { + id: 'msg-1', + role: 'assistant' as const, + parts: [], + timestamp: Date.now(), + } + await store.appendMessage(sessionId, msg) + await store.markInterrupted(sessionId, 'msg-1') + const interrupted = await store.getInterruptedSession() + expect(interrupted?.sessionId).toBe(sessionId) + }) + + it('lists sessions with filter', async () => { + const s1 = await store.createSession({ title: 'S1', projectPath: '/tmp/p1', provider: 'claude' }) + // Ensure different lastTurnAt timestamps + await new Promise(r => setTimeout(r, 10)) + const s2 = await store.createSession({ title: 'S2', projectPath: '/tmp/p2', provider: 'claude' }) + await store.updateStatus(s1, 'archived') + const active = await store.list({ status: 'active' }) + // After archiving s1, only s2 remains active + expect(active).toHaveLength(1) + expect(active[0].title).toBe('S2') + const archived = await store.list({ status: 'archived' }) + expect(archived).toHaveLength(1) + expect(archived[0].title).toBe('S1') + }) + + it('forks session with new id', async () => { + const parentId = await store.createSession({ + title: 'Parent', + projectPath: '/tmp/test', + provider: 'claude', + }) + const forkId = await store.fork(parentId) + const forkMeta = await store.getMeta(forkId) + expect(forkMeta?.parentId).toBe(parentId) + expect(forkId).not.toBe(parentId) + }) + + it('updates session meta', async () => { + const sessionId = await store.createSession({ + title: 'Original', + projectPath: '/tmp/test', + provider: 'claude', + }) + await store.updateMeta(sessionId, { title: 'Updated' }) + const meta = await store.getMeta(sessionId) + expect(meta?.title).toBe('Updated') + }) + + it('deletes session', async () => { + const sessionId = await store.createSession({ + title: 'ToDelete', + projectPath: '/tmp/test', + provider: 'claude', + }) + await store.deleteSession(sessionId) + const meta = await store.getMeta(sessionId) + expect(meta?.status).toBe('deleted') + }) +}) \ No newline at end of file diff --git a/apps/core/src/session/store.ts b/apps/core/src/session/store.ts new file mode 100644 index 00000000..865a8db5 --- /dev/null +++ b/apps/core/src/session/store.ts @@ -0,0 +1,276 @@ +// ============================================================================= +// SessionStore - JSONL-based file operations for session persistence +// PRIMARY: Provides file-based session storage at ~/.freecode/sessions/ +// STORAGE: Sessions stored at {baseDir}/sessions/{projectDir}/{sessionId}/ with meta.json + messages.jsonl +// PROJECT DIR: Project path is formatted using path-formatter (e.g., /home/ayande/Project → home__ayande__Project) +// ============================================================================= + +import { mkdir, readFile, writeFile, readdir } from 'fs/promises' +import { join } from 'path' +import { randomUUID } from 'crypto' +import { formatSessionDirName } from '../store/path-formatter.js' + +// ============================================================================ +// Types +// ============================================================================ + +export interface SessionMeta { + id: string + title: string + projectPath: string + provider: string + model?: string + status: 'active' | 'interrupted' | 'archived' | 'deleted' + createdAt: number + updatedAt: number + lastTurnAt: number + turnCount: number + parentId?: string + aggregatedTokenCount?: number +} + +export interface SerializedMessage { + id: string + role: 'user' | 'assistant' + parts: Array<{ + type: 'text' | 'code' | 'tool' + content?: string + language?: string + tool?: { name: string; args: Record } + result?: string + }> + timestamp: number + interrupted?: boolean +} + +export interface CreateSessionOptions { + title: string + projectPath: string + provider: string + model?: string +} + +export interface SessionStore { + createSession(opts: CreateSessionOptions, forcedId?: string): Promise + getMeta(sessionId: string, projectPath?: string): Promise + getMetaBySessionId(formattedProjDir: string, sessionId: string): Promise + updateMeta(sessionId: string, updates: Partial, projectPath?: string): Promise + updateStatus(sessionId: string, status: SessionMeta['status'], projectPath?: string): Promise + deleteSession(sessionId: string, projectPath?: string): Promise + + appendMessage(sessionId: string, message: SerializedMessage, projectPath?: string): Promise + getMessages(sessionId: string, projectPath?: string): Promise + markInterrupted(sessionId: string, messageId: string, projectPath?: string): Promise + + list(filter?: { status?: SessionMeta['status']; projectPath?: string }): Promise + fork(sessionId: string, newProjectPath?: string): Promise + + getInterruptedSession(): Promise<{ sessionId: string; messageId: string } | null> +} + +// ============================================================================ +// Constants +// ============================================================================ + +const SESSION_DIR = 'sessions' +const META_FILE = 'meta.json' +const MESSAGES_FILE = 'messages.jsonl' + +// ============================================================================ +// Helpers +// ============================================================================ + +async function ensureDir(dir: string): Promise { + try { + await mkdir(dir, { recursive: true }) + } catch { + // already exists + } +} + +async function readJson(path: string): Promise { + try { + const data = await readFile(path, 'utf-8') + return JSON.parse(data) as T + } catch { + return null + } +} + +async function writeJson(path: string, data: unknown): Promise { + await writeFile(path, JSON.stringify(data, null, 2), 'utf-8') +} + +// ============================================================================ +// Factory +// ============================================================================ + +export async function createSessionStore(baseDir: string): Promise { + await ensureDir(join(baseDir, SESSION_DIR)) + return new SessionStoreImpl(baseDir) +} + +// ============================================================================ +// Implementation +// ============================================================================ + +class SessionStoreImpl implements SessionStore { + constructor(private baseDir: string, private projectDir?: string) {} + + private getProjectDir(projectPath: string): string { + return formatSessionDirName(projectPath) + } + + private sessionDir(sessionId: string, projectPath?: string): string { + const projDir = projectPath ? this.getProjectDir(projectPath) : this.projectDir + if (!projDir) throw new Error('Project path required') + return join(this.baseDir, SESSION_DIR, projDir, sessionId) + } + + private sessionDirFromFormatted(sessionId: string, formattedProjDir: string): string { + return join(this.baseDir, SESSION_DIR, formattedProjDir, sessionId) + } + + private metaPath(sessionId: string, projectPath?: string): string { + return join(this.sessionDir(sessionId, projectPath), META_FILE) + } + + private metaPathFromFormatted(sessionId: string, formattedProjDir: string): string { + return join(this.sessionDirFromFormatted(sessionId, formattedProjDir), META_FILE) + } + + private messagesPath(sessionId: string, projectPath?: string): string { + return join(this.sessionDir(sessionId, projectPath), MESSAGES_FILE) + } + + private messagesPathFromFormatted(sessionId: string, formattedProjDir: string): string { + return join(this.sessionDirFromFormatted(sessionId, formattedProjDir), MESSAGES_FILE) + } + + private projectSessionsDir(projectPath: string): string { + return join(this.baseDir, SESSION_DIR, this.getProjectDir(projectPath)) + } + + async createSession(opts: CreateSessionOptions, forcedId?: string): Promise { + const id = forcedId || randomUUID() + const now = Date.now() + const meta: SessionMeta = { + id, + title: opts.title, + projectPath: opts.projectPath, + provider: opts.provider, + model: opts.model, + status: 'active', + createdAt: now, + updatedAt: now, + lastTurnAt: now, + turnCount: 0, + } + const projDir = this.getProjectDir(opts.projectPath) + await ensureDir(join(this.baseDir, SESSION_DIR, projDir)) + await ensureDir(this.sessionDir(id, opts.projectPath)) + await writeJson(this.metaPath(id, opts.projectPath), meta) + await writeFile(this.messagesPath(id, opts.projectPath), '', 'utf-8') + return id + } + + async getMeta(sessionId: string, projectPath?: string): Promise { + return readJson(this.metaPath(sessionId, projectPath)) + } + + async getMetaBySessionId(formattedProjDir: string, sessionId: string): Promise { + return readJson(this.metaPathFromFormatted(sessionId, formattedProjDir)) + } + + async updateMeta(sessionId: string, updates: Partial, projectPath?: string): Promise { + const meta = await this.getMeta(sessionId, projectPath) + if (!meta) return + const updated = { ...meta, ...updates, updatedAt: Date.now() } + await writeJson(this.metaPath(sessionId, projectPath), updated) + } + + async updateStatus(sessionId: string, status: SessionMeta['status'], projectPath?: string): Promise { + await this.updateMeta(sessionId, { status }, projectPath) + } + + async deleteSession(sessionId: string, projectPath?: string): Promise { + await this.updateStatus(sessionId, 'deleted', projectPath) + } + + async appendMessage(sessionId: string, message: SerializedMessage, projectPath?: string): Promise { + const line = JSON.stringify(message) + '\n' + await writeFile(this.messagesPath(sessionId, projectPath), line, { flag: 'a' }) + } + + async getMessages(sessionId: string, projectPath?: string): Promise { + const content = await readFile(this.messagesPath(sessionId, projectPath), 'utf-8').catch(() => '') + if (!content.trim()) return [] + return content.trim().split('\n').map(line => JSON.parse(line) as SerializedMessage) + } + + async markInterrupted(sessionId: string, messageId: string, projectPath?: string): Promise { + const messages = await this.getMessages(sessionId, projectPath) + const idx = messages.findIndex(m => m.id === messageId) + if (idx !== -1) { + messages[idx] = { ...messages[idx], interrupted: true } + } + const content = messages.map(m => JSON.stringify(m)).join('\n') + '\n' + await writeFile(this.messagesPath(sessionId, projectPath), content, 'utf-8') + await this.updateStatus(sessionId, 'interrupted', projectPath) + } + + async list(filter?: { status?: SessionMeta['status']; projectPath?: string }): Promise { + const sessionsDir = join(this.baseDir, SESSION_DIR) + let projectDirs: string[] + try { + projectDirs = await readdir(sessionsDir) + } catch { + return [] + } + const metas: SessionMeta[] = [] + for (const projDir of projectDirs) { + const projPath = join(sessionsDir, projDir) + let sessionIds: string[] + try { + sessionIds = await readdir(projPath) + } catch { + continue + } + for (const id of sessionIds) { + const meta = await this.getMetaBySessionId(projDir, id) + if (!meta) continue + if (filter?.status && meta.status !== filter.status) continue + if (filter?.projectPath && meta.projectPath !== filter.projectPath) continue + metas.push(meta) + } + } + return metas.sort((a, b) => b.lastTurnAt - a.lastTurnAt) + } + + async fork(sessionId: string, newProjectPath?: string): Promise { + const meta = await this.getMeta(sessionId) + if (!meta) throw new Error('Session not found') + const targetProjectPath = newProjectPath || meta.projectPath + const newId = await this.createSession({ + title: meta.title + ' (fork)', + projectPath: targetProjectPath, + provider: meta.provider, + model: meta.model, + }) + await this.updateMeta(newId, { parentId: sessionId, turnCount: meta.turnCount }, targetProjectPath) + const messages = await this.getMessages(sessionId, meta.projectPath) + for (const msg of messages) { + await this.appendMessage(newId, msg, targetProjectPath) + } + return newId + } + + async getInterruptedSession(): Promise<{ sessionId: string; messageId: string } | null> { + const all = await this.list({ status: 'interrupted' }) + if (all.length === 0) return null + const session = all[0] + const messages = await this.getMessages(session.id, session.projectPath) + const last = messages[messages.length - 1] + return last ? { sessionId: session.id, messageId: last.id } : null + } +} \ No newline at end of file diff --git a/apps/core/src/store/index.ts b/apps/core/src/store/index.ts index a73a4e9a..8c269807 100644 --- a/apps/core/src/store/index.ts +++ b/apps/core/src/store/index.ts @@ -31,4 +31,11 @@ export { JsonThreadStoreImpl, createJsonThreadStore } from "./json-store" export { SqliteThreadStoreImpl } from "./sqlite-store" // Remote Session Sync -export { RemoteSessionSync, getRemoteSync, type ExportedSession, type RemoteSessionConfig } from "./remote" \ No newline at end of file +export { RemoteSessionSync, getRemoteSync, type ExportedSession, type RemoteSessionConfig } from "./remote" + +// Path Formatter +export { + formatSessionDirName, + parseSessionDirName, + isSessionDirName, +} from "./path-formatter" \ No newline at end of file diff --git a/apps/core/src/store/path-formatter.ts b/apps/core/src/store/path-formatter.ts new file mode 100644 index 00000000..4d299f5c --- /dev/null +++ b/apps/core/src/store/path-formatter.ts @@ -0,0 +1,77 @@ +// ============================================================================= +// Path Formatter - Convert project paths to session directory names +// ============================================================================= +// +// Converts a project path like /home/ayande/Project/opencode +// to a safe directory name like home-ayande-Project-opencode +// +// The separator is chosen to avoid collisions with path characters: +// - Forward slashes are replaced with PATH_SEP +// - Hyphens are escaped as HYPHEN_ESCAPE in segments +// +// Usage: +// formatSessionDirName("/home/ayande/Project/opencode") +// // → "home-ayande-Project-opencode" +// +// parseSessionDirName("home-ayande-Project-opencode") +// // → "/home/ayande/Project/opencode" + +const PATH_SEP = '-' +const HYPHEN_ESCAPE = '_h_' +const SEGMENT_SEP = '__' + +/** + * Convert an absolute project path to a safe directory name. + * + * Examples: + * /home/ayande/Project/opencode → home-ayande-Project-opencode + * /home/ayande/Project/my-project → home-ayande-Project-my_ h_project + * /Users/john/code/my-project → Users-john-code-my_ h_project + * C:\Users\john\projects\myapp → C-Users-john-projects-my_ h_app + */ +export function formatSessionDirName(projectPath: string): string { + // Normalize backslashes to forward slashes + const normalized = projectPath.replace(/\\/g, '/') + + // Remove leading/trailing slashes + const stripped = normalized.replace(/^\/+|\/+$/g, '') + + if (!stripped) return '' + + // Split into segments and process each + const segments = stripped.split('/') + + const formatted = segments.map(segment => { + // Escape hyphens within segments (they're our separator) + return segment.replace(/-/g, HYPHEN_ESCAPE) + }).join(SEGMENT_SEP) + + return formatted +} + +/** + * Convert a session directory name back to an absolute project path. + * + * Examples: + * home__ayande__Project__opencode → /home/ayande/Project/opencode + * home__ayande__Project__my_ h_project → /home/ayande/Project/my-project + */ +export function parseSessionDirName(dirName: string): string { + if (!dirName) return '' + + // Split by segment separator, unescape hyphens, rejoin with slashes + const segments = dirName.split(SEGMENT_SEP) + + const path = segments.map(segment => { + return segment.replace(new RegExp(HYPHEN_ESCAPE, 'g'), '-') + }).join('/') + + return '/' + path +} + +/** + * Check if a string looks like a formatted session directory name. + */ +export function isSessionDirName(str: string): boolean { + return str.length > 0 && str.indexOf(SEGMENT_SEP) !== -1 +} \ No newline at end of file diff --git a/apps/tui/src/commands/built-in.ts b/apps/tui/src/commands/built-in.ts index d59a0709..5970a0b2 100644 --- a/apps/tui/src/commands/built-in.ts +++ b/apps/tui/src/commands/built-in.ts @@ -1,6 +1,5 @@ import { registerCommand, type Command, type CommandContext } from "./index.js"; import { AVAILABLE_MODELS } from "../models.js"; -import { registerFreecodeCommand } from "./freecode/index.js"; const helpCommand: Command = { name: "help", @@ -11,8 +10,10 @@ const helpCommand: Command = { - **/help** - Show this help message - **/clear** - Clear all messages - **/model** - Select AI model +- **/resume** - Resume a previous session - **/exit** - Exit FreeCode -- **/freecode** - Send prompt to ChatGPT and apply file changes`); + +Just type your prompt to start chatting!`); }, }; @@ -41,10 +42,19 @@ const modelCommand: Command = { }, }; +const resumeCommand: Command = { + name: "resume", + description: "Resume a previous session", + execute: (_args, ctx) => { + ctx.showMessage(`**Select a session to resume:**`); + ctx.showResumePicker?.(); + }, +}; + export function registerBuiltInCommands(): void { registerCommand(helpCommand); registerCommand(clearCommand); registerCommand(exitCommand); registerCommand(modelCommand); - registerFreecodeCommand(); + registerCommand(resumeCommand); } \ No newline at end of file diff --git a/apps/tui/src/commands/freecode/index.ts b/apps/tui/src/commands/freecode/index.ts index 3dd97ad8..9fae2446 100644 --- a/apps/tui/src/commands/freecode/index.ts +++ b/apps/tui/src/commands/freecode/index.ts @@ -7,19 +7,21 @@ import { registerCommand, type Command, type CommandContext } from "../index.js" import { startCli, sessionStart, - sessionSend, + sessionSendStreaming, listProviders, type SessionInfo, } from "../../ipc/client.js"; -import { playSound } from "./sound.js"; import { playAlert } from "./alert.js"; +import { getRandomElapsedPhrase, getRandomInProgressPhrase } from "../../utils/elapsed-phrases.js"; +import { getModelContextLimit } from "../../utils/model-limits.js"; +import { formatTokenCount } from "../../utils/format-tokens.js"; export { stopSound } from "./sound.js"; // State let currentSession: SessionInfo | null = null; let providersLoaded = false; let cachedProviders: Array<{ id: string; name: string }> = []; -let currentProvider = "minimax"; // Default to minimax +let currentProvider = "minimax"; async function ensureProviders(): Promise { if (!providersLoaded) { @@ -46,8 +48,6 @@ async function ensureSession(ctx: CommandContext): Promise { if (currentSession) return true; startCli(); - - // Small delay to let CLI start await new Promise((resolve) => setTimeout(resolve, 500)); try { @@ -81,29 +81,43 @@ ${formatProviderList()}`); return; } - ctx.showMessage(`**You:** ${userPrompt}`); - ctx.showMessage("Processing..."); - - // Play sound while processing - // playSound(); + // Show user message with gray background + ctx.createUserMessage(`**You:** ${userPrompt}`); - // Track start time for elapsed display + // Show in-progress message and track it for later removal + const inProgressMsg = ctx.createInProgressMessage(getRandomInProgressPhrase()); + const inProgressId = inProgressMsg.id; const startTime = Date.now(); - // Ensure CLI is running and we have a session const ready = await ensureSession(ctx); if (!ready) return; try { - const result = await sessionSend(currentSession!.sessionId, userPrompt) as { - success: boolean; - message?: string; - content?: string; - turnCount?: number; - iterationCount?: number; - }; - - // Calculate elapsed time + const result = await sessionSendStreaming( + currentSession!.sessionId, + userPrompt, + undefined, + (event) => ctx.handleToolEvent?.(event) + ); + + // Update in-progress message with token counts + const contextLimit = getModelContextLimit(`${currentProvider}/MiniMax-M2`); + ctx.updateInProgressMessage( + inProgressId, + getRandomInProgressPhrase(), + result.usage?.inputTokens ?? 0, + result.usage?.outputTokens ?? 0, + contextLimit, + startTime, + result.turnCount || 1 + ); + + // Brief pause so user can see final token state before it disappears + await new Promise(resolve => setTimeout(resolve, 500)); + + // Remove in-progress message now that response has arrived + ctx.removeMessageById(inProgressId); + const elapsed = Date.now() - startTime; const seconds = Math.floor(elapsed / 1000); const minutes = Math.floor(seconds / 60); @@ -112,20 +126,25 @@ ${formatProviderList()}`); if (result.success) { const response = result.content || result.message; - ctx.showMessage(`**FreeCode:** ${response || "Done!"}`); - ctx.showMessage(chalk.dim(`Baked for ${timeStr}`)); - // stopSound(); + ctx.createAssistantMessage(`**FreeCode:** ${response || "Done!"}`); + const inTokens = result.usage?.inputTokens ?? 0; + const outTokens = result.usage?.outputTokens ?? 0; + let tokenInfo = `↓${formatTokenCount(inTokens)} ↑${formatTokenCount(outTokens)}`; + if (contextLimit > 0) { + tokenInfo += ` [${formatTokenCount(inTokens)}/${formatTokenCount(contextLimit)}]`; + } + ctx.createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr} ${tokenInfo} (x${result.turnCount || 1})`); playAlert(); } else { - ctx.showMessage(`**FreeCode:** ${result.message || "Unknown error"}`); - // stopSound(); + ctx.createSystemMessage(`**Error:** ${result.message || "Unknown error"}`); + ctx.createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr}`); playAlert(); } } catch (error) { + ctx.removeMessageById(inProgressId); ctx.showMessage( `Error: ${error instanceof Error ? error.message : String(error)}` ); - // stopSound(); playAlert(); } }, @@ -133,4 +152,4 @@ ${formatProviderList()}`); export function registerFreecodeCommand(): void { registerCommand(freecodeCommand); -} \ No newline at end of file +} diff --git a/apps/tui/src/commands/index.ts b/apps/tui/src/commands/index.ts index cdb5258c..a240e5dc 100644 --- a/apps/tui/src/commands/index.ts +++ b/apps/tui/src/commands/index.ts @@ -1,8 +1,22 @@ +import type { Component } from "@earendil-works/pi-tui"; import type { AutocompleteItem, SlashCommand } from "@earendil-works/pi-tui"; +import type { StreamEvent } from "@freecode/shared"; -export interface CommandContext { +export interface MessageCreators { + createUserMessage(content: string): { component: Component; id: number }; + createAssistantMessage(content: string): { component: Component; id: number }; + createSystemMessage(content: string): { component: Component; id: number }; + createInProgressMessage(phrase: string, inputTokens?: number, outputTokens?: number, contextLimit?: number): { component: Component; id: number }; + updateInProgressMessage(id: number, phrase: string, inputTokens: number, outputTokens: number, contextLimit: number, startTime: number, turns: number): void; + insertBeforeEditor(component: Component): void; + removeMessageById(id: number): void; +} + +export interface CommandContext extends MessageCreators { showMessage(content: string): void; showModelSelector?(): void; + showResumePicker?(): void; + handleToolEvent?(event: StreamEvent): void; } export interface Command { @@ -52,4 +66,4 @@ export function registerCommand(command: Command): void { export function getCommand(name: string): Command | undefined { return commandRegistry.get(name); -} \ No newline at end of file +} diff --git a/apps/tui/src/components/index.ts b/apps/tui/src/components/index.ts new file mode 100644 index 00000000..b802c828 --- /dev/null +++ b/apps/tui/src/components/index.ts @@ -0,0 +1,177 @@ +import type { Component } from "@earendil-works/pi-tui"; +import { + addMessage, + removeMessage, + getInProgress, + subscribeToMessages, + clearMessages, + updateMessage, +} from "../state/message-store.js"; +import { createMessageComponent } from "./message-row.js"; +import type { MessageType, MessageInstance } from "./message-types.js"; +import { ToolProgressMessage } from "./tool-progress-message.js"; +import { ToolResultMessage } from "./tool-result-message.js"; + +/** + * Add a user message to the store and return the message instance + */ +export function createUserMessage(content: string): MessageInstance { + const component = createMessageComponent("user", content); + return addMessage("user", content, component); +} + +/** + * Add an assistant message to the store and return the message instance + */ +export function createAssistantMessage(content: string): MessageInstance { + const component = createMessageComponent("assistant", content); + return addMessage("assistant", content, component); +} + +/** + * Add a system message to the store and return the message instance + */ +export function createSystemMessage(content: string): MessageInstance { + const component = createMessageComponent("system", content); + return addMessage("system", content, component); +} + +/** + * Add an in-progress message to the store and return the message instance + */ +export function createInProgressMessage( + phrase: string, + inputTokens = 0, + outputTokens = 0, + contextLimit = 0, + turns = 1 +): MessageInstance { + const startTime = Date.now(); + const component = createMessageComponent("in_progress", phrase, startTime, inputTokens, outputTokens, contextLimit, turns); + return addMessage("in_progress", phrase, component); +} + +/** + * Remove a message by ID from the store + */ +export function removeMessageById(id: number): MessageInstance | undefined { + return removeMessage(id); +} + +/** + * Update an in-progress message with new token counts + */ +export function updateInProgressMessage( + id: number, + phrase: string, + inputTokens: number, + outputTokens: number, + contextLimit: number, + startTime: number, + turns: number +): MessageInstance | undefined { + const component = createMessageComponent("in_progress", phrase, startTime, inputTokens, outputTokens, contextLimit, turns); + return updateMessage(id, phrase, component); +} + +export function createToolProgressMessage( + toolCallId: string, + toolName: string, + args: Record +): MessageInstance { + const component = new ToolProgressMessage({ + toolCallId, + toolName, + args, + outputLines: [], + }); + return addMessage("tool", toolName, component); +} + +export function createToolResultMessage( + toolCallId: string, + toolName: string, + args: Record, + result: string | undefined, + success: boolean, + duration_ms?: number +): MessageInstance { + const component = new ToolResultMessage({ + toolCallId, + toolName, + args, + result, + success, + duration_ms, + }); + return addMessage("tool", toolName, component); +} + +/** + * Create a thinking message - dimmed cyan text showing LLM reasoning + */ +export function createThinkingMessage(content: string): MessageInstance { + const component = createMessageComponent("thinking", content); + return addMessage("thinking", content, component); +} + +/** + * Get the current in-progress message, if any + */ +export function getPendingInProgress(): MessageInstance | undefined { + return getInProgress(); +} + +/** + * Subscribe to message store changes + */ +export function onMessagesChange(callback: (messages: MessageInstance[]) => void): () => void { + return subscribeToMessages(callback); +} + +export { subscribeToMessages }; + +/** + * Clear all messages from the store + */ +export function clearAllMessages(): void { + clearMessages(); +} + +/** + * Load messages from a resumed session into the UI + */ +export function loadSessionMessages(messages: Array<{ + id: string + role: 'user' | 'assistant' + parts: Array<{ + type: 'text' | 'code' | 'tool' + content?: string + language?: string + tool?: { name: string; args: Record } + result?: string + }> + timestamp: number +}>): void { + for (const msg of messages) { + let content = "" + if (msg.role === "user") { + content = msg.parts.map(p => p.type === "text" ? p.content || "" : "").join("") + } else { + // assistant message - extract text content + const textParts = msg.parts.filter(p => p.type === "text") + content = textParts.map(p => p.content || "").join("\n") + } + if (content) { + const label = msg.role === "user" ? "**You:**" : "**FreeCode:**" + addMessage(msg.role, content, createMessageComponent(msg.role as MessageType, `${label} ${content}`)) + } + } +} + +// Re-export types for convenience +export type { MessageInstance, MessageType } from "./message-types.js"; + +// Re-export tool message components +export { ToolProgressMessage, type ToolProgressMessageOptions } from "./tool-progress-message.js"; +export { ToolResultMessage, type ToolResultMessageOptions } from "./tool-result-message.js"; diff --git a/apps/tui/src/components/message-registry.ts b/apps/tui/src/components/message-registry.ts new file mode 100644 index 00000000..60fed972 --- /dev/null +++ b/apps/tui/src/components/message-registry.ts @@ -0,0 +1,6 @@ +// Re-export from the new store-based architecture +// This file exists for backwards compatibility with any code that imports from it +export { addMessage, removeMessage, clearMessages, createMessageId } from "../state/message-store.js"; +export { getMessage, getMessagesByType } from "../state/message-store.js"; +import type { MessageType, MessageInstance } from "./message-types.js"; +export type { MessageType, MessageInstance }; \ No newline at end of file diff --git a/apps/tui/src/components/message-row.ts b/apps/tui/src/components/message-row.ts new file mode 100644 index 00000000..44953825 --- /dev/null +++ b/apps/tui/src/components/message-row.ts @@ -0,0 +1,192 @@ +import { Box, Markdown, Text, truncateToWidth, type Component } from "@earendil-works/pi-tui"; +import chalk from "chalk"; +import { defaultMarkdownTheme } from "../themes.js"; +import type { MessageType } from "./message-types.js"; +import { formatTokenCount } from "../utils/format-tokens.js"; + +/** + * In-progress message component with live timer and token counts. + * Renders "phrase (Xs) ↓inputTokens ↑outputTokens [████░░░░░ 50k/200k]" + * Input tokens are estimated live based on elapsed time (~1k tokens per second). + */ +class InProgressMessage implements Component { + private phrase: string; + private startTime: number; + private baseInputTokens: number; + private outputTokens: number; + private contextLimit: number; + private turns: number; + + constructor(phrase: string, startTime: number, baseInputTokens: number, outputTokens: number, contextLimit: number, turns: number) { + this.phrase = phrase; + this.startTime = startTime; + this.baseInputTokens = baseInputTokens; + this.outputTokens = outputTokens; + this.contextLimit = contextLimit; + this.turns = turns; + } + + render(width: number): string[] { + const elapsed = Math.floor((Date.now() - this.startTime) / 1000); + // Estimate: ~1k tokens per second of processing (rough approximation) + const estimatedInputTokens = this.baseInputTokens + (elapsed * 1000); + const inStr = formatTokenCount(estimatedInputTokens); + const outStr = formatTokenCount(this.outputTokens); + let display = `${chalk.yellow(this.phrase)}${chalk.dim(` (${elapsed}s)`)} ${chalk.dim(`↓${inStr}`)} ${chalk.dim(`↑${outStr}`)} ${chalk.dim(`(x${this.turns})`)}`; + + if (this.contextLimit > 0) { + const pct = Math.min(estimatedInputTokens / this.contextLimit, 1); + const barWidth = Math.min(10, Math.max(3, Math.floor(width / 12))); + const filled = Math.round(pct * barWidth); + const empty = barWidth - filled; + const bar = '█'.repeat(filled) + '░'.repeat(empty); + const current = formatTokenCount(estimatedInputTokens); + const limit = formatTokenCount(this.contextLimit); + display += ` ${chalk.dim(`[${bar} ${current}/${limit}]`)}`; + } + + // Always use a reasonable max width to ensure fit on all screens + // 80 is safe minimum, but use actual width if reasonable + // Subtract 1 to account for ANSI codes throwing off truncateToWidth + const maxWidth = Math.max(40, Math.min(width, 200)) - 1; + const truncated = truncateToWidth(display, maxWidth); + return [truncated]; + } + + invalidate(): void {} + + getMinWidth(): number { + return 10; + } + + getMinHeight(): number { + return 1; + } + + addChild(_component: Component): void {} + + destroy(): void {} +} + +// Regex to strip message prefixes (e.g., **You:** or **FreeCode:**) +const MESSAGE_PREFIX_RE = /^\*\*.*?:\*\*\s*/; + +function stripPrefix(content: string): string { + return content.replace(MESSAGE_PREFIX_RE, ""); +} + +/** + * Wrapper that truncates child component output to fit available width. + * Use this instead of hardcoding widths - respects actual terminal width. + */ +class WidthBounded implements Component { + private inner: Component; + + constructor(inner: Component) { + this.inner = inner; + } + + render(width: number): string[] { + const safeWidth = Math.max(20, width - 1); + return this.inner.render(safeWidth).map((line) => truncateToWidth(line, safeWidth)); + } + + invalidate(): void { + if (typeof this.inner.invalidate === "function") this.inner.invalidate(); + } + + addChild(_component: Component): void {} + destroy(): void {} +} + +/** + * Create a user message component — gray background with markdown content + */ +export function createUserMessageComponent(content: string): Component { + const displayContent = stripPrefix(content); + + const box = new Box(0, 0, (text: string) => { + return text.split("\n").map((line) => chalk.bgRgb(80, 80, 80)(line)).join("\n"); + }); + const markdown = new Markdown(displayContent, 1, 1, defaultMarkdownTheme); + box.addChild(markdown); + + return new WidthBounded(box); +} + +/** + * Create an assistant message component — markdown with colored output + */ +export function createAssistantMessageComponent(content: string): Component { + const displayContent = stripPrefix(content); + + const box = new Box(1, 1); + const markdown = new Markdown(displayContent, 1, 1, defaultMarkdownTheme); + box.addChild(markdown); + + return new WidthBounded(box); +} + +/** + * Create a thinking message component — dimmed cyan text with thinking prefix + */ +export function createThinkingMessageComponent(content: string): Component { + const box = new Box(1, 1) + const lines = content.split("\n") + for (const line of lines) { + const text = new Text(chalk.dim.cyan(`Thinking: ${line}`), 1, 1) + box.addChild(text) + } + return new WidthBounded(box) +} +export function createSystemMessageComponent(content: string): Component { + const displayContent = stripPrefix(content); + + const box = new Box(1, 1); + const text = new Text(chalk.dim(displayContent), 1, 1); + box.addChild(text); + + return new WidthBounded(box); +} + +/** + * Create an in-progress message component — dimmed yellow text (for "Simmering...", etc.) + */ +export function createInProgressMessageComponent(phrase: string, startTime: number, inputTokens: number, outputTokens: number, contextLimit: number, turns: number): Component { + return new InProgressMessage(phrase, startTime, inputTokens, outputTokens, contextLimit, turns); +} + +/** + * Factory function to create the appropriate component based on message type + */ +export function createMessageComponent( + type: MessageType, + content: string, + startTime?: number, + inputTokens?: number, + outputTokens?: number, + contextLimit?: number, + turns?: number +): Component { + switch (type) { + case "user": + return createUserMessageComponent(content); + case "assistant": + return createAssistantMessageComponent(content); + case "system": + return createSystemMessageComponent(content); + case "thinking": + return createThinkingMessageComponent(content); + case "in_progress": + return createInProgressMessageComponent( + content, + startTime ?? Date.now(), + inputTokens ?? 0, + outputTokens ?? 0, + contextLimit ?? 0, + turns ?? 1 + ); + default: + return createSystemMessageComponent(content); + } +} diff --git a/apps/tui/src/components/message-types.ts b/apps/tui/src/components/message-types.ts new file mode 100644 index 00000000..5a243dab --- /dev/null +++ b/apps/tui/src/components/message-types.ts @@ -0,0 +1,15 @@ +import type { Component } from "@earendil-works/pi-tui"; + +export type MessageType = "user" | "assistant" | "system" | "in_progress" | "tool" | "thinking"; + +export interface MessageInstance { + id: number; + type: MessageType; + content: string; // raw content for reference + component: Component; + timestamp: number; // also serves as startTime for in-progress messages +} + +export interface MessageStoreOptions { + maxMessages?: number; // optional cap for memory management +} diff --git a/apps/tui/src/components/resume-picker.tsx b/apps/tui/src/components/resume-picker.tsx new file mode 100644 index 00000000..0c03b56b --- /dev/null +++ b/apps/tui/src/components/resume-picker.tsx @@ -0,0 +1,74 @@ +// ============================================================================= +// Resume Picker - Session selection component for resuming previous sessions +// ============================================================================= + +import { SelectList, type SelectItem, type SelectListTheme } from "@earendil-works/pi-tui" + +// SessionMeta interface (matching the core session store) +export interface SessionMeta { + id: string + title: string + projectPath: string + provider: string + model?: string + status: 'active' | 'interrupted' | 'archived' | 'deleted' + createdAt: number + updatedAt: number + lastTurnAt: number + turnCount: number + parentId?: string + aggregatedTokenCount?: number +} + +export interface ResumePickerCallbacks { + onSelect: (sessionId: string) => void + onCancel: () => void +} + +export function createResumePicker( + sessions: SessionMeta[], + callbacks: ResumePickerCallbacks, + theme?: SelectListTheme +): { component: SelectList; cleanup: () => void } { + const items: SelectItem[] = sessions.map((s) => ({ + label: `${s.title} (${s.projectPath})`, + value: s.id, + description: `${s.turnCount} turns \u2022 ${formatRelativeTime(s.lastTurnAt)}${s.status === 'interrupted' ? ' [Interrupted]' : ''}`, + })) + + const picker = new SelectList(items, Math.min(items.length, 10), theme ?? defaultSelectListTheme) + + picker.onSelect = async (item: SelectItem) => { + callbacks.onSelect(item.value) + } + + picker.onCancel = () => { + callbacks.onCancel() + } + + return { + component: picker, + cleanup: () => { + /* nothing to cleanup */ + }, + } +} + +function formatRelativeTime(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000) + if (seconds < 60) return 'just now' + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +const defaultSelectListTheme: SelectListTheme = { + selectedPrefix: (text) => `> ${text}`, + selectedText: (text) => text, + description: (text) => text, + scrollInfo: (text) => text, + noMatch: (text) => text, +} \ No newline at end of file diff --git a/apps/tui/src/components/tool-messages.test.ts b/apps/tui/src/components/tool-messages.test.ts new file mode 100644 index 00000000..071b6344 --- /dev/null +++ b/apps/tui/src/components/tool-messages.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { clearMessages, getMessages } from "../state/message-store.js"; +import { createToolProgressMessage } from "./index.js"; + +test("createToolProgressMessage adds a renderable tool message", () => { + clearMessages(); + + const message = createToolProgressMessage("call-1", "Write", { path: "FREECODE.md" }); + + const messages = getMessages(); + assert.equal(messages.length, 1); + assert.equal(messages[0], message); + assert.equal(message.type, "tool"); + assert.match(message.component.render(80).join("\n"), /Write/); + assert.match(message.component.render(80).join("\n"), /FREECODE\.md/); +}); diff --git a/apps/tui/src/components/tool-progress-message.ts b/apps/tui/src/components/tool-progress-message.ts new file mode 100644 index 00000000..847fab2e --- /dev/null +++ b/apps/tui/src/components/tool-progress-message.ts @@ -0,0 +1,100 @@ +import { Component, TUI, Text, Box, truncateToWidth } from "@earendil-works/pi-tui"; +import chalk from "chalk"; + +export interface ToolProgressMessageOptions { + toolCallId: string; + toolName: string; + args: Record; + outputLines: string[]; +} + +// Color mapping for different tools +const TOOL_COLORS: Record string> = { + Read: (t) => chalk.blue(t), + Write: (t) => chalk.green(t), + Edit: (t) => chalk.yellow(t), + Bash: (t) => chalk.red(t), + Glob: (t) => chalk.cyan(t), + Grep: (t) => chalk.magenta(t), + Skill: (t) => chalk.white(t), + Agent: (t) => chalk.white(t), +}; + +export class ToolProgressMessage implements Component { + private toolCallId: string; + private toolName: string; + private args: Record; + private outputLines: string[]; + private tui?: TUI; + private animationFrame = 0; + private intervalId?: ReturnType; + + constructor(options: ToolProgressMessageOptions) { + this.toolCallId = options.toolCallId; + this.toolName = options.toolName; + this.args = options.args; + this.outputLines = options.outputLines; + } + + setTui(tui: TUI): void { + this.tui = tui; + // Start animation + this.intervalId = setInterval(() => { + this.animationFrame = (this.animationFrame + 1) % 4; + this.tui?.requestRender(); + }, 250); + } + + updateOutput(outputLines: string[]): void { + this.outputLines = outputLines; + } + + invalidate(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = undefined; + } + } + + render(width: number): string[] { + const colorFn = TOOL_COLORS[this.toolName] || ((t: string) => t); + const spinner = ["⠋", "⠙", "⠹", "⠸"][this.animationFrame]; + const argsStr = this.formatArgs(); + + const lines: string[] = []; + + // Header line: [spinner] ToolName (args) + const headerWidth = Math.max(20, width - 3); + let header = `${chalk.dim("[")}${chalk.yellow(spinner)}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")}`; + header = truncateToWidth(header, headerWidth); + lines.push(header); + + // Output lines with tree view - account for prefix (3 chars: "│ ") + const outputWidth = Math.max(20, width - 4); + for (const outputLine of this.outputLines.slice(-5)) { + lines.push(`${chalk.dim("│")} ${chalk.dim(truncateToWidth(outputLine, outputWidth))}`); + } + + return lines; + } + + private formatArgs(): string { + const entries = Object.entries(this.args); + if (entries.length === 0) return ""; + + const truncate = (s: string, max = 40) => s.length > max ? s.slice(0, max) + "..." : s; + + let result = entries + .map(([k, v]) => { + const vStr = typeof v === "string" ? v : JSON.stringify(v); + return `${k}: ${chalk.green(truncate(vStr))}`; + }) + .join(", "); + + // If result exceeds 100 chars, truncate the whole thing + if (result.length > 100) { + result = result.slice(0, 100) + "..."; + } + return result; + } +} diff --git a/apps/tui/src/components/tool-result-message.ts b/apps/tui/src/components/tool-result-message.ts new file mode 100644 index 00000000..0649f1f4 --- /dev/null +++ b/apps/tui/src/components/tool-result-message.ts @@ -0,0 +1,96 @@ +import { Component, Text, Box, truncateToWidth } from "@earendil-works/pi-tui"; +import chalk from "chalk"; + +export interface ToolResultMessageOptions { + toolCallId: string; + toolName: string; + args: Record; + result?: string; + success: boolean; + duration_ms?: number; +} + +// Color mapping for different tools +const TOOL_COLORS: Record string> = { + Read: (t) => chalk.blue(t), + Write: (t) => chalk.green(t), + Edit: (t) => chalk.yellow(t), + Bash: (t) => chalk.red(t), + Glob: (t) => chalk.cyan(t), + Grep: (t) => chalk.magenta(t), + Skill: (t) => chalk.white(t), + Agent: (t) => chalk.white(t), +}; + +export class ToolResultMessage implements Component { + private toolCallId: string; + private toolName: string; + private args: Record; + private result?: string; + private success: boolean; + private duration_ms?: number; + + constructor(options: ToolResultMessageOptions) { + this.toolCallId = options.toolCallId; + this.toolName = options.toolName; + this.args = options.args; + this.result = options.result; + this.success = options.success; + this.duration_ms = options.duration_ms; + } + + invalidate(): void { + // Nothing to clean up + } + + render(width: number): string[] { + const colorFn = TOOL_COLORS[this.toolName] || ((t: string) => t); + const statusIcon = this.success ? chalk.green("✓") : chalk.red("✗"); + const argsStr = this.formatArgs(); + const duration = this.duration_ms ? `(${this.duration_ms}ms)` : ""; + + const lines: string[] = []; + + // Header line: [✓/✗] ToolName (args) (duration) - truncated to safe width + const safeWidth = Math.max(20, width - 1); + let header = `${chalk.dim("[")}${statusIcon}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")} ${chalk.dim(duration)}`; + header = truncateToWidth(header, safeWidth); + lines.push(header); + + // Result with tree view character - account for prefix + const resultWidth = safeWidth - 3; // 3 for " ⎿ " + if (this.result) { + const truncatedResult = truncateToWidth(this.result, resultWidth); + lines.push(`${chalk.dim("⎿")} ${chalk.dim(truncatedResult)}`); + } else if (this.success) { + lines.push(`${chalk.dim("⎿")} ${chalk.dim("(no output)")}`); + } + + return lines; + } + + private formatArgs(): string { + const entries = Object.entries(this.args); + if (entries.length === 0) return ""; + + const truncate = (s: string, max = 40) => s.length > max ? s.slice(0, max) + "..." : s; + + let result = entries + .map(([k, v]) => { + const vStr = typeof v === "string" ? v : JSON.stringify(v); + return `${k}: ${chalk.green(truncate(vStr))}`; + }) + .join(", "); + + // If result exceeds 100 chars, truncate the whole thing + if (result.length > 100) { + result = result.slice(0, 100) + "..."; + } + return result; + } + + private truncateResult(result: string, maxLen: number): string { + if (result.length <= maxLen) return result; + return result.slice(0, maxLen) + "..."; + } +} diff --git a/apps/tui/src/components/virtual-message-list.ts b/apps/tui/src/components/virtual-message-list.ts new file mode 100644 index 00000000..b34d144e --- /dev/null +++ b/apps/tui/src/components/virtual-message-list.ts @@ -0,0 +1,115 @@ +import { type Component, type TUI } from "@earendil-works/pi-tui"; +import type { MessageInstance } from "./message-types.js"; +import { subscribeToMessages, getMessages } from "../state/message-store.js"; + +/** + * VirtualMessageList — scrollable message history that implements pi-tui's Component interface. + * + * Subscribes to MessageStore and re-renders when messages change. + * Only renders the last N messages to avoid memory/performance issues. + */ +export class VirtualMessageList implements Component { + private messages: MessageInstance[] = []; + private maxVisible: number; + private unsubscribe: (() => void) | null = null; + private invalidated = false; + private tui: TUI | null = null; + private tickInterval: ReturnType | null = null; + + constructor(maxVisible = 100) { + this.maxVisible = maxVisible; + // Subscribe to message store changes + this.unsubscribe = subscribeToMessages((msgs) => { + this.messages = msgs; + this.invalidate(); + this.scheduleTick(); + }); + // Initialize with current messages + this.messages = getMessages(); + } + + /** + * Set the TUI instance for triggering renders + */ + setTui(tui: TUI): void { + this.tui = tui; + } + + /** + * Mark the component as needing re-render + */ + invalidate(): void { + this.invalidated = true; + if (this.tui) { + this.tui.requestRender(); + } + } + + /** + * Schedule a tick interval if an in-progress message exists + */ + private scheduleTick(): void { + const hasInProgress = this.messages.some((m) => m.type === "in_progress"); + if (!hasInProgress) return; + + if (this.tickInterval) return; + + this.tickInterval = setInterval(() => { + if (this.tickInterval) { + clearInterval(this.tickInterval); + this.tickInterval = null; + } + this.invalidate(); + // Reschedule if in-progress message still exists + this.scheduleTick(); + }, 1000); + } + + /** + * Render the message list. + * In-progress message always stays at the bottom; all other messages render above it. + */ + render(width: number): string[] { + this.invalidated = false; + + const lines: string[] = []; + + // Separate in-progress message from others + const regularMessages = this.messages.filter((m) => m.type !== "in_progress"); + const inProgressMessage = this.messages.find((m) => m.type === "in_progress"); + + // Render regular messages first (older messages, then newer ones) + const visibleMessages = regularMessages.slice(-this.maxVisible); + + for (const msg of visibleMessages) { + const msgLines = msg.component.render(width); + for (const line of msgLines) { + lines.push(line); + } + } + + // Render in-progress message at the very bottom (if exists) + if (inProgressMessage) { + const inProgressLines = inProgressMessage.component.render(width); + for (const line of inProgressLines) { + lines.push(line); + } + } + + return lines; + } + + /** + * Cleanup subscription and tick interval when component is destroyed + */ + destroy(): void { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } + if (this.tickInterval) { + clearInterval(this.tickInterval); + this.tickInterval = null; + } + } +} diff --git a/apps/tui/src/index.ts b/apps/tui/src/index.ts index b9523127..46d06f4c 100644 --- a/apps/tui/src/index.ts +++ b/apps/tui/src/index.ts @@ -1,17 +1,21 @@ #!/usr/bin/env node -import { ProcessTerminal, TUI, Key, matchesKey, CombinedAutocompleteProvider, SelectList, Box, type Component, type SelectItem, type SelectListTheme } from "@earendil-works/pi-tui"; +import { ProcessTerminal, TUI, Key, matchesKey, CombinedAutocompleteProvider, SelectList, type SelectItem, type SelectListTheme } from "@earendil-works/pi-tui"; import { commandRegistry } from "./commands/index.js"; import { registerBuiltInCommands } from "./commands/built-in.js"; import { Editor } from "@earendil-works/pi-tui"; -import { Markdown } from "@earendil-works/pi-tui"; import { Text } from "@earendil-works/pi-tui"; import chalk from "chalk"; -import { defaultEditorTheme, defaultMarkdownTheme } from "./themes.js"; +import { defaultEditorTheme } from "./themes.js"; import { logoLines, logoTagline } from "./assets/logo.js"; +import { getRandomElapsedPhrase, getRandomInProgressPhrase } from "./utils/elapsed-phrases.js"; +import { getModelContextLimit } from "./utils/model-limits.js"; +import { formatTokenCount } from "./utils/format-tokens.js"; import { startCli, sessionStart, - sessionSend, + sessionSendStreaming, + sessionList, + sessionResume, listProviders, listModels, getCurrentModel, @@ -20,13 +24,31 @@ import { type SessionInfo, type ModelInfo } from "./ipc/client.js"; +import { + createUserMessage, + createAssistantMessage, + createSystemMessage, + createInProgressMessage, + removeMessageById, + updateInProgressMessage, + subscribeToMessages, + onMessagesChange, + createToolProgressMessage, + createToolResultMessage, + createThinkingMessage, + ToolProgressMessage, + type MessageInstance, + loadSessionMessages, +} from "./components/index.js"; +import { VirtualMessageList } from "./components/virtual-message-list.js"; +import { createResumePicker } from "./components/resume-picker.js"; +import type { StreamEvent } from "@freecode/shared"; registerBuiltInCommands(); let tui: TUI; let messageCount = 0; -// Session state (used across editor.onSubmit and model selectors) let currentSession: SessionInfo | null = null; let currentProvider = ""; let currentModel = ""; @@ -34,9 +56,13 @@ let currentModel = ""; let modelDisplay: Text; let modelSelector: SelectList | null = null; let providerSelector: SelectList | null = null; +let resumeSelector: SelectList | null = null; let apiKeyEditor: Editor | null = null; let apiKeyPrompt: Text | null = null; -let modelDisplayIdx = -1; // Track index of model display in children +let modelDisplayIdx = -1; + +let messageList: VirtualMessageList; +const toolMessageComponents = new Map }>(); const terminal = new ProcessTerminal(); tui = new TUI(terminal); @@ -49,7 +75,13 @@ Type your messages below. Press Ctrl+C to exit.`; tui.addChild(new Text(welcomeText)); +// Create message list and add to tui BEFORE editor +messageList = new VirtualMessageList(200); +messageList.setTui(tui); +tui.addChild(messageList); + const editor = new Editor(tui, defaultEditorTheme); +editor.setText("❯ "); const autocompleteProvider = new CombinedAutocompleteProvider( commandRegistry.getSlashCommands(), @@ -75,14 +107,12 @@ const defaultSelectListTheme: SelectListTheme = { }; function updateModelDisplay(): void { - // Always update the display text const displayText = currentProvider && currentModel ? `${currentProvider}/${currentModel}` : "not selected"; modelDisplay = new Text(chalk.dim(`Model: ${displayText}`)); - // Use stored index to update model display if (modelDisplayIdx >= 0 && modelDisplayIdx < tui.children.length) { tui.children[modelDisplayIdx] = modelDisplay; } @@ -91,34 +121,7 @@ function updateModelDisplay(): void { } function showMessage(content: string): void { - // Check if this is a user message (contains **You with chalk styling) - // Content looks like: **You:** message - const isUserMessage = content.includes("**") && content.includes("You") && content.includes(":"); - - let msg: Component; - let displayContent = content; - if (isUserMessage) { - // Remove the **You:** prefix for display but keep background - displayContent = content.replace(/\*\*[^\:]+:\*\*\s*/, ""); - const box = new Box(0, 0, (text: string) => { - return text.split('\n').map(line => chalk.bgRgb(80, 80, 80)(line)).join('\n'); - }); - msg = new Markdown(displayContent, 1, 1, defaultMarkdownTheme); - box.addChild(msg); - msg = box; - } else if (content.includes("**") && content.includes("FreeCode") && content.includes(":")) { - // Remove the **FreeCode:** prefix for assistant messages too - displayContent = content.replace(/\*\*[^\:]+:\*\*\s*/, ""); - msg = new Markdown(displayContent, 1, 1, defaultMarkdownTheme); - } else { - msg = new Markdown(content, 1, 1, defaultMarkdownTheme); - } - - const children = tui.children; - // Insert after welcome (index 0), before editor and model display - const editorIdx = children.indexOf(editor); - children.splice(editorIdx, 0, msg); - tui.requestRender(); + createSystemMessage(content); } function removeSelector(selector: SelectList | null): void { @@ -139,6 +142,12 @@ function hideModelSelector(): void { tui.requestRender(); } +function hideResumeSelector(): void { + removeSelector(resumeSelector); + resumeSelector = null; + tui.requestRender(); +} + function removeApiKeyEditor(): void { if (apiKeyEditor) { const idx = tui.children.indexOf(apiKeyEditor); @@ -219,15 +228,12 @@ async function showModelSelector(providerId: string): Promise { currentProvider = providerId; currentModel = item.value; - // Check if provider has API key const providers = await listProviders(); const providerInfo = (providers as any[]).find((p: any) => p.id === providerId); if (providerInfo && !providerInfo.hasApiKey) { - // Show API key input await showApiKeyInput(providerId, item.value); } else { - // Already has API key, just save current model await setCurrentModel(providerId, item.value); updateModelDisplay(); showMessage(`**Model changed to:** ${providerId}/${item.value}`); @@ -256,13 +262,10 @@ async function showApiKeyInput(providerId: string, modelId: string): Promise { const apiKey = value.trim(); @@ -271,7 +274,6 @@ async function showApiKeyInput(providerId: string, modelId: string): Promise { + hideResumeSelector(); + hideModelSelector(); + + try { + const sessions = await sessionList({}); + + if (sessions.length === 0) { + showMessage("**No sessions found.**"); + return; + } + + // Sort by lastTurnAt descending (most recent first) + sessions.sort((a, b) => b.lastTurnAt - a.lastTurnAt); + + const { component: picker } = createResumePicker( + sessions, + { + onSelect: async (sessionId: string) => { + hideResumeSelector(); + showMessage(`**Resuming session...**`); + try { + const result = await sessionResume(sessionId); + currentSession = { sessionId: result.sessionId }; + // Load messages from the resumed session into the UI + if (result.messages && result.messages.length > 0) { + loadSessionMessages(result.messages); + } + showMessage(`**Session resumed with ${result.messages?.length || 0} messages.**`); + } catch (err) { + showMessage(`**Error resuming session:** ${err}`); + } + tui.setFocus(editor); + tui.requestRender(); + }, + onCancel: () => { + hideResumeSelector(); + tui.setFocus(editor); + tui.requestRender(); + }, + }, + defaultSelectListTheme + ); + + resumeSelector = picker; + + const editorIdx = tui.children.indexOf(editor); + tui.children.splice(editorIdx + 1, 0, resumeSelector); + tui.setFocus(resumeSelector); + tui.requestRender(); + } catch (err) { + showMessage(`**Error loading sessions:** ${err}`); + } +} + async function loadCurrentModel(): Promise { startCli(); - // Wait for CLI to initialize await new Promise(resolve => setTimeout(resolve, 800)); try { @@ -304,6 +359,53 @@ async function loadCurrentModel(): Promise { } } +function handleToolEvent(event: StreamEvent): void { + switch (event.type) { + case "tool_start": { + const toolMsg = createToolProgressMessage(event.toolCallId, event.toolName, event.args); + const progressComponent = toolMsg.component as ToolProgressMessage; + progressComponent.setTui(tui); + toolMessageComponents.set(event.toolCallId, { + progress: progressComponent, + id: toolMsg.id, + args: event.args, + }); + break; + } + case "tool_output": { + const entry = toolMessageComponents.get(event.toolCallId); + if (entry) { + entry.progress.updateOutput(event.content.split('\n').slice(-5)); + } + tui.requestRender(); + break; + } + case "tool_complete": { + const entry = toolMessageComponents.get(event.toolCallId); + if (entry) { + entry.progress.invalidate(); + removeMessageById(entry.id); + toolMessageComponents.delete(event.toolCallId); + } + createToolResultMessage( + event.toolCallId, + event.toolName, + entry?.args ?? {}, + event.result, + event.success, + event.duration_ms + ); + break; + } + case "thinking": { + // Create or update thinking message - dimmed cyan stream + const thinkingComponent = createThinkingMessage(event.content); + tui.requestRender(); + break; + } + } +} + editor.onSubmit = async (value: string) => { const trimmed = value.trim(); if (!trimmed) return; @@ -316,7 +418,19 @@ editor.onSubmit = async (value: string) => { if (commandName) { const command = commandRegistry.get(commandName); if (command) { - command.execute(args, { showMessage, showModelSelector: showProviderSelector }); + command.execute(args, { + showMessage, + showModelSelector: showProviderSelector, + showResumePicker: showResumePicker, + createUserMessage: (content: string) => createUserMessage(content), + createAssistantMessage: (content: string) => createAssistantMessage(content), + createSystemMessage: (content: string) => createSystemMessage(content), + createInProgressMessage: (phrase: string, inputTokens = 0, outputTokens = 0, contextLimit = 0) => createInProgressMessage(phrase, inputTokens, outputTokens, contextLimit), + updateInProgressMessage: (id: number, phrase: string, inputTokens: number, outputTokens: number, contextLimit: number, startTime: number, turns: number) => updateInProgressMessage(id, phrase, inputTokens, outputTokens, contextLimit, startTime, turns), + insertBeforeEditor: () => { /* no-op - messages go through store now */ }, + removeMessageById: (id: number) => removeMessageById(id), + handleToolEvent, + }); return; } else { showMessage(`**Error:** Unknown command: /${commandName}. Type /help for available commands.`); @@ -326,22 +440,18 @@ editor.onSubmit = async (value: string) => { } messageCount++; - showMessage(`**${chalk.red("You")}:** ${trimmed}`); - showMessage("Processing..."); - // Track start time for elapsed display - const startTime = Date.now(); + // Create messages through the store - VirtualMessageList handles rendering + createUserMessage(`**${chalk.red("You")}:** ${trimmed}`); + const inProgressMsg = createInProgressMessage(getRandomInProgressPhrase()); + - // Ensure CLI is running startCli(); - // Wait for CLI to start if needed await new Promise(resolve => setTimeout(resolve, 500)); - // Get or create session if (!currentSession) { try { - // Get current model from config if not set if (!currentProvider) { try { const current = await getCurrentModel(); @@ -359,22 +469,42 @@ editor.onSubmit = async (value: string) => { provider: currentProvider || "minimax", }) as SessionInfo; } catch (error) { + removeMessageById(inProgressMsg.id); showMessage(`**Error:** Failed to start session: ${error instanceof Error ? error.message : String(error)}`); return; } } try { - const result = await sessionSend(currentSession.sessionId, trimmed) as { - success: boolean; - message?: string; - content?: string; - turnCount?: number; - iterationCount?: number; - }; + const result = await sessionSendStreaming( + currentSession.sessionId, + trimmed, + undefined, + (event: StreamEvent) => { + handleToolEvent(event); + } + ); + + // Update in-progress message with token counts from result + const contextLimit = getModelContextLimit(`${currentProvider}/${currentModel}`); + updateInProgressMessage( + inProgressMsg.id, + getRandomInProgressPhrase(), + result.usage?.inputTokens ?? 0, + result.usage?.outputTokens ?? 0, + contextLimit, + inProgressMsg.timestamp, + result.turnCount || 1 + ); - // Calculate elapsed time - const elapsed = Date.now() - startTime; + // Brief pause so user can see final token state before it disappears + await new Promise(resolve => setTimeout(resolve, 500)); + + // Remove in-progress message now that response has arrived + removeMessageById(inProgressMsg.id); + + + const elapsed = Date.now() - inProgressMsg.timestamp; const seconds = Math.floor(elapsed / 1000); const minutes = Math.floor(seconds / 60); const secs = seconds % 60; @@ -382,18 +512,27 @@ editor.onSubmit = async (value: string) => { if (result.success) { const response = result.content || result.message; - showMessage(`**FreeCode:** ${response || "Done!"}`); - showMessage(chalk.dim(`Baked for ${timeStr}`)); + createAssistantMessage(`**FreeCode:** ${response || "Done!"}`); + const inTokens = result.usage?.inputTokens ?? 0; + const outTokens = result.usage?.outputTokens ?? 0; + const contextLimit = getModelContextLimit(`${currentProvider}/${currentModel}`); + let tokenInfo = `↓${formatTokenCount(inTokens)} ↑${formatTokenCount(outTokens)}`; + if (contextLimit > 0) { + tokenInfo += ` [${formatTokenCount(inTokens)}/${formatTokenCount(contextLimit)}]`; + } + createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr} ${tokenInfo} (x${result.turnCount || 1})`); } else { - showMessage(`**FreeCode:** ${result.message || "Unknown error"}`); - showMessage(chalk.dim(`Baked for ${timeStr}`)); + createSystemMessage(`**Error:** ${result.message || "Unknown error"}`); + createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr}`); } } catch (error) { + removeMessageById(inProgressMsg.id); showMessage(`**Error:** ${error instanceof Error ? error.message : String(error)}`); + } finally { + editor.setText("❯ "); } }; -// Handle Ctrl+C for clean exit from keyboard tui.addInputListener((data) => { if (matchesKey(data, Key.ctrl("c"))) { if (tui) { @@ -404,11 +543,28 @@ tui.addInputListener((data) => { return undefined; }); -// Load current model from config on startup loadCurrentModel(); -// Stop sound on exit +// Check for interrupted sessions on startup +async function checkForInterruptedSession(): Promise { + try { + const sessions = await sessionList({ status: 'interrupted' }); + if (sessions.length > 0) { + showMessage("**Interrupted session detected. Type /resume to continue or start a new session.**"); + } + } catch { + // Ignore - session might not be available yet + } +} + +checkForInterruptedSession(); + +// Wire stderr to system messages via store +startCli((stderrMsg) => { + createSystemMessage(stderrMsg); +}); + const freecodeModule = await import("./commands/freecode/index.js"); process.on("exit", () => freecodeModule.stopSound?.()); -tui.start(); \ No newline at end of file +tui.start(); diff --git a/apps/tui/src/ipc/client.ts b/apps/tui/src/ipc/client.ts index dcf06a1d..f2fe6f5b 100644 --- a/apps/tui/src/ipc/client.ts +++ b/apps/tui/src/ipc/client.ts @@ -13,6 +13,7 @@ import type { ToolResult, SessionConfig, ProviderInfo, + StreamEvent, } from "@freecode/shared"; // ============================================================================= @@ -26,6 +27,7 @@ let pendingRequests = new Map< number | string, { resolve: (value: unknown) => void; reject: (error: Error) => void } >(); +let onStreamEvent: ((event: StreamEvent) => void) | null = null; function generateId(): number { return ++requestId; @@ -34,10 +36,16 @@ function generateId(): number { function parseResponse(data: string): JsonRpcResponse[] { const responses: JsonRpcResponse[] = []; const lines = data.split("\n"); + messageBuffer = lines.pop() ?? ""; for (const line of lines) { if (!line.trim()) continue; try { - responses.push(JSON.parse(line) as JsonRpcResponse); + const parsed = JSON.parse(line); + if (parsed.type && !parsed.jsonrpc && onStreamEvent) { + onStreamEvent(parsed as StreamEvent); + continue; + } + responses.push(parsed as JsonRpcResponse); } catch { // Skip malformed lines } @@ -45,7 +53,7 @@ function parseResponse(data: string): JsonRpcResponse[] { return responses; } -export function startCli(): void { +export function startCli(onStderr?: (msg: string) => void): void { if (cliProcess) return; // Project root is the monorepo root (where pnpm-workspace.yaml lives) @@ -67,13 +75,12 @@ export function startCli(): void { cliProcess.stdout?.setEncoding("utf-8"); cliProcess.stderr?.on("data", (data) => { - console.error("[CLI stderr]", data.toString()); + onStderr?.(data.toString().trim()); }); cliProcess.stdout?.on("data", (data: string) => { messageBuffer += data; const responses = parseResponse(messageBuffer); - messageBuffer = ""; for (const response of responses) { const pending = pendingRequests.get(response.id); @@ -149,8 +156,38 @@ export async function sessionStop(sessionId: string): Promise { await sendRequest("session.stop", { sessionId }); } -export async function sessionSend(sessionId: string, message: string, model?: string): Promise { - return await sendRequest("session.send", { sessionId, message, model }); +export interface SessionSendResult { + success: boolean; + message?: string; + content?: string; + turnCount?: number; + iterationCount?: number; + usage?: { inputTokens: number; outputTokens: number }; +} + +export async function sessionSend(sessionId: string, message: string, model?: string): Promise { + return await sendRequest("session.send", { sessionId, message, model }) as SessionSendResult; +} + +export async function sessionSendStreaming( + sessionId: string, + message: string, + model: string | undefined, + onEvent: (event: StreamEvent) => void +): Promise { + return new Promise((resolve, reject) => { + if (!cliProcess || !cliProcess.stdin) { + reject(new Error("CLI not running")); + return; + } + + onStreamEvent = onEvent; + + const id = generateId(); + const request: JsonRpcRequest = { jsonrpc: "2.0", id, method: "session.send", params: { sessionId, message, model } }; + pendingRequests.set(id, { resolve: resolve as (value: unknown) => void, reject }); + cliProcess.stdin.write(JSON.stringify(request) + "\n"); + }); } // ============================================================================= @@ -190,4 +227,50 @@ export async function setCurrentModel(provider: string, model: string): Promise< export async function getCurrentModel(): Promise<{ provider: string; model: string } | undefined> { return (await sendRequest("config.getCurrentModel")) as { provider: string; model: string } | undefined -} \ No newline at end of file +} + +// ============================================================================= +// Session List/Resume Methods +// ============================================================================= + +export interface SessionMeta { + id: string + title: string + projectPath: string + provider: string + model?: string + status: 'active' | 'interrupted' | 'archived' | 'deleted' + createdAt: number + updatedAt: number + lastTurnAt: number + turnCount: number + parentId?: string + aggregatedTokenCount?: number +} + +export interface SessionFilter { + status?: 'active' | 'interrupted' | 'archived' | 'deleted' + projectPath?: string +} + +export async function sessionList(filter?: SessionFilter): Promise { + return (await sendRequest("session.list", filter as Record)) as SessionMeta[] +} + +export async function sessionResume(sessionId: string): Promise<{ sessionId: string; messages?: SerializedMessage[] }> { + return (await sendRequest("session.resume", { sessionId })) as { sessionId: string; messages?: SerializedMessage[] } +} + +export interface SerializedMessage { + id: string + role: 'user' | 'assistant' + parts: Array<{ + type: 'text' | 'code' | 'tool' + content?: string + language?: string + tool?: { name: string; args: Record } + result?: string + }> + timestamp: number + interrupted?: boolean +} diff --git a/apps/tui/src/state/message-store.ts b/apps/tui/src/state/message-store.ts new file mode 100644 index 00000000..4acfce85 --- /dev/null +++ b/apps/tui/src/state/message-store.ts @@ -0,0 +1,174 @@ +import type { Component } from "@earendil-works/pi-tui"; +import type { MessageInstance, MessageType, MessageStoreOptions } from "../components/message-types.js"; + +type Subscriber = (messages: MessageInstance[]) => void; + +class MessageStoreImpl { + private messages: MessageInstance[] = []; + private subscribers = new Set(); + private idCounter = 0; + private maxMessages: number | undefined; + + constructor(options: MessageStoreOptions = {}) { + this.maxMessages = options.maxMessages; + } + + private generateId(): number { + return ++this.idCounter; + } + + /** + * Add a new message to the store + */ + add(type: MessageType, content: string, component: Component): MessageInstance { + const message: MessageInstance = { + id: this.generateId(), + type, + content, + component, + timestamp: Date.now(), + }; + + this.messages.push(message); + + // Cap memory usage if limit set + if (this.maxMessages && this.messages.length > this.maxMessages) { + this.messages = this.messages.slice(-this.maxMessages); + } + + this.notify(); + return message; + } + + /** + * Remove a message by its ID + */ + remove(id: number): MessageInstance | undefined { + const index = this.messages.findIndex((m) => m.id === id); + if (index === -1) return undefined; + + const removed = this.messages.splice(index, 1)[0]; + this.notify(); + return removed; + } + + /** + * Update a message's content and component by ID + */ + update(id: number, content: string, component: Component): MessageInstance | undefined { + const message = this.messages.find((m) => m.id === id); + if (!message) return undefined; + + message.content = content; + message.component = component; + this.notify(); + return message; + } + + /** + * Get all messages + */ + getMessages(): MessageInstance[] { + return [...this.messages]; + } + + /** + * Get messages filtered by type + */ + getByType(type: MessageType): MessageInstance[] { + return this.messages.filter((m) => m.type === type); + } + + /** + * Get the most recent in-progress message, if any + */ + getInProgress(): MessageInstance | undefined { + return this.messages.find((m) => m.type === "in_progress"); + } + + /** + * Remove all messages of a specific type + */ + removeByType(type: MessageType): MessageInstance[] { + const removed = this.messages.filter((m) => m.type === type); + this.messages = this.messages.filter((m) => m.type !== type); + if (removed.length > 0) { + this.notify(); + } + return removed; + } + + /** + * Clear all messages + */ + clear(): void { + this.messages = []; + this.notify(); + } + + /** + * Subscribe to message store changes + * Returns an unsubscribe function + */ + subscribe(callback: Subscriber): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + /** + * Internal notification to all subscribers + */ + private notify(): void { + const snapshot = this.getMessages(); + for (const callback of this.subscribers) { + callback(snapshot); + } + } +} + +// Singleton instance +export const messageStore = new MessageStoreImpl(); + +// Helper functions that delegate to the store +export function addMessage(type: MessageType, content: string, component: Component): MessageInstance { + return messageStore.add(type, content, component); +} + +export function removeMessage(id: number): MessageInstance | undefined { + return messageStore.remove(id); +} + +export function getMessages(): MessageInstance[] { + return messageStore.getMessages(); +} + +export function getInProgress(): MessageInstance | undefined { + return messageStore.getInProgress(); +} + +export function updateMessage(id: number, content: string, component: Component): MessageInstance | undefined { + return messageStore.update(id, content, component); +} + +export function clearMessages(): void { + messageStore.clear(); +} + +export function subscribeToMessages(callback: Subscriber): () => void { + return messageStore.subscribe(callback); +} + +let messageIdCounter = 0; +export function createMessageId(): number { + return ++messageIdCounter; +} + +export function getMessage(id: number): MessageInstance | undefined { + return messageStore.getMessages().find((m) => m.id === id); +} + +export function getMessagesByType(type: MessageType): MessageInstance[] { + return messageStore.getByType(type); +} \ No newline at end of file diff --git a/apps/tui/src/utils/elapsed-phrases.ts b/apps/tui/src/utils/elapsed-phrases.ts new file mode 100644 index 00000000..2ce8c095 --- /dev/null +++ b/apps/tui/src/utils/elapsed-phrases.ts @@ -0,0 +1,44 @@ +// Random elapsed-time phrases for completion messages +const elapsedPhrases = [ + "Baked", "Cooked", "Completed", "Finished", "Processed", + "Executed", "Generated", "Built", "Produced", "Shipped", + "Ready", "All set", "Wrapped up", "Done", +]; + +export function getRandomElapsedPhrase(): string { + return elapsedPhrases[Math.floor(Math.random() * elapsedPhrases.length)]; +} + +// Random in-progress phrases for processing messages +const inProgressPhrases = [ + // Cooking Theme + "Cooking up a solution...", "Baking your answer...", "Brewing something useful...", + "Simmering...", "Baking...", "Preheating...", "Whisking ideas...", + "Mixing ingredients...", "Seasoning the solution...", "Stirring the pot...", + "Letting it marinate...", "Chef at work...", "Preparing the recipe...", + "Plating the result...", + // Workshop / Builder Theme + "Forging...", "Crafting...", "Assembling...", "Building...", + "Hammering it out...", "Refining...", "Shaping the solution...", + "Putting the pieces together...", "In the workshop...", "Polishing the details...", + // Factory Theme + "Manufacturing...", "Fabricating...", "Producing...", "Calibrating...", + "Running the assembly line...", "Finalizing production...", + // Thinking Theme + "Brewing ideas...", "Connecting the dots...", "Mapping it out...", + "Crunching thoughts...", "Exploring possibilities...", "Thinking...", + "Reasoning...", "Working it through...", "Solving...", "Analyzing...", + // Magic / Alchemy Theme + "Conjuring...", "Brewing a potion...", "Casting spells...", "Mixing elixirs...", + "Performing alchemy...", "Summoning answers...", "Enchanting the output...", + // Fun Agent Messages + "Cooking up a solution...", "Baking your answer...", "Brewing something useful...", + "Crafting the perfect response...", "Sharpening the code...", + "Assembling the pieces...", "Taming the bugs...", + "Teaching electrons new tricks...", "Negotiating with the compiler...", + "Convincing the code to cooperate...", +]; + +export function getRandomInProgressPhrase(): string { + return inProgressPhrases[Math.floor(Math.random() * inProgressPhrases.length)]; +} \ No newline at end of file diff --git a/apps/tui/src/utils/format-tokens.ts b/apps/tui/src/utils/format-tokens.ts new file mode 100644 index 00000000..a976c793 --- /dev/null +++ b/apps/tui/src/utils/format-tokens.ts @@ -0,0 +1,9 @@ +/** + * Format a token count number into a human-readable string. + * e.g., 12300 → "12.3k", 1500000 → "1.5M" + */ +export function formatTokenCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return n.toString(); +} diff --git a/apps/tui/src/utils/model-limits.ts b/apps/tui/src/utils/model-limits.ts new file mode 100644 index 00000000..36a53735 --- /dev/null +++ b/apps/tui/src/utils/model-limits.ts @@ -0,0 +1,55 @@ +/** + * Model context and output limits. + * Values are token counts for the model's context window and max output. + */ + +export interface ModelLimits { + context: number; + output: number; +} + +// Known model limits by model ID (provider/model-name format) +export const MODEL_LIMITS: Record = { + // MiniMax models + "minimax/MiniMax-M2": { context: 1_000_000, output: 8192 }, + "minimax/minimax-text-01": { context: 1_000_000, output: 8192 }, + "minimax/minimax-text-01-prefill": { context: 1_000_000, output: 8192 }, + "minimax/agent-minimax-text-01": { context: 1_000_000, output: 8192 }, + + // OpenAI models + "openai/gpt-4o": { context: 128_000, output: 16384 }, + "openai/gpt-4o-mini": { context: 128_000, output: 16384 }, + "openai/gpt-4-turbo": { context: 128_000, output: 4096 }, + "openai/gpt-3.5-turbo": { context: 16_385, output: 4096 }, + + // Anthropic models + "anthropic/claude-sonnet-4-20250514": { context: 200_000, output: 8192 }, + "anthropic/claude-opus-4-20250514": { context: 200_000, output: 8192 }, + "anthropic/claude-3-5-sonnet-latest": { context: 200_000, output: 8192 }, + "anthropic/claude-3-opus-latest": { context: 200_000, output: 4096 }, + "anthropic/claude-3-sonnet-latest": { context: 200_000, output: 4096 }, + "anthropic/claude-3-haiku-latest": { context: 200_000, output: 4096 }, + + // Google models + "google/gemini-2.0-flash": { context: 1_000_000, output: 8192 }, + "google/gemini-1.5-pro": { context: 2_000_000, output: 8192 }, + "google/gemini-1.5-flash": { context: 1_000_000, output: 8192 }, + + // GitHub Copilot + "github-copilot/gpt-4o": { context: 128_000, output: 4096 }, + "github-copilot/gpt-4o-mini": { context: 128_000, output: 4096 }, +}; + +/** + * Get context limit for a model. Returns 0 if unknown. + */ +export function getModelContextLimit(modelId: string): number { + return MODEL_LIMITS[modelId]?.context ?? 0; +} + +/** + * Get output limit for a model. Returns 0 if unknown. + */ +export function getModelOutputLimit(modelId: string): number { + return MODEL_LIMITS[modelId]?.output ?? 0; +} diff --git a/docs/superpowers/plans/2026-06-01-tools-ui-streaming.md b/docs/superpowers/plans/2026-06-01-tools-ui-streaming.md new file mode 100644 index 00000000..b86a838f --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-tools-ui-streaming.md @@ -0,0 +1,789 @@ +# Tool Progress Streaming Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Display live tool progress in TUI when agent uses tools (Read, Write, Bash, etc.) + +**Architecture:** CLI streams JSON events (tool_start/tool_output/tool_complete) to TUI as tools execute. TUI parses events and renders tool progress components with live output. + +**Tech Stack:** TypeScript, pi-tui components, JSON-RPC over stdin/stdout + +--- + +## File Structure + +``` +packages/shared/src/ipc/protocol.ts - Add StreamEvent types +apps/core/src/agent/types.ts - Add onToolEvent to RunOptions +apps/core/src/agent/loop.ts - Emit tool events during execution +apps/core/src/server.ts - Stream events to stdout during session.send +apps/tui/src/ipc/client.ts - Add sessionSendStreaming with event parsing +apps/tui/src/state/message-store.ts - Add tool message state management +apps/tui/src/components/tool-progress-message.ts - NEW: in-progress display +apps/tui/src/components/tool-result-message.ts - NEW: completed/error display +apps/tui/src/components/index.ts - Export new components +apps/tui/src/index.ts - Wire streaming into message flow +``` + +--- + +## Task 1: Add StreamEvent Types to Protocol + +**Files:** +- Modify: `packages/shared/src/ipc/protocol.ts` + +- [ ] **Step 1: Add StreamEvent union type** + +```typescript +// Add after existing StreamResponse type (around line 28) + +export type StreamEvent = + | { type: "tool_start"; toolCallId: string; toolName: string; args: Record } + | { type: "tool_output"; toolCallId: string; content: string } + | { type: "tool_complete"; toolCallId: string; toolName: string; result: string; success: boolean; duration_ms?: number } + | { type: "text"; content: string } + | { type: "done"; content: string } + | { type: "error"; content: string }; +``` + +- [ ] **Step 2: Verify types compile** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p packages/shared/tsconfig.json` +Expected: No errors + +--- + +## Task 2: Add onToolEvent Callback to AgentLoop + +**Files:** +- Modify: `apps/core/src/agent/types.ts:174-180` (UserInput interface area) +- Modify: `apps/core/src/agent/loop.ts:68` (run method signature) + +- [ ] **Step 1: Add onToolEvent to UserInput type** + +Find the UserInput interface (around line 174) and add: + +```typescript +export interface UserInput { + prompt: string + sessionId: string + provider: string + model?: string + projectPath: string + onToolEvent?: (event: StreamEvent) => void // NEW +} +``` + +- [ ] **Step 2: Modify AgentLoop.run() to accept and pass onToolEvent** + +In `loop.ts` line 68, modify: + +```typescript +async run(input: UserInput): Promise { + // ... existing code ... + // Pass onToolEvent to executeTool +} +``` + +Add a class property to store onToolEvent: + +```typescript +private onToolEvent: ((event: StreamEvent) => void) | undefined; + +constructor(sessionId: string, config?: ...) { + // ... existing constructor code ... +} + +async run(input: UserInput): Promise { + this.onToolEvent = input.onToolEvent; // NEW - store callback + // ... rest of run method +} +``` + +- [ ] **Step 3: Update executeTool to emit tool_start event** + +In `loop.ts` around line 499 (`executeTool` method), add tool_start emission after the PreToolUse hook check and before tool execution: + +```typescript +private async executeTool(toolCall: ToolCall): Promise { + const startTime = Date.now() + + // ... existing hooks code (lines 503-556) ... + + // Emit tool_start event BEFORE execution + this.onToolEvent?.({ + type: "tool_start", + toolCallId: toolCall.id, + toolName: toolCall.tool, + args: toolCall.args as Record, + }); + + // ... rest of executeTool (line 558 onwards) ... +} +``` + +- [ ] **Step 4: Emit tool_output events from BashTool** + +For the Bash tool, we need to capture output as it happens. Add output buffering in the orchestrator or tool execution. For simplicity, emit `tool_output` when tool has meaningful progress. + +In `loop.ts` around line 564-596, after tool execution and before the post hook: + +```typescript +// After result is available (after line 569) +if (result.stdout) { + // Emit stdout as tool_output + const outputLines = result.stdout.split('\n').slice(-5).join('\n'); // last 5 lines + this.onToolEvent?.({ + type: "tool_output", + toolCallId: toolCall.id, + content: outputLines, + }); +} +``` + +- [ ] **Step 5: Emit tool_complete event after result** + +In `loop.ts` around line 596-600, after `BusEvents.toolCompleted`: + +```typescript +// Emit tool_complete event +this.onToolEvent?.({ + type: "tool_complete", + toolCallId: toolCall.id, + toolName: toolCall.tool, + result: result.stdout || result.error || "", + success: !result.error, + duration_ms: Date.now() - startTime, +}); +``` + +- [ ] **Step 6: Verify types compile** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/core/tsconfig.json` +Expected: No errors + +--- + +## Task 3: Modify CLI Server to Stream Events + +**Files:** +- Modify: `apps/core/src/server.ts:102-131` + +- [ ] **Step 1: Update session.send to stream events** + +In `server.ts`, modify the `session.send` handler (lines 102-131) to stream events: + +```typescript +"session.send": async (params: Record): Promise => { + const { sessionId, message, model } = params as { sessionId: string; message: string; model?: string }; + const session = getSession(sessionId); + + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + const config = readConfig() + const currentProvider = config.current?.provider || session.provider + + if (model) { + session.model = model; + } + + // Emit events to stdout immediately for streaming + const emitEvent = (event: StreamEvent) => { + process.stdout.write(JSON.stringify(event) + "\n"); + }; + + const loop = createAgentLoop(sessionId, { maxIterations: 100 }) + const result = await loop.run({ + prompt: message, + sessionId, + provider: currentProvider, + model: session.model, + projectPath: session.projectPath, + onToolEvent: emitEvent, // NEW: pass emit function + }) + + // Emit done event + emitEvent({ type: "done", content: result.message || "Done" }); + + return result; +}, +``` + +- [ ] **Step 2: Verify server compiles** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/core/tsconfig.json` +Expected: No errors + +--- + +## Task 4: Add sessionSendStreaming to TUI IPC Client + +**Files:** +- Modify: `apps/tui/src/ipc/client.ts` + +- [ ] **Step 1: Add StreamEvent import** + +At the top of `client.ts`, add import: + +```typescript +import type { StreamEvent } from "@freecode/shared"; +``` + +- [ ] **Step 2: Add sessionSendStreaming function** + +After the existing `sessionSend` function (around line 163), add: + +```typescript +export async function sessionSendStreaming( + sessionId: string, + message: string, + model: string | undefined, + onEvent: (event: StreamEvent) => void +): Promise { + return new Promise((resolve, reject) => { + if (!cliProcess || !cliProcess.stdin) { + reject(new Error("CLI not running")); + return; + } + + const id = generateId(); + const request: JsonRpcRequest = { jsonrpc: "2.0", id, method: "session.send", params: { sessionId, message, model } }; + + // Buffer for parsing streaming events + let eventBuffer = ""; + + // Set up temporary handler for streaming responses + const originalHandler = cliProcess.stdout?.on; + const handleData = (data: string) => { + eventBuffer += data; + const lines = eventBuffer.split("\n"); + + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i]; + if (!line.trim()) continue; + + // Check if this is a StreamEvent (not JSON-RPC response) + if (line.startsWith("{")) { + try { + const parsed = JSON.parse(line); + // If it has type field and no jsonrpc, it's a StreamEvent + if (parsed.type && !parsed.jsonrpc) { + onEvent(parsed as StreamEvent); + continue; + } + // If it has jsonrpc, it's a response - handle it + if (parsed.jsonrpc) { + pendingRequests.delete(parsed.id); + if (parsed.error) { + reject(new Error(parsed.error.message)); + } else { + resolve(parsed.result as SessionSendResult); + } + } + } catch { + // Not JSON, skip + } + } + } + + // Keep unparsed remainder + eventBuffer = lines[lines.length - 1] || ""; + }; + + // Send request + pendingRequests.set(id, { resolve: resolve as (value: unknown) => void, reject }); + cliProcess.stdin.write(JSON.stringify(request) + "\n"); + }); +} +``` + +- [ ] **Step 3: Verify client compiles** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/tui/tsconfig.json` +Expected: No errors + +--- + +## Task 5: Add Tool Message State to Message Store + +**Files:** +- Modify: `apps/tui/src/state/message-store.ts` + +- [ ] **Step 1: Add ToolMessage interface and methods** + +Add at the end of the file, before the exports: + +```typescript +export interface ToolMessage { + id: number; + toolCallId: string; + toolName: string; + args: Record; + status: "pending" | "running" | "complete" | "error"; + outputLines: string[]; + result?: string; + success?: boolean; + duration_ms?: number; + timestamp: number; + component?: Component; +} + +class MessageStoreImpl { + // ... existing code ... + + // NEW: Tool message tracking + private toolMessages = new Map(); + + addToolMessage(toolCallId: string, toolName: string, args: Record): ToolMessage { + const id = this.generateId(); + const toolMsg: ToolMessage = { + id, + toolCallId, + toolName, + args, + status: "pending", + outputLines: [], + timestamp: Date.now(), + }; + this.toolMessages.set(toolCallId, toolMsg); + this.notify(); + return toolMsg; + } + + updateToolStatus(toolCallId: string, status: ToolMessage["status"], updates?: Partial): void { + const toolMsg = this.toolMessages.get(toolCallId); + if (toolMsg) { + toolMsg.status = status; + if (updates) { + Object.assign(toolMsg, updates); + } + this.notify(); + } + } + + appendToolOutput(toolCallId: string, content: string): void { + const toolMsg = this.toolMessages.get(toolCallId); + if (toolMsg) { + // Keep last 5 lines + const lines = content.split('\n'); + toolMsg.outputLines = lines.slice(-5); + this.notify(); + } + } + + getToolMessage(toolCallId: string): ToolMessage | undefined { + return this.toolMessages.get(toolCallId); + } + + getAllToolMessages(): ToolMessage[] { + return Array.from(this.toolMessages.values()); + } +} + +// NEW: Export helper functions +export function addToolMessage(toolCallId: string, toolName: string, args: Record): ToolMessage { + return messageStore.addToolMessage(toolCallId, toolName, args); +} + +export function updateToolStatus(toolCallId: string, status: ToolMessage["status"], updates?: Partial): void { + messageStore.updateToolStatus(toolCallId, status, updates); +} + +export function appendToolOutput(toolCallId: string, content: string): void { + messageStore.appendToolOutput(toolCallId, content); +} + +export function getToolMessage(toolCallId: string): ToolMessage | undefined { + return messageStore.getToolMessage(toolCallId); +} +``` + +- [ ] **Step 2: Verify message store compiles** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/tui/tsconfig.json` +Expected: No errors + +--- + +## Task 6: Create ToolProgressMessage Component + +**Files:** +- Create: `apps/tui/src/components/tool-progress-message.ts` + +- [ ] **Step 1: Create the component** + +```typescript +import { Component, TUI, Text, Box } from "@earendil-works/pi-tui"; +import chalk from "chalk"; + +export interface ToolProgressMessageOptions { + toolCallId: string; + toolName: string; + args: Record; + outputLines: string[]; +} + +// Color mapping for different tools +const TOOL_COLORS: Record string> = { + Read: (t) => chalk.blue(t), + Write: (t) => chalk.green(t), + Edit: (t) => chalk.yellow(t), + Bash: (t) => chalk.red(t), + Glob: (t) => chalk.cyan(t), + Grep: (t) => chalk.magenta(t), + Skill: (t) => chalk.white(t), + Agent: (t) => chalk.white(t), +}; + +export class ToolProgressMessage implements Component { + private toolCallId: string; + private toolName: string; + private args: Record; + private outputLines: string[]; + private tui?: TUI; + private animationFrame = 0; + private intervalId?: ReturnType; + + constructor(options: ToolProgressMessageOptions) { + this.toolCallId = options.toolCallId; + this.toolName = options.toolName; + this.args = options.args; + this.outputLines = options.outputLines; + } + + setTui(tui: TUI): void { + this.tui = tui; + // Start animation + this.intervalId = setInterval(() => { + this.animationFrame = (this.animationFrame + 1) % 4; + this.tui?.requestRender(); + }, 250); + } + + updateOutput(outputLines: string[]): void { + this.outputLines = outputLines; + } + + invalidate(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = undefined; + } + } + + render(width: number): string[] { + const colorFn = TOOL_COLORS[this.toolName] || ((t: string) => t); + const spinner = ["⠋", "⠙", "⠹", "⠸"][this.animationFrame]; + const argsStr = this.formatArgs(); + + const lines: string[] = []; + + // Header line: [spinner] ToolName (args) + lines.push(`${chalk.dim("[")}${chalk.yellow(spinner)}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")}`); + + // Output lines with tree view + for (const outputLine of this.outputLines.slice(-5)) { + lines.push(`${chalk.dim("│ ")}${chalk.dim(outputLine)}`); + } + + return lines; + } + + private formatArgs(): string { + const entries = Object.entries(this.args); + if (entries.length === 0) return ""; + + // Truncate long values + const truncate = (s: string, max = 40) => s.length > max ? s.slice(0, max) + "..." : s; + + return entries + .map(([k, v]) => { + const vStr = typeof v === "string" ? v : JSON.stringify(v); + return `${k}: ${chalk.green(truncate(vStr))}`; + }) + .join(", "); + } +} +``` + +- [ ] **Step 2: Verify component compiles** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/tui/tsconfig.json` +Expected: No errors + +--- + +## Task 7: Create ToolResultMessage Component + +**Files:** +- Create: `apps/tui/src/components/tool-result-message.ts` + +- [ ] **Step 1: Create the component** + +```typescript +import { Component, Text, Box } from "@earendil-works/pi-tui"; +import chalk from "chalk"; + +export interface ToolResultMessageOptions { + toolCallId: string; + toolName: string; + args: Record; + result?: string; + success: boolean; + duration_ms?: number; +} + +// Color mapping for different tools +const TOOL_COLORS: Record string> = { + Read: (t) => chalk.blue(t), + Write: (t) => chalk.green(t), + Edit: (t) => chalk.yellow(t), + Bash: (t) => chalk.red(t), + Glob: (t) => chalk.cyan(t), + Grep: (t) => chalk.magenta(t), + Skill: (t) => chalk.white(t), + Agent: (t) => chalk.white(t), +}; + +export class ToolResultMessage implements Component { + private toolCallId: string; + private toolName: string; + private args: Record; + private result?: string; + private success: boolean; + private duration_ms?: number; + + constructor(options: ToolResultMessageOptions) { + this.toolCallId = options.toolCallId; + this.toolName = options.toolName; + this.args = options.args; + this.result = options.result; + this.success = options.success; + this.duration_ms = options.duration_ms; + } + + invalidate(): void { + // Nothing to clean up + } + + render(width: number): string[] { + const colorFn = TOOL_COLORS[this.toolName] || ((t: string) => t); + const statusIcon = this.success ? chalk.green("✓") : chalk.red("✗"); + const argsStr = this.formatArgs(); + const duration = this.duration_ms ? `(${this.duration_ms}ms)` : ""; + + const lines: string[] = []; + + // Header line: [✓/✗] ToolName (args) (duration) + lines.push(`${chalk.dim("[")}${statusIcon}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")} ${chalk.dim(duration)}`); + + // Result with tree view character + if (this.result) { + const truncatedResult = this.truncateResult(this.result, 200); + lines.push(`${chalk.dim("⎿")} ${chalk.dim(truncatedResult)}`); + } else if (this.success) { + lines.push(`${chalk.dim("⎿")} ${chalk.dim("(no output)")}`); + } + + return lines; + } + + private formatArgs(): string { + const entries = Object.entries(this.args); + if (entries.length === 0) return ""; + + const truncate = (s: string, max = 40) => s.length > max ? s.slice(0, max) + "..." : s; + + return entries + .map(([k, v]) => { + const vStr = typeof v === "string" ? v : JSON.stringify(v); + return `${k}: ${chalk.green(truncate(vStr))}`; + }) + .join(", "); + } + + private truncateResult(result: string, maxLen: number): string { + if (result.length <= maxLen) return result; + return result.slice(0, maxLen) + "..."; + } +} +``` + +- [ ] **Step 2: Verify component compiles** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/tui/tsconfig.json` +Expected: No errors + +--- + +## Task 8: Export New Components + +**Files:** +- Modify: `apps/tui/src/components/index.ts` + +- [ ] **Step 1: Add exports for new components** + +Add to the exports: + +```typescript +export { ToolProgressMessage, type ToolProgressMessageOptions } from "./tool-progress-message.js"; +export { ToolResultMessage, type ToolResultMessageOptions } from "./tool-result-message.js"; +``` + +- [ ] **Step 2: Verify exports compile** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/tui/tsconfig.json` +Expected: No errors + +--- + +## Task 9: Wire Streaming into TUI index.ts + +**Files:** +- Modify: `apps/tui/src/index.ts:356-402` (session.send handling) + +- [ ] **Step 1: Import new components and streaming function** + +Add imports at the top of index.ts: + +```typescript +import { + // ... existing imports ... + type ToolProgressMessageOptions, + type ToolResultMessageOptions, +} from "./components/index.js"; +import { + // ... existing imports ... + addToolMessage, + updateToolStatus, + appendToolOutput, +} from "./state/message-store.js"; +import { sessionSendStreaming } from "./ipc/client.js"; +import type { StreamEvent } from "@freecode/shared"; +import { ToolProgressMessage, ToolResultMessage } from "./components/index.js"; +``` + +- [ ] **Step 2: Create tool message components map** + +Add after the messageList initialization (around line 54): + +```typescript +const toolMessageComponents = new Map(); +``` + +- [ ] **Step 3: Replace session.send with sessionSendStreaming and handle events** + +In `index.ts` around line 357, replace: + +```typescript +// OLD: +const result = await sessionSend(currentSession.sessionId, trimmed); + +// NEW: +const result = await sessionSendStreaming( + currentSession.sessionId, + trimmed, + undefined, + (event: StreamEvent) => { + handleToolEvent(event); + } +); +``` + +- [ ] **Step 4: Add handleToolEvent function** + +Add before the `editor.onSubmit` assignment (around line 289): + +```typescript +function handleToolEvent(event: StreamEvent): void { + switch (event.type) { + case "tool_start": { + const toolMsg = addToolMessage(event.toolCallId, event.toolName, event.args); + const progressComponent = new ToolProgressMessage({ + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + outputLines: [], + }); + progressComponent.setTui(tui); + toolMessageComponents.set(event.toolCallId, { + progress: progressComponent, + id: toolMsg.id, + }); + // Add to message store for rendering + createInProgressMessage(`Running ${event.toolName}...`); + break; + } + case "tool_output": { + appendToolOutput(event.toolCallId, event.content); + const entry = toolMessageComponents.get(event.toolCallId); + if (entry) { + entry.progress.updateOutput(event.content.split('\n').slice(-5)); + } + tui.requestRender(); + break; + } + case "tool_complete": { + const entry = toolMessageComponents.get(event.toolCallId); + if (entry) { + // Remove progress component from message store + removeMessageById(entry.id); + toolMessageComponents.delete(event.toolCallId); + } + // Create result message + const resultComponent = new ToolResultMessage({ + toolCallId: event.toolCallId, + toolName: event.toolName, + args: {}, + result: event.result, + success: event.success, + duration_ms: event.duration_ms, + }); + // Add to virtual message list + createInProgressMessage(event.success ? `${event.toolName} completed` : `${event.toolName} failed`); + break; + } + } +} +``` + +- [ ] **Step 5: Verify TUI compiles** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/tui/tsconfig.json` +Expected: No errors (may have some, debug as needed) + +--- + +## Task 10: Test End-to-End + +**Files:** +- None (testing existing code) + +- [ ] **Step 1: Start TUI and verify it builds** + +Run: `cd /home/ayande/Project/freecode && pnpm --filter @freecode/tui build` +Expected: Build succeeds + +- [ ] **Step 2: Test tool streaming (manual test)** + +Run TUI with: `pnpm --filter @freecode/tui dev` + +Send a message that will trigger tool usage (e.g., "List the files in this directory" or "Read package.json") + +Expected: See tool progress displayed with spinner, tool name, args, and live output + +--- + +## Implementation Notes + +1. **Event streaming uses stdout directly** - This is a simplification. Events are written directly to stdout, interleaved with the JSON-RPC response. The client parses line-by-line to distinguish events from responses. + +2. **Buffer management** - The client maintains a buffer and splits on newlines, keeping any incomplete line for the next data chunk. + +3. **Tool colors are hardcoded** - If you need different colors, modify `TOOL_COLORS` in each component. + +4. **Last 5 lines only** - For `tool_output`, we keep only the last 5 lines to avoid memory issues with large outputs. + +5. **Progress animation** - The `ToolProgressMessage` uses a simple 4-frame spinner animation. Frames: `⠋⠙⠹⠸` \ No newline at end of file diff --git a/docs/superpowers/plans/2026-06-02-memory-session-plan.md b/docs/superpowers/plans/2026-06-02-memory-session-plan.md new file mode 100644 index 00000000..c633dea6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-memory-session-plan.md @@ -0,0 +1,1191 @@ +# Memory/Session System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement persistent session storage at `~/.freecode/sessions/` with JSONL message streaming, interrupt handling, resume, and URL-based remote sync. + +**Architecture:** Session data stored at `~/.freecode/sessions/{id}/` with `meta.json` (metadata) and `messages.jsonl` (streaming append). ThreadStore provides SQLite-first structured queries. SessionManager orchestrates lifecycle operations. TUI exposes resume picker. + +**Tech Stack:** TypeScript, Node.js fs/promises streaming, better-sqlite3 (or sql.js), zod, pi-tui + +--- + +## File Structure + +``` +~/.freecode/ # Central storage (created on first run) +├── sessions/ # Session data +│ └── {sessionId}/ +│ ├── meta.json # Session metadata +│ └── messages.jsonl # Streaming message append +├── state/ +│ ├── freecode.db # SQLite (threads, turns) +│ └── store.json # JSON fallback +└── config.json # Zod-validated config + +apps/core/src/ +├── session/ +│ ├── store.ts # NEW: SessionStore - JSONL file ops +│ ├── manager.ts # MODIFY: add resume, fork, interrupt +│ ├── types.ts # MODIFY: add SessionMeta, Message types +│ └── index.ts # MODIFY: export SessionStore +├── store/ +│ ├── thread-store.ts # EXISTING: keep as-is +│ ├── sqlite-store.ts # EXISTING: keep as-is +│ ├── json-store.ts # EXISTING: keep as-is +│ ├── remote.ts # EXISTING: RemoteSessionSync +│ └── types.ts # EXISTING: StoredThread/StoredTurn +├── memory/ +│ ├── storage.ts # EXISTING: FileMemoryStorage +│ └── mem-store.ts # EXISTING: MemoryStore +└── server.ts # MODIFY: add session IPC handlers + +apps/tui/src/ +├── components/ +│ └── resume-picker.tsx # NEW: session picker component +└── ipc/ + └── client.ts # MODIFY: add session methods +``` + +--- + +## Task 1: SessionStore (JSONL File Operations) + +**Files:** +- Create: `apps/core/src/session/store.ts` +- Test: `apps/core/src/session/store.test.ts` +- Dependencies: Read `apps/core/src/store/types.ts` for StoredThread/StoredTurn patterns + +- [ ] **Step 1: Write failing test** + +```typescript +// apps/core/src/session/store.test.ts +import { describe, it, expect, beforeEach } from 'vitest' +import { SessionStore } from './store' +import { join } from 'path' +import { rm } from 'fs/promises' + +describe('SessionStore', () => { + const testDir = '/tmp/freecode-test-session-store' + let store: SessionStore + + beforeEach(async () => { + await rm(testDir, { recursive: true, force: true }) + store = await SessionStore.create(testDir) + }) + + it('creates session directory with meta.json', async () => { + const sessionId = await store.createSession({ + title: 'Test Session', + projectPath: '/tmp/test', + provider: 'claude', + }) + const meta = await store.getMeta(sessionId) + expect(meta.id).toBe(sessionId) + expect(meta.title).toBe('Test Session') + expect(meta.status).toBe('active') + }) + + it('appends messages to messages.jsonl', async () => { + const sessionId = await store.createSession({ + title: 'Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + const msg = { id: 'msg-1', role: 'user' as const, parts: [{ type: 'text' as const, content: 'hello' }], timestamp: Date.now() } + await store.appendMessage(sessionId, msg) + const messages = await store.getMessages(sessionId) + expect(messages).toHaveLength(1) + expect(messages[0].parts[0]).toEqual({ type: 'text', content: 'hello' }) + }) + + it('marks message as interrupted', async () => { + const sessionId = await store.createSession({ + title: 'Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + const msg = { id: 'msg-1', role: 'assistant' as const, parts: [], timestamp: Date.now() } + await store.appendMessage(sessionId, msg) + await store.markInterrupted(sessionId, 'msg-1') + const msgs = await store.getMessages(sessionId) + expect(msgs[0].interrupted).toBe(true) + }) + + it('detects interrupted sessions', async () => { + const sessionId = await store.createSession({ + title: 'Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + const msg = { id: 'msg-1', role: 'assistant' as const, parts: [], timestamp: Date.now() } + await store.appendMessage(sessionId, msg) + await store.markInterrupted(sessionId, 'msg-1') + const interrupted = await store.getInterruptedSession() + expect(interrupted?.sessionId).toBe(sessionId) + }) + + it('lists sessions with filter', async () => { + const s1 = await store.createSession({ title: 'S1', projectPath: '/tmp/p1', provider: 'claude' }) + const s2 = await store.createSession({ title: 'S2', projectPath: '/tmp/p2', provider: 'claude' }) + await store.updateStatus(s1, 'archived') + const active = await store.list({ status: 'active' }) + expect(active).toHaveLength(2) + const archived = await store.list({ status: 'archived' }) + expect(archived).toHaveLength(1) + }) + + it('forks session with new id', async () => { + const parentId = await store.createSession({ title: 'Parent', projectPath: '/tmp/test', provider: 'claude' }) + const forkId = await store.fork(parentId) + const forkMeta = await store.getMeta(forkId) + expect(forkMeta.parentId).toBe(parentId) + expect(forkId).not.toBe(parentId) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/core && npx vitest src/session/store.test.ts --run` +Expected: FAIL with "Cannot find module './store'" + +- [ ] **Step 3: Write minimal SessionStore implementation** + +```typescript +// apps/core/src/session/store.ts +import { mkdir, readFile, writeFile, readdir, rm } from 'fs/promises' +import { join } from 'path' +import { randomUUID } from 'crypto' + +export interface SessionMeta { + id: string + title: string + projectPath: string + provider: string + model?: string + status: 'active' | 'interrupted' | 'archived' | 'deleted' + createdAt: number + updatedAt: number + lastTurnAt: number + turnCount: number + parentId?: string + aggregatedTokenCount?: number +} + +export interface SerializedMessage { + id: string + role: 'user' | 'assistant' + parts: Array<{ + type: 'text' | 'code' | 'tool' + content?: string + language?: string + tool?: { name: string; args: Record } + result?: string + }> + timestamp: number + interrupted?: boolean +} + +export interface CreateSessionOptions { + title: string + projectPath: string + provider: string + model?: string +} + +export interface SessionStore { + createSession(opts: CreateSessionOptions): Promise + getMeta(sessionId: string): Promise + updateMeta(sessionId: string, updates: Partial): Promise + updateStatus(sessionId: string, status: SessionMeta['status']): Promise + deleteSession(sessionId: string): Promise + + appendMessage(sessionId: string, message: SerializedMessage): Promise + getMessages(sessionId: string): Promise + markInterrupted(sessionId: string, messageId: string): Promise + + list(filter?: { status?: SessionMeta['status']; projectPath?: string }): Promise + fork(sessionId: string): Promise + + getInterruptedSession(): Promise<{ sessionId: string; messageId: string } | null> +} + +const SESSION_DIR = 'sessions' +const META_FILE = 'meta.json' +const MESSAGES_FILE = 'messages.jsonl' + +async function ensureDir(dir: string): Promise { + try { + await mkdir(dir, { recursive: true }) + } catch { /* already exists */ } +} + +async function readJson(path: string): Promise { + try { + const data = await readFile(path, 'utf-8') + return JSON.parse(data) as T + } catch { + return null + } +} + +async function writeJson(path: string, data: unknown): Promise { + await writeFile(path, JSON.stringify(data, null, 2), 'utf-8') +} + +export async function createSessionStore(baseDir: string): Promise { + await ensureDir(join(baseDir, SESSION_DIR)) + return new SessionStoreImpl(baseDir) +} + +class SessionStoreImpl implements SessionStore { + constructor(private baseDir: string) {} + + private sessionDir(sessionId: string): string { + return join(this.baseDir, SESSION_DIR, sessionId) + } + + private metaPath(sessionId: string): string { + return join(this.sessionDir(sessionId), META_FILE) + } + + private messagesPath(sessionId: string): string { + return join(this.sessionDir(sessionId), MESSAGES_FILE) + } + + async createSession(opts: CreateSessionOptions): Promise { + const id = randomUUID() + const now = Date.now() + const meta: SessionMeta = { + id, + title: opts.title, + projectPath: opts.projectPath, + provider: opts.provider, + model: opts.model, + status: 'active', + createdAt: now, + updatedAt: now, + lastTurnAt: now, + turnCount: 0, + } + await ensureDir(this.sessionDir(id)) + await writeJson(this.metaPath(id), meta) + await writeFile(this.messagesPath(id), '', 'utf-8') + return id + } + + async getMeta(sessionId: string): Promise { + return readJson(this.metaPath(sessionId)) + } + + async updateMeta(sessionId: string, updates: Partial): Promise { + const meta = await this.getMeta(sessionId) + if (!meta) return + const updated = { ...meta, ...updates, updatedAt: Date.now() } + await writeJson(this.metaPath(sessionId), updated) + } + + async updateStatus(sessionId: string, status: SessionMeta['status']): Promise { + await this.updateMeta(sessionId, { status }) + } + + async deleteSession(sessionId: string): Promise { + await this.updateStatus(sessionId, 'deleted') + } + + async appendMessage(sessionId: string, message: SerializedMessage): Promise { + const line = JSON.stringify(message) + '\n' + const stream = await import('fs').then(fs => fs.createWriteStream(this.messagesPath(sessionId), { flags: 'a' })) + return new Promise((resolve, reject) => { + stream.write(line, (err: Error | null) => { + if (err) reject(err); else resolve() + }) + }) + } + + async getMessages(sessionId: string): Promise { + const content = await readFile(this.messagesPath(sessionId), 'utf-8').catch(() => '') + if (!content.trim()) return [] + return content.trim().split('\n').map(line => JSON.parse(line) as SerializedMessage) + } + + async markInterrupted(sessionId: string, messageId: string): Promise { + const messages = await this.getMessages(sessionId) + const idx = messages.findIndex(m => m.id === messageId) + if (idx !== -1) { + messages[idx] = { ...messages[idx], interrupted: true } + } + await writeFile(this.messagesPath(sessionId), messages.map(m => JSON.stringify(m)).join('\n') + '\n', 'utf-8') + await this.updateStatus(sessionId, 'interrupted') + } + + async list(filter?: { status?: SessionMeta['status']; projectPath?: string }): Promise { + const sessionsDir = join(this.baseDir, SESSION_DIR) + let entries: string[] + try { + entries = await readdir(sessionsDir) + } catch { + return [] + } + const metas: SessionMeta[] = [] + for (const id of entries) { + const meta = await this.getMeta(id) + if (!meta) continue + if (filter?.status && meta.status !== filter.status) continue + if (filter?.projectPath && meta.projectPath !== filter.projectPath) continue + metas.push(meta) + } + return metas.sort((a, b) => b.lastTurnAt - a.lastTurnAt) + } + + async fork(sessionId: string): Promise { + const meta = await this.getMeta(sessionId) + if (!meta) throw new Error('Session not found') + const newId = await this.createSession({ + title: meta.title + ' (fork)', + projectPath: meta.projectPath, + provider: meta.provider, + model: meta.model, + }) + await this.updateMeta(newId, { parentId: sessionId, turnCount: meta.turnCount }) + const messages = await this.getMessages(sessionId) + for (const msg of messages) { + await this.appendMessage(newId, msg) + } + return newId + } + + async getInterruptedSession(): Promise<{ sessionId: string; messageId: string } | null> { + const all = await this.list({ status: 'interrupted' }) + if (all.length === 0) return null + const session = all[0] + const messages = await this.getMessages(session.id) + const last = messages[messages.length - 1] + return last ? { sessionId: session.id, messageId: last.id } : null + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/core && npx vitest src/session/store.test.ts --run` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +cd /home/ayande/Project/freecode +git add apps/core/src/session/store.ts apps/core/src/session/store.test.ts +git commit -m "$(cat <<'EOF' +feat: add SessionStore for JSONL file operations + +SessionStore handles session creation, JSONL message append, +interrupt marking, and session listing. Used by SessionManager +for persistent session storage at ~/.freecode/sessions/. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Task 2: Integrate SessionStore into SessionManager + +**Files:** +- Modify: `apps/core/src/session/manager.ts:1-50` +- Read first: `apps/core/src/session/manager.ts` (full file) + +- [ ] **Step 1: Write failing test** + +```typescript +// apps/core/src/session/manager.test.ts +import { describe, it, expect, beforeEach } from 'vitest' +import { rm } from 'fs/promises' +import { SessionManager, createSessionManager } from './manager' + +describe('SessionManager', () => { + const testBase = '/tmp/freecode-test-manager' + let manager: SessionManager + + beforeEach(async () => { + await rm(testBase, { recursive: true, force: true }) + manager = await createSessionManager(testBase) + }) + + it('starts a new session', async () => { + const id = await manager.start('/tmp/test', 'claude', 'Test Session') + expect(id).toBeTruthy() + const ctx = await manager.resume(id) + expect(ctx.id).toBe(id) + expect(ctx.title).toBe('Test Session') + }) + + it('resumes and gets complete message history', async () => { + const id = await manager.start('/tmp/test', 'claude') + const msg = { id: 'msg-1', role: 'user' as const, parts: [{ type: 'text' as const, content: 'hello' }], timestamp: Date.now() } + await manager.appendMessage(id, msg) + const ctx = await manager.resume(id) + expect(ctx.messages).toHaveLength(1) + }) + + it('detects interrupted session and injects resume marker', async () => { + const id = await manager.start('/tmp/test', 'claude') + const msg = { id: 'msg-1', role: 'assistant' as const, parts: [], timestamp: Date.now() } + await manager.appendMessage(id, msg) + await manager.markInterrupted(id, 'msg-1') + const ctx = await manager.resume(id) + // Should inject "Continue from where you left off." message + expect(ctx.messages).toHaveLength(2) // original + injected + }) + + it('forks session with full history', async () => { + const parentId = await manager.start('/tmp/test', 'claude') + await manager.appendMessage(parentId, { id: 'msg-1', role: 'user', parts: [{ type: 'text', content: 'hello' }], timestamp: Date.now() }) + const forkId = await manager.fork(parentId) + const parentCtx = await manager.resume(parentId) + const forkCtx = await manager.resume(forkId) + expect(forkCtx.messages).toHaveLength(parentCtx.messages.length) + }) + + it('lists sessions with project filter', async () => { + await manager.start('/tmp/p1', 'claude') + await manager.start('/tmp/p2', 'claude') + const p1Sessions = await manager.list({ projectPath: '/tmp/p1' }) + expect(p1Sessions).toHaveLength(1) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/core && npx vitest src/session/manager.test.ts --run` +Expected: FAIL with "createSessionManager not found" or missing methods + +- [ ] **Step 3: Read existing manager and modify** + +Read `apps/core/src/session/manager.ts` first, then modify it to: +1. Use `createSessionStore` instead of `createThreadStoreService` for session operations +2. Add `appendMessage(sessionId, message)` method +3. Add `markInterrupted(sessionId, messageId)` method +4. Add `getInterruptedSession()` helper +5. Update `resume()` to detect interrupt and inject synthetic message +6. Change `list()` to use SessionStore (keep ThreadStore for structured queries) + +The key change in `resume()`: +```typescript +async resume(sessionId: string): Promise { + const meta = await this.sessionStore.getMeta(sessionId) + if (!meta) throw new Error('Session not found') + const messages = await this.sessionStore.getMessages(sessionId) + + // Detect interrupted state → inject resume marker + if (meta.status === 'interrupted') { + const lastMsg = messages[messages.length - 1] + if (lastMsg?.interrupted) { + const resumeMsg: SerializedMessage = { + id: randomUUID(), + role: 'user', + parts: [{ type: 'text', content: 'Continue from where you left off.' }], + timestamp: Date.now(), + } + messages.push(resumeMsg) + } + } + + return { id: meta.id, title: meta.title, projectPath: meta.projectPath, provider: meta.provider, status: meta.status, messages, turnCount: meta.turnCount, createdAt: meta.createdAt } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/core && npx vitest src/session/manager.test.ts --run` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/session/manager.ts apps/core/src/session/manager.test.ts +git commit -m "$(cat <<'EOF' +feat: integrate SessionStore into SessionManager + +SessionManager now uses SessionStore for JSONL-based session +persistence. Resume detects interrupted state and injects +"Continue from where you left off." marker message. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Task 3: Session Message Streaming in Agent Loop + +**Files:** +- Modify: `apps/core/src/agent/loop.ts` +- Read first: `apps/core/src/agent/loop.ts` (full file, look for where messages are created/emitted) +- Read first: `apps/core/src/session/types.ts` + +- [ ] **Step 1: Understand the agent loop message flow** + +Read `apps/core/src/agent/loop.ts` and identify where: +1. User messages are created +2. Assistant messages are created/streamed +3. Tool calls and results are recorded +4. Turn ends + +Then read `apps/core/src/session/types.ts` to understand `SessionState` and `Message` types. + +- [ ] **Step 2: Write integration test** + +```typescript +// apps/core/src/agent/loop-stream.test.ts +// Test that agent loop streams messages to SessionStore +import { describe, it, expect, beforeEach } from 'vitest' +import { rm } from 'fs/promises' +import { createSessionManager } from '../session/manager' +import { SessionStore } from '../session/store' + +it('agent loop streams messages to session store', async () => { + const baseDir = '/tmp/freecode-test-loop-stream' + await rm(baseDir, { recursive: true, force: true }) + const manager = await createSessionManager(baseDir) + const sessionId = await manager.start('/tmp/test', 'claude', 'Loop Test') + + // Simulate message append (this is what loop.ts will call) + await manager.appendMessage(sessionId, { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'hello' }], + timestamp: Date.now(), + }) + + const ctx = await manager.resume(sessionId) + expect(ctx.messages).toHaveLength(1) + expect(ctx.messages[0].parts[0]).toEqual({ type: 'text', content: 'hello' }) +}) +``` + +- [ ] **Step 3: Modify agent loop to use SessionStore** + +Find where the agent loop creates messages and add calls to `sessionStore.appendMessage()`. The key insertion points: + +1. After user message is submitted → append user message +2. When assistant message starts/segments → append assistant message +3. After tool call → append tool call message +4. After tool result → append tool result message + +Pass `sessionStore` into the agent loop via constructor or context. Use Effect context pattern if available, otherwise parameter injection. + +```typescript +// In agent/loop.ts, add sessionStore parameter: +// constructor( +// ... +// private readonly sessionStore?: SessionStore +// ) + +// After each message/turn: +if (this.sessionStore && sessionId) { + await this.sessionStore.appendMessage(sessionId, serializedMessage) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cd apps/core && npx vitest src/agent/ --run` +Expected: Existing tests pass, new integration test passes + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/agent/loop.ts apps/core/src/agent/loop-stream.test.ts +git commit -m "$(cat <<'EOF' +feat: stream agent loop messages to SessionStore + +Agent loop now appends all messages (user, assistant, tool calls, +results) to the session's messages.jsonl file for complete +history persistence. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Task 4: Interrupt Handling (Ctrl+C Signal) + +**Files:** +- Modify: `apps/core/src/server.ts` (add signal handler) +- Create: `apps/core/src/session/interrupt.ts` (interrupt detection and handling) +- Modify: `apps/core/src/agent/loop.ts` (call markInterrupted on signal) +- Test: `apps/core/src/session/interrupt.test.ts` + +- [ ] **Step 1: Write interrupt service** + +```typescript +// apps/core/src/session/interrupt.ts +import { signal } from 'process' + +export interface InterruptState { + sessionId: string | null + messageId: string | null + pending: boolean +} + +export class InterruptHandler { + private sessionId: string | null = null + private messageId: string | null = null + + setActive(sessionId: string, messageId: string): void { + this.sessionId = sessionId + this.messageId = messageId + } + + clear(): void { + this.sessionId = null + this.messageId = null + } + + getState(): InterruptState { + return { + sessionId: this.sessionId, + messageId: this.messageId, + pending: this.sessionId !== null, + } + } + + setupSignalHandler(onInterrupt: (sessionId: string, messageId: string) => void): void { + let lastSigInt = 0 + process.on('SIGINT', () => { + const now = Date.now() + if (now - lastSigInt < 1000) { + // Double Ctrl+C → force exit + process.exit(1) + } + lastSigInt = now + if (this.sessionId && this.messageId) { + onInterrupt(this.sessionId, this.messageId) + } + }) + } +} + +let globalHandler: InterruptHandler | null = null + +export function getInterruptHandler(): InterruptHandler { + if (!globalHandler) { + globalHandler = new InterruptHandler() + } + return globalHandler +} +``` + +- [ ] **Step 2: Integrate into server.ts** + +In `server.ts`, after session is started and during message streaming: + +```typescript +import { getInterruptHandler } from './session/interrupt' +import { getSessionManager } from './session/manager' + +// After session.start handler: +const handler = getInterruptHandler() +handler.setupSignalHandler(async (sessionId, messageId) => { + const manager = await getSessionManager() + await manager.markInterrupted(sessionId, messageId) +}) +``` + +- [ ] **Step 3: Wire into agent loop** + +In `agent/loop.ts`, after starting a message (when streaming begins): + +```typescript +import { getInterruptHandler } from '../session/interrupt' + +// When starting to stream an assistant message: +getInterruptHandler().setActive(sessionId, messageId) +``` + +- [ ] **Step 4: Write test** + +```typescript +// apps/core/src/session/interrupt.test.ts +import { describe, it, expect } from 'vitest' +import { InterruptHandler } from './interrupt' + +describe('InterruptHandler', () => { + it('tracks active session/message', () => { + const handler = new InterruptHandler() + expect(handler.getState().pending).toBe(false) + + handler.setActive('session-1', 'msg-1') + expect(handler.getState().pending).toBe(true) + expect(handler.getState().sessionId).toBe('session-1') + expect(handler.getState().messageId).toBe('msg-1') + + handler.clear() + expect(handler.getState().pending).toBe(false) + }) +}) +``` + +- [ ] **Step 5: Run tests and commit** + +Run: `cd apps/core && npx vitest src/session/interrupt.test.ts --run` +Commit: +```bash +git add apps/core/src/server.ts apps/core/src/session/interrupt.ts apps/core/src/session/interrupt.test.ts +git commit -m "$(cat <<'EOF' +feat: add Ctrl+C interrupt handling + +InterruptHandler tracks active session/message and marks +interrupted messages on double-Ctrl+C. Session resumes with +injected "Continue from where you left off." marker. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Task 5: Resume Picker (TUI Component) + +**Files:** +- Create: `apps/tui/src/components/resume-picker.tsx` +- Modify: `apps/tui/src/index.ts` (add resume command) +- Modify: `apps/tui/src/ipc/client.ts` (add session.list, session.resume calls) + +- [ ] **Step 1: Write resume picker component** + +```typescript +// apps/tui/src/components/resume-picker.tsx +import React, { useEffect, useState } from 'react' +import { Box, Text, Key } from 'ink' +import { SessionMeta } from '@freecode/shared' + +interface Props { + sessions: SessionMeta[] + onSelect: (sessionId: string) => void + onCancel: () => void +} + +export const ResumePicker: React.FC = ({ sessions, onSelect, onCancel }) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowUp': + setSelectedIndex(i => Math.max(0, i - 1)) + break + case 'ArrowDown': + setSelectedIndex(i => Math.min(sessions.length - 1, i + 1)) + break + case 'Enter': + if (sessions[selectedIndex]) { + onSelect(sessions[selectedIndex].id) + } + break + case 'Escape': + case 'q': + onCancel() + break + } + } + process.stdin.on('keypress', handleKey) + return () => process.stdin.off('keypress', handleKey) + }, [selectedIndex, sessions, onSelect, onCancel]) + + return ( + + + Select a session to resume: + + {sessions.map((session, i) => ( + + + {i === selectedIndex ? '❯ ' : ' '} + + + {session.title} — {session.projectPath} + + ({session.turnCount} turns) + {session.status === 'interrupted' && ( + [Interrupted] + )} + + ))} + + ↑↓ navigate · Enter resume · Ctrl+C cancel + + + ) +} +``` + +- [ ] **Step 2: Modify IPC client** + +In `apps/tui/src/ipc/client.ts`, add session methods: + +```typescript +async listSessions(filter?: { status?: string; projectPath?: string }): Promise { + return this.call('session.list', filter ?? {}) +} + +async resumeSession(sessionId: string): Promise { + return this.call('session.resume', { sessionId }) +} + +async startSession(projectPath: string, provider?: string, title?: string): Promise { + return this.call('session.start', { projectPath, provider, title }) +} +``` + +- [ ] **Step 3: Add resume command to TUI** + +In `apps/tui/src/index.ts`, add command handler: + +```typescript +ipc.on('session.list', async (params: { status?: string; projectPath?: string }) => { + return await client.listSessions(params) +}) + +ipc.on('session.resume', async (params: { sessionId: string }) => { + return await client.resumeSession(params.sessionId) +}) + +// Detect interrupted session on startup +const interrupted = await client.getInterruptedSession() +if (interrupted) { + // Show prompt to resume +} +``` + +- [ ] **Step 4: Test in TUI** + +Run the TUI and verify: +1. On startup, if interrupted session exists, picker appears +2. `/resume` command opens picker +3. Keyboard navigation works +4. Selected session resumes correctly + +- [ ] **Step 5: Commit** + +```bash +git add apps/tui/src/components/resume-picker.tsx apps/tui/src/ipc/client.ts apps/tui/src/index.ts +git commit -m "$(cat <<'EOF' +feat: add resume picker to TUI + +ResumePicker component shows session list with keyboard +navigation. Auto-detects interrupted sessions on startup +and prompts user to resume. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Task 6: Session IPC Handlers (server.ts) + +**Files:** +- Modify: `apps/core/src/server.ts` +- Read first: `apps/core/src/server.ts` (full file) + +- [ ] **Step 1: Add session handlers** + +In `server.ts`, add handlers for session operations. Read the existing file first, then add: + +```typescript +// After existing session handlers, add: +import { getSessionManager } from './session/manager' +import { createSessionStore } from './session/store' +import { getInterruptHandler } from './session/interrupt' +import { getFreeCodeDir } from './utils/freecode-dir' + +// Initialize session store and manager at startup +let sessionStore: ReturnType | null = null +let sessionManager: ReturnType | null = null + +async function getSessionStore() { + if (!sessionStore) { + const baseDir = getFreeCodeDir() + sessionStore = await createSessionStore(baseDir) + } + return sessionStore +} + +async function getSM() { + if (!sessionManager) { + const store = await getSessionStore() + sessionManager = await createSessionManager(store) + } + return sessionManager +} + +// Add to methodHandlers: +'session.list': async (params) => { + const manager = await getSM() + return manager.list(params) +}, +'session.resume': async (params) => { + const manager = await getSM() + return manager.resume(params.sessionId) +}, +'session.fork': async (params) => { + const manager = await getSM() + return manager.fork(params.sessionId, params.point) +}, +'session.archive': async (params) => { + const manager = await getSM() + await manager.archive(params.sessionId) +}, +'session.delete': async (params) => { + const manager = await getSM() + await manager.delete(params.sessionId) +}, +'session.export': async (params) => { + const manager = await getSM() + return manager.export(params.sessionId) +}, +'session.import': async (params) => { + const manager = await getSM() + return manager.import(params.url) +}, +``` + +- [ ] **Step 2: Add helper to get interrupted session** + +```typescript +'session.getInterrupted': async () => { + const store = await getSessionStore() + return store.getInterruptedSession() +} +``` + +- [ ] **Step 3: Wire interrupt handler setup** + +After session is started, setup signal handler: + +```typescript +'session.start': async (params) => { + const manager = await getSM() + const sessionId = await manager.start(params.projectPath, params.provider, params.title) + // Setup interrupt handler for this session + getInterruptHandler().setupSignalHandler(async (sid, mid) => { + const mgr = await getSM() + await mgr.markInterrupted(sid, mid) + }) + return { sessionId } +} +``` + +- [ ] **Step 4: Test all IPC handlers** + +Create integration test file `apps/core/src/server.session.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest' +import { rm } from 'fs/promises' +import { JsonRpcServer } from './server' + +describe('Session IPC handlers', () => { + // Test that JSON-RPC requests for session.* methods work correctly + // Mock stdin/stdout and send JSON-RPC requests directly +}) +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/server.ts +git commit -m "$(cat <<'EOF' +feat: add session IPC handlers to server + +Server now handles session.list, session.resume, session.fork, +session.archive, session.delete, session.export, session.import, +and session.getInterrupted via JSON-RPC. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Task 7: Remote Session Sync (URL Export/Import) + +**Files:** +- Modify: `apps/core/src/store/remote.ts` (existing, add URL-based export/import) +- Modify: `apps/core/src/session/manager.ts` (add export/import methods) +- Test: `apps/core/src/session/remote.test.ts` + +- [ ] **Step 1: Read existing remote.ts** + +Read `apps/core/src/store/remote.ts` to understand `RemoteSessionSync` class. + +- [ ] **Step 2: Write test for URL export/import** + +```typescript +// apps/core/src/session/remote.test.ts +import { describe, it, expect, beforeEach } from 'vitest' +import { rm } from 'fs/promises' +import { createSessionStore } from './store' + +describe('Session URL export/import', () => { + const testDir = '/tmp/freecode-test-remote' + let store: Awaited> + + beforeEach(async () => { + await rm(testDir, { recursive: true, force: true }) + store = await createSessionStore(testDir) + }) + + it('serializes session for export', async () => { + const sessionId = await store.createSession({ + title: 'Export Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + await store.appendMessage(sessionId, { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'hello' }], + timestamp: Date.now(), + }) + const meta = await store.getMeta(sessionId) + const messages = await store.getMessages(sessionId) + // Export should produce a serializable object + const exported = { meta, messages } + expect(exported.meta.id).toBe(sessionId) + expect(exported.messages).toHaveLength(1) + }) + + it('deserializes and creates new session', async () => { + const sessionId = await store.createSession({ + title: 'Import Source', + projectPath: '/tmp/test', + provider: 'claude', + }) + const originalMeta = await store.getMeta(sessionId) + const originalMessages = await store.getMessages(sessionId) + + // Simulate import: create new session from exported data + const newId = await store.createSession({ + title: originalMeta.title + ' (imported)', + projectPath: originalMeta.projectPath, + provider: originalMeta.provider, + }) + for (const msg of originalMessages) { + await store.appendMessage(newId, msg) + } + const newMessages = await store.getMessages(newId) + expect(newMessages).toHaveLength(originalMessages.length) + expect(newId).not.toBe(sessionId) + }) +}) +``` + +- [ ] **Step 3: Add export/import to SessionManager** + +In `apps/core/src/session/manager.ts`, add: + +```typescript +async export(sessionId: string): Promise<{ url: string; expiresAt: number }> { + const meta = await this.sessionStore.getMeta(sessionId) + if (!meta) throw new Error('Session not found') + const messages = await this.sessionStore.getMessages(sessionId) + const payload = JSON.stringify({ meta, messages }) + + // POST to sync endpoint (configurable via config.json) + const endpoint = getConfig().syncEndpoint ?? 'https://sync.freecode.dev' + const response = await fetch(`${endpoint}/upload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + }) + if (!response.ok) throw new Error('Export failed') + const result = await response.json() as { url: string; expiresAt: number } + return result +} + +async import(url: string): Promise { + const response = await fetch(url) + if (!response.ok) throw new Error('Import failed') + const { meta, messages } = await response.json() as { meta: SessionMeta; messages: SerializedMessage[] } + + const newId = await this.sessionStore.createSession({ + title: meta.title + ' (imported)', + projectPath: meta.projectPath, + provider: meta.provider, + model: meta.model, + }) + for (const msg of messages) { + await this.sessionStore.appendMessage(newId, msg) + } + await this.sessionStore.updateMeta(newId, { status: 'imported' as const }) + return newId +} +``` + +- [ ] **Step 4: Run tests and commit** + +Run: `cd apps/core && npx vitest src/session/remote.test.ts --run` +Commit: +```bash +git add apps/core/src/session/manager.ts apps/core/src/session/remote.test.ts +git commit -m "$(cat <<'EOF' +feat: add URL-based session export/import + +Session export serializes meta+messages and POSTs to sync +endpoint. Session import GETs URL and creates local session. +Enables sharing sessions via short URLs. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Self-Review Checklist + +1. **Spec coverage**: All v1 items covered? + - [x] Session storage at `~/.freecode/sessions/` → Task 1 + - [x] JSONL message streaming → Task 1 + Task 3 + - [x] Interrupt handling (Ctrl+C) → Task 4 + - [x] Resume with complete chat history → Task 2 + - [x] Resume picker → Task 5 + - [x] Session list/fork/archive/delete → Tasks 2 + 6 + - [x] URL-based export/import → Task 7 + +2. **Placeholder scan**: No TODOs, no "TBD", no "implement later" + +3. **Type consistency**: + - `SessionMeta.status`: `'active' | 'interrupted' | 'archived' | 'deleted'` — used consistently + - `SerializedMessage` structure matches between store.ts and manager.ts + - `session.list` filter uses same property names as `SessionMeta` + +4. **File paths**: All exact, no relative paths +5. **Commands**: All test commands use exact paths with `npx vitest` +6. **Code blocks**: Every step that changes code has the actual code + +--- + +## Execution + +**Plan complete and saved to `docs/superpowers/plans/2026-06-02-memory-session-plan.md`.** + +Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? \ No newline at end of file diff --git a/docs/superpowers/specs/2026-06-01-tools-ui-design.md b/docs/superpowers/specs/2026-06-01-tools-ui-design.md new file mode 100644 index 00000000..33f8a45e --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-tools-ui-design.md @@ -0,0 +1,227 @@ +# Tool Progress Streaming Design + +**Date:** 2026-06-01 +**Status:** Draft + +## Overview + +Implement streaming tool progress display in the FreeCode TUI. When the agent uses tools (Read, Write, Bash, etc.), the TUI displays live progress with tool name, parameters, and output tail - similar to Claude Code's UX. + +## Architecture + +``` +┌─────────────┐ JSON-RPC events ┌─────────────┐ +│ CLI │ ──────────────────────► │ TUI │ +│ (server) │ tool_start/output/ │ (client) │ +│ │ complete streamed │ │ +└─────────────┘ as they happen └─────────────┘ +``` + +## 1. Streaming Event Protocol + +### New Event Types + +Add to `/packages/shared/src/ipc/protocol.ts`: + +```typescript +export type StreamEvent = + | { type: "tool_start"; toolCallId: string; toolName: string; args: Record } + | { type: "tool_output"; toolCallId: string; content: string } + | { type: "tool_complete"; toolCallId: string; toolName: string; result: string; success: boolean; duration_ms?: number } + | { type: "text"; content: string } + | { type: "error"; content: string }; + +export interface StreamResponse { + type: "event" | "done" | "error"; + event?: StreamEvent; + result?: unknown; + error?: string; +} +``` + +### Event Flow + +``` +TUI → CLI: {"jsonrpc":"2.0","method":"session.send","params":{...},"id":1} +CLI → TUI: {"type":"tool_start","toolCallId":"abc","toolName":"Read","args":{"path":"/foo.txt"}} +CLI → TUI: {"type":"tool_output","toolCallId":"abc","content":"Reading..."} +CLI → TUI: {"type":"tool_output","toolCallId":"abc","content":"234 lines"} +CLI → TUI: {"type":"tool_complete","toolCallId":"abc","toolName":"Read","result":"...(truncated)...","success":true,"duration_ms":145} +CLI → TUI: {"type":"done","content":"Agent finished"} +CLI → TUI: {"jsonrpc":"2.0","result":{LoopResult},"id":1} +``` + +## 2. CLI Changes + +### File: `apps/core/src/server.ts` + +Modify `session.send` handler to stream events: + +```typescript +"session.send": async (params: Record, emitFn?: (event: StreamEvent) => void): Promise => { + const { sessionId, message, model } = params; + // ... + const loop = createAgentLoop(sessionId, { + maxIterations: 100, + onToolEvent: (event: StreamEvent) => { + // Emit streaming event to TUI + process.stdout.write(JSON.stringify(event) + "\n"); + } + }); + const result = await loop.run({...}); + return result; +} +``` + +### File: `apps/core/src/agent/loop.ts` + +Add `onToolEvent` callback to `AgentLoop.run()`: + +```typescript +interface RunOptions { + prompt: string; + sessionId: string; + provider: string; + model: string; + projectPath: string; + onToolEvent?: (event: StreamEvent) => void; +} +``` + +Emit events in `executeTool()`: +- `tool_start` before executing +- `tool_output` for live output (buffer and flush periodically) +- `tool_complete` after execution finishes + +## 3. TUI Changes + +### IPC Client: `apps/tui/src/ipc/client.ts` + +```typescript +export async function sessionSendStreaming( + sessionId: string, + message: string, + model: string | undefined, + onEvent: (event: StreamEvent) => void +): Promise { + // Send request and parse streaming events from response stream + // For each line, check if it's a StreamEvent or final result +} +``` + +### State: `apps/tui/src/state/message-store.ts` + +```typescript +interface ToolMessage { + id: string; + toolCallId: string; + toolName: string; + args: Record; + status: 'pending' | 'running' | 'complete' | 'error'; + outputLines: string[]; + result?: string; + success?: boolean; + duration_ms?: number; + timestamp: number; +} + +// New methods +addToolMessage(msg: ToolMessage): void +updateToolMessage(id: string, updates: Partial): void +appendToolOutput(id: string, content: string): void // Keep last 5 lines +``` + +### Components: `apps/tui/src/components/` + +#### `tool-progress-message.ts` + +Displays a tool in progress with live output: + +```typescript +export class ToolProgressMessage implements Component { + // Shows: [●] ToolName (args) + // └─ output line 1 + // └─ output line 2 + // └─ ... + // └─ (last 5 lines) +} +``` + +#### `tool-result-message.ts` + +Displays completed tool with result: + +```typescript +export class ToolResultMessage implements Component { + // Shows: [✓] ToolName (args) (duration_ms) + // ⎿ result preview (truncated) + // Or for errors: + // [✗] ToolName (args) + // ⎿ Error message +} +``` + +## 4. Display Format + +### Running Tool +``` +[●] Read (path: "/src/index.ts") + └─ Reading... + └─ 234 lines +``` + +### Completed Tool (Success) +``` +[✓] Read (path: "/src/index.ts") (145ms) + ⎿ 2453 chars +``` + +### Completed Tool (Error) +``` +[✗] Bash (command: "rm -rf /") + ⎿ Error: Permission denied +``` + +### Color Coding +- `Read` → blue +- `Write` → green +- `Edit` → yellow +- `Bash` → red +- `Glob` → cyan +- `Grep` → magenta +- `Skill` → white +- `Agent` → white + +## 5. File Changes Summary + +| File | Change | +|------|--------| +| `packages/shared/src/ipc/protocol.ts` | Add `StreamEvent` types | +| `apps/core/src/agent/loop.ts` | Add `onToolEvent` callback | +| `apps/core/src/agent/types.ts` | Add `RunOptions.onToolEvent` | +| `apps/core/src/server.ts` | Stream events to stdout | +| `apps/tui/src/ipc/client.ts` | Add `sessionSendStreaming` | +| `apps/tui/src/state/message-store.ts` | Add tool message methods | +| `apps/tui/src/components/tool-progress-message.ts` | NEW - in-progress display | +| `apps/tui/src/components/tool-result-message.ts` | NEW - result display | +| `apps/tui/src/components/index.ts` | Export new components | +| `apps/tui/src/index.ts` | Wire streaming into message flow | + +## 6. Implementation Order + +1. Add `StreamEvent` types to protocol +2. Modify CLI `session.send` to stream events +3. Add `sessionSendStreaming` to TUI IPC client +4. Add tool message state to message-store +5. Create `ToolProgressMessage` component +6. Create `ToolResultMessage` component +7. Wire into TUI index.ts +8. Test end-to-end + +## 7. Edge Cases + +- **No tools used**: Stream just text/done events, no tool messages +- **Multiple sequential tools**: Show each in own row, sequential updates +- **Tool with no output**: Show tool_start then tool_complete with "(no output)" +- **Very long output**: Keep only last 5 lines in state, full result in tool_complete +- **Tool error**: Show error state with error message in result \ No newline at end of file diff --git a/docs/superpowers/specs/2026-06-02-memory-session-design.md b/docs/superpowers/specs/2026-06-02-memory-session-design.md new file mode 100644 index 00000000..3ce40a4a --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-memory-session-design.md @@ -0,0 +1,317 @@ +# Memory/Session System Design + +**Date:** 2026-06-02 +**Status:** Draft + +--- + +## Overview + +FreeCode's memory/session system provides persistent, resumable conversation history with remote sync capability. Sessions store complete chat history (messages, tool calls, metadata) at a central location (`~/.freecode/`), enabling interruption recovery, cross-machine resume, and URL-based session sharing. + +--- + +## Storage Architecture + +### Central Storage (`~/.freecode/`) + +All session data lives centrally, not in project directories. This enables: +- Sessions persist across machines +- Project roots stay clean (no `.freecode/` cluttering git) +- Easy backup/sync of all conversation history + +``` +~/.freecode/ +├── sessions/ # Session data (primary) +│ └── {sessionId}/ +│ ├── meta.json # Session metadata +│ ├── messages.jsonl # Full message transcript (streaming append) +│ └── memory.json # Session-level memory state (compaction) +├── memory/ # Project-level persistent memory +│ └── {projectSlug}/ +│ └── memory.md # Auto-extracted conversation notes +├── state/ +│ ├── freecode.db # SQLite (threads, turns, tool_calls) +│ └── store.json # JSON fallback +└── config.json # Zod-validated config +``` + +### Project-Level `.freecode/` (Optional) + +Project roots may contain a lightweight `.freecode/` for: +- Project-specific config (`provider`, `model`, `customInstructions`) +- Session refs (pointer to central session ID, not full data) +- This is NOT where session history lives + +### Session Data Structure + +**`meta.json`** — Session metadata: +```typescript +interface SessionMeta { + id: string // UUID + title: string // Auto-generated or user-set + projectPath: string // Absolute path + provider: string // e.g., "claude", "chatgpt" + model?: string // e.g., "claude-opus-4-6" + status: "active" | "interrupted" | "archived" | "deleted" + createdAt: number // Unix timestamp ms + updatedAt: number + lastTurnAt: number + turnCount: number + parentId?: string // If forked from another session + aggregatedTokenCount?: number +} +``` + +**`messages.jsonl`** — One JSON object per message, appended as produced: +``` +{"id":"msg-1","role":"user","parts":[{"type":"text","content":"hello"}],"timestamp":1700000000000} +{"id":"msg-2","role":"assistant","parts":[{"type":"tool","tool":{"name":"Read","args":{"path":"/foo.txt"}}}],"timestamp":1700000001000} +{"id":"msg-3","role":"assistant","parts":[{"type":"tool","tool":{"name":"Read","args":{}},"result":"file contents..."}],"timestamp":1700000001500} +``` + +**`memory.json`** — Session compaction state (created after first compaction): +```typescript +interface SessionMemory { + sessionId: string + summaries: CompactionSummary[] // Summarized message ranges + tokenCount: number + totalCompactions: number + lastCompactionAt?: number + preservedRecentMessages: SerializedMessage[] // Last 2 turns uncompacted +} +``` + +--- + +## Session Lifecycle + +### Session Start + +``` +session.start(projectPath, provider?, title?) → { sessionId } +``` + +1. Generate UUID for session +2. Create `~/.freecode/sessions/{sessionId}/` +3. Write `meta.json` with status "active" +4. Return sessionId to caller + +### Turn Execution + +Each turn: +1. Append user message to `messages.jsonl` +2. Run agent loop, streaming assistant messages to `messages.jsonl` +3. On tool call: append tool request +4. On tool result: append tool result +5. On turn complete: update `meta.json` (lastTurnAt, turnCount) + +### Interrupt Handling (Ctrl+C) + +On interrupt signal: +1. Mark current message in `messages.jsonl` with `"interrupted": true` +2. Update `meta.json` status → `"interrupted"` +3. Store interrupt point for resume detection + +### Session Resume + +``` +session.resume(sessionId) → SessionContext +``` + +Resume flow: +1. Load `meta.json` for session metadata +2. Stream-read `messages.jsonl` to reconstruct full history +3. Detect interrupted state: + - If last message has `"interrupted": true` → inject synthetic `"Continue from where you left off."` user message +4. If session has `memory.json` (compacted) → load summaries + preserved recent messages +5. Restore session state and continue + +### Session Fork + +``` +session.fork(sessionId, point?) → { newSessionId } +``` + +Creates a new session branching from current point. Copies `meta.json` with new ID and `parentId` reference. + +### Session Archive/Delete + +- **Archive**: Mark status `"archived"`, retain all data +- **Delete**: Mark status `"deleted"`, data retained until purge + +--- + +## Remote Sync (URL-Based Sharing) + +### Export (Upload to URL) + +``` +session.export(sessionId) → { url: string, expiresAt: number } +``` + +1. Serialize session: `meta.json` + `messages.jsonl` + `memory.json` +2. Compress (gzip) and POST to sync endpoint +3. Server returns short URL code (e.g., `https://sync.freecode.dev/a3f8b2`) +4. URL valid for 7 days by default + +### Import (Download from URL) + +``` +session.import(url) → { sessionId } +``` + +1. GET the URL, decompress response +2. Validate session structure +3. Create new session in `~/.freecode/sessions/` with new UUID +4. Mark as `"imported"` with original metadata preserved + +### Sync Endpoint (Configurable) + +Default: `https://sync.freecode.dev` +Configurable via `config.json`: +```typescript +{ + "syncEndpoint": "https://sync.freecode.dev", + "syncApiKey"?: string, // Optional for private sharing + "syncUrlExpiryDays": 7 +} +``` + +--- + +## Thread Store (SQLite + JSON Fallback) + +For structured queries (search, list, aggregation): + +**SQLite primary** (`~/.freecode/state/freecode.db`): +```sql +CREATE TABLE threads ( + id TEXT PRIMARY KEY, + title TEXT, + project_path TEXT, + provider TEXT, + status TEXT, + created_at INTEGER, + updated_at INTEGER, + last_turn_at INTEGER, + turn_count INTEGER DEFAULT 0, + parent_id TEXT +); + +CREATE TABLE turns ( + id TEXT PRIMARY KEY, + thread_id TEXT REFERENCES threads(id), + turn_number INTEGER, + prompt TEXT, + response TEXT, + created_at INTEGER, + duration_ms INTEGER, + tool_call_count INTEGER DEFAULT 0 +); + +CREATE TABLE tool_calls ( + id TEXT PRIMARY KEY, + turn_id TEXT REFERENCES turns(id), + tool_name TEXT, + args TEXT, -- JSON + result TEXT, + error TEXT, + duration_ms INTEGER, + sequence INTEGER +); +``` + +**JSON fallback** (`~/.freecode/state/store.json`) — when SQLite unavailable: +```typescript +interface JsonStore { + threads: Record + turns: Record + metadata: { version: number; lastUpdated: number } +} +``` + +**Factory pattern** — always try SQLite first: +```typescript +async function getThreadStore(): Promise { + try { + return await SQLiteThreadStore.create() + } catch { + return JsonThreadStore.create() + } +} +``` + +--- + +## Resume Picker + +On startup (if interrupted session detected or user runs `/resume`): + +1. Query `sessions/` for recent sessions with status `active` or `interrupted` +2. Display picker with: + - Session title + project path + - Last turn time (relative: "2 hours ago") + - Status badge: "Active" / "Interrupted" + - Turn count +3. User selects → `session.resume(selectedId)` + +Keyboard navigation: +- `↑/↓` — navigate +- `Enter` — resume selected +- `Ctrl+C` — exit picker, start fresh session + +--- + +## IPC Protocol (Session Operations) + +| Method | Params | Returns | Description | +|--------|--------|---------|-------------| +| `session.start` | `{ projectPath, provider?, title? }` | `{ sessionId }` | Start new session | +| `session.resume` | `{ sessionId }` | `{ sessionId }` | Resume existing session | +| `session.list` | `{ projectPath?, status? }` | `SessionMeta[]` | List sessions | +| `session.fork` | `{ sessionId, point? }` | `{ newSessionId }` | Fork session | +| `session.archive` | `{ sessionId }` | `void` | Archive session | +| `session.delete` | `{ sessionId }` | `void` | Delete session | +| `session.export` | `{ sessionId }` | `{ url, expiresAt }` | Upload to URL | +| `session.import` | `{ url }` | `{ sessionId }` | Download from URL | +| `memory.query` | `{ query, projectPath? }` | `MemoryEntry[]` | Search memory | +| `memory.buildPrompt` | `{ projectPath }` | `string` | Build memory context | + +--- + +## Error Handling + +| Failure | Recovery | +|---------|----------| +| JSONL write fails | Fall back to buffered write, flush on turn end | +| SQLite unavailable | Fall back to JSON store | +| Import corrupt data | Reject with validation errors, never partial import | +| Export endpoint down | Save export locally, queue for retry | +| Disk full | Warn user, suggest archive/delete old sessions | + +--- + +## v1 Scope + +### Included +- [x] Session storage at `~/.freecode/sessions/` +- [x] Message streaming to JSONL (append-only) +- [x] Interrupt handling (Ctrl+C marks session interrupted) +- [x] Resume with complete chat history +- [x] Resume picker UI +- [x] Session list/fork/archive/delete +- [x] URL-based export/import + +### Not in v1 +- [ ] Compaction service (summarize old messages) +- [ ] Session memory (auto-extract notes to MEMORY.md) +- [ ] Teleport (remote session viewing via WebSocket) +- [ ] Background jobs tied to session lifecycle + +--- + +## References + +- claude-code: `sessionStorage.ts`, `conversationRecovery.ts`, `teleport.tsx` +- opencode: `session.ts`, `storage.ts`, `session-replay.ts`, `sync.ts` \ No newline at end of file diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 255340d6..a787c50b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -25,6 +25,7 @@ export type { JsonRpcRequest, JsonRpcResponse, StreamResponse, + StreamEvent, MethodName, MethodParams, MethodResult, diff --git a/packages/shared/src/ipc/protocol.ts b/packages/shared/src/ipc/protocol.ts index 9ac73d15..e7a7e6ac 100644 --- a/packages/shared/src/ipc/protocol.ts +++ b/packages/shared/src/ipc/protocol.ts @@ -27,6 +27,15 @@ export type StreamResponse = | { type: "done"; content: string; toolName?: undefined; toolArgs?: undefined; toolResult?: undefined } | { type: "error"; content: string; toolName?: undefined; toolArgs?: undefined; toolResult?: undefined }; +export type StreamEvent = + | { type: "tool_start"; toolCallId: string; toolName: string; args: Record } + | { type: "tool_output"; toolCallId: string; content: string } + | { type: "tool_complete"; toolCallId: string; toolName: string; result: string; success: boolean; duration_ms?: number } + | { type: "thinking"; content: string } // Streaming thinking/reasoning + | { type: "text"; content: string } + | { type: "done"; content: string } + | { type: "error"; content: string }; + // ============================================================================= // IPC Method Signatures // =============================================================================