diff --git a/docs/railway-deploy.md b/docs/railway-deploy.md index f204810..bc53878 100644 --- a/docs/railway-deploy.md +++ b/docs/railway-deploy.md @@ -104,7 +104,7 @@ The Railway template includes a persistent volume mounted at `/data`. This is se - **Agent ID** - No need to set `LETTA_AGENT_ID` manually after first run - **Cron jobs** - Scheduled tasks survive restarts -- **Skills** - Downloaded skills persist +- **Skills** - Agent-scoped (`.letta/agents/.../skills`) and working-dir (`WORKING_DIR/.skills`) skills persist - **Attachments** - Downloaded media files ### Volume Size @@ -123,6 +123,8 @@ If you deploy manually from a fork instead of using the template, you'll need to LettaBot automatically detects `RAILWAY_VOLUME_MOUNT_PATH` and uses it for persistent data. +By default (when `WORKING_DIR` is unset), LettaBot uses `$RAILWAY_VOLUME_MOUNT_PATH/data` as the working directory, so `WORKING_DIR/.skills` is persisted across redeploys. Agent-scoped skills are also stored under `$RAILWAY_VOLUME_MOUNT_PATH/.letta/agents/.../skills`. + ## Remote Pairing Approval When using `pairing` DM policy on a cloud deployment, you need a way to approve new users without CLI access. diff --git a/docs/skills.md b/docs/skills.md index 1128a4a..d64e759 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -23,6 +23,8 @@ LettaBot scans these directories in priority order. Same-name skills at higher p | 4 | `skills/` (in lettabot repo) | Bundled | Ships with lettabot | | 5 (lowest) | `~/.agents/skills/` | skills.sh | Installed via [skills.sh](https://skills.sh) | +On Railway with a mounted volume, LettaBot stores `.letta` skill paths under `$RAILWAY_VOLUME_MOUNT_PATH/.letta/` by default, so agent-scoped and global skills survive redeploys. + Feature-gated skills are copied from source directories into the agent-scoped directory (`~/.letta/agents/{id}/skills/`) when a session is first acquired. The copy is idempotent -- skills already present in the target are skipped. ## Feature-gated skills @@ -114,6 +116,10 @@ When a session starts, `prependSkillDirsToPath()` in `src/skills/loader.ts` prep 1. **Agent-scoped skills** (`~/.letta/agents/{id}/skills/`) — feature-gated skills installed by `installSkillsToAgent()` on startup. 2. **Working-dir skills** (`WORKING_DIR/.skills/`) — skills enabled via `lettabot skills enable ` or the interactive `lettabot skills` wizard. +On Railway with a mounted volume, `WORKING_DIR` defaults to `$RAILWAY_VOLUME_MOUNT_PATH/data` when not explicitly set, so working-dir skills are persisted on the volume by default. + +On Railway with a mounted volume, agent-scoped skills are stored under `$RAILWAY_VOLUME_MOUNT_PATH/.letta/agents/{id}/skills/`. + Only directories containing at least one non-`.md` file are added. The prepend is idempotent — directories already on PATH are not duplicated. PATH is not restored after the call; the augmented PATH persists for the lifetime of the process, which is correct because the subprocess retains its inherited environment. To verify skill directories are present after startup, check the child subprocess's `/proc/[pid]/environ` (not the parent lettabot process, which shares the same augmented PATH). diff --git a/src/skills/loader.test.ts b/src/skills/loader.test.ts index 287d42e..8a3f3dc 100644 --- a/src/skills/loader.test.ts +++ b/src/skills/loader.test.ts @@ -12,8 +12,66 @@ import { isVoiceMemoConfigured, } from './loader.js'; +const ORIGINAL_WORKING_DIR = process.env.WORKING_DIR; +const ORIGINAL_RAILWAY_VOLUME_MOUNT_PATH = process.env.RAILWAY_VOLUME_MOUNT_PATH; + +async function importFreshLoader() { + vi.resetModules(); + return import('./loader.js'); +} + +function restoreLoaderPathEnv() { + if (ORIGINAL_WORKING_DIR === undefined) { + delete process.env.WORKING_DIR; + } else { + process.env.WORKING_DIR = ORIGINAL_WORKING_DIR; + } + + if (ORIGINAL_RAILWAY_VOLUME_MOUNT_PATH === undefined) { + delete process.env.RAILWAY_VOLUME_MOUNT_PATH; + } else { + process.env.RAILWAY_VOLUME_MOUNT_PATH = ORIGINAL_RAILWAY_VOLUME_MOUNT_PATH; + } +} + describe('skills loader', () => { + afterEach(() => { + restoreLoaderPathEnv(); + vi.resetModules(); + }); + + describe('working directory resolution', () => { + it('uses Railway volume-backed working dir when WORKING_DIR is not set', async () => { + process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway-volume'; + delete process.env.WORKING_DIR; + + const mod = await importFreshLoader(); + + expect(mod.WORKING_DIR).toBe('/railway-volume/data'); + expect(mod.WORKING_SKILLS_DIR).toBe('/railway-volume/data/.skills'); + }); + + it('prefers explicit WORKING_DIR over Railway volume mount', async () => { + process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway-volume'; + process.env.WORKING_DIR = '/custom/workdir'; + + const mod = await importFreshLoader(); + + expect(mod.WORKING_DIR).toBe('/custom/workdir'); + expect(mod.WORKING_SKILLS_DIR).toBe('/custom/workdir/.skills'); + }); + }); + describe('getAgentSkillsDir', () => { + it('uses Railway volume path for agent-scoped skills when mounted', async () => { + process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway-volume'; + + const mod = await importFreshLoader(); + const dir = mod.getAgentSkillsDir('agent-railway'); + + expect(dir).toBe('/railway-volume/.letta/agents/agent-railway/skills'); + }); + it('returns path containing agent ID', () => { const agentId = 'agent-test-123'; const dir = getAgentSkillsDir(agentId); diff --git a/src/skills/loader.ts b/src/skills/loader.ts index e970677..78b58c3 100644 --- a/src/skills/loader.ts +++ b/src/skills/loader.ts @@ -7,13 +7,17 @@ import { execSync } from 'node:child_process'; import { join, resolve, delimiter } from 'node:path'; import matter from 'gray-matter'; import type { SkillEntry, ClawdbotMetadata } from './types.js'; +import { getWorkingDir } from '../utils/paths.js'; // Skills directories (in priority order: project > agent > global > bundled > skills.sh) const HOME = process.env.HOME || process.env.USERPROFILE || ''; -export const WORKING_DIR = process.env.WORKING_DIR || '/tmp/lettabot'; +const LETTA_HOME = process.env.RAILWAY_VOLUME_MOUNT_PATH + ? join(process.env.RAILWAY_VOLUME_MOUNT_PATH, '.letta') + : join(HOME, '.letta'); +export const WORKING_DIR = getWorkingDir(); export const PROJECT_SKILLS_DIR = resolve(process.cwd(), '.skills'); export const WORKING_SKILLS_DIR = join(WORKING_DIR, '.skills'); // skills enabled via CLI -export const GLOBAL_SKILLS_DIR = join(HOME, '.letta', 'skills'); +export const GLOBAL_SKILLS_DIR = join(LETTA_HOME, 'skills'); export const SKILLS_SH_DIR = join(HOME, '.agents', 'skills'); // skills.sh global installs // Bundled skills from the lettabot repo itself @@ -29,7 +33,7 @@ export const BUNDLED_SKILLS_DIR = resolve(__dirname, '../../skills'); // lettabo * Get the agent-scoped skills directory for a specific agent */ export function getAgentSkillsDir(agentId: string): string { - return join(HOME, '.letta', 'agents', agentId, 'skills'); + return join(LETTA_HOME, 'agents', agentId, 'skills'); } /** @@ -69,7 +73,7 @@ export function getWorkingSkillExecutableDirs(): string[] { * Permanently prepend skill directories to PATH so that subprocesses * spawned subsequently inherit them. * - * Includes both agent-scoped skills (~/.letta/agents/{id}/skills/) and + * Includes both agent-scoped skills (.letta/agents/{id}/skills) and * working-dir skills (WORKING_DIR/.skills/) so that skills enabled via * `lettabot skills enable` are available without needing a feature-gate. * @@ -411,7 +415,7 @@ export function installSkillsToWorkingDir(workingDir: string, config: SkillsInst /** * Install feature-gated skills to the agent-scoped skills directory - * (~/.letta/agents/{agentId}/skills/) + * (.letta/agents/{agentId}/skills) * * This aligns with Letta Code CLI which uses agent-scoped skills. * Called after agent creation in bot.ts.