4744cf819b
- Current Status: add Stage 6 daemon infrastructure in progress - Architecture table: expand daemon/__init__.py stub to all 5 daemon modules - Code Inventory: add daemon.core, daemon.pid, daemon.ipc, daemon.service sections with function signatures and purposes - Internal classes: add PluginSupervisor and PidFile; expand DaemonConfig Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
570 lines
30 KiB
Markdown
570 lines
30 KiB
Markdown
# Pyra — Developer Guide
|
||
|
||
## What Is This
|
||
|
||
Pyra is a personal AI assistant CLI combining a multi-provider AI chat interface with
|
||
a plugin/integration system (Stage 2+) and an encrypted vault (Stage 3+).
|
||
|
||
## Current Status
|
||
|
||
**Stage 3 — Memory Database: complete** (2026-05-18)
|
||
**Stage 6 — Daemon infrastructure: in progress** (`feat/daemon` branch)
|
||
Next: Stage 4 — Vault Encryption (skipped for now); messaging bots (Stage 6 remainder)
|
||
|
||
## Project Roadmap
|
||
|
||
### Stage 1 — Core CLI ✅ COMPLETE
|
||
Working `pyra` executable with provider setup wizard, streaming chat REPL, .md-based
|
||
memory in `~/.pyra/memory/`, and hard security boundaries around the vault.
|
||
|
||
### Stage 2 — Plugin Framework ✅ COMPLETE
|
||
- `src/pyra/plugins/` package: `base.py`, `loader.py`, `registry.py`, `executor.py`, `install.py`
|
||
- `src/pyra/bundled_plugins/` — ships bundled plugin scripts with pyra
|
||
- `src/pyra/daemon/` stub (CLI surface only; daemon itself is Stage 6)
|
||
- Config: `PluginConfig` + `DaemonConfig` added to `PyraConfig`
|
||
- Bootstrap: `~/.pyra/plugins/` and `~/.pyra/logs/` created on startup
|
||
- Chat session: AI tool-use loop (up to 10 iterations), approval gate, plugin slash commands
|
||
- CLI: `pyra plugin list/install/enable/disable/setup`, `pyra daemon *` (stubs at Stage 2; implemented in Stage 6)
|
||
|
||
### Stage 3 — Memory Database ✅ COMPLETE
|
||
- `src/pyra/memory/database.py`: SQLite + FTS5 via `memory_meta` + `memory_fts` tables
|
||
- `memory_meta` columns: `path`, `category`, `size_bytes`, `modified`, `summary`, `keywords`, `embedding BLOB` (reserved for Stage 8)
|
||
- `list_memories()` queries DB; `lookup_memories()` uses FTS5 with JSON-index fallback
|
||
- `write_memory()` / `append_memory()` upsert to DB on every write
|
||
- `bootstrap()` calls `init_db()` + `migrate_from_files()` (one-shot migration of existing `.md` files)
|
||
- `.md` files remain the canonical store; DB is the search index
|
||
|
||
### Stage 4 — Vault Encryption
|
||
Encrypt `~/.pyra/vault/secrets/` using `age` (or GPG fallback). Pyra decrypts in memory
|
||
at call time only — no plaintext ever written to disk after initial setup. Secret
|
||
rotation support. Per-key passphrases optional.
|
||
|
||
### Stage 5 — Skills System
|
||
YAML-defined multi-plugin workflows with event triggers and AI-driven selection.
|
||
Skills compose existing plugin tools into automated pipelines with conditional branching
|
||
and human-in-the-loop decision points.
|
||
|
||
### Stage 6 — Daemon + Messaging Bots
|
||
Always-on asyncio daemon, IPC socket, launchd/systemd service. Bundled bots:
|
||
`matrix_bot`, `telegram_bot`, `signal_bot`. Sender allowlist, bcrypt passphrase
|
||
challenge, rate limiting (20 msg/hr), injection scanning on all incoming messages,
|
||
tool approval over messaging (2-min timeout).
|
||
|
||
### Stage 7 — Security Audit Sub-agent
|
||
`pyra security audit` — sandboxed agent scanning for prompt injection in memory files,
|
||
unexpected vault access in `security.log`, outdated CVEs, permission drift on `~/.pyra/`.
|
||
Report written to `~/.pyra/security_audit.md` (not AI-readable during normal chat).
|
||
|
||
### Stage 8 — Web UI / Advanced Features
|
||
Optional local web interface (FastAPI + HTMX or similar). Embedding-based memory search
|
||
via `sqlite-vec`. Multi-profile support (work vs personal).
|
||
|
||
---
|
||
|
||
### Plugin Catalog (not stage-gated — ships when ready)
|
||
|
||
Plugins are developed independently on `plugin/<name>` branches and merged to `main`
|
||
only when complete. All integrations are standalone Python plugin scripts in
|
||
`~/.pyra/plugins/` — not hardcoded in `src/pyra/`. Plugin credentials are stored in
|
||
the vault under namespaced keys (`plugin:{name}:{key}`).
|
||
|
||
| Plugin | Branch | Status |
|
||
|--------|--------|--------|
|
||
| `nextcloud` | `plugin/nextcloud` | planned |
|
||
| `email` | `plugin/email` | planned |
|
||
| `websearch` | `plugin/websearch` | planned |
|
||
| `headless_browser` | `plugin/headless_browser` | planned |
|
||
| `server_manager` | `plugin/server_manager` | planned |
|
||
| `matrix_bot` | `plugin/matrix_bot` | planned |
|
||
| `telegram_bot` | `plugin/telegram_bot` | planned |
|
||
| `signal_bot` | `plugin/signal_bot` | planned |
|
||
| `ssh_tool` | `plugin/ssh_tool` | planned |
|
||
| `docker_tool` | `plugin/docker_tool` | planned |
|
||
| `gdrive` | `plugin/gdrive` | planned |
|
||
| `onedrive` | `plugin/onedrive` | planned |
|
||
| `dropbox_tool` | `plugin/dropbox_tool` | planned |
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
### Source: `src/pyra/`
|
||
|
||
| Module | Purpose |
|
||
|--------|---------|
|
||
| `cli.py` | Click entrypoint. Subcommands: `setup`, `chat`, `memory`, `plugin`, `daemon` |
|
||
| `setup/providers.py` | Provider registry — pure data, no I/O |
|
||
| `setup/wizard.py` | questionary-based interactive setup wizard |
|
||
| `config/schema.py` | Pydantic v2 models — `PyraConfig`, `GeneralConfig`, `PluginConfig`, `DaemonConfig`; `plugin_settings` dict |
|
||
| `config/tui.py` | `textual`-based `/config` TUI — `ConfigApp`, `GENERAL_FIELDS`, `launch_config_tui()` |
|
||
| `config/manager.py` | ruamel.yaml round-trip config read/write, chmod 600 enforced |
|
||
| `config/dirs.py` | `bootstrap()` — creates `~/.pyra/` tree, checks vault sentinel every startup |
|
||
| `chat/session.py` | prompt_toolkit REPL loop, AI tool-use loop, plugin slash commands |
|
||
| `chat/planner.py` | `TaskPlanner` — multi-step plan approval loop, per-step AI execution and verification |
|
||
| `chat/renderer.py` | Streaming + non-streaming markdown via rich, injection warning panel |
|
||
| `chat/history.py` | Conversation list, token budget trimming, tool message support |
|
||
| `memory/database.py` | SQLite+FTS5 — `init_db()`, `upsert()`, `remove()`, `search()`, `list_all()`, `migrate_from_files()` |
|
||
| `memory/reader.py` | `list_memories()` (DB-backed), `read_memory()`, `lookup_memories()` (FTS5), `load_context_for_session()` |
|
||
| `memory/writer.py` | `write_memory()`, `append_memory()` — writes file + upserts to DB |
|
||
| `memory/index.py` | Auto-regenerate `MEMORY_INDEX.md` + `memory_index.json` on every write |
|
||
| `vault/reader.py` | `get_key(key)` — sole accessor of `vault/secrets/api_keys.json` |
|
||
| `vault/writer.py` | `set_key()`, `delete_key()` — only called from setup wizard + plugin setup |
|
||
| `security/boundaries.py` | `assert_safe_path()`, `check_vault_lock()`, `BLOCKED_PREFIXES` |
|
||
| `security/injection.py` | `scan_response()` — 15 regex patterns, 4 categories, logs to `security.log` |
|
||
| `utils/paths.py` | `pyra_home()`, `ensure_dir()`, `safe_chmod()`, `expand()` |
|
||
| `plugins/base.py` | `Tool` dataclass, `PyraPlugin` Protocol, `BasePlugin` helper class |
|
||
| `plugins/loader.py` | Discovers + loads plugins via importlib; failures isolated per plugin |
|
||
| `plugins/registry.py` | Singleton: aggregates tools, slash commands, system prompt additions |
|
||
| `plugins/executor.py` | Approval gate: scan args → prompt → execute → scan result → log |
|
||
| `plugins/install.py` | Copies bundled plugins to `~/.pyra/plugins/` |
|
||
| `bundled_plugins/` | Standalone plugin scripts shipped with pyra (installed on demand) |
|
||
| `daemon/pid.py` | Atomic PID file — write, read, stale detection (POSIX + Windows), context manager |
|
||
| `daemon/ipc.py` | IPC transport — Unix socket chmod 600 + UID-check (Linux/macOS) or TCP loopback + port file (Windows); newline-delimited JSON protocol |
|
||
| `daemon/service.py` | OS service file generation + install/uninstall — launchd plist (macOS), systemd user unit (Linux), schtasks XML (Windows) |
|
||
| `daemon/core.py` | asyncio event loop entry point, `PluginSupervisor` (per-task restart, max 10×, 5s back-off, reload), IPC command dispatch, signal handling |
|
||
| `daemon/__init__.py` | Public daemon API exports |
|
||
|
||
### Runtime: `~/.pyra/`
|
||
|
||
```
|
||
~/.pyra/
|
||
├── config.yaml chmod 600 ← provider_id, model, base_url, enabled plugins
|
||
├── security.log chmod 600 ← injection event log
|
||
├── memory/ chmod 700
|
||
│ ├── user/profile.md
|
||
│ ├── context/
|
||
│ ├── knowledge/
|
||
│ └── MEMORY_INDEX.md
|
||
├── plugins/ chmod 700 ← active plugins (each is a dir with manifest.json + plugin.py)
|
||
│ └── <name>/
|
||
│ ├── manifest.json
|
||
│ └── plugin.py
|
||
├── logs/ chmod 700
|
||
│ ├── tool_executions.log chmod 600 ← every tool call: approved/declined, args, result preview
|
||
│ └── plugin_errors.log chmod 600 ← plugin load failures
|
||
└── vault/ chmod 700 ← AI CANNOT ACCESS
|
||
├── .vault_lock chmod 400 ← sentinel; missing = refuse to start
|
||
└── secrets/
|
||
└── api_keys.json chmod 400 ← ALL secrets (AI keys + plugin credentials)
|
||
```
|
||
|
||
### Plugin Credential Naming Convention
|
||
|
||
Plugin credentials live in the vault under namespaced keys:
|
||
```
|
||
plugin:{plugin_name}:{key_name}
|
||
```
|
||
Examples: `plugin:nextcloud:password`, `plugin:matrix_bot:access_token`
|
||
|
||
The vault's `get_key()` / `set_key()` accept any string — the namespace is enforced
|
||
by convention in each plugin's `setup()` method.
|
||
|
||
### Writing a Plugin
|
||
|
||
1. Create `~/.pyra/plugins/<name>/manifest.json`:
|
||
```json
|
||
{"name": "<name>", "version": "1.0.0", "description": "...", "author": "you"}
|
||
```
|
||
2. Create `~/.pyra/plugins/<name>/plugin.py` exporting `get_plugin() -> BasePlugin`:
|
||
```python
|
||
from pyra.plugins.base import BasePlugin, ConfigField, Tool
|
||
|
||
class MyPlugin(BasePlugin):
|
||
name = "<name>"
|
||
description = "..."
|
||
version = "1.0.0"
|
||
|
||
def on_load(self, vault_reader):
|
||
self._secret = vault_reader("plugin:<name>:secret")
|
||
|
||
def tools(self):
|
||
return [
|
||
Tool("my_tool", "Does X", {"type": "object", "properties": {}},
|
||
self._do_x, requires_approval=True)
|
||
]
|
||
|
||
def _do_x(self):
|
||
return "result"
|
||
|
||
def setup(self, console, vault_writer):
|
||
secret = console.input("Enter secret: ")
|
||
vault_writer("plugin:<name>:secret", secret)
|
||
|
||
def config_fields(self):
|
||
# Declare user-adjustable settings. Values are saved to config.yaml
|
||
# under plugin_settings["<name>"] and rendered in /config → plugin tab.
|
||
return [
|
||
ConfigField("api_url", "API URL", "text", "https://example.com",
|
||
description="Base URL for the service"),
|
||
ConfigField("verify_ssl", "Verify SSL", "bool", True),
|
||
]
|
||
|
||
def get_plugin():
|
||
return MyPlugin()
|
||
```
|
||
3. `pyra plugin enable <name>`
|
||
|
||
**Plugin rules:**
|
||
- Never import from `pyra.vault` directly — use the `vault_reader`/`vault_writer` callables
|
||
- All write/destructive tools must set `requires_approval=True`
|
||
- Return strings from tool handlers (truncated to 4000 chars by executor)
|
||
- Implement `config_fields()` for any user-adjustable settings beyond credentials.
|
||
Return a list of `ConfigField` objects — the `/config` TUI renders them automatically
|
||
and saves values to `config.yaml` under `plugin_settings["<name>"]`.
|
||
Plugins that need no configuration can omit this method (base no-op is used).
|
||
|
||
---
|
||
|
||
## Security Rules (never break these)
|
||
|
||
1. **Never pass config file contents into a system prompt** — config may reveal provider/model
|
||
2. **Never bypass `assert_safe_path()`** — not even in tests (use `tmp_pyra_home` fixture instead)
|
||
3. **Always `chmod 600/400`** after writing any file in `~/.pyra/`
|
||
4. **No shell execution from AI-generated text** — plugins use explicit approval gates
|
||
5. **`vault/reader.py` and `vault/writer.py` are the only modules that may open `api_keys.json`**
|
||
6. **API key retrieved inline at call time** — never stored as an instance variable or logged
|
||
7. **Tool arguments and results are always injection-scanned** before being used or returned to AI
|
||
8. **Plugin directories are validated with `assert_safe_path()`** before loading (symlink protection)
|
||
9. **Messaging bot security**: sender allowlist + bcrypt passphrase + rate limiting (Stage 2.4)
|
||
|
||
## Adding a New Provider
|
||
|
||
Edit `src/pyra/setup/providers.py`. Add a new `Provider` dataclass entry with all required fields.
|
||
litellm handles dispatch automatically via the `litellm_prefix` field.
|
||
Add a test in `tests/unit/test_providers.py` to verify the new entry.
|
||
|
||
## Installing for Development
|
||
|
||
```bash
|
||
uv venv && source .venv/bin/activate
|
||
uv pip install -e ".[dev]"
|
||
pyra setup
|
||
|
||
# Install optional plugin dependencies:
|
||
uv pip install -e ".[nextcloud]" # Nextcloud plugin
|
||
uv pip install -e ".[ssh]" # SSH plugin
|
||
uv pip install -e ".[all-plugins]" # Everything
|
||
```
|
||
|
||
## Running Tests
|
||
|
||
```bash
|
||
pytest tests/ -v # all unit + security tests
|
||
pytest tests/integration/test_lmstudio.py # requires LM Studio at localhost:1234
|
||
```
|
||
|
||
## Commit Convention
|
||
|
||
```
|
||
feat(module): short description
|
||
fix(module): short description
|
||
test: description
|
||
docs: description
|
||
chore: description
|
||
```
|
||
|
||
---
|
||
|
||
## Workflow Rules
|
||
|
||
### Testing
|
||
|
||
- **Write tests for every new feature.** A feature without tests is incomplete — do not commit without them.
|
||
- New tests go in `tests/unit/` for pure-logic helpers and `tests/security/` for security-boundary code.
|
||
- All existing tests must continue to pass — run `pytest tests/ -v` before committing.
|
||
- Test pure functions directly; do not test interactive I/O (questionary, Rich output) — only test the logic helpers those flows call.
|
||
- For Rich output, capture side effects by monkeypatching `console.print` rather than using `capsys`.
|
||
|
||
### Bugfixes
|
||
|
||
- **Stay under 50 lines changed.** Find the root cause and fix it directly.
|
||
- If the fix seems to require more than 50 lines, it is probably a refactor, not a bugfix — stop and discuss with the user before proceeding.
|
||
- Do not write workarounds, fallback layers, or compatibility shims to route around a bug. Remove the cause.
|
||
|
||
### Committing Changes
|
||
|
||
- **Commit after every logical unit of work** — do not batch unrelated changes into one commit and do not wait until the end of a session.
|
||
- **One commit per concern.** If a session touches a file for two different reasons (e.g. a bugfix and a cleanup), those are two separate commits — staged and committed independently, even if the file is the same.
|
||
- Use the project commit convention: `feat(module):`, `fix(module):`, `test:`, `docs:`, `chore:` followed by a short description.
|
||
- Always `git add` only the files relevant to that commit — never `git add .` blindly.
|
||
- **Always push after committing** — every commit goes to the remote Gitea repository immediately.
|
||
|
||
### Git Worktrees — Required for All Branch Work
|
||
|
||
**Never switch branches in the main working directory.** Always use a git worktree so that
|
||
multiple sessions (plugins, features, bugfixes) can run in parallel without interfering with
|
||
each other or with `main`.
|
||
|
||
```bash
|
||
# Create a worktree for a plugin branch
|
||
git worktree add ../pyra-plugin-nextcloud -b plugin/nextcloud
|
||
|
||
# Create a worktree for a feature branch
|
||
git worktree add ../pyra-feat-vault -b feat/vault-encryption
|
||
|
||
# List active worktrees
|
||
git worktree list
|
||
|
||
# Remove a worktree after merging
|
||
git worktree remove ../pyra-plugin-nextcloud
|
||
```
|
||
|
||
Each worktree is a full checkout at a separate path. Work on it exactly like the main repo —
|
||
commit, push, run tests — without touching the `main` worktree.
|
||
|
||
**Rules:**
|
||
- The main working directory (`/Users/nik/Documents/Progamming/pyra`) always stays on `main`.
|
||
- Do **not** run `git checkout <branch>` in the main directory — create a worktree instead.
|
||
- When a Claude Code session is asked to work on a branch, it must create (or reuse) a worktree
|
||
for that branch before making any changes.
|
||
|
||
### Plugin Branches
|
||
|
||
- Every plugin is developed on its own branch: `plugin/<name>` (e.g. `plugin/nextcloud`), in its
|
||
own worktree (e.g. `../pyra-plugin-nextcloud`).
|
||
- A plugin branch is **never merged to `main` until the plugin is complete and tested**.
|
||
- `main` always contains only production-ready core source code (`src/pyra/` framework).
|
||
- If plugin work uncovers a bug in core Pyra code, fix it on a dedicated `fix/...` branch
|
||
off `main` (in its own worktree), merge to `main`, push, then rebase the plugin branch.
|
||
- Plugin branches may be pushed to remote for backup/review at any time.
|
||
- Do **not** merge plugin branches to `main` prematurely — a half-working plugin on `main`
|
||
is worse than one that isn't there yet.
|
||
|
||
### Avoid Duplication — Check the Inventory First
|
||
|
||
Before writing any new utility function, class, or import block, check the **Code Inventory** section below. Everything listed there already exists and is importable. Writing a duplicate wastes code and introduces divergence.
|
||
|
||
---
|
||
|
||
## Code Inventory
|
||
|
||
### Third-party libraries (`pyproject.toml` dependencies)
|
||
|
||
| Library | Min version | Used in | Purpose |
|
||
|---------|-------------|---------|---------|
|
||
| `litellm` | 1.40.0 | `chat/session.py`, `setup/wizard.py` | Multi-provider LLM completion (streaming + non-streaming) and tool-use dispatch |
|
||
| `rich` | 13.0.0 | `chat/renderer.py`, `cli.py`, `setup/wizard.py`, `plugins/executor.py` | Terminal UI — `Console`, `Panel`, `Markdown`, `Live`, `Text` |
|
||
| `click` | 8.1.0 | `cli.py` | CLI entrypoint, `@click.group`, `@click.command`, arguments |
|
||
| `prompt_toolkit` | 3.0.0 | `chat/session.py` | REPL input loop — `PromptSession`, `FileHistory` |
|
||
| `questionary` | 2.0.0 | `setup/wizard.py` | Interactive `select` / `text` / `password` prompts |
|
||
| `ruamel.yaml` | 0.18.0 | `config/manager.py` | Round-trip YAML read/write (preserves comments and formatting) |
|
||
| `pydantic` | 2.0.0 | `config/schema.py` | Config validation via `BaseModel` |
|
||
| `httpx` | 0.27.0 | `setup/wizard.py` | HTTP GET for local-server connectivity checks |
|
||
| `textual` | 1.0.0 | `config/tui.py` | Full-screen TUI framework — tabs, inputs, switches, data tables for `/config` |
|
||
|
||
Optional plugin extras (declared in `pyproject.toml [project.optional-dependencies]`):
|
||
|
||
| Extra | Libraries | Intended for |
|
||
|-------|-----------|--------------|
|
||
| `nextcloud` | `caldav`, `webdav4`, `vobject` | CalDAV / CardDAV / WebDAV |
|
||
| `matrix` | `matrix-nio`, `aiofiles` | Matrix bot |
|
||
| `telegram` | `python-telegram-bot` | Telegram bot |
|
||
| `ssh` | `paramiko` | SSH plugin |
|
||
| `docker` | `docker` | Docker plugin |
|
||
| `gdrive` | `google-api-python-client`, `google-auth-oauthlib` | Google Drive |
|
||
| `onedrive` | `msal` | OneDrive device-flow auth |
|
||
| `dropbox` | `dropbox` | Dropbox |
|
||
|
||
### Standard library modules in use
|
||
|
||
| Module | Used in | Notes |
|
||
|--------|---------|-------|
|
||
| `pathlib.Path` | everywhere | Default for all paths — never use `os.path` string joins |
|
||
| `os` | `utils/paths.py` | Only for `os.name` (Windows guard) |
|
||
| `json` | `vault/reader.py`, `vault/writer.py`, `plugins/loader.py`, `plugins/executor.py`, `plugins/install.py` | Vault file, manifests, tool args/results |
|
||
| `re` | `security/injection.py` | Compiled injection-detection patterns |
|
||
| `datetime` | `security/injection.py`, `memory/reader.py`, `memory/index.py`, `plugins/loader.py`, `plugins/executor.py` | Log timestamps, file mtimes |
|
||
| `dataclasses` | `security/injection.py`, `memory/reader.py`, `plugins/base.py` | `@dataclass` — `InjectionWarning`, `MemoryFile`, `Tool` |
|
||
| `importlib.util` | `plugins/loader.py` | Dynamic plugin loading (`spec_from_file_location`) |
|
||
| `sys` | `cli.py`, `plugins/loader.py` | `sys.exit`, `sys.modules` for dynamic module registration |
|
||
| `shutil` | `plugins/install.py` | `copytree`, `rmtree` for bundled plugin installation |
|
||
| `typing` | `plugins/base.py`, `chat/history.py`, `plugins/registry.py` | `Protocol`, `Callable`, `Coroutine`, `Any`, `TYPE_CHECKING` |
|
||
|
||
### Internal utility functions — import, do not rewrite
|
||
|
||
#### `utils.paths`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `pyra_home` | `() -> Path` | Returns `~/.pyra/` |
|
||
| `ensure_dir` | `(path: Path, mode=0o700) -> Path` | `mkdir -p` + `chmod` in one call |
|
||
| `safe_chmod` | `(path: Path, mode: int) -> None` | `chmod` that silently skips on Windows |
|
||
|
||
#### `security.boundaries`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `assert_safe_path` | `(path: Path) -> None` | Raises `VaultAccessError` if path resolves into vault |
|
||
| `check_vault_lock` | `() -> None` | Raises `PyraSecurityError` if vault sentinel is missing |
|
||
|
||
Exceptions: `VaultAccessError(PermissionError)`, `PyraSecurityError(RuntimeError)`
|
||
|
||
#### `security.injection`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `scan_response` | `(text: str) -> list[InjectionWarning]` | Runs 15 compiled regex patterns, logs hits to `security.log` |
|
||
| `redact_api_keys` | `(text: str) -> str` | Replaces key-shaped strings with `[REDACTED]` |
|
||
|
||
Dataclass: `InjectionWarning(pattern_label: str, matched_text: str)`
|
||
|
||
#### `config.manager`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `load_config` | `() -> PyraConfig` | Reads `config.yaml`, validates via Pydantic; raises `FileNotFoundError` if missing |
|
||
| `save_config` | `(cfg: PyraConfig) -> None` | Writes `config.yaml`, enforces `chmod 600` |
|
||
| `config_exists` | `() -> bool` | True if `config.yaml` exists |
|
||
| `config_path` | `() -> Path` | Absolute path to `config.yaml` |
|
||
|
||
#### `config.tui`
|
||
|
||
| Symbol | Purpose |
|
||
|--------|---------|
|
||
| `launch_config_tui` | `() -> None` — opens the full-screen configuration TUI; blocks until user presses `q`/Escape |
|
||
| `GENERAL_FIELDS` | List of `_CoreField` entries — the single place to add new core settings to the General tab |
|
||
|
||
#### `config.dirs`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `bootstrap` | `() -> None` | Creates `~/.pyra/` directory tree and checks vault sentinel; called at every startup |
|
||
|
||
#### `vault.reader` / `vault.writer`
|
||
|
||
| Function | Module | Signature | Purpose |
|
||
|----------|--------|-----------|---------|
|
||
| `get_key` | `vault.reader` | `(provider_id: str) -> str \| None` | Sole vault reader — never call `open(api_keys.json)` anywhere else |
|
||
| `set_key` | `vault.writer` | `(provider_id: str, api_key: str) -> None` | Stores or overwrites a key in the vault |
|
||
| `delete_key` | `vault.writer` | `(provider_id: str) -> bool` | Removes a key; returns `True` if it existed |
|
||
|
||
#### `memory.database`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `init_db` | `() -> None` | Creates `memory.db` with `memory_meta` + `memory_fts` tables; chmod 600 |
|
||
| `upsert` | `(path, *, content, category, size_bytes, modified, summary, keywords) -> None` | Insert or replace one entry in both tables |
|
||
| `remove` | `(path: str) -> None` | Delete entry from both tables |
|
||
| `search` | `(query: str, limit: int = 20) -> list[dict]` | FTS5 MATCH search; returns `[{file, summary, keywords, snippet}]` |
|
||
| `list_all` | `() -> list[dict]` | All rows from `memory_meta` ordered by path |
|
||
| `migrate_from_files` | `() -> None` | One-shot: populate DB from existing `.md` files if DB is empty |
|
||
|
||
#### `memory.reader`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `list_memories` | `() -> list[MemoryFile]` | Queries DB (`memory_meta`); falls back to file scan if DB empty |
|
||
| `read_memory` | `(name: str) -> str` | Reads memory file by relative path; validates against vault/traversal |
|
||
| `lookup_memories` | `(query: str) -> list[dict]` | FTS5 full-text search; falls back to JSON index substring search |
|
||
| `load_context_for_session` | `() -> str` | Concatenates all memory files into a system-prompt block |
|
||
|
||
Dataclass: `MemoryFile(name, path, category, size_bytes, modified)`
|
||
|
||
#### `memory.writer`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `write_memory` | `(name: str, content: str, summary: str, keywords: list[str]) -> Path` | Creates/overwrites a memory `.md` file, updates index and DB |
|
||
| `append_memory` | `(name: str, content: str) -> Path` | Appends to a memory file (creates if missing), updates index and DB |
|
||
|
||
#### `memory.index`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `update_index` | `() -> None` | Regenerates `MEMORY_INDEX.md` and `memory_index.json` — called automatically by writer functions |
|
||
|
||
#### `setup.providers`
|
||
|
||
| Symbol | Kind | Purpose |
|
||
|--------|------|---------|
|
||
| `PROVIDERS` | `list[Provider]` | All registered providers in display order |
|
||
| `PROVIDERS_BY_ID` | `dict[str, Provider]` | Fast id lookup |
|
||
| `get_provider` | `(provider_id: str) -> Provider` | Raises `KeyError` for unknown ids |
|
||
| `Provider` | frozen dataclass | `id`, `display_name`, `requires_key`, `default_model`, `litellm_prefix`, `base_url`, `key_env_var`, `connectivity_check`, `group` |
|
||
|
||
#### `plugins.loader`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `load_plugins` | `(plugins_dir: Path) -> list[PyraPlugin]` | Discovers all valid plugin directories |
|
||
| `load_plugin_by_name` | `(name: str, plugins_dir: Path) -> PyraPlugin \| None` | Loads a single plugin; returns `None` on any failure |
|
||
|
||
#### `plugins.install`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `get_bundled_plugins_dir` | `() -> Path` | Path to `src/pyra/bundled_plugins/` |
|
||
| `install_bundled_plugin` | `(name, bundled_dir, plugins_dir) -> None` | Copies bundled plugin dir to `~/.pyra/plugins/`, sets permissions |
|
||
| `list_bundled_plugins` | `(bundled_dir: Path) -> list[str]` | Names of all bundled plugins that have a `manifest.json` |
|
||
| `read_manifest` | `(plugin_dir: Path) -> dict` | Reads `manifest.json`; returns `{}` if missing |
|
||
|
||
#### `daemon.core`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `run_foreground` | `() -> None` | Entry point for `pyra daemon run` — loads config + plugins, writes PID file, runs asyncio loop |
|
||
| `start_background` | `() -> None` | Spawns `pyra daemon run` as a detached subprocess (`start_new_session` on POSIX, `DETACHED_PROCESS` on Windows) |
|
||
|
||
#### `daemon.pid`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `resolve_pid_path` | `(cfg_path: str) -> Path` | Expand `~` and resolve to absolute Path |
|
||
|
||
#### `daemon.ipc`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `send_command` | `(address, msg, timeout=5.0) -> IpcResponse` | Synchronous CLI helper — `asyncio.run(IpcClient.send(...))` |
|
||
| `get_socket_path` | `(cfg: str) -> Path` | Expand `~` and return Unix socket path |
|
||
| `is_unix_socket` | `() -> bool` | True on Linux/macOS (`sys.platform != 'nt'`) |
|
||
| `get_port_file_path` | `() -> Path` | Path to `~/.pyra/daemon.port` (Windows TCP port file) |
|
||
|
||
#### `daemon.service`
|
||
|
||
| Function | Signature | Purpose |
|
||
|----------|-----------|---------|
|
||
| `detect_platform` | `() -> Literal["macos","linux","windows"]` | Detect current OS |
|
||
| `find_pyra_executable` | `() -> str` | `shutil.which("pyra")` → sibling fallback → `sys.executable -m pyra` |
|
||
| `install_service` | `() -> None` | Generate + register OS service (reads config for log/pid paths) |
|
||
| `uninstall_service` | `() -> None` | Deregister OS service |
|
||
| `render_launchd_plist` | `(exe, log_file, pid_file) -> str` | macOS plist template |
|
||
| `render_systemd_unit` | `(exe, log_file) -> str` | Linux systemd unit template |
|
||
| `render_schtasks_xml` | `(exe) -> str` | Windows Task Scheduler XML template (write as UTF-16) |
|
||
|
||
#### `chat.renderer` — rendering functions and shared `console`
|
||
|
||
Import `console` from here; do not create a second `rich.Console()` in new code.
|
||
|
||
| Symbol | Purpose |
|
||
|--------|---------|
|
||
| `console` | Module-level `rich.Console` — the single shared terminal instance |
|
||
| `render_streaming_response(stream)` | Renders a litellm streaming response with `Live` + `Markdown`, returns final text |
|
||
| `render_text_response(text)` | Renders a complete string as `Markdown` |
|
||
| `render_injection_warning(warnings)` | Yellow `Panel` showing detected pattern labels |
|
||
| `render_error(message)` | Red `Panel` |
|
||
| `render_info(message)` | Dim plain text line |
|
||
| `render_system(message)` | Cyan `Panel` |
|
||
|
||
### Internal classes
|
||
|
||
| Class | Module | Notes |
|
||
|-------|--------|-------|
|
||
| `PyraConfig` | `config.schema` | Top-level config; fields: `ai`, `general`, `memory`, `security`, `plugins`, `daemon`, `plugin_settings` |
|
||
| `GeneralConfig` | `config.schema` | `general:` block — `user_name`, `assistant_name` |
|
||
| `ProviderConfig` | `config.schema` | `ai:` block — `provider_id`, `model`, `base_url` |
|
||
| `PluginConfig` | `config.schema` | `plugins:` block — `enabled`, `require_approval`, `log_executions` |
|
||
| `DaemonConfig` | `config.schema` | `daemon:` block — `enabled`, `socket_path`, `log_file`, `pid_file`, `ipc_port` |
|
||
| `MemoryConfig` | `config.schema` | `memory:` block — `max_tokens_in_context`, `auto_load` |
|
||
| `SecurityConfig` | `config.schema` | `security:` block — `injection_detection`, `log_injections` |
|
||
| `ConversationHistory` | `chat.history` | Holds message list; builds API payload via `build_for_api()`; trims to token budget |
|
||
| `PluginRegistry` | `plugins.registry` | Singleton (`instance()` / `reset()`); aggregates tools, slash commands, system prompt additions |
|
||
| `ToolExecutor` | `plugins.executor` | Approval gate + injection scan + logging; call via `execute()` or `execute_tool_call_batch()` |
|
||
| `ConfigField` | `plugins.base` | Dataclass — declares one plugin config option (`key`, `label`, `type`, `default`, `options`, `description`); returned by `config_fields()` |
|
||
| `Tool` | `plugins.base` | Dataclass — `name`, `description`, `parameters` (JSON Schema), `handler`, `requires_approval` |
|
||
| `PyraPlugin` | `plugins.base` | `@runtime_checkable` Protocol — the plugin interface |
|
||
| `BasePlugin` | `plugins.base` | Concrete base with no-op defaults; plugins should inherit this |
|
||
| `TaskPlanner` | `chat.planner` | Multi-step plan runner; `make_tool_handler()` returns the callable wired into the chat session; presents plan for user approval, executes each step via litellm with up to 5 tool-use iterations, verifies output before proceeding |
|
||
| `PluginSupervisor` | `daemon.core` | asyncio supervisor — `add_task(name, factory)`, `start()`, `stop()`, `reload()`, `status()`; restarts crashed tasks up to 10× with 5s back-off |
|
||
| `PidFile` | `daemon.pid` | `write()` (atomic), `read()`, `is_stale()`, `remove()`, context manager; `PidFileError(OSError)` raised when live PID already exists |
|