files — state on disk and in SQLite
NPCAgent has no server and no account. Everything it knows lives in two places:
a .npcagent/ directory inside the repository you invoked it from, and
~/.npcagent/ for machine-wide defaults. Deleting the first resets a project;
deleting both resets the installation.
Repository layout
Created by npc init, or implicitly by
Runtime.for_repo() through ensure_repo_ready(). The five
subdirectories under .npcagent/ are fixed in
_NPCAGENT_SUBDIRS; everything deeper appears on demand.
Only three of these are gitignored — .npcagent/db/,
.npcagent/memory/ and .npcagent-worktrees/. That is a deliberate
asymmetry: project files, plans and ledgers are meant to be committable, so a plan
artefact and its Stage 2 expansion can be reviewed in a pull request like any other
document. Databases, working memory and scratch worktrees are not.
What survives a run depends on worktree_cleanup_mode, default
archive. delete removes the worktree and the branch;
archive renames the directory to
<task_id>.archived-<UTC ISO timestamp> and keeps the branch for
forensic recovery; keep leaves everything registered with git. Cleanup
failure is logged as worktree.cleanup_failed, never raised — a cleanup
problem must not fail an otherwise successful run.
Home directory
Three of these directories sit in the middle of a three-level search stack — bundled catalog first, user directory second, repository directory third, with later entries overriding earlier ones. That applies identically to workflows, skills and prompts, so a repository can shadow a bundled workflow without editing the installation.
Database
SQLite through aiosqlite, with SQLAlchemy 2.0 async sessions. Schema is
owned by Alembic — twelve migrations in a linear chain — and the ORM models exist to
mirror it, not to create it. Tests build the schema from the models via
Base.metadata.create_all; production never does.
uq_runs_root_idempotency is what makes a cron tick or a webhook redelivery
safe to repeat, and uq_workflow_state_run_id is what guarantees a run has
at most one workflow-state row for the compare-and-set to contend over.| Revision | Adds |
|---|---|
0001_initial | scaffolding only; down_revision = None |
0002_events | events |
0003_spawn_envelopes | spawn_envelopes |
0004_runs | runs |
0005_primitives | primitives |
0006_workflow_state | workflow_state |
0007_workflow_signals | workflow_signals |
0008_messages_fts | conversation_turns FTS5 table + the agent.message trigger |
0009_tasks | tasks |
0010_runs_payload | runs.payload_json, index on next_checkpoint_at |
0011_workflow_signal_claims | workflow_signals.claimed_at + index |
0012_runs_claimed_at | runs.claimed_at, backfill nulling stale claimed_by, + index |
Migrations run in-process, not through a subprocess, and in a dedicated thread. The
reason is stated in repo_init.py: Alembic's env creates an async engine via
asyncio.run, but Runtime construction is synchronous and may
happen while an event loop is already running. A short-lived thread gives Alembic a
context with no active loop.
Configuration
Settings resolve through pydantic-settings with
extra = "forbid" — an unknown key is an error, not a warning. Sources in
decreasing priority: constructor arguments, environment,
.env, the TOML file, then file secrets. The TOML path is
$NPCAGENT_CONFIG_FILE if set, otherwise
~/.npcagent/config.toml.
| Field | Default | Constraint |
|---|---|---|
data_dir | ~/.npcagent | — |
log_level | INFO | DEBUG · INFO · WARNING · ERROR · CRITICAL, upper-cased |
log_json | False | — |
default_cli_provider | claude | normalised to a canonical provider id |
default_model | sonnet | validated against the provider's aliases |
max_spawn_depth | 5 | 1 ≤ n ≤ 20 |
prompt_cache_ttl_seconds | 300 | > 0 |
cron_tick_interval_seconds | 60 | > 0 |
webhook_port | 8765 | 1 ≤ n ≤ 65535 |
worktree_cleanup_mode | archive | keep · delete · archive |
worktree_base_branch | HEAD | so main, master or a feature branch all work |
Only two of these are written by npc model:
default_cli_provider and default_model. Everything else is
environment or hand-edited TOML.
Environment
Beyond the settings prefix, a number of variables are read directly at their point of use. They are listed here because several of them are the only way to reach the behaviour they control.
| Variable | Effect |
|---|---|
NPCAGENT_CONFIG_FILE | override the TOML path |
NPCAGENT_DB_PATH | override the database file when no repo path is bound |
NPCAGENT_USER_PLUGIN_DIR | replace ~/.npcagent/plugins/ in discovery |
NPCAGENT_USER_WORKFLOW_DIR | replace ~/.npcagent/workflows/ |
NPCAGENT_USER_SKILL_DIR | replace ~/.npcagent/skills/ |
NPCAGENT_FORCE_OFFLINE_PLANNER | use the deterministic planner for both stages; execution is unaffected |
NPCAGENT_LIVE_COMPACT | equivalent to --compact |
NPCAGENT_NO_AUTO_SERVE | stop bare npc from autostarting the daemon |
NPCAGENT_CAO_SHELL_TIMEOUT_SECONDS | patched into CAO; default 60.0 |
NPCAGENT_CLAUDE_STARTUP_TIMEOUT_SECONDS | Claude Code readiness timeout; default 120.0. Suggested in the failure message when a launch times out. |
NPCAGENT_CAO_KEEP_SESSIONS | leave tmux sessions alive after a run |
NPCAGENT_API_TOKEN | enable bearer auth on the REST surface |
NPCAGENT_API_ALLOW_REMOTE | permit a non-loopback bind; also requires a token |
NPCAGENT_OTEL | Stub logs "OTEL enabled (stub - no exporter wired yet)" |
ANTHROPIC_API_KEY | optional; enables the live Stage 1 and Stage 2 planners only |
BRAVE_API_KEY / EXA_API_KEY | back the web_search tool; without either it raises not_configured |
CODEX_HOME / KIMI_HOME | where dynamic model discovery looks for a provider's model cache |
NPCAGENT_FORCE_STUB_CAO, NPCAGENT_TEST_STUB_GOALS and
NPCAGENT_FAKE_SPAWNER are checked on the root CLI callback. If any is set,
the process prints an error naming the offenders and exits 2. They are
historical stub switches, and the check exists so a misconfigured shell cannot quietly
regress the runtime into a fake artefact writer.
Memory cascade
A memory read walks four scopes, most specific first, and returns the first hit. Symlinked paths are skipped on read and rejected outright on write.
- 1 · agent
<repo>/.npcagent/agents/<agent>/- 2 · project
<repo>/.npcagent/projects/<project>/memory/— the default write level- 3 · repo
<repo>/.npcagent/memory/- 4 · global
~/.npcagent/memory/— always present, re-evaluated at call time so a changed$HOMEis honoured
Within any scope directory the layout is the same: a mutable current.md,
dated YYYY-MM-DD.md dailies, an archive/, and — once facts have
been extracted — semantic/facts.json. Writes take an
fcntl.flock. Compaction rolls dailies older than one day into
weekly/YYYY-WNN.md and weeklies older than seven days into
monthly/YYYY-MM.md, moving originals to archive/. It is
idempotent, because archived files are no longer discoverable.
Entries are filed under one of six closed categories:
decision, preference, commitment,
constraint, open_loop, fact.
Injection is cache-shaped
Memory reaches an agent as a single XML-ish block appended to the system prompt after a static prefix, with an explicit cache breakpoint between them:
The block is capped at 32,000 characters and truncated with a
<truncated/> marker. On the way back out,
scrub_memory_tags() strips these tags from agent output before it is
displayed or written to the event log, and a StreamingScrubber handles the
case where a tag is split across two stream chunks.
LocalMemoryProvider.search() is a plain substring scan over
*.md — it does not use the FTS5 index, which only ever indexes
agent.message events. decay() is a documented no-op: there is
no TTL or retention policy. The scope= keyword on get,
put and search is accepted and ignored; the provider's bound
scope is what governs resolution.