From 436938ca2513352e0aef1c2d3a8eb3c80b113bd9 Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Sat, 13 Jun 2026 00:02:55 +0200 Subject: [PATCH 1/2] Add Letta Code agent support Adds agent-shell-letta.el with a Letta Code agent configuration driven through the letta-code-acp adapter (wraps 'letta -p' over ACP). Letta agents are stateful with long-term memory, so alongside the standard start command this adds two Letta-specific entry points: agent-shell-letta-start-main-chat attaches to the agent's persistent main conversation, and agent-shell-letta-start-conversation spawns an isolated one. --- agent-shell-letta.el | 212 +++++++++++++++++++++++++++++++++++++++++++ agent-shell.el | 3 + 2 files changed, 215 insertions(+) create mode 100644 agent-shell-letta.el diff --git a/agent-shell-letta.el b/agent-shell-letta.el new file mode 100644 index 00000000..a38c5b04 --- /dev/null +++ b/agent-shell-letta.el @@ -0,0 +1,212 @@ +;;; agent-shell-letta.el --- Letta Code agent configuration -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Matias Forbord + +;; Author: Matias Forbord +;; URL: https://github.com/xenodium/agent-shell + +;; This package is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation; either version 3, or (at your option) +;; any later version. + +;; This package is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with GNU Emacs. If not, see . + +;;; Commentary: +;; +;; This file includes Letta Code-specific configurations. +;; +;; Letta agents are stateful: they keep long-term memory across +;; sessions and conversations. This integration drives Letta Code +;; through the `letta-code-acp' adapter, which wraps +;; `letta -p --output-format stream-json'. +;; +;; Letta-specific notes: +;; +;; - A Letta agent has a persistent "main chat" conversation plus any +;; number of side conversations. `agent-shell-letta-start-main-chat' +;; attaches to the former; `agent-shell-letta-start-conversation' +;; spawns a fresh one. +;; - Set `agent-shell-letta-agent-id' to pin shells to a specific agent. +;; + +;;; Code: + +(eval-when-compile + (require 'cl-lib)) +(require 'shell-maker) +(require 'acp) + +(declare-function agent-shell--indent-string "agent-shell") +(declare-function agent-shell-make-agent-config "agent-shell") +(autoload 'agent-shell-make-agent-config "agent-shell") +(declare-function agent-shell--make-acp-client "agent-shell") +(declare-function agent-shell--dwim "agent-shell") + +(defcustom agent-shell-letta-acp-command + '("letta-code-acp") + "Command and parameters for the Letta Code ACP adapter. + +The first element is the command name, and the rest are command parameters." + :type '(repeat string) + :group 'agent-shell) + +(defcustom agent-shell-letta-environment + nil + "Environment variables for the Letta Code ACP adapter. + +This should be a list of \"NAME=VALUE\" strings, typically built with +`agent-shell-make-environment-variables'. Use it to inject +LETTA_API_KEY for Letta Cloud, or leave credentials to the adapter's +own environment for local backends." + :type '(repeat string) + :group 'agent-shell) + +(defcustom agent-shell-letta-agent-id + nil + "Optional Letta agent ID to pin shell sessions to. + +When nil, the adapter lets `letta' pick an agent (resume last, or +create a new one)." + :type '(choice (const :tag "Let adapter choose" nil) + (string :tag "Agent ID")) + :group 'agent-shell) + +(defcustom agent-shell-letta-model + nil + "Optional Letta model handle (e.g. \"auto\")." + :type '(choice (const :tag "Default" nil) + (string :tag "Model handle")) + :group 'agent-shell) + +(defcustom agent-shell-letta-permission-mode + nil + "Optional Letta permission mode. + +When non-nil, passed to the adapter via the LETTA_PERMISSION_MODE +environment variable so the spawned `letta -p' uses it." + :type '(choice (const :tag "Letta default (unrestricted)" nil) + (const "standard") + (const "acceptEdits") + (const "unrestricted") + (const "memory")) + :group 'agent-shell) + +(defcustom agent-shell-letta-conversation-id + nil + "Default Letta conversation to attach to on session start. + +Set to an explicit \"conv-...\" id to resume that conversation +\(in which case `agent-shell-letta-agent-id' is ignored; headless +derives the agent from the conversation). Set to \"default\" to +attach to the agent's main chat. + +When nil (the default), the start command decides: the agent's main +chat via `agent-shell-letta-start-main-chat' or a fresh conversation +via `agent-shell-letta-start-conversation'." + :type '(choice (const :tag "Honor the start command (default)" nil) + (const :tag "Agent's main chat" "default") + (string :tag "Explicit conversation id")) + :group 'agent-shell) + +(defun agent-shell-letta-make-agent-config () + "Create a Letta Code agent configuration. + +Returns an agent configuration alist using `agent-shell-make-agent-config'." + (agent-shell-make-agent-config + :identifier 'letta + :mode-line-name "Letta" + :buffer-name "Letta" + :shell-prompt "Letta> " + :shell-prompt-regexp "Letta> " + :welcome-function #'agent-shell-letta--welcome-message + :client-maker (lambda (buffer) + (agent-shell-letta-make-client :buffer buffer)) + :install-instructions + "Install the adapter with 'npm install -g letta-code-acp', or +customize `agent-shell-letta-acp-command' to point at a local build, +e.g. '(\"node\" \"/path/to/letta-code-acp/dist/cli.js\").")) + +(defun agent-shell-letta-start-agent () + "Start an interactive Letta Code agent shell." + (interactive) + (agent-shell--dwim :config (agent-shell-letta-make-agent-config) + :new-shell t)) + +(defun agent-shell-letta-start-main-chat () + "Start a Letta Code agent shell attached to the agent's main chat. + +The main chat is the persistent top-level conversation for the pinned +agent (`agent-shell-letta-agent-id'). Shells opened with this command +attach to the same conversation and share its memory." + (interactive) + (let ((agent-shell-letta-conversation-id "default")) + (agent-shell--dwim :config (agent-shell-letta-make-agent-config) + :new-shell t))) + +(defun agent-shell-letta-start-conversation () + "Start a Letta Code agent shell in a fresh conversation. + +Spawns a new conversation under the pinned agent +\(`agent-shell-letta-agent-id'), separate from the agent's main chat +and from any other shell." + (interactive) + (let ((agent-shell-letta-conversation-id nil)) + (agent-shell--dwim :config (agent-shell-letta-make-agent-config) + :new-shell t))) + +(cl-defun agent-shell-letta-make-client (&key buffer) + "Create a Letta Code ACP client with BUFFER as context." + (unless buffer + (error "Missing required argument: :buffer")) + (let ((environment (copy-sequence (or agent-shell-letta-environment (list))))) + (when agent-shell-letta-agent-id + (push (format "LETTA_AGENT_ID=%s" agent-shell-letta-agent-id) + environment)) + (when agent-shell-letta-model + (push (format "LETTA_MODEL=%s" agent-shell-letta-model) + environment)) + (when agent-shell-letta-permission-mode + (push (format "LETTA_PERMISSION_MODE=%s" agent-shell-letta-permission-mode) + environment)) + (when agent-shell-letta-conversation-id + (push (format "LETTA_CONVERSATION_ID=%s" agent-shell-letta-conversation-id) + environment)) + (agent-shell--make-acp-client :command (car agent-shell-letta-acp-command) + :command-params (cdr agent-shell-letta-acp-command) + :environment-variables environment + :context-buffer buffer))) + +(defun agent-shell-letta--welcome-message (config) + "Return Letta welcome message using `shell-maker' CONFIG." + (let ((art (agent-shell--indent-string 4 (agent-shell-letta--ascii-art))) + (message (string-trim-left (shell-maker-welcome-message config) "\n"))) + (concat "\n\n" + art + "\n\n" + message))) + +(defun agent-shell-letta--ascii-art () + "Letta ASCII art." + (let* ((is-dark (eq (frame-parameter nil 'background-mode) 'dark)) + (text (string-trim " + ██╗ ███████╗████████╗████████╗ █████╗ + ██║ ██╔════╝╚══██╔══╝╚══██╔══╝██╔══██╗ + ██║ █████╗ ██║ ██║ ███████║ + ██║ ██╔══╝ ██║ ██║ ██╔══██║ + ███████╗███████╗ ██║ ██║ ██║ ██║ + ╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ +" "\n"))) + (propertize text 'font-lock-face (if is-dark + '(:foreground "#7fb3ff" :inherit fixed-pitch) + '(:foreground "#2a5db0" :inherit fixed-pitch))))) + +(provide 'agent-shell-letta) + +;;; agent-shell-letta.el ends here diff --git a/agent-shell.el b/agent-shell.el index 7124f149..f107ec86 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -71,6 +71,7 @@ (require 'agent-shell-hermes) (require 'agent-shell-kimi) (require 'agent-shell-kiro) +(require 'agent-shell-letta) (require 'agent-shell-mistral) (require 'agent-shell-openai) (require 'agent-shell-opencode) @@ -577,6 +578,7 @@ Goose, Cursor, CodeBuddy, Auggie, and others." (agent-shell-goose-make-agent-config) (agent-shell-kimi-make-config) (agent-shell-kiro-make-config) + (agent-shell-letta-make-agent-config) (agent-shell-mistral-make-config) (agent-shell-opencode-make-agent-config) (agent-shell-pi-make-agent-config) @@ -612,6 +614,7 @@ configuration alist for backwards compatibility." (const :tag "Hermes" hermes) (const :tag "Kimi" kimi) (const :tag "Kiro" kiro) + (const :tag "Letta Code" letta) (const :tag "Mistral" le-chat) (const :tag "OpenCode" opencode) (const :tag "Pi" pi) From 10ded93bf1619440c8959d91fe967cd8fdab9ddb Mon Sep 17 00:00:00 2001 From: Matias Forbord Date: Sat, 13 Jun 2026 01:04:18 +0200 Subject: [PATCH 2/2] Trim Letta customization surface to command and environment Per CONTRIBUTING guidance on limiting defcustom: agent id, model, and permission mode are plain environment passthroughs, so they are documented as LETTA_* variables for agent-shell-letta-environment instead of dedicated defcustoms. Conversation selection becomes an internal defvar bound by the start commands. --- agent-shell-letta.el | 97 ++++++++++++-------------------------------- 1 file changed, 25 insertions(+), 72 deletions(-) diff --git a/agent-shell-letta.el b/agent-shell-letta.el index a38c5b04..86b2adde 100644 --- a/agent-shell-letta.el +++ b/agent-shell-letta.el @@ -27,13 +27,17 @@ ;; through the `letta-code-acp' adapter, which wraps ;; `letta -p --output-format stream-json'. ;; -;; Letta-specific notes: +;; A Letta agent has a persistent "main chat" conversation plus any +;; number of side conversations. `agent-shell-letta-start-main-chat' +;; attaches to the former; `agent-shell-letta-start-conversation' +;; spawns a fresh one. ;; -;; - A Letta agent has a persistent "main chat" conversation plus any -;; number of side conversations. `agent-shell-letta-start-main-chat' -;; attaches to the former; `agent-shell-letta-start-conversation' -;; spawns a fresh one. -;; - Set `agent-shell-letta-agent-id' to pin shells to a specific agent. +;; The adapter is configured through environment variables set via +;; `agent-shell-letta-environment', for example: +;; +;; LETTA_API_KEY=... Letta Cloud credentials. +;; LETTA_AGENT_ID=agent-... Pin shells to a specific agent. +;; LETTA_MODEL=auto Model handle. ;; ;;; Code: @@ -63,57 +67,16 @@ The first element is the command name, and the rest are command parameters." This should be a list of \"NAME=VALUE\" strings, typically built with `agent-shell-make-environment-variables'. Use it to inject -LETTA_API_KEY for Letta Cloud, or leave credentials to the adapter's -own environment for local backends." +LETTA_API_KEY for Letta Cloud, LETTA_AGENT_ID to pin shells to a +specific agent, or LETTA_MODEL to select a model." :type '(repeat string) :group 'agent-shell) -(defcustom agent-shell-letta-agent-id - nil - "Optional Letta agent ID to pin shell sessions to. - -When nil, the adapter lets `letta' pick an agent (resume last, or -create a new one)." - :type '(choice (const :tag "Let adapter choose" nil) - (string :tag "Agent ID")) - :group 'agent-shell) +(defvar agent-shell-letta--conversation-id nil + "Letta conversation the next shell session attaches to. -(defcustom agent-shell-letta-model - nil - "Optional Letta model handle (e.g. \"auto\")." - :type '(choice (const :tag "Default" nil) - (string :tag "Model handle")) - :group 'agent-shell) - -(defcustom agent-shell-letta-permission-mode - nil - "Optional Letta permission mode. - -When non-nil, passed to the adapter via the LETTA_PERMISSION_MODE -environment variable so the spawned `letta -p' uses it." - :type '(choice (const :tag "Letta default (unrestricted)" nil) - (const "standard") - (const "acceptEdits") - (const "unrestricted") - (const "memory")) - :group 'agent-shell) - -(defcustom agent-shell-letta-conversation-id - nil - "Default Letta conversation to attach to on session start. - -Set to an explicit \"conv-...\" id to resume that conversation -\(in which case `agent-shell-letta-agent-id' is ignored; headless -derives the agent from the conversation). Set to \"default\" to -attach to the agent's main chat. - -When nil (the default), the start command decides: the agent's main -chat via `agent-shell-letta-start-main-chat' or a fresh conversation -via `agent-shell-letta-start-conversation'." - :type '(choice (const :tag "Honor the start command (default)" nil) - (const :tag "Agent's main chat" "default") - (string :tag "Explicit conversation id")) - :group 'agent-shell) +\"default\" attaches to the agent's main chat. nil spawns a fresh +conversation. Bound by the start commands; not user-facing.") (defun agent-shell-letta-make-agent-config () "Create a Letta Code agent configuration. @@ -142,22 +105,21 @@ e.g. '(\"node\" \"/path/to/letta-code-acp/dist/cli.js\").")) (defun agent-shell-letta-start-main-chat () "Start a Letta Code agent shell attached to the agent's main chat. -The main chat is the persistent top-level conversation for the pinned -agent (`agent-shell-letta-agent-id'). Shells opened with this command -attach to the same conversation and share its memory." +The main chat is the persistent top-level conversation of the Letta +agent. Shells opened with this command attach to the same +conversation and share its memory." (interactive) - (let ((agent-shell-letta-conversation-id "default")) + (let ((agent-shell-letta--conversation-id "default")) (agent-shell--dwim :config (agent-shell-letta-make-agent-config) :new-shell t))) (defun agent-shell-letta-start-conversation () "Start a Letta Code agent shell in a fresh conversation. -Spawns a new conversation under the pinned agent -\(`agent-shell-letta-agent-id'), separate from the agent's main chat -and from any other shell." +Spawns a new conversation, separate from the agent's main chat and +from any other shell." (interactive) - (let ((agent-shell-letta-conversation-id nil)) + (let ((agent-shell-letta--conversation-id nil)) (agent-shell--dwim :config (agent-shell-letta-make-agent-config) :new-shell t))) @@ -166,17 +128,8 @@ and from any other shell." (unless buffer (error "Missing required argument: :buffer")) (let ((environment (copy-sequence (or agent-shell-letta-environment (list))))) - (when agent-shell-letta-agent-id - (push (format "LETTA_AGENT_ID=%s" agent-shell-letta-agent-id) - environment)) - (when agent-shell-letta-model - (push (format "LETTA_MODEL=%s" agent-shell-letta-model) - environment)) - (when agent-shell-letta-permission-mode - (push (format "LETTA_PERMISSION_MODE=%s" agent-shell-letta-permission-mode) - environment)) - (when agent-shell-letta-conversation-id - (push (format "LETTA_CONVERSATION_ID=%s" agent-shell-letta-conversation-id) + (when agent-shell-letta--conversation-id + (push (format "LETTA_CONVERSATION_ID=%s" agent-shell-letta--conversation-id) environment)) (agent-shell--make-acp-client :command (car agent-shell-letta-acp-command) :command-params (cdr agent-shell-letta-acp-command)