Skip to content
Merged
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
25 changes: 13 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ Instructions for AI coding agents working on the PrintPal codebase.

## What is PrintPal?

PrintPal is a terminal-based 3D printing assistant. It uses an AI agent (smolagents + LiteLLM) to drive a 3D printing pipeline via an MCP server called PrintMCP. The agent searches Thingiverse, downloads models, slices them with CuraEngine, and prints them via OctoPrint — all from a REPL with slash commands.
PrintPal is a terminal-based 3D printing assistant. It uses an AI agent (smolagents + LiteLLM) to drive a 3D printing pipeline via an MCP server called PrintMCP. The agent searches Thingiverse, downloads models, slices them with CuraEngine **or OrcaSlicer**, and prints them via OctoPrint — all from a REPL with slash commands.

## Tech stack

- **Python 3.10+** (target `>=3.10` in pyproject.toml)
- **uv** for dependency management — `uv sync` to install, `uv run printpal` to run
- **smolagents** (HuggingFace) — the agent framework that runs the LLM and executes tool calls
- **PrintMCP** (PyPI: `printmcp`) — the MCP server providing Thingiverse/Cura/OctoPrint tools, launched via `uvx printmcp`
- **PrintMCP** (PyPI: `printmcp`) — the MCP server providing Thingiverse/Cura/OrcaSlicer/OctoPrint tools, launched via `uvx printmcp`
- **Rich** — terminal UI (panels, tables, styled text)
- **prompt_toolkit** — input handling (placeholder text, arrow-key history, Shift+Tab key bindings)
- **SQLite** — everything is stored in `~/.printpal/printpal.db` (sessions, things as BLOBs, settings, logs)
Expand All @@ -20,13 +20,13 @@ PrintPal is a terminal-based 3D printing assistant. It uses an AI agent (smolage

| File | Responsibility | Key things to know |
|------|---------------|-------------------|
| `src/printpal/app.py` | Entry point, bootstrap, REPL loop, command dispatch | Contains `_inject_db_settings()`, `_sanitize_session_name()`, error resilience wrappers, auto-save logic. `server_params` is created after DB settings injection so PrintMCP gets the right env. `MODEL_ID` is imported from `config.py`. |
| `src/printpal/config.py` | App-level constants | `MODEL_ID`, `SETTING_TO_ENV` (DB key → env var mapping), `TIPS`, `EXAMPLES`. When model switching is added, the model registry will live here. |
| `src/printpal/commands/` | All `/slash` command implementations (sub-package) | `__init__.py` re-exports all `cmd_*` functions + `COMMANDS` dict. Split by domain: `session.py`, `thing.py`, `print.py`, `mode.py`, `admin.py`, `help.py`, `helpers.py`. |
| `src/printpal/app.py` | Entry point, bootstrap, REPL loop, command dispatch | Contains `_inject_db_settings()`, `_sanitize_session_name()`, error resilience wrappers, auto-save logic. Command args are parsed with `shlex` (quoted values work). `_printmcp_server_params()` picks `uvx printmcp` (PyPI) by default, or the `PRINTPAL_PRINTMCP_COMMAND` override for a local checkout. `MODEL_ID` is imported from `config.py`. |
| `src/printpal/config.py` | App-level constants | `MODEL_ID`, `SETTING_TO_ENV` (DB key → env var mapping), `ORCA_DEFAULTS` + `ORCA_FLAG_OVERRIDES` (default OrcaSlicer presets + slice-flag → setting map), `TIPS`, `EXAMPLES`. When model switching is added, the model registry will live here. |
| `src/printpal/commands/` | All `/slash` command implementations (sub-package) | `__init__.py` re-exports all `cmd_*` functions + `COMMANDS` dict. Split by domain: `session.py`, `thing.py` (incl. `/slice` with Cura/Orca backend pick), `printer.py` (`/printer` presets), `print.py`, `mode.py`, `admin.py`, `help.py`, `helpers.py`. |
| `src/printpal/permissions.py` | Permission system | `PermissionTool` subclasses `smolagents.Tool` (required by CodeAgent's isinstance check). Wraps each MCP tool, intercepts `forward()`. Three modes: DEFAULT (ask all), AUTO (read-only/safe auto-approved), BYPASS (everything auto-approved). Per-session allow-list stored in DB. |
| `src/printpal/sessions.py` | Session save/load | Serializes `AgentMemory` steps to JSON, reconstructs on load. Drops `model_input_messages` on load (not needed for continuation). `load_session()` returns a dict with `success`, `prompt_history`, and `permissions`. |
| `src/printpal/db.py` | SQLite layer | Every function opens/closes its own connection (uses WAL mode). `init_db()` adds columns incrementally for schema migrations. `migrate_to_blob_storage()` converts old `file_path` rows to `file_data` BLOBs. |
| `src/printpal/scanner.py` | Download + slice scanner | Walks agent memory after `agent.run()` to detect `thingiverse_download_model` and `cura_slice_model` calls. Reads files from disk into BLOBs, deletes disk files. |
| `src/printpal/scanner.py` | Download + slice scanner | Walks agent memory after `agent.run()` to detect `thingiverse_download_model` and (`cura_slice_model` or `orca_slice_model`) calls. Reads files from disk into BLOBs, deletes disk files. |
| `src/printpal/completer.py` | Command autocomplete | `CommandCompleter` does prefix matching on slash commands. Only activates when input starts with `/`. |
| `src/printpal/pricing.py` | Model pricing data | `MODEL_PRICING` dict (per-1M-token prices), `MASKED_KEYS`, `RESTART_KEYS`. |
| `src/printpal/ui.py` | Shared UI constants + helpers | `console = Console(highlight=False)`, `ACCENT = "#d4b702"` (matches smolagents' yellow), `LOGO` (ASCII art), `format_size()`, `format_duration()`, `prompt_yes_no()`, `make_bar()` (visual progress bars), `format_tokens()` (compact token formatting). Import from here, don't create new Console instances. |
Expand All @@ -44,7 +44,7 @@ agent = CodeAgent(tools=wrapped_tools, model=model)
`/slice`, `/print`, etc. call MCP tools directly via `call_tool()` or `tool.forward()` — no LLM, no tokens spent. These bypass the permission system since they're user-initiated.

### Download + slice scanner
smolagents wraps all MCP tool calls inside a Python code executor, so `step.tool_calls[0].name` is always `python_interpreter`, not the actual tool name. The scanner (in `scanner.py`) checks `step.code_action` for `thingiverse_download_model` and `cura_slice_model`, parses the results from `step.observations` using `ast.literal_eval`, reads the files from disk into BLOBs, and deletes the disk files.
smolagents wraps all MCP tool calls inside a Python code executor, so `step.tool_calls[0].name` is always `python_interpreter`, not the actual tool name. The scanner (in `scanner.py`) checks `step.code_action` for `thingiverse_download_model`/`cura_slice_model`/`orca_slice_model`, parses the results from `step.observations` using `ast.literal_eval`, reads the files from disk into BLOBs, and deletes the disk files.

### mcpadapt monkeypatch
`jsonref.replace_refs` (used by mcpadapt) returns lazy proxy objects that aren't JSON-serializable. We monkeypatch it in `app.py` to deep-copy the result. This is a known mcpadapt 0.1.20 bug.
Expand All @@ -64,16 +64,17 @@ The REPL loop has nested try/except: outer catches `EOFError`/`KeyboardInterrupt
| `OCTOPRINT_URL` | `.env` or `/config` | OctoPrint server URL |
| `OCTOPRINT_API_KEY` | `.env` or `/config` | OctoPrint API key |
| `PRINTMCP_CURA_DIR` | `.env` or `/config` | Cura install path (auto-detected if unset) |
| `PRINTMCP_ORCA_COMMAND` | `.env` or `/config` | OrcaSlicer launch command (auto-detected if unset) |
| `PRINTMCP_ORCA_PROFILES` | `.env` or `/config` | OrcaSlicer bundled presets dir (auto-detected if unset) |
| `PRINTPAL_PRINTMCP_COMMAND` | env only | Override the PrintMCP launch command (e.g. `uv run --directory /path/to/PrintMCP printmcp`) to use a local checkout instead of PyPI. |

DB settings (via `/config set`) override `.env` values. They're injected into `os.environ` on startup before the MCP server starts.

## Testing

No tests currently. When adding tests:
- MCP tools should be mocked (no real Thingiverse/OctoPrint calls)
- DB tests should use a temp SQLite file, not `~/.printpal/printpal.db`
- Use `pytest` (in dev dependencies): `uv run pytest`
- Lint with `uvx ruff check src tests` (or `uvx ruff check .`)
`pytest` (in dev dependencies) — `uv run python -m pytest` from the repo root. Tests are offline
and isolate the DB (monkeypatch `db.DB_PATH` to a temp file). Mark end-to-end tests with
`@pytest.mark.integration`. Lint with `uvx ruff check src tests` (or `uvx ruff check .`).

## Things to avoid

Expand Down
64 changes: 59 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ It's like having a 3D printing expert sitting next to you, ready to find and pre

- **Natural language search** — just describe what you want to print
- **Self-contained storage** — all models and G-code stored as BLOBs in SQLite, no external files
- **Direct slicing** — `/slice <id>` runs CuraEngine instantly, zero AI tokens
- **Direct slicing** — `/slice <id>` runs CuraEngine or OrcaSlicer instantly, zero AI tokens
- **Full print pipeline** — `/print <id>` preheats, uploads to OctoPrint, and starts
- **Permission system** — control what the agent can do (Manual / Auto / Bypass modes)
- **Session management** — save/load conversations with full memory and prompt history
Expand Down Expand Up @@ -100,7 +100,7 @@ Settings can be stored in `.env` (for initial setup) or managed at runtime with
| `thingiverse_token` | `THINGIVERSE_TOKEN` | Searching & downloading | Yes |
| `octoprint_url` | `OCTOPRINT_URL` | Printing | No |
| `octoprint_api_key` | `OCTOPRINT_API_KEY` | Printing | Yes |
| `cura_dir` | `PRINTMCP_CURA_DIR` | Slicing (auto-detected) | No |
| `cura_dir` | `PRINTMCP_CURA_DIR` | Slicing (Cura; auto-detected) | No |
| `auto_save` | — | Optional convenience | No |

## 🎮 Commands
Expand All @@ -127,16 +127,51 @@ Settings can be stored in `.env` (for initial setup) or managed at runtime with

### Slicing

`/slice` uses whichever slicer you have. **OrcaSlicer is preferred** when both are installed; force one with `--slicer cura|orca`.

| Command | Description |
|---------|-------------|
| `/slice <id>` | Slice a model to G-code using CuraEngine |
| `/slice <id>` | Slice a model to G-code (auto-detected slicer) |
| `/slice <id> --slicer cura` | Force CuraEngine for this slice |
| `/slice <id> --slicer orca` | Force OrcaSlicer for this slice |
| `/slice <id> --layer-height 0.12` | Set layer height (0.05–0.6mm) |
| `/slice <id> --infill 40 --supports` | Set infill % and enable supports |
| `/slice <id> --printer creality_ender3pro` | Set printer profile |
| `/slice <id> --printer creality_ender3pro` | Set printer profile (Cura) |
| `/slice <id> --temp 210 --bed 65` | Set nozzle and bed temperatures |
| `/slice <id> --adhesion brim` | Set adhesion type (skirt/brim/raft/none) |

Short flags: `--lh`, `--inf`, `--sup`, `--ad`, `--t`, `--b`, `--p`
Short flags: `--lh`, `--inf`, `--sup`, `--ad`, `--t`, `--b`, `--p`, `--sl`

For **OrcaSlicer**, PrintPal uses its 3-tier presets (machine / process / filament). Defaults assume an Ender-3 Pro with a 0.4 nozzle; point them at your machine and material via `/config`:

```bash
/config set slicer orca # prefer OrcaSlicer always
/config set orca_machine "Creality Ender-3 Pro 0.4 nozzle"
/config set orca_process "0.20mm Standard @Creality Ender3 Pro 0.4"
/config set orca_filament "Creality Generic PLA"
```

Find preset names with the PrintMCP `orca_list_profiles` tool (or ask the agent: "list my OrcaSlicer machine presets").

### Printers

A **printer preset** bundles everything the slicers need for one machine — the Cura definition id and the OrcaSlicer machine/process/filament presets — under one name. `/printer use <name>` applies the bundle so `/slice` (and the agent) slice for that printer. A default `ender-3-pro` preset ships with the app.

| Command | Description |
|---------|-------------|
| `/printer` or `/printer list` | List presets (marks the active one with ●) |
| `/printer show [name]` | Show one preset's slicer settings (no arg = active) |
| `/printer use <name>` | Activate a preset — applies it to slicing |
| `/printer add <name> [options]` | Save a preset. Options: `--cura <id> --machine <m> --process <p> --filament <f>` |
| `/printer remove <name>` | Delete a preset |

Adding with no options pre-fills from the Ender-3 Pro defaults so you can tweak from a working baseline. Names with spaces work either quoted or unquoted (the command joins the remaining words):

```bash
/printer add "Voron 2.4" --machine "Voron 2.4 0.4 nozzle" --process "0.20mm Standard" --filament "Generic PLA"
/printer use Voron 2.4
/slice 1
```

### Printing

Expand Down Expand Up @@ -228,6 +263,24 @@ PrintPal connects to **[PrintMCP](https://github.com/SourceBox-LLC/PrintMCP)**

The AI agent (powered by [smolagents](https://github.com/huggingface/smolagents) + LiteLLM) uses these tools autonomously. Slash commands like `/slice` and `/print` call the tools directly — no AI tokens spent.

### Developing against a local PrintMCP

By default PrintPal launches the published PrintMCP package (`uvx printmcp`). To develop both repos together — e.g. you're editing PrintMCP's tools and want PrintPal to use your local checkout — set `PRINTPAL_PRINTMCP_COMMAND` to the launch command before starting PrintPal:

```bash
# Point PrintPal at your local PrintMCP checkout:
export PRINTPAL_PRINTMCP_COMMAND="uv run --directory /path/to/PrintMCP printmcp"
uv run printpal
```

or, from any venv that already has `printmcp` installed:

```bash
export PRINTPAL_PRINTMCP_COMMAND="python -m printmcp"
```

The value is split with `shlex`, so quoted paths with spaces work. Which server you're talking to is shown in the startup banner — `pypi (uvx printmcp)` by default or `local (<your command>)` when overridden. Unset the variable to go back to the PyPI release.

### Database

Everything is stored in a single SQLite file at `~/.printpal/printpal.db`:
Expand All @@ -251,6 +304,7 @@ Backups are stored at `~/.printpal/backups/`.
| `THINGIVERSE_TOKEN is not set` | Run `/config set thingiverse_token <token>` or add to `.env` |
| `OCTOPRINT_URL and OCTOPRINT_API_KEY not set` | Run `/config set octoprint_url <url>` and `/config set octoprint_api_key <key>` |
| `CuraEngine not found` | Install Ultimaker Cura, or set `PRINTMCP_CURA_DIR` via `/config set cura_dir <path>` |
| `orca_slice_model tool not found` | Install [OrcaSlicer](https://www.orcaslicer.com/) (native or Flatpak), or slice with Cura: `/slice <id> --slicer cura` |
| Permission denied on every tool call | Switch to auto mode: `/mode auto` or press Shift+Tab |
| `uv` not found | Install uv: `pip install uv` or see [uv docs](https://docs.astral.sh/uv/) |

Expand Down
19 changes: 19 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,22 @@ build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/printpal"]

[tool.pytest.ini_options]
markers = [
"integration: end-to-end tests that require real tools (e.g. OrcaSlicer) or sibling checkouts; deselect with -m 'not integration'",
]

[tool.ruff]
line-length = 100
target-version = "py310"

[tool.ruff.lint]
# Keep CI stable as ruff evolves: select a fixed, intentional rule set (the
# pycodestyle/pyflakes/isort/pep8-naming/pyupgrade/bugbear family), matching
# what PrintMCP enforces. Newer ruff's broader defaults (blind-except, naive
# datetimes, try/except-pass, nested-if hints) flag the REPL's deliberate
# never-crash guards and the DB's local-time timestamp convention.
select = ["E", "F", "I", "N", "UP", "B"]
# Long lines are handled by the formatter; don't double-flag them.
ignore = ["E501"]
Loading
Loading