mirror of
https://github.com/pocketpaw/pocketpaw.git
synced 2026-05-13 21:21:53 +00:00
Merge branch 'dev' into feat/deep-agents-backend
This commit is contained in:
11
.env.example
11
.env.example
@@ -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
11
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
273
docs/deployment/discord-docker.mdx
Normal file
273
docs/deployment/discord-docker.mdx
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
3
src/pocketpaw/vectordb/__init__.py
Normal file
3
src/pocketpaw/vectordb/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .protocol import VectorStoreProtocol
|
||||
|
||||
__all__ = ["VectorStoreProtocol"]
|
||||
83
src/pocketpaw/vectordb/chroma_adapter.py
Normal file
83
src/pocketpaw/vectordb/chroma_adapter.py
Normal 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
|
||||
32
src/pocketpaw/vectordb/protocol.py
Normal file
32
src/pocketpaw/vectordb/protocol.py
Normal 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."""
|
||||
...
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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})
|
||||
|
||||
101
tests/test_fetch_security.py
Normal file
101
tests/test_fetch_security.py
Normal 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))
|
||||
@@ -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(
|
||||
|
||||
227
tests/test_router_fallback.py
Normal file
227
tests/test_router_fallback.py
Normal 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)
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
254
tests/test_soul_v024_smoke.py
Normal file
254
tests/test_soul_v024_smoke.py
Normal 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
80
tests/test_vectordb.py
Normal 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"
|
||||
Reference in New Issue
Block a user