From a3c944bd13c8ea7ce58643f8cff10937788c6cb7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 05:37:01 +0100 Subject: [PATCH] feat: expose memfs (memory filesystem) option in lettabot config and SDK session (#336) Adds features.memfs config key that controls whether the Letta Code CLI receives --memfs or --no-memfs when creating/resuming SDK sessions. This enables lettabot users to opt into git-backed memory filesystem (context repositories) for persistent local memory sync. - Config types: memfs?: boolean on AgentConfig.features, LettaBotConfig.features, BotConfig - Bot wiring: baseSessionOptions() and createAgent() pass memfs to SDK when defined - Main wiring: YAML config takes precedence, LETTABOT_MEMFS env var as fallback - Legacy fix: conversations passthrough in single-agent normalization - Tests: 3 memfs wiring tests (true/false/undefined), 2 conversations passthrough tests - Docs: configuration.md section with known limitations, example YAML Fixes #335 Written by Cameron and Letta Code "The best way to predict the future is to implement it." -- David Heinemeier Hansson --- docs/configuration.md | 35 ++++++++++- lettabot.example.yaml | 1 + src/config/normalize.test.ts | 29 +++++++++ src/config/types.ts | 3 + src/core/bot.ts | 3 + src/core/sdk-session-contract.test.ts | 87 +++++++++++++++++++++++++++ src/core/types.ts | 3 + src/main.ts | 10 +++ 8 files changed, 170 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index adc3681..ea6e584 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -200,7 +200,7 @@ Each entry in `agents:` accepts: | `model` | string | No | Model for agent creation | | `conversations` | object | No | Conversation routing config (shared vs per-channel) | | `channels` | object | No | Channel configs (same schema as top-level `channels:`). At least one agent must have channels. | -| `features` | object | No | Per-agent features (cron, heartbeat, maxToolCalls) | +| `features` | object | No | Per-agent features (cron, heartbeat, memfs, maxToolCalls) | | `polling` | object | No | Per-agent polling config (Gmail, etc.) | | `integrations` | object | No | Per-agent integrations (Google, etc.) | @@ -464,6 +464,39 @@ features: Enable scheduled tasks. See [Cron Setup](./cron-setup.md). +### Memory Filesystem (memfs) + +Memory filesystem (also known as **Context Repositories**) syncs your agent's memory blocks to local files in a git-backed directory. This enables: + +- **Persistent local memory**: Memory blocks are synced to `~/.letta/agents//memory/` as Markdown files +- **Git versioning**: Every change to memory is automatically versioned with informative commit messages +- **Direct editing**: Memory files can be edited with standard tools and synced back to the agent +- **Multi-agent collaboration**: Subagents can work in git worktrees and merge changes back + +```yaml +features: + memfs: true +``` + +When `memfs` is enabled, the SDK passes `--memfs` to the Letta Code CLI on each session. When set to `false`, `--no-memfs` is passed to explicitly disable it. When omitted (default), the agent's existing memfs setting is left unchanged. + +You can also enable memfs via environment variable (only `true` and `false` are recognized): + +```bash +LETTABOT_MEMFS=true npm start +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `features.memfs` | boolean | _(undefined)_ | Enable/disable memory filesystem. `true` enables, `false` disables, omit to leave unchanged. | + +#### Known Limitations + +- **Headless conflict resolution** ([letta-ai/letta-code#808](https://github.com/letta-ai/letta-code/issues/808)): If memory filesystem sync conflicts exist, the CLI exits with code 1 in headless mode (which is how lettabot runs). There is currently no way to resolve conflicts programmatically. **Workaround**: Run the agent interactively first (`letta --agent `) to resolve conflicts, then restart lettabot. +- **Windows paths** ([letta-ai/letta-code#914](https://github.com/letta-ai/letta-code/issues/914)): Path separator issues on Windows have been fixed in Letta Code, but ensure you're on the latest version. + +For more details, see the [Letta Code memory documentation](https://docs.letta.com/letta-code/memory/) and the [Context Repositories blog post](https://www.letta.com/blog/context-repositories). + ### No-Reply (Opt-Out) The agent can choose not to respond to a message by sending exactly: diff --git a/lettabot.example.yaml b/lettabot.example.yaml index d540720..647a671 100644 --- a/lettabot.example.yaml +++ b/lettabot.example.yaml @@ -73,6 +73,7 @@ features: enabled: false intervalMin: 30 # skipRecentUserMin: 5 # Skip auto-heartbeats for N minutes after user message (0 disables) + # memfs: true # Enable memory filesystem (git-backed context repository). Syncs memory blocks to local files. # Attachment handling (defaults to 20MB if omitted) # attachments: diff --git a/src/config/normalize.test.ts b/src/config/normalize.test.ts index 60be563..cbd387f 100644 --- a/src/config/normalize.test.ts +++ b/src/config/normalize.test.ts @@ -448,6 +448,35 @@ describe('normalizeAgents', () => { expect(agents[1].displayName).toBe('👾 DevOps'); }); + it('should pass through conversations config in legacy mode', () => { + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot' }, + channels: {}, + conversations: { + mode: 'per-channel', + heartbeat: 'dedicated', + }, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].conversations?.mode).toBe('per-channel'); + expect(agents[0].conversations?.heartbeat).toBe('dedicated'); + }); + + it('should pass through conversations as undefined when not set', () => { + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].conversations).toBeUndefined(); + }); + it('should normalize onboarding-generated agents[] config (no legacy agent/channels)', () => { // This matches the shape that onboarding now writes: agents[] at top level, // with no legacy agent/channels/features fields. diff --git a/src/config/types.ts b/src/config/types.ts index bc0f40e..9e73b7e 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -63,6 +63,7 @@ export interface AgentConfig { promptFile?: string; // Path to prompt file (re-read each tick for live editing) target?: string; // Delivery target ("telegram:123", "slack:C123", etc.) }; + memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions maxToolCalls?: number; }; /** Polling config */ @@ -135,6 +136,7 @@ export interface LettaBotConfig { target?: string; // Delivery target ("telegram:123", "slack:C123", etc.) }; inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths. + memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100) }; @@ -549,6 +551,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { displayName: config.agent?.displayName, model, channels, + conversations: config.conversations, features: config.features, polling: config.polling, integrations: config.integrations, diff --git a/src/core/bot.ts b/src/core/bot.ts index 098b0fc..a770f7b 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -261,6 +261,8 @@ export class LettaBot implements AgentSession { ], cwd: this.config.workingDir, tools: [createManageTodoTool(this.getTodoAgentKey())], + // Memory filesystem (context repository): true -> --memfs, false -> --no-memfs, undefined -> leave unchanged + ...(this.config.memfs !== undefined ? { memfs: this.config.memfs } : {}), // In bypassPermissions mode, canUseTool is only called for interactive // tools (AskUserQuestion, ExitPlanMode). When no callback is provided // (background triggers), the SDK auto-denies interactive tools. @@ -398,6 +400,7 @@ export class LettaBot implements AgentSession { const newAgentId = await createAgent({ systemPrompt: SYSTEM_PROMPT, memory: loadMemoryBlocks(this.config.agentName), + ...(this.config.memfs !== undefined ? { memfs: this.config.memfs } : {}), }); const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; this.store.setAgent(newAgentId, currentBaseUrl); diff --git a/src/core/sdk-session-contract.test.ts b/src/core/sdk-session-contract.test.ts index 4c949b3..4a310eb 100644 --- a/src/core/sdk-session-contract.test.ts +++ b/src/core/sdk-session-contract.test.ts @@ -164,4 +164,91 @@ describe('SDK session contract', () => { expect(secondSession.close).toHaveBeenCalledTimes(1); expect(vi.mocked(createSession)).toHaveBeenCalledTimes(2); }); + + it('passes memfs: true to createSession when config sets memfs true', async () => { + const mockSession = { + initialize: vi.fn(async () => undefined), + send: vi.fn(async (_message: unknown) => undefined), + stream: vi.fn(() => + (async function* () { + yield { type: 'assistant', content: 'ack' }; + yield { type: 'result', success: true }; + })() + ), + close: vi.fn(() => undefined), + agentId: 'agent-contract-test', + conversationId: 'conversation-contract-test', + }; + + vi.mocked(createSession).mockReturnValue(mockSession as never); + + const bot = new LettaBot({ + workingDir: join(dataDir, 'working'), + allowedTools: [], + memfs: true, + }); + + await bot.sendToAgent('test'); + + const opts = vi.mocked(createSession).mock.calls[0][1]; + expect(opts).toHaveProperty('memfs', true); + }); + + it('passes memfs: false to createSession when config sets memfs false', async () => { + const mockSession = { + initialize: vi.fn(async () => undefined), + send: vi.fn(async (_message: unknown) => undefined), + stream: vi.fn(() => + (async function* () { + yield { type: 'assistant', content: 'ack' }; + yield { type: 'result', success: true }; + })() + ), + close: vi.fn(() => undefined), + agentId: 'agent-contract-test', + conversationId: 'conversation-contract-test', + }; + + vi.mocked(createSession).mockReturnValue(mockSession as never); + + const bot = new LettaBot({ + workingDir: join(dataDir, 'working'), + allowedTools: [], + memfs: false, + }); + + await bot.sendToAgent('test'); + + const opts = vi.mocked(createSession).mock.calls[0][1]; + expect(opts).toHaveProperty('memfs', false); + }); + + it('omits memfs key from createSession options when config memfs is undefined', async () => { + const mockSession = { + initialize: vi.fn(async () => undefined), + send: vi.fn(async (_message: unknown) => undefined), + stream: vi.fn(() => + (async function* () { + yield { type: 'assistant', content: 'ack' }; + yield { type: 'result', success: true }; + })() + ), + close: vi.fn(() => undefined), + agentId: 'agent-contract-test', + conversationId: 'conversation-contract-test', + }; + + vi.mocked(createSession).mockReturnValue(mockSession as never); + + const bot = new LettaBot({ + workingDir: join(dataDir, 'working'), + allowedTools: [], + // memfs intentionally omitted + }); + + await bot.sendToAgent('test'); + + const opts = vi.mocked(createSession).mock.calls[0][1]; + expect(opts).not.toHaveProperty('memfs'); + }); }); diff --git a/src/core/types.ts b/src/core/types.ts index b40c818..4f35ad7 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -135,6 +135,9 @@ export interface BotConfig { // Safety maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100) + // Memory filesystem (context repository) + memfs?: boolean; // true -> --memfs, false -> --no-memfs, undefined -> leave unchanged + // Security allowedUsers?: string[]; // Empty = allow all diff --git a/src/main.ts b/src/main.ts index b174273..7230618 100644 --- a/src/main.ts +++ b/src/main.ts @@ -519,6 +519,9 @@ async function main() { for (const agentConfig of agents) { console.log(`\n[Setup] Configuring agent: ${agentConfig.name}`); + // Resolve memfs: YAML config takes precedence, then env var, then undefined (leave unchanged) + const resolvedMemfs = agentConfig.features?.memfs ?? (process.env.LETTABOT_MEMFS === 'true' ? true : process.env.LETTABOT_MEMFS === 'false' ? false : undefined); + // Create LettaBot for this agent const bot = new LettaBot({ workingDir: globalConfig.workingDir, @@ -527,6 +530,7 @@ async function main() { disallowedTools: globalConfig.disallowedTools, displayName: agentConfig.displayName, maxToolCalls: agentConfig.features?.maxToolCalls, + memfs: resolvedMemfs, conversationMode: agentConfig.conversations?.mode || 'shared', heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active', skills: { @@ -535,6 +539,12 @@ async function main() { }, }); + // Log memfs config (from either YAML or env var) + if (resolvedMemfs !== undefined) { + const source = agentConfig.features?.memfs !== undefined ? '' : ' (from LETTABOT_MEMFS env)'; + console.log(`[Agent:${agentConfig.name}] memfs: ${resolvedMemfs ? 'enabled' : 'disabled'}${source}`); + } + // Apply explicit agent ID from config (before store verification) let initialStatus = bot.getStatus(); if (agentConfig.id && !initialStatus.agentId) {