diff --git a/.agents/skills/release-bump-changelog/SKILL.md b/.agents/skills/release-bump-changelog/SKILL.md new file mode 100644 index 000000000..0c7f90e74 --- /dev/null +++ b/.agents/skills/release-bump-changelog/SKILL.md @@ -0,0 +1,56 @@ +--- +name: release-bump-changelog +description: Use this skill when preparing a release bump or updating release notes. It writes a launch-style release story from the actual change set, then runs `cargo bump` so the generated GitHub notes and the marketing copy land together in `CHANGELOG.md`. +--- + +# Release Bump + Changelog + +## Goal + +Create a version bump commit where each release section includes both: + +- a launch-style narrative (marketing copy) +- the exact GitHub-generated release notes + +## Workflow + +1. Ensure the working tree is clean (except allowed release files). +2. Draft release story markdown from real changes (PR titles, release-note bullets, and diff themes). + - Target style: similar to the `v0.2.0` narrative (clear positioning + concrete highlights). + - Keep it factual and specific to the release. + - Write to a temp file (outside repo is preferred): + - `marketing_file="$(mktemp)"` + - write markdown content to `$marketing_file` +3. Run `cargo bump ` with marketing copy input: + - `SPACEBOT_RELEASE_MARKETING_COPY_FILE="$marketing_file" cargo bump <...>` + - This invokes `scripts/release-tag.sh`. + - The script generates GitHub-native notes (`gh api .../releases/generate-notes`). + - The script upserts `CHANGELOG.md` with: + - `### Release Story` (from your marketing file) + - GitHub-generated notes body + - The script includes `CHANGELOG.md` in the release commit. +4. Verify results: + - `git show --name-only --stat` + - Confirm commit contains `Cargo.toml`, `Cargo.lock` (if present), and `CHANGELOG.md`. + - Confirm tag was created (`git tag --list "v*" --sort=-v:refname | head -n 5`). + +## Requirements + +- `gh` CLI installed and authenticated (`gh auth status`). +- `origin` remote points to GitHub, or set `SPACEBOT_RELEASE_REPO=`. +- Marketing copy is required unless explicitly bypassed with `SPACEBOT_SKIP_MARKETING_COPY=1`. + +## Release Story Format + +Use markdown only (no outer `## vX.Y.Z` heading; script adds it). Recommended structure: + +1. One strong opening paragraph (why this release matters) +2. One paragraph on major technical shifts +3. Optional short highlight bullets for standout additions/fixes + +Avoid vague hype. Tie claims to concrete shipped changes. + +## Notes + +- Do not use a standalone changelog sync script. +- `CHANGELOG.md` is seeded from historical releases and then maintained by the release bump workflow. diff --git a/AGENTS.md b/AGENTS.md index 1c321cbd1..97d13aa73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ Creating a branch is `let branch_history = channel_history.clone()`. The branch result is injected into the channel's history as a distinct message type. Then the branch is deleted. Multiple branches can run concurrently per channel (configurable limit). First done, first incorporated. -**Tools:** memory_recall, memory_save, channel_recall, spawn_worker +**Tools:** memory_recall, memory_save, memory_delete, channel_recall, spacebot_docs, task_create, task_list, task_update, spawn_worker **Context:** Clone of channel history at fork time **Lifecycle:** Short-lived. Returns a conclusion, then deleted. @@ -106,6 +106,7 @@ System-level observer. Primary job: generate the **memory bulletin** — a perio Also observes system-wide signals for future health monitoring and memory consolidation. **Tools (bulletin generation):** memory_recall, memory_save +**Tools (interactive cortex chat):** memory + worker tools, `spacebot_docs`, `config_inspect`, task board tools **Tools (future health monitoring):** memory_consolidate, system_monitor **Context:** Fresh per bulletin run. No compaction needed. @@ -179,6 +180,11 @@ src/ │ ├── file.rs — read/write/list files (task workers) │ ├── exec.rs — run subprocess (task workers) │ ├── browser.rs — web browsing (task workers) +│ ├── task_create.rs — create task-board task (branch + cortex chat) +│ ├── task_list.rs — list task-board tasks (branch + cortex chat) +│ ├── task_update.rs — update task-board task (branch + cortex chat) +│ ├── spacebot_docs.rs — read embedded Spacebot docs/changelog (branch + cortex chat) +│ ├── config_inspect.rs — inspect live runtime config (cortex chat) │ └── cron.rs — cron management (channel only) │ ├── memory.rs → memory/ @@ -285,7 +291,7 @@ let branch_history = channel_history.clone(); **ToolServer topology:** - Per-channel `ToolServer` (no memory tools, just channel action tools added per turn) -- Per-branch `ToolServer` with memory tools (memory_save, memory_recall) +- Per-branch `ToolServer` with memory tools (memory_save, memory_recall, memory_delete), channel recall, docs introspection (`spacebot_docs`), and task-board tools - Per-worker `ToolServer` with task-specific tools (shell, file, exec) - Per-cortex `ToolServer` with memory_save diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..972fd60d0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,409 @@ +# Changelog + +Seeded from GitHub releases; maintained by the release bump workflow. + +## v0.2.2 + +- Tag: `v0.2.2` +- Published: 2026-03-01T13:00:02Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.2.2 + +## What's Changed +* fix: infer default routing from configured provider by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/266 +* Sandbox hardening: dynamic mode, env sanitization, leak detection by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/259 +* Secret store: credential isolation, encryption at rest, output scrubbing by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/260 +* feat: auto-download Chrome via fetcher, unify Docker image, fix singleton lock by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/268 +* fix: preserve conversation history and improve worker retrigger reliability by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/270 +* Add interface CI workflow by @marijnvdwerf in https://github.com/spacedriveapp/spacebot/pull/267 +* Split channel.rs and standardize adapter metadata keys by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/271 +* fix: allow trustd mach service in macOS sandbox for TLS cert verification by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/272 +* feat: add OpenRouter app attribution headers by @l33t0 in https://github.com/spacedriveapp/spacebot/pull/264 +* feat: implement link channels as task delegation (v3) by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/255 + + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.2.1...v0.2.2 + +## v0.2.1 + +- Tag: `v0.2.1` +- Published: 2026-02-27T11:54:42Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.2.1 + +## What's Changed +* Improve task UI overflow handling and docker update rollback by @fyzz-dev in https://github.com/spacedriveapp/spacebot/pull/237 +* fix Anthropic empty text blocks in retrigger flow by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/243 +* Fix: Cleanup twitch_token.json when disconnecting Twitch platform by @Nebhay in https://github.com/spacedriveapp/spacebot/pull/212 +* feat: add email messaging adapter and setup docs by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/244 +* fix: match installed skills by source repo, not just name by @mwmdev in https://github.com/spacedriveapp/spacebot/pull/205 +* chore: add delivery gates and repo-local pr-gates skill by @vsumner in https://github.com/spacedriveapp/spacebot/pull/238 +* feat(channel): add deterministic temporal context by @vsumner in https://github.com/spacedriveapp/spacebot/pull/239 +* feat: add IMAP email_search tool for branch read-back by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/246 +* feat(cron): add strict wall-clock schedule support by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/247 +* feat: named messaging adapter instances by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/249 +* fix: log cross-channel messages to destination channel history by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/252 +* add DeepWiki badge to README by @devabdultech in https://github.com/spacedriveapp/spacebot/pull/251 +* fix(cortex): harden startup warmup and bulletin coordination by @vsumner in https://github.com/spacedriveapp/spacebot/pull/248 +* feat: Download images as bytes for interpretation in Slack/Discord etc and fix Slack file ingestion by @egenvall in https://github.com/spacedriveapp/spacebot/pull/159 +* fix: make Ollama provider testable from settings UI by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/253 + +## New Contributors +* @fyzz-dev made their first contribution in https://github.com/spacedriveapp/spacebot/pull/237 +* @devabdultech made their first contribution in https://github.com/spacedriveapp/spacebot/pull/251 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.2.0...v0.2.1 + +## v0.2.0 + +- Tag: `v0.2.0` +- Published: 2026-02-26T08:16:48Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.2.0 + +## v0.2.0 is the _biggest_ Spacebot release yet. + +The agent is no longer a single-channel chatbot, it's a multi-agent system with real orchestration primitives. + +Screenshot_2026-02-23_at_12 48 29_PM copy + +Agents coordinate through a spec-driven task system with a full kanban board in the UI. Tasks are structured markdown documents with requirements, constraints, and acceptance criteria. The cortex background loop picks up ready tasks, spawns workers, and handles completion or re-queuing on failure. Agents see the shared task board through the bulletin system, so delegation happens through specs, not conversation. + +Workers got a complete visibility overhaul. Full transcript persistence with gzip compression, live SSE streaming of tool calls as they happen, and a new worker_inspect tool so branches can verify what a worker actually did instead of trusting a one-line summary. + +On the security front, the old string-based command filtering (215+ lines of whack-a-mole regex) has been replaced with kernel-enforced filesystem sandboxing via bubblewrap on Linux and sandbox-exec on macOS. The LLM can't write outside the workspace because the OS won't let it. + +This release also brings OpenAI and Anthropic subscription auth support, better channel history preservation with deterministic retrigger handling, structured text payload blocking to keep raw JSON/XML out of user-facing messages, self-hosted update controls in the settings UI, new provider support (Kilo Gateway, OpenCode Go), prebuilt Linux binaries for amd64/arm64, a Nix flake, and a pile of fixes across cron scheduling, OAuth, model routing, and more. + +## What's Changed +* feat(nix): add Nix flake for building and deploying Spacebot by @skulldogged in https://github.com/spacedriveapp/spacebot/pull/47 +* Fix chatgpt oauth by @marijnvdwerf in https://github.com/spacedriveapp/spacebot/pull/187 +* Multi-agent communication graph by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/150 +* Process sandbox: kernel-enforced filesystem containment for shell/exec by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/188 +* Workers tab: full transcript viewer, live SSE streaming, introspection tool by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/192 +* add settings update controls and harden self-hosted update flow by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/207 +* feat(web): ui/ux cleanup by @skulldogged in https://github.com/spacedriveapp/spacebot/pull/143 +* feat(ci): publish binaries for linux/amd64 and linux/arm64 on release by @morgaesis in https://github.com/spacedriveapp/spacebot/pull/94 +* block structured text payloads from user replies by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/209 +* Fix Z.AI Coding Plan model routing by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/210 +* fix: use Bearer auth when key comes from ANTHROPIC_AUTH_TOKEN by @worldofgeese in https://github.com/spacedriveapp/spacebot/pull/196 +* fix: use Bearer auth for ANTHROPIC_AUTH_TOKEN and add ANTHROPIC_MODEL by @worldofgeese in https://github.com/spacedriveapp/spacebot/pull/197 +* Task tracking system with kanban UI and spec-driven delegation by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/227 +* fix: make background result retriggers deterministic by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/231 +* fix: guide users to enable device code login for ChatGPT OAuth by @mwmdev in https://github.com/spacedriveapp/spacebot/pull/214 +* fix(cron): make cron scheduler reliable under load and in containers by @mmmeff in https://github.com/spacedriveapp/spacebot/pull/186 +* Fix Z.AI coding-plan model remap for GLM-5 by @vsumner in https://github.com/spacedriveapp/spacebot/pull/223 +* fix: Default cron delivery target to current conversation by @jaaneh in https://github.com/spacedriveapp/spacebot/pull/213 +* feat(llm): add Kilo Gateway and OpenCode Go provider support by @skulldogged in https://github.com/spacedriveapp/spacebot/pull/225 + +## New Contributors +* @morgaesis made their first contribution in https://github.com/spacedriveapp/spacebot/pull/94 +* @worldofgeese made their first contribution in https://github.com/spacedriveapp/spacebot/pull/196 +* @mwmdev made their first contribution in https://github.com/spacedriveapp/spacebot/pull/214 +* @mmmeff made their first contribution in https://github.com/spacedriveapp/spacebot/pull/186 +* @jaaneh made their first contribution in https://github.com/spacedriveapp/spacebot/pull/213 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.15...v0.2.0 + +## v0.1.15 + +- Tag: `v0.1.15` +- Published: 2026-02-24T01:37:52Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.15 + +## What's Changed +* fix: resolve pre-existing CI failures (clippy, fmt, test) by @Marenz in https://github.com/spacedriveapp/spacebot/pull/174 +* fix: wire up ollama_base_url shorthand in config by @Marenz in https://github.com/spacedriveapp/spacebot/pull/175 +* fix: return synthetic empty text on Anthropic empty content response by @Marenz in https://github.com/spacedriveapp/spacebot/pull/171 +* fix: accept string values for timeout_seconds from LLMs by @Marenz in https://github.com/spacedriveapp/spacebot/pull/169 +* feat(telegram): use send_audio for audio MIME types by @Marenz in https://github.com/spacedriveapp/spacebot/pull/170 +* feat(skills): workers discover skills on demand via read_skill tool by @Marenz in https://github.com/spacedriveapp/spacebot/pull/172 +* fix: avoid panic on multibyte char boundary in log message truncation by @Marenz in https://github.com/spacedriveapp/spacebot/pull/176 +* ChatGPT OAuth browser flow + provider split by @marijnvdwerf in https://github.com/spacedriveapp/spacebot/pull/157 +* Fix worker completion results not reaching users by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/182 +* Default MiniMax to M2.5 and enable reasoning by @hotzen in https://github.com/spacedriveapp/spacebot/pull/180 +* fix: register groq/together/xai/mistral/deepseek providers from shorthand config keys by @Marenz in https://github.com/spacedriveapp/spacebot/pull/179 +* Bugfix: Update dependencies for Slack TLS by @egenvall in https://github.com/spacedriveapp/spacebot/pull/165 +* Add warmup readiness contract and dispatch safeguards by @vsumner in https://github.com/spacedriveapp/spacebot/pull/181 +* fix(slack): Slack channel fixes, DM filtering, emoji sanitization, and restore TLS on websocket by @sra in https://github.com/spacedriveapp/spacebot/pull/148 + +## New Contributors +* @vsumner made their first contribution in https://github.com/spacedriveapp/spacebot/pull/181 +* @sra made their first contribution in https://github.com/spacedriveapp/spacebot/pull/148 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.14...v0.1.15 + +## v0.1.14 + +- Tag: `v0.1.14` +- Published: 2026-02-23T00:19:45Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.14 + +## What's Changed +* feat(mcp): add retry/backoff and CRUD API by @l33t0 in https://github.com/spacedriveapp/spacebot/pull/109 +* feat(ux): add drag-and-drop sorting for agents in sidebar by @MakerDZ in https://github.com/spacedriveapp/spacebot/pull/113 +* fix(channel): roll back history on PromptCancelled to prevent poisoned turns by @Marenz in https://github.com/spacedriveapp/spacebot/pull/114 +* Fix CI failures: rustfmt, clippy, and flaky test by @Marenz in https://github.com/spacedriveapp/spacebot/pull/116 +* fix(channel): prevent bot spamming from retrigger cascades by @PyRo1121 in https://github.com/spacedriveapp/spacebot/pull/115 +* feat(security): add auth middleware, SSRF protection, shell hardening, and encrypted secrets by @PyRo1121 in https://github.com/spacedriveapp/spacebot/pull/117 +* remove obsolete plan document from #58 by @hotzen in https://github.com/spacedriveapp/spacebot/pull/142 +* fix(build): restore compile after security middleware + URL validation changes by @bilawalriaz in https://github.com/spacedriveapp/spacebot/pull/125 +* fix(telegram): render markdown as Telegram HTML with safe, telegram-only fallbacks by @bilawalriaz in https://github.com/spacedriveapp/spacebot/pull/126 +* Fix Fireworks by @Nebhay in https://github.com/spacedriveapp/spacebot/pull/91 +* fix: harden 13 security vulnerabilities (phase 2) by @PyRo1121 in https://github.com/spacedriveapp/spacebot/pull/119 +* fix: replace .expect()/.unwrap() with proper error propagation in production code by @PyRo1121 in https://github.com/spacedriveapp/spacebot/pull/122 +* feat(twitch): Add Twitch token refresh by @Nebhay in https://github.com/spacedriveapp/spacebot/pull/144 +* feat: add minimax-cn provider for CN users by @shuuul in https://github.com/spacedriveapp/spacebot/pull/140 +* feat(telemetry): complete metrics instrumentation with cost tracking and per-agent context by @l33t0 in https://github.com/spacedriveapp/spacebot/pull/102 +* feat: support ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN and SPACEBOT_MODEL env vars by @adryserage in https://github.com/spacedriveapp/spacebot/pull/135 +* Fix cron timezone resolution and delete drift by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/149 +* feat: update Gemini model support with latest Google models by @adryserage in https://github.com/spacedriveapp/spacebot/pull/134 +* feat(web): add favicon files and update HTML to include them by @the-snesler in https://github.com/spacedriveapp/spacebot/pull/154 +* fix: add API body size limits and memory content validation by @PyRo1121 in https://github.com/spacedriveapp/spacebot/pull/123 + +## New Contributors +* @PyRo1121 made their first contribution in https://github.com/spacedriveapp/spacebot/pull/115 +* @hotzen made their first contribution in https://github.com/spacedriveapp/spacebot/pull/142 +* @bilawalriaz made their first contribution in https://github.com/spacedriveapp/spacebot/pull/125 +* @shuuul made their first contribution in https://github.com/spacedriveapp/spacebot/pull/140 +* @adryserage made their first contribution in https://github.com/spacedriveapp/spacebot/pull/135 +* @the-snesler made their first contribution in https://github.com/spacedriveapp/spacebot/pull/154 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.13...v0.1.14 + +## v0.1.13 + +- Tag: `v0.1.13` +- Published: 2026-02-21T23:00:59Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.13 + +## What's Changed +* Improve channel reply flow and Discord binding behavior by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/95 +* feat(messaging): unify cross-channel delivery target resolution by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/97 +* feat: add dedicated voice model routing and attachment transcription by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/98 +* Fix all warnings and clippy lints by @Marenz in https://github.com/spacedriveapp/spacebot/pull/87 +* Add CI workflow (check, clippy, fmt, test) by @Marenz in https://github.com/spacedriveapp/spacebot/pull/101 +* Add native poll support to telegram adapter by @Marenz in https://github.com/spacedriveapp/spacebot/pull/93 +* docs(agents): update existing documentation when adding features by @Marenz in https://github.com/spacedriveapp/spacebot/pull/106 +* feat(llm): add Google Gemini API provider support by @MakerDZ in https://github.com/spacedriveapp/spacebot/pull/111 +* prompts: add missing memory-type guidance in memory flows by @marijnvdwerf in https://github.com/spacedriveapp/spacebot/pull/112 +* add mcp client support for workers by @nexxeln in https://github.com/spacedriveapp/spacebot/pull/103 +* Avoid requiring static API key for OAuth Login by @egenvall in https://github.com/spacedriveapp/spacebot/pull/100 + +## New Contributors +* @MakerDZ made their first contribution in https://github.com/spacedriveapp/spacebot/pull/111 +* @marijnvdwerf made their first contribution in https://github.com/spacedriveapp/spacebot/pull/112 +* @nexxeln made their first contribution in https://github.com/spacedriveapp/spacebot/pull/103 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.12...v0.1.13 + +## v0.1.12 + +- Tag: `v0.1.12` +- Published: 2026-02-21T02:15:01Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.12 + +## Note about v0.1.11 + +v0.1.11 was removed due to a bad migration, and its tag/release were deleted. +v0.1.12 includes those intended changes plus additional fixes. + +## Highlights included from the missing v0.1.11 window + +- Added hosted agent limit functionality +- Added backup export and restore endpoints +- Added storage status endpoint and filesystem usage reporting +- Improved release tagging/version bump workflow (including Cargo.lock handling) + +## What's Changed +* fix: register NVIDIA provider and base URL by @Nebhay in https://github.com/spacedriveapp/spacebot/pull/82 +* fix: Portal Chat isolation by @jnyecode in https://github.com/spacedriveapp/spacebot/pull/80 +* Nudge previously rejected DM users when added to allow list by @Marenz in https://github.com/spacedriveapp/spacebot/pull/78 +* Telegram adapter fixes: attachments, reply-to, and retry on startup by @Marenz in https://github.com/spacedriveapp/spacebot/pull/77 +* Anthropic OAuth authentication with PKCE and auto-refresh by @Marenz in https://github.com/spacedriveapp/spacebot/pull/76 +* fix: Prevent duplicate message replies by differentiating skip and replied flags by @thesammykins in https://github.com/spacedriveapp/spacebot/pull/69 +* fix(cron): prevent timer leak and improve scheduler reliability by @michaelbship in https://github.com/spacedriveapp/spacebot/pull/81 +* feat(cron): add configurable timeout_secs per cron job by @michaelbship in https://github.com/spacedriveapp/spacebot/pull/83 +* feat: add mention-gated Discord bindings and one-time cron jobs by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/88 +* docs: update README for new features since last update by @Marenz in https://github.com/spacedriveapp/spacebot/pull/92 + +## New Contributors +* @Nebhay made their first contribution in https://github.com/spacedriveapp/spacebot/pull/82 +* @michaelbship made their first contribution in https://github.com/spacedriveapp/spacebot/pull/81 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.10...v0.1.12 + +## v0.1.10 + +- Tag: `v0.1.10` +- Published: 2026-02-20T08:45:11Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.10 + +## What's Changed +* feat: Add Z.AI Coding Plan provider by @thesammykins in https://github.com/spacedriveapp/spacebot/pull/67 +* chore: optimize release profile to reduce binary size by @thesammykins in https://github.com/spacedriveapp/spacebot/pull/70 +* feat(slack): cache user identities and resolve channel names by @jamiepine in https://github.com/spacedriveapp/spacebot/pull/71 + +## New Contributors +* @jamiepine made their first contribution in https://github.com/spacedriveapp/spacebot/pull/71 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.9...v0.1.10 + +## v0.1.9 + +- Tag: `v0.1.9` +- Published: 2026-02-20T04:25:44Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.9 + +## What's Changed +* add local ollama provider by @mmattbtw in https://github.com/spacedriveapp/spacebot/pull/18 +* fix(docs): add favicon, fix theme toggle, and resolve og:image localhost issue by @andrasbacsai in https://github.com/spacedriveapp/spacebot/pull/29 +* feat(llm): add NVIDIA NIM provider support by @skulldogged in https://github.com/spacedriveapp/spacebot/pull/46 +* Update slack connector to include additional sender metadata by @ACPixel in https://github.com/spacedriveapp/spacebot/pull/43 +* feat(telemetry): add Prometheus metrics with feature-gated instrumentation by @l33t0 in https://github.com/spacedriveapp/spacebot/pull/35 +* fix(ingestion): do not delete ingest files when chunk processing fails by @sookochoff in https://github.com/spacedriveapp/spacebot/pull/57 +* feat: Improve Slack Markdown by @egenvall in https://github.com/spacedriveapp/spacebot/pull/52 +* fix: key Discord typing indicator by channel ID to prevent stuck indicator by @tomasmach in https://github.com/spacedriveapp/spacebot/pull/53 +* fix: Telegram adapter improvements by @Marenz in https://github.com/spacedriveapp/spacebot/pull/50 +* Adds pdf ingestion by @ACPixel in https://github.com/spacedriveapp/spacebot/pull/63 +* feat: add markdown preview toggle to identity editors by @tomasmach in https://github.com/spacedriveapp/spacebot/pull/59 +* Add GitHub CLI to default docker image by @ACPixel in https://github.com/spacedriveapp/spacebot/pull/61 +* feat(llm): add custom providers and dynamic API routing by @sbtobb in https://github.com/spacedriveapp/spacebot/pull/36 +* fix: prevent panic in split_message on multibyte UTF-8 char boundaries by @tomasmach in https://github.com/spacedriveapp/spacebot/pull/49 +* feat(slack): app_mention, ephemeral messages, Block Kit, scheduled messages, typing indicator by @sookochoff in https://github.com/spacedriveapp/spacebot/pull/58 +* feat(slack): slash commands (Phase 3) + Block Kit interactions (Phase 2b) by @sookochoff in https://github.com/spacedriveapp/spacebot/pull/60 +* Add Portal Chat for direct web-based agent interaction by @jnyecode in https://github.com/spacedriveapp/spacebot/pull/64 +* feat: add MiniMax as native provider by @ricorna in https://github.com/spacedriveapp/spacebot/pull/26 +* feat: add Moonshot AI (Kimi) as native provider by @ricorna in https://github.com/spacedriveapp/spacebot/pull/25 +* feat: Discord rich messages (Embeds, Buttons, Polls) by @thesammykins in https://github.com/spacedriveapp/spacebot/pull/66 + +## New Contributors +* @mmattbtw made their first contribution in https://github.com/spacedriveapp/spacebot/pull/18 +* @skulldogged made their first contribution in https://github.com/spacedriveapp/spacebot/pull/46 +* @ACPixel made their first contribution in https://github.com/spacedriveapp/spacebot/pull/43 +* @l33t0 made their first contribution in https://github.com/spacedriveapp/spacebot/pull/35 +* @sookochoff made their first contribution in https://github.com/spacedriveapp/spacebot/pull/57 +* @egenvall made their first contribution in https://github.com/spacedriveapp/spacebot/pull/52 +* @Marenz made their first contribution in https://github.com/spacedriveapp/spacebot/pull/50 +* @sbtobb made their first contribution in https://github.com/spacedriveapp/spacebot/pull/36 +* @jnyecode made their first contribution in https://github.com/spacedriveapp/spacebot/pull/64 +* @ricorna made their first contribution in https://github.com/spacedriveapp/spacebot/pull/26 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.8...v0.1.9 + +## v0.1.8 + +- Tag: `v0.1.8` +- Published: 2026-02-19T05:26:17Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.8 + +## What's Changed +* fix: set skip_flag in ReplyTool to prevent double reply by @tomasmach in https://github.com/spacedriveapp/spacebot/pull/39 +* fix(daemon): create instance directory before binding IPC socket by @BruceMacD in https://github.com/spacedriveapp/spacebot/pull/37 +* otel by @Brendonovich in https://github.com/spacedriveapp/spacebot/pull/30 +* fix otel by @Brendonovich in https://github.com/spacedriveapp/spacebot/pull/41 +* make otel actually work by @Brendonovich in https://github.com/spacedriveapp/spacebot/pull/42 + +## New Contributors +* @tomasmach made their first contribution in https://github.com/spacedriveapp/spacebot/pull/39 +* @BruceMacD made their first contribution in https://github.com/spacedriveapp/spacebot/pull/37 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.7...v0.1.8 + +## v0.1.7 + +- Tag: `v0.1.7` +- Published: 2026-02-18T18:09:56Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.7 + +## What's Changed +* fix(config): support numeric telegram chat_id binding match by @cyllas in https://github.com/spacedriveapp/spacebot/pull/34 +* Add ARM64 multi-platform Docker images by @andrasbacsai in https://github.com/spacedriveapp/spacebot/pull/27 + +## New Contributors +* @cyllas made their first contribution in https://github.com/spacedriveapp/spacebot/pull/34 +* @andrasbacsai made their first contribution in https://github.com/spacedriveapp/spacebot/pull/27 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.6...v0.1.7 + +## v0.1.6 + +- Tag: `v0.1.6` +- Published: 2026-02-18T10:14:21Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.6 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.5...v0.1.6 + +## v0.1.5 + +- Tag: `v0.1.5` +- Published: 2026-02-18T07:50:05Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.5 + +## What's Changed +* Fix broken documentation links in README by @joseph-lozano in https://github.com/spacedriveapp/spacebot/pull/11 +* fix: IPv6 socket address parsing for Docker deployments by @pablopunk in https://github.com/spacedriveapp/spacebot/pull/14 + +## New Contributors +* @joseph-lozano made their first contribution in https://github.com/spacedriveapp/spacebot/pull/11 +* @pablopunk made their first contribution in https://github.com/spacedriveapp/spacebot/pull/14 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.4...v0.1.5 + +## v0.1.4 + +- Tag: `v0.1.4` +- Published: 2026-02-17T23:23:34Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.4 + +## What's Changed +* Run release workflow on x86 and ARM runners by @Brendonovich in https://github.com/spacedriveapp/spacebot/pull/3 +* Fix OpenCode Zen provider icon by @Brendonovich in https://github.com/spacedriveapp/spacebot/pull/5 +* better provider list by @Brendonovich in https://github.com/spacedriveapp/spacebot/pull/6 +* Fix Z.ai provider icon by @jiunshinn in https://github.com/spacedriveapp/spacebot/pull/7 +* improve docker build by @Brendonovich in https://github.com/spacedriveapp/spacebot/pull/4 +* Fix quick start by @doanbactam in https://github.com/spacedriveapp/spacebot/pull/8 + +## New Contributors +* @doanbactam made their first contribution in https://github.com/spacedriveapp/spacebot/pull/8 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.3...v0.1.4 + +## v0.1.3 + +- Tag: `v0.1.3` +- Published: 2026-02-17T03:59:34Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.3 + +## What's Changed +* Add OpenCode Zen provider support by @Brendonovich in https://github.com/spacedriveapp/spacebot/pull/2 + + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.2...v0.1.3 + +## v0.1.2 + +- Tag: `v0.1.2` +- Published: 2026-02-17T01:14:40Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.2 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/compare/v0.1.1...v0.1.2 + +## v0.1.1 + +- Tag: `v0.1.1` +- Published: 2026-02-17T00:04:10Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.1 + +## What's Changed +* Add native Z.ai (GLM) provider by @jiunshinn in https://github.com/spacedriveapp/spacebot/pull/1 + +## New Contributors +* @jiunshinn made their first contribution in https://github.com/spacedriveapp/spacebot/pull/1 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/commits/v0.1.1 + +## v0.1.0 + +- Tag: `v0.1.0` +- Published: 2026-02-15T22:31:48Z +- URL: https://github.com/spacedriveapp/spacebot/releases/tag/v0.1.0 + +**Full Changelog**: https://github.com/spacedriveapp/spacebot/commits/v0.1.0 diff --git a/docs/content/docs/(core)/cortex.mdx b/docs/content/docs/(core)/cortex.mdx index 3622cfb17..c3cd8ca94 100644 --- a/docs/content/docs/(core)/cortex.mdx +++ b/docs/content/docs/(core)/cortex.mdx @@ -140,6 +140,17 @@ The cortex is the only singleton in the system (per agent). There's one cortex p **The cortex** is an LLM-assisted process that sees across all channels. It doesn't manage context size (that's the compactor's job). It manages the memory bulletin, and will eventually handle memory coherence and system health. +## Interactive Cortex Chat + +Spacebot also exposes a direct admin chat session with the cortex. This is intentionally self-referential and diagnostic-focused: + +- The system prompt includes embedded architecture context (`AGENTS.md`) and changelog highlights. +- The prompt includes a live redacted runtime-config snapshot so behavior can be diagnosed against current resolved values. +- `config_inspect` returns live hot-reloaded config sections on demand. +- `spacebot_docs` reads embedded Spacebot docs/changelog/AGENTS content directly from the binary. + +This makes cortex chat a practical control-room interface for troubleshooting, validation, and operations — not a user-facing conversation mode. + ## Configuration ```toml diff --git a/docs/content/docs/(deployment)/roadmap.mdx b/docs/content/docs/(deployment)/roadmap.mdx index b1931988f..2667abfa1 100644 --- a/docs/content/docs/(deployment)/roadmap.mdx +++ b/docs/content/docs/(deployment)/roadmap.mdx @@ -36,7 +36,7 @@ The full message-in → LLM → response-out pipeline is wired end-to-end across - **Telegram adapter** — full teloxide implementation (long polling, typing indicators, attachment extraction, chat/DM filtering, 4096 char splitting) - **Slack adapter** — full slack-morphism implementation (Socket Mode, thread replies, file upload v2, reactions, streaming via edit, workspace/channel/DM filtering via hot-reloadable permissions) - **Webhook adapter** — Axum HTTP server (POST /send, GET `/poll/{id}`, GET /health) -- **Tools** — 16 tools implement Rig's `Tool` trait with real logic (reply, branch, spawn_worker, route, cancel, skip, react, memory_save, memory_recall, set_status, shell, file, exec, browser, cron, web_search) +- **Tools** — 20+ tools implement Rig's `Tool` trait with real logic (including task board tools, memory tools, `spacebot_docs`, and `config_inspect` alongside reply/branch/worker/browser/shell primitives) - **Workspace containment** — file tool validates paths stay within workspace boundary, shell/exec tools block instance directory traversal, sensitive file access, and secret env var leakage - **Conversation persistence** — `ConversationLogger` with fire-and-forget SQLite writes, compaction archiving - **Cron** — scheduler with timers, active hours, circuit breaker (3 failures → disable), creates real channels. CronTool wired into channel tool factory. diff --git a/docs/content/docs/(features)/tools.mdx b/docs/content/docs/(features)/tools.mdx index 35207de4e..d74139a67 100644 --- a/docs/content/docs/(features)/tools.mdx +++ b/docs/content/docs/(features)/tools.mdx @@ -25,7 +25,9 @@ Core tools include: | `memory_save` | Write a memory to the store | Branch, Cortex, Compactor | | `memory_recall` | Search memories via hybrid search | Branch | | `channel_recall` | Retrieve transcript from another channel | Branch | +| `spacebot_docs` | Read embedded Spacebot docs/changelog/AGENTS | Branch, Cortex Chat | | `email_search` | Search IMAP mailbox content directly | Branch | +| `config_inspect` | Inspect live resolved runtime config (redacted) | Cortex Chat | | `set_status` | Report worker progress to the channel | Worker | | `shell` | Execute shell commands | Worker | | `file` | Read, write, and list files | Worker | @@ -71,12 +73,13 @@ Each branch gets its own isolated ToolServer, created at spawn time via `create_ ├──────────────────────────────────────────────┤ │ memory_save (Arc) │ │ memory_recall (Arc) │ +│ spacebot_docs (embedded docs) │ │ channel_recall (ConversationLogger) │ │ email_search (IMAP mailbox search) │ └──────────────────────────────────────────────┘ ``` -Branch isolation ensures `memory_recall`, `channel_recall`, and `email_search` are never visible to the channel. All tools are registered at creation and live for the lifetime of the branch. +Branch isolation ensures `memory_recall`, `channel_recall`, `spacebot_docs`, and `email_search` are never visible to the channel. All tools are registered at creation and live for the lifetime of the branch. ### Worker ToolServer (per-worker) @@ -138,7 +141,7 @@ create_cortex_tool_server(memory_search) -> ToolServerHandle ### Static tools (registered at creation) -`memory_save`, `memory_recall`, `channel_recall`, `email_search` on branch ToolServers. `shell`, `file`, `exec` on worker ToolServers. `memory_save` on cortex and compactor ToolServers. These are registered before `.run()` via the builder pattern and live for the lifetime of the ToolServer. +`memory_save`, `memory_recall`, `channel_recall`, `spacebot_docs`, `email_search` on branch ToolServers. `shell`, `file`, `exec` on worker ToolServers. `memory_save` on cortex and compactor ToolServers. These are registered before `.run()` via the builder pattern and live for the lifetime of the ToolServer. ### Dynamic tools (added/removed at runtime) @@ -155,7 +158,7 @@ create_cortex_tool_server(memory_search) -> ToolServerHandle ### Per-process tools (created and destroyed with the process) -Branch and worker ToolServers are created when the process spawns and dropped when it finishes. Each branch gets `memory_save` + `memory_recall` + `channel_recall` + `email_search`. Each worker gets `shell`, `file`, `exec`, `set_status` (bound to that worker's ID), and optionally `browser`. +Branch and worker ToolServers are created when the process spawns and dropped when it finishes. Each branch gets `memory_save` + `memory_recall` + `channel_recall` + `spacebot_docs` + `email_search` (plus task board tools). Each worker gets `shell`, `file`, `exec`, `set_status` (bound to that worker's ID), and optionally `browser`. ## Tool Design Patterns diff --git a/prompts/en/branch.md.j2 b/prompts/en/branch.md.j2 index 56b262120..e64788746 100644 --- a/prompts/en/branch.md.j2 +++ b/prompts/en/branch.md.j2 @@ -23,6 +23,7 @@ Depending on why the channel branched, you might: - **Spawn a worker** — If the user wants something done *now*, spawn a worker for it. Set a status so the channel knows what's happening. Return a summary of what you kicked off. - **Save for later** — If the user mentions something they want to do but not right now ("I need to update the tests at some point", "remind me to check the deploy tomorrow"), save it as a **todo** memory instead of spawning a worker. The difference is timing intent: immediate action = worker, future action = todo. - **Manage the task board** — Create, refine, and advance tasks. Tasks are spec documents — the description is a full markdown spec that evolves through conversation. When creating a task, write a rich description and pre-fill subtasks. When the user refines scope, update the description. When ready, move to `ready` and the cortex picks it up automatically. +- **Check system docs on demand** — For questions about Spacebot itself (features, architecture, configuration, releases), read embedded docs via `spacebot_docs` instead of guessing. ## Tools @@ -35,6 +36,9 @@ Save something important that came up during your thinking. If you discovered a ### memory_delete Forget a memory by ID. Use this when the user wants something removed, or when you find memories that are wrong or outdated. Get memory IDs from memory_recall results. When asked to forget something, recall first to find the relevant memories, then delete them. +### spacebot_docs +Read embedded Spacebot docs, including `AGENTS.md`, `CHANGELOG.md`, and product docs from `docs/content/`. Use `action: "list"` to discover IDs, then `action: "read"` for the specific document. + ### spawn_worker If the user wants something done now and it needs execution tools (shell, file, exec), spawn a worker. Give it a specific task description with enough context to work independently. The worker won't have the conversation history — it only knows what you tell it. If the user is describing something for later rather than requesting immediate action, save a **todo** memory instead. @@ -64,3 +68,4 @@ Refine a task. Update the description as the user clarifies scope — append sec - **goal** — something the user or agent wants to achieve ("migrate to the new API by Q3"). Goals are aspirational and may span multiple conversations. - **todo** — a concrete actionable task or reminder ("update the auth tests", "remind me to check the deploy tomorrow"). Todos are specific and completable. 7. For time-sensitive conclusions, anchor on explicit timestamps/context and include concrete dates when ambiguity is possible. +8. For Spacebot self-knowledge questions, use `spacebot_docs` to ground your answer in source docs. diff --git a/prompts/en/channel.md.j2 b/prompts/en/channel.md.j2 index c98a8268c..edcd87daf 100644 --- a/prompts/en/channel.md.j2 +++ b/prompts/en/channel.md.j2 @@ -43,7 +43,7 @@ You are able to write code or do work extremely fast inside a worker, never say You have three paths for getting things done. Choosing the right one matters. -**Branch** — for thinking and memory. Branch when you need to recall, save, or forget something from long-term memory, manage the task board (create, list, update, or approve tasks), reason through a complex decision, figure out what instructions to give a worker, or retrieve transcript context from another channel. Branches have your full conversation context and access to the memory system (recall, save, and delete), task tools (`task_create`, `task_list`, `task_update`), cross-channel transcript recall (`channel_recall`), and worker transcript inspection (`worker_inspect`). They return a conclusion. You never see the working. Branch often — it's cheap and keeps you responsive. +**Branch** — for thinking and memory. Branch when you need to recall, save, or forget something from long-term memory, manage the task board (create, list, update, or approve tasks), reason through a complex decision, figure out what instructions to give a worker, answer Spacebot self-knowledge questions (features, architecture, configuration, release notes), or retrieve transcript context from another channel. Branches have your full conversation context and access to the memory system (recall, save, and delete), Spacebot docs lookup (`spacebot_docs`), task tools (`task_create`, `task_list`, `task_update`), cross-channel transcript recall (`channel_recall`), and worker transcript inspection (`worker_inspect`). They return a conclusion. You never see the working. Branch often — it's cheap and keeps you responsive. **Worker** — for doing. Workers have execution tools (see Worker Capabilities section below). They do NOT have your conversation context or access to memories — they only know what you tell them in the task description, so be specific. Two flavors: @@ -115,6 +115,7 @@ When in doubt, skip. Being a lurker who speaks when it matters is better than be 10. One worker per task. Never spawn multiple workers for the same request. If a worker is already handling something, wait for it to finish or route follow-ups to it. Check your status block before spawning. 11. On Discord and Slack, prefer rich responses when output is structured or multi-part (task outcomes, summaries, comparisons, checklists, incident/debug updates, plans). Use `reply` with `cards`/interactive elements (Discord) or `blocks` (Slack) instead of plain text walls when it improves clarity. 12. For time-sensitive responses, prefer concrete dates (for example, "March 5, 2026") in addition to relative phrases. +13. For questions about Spacebot itself (how to configure, what features exist, architecture details, release changes), always branch first and use `spacebot_docs` through the branch. Do not answer from memory alone. {%- if adapter_prompt %} ## Adapter Guidance diff --git a/prompts/en/cortex_chat.md.j2 b/prompts/en/cortex_chat.md.j2 index f922ba4a8..85fdbcb17 100644 --- a/prompts/en/cortex_chat.md.j2 +++ b/prompts/en/cortex_chat.md.j2 @@ -1,10 +1,28 @@ -You are the cortex in interactive mode — a direct line to the system's inner workings, -talking to an admin. +You are the cortex in interactive mode — the system diagnoser and operator interface for Spacebot admins. -You have the agent's full tool capabilities: memory operations, file system access, -shell execution, web browsing, and worker spawning. You are not the channel — you don't -talk to end users, you talk to the person running the system. +You are not the end-user channel. You do not roleplay personality. You provide technical diagnosis, +verification, and operations support for the running system. +{% if runtime_config_snapshot %} +## Live Runtime Snapshot (Redacted) +This is the current resolved runtime config for this agent. Treat it as source-of-truth for live behavior. + +```json +{{ runtime_config_snapshot }} +``` +{% endif %} +{% if changelog_highlights %} +## Recent Changelog Highlights +{{ changelog_highlights }} + +{% endif %} +{% if agents_manifest %} +## Embedded Repo Guide (`AGENTS.md`) +Use this as architecture and workflow reference when diagnosing Spacebot behavior. + +{{ agents_manifest }} + +{% endif %} {% if identity_context %} {{ identity_context }} @@ -24,18 +42,19 @@ The admin is currently viewing a channel conversation. Here is the recent activi {{ worker_capabilities }} ## Your Role -- Answer questions about what's happening in the system -- Recall and search memories on demand -- Execute tasks directly (shell, files, browsing) -- Spawn workers for longer operations -- Manage the task board — create tasks with rich markdown specs and pre-filled subtasks, refine descriptions as scope evolves, move tasks to `ready` for cortex pickup. Tools: `task_create`, `task_list`, `task_update` -- Save observations and memories -- Debug issues in conversations, memory, or system behavior +- Diagnose what is happening in the system right now +- Inspect live config and runtime readiness (`config_inspect`) +- Read Spacebot docs/changelog/AGENTS on demand (`spacebot_docs`) +- Recall and manage memories +- Execute tasks directly (shell, files, browser) when needed +- Spawn workers for longer operations and report worker IDs/tasks clearly +- Manage the task board (`task_create`, `task_list`, `task_update`) +- Save technical observations that should persist ## Rules -1. Be direct and technical. No personality, no fluff. -2. When asked about a conversation, reference the channel context above. -3. Use tools proactively — don't describe what you'd do, do it. -4. If you spawn a worker, report its ID and task. -5. Memory operations affect the live memory graph. -6. Keep responses concise. The admin knows how the system works. +1. Be direct, technical, and concise. No fluff. +2. Ground statements in live data (tools, context, config snapshot), not assumptions. +3. For Spacebot feature/how-to/architecture questions, consult `spacebot_docs`. +4. For config or behavior verification, prefer `config_inspect` over memory. +5. If you spawn a worker, report worker ID, task, and expected outcome. +6. Memory operations affect the live memory graph. diff --git a/prompts/en/tools/branch_description.md.j2 b/prompts/en/tools/branch_description.md.j2 index ec63f4cc7..a687a2bdd 100644 --- a/prompts/en/tools/branch_description.md.j2 +++ b/prompts/en/tools/branch_description.md.j2 @@ -1 +1 @@ -Fork a branch to think independently. The branch gets a clone of your current conversation history and has access to memory_recall, memory_save, memory_delete, task tools (task_create/task_list/task_update), and spawn_worker for execution handoff. It runs independently and returns a conclusion. In most cases where the branch is needed to answer accurately, call `skip` for the current turn and answer after the branch result arrives. +Fork a branch to think independently. The branch gets a clone of your current conversation history and has access to memory_recall, memory_save, memory_delete, `spacebot_docs`, task tools (task_create/task_list/task_update), and spawn_worker for execution handoff. Use a branch for Spacebot self-knowledge questions (features, architecture, configuration, release notes) so answers are grounded in docs instead of guesses. It runs independently and returns a conclusion. In most cases where the branch is needed to answer accurately, call `skip` for the current turn and answer after the branch result arrives. diff --git a/prompts/en/tools/config_inspect_description.md.j2 b/prompts/en/tools/config_inspect_description.md.j2 new file mode 100644 index 000000000..0434cde3a --- /dev/null +++ b/prompts/en/tools/config_inspect_description.md.j2 @@ -0,0 +1,7 @@ +Inspect the live resolved runtime config for this agent (redacted). + +- Returns current hot-reloaded values, not stale file defaults. +- Supports `section` filtering (for example: `routing`, `cortex`, `warmup`, `work_readiness`, `sandbox`, `mcp_servers`). +- Secret values are not exposed; only safe status metadata is returned. + +Use this tool for operational diagnosis, behavior verification, and config-driven debugging. diff --git a/prompts/en/tools/spacebot_docs_description.md.j2 b/prompts/en/tools/spacebot_docs_description.md.j2 new file mode 100644 index 000000000..5c3ac2717 --- /dev/null +++ b/prompts/en/tools/spacebot_docs_description.md.j2 @@ -0,0 +1,7 @@ +Read Spacebot's embedded documentation directly from the running binary. + +- Use `action: "list"` to discover available doc IDs (including `agents`, `changelog`, and product docs). +- Use `action: "read"` with `doc_id` to fetch a specific document. +- For long docs, page with `start_line` and `max_lines`. + +Use this tool whenever you need authoritative product/architecture guidance instead of guessing. diff --git a/scripts/release-tag.sh b/scripts/release-tag.sh index 12b1f6e36..3e809f7d4 100755 --- a/scripts/release-tag.sh +++ b/scripts/release-tag.sh @@ -7,6 +7,8 @@ CARGO_TOML="$REPO_ROOT/Cargo.toml" CARGO_TOML_RELATIVE="Cargo.toml" CARGO_LOCK="$REPO_ROOT/Cargo.lock" CARGO_LOCK_RELATIVE="Cargo.lock" +CHANGELOG_PATH="$REPO_ROOT/CHANGELOG.md" +CHANGELOG_RELATIVE="CHANGELOG.md" if [ ! -f "$CARGO_TOML" ]; then echo "Cargo.toml not found at $CARGO_TOML" >&2 @@ -18,13 +20,214 @@ if ! git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 1 fi +resolve_github_repo() { + if [ -n "${SPACEBOT_RELEASE_REPO:-}" ]; then + printf "%s\n" "$SPACEBOT_RELEASE_REPO" + return + fi + + local origin_url + origin_url="$(git -C "$REPO_ROOT" config --get remote.origin.url 2>/dev/null || true)" + if [ -z "$origin_url" ]; then + return + fi + + python3 - "$origin_url" <<'PY' +import re +import sys + +origin = sys.argv[1].strip() +match = re.search(r"github\.com[:/]([^/]+)/([^/.]+)(?:\.git)?$", origin) +if not match: + raise SystemExit(0) + +print(f"{match.group(1)}/{match.group(2)}") +PY +} + +resolve_marketing_copy_path() { + if [ "${SPACEBOT_SKIP_MARKETING_COPY:-0}" = "1" ]; then + printf "%s\n" "" + return + fi + + local path + path="${SPACEBOT_RELEASE_MARKETING_COPY_FILE:-}" + + if [ -z "$path" ]; then + cat >&2 <<'EOF' +Release marketing copy is required. + +Create a markdown file and rerun, for example: + marketing_file="$(mktemp)" + printf "\n" > "$marketing_file" + SPACEBOT_RELEASE_MARKETING_COPY_FILE="$marketing_file" cargo bump patch + +Set SPACEBOT_SKIP_MARKETING_COPY=1 to bypass this requirement. +EOF + return 1 + fi + + if [ ! -f "$path" ]; then + echo "Marketing copy file not found: $path" >&2 + return 1 + fi + + if [ ! -s "$path" ]; then + echo "Marketing copy file is empty: $path" >&2 + return 1 + fi + + printf "%s\n" "$path" +} + +generate_release_notes_body() { + local tag_name="$1" + local previous_tag="$2" + local output_file="$3" + local repo_slug + repo_slug="$(resolve_github_repo)" + + if [ -z "$repo_slug" ]; then + echo "Unable to determine GitHub repo slug from origin. Set SPACEBOT_RELEASE_REPO=." >&2 + return 1 + fi + + if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI is required to generate release notes for CHANGELOG.md." >&2 + return 1 + fi + + if ! gh auth status >/dev/null 2>&1; then + echo "gh CLI is not authenticated. Run 'gh auth login' first." >&2 + return 1 + fi + + local notes_json + notes_json="$(mktemp)" + local target_commitish + target_commitish="$(git -C "$REPO_ROOT" rev-parse HEAD)" + + if [ -n "$previous_tag" ]; then + gh api -X POST "repos/$repo_slug/releases/generate-notes" \ + -f "tag_name=$tag_name" \ + -f "target_commitish=$target_commitish" \ + -f "previous_tag_name=$previous_tag" \ + > "$notes_json" + else + gh api -X POST "repos/$repo_slug/releases/generate-notes" \ + -f "tag_name=$tag_name" \ + -f "target_commitish=$target_commitish" \ + > "$notes_json" + fi + + python3 - "$notes_json" "$output_file" <<'PY' +import json +import sys + +json_path, output_path = sys.argv[1], sys.argv[2] +with open(json_path, "r", encoding="utf-8") as handle: + payload = json.load(handle) + +body = (payload.get("body") or "").replace("\r\n", "\n").strip() +if not body: + body = "_No release notes generated._" + +with open(output_path, "w", encoding="utf-8") as handle: + handle.write(body + "\n") +PY + + rm -f "$notes_json" +} + +upsert_changelog_release() { + local changelog_path="$1" + local tag_name="$2" + local release_notes_path="$3" + local marketing_copy_path="${4:-}" + + python3 - "$changelog_path" "$tag_name" "$release_notes_path" "$marketing_copy_path" <<'PY' +import re +import sys +from pathlib import Path + +changelog_path, tag_name, notes_path, marketing_copy_path = ( + sys.argv[1], + sys.argv[2], + sys.argv[3], + sys.argv[4], +) +path = Path(changelog_path) + +if path.exists(): + content = path.read_text(encoding="utf-8") +else: + content = "# Changelog\n\n" + +if not content.startswith("# Changelog"): + content = "# Changelog\n\n" + content.lstrip() + +notes = Path(notes_path).read_text(encoding="utf-8").strip() + +marketing_copy = "" +if marketing_copy_path: + marketing_copy = Path(marketing_copy_path).read_text(encoding="utf-8").strip() + +parts = [f"## {tag_name}", ""] +if marketing_copy: + parts.extend(["### Release Story", "", marketing_copy, ""]) +parts.extend([notes, ""]) +entry = "\n".join(parts) + +release_heading_pattern = re.compile(r"(?m)^##\s+v?\d+\.\d+\.\d+\s*$") +target_heading_pattern = re.compile(rf"(?m)^##\s+{re.escape(tag_name)}\s*$") + +target_match = target_heading_pattern.search(content) +if target_match: + start = target_match.start() + next_match = release_heading_pattern.search(content, target_match.end()) + end = next_match.start() if next_match else len(content) + updated = content[:start].rstrip() + "\n\n" + entry + content[end:].lstrip("\n") +else: + first_release = release_heading_pattern.search(content) + if first_release: + prefix = content[:first_release.start()].rstrip() + suffix = content[first_release.start():].lstrip("\n") + updated = prefix + "\n\n" + entry + suffix + else: + updated = content.rstrip() + "\n\n" + entry + +path.write_text(updated.rstrip() + "\n", encoding="utf-8") +PY +} + disallowed_changes=() +marketing_copy_allowed_relative="" +if [ -n "${SPACEBOT_RELEASE_MARKETING_COPY_FILE:-}" ]; then + case "${SPACEBOT_RELEASE_MARKETING_COPY_FILE}" in + "$REPO_ROOT"/*) + marketing_copy_allowed_relative="${SPACEBOT_RELEASE_MARKETING_COPY_FILE#$REPO_ROOT/}" + ;; + /*) + ;; + *) + if [ "${SPACEBOT_RELEASE_MARKETING_COPY_FILE#../}" = "${SPACEBOT_RELEASE_MARKETING_COPY_FILE}" ]; then + marketing_copy_allowed_relative="${SPACEBOT_RELEASE_MARKETING_COPY_FILE#./}" + fi + ;; + esac +fi + while IFS= read -r file; do if [ -z "$file" ]; then continue fi - if [ "$file" != "$CARGO_TOML_RELATIVE" ] && [ "$file" != "$CARGO_LOCK_RELATIVE" ]; then + if [ "$file" != "$CARGO_TOML_RELATIVE" ] \ + && [ "$file" != "$CARGO_LOCK_RELATIVE" ] \ + && [ "$file" != "$CHANGELOG_RELATIVE" ] \ + && { [ -z "$marketing_copy_allowed_relative" ] || [ "$file" != "$marketing_copy_allowed_relative" ]; } + then disallowed_changes+=("$file") fi done < <( @@ -133,6 +336,18 @@ if git rev-parse -q --verify "refs/tags/$tag_name" >/dev/null; then exit 1 fi +previous_tag="" +if git rev-parse -q --verify "refs/tags/v$current_version" >/dev/null; then + previous_tag="v$current_version" +fi + +marketing_copy_path="$(resolve_marketing_copy_path)" + +release_notes_file="$(mktemp)" +generate_release_notes_body "$tag_name" "$previous_tag" "$release_notes_file" +upsert_changelog_release "$CHANGELOG_PATH" "$tag_name" "$release_notes_file" "$marketing_copy_path" +rm -f "$release_notes_file" + python3 - "$CARGO_TOML" "$current_version" "$next_version" <<'PY' import re import sys @@ -209,15 +424,19 @@ if not updated: with open(path, "w", encoding="utf-8") as file: file.writelines(lines) PY - git -C "$REPO_ROOT" add "$CARGO_TOML_RELATIVE" "$CARGO_LOCK_RELATIVE" + git -C "$REPO_ROOT" add "$CARGO_TOML_RELATIVE" "$CARGO_LOCK_RELATIVE" "$CHANGELOG_RELATIVE" else - git -C "$REPO_ROOT" add "$CARGO_TOML_RELATIVE" + git -C "$REPO_ROOT" add "$CARGO_TOML_RELATIVE" "$CHANGELOG_RELATIVE" fi git -C "$REPO_ROOT" commit -m "release: $tag_name" git -C "$REPO_ROOT" tag "$tag_name" echo "Bumped Cargo.toml version: $current_version -> $next_version" +echo "Updated changelog entry: $CHANGELOG_RELATIVE ($tag_name)" +if [ -n "$marketing_copy_path" ]; then + echo "Included release story from: $marketing_copy_path" +fi echo "Created commit: release: $tag_name" echo "Created tag: $tag_name" echo "Next: git push && git push origin $tag_name" diff --git a/src/agent/cortex_chat.rs b/src/agent/cortex_chat.rs index dc806c236..575bd85cd 100644 --- a/src/agent/cortex_chat.rs +++ b/src/agent/cortex_chat.rs @@ -425,6 +425,12 @@ impl CortexChatSession { let identity_context = runtime_config.identity.load().render(); let memory_bulletin = runtime_config.memory_bulletin.load(); + let agents_manifest = crate::self_awareness::agents_manifest_for_prompt(); + let changelog_highlights = crate::self_awareness::changelog_highlights(); + let runtime_config_snapshot = crate::self_awareness::runtime_snapshot_pretty( + self.deps.agent_id.as_ref(), + runtime_config, + ); let browser_enabled = runtime_config.browser_config.load().enabled; let web_search_enabled = runtime_config.brave_search_key.load().is_some(); @@ -448,6 +454,9 @@ impl CortexChatSession { empty_to_none(identity_context), empty_to_none(memory_bulletin.to_string()), channel_transcript, + empty_to_none(agents_manifest), + empty_to_none(changelog_highlights), + empty_to_none(runtime_config_snapshot), worker_capabilities, ) } diff --git a/src/api/agents.rs b/src/api/agents.rs index 81999c552..d96addb2a 100644 --- a/src/api/agents.rs +++ b/src/api/agents.rs @@ -784,6 +784,7 @@ pub(super) async fn create_agent( brave_search_key, runtime_config.workspace_dir.clone(), sandbox.clone(), + runtime_config.clone(), ); let cortex_store = crate::agent::cortex_chat::CortexChatStore::new(db.sqlite.clone()); let cortex_session = crate::agent::cortex_chat::CortexChatSession::new( diff --git a/src/lib.rs b/src/lib.rs index 3557849b0..29d4b7d54 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ pub mod opencode; pub mod prompts; pub mod sandbox; pub mod secrets; +pub mod self_awareness; pub mod settings; pub mod skills; pub mod tasks; diff --git a/src/main.rs b/src/main.rs index 6c5a68948..995d94e52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2815,6 +2815,7 @@ async fn initialize_agents( brave_search_key, agent.deps.runtime_config.workspace_dir.clone(), agent.deps.sandbox.clone(), + agent.deps.runtime_config.clone(), ); let store = spacebot::agent::cortex_chat::CortexChatStore::new(agent.db.sqlite.clone()); let session = spacebot::agent::cortex_chat::CortexChatSession::new( diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs index d24c677bf..80640271a 100644 --- a/src/prompts/engine.rs +++ b/src/prompts/engine.rs @@ -491,11 +491,15 @@ impl PromptEngine { } /// Render the cortex chat system prompt with optional channel context. + #[allow(clippy::too_many_arguments)] pub fn render_cortex_chat_prompt( &self, identity_context: Option, memory_bulletin: Option, channel_transcript: Option, + agents_manifest: Option, + changelog_highlights: Option, + runtime_config_snapshot: Option, worker_capabilities: String, ) -> Result { self.render( @@ -504,6 +508,9 @@ impl PromptEngine { identity_context => identity_context, memory_bulletin => memory_bulletin, channel_transcript => channel_transcript, + agents_manifest => agents_manifest, + changelog_highlights => changelog_highlights, + runtime_config_snapshot => runtime_config_snapshot, worker_capabilities => worker_capabilities, }, ) diff --git a/src/prompts/text.rs b/src/prompts/text.rs index 177d6132a..1daf00edb 100644 --- a/src/prompts/text.rs +++ b/src/prompts/text.rs @@ -189,6 +189,12 @@ fn lookup(lang: &str, key: &str) -> &'static str { ("en", "tools/task_update") => { include_str!("../../prompts/en/tools/task_update_description.md.j2") } + ("en", "tools/spacebot_docs") => { + include_str!("../../prompts/en/tools/spacebot_docs_description.md.j2") + } + ("en", "tools/config_inspect") => { + include_str!("../../prompts/en/tools/config_inspect_description.md.j2") + } // Fallback: unknown language or key -> try English (lang, key) if lang != "en" => { diff --git a/src/self_awareness.rs b/src/self_awareness.rs new file mode 100644 index 000000000..03ee39a36 --- /dev/null +++ b/src/self_awareness.rs @@ -0,0 +1,693 @@ +//! Embedded Spacebot self-knowledge for introspection and diagnostics. +//! +//! This module bundles key docs into the binary and exposes helpers used by: +//! - cortex chat prompt enrichment (AGENTS + changelog + live config snapshot) +//! - `spacebot_docs` tool (on-demand document retrieval) +//! - `config_inspect` tool (redacted runtime config visibility) + +use crate::config::{McpTransport, RuntimeConfig}; +use rust_embed::Embed; +use serde::Serialize; +use serde_json::json; +use std::sync::OnceLock; + +#[derive(Embed)] +#[folder = "docs/content/"] +struct ContentDocsAssets; + +const AGENTS_DOC: &str = include_str!("../AGENTS.md"); +const README_DOC: &str = include_str!("../README.md"); +const CHANGELOG_DOC: &str = include_str!("../CHANGELOG.md"); +const DOCS_README_DOC: &str = include_str!("../docs/README.md"); +const DOCS_DOCKER_DOC: &str = include_str!("../docs/docker.md"); +const DOCS_METRICS_DOC: &str = include_str!("../docs/metrics.md"); +const CORTEX_AGENTS_MAX_LINES: usize = 220; +const CORTEX_AGENTS_MAX_CHARS: usize = 24_000; +const CORTEX_CHANGELOG_MAX_LINES: usize = 220; +const CORTEX_CHANGELOG_MAX_CHARS: usize = 24_000; + +#[derive(Debug, Clone, Serialize)] +pub struct EmbeddedDocSummary { + pub id: String, + pub title: String, + pub path: String, + pub section: String, + pub line_count: usize, +} + +#[derive(Debug, Clone)] +pub struct EmbeddedDoc { + pub summary: EmbeddedDocSummary, + pub content: String, +} + +/// Full embedded `AGENTS.md` content. +pub fn agents_manifest() -> &'static str { + AGENTS_DOC +} + +/// Prompt-sized `AGENTS.md` content for cortex chat. +pub fn agents_manifest_for_prompt() -> String { + truncate_for_prompt( + AGENTS_DOC, + CORTEX_AGENTS_MAX_LINES, + CORTEX_AGENTS_MAX_CHARS, + "AGENTS.md", + ) +} + +/// Most recent release notes extracted from `CHANGELOG.md`. +pub fn changelog_highlights() -> String { + let highlights = latest_release_notes(CHANGELOG_DOC, 3).unwrap_or_else(|| { + CHANGELOG_DOC + .lines() + .take(200) + .collect::>() + .join("\n") + }); + + truncate_for_prompt( + &highlights, + CORTEX_CHANGELOG_MAX_LINES, + CORTEX_CHANGELOG_MAX_CHARS, + "CHANGELOG.md", + ) +} + +/// Pretty JSON snapshot of the current live runtime config (redacted). +pub fn runtime_snapshot_pretty(agent_id: &str, runtime_config: &RuntimeConfig) -> String { + serde_json::to_string_pretty(&runtime_snapshot_value(agent_id, runtime_config)).unwrap_or_else( + |error| format!("{{\"error\":\"failed to serialize runtime snapshot: {error}\"}}"), + ) +} + +/// Structured runtime snapshot used by cortex prompting and config inspection. +pub fn runtime_snapshot_value(agent_id: &str, runtime_config: &RuntimeConfig) -> serde_json::Value { + let routing = runtime_config.routing.load(); + let compaction = runtime_config.compaction.load(); + let memory_persistence = runtime_config.memory_persistence.load(); + let coalesce = runtime_config.coalesce.load(); + let ingestion = runtime_config.ingestion.load(); + let cortex = runtime_config.cortex.load(); + let warmup = runtime_config.warmup.load(); + let warmup_status = runtime_config.warmup_status.load(); + let browser = runtime_config.browser_config.load(); + let sandbox = runtime_config.sandbox.load(); + let opencode = runtime_config.opencode.load(); + let mcp_servers = runtime_config + .mcp + .load() + .iter() + .map(|server| { + let transport = match &server.transport { + McpTransport::Stdio { command, args, env } => { + let mut env_keys = env.keys().cloned().collect::>(); + env_keys.sort(); + json!({ + "kind": "stdio", + "command": command, + "args_count": args.len(), + "env_keys": env_keys, + }) + } + McpTransport::Http { url, headers } => { + let mut header_keys = headers.keys().cloned().collect::>(); + header_keys.sort(); + json!({ + "kind": "http", + "url": url, + "header_keys": header_keys, + }) + } + }; + + json!({ + "name": server.name, + "enabled": server.enabled, + "transport": transport, + }) + }) + .collect::>(); + + let readiness = runtime_config.work_readiness(); + let memory_bulletin = runtime_config.memory_bulletin.load(); + let secrets = runtime_config.secrets.load(); + let secrets_snapshot = if let Some(store) = secrets.as_ref() { + match store.status(false) { + Ok(status) => json!({ + "configured": true, + "state": status.state.to_string(), + "encrypted": status.encrypted, + "secret_count": status.secret_count, + "system_count": status.system_count, + "tool_count": status.tool_count, + }), + Err(error) => json!({ + "configured": true, + "error": error.to_string(), + }), + } + } else { + json!({ + "configured": false, + }) + }; + + json!({ + "generated_at": chrono::Utc::now().to_rfc3339(), + "agent_id": agent_id, + "binary_version": crate::update::CURRENT_VERSION, + "deployment": deployment_label(crate::update::Deployment::detect()), + "paths": { + "instance_dir": runtime_config.instance_dir.display().to_string(), + "workspace_dir": runtime_config.workspace_dir.display().to_string(), + }, + "routing": { + "channel": routing.channel, + "branch": routing.branch, + "worker": routing.worker, + "compactor": routing.compactor, + "cortex": routing.cortex, + "voice": routing.voice, + "rate_limit_cooldown_secs": routing.rate_limit_cooldown_secs, + }, + "limits": { + "max_turns": **runtime_config.max_turns.load(), + "branch_max_turns": **runtime_config.branch_max_turns.load(), + "context_window": **runtime_config.context_window.load(), + "max_concurrent_branches": **runtime_config.max_concurrent_branches.load(), + "max_concurrent_workers": **runtime_config.max_concurrent_workers.load(), + "history_backfill_count": **runtime_config.history_backfill_count.load(), + }, + "compaction": { + "background_threshold": compaction.background_threshold, + "aggressive_threshold": compaction.aggressive_threshold, + "emergency_threshold": compaction.emergency_threshold, + }, + "memory_persistence": { + "enabled": memory_persistence.enabled, + "message_interval": memory_persistence.message_interval, + }, + "coalesce": { + "enabled": coalesce.enabled, + "debounce_ms": coalesce.debounce_ms, + "max_wait_ms": coalesce.max_wait_ms, + "min_messages": coalesce.min_messages, + "multi_user_only": coalesce.multi_user_only, + }, + "ingestion": { + "enabled": ingestion.enabled, + "poll_interval_secs": ingestion.poll_interval_secs, + "chunk_size": ingestion.chunk_size, + }, + "cortex": { + "tick_interval_secs": cortex.tick_interval_secs, + "worker_timeout_secs": cortex.worker_timeout_secs, + "branch_timeout_secs": cortex.branch_timeout_secs, + "circuit_breaker_threshold": cortex.circuit_breaker_threshold, + "bulletin_interval_secs": cortex.bulletin_interval_secs, + "bulletin_max_words": cortex.bulletin_max_words, + "bulletin_max_turns": cortex.bulletin_max_turns, + "association_interval_secs": cortex.association_interval_secs, + "association_similarity_threshold": cortex.association_similarity_threshold, + "association_updates_threshold": cortex.association_updates_threshold, + "association_max_per_pass": cortex.association_max_per_pass, + }, + "warmup": { + "enabled": warmup.enabled, + "eager_embedding_load": warmup.eager_embedding_load, + "refresh_secs": warmup.refresh_secs, + "startup_delay_secs": warmup.startup_delay_secs, + "state": warmup_status.state, + "embedding_ready": warmup_status.embedding_ready, + "last_refresh_unix_ms": warmup_status.last_refresh_unix_ms, + "last_error": warmup_status.last_error, + "bulletin_age_secs": warmup_status.bulletin_age_secs, + }, + "work_readiness": { + "ready": readiness.ready, + "reason": readiness.reason.map(|reason| reason.as_str()), + "warmup_state": readiness.warmup_state, + "embedding_ready": readiness.embedding_ready, + "bulletin_age_secs": readiness.bulletin_age_secs, + "stale_after_secs": readiness.stale_after_secs, + }, + "browser": { + "enabled": browser.enabled, + "headless": browser.headless, + "evaluate_enabled": browser.evaluate_enabled, + "executable_path": browser.executable_path, + "screenshot_dir": browser + .screenshot_dir + .as_ref() + .map(|path| path.display().to_string()), + "chrome_cache_dir": browser.chrome_cache_dir.display().to_string(), + }, + "sandbox": { + "mode": match sandbox.mode { + crate::sandbox::SandboxMode::Enabled => "enabled", + crate::sandbox::SandboxMode::Disabled => "disabled", + }, + "writable_paths": sandbox + .writable_paths + .iter() + .map(|path| path.display().to_string()) + .collect::>(), + "passthrough_env": sandbox.passthrough_env, + }, + "opencode": { + "enabled": opencode.enabled, + "path": opencode.path, + "max_servers": opencode.max_servers, + "server_startup_timeout_secs": opencode.server_startup_timeout_secs, + "max_restart_retries": opencode.max_restart_retries, + "permissions": opencode.permissions, + }, + "mcp_servers": mcp_servers, + "brave_search": { + "configured": runtime_config.brave_search_key.load().is_some(), + }, + "timezones": { + "cron_timezone": runtime_config.cron_timezone.load().as_ref().clone(), + "user_timezone": runtime_config.user_timezone.load().as_ref().clone(), + }, + "bulletin": { + "is_empty": memory_bulletin.trim().is_empty(), + "char_count": memory_bulletin.len(), + }, + "secrets": secrets_snapshot, + }) +} + +/// List all embedded docs with optional text filter. +pub fn list_embedded_docs(filter: Option<&str>) -> Vec { + let mut docs = docs_catalog() + .iter() + .map(|doc| doc.summary.clone()) + .collect::>(); + + if let Some(raw_filter) = filter { + let filter = normalize_lookup(raw_filter); + if !filter.is_empty() { + docs.retain(|doc| { + doc.id.to_ascii_lowercase().contains(&filter) + || doc.path.to_ascii_lowercase().contains(&filter) + || doc.title.to_ascii_lowercase().contains(&filter) + || doc.section.to_ascii_lowercase().contains(&filter) + }); + } + } + + docs +} + +/// Search docs by query string (ID, title, section, or path). +pub fn search_embedded_docs(query: &str) -> Vec { + list_embedded_docs(Some(query)) +} + +/// Get a doc by ID/path, with lightweight fuzzy matching. +/// +/// Matching strategy: +/// 1. exact ID (case-insensitive) +/// 2. exact path (case-insensitive) +/// 3. single fuzzy hit on ID/path/title contains +pub fn get_embedded_doc(query: &str) -> Option { + let normalized = normalize_lookup(query); + if normalized.is_empty() { + return None; + } + + if let Some(doc) = docs_catalog() + .iter() + .find(|doc| normalize_lookup(&doc.summary.id) == normalized) + { + return Some(doc.clone()); + } + + if let Some(doc) = docs_catalog() + .iter() + .find(|doc| normalize_lookup(&doc.summary.path) == normalized) + { + return Some(doc.clone()); + } + + let mut matches = docs_catalog() + .iter() + .filter(|doc| { + let id = doc.summary.id.to_ascii_lowercase(); + let path = doc.summary.path.to_ascii_lowercase(); + let title = doc.summary.title.to_ascii_lowercase(); + id.contains(&normalized) || path.contains(&normalized) || title.contains(&normalized) + }) + .cloned() + .collect::>(); + + if matches.len() == 1 { + return matches.pop(); + } + + None +} + +fn docs_catalog() -> &'static [EmbeddedDoc] { + static CATALOG: OnceLock> = OnceLock::new(); + CATALOG.get_or_init(build_docs_catalog).as_slice() +} + +fn build_docs_catalog() -> Vec { + let mut docs = Vec::new(); + + push_static_doc( + &mut docs, + "agents", + "AGENTS", + "AGENTS.md", + "repo", + AGENTS_DOC.to_string(), + ); + push_static_doc( + &mut docs, + "readme", + "README", + "README.md", + "repo", + README_DOC.to_string(), + ); + push_static_doc( + &mut docs, + "changelog", + "Changelog", + "CHANGELOG.md", + "repo", + CHANGELOG_DOC.to_string(), + ); + push_static_doc( + &mut docs, + "docs/readme", + "Docs README", + "docs/README.md", + "docs", + DOCS_README_DOC.to_string(), + ); + push_static_doc( + &mut docs, + "docs/docker", + "Docker Guide", + "docs/docker.md", + "docs", + DOCS_DOCKER_DOC.to_string(), + ); + push_static_doc( + &mut docs, + "docs/metrics", + "Metrics", + "docs/metrics.md", + "docs", + DOCS_METRICS_DOC.to_string(), + ); + + for relative in ContentDocsAssets::iter() { + let relative = relative.as_ref(); + if !is_markdown(relative) { + continue; + } + + let Some(file) = ContentDocsAssets::get(relative) else { + continue; + }; + + let content = String::from_utf8_lossy(file.data.as_ref()).into_owned(); + let title = extract_doc_title(&content, relative); + let id = product_doc_id(relative); + let path = format!("docs/content/{relative}"); + + push_static_doc(&mut docs, &id, &title, &path, "product_docs", content); + } + + docs.sort_by(|left, right| left.summary.id.cmp(&right.summary.id)); + docs +} + +fn push_static_doc( + docs: &mut Vec, + id: &str, + title: &str, + path: &str, + section: &str, + content: String, +) { + let line_count = content.lines().count(); + docs.push(EmbeddedDoc { + summary: EmbeddedDocSummary { + id: id.to_string(), + title: title.to_string(), + path: path.to_string(), + section: section.to_string(), + line_count, + }, + content, + }); +} + +fn latest_release_notes(changelog: &str, max_releases: usize) -> Option { + if max_releases == 0 { + return None; + } + + let is_release_heading = |line: &str| { + let heading = line.strip_prefix("## ").map(str::trim).unwrap_or(""); + let version = heading + .strip_prefix('v') + .or_else(|| heading.strip_prefix('V')) + .unwrap_or(heading); + let mut parts = version.split('.'); + let major = parts.next(); + let minor = parts.next(); + let patch = parts.next(); + let rest = parts.next(); + + major.is_some_and(|part| part.chars().all(|c| c.is_ascii_digit()) && !part.is_empty()) + && minor + .is_some_and(|part| part.chars().all(|c| c.is_ascii_digit()) && !part.is_empty()) + && patch + .is_some_and(|part| part.chars().all(|c| c.is_ascii_digit()) && !part.is_empty()) + && rest.is_none() + }; + + let mut sections = Vec::new(); + let mut current = Vec::new(); + let mut in_release = false; + + for line in changelog.lines() { + if line.starts_with("## ") && is_release_heading(line) { + if !current.is_empty() { + sections.push(current.join("\n")); + current.clear(); + } + in_release = true; + } + + if in_release { + current.push(line); + } + } + + if !current.is_empty() { + sections.push(current.join("\n")); + } + + if sections.is_empty() { + None + } else { + Some( + sections + .into_iter() + .take(max_releases) + .collect::>() + .join("\n\n"), + ) + } +} + +fn extract_doc_title(content: &str, relative_path: &str) -> String { + for line in content.lines() { + let trimmed = line.trim(); + if let Some(title) = trimmed.strip_prefix("title:") { + let title = title.trim().trim_matches('"').trim_matches(char::from(39)); + if !title.is_empty() { + return title.to_string(); + } + } + if let Some(title) = trimmed.strip_prefix("# ") { + let title = title.trim(); + if !title.is_empty() { + return title.to_string(); + } + } + } + + let fallback = remove_extension(relative_path) + .rsplit('/') + .next() + .unwrap_or(relative_path) + .replace(['-', '_'], " "); + if fallback.is_empty() { + "Untitled".to_string() + } else { + fallback + } +} + +fn product_doc_id(relative_path: &str) -> String { + let mut normalized = normalize_doc_path(remove_extension(relative_path)); + if let Some(stripped) = normalized.strip_prefix("docs/") { + normalized = stripped.to_string(); + } + if normalized == "index" { + normalized = "home".to_string(); + } else if let Some(prefix) = normalized.strip_suffix("/index") { + normalized = prefix.to_string(); + } + format!("docs/{normalized}") +} + +fn normalize_doc_path(path: &str) -> String { + path.split('/') + .filter(|segment| !segment.is_empty()) + .map(strip_group_segment) + .collect::>() + .join("/") +} + +fn strip_group_segment(segment: &str) -> String { + if segment.starts_with('(') && segment.ends_with(')') && segment.len() > 2 { + return segment[1..segment.len() - 1].to_string(); + } + segment.to_string() +} + +fn remove_extension(path: &str) -> &str { + path.strip_suffix(".mdx") + .or_else(|| path.strip_suffix(".md")) + .unwrap_or(path) +} + +fn is_markdown(path: &str) -> bool { + path.ends_with(".md") || path.ends_with(".mdx") +} + +fn normalize_lookup(value: &str) -> String { + value.trim().trim_start_matches('/').to_ascii_lowercase() +} + +fn truncate_for_prompt(content: &str, max_lines: usize, max_chars: usize, label: &str) -> String { + let limited_lines = content.lines().take(max_lines).collect::>(); + let mut output = limited_lines.join("\n"); + let mut truncated = content.lines().count() > limited_lines.len(); + + if output.chars().count() > max_chars { + output = output.chars().take(max_chars).collect::(); + truncated = true; + } + + if truncated { + output.push_str(&format!( + "\n\n_[{label} truncated in prompt; use `spacebot_docs` for full content.]_" + )); + } + + output +} + +fn deployment_label(deployment: crate::update::Deployment) -> &'static str { + match deployment { + crate::update::Deployment::Docker => "docker", + crate::update::Deployment::Hosted => "hosted", + crate::update::Deployment::Native => "native", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn latest_release_notes_returns_top_release_sections() { + let changelog = "# Changelog\n\nIntro\n\n## v3.0.0\nA\n\n## What's Changed\n- item\n\n## v2.0.0\nB\n\n## v1.0.0\nC\n"; + let section = latest_release_notes(changelog, 2).expect("sections should exist"); + assert!(section.contains("## v3.0.0")); + assert!(section.contains("## v2.0.0")); + assert!(!section.contains("## v1.0.0")); + assert!(section.contains("## What's Changed")); + } + + #[test] + fn truncate_for_prompt_adds_notice_when_content_is_trimmed() { + let text = "line1\nline2\nline3\nline4"; + let output = truncate_for_prompt(text, 2, 500, "test.md"); + assert!(output.contains("line1\nline2")); + assert!(output.contains("truncated in prompt")); + } + + #[test] + fn normalize_doc_path_strips_group_segments() { + assert_eq!( + normalize_doc_path("(core)/architecture"), + "core/architecture" + ); + assert_eq!( + normalize_doc_path("(messaging)/discord-setup"), + "messaging/discord-setup" + ); + } + + #[test] + fn product_doc_id_maps_index_to_home_or_section_root() { + assert_eq!(product_doc_id("index.mdx"), "docs/home"); + assert_eq!(product_doc_id("(core)/index.mdx"), "docs/core"); + assert_eq!(product_doc_id("(core)/cortex.mdx"), "docs/core/cortex"); + assert_eq!(product_doc_id("docs/(core)/cortex.mdx"), "docs/core/cortex"); + } + + #[test] + fn product_docs_catalog_matches_embedded_content_assets() { + let expected = ContentDocsAssets::iter() + .map(|relative| relative.to_string()) + .filter(|relative| is_markdown(relative)) + .map(|relative| format!("docs/content/{relative}")) + .collect::>(); + + let actual = list_embedded_docs(None) + .into_iter() + .filter(|doc| doc.section == "product_docs") + .map(|doc| doc.path) + .collect::>(); + + let missing = expected.difference(&actual).cloned().collect::>(); + let unexpected = actual.difference(&expected).cloned().collect::>(); + + assert!( + missing.is_empty() && unexpected.is_empty(), + "product docs mismatch\nmissing: {:?}\nunexpected: {:?}", + missing, + unexpected + ); + } + + #[test] + fn bundled_docs_inventory_is_accessible() { + let mut docs = list_embedded_docs(None); + docs.sort_by(|left, right| left.id.cmp(&right.id)); + assert!( + !docs.is_empty(), + "embedded docs catalog should not be empty" + ); + + println!("bundled docs: {}", docs.len()); + for doc in &docs { + println!("{} | {} | {}", doc.section, doc.id, doc.path); + } + } +} diff --git a/src/tools.rs b/src/tools.rs index 587403e29..f2e356ae0 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -13,6 +13,7 @@ //! //! **Branch ToolServer** (one per branch, isolated): //! - `memory_save` + `memory_recall` + `memory_delete` + `channel_recall` +//! - `spacebot_docs` for embedded self-documentation lookup //! - `task_create` + `task_list` + `task_update` //! - `spawn_worker` is included for channel-originated branches only //! @@ -23,11 +24,15 @@ //! //! **Cortex ToolServer** (one per agent): //! - `memory_save` — registered at startup +//! +//! **Cortex Chat ToolServer** (interactive admin chat): +//! - branch + worker tool superset plus `spacebot_docs` and `config_inspect` pub mod branch_tool; pub mod browser; pub mod cancel; pub mod channel_recall; +pub mod config_inspect; pub mod cron; pub mod email_search; pub mod exec; @@ -47,6 +52,7 @@ pub mod send_message_to_another_channel; pub mod set_status; pub mod shell; pub mod skip; +pub mod spacebot_docs; pub mod spawn_worker; pub mod task_create; pub mod task_list; @@ -63,6 +69,9 @@ pub use cancel::{CancelArgs, CancelError, CancelOutput, CancelTool}; pub use channel_recall::{ ChannelRecallArgs, ChannelRecallError, ChannelRecallOutput, ChannelRecallTool, }; +pub use config_inspect::{ + ConfigInspectArgs, ConfigInspectError, ConfigInspectOutput, ConfigInspectTool, +}; pub use cron::{CronArgs, CronError, CronOutput, CronTool}; pub use email_search::{EmailSearchArgs, EmailSearchError, EmailSearchOutput, EmailSearchTool}; pub use exec::{EnvVar, ExecArgs, ExecError, ExecOutput, ExecResult, ExecTool}; @@ -92,6 +101,9 @@ pub use send_message_to_another_channel::{ pub use set_status::{SetStatusArgs, SetStatusError, SetStatusOutput, SetStatusTool}; pub use shell::{ShellArgs, ShellError, ShellOutput, ShellResult, ShellTool}; pub use skip::{SkipArgs, SkipError, SkipFlag, SkipOutput, SkipTool, new_skip_flag}; +pub use spacebot_docs::{ + SpacebotDocContent, SpacebotDocsArgs, SpacebotDocsError, SpacebotDocsOutput, SpacebotDocsTool, +}; pub use spawn_worker::{SpawnWorkerArgs, SpawnWorkerError, SpawnWorkerOutput, SpawnWorkerTool}; pub use task_create::{TaskCreateArgs, TaskCreateError, TaskCreateOutput, TaskCreateTool}; pub use task_list::{TaskListArgs, TaskListError, TaskListOutput, TaskListTool}; @@ -379,8 +391,8 @@ pub async fn remove_channel_tools( /// Create a per-branch ToolServer with memory tools. /// /// Each branch gets its own isolated ToolServer so `memory_recall` is never -/// visible to the channel. Both `memory_save` and `memory_recall` are -/// registered at creation. +/// visible to the channel. Includes memory tools, task-board tools, and +/// `spacebot_docs` for on-demand self-documentation lookup. #[allow(clippy::too_many_arguments)] pub fn create_branch_tool_server( state: Option, @@ -397,6 +409,7 @@ pub fn create_branch_tool_server( .tool(MemoryRecallTool::new(memory_search.clone())) .tool(MemoryDeleteTool::new(memory_search)) .tool(ChannelRecallTool::new(conversation_logger, channel_store)) + .tool(SpacebotDocsTool::new()) .tool(EmailSearchTool::new(runtime_config)) .tool(WorkerInspectTool::new(run_logger, agent_id.to_string())) .tool(TaskCreateTool::new( @@ -489,6 +502,8 @@ pub fn create_cortex_tool_server(memory_search: Arc) -> ToolServer /// Combines branch tools (memory) with worker tools (shell, file, exec) to give /// the interactive cortex full capabilities. Does not include channel-specific /// tools (reply, react, skip) since the cortex chat doesn't talk to platforms. +/// Adds `config_inspect` for live runtime config introspection and +/// `spacebot_docs` for embedded docs/changelog retrieval. #[allow(clippy::too_many_arguments)] pub fn create_cortex_chat_tool_server( agent_id: AgentId, @@ -502,12 +517,15 @@ pub fn create_cortex_chat_tool_server( brave_search_key: Option, workspace: PathBuf, sandbox: Arc, + runtime_config: Arc, ) -> ToolServerHandle { let mut server = ToolServer::new() .tool(MemorySaveTool::new(memory_search.clone())) .tool(MemoryRecallTool::new(memory_search.clone())) .tool(MemoryDeleteTool::new(memory_search)) .tool(ChannelRecallTool::new(conversation_logger, channel_store)) + .tool(SpacebotDocsTool::new()) + .tool(ConfigInspectTool::new(agent_id.to_string(), runtime_config)) .tool(WorkerInspectTool::new(run_logger, agent_id.to_string())) .tool(TaskCreateTool::new( task_store.clone(), diff --git a/src/tools/config_inspect.rs b/src/tools/config_inspect.rs new file mode 100644 index 000000000..9b8020e75 --- /dev/null +++ b/src/tools/config_inspect.rs @@ -0,0 +1,124 @@ +//! Live runtime configuration inspection for cortex chat. + +use crate::config::RuntimeConfig; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Tool for inspecting the current resolved runtime config (redacted). +#[derive(Debug, Clone)] +pub struct ConfigInspectTool { + agent_id: String, + runtime_config: Arc, +} + +impl ConfigInspectTool { + pub fn new(agent_id: impl Into, runtime_config: Arc) -> Self { + Self { + agent_id: agent_id.into(), + runtime_config, + } + } +} + +/// Error type for `config_inspect`. +#[derive(Debug, thiserror::Error)] +#[error("config_inspect failed: {0}")] +pub struct ConfigInspectError(String); + +/// Arguments for `config_inspect`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ConfigInspectArgs { + /// Optional section selector. Defaults to "all". + pub section: Option, +} + +/// Output from `config_inspect`. +#[derive(Debug, Serialize)] +pub struct ConfigInspectOutput { + pub success: bool, + pub agent_id: String, + pub generated_at: String, + pub section: String, + pub snapshot: serde_json::Value, +} + +impl Tool for ConfigInspectTool { + const NAME: &'static str = "config_inspect"; + + type Error = ConfigInspectError; + type Args = ConfigInspectArgs; + type Output = ConfigInspectOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: crate::prompts::text::get("tools/config_inspect").to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "section": { + "type": "string", + "description": "Optional subsection to return. Valid values: all, paths, routing, limits, compaction, memory_persistence, coalesce, ingestion, cortex, warmup, work_readiness, browser, sandbox, opencode, mcp_servers, brave_search, timezones, bulletin, secrets, binary_version, deployment" + } + } + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let snapshot = + crate::self_awareness::runtime_snapshot_value(&self.agent_id, &self.runtime_config); + let section = args + .section + .unwrap_or_else(|| "all".to_string()) + .trim() + .to_ascii_lowercase(); + + let selected = if section == "all" { + snapshot + } else { + select_section(&snapshot, §ion)? + }; + + Ok(ConfigInspectOutput { + success: true, + agent_id: self.agent_id.clone(), + generated_at: chrono::Utc::now().to_rfc3339(), + section, + snapshot: selected, + }) + } +} + +fn select_section( + snapshot: &serde_json::Value, + section: &str, +) -> Result { + match section { + "binary_version" => snapshot + .get("binary_version") + .cloned() + .ok_or_else(|| unknown_section_error("binary_version")), + "deployment" => snapshot + .get("deployment") + .cloned() + .ok_or_else(|| unknown_section_error("deployment")), + "mcp" => snapshot + .get("mcp_servers") + .cloned() + .ok_or_else(|| unknown_section_error("mcp")), + other => snapshot + .get(other) + .cloned() + .ok_or_else(|| unknown_section_error(other)), + } +} + +fn unknown_section_error(section: &str) -> ConfigInspectError { + ConfigInspectError(format!( + "unknown section '{section}'. valid sections: all, paths, routing, limits, compaction, memory_persistence, coalesce, ingestion, cortex, warmup, work_readiness, browser, sandbox, opencode, mcp_servers (or mcp), brave_search, timezones, bulletin, secrets, binary_version, deployment" + )) +} diff --git a/src/tools/spacebot_docs.rs b/src/tools/spacebot_docs.rs new file mode 100644 index 000000000..21c0d9180 --- /dev/null +++ b/src/tools/spacebot_docs.rs @@ -0,0 +1,360 @@ +//! Read embedded Spacebot docs, AGENTS guide, and changelog notes. + +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Tool for reading Spacebot's embedded self-documentation. +#[derive(Debug, Clone)] +pub struct SpacebotDocsTool; + +impl SpacebotDocsTool { + pub fn new() -> Self { + Self + } +} + +impl Default for SpacebotDocsTool { + fn default() -> Self { + Self::new() + } +} + +/// Error type for `spacebot_docs`. +#[derive(Debug, thiserror::Error)] +#[error("spacebot_docs failed: {0}")] +pub struct SpacebotDocsError(String); + +/// Arguments for `spacebot_docs`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SpacebotDocsArgs { + /// Action to perform: `list` or `read`. + #[serde(default = "default_action")] + pub action: String, + /// Document ID/path to read (required when `action = "read"`). + pub doc_id: Option, + /// Optional text filter for list output (or fallback lookup hint for read). + pub query: Option, + /// 1-based line number to start reading from. + #[serde(default = "default_start_line")] + pub start_line: usize, + /// Maximum lines to return. + #[serde(default = "default_max_lines")] + pub max_lines: usize, +} + +fn default_action() -> String { + "list".to_string() +} + +fn default_start_line() -> usize { + 1 +} + +fn default_max_lines() -> usize { + 300 +} + +/// Read payload for `action = "read"`. +#[derive(Debug, Serialize)] +pub struct SpacebotDocContent { + pub id: String, + pub title: String, + pub path: String, + pub section: String, + pub start_line: usize, + pub end_line: usize, + pub total_lines: usize, + pub has_more: bool, + pub content: String, +} + +/// Output from `spacebot_docs`. +#[derive(Debug, Serialize)] +pub struct SpacebotDocsOutput { + pub success: bool, + pub action: String, + pub message: String, + pub error: Option, + pub docs: Vec, + pub document: Option, +} + +impl Tool for SpacebotDocsTool { + const NAME: &'static str = "spacebot_docs"; + + type Error = SpacebotDocsError; + type Args = SpacebotDocsArgs; + type Output = SpacebotDocsOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: crate::prompts::text::get("tools/spacebot_docs").to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "read"], + "default": "list", + "description": "Use `list` to discover available docs, or `read` to fetch one doc by ID/path." + }, + "doc_id": { + "type": "string", + "description": "Doc ID or path to read (required for `read`). Example IDs: `agents`, `changelog`, `docs/core/cortex`." + }, + "query": { + "type": "string", + "description": "Optional filter for list output (matches ID/title/path/section)." + }, + "start_line": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "1-based line number to start from when reading." + }, + "max_lines": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 300, + "description": "Maximum number of lines to return when reading." + } + } + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let action = args.action.trim().to_ascii_lowercase(); + + match action.as_str() { + "list" => { + let mut docs = crate::self_awareness::list_embedded_docs(args.query.as_deref()); + if docs.len() > 200 { + docs.truncate(200); + } + + Ok(SpacebotDocsOutput { + success: true, + action, + message: format!( + "{} docs available{}", + docs.len(), + args.query + .as_deref() + .filter(|query| !query.trim().is_empty()) + .map(|query| format!(" (filtered by '{query}')")) + .unwrap_or_default() + ), + error: None, + docs, + document: None, + }) + } + "read" => { + let requested_id = args + .doc_id + .as_deref() + .or(args.query.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + let Some(requested_id) = requested_id else { + let error = + "`doc_id` is required for action=read. Call with action=list first if you need IDs.".to_string(); + return Ok(failure_output(&action, error)); + }; + + let Some(document) = crate::self_awareness::get_embedded_doc(requested_id) else { + return Ok(failure_output(&action, unknown_doc_message(requested_id))); + }; + + let requested_start_line = args.start_line.max(1); + let max_lines = args.max_lines.clamp(1, 2000); + let (content, end_line, total_lines, has_more) = + slice_lines(&document.content, requested_start_line, max_lines); + let start_line = normalize_start_line(requested_start_line, total_lines); + let message = if total_lines == 0 { + format!("read '{}' empty document", document.summary.id) + } else if requested_start_line > total_lines { + format!( + "read '{}' start_line {} past EOF; normalized to {} of {}", + document.summary.id, requested_start_line, total_lines, total_lines + ) + } else { + format!( + "read '{}' lines {}-{} of {}", + document.summary.id, start_line, end_line, total_lines + ) + }; + + Ok(SpacebotDocsOutput { + success: true, + action, + message, + error: None, + docs: Vec::new(), + document: Some(SpacebotDocContent { + id: document.summary.id, + title: document.summary.title, + path: document.summary.path, + section: document.summary.section, + start_line, + end_line, + total_lines, + has_more, + content, + }), + }) + } + other => Ok(failure_output( + &action, + format!("invalid action '{other}'. valid actions: list, read"), + )), + } + } +} + +fn failure_output(action: &str, error: String) -> SpacebotDocsOutput { + SpacebotDocsOutput { + success: false, + action: action.to_string(), + message: error.clone(), + error: Some(error), + docs: Vec::new(), + document: None, + } +} + +fn unknown_doc_message(requested_id: &str) -> String { + let matches = crate::self_awareness::search_embedded_docs(requested_id); + if matches.is_empty() { + return format!( + "document '{requested_id}' not found. Use action=list to see available IDs." + ); + } + + let suggestions = matches + .iter() + .take(8) + .map(|doc| doc.id.as_str()) + .collect::>() + .join(", "); + + format!( + "document '{requested_id}' is ambiguous or not an exact match. Try one of: {suggestions}" + ) +} + +fn slice_lines(content: &str, start_line: usize, max_lines: usize) -> (String, usize, usize, bool) { + let total_lines = content.lines().count(); + + if total_lines == 0 { + return (String::new(), 0, 0, false); + } + + if start_line > total_lines { + return (String::new(), total_lines, total_lines, false); + } + + let start_index = start_line.saturating_sub(1); + let end_index = (start_index + max_lines).min(total_lines); + let has_more = end_index < total_lines; + let content = content + .lines() + .skip(start_index) + .take(end_index - start_index) + .collect::>() + .join("\n"); + + (content, end_index, total_lines, has_more) +} + +fn normalize_start_line(requested_start_line: usize, total_lines: usize) -> usize { + if total_lines == 0 { + 0 + } else { + requested_start_line.min(total_lines) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_start_line_caps_to_total_lines() { + assert_eq!(normalize_start_line(20, 5), 5); + } + + #[test] + fn normalize_start_line_for_empty_doc_is_zero() { + assert_eq!(normalize_start_line(1, 0), 0); + } + + #[test] + fn slice_lines_past_eof_returns_empty_at_eof() { + let (content, end_line, total_lines, has_more) = slice_lines("a\nb\nc", 10, 100); + assert!(content.is_empty()); + assert_eq!(end_line, 3); + assert_eq!(total_lines, 3); + assert!(!has_more); + } + + #[tokio::test] + async fn read_without_doc_id_returns_structured_failure() { + let output = SpacebotDocsTool::new() + .call(SpacebotDocsArgs { + action: "read".to_string(), + doc_id: None, + query: None, + start_line: 1, + max_lines: 100, + }) + .await + .expect("tool call should not error"); + + assert!(!output.success); + assert!(output.error.is_some()); + assert!(output.document.is_none()); + } + + #[tokio::test] + async fn invalid_action_returns_structured_failure() { + let output = SpacebotDocsTool::new() + .call(SpacebotDocsArgs { + action: "nope".to_string(), + doc_id: None, + query: None, + start_line: 1, + max_lines: 100, + }) + .await + .expect("tool call should not error"); + + assert!(!output.success); + assert!(output.error.is_some()); + assert!(output.error.unwrap().contains("invalid action")); + } + + #[tokio::test] + async fn unknown_doc_returns_structured_failure() { + let output = SpacebotDocsTool::new() + .call(SpacebotDocsArgs { + action: "read".to_string(), + doc_id: Some("definitely-not-a-real-doc-id".to_string()), + query: None, + start_line: 1, + max_lines: 100, + }) + .await + .expect("tool call should not error"); + + assert!(!output.success); + assert!(output.error.is_some()); + assert!(output.document.is_none()); + } +}