diff --git a/src/agent/sessionHistory.ts b/src/agent/sessionHistory.ts new file mode 100644 index 0000000..63582be --- /dev/null +++ b/src/agent/sessionHistory.ts @@ -0,0 +1,176 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { SessionStatsSnapshot } from "./stats"; + +export interface SessionHistoryEntry { + agent_id: string; + session_id: string; + timestamp: number; + project: string; + model: string; + provider: string; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + cached_input_tokens: number; + cache_write_tokens: number; + reasoning_tokens: number; + context_tokens?: number; + steps: number; + }; + duration: { + api_ms: number; + wall_ms: number; + }; + cost: { + type: "hosted" | "byok"; + credits_used?: number; + usd_byok?: number; + }; + message_count?: number; + tool_call_count?: number; + exit_reason?: string; +} + +interface SessionStartData { + agentId: string; + sessionId: string; + project: string; + model: string; + provider: string; +} + +/** + * Get the Letta Code history directory + */ +function getHistoryDir(): string { + const homeDir = os.homedir(); + return path.join(homeDir, ".letta-code"); +} + +/** + * Get the session history file path + */ +function getHistoryFilePath(): string { + return path.join(getHistoryDir(), "sessions.jsonl"); +} + +/** + * Ensure the history directory and file exist + */ +function ensureHistoryFile(): void { + const dir = getHistoryDir(); + const filePath = getHistoryFilePath(); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, ""); + } +} + +/** + * Record session start data (called when a session begins) + */ +export function recordSessionStart(data: SessionStartData): void { + ensureHistoryFile(); + + const entry: SessionHistoryEntry = { + agent_id: data.agentId, + session_id: data.sessionId, + timestamp: Date.now(), + project: data.project, + model: data.model, + provider: data.provider, + usage: { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + cached_input_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + steps: 0, + }, + duration: { + api_ms: 0, + wall_ms: 0, + }, + cost: { + type: "hosted", // Default, will be updated on session end + }, + }; + + const filePath = getHistoryFilePath(); + fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`); +} + +/** + * Update session entry with final stats (called when session ends) + * For now, this just updates an in-memory tracking - actual file updates + * would require rewriting the file or using a different storage approach + */ +export function recordSessionEnd( + agentId: string, + sessionId: string, + stats: SessionStatsSnapshot, + sessionInfo?: { project?: string; model?: string; provider?: string }, + cost?: { credits_used?: number; usd_byok?: number; type: "hosted" | "byok" }, + metadata?: { + messageCount?: number; + toolCallCount?: number; + exitReason?: string; + }, +): void { + // For now, we'll append a new "end" entry with the final stats + // A more sophisticated approach would update the existing entry + const entry: SessionHistoryEntry = { + agent_id: agentId, + session_id: sessionId, + timestamp: Date.now(), + project: sessionInfo?.project ?? "", + model: sessionInfo?.model ?? "", + provider: sessionInfo?.provider ?? "", + usage: { + prompt_tokens: stats.usage.promptTokens, + completion_tokens: stats.usage.completionTokens, + total_tokens: stats.usage.totalTokens, + cached_input_tokens: stats.usage.cachedInputTokens, + cache_write_tokens: stats.usage.cacheWriteTokens, + reasoning_tokens: stats.usage.reasoningTokens, + context_tokens: stats.usage.contextTokens, + steps: stats.usage.stepCount, + }, + duration: { + api_ms: stats.totalApiMs, + wall_ms: stats.totalWallMs, + }, + cost: cost || { type: "hosted" }, + message_count: metadata?.messageCount, + tool_call_count: metadata?.toolCallCount, + exit_reason: metadata?.exitReason, + }; + + // For v1, we just append end entries - in future, we could update the start entry + const filePath = getHistoryFilePath(); + fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`); +} + +/** + * Read all session history entries + */ +export function getSessionHistory(): SessionHistoryEntry[] { + const filePath = getHistoryFilePath(); + + if (!fs.existsSync(filePath)) { + return []; + } + + const content = fs.readFileSync(filePath, "utf-8"); + const lines = content.split("\n").filter((line) => line.trim()); + + return lines.map((line) => JSON.parse(line) as SessionHistoryEntry); +} diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 5e47a19..7f35a15 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -59,6 +59,7 @@ import { type ModelReasoningEffort, } from "../agent/model"; import { INTERRUPT_RECOVERY_ALERT } from "../agent/promptAssets"; +import { recordSessionEnd } from "../agent/sessionHistory"; import { SessionStats } from "../agent/stats"; import { INTERRUPTED_BY_USER, @@ -5493,6 +5494,28 @@ export default function App({ const stats = sessionStatsRef.current.getSnapshot(); telemetry.trackSessionEnd(stats, "exit_command"); + // Record session to local history file + try { + recordSessionEnd( + agentId, + telemetry.getSessionId(), + stats, + { + project: projectDirectory, + model: currentModelLabel ?? "", + provider: currentModelProvider ?? "", + }, + undefined, + { + messageCount: telemetry.getMessageCount(), + toolCallCount: telemetry.getToolCallCount(), + exitReason: "exit_command", + }, + ); + } catch { + // Non-critical, don't fail the exit + } + // Flush telemetry before exit await telemetry.flush(); @@ -5501,7 +5524,13 @@ export default function App({ setTimeout(() => { process.exit(0); }, 100); - }, [runEndHooks]); + }, [ + runEndHooks, + agentId, + projectDirectory, + currentModelLabel, + currentModelProvider, + ]); // Handler when user presses UP/ESC to load queue into input for editing const handleEnterQueueEditMode = useCallback(() => { @@ -7369,6 +7398,28 @@ export default function App({ const stats = sessionStatsRef.current.getSnapshot(); telemetry.trackSessionEnd(stats, "logout"); + // Record session to local history file + try { + recordSessionEnd( + agentId, + telemetry.getSessionId(), + stats, + { + project: projectDirectory, + model: currentModelLabel ?? "", + provider: currentModelProvider ?? "", + }, + undefined, + { + messageCount: telemetry.getMessageCount(), + toolCallCount: telemetry.getToolCallCount(), + exitReason: "logout", + }, + ); + } catch { + // Non-critical, don't fail the exit + } + // Flush telemetry before exit await telemetry.flush(); diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts index 549d987..d665c5b 100644 --- a/src/telemetry/index.ts +++ b/src/telemetry/index.ts @@ -238,6 +238,20 @@ class TelemetryManager { return this.sessionId; } + /** + * Get the current message count + */ + getMessageCount(): number { + return this.messageCount; + } + + /** + * Get the current tool call count + */ + getToolCallCount(): number { + return this.toolCallCount; + } + /** * Track session start */