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
This commit is contained in:
github-actions[bot]
2026-02-22 05:37:01 +01:00
committed by GitHub
parent 4e697001c0
commit a3c944bd13
8 changed files with 170 additions and 1 deletions

View File

@@ -200,7 +200,7 @@ Each entry in `agents:` accepts:
| `model` | string | No | Model for agent creation | | `model` | string | No | Model for agent creation |
| `conversations` | object | No | Conversation routing config (shared vs per-channel) | | `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. | | `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.) | | `polling` | object | No | Per-agent polling config (Gmail, etc.) |
| `integrations` | object | No | Per-agent integrations (Google, etc.) | | `integrations` | object | No | Per-agent integrations (Google, etc.) |
@@ -464,6 +464,39 @@ features:
Enable scheduled tasks. See [Cron Setup](./cron-setup.md). 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/<agent-id>/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 <agent-id>`) 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) ### No-Reply (Opt-Out)
The agent can choose not to respond to a message by sending exactly: The agent can choose not to respond to a message by sending exactly:

View File

@@ -73,6 +73,7 @@ features:
enabled: false enabled: false
intervalMin: 30 intervalMin: 30
# skipRecentUserMin: 5 # Skip auto-heartbeats for N minutes after user message (0 disables) # 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) # Attachment handling (defaults to 20MB if omitted)
# attachments: # attachments:

View File

@@ -448,6 +448,35 @@ describe('normalizeAgents', () => {
expect(agents[1].displayName).toBe('👾 DevOps'); 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)', () => { it('should normalize onboarding-generated agents[] config (no legacy agent/channels)', () => {
// This matches the shape that onboarding now writes: agents[] at top level, // This matches the shape that onboarding now writes: agents[] at top level,
// with no legacy agent/channels/features fields. // with no legacy agent/channels/features fields.

View File

@@ -63,6 +63,7 @@ export interface AgentConfig {
promptFile?: string; // Path to prompt file (re-read each tick for live editing) promptFile?: string; // Path to prompt file (re-read each tick for live editing)
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.) target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
}; };
memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
maxToolCalls?: number; maxToolCalls?: number;
}; };
/** Polling config */ /** Polling config */
@@ -135,6 +136,7 @@ export interface LettaBotConfig {
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.) 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. 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) 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, displayName: config.agent?.displayName,
model, model,
channels, channels,
conversations: config.conversations,
features: config.features, features: config.features,
polling: config.polling, polling: config.polling,
integrations: config.integrations, integrations: config.integrations,

View File

@@ -261,6 +261,8 @@ export class LettaBot implements AgentSession {
], ],
cwd: this.config.workingDir, cwd: this.config.workingDir,
tools: [createManageTodoTool(this.getTodoAgentKey())], 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 // In bypassPermissions mode, canUseTool is only called for interactive
// tools (AskUserQuestion, ExitPlanMode). When no callback is provided // tools (AskUserQuestion, ExitPlanMode). When no callback is provided
// (background triggers), the SDK auto-denies interactive tools. // (background triggers), the SDK auto-denies interactive tools.
@@ -398,6 +400,7 @@ export class LettaBot implements AgentSession {
const newAgentId = await createAgent({ const newAgentId = await createAgent({
systemPrompt: SYSTEM_PROMPT, systemPrompt: SYSTEM_PROMPT,
memory: loadMemoryBlocks(this.config.agentName), memory: loadMemoryBlocks(this.config.agentName),
...(this.config.memfs !== undefined ? { memfs: this.config.memfs } : {}),
}); });
const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com';
this.store.setAgent(newAgentId, currentBaseUrl); this.store.setAgent(newAgentId, currentBaseUrl);

View File

@@ -164,4 +164,91 @@ describe('SDK session contract', () => {
expect(secondSession.close).toHaveBeenCalledTimes(1); expect(secondSession.close).toHaveBeenCalledTimes(1);
expect(vi.mocked(createSession)).toHaveBeenCalledTimes(2); 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');
});
}); });

View File

@@ -135,6 +135,9 @@ export interface BotConfig {
// Safety // Safety
maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100) 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 // Security
allowedUsers?: string[]; // Empty = allow all allowedUsers?: string[]; // Empty = allow all

View File

@@ -519,6 +519,9 @@ async function main() {
for (const agentConfig of agents) { for (const agentConfig of agents) {
console.log(`\n[Setup] Configuring agent: ${agentConfig.name}`); 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 // Create LettaBot for this agent
const bot = new LettaBot({ const bot = new LettaBot({
workingDir: globalConfig.workingDir, workingDir: globalConfig.workingDir,
@@ -527,6 +530,7 @@ async function main() {
disallowedTools: globalConfig.disallowedTools, disallowedTools: globalConfig.disallowedTools,
displayName: agentConfig.displayName, displayName: agentConfig.displayName,
maxToolCalls: agentConfig.features?.maxToolCalls, maxToolCalls: agentConfig.features?.maxToolCalls,
memfs: resolvedMemfs,
conversationMode: agentConfig.conversations?.mode || 'shared', conversationMode: agentConfig.conversations?.mode || 'shared',
heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active', heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active',
skills: { 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) // Apply explicit agent ID from config (before store verification)
let initialStatus = bot.getStatus(); let initialStatus = bot.getStatus();
if (agentConfig.id && !initialStatus.agentId) { if (agentConfig.id && !initialStatus.agentId) {