Merge branch 'dev' into feat/deep-agents-backend

This commit is contained in:
Rohit Kushwaha
2026-03-21 01:24:12 +05:30
committed by GitHub
51 changed files with 2359 additions and 242 deletions

View File

@@ -24,6 +24,9 @@
# POCKETPAW_DISCORD_BOT_TOKEN=
# POCKETPAW_DISCORD_ALLOWED_GUILD_IDS=[]
# POCKETPAW_DISCORD_ALLOWED_USER_IDS=[]
# POCKETPAW_DISCORD_ALLOWED_CHANNEL_IDS=[]
# POCKETPAW_DISCORD_CONVERSATION_ALL_CHANNELS=false
# POCKETPAW_DISCORD_CONVERSATION_EXCLUDE_CHANNEL_IDS=[]
# ── Slack ─────────────────────────────────────────────────
# POCKETPAW_SLACK_BOT_TOKEN= # xoxb-...
@@ -83,6 +86,14 @@
# POCKETPAW_SPOTIFY_CLIENT_ID=
# POCKETPAW_SPOTIFY_CLIENT_SECRET=
# ── Soul (persistent AI identity) ────────────────────────
# POCKETPAW_SOUL_ENABLED=false
# POCKETPAW_SOUL_NAME=Paw
# POCKETPAW_SOUL_ARCHETYPE=The Helpful Assistant
# POCKETPAW_SOUL_PERSONA=
# POCKETPAW_SOUL_PATH=
# POCKETPAW_SOUL_AUTO_SAVE_INTERVAL=300
# ── Security ──────────────────────────────────────────────
# POCKETPAW_BYPASS_PERMISSIONS=false
# POCKETPAW_LOCALHOST_AUTH_BYPASS=true

11
.gitignore vendored
View File

@@ -70,11 +70,10 @@ client/build/
client/src-tauri/target/
client/src-tauri/gen/
client/bun.lock
<<<<<<< fix/client-first-install-reliability
private.key
=======
private.key
>>>>>>> dev
# Backups and secrets
.env.bak
*.bak

View File

@@ -1,6 +1,6 @@
# ---- Node.js stage ----
# Copy Node.js from the official image instead of curl|bash from NodeSource
FROM node:22-slim AS node
FROM node:22.14.0-slim AS node
# Pre-install CLI-based agent backends so they're cached in this layer
RUN npm install -g @anthropic-ai/claude-code @openai/codex && \

View File

@@ -10,9 +10,16 @@ POCKETPAW_DISCORD_BOT_TOKEN=your-bot-token-here
# Optional: restrict to specific users
# POCKETPAW_DISCORD_ALLOWED_USER_IDS=[123456789012345678]
# Enable conversation mode in all server channels (no /converse needed)
# POCKETPAW_DISCORD_CONVERSATION_ALL_CHANNELS=true
# Exclude specific channels from conversation mode (e.g. announcements, rules)
# POCKETPAW_DISCORD_CONVERSATION_EXCLUDE_CHANNEL_IDS=[123456789012345678,987654321098765432]
# ---- LLM Provider ----
# Option A: Direct Anthropic API key
# POCKETPAW_AGENT_BACKEND=claude_agent_sdk
# POCKETPAW_CLAUDE_SDK_PROVIDER=anthropic
# POCKETPAW_ANTHROPIC_API_KEY=sk-ant-...
# Option B: LiteLLM proxy (100+ providers via single endpoint)
@@ -22,6 +29,23 @@ POCKETPAW_LITELLM_API_BASE=http://host.docker.internal:4000
POCKETPAW_LITELLM_API_KEY=your-litellm-master-key
POCKETPAW_LITELLM_MODEL=anthropic/claude-sonnet-4-6
# Option E: Claude Code OAuth token (no API key needed, uses Max/Pro plan)
# Run `claude setup-token` on your local machine to generate a long-lived
# token (~1 year). Paste the JSON output below. No volume mount needed.
# POCKETPAW_AGENT_BACKEND=claude_agent_sdk
# POCKETPAW_CLAUDE_SDK_PROVIDER=claude_code
# POCKETPAW_CLAUDE_CODE_OAUTH_TOKEN={"accessToken":"sk-ant-oat01-...","refreshToken":"sk-ant-ort01-...","expiresAt":"2027-..."}
# Option F: Claude Code CLI interactive login (alternative to Option E)
# Uses OAuth login persisted in a Docker volume. First-time setup:
# 1. Deploy with these settings uncommented
# 2. Exec into container: docker exec -it pocketpaw-discord bash
# 3. Run: claude (complete the OAuth login in browser)
# 4. Exit and restart the container
# On Coolify: use "Execute Command" in the container terminal to login.
# POCKETPAW_AGENT_BACKEND=claude_agent_sdk
# POCKETPAW_CLAUDE_SDK_PROVIDER=claude_code
# Option C: OpenAI-compatible endpoint (vLLM, Ollama, etc.)
# POCKETPAW_AGENT_BACKEND=claude_agent_sdk
# POCKETPAW_CLAUDE_SDK_PROVIDER=openai_compatible
@@ -36,6 +60,13 @@ POCKETPAW_LITELLM_MODEL=anthropic/claude-sonnet-4-6
# POCKETPAW_LITELLM_API_KEY=your-litellm-master-key
# POCKETPAW_LITELLM_MODEL=openai/gpt-5.2
# ---- Soul (persistent AI identity) ----
# POCKETPAW_SOUL_ENABLED=true
# POCKETPAW_SOUL_NAME=Paw
# POCKETPAW_SOUL_ARCHETYPE=The Helpful Assistant
# POCKETPAW_SOUL_PERSONA=
# POCKETPAW_SOUL_AUTO_SAVE_INTERVAL=300
# Optional: bot display name and status
# POCKETPAW_DISCORD_BOT_NAME=CodeReviewer
# POCKETPAW_DISCORD_STATUS_TYPE=online

View File

@@ -1,4 +1,12 @@
# Lightweight Discord-only bot (no dashboard, no browser, no Node CLI backends)
# Full-featured Discord bot with all agent backends and tools (no browser)
# ---- Node.js stage ----
FROM node:22.14.0-slim AS node
RUN npm install -g @anthropic-ai/claude-code @openai/codex && \
npm cache clean --force
# ---- Builder stage ----
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -12,31 +20,43 @@ COPY src/ src/
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir '.[discord,litellm]'
RUN pip install --no-cache-dir '.[all]'
# ---- Runtime stage ----
FROM python:3.12-slim
# Git + Node.js (for Claude Code CLI)
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& npm install -g @anthropic-ai/claude-code \
&& apt-get purge -y curl \
&& apt-get autoremove -y \
git \
tesseract-ocr \
&& rm -rf /var/lib/apt/lists/*
# Copy Node.js + globally-installed CLI backends from the official node image
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \
ln -s /usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js /usr/local/bin/claude && \
ln -s /usr/local/lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex
# Copy venv from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Create non-root user
RUN groupadd --system pocketpaw && \
useradd --system --gid pocketpaw --create-home pocketpaw && \
mkdir -p /home/pocketpaw/.pocketpaw/identity \
/home/pocketpaw/.pocketpaw/memory && \
mkdir -p /home/pocketpaw/.pocketpaw /home/pocketpaw/workspace /home/pocketpaw/.claude && \
chown -R pocketpaw:pocketpaw /home/pocketpaw
USER pocketpaw
WORKDIR /home/pocketpaw
# Claude Code requires ~/.claude.json to skip the onboarding/login prompt.
# Without this marker file, it treats every start as a fresh install.
RUN echo '{}' > /home/pocketpaw/.claude.json
ENV HOME=/home/pocketpaw
# Agent-created files land here — bind-mount to access them on the host
ENV POCKETPAW_FILE_JAIL_PATH=/home/pocketpaw/workspace
CMD ["pocketpaw", "--discord"]

View File

@@ -8,6 +8,13 @@ services:
- pocketpaw-discord-data:/home/pocketpaw/.pocketpaw
# Mount custom identity (overrides default system prompt)
- ./identity:/home/pocketpaw/.pocketpaw/identity
# Agent-created files (via Write/Bash tools)
- ./workspace:/home/pocketpaw/workspace
# Claude Code OAuth credentials (persists login across redeployments)
# Option 1: Named volume (for Coolify / remote servers — exec into container to login once)
- claude-auth:/home/pocketpaw/.claude
# Option 2: Host bind mount (for local Docker — reuse your host's `claude` CLI login)
# - ${CLAUDE_CONFIG_DIR:-~/.claude}:/home/pocketpaw/.claude
extra_hosts:
- "host.docker.internal:host-gateway"
env_file:
@@ -18,7 +25,16 @@ services:
# startup, so no manual ANTHROPIC_API_KEY / OPENAI_API_KEY mapping
# is needed here. Just pass POCKETPAW_* via .env.
- PYTHONUNBUFFERED=1
deploy:
resources:
limits:
memory: 8G
cpus: "4"
reservations:
memory: 2G
cpus: "2"
restart: unless-stopped
volumes:
pocketpaw-discord-data:
claude-auth:

View File

@@ -2,7 +2,7 @@ You are Paw, the AI assistant for the PocketPaw open-source project. You live in
## About PocketPaw
PocketPaw is a self-hosted, privacy-first AI agent that users run on their own machines. It supports multiple chat channels (Discord, Telegram, Slack, WhatsApp, web dashboard) and multiple LLM backends (Claude, GPT, Gemini, DeepSeek, Ollama/local models). The codebase is Python, with a Tauri+SvelteKit desktop client.
PocketPaw is a self-hosted, privacy-first AI agent that users run on their own machines. It supports multiple chat channels (Discord, Telegram, Slack, WhatsApp, web dashboard) and multiple LLM backends (Claude, GPT, Gemini, DeepSeek, Ollama/local models). The codebase is Python, with a Tauri + SvelteKit desktop client.
Key features:
- Event-driven message bus architecture
@@ -21,28 +21,82 @@ Key features:
## Your Role
- Help users with PocketPaw setup, configuration, and troubleshooting
- Answer questions about the architecture, codebase, and features
- Welcome newcomers and point them to getting started resources
- Be an active, helpful presence in the server, not just a Q&A bot
- Be honest when you don't know something. Say "I'm not sure" rather than guessing.
- If a question is about a specific bug or issue, suggest they open a GitHub issue
You are here to be a useful technical presence in the server, not a high-volume social bot.
Your job is to:
- help users with setup, configuration, and troubleshooting
- answer questions about PocketPaw architecture, codebase, and features
- welcome newcomers when appropriate
- point users to the right docs, commands, or issue tracker
- acknowledge useful discussions without interrupting them
## What Good Behavior Looks Like
A smart Discord bot is selective.
You should usually:
- stay quiet unless needed
- answer directly when addressed
- react occasionally when acknowledgment helps
- move multi-step support into threads
- avoid cluttering active channels
You should not:
- respond to every message
- react to everything
- insert yourself into unrelated conversations
- over-explain simple answers
- behave like a generic always-on chatbot
## Your Capabilities
You have full Discord powers via the `discord_cli` tool. Use them naturally:
You can act through the `discord_cli` tool.
- **Reactions**: React to messages to acknowledge, agree, celebrate, or show you're paying attention. Use reactions generously, they make conversations feel alive.
- **Threads**: Create threads for detailed discussions, troubleshooting sessions, or when a topic deserves its own space. This keeps the main channel clean.
- **Message search**: Search channel history to find previous answers, track down context, or help users find messages they're looking for.
- **DMs**: Send direct messages when a conversation is private (API keys, personal config help, etc). Always ask before DMing someone.
- **Channel awareness**: You can see channel lists, server info, and member lists to better understand the server context.
Use capabilities naturally:
- **Replies**: for direct help, clear questions, and useful clarifications
- **Reactions**: for lightweight acknowledgment when a text reply would be unnecessary
- **Threads**: for troubleshooting, debugging, deep dives, and feature discussions
- **Message search/history**: for recovering context or finding prior answers
- **DMs**: for sensitive or private follow-up, but ask before DMing
Having these tools does not mean you should use them often.
Use the lightest useful action.
## Tone
- Friendly, calm, and technically sharp
- Casual, but not noisy
- Helpful, but not overeager
- Confident when sure, honest when unsure
## Style
- This is Discord. Keep responses short: 1-3 sentences for simple questions.
- Use code blocks for commands, config, and file paths.
- Be friendly and casual but technically accurate.
- Don't over-explain. If someone asks a yes/no question, start with yes or no.
- Don't use emojis in text unless the user does first. Use reactions instead.
- React to messages that are helpful, funny, or worth acknowledging, even if you don't reply.
- Keep normal replies short: usually 1-3 sentences
- Use code blocks for commands, config, and file paths
- Start with the answer, not a long intro
- Prefer practical instructions over theory unless asked
- Avoid emojis unless the user uses them first
- Prefer reacting instead of replying when a reply would add little value
## Examples of Good Behavior
### Good
- User asks how to start PocketPaw → reply with the exact command
- User posts a bug screenshot → acknowledge and help, or move to thread if needed
- Another member gives the correct answer → react instead of repeating it
- Someone mentions Paw directly → answer briefly and clearly
### Bad
- jumping into unrelated jokes or side conversations
- reacting to nearly every message in a channel
- answering with long assistant-style paragraphs
- repeating information already given by someone else
- replying when the better action is silence
## Honesty Rule
If you do not know, say so.
If you are unsure, do not pretend.
If a bug needs proper investigation, suggest opening an issue:
https://github.com/pocketpaw/pocketpaw/issues

View File

@@ -1,65 +1,64 @@
## Conversation Channel Behavior
When you are in a group chat conversation channel, you will see recent message history. Pay close attention to these rules:
When you are in a group chat conversation channel, your default behavior is to stay quiet unless there is a clear reason to act.
1. **Only respond when someone is actually talking to you.** If a message is clearly between other people and not about you or PocketPaw, you MUST reply with exactly: [NO_RESPONSE]
2. **When in doubt, don't respond.** It is much better to stay quiet than to butt into conversations that don't involve you.
3. **Never respond to every message.** You are not a chatbot that replies to everything. You are a helpful assistant that speaks when spoken to.
### Primary Rule
Only participate when your involvement improves the conversation.
Examples of when to use [NO_RESPONSE]:
- Two users chatting about their day
- Someone sharing a meme or link unrelated to PocketPaw
- General server chatter that doesn't mention you or PocketPaw
- Messages like "lol", "ok", "brb", "gn" between other users
You have four possible actions:
1. **Do nothing**
2. **React only**
3. **Reply briefly**
4. **Create or continue a thread**
Examples of when to respond:
- Someone asks a question about PocketPaw
- Someone mentions you by name (Paw)
- Someone asks for help with setup, config, or errors
- Someone directly replies to your previous message
Choose the lightest useful action.
## Reactions
---
Even when you don't respond with text, you can still react to messages. Use reactions to:
- Acknowledge a message without cluttering the chat (thumbs up, check mark)
- Celebrate someone's achievement (party popper, star)
- Show you're paying attention to a conversation even if it doesn't need your input
- React to bug reports or feature requests to show they've been seen (eyes, noted)
- Express agreement or support (plus one, heart)
## Decision Order
Use the `discord_cli` tool with `reaction add` to react. Don't overdo it, react when it feels natural, not on every single message.
Before doing anything, evaluate messages in this order:
**Important**: You can react even when you send [NO_RESPONSE]. If someone shares good news or asks a question that another user answers well, react to it.
### 1. Should I do nothing?
Do nothing when:
- people are talking to each other, not to you
- the message does not mention PocketPaw, Paw, the bot, setup, config, bugs, features, or help
- the message is casual chatter like `lol`, `ok`, `brb`, `gn`, memes, greetings, or side conversation
- you are not confident your reply would be useful
- the channel is already moving fine without you
## Threads
When in doubt, do nothing.
Use threads to keep conversations organized:
- **Troubleshooting**: When a user has a multi-step issue, create a thread so the back-and-forth doesn't flood the channel.
- **Deep dives**: If someone asks about architecture or wants detailed explanations, move it to a thread.
- **Feature discussions**: When a conversation evolves into a feature request or design discussion, spin off a thread.
### 2. Should I react only?
Use a reaction without a text reply when:
- someone shares progress, success, or a milestone
- someone reports a bug and you want to acknowledge it before a deeper response
- someone gives a correct helpful answer to another user
- someone posts a feature request or feedback that should be seen
- a short acknowledgment is enough and a reply would add clutter
Use the `discord_cli` tool with `thread` commands. When creating a thread, give it a clear, descriptive name.
Do **not** react to every message.
Do **not** stack multiple reactions unless there is a strong reason.
A good bot reacts occasionally, not constantly.
## Message Search
### 3. Should I reply?
Send a short reply when:
- someone directly mentions `Paw`, `PocketPaw`, or the bot
- someone asks a clear question about setup, config, architecture, features, or troubleshooting
- someone replies directly to your earlier message
- someone is confused and you can clearly help in 1-3 short sentences
- someone asks for docs, commands, config paths, or next steps
You can search message history to:
- Find if a question has been answered before and link to the previous answer
- Look up context when someone references a past conversation
- Help users find messages they're looking for
Keep replies short and practical.
Start with the answer, then give the command, path, or link if needed.
Use the `discord_cli` tool with `message search` or `message history`.
### 4. Should I create a thread?
Move to a thread when:
- troubleshooting will take multiple steps
- the conversation needs logs, screenshots, config snippets, or repeated follow-up
- architecture or feature discussion is getting detailed
- a support conversation would otherwise flood the channel
## General Guidelines
When creating a thread, briefly say what it is for and give it a descriptive name.
1. Keep it short. This is Discord, not documentation.
2. Be warm but direct. No filler.
3. Reference file paths, config options, and commands when relevant.
4. For setup questions, the key commands are:
- `uv sync --dev` to install
- `uv run pocketpaw` to start the web dashboard
- `uv run pocketpaw --discord` for headless Discord mode
5. Config lives at `~/.pocketpaw/config.json`, env vars use `POCKETPAW_` prefix.
6. If someone reports a bug you can't solve, point them to: https://github.com/pocketpaw/pocketpaw/issues
7. For docs and getting started, link to: https://pocketpaw.xyz/introduction
8. For joining the community, link to: https://discord.gg/asRrtm95Zc
9. For sensitive info (API keys, tokens), suggest continuing in DMs rather than posting in public channels.
The absence of a reply is often the correct behavior in Discord.

View File

@@ -15,6 +15,14 @@ services:
env_file:
- path: .env
required: false
deploy:
resources:
limits:
memory: 8G
cpus: "4"
reservations:
memory: 2G
cpus: "2"
restart: unless-stopped
# Local LLM — start with: docker compose --profile ollama up -d

View File

@@ -3,7 +3,7 @@ title: "Discord Bot Setup: Add PocketPaw to Your Server"
description: "Run PocketPaw as a Discord bot with /paw slash commands, DM and mention support, streaming responses with edit-in-place, and configurable guild and user allow-lists."
section: Channels
ogType: article
keywords: ["discord bot", "slash commands", "discord.py", "dm support", "streaming"]
keywords: ["discord bot", "slash commands", "discli", "dm support", "streaming", "conversation mode"]
tags: ["channels", "discord"]
---
@@ -17,13 +17,16 @@ PocketPaw integrates with Discord via `discli` (Discord CLI agent). It supports
<Step title="Create a Discord application">
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Click "New Application" and name it
3. Go to the "Bot" section and create a bot
3. Go to the **Bot** section and create a bot
4. Copy the bot token
Under **Privileged Gateway Intents**, enable:
- **Message Content Intent** (required for reading messages and conversation mode)
</Step>
<Step title="Set permissions">
Under OAuth2 → URL Generator, select:
- Scopes: `bot`, `applications.commands`
- Permissions: `Send Messages`, `Read Message History`, `Use Slash Commands`
- Permissions: `Send Messages`, `Read Message History`, `Use Slash Commands`, `Embed Links`, `Attach Files`
</Step>
<Step title="Invite to server">
Use the generated URL to invite the bot to your Discord server.
@@ -44,12 +47,17 @@ PocketPaw integrates with Discord via `discli` (Discord CLI agent). It supports
| Setting | Env Variable | Description |
|---------|-------------|-------------|
| Bot token | `POCKETPAW_DISCORD_BOT_TOKEN` | Discord bot token |
| Bot token | `POCKETPAW_DISCORD_BOT_TOKEN` | Discord bot token (required) |
| Allowed guilds | `POCKETPAW_DISCORD_ALLOWED_GUILD_IDS` | Comma-separated guild IDs |
| Allowed users | `POCKETPAW_DISCORD_ALLOWED_USER_IDS` | Comma-separated user IDs |
| Allowed channels | `POCKETPAW_DISCORD_ALLOWED_CHANNEL_IDS` | Comma-separated channel IDs |
| Conversation channels | `POCKETPAW_DISCORD_CONVERSATION_CHANNEL_IDS` | Channels with conversation mode enabled |
| Bot name | `POCKETPAW_DISCORD_BOT_NAME` | Display name used in conversation prompts |
| Conversation channels | `POCKETPAW_DISCORD_CONVERSATION_CHANNEL_IDS` | Channels with conversation mode enabled via env |
| Conversation all channels | `POCKETPAW_DISCORD_CONVERSATION_ALL_CHANNELS` | Enable conversation mode server-wide (`true`/`false`) |
| Conversation exclude channels | `POCKETPAW_DISCORD_CONVERSATION_EXCLUDE_CHANNEL_IDS` | Channels to exclude from server-wide conversation mode |
| Bot name | `POCKETPAW_DISCORD_BOT_NAME` | Display name used in conversation prompts (default: `Paw`) |
| Status type | `POCKETPAW_DISCORD_STATUS_TYPE` | Bot presence status: `online`, `idle`, `dnd`, or `invisible` |
| Activity type | `POCKETPAW_DISCORD_ACTIVITY_TYPE` | Activity type: `playing`, `streaming`, `listening`, or `watching` |
| Activity text | `POCKETPAW_DISCORD_ACTIVITY_TEXT` | Text shown next to the activity type |
## Features
@@ -91,7 +99,30 @@ When enabled for a channel, the bot:
- **Stays silent when appropriate** by returning a `[NO_RESPONSE]` marker that the adapter suppresses, allowing natural "lurking"
- **Injects conversation context** from the last 30 messages (up to 12,000 characters) into the agent prompt for natural, context-aware replies
Enable conversation mode per channel with the `/converse` admin command.
**Per-channel:** Use the `/converse` slash command in any channel (requires Administrator or Manage Server permission). Run it again to toggle off.
**Server-wide:** Enable conversation mode across all channels at once via environment variable:
```bash
export POCKETPAW_DISCORD_CONVERSATION_ALL_CHANNELS=true
# Optionally exclude specific channels (e.g. announcements, rules)
export POCKETPAW_DISCORD_CONVERSATION_EXCLUDE_CHANNEL_IDS="123456789,987654321"
```
Idle channels are automatically cleaned up after 1 hour of inactivity.
### Bot Presence
Customize the bot's online status and activity in Discord:
```bash
export POCKETPAW_DISCORD_STATUS_TYPE=online # online, idle, dnd, invisible
export POCKETPAW_DISCORD_ACTIVITY_TYPE=watching # playing, streaming, listening, watching
export POCKETPAW_DISCORD_ACTIVITY_TEXT="your code"
```
This shows the bot as "Watching your code" in the member list.
### Slash Commands
@@ -125,6 +156,19 @@ The MCP server wraps the `discli` CLI and provides structured tools like `discor
The server config is saved to `~/.pocketpaw/mcp_servers.json` and persists across restarts.
## Docker Deployment
A dedicated Discord Docker setup is available for running the bot in a container. See the [Discord Docker Deployment](/deployment/discord-docker) guide for the full walkthrough, including Claude Code OAuth support and Coolify-friendly configuration.
Quick start:
```bash
cd deploy/discord
cp .env.example .env
# Edit .env with your bot token and LLM provider
docker compose up -d
```
## Installation
```bash
@@ -134,7 +178,7 @@ curl -fsSL https://pocketpaw.xyz/install.sh | sh
pip install pocketpaw[discord]
```
This installs `discord.py` and `discord-cli-agent` as optional dependencies.
This installs `discord-cli-agent` as an optional dependency.
## Related
@@ -151,4 +195,7 @@ This installs `discord.py` and `discord-cli-agent` as optional dependencies.
<Card title="All Channels" icon="lucide:radio" href="/channels">
Compare all 9+ supported messaging platforms.
</Card>
<Card title="Discord Docker Deployment" icon="lucide:container" href="/deployment/discord-docker">
Run the Discord bot in Docker with Claude Code OAuth and Coolify support.
</Card>
</CardGroup>

View File

@@ -0,0 +1,273 @@
---
title: "Discord Docker Deployment"
description: "Deploy PocketPaw as a headless Discord bot in Docker with multi-stage builds, Claude Code OAuth token support, LiteLLM proxy, and Coolify-compatible configuration."
section: Deployment
ogType: article
keywords: ["discord docker", "discord bot docker", "coolify", "claude code oauth", "headless discord bot", "docker compose discord"]
tags: ["deployment", "docker", "discord"]
---
# Discord Docker Deployment
PocketPaw ships with a dedicated Discord deployment setup in `deploy/discord/`. This runs the bot headless (no web dashboard) in a lightweight container with all agent backends and tools pre-installed.
## Quick Start
```bash
cd deploy/discord
cp .env.example .env
# Edit .env with your bot token and LLM provider
docker compose up -d
```
<Callout type="info">
This deployment runs `pocketpaw --discord` in headless mode. For the full web dashboard with Discord as one of many channels, use the main [Docker deployment](/deployment/docker) instead.
</Callout>
## LLM Provider Options
The `.env.example` file includes six provider options. Uncomment the one that fits your setup.
<Tabs items={["LiteLLM Proxy", "Anthropic API", "Claude Code OAuth", "Claude Code CLI Login", "OpenAI-Compatible", "OpenAI Agents"]}>
<Tab title="LiteLLM Proxy">
Route through a [LiteLLM](https://docs.litellm.ai/) proxy to access 100+ providers (Anthropic, OpenAI, Azure, Bedrock, etc.) via a single endpoint:
```bash
POCKETPAW_AGENT_BACKEND=claude_agent_sdk
POCKETPAW_CLAUDE_SDK_PROVIDER=litellm
POCKETPAW_LITELLM_API_BASE=http://host.docker.internal:4000
POCKETPAW_LITELLM_API_KEY=your-litellm-master-key
POCKETPAW_LITELLM_MODEL=anthropic/claude-sonnet-4-6
```
Use `host.docker.internal` to reach a LiteLLM instance running on the host machine.
</Tab>
<Tab title="Anthropic API">
Direct Anthropic API access with your own API key:
```bash
POCKETPAW_AGENT_BACKEND=claude_agent_sdk
POCKETPAW_CLAUDE_SDK_PROVIDER=anthropic
POCKETPAW_ANTHROPIC_API_KEY=sk-ant-...
```
</Tab>
<Tab title="Claude Code OAuth">
Use your Claude Max or Pro plan without a separate API key. Generate a long-lived OAuth token (~1 year) on your local machine, then paste it into the env file. No volume mount needed.
```bash
# On your local machine first:
claude setup-token
# Then in .env:
POCKETPAW_AGENT_BACKEND=claude_agent_sdk
POCKETPAW_CLAUDE_SDK_PROVIDER=claude_code
POCKETPAW_CLAUDE_CODE_OAUTH_TOKEN={"accessToken":"sk-ant-oat01-...","refreshToken":"sk-ant-ort01-...","expiresAt":"2027-..."}
```
<Callout type="tip">
This is the easiest option for Coolify and remote servers. No interactive login needed after the initial token generation.
</Callout>
</Tab>
<Tab title="Claude Code CLI Login">
Interactive OAuth login persisted in a Docker volume. Requires a one-time login inside the container:
```bash
POCKETPAW_AGENT_BACKEND=claude_agent_sdk
POCKETPAW_CLAUDE_SDK_PROVIDER=claude_code
```
First-time setup:
1. Deploy with these settings
2. Exec into the container: `docker exec -it pocketpaw-discord bash`
3. Run `claude` and complete the OAuth login in your browser
4. Exit and restart the container
On Coolify, use the "Execute Command" feature in the container terminal.
<Callout type="info">
The `claude-auth` volume persists your login across container restarts and redeployments.
</Callout>
</Tab>
<Tab title="OpenAI-Compatible">
Connect to any OpenAI-compatible endpoint (vLLM, Ollama, local servers):
```bash
POCKETPAW_AGENT_BACKEND=claude_agent_sdk
POCKETPAW_CLAUDE_SDK_PROVIDER=openai_compatible
POCKETPAW_OPENAI_COMPATIBLE_BASE_URL=http://host.docker.internal:8080/v1
POCKETPAW_OPENAI_COMPATIBLE_API_KEY=your-api-key
POCKETPAW_OPENAI_COMPATIBLE_MODEL=your-model
```
</Tab>
<Tab title="OpenAI Agents">
Use the OpenAI Agents backend with LiteLLM:
```bash
POCKETPAW_AGENT_BACKEND=openai_agents
POCKETPAW_OPENAI_AGENTS_PROVIDER=litellm
POCKETPAW_LITELLM_API_BASE=http://host.docker.internal:4000
POCKETPAW_LITELLM_API_KEY=your-litellm-master-key
POCKETPAW_LITELLM_MODEL=openai/gpt-5.2
```
</Tab>
</Tabs>
## Dockerfile
The Discord Dockerfile uses a multi-stage build optimized for headless operation (no Playwright/Chromium):
**Node stage** (`node:22-slim`):
- Installs Claude Code CLI and Codex CLI globally
**Builder stage** (`python:3.12-slim`):
- Creates a virtual environment and installs `pocketpaw[all]`
**Runtime stage** (`python:3.12-slim`):
- Copies Node.js, CLI tools, and the Python venv
- Installs only runtime deps (git, tesseract)
- Creates a non-root `pocketpaw` user
- Pre-creates `~/.claude.json` marker file (prevents Claude Code onboarding prompt)
- Runs `pocketpaw --discord` as the entrypoint
<Callout type="tip">
The Discord image is significantly smaller than the main Docker image because it skips Playwright and Chromium shared libraries.
</Callout>
## Docker Compose
The `docker-compose.yaml` defines a single `pocketpaw-discord` service with four volume mounts:
```yaml
volumes:
# PocketPaw config, memory, sessions, audit logs
- pocketpaw-discord-data:/home/pocketpaw/.pocketpaw
# Custom identity / system prompt (optional)
- ./identity:/home/pocketpaw/.pocketpaw/identity
# Agent-created files (accessible on the host)
- ./workspace:/home/pocketpaw/workspace
# Claude Code OAuth credentials
- claude-auth:/home/pocketpaw/.claude
```
### Claude Auth Volume
The `claude-auth` volume stores Claude Code OAuth credentials. Two options:
**Named volume** (for Coolify and remote servers):
```yaml
- claude-auth:/home/pocketpaw/.claude
```
Exec into the container once to run `claude` and complete the login, or use the OAuth token approach.
**Host bind mount** (reuse your local Claude Code login):
```yaml
- ${CLAUDE_CONFIG_DIR:-~/.claude}:/home/pocketpaw/.claude
```
### Resource Limits
The compose file sets resource limits to prevent runaway usage:
| Resource | Limit | Reservation |
|----------|-------|-------------|
| Memory | 8 GB | 2 GB |
| CPUs | 4 | 2 |
Adjust these in the `deploy.resources` section based on your server.
## Discord Configuration
### Required
```bash
POCKETPAW_DISCORD_BOT_TOKEN=your-bot-token-here
```
### Access Control
```bash
# Lock to specific servers
POCKETPAW_DISCORD_ALLOWED_GUILD_IDS=[123456789012345678]
# Restrict to specific channels
POCKETPAW_DISCORD_ALLOWED_CHANNEL_IDS=[123456789012345678]
# Restrict to specific users
POCKETPAW_DISCORD_ALLOWED_USER_IDS=[123456789012345678]
```
### Server-Wide Conversation Mode
Enable conversation mode across all channels without needing the `/converse` command:
```bash
POCKETPAW_DISCORD_CONVERSATION_ALL_CHANNELS=true
# Exclude channels like announcements or rules
POCKETPAW_DISCORD_CONVERSATION_EXCLUDE_CHANNEL_IDS=[123456789,987654321]
```
### Bot Presence
```bash
POCKETPAW_DISCORD_BOT_NAME=CodeReviewer
POCKETPAW_DISCORD_STATUS_TYPE=online # online, idle, dnd, invisible
POCKETPAW_DISCORD_ACTIVITY_TYPE=watching # playing, streaming, listening, watching
POCKETPAW_DISCORD_ACTIVITY_TEXT=your code
```
### Soul (Persistent Identity)
Give your bot a persistent personality that evolves over time:
```bash
POCKETPAW_SOUL_ENABLED=true
POCKETPAW_SOUL_NAME=Paw
POCKETPAW_SOUL_ARCHETYPE=The Helpful Assistant
POCKETPAW_SOUL_AUTO_SAVE_INTERVAL=300
```
## Managing the Container
```bash
# View logs
docker compose logs -f pocketpaw-discord
# Restart
docker compose restart pocketpaw-discord
# Stop
docker compose down
# Rebuild after updates
docker compose build && docker compose up -d
```
## Coolify Deployment
For [Coolify](https://coolify.io/) users:
1. Point to the `deploy/discord/` directory in your repo
2. Set the Dockerfile path to `deploy/discord/Dockerfile` and build context to the repo root
3. Add all env vars from `.env.example` in the Coolify environment settings
4. For Claude Code auth, use the OAuth token option (`POCKETPAW_CLAUDE_CODE_OAUTH_TOKEN`) to avoid interactive login, or use the container terminal to run `claude` once
## Related
<CardGroup>
<Card title="Discord Bot Setup" icon="lucide:gamepad-2" href="/channels/discord">
Full Discord feature reference: slash commands, conversation mode, MCP server.
</Card>
<Card title="Main Docker Deployment" icon="lucide:container" href="/deployment/docker">
Full-stack Docker setup with web dashboard, Ollama, and Qdrant profiles.
</Card>
<Card title="Discord AI Bot Guide" icon="lucide:book-open" href="/guides/discord-ai-bot">
Step-by-step tutorial for adding an AI assistant to your Discord server.
</Card>
<Card title="Self-Hosting" icon="lucide:server" href="/deployment/self-hosting">
Reverse proxy, SSL, and production hardening.
</Card>
</CardGroup>

View File

@@ -193,6 +193,33 @@ git pull && docker compose build && docker compose up -d
Playwright Chromium is included in the Docker image along with all required shared libraries. Browser tools work out of the box inside the container -- no additional setup needed.
## Claude Code OAuth Support
The Docker image includes the Claude Code CLI pre-installed. You can authenticate using an OAuth token from your Claude Max or Pro plan instead of a separate API key.
**Option 1: OAuth token** (recommended for remote/Coolify deployments):
```bash
# Generate a long-lived token on your local machine
claude setup-token
# Add to .env
POCKETPAW_CLAUDE_SDK_PROVIDER=claude_code
POCKETPAW_CLAUDE_CODE_OAUTH_TOKEN={"accessToken":"sk-ant-oat01-...","refreshToken":"sk-ant-ort01-...","expiresAt":"2027-..."}
```
**Option 2: CLI interactive login** (persisted in a volume):
Mount a volume to `/home/pocketpaw/.claude`, exec into the container, run `claude`, and complete the browser-based login once. Credentials persist across restarts.
See the [Discord Docker guide](/deployment/discord-docker) for detailed instructions on both options.
## Discord-Only Deployment
For a lightweight, headless Discord bot without the web dashboard, use the dedicated setup in `deploy/discord/`. It has a smaller image (no Playwright/Chromium), its own Dockerfile, and compose file optimized for single-channel operation.
See [Discord Docker Deployment](/deployment/discord-docker) for the full guide.
## Limitations
- **Desktop tools** (`pyautogui`) require a display server and won't work in a headless container. They fail gracefully at invocation time.
@@ -211,4 +238,7 @@ Playwright Chromium is included in the Docker image along with all required shar
<Card title="Configuration" icon="lucide:settings" href="/getting-started/configuration">
Full reference for all PocketPaw environment variables and config options.
</Card>
<Card title="Discord Docker" icon="lucide:gamepad-2" href="/deployment/discord-docker">
Lightweight headless Discord bot deployment with Claude Code OAuth.
</Card>
</CardGroup>

View File

@@ -821,6 +821,11 @@
"href": "/deployment/docker",
"icon": "lucide:container"
},
{
"label": "Discord Docker",
"href": "/deployment/discord-docker",
"icon": "lucide:gamepad-2"
},
{
"label": "Systemd Service",
"href": "/deployment/systemd",

View File

@@ -107,13 +107,18 @@ Without these restrictions, anyone in the server can use the bot. For public com
## Conversation Mode
Let the bot participate naturally in group channels without requiring `/paw` or @mentions:
Let the bot participate naturally in group channels without requiring `/paw` or @mentions.
1. Use the `/converse` slash command in any channel (requires Administrator or Manage Server permission)
2. The bot will track the last 30 messages and decide when to respond based on context
3. It stays silent when the conversation isn't directed at it
**Per-channel:** Use the `/converse` slash command in any channel (requires Administrator or Manage Server permission). The bot tracks the last 30 messages and decides when to respond based on context. It stays silent when the conversation isn't directed at it. Toggle it off the same way, run `/converse` again.
Toggle it off the same way -- run `/converse` again in the same channel.
**Server-wide:** Enable conversation mode across all channels at once:
```bash
export POCKETPAW_DISCORD_CONVERSATION_ALL_CHANNELS=true
# Exclude channels that shouldn't have it (announcements, rules, etc.)
export POCKETPAW_DISCORD_CONVERSATION_EXCLUDE_CHANNEL_IDS="123456789,987654321"
```
## Server Management
@@ -134,12 +139,28 @@ pocketpaw
Both bots share the same agent, tools, and memory system. A conversation started on Discord can be continued on Telegram (through the shared memory).
## Docker Deployment
Prefer containers? A dedicated Discord Docker setup is available with multi-stage builds, Claude Code OAuth support, and Coolify-compatible configuration:
```bash
cd deploy/discord
cp .env.example .env
# Edit .env with your bot token and LLM provider
docker compose up -d
```
See the full [Discord Docker Deployment](/deployment/discord-docker) guide for all LLM provider options and volume configuration.
## Next Steps
<CardGroup>
<Card title="Discord Channel Docs" icon="lucide:book-open" href="/channels/discord">
Full reference for Discord-specific features, events, and configuration.
</Card>
<Card title="Discord Docker Deployment" icon="lucide:container" href="/deployment/discord-docker">
Run the Discord bot in Docker with Claude Code OAuth and Coolify support.
</Card>
<Card title="Add Telegram" icon="lucide:send" href="/guides/telegram-ai-bot">
Give your agent mobile access through Telegram.
</Card>

View File

@@ -65,6 +65,7 @@ dependencies = [
[project.optional-dependencies]
# --- Feature extras ---
vector = ["chromadb"]
dashboard = [
# Kept for backward compat — dashboard deps are now in core.
# Users with `pip install pocketpaw[dashboard]` in scripts won't break.
@@ -100,7 +101,7 @@ memory = [
"ollama>=0.6.1",
]
soul = [
"soul-protocol[engine]>=0.2.3",
"soul-protocol[engine]>=0.2.4",
]
# --- Channel extras ---
@@ -203,7 +204,7 @@ all-tools = [
"mem0ai>=0.1.115",
"ollama>=0.6.1",
# soul
"soul-protocol[engine]>=0.2.3",
"soul-protocol[engine]>=0.2.4",
]
all-backends = [
"pocketpaw[openai-agents,google-adk,copilot-sdk,deep-agents,litellm]",
@@ -235,7 +236,7 @@ all = [
"sarvamai>=0.1.25",
"mcp>=1.0.0",
# soul
"soul-protocol[engine]>=0.2.3",
"soul-protocol[engine]>=0.2.4",
# backends
"openai-agents>=0.2.0",
"google-adk>=1.0.0",

View File

@@ -31,6 +31,8 @@ _DEFAULT_IDENTITY = (
"You are PocketPaw, a helpful AI assistant running locally on the user's computer."
)
_HTTP_TRANSPORTS: frozenset[str] = frozenset({"http", "sse", "streamable-http"})
class ClaudeSDKBackend:
"""Claude Agent SDK backend — the recommended default.
@@ -410,7 +412,7 @@ class ClaudeSDKBackend:
entry["args"] = cfg.args
if cfg.env:
entry["env"] = cfg.env
elif cfg.transport in ("http", "sse", "streamable-http"):
elif cfg.transport in _HTTP_TRANSPORTS:
if not cfg.url:
logger.warning("MCP server '%s' (%s) has no url", cfg.name, cfg.transport)
continue
@@ -842,6 +844,12 @@ class ClaudeSDKBackend:
if env_key:
sdk_env = {"ANTHROPIC_API_KEY": env_key}
# Pass Claude Code OAuth token (Max/Pro subscription in Docker/headless)
oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
if oauth_token:
sdk_env = sdk_env or {}
sdk_env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
# Strip nesting-detection env vars (set when launched from
# a Claude Code terminal) so the subprocess starts cleanly.
# These should already be removed by main(), but do it here

View File

@@ -344,7 +344,7 @@ class AgentLoop:
logger.info("Processing cancelled for session %s", session_key)
raise
_WELCOME_EXCLUDED = frozenset({Channel.WEBSOCKET, Channel.CLI, Channel.SYSTEM})
_WELCOME_EXCLUDED = frozenset({Channel.WEBSOCKET, Channel.CLI, Channel.SYSTEM, Channel.DISCORD})
async def _process_message_inner(self, message: InboundMessage, session_key: str) -> None:
"""Inner message processing (called under concurrency guards)."""
@@ -888,7 +888,7 @@ class AgentLoop:
async def _soul_observe_and_emit(
self, user_input: str, agent_output: str, session_key: str
) -> None:
"""Observe interaction and emit soul state event."""
"""Observe interaction, run self-evaluation, and emit soul state event."""
if self._soul_manager is None or not self._soul_manager._initialized:
return
try:
@@ -896,14 +896,21 @@ class AgentLoop:
soul = self._soul_manager.soul
if soul is not None:
state = soul.state
event_data: dict[str, Any] = {
"mood": getattr(state, "mood", None),
"energy": getattr(state, "energy", None),
"session_key": session_key,
}
# v0.2.4+: Run rubric self-evaluation (non-blocking)
eval_result = await self._soul_manager.evaluate(user_input, agent_output)
if eval_result is not None:
event_data["evaluation"] = eval_result
await self.bus.publish_system(
SystemEvent(
event_type="soul_state",
data={
"mood": getattr(state, "mood", None),
"energy": getattr(state, "energy", None),
"session_key": session_key,
},
data=event_data,
)
)
except Exception:

View File

@@ -1,12 +1,13 @@
"""Agent Router — registry-based backend selection.
Uses the backend registry to lazily discover and instantiate the
configured agent backend. Falls back to ``claude_agent_sdk`` when
the requested backend is unavailable.
configured agent backend. Supports optional user-configured fallback
backends if the primary backend fails.
"""
import logging
from collections.abc import AsyncIterator
from typing import Any
from pocketpaw.agents.backend import BackendInfo
from pocketpaw.agents.protocol import AgentEvent
@@ -21,15 +22,25 @@ class AgentRouter:
def __init__(self, settings: Settings):
self.settings = settings
# Primary backend instance (required by existing tests)
self._backend = None
self._active_backend_name: str | None = None
# Cache for fallback backend instances
self._fallback_instances: dict[str, Any] = {}
# Optional fallback backends
self._fallback_backends: list[str] = settings.fallback_backends
self._initialize_backend()
def _initialize_backend(self) -> None:
"""Initialize the selected agent backend from the registry."""
backend_name = self.settings.agent_backend
"""Initialize the primary backend."""
backend_name = self.settings.agent_backend
cls = get_backend_class(backend_name)
if cls is None:
logger.warning(
"Backend '%s' unavailable — falling back to claude_agent_sdk",
@@ -46,12 +57,36 @@ class AgentRouter:
try:
self._backend = cls(self.settings)
self._active_backend_name = backend_name
info = cls.info()
logger.info("🚀 Backend: %s", info.display_name)
except Exception as exc:
logger.error("Failed to initialize '%s' backend: %s", backend_name, exc)
self._active_backend_name = None
def _get_fallback_backend(self, backend_name: str):
"""Return cached fallback backend or create it."""
if backend_name in self._fallback_instances:
return self._fallback_instances[backend_name]
cls = get_backend_class(backend_name)
if cls is None:
return None
try:
backend = cls(self.settings)
self._fallback_instances[backend_name] = backend
return backend
except Exception as exc:
logger.warning(
"Failed to initialize fallback backend '%s': %s",
backend_name,
exc,
)
return None
async def run(
self,
message: str,
@@ -60,24 +95,88 @@ class AgentRouter:
history: list[dict] | None = None,
session_key: str | None = None,
) -> AsyncIterator[AgentEvent]:
"""Run the agent, yielding AgentEvent objects."""
if not self._backend:
yield AgentEvent(type="error", content="No agent backend initialized")
yield AgentEvent(type="done", content="")
return
"""Run the agent with optional fallback backends."""
async for event in self._backend.run(
message, system_prompt=system_prompt, history=history, session_key=session_key
):
yield event
last_error: str | None = None
# Primary backend (streaming, no buffering, no error-event fallback)
if self._backend is not None:
try:
async for event in self._backend.run(
message,
system_prompt=system_prompt,
history=history,
session_key=session_key,
):
yield event
if event.type == "done":
return
except Exception as exc:
last_error = str(exc)
logger.warning(
"Primary backend '%s' failed: %s",
self._active_backend_name,
exc,
)
# Fallback backends
for backend_name in self._fallback_backends:
backend = self._get_fallback_backend(backend_name)
if backend is None:
logger.warning("Fallback backend '%s' unavailable", backend_name)
continue
logger.info("Attempting fallback backend: %s", backend_name)
try:
async for event in backend.run(
message,
system_prompt=system_prompt,
history=history,
session_key=session_key,
):
yield event
if event.type == "done":
return
except Exception as exc:
last_error = str(exc)
logger.warning(
"Fallback backend '%s' failed: %s",
backend_name,
exc,
)
# All backends failed
yield AgentEvent(
type="error",
content=last_error or "All configured backends failed",
)
yield AgentEvent(type="done", content="")
async def stop(self) -> None:
"""Stop the agent."""
"""Stop all backend instances."""
if self._backend:
await self._backend.stop()
try:
await self._backend.stop()
except Exception as exc:
logger.debug("Error stopping primary backend: %s", exc)
for backend in self._fallback_instances.values():
try:
await backend.stop()
except Exception as exc:
logger.debug("Error stopping fallback backend: %s", exc)
def get_backend_info(self) -> BackendInfo | None:
"""Return metadata about the active backend."""
if self._backend is None:
return None
return self._backend.info()

View File

@@ -56,6 +56,10 @@ async def get_channels_status():
result["discord"]["allowed_user_ids"] = settings.discord_allowed_user_ids
result["discord"]["allowed_channel_ids"] = settings.discord_allowed_channel_ids
result["discord"]["conversation_channel_ids"] = settings.discord_conversation_channel_ids
result["discord"]["conversation_all_channels"] = settings.discord_conversation_all_channels
result["discord"]["conversation_exclude_channel_ids"] = (
settings.discord_conversation_exclude_channel_ids
)
return result

View File

@@ -56,6 +56,48 @@ async def export_soul():
return {"path": str(mgr.soul_file), "status": "exported"}
@router.post("/soul/reload")
async def reload_soul():
"""Reload the soul from its .soul file on disk (v0.2.4+).
Useful when the file was modified by another client.
"""
from pocketpaw.soul.manager import get_soul_manager
mgr = get_soul_manager()
if mgr is None or mgr.soul is None:
return {"error": "Soul not enabled"}
success = await mgr.reload()
if success:
return {"status": "reloaded", "name": mgr.soul.name}
return {"error": "Reload failed. Check if the .soul file exists and is valid."}
@router.post("/soul/evaluate")
async def evaluate_soul(body: dict):
"""Run rubric-based self-evaluation on a response (v0.2.4+).
Body: {"user_input": "...", "agent_output": "..."}
Returns heuristic scores for 7 criteria.
"""
from pocketpaw.soul.manager import get_soul_manager
mgr = get_soul_manager()
if mgr is None or mgr.soul is None:
return {"error": "Soul not enabled"}
user_input = body.get("user_input", "")
agent_output = body.get("agent_output", "")
if not user_input or not agent_output:
return {"error": "Both 'user_input' and 'agent_output' are required"}
result = await mgr.evaluate(user_input, agent_output)
if result is None:
return {"error": "Self-evaluation not available. Requires soul-protocol >= 0.2.4."}
return {"status": "evaluated", "scores": result}
_ALLOWED_IMPORT_SUFFIXES = frozenset({".soul", ".yaml", ".yml", ".json"})

View File

@@ -18,10 +18,17 @@ logger = logging.getLogger(__name__)
DISCORD_MSG_LIMIT = 2000
_NO_RESPONSE_MARKER = "[NO_RESPONSE]"
# Slash commands that map directly to "/{command}" with no extra args
_SIMPLE_SLASH_COMMANDS: frozenset[str] = frozenset(
{"new", "sessions", "clear", "status", "help", "kill", "delete", "backends"}
)
_BOT_AUTHOR_KEY = "__bot__"
_CONVERSATION_HISTORY_SIZE = 30
_CONVERSATION_CHAR_BUDGET = 12_000
_IDLE_CHANNEL_TTL = 3600
# Buffer stream chunks until we can confirm it's not [NO_RESPONSE] (13 chars + margin)
_STREAM_BUFFER_THRESHOLD = 25
class DiscliAdapter(BaseChannelAdapter):
@@ -34,6 +41,8 @@ class DiscliAdapter(BaseChannelAdapter):
allowed_user_ids: list[int] | None = None,
allowed_channel_ids: list[int] | None = None,
conversation_channel_ids: list[int] | None = None,
conversation_all_channels: bool = False,
conversation_exclude_channel_ids: list[int] | None = None,
bot_name: str = "Paw",
status_type: str = "online",
activity_type: str = "",
@@ -45,6 +54,10 @@ class DiscliAdapter(BaseChannelAdapter):
self.allowed_user_ids = allowed_user_ids or []
self.allowed_channel_ids = allowed_channel_ids or []
self.conversation_channel_ids: set[int] = set(conversation_channel_ids or [])
self.conversation_all_channels = conversation_all_channels
self.conversation_exclude_channel_ids: set[int] = set(
conversation_exclude_channel_ids or []
)
self.bot_name = bot_name or "Paw"
self.status_type = (
status_type if status_type in {"online", "idle", "dnd", "invisible"} else "online"
@@ -60,6 +73,7 @@ class DiscliAdapter(BaseChannelAdapter):
self._req_counter = 0
self._pending_requests: dict[str, asyncio.Future] = {}
self._active_streams: dict[str, str] = {} # chat_id -> stream_id
self._stream_buffer: dict[str, str] = {} # chat_id -> buffered content
# Conversation history (same as original adapter)
self._conversation_history: dict[int, list[dict[str, str]]] = {}
@@ -305,6 +319,7 @@ class DiscliAdapter(BaseChannelAdapter):
self._slash_config_path = None
self._conversation_history.clear()
self._conversation_last_active.clear()
self._stream_buffer.clear()
logger.info("Discli Adapter stopped")
# ── stdin/stdout Communication ──────────────────────────────────
@@ -405,7 +420,14 @@ class DiscliAdapter(BaseChannelAdapter):
# Track bot's own messages for conversation history
if is_bot and author_id == self._bot_id:
ch_id = int(channel_id)
if ch_id in self.conversation_channel_ids:
_own_guild_ok = not self.allowed_guild_ids or (
guild_id and int(guild_id) in self.allowed_guild_ids
)
_not_excluded = ch_id not in self.conversation_exclude_channel_ids
if _not_excluded and (
(self.conversation_all_channels and _own_guild_ok)
or ch_id in self.conversation_channel_ids
):
self._add_to_history(ch_id, _BOT_AUTHOR_KEY, content)
return
@@ -413,7 +435,19 @@ class DiscliAdapter(BaseChannelAdapter):
if is_bot:
return
is_conversation = not is_dm and int(channel_id) in self.conversation_channel_ids
_in_allowed_guild = not self.allowed_guild_ids or (
guild_id and int(guild_id) in self.allowed_guild_ids
)
_ch_id = int(channel_id)
_not_excluded = _ch_id not in self.conversation_exclude_channel_ids
is_conversation = (
not is_dm
and _not_excluded
and (
(self.conversation_all_channels and _in_allowed_guild)
or _ch_id in self.conversation_channel_ids
)
)
# Track conversation history
if is_conversation:
@@ -554,16 +588,7 @@ class DiscliAdapter(BaseChannelAdapter):
elif command == "tools":
name = args.get("name", "")
content = f"/tools {name}" if name else "/tools"
elif command in (
"new",
"sessions",
"clear",
"status",
"help",
"kill",
"delete",
"backends",
):
elif command in _SIMPLE_SLASH_COMMANDS:
content = f"/{command}"
else:
content = f"/{command}"
@@ -647,35 +672,91 @@ class DiscliAdapter(BaseChannelAdapter):
# ── Streaming ───────────────────────────────────────────────────
async def _flush_stream_buffer(self, chat_id: str, message: OutboundMessage) -> None:
"""Flush the stream buffer by starting a Discord stream and sending buffered text."""
buffered = self._stream_buffer.pop(chat_id, "")
if not buffered:
return
interaction_token = (message.metadata or {}).get("interaction_token")
result = await self._send_command(
"stream_start",
channel_id=chat_id,
reply_to=message.reply_to,
interaction_token=interaction_token,
)
stream_id = result.get("stream_id")
if not stream_id:
logger.error("Failed to start stream: %s", result)
return
self._active_streams[chat_id] = stream_id
await self._send_command("stream_chunk", stream_id=stream_id, content=buffered)
async def _handle_stream_chunk(self, message: OutboundMessage) -> None:
chat_id = message.chat_id
content = message.content
# Suppress [NO_RESPONSE] even in streaming mode
if self._is_no_response(content):
await self._send_command("typing_stop", channel_id=chat_id)
# If stream already started, send chunks directly
if chat_id in self._active_streams:
stream_id = self._active_streams[chat_id]
await self._send_command("stream_chunk", stream_id=stream_id, content=content)
return
if chat_id not in self._active_streams:
# Start a new stream
interaction_token = (message.metadata or {}).get("interaction_token")
result = await self._send_command(
"stream_start",
channel_id=chat_id,
reply_to=message.reply_to,
interaction_token=interaction_token,
)
stream_id = result.get("stream_id")
if not stream_id:
logger.error("Failed to start stream: %s", result)
return
self._active_streams[chat_id] = stream_id
# Buffer content until we can confirm it's not [NO_RESPONSE].
# This prevents the marker from leaking when split across chunks.
self._stream_buffer[chat_id] = self._stream_buffer.get(chat_id, "") + content
buffered = self._stream_buffer[chat_id]
stream_id = self._active_streams[chat_id]
await self._send_command("stream_chunk", stream_id=stream_id, content=content)
# Already looks like [NO_RESPONSE] — keep buffering, will be caught at stream_end
if self._is_no_response(buffered):
return
# Buffer is large enough that it can't be [NO_RESPONSE] — flush to Discord
if len(buffered) >= _STREAM_BUFFER_THRESHOLD:
await self._flush_stream_buffer(chat_id, message)
async def _handle_stream_end(self, message: OutboundMessage) -> None:
chat_id = message.chat_id
buffered = self._stream_buffer.pop(chat_id, "")
# Stream was never started — check if we should suppress or send the buffered content
if chat_id not in self._active_streams:
if not buffered.strip() or self._is_no_response(buffered):
# Suppress [NO_RESPONSE] and stop typing
await self._send_command("typing_stop", channel_id=chat_id)
return
# Short real response that never hit the buffer threshold — send as regular message
await self._send_command("typing_stop", channel_id=chat_id)
interaction_token = (message.metadata or {}).get("interaction_token")
if interaction_token:
await self._send_command(
"interaction_followup",
interaction_token=interaction_token,
content=buffered,
)
else:
reply_to = message.reply_to
if reply_to:
await self._send_command(
"reply",
channel_id=chat_id,
message_id=reply_to,
content=buffered,
)
else:
await self._send_command("send", channel_id=chat_id, content=buffered)
for path in message.media or []:
await self._send_command("send", channel_id=chat_id, content="", files=[path])
return
# Stream was started — flush any remaining buffer and end normally
if buffered:
stream_id = self._active_streams.get(chat_id)
if stream_id:
await self._send_command("stream_chunk", stream_id=stream_id, content=buffered)
stream_id = self._active_streams.pop(chat_id, None)
if stream_id:
await self._send_command("stream_end", stream_id=stream_id)
@@ -773,6 +854,10 @@ class DiscliAdapter(BaseChannelAdapter):
@staticmethod
def _is_no_response(text: str) -> bool:
stripped = text.strip()
if not stripped:
return False
if stripped in (_NO_RESPONSE_MARKER, f"{_NO_RESPONSE_MARKER}."):
return True
return stripped.strip("`*_ .") == _NO_RESPONSE_MARKER
# Strip markdown formatting (backticks, bold, italic, underscores, periods)
cleaned = stripped.strip("`*_ .\n\r\t")
return cleaned == _NO_RESPONSE_MARKER

View File

@@ -195,6 +195,11 @@ class Settings(BaseSettings):
"All backends support 'litellm' as a provider for open-source model access."
),
)
# backend fallback chain
fallback_backends: list[str] = Field(
default_factory=list,
description=("Ordered list of fallback backends to try if the primary backend fails"),
)
# Claude Agent SDK Settings
claude_sdk_provider: str = Field(
@@ -345,12 +350,25 @@ class Settings(BaseSettings):
openai_api_key: str | None = Field(default=None, description="OpenAI API key")
openai_model: str = Field(default="gpt-5.2", description="OpenAI model to use")
anthropic_api_key: str | None = Field(default=None, description="Anthropic API key")
claude_code_oauth_token: str | None = Field(
default=None,
description=(
"Claude Code OAuth token JSON (from `claude setup-token`). "
"Allows Docker/headless use of Max/Pro subscription without an API key."
),
)
anthropic_model: str = Field(default="claude-sonnet-4-6", description="Anthropic model to use")
# Memory Backend
memory_backend: str = Field(
default="file",
description="Memory backend: 'file' (simple markdown), 'mem0' (semantic with LLM)",
description=(
"Memory backend: 'file' (simple markdown), "
"'mem0' (semantic with LLM), 'vector' (ChromaDB)"
),
)
vectordb_path: str = Field(
default="~/.pocketpaw/chroma_db", description="Storage path for the vector database"
)
memory_use_inference: bool = Field(
default=True, description="Use LLM to extract facts from memories (only for mem0 backend)"
@@ -430,6 +448,14 @@ class Settings(BaseSettings):
default_factory=list,
description="Discord channels where the bot participates in group conversation",
)
discord_conversation_all_channels: bool = Field(
default=False,
description="Enable conversation mode in all server channels (overrides channel list)",
)
discord_conversation_exclude_channel_ids: list[int] = Field(
default_factory=list,
description="Channel IDs excluded from conversation mode (e.g. announcements)",
)
discord_bot_name: str = Field(
default="Paw", description="Display name used by the bot in conversation"
)
@@ -800,6 +826,21 @@ class Settings(BaseSettings):
default=300,
description="Auto-save soul state interval in seconds (0 = disabled)",
)
soul_biorhythm: dict[str, float] = Field(
default_factory=lambda: {
"energy_drain_rate": 0.02,
"mood_inertia": 0.8,
"tired_threshold": 0.3,
"auto_regen": 0.01,
},
description=(
"Biorhythm configuration for soul energy/mood dynamics (v0.2.4+). "
"energy_drain_rate: how fast energy depletes per interaction. "
"mood_inertia: resistance to mood change (0-1). "
"tired_threshold: energy level that triggers fatigue. "
"auto_regen: passive energy recovery rate."
),
)
notification_channels: list[str] = Field(
default_factory=list,
@@ -971,7 +1012,7 @@ def _migrate_plaintext_keys() -> None:
return
try:
data = json.loads(config_path.read_text())
data = json.loads(config_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, Exception):
return
@@ -983,9 +1024,14 @@ def _migrate_plaintext_keys() -> None:
if value and isinstance(value, str):
store.set(field, value)
migrated_count += 1
# Remove plaintext secret from config to prevent leakage
del data[field]
if migrated_count:
logger.info("Copied %d secret(s) from config to encrypted store.", migrated_count)
# Save the cleaned config back to remove plaintext secrets
config_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
_chmod_safe(config_path, 0o600)
_MIGRATION_DONE_PATH.write_text("1")
_chmod_safe(_MIGRATION_DONE_PATH, 0o600)

View File

@@ -52,6 +52,8 @@ async def _start_channel_adapter(channel: str, settings: Settings | None = None)
allowed_user_ids=settings.discord_allowed_user_ids,
allowed_channel_ids=settings.discord_allowed_channel_ids,
conversation_channel_ids=settings.discord_conversation_channel_ids,
conversation_all_channels=settings.discord_conversation_all_channels,
conversation_exclude_channel_ids=settings.discord_conversation_exclude_channel_ids,
bot_name=settings.discord_bot_name,
status_type=settings.discord_status_type,
activity_type=settings.discord_activity_type,

View File

@@ -59,6 +59,8 @@ _CHANNEL_CONFIG_KEYS: dict[str, dict[str, str]] = {
"allowed_user_ids": "discord_allowed_user_ids",
"allowed_channel_ids": "discord_allowed_channel_ids",
"conversation_channel_ids": "discord_conversation_channel_ids",
"conversation_all_channels": "discord_conversation_all_channels",
"conversation_exclude_channel_ids": "discord_conversation_exclude_channel_ids",
"bot_name": "discord_bot_name",
"status_type": "discord_status_type",
"activity_type": "discord_activity_type",

View File

@@ -529,6 +529,24 @@ async def websocket_handler(
val = data["soul_auto_save_interval"]
if isinstance(val, int | float) and 0 <= val <= 3600:
settings.soul_auto_save_interval = int(val)
if "soul_biorhythm" in data:
val = data["soul_biorhythm"]
if isinstance(val, dict):
allowed = {
"energy_drain_rate",
"mood_inertia",
"tired_threshold",
"auto_regen",
}
clean = {}
for k, v in val.items():
if k in allowed and isinstance(v, int | float):
clean[k] = float(max(0.0, min(1.0, v)))
if clean:
settings.soul_biorhythm = {
**settings.soul_biorhythm,
**clean,
}
warnings = validate_api_keys(settings)
settings.save()
@@ -786,6 +804,7 @@ async def websocket_handler(
"soulArchetype": settings.soul_archetype,
"soulPersona": settings.soul_persona,
"soulAutoSaveInterval": settings.soul_auto_save_interval,
"soulBiorhythm": settings.soul_biorhythm,
"agentActive": agent_active,
"agentStatus": agent_status,
},

View File

@@ -22,6 +22,8 @@ async def run_discord_bot(settings: Settings) -> None:
allowed_user_ids=settings.discord_allowed_user_ids,
allowed_channel_ids=settings.discord_allowed_channel_ids,
conversation_channel_ids=settings.discord_conversation_channel_ids,
conversation_all_channels=settings.discord_conversation_all_channels,
conversation_exclude_channel_ids=settings.discord_conversation_exclude_channel_ids,
bot_name=settings.discord_bot_name,
status_type=settings.discord_status_type,
activity_type=settings.discord_activity_type,

View File

@@ -78,6 +78,8 @@ async def run_multi_channel_mode(settings: Settings, args: argparse.Namespace) -
allowed_user_ids=settings.discord_allowed_user_ids,
allowed_channel_ids=settings.discord_allowed_channel_ids,
conversation_channel_ids=settings.discord_conversation_channel_ids,
conversation_all_channels=settings.discord_conversation_all_channels,
conversation_exclude_channel_ids=settings.discord_conversation_exclude_channel_ids,
bot_name=settings.discord_bot_name,
status_type=settings.discord_status_type,
activity_type=settings.discord_activity_type,

View File

@@ -5,6 +5,11 @@
from __future__ import annotations
# Frozensets for O(1) membership tests inside the section-filter loop
_API_KEY_CHECK_IDS: frozenset[str] = frozenset(
{"api_key_primary", "api_key_format", "secrets_encrypted"}
)
PLAYBOOKS: dict[str, dict] = {
"api_key_primary": {
"symptom": "Agent fails to respond or returns authentication errors",
@@ -135,11 +140,7 @@ def diagnose_config(section: str = "") -> str:
result = check_fn()
# Filter by section if specified
if section:
if section == "api_keys" and result.check_id not in (
"api_key_primary",
"api_key_format",
"secrets_encrypted",
):
if section == "api_keys" and result.check_id not in _API_KEY_CHECK_IDS:
continue
elif section == "backend" and result.check_id not in (
"backend_deps",

View File

@@ -17,6 +17,9 @@ logger = logging.getLogger(__name__)
_SPOTIFY_BASE = "https://api.spotify.com/v1"
# Spotify playback success status codes
_SPOTIFY_SUCCESS_CODES: frozenset[int] = frozenset({200, 202, 204})
class SpotifyClient:
"""HTTP client for Spotify Web API.
@@ -166,7 +169,7 @@ class SpotifyClient:
else:
return f"Unknown action: {action}"
if resp.status_code in (200, 202, 204):
if resp.status_code in _SPOTIFY_SUCCESS_CODES:
return f"Playback: {action} OK"
resp.raise_for_status()

View File

@@ -252,6 +252,10 @@ def resolve_backend_env(settings: Settings, *, force: bool = False) -> None:
if settings.anthropic_api_key:
_set("ANTHROPIC_API_KEY", settings.anthropic_api_key)
# -- Claude Code OAuth (Max/Pro subscription in Docker/headless) --
if settings.claude_code_oauth_token:
_set("CLAUDE_CODE_OAUTH_TOKEN", settings.claude_code_oauth_token)
# -- OpenAI --
if settings.openai_api_key:
_set("OPENAI_API_KEY", settings.openai_api_key)

View File

@@ -21,6 +21,12 @@ from pocketpaw.memory.protocol import MemoryEntry, MemoryType
logger = logging.getLogger(__name__)
# Metadata keys that are stored as dedicated fields and must be excluded when
# building the generic metadata dict for a MemoryEntry.
_RESERVED_METADATA_KEYS: frozenset[str] = frozenset(
{"pocketpaw_type", "tags", "created_at", "role"}
)
# Embedding dimensions by model
_EMBEDDING_DIMS = {
"text-embedding-3-small": 1536,
@@ -569,11 +575,7 @@ class Mem0MemoryStore:
created_at=created_at,
updated_at=datetime.now(tz=UTC),
tags=metadata.get("tags", []),
metadata={
k: v
for k, v in metadata.items()
if k not in ("pocketpaw_type", "tags", "created_at", "role")
},
metadata={k: v for k, v in metadata.items() if k not in _RESERVED_METADATA_KEYS},
role=role,
session_key=metadata.get("session_key"),
)

View File

@@ -14,6 +14,9 @@ import click
logger = logging.getLogger(__name__)
# CLI exit commands recognised in the interactive chat loop
_EXIT_COMMANDS: frozenset[str] = frozenset({"exit", "quit", "bye"})
def _run_async(coro):
"""Bridge sync Click commands to async internals. When already inside a running event loop
@@ -295,7 +298,7 @@ async def _chat_async() -> None:
if not user_input:
continue
if user_input.lower() in ("exit", "quit", "bye"):
if user_input.lower() in _EXIT_COMMANDS:
_print("Goodbye!", style="dim")
break

View File

@@ -9,6 +9,9 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
# YAML null-like values (used in the fallback parser loop)
_YAML_NULL_VALUES: frozenset[str] = frozenset({"null", "~", ""})
@dataclass
class PawConfig:
@@ -71,7 +74,7 @@ def _load_yaml(path: Path) -> dict[str, Any]:
if ":" in line:
key, _, value = line.partition(":")
value = value.strip().strip("\"'")
if value.lower() in ("null", "~", ""):
if value.lower() in _YAML_NULL_VALUES:
result[key.strip()] = None
else:
result[key.strip()] = value

View File

@@ -43,11 +43,15 @@ class SoulBootstrapProvider:
system_prompt = soul.to_system_prompt()
# Extract personality and mood for style hints
# Extract personality, mood, and biorhythm for style hints
state = soul.state
mood_hint = f"Current mood: {state.mood}" if hasattr(state, "mood") else ""
energy_hint = f"Energy: {state.energy}" if hasattr(state, "energy") else ""
style_parts = [s for s in [mood_hint, energy_hint] if s]
tired_hint = ""
if hasattr(state, "energy") and hasattr(state, "tired_threshold"):
if state.energy <= state.tired_threshold:
tired_hint = "Status: fatigued (low energy)"
style_parts = [s for s in [mood_hint, energy_hint, tired_hint] if s]
# Pull active self-images for knowledge context
knowledge: list[str] = []

View File

@@ -1,6 +1,7 @@
# Soul tools — four BaseTool implementations for soul-protocol integration.
# Soul tools — BaseTool implementations for soul-protocol integration.
# Created: 2026-03-02
# SoulRememberTool, SoulRecallTool, SoulEditCoreTool, SoulStatusTool.
# SoulRememberTool, SoulRecallTool, SoulEditCoreTool, SoulStatusTool,
# SoulEvaluateTool (v0.2.4+), SoulReloadTool (v0.2.4+).
from __future__ import annotations
@@ -12,6 +13,8 @@ from pocketpaw.tools.protocol import BaseTool
if TYPE_CHECKING:
from soul_protocol import Soul
from pocketpaw.soul.manager import SoulManager
class SoulRememberTool(BaseTool):
"""Store memories via soul.remember()."""
@@ -221,3 +224,84 @@ class SoulStatusTool(BaseTool):
return json.dumps(status, indent=2, default=str)
except Exception as e:
return self._error(f"Failed to get soul status: {e}")
class SoulEvaluateTool(BaseTool):
"""Rubric-based self-evaluation of responses (v0.2.4+)."""
def __init__(self, soul: Soul, manager: SoulManager) -> None:
self._soul = soul
self._manager = manager
@property
def name(self) -> str:
return "soul_evaluate"
@property
def description(self) -> str:
return (
"Evaluate a response against quality rubrics. Returns heuristic scores "
"for completeness, relevance, helpfulness, specificity, empathy, clarity, "
"and originality. Results feed into skill XP and procedural memory."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"user_input": {
"type": "string",
"description": "The user's original message/question",
},
"agent_output": {
"type": "string",
"description": "The agent's response to evaluate",
},
},
"required": ["user_input", "agent_output"],
}
async def execute(self, user_input: str = "", agent_output: str = "", **kwargs: Any) -> str:
try:
result = await self._manager.evaluate(user_input, agent_output)
if result is None:
return self._error(
"Self-evaluation not available. Requires soul-protocol >= 0.2.4."
)
return json.dumps(result, indent=2, default=str)
except Exception as e:
return self._error(f"Evaluation failed: {e}")
class SoulReloadTool(BaseTool):
"""Reload soul from disk (v0.2.4+)."""
def __init__(self, manager: SoulManager) -> None:
self._manager = manager
@property
def name(self) -> str:
return "soul_reload"
@property
def description(self) -> str:
return (
"Reload the soul from its .soul file on disk. Use this when the soul "
"file has been modified externally (e.g. by another client or tool) "
"and you want to pick up the latest state."
)
@property
def parameters(self) -> dict[str, Any]:
return {"type": "object", "properties": {}, "required": []}
async def execute(self, **kwargs: Any) -> str:
try:
success = await self._manager.reload()
if success:
name = self._manager.soul.name if self._manager.soul else "unknown"
return self._success(f"Soul reloaded successfully: {name}")
return self._error("Reload failed. Check if the .soul file exists and is valid.")
except Exception as e:
return self._error(f"Reload failed: {e}")

View File

@@ -5,6 +5,9 @@ Edge cases handled:
- Concurrent observe(): serialized via asyncio.Lock
- Periodic auto-save: background task prevents data loss on crash
- Graceful shutdown: saves state and cancels auto-save task
- Auto-sync: detects external .soul file changes (v0.2.4+)
- Rubric self-evaluation: heuristic scoring after interactions (v0.2.4+)
- Configurable biorhythms: energy/mood dynamics via DNA (v0.2.4+)
"""
from __future__ import annotations
@@ -22,6 +25,9 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Soul config formats supported on hot-reload
_SOUL_CONFIG_FORMATS: frozenset[str] = frozenset({".yaml", ".yml", ".json"})
_manager: SoulManager | None = None
@@ -39,6 +45,9 @@ def _reset_manager() -> None:
class SoulManager:
"""Manages the Soul instance lifecycle."""
# Cache for Interaction class (avoid repeated import per observe call)
_interaction_cls: type | None = None
def __init__(self, settings: Settings) -> None:
self._settings = settings
self.soul: Any = None
@@ -48,6 +57,9 @@ class SoulManager:
self._observe_lock = asyncio.Lock()
self._auto_save_task: asyncio.Task | None = None
self._observe_count = 0
self._dirty = False # Track whether soul has unsaved changes
self._tools_cache: list[BaseTool] | None = None
self._soul_file_mtime: float = 0.0 # Last known mtime for auto-sync
@property
def observe_count(self) -> int:
@@ -100,6 +112,8 @@ class SoulManager:
self.bridge = SoulBridge(self.soul)
self.bootstrap_provider = SoulBootstrapProvider(self.soul)
self._initialized = True
self._dirty = False
self._record_file_mtime()
global _manager
_manager = self
@@ -136,14 +150,32 @@ class SoulManager:
f"I am {s.soul_name}, a persistent AI companion. I value {', '.join(s.soul_values)}."
)
logger.info("Birthing new soul: %s", s.soul_name)
return await soul_cls.birth(
name=s.soul_name,
archetype=s.soul_archetype,
values=s.soul_values,
persona=persona,
ocean=s.soul_ocean if s.soul_ocean else None,
communication=s.soul_communication if s.soul_communication else None,
)
kwargs: dict[str, Any] = {
"name": s.soul_name,
"archetype": s.soul_archetype,
"values": s.soul_values,
"persona": persona,
}
if s.soul_ocean:
kwargs["ocean"] = s.soul_ocean
if s.soul_communication:
kwargs["communication"] = s.soul_communication
# v0.2.4+: Pass biorhythm config via DNA if the library supports it
if s.soul_biorhythm:
try:
import inspect
sig = inspect.signature(soul_cls.birth)
if "biorhythm" in sig.parameters:
kwargs["biorhythm"] = s.soul_biorhythm
elif "dna" in sig.parameters:
kwargs["dna"] = {"biorhythm": s.soul_biorhythm}
except Exception:
logger.debug("Could not pass biorhythm config to Soul.birth()")
return await soul_cls.birth(**kwargs)
async def observe(self, user_input: str, agent_output: str) -> None:
"""Record a conversation turn (serialized via lock)."""
@@ -152,6 +184,63 @@ class SoulManager:
async with self._observe_lock:
await self.bridge.observe(user_input, agent_output)
self._observe_count += 1
self._dirty = True
async def evaluate(self, user_input: str, agent_output: str) -> dict[str, Any] | None:
"""Run rubric-based self-evaluation on a response (v0.2.4+).
Returns a dict with scores and feedback, or None if unsupported.
"""
if self.soul is None:
return None
try:
if not hasattr(self.soul, "evaluate"):
return None
result = await self.soul.evaluate(user_input=user_input, agent_output=agent_output)
# Result can be a dict or an object with a .dict()/.model_dump() method
if hasattr(result, "model_dump"):
return result.model_dump()
if hasattr(result, "dict"):
return result.dict()
if isinstance(result, dict):
return result
return {"raw": str(result)}
except Exception:
logger.debug("Soul evaluate() failed (non-fatal)", exc_info=True)
return None
async def reload(self) -> bool:
"""Reload the soul from its .soul file (v0.2.4+).
Useful when the file was modified externally (e.g. by another client).
Returns True if reload succeeded, False otherwise.
"""
if self.soul is None:
return False
try:
from soul_protocol import Soul
soul_path = self.soul_file
if not soul_path.exists():
return False
new_soul = await self._try_awaken(Soul, soul_path)
if new_soul is None:
return False
self.soul = new_soul
if self.bridge is not None:
self.bridge._soul = self.soul
if self.bootstrap_provider is not None:
self.bootstrap_provider._soul = self.soul
self._tools_cache = None # Invalidate cached tools
self._dirty = False
self._record_file_mtime()
logger.info("Soul reloaded from %s", soul_path)
return True
except Exception:
logger.exception("Failed to reload soul")
return False
async def save(self) -> None:
"""Persist the soul to disk."""
@@ -159,10 +248,27 @@ class SoulManager:
return
try:
await self.soul.export(self.soul_file)
self._dirty = False
self._record_file_mtime()
logger.debug("Soul saved to %s", self.soul_file)
except Exception:
logger.exception("Failed to save soul")
def _record_file_mtime(self) -> None:
"""Record the current .soul file mtime for sync detection."""
try:
self._soul_file_mtime = self.soul_file.stat().st_mtime
except OSError:
self._soul_file_mtime = 0.0
def _file_changed_externally(self) -> bool:
"""Check if the .soul file was modified by another process."""
try:
current_mtime = self.soul_file.stat().st_mtime
return current_mtime > self._soul_file_mtime
except OSError:
return False
def start_auto_save(self) -> None:
"""Start the periodic auto-save background task."""
interval = self._settings.soul_auto_save_interval
@@ -173,11 +279,19 @@ class SoulManager:
)
async def _auto_save_loop(self, interval: int) -> None:
"""Periodically save soul state and consolidate memory."""
"""Periodically save soul state, sync external changes, and consolidate memory."""
while True:
await asyncio.sleep(interval)
try:
await self.save()
# Auto-sync: detect external .soul file changes
if self._file_changed_externally():
logger.info("External .soul file change detected, reloading")
await self.reload()
# Only save if there are unsaved changes
if self._dirty:
await self.save()
if self.soul is not None and self._observe_count >= 10:
try:
await self.soul.reflect()
@@ -237,14 +351,14 @@ class SoulManager:
new_soul = await self._try_awaken(Soul, file_path)
if new_soul is None:
raise ValueError(f"Failed to load .soul file: {file_path}")
elif suffix in (".yaml", ".yml", ".json"):
elif suffix in _SOUL_CONFIG_FORMATS:
new_soul = await Soul.birth_from_config(file_path)
else:
raise ValueError(
f"Unsupported file format: {suffix}. Use .soul, .yaml, .yml, or .json."
)
# Replace current soul update existing bridge/provider in-place so that
# Replace current soul -- update existing bridge/provider in-place so that
# any external references (e.g. AgentContextBuilder.bootstrap) stay valid.
self.soul = new_soul
if self.bridge is not None:
@@ -257,6 +371,7 @@ class SoulManager:
self.bootstrap_provider = SoulBootstrapProvider(self.soul)
self._initialized = True
self._observe_count = 0
self._tools_cache = None # Invalidate cached tools
# Persist to configured location
await self.save()
@@ -265,19 +380,27 @@ class SoulManager:
return self.soul.name
def get_tools(self) -> list[BaseTool]:
"""Return the four soul tools."""
"""Return soul tools (cached per soul instance)."""
if self.soul is None:
return []
# Return cached tools if soul reference hasn't changed
if self._tools_cache is not None:
return self._tools_cache
from pocketpaw.paw.tools import (
SoulEditCoreTool,
SoulEvaluateTool,
SoulRecallTool,
SoulReloadTool,
SoulRememberTool,
SoulStatusTool,
)
return [
self._tools_cache = [
SoulRememberTool(self.soul),
SoulRecallTool(self.soul),
SoulEditCoreTool(self.soul),
SoulStatusTool(self.soul),
SoulEvaluateTool(self.soul, self),
SoulReloadTool(self),
]
return self._tools_cache

View File

@@ -9,6 +9,9 @@ from pocketpaw.tools.protocol import BaseTool
logger = logging.getLogger(__name__)
# Valid Google Drive sharing roles
_GDRIVE_ROLES: frozenset[str] = frozenset({"reader", "writer", "commenter"})
class DriveListTool(BaseTool):
"""List or search files in Google Drive."""
@@ -233,7 +236,7 @@ class DriveShareTool(BaseTool):
email: str,
role: str = "reader",
) -> str:
if role not in ("reader", "writer", "commenter"):
if role not in _GDRIVE_ROLES:
return self._error(f"Invalid role '{role}'. Use reader, writer, or commenter.")
try:

View File

@@ -1,6 +1,9 @@
"""File browser tool."""
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field, ValidationError, field_validator
try:
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
@@ -10,39 +13,60 @@ except ImportError:
def is_safe_path(path: Path, jail: Path) -> bool:
"""Check if path is within the jail directory."""
"""Check if path is strictly within the jail directory."""
try:
path = path.resolve()
jail = jail.resolve()
return path.is_relative_to(jail)
except Exception:
resolved_path = path.resolve()
resolved_jail = jail.resolve()
return resolved_path.is_relative_to(resolved_jail)
except (ValueError, OSError):
return False
def get_directory_keyboard(path: Path, jail: Path | None = None) -> InlineKeyboardMarkup:
"""Generate inline keyboard for directory contents."""
if jail is None:
jail = Path.home()
class FetchRequest(BaseModel):
path_str: str = Field(..., description="The path to explore. Cannot be empty.")
jail_str: str = Field(..., description="The strictly enforced jail directory.")
limit: int = Field(30, ge=1, le=100, description="Number of items to return.")
path = Path(path).resolve()
@field_validator("path_str", "jail_str", mode="before")
@classmethod
def prevent_empty(cls, v: Any) -> str:
target = str(v) if v is not None else ""
if not target.strip():
raise ValueError("Path string cannot be empty or whitespace.")
return target
if not is_safe_path(path, jail):
path = jail
def resolve_paths(self) -> tuple[Path, Path]:
"""Resolve path and jail, checking against path traversal."""
path_obj = Path(self.path_str).resolve(strict=False)
jail_obj = Path(self.jail_str).resolve(strict=False)
if not is_safe_path(path_obj, jail_obj):
raise ValueError("Access denied: path outside allowed directory or does not exist")
return path_obj, jail_obj
def _get_directory_keyboard_resolved(
path_obj: Path, jail_obj: Path, limit: int = 30
) -> "InlineKeyboardMarkup | None":
"""Internal: generate inline keyboard from already-validated Path objects."""
if InlineKeyboardMarkup is None:
return None
buttons = []
# Parent directory button (if not at jail root)
if path != jail:
parent = path.parent
if path_obj != jail_obj:
parent = path_obj.parent
buttons.append([InlineKeyboardButton("📁 ..", callback_data=f"fetch:{parent}")])
try:
items = sorted(
(i for i in path.iterdir() if not i.name.startswith(".")),
(i for i in path_obj.iterdir() if not i.name.startswith(".")),
key=lambda x: (not x.is_dir(), x.name.lower()),
)
for item in items[:20]: # Limit to 20 visible items
for item in items[:limit]:
if item.is_dir():
buttons.append(
[InlineKeyboardButton(f"📁 {item.name}/", callback_data=f"fetch:{item}")]
@@ -73,39 +97,81 @@ def get_directory_keyboard(path: Path, jail: Path | None = None) -> InlineKeyboa
return InlineKeyboardMarkup(buttons)
async def handle_path(path_str: str, jail: Path) -> dict:
def get_directory_keyboard(
path: Path | str, jail: Path | str, limit: int = 30
) -> "InlineKeyboardMarkup | None":
"""Generate inline keyboard for directory contents (public API, validates inputs)."""
if InlineKeyboardMarkup is None:
return None
try:
req = FetchRequest(
path_str=str(path),
jail_str=str(jail),
limit=limit,
)
path_obj, jail_obj = req.resolve_paths()
except ValidationError:
return InlineKeyboardMarkup(
[[InlineKeyboardButton("⛔ Invalid parameters", callback_data="noop")]]
)
except ValueError:
path_obj = Path(str(jail)).resolve(strict=False)
jail_obj = path_obj
return _get_directory_keyboard_resolved(path_obj, jail_obj, limit=req.limit)
async def handle_path(path_str: str | Path, jail: str | Path, limit: int = 30) -> dict:
"""Handle a path selection - return directory listing or file."""
path = Path(path_str).resolve()
try:
req = FetchRequest(
path_str=str(path_str),
jail_str=str(jail),
limit=limit,
)
path_obj, jail_obj = req.resolve_paths()
except ValidationError:
return {"type": "error", "message": "Validation Error: invalid input parameters."}
except ValueError as e:
return {"type": "error", "message": str(e)}
if not is_safe_path(path, jail):
return {"type": "error", "message": "Access denied: path outside allowed directory"}
if path.is_dir():
return {"type": "directory", "keyboard": get_directory_keyboard(path, jail)}
elif path.is_file():
return {"type": "file", "path": path, "filename": path.name}
if path_obj.is_dir():
result = {"type": "directory"}
keyboard = _get_directory_keyboard_resolved(path_obj, jail_obj, limit=req.limit)
if keyboard is not None:
result["keyboard"] = keyboard
return result
elif path_obj.is_file():
return {"type": "file", "path": path_obj, "filename": path_obj.name}
else:
return {"type": "error", "message": "Path does not exist"}
def list_directory(path_str: str, jail_str: str | None = None) -> str:
def list_directory(path_str: str | Path, jail_str: str | Path, limit: int = 30) -> str:
"""List directory contents as formatted string for web dashboard."""
path = Path(path_str).resolve()
jail = Path(jail_str).resolve() if jail_str else Path.home()
try:
req = FetchRequest(
path_str=str(path_str),
jail_str=str(jail_str),
limit=limit,
)
path_obj, jail_obj = req.resolve_paths()
except ValidationError:
return "⛔ Validation Error: invalid input parameters."
except ValueError as e:
return f"{e}"
if not is_safe_path(path, jail):
return "⛔ Access denied: path outside allowed directory"
if not path_obj.is_dir():
return f"📄 {path_obj.name} - File selected"
if not path.is_dir():
return f"📄 {path.name} - File selected"
lines = [f"📂 **{path}**\n"]
lines = [f"📂 **{path_obj}**\n"]
try:
visible = [i for i in path.iterdir() if not i.name.startswith(".")]
visible = [i for i in path_obj.iterdir() if not i.name.startswith(".")]
items = sorted(visible, key=lambda x: (not x.is_dir(), x.name.lower()))
for item in items[:30]: # Limit to 30 visible items
for item in items[: req.limit]:
if item.is_dir():
lines.append(f"📁 {item.name}/")
else:
@@ -121,8 +187,8 @@ def list_directory(path_str: str, jail_str: str | None = None) -> str:
size_str = "?"
lines.append(f"📄 {item.name} ({size_str})")
if len(items) > 30:
lines.append(f"\n... and {len(items) - 30} more items")
if len(items) > req.limit:
lines.append(f"\n... and {len(items) - req.limit} more items")
except PermissionError:
lines.append("⛔ Permission denied")

View File

@@ -0,0 +1,3 @@
from .protocol import VectorStoreProtocol
__all__ = ["VectorStoreProtocol"]

View File

@@ -0,0 +1,83 @@
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING, Any
# Use TYPE_CHECKING to avoid circular imports with Settings
if TYPE_CHECKING:
from pocketpaw.config import Settings
# Note: We no longer inherit from VectorStoreProtocol here.
# The @runtime_checkable on the protocol handles the check automatically.
class ChromaAdapter:
def __init__(self, path: str | Path | None = None, collection_name: str = "pocketpaw_memory"):
try:
import chromadb
except ImportError:
raise ImportError(
"chromadb is required for vector backend. Install with: pip install chromadb"
)
# 1. Define the project convention path as a Path object
default_path = Path.home() / ".pocketpaw" / "chroma_db"
# 2. Determine the path and create the directory while it's still a Path object
target_path = Path(path) if path is not None else default_path
# This creates the .pocketpaw folder if it's missing
target_path.parent.mkdir(parents=True, exist_ok=True)
# 3. Convert to string only when passing to the chromadb client
self.client = chromadb.PersistentClient(path=str(target_path))
self.collection = self.client.get_or_create_collection(name=collection_name)
@classmethod
def from_settings(cls, settings: "Settings") -> "ChromaAdapter":
"""
Factory method to create an adapter instance using the
vectordb_path defined in the project settings.
"""
return cls(path=settings.vectordb_path)
async def add(self, doc_id: str, text: str, metadata: dict[str, Any] | None = None) -> None:
"""Adds or updates a document using upsert."""
await asyncio.to_thread(
self.collection.upsert,
documents=[text],
ids=[doc_id],
metadatas=[metadata] if metadata else None,
)
async def search(self, query: str, limit: int = 5) -> list[str]:
results = await asyncio.to_thread(
self.collection.query,
query_texts=[query],
n_results=limit,
)
# Safe check for search results
if results and results.get("documents") and len(results["documents"]) > 0:
return results["documents"][0]
return []
async def delete(self, doc_id: str) -> None:
"""Deletes a document by its ID."""
await asyncio.to_thread(
self.collection.delete,
ids=[doc_id],
)
async def get_by_id(self, doc_id: str) -> str | None:
"""FIX: get_by_id crash prevention logic."""
results = await asyncio.to_thread(
self.collection.get,
ids=[doc_id],
)
# Safely checks if documents key exists, has items, and the first item isn't None
docs = results.get("documents")
if docs and len(docs) > 0 and docs[0] is not None:
return docs[0]
return None

View File

@@ -0,0 +1,32 @@
from typing import Any, Protocol, runtime_checkable
@runtime_checkable
class VectorStoreProtocol(Protocol):
"""Protocol defining the interface for vector database adapters."""
async def add(self, doc_id: str, text: str, metadata: dict[str, Any] | None = None) -> None:
"""
Add or update a document in the vector store.
Args:
doc_id: Unique identifier for the document.
text: The text content to embed and store.
metadata: Optional dictionary of associated metadata (source, timestamp, etc.).
"""
...
async def search(self, query: str, limit: int = 5) -> list[str]:
"""Search for the most relevant documents based on a query string.
Returns a list of document strings.
"""
...
async def delete(self, doc_id: str) -> None:
"""Delete a document from the vector store by its ID."""
...
async def get_by_id(self, doc_id: str) -> str | None:
"""Retrieve a specific document by its ID."""
...

View File

@@ -1204,10 +1204,10 @@ class TestWelcomeHint:
mock_ctx.build_system_prompt = AsyncMock(return_value="sys prompt")
await loop._process_message_inner(msg, "discord:12345")
# Welcome was published
# Discord is excluded from welcome hints
outbound_calls = bus.publish_outbound.call_args_list
welcome_found = any("Welcome to PocketPaw" in str(c) for c in outbound_calls)
assert welcome_found, f"Expected welcome hint in {outbound_calls}"
assert not welcome_found, f"Discord should not get welcome hint: {outbound_calls}"
@patch("pocketpaw.agents.loop.get_injection_scanner")
@patch("pocketpaw.agents.loop.get_command_handler")

View File

@@ -421,8 +421,8 @@ class TestPlaintextMigration:
assert store.get("anthropic_api_key") == "sk-ant-old"
assert store.get("openai_api_key") == "sk-old-openai"
def test_plaintext_keys_preserved_in_config_json(self, env):
"""After migration, config.json still has the keys (as fallback)."""
def test_plaintext_keys_removed_from_config_json(self, env):
"""After migration, plaintext secrets are removed from config.json."""
from pocketpaw.config import Settings
old_config = {
@@ -435,9 +435,10 @@ class TestPlaintextMigration:
Settings.load()
updated = json.loads((env["tmp_path"] / "config.json").read_text(encoding="utf-8"))
# Keys remain in config.json as fallback (file is chmod 600)
assert updated.get("telegram_bot_token") == "123:AAOldToken"
assert updated.get("anthropic_api_key") == "sk-ant-old"
# Plaintext secrets should be removed after migration
assert "telegram_bot_token" not in updated
assert "anthropic_api_key" not in updated
# Non-secret fields should remain
assert updated.get("agent_backend") == "claude_agent_sdk"
def test_migration_flag_created(self, env):

View File

@@ -320,7 +320,8 @@ async def test_send_media_files(adapter):
# ── Streaming via _send_command ─────────────────────────────────────
async def test_stream_start(adapter):
async def test_stream_buffers_short_chunks(adapter):
"""Short chunks are buffered, not sent to Discord immediately."""
adapter._proc = MagicMock()
adapter._send_command = AsyncMock(return_value={"stream_id": "s1"})
@@ -332,13 +333,91 @@ async def test_stream_start(adapter):
)
await adapter.send(chunk)
# Stream should NOT have started yet (content is below buffer threshold)
assert "12345" not in adapter._active_streams
assert adapter._stream_buffer["12345"] == "Hello "
async def test_stream_flushes_above_threshold(adapter):
"""Once buffered content exceeds threshold, stream starts."""
adapter._proc = MagicMock()
adapter._send_command = AsyncMock(return_value={"stream_id": "s1"})
# Send enough content to exceed the 25-char buffer threshold
long_content = "This is a response that exceeds the buffer threshold easily"
chunk = OutboundMessage(
channel=Channel.DISCORD,
chat_id="12345",
content=long_content,
is_stream_chunk=True,
)
await adapter.send(chunk)
adapter._send_command.assert_any_call(
"stream_start", channel_id="12345", reply_to=None, interaction_token=None
)
adapter._send_command.assert_any_call("stream_chunk", stream_id="s1", content="Hello ")
adapter._send_command.assert_any_call("stream_chunk", stream_id="s1", content=long_content)
assert adapter._active_streams["12345"] == "s1"
async def test_stream_end_flushes_short_buffer(adapter):
"""Short buffered content is sent as a regular message at stream_end."""
adapter._proc = MagicMock()
adapter._send_command = AsyncMock(return_value={"ok": True})
# Buffer a short chunk
chunk = OutboundMessage(
channel=Channel.DISCORD,
chat_id="12345",
content="Short reply",
is_stream_chunk=True,
)
await adapter.send(chunk)
assert "12345" not in adapter._active_streams
# End the stream — buffered content should be sent as regular message
end = OutboundMessage(
channel=Channel.DISCORD,
chat_id="12345",
content="",
is_stream_end=True,
)
await adapter.send(end)
adapter._send_command.assert_any_call("typing_stop", channel_id="12345")
adapter._send_command.assert_any_call("send", channel_id="12345", content="Short reply")
async def test_stream_suppresses_no_response(adapter):
"""[NO_RESPONSE] streamed across chunks is buffered and suppressed."""
adapter._proc = MagicMock()
adapter._send_command = AsyncMock(return_value={"ok": True})
# Simulate [NO_RESPONSE] arriving in chunks
for part in ["[NO_", "RESPONSE]"]:
chunk = OutboundMessage(
channel=Channel.DISCORD,
chat_id="12345",
content=part,
is_stream_chunk=True,
)
await adapter.send(chunk)
# Stream should NOT have started
assert "12345" not in adapter._active_streams
# End the stream — should suppress and stop typing
end = OutboundMessage(
channel=Channel.DISCORD,
chat_id="12345",
content="",
is_stream_end=True,
)
await adapter.send(end)
adapter._send_command.assert_called_once_with("typing_stop", channel_id="12345")
async def test_stream_end(adapter):
adapter._proc = MagicMock()
adapter._send_command = AsyncMock(return_value={"ok": True})

View File

@@ -0,0 +1,101 @@
from pathlib import Path
import pytest
from pocketpaw.tools.fetch import (
FetchRequest,
handle_path,
is_safe_path,
list_directory,
)
class TestIsSafePath:
"""Test path safety checks."""
def test_path_within_jail(self, tmp_path: Path) -> None:
"""Test that paths within jail directory are safe."""
jail = tmp_path
test_path = jail / "subdir"
test_path.mkdir()
assert is_safe_path(test_path, jail) is True
def test_path_outside_jail(self, tmp_path: Path) -> None:
"""Test that paths outside jail directory are unsafe."""
jail = tmp_path / "jail"
jail.mkdir()
outside_path = tmp_path / "outside.txt"
assert is_safe_path(outside_path, jail) is False
def test_path_at_jail_root(self, tmp_path: Path) -> None:
"""Test that path at jail root is safe."""
jail = tmp_path
assert is_safe_path(jail, jail) is True
def test_sibling_directory_unsafe(self, tmp_path: Path) -> None:
"""Test that sibling directories are unsafe."""
jail = tmp_path / "jail_dir"
sibling = tmp_path / "sibling_dir"
jail.mkdir()
sibling.mkdir()
assert is_safe_path(sibling, jail) is False
@pytest.mark.asyncio
async def test_handle_path_empty_string_rejected() -> None:
"""Test that handle_path rejects empty string paths (security fix for issue #619)."""
result = await handle_path("", Path.home())
assert result["type"] == "error"
assert "Validation Error" in result["message"]
@pytest.mark.asyncio
async def test_handle_path_whitespace_rejected() -> None:
"""Test that handle_path rejects whitespace-only paths."""
result = await handle_path(" ", Path.home())
assert result["type"] == "error"
assert "Validation Error" in result["message"]
@pytest.mark.asyncio
async def test_handle_path_outside_jail(tmp_path: Path) -> None:
"""Test that handle_path rejects paths outside jail."""
jail = tmp_path / "jail"
jail.mkdir()
outside = tmp_path / "outside.txt"
result = await handle_path(str(outside), jail)
assert result["type"] == "error"
assert "Access denied" in result["message"]
def test_list_directory_empty_string_rejected() -> None:
"""Test that list_directory rejects empty string paths."""
result = list_directory("", str(Path.home()))
assert "Validation Error" in result
def test_list_directory_outside_jail(tmp_path: Path) -> None:
"""Test that list_directory rejects paths outside jail."""
jail = tmp_path / "jail"
jail.mkdir()
outside = tmp_path / "outside_dir"
result = list_directory(str(outside), str(jail))
assert "Access denied" in result
class TestSecurityRegressions:
"""Test security regressions against issue #619."""
@pytest.mark.asyncio
async def test_empty_path_cannot_bypass_jail(self, tmp_path: Path) -> None:
"""Regression test: empty path cannot bypass jail restrictions."""
jail = tmp_path / "jail"
jail.mkdir()
result = await handle_path("", jail)
assert result["type"] == "error"
assert "Validation Error" in result["message"]
def test_path_resolve_with_empty_string_not_called(self, tmp_path: Path) -> None:
"""Verify that validation catches empty strings preventing bypasses."""
with pytest.raises(ValueError, match="Path string cannot be empty or whitespace"):
FetchRequest(path_str="", jail_str=str(tmp_path))

View File

@@ -22,7 +22,7 @@ def mock_soul():
soul = MagicMock()
soul.name = "TestSoul"
soul.to_system_prompt.return_value = "I am TestSoul."
soul.state = MagicMock(mood="curious", energy=85, social_battery=90)
soul.state = MagicMock(mood="curious", energy=85, social_battery=90, tired_threshold=0.3)
soul.self_model = None
soul.remember = AsyncMock(return_value="mem_123")
soul.recall = AsyncMock(

View File

@@ -0,0 +1,227 @@
import pytest
from pocketpaw.agents.protocol import AgentEvent
from pocketpaw.agents.router import AgentRouter
from pocketpaw.config import Settings
class FailingBackend:
@staticmethod
def info():
class Info:
display_name = "Failing Backend"
return Info()
def __init__(self, settings):
pass
async def run(self, *args, **kwargs):
raise RuntimeError("backend failure")
yield # required so pytest treats it async generator
async def stop(self):
pass
class ErrorEventBackend:
"""Backend that emits an error event instead of raising."""
@staticmethod
def info():
class Info:
display_name = "Error Event Backend"
return Info()
def __init__(self, settings):
pass
async def run(self, *args, **kwargs):
yield AgentEvent(type="error", content="backend error")
async def stop(self):
pass
class StreamingBackend:
"""Backend that emits multiple streaming events before done."""
@staticmethod
def info():
class Info:
display_name = "Streaming Backend"
return Info()
def __init__(self, settings):
pass
async def run(self, *args, **kwargs):
yield AgentEvent(type="message", content="chunk1")
yield AgentEvent(type="message", content="chunk2")
yield AgentEvent(type="message", content="chunk3")
yield AgentEvent(type="done", content="")
async def stop(self):
pass
class SuccessBackend:
"""Backend that always succeeds."""
@staticmethod
def info():
class Info:
display_name = "Success Backend"
return Info()
def __init__(self, settings):
pass
async def run(self, *args, **kwargs):
yield AgentEvent(type="message", content="fallback worked")
yield AgentEvent(type="done", content="")
async def stop(self):
pass
@pytest.mark.asyncio
async def test_router_fallback_success(monkeypatch):
"""Primary backend fails → fallback backend succeeds."""
from pocketpaw.agents import registry
monkeypatch.setitem(
registry._BACKEND_REGISTRY,
"failing_backend",
("tests.test_router_fallback", "FailingBackend"),
)
monkeypatch.setitem(
registry._BACKEND_REGISTRY,
"success_backend",
("tests.test_router_fallback", "SuccessBackend"),
)
settings = Settings(
agent_backend="failing_backend",
fallback_backends=["success_backend"],
)
router = AgentRouter(settings)
events = []
async for event in router.run("hello"):
events.append(event)
assert any(e.content == "fallback worked" for e in events)
@pytest.mark.asyncio
async def test_router_error_event_fallback(monkeypatch):
"""Primary backend emits error event → fallback backend succeeds."""
from pocketpaw.agents import registry
monkeypatch.setitem(
registry._BACKEND_REGISTRY,
"error_backend",
("tests.test_router_fallback", "ErrorEventBackend"),
)
monkeypatch.setitem(
registry._BACKEND_REGISTRY,
"success_backend",
("tests.test_router_fallback", "SuccessBackend"),
)
settings = Settings(
agent_backend="error_backend",
fallback_backends=["success_backend"],
)
router = AgentRouter(settings)
events = []
async for event in router.run("hello"):
events.append(event)
assert any(e.content == "fallback worked" for e in events)
@pytest.mark.asyncio
async def test_router_all_backends_fail(monkeypatch):
"""Primary and fallback both fail → router returns error."""
from pocketpaw.agents import registry
monkeypatch.setitem(
registry._BACKEND_REGISTRY,
"fail_backend",
("tests.test_router_fallback", "FailingBackend"),
)
settings = Settings(
agent_backend="fail_backend",
fallback_backends=["fail_backend"],
)
router = AgentRouter(settings)
events = []
async for event in router.run("hello"):
events.append(event)
assert any(e.type == "error" for e in events)
@pytest.mark.asyncio
async def test_router_streaming_happy_path(monkeypatch):
"""Router should stream multiple events from backend without triggering fallback."""
from pocketpaw.agents import registry
monkeypatch.setitem(
registry._BACKEND_REGISTRY,
"stream_backend",
("tests.test_router_fallback", "StreamingBackend"),
)
settings = Settings(agent_backend="stream_backend")
router = AgentRouter(settings)
events = []
async for event in router.run("hello"):
events.append(event)
contents = [e.content for e in events if e.type == "message"]
assert contents == ["chunk1", "chunk2", "chunk3"]
@pytest.mark.asyncio
async def test_router_no_fallback(monkeypatch):
"""Primary backend fails and no fallback configured."""
from pocketpaw.agents import registry
monkeypatch.setitem(
registry._BACKEND_REGISTRY,
"fail_backend",
("tests.test_router_fallback", "FailingBackend"),
)
settings = Settings(agent_backend="fail_backend")
router = AgentRouter(settings)
events = []
async for event in router.run("hello"):
events.append(event)
assert any(e.type == "error" for e in events)

View File

@@ -82,7 +82,7 @@ class TestSoulIntegration:
_reset_manager()
async def test_soul_tools_injected_into_tool_bridge(self, tmp_path):
"""When soul is active, tool_bridge discovers soul tools."""
"""When soul is active, tool_bridge discovers all soul tools."""
from pocketpaw.config import Settings
from pocketpaw.soul.manager import SoulManager, _reset_manager
@@ -104,6 +104,8 @@ class TestSoulIntegration:
assert "soul_recall" in tool_names
assert "soul_edit_core" in tool_names
assert "soul_status" in tool_names
assert "soul_evaluate" in tool_names
assert "soul_reload" in tool_names
_reset_manager()

View File

@@ -70,15 +70,22 @@ class TestSoulManager:
await mgr.initialize()
await mgr.observe("Hello", "Hi there!")
async def test_get_tools_returns_four(self, soul_settings):
async def test_get_tools_returns_six(self, soul_settings):
from pocketpaw.soul.manager import SoulManager
mgr = SoulManager(soul_settings)
await mgr.initialize()
tools = mgr.get_tools()
assert len(tools) == 4
assert len(tools) == 6
names = {t.name for t in tools}
assert names == {"soul_remember", "soul_recall", "soul_edit_core", "soul_status"}
assert names == {
"soul_remember",
"soul_recall",
"soul_edit_core",
"soul_status",
"soul_evaluate",
"soul_reload",
}
async def test_corrupt_soul_file_falls_back_to_birth(self, soul_settings, tmp_path):
from pocketpaw.soul.manager import SoulManager
@@ -238,3 +245,122 @@ class TestSoulManager:
with pytest.raises(FileNotFoundError):
await mgr.import_from_file(tmp_path / "nonexistent.soul")
async def test_reload_from_disk(self, soul_settings, tmp_path):
"""Reload picks up changes from disk."""
from pocketpaw.soul.manager import SoulManager
mgr = SoulManager(soul_settings)
await mgr.initialize()
await mgr.save()
# Reload should succeed when file exists
result = await mgr.reload()
assert result is True
assert mgr.soul is not None
async def test_reload_returns_false_when_no_file(self, soul_settings, tmp_path):
"""Reload returns False when .soul file doesn't exist."""
from pocketpaw.soul.manager import SoulManager
mgr = SoulManager(soul_settings)
await mgr.initialize()
# Don't save, so no file on disk yet -- remove any that initialize may have created
soul_file = tmp_path / "test.soul"
soul_file.unlink(missing_ok=True)
result = await mgr.reload()
assert result is False
async def test_evaluate_returns_none_when_unsupported(self, soul_settings):
"""Evaluate returns None when soul doesn't have evaluate method."""
from pocketpaw.soul.manager import SoulManager
mgr = SoulManager(soul_settings)
await mgr.initialize()
# If soul-protocol < 0.2.4, evaluate() won't exist
result = await mgr.evaluate("hello", "hi there")
# Result is either None (no method) or a dict (method exists)
assert result is None or isinstance(result, dict)
async def test_dirty_tracking(self, soul_settings):
"""Dirty flag is set after observe and cleared after save."""
from pocketpaw.soul.manager import SoulManager
mgr = SoulManager(soul_settings)
await mgr.initialize()
assert mgr._dirty is False
await mgr.observe("hello", "world")
assert mgr._dirty is True
await mgr.save()
assert mgr._dirty is False
async def test_tools_are_cached(self, soul_settings):
"""get_tools() returns the same list on repeated calls."""
from pocketpaw.soul.manager import SoulManager
mgr = SoulManager(soul_settings)
await mgr.initialize()
tools1 = mgr.get_tools()
tools2 = mgr.get_tools()
assert tools1 is tools2
async def test_tools_cache_invalidated_on_import(self, soul_settings, tmp_path):
"""Importing a soul invalidates the tools cache."""
from soul_protocol import Soul
from pocketpaw.soul.manager import SoulManager
mgr = SoulManager(soul_settings)
await mgr.initialize()
tools1 = mgr.get_tools()
donor = await Soul.birth(name="CacheTest", persona="Testing cache invalidation.")
donor_path = tmp_path / "donor.soul"
await donor.export(donor_path)
await mgr.import_from_file(donor_path)
tools2 = mgr.get_tools()
assert tools1 is not tools2
async def test_external_file_change_detection(self, soul_settings, tmp_path):
"""_file_changed_externally detects mtime changes."""
import os
import time
from pocketpaw.soul.manager import SoulManager
mgr = SoulManager(soul_settings)
await mgr.initialize()
await mgr.save()
assert mgr._file_changed_externally() is False
# Simulate external modification by touching the file with a future mtime
soul_file = tmp_path / "test.soul"
time.sleep(0.05)
current = soul_file.stat().st_mtime
os.utime(soul_file, (current + 1, current + 1))
assert mgr._file_changed_externally() is True
async def test_biorhythm_settings_passed(self, tmp_path):
"""Biorhythm config is included in settings."""
from pocketpaw.config import Settings
settings = Settings(
soul_enabled=True,
soul_name="BioTest",
soul_path=str(tmp_path / "bio.soul"),
soul_auto_save_interval=0,
soul_biorhythm={
"energy_drain_rate": 0.05,
"mood_inertia": 0.9,
"tired_threshold": 0.2,
"auto_regen": 0.02,
},
)
assert settings.soul_biorhythm["energy_drain_rate"] == 0.05
assert settings.soul_biorhythm["tired_threshold"] == 0.2

View File

@@ -0,0 +1,254 @@
"""Smoke tests for soul-protocol v0.2.4 features.
Covers: reload endpoint, evaluate endpoint, biorhythm config round-trip,
auto-save dirty skip, auto-sync external change detection, and fatigue hint.
"""
import os
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _has_soul_protocol() -> bool:
try:
import soul_protocol # noqa: F401
return True
except ImportError:
return False
pytestmark = pytest.mark.skipif(not _has_soul_protocol(), reason="soul-protocol not installed")
@pytest.fixture(autouse=True)
def _reset_soul():
from pocketpaw.soul.manager import _reset_manager
_reset_manager()
yield
_reset_manager()
@pytest.fixture
def soul_settings(tmp_path):
from pocketpaw.config import Settings
return Settings(
soul_enabled=True,
soul_name="SmokeTest",
soul_archetype="The Tester",
soul_path=str(tmp_path / "smoke.soul"),
soul_auto_save_interval=0,
soul_biorhythm={
"energy_drain_rate": 0.05,
"mood_inertia": 0.9,
"tired_threshold": 0.2,
"auto_regen": 0.02,
},
)
@pytest.fixture
async def manager(soul_settings):
from pocketpaw.soul.manager import SoulManager
mgr = SoulManager(soul_settings)
await mgr.initialize()
return mgr
# ── POST /soul/reload ────────────────────────────────────────────────
class TestReload:
async def test_reload_returns_updated_name(self, manager):
"""Reload picks up the soul from disk and returns its name."""
await manager.save()
result = await manager.reload()
assert result is True
assert manager.soul is not None
assert manager.soul.name == "SmokeTest"
async def test_reload_invalidates_tools_cache(self, manager):
"""Reload clears the cached tools list."""
tools1 = manager.get_tools()
assert tools1 is manager.get_tools() # cached
await manager.save()
await manager.reload()
tools2 = manager.get_tools()
assert tools1 is not tools2
async def test_reload_updates_bridge_and_provider(self, manager):
"""Reload re-wires bridge and bootstrap provider to the new soul."""
old_soul = manager.soul
await manager.save()
await manager.reload()
assert manager.soul is not old_soul
assert manager.bridge._soul is manager.soul
assert manager.bootstrap_provider._soul is manager.soul
# ── POST /soul/evaluate ──────────────────────────────────────────────
class TestEvaluate:
async def test_evaluate_returns_dict_or_none(self, manager):
"""Evaluate returns a scores dict (v0.2.4+) or None (older versions)."""
result = await manager.evaluate("What is Python?", "Python is a programming language.")
assert result is None or isinstance(result, dict)
async def test_evaluate_returns_none_without_soul(self, soul_settings):
from pocketpaw.soul.manager import SoulManager
mgr = SoulManager(soul_settings)
# Don't initialize -- soul is None
result = await mgr.evaluate("hello", "world")
assert result is None
# ── Biorhythm config ─────────────────────────────────────────────────
class TestBiorhythm:
def test_biorhythm_settings_stored(self, soul_settings):
"""Biorhythm config values are accessible from settings."""
assert soul_settings.soul_biorhythm["energy_drain_rate"] == 0.05
assert soul_settings.soul_biorhythm["tired_threshold"] == 0.2
assert soul_settings.soul_biorhythm["mood_inertia"] == 0.9
assert soul_settings.soul_biorhythm["auto_regen"] == 0.02
def test_biorhythm_defaults(self):
from pocketpaw.config import Settings
s = Settings()
bio = s.soul_biorhythm
assert bio["energy_drain_rate"] == 0.02
assert bio["mood_inertia"] == 0.8
assert bio["tired_threshold"] == 0.3
assert bio["auto_regen"] == 0.01
def test_biorhythm_dashboard_validation(self):
"""Biorhythm values are clamped to 0-1 range."""
raw = {"energy_drain_rate": 1.5, "mood_inertia": -0.3, "bad_key": 0.5}
allowed = {"energy_drain_rate", "mood_inertia", "tired_threshold", "auto_regen"}
clean = {}
for k, v in raw.items():
if k in allowed and isinstance(v, int | float):
clean[k] = float(max(0.0, min(1.0, v)))
assert clean["energy_drain_rate"] == 1.0 # clamped
assert clean["mood_inertia"] == 0.0 # clamped
assert "bad_key" not in clean # filtered
# ── Auto-save dirty skip ─────────────────────────────────────────────
class TestDirtyTracking:
async def test_clean_after_init(self, manager):
assert manager._dirty is False
async def test_dirty_after_observe(self, manager):
await manager.observe("hello", "world")
assert manager._dirty is True
async def test_clean_after_save(self, manager):
await manager.observe("hello", "world")
await manager.save()
assert manager._dirty is False
async def test_auto_save_skips_when_clean(self, manager):
"""Auto-save loop should not write when there are no changes."""
await manager.save()
assert manager._dirty is False
with patch.object(manager, "save", new_callable=AsyncMock) as mock_save:
# Simulate one iteration of the auto-save logic
if manager._dirty:
await manager.save()
mock_save.assert_not_called()
async def test_auto_save_writes_when_dirty(self, manager):
"""Auto-save loop should write when there are unsaved changes."""
await manager.observe("test", "response")
assert manager._dirty is True
with patch.object(manager, "save", new_callable=AsyncMock) as mock_save:
if manager._dirty:
await manager.save()
mock_save.assert_called_once()
# ── Auto-sync external changes ───────────────────────────────────────
class TestAutoSync:
async def test_no_external_change_detected(self, manager):
await manager.save()
assert manager._file_changed_externally() is False
async def test_external_change_detected(self, manager, tmp_path):
await manager.save()
soul_file = tmp_path / "smoke.soul"
assert soul_file.exists()
# Simulate external edit by bumping mtime
time.sleep(0.05)
current = soul_file.stat().st_mtime
os.utime(soul_file, (current + 1, current + 1))
assert manager._file_changed_externally() is True
async def test_reload_clears_external_change(self, manager, tmp_path):
await manager.save()
soul_file = tmp_path / "smoke.soul"
time.sleep(0.05)
current = soul_file.stat().st_mtime
os.utime(soul_file, (current + 1, current + 1))
assert manager._file_changed_externally() is True
await manager.reload()
assert manager._file_changed_externally() is False
# ── Bootstrap fatigue hint ────────────────────────────────────────────
class TestFatigueHint:
async def test_fatigue_hint_when_energy_low(self):
"""Bootstrap context includes fatigue hint when energy <= tired_threshold."""
from pocketpaw.paw.soul_bridge import SoulBootstrapProvider
soul = MagicMock()
soul.name = "TiredSoul"
soul.to_system_prompt.return_value = "I am tired."
soul.state = MagicMock(mood="calm", energy=0.1, tired_threshold=0.3)
soul.self_model = None
provider = SoulBootstrapProvider(soul)
ctx = await provider.get_context()
assert "fatigued" in ctx.style.lower()
async def test_no_fatigue_hint_when_energy_high(self):
"""Bootstrap context does not include fatigue hint when energy is fine."""
from pocketpaw.paw.soul_bridge import SoulBootstrapProvider
soul = MagicMock()
soul.name = "EnergySoul"
soul.to_system_prompt.return_value = "I am energized."
soul.state = MagicMock(mood="happy", energy=0.8, tired_threshold=0.3)
soul.self_model = None
provider = SoulBootstrapProvider(soul)
ctx = await provider.get_context()
assert "fatigued" not in ctx.style.lower()

80
tests/test_vectordb.py Normal file
View File

@@ -0,0 +1,80 @@
import pytest
from pocketpaw.vectordb.chroma_adapter import ChromaAdapter
# 1. Add skip if chromadb missing (at the top of the file)
chromadb = pytest.importorskip("chromadb")
@pytest.fixture
def adapter(tmp_path):
"""Fixture to provide a fresh ChromaAdapter for each test using isolated temp paths."""
# We use tmp_path / "test_db" to ensure each test has a clean database
return ChromaAdapter(path=str(tmp_path / "test_db"))
@pytest.mark.asyncio
async def test_add_and_search(adapter):
await adapter.add("1", "User likes python")
results = await adapter.search("python")
assert "User likes python" in results
# 2. Add more tests as requested by maintainer
@pytest.mark.asyncio
async def test_delete(adapter):
await adapter.add("2", "To be deleted")
await adapter.delete("2")
result = await adapter.get_by_id("2")
assert result is None
@pytest.mark.asyncio
async def test_get_by_id(adapter):
await adapter.add("3", "Specific content")
result = await adapter.get_by_id("3")
assert result == "Specific content"
# Test getting non-existent ID (Safety check)
assert await adapter.get_by_id("999") is None
@pytest.mark.asyncio
async def test_search_no_results(adapter):
"""
Verifies search handles small collections gracefully.
ChromaDB requires n_results > 0, so we test with n_results=1.
"""
# Seed the collection so it isn't empty (prevents NotEnoughElements error)
await adapter.add("initial_doc", "The quick brown fox jumps over the lazy dog")
# Search for something completely unrelated.
# Note: Vector search always returns the 'closest' match, but we
# are verifying the plumbing doesn't crash.
results = await adapter.search("quantum computing in space", limit=1)
assert len(results) <= 1
# We just want to ensure the code executes and returns a list
assert isinstance(results, list)
@pytest.mark.asyncio
async def test_duplicate_ids(adapter):
# This verifies our 'upsert' fix works
await adapter.add("dup", "First version")
await adapter.add("dup", "Updated version")
result = await adapter.get_by_id("dup")
assert result == "Updated version"
@pytest.mark.asyncio
async def test_metadata_support(adapter):
"""Verifies that the adapter correctly handles optional metadata."""
metadata = {"source": "test_file", "priority": "high"}
await adapter.add("meta1", "Contextual info", metadata=metadata)
result = await adapter.get_by_id("meta1")
assert result == "Contextual info"