mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-07-28 01:27:58 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49183d7e8d | |||
| e14f8daecb | |||
| 85dcfccad6 | |||
| 9a0efd7bab | |||
| 0132837d22 | |||
| a680d8fcfa | |||
| c87b154b61 | |||
| 994c0aea11 | |||
| b6e6d25d75 | |||
| 8f891d5dd0 | |||
| 0f4a997b47 | |||
| 79c075fba0 | |||
| ffffb6a0cb | |||
| c4fb7a2001 | |||
| 4c5adcd78e | |||
| cf24f32896 | |||
| 4ccb6268a4 | |||
| 1d9b1c444a | |||
| 0aff1bd7ab | |||
| 1a6dd0efe5 | |||
| 4b02293516 | |||
| 883d43b37d | |||
| 3b24a48a8c | |||
| 027226997f | |||
| ce5274d179 | |||
| 79ae6bf97f | |||
| 93a58e057e | |||
| 70b9cb9ea0 | |||
| 8fd07bcacb | |||
| e56ab1f16e | |||
| addaef78ad | |||
| ba881f8273 | |||
| 612e485d4e | |||
| 9068fde274 | |||
| ce4a6c9bba | |||
| c3aa8c088c | |||
| acc2c5c6aa | |||
| ff247b63ac | |||
| 0c404869ae | |||
| 263e940825 | |||
| 9721c36e55 | |||
| 1758eea948 | |||
| f5b2ce7482 | |||
| 46ffda264f | |||
| 88bda73db6 | |||
| 29e019ec66 | |||
| cc7b4ca86b | |||
| 697e94fe8c | |||
| 052c742fe7 |
@@ -20,17 +20,6 @@
|
|||||||
|
|
||||||
[中文](docs/project/README.zh.md) | [日本語](docs/project/README.ja.md) | [한국어](docs/project/README.ko.md) | [Português](docs/project/README.pt-br.md) | [Tiếng Việt](docs/project/README.vi.md) | [Français](docs/project/README.fr.md) | [Italiano](docs/project/README.it.md) | [Bahasa Indonesia](docs/project/README.id.md) | [Malay](docs/project/README.ms.md) | **English**
|
[中文](docs/project/README.zh.md) | [日本語](docs/project/README.ja.md) | [한국어](docs/project/README.ko.md) | [Português](docs/project/README.pt-br.md) | [Tiếng Việt](docs/project/README.vi.md) | [Français](docs/project/README.fr.md) | [Italiano](docs/project/README.it.md) | [Bahasa Indonesia](docs/project/README.id.md) | [Malay](docs/project/README.ms.md) | **English**
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://picopaw.ai">
|
|
||||||
<img src="assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
|
||||||
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -60,15 +49,13 @@
|
|||||||
> **Security Notice**
|
> **Security Notice**
|
||||||
>
|
>
|
||||||
> * **NO CRYPTO:** PicoClaw has **not** issued any official tokens or cryptocurrency. All claims on `pump.fun` or other trading platforms are **scams**.
|
> * **NO CRYPTO:** PicoClaw has **not** issued any official tokens or cryptocurrency. All claims on `pump.fun` or other trading platforms are **scams**.
|
||||||
> * **OFFICIAL DOMAIN:** The **ONLY** official PicoClaw website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
||||||
> * **BEWARE:** Many lookalike `.ai/.org/.com/.net/...` domains have been registered by third parties. Only trust domains explicitly linked from this README.
|
> * **BEWARE:** Many `.ai/.org/.com/.net/...` domains have been registered by third parties. Do not trust them.
|
||||||
> * **NOTE:** PicoClaw is in early rapid development. There may be unresolved security issues. Do not deploy to production before v1.0.
|
> * **NOTE:** PicoClaw is in early rapid development. There may be unresolved security issues. Do not deploy to production before v1.0.
|
||||||
> * **NOTE:** PicoClaw has recently merged many PRs. Recent builds may use 10-20MB RAM. Resource optimization is planned after feature stabilization.
|
> * **NOTE:** PicoClaw has recently merged many PRs. Recent builds may use 10-20MB RAM. Resource optimization is planned after feature stabilization.
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw on AliExpress!** You can now purchase LicheeRV-Claw from [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), making it easier to try PicoClaw on compact RISC-V hardware.
|
2026-05-11 🛒 **LicheeRV-Claw on AliExpress!** You can now purchase LicheeRV-Claw from [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), making it easier to try PicoClaw on compact RISC-V hardware.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -494,6 +481,7 @@ Talk to your PicoClaw through 19+ messaging platforms:
|
|||||||
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
|
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
|
||||||
| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) |
|
| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) |
|
||||||
| **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) |
|
| **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) |
|
||||||
|
| **Delta Chat** | Easy (account script or email/password) | JSON-RPC (email/E2EE) | [Guide](docs/channels/deltachat/README.md) |
|
||||||
| **DingTalk** | Medium (client credentials) | Stream | [Guide](docs/channels/dingtalk/README.md) |
|
| **DingTalk** | Medium (client credentials) | Stream | [Guide](docs/channels/dingtalk/README.md) |
|
||||||
| **Feishu / Lark** | Medium (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.md) |
|
| **Feishu / Lark** | Medium (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.md) |
|
||||||
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
|
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 188 KiB |
@@ -0,0 +1,148 @@
|
|||||||
|
> Back to [README](../../../README.md)
|
||||||
|
|
||||||
|
# Delta Chat Channel
|
||||||
|
|
||||||
|
PicoClaw can run as a Delta Chat bot by launching a local
|
||||||
|
`deltachat-rpc-server` process and talking to it over JSON-RPC. The RPC server
|
||||||
|
handles the email account, IMAP/SMTP connection, message store, and encryption
|
||||||
|
keys.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Install the Delta Chat RPC server. If `deltachat-rpc-server` is on `PATH`,
|
||||||
|
PicoClaw can find it automatically; otherwise set `rpc_server_path` to the
|
||||||
|
exact binary path.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install deltachat-rpc-server
|
||||||
|
which deltachat-rpc-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Prebuilt binaries are also available from the
|
||||||
|
[Delta Chat core releases](https://github.com/deltachat/deltachat-core-rust/releases).
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
|
||||||
|
The easiest setup is to let PicoClaw create a chatmail account in Delta
|
||||||
|
Chat's local account store. Put a relay marker in `email` using an empty local
|
||||||
|
part, for example `@nine.testrun.org`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channel_list": {
|
||||||
|
"deltachat": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "deltachat",
|
||||||
|
"allow_from": ["friend@example.org"],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"email": "@nine.testrun.org",
|
||||||
|
"display_name": "PicoClaw Bot",
|
||||||
|
"avatar_image": "/home/me/bot-avatar.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
On startup, PicoClaw creates the account through `deltachat-rpc-server`, then
|
||||||
|
stops with an error that contains the generated address. Replace the relay
|
||||||
|
marker with that full email address and run PicoClaw again:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "bot123@nine.testrun.org",
|
||||||
|
"display_name": "PicoClaw Bot",
|
||||||
|
"avatar_image": "/home/me/bot-avatar.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `email` is missing, the startup error lists the built-in relay choices copied
|
||||||
|
from Parla. You can use one of those relay markers, or a custom chatmail relay
|
||||||
|
with the same `@server.name` form.
|
||||||
|
|
||||||
|
`password` is not needed for PicoClaw-created chatmail accounts. Omit it when
|
||||||
|
`email` points to an already configured account in `data_dir`; the JSON-RPC
|
||||||
|
server owns the mailbox password. The legacy password-based path remains only
|
||||||
|
for classic email accounts that PicoClaw must configure itself. In that mode,
|
||||||
|
`password` is a secure field; on first config load it is moved to
|
||||||
|
`~/.picoclaw/.security.yml`, and it can also be set with
|
||||||
|
`PICOCLAW_CHANNELS_DELTACHAT_PASSWORD`.
|
||||||
|
|
||||||
|
`display_name` and `avatar_image` are optional profile settings. When present,
|
||||||
|
PicoClaw applies them on every startup, so changing the avatar path in config is
|
||||||
|
enough to update the bot profile.
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `email` | Yes | Full bot mailbox address, or first-run relay marker such as `@nine.testrun.org` |
|
||||||
|
| `rpc_server_path` | No | Path to `deltachat-rpc-server`; only needed when it is not on `PATH` |
|
||||||
|
| `password` | No | Legacy only; required when PicoClaw must configure/reconfigure a classic mailbox itself |
|
||||||
|
| `display_name` | No | Startup-applied profile name shown to contacts and used for group mention detection |
|
||||||
|
| `avatar_image` | No | Startup-applied profile avatar image path; `~` is expanded. Missing files are warned and ignored |
|
||||||
|
| `data_dir` | No | Account database directory. Default: `~/.picoclaw/deltachat/<channel-name>` |
|
||||||
|
| `invite_link` | No | Delta Chat invite link to join on startup |
|
||||||
|
| `allow_crosspost` | No | Default `false`. When `true`, senders allowed by `allow_from` may use `message` tool targets outside the current chat, or resolve recipients by email/contact/chat name |
|
||||||
|
| `imap_server`, `imap_port` | No | Manual IMAP override for password-based configuration |
|
||||||
|
| `smtp_server`, `smtp_port` | No | Manual SMTP override for password-based configuration |
|
||||||
|
|
||||||
|
Standard channel fields such as `allow_from`, `group_trigger`, and
|
||||||
|
`reasoning_channel_id` also apply.
|
||||||
|
|
||||||
|
## First Run
|
||||||
|
|
||||||
|
With `email` set to `@server`, PicoClaw creates the chatmail account, prints
|
||||||
|
the generated full email in the startup error, and exits. Update `email` to that
|
||||||
|
full address and run PicoClaw again. On later runs, PicoClaw selects the
|
||||||
|
configured account by `email`, applies optional profile settings, marks it as a
|
||||||
|
bot, and starts IO.
|
||||||
|
|
||||||
|
With a new `data_dir` plus legacy `password`, PicoClaw can still configure a
|
||||||
|
classic email account and validate the mailbox credentials; after that, the
|
||||||
|
account is reused from the local data directory.
|
||||||
|
|
||||||
|
Delta Chat requires peers to learn the bot's encryption key before messaging
|
||||||
|
it. On startup PicoClaw prints the bot invite link and QR code. Add the bot from
|
||||||
|
Delta Chat with that invite, not by typing the bare email address.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- Direct chats always respond after `allow_from` passes.
|
||||||
|
- Group chats follow `group_trigger`; without one, every group message is
|
||||||
|
handled.
|
||||||
|
- Messages from the bot itself, device chats, and info/system messages are
|
||||||
|
ignored.
|
||||||
|
- Accepted inbound messages are marked seen after the allow-list check.
|
||||||
|
- Incoming attachments (images, audio, video, documents) are registered with
|
||||||
|
the media store and handed to the agent, so it can view images or operate on
|
||||||
|
the files directly. If no media store is available, the path is appended
|
||||||
|
inline as `[attachment: /path]` instead.
|
||||||
|
- Outbound attachments are supported: when the agent emits media, each file is
|
||||||
|
sent as a Delta Chat message (with the caption as text). Delta Chat infers the
|
||||||
|
view type from the file, so images, GIFs, and videos render natively.
|
||||||
|
- Crosspost recipient lookup is disabled by default. The agent can always reply
|
||||||
|
to the current numeric chat ID, but sending to another numeric chat ID or
|
||||||
|
resolving an email/contact/chat name requires `allow_crosspost: true`
|
||||||
|
and the current sender must pass `allow_from`; `allow_from: ["*"]` allows this
|
||||||
|
for any sender.
|
||||||
|
- Voice is supported in both directions when voice providers are configured:
|
||||||
|
incoming voice notes are transcribed by the agent's ASR and the transcript is
|
||||||
|
passed to the model; the agent can reply with synthesized speech, which is
|
||||||
|
delivered as a native Delta Chat voice message (`send_tts`). This requires an
|
||||||
|
ASR and/or TTS provider under `voice` — it is not Delta Chat-specific config.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| `deltachat-rpc-server not found on PATH` or `rpc_server_path ... not found` | Install the RPC server on PATH, or set `rpc_server_path` to an absolute path |
|
||||||
|
| `email is required` | Choose one listed chatmail server, set `email` to a first-run marker such as `@nine.testrun.org`, run `picoclaw g`, then replace it with the generated full email |
|
||||||
|
| `created chatmail account ...` | Replace the `@server` marker in `email` with the generated full email and run PicoClaw again |
|
||||||
|
| `account ... is not configured in data_dir` | Point `data_dir` at the existing JSON-RPC account store, or use `email="@server"` to create one |
|
||||||
|
| `configure (check email/password/server)` | Check credentials, app password requirements, or IMAP/SMTP overrides |
|
||||||
|
| Bot does not answer in a group | Check `group_trigger`; mention `display_name` or use a configured prefix |
|
||||||
|
| Bot ignores a sender | Add the sender email to `allow_from`, or use `["*"]` for open access |
|
||||||
|
| Sender cannot message the bot | Re-add the bot with the startup QR/invite so Delta Chat can establish encryption |
|
||||||
|
| Agent cannot send to an email/name/other chat ID | Enable `settings.allow_crosspost` and allow the controlling sender in `allow_from`; this capability is disabled by default for privacy |
|
||||||
@@ -20,17 +20,6 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://picopaw.ai">
|
|
||||||
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
|
||||||
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -68,8 +57,6 @@
|
|||||||
|
|
||||||
## 📢 Actualités
|
## 📢 Actualités
|
||||||
|
|
||||||
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw disponible sur AliExpress !** Vous pouvez désormais acheter le LicheeRV-Claw sur [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), ce qui facilite l'essai de PicoClaw sur du matériel RISC-V compact.
|
2026-05-11 🛒 **LicheeRV-Claw disponible sur AliExpress !** Vous pouvez désormais acheter le LicheeRV-Claw sur [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), ce qui facilite l'essai de PicoClaw sur du matériel RISC-V compact.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,17 +20,6 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://picopaw.ai">
|
|
||||||
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
|
||||||
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -67,8 +56,6 @@
|
|||||||
|
|
||||||
## 📢 Berita
|
## 📢 Berita
|
||||||
|
|
||||||
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Kini Anda dapat membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), sehingga lebih mudah mencoba PicoClaw di hardware RISC-V ringkas.
|
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Kini Anda dapat membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), sehingga lebih mudah mencoba PicoClaw di hardware RISC-V ringkas.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,17 +20,6 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://picopaw.ai">
|
|
||||||
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
|
||||||
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -67,8 +56,6 @@
|
|||||||
|
|
||||||
## 📢 Novità
|
## 📢 Novità
|
||||||
|
|
||||||
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw disponibile su AliExpress!** Ora puoi acquistare LicheeRV-Claw su [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), rendendo più semplice provare PicoClaw su hardware RISC-V compatto.
|
2026-05-11 🛒 **LicheeRV-Claw disponibile su AliExpress!** Ora puoi acquistare LicheeRV-Claw su [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), rendendo più semplice provare PicoClaw su hardware RISC-V compatto.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,17 +20,6 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://picopaw.ai">
|
|
||||||
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
|
||||||
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -67,8 +56,6 @@
|
|||||||
|
|
||||||
## 📢 ニュース
|
## 📢 ニュース
|
||||||
|
|
||||||
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw が AliExpress で購入可能に!** [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) から LicheeRV-Claw を購入できるようになり、コンパクトな RISC-V ハードウェアで PicoClaw を試しやすくなりました。
|
2026-05-11 🛒 **LicheeRV-Claw が AliExpress で購入可能に!** [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) から LicheeRV-Claw を購入できるようになり、コンパクトな RISC-V ハードウェアで PicoClaw を試しやすくなりました。
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,17 +20,6 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | **한국어** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | **한국어** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://picopaw.ai">
|
|
||||||
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
|
||||||
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -67,8 +56,6 @@
|
|||||||
|
|
||||||
## 📢 뉴스
|
## 📢 뉴스
|
||||||
|
|
||||||
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw를 AliExpress에서 구매할 수 있습니다!** 이제 [AliExpress](https://www.aliexpress.com/item/1005006519668532.html)에서 LicheeRV-Claw를 구매해 소형 RISC-V 하드웨어에서 PicoClaw를 더 쉽게 사용해 볼 수 있습니다.
|
2026-05-11 🛒 **LicheeRV-Claw를 AliExpress에서 구매할 수 있습니다!** 이제 [AliExpress](https://www.aliexpress.com/item/1005006519668532.html)에서 LicheeRV-Claw를 구매해 소형 RISC-V 하드웨어에서 PicoClaw를 더 쉽게 사용해 볼 수 있습니다.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,17 +20,6 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](../../README.md)
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://picopaw.ai">
|
|
||||||
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
|
||||||
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -67,8 +56,6 @@
|
|||||||
|
|
||||||
## 📢 Berita
|
## 📢 Berita
|
||||||
|
|
||||||
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Anda kini boleh membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), menjadikannya lebih mudah untuk mencuba PicoClaw pada perkakasan RISC-V yang kompak.
|
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Anda kini boleh membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), menjadikannya lebih mudah untuk mencuba PicoClaw pada perkakasan RISC-V yang kompak.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,17 +20,6 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://picopaw.ai">
|
|
||||||
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
|
||||||
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -67,8 +56,6 @@
|
|||||||
|
|
||||||
## 📢 Novidades
|
## 📢 Novidades
|
||||||
|
|
||||||
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw no AliExpress!** Agora você pode comprar o LicheeRV-Claw no [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), facilitando testar o PicoClaw em hardware RISC-V compacto.
|
2026-05-11 🛒 **LicheeRV-Claw no AliExpress!** Agora você pode comprar o LicheeRV-Claw no [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), facilitando testar o PicoClaw em hardware RISC-V compacto.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,17 +20,6 @@
|
|||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://picopaw.ai">
|
|
||||||
<img src="../../assets/picopaw-banner-en.webp" alt="PicoPaw AI: Your AI Desktop Buddy" width="100%">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>PicoPaw AI is now live at <a href="https://picopaw.ai">picopaw.ai</a>.</strong><br>
|
|
||||||
Create, preview, and share playful AI companions for the PicoClaw ecosystem.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -67,8 +56,6 @@
|
|||||||
|
|
||||||
## 📢 Tin tức
|
## 📢 Tin tức
|
||||||
|
|
||||||
2026-06-11 🐾 **PicoPaw AI is live!** Explore the new PicoPaw companion experience at [picopaw.ai](https://picopaw.ai), with animated AI pet previews and ecosystem updates for PicoClaw users.
|
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw đã có trên AliExpress!** Bạn hiện có thể mua LicheeRV-Claw trên [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), giúp việc thử PicoClaw trên phần cứng RISC-V nhỏ gọn dễ dàng hơn.
|
2026-05-11 🛒 **LicheeRV-Claw đã có trên AliExpress!** Bạn hiện có thể mua LicheeRV-Claw trên [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), giúp việc thử PicoClaw trên phần cứng RISC-V nhỏ gọn dễ dàng hơn.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -20,17 +20,6 @@
|
|||||||
|
|
||||||
**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://picopaw.ai/zh">
|
|
||||||
<img src="../../assets/picopaw-banner-zh.webp" alt="PicoPaw AI:你的 AI 桌面伙伴" width="100%">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>PicoPaw AI 已在 <a href="https://picopaw.ai/zh">picopaw.ai/zh</a> 上线。</strong><br>
|
|
||||||
创建、预览并分享面向 PicoClaw 生态的 AI 桌面伙伴。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -60,15 +49,13 @@
|
|||||||
> **🚨 安全声明**
|
> **🚨 安全声明**
|
||||||
>
|
>
|
||||||
> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
|
> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
|
||||||
> - **官方域名:** 唯一的 PicoClaw 官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
|
> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
|
||||||
> - **警惕:** 许多相似的 `.ai/.org/.com/.net/...` 后缀域名被第三方抢注。请只信任本 README 明确链接的域名。
|
> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
|
||||||
> - **注意:** PicoClaw 正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在 1.0 正式版发布前,请不要将其部署到生产环境中。
|
> - **注意:** PicoClaw 正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在 1.0 正式版发布前,请不要将其部署到生产环境中。
|
||||||
> - **注意:** PicoClaw 最近合并了大量 PR,近期版本可能内存占用较大 (10~20MB),我们将在功能较为收敛后进行资源占用优化。
|
> - **注意:** PicoClaw 最近合并了大量 PR,近期版本可能内存占用较大 (10~20MB),我们将在功能较为收敛后进行资源占用优化。
|
||||||
|
|
||||||
## 📢 新闻
|
## 📢 新闻
|
||||||
|
|
||||||
2026-06-11 🐾 **PicoPaw AI 上线!** 访问 [picopaw.ai/zh](https://picopaw.ai/zh),体验全新的 PicoPaw 桌面伙伴,预览会动的 AI 宠物,并关注 PicoClaw 生态更新。
|
|
||||||
|
|
||||||
2026-05-11 🛒 **LicheeRV-Claw 已上架淘宝!** 现在可以在 [淘宝](https://item.taobao.com/item.htm?abbucket=20&id=764939520376) 购买 LicheeRV-Claw,更方便地在小型 RISC-V 硬件上体验 PicoClaw。
|
2026-05-11 🛒 **LicheeRV-Claw 已上架淘宝!** 现在可以在 [淘宝](https://item.taobao.com/item.htm?abbucket=20&id=764939520376) 购买 LicheeRV-Claw,更方便地在小型 RISC-V 硬件上体验 PicoClaw。
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/sipeed/picoclaw
|
module github.com/sipeed/picoclaw
|
||||||
|
|
||||||
go 1.25.11
|
go 1.25.12
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fyne.io/systray v1.12.2
|
fyne.io/systray v1.12.2
|
||||||
@@ -8,7 +8,7 @@ require (
|
|||||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0
|
||||||
github.com/SevereCloud/vksdk/v3 v3.3.1
|
github.com/SevereCloud/vksdk/v3 v3.3.1
|
||||||
github.com/adhocore/gronx v1.20.0
|
github.com/adhocore/gronx v1.20.0
|
||||||
github.com/anthropics/anthropic-sdk-go v1.50.2
|
github.com/anthropics/anthropic-sdk-go v1.55.1
|
||||||
github.com/atc0005/go-teams-notify/v2 v2.14.0
|
github.com/atc0005/go-teams-notify/v2 v2.14.0
|
||||||
github.com/aws/aws-sdk-go-v2 v1.42.0
|
github.com/aws/aws-sdk-go-v2 v1.42.0
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.25
|
github.com/aws/aws-sdk-go-v2/config v1.32.25
|
||||||
@@ -119,7 +119,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
|
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.39.0 // indirect
|
||||||
modernc.org/libc v1.73.4 // indirect
|
modernc.org/libc v1.73.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
@@ -148,7 +148,7 @@ require (
|
|||||||
github.com/valyala/fastjson v1.6.10 // indirect
|
github.com/valyala/fastjson v1.6.10 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
golang.org/x/crypto v0.51.0
|
golang.org/x/crypto v0.53.0
|
||||||
golang.org/x/net v0.55.0
|
golang.org/x/net v0.55.0
|
||||||
golang.org/x/sync v0.21.0
|
golang.org/x/sync v0.21.0
|
||||||
golang.org/x/sys v0.46.0
|
golang.org/x/sys v0.46.0
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg
|
|||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.50.2 h1:K+YJWWzeN2h5MAbh9xeUWY8yAB2oOMp2xLLAODrVBXA=
|
github.com/anthropics/anthropic-sdk-go v1.55.1 h1:GxukHUVou6AFIngxa/Aw1z79hmwg13Hmn++KE9werbM=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.50.2/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
|
github.com/anthropics/anthropic-sdk-go v1.55.1/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
|
||||||
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
|
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
|
||||||
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
|
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
|
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
|
||||||
@@ -362,16 +362,16 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP
|
|||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
@@ -438,8 +438,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -449,8 +449,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
|
|||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|||||||
@@ -333,6 +333,15 @@ func (al *AgentLoop) buildCommandsRuntime(
|
|||||||
if opts == nil {
|
if opts == nil {
|
||||||
return fmt.Errorf("process options not available")
|
return fmt.Errorf("process options not available")
|
||||||
}
|
}
|
||||||
|
// /clear can arrive before any turn has persisted session scope
|
||||||
|
// metadata (runAgentLoop records it per turn), so record it here to
|
||||||
|
// let the ContextManager resolve which agent owns the session.
|
||||||
|
ensureSessionMetadata(
|
||||||
|
agent.Sessions,
|
||||||
|
opts.Dispatch.SessionKey,
|
||||||
|
opts.Dispatch.SessionScope,
|
||||||
|
opts.Dispatch.SessionAliases,
|
||||||
|
)
|
||||||
return al.contextManager.Clear(ctx, opts.SessionKey)
|
return al.contextManager.Clear(ctx, opts.SessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,10 +100,15 @@ func resolveMediaRefs(
|
|||||||
|
|
||||||
localPath, meta, err := store.ResolveWithMeta(ref)
|
localPath, meta, err := store.ResolveWithMeta(ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{
|
fields := map[string]any{
|
||||||
"ref": ref,
|
"ref": ref,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
}
|
||||||
|
if idx < currentTurnStart {
|
||||||
|
logger.DebugCF("agent", "Skipped stale historical media ref", fields)
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "Failed to resolve media ref", fields)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3365,6 +3365,105 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_ClearCommandClearsRoutedAgentSession(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: filepath.Join(workspace, "default"),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
List: []config.AgentConfig{
|
||||||
|
{
|
||||||
|
ID: "main",
|
||||||
|
Default: true,
|
||||||
|
Workspace: filepath.Join(workspace, "main"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "support",
|
||||||
|
Workspace: filepath.Join(workspace, "support"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Dispatch: &config.DispatchConfig{
|
||||||
|
Rules: []config.DispatchRule{
|
||||||
|
{
|
||||||
|
Name: "support-dingtalk",
|
||||||
|
Agent: "support",
|
||||||
|
When: config.DispatchSelector{
|
||||||
|
Channel: "dingtalk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Session: config.SessionConfig{
|
||||||
|
Dimensions: []string{"chat"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), &countingMockProvider{response: "LLM reply"})
|
||||||
|
mainAgent, ok := al.registry.GetAgent("main")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected main agent")
|
||||||
|
}
|
||||||
|
supportAgent, ok := al.registry.GetAgent("support")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected support agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := testInboundMessage(bus.InboundMessage{
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "dingtalk",
|
||||||
|
ChatID: "chat1",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user1",
|
||||||
|
},
|
||||||
|
Content: "/clear",
|
||||||
|
})
|
||||||
|
route, routedAgent, err := al.resolveMessageRoute(msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveMessageRoute() error = %v", err)
|
||||||
|
}
|
||||||
|
if routedAgent != supportAgent {
|
||||||
|
t.Fatalf("routed agent = %s, want support", routedAgent.ID)
|
||||||
|
}
|
||||||
|
sessionKey := al.allocateRouteSession(route, msg).SessionKey
|
||||||
|
|
||||||
|
mainHistory := []providers.Message{{Role: "user", Content: "main history"}}
|
||||||
|
supportHistory := []providers.Message{{Role: "user", Content: "support history"}}
|
||||||
|
mainAgent.Sessions.SetHistory(sessionKey, mainHistory)
|
||||||
|
mainAgent.Sessions.SetSummary(sessionKey, "main summary")
|
||||||
|
supportAgent.Sessions.SetHistory(sessionKey, supportHistory)
|
||||||
|
supportAgent.Sessions.SetSummary(sessionKey, "support summary")
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if response != "Chat history cleared!" {
|
||||||
|
t.Fatalf("response = %q, want clear confirmation", response)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := supportAgent.Sessions.GetHistory(sessionKey); len(got) != 0 {
|
||||||
|
t.Fatalf("support history len = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
if got := supportAgent.Sessions.GetSummary(sessionKey); got != "" {
|
||||||
|
t.Fatalf("support summary = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := mainAgent.Sessions.GetHistory(sessionKey); len(got) != len(mainHistory) {
|
||||||
|
t.Fatalf("main history len = %d, want %d", len(got), len(mainHistory))
|
||||||
|
} else if got[0].Role != mainHistory[0].Role {
|
||||||
|
t.Fatalf("main history[0].Role = %q, want %q", got[0].Role, mainHistory[0].Role)
|
||||||
|
} else if got[0].Content != mainHistory[0].Content {
|
||||||
|
t.Fatalf("main history[0].Content = %q, want %q", got[0].Content, mainHistory[0].Content)
|
||||||
|
}
|
||||||
|
if got := mainAgent.Sessions.GetSummary(sessionKey); got != "main summary" {
|
||||||
|
t.Fatalf("main summary = %q, want %q", got, "main summary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessMessage_MCPCommandsHandledWithoutLLMCall(t *testing.T) {
|
func TestProcessMessage_MCPCommandsHandledWithoutLLMCall(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ func (m *legacyContextManager) Ingest(_ context.Context, _ *IngestRequest) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *legacyContextManager) Clear(_ context.Context, sessionKey string) error {
|
func (m *legacyContextManager) Clear(_ context.Context, sessionKey string) error {
|
||||||
agent := m.al.registry.GetDefaultAgent()
|
// Routed (non-default) agents keep history in their own session store,
|
||||||
|
// so resolve the owning agent instead of assuming the default one.
|
||||||
|
agent := m.al.agentForSession(sessionKey)
|
||||||
if agent == nil || agent.Sessions == nil {
|
if agent == nil || agent.Sessions == nil {
|
||||||
return fmt.Errorf("sessions not initialized")
|
return fmt.Errorf("sessions not initialized")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -658,6 +659,91 @@ func TestIngestCalledDuringTurn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClearCommandRoutedAgentCallsContextManagerClear(t *testing.T) {
|
||||||
|
cleanup := resetCMRegistry()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock := &trackingContextManager{}
|
||||||
|
factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
||||||
|
return mock, nil
|
||||||
|
}
|
||||||
|
if err := RegisterContextManager("clear_track_cm", factory); err != nil {
|
||||||
|
t.Fatalf("register failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: filepath.Join(workspace, "default"),
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
ContextManager: "clear_track_cm",
|
||||||
|
},
|
||||||
|
List: []config.AgentConfig{
|
||||||
|
{
|
||||||
|
ID: "main",
|
||||||
|
Default: true,
|
||||||
|
Workspace: filepath.Join(workspace, "main"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "support",
|
||||||
|
Workspace: filepath.Join(workspace, "support"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Dispatch: &config.DispatchConfig{
|
||||||
|
Rules: []config.DispatchRule{
|
||||||
|
{
|
||||||
|
Name: "support-dingtalk",
|
||||||
|
Agent: "support",
|
||||||
|
When: config.DispatchSelector{
|
||||||
|
Channel: "dingtalk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Session: config.SessionConfig{
|
||||||
|
Dimensions: []string{"chat"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), &simpleMockProvider{response: "done"})
|
||||||
|
if al.contextManager != mock {
|
||||||
|
t.Fatalf("expected mock context manager, got %T", al.contextManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := testInboundMessage(bus.InboundMessage{
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "dingtalk",
|
||||||
|
ChatID: "chat1",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user1",
|
||||||
|
},
|
||||||
|
Content: "/clear",
|
||||||
|
})
|
||||||
|
route, _, err := al.resolveMessageRoute(msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveMessageRoute() error = %v", err)
|
||||||
|
}
|
||||||
|
sessionKey := al.allocateRouteSession(route, msg).SessionKey
|
||||||
|
|
||||||
|
if _, err := al.processMessage(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := mock.clearCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("Clear calls = %d, want 1", got)
|
||||||
|
}
|
||||||
|
mock.mu.Lock()
|
||||||
|
gotKey := mock.lastClearKey
|
||||||
|
mock.mu.Unlock()
|
||||||
|
if gotKey != sessionKey {
|
||||||
|
t.Fatalf("Clear session key = %q, want %q", gotKey, sessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// forceCompression edge cases (via legacy Compact)
|
// forceCompression edge cases (via legacy Compact)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -712,10 +798,12 @@ type trackingContextManager struct {
|
|||||||
assembleCalls atomic.Int64
|
assembleCalls atomic.Int64
|
||||||
compactCalls atomic.Int64
|
compactCalls atomic.Int64
|
||||||
ingestCalls atomic.Int64
|
ingestCalls atomic.Int64
|
||||||
|
clearCalls atomic.Int64
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
lastAssemble *AssembleRequest
|
lastAssemble *AssembleRequest
|
||||||
lastCompact *CompactRequest
|
lastCompact *CompactRequest
|
||||||
lastIngest *IngestRequest
|
lastIngest *IngestRequest
|
||||||
|
lastClearKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *trackingContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) {
|
func (m *trackingContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) {
|
||||||
@@ -742,7 +830,13 @@ func (m *trackingContextManager) Ingest(_ context.Context, req *IngestRequest) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *trackingContextManager) Clear(_ context.Context, _ string) error { return nil }
|
func (m *trackingContextManager) Clear(_ context.Context, sessionKey string) error {
|
||||||
|
m.clearCalls.Add(1)
|
||||||
|
m.mu.Lock()
|
||||||
|
m.lastClearKey = sessionKey
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// resetCMRegistry clears the global factory registry and returns a cleanup
|
// resetCMRegistry clears the global factory registry and returns a cleanup
|
||||||
// function that restores the original state after the test.
|
// function that restores the original state after the test.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
type seahorseContextManager struct {
|
type seahorseContextManager struct {
|
||||||
engine *seahorse.Engine
|
engine *seahorse.Engine
|
||||||
sessions session.SessionStore // for startup bootstrap
|
sessions session.SessionStore // for startup bootstrap
|
||||||
|
al *AgentLoop // for resolving the agent that owns a session
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSeahorseContextManager creates a seahorse-backed ContextManager.
|
// newSeahorseContextManager creates a seahorse-backed ContextManager.
|
||||||
@@ -47,6 +48,7 @@ func newSeahorseContextManager(_ json.RawMessage, al *AgentLoop) (ContextManager
|
|||||||
mgr := &seahorseContextManager{
|
mgr := &seahorseContextManager{
|
||||||
engine: engine,
|
engine: engine,
|
||||||
sessions: agent.Sessions,
|
sessions: agent.Sessions,
|
||||||
|
al: al,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register seahorse tools with the agent's tool registry
|
// Register seahorse tools with the agent's tool registry
|
||||||
@@ -160,10 +162,18 @@ func (m *seahorseContextManager) Clear(ctx context.Context, sessionKey string) e
|
|||||||
if err := m.engine.ClearSession(ctx, sessionKey); err != nil {
|
if err := m.engine.ClearSession(ctx, sessionKey); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if m.sessions != nil {
|
// The session may belong to a routed (non-default) agent whose JSONL
|
||||||
m.sessions.SetHistory(sessionKey, []providers.Message{})
|
// store differs from the bootstrap store, so clear the owner's store.
|
||||||
m.sessions.SetSummary(sessionKey, "")
|
sessions := m.sessions
|
||||||
return m.sessions.Save(sessionKey)
|
if m.al != nil {
|
||||||
|
if agent := m.al.agentForSession(sessionKey); agent != nil && agent.Sessions != nil {
|
||||||
|
sessions = agent.Sessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sessions != nil {
|
||||||
|
sessions.SetHistory(sessionKey, []providers.Message{})
|
||||||
|
sessions.SetSummary(sessionKey, "")
|
||||||
|
return sessions.Save(sessionKey)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-8
@@ -103,8 +103,25 @@ func NewAgentInstance(
|
|||||||
toolsRegistry.Register(tools.NewReadFileBytesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
|
toolsRegistry.Register(tools.NewReadFileBytesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("edit_file") {
|
||||||
|
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("append_file") {
|
||||||
|
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
// Build write_file's copy from the registered editors so it steers the agent
|
||||||
|
// to edit_file/append_file only when those tools are actually available.
|
||||||
if cfg.Tools.IsToolEnabled("write_file") {
|
if cfg.Tools.IsToolEnabled("write_file") {
|
||||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
writeTool := tools.NewWriteFileTool(workspace, restrict, allowWritePaths)
|
||||||
|
var altTools []string
|
||||||
|
if toolsRegistry.HasRegistered("append_file") {
|
||||||
|
altTools = append(altTools, "append_file")
|
||||||
|
}
|
||||||
|
if toolsRegistry.HasRegistered("edit_file") {
|
||||||
|
altTools = append(altTools, "edit_file")
|
||||||
|
}
|
||||||
|
writeTool.SetAlternativeTools(altTools)
|
||||||
|
toolsRegistry.Register(writeTool)
|
||||||
}
|
}
|
||||||
if cfg.Tools.IsToolEnabled("list_dir") {
|
if cfg.Tools.IsToolEnabled("list_dir") {
|
||||||
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
||||||
@@ -119,13 +136,6 @@ func NewAgentInstance(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Tools.IsToolEnabled("edit_file") {
|
|
||||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
|
||||||
}
|
|
||||||
if cfg.Tools.IsToolEnabled("append_file") {
|
|
||||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionsDir := filepath.Join(workspace, "sessions")
|
sessionsDir := filepath.Join(workspace, "sessions")
|
||||||
sessions := initSessionStore(sessionsDir)
|
sessions := initSessionStore(sessionsDir)
|
||||||
|
|
||||||
|
|||||||
@@ -584,6 +584,102 @@ func TestNewAgentInstance_ReadFileModeSelectsSchema(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// write_file copy names append_file/edit_file only when they are registered.
|
||||||
|
func TestNewAgentInstance_WriteFileCopyReflectsAvailableAltTools(t *testing.T) {
|
||||||
|
newCfg := func(editEnabled, appendEnabled bool) *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: t.TempDir(),
|
||||||
|
ModelName: "test-model",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
WriteFile: config.ToolConfig{Enabled: true},
|
||||||
|
EditFile: config.ToolConfig{Enabled: editEnabled},
|
||||||
|
AppendFile: config.ToolConfig{Enabled: appendEnabled},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeToolDesc := func(cfg *config.Config) string {
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
|
||||||
|
writeTool, ok := agent.Tools.Get("write_file")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("write_file tool not registered")
|
||||||
|
}
|
||||||
|
return writeTool.Description()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("only write_file exposed", func(t *testing.T) {
|
||||||
|
desc := writeToolDesc(newCfg(false, false))
|
||||||
|
if strings.Contains(desc, "append_file") || strings.Contains(desc, "edit_file") {
|
||||||
|
t.Fatalf("write_file must not reference unavailable tools, got: %q", desc)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("only append_file exposed", func(t *testing.T) {
|
||||||
|
desc := writeToolDesc(newCfg(false, true))
|
||||||
|
if !strings.Contains(desc, "append_file") {
|
||||||
|
t.Fatalf("expected write_file to reference append_file, got: %q", desc)
|
||||||
|
}
|
||||||
|
if strings.Contains(desc, "edit_file") {
|
||||||
|
t.Fatalf("write_file must not reference disabled edit_file, got: %q", desc)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("both exposed", func(t *testing.T) {
|
||||||
|
desc := writeToolDesc(newCfg(true, true))
|
||||||
|
if !strings.Contains(desc, "append_file") || !strings.Contains(desc, "edit_file") {
|
||||||
|
t.Fatalf("expected write_file to reference both alternatives, got: %q", desc)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Availability follows the per-agent allowlist, not just the enable flag:
|
||||||
|
// editors enabled globally but hidden by frontmatter must not be named.
|
||||||
|
func TestNewAgentInstance_WriteFileCopyExcludesAllowlistHiddenAltTools(t *testing.T) {
|
||||||
|
workspace := setupWorkspace(t, map[string]string{
|
||||||
|
"AGENT.md": "---\ntools: [write_file]\n---\n# Agent\n",
|
||||||
|
})
|
||||||
|
defer cleanupWorkspace(t, workspace)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: workspace,
|
||||||
|
ModelName: "test-model",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
WriteFile: config.ToolConfig{Enabled: true},
|
||||||
|
EditFile: config.ToolConfig{Enabled: true},
|
||||||
|
AppendFile: config.ToolConfig{Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := NewAgentInstance(&config.AgentConfig{
|
||||||
|
ID: "restricted",
|
||||||
|
Workspace: workspace,
|
||||||
|
}, &cfg.Agents.Defaults, cfg, &mockProvider{})
|
||||||
|
|
||||||
|
if _, ok := agent.Tools.Get("edit_file"); ok {
|
||||||
|
t.Fatal("edit_file should be blocked by the allowlist")
|
||||||
|
}
|
||||||
|
if _, ok := agent.Tools.Get("append_file"); ok {
|
||||||
|
t.Fatal("append_file should be blocked by the allowlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
writeTool, ok := agent.Tools.Get("write_file")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("write_file tool not registered")
|
||||||
|
}
|
||||||
|
if desc := writeTool.Description(); strings.Contains(desc, "append_file") ||
|
||||||
|
strings.Contains(desc, "edit_file") {
|
||||||
|
t.Fatalf("write_file must not name allowlist-hidden tools, got: %q", desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {
|
func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
|
|
||||||
|
|||||||
@@ -495,11 +495,16 @@ func (p *Pipeline) CallLLM(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save finishReason to turnState for SubTurn truncation detection
|
// Save finishReason and usage on the turn state. Use ts directly (the
|
||||||
if innerTS := turnStateFromContext(ctx); innerTS != nil {
|
// authoritative turn state for this call) rather than a context lookup:
|
||||||
innerTS.SetLastFinishReason(exec.response.FinishReason)
|
// the raw ctx passed to CallLLM is not seeded with turnState (only turnCtx
|
||||||
|
// is), so turnStateFromContext(ctx) returns nil here and silently dropped
|
||||||
|
// both the finish reason and the per-turn token usage. ts is also exactly
|
||||||
|
// what the streaming publisher reads via GetLastUsage at finalize.
|
||||||
|
if ts != nil {
|
||||||
|
ts.SetLastFinishReason(exec.response.FinishReason)
|
||||||
if exec.response.Usage != nil {
|
if exec.response.Usage != nil {
|
||||||
innerTS.SetLastUsage(exec.response.Usage)
|
ts.SetLastUsage(exec.response.Usage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ func (p *Pipeline) tryConfiguredStreamingLLM(
|
|||||||
channel: ts.channel,
|
channel: ts.channel,
|
||||||
chatID: ts.chatID,
|
chatID: ts.chatID,
|
||||||
modelName: exec.llmModelName,
|
modelName: exec.llmModelName,
|
||||||
|
ts: ts,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("agent", "configured streaming enabled", map[string]any{
|
logger.DebugCF("agent", "configured streaming enabled", map[string]any{
|
||||||
@@ -376,6 +377,7 @@ type streamingChunkPublisher struct {
|
|||||||
published bool
|
published bool
|
||||||
reasoningPublished bool
|
reasoningPublished bool
|
||||||
err error
|
err error
|
||||||
|
ts *turnState
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *streamingChunkPublisher) Update(ctx context.Context, accumulated string) {
|
func (p *streamingChunkPublisher) Update(ctx context.Context, accumulated string) {
|
||||||
@@ -445,6 +447,11 @@ func (p *streamingChunkPublisher) Finalize(ctx context.Context, content string,
|
|||||||
if setter, ok := p.streamer.(interface{ SetModelName(modelName string) }); ok {
|
if setter, ok := p.streamer.(interface{ SetModelName(modelName string) }); ok {
|
||||||
setter.SetModelName(p.modelName)
|
setter.SetModelName(p.modelName)
|
||||||
}
|
}
|
||||||
|
if usage := p.ts.GetLastUsage(); usage != nil {
|
||||||
|
if setter, ok := p.streamer.(interface{ SetTurnUsage(in, out int) }); ok {
|
||||||
|
setter.SetTurnUsage(usage.PromptTokens, usage.CompletionTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
var err error
|
var err error
|
||||||
if streamer, ok := p.streamer.(bus.ContextUsageStreamer); ok {
|
if streamer, ok := p.streamer.(bus.ContextUsageStreamer); ok {
|
||||||
err = streamer.FinalizeWithContext(ctx, content, contextUsage)
|
err = streamer.FinalizeWithContext(ctx, content, contextUsage)
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ func (c *BaseChannel) HandleMessageWithContext(
|
|||||||
media []string,
|
media []string,
|
||||||
inboundCtx bus.InboundContext,
|
inboundCtx bus.InboundContext,
|
||||||
senderOpts ...bus.SenderInfo,
|
senderOpts ...bus.SenderInfo,
|
||||||
) {
|
) error {
|
||||||
// Use SenderInfo-based allow check when available, else fall back to string
|
// Use SenderInfo-based allow check when available, else fall back to string
|
||||||
var sender bus.SenderInfo
|
var sender bus.SenderInfo
|
||||||
if len(senderOpts) > 0 {
|
if len(senderOpts) > 0 {
|
||||||
@@ -275,11 +275,11 @@ func (c *BaseChannel) HandleMessageWithContext(
|
|||||||
senderID := strings.TrimSpace(inboundCtx.SenderID)
|
senderID := strings.TrimSpace(inboundCtx.SenderID)
|
||||||
if sender.CanonicalID != "" || sender.PlatformID != "" {
|
if sender.CanonicalID != "" || sender.PlatformID != "" {
|
||||||
if !c.IsAllowedSender(sender) {
|
if !c.IsAllowedSender(sender) {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if !c.IsAllowed(senderID) {
|
if !c.IsAllowed(senderID) {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +350,9 @@ func (c *BaseChannel) HandleMessageWithContext(
|
|||||||
"chat_id": deliveryChatID,
|
"chat_id": deliveryChatID,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleInboundContext publishes a normalized inbound message using only the
|
// HandleInboundContext publishes a normalized inbound message using only the
|
||||||
@@ -361,8 +363,8 @@ func (c *BaseChannel) HandleInboundContext(
|
|||||||
media []string,
|
media []string,
|
||||||
inboundCtx bus.InboundContext,
|
inboundCtx bus.InboundContext,
|
||||||
senderOpts ...bus.SenderInfo,
|
senderOpts ...bus.SenderInfo,
|
||||||
) {
|
) error {
|
||||||
c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...)
|
return c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *BaseChannel) SetRunning(running bool) {
|
func (c *BaseChannel) SetRunning(running bool) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,398 @@
|
|||||||
|
package deltachat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listen is the inbound message loop. It blocks on wait_next_msgs and feeds
|
||||||
|
// each new message into the PicoClaw inbound pipeline.
|
||||||
|
func (c *DeltaChatChannel) listen() {
|
||||||
|
logger.InfoCF("deltachat", "Listening for messages", map[string]any{
|
||||||
|
"account_id": c.accountID,
|
||||||
|
"email": c.selfAddr,
|
||||||
|
})
|
||||||
|
for c.IsRunning() && c.ctx.Err() == nil {
|
||||||
|
raw, err := c.rpc.call(c.ctx, "wait_next_msgs", c.accountID)
|
||||||
|
if err != nil {
|
||||||
|
if c.ctx.Err() != nil || !c.IsRunning() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.ErrorCF("deltachat", "wait_next_msgs failed", map[string]any{"error": err.Error()})
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var messageIDs []int64
|
||||||
|
if err := json.Unmarshal(raw, &messageIDs); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messageIDs) > 0 {
|
||||||
|
logger.DebugCF("deltachat", "Received message batch", map[string]any{
|
||||||
|
"count": len(messageIDs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, messageID := range messageIDs {
|
||||||
|
c.handleMessage(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessage fetches one message, applies inbound filtering, and publishes it.
|
||||||
|
func (c *DeltaChatChannel) handleMessage(messageID int64) {
|
||||||
|
msg, err := c.getMessage(messageID)
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("deltachat", "get_message failed", map[string]any{
|
||||||
|
"message_id": messageID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.IsInfo || (strings.TrimSpace(msg.Text) == "" && msg.File == "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
senderAddr := ""
|
||||||
|
if msg.Sender != nil {
|
||||||
|
senderAddr = msg.Sender.Address
|
||||||
|
}
|
||||||
|
if senderAddr != "" && strings.EqualFold(senderAddr, c.selfAddr) {
|
||||||
|
logger.DebugCF("deltachat", "Drop: own message", map[string]any{"message_id": messageID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chat, err := c.getFullChat(msg.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("deltachat", "get_full_chat_by_id failed", map[string]any{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Device messages are core-generated notices, not real conversations.
|
||||||
|
if chat.IsDeviceChat {
|
||||||
|
logger.DebugCF("deltachat", "Drop: device message", map[string]any{"chat_id": msg.ChatID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isGroup := chat.ChatType != chatTypeSingle
|
||||||
|
|
||||||
|
logger.DebugCF("deltachat", "Inbound message", map[string]any{
|
||||||
|
"message_id": messageID,
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"from": senderAddr,
|
||||||
|
"is_group": isGroup,
|
||||||
|
"has_file": msg.File != "",
|
||||||
|
"text_len": len(strings.TrimSpace(msg.Text)),
|
||||||
|
})
|
||||||
|
|
||||||
|
senderName := senderAddr
|
||||||
|
if msg.Sender != nil {
|
||||||
|
if msg.Sender.DisplayName != "" {
|
||||||
|
senderName = msg.Sender.DisplayName
|
||||||
|
} else if msg.Sender.Name != "" {
|
||||||
|
senderName = msg.Sender.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if senderName == "" {
|
||||||
|
senderName = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID := strconv.FormatInt(msg.ChatID, 10)
|
||||||
|
messageIDStr := strconv.FormatInt(msg.ID, 10)
|
||||||
|
|
||||||
|
content := strings.TrimSpace(msg.Text)
|
||||||
|
|
||||||
|
// Register any attachment with the media store so the agent pipeline can
|
||||||
|
// view images and operate on files. The ref is scoped to the same key the
|
||||||
|
// BaseChannel derives for this message, so it is released with the turn.
|
||||||
|
var mediaRefs []string
|
||||||
|
if msg.File != "" {
|
||||||
|
scope := channels.BuildMediaScope(c.Name(), chatID, messageIDStr)
|
||||||
|
if ref := c.registerInboundFile(scope, msg); ref != "" {
|
||||||
|
mediaRefs = append(mediaRefs, ref)
|
||||||
|
} else {
|
||||||
|
// Fallback when no media store is available: surface the path inline
|
||||||
|
// so the attachment is not silently lost.
|
||||||
|
annotation := fmt.Sprintf("[attachment: %s]", msg.File)
|
||||||
|
if content == "" {
|
||||||
|
content = annotation
|
||||||
|
} else {
|
||||||
|
content = content + "\n" + annotation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file with no caption still warrants a turn; give the agent a minimal
|
||||||
|
// placeholder so the message survives the empty-content guard below. Audio
|
||||||
|
// gets a "[voice]" tag specifically, so the agent's transcription step can
|
||||||
|
// substitute the transcript in place rather than appending it.
|
||||||
|
if content == "" && len(mediaRefs) > 0 {
|
||||||
|
if utils.IsAudioFile(msg.FileName, msg.FileMime) {
|
||||||
|
content = "[voice]"
|
||||||
|
} else {
|
||||||
|
content = "[media]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sender := bus.SenderInfo{
|
||||||
|
Platform: config.ChannelDeltaChat,
|
||||||
|
PlatformID: senderAddr,
|
||||||
|
CanonicalID: identity.BuildCanonicalID(config.ChannelDeltaChat, senderAddr),
|
||||||
|
Username: senderAddr,
|
||||||
|
DisplayName: senderName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
logger.DebugCF("deltachat", "Drop: sender not in allow_from", map[string]any{
|
||||||
|
"from": senderAddr,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isMentioned := false
|
||||||
|
if isGroup {
|
||||||
|
botName := c.config.DisplayName
|
||||||
|
if botName == "" {
|
||||||
|
botName = c.selfAddr
|
||||||
|
}
|
||||||
|
isMentioned = mentionsBot(content, botName, c.selfAddr)
|
||||||
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||||
|
if !respond {
|
||||||
|
logger.DebugCF("deltachat", "Drop: group trigger not satisfied", map[string]any{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"mentioned": isMentioned,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := map[string]string{
|
||||||
|
"platform": config.ChannelDeltaChat,
|
||||||
|
"chat_name": chat.Name,
|
||||||
|
}
|
||||||
|
if msg.File != "" {
|
||||||
|
metadata["file"] = msg.File
|
||||||
|
metadata["file_name"] = msg.FileName
|
||||||
|
metadata["file_mime"] = msg.FileMime
|
||||||
|
}
|
||||||
|
|
||||||
|
inboundCtx := bus.InboundContext{
|
||||||
|
Channel: config.ChannelDeltaChat,
|
||||||
|
ChatID: chatID,
|
||||||
|
SenderID: senderAddr,
|
||||||
|
MessageID: messageIDStr,
|
||||||
|
Mentioned: isMentioned,
|
||||||
|
Raw: metadata,
|
||||||
|
}
|
||||||
|
if isGroup {
|
||||||
|
inboundCtx.ChatType = "group"
|
||||||
|
} else {
|
||||||
|
inboundCtx.ChatType = "direct"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("deltachat", "Dispatching to agent", map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"chat_type": inboundCtx.ChatType,
|
||||||
|
"from": senderAddr,
|
||||||
|
})
|
||||||
|
if err := c.HandleInboundContext(c.ctx, chatID, content, mediaRefs, inboundCtx, sender); err != nil {
|
||||||
|
logger.ErrorCF("deltachat", "Dispatch failed; leaving message unseen", map[string]any{
|
||||||
|
"message_id": messageID,
|
||||||
|
"chat_id": chatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := c.rpc.call(c.ctx, "markseen_msgs", c.accountID, []int64{messageID}); err != nil {
|
||||||
|
logger.WarnCF("deltachat", "Failed to mark message seen", map[string]any{
|
||||||
|
"message_id": messageID,
|
||||||
|
"chat_id": chatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerInboundFile records an inbound attachment with the media store under
|
||||||
|
// the given scope and returns its media:// ref. Returns "" when there is no
|
||||||
|
// media store or registration fails, letting the caller fall back to an inline
|
||||||
|
// path annotation.
|
||||||
|
//
|
||||||
|
// Delta Chat stores attachments inside the account directory, next to the
|
||||||
|
// credential database — a location tools are intentionally NOT allowed to read.
|
||||||
|
// We therefore copy the single attachment out into the shared media temp dir
|
||||||
|
// (which read_file/load_image are permitted to access) and register that copy,
|
||||||
|
// so the agent can actually open the file. The copy is store-managed and deleted
|
||||||
|
// when the turn's scope is released.
|
||||||
|
func (c *DeltaChatChannel) registerInboundFile(scope string, msg *dcMessage) string {
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
filename := msg.FileName
|
||||||
|
if filename == "" {
|
||||||
|
filename = filepath.Base(msg.File)
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath, err := copyToMediaTemp(msg.File, filename)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("deltachat", "Failed to copy attachment into media dir", map[string]any{
|
||||||
|
"file": msg.File,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
|
Filename: filename,
|
||||||
|
ContentType: msg.FileMime,
|
||||||
|
Source: config.ChannelDeltaChat,
|
||||||
|
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||||
|
}, scope)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("deltachat", "Failed to register attachment with media store", map[string]any{
|
||||||
|
"file": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
_ = os.Remove(localPath)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyToMediaTemp copies srcPath into the shared media temp directory under a
|
||||||
|
// unique name and returns the destination path. The media temp dir is the
|
||||||
|
// location the read_file/load_image tools are permitted to read, so copying here
|
||||||
|
// makes the attachment readable without exposing Delta Chat's account directory.
|
||||||
|
func copyToMediaTemp(srcPath, filename string) (string, error) {
|
||||||
|
if err := os.MkdirAll(media.TempDir(), 0o700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
safe := utils.SanitizeFilename(filename)
|
||||||
|
if safe == "" {
|
||||||
|
safe = filepath.Base(srcPath)
|
||||||
|
}
|
||||||
|
dstPath := filepath.Join(media.TempDir(), uuid.NewString()[:8]+"_"+safe)
|
||||||
|
|
||||||
|
src, err := os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
dst, err := os.Create(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(dst, src); err != nil {
|
||||||
|
dst.Close()
|
||||||
|
_ = os.Remove(dstPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := dst.Close(); err != nil {
|
||||||
|
_ = os.Remove(dstPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dstPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeltaChatChannel) getMessage(messageID int64) (*dcMessage, error) {
|
||||||
|
raw, err := c.rpc.call(c.ctx, "get_message", c.accountID, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var msg dcMessage
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeltaChatChannel) getFullChat(chatID int64) (*dcChat, error) {
|
||||||
|
raw, err := c.rpc.call(c.ctx, "get_full_chat_by_id", c.accountID, chatID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var chat dcChat
|
||||||
|
if err := json.Unmarshal(raw, &chat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &chat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mentionsBot reports whether the message references the bot by display name or
|
||||||
|
// the local-part of its email address (a common addressing convention).
|
||||||
|
func mentionsBot(content, displayName, email string) bool {
|
||||||
|
if containsMentionToken(content, displayName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if local, _, ok := strings.Cut(email, "@"); ok && local != "" {
|
||||||
|
if containsMentionToken(content, "@"+local) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsMentionToken(content, token string) bool {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
contentRunes := []rune(strings.ToLower(content))
|
||||||
|
tokenRunes := []rune(strings.ToLower(token))
|
||||||
|
if len(tokenRunes) == 0 || len(tokenRunes) > len(contentRunes) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i <= len(contentRunes)-len(tokenRunes); i++ {
|
||||||
|
if !sameRunes(contentRunes[i:i+len(tokenRunes)], tokenRunes) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
before := i == 0 || !isMentionWordRune(contentRunes[i-1])
|
||||||
|
afterIdx := i + len(tokenRunes)
|
||||||
|
after := afterIdx >= len(contentRunes) || !isMentionWordRune(contentRunes[afterIdx])
|
||||||
|
if before && after {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameRunes(a, b []rune) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMentionWordRune(r rune) bool {
|
||||||
|
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package deltachat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory(
|
||||||
|
config.ChannelDeltaChat,
|
||||||
|
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
bc := cfg.Channels[channelName]
|
||||||
|
if bc == nil || !bc.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
decoded, err := bc.GetDecoded()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c, ok := decoded.(*config.DeltaChatSettings)
|
||||||
|
if !ok {
|
||||||
|
return nil, channels.ErrSendFailed
|
||||||
|
}
|
||||||
|
ch, err := NewDeltaChatChannel(bc, c, b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if channelName != config.ChannelDeltaChat {
|
||||||
|
ch.SetName(channelName)
|
||||||
|
}
|
||||||
|
return ch, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package deltachat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rpcRequest is a single JSON-RPC 2.0 request. Delta Chat uses positional
|
||||||
|
// (array) parameters.
|
||||||
|
type rpcRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params []any `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// rpcResponse is a single JSON-RPC 2.0 response.
|
||||||
|
type rpcResponse struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Error *rpcError `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *rpcError) Error() string {
|
||||||
|
return fmt.Sprintf("deltachat rpc error %d: %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rpcClient drives a `deltachat-rpc-server` child process over stdio using
|
||||||
|
// newline-delimited JSON-RPC 2.0. The server answers requests asynchronously
|
||||||
|
// and out of order, so every call is correlated back to its caller by id.
|
||||||
|
type rpcClient struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdin io.WriteCloser
|
||||||
|
stdout io.ReadCloser
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
nextID uint64
|
||||||
|
pending map[uint64]chan rpcResponse
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRPC spawns the RPC server with DC_ACCOUNTS_PATH pointing at dataDir and
|
||||||
|
// begins the background read loop.
|
||||||
|
func startRPC(serverPath, dataDir string) (*rpcClient, error) {
|
||||||
|
cmd := exec.Command(serverPath)
|
||||||
|
cmd.Env = append(cmd.Environ(), "DC_ACCOUNTS_PATH="+dataDir)
|
||||||
|
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("deltachat rpc stdin: %w", err)
|
||||||
|
}
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("deltachat rpc stdout: %w", err)
|
||||||
|
}
|
||||||
|
// Let the server's logs flow to our stderr for easy diagnostics.
|
||||||
|
cmd.Stderr = logWriter{}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("start deltachat-rpc-server (%s): %w", serverPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &rpcClient{
|
||||||
|
cmd: cmd,
|
||||||
|
stdin: stdin,
|
||||||
|
stdout: stdout,
|
||||||
|
pending: make(map[uint64]chan rpcResponse),
|
||||||
|
}
|
||||||
|
go c.readLoop()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLoop reads newline-delimited responses and dispatches them to waiters.
|
||||||
|
func (c *rpcClient) readLoop() {
|
||||||
|
reader := bufio.NewReader(c.stdout)
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadBytes('\n')
|
||||||
|
if len(line) > 0 {
|
||||||
|
var resp rpcResponse
|
||||||
|
if jsonErr := json.Unmarshal(line, &resp); jsonErr == nil && resp.ID != 0 {
|
||||||
|
c.mu.Lock()
|
||||||
|
ch := c.pending[resp.ID]
|
||||||
|
delete(c.pending, resp.ID)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if ch != nil {
|
||||||
|
ch <- resp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.failAll(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failAll wakes every pending caller with an error (used on EOF / shutdown).
|
||||||
|
func (c *rpcClient) failAll(err error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.closed = true
|
||||||
|
for id, ch := range c.pending {
|
||||||
|
ch <- rpcResponse{Error: &rpcError{Code: -1, Message: "rpc closed: " + err.Error()}}
|
||||||
|
delete(c.pending, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// call issues one request and blocks until the matching response arrives, the
|
||||||
|
// context is canceled, or the server goes away.
|
||||||
|
func (c *rpcClient) call(ctx context.Context, method string, params ...any) (json.RawMessage, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.closed {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("deltachat rpc: connection closed")
|
||||||
|
}
|
||||||
|
c.nextID++
|
||||||
|
id := c.nextID
|
||||||
|
ch := make(chan rpcResponse, 1)
|
||||||
|
c.pending[id] = ch
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
req := rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
c.clearPending(id)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
_, err = c.stdin.Write(data)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
c.clearPending(id)
|
||||||
|
return nil, fmt.Errorf("deltachat rpc write %s: %w", method, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
c.clearPending(id)
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case resp := <-ch:
|
||||||
|
if resp.Error != nil {
|
||||||
|
return nil, resp.Error
|
||||||
|
}
|
||||||
|
return resp.Result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rpcClient) clearPending(id uint64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.pending, id)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// close terminates the RPC server process.
|
||||||
|
func (c *rpcClient) close() {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.closed = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
if c.stdin != nil {
|
||||||
|
_ = c.stdin.Close()
|
||||||
|
}
|
||||||
|
if c.cmd != nil && c.cmd.Process != nil {
|
||||||
|
_ = c.cmd.Process.Kill()
|
||||||
|
_ = c.cmd.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logWriter forwards deltachat-rpc-server stderr lines into the logger.
|
||||||
|
type logWriter struct{}
|
||||||
|
|
||||||
|
func (logWriter) Write(p []byte) (int, error) {
|
||||||
|
logger.DebugC("deltachat", string(p))
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
@@ -69,7 +69,8 @@ func NewLINEChannel(
|
|||||||
return nil, fmt.Errorf("failed to create LINE messaging client: %w", err)
|
return nil, fmt.Errorf("failed to create LINE messaging client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
base := channels.NewBaseChannel("line", cfg, messageBus, bc.AllowFrom,
|
base := channels.NewBaseChannel(
|
||||||
|
"line", cfg, messageBus, bc.AllowFrom,
|
||||||
channels.WithMaxMessageLength(5000),
|
channels.WithMaxMessageLength(5000),
|
||||||
channels.WithGroupTrigger(bc.GroupTrigger),
|
channels.WithGroupTrigger(bc.GroupTrigger),
|
||||||
channels.WithReasoningChannelID(bc.ReasoningChannelID),
|
channels.WithReasoningChannelID(bc.ReasoningChannelID),
|
||||||
@@ -482,7 +483,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
|||||||
Messages: []messaging_api.MessageInterface{&textMsg},
|
Messages: []messaging_api.MessageInterface{&textMsg},
|
||||||
})
|
})
|
||||||
if resp != nil && resp.Body != nil {
|
if resp != nil && resp.Body != nil {
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
|
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
|
||||||
@@ -588,7 +589,7 @@ func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), e
|
|||||||
// classifySDKError maps an SDK HTTP response to the project's sentinel errors.
|
// classifySDKError maps an SDK HTTP response to the project's sentinel errors.
|
||||||
func classifySDKError(resp *http.Response, err error) error {
|
func classifySDKError(resp *http.Response, err error) error {
|
||||||
if resp != nil && resp.Body != nil {
|
if resp != nil && resp.Body != nil {
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+38
-9
@@ -699,18 +699,34 @@ func setStreamerModelName(streamer any, modelName string) {
|
|||||||
setter.SetModelName(modelName)
|
setter.SetModelName(modelName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type turnUsageStreamer interface {
|
||||||
|
SetTurnUsage(inputTokens, outputTokens int)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setStreamerTurnUsage forwards real per-turn token usage to a streamer that
|
||||||
|
// supports it, transparently unwrapping the manager's streamer wrappers.
|
||||||
|
func setStreamerTurnUsage(streamer any, inputTokens, outputTokens int) {
|
||||||
|
setter, ok := streamer.(turnUsageStreamer)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setter.SetTurnUsage(inputTokens, outputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
// splitMarkerStreamer turns accumulated streaming text containing
|
// splitMarkerStreamer turns accumulated streaming text containing
|
||||||
// MessageSplitMarker into separate channel stream messages.
|
// MessageSplitMarker into separate channel stream messages.
|
||||||
type splitMarkerStreamer struct {
|
type splitMarkerStreamer struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
current bus.Streamer
|
current bus.Streamer
|
||||||
reasoning bus.ReasoningStreamer
|
reasoning bus.ReasoningStreamer
|
||||||
begin func(context.Context) (bus.Streamer, error)
|
begin func(context.Context) (bus.Streamer, error)
|
||||||
completedParts int
|
completedParts int
|
||||||
finalized bool
|
finalized bool
|
||||||
onFinalize func(context.Context, string)
|
onFinalize func(context.Context, string)
|
||||||
clearMarker func()
|
clearMarker func()
|
||||||
modelName string
|
modelName string
|
||||||
|
turnInputTokens int
|
||||||
|
turnOutputTokens int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *splitMarkerStreamer) Update(ctx context.Context, content string) error {
|
func (s *splitMarkerStreamer) Update(ctx context.Context, content string) error {
|
||||||
@@ -761,6 +777,14 @@ func (s *splitMarkerStreamer) SetModelName(modelName string) {
|
|||||||
setStreamerModelName(s.reasoning, s.modelName)
|
setStreamerModelName(s.reasoning, s.modelName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *splitMarkerStreamer) SetTurnUsage(inputTokens, outputTokens int) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.turnInputTokens = inputTokens
|
||||||
|
s.turnOutputTokens = outputTokens
|
||||||
|
setStreamerTurnUsage(s.current, s.turnInputTokens, s.turnOutputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *splitMarkerStreamer) Cancel(ctx context.Context) {
|
func (s *splitMarkerStreamer) Cancel(ctx context.Context) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -840,6 +864,7 @@ func (s *splitMarkerStreamer) ensureCurrentLocked(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
s.current = streamer
|
s.current = streamer
|
||||||
setStreamerModelName(s.current, s.modelName)
|
setStreamerModelName(s.current, s.modelName)
|
||||||
|
setStreamerTurnUsage(s.current, s.turnInputTokens, s.turnOutputTokens)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -928,6 +953,10 @@ func (s *finalizeHookStreamer) SetModelName(modelName string) {
|
|||||||
setStreamerModelName(s.Streamer, strings.TrimSpace(modelName))
|
setStreamerModelName(s.Streamer, strings.TrimSpace(modelName))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *finalizeHookStreamer) SetTurnUsage(inputTokens, outputTokens int) {
|
||||||
|
setStreamerTurnUsage(s.Streamer, inputTokens, outputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *finalizeHookStreamer) runFinalizeHook(ctx context.Context, content string) {
|
func (s *finalizeHookStreamer) runFinalizeHook(ctx context.Context, content string) {
|
||||||
if s.onFinalize != nil {
|
if s.onFinalize != nil {
|
||||||
s.onFinalize(ctx, content)
|
s.onFinalize(ctx, content)
|
||||||
|
|||||||
@@ -3383,3 +3383,56 @@ func TestManager_SendPlaceholder(t *testing.T) {
|
|||||||
t.Error("expected SendPlaceholder to fail for unknown channel")
|
t.Error("expected SendPlaceholder to fail for unknown channel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// turnUsageTrackingStreamer is a mockStreamer that records SetTurnUsage calls,
|
||||||
|
// used to verify the manager's streamer wrappers forward per-turn token usage
|
||||||
|
// to the inner streamer (regression: the wrappers previously dropped it because
|
||||||
|
// SetTurnUsage is not part of the bus.Streamer interface).
|
||||||
|
type turnUsageTrackingStreamer struct {
|
||||||
|
mockStreamer
|
||||||
|
inputTokens int
|
||||||
|
outputTokens int
|
||||||
|
usageCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *turnUsageTrackingStreamer) SetTurnUsage(inputTokens, outputTokens int) {
|
||||||
|
m.usageCalls++
|
||||||
|
m.inputTokens = inputTokens
|
||||||
|
m.outputTokens = outputTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalizeHookStreamerForwardsTurnUsage(t *testing.T) {
|
||||||
|
inner := &turnUsageTrackingStreamer{}
|
||||||
|
wrapper := &finalizeHookStreamer{Streamer: inner}
|
||||||
|
|
||||||
|
setter, ok := any(wrapper).(turnUsageStreamer)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("finalizeHookStreamer does not satisfy turnUsageStreamer")
|
||||||
|
}
|
||||||
|
setter.SetTurnUsage(1234, 567)
|
||||||
|
|
||||||
|
if inner.usageCalls != 1 {
|
||||||
|
t.Fatalf("inner SetTurnUsage calls = %d, want 1", inner.usageCalls)
|
||||||
|
}
|
||||||
|
if inner.inputTokens != 1234 || inner.outputTokens != 567 {
|
||||||
|
t.Errorf("inner usage = (%d, %d), want (1234, 567)", inner.inputTokens, inner.outputTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitMarkerStreamerForwardsTurnUsage(t *testing.T) {
|
||||||
|
inner := &turnUsageTrackingStreamer{}
|
||||||
|
wrapper := &splitMarkerStreamer{current: inner}
|
||||||
|
|
||||||
|
setter, ok := any(wrapper).(turnUsageStreamer)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("splitMarkerStreamer does not satisfy turnUsageStreamer")
|
||||||
|
}
|
||||||
|
setter.SetTurnUsage(1234, 567)
|
||||||
|
|
||||||
|
if inner.usageCalls != 1 {
|
||||||
|
t.Fatalf("inner SetTurnUsage calls = %d, want 1", inner.usageCalls)
|
||||||
|
}
|
||||||
|
if inner.inputTokens != 1234 || inner.outputTokens != 567 {
|
||||||
|
t.Errorf("inner usage = (%d, %d), want (1234, 567)", inner.inputTokens, inner.outputTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ type PicoChannel struct {
|
|||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
progress *channels.ToolFeedbackAnimator
|
progress *channels.ToolFeedbackAnimator
|
||||||
deleteMessageFn func(context.Context, string, string) error
|
deleteMessageFn func(context.Context, string, string) error
|
||||||
|
// broadcastFn lets tests intercept outbound broadcasts. nil → broadcastToSession.
|
||||||
|
broadcastFn func(chatID string, msg PicoMessage) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPicoChannel creates a new Pico Protocol channel.
|
// NewPicoChannel creates a new Pico Protocol channel.
|
||||||
@@ -531,6 +533,8 @@ type picoStreamer struct {
|
|||||||
channel *PicoChannel
|
channel *PicoChannel
|
||||||
chatID string
|
chatID string
|
||||||
modelName string
|
modelName string
|
||||||
|
turnInputTokens int
|
||||||
|
turnOutputTokens int
|
||||||
messageID string
|
messageID string
|
||||||
reasoningID string
|
reasoningID string
|
||||||
throttleInterval time.Duration
|
throttleInterval time.Duration
|
||||||
@@ -553,6 +557,17 @@ func (s *picoStreamer) SetModelName(modelName string) {
|
|||||||
s.modelName = strings.TrimSpace(modelName)
|
s.modelName = strings.TrimSpace(modelName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTurnUsage records the real per-turn LLM token usage to emit on finalize.
|
||||||
|
func (s *picoStreamer) SetTurnUsage(inputTokens, outputTokens int) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.turnInputTokens = inputTokens
|
||||||
|
s.turnOutputTokens = outputTokens
|
||||||
|
}
|
||||||
|
|
||||||
func (s *picoStreamer) Update(ctx context.Context, content string) error {
|
func (s *picoStreamer) Update(ctx context.Context, content string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -661,8 +676,9 @@ func (s *picoStreamer) sendLocked(ctx context.Context, content string, contextUs
|
|||||||
payload[PayloadKeyModelName] = s.modelName
|
payload[PayloadKeyModelName] = s.modelName
|
||||||
}
|
}
|
||||||
setContextUsagePayload(payload, contextUsage)
|
setContextUsagePayload(payload, contextUsage)
|
||||||
|
setTurnUsagePayload(payload, s.turnInputTokens, s.turnOutputTokens)
|
||||||
outMsg := newMessage(TypeMessageCreate, payload)
|
outMsg := newMessage(TypeMessageCreate, payload)
|
||||||
if err := s.channel.broadcastToSession(s.chatID, outMsg); err != nil {
|
if err := s.channel.broadcast(s.chatID, outMsg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else if content != s.lastContent || contextUsage != nil {
|
} else if content != s.lastContent || contextUsage != nil {
|
||||||
@@ -673,6 +689,7 @@ func (s *picoStreamer) sendLocked(ctx context.Context, content string, contextUs
|
|||||||
if s.modelName != "" {
|
if s.modelName != "" {
|
||||||
payload[PayloadKeyModelName] = s.modelName
|
payload[PayloadKeyModelName] = s.modelName
|
||||||
}
|
}
|
||||||
|
setTurnUsagePayload(payload, s.turnInputTokens, s.turnOutputTokens)
|
||||||
if err := s.channel.editMessagePayload(ctx, s.chatID, s.messageID, payload, contextUsage); err != nil {
|
if err := s.channel.editMessagePayload(ctx, s.chatID, s.messageID, payload, contextUsage); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -932,6 +949,14 @@ func (c *PicoChannel) handleMediaDownload(w http.ResponseWriter, r *http.Request
|
|||||||
http.ServeContent(w, r, filename, info.ModTime(), file)
|
http.ServeContent(w, r, filename, info.ModTime(), file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// broadcast routes through broadcastFn when set (tests), else broadcastToSession.
|
||||||
|
func (c *PicoChannel) broadcast(chatID string, msg PicoMessage) error {
|
||||||
|
if c.broadcastFn != nil {
|
||||||
|
return c.broadcastFn(chatID, msg)
|
||||||
|
}
|
||||||
|
return c.broadcastToSession(chatID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
// broadcastToSession sends a message to all connections with a matching session.
|
// broadcastToSession sends a message to all connections with a matching session.
|
||||||
func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
|
func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
|
||||||
// chatID format: "pico:<sessionID>"
|
// chatID format: "pico:<sessionID>"
|
||||||
@@ -1403,6 +1428,20 @@ func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setTurnUsagePayload attaches real per-turn LLM token usage to the payload.
|
||||||
|
// Input and output are kept separate (billed at different rates); total is a
|
||||||
|
// convenience sum. Omitted entirely when both counts are zero.
|
||||||
|
func setTurnUsagePayload(payload map[string]any, inputTokens, outputTokens int) {
|
||||||
|
if inputTokens <= 0 && outputTokens <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload[PayloadKeyUsage] = map[string]any{
|
||||||
|
"input_tokens": inputTokens,
|
||||||
|
"output_tokens": outputTokens,
|
||||||
|
"total_tokens": inputTokens + outputTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func picoToolCallsPayload(msg bus.OutboundMessage) ([]utils.VisibleToolCall, bool) {
|
func picoToolCallsPayload(msg bus.OutboundMessage) ([]utils.VisibleToolCall, bool) {
|
||||||
raw := strings.TrimSpace(msg.Context.Raw[PayloadKeyToolCalls])
|
raw := strings.TrimSpace(msg.Context.Raw[PayloadKeyToolCalls])
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package pico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetTurnUsagePayload(t *testing.T) {
|
||||||
|
t.Run("populates usage block when counts present", func(t *testing.T) {
|
||||||
|
payload := map[string]any{PayloadKeyContent: "hi"}
|
||||||
|
setTurnUsagePayload(payload, 1234, 567)
|
||||||
|
|
||||||
|
raw, ok := payload[PayloadKeyUsage]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected %q key in payload", PayloadKeyUsage)
|
||||||
|
}
|
||||||
|
usage, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("usage block is not a map: %T", raw)
|
||||||
|
}
|
||||||
|
if usage["input_tokens"] != 1234 {
|
||||||
|
t.Errorf("input_tokens = %v, want 1234", usage["input_tokens"])
|
||||||
|
}
|
||||||
|
if usage["output_tokens"] != 567 {
|
||||||
|
t.Errorf("output_tokens = %v, want 567", usage["output_tokens"])
|
||||||
|
}
|
||||||
|
if usage["total_tokens"] != 1801 {
|
||||||
|
t.Errorf("total_tokens = %v, want 1801", usage["total_tokens"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("omits usage block when both counts zero", func(t *testing.T) {
|
||||||
|
payload := map[string]any{PayloadKeyContent: "hi"}
|
||||||
|
setTurnUsagePayload(payload, 0, 0)
|
||||||
|
if _, ok := payload[PayloadKeyUsage]; ok {
|
||||||
|
t.Errorf("expected no %q key when counts are zero", PayloadKeyUsage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCaptureStreamer returns a streamer whose broadcasts are captured into the
|
||||||
|
// returned map pointer, so tests need no live websocket.
|
||||||
|
func newCaptureStreamer() (*picoStreamer, *map[string]any) {
|
||||||
|
var last map[string]any
|
||||||
|
ch := &PicoChannel{}
|
||||||
|
ch.broadcastFn = func(chatID string, msg PicoMessage) error {
|
||||||
|
last = msg.Payload
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := &picoStreamer{channel: ch, chatID: "c1"}
|
||||||
|
return s, &last
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamerEmitsUsageOnFinalize(t *testing.T) {
|
||||||
|
s, last := newCaptureStreamer()
|
||||||
|
s.SetTurnUsage(100, 40)
|
||||||
|
|
||||||
|
// sendLocked with empty messageID takes the create branch, which attaches
|
||||||
|
// usage from the streamer's stored counts.
|
||||||
|
s.mu.Lock()
|
||||||
|
err := s.sendLocked(context.Background(), "answer", nil)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sendLocked: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := (*last)[PayloadKeyUsage]; !ok {
|
||||||
|
t.Fatalf("expected usage in payload, got %+v", *last)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ const (
|
|||||||
PayloadKeyPlaceholder = "placeholder"
|
PayloadKeyPlaceholder = "placeholder"
|
||||||
PayloadKeyToolCalls = "tool_calls"
|
PayloadKeyToolCalls = "tool_calls"
|
||||||
PayloadKeyModelName = "model_name"
|
PayloadKeyModelName = "model_name"
|
||||||
|
PayloadKeyUsage = "usage"
|
||||||
|
|
||||||
MessageKindThought = "thought"
|
MessageKindThought = "thought"
|
||||||
MessageKindToolCalls = "tool_calls"
|
MessageKindToolCalls = "tool_calls"
|
||||||
|
|||||||
@@ -599,6 +599,31 @@ type MatrixSettings struct {
|
|||||||
CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"`
|
CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeltaChatSettings configures the Delta Chat channel. Delta Chat is an
|
||||||
|
// email-based, end-to-end encrypted messenger; PicoClaw talks to a local
|
||||||
|
// `deltachat-rpc-server` process over JSON-RPC (stdio).
|
||||||
|
//
|
||||||
|
// Email is the only required setting. A full address selects an already
|
||||||
|
// configured account in DataDir; a first-run marker such as "@nine.testrun.org"
|
||||||
|
// creates a chatmail account and tells the user which full email to save.
|
||||||
|
// Mailbox credentials stay in the Delta Chat account store. DisplayName and
|
||||||
|
// AvatarImage are optional profile settings applied on startup. Password remains
|
||||||
|
// only for legacy PicoClaw-managed email configuration.
|
||||||
|
type DeltaChatSettings struct {
|
||||||
|
Email string `json:"email" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_EMAIL"`
|
||||||
|
Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_DELTACHAT_PASSWORD"`
|
||||||
|
DisplayName string `json:"display_name,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_DISPLAY_NAME"`
|
||||||
|
AvatarImage string `json:"avatar_image,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_AVATAR_IMAGE"`
|
||||||
|
DataDir string `json:"data_dir,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_DATA_DIR"`
|
||||||
|
RPCServerPath string `json:"rpc_server_path,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_RPC_SERVER_PATH"`
|
||||||
|
InviteLink string `json:"invite_link,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_INVITE_LINK"`
|
||||||
|
AllowCrosspost bool `json:"allow_crosspost,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_ALLOW_CROSSPOST"`
|
||||||
|
IMAPServer string `json:"imap_server,omitempty" yaml:"-"`
|
||||||
|
IMAPPort int `json:"imap_port,omitempty" yaml:"-"`
|
||||||
|
SMTPServer string `json:"smtp_server,omitempty" yaml:"-"`
|
||||||
|
SMTPPort int `json:"smtp_port,omitempty" yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
type LINESettings struct {
|
type LINESettings struct {
|
||||||
ChannelSecret SecureString `json:"channel_secret,omitzero" yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
ChannelSecret SecureString `json:"channel_secret,omitzero" yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
||||||
ChannelAccessToken SecureString `json:"channel_access_token,omitzero" yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
|
ChannelAccessToken SecureString `json:"channel_access_token,omitzero" yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const (
|
|||||||
ChannelDingTalk = "dingtalk"
|
ChannelDingTalk = "dingtalk"
|
||||||
ChannelSlack = "slack"
|
ChannelSlack = "slack"
|
||||||
ChannelMatrix = "matrix"
|
ChannelMatrix = "matrix"
|
||||||
|
ChannelDeltaChat = "deltachat"
|
||||||
ChannelLINE = "line"
|
ChannelLINE = "line"
|
||||||
ChannelOneBot = "onebot"
|
ChannelOneBot = "onebot"
|
||||||
ChannelQQ = "qq"
|
ChannelQQ = "qq"
|
||||||
@@ -669,6 +670,7 @@ var channelSettingsFactory = map[string]any{
|
|||||||
ChannelDingTalk: (DingTalkSettings{}),
|
ChannelDingTalk: (DingTalkSettings{}),
|
||||||
ChannelSlack: (SlackSettings{}),
|
ChannelSlack: (SlackSettings{}),
|
||||||
ChannelMatrix: (MatrixSettings{}),
|
ChannelMatrix: (MatrixSettings{}),
|
||||||
|
ChannelDeltaChat: (DeltaChatSettings{}),
|
||||||
ChannelLINE: (LINESettings{}),
|
ChannelLINE: (LINESettings{}),
|
||||||
ChannelOneBot: (OneBotSettings{}),
|
ChannelOneBot: (OneBotSettings{}),
|
||||||
ChannelQQ: (QQSettings{}),
|
ChannelQQ: (QQSettings{}),
|
||||||
|
|||||||
@@ -1019,6 +1019,38 @@ func TestDefaultConfig_ChannelStreamingDisabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig_DeltaChatExample(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
deltachat := cfg.Channels.Get(ChannelDeltaChat)
|
||||||
|
if deltachat == nil {
|
||||||
|
t.Fatal("DefaultConfig() missing deltachat channel")
|
||||||
|
}
|
||||||
|
if deltachat.Enabled {
|
||||||
|
t.Fatal("DefaultConfig().deltachat should be disabled")
|
||||||
|
}
|
||||||
|
if !deltachat.GroupTrigger.MentionOnly {
|
||||||
|
t.Fatal("DefaultConfig().deltachat should use mention-only group trigger")
|
||||||
|
}
|
||||||
|
decoded, err := deltachat.GetDecoded()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deltachat GetDecoded() error = %v", err)
|
||||||
|
}
|
||||||
|
settings, ok := decoded.(*DeltaChatSettings)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("deltachat settings type = %T, want *DeltaChatSettings", decoded)
|
||||||
|
}
|
||||||
|
if settings.Email != "@nine.testrun.org" {
|
||||||
|
t.Fatalf("DefaultConfig().deltachat.settings.email = %q, want @nine.testrun.org", settings.Email)
|
||||||
|
}
|
||||||
|
if settings.Password.String() != "" {
|
||||||
|
t.Fatal("DefaultConfig().deltachat.settings.password should be empty")
|
||||||
|
}
|
||||||
|
if settings.DisplayName == "" {
|
||||||
|
t.Fatal("DefaultConfig().deltachat.settings.display_name should be populated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateSingletonChannels_RejectsMultipleInstances(t *testing.T) {
|
func TestValidateSingletonChannels_RejectsMultipleInstances(t *testing.T) {
|
||||||
channels := ChannelsConfig{
|
channels := ChannelsConfig{
|
||||||
"pico1": &Channel{Enabled: true, Type: ChannelPico},
|
"pico1": &Channel{Enabled: true, Type: ChannelPico},
|
||||||
|
|||||||
@@ -549,6 +549,13 @@ func defaultChannels() ChannelsConfig {
|
|||||||
"join_on_invite": true,
|
"join_on_invite": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"deltachat": map[string]any{
|
||||||
|
"group_trigger": map[string]any{"mention_only": true},
|
||||||
|
"settings": map[string]any{
|
||||||
|
"email": "@nine.testrun.org",
|
||||||
|
"display_name": "PicoClaw Bot",
|
||||||
|
},
|
||||||
|
},
|
||||||
"line": map[string]any{
|
"line": map[string]any{
|
||||||
"group_trigger": map[string]any{"mention_only": true},
|
"group_trigger": map[string]any{"mention_only": true},
|
||||||
"settings": map[string]any{
|
"settings": map[string]any{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/sipeed/picoclaw/pkg/audio/tts"
|
"github.com/sipeed/picoclaw/pkg/audio/tts"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/deltachat"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
||||||
|
|||||||
@@ -181,8 +181,15 @@ func buildParams(
|
|||||||
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
||||||
}
|
}
|
||||||
for _, tc := range msg.ToolCalls {
|
for _, tc := range msg.ToolCalls {
|
||||||
|
// Resolve tool name: prefer tc.Name, fallback to tc.Function.Name
|
||||||
|
// (tc.Name/tc.Arguments are json:"-" and may be empty when
|
||||||
|
// history is reloaded from the session store)
|
||||||
|
toolName := tc.Name
|
||||||
|
if toolName == "" && tc.Function != nil {
|
||||||
|
toolName = tc.Function.Name
|
||||||
|
}
|
||||||
// Skip tool calls with empty names to avoid API errors
|
// Skip tool calls with empty names to avoid API errors
|
||||||
if tc.Name == "" {
|
if toolName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
args := tc.Arguments
|
args := tc.Arguments
|
||||||
@@ -194,7 +201,7 @@ func buildParams(
|
|||||||
if args == nil {
|
if args == nil {
|
||||||
args = map[string]any{}
|
args = map[string]any{}
|
||||||
}
|
}
|
||||||
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, tc.Name))
|
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, toolName))
|
||||||
}
|
}
|
||||||
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -77,6 +78,124 @@ func TestBuildParams_ToolCallMessage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBuildParams_ToolCallFunctionFallback verifies that tool calls whose
|
||||||
|
// runtime-only fields were lost in a JSON round-trip through the session store
|
||||||
|
// (ToolCall.Name/Arguments are json:"-"; only ToolCall.Function survives) fall
|
||||||
|
// back to Function.Name / Function.Arguments, so the tool_use block is still
|
||||||
|
// emitted and its tool_result pair stays intact. Without the fallback the
|
||||||
|
// tool_use is skipped and the orphaned tool_result 400s at the API
|
||||||
|
// ("unexpected tool_use_id found in tool_result blocks").
|
||||||
|
func TestBuildParams_ToolCallFunctionFallback(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
toolCall ToolCall
|
||||||
|
wantSkipped bool
|
||||||
|
wantToolName string
|
||||||
|
wantInput map[string]any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "deserialized history shape falls back to Function fields",
|
||||||
|
toolCall: ToolCall{
|
||||||
|
ID: "toolu-fallback-1",
|
||||||
|
Name: "",
|
||||||
|
Arguments: nil,
|
||||||
|
Function: &FunctionCall{Name: "x", Arguments: `{"a":1}`},
|
||||||
|
},
|
||||||
|
wantToolName: "x",
|
||||||
|
wantInput: map[string]any{"a": float64(1)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "runtime shape with Name set and Function nil still works",
|
||||||
|
toolCall: ToolCall{
|
||||||
|
ID: "toolu-runtime-1",
|
||||||
|
Name: "y",
|
||||||
|
Arguments: map[string]any{"b": 2},
|
||||||
|
},
|
||||||
|
wantToolName: "y",
|
||||||
|
wantInput: map[string]any{"b": 2},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both Name and Function.Name empty is skipped",
|
||||||
|
toolCall: ToolCall{
|
||||||
|
ID: "toolu-empty-1",
|
||||||
|
Name: "",
|
||||||
|
Function: &FunctionCall{Name: "", Arguments: `{"c":3}`},
|
||||||
|
},
|
||||||
|
wantSkipped: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "user", Content: "run the tool"},
|
||||||
|
{Role: "assistant", Content: "", ToolCalls: []ToolCall{tt.toolCall}},
|
||||||
|
{Role: "tool", ToolCallID: tt.toolCall.ID, Content: "result"},
|
||||||
|
}
|
||||||
|
|
||||||
|
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
|
}
|
||||||
|
if len(params.Messages) != 3 {
|
||||||
|
t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantMsg := params.Messages[1]
|
||||||
|
var toolUses []*anthropic.ToolUseBlockParam
|
||||||
|
for _, block := range assistantMsg.Content {
|
||||||
|
if block.OfToolUse != nil {
|
||||||
|
toolUses = append(toolUses, block.OfToolUse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tool_result in the following user message always carries the
|
||||||
|
// original ID; look it up once for both branches.
|
||||||
|
toolResultMsg := params.Messages[2]
|
||||||
|
if len(toolResultMsg.Content) != 1 || toolResultMsg.Content[0].OfToolResult == nil {
|
||||||
|
t.Fatalf("message after assistant = %+v, want single tool_result block", toolResultMsg.Content)
|
||||||
|
}
|
||||||
|
toolResult := toolResultMsg.Content[0].OfToolResult
|
||||||
|
|
||||||
|
if tt.wantSkipped {
|
||||||
|
if len(toolUses) != 0 {
|
||||||
|
t.Fatalf("tool_use blocks = %d, want 0 (tool call skipped)", len(toolUses))
|
||||||
|
}
|
||||||
|
// Note: matching current behavior, the orphaned tool_result is
|
||||||
|
// still emitted even though its tool_use block was skipped.
|
||||||
|
if toolResult.ToolUseID != tt.toolCall.ID {
|
||||||
|
t.Fatalf("orphaned tool_result ToolUseID = %q, want %q",
|
||||||
|
toolResult.ToolUseID, tt.toolCall.ID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a) tool_use block emitted with resolved name, id, and parsed input.
|
||||||
|
if len(toolUses) != 1 {
|
||||||
|
t.Fatalf("tool_use blocks = %d, want 1", len(toolUses))
|
||||||
|
}
|
||||||
|
toolUse := toolUses[0]
|
||||||
|
if toolUse.Name != tt.wantToolName {
|
||||||
|
t.Errorf("tool_use Name = %q, want %q", toolUse.Name, tt.wantToolName)
|
||||||
|
}
|
||||||
|
if toolUse.ID != tt.toolCall.ID {
|
||||||
|
t.Errorf("tool_use ID = %q, want %q", toolUse.ID, tt.toolCall.ID)
|
||||||
|
}
|
||||||
|
gotInput, ok := toolUse.Input.(map[string]any)
|
||||||
|
if !ok || !reflect.DeepEqual(gotInput, tt.wantInput) {
|
||||||
|
t.Errorf("tool_use Input = %#v, want %#v", toolUse.Input, tt.wantInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) the following user message's tool_result references the same
|
||||||
|
// id as the tool_use block — the pair is intact.
|
||||||
|
if toolResult.ToolUseID != toolUse.ID {
|
||||||
|
t.Errorf("tool_result ToolUseID = %q, want %q (paired with tool_use)",
|
||||||
|
toolResult.ToolUseID, toolUse.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildParams_WithTools(t *testing.T) {
|
func TestBuildParams_WithTools(t *testing.T) {
|
||||||
tools := []ToolDefinition{
|
tools := []ToolDefinition{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -233,12 +233,26 @@ func buildRequestBody(
|
|||||||
|
|
||||||
// Add tool_use blocks
|
// Add tool_use blocks
|
||||||
for _, tc := range msg.ToolCalls {
|
for _, tc := range msg.ToolCalls {
|
||||||
if strings.TrimSpace(tc.Name) == "" {
|
// Resolve tool name: prefer tc.Name, fallback to tc.Function.Name
|
||||||
|
// (tc.Name/tc.Arguments are json:"-" and may be empty when
|
||||||
|
// history is reloaded from the session store)
|
||||||
|
toolName := tc.Name
|
||||||
|
if toolName == "" && tc.Function != nil {
|
||||||
|
toolName = tc.Function.Name
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(toolName) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle nil Arguments (GLM-4 may return null input)
|
// Resolve arguments: prefer tc.Arguments, fallback to parsing
|
||||||
|
// tc.Function.Arguments
|
||||||
input := tc.Arguments
|
input := tc.Arguments
|
||||||
|
if input == nil && tc.Function != nil && tc.Function.Arguments != "" {
|
||||||
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||||
|
input = map[string]any{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle nil Arguments (GLM-4 may return null input)
|
||||||
if input == nil {
|
if input == nil {
|
||||||
input = map[string]any{}
|
input = map[string]any{}
|
||||||
}
|
}
|
||||||
@@ -246,7 +260,7 @@ func buildRequestBody(
|
|||||||
toolUse := map[string]any{
|
toolUse := map[string]any{
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": tc.ID,
|
"id": tc.ID,
|
||||||
"name": tc.Name,
|
"name": toolName,
|
||||||
"input": input,
|
"input": input,
|
||||||
}
|
}
|
||||||
content = append(content, toolUse)
|
content = append(content, toolUse)
|
||||||
|
|||||||
@@ -614,6 +614,126 @@ func TestBuildRequestBody_UserToolResultsMerged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBuildRequestBody_ToolCallFunctionFallback verifies that tool calls whose
|
||||||
|
// runtime-only fields were lost in a JSON round-trip through the session store
|
||||||
|
// (ToolCall.Name/Arguments are json:"-"; only ToolCall.Function survives) fall
|
||||||
|
// back to Function.Name / Function.Arguments, so the tool_use block is still
|
||||||
|
// emitted and its tool_result pair stays intact. Without the fallback the
|
||||||
|
// tool_use is skipped and the orphaned tool_result 400s at the API
|
||||||
|
// ("unexpected tool_use_id found in tool_result blocks").
|
||||||
|
func TestBuildRequestBody_ToolCallFunctionFallback(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
toolCall ToolCall
|
||||||
|
wantSkipped bool
|
||||||
|
wantToolName string
|
||||||
|
wantInput map[string]any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "deserialized history shape falls back to Function fields",
|
||||||
|
toolCall: ToolCall{
|
||||||
|
ID: "toolu-fallback-1",
|
||||||
|
Name: "",
|
||||||
|
Arguments: nil,
|
||||||
|
Function: &FunctionCall{Name: "x", Arguments: `{"a":1}`},
|
||||||
|
},
|
||||||
|
wantToolName: "x",
|
||||||
|
wantInput: map[string]any{"a": float64(1)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "runtime shape with Name set and Function nil still works",
|
||||||
|
toolCall: ToolCall{
|
||||||
|
ID: "toolu-runtime-1",
|
||||||
|
Name: "y",
|
||||||
|
Arguments: map[string]any{"b": 2},
|
||||||
|
},
|
||||||
|
wantToolName: "y",
|
||||||
|
wantInput: map[string]any{"b": 2},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both Name and Function.Name empty is skipped",
|
||||||
|
toolCall: ToolCall{
|
||||||
|
ID: "toolu-empty-1",
|
||||||
|
Name: "",
|
||||||
|
Function: &FunctionCall{Name: "", Arguments: `{"c":3}`},
|
||||||
|
},
|
||||||
|
wantSkipped: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "user", Content: "run the tool"},
|
||||||
|
{Role: "assistant", Content: "", ToolCalls: []ToolCall{tt.toolCall}},
|
||||||
|
{Role: "tool", ToolCallID: tt.toolCall.ID, Content: "result"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 8192})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRequestBody() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiMessages := got["messages"].([]any)
|
||||||
|
if len(apiMessages) != 3 {
|
||||||
|
t.Fatalf("expected 3 API messages, got %d", len(apiMessages))
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantMsg := apiMessages[1].(map[string]any)
|
||||||
|
content := assistantMsg["content"].([]any)
|
||||||
|
|
||||||
|
if tt.wantSkipped {
|
||||||
|
if len(content) != 0 {
|
||||||
|
t.Fatalf("assistant content = %#v, want empty (tool call skipped)", content)
|
||||||
|
}
|
||||||
|
// Note: matching current behavior, the orphaned tool_result is
|
||||||
|
// still emitted in the following user message even though its
|
||||||
|
// tool_use block was skipped.
|
||||||
|
toolResultMsg := apiMessages[2].(map[string]any)
|
||||||
|
blocks := toolResultMsg["content"].([]map[string]any)
|
||||||
|
if len(blocks) != 1 || blocks[0]["tool_use_id"] != tt.toolCall.ID {
|
||||||
|
t.Fatalf("orphaned tool_result = %#v, want single block with tool_use_id %q",
|
||||||
|
blocks, tt.toolCall.ID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a) tool_use block emitted with resolved name, id, and parsed input.
|
||||||
|
if len(content) != 1 {
|
||||||
|
t.Fatalf("assistant content length = %d, want 1 tool_use block", len(content))
|
||||||
|
}
|
||||||
|
toolUse := content[0].(map[string]any)
|
||||||
|
if toolUse["type"] != "tool_use" {
|
||||||
|
t.Fatalf("block type = %v, want tool_use", toolUse["type"])
|
||||||
|
}
|
||||||
|
if toolUse["name"] != tt.wantToolName {
|
||||||
|
t.Errorf("tool_use name = %v, want %q", toolUse["name"], tt.wantToolName)
|
||||||
|
}
|
||||||
|
if toolUse["id"] != tt.toolCall.ID {
|
||||||
|
t.Errorf("tool_use id = %v, want %q", toolUse["id"], tt.toolCall.ID)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(toolUse["input"], tt.wantInput) {
|
||||||
|
t.Errorf("tool_use input = %#v, want %#v", toolUse["input"], tt.wantInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) the following user message's tool_result references the same
|
||||||
|
// id as the tool_use block — the pair is intact.
|
||||||
|
toolResultMsg := apiMessages[2].(map[string]any)
|
||||||
|
if toolResultMsg["role"] != "user" {
|
||||||
|
t.Fatalf("message after assistant role = %v, want user", toolResultMsg["role"])
|
||||||
|
}
|
||||||
|
blocks := toolResultMsg["content"].([]map[string]any)
|
||||||
|
if len(blocks) != 1 {
|
||||||
|
t.Fatalf("tool_result blocks = %d, want 1", len(blocks))
|
||||||
|
}
|
||||||
|
if blocks[0]["tool_use_id"] != toolUse["id"] {
|
||||||
|
t.Errorf("tool_result tool_use_id = %v, want %v (paired with tool_use)",
|
||||||
|
blocks[0]["tool_use_id"], toolUse["id"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody.
|
// TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody.
|
||||||
func TestParseResponseBodyEdgeCases(t *testing.T) {
|
func TestParseResponseBodyEdgeCases(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
@@ -862,7 +862,8 @@ func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WriteFileTool struct {
|
type WriteFileTool struct {
|
||||||
fs fileSystem
|
fs fileSystem
|
||||||
|
altTools []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWriteFileTool(
|
func NewWriteFileTool(
|
||||||
@@ -874,7 +875,32 @@ func NewWriteFileTool(
|
|||||||
if len(allowPaths) > 0 {
|
if len(allowPaths) > 0 {
|
||||||
patterns = allowPaths[0]
|
patterns = allowPaths[0]
|
||||||
}
|
}
|
||||||
return &WriteFileTool{fs: buildFs(workspace, restrict, patterns)}
|
// Default to both alternatives so standalone callers keep the full guidance;
|
||||||
|
// the agent wiring narrows this to the tools actually registered.
|
||||||
|
return &WriteFileTool{
|
||||||
|
fs: buildFs(workspace, restrict, patterns),
|
||||||
|
altTools: []string{"append_file", "edit_file"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAlternativeTools limits which alternatives the copy names, so it never
|
||||||
|
// directs the model to tools that are not available.
|
||||||
|
func (t *WriteFileTool) SetAlternativeTools(names []string) {
|
||||||
|
present := make(map[string]bool, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
present[name] = true
|
||||||
|
}
|
||||||
|
ordered := make([]string, 0, 2)
|
||||||
|
for _, name := range []string{"append_file", "edit_file"} {
|
||||||
|
if present[name] {
|
||||||
|
ordered = append(ordered, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.altTools = ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *WriteFileTool) altToolsPhrase() string {
|
||||||
|
return strings.Join(t.altTools, " or ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *WriteFileTool) Name() string {
|
func (t *WriteFileTool) Name() string {
|
||||||
@@ -882,10 +908,24 @@ func (t *WriteFileTool) Name() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *WriteFileTool) Description() string {
|
func (t *WriteFileTool) Description() string {
|
||||||
return "Write content to a file. Content is written byte-for-byte after argument decoding. Standard JSON escaping applies: \\n for newline and \\\\n for a literal backslash-n sequence. If the file already exists, you must set overwrite=true to replace it."
|
desc := "Write content to a file, replacing any existing content. Content is written byte-for-byte after argument decoding. Standard JSON escaping applies: \\n for newline and \\\\n for a literal backslash-n sequence. If the file already exists you must set overwrite=true, which replaces the ENTIRE file."
|
||||||
|
if phrase := t.altToolsPhrase(); phrase != "" {
|
||||||
|
desc += fmt.Sprintf(
|
||||||
|
" To add to or change part of an existing file without losing its current contents, use %s instead.",
|
||||||
|
phrase,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return desc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *WriteFileTool) Parameters() map[string]any {
|
func (t *WriteFileTool) Parameters() map[string]any {
|
||||||
|
overwriteDesc := "Set to true to replace an existing file in full. This discards the file's current contents."
|
||||||
|
if phrase := t.altToolsPhrase(); phrase != "" {
|
||||||
|
overwriteDesc = fmt.Sprintf(
|
||||||
|
"Set to true to replace an existing file in full. This discards the file's current contents — to preserve them, use %s instead of write_file.",
|
||||||
|
phrase,
|
||||||
|
)
|
||||||
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
@@ -899,7 +939,7 @@ func (t *WriteFileTool) Parameters() map[string]any {
|
|||||||
},
|
},
|
||||||
"overwrite": map[string]any{
|
"overwrite": map[string]any{
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Must be set to true to overwrite an existing file.",
|
"description": overwriteDesc,
|
||||||
"default": false,
|
"default": false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -922,8 +962,20 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
|
|||||||
|
|
||||||
if !overwrite {
|
if !overwrite {
|
||||||
if _, err := t.fs.Open(path); err == nil {
|
if _, err := t.fs.Open(path); err == nil {
|
||||||
|
if phrase := t.altToolsPhrase(); phrase != "" {
|
||||||
|
return ErrorResult(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"file: %s already exists. To add to it or change part of it without losing the current contents, use %s. Only set overwrite=true if you intend to replace the entire file.",
|
||||||
|
path,
|
||||||
|
phrase,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
return ErrorResult(
|
return ErrorResult(
|
||||||
fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path),
|
fmt.Sprintf(
|
||||||
|
"file: %s already exists. Set overwrite=true only if you intend to replace the entire file, which discards its current contents.",
|
||||||
|
path,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1064,8 +1116,8 @@ func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// os.Root api on windows only accept forward slashes (/)
|
// os.Root API on Windows only accepts forward slashes (/).
|
||||||
relPath = filepath.ToSlash(relPath)
|
relPath = normalizeRootRelPath(relPath)
|
||||||
|
|
||||||
return fn(root, relPath)
|
return fn(root, relPath)
|
||||||
}
|
}
|
||||||
@@ -1230,6 +1282,17 @@ func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSys
|
|||||||
return sandbox
|
return sandbox
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeRootRelPath(relPath string) string {
|
||||||
|
return normalizeRootRelPathForSeparator(relPath, os.PathSeparator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRootRelPathForSeparator(relPath string, sep rune) string {
|
||||||
|
if sep == '\\' {
|
||||||
|
return strings.ReplaceAll(relPath, `\`, `/`)
|
||||||
|
}
|
||||||
|
return relPath
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to get a safe relative path for os.Root usage
|
// Helper to get a safe relative path for os.Root usage
|
||||||
func getSafeRelPath(workspace, path string) (string, error) {
|
func getSafeRelPath(workspace, path string) (string, error) {
|
||||||
if workspace == "" {
|
if workspace == "" {
|
||||||
|
|||||||
@@ -250,6 +250,9 @@ func TestFilesystemTool_WriteFile_OverwriteDefaultBlocked(t *testing.T) {
|
|||||||
assert.True(t, result.IsError, "expected error when overwriting without overwrite=true")
|
assert.True(t, result.IsError, "expected error when overwriting without overwrite=true")
|
||||||
assert.Contains(t, result.ForLLM, "already exists")
|
assert.Contains(t, result.ForLLM, "already exists")
|
||||||
assert.Contains(t, result.ForLLM, "overwrite=true")
|
assert.Contains(t, result.ForLLM, "overwrite=true")
|
||||||
|
// The guard must steer toward non-destructive tools rather than only coaching overwrite.
|
||||||
|
assert.Contains(t, result.ForLLM, "append_file")
|
||||||
|
assert.Contains(t, result.ForLLM, "edit_file")
|
||||||
|
|
||||||
// Original content must be untouched
|
// Original content must be untouched
|
||||||
data, err := os.ReadFile(testFile)
|
data, err := os.ReadFile(testFile)
|
||||||
@@ -257,6 +260,76 @@ func TestFilesystemTool_WriteFile_OverwriteDefaultBlocked(t *testing.T) {
|
|||||||
assert.Equal(t, "original", string(data))
|
assert.Equal(t, "original", string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy (description, overwrite param, guard) only names available alternatives.
|
||||||
|
func TestFilesystemTool_WriteFile_AltToolsConditionalCopy(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "existing.txt")
|
||||||
|
os.WriteFile(testFile, []byte("original"), 0o644)
|
||||||
|
|
||||||
|
overwriteParamDesc := func(tool *WriteFileTool) string {
|
||||||
|
props := tool.Parameters()["properties"].(map[string]any)
|
||||||
|
return props["overwrite"].(map[string]any)["description"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no alternatives available", func(t *testing.T) {
|
||||||
|
tool := NewWriteFileTool("", false)
|
||||||
|
tool.SetAlternativeTools(nil)
|
||||||
|
|
||||||
|
assert.NotContains(t, tool.Description(), "append_file")
|
||||||
|
assert.NotContains(t, tool.Description(), "edit_file")
|
||||||
|
assert.NotContains(t, overwriteParamDesc(tool), "append_file")
|
||||||
|
assert.NotContains(t, overwriteParamDesc(tool), "edit_file")
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"content": "new content",
|
||||||
|
})
|
||||||
|
assert.True(t, result.IsError, "expected overwrite guard to still block")
|
||||||
|
assert.Contains(t, result.ForLLM, "already exists")
|
||||||
|
assert.Contains(t, result.ForLLM, "overwrite=true")
|
||||||
|
assert.NotContains(t, result.ForLLM, "append_file")
|
||||||
|
assert.NotContains(t, result.ForLLM, "edit_file")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("only append_file available", func(t *testing.T) {
|
||||||
|
tool := NewWriteFileTool("", false)
|
||||||
|
tool.SetAlternativeTools([]string{"append_file"})
|
||||||
|
|
||||||
|
assert.Contains(t, tool.Description(), "append_file")
|
||||||
|
assert.NotContains(t, tool.Description(), "edit_file")
|
||||||
|
assert.Contains(t, overwriteParamDesc(tool), "append_file")
|
||||||
|
assert.NotContains(t, overwriteParamDesc(tool), "edit_file")
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"content": "new content",
|
||||||
|
})
|
||||||
|
assert.True(t, result.IsError)
|
||||||
|
assert.Contains(t, result.ForLLM, "append_file")
|
||||||
|
assert.NotContains(t, result.ForLLM, "edit_file")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("both available uses canonical order", func(t *testing.T) {
|
||||||
|
tool := NewWriteFileTool("", false)
|
||||||
|
// Reversed input to confirm the order is normalized.
|
||||||
|
tool.SetAlternativeTools([]string{"edit_file", "append_file"})
|
||||||
|
|
||||||
|
assert.Contains(t, tool.Description(), "append_file or edit_file")
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"content": "new content",
|
||||||
|
})
|
||||||
|
assert.True(t, result.IsError)
|
||||||
|
assert.Contains(t, result.ForLLM, "append_file or edit_file")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Blocked writes must leave the original untouched.
|
||||||
|
data, err := os.ReadFile(testFile)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "original", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
// TestFilesystemTool_WriteFile_OverwriteExplicitAllowed verifies that setting
|
// TestFilesystemTool_WriteFile_OverwriteExplicitAllowed verifies that setting
|
||||||
// overwrite=true replaces the existing file.
|
// overwrite=true replaces the existing file.
|
||||||
func TestFilesystemTool_WriteFile_OverwriteExplicitAllowed(t *testing.T) {
|
func TestFilesystemTool_WriteFile_OverwriteExplicitAllowed(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package fstools
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeRootRelPathForWindowsSeparator(t *testing.T) {
|
||||||
|
got := normalizeRootRelPathForSeparator(`aaa\bbb\file.txt`, '\\')
|
||||||
|
want := "aaa/bbb/file.txt"
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("normalizeRootRelPathForSeparator() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeRootRelPathForUnixSeparatorLeavesBackslashUnchanged(t *testing.T) {
|
||||||
|
input := `aaa\bbb\file.txt`
|
||||||
|
got := normalizeRootRelPathForSeparator(input, '/')
|
||||||
|
|
||||||
|
if got != input {
|
||||||
|
t.Fatalf("normalizeRootRelPathForSeparator() = %q, want %q", got, input)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -315,7 +315,7 @@ func (p *BraveSearchProvider) Search(
|
|||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = fmt.Errorf("failed to read response: %w", err)
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||||
@@ -448,7 +448,7 @@ func (p *TavilySearchProvider) Search(
|
|||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = fmt.Errorf("failed to read response: %w", err)
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||||
@@ -906,7 +906,7 @@ func (p *SogouSearchProvider) Search(
|
|||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
return "", fmt.Errorf("failed to read response: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1147,7 +1147,7 @@ func (p *PerplexitySearchProvider) Search(
|
|||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = fmt.Errorf("failed to read response: %w", err)
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||||
|
|||||||
+20
-21
@@ -1135,36 +1135,35 @@ func expandPowerShellEnvVars(cmd string) string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) commandMatchesAllowPattern(lower string) bool {
|
||||||
|
for _, pattern := range t.allowPatterns {
|
||||||
|
if pattern.MatchString(lower) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, pattern := range t.customAllowPatterns {
|
||||||
|
if pattern.MatchString(lower) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ExecTool) guardCommand(command, cwd string) string {
|
func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
cmd := strings.TrimSpace(command)
|
cmd := strings.TrimSpace(command)
|
||||||
lower := strings.ToLower(cmd)
|
lower := strings.ToLower(cmd)
|
||||||
|
|
||||||
// Custom allow patterns exempt a command from deny checks.
|
// Deny patterns always apply, even when a command matches a custom allow rule.
|
||||||
explicitlyAllowed := false
|
// Custom allow rules can permit a command, but must not disable secret-safety
|
||||||
for _, pattern := range t.customAllowPatterns {
|
// deny rules such as jq env access checks (#3079).
|
||||||
|
for _, pattern := range t.denyPatterns {
|
||||||
if pattern.MatchString(lower) {
|
if pattern.MatchString(lower) {
|
||||||
explicitlyAllowed = true
|
return "Command blocked by safety guard (dangerous pattern detected)"
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !explicitlyAllowed {
|
|
||||||
for _, pattern := range t.denyPatterns {
|
|
||||||
if pattern.MatchString(lower) {
|
|
||||||
return "Command blocked by safety guard (dangerous pattern detected)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(t.allowPatterns) > 0 {
|
if len(t.allowPatterns) > 0 {
|
||||||
allowed := false
|
if !t.commandMatchesAllowPattern(lower) {
|
||||||
for _, pattern := range t.allowPatterns {
|
|
||||||
if pattern.MatchString(lower) {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !allowed {
|
|
||||||
return "Command blocked by safety guard (not in allowlist)"
|
return "Command blocked by safety guard (not in allowlist)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1936,3 +1936,54 @@ func TestShellTool_SchemelessURLDetection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShellTool_CustomAllowDoesNotBypassDenyPatterns(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Tools.Exec.EnableDenyPatterns = true
|
||||||
|
cfg.Tools.Exec.CustomAllowPatterns = []string{`^jq\b`}
|
||||||
|
cfg.Tools.Exec.CustomDenyPatterns = []string{`\$env\b`, `(^|[^.$a-z0-9_])env([^a-z0-9_]|$)`}
|
||||||
|
|
||||||
|
tool, err := NewExecToolWithConfig(t.TempDir(), false, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewExecToolWithConfig() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := tool.guardCommand(`jq -n '$ENV.PICOCLAW_VARIANT_CANARY'`, t.TempDir())
|
||||||
|
if !strings.Contains(got, "dangerous pattern detected") {
|
||||||
|
t.Fatalf("custom allow should not bypass deny patterns, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellTool_CustomAllowStillPermitsSafeMatch(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Tools.Exec.EnableDenyPatterns = true
|
||||||
|
cfg.Tools.Exec.CustomAllowPatterns = []string{`^jq\b`}
|
||||||
|
cfg.Tools.Exec.CustomDenyPatterns = []string{`\$env\b`, `(^|[^.$a-z0-9_])env([^a-z0-9_]|$)`}
|
||||||
|
|
||||||
|
tool, err := NewExecToolWithConfig(t.TempDir(), false, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewExecToolWithConfig() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := tool.guardCommand(`jq -n '"ok"'`, t.TempDir())
|
||||||
|
if got != "" {
|
||||||
|
t.Fatalf("safe custom-allowed command should pass guard, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellTool_CustomAllowDoesNotBecomeStrictAllowlist(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Tools.Exec.EnableDenyPatterns = true
|
||||||
|
cfg.Tools.Exec.CustomAllowPatterns = []string{`^jq\b`}
|
||||||
|
cfg.Tools.Exec.CustomDenyPatterns = []string{`\$env\b`, `(^|[^.$a-z0-9_])env([^a-z0-9_]|$)`}
|
||||||
|
|
||||||
|
tool, err := NewExecToolWithConfig(t.TempDir(), false, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewExecToolWithConfig() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := tool.guardCommand("ls", t.TempDir())
|
||||||
|
if got != "" {
|
||||||
|
t.Fatalf("custom allow patterns should not become a strict allowlist, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||||
@@ -89,6 +90,60 @@ func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, er
|
|||||||
return h.store.IsInitialized(ctx)
|
return h.store.IsInitialized(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func launcherSetupCrossSite(r *http.Request) bool {
|
||||||
|
fetchSite := strings.ToLower(strings.TrimSpace(r.Header.Get("Sec-Fetch-Site")))
|
||||||
|
if fetchSite == "cross-site" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" {
|
||||||
|
return !sameLauncherRequestOrigin(r, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if referer := strings.TrimSpace(r.Header.Get("Referer")); referer != "" {
|
||||||
|
return !sameLauncherRequestOrigin(r, referer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameLauncherRequestOrigin(r *http.Request, raw string) bool {
|
||||||
|
if strings.ContainsAny(raw, " \t\r\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
wantScheme := launcherRequestScheme(r)
|
||||||
|
wantHost := r.Host
|
||||||
|
if wantHost == "" {
|
||||||
|
wantHost = r.URL.Host
|
||||||
|
}
|
||||||
|
return strings.EqualFold(u.Scheme, wantScheme) && strings.EqualFold(u.Host, wantHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
func launcherRequestScheme(r *http.Request) string {
|
||||||
|
if proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); proto != "" {
|
||||||
|
if i := strings.IndexByte(proto, ','); i >= 0 {
|
||||||
|
proto = proto[:i]
|
||||||
|
}
|
||||||
|
proto = strings.ToLower(strings.TrimSpace(proto))
|
||||||
|
if proto == "http" || proto == "https" {
|
||||||
|
return proto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.TLS != nil {
|
||||||
|
return "https"
|
||||||
|
}
|
||||||
|
if r.URL != nil && r.URL.Scheme != "" {
|
||||||
|
return r.URL.Scheme
|
||||||
|
}
|
||||||
|
return "http"
|
||||||
|
}
|
||||||
|
|
||||||
func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
var body launcherAuthLoginBody
|
var body launcherAuthLoginBody
|
||||||
@@ -198,6 +253,12 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque
|
|||||||
func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
|
func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if launcherSetupCrossSite(r) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"cross-site setup request rejected"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if h.store == nil {
|
if h.store == nil {
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
if h.storeErr != nil {
|
if h.storeErr != nil {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLauncherAuthSetupRejectsCrossSiteFirstRun(t *testing.T) {
|
||||||
|
store := &fakePasswordStore{}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||||
|
SessionCookie: "session-cookie-value",
|
||||||
|
PasswordStore: store,
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"http://127.0.0.1:18800/api/auth/setup",
|
||||||
|
strings.NewReader(`{"password":"CrossSitePwn123!","confirm":"CrossSitePwn123!"}`),
|
||||||
|
)
|
||||||
|
req.Header.Set("Origin", "https://evil.example")
|
||||||
|
req.Header.Set("Referer", "https://evil.example/attack")
|
||||||
|
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("cross-site setup code = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if store.initialized || store.password != "" {
|
||||||
|
t.Fatalf("cross-site setup mutated store: initialized=%v password=%q", store.initialized, store.password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLauncherAuthSetupAllowsSameOriginFirstRun(t *testing.T) {
|
||||||
|
store := &fakePasswordStore{}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||||
|
SessionCookie: "session-cookie-value",
|
||||||
|
PasswordStore: store,
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"http://127.0.0.1:18800/api/auth/setup",
|
||||||
|
strings.NewReader(`{"password":"LocalSetup123!","confirm":"LocalSetup123!"}`),
|
||||||
|
)
|
||||||
|
req.Header.Set("Origin", "http://127.0.0.1:18800")
|
||||||
|
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("same-origin setup code = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !store.initialized || store.password != "LocalSetup123!" {
|
||||||
|
t.Fatalf("same-origin setup store: initialized=%v password=%q", store.initialized, store.password)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,14 +35,14 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.5",
|
"react": "19.2.5",
|
||||||
"react-dom": "19.2.5",
|
"react-dom": "19.2.5",
|
||||||
"react-i18next": "^17.0.6",
|
"react-i18next": "^17.0.7",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-textarea-autosize": "^8.5.9",
|
"react-textarea-autosize": "^8.5.9",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.12.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.58.2",
|
"@typescript-eslint/eslint-plugin": "^8.58.2",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.59.3",
|
"typescript-eslint": "^8.62.1",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+701
-776
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user