From 4cc4928bb0ffa2e0c397fcb8225fbd42c4af61b0 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Mon, 15 Dec 2025 19:44:34 -0800 Subject: [PATCH] feat: add session context system reminder on first message (#229) Co-authored-by: Letta --- src/cli/App.tsx | 37 ++++- src/cli/helpers/sessionContext.ts | 243 ++++++++++++++++++++++++++++++ src/settings-manager.ts | 2 + 3 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 src/cli/helpers/sessionContext.ts diff --git a/src/cli/App.tsx b/src/cli/App.tsx index e59b863..7633bbf 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -360,6 +360,8 @@ export default function App({ >(null); const [llmConfig, setLlmConfig] = useState(null); const [agentName, setAgentName] = useState(null); + const [agentDescription, setAgentDescription] = useState(null); + const [agentLastRunAt, setAgentLastRunAt] = useState(null); const currentModelLabel = llmConfig?.model_endpoint_type && llmConfig?.model ? `${llmConfig.model_endpoint_type}/${llmConfig.model}` @@ -391,6 +393,9 @@ export default function App({ // Session stats tracking const sessionStatsRef = useRef(new SessionStats()); + // Track if we've sent the session context for this CLI session + const hasSentSessionContextRef = useRef(false); + // Show exit stats on exit const [showExitStats, setShowExitStats] = useState(false); @@ -617,6 +622,11 @@ export default function App({ const agent = await client.agents.retrieve(agentId); setLlmConfig(agent.llm_config); setAgentName(agent.name); + setAgentDescription(agent.description ?? null); + // Get last message timestamp from agent state if available + const lastRunCompletion = (agent as { last_run_completion?: string }) + .last_run_completion; + setAgentLastRunAt(lastRunCompletion ?? null); // Detect current toolset from attached tools const { detectToolsetFromAgent } = await import("../tools/toolset"); @@ -2418,8 +2428,29 @@ ${recentCommits} // Prepend skill unload reminder if skills are loaded (using cached flag) const skillUnloadReminder = getSkillUnloadReminder(); - // Combine reminders with content (plan mode first, then skill unload) - const allReminders = planModeReminder + skillUnloadReminder; + // Prepend session context on first message of CLI session (if enabled) + let sessionContextReminder = ""; + const sessionContextEnabled = settingsManager.getSetting( + "sessionContextEnabled", + ); + if (!hasSentSessionContextRef.current && sessionContextEnabled) { + const { buildSessionContext } = await import( + "./helpers/sessionContext" + ); + sessionContextReminder = buildSessionContext({ + agentInfo: { + id: agentId, + name: agentName, + description: agentDescription, + lastRunAt: agentLastRunAt, + }, + }); + hasSentSessionContextRef.current = true; + } + + // Combine reminders with content (session context first, then plan mode, then skill unload) + const allReminders = + sessionContextReminder + planModeReminder + skillUnloadReminder; const messageContent = allReminders && typeof contentParts === "string" ? allReminders + contentParts @@ -2553,6 +2584,8 @@ ${recentCommits} refreshDerived, agentId, agentName, + agentDescription, + agentLastRunAt, handleExit, isExecutingTool, queuedApprovalResults, diff --git a/src/cli/helpers/sessionContext.ts b/src/cli/helpers/sessionContext.ts new file mode 100644 index 0000000..f77ff5f --- /dev/null +++ b/src/cli/helpers/sessionContext.ts @@ -0,0 +1,243 @@ +// src/cli/helpers/sessionContext.ts +// Generates session context system reminder for the first message of each CLI session + +import { execSync } from "node:child_process"; +import { platform } from "node:os"; +import { LETTA_CLOUD_API_URL } from "../../auth/oauth"; +import { settingsManager } from "../../settings-manager"; +import { getVersion } from "../../version"; + +interface AgentInfo { + id: string; + name: string | null; + description?: string | null; + lastRunAt?: string | null; +} + +interface SessionContextOptions { + agentInfo: AgentInfo; + serverUrl?: string; +} + +/** + * Get the current local time in a human-readable format + */ +function getLocalTime(): string { + const now = new Date(); + return now.toLocaleString(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }); +} + +/** + * Get device type based on platform + */ +function getDeviceType(): string { + const p = platform(); + switch (p) { + case "darwin": + return "macOS"; + case "win32": + return "Windows"; + case "linux": + return "Linux"; + default: + return p; + } +} + +/** + * Format relative time from a date string + */ +function getRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffDays > 0) { + return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; + } + if (diffHours > 0) { + return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`; + } + if (diffMins > 0) { + return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`; + } + return "just now"; +} + +/** + * Safely execute a git command, returning null on failure + */ +function safeGitExec(command: string, cwd: string): string | null { + try { + return execSync(command, { cwd, encoding: "utf-8", stdio: "pipe" }).trim(); + } catch { + return null; + } +} + +/** + * Gather git information if in a git repository + * Returns truncated commits (3) and status (20 lines) + * Each field is gathered independently with fallbacks + */ +function getGitInfo(): { + isGitRepo: boolean; + branch?: string; + recentCommits?: string; + status?: string; +} { + const cwd = process.cwd(); + + try { + // Check if we're in a git repo + execSync("git rev-parse --git-dir", { cwd, stdio: "pipe" }); + + // Get current branch (with fallback) + const branch = safeGitExec("git branch --show-current", cwd) ?? "(unknown)"; + + // Get recent commits (3 commits with author, with fallback) + const recentCommits = + safeGitExec('git log --format="%h %s (%an)" -3', cwd) ?? + "(failed to get commits)"; + + // Get git status (truncate to 20 lines, with fallback) + const fullStatus = + safeGitExec("git status --short", cwd) ?? "(failed to get status)"; + const statusLines = fullStatus.split("\n"); + let status = fullStatus; + if (statusLines.length > 20) { + status = + statusLines.slice(0, 20).join("\n") + + `\n... and ${statusLines.length - 20} more files`; + } + + return { + isGitRepo: true, + branch, + recentCommits, + status: status || "(clean working tree)", + }; + } catch { + return { isGitRepo: false }; + } +} + +/** + * Build the full session context system reminder + * Returns empty string on any failure (graceful degradation) + */ +export function buildSessionContext(options: SessionContextOptions): string { + try { + const { agentInfo, serverUrl } = options; + const cwd = process.cwd(); + + // Gather info with safe fallbacks + let version = "unknown"; + try { + version = getVersion(); + } catch { + // version stays "unknown" + } + + let deviceType = "unknown"; + try { + deviceType = getDeviceType(); + } catch { + // deviceType stays "unknown" + } + + let localTime = "unknown"; + try { + localTime = getLocalTime(); + } catch { + // localTime stays "unknown" + } + + const gitInfo = getGitInfo(); + + // Get server URL + let actualServerUrl = LETTA_CLOUD_API_URL; + try { + const settings = settingsManager.getSettings(); + actualServerUrl = + serverUrl || + process.env.LETTA_BASE_URL || + settings.env?.LETTA_BASE_URL || + LETTA_CLOUD_API_URL; + } catch { + // actualServerUrl stays default + } + + // Format last run info + let lastRunInfo = "No previous messages"; + if (agentInfo.lastRunAt) { + try { + const lastRunDate = new Date(agentInfo.lastRunAt); + const localLastRun = lastRunDate.toLocaleString(); + const relativeTime = getRelativeTime(agentInfo.lastRunAt); + lastRunInfo = `${localLastRun} (${relativeTime})`; + } catch { + lastRunInfo = "(failed to parse last run time)"; + } + } + + // Build the context + let context = ` +This is an automated message providing context about the user's environment. +The user has just initiated a new connection via the [Letta Code CLI client](https://docs.letta.com/letta-code/index.md). + +## Device Information +- **Local time**: ${localTime} +- **Device type**: ${deviceType} +- **Letta Code version**: ${version} +- **Current working directory**: ${cwd} +`; + + // Add git info if available + if (gitInfo.isGitRepo) { + context += `- **Git repository**: Yes (branch: ${gitInfo.branch}) + +### Recent Commits +\`\`\` +${gitInfo.recentCommits} +\`\`\` + +### Git Status +\`\`\` +${gitInfo.status} +\`\`\` +`; + } else { + context += `- **Git repository**: No +`; + } + + // Add agent info + context += ` +## Agent Information (i.e. information about you) +- **Agent ID**: ${agentInfo.id} +- **Agent name**: ${agentInfo.name || "(unnamed)"} (the user can change this with /rename) +- **Agent description**: ${agentInfo.description || "(no description)"} (the user can change this with /description) +- **Last message**: ${lastRunInfo} +- **Server location**: ${actualServerUrl} +`; + + return context; + } catch { + // If anything fails catastrophically, return empty string + // This ensures the user's message still gets sent + return ""; + } +} diff --git a/src/settings-manager.ts b/src/settings-manager.ts index 7e376a8..9a0ad59 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -10,6 +10,7 @@ export interface Settings { lastAgent: string | null; tokenStreaming: boolean; enableSleeptime: boolean; + sessionContextEnabled: boolean; // Send device/agent context on first message of each session globalSharedBlockIds: Record; // DEPRECATED: kept for backwards compat profiles?: Record; // DEPRECATED: old format, kept for migration pinnedAgents?: string[]; // Array of agent IDs pinned globally @@ -38,6 +39,7 @@ const DEFAULT_SETTINGS: Settings = { lastAgent: null, tokenStreaming: false, enableSleeptime: false, + sessionContextEnabled: true, globalSharedBlockIds: {}, };