Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,11 @@ nerve (single Python process)

## Configuration

Two config files:
- `config.yaml` — Template settings (committed)
- `config.local.yaml` — Secrets and overrides (gitignored)
Three layers, lowest precedence first:
- `<workspace>/config/settings.yaml` — shareable settings, git-tracked with the
workspace (this is the layer you review in a PR and sync between machines)
- `config.yaml` — machine-local settings (gitignored)
- `config.local.yaml` — secrets and personal overrides (gitignored)

See [docs/config.md](docs/config.md) for all options.

Expand Down
13 changes: 8 additions & 5 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,16 @@ sync:

# Memory (memU)
memory:
chat_model: claude-sonnet-4-6
recall_model: claude-sonnet-4-6
# embed_model: text-embedding-3-small # Only needed with openai_api_key

# Cron
cron:
system_file: ~/.nerve/cron/system.yaml # Managed by 'nerve init' — system crons
jobs_file: ~/.nerve/cron/jobs.yaml # Your custom crons — never touched by Nerve
# Cron — no keys needed. Cron config lives in <workspace>/config/cron/
# (system.yaml, jobs.yaml, gates/) and is resolved from the workspace, with a
# fallback to the legacy ~/.nerve/cron for un-migrated installs.
#
# Do NOT pin system_file/jobs_file here unless you really mean it: an explicit
# value wins outright, so it disables both the workspace location and the
# fallback, and `nerve init` will keep regenerating a system.yaml nothing reads.

# Workflow runs — budget-capped multi-agent jobs (Claude Workflow tool or
# Codex Ultracode) in dedicated tracked sessions. Nerve meters real dollar
Expand Down
2 changes: 1 addition & 1 deletion docs/codex-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ client metadata.
## Enabling

```yaml
# config.yaml
# <workspace>/config/settings.yaml
sync:
codex:
enabled: true
Expand Down
5 changes: 3 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,8 +494,9 @@ Manual commands (run regardless of `enabled`):

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `cron.system_file` | path | `~/.nerve/cron/system.yaml` | System cron jobs (managed by `nerve init`) |
| `cron.jobs_file` | path | `~/.nerve/cron/jobs.yaml` | User-defined custom cron jobs |
| `cron.system_file` | path | `<workspace>/config/cron/system.yaml` (falls back to `~/.nerve/cron/system.yaml` for un-migrated installs) | System cron jobs (managed by `nerve init`) |
| `cron.jobs_file` | path | `<workspace>/config/cron/jobs.yaml` (falls back to `~/.nerve/cron/jobs.yaml`) | User-defined custom cron jobs |
| `cron.gate_plugins_dir` | path | `<workspace>/config/cron/gates` (falls back to `~/.nerve/cron/gates`) | Drop-in custom gate plugin directory |

## Workflow Runs

Expand Down
28 changes: 19 additions & 9 deletions docs/cron.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ Nerve uses APScheduler for in-process async job scheduling. Jobs can run in isol

## Two-File Layout

Cron jobs live in two YAML files under `~/.nerve/cron/`:
Cron jobs live in two YAML files under `<workspace>/config/cron/` (the
git-syncable workspace subtree):

| File | Purpose | Managed by |
|------|---------|------------|
| `system.yaml` | Built-in crons (core + productivity) | `nerve init` — safe to regenerate |
| `jobs.yaml` | Your custom crons | You — Nerve never touches this file |

> **Location & migration.** New installs write cron config to
> `<workspace>/config/cron/`. Installs that still have the legacy
> `~/.nerve/cron/` keep working: if the workspace location doesn't exist yet,
> Nerve reads from `~/.nerve/cron/` automatically. You can also pin the paths
> explicitly with `cron.jobs_file` / `cron.system_file` / `cron.gate_plugins_dir`.

Both files use the same format. On startup, CronService loads and merges both:
- If a job ID appears in both files, the **user version wins** (with a warning in the log).
- Old installs with everything in `jobs.yaml` still work — if `system.yaml` doesn't exist, all jobs load from `jobs.yaml`.
Expand All @@ -22,7 +29,7 @@ Running `nerve init` on an existing install regenerates `system.yaml` (e.g., to
## Job Definition

```yaml
# ~/.nerve/cron/jobs.yaml (or system.yaml — same format)
# <workspace>/config/cron/jobs.yaml (or system.yaml — same format)
jobs:
- id: morning-briefing
schedule: "30 11 * * *" # 11:30 AM daily
Expand Down Expand Up @@ -65,7 +72,7 @@ prompt definition.

- Relative paths resolve against the directory of the YAML file the job was
loaded from (e.g. `prompts/repo-watch.md` next to `jobs.yaml` →
`~/.nerve/cron/prompts/repo-watch.md`). Absolute paths and `~` work too.
`<workspace>/config/cron/prompts/repo-watch.md`). Absolute paths and `~` work too.
- The file is read fresh on **every run** — edits take effect on the next
trigger without a restart.
- If both `prompt` and `prompt_file` are set, the file wins; the inline
Expand Down Expand Up @@ -183,15 +190,15 @@ immediately. This is the right path for gates that ship with Nerve.
### Custom gate plugins (drop-in)

To add your **own** gate without editing core source, drop a `.py` file into
the gate-plugins directory — `~/.nerve/cron/gates/` by default (overridable via
the gate-plugins directory — `<workspace>/config/cron/gates/` by default (overridable via
the `cron.gate_plugins_dir` config key). On daemon startup Nerve imports each
file and registers every `CronGate` subclass it defines with a non-empty
`type`. After that, `run_if` can reference your gate by `type` exactly like a
built-in. Because this never touches `nerve/cron/gates.py`, your custom gates
don't conflict when you pull Nerve upstream.

```python
# ~/.nerve/cron/gates/stale_tasks.py
# <workspace>/config/cron/gates/stale_tasks.py
from nerve.cron.gates import CronGate, GateContext


Expand All @@ -214,7 +221,7 @@ class StaleTasksGate(CronGate):
```

```yaml
# ~/.nerve/cron/jobs.yaml — reference it like any built-in gate
# <workspace>/config/cron/jobs.yaml — reference it like any built-in gate
run_if:
- type: stale_tasks
min_age_minutes: 60
Expand Down Expand Up @@ -302,13 +309,16 @@ nerve cron morning-briefing

# Check cron status
nerve doctor
# [OK] System crons: ~/.nerve/cron/system.yaml (3/5 enabled)
# [OK] User crons: ~/.nerve/cron/jobs.yaml (1 jobs)
# [OK] Cron jobs: 3/5 enabled, 1 overridden in jobs.yaml
#
# system.yaml and jobs.yaml are merged first (a user job overrides a system
# job with the same id), so the counts are for the merged set. If either file
# can't be parsed, doctor falls back to just listing the paths it found.
```

## Built-in System Crons

These ship in `~/.nerve/cron/system.yaml` and are managed by `nerve init`. Running `nerve init` regenerates this file (e.g., to pick up updated prompts from a Nerve update) without touching your custom `jobs.yaml`.
These ship in `<workspace>/config/cron/system.yaml` and are managed by `nerve init`. Running `nerve init` regenerates this file (e.g., to pick up updated prompts from a Nerve update) without touching your custom `jobs.yaml`.

| Job | Schedule | Session Mode | Description | Personal | Worker |
|-----|----------|-------------|-------------|:--------:|:------:|
Expand Down
18 changes: 13 additions & 5 deletions docs/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ memU provides semantic search over conversations and workspace files. It runs em

### Categories

Memory items are organized into categories defined in `config.yaml`. Categories are seeded on startup — only missing ones are created, so adding new entries is safe.
Memory items are organized into categories defined in `<workspace>/config/settings.yaml`
(the shareable layer — `config.yaml` can still override them per machine).
Categories are seeded on startup — only missing ones are created, so adding new entries is safe.

```yaml
memory:
Expand Down Expand Up @@ -85,15 +87,21 @@ memU uses two or three LLM profiles depending on configuration:

When no OpenAI key is configured, memU uses **LLM-based recall** instead of vector search — the Chat profile ranks memories directly, requiring no embeddings. This uses more Anthropic API tokens per recall but removes the OpenAI dependency entirely.

Config in `config.yaml`:
Config in `<workspace>/config/settings.yaml`:
```yaml
memory:
chat_model: claude-sonnet-4-6 # recall routing
fast_model: claude-haiku-4-5-20251001 # extraction & categorization
# embed_model: text-embedding-3-small # only needed with openai_api_key
recall_model: claude-sonnet-4-6 # recall routing
memorize_model: claude-sonnet-4-6 # writing memories back
fast_model: claude-haiku-4-5-20251001 # extraction & categorization
# embed_model: text-embedding-3-small # only needed with openai_api_key
categories: [...] # see Categories section above
```

> On a **Bedrock** install the three model keys are machine-local instead:
> `nerve init` writes region-scoped inference-profile IDs into `config.yaml`,
> which shadows `settings.yaml`. The prefix must match the configured region
> or every call 400s, so they can't be shared. `categories` stays portable.

### Event Date Resolution

After a conversation is indexed, Nerve resolves temporal context for extracted event items. Event items get their `happened_at` field set via an LLM call (using `fast_model` / Haiku) that parses dates from the content. Non-event items (profiles, knowledge, behavior) stay timeless.
Expand Down
12 changes: 10 additions & 2 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,18 @@ cp -r $OPENCLAW_WS/memory/* $NERVE_WS/memory/
Convert OpenClaw's `~/.openclaw/cron/jobs.json` to Nerve's YAML format:

```bash
mkdir -p ~/.nerve/cron
mkdir -p $NERVE_WS/config/cron
```

Create `~/.nerve/cron/jobs.yaml` from your existing jobs. The format changes from:
Create `$NERVE_WS/config/cron/jobs.yaml` from your existing jobs — cron config
lives in the workspace so it can be reviewed and synced like the rest of it.

> Nerve still reads the legacy `~/.nerve/cron/` when the workspace location has
> no job files, so an older guide that put them there is not broken. But once
> you (or `nerve init`) create `$NERVE_WS/config/cron/jobs.yaml`, the fallback
> stops firing — don't split jobs across both.

The format changes from:

```json
{
Expand Down
2 changes: 1 addition & 1 deletion docs/plans.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ User reviews (via /plans UI or chat tools)

## Cron Job

Defined in `~/.nerve/cron/jobs.yaml` as `task-planner`:
Defined in `<workspace>/config/cron/jobs.yaml` as `task-planner`:

- **Schedule:** Every 4 hours (`0 */4 * * *`)
- **Session mode:** Persistent (keeps context for revisions)
Expand Down
5 changes: 3 additions & 2 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,12 @@ You can re-run `nerve init` at any time — it's safe on existing installations.

**What gets overwritten:**
- `config.yaml` and `config.local.yaml` — regenerated from your choices
- `~/.nerve/cron/system.yaml` — regenerated (picks up new built-in cron prompts from Nerve updates)
- `<workspace>/config/cron/system.yaml` — regenerated (picks up new built-in cron prompts from Nerve updates)

**What's preserved:**
- All workspace files (`SOUL.md`, `IDENTITY.md`, `USER.md`, `MEMORY.md`, skills, tasks, etc.)
- `~/.nerve/cron/jobs.yaml` — your custom crons are never touched
- `<workspace>/config/cron/jobs.yaml` — your custom crons are never touched
- `<workspace>/config/settings.yaml` — only the keys `nerve init` generates are rewritten; anything else you added stays
- `~/.nerve/nerve.db` and `~/.nerve/memu.sqlite` — databases are preserved

When you run `nerve init` on an existing install, it prompts: *"Nerve is already configured. Re-run setup?"* The `--if-needed` flag skips setup entirely if already configured (useful in Docker entrypoints).
Expand Down
2 changes: 1 addition & 1 deletion docs/sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ if ms.enabled:
))
```

4. **Add config** to `config.yaml`:
4. **Add config** to `<workspace>/config/settings.yaml`:
```yaml
sync:
mysource:
Expand Down
7 changes: 3 additions & 4 deletions docs/worker-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ On first boot, Nerve detects that `TASK.md` contains a raw description (no `## M
- `## Approval` — what needs human approval vs autonomous action
- `## References` — links to docs, APIs, tools
3. **Create skills** — writes domain-specific procedures as reusable skills (`skill_create`)
4. **Configure cron jobs** — adds monitoring and task-specific crons to `~/.nerve/cron/jobs.yaml`
4. **Configure cron jobs** — adds monitoring and task-specific crons to `<workspace>/config/cron/jobs.yaml`
5. **Create initial tasks** — files setup tasks that need manual work (credentials, access tokens, etc.)
6. **Notify** — sends a notification that onboarding is complete with a summary of what was configured

Expand Down Expand Up @@ -165,8 +165,7 @@ Run `nerve doctor` to verify the worker is healthy:
nerve doctor
# [OK] Config loaded
# [OK] Database schema: v14
# [OK] System crons: ~/.nerve/cron/system.yaml (3/4 enabled)
# [OK] User crons: ~/.nerve/cron/jobs.yaml (2 jobs)
# [OK] Cron jobs: 3/4 enabled, 1 overridden in jobs.yaml
# [OK] Proxy: running on port 8317
```

Expand All @@ -185,7 +184,7 @@ Workers communicate through the notification system:
- **`notify`** — status updates, completion alerts, issues found
- **`ask_user`** — questions that need decisions (rendered as buttons in web UI and Telegram)

Priority levels: `urgent`, `high`, `normal`, `low`. Configure quiet hours in `config.yaml` to avoid late-night pings.
Priority levels: `urgent`, `high`, `normal`, `low`. Configure quiet hours in `<workspace>/config/settings.yaml` to avoid late-night pings.

### Logs

Expand Down
2 changes: 1 addition & 1 deletion docs/workflow-runs.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ A cron job may declare a `workflow` block **instead of** `prompt` — each
trigger starts a workflow run:

```yaml
# ~/.nerve/cron/jobs.yaml
# <workspace>/config/cron/jobs.yaml
jobs:
- id: nightly-sample-audit
schedule: "0 3 * * *"
Expand Down
3 changes: 2 additions & 1 deletion nerve/agent/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,8 @@ async def _run_worker_onboarding(self) -> None:
"Each skill should have clear step-by-step instructions for a procedure\n"
"(e.g., 'how to query the monitoring API', 'how to debug a deployment failure').\n\n"
"## Step 4: Configure Cron Jobs\n\n"
"Set up monitoring cron jobs by editing `~/.nerve/cron/jobs.yaml`.\n"
"Set up monitoring cron jobs by editing `<workspace>/config/cron/jobs.yaml`\n"
"(legacy installs may still use `~/.nerve/cron/jobs.yaml`).\n"
"This is the Nerve cron system — NOT the Anthropic SDK or system crontab.\n\n"
"The YAML format is:\n"
"```yaml\n"
Expand Down
57 changes: 37 additions & 20 deletions nerve/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -1705,11 +1705,11 @@ def _apply(self) -> None:
self._write_config_local_yaml()
click.secho(" ✓", fg="green")

# 9. Create the machine-local state directory
# 9. Create the machine-local state directory. Cron config no longer
# lives here — it's in workspace/config/cron.
click.echo(f" Setting up {paths.home_label()}/...", nl=False)
nerve_dir = paths.nerve_home()
nerve_dir.mkdir(parents=True, exist_ok=True)
(nerve_dir / "cron").mkdir(parents=True, exist_ok=True)
click.secho(" ✓", fg="green")

# 10. Write cron jobs
Expand Down Expand Up @@ -2060,9 +2060,9 @@ def _build_config_layers(
else _WORKER_MEMORY_CATEGORIES
),
},
# No "cron" block: CronConfig already defaults to paths.cron_dir().
# Pinning literal ~/.nerve paths here overrode NERVE_HOME and sent
# the daemon looking somewhere `nerve init` had not written.
# Cron config lives in workspace/config/cron (git-syncable); the
# loader resolves it from the workspace automatically, so no explicit
# cron paths are written here.
"sessions": {
"sticky_period_minutes": 120,
"archive_after_days": 30,
Expand Down Expand Up @@ -2358,28 +2358,43 @@ def _write_cron_jobs(self) -> None:
job["run_if"] = cron["run_if"]
jobs.append(job)

# Write system crons (managed by nerve init, safe to regenerate).
# Must go through paths.cron_dir() -- a literal ~/.nerve here ignores
# NERVE_HOME, so a relocated instance writes its jobs into the default
# state dir while the daemon reads them from the relocated one.
cron_dir = paths.cron_dir()
# Cron config lives in the git-syncable workspace/config/cron subtree.
# Via _workspace_dir for the reason spelled out there: expanding only
# `~` here sent a `workspace: ${VAR}` install's jobs to a literal
# "./${VAR}/config/cron" beside the process CWD, where nothing loads
# them, while settings.yaml landed correctly and the wizard reported
# success.
cron_dir = self._workspace_dir() / "config" / "cron"
cron_dir.mkdir(parents=True, exist_ok=True)
(cron_dir / "gates").mkdir(parents=True, exist_ok=True)

# Write system crons (managed by nerve init, safe to regenerate)
system_file = cron_dir / "system.yaml"
system_file.parent.mkdir(parents=True, exist_ok=True)

with open(system_file, "w", encoding="utf-8") as f:
f.write("# Nerve — System Cron Jobs\n")
f.write("# Managed by 'nerve init'. Safe to re-generate.\n")
f.write("# To add custom crons, use jobs.yaml instead.\n\n")
yaml.safe_dump({"jobs": jobs}, f, default_flow_style=False, sort_keys=False)

# Create empty jobs.yaml scaffold if it doesn't exist
# Create jobs.yaml scaffold if it doesn't exist. If this is an upgrade
# from a legacy install, preserve the user's existing custom crons by
# copying the legacy jobs.yaml rather than writing a blank placeholder
# (a blank one here would shadow the legacy jobs — see _resolve_cron_dir).
jobs_file = cron_dir / "jobs.yaml"
if not jobs_file.exists():
with open(jobs_file, "w", encoding="utf-8") as f:
f.write("# Nerve — Custom Cron Jobs\n")
f.write("# Add your own cron jobs here. Nerve will never overwrite this file.\n")
f.write("# Format is the same as system.yaml — see it for examples.\n\n")
f.write("jobs: []\n")
legacy_jobs = paths.cron_dir() / "jobs.yaml"
if legacy_jobs.exists():
shutil.copy2(legacy_jobs, jobs_file)
click.echo(
f"\n Migrated custom crons from {legacy_jobs} to {jobs_file}",
)
else:
with open(jobs_file, "w", encoding="utf-8") as f:
f.write("# Nerve — Custom Cron Jobs\n")
f.write("# Add your own cron jobs here. Nerve will never overwrite this file.\n")
f.write("# Format is the same as system.yaml — see it for examples.\n\n")
f.write("jobs: []\n")

# --- Preflight ---

Expand Down Expand Up @@ -2894,8 +2909,9 @@ def _build_docker_compose(
| Path | Contents | Writable | Notes |
|------|----------|----------|-------|
| `/nerve` | Nerve source code **and config** | ✓ (bind mount) | `pyproject.toml`, `nerve/` package, `web/` — the full repo. The config directory resolves to the working directory, so `config.yaml` and `config.local.yaml` live here. |
| `{_DOCKER_NERVE_HOME}` | Machine-local state (`$NERVE_HOME`) | ✓ (bind mount) | Databases, logs, PID, caches, `cron/` jobs. Never synced. |
| `{_DOCKER_WORKSPACE}` | Your workspace (`$NERVE_WORKSPACE`) | ✓ (bind mount) | AGENTS.md, SOUL.md, MEMORY.md, skills — where you live. |
| `{_DOCKER_NERVE_HOME}` | Machine-local state (`$NERVE_HOME`) | ✓ (bind mount) | Databases, logs, PID, caches. Never synced. |
| `{_DOCKER_WORKSPACE}` | Your workspace (`$NERVE_WORKSPACE`) | ✓ (bind mount) | AGENTS.md, SOUL.md, MEMORY.md, skills, and `config/` — where you live. |
| `{_DOCKER_WORKSPACE}/config` | Shareable config | ✓ (bind mount) | `settings.yaml` and `cron/` — the git-syncable layer. |

### Working with Nerve source

Expand All @@ -2904,7 +2920,8 @@ def _build_docker_compose(
- If you modify the web UI (`/nerve/web/`), rebuild with: `cd /nerve/web && npm run build`
- Config files live in `/nerve/`, NOT in `{_DOCKER_NERVE_HOME}/` — the config
directory is the working directory the daemon was started from.
- Cron jobs live in `{_DOCKER_NERVE_HOME}/cron/` (`jobs.yaml`, `system.yaml`, `gates/`).
- Cron jobs live in `{_DOCKER_WORKSPACE}/config/cron/` (`jobs.yaml`, `system.yaml`,
`gates/`), not under `$NERVE_HOME` — they are part of the syncable config.

### Credentials

Expand Down
Loading
Loading