diff --git a/.gitignore b/.gitignore
index ce30d749e..3ff195fbf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,7 +10,7 @@ build/
*.out
/picoclaw
/picoclaw-test
-cmd/picoclaw/workspace
+cmd/**/workspace
# Picoclaw specific
diff --git a/.golangci.yaml b/.golangci.yaml
index d45d69e67..d0ba90716 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -28,9 +28,7 @@ linters:
- wsl_v5
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
- - bodyclose
- contextcheck
- - dogsled
- embeddedstructfieldcheck
- errcheck
- errchkjson
@@ -45,32 +43,24 @@ linters:
- gocritic
- gocyclo
- godox
- - goprintffuncname
- gosec
- ineffassign
- lll
- maintidx
- - misspell
- mnd
- modernize
- - nakedret
- nestif
- nilnil
- paralleltest
- perfsprint
- - prealloc
- - predeclared
- revive
- staticcheck
- tagalign
- testifylint
- thelper
- unparam
- - unused
- usestdlibvars
- usetesting
- - wastedassign
- - whitespace
settings:
errcheck:
check-type-assertions: true
@@ -152,6 +142,9 @@ linters:
- gocognit
- gocyclo
path: _test\.go$
+ - linters:
+ - nolintlint
+ path: 'pkg/tools/(i2c\.go|spi\.go)$'
issues:
max-issues-per-linter: 0
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 2c47f7d86..af26509e6 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -5,7 +5,7 @@ version: 2
before:
hooks:
- go mod tidy
- - go generate ./cmd/picoclaw
+ - go generate ./cmd/picoclaw/...
builds:
- id: picoclaw
@@ -15,10 +15,10 @@ builds:
- stdjson
ldflags:
- -s -w
- - -X main.version={{ .Version }}
- - -X main.gitCommit={{ .ShortCommit }}
- - -X main.buildTime={{ .Date }}
- - -X main.goVersion={{ .Env.GOVERSION }}
+ - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }}
+ - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }}
+ - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }}
+ - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }}
goos:
- linux
- windows
@@ -28,9 +28,10 @@ builds:
- amd64
- arm64
- riscv64
- - s390x
- - mips64
+ - loong64
- arm
+ goarm:
+ - "7"
main: ./cmd/picoclaw
ignore:
- goos: windows
@@ -67,6 +68,25 @@ archives:
- goos: windows
formats: [zip]
+nfpms:
+ - id: picoclaw
+ package_name: picoclaw
+ file_name_template: >-
+ {{ .PackageName }}_
+ {{- if eq .Arch "amd64" }}x86_64
+ {{- else if eq .Arch "arm64" }}aarch64
+ {{- else if eq .Arch "arm" }}armv{{ .Arm }}
+ {{- else }}{{ .Arch }}{{ end }}
+ vendor: picoclaw
+ homepage: https://github.com/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw
+ maintainer: picoclaw contributors
+ description: picoclaw - a tool for managing and running tasks
+ license: MIT
+ formats:
+ - rpm
+ - deb
+ bindir: /usr/bin
+
changelog:
sort: asc
filters:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..88227f493
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,302 @@
+# Contributing to PicoClaw
+
+Thank you for your interest in contributing to PicoClaw! This project is a community-driven effort to build the lightweight and versatile personal AI assistant. We welcome contributions of all kinds: bug fixes, features, documentation, translations, and testing.
+
+PicoClaw itself was substantially developed with AI assistance — we embrace this approach and have built our contribution process around it.
+
+## Table of Contents
+
+- [Code of Conduct](#code-of-conduct)
+- [Ways to Contribute](#ways-to-contribute)
+- [Getting Started](#getting-started)
+- [Development Setup](#development-setup)
+- [Making Changes](#making-changes)
+- [AI-Assisted Contributions](#ai-assisted-contributions)
+- [Pull Request Process](#pull-request-process)
+- [Branch Strategy](#branch-strategy)
+- [Code Review](#code-review)
+- [Communication](#communication)
+
+---
+
+## Code of Conduct
+
+We are committed to maintaining a welcoming and respectful community. Be kind, constructive, and assume good faith. Harassment or discrimination of any kind will not be tolerated.
+
+---
+
+## Ways to Contribute
+
+- **Bug reports** — Open an issue using the bug report template.
+- **Feature requests** — Open an issue using the feature request template; discuss before implementing.
+- **Code** — Fix bugs or implement features. See the workflow below.
+- **Documentation** — Improve READMEs, docs, inline comments, or translations.
+- **Testing** — Run PicoClaw on new hardware, channels, or LLM providers and report your results.
+
+For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction.
+
+---
+
+## Getting Started
+
+1. **Fork** the repository on GitHub.
+2. **Clone** your fork locally:
+ ```bash
+ git clone https://github.com//picoclaw.git
+ cd picoclaw
+ ```
+3. Add the upstream remote:
+ ```bash
+ git remote add upstream https://github.com/sipeed/picoclaw.git
+ ```
+
+---
+
+## Development Setup
+
+### Prerequisites
+
+- Go 1.25 or later
+- `make`
+
+### Build
+
+```bash
+make build # Build binary (runs go generate first)
+make generate # Run go generate only
+make check # Full pre-commit check: deps + fmt + vet + test
+```
+
+### Running Tests
+
+```bash
+make test # Run all tests
+go test -run TestName -v ./pkg/session/ # Run a single test
+go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
+```
+
+### Code Style
+
+```bash
+make fmt # Format code
+make vet # Static analysis
+make lint # Full linter run
+```
+
+All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early.
+
+---
+
+## Making Changes
+
+### Branching
+
+Always branch off `main` and target `main` in your PR. Never push directly to `main` or any `release/*` branch:
+
+```bash
+git checkout main
+git pull upstream main
+git checkout -b your-feature-branch
+```
+
+Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider`, `docs/contributing-guide`.
+
+### Commits
+
+- Write clear, concise commit messages in English.
+- Use the imperative mood: "Add retry logic" not "Added retry logic".
+- Reference the related issue when relevant: `Fix session leak (#123)`.
+- Keep commits focused. One logical change per commit is preferred.
+- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
+- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/
+
+### Keeping Up to Date
+
+Rebase your branch onto upstream `main` before opening a PR:
+
+```bash
+git fetch upstream
+git rebase upstream/main
+```
+
+---
+
+## AI-Assisted Contributions
+
+PicoClaw was built with substantial AI assistance, and we fully embrace AI-assisted development. However, contributors must understand their responsibilities when using AI tools.
+
+### Disclosure Is Required
+
+Every PR must disclose AI involvement using the PR template's **🤖 AI Code Generation** section. There are three levels:
+
+| Level | Description |
+|---|---|
+| 🤖 Fully AI-generated | AI wrote the code; contributor reviewed and validated it |
+| 🛠️ Mostly AI-generated | AI produced the draft; contributor made significant modifications |
+| 👨💻 Mostly Human-written | Contributor led; AI provided suggestions or none at all |
+
+Honest disclosure is expected. There is no stigma attached to any level — what matters is the quality of the contribution.
+
+### You Are Responsible for What You Submit
+
+Using AI to generate code does not reduce your responsibility as the contributor. Before opening a PR with AI-generated code, you must:
+
+- **Read and understand** every line of the generated code.
+- **Test it** in a real environment (see the Test Environment section of the PR template).
+- **Check for security issues** — AI models can generate subtly insecure code (e.g., path traversal, injection, credential exposure). Review carefully.
+- **Verify correctness** — AI-generated logic can be plausible-sounding but wrong. Validate the behavior, not just the syntax.
+
+PRs where it is clear the contributor has not read or tested the AI-generated code will be closed without review.
+
+### AI-Generated Code Quality Standards
+
+AI-generated contributions are held to the **same quality bar** as human-written code:
+
+- It must pass all CI checks (`make check`).
+- It must be idiomatic Go and consistent with the existing codebase style.
+- It must not introduce unnecessary abstractions, dead code, or over-engineering.
+- It must include or update tests where appropriate.
+
+### Security Review
+
+AI-generated code requires extra security scrutiny. Pay special attention to:
+
+- File path handling and sandbox escapes (see commit `244eb0b` for a real example)
+- External input validation in channel handlers and tool implementations
+- Credential or secret handling
+- Command execution (`exec.Command`, shell invocations)
+
+If you are unsure whether a piece of AI-generated code is safe, say so in the PR — reviewers will help.
+
+---
+
+## Pull Request Process
+
+### Before Opening a PR
+
+- [ ] Run `make check` and ensure it passes locally.
+- [ ] Fill in the PR template completely, including the AI disclosure section.
+- [ ] Link any related issue(s) in the PR description.
+- [ ] Keep the PR focused. Avoid bundling unrelated changes together.
+
+### PR Template Sections
+
+The PR template asks for:
+
+- **Description** — What does this change do and why?
+- **Type of Change** — Bug fix, feature, docs, or refactor.
+- **AI Code Generation** — Disclosure of AI involvement (required).
+- **Related Issue** — Link to the issue this addresses.
+- **Technical Context** — Reference URLs and reasoning (skip for pure docs PRs).
+- **Test Environment** — Hardware, OS, model/provider, and channels used for testing.
+- **Evidence** — Optional logs or screenshots demonstrating the change works.
+- **Checklist** — Self-review confirmation.
+
+### PR Size
+
+Prefer small, reviewable PRs. A PR that changes 200 lines across 5 files is much easier to review than one that changes 2000 lines across 30 files. If your feature is large, consider splitting it into a series of smaller, logically complete PRs.
+
+---
+
+## Branch Strategy
+
+### Long-Lived Branches
+
+- **`main`** — the active development branch. All feature PRs target `main`. The branch is protected: direct pushes are not permitted, and at least one maintainer approval is required before merging.
+- **`release/x.y`** — stable release branches, cut from `main` when a version is ready to ship. These branches are more strictly protected than `main`.
+
+### Requirements to Merge into `main`
+
+A PR can only be merged when all of the following are satisfied:
+
+1. **CI passes** — All GitHub Actions workflows (lint, test, build) must be green.
+2. **Reviewer approval** — At least one maintainer has approved the PR.
+3. **No unresolved review comments** — All review threads must be resolved.
+4. **PR template is complete** — Including AI disclosure and test environment.
+
+### Who Can Merge
+
+Only maintainers can merge PRs. Contributors cannot merge their own PRs, even if they have write access.
+
+### Merge Strategy
+
+We use **squash merge** for most PRs to keep the `main` history clean and readable. Each merged PR becomes a single commit referencing the PR number, e.g.:
+
+```
+feat: Add Ollama provider support (#491)
+```
+
+If a PR consists of multiple independent, well-separated commits that tell a clear story, a regular merge may be used at the maintainer's discretion.
+
+### Release Branches
+
+When a version is ready, maintainers cut a `release/x.y` branch from `main`. After that point:
+
+- **New features are not backported.** The release branch receives no new functionality after it is cut.
+- **Security fixes and critical bug fixes are cherry-picked.** If a fix in `main` qualifies (security vulnerability, data loss, crash), maintainers will cherry-pick the relevant commit(s) onto the affected `release/x.y` branch and issue a patch release.
+
+If you believe a fix in `main` should be backported to a release branch, note it in the PR description or open a separate issue. The decision rests with the maintainers.
+
+Release branches have stricter protections than `main` and are never directly pushed to under any circumstances.
+
+---
+
+## Code Review
+
+### For Contributors
+
+- Respond to review comments within a reasonable time. If you need more time, say so.
+- When you update a PR in response to feedback, briefly note what changed (e.g., "Updated to use `sync.RWMutex` as suggested").
+- If you disagree with feedback, engage respectfully. Explain your reasoning; reviewers can be wrong too.
+- Do not force-push after a review has started — it makes it harder for reviewers to see what changed. Use additional commits instead; the maintainer will squash on merge.
+
+### For Reviewers
+
+Review for:
+
+1. **Correctness** — Does the code do what it claims? Are there edge cases?
+2. **Security** — Especially for AI-generated code, tool implementations, and channel handlers.
+3. **Architecture** — Is the approach consistent with the existing design?
+4. **Simplicity** — Is there a simpler solution? Does this add unnecessary complexity?
+5. **Tests** — Are the changes covered by tests? Are existing tests still meaningful?
+
+Be constructive and specific. "This could have a race condition if two goroutines call this concurrently — consider using a mutex here" is better than "this looks wrong".
+
+
+### Reviewer List
+Once your PR is submitted, you can reach out to the assigned reviewers listed in the following table.
+
+|Function| Reviewer|
+|--- |--- |
+|Provider|@yinwm |
+|Channel |@yinwm |
+|Agent |@lxowalle|
+|Tools |@lxowalle|
+|SKill ||
+|MCP ||
+|Optimization|@lxowalle|
+|Security||
+|AI CI |@imguoguo|
+|UX ||
+|Document||
+
+---
+
+## Communication
+
+- **GitHub Issues** — Bug reports, feature requests, design discussions.
+- **GitHub Discussions** — General questions, ideas, community conversation.
+- **Pull Request comments** — Code-specific feedback.
+- **Wechat&Discord** — We will invite you when you have at least one merged PR
+
+When in doubt, open an issue before writing code. It costs little and prevents wasted effort.
+
+---
+
+## A Note on the Project's AI-Driven Origin
+
+PicoClaw's architecture was substantially designed and implemented with AI assistance, guided by human oversight. If you find something that looks odd or over-engineered, it may be an artifact of that process — opening an issue to discuss it is always welcome.
+
+We believe AI-assisted development done responsibly produces great results. We also believe humans must remain accountable for what they ship. These two beliefs are not in conflict.
+
+Thank you for contributing!
diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md
new file mode 100644
index 000000000..01a1abfd5
--- /dev/null
+++ b/CONTRIBUTING.zh.md
@@ -0,0 +1,303 @@
+# 参与贡献 PicoClaw
+
+感谢你对 PicoClaw 的关注!本项目是一个社区驱动的开源项目,目标是构建 轻量灵活,人人可用 的个人AI助手。我们欢迎一切形式的贡献:Bug 修复、新功能、文档、翻译和测试。
+
+PicoClaw 本身在很大程度上是借助 AI 辅助开发的——我们拥抱这种方式,并围绕它构建了贡献流程。
+
+## 目录
+
+- [行为准则](#行为准则)
+- [贡献方式](#贡献方式)
+- [快速开始](#快速开始)
+- [开发环境配置](#开发环境配置)
+- [提交修改](#提交修改)
+- [AI 辅助贡献](#ai-辅助贡献)
+- [Pull Request 流程](#pull-request-流程)
+- [分支策略](#分支策略)
+- [代码审查](#代码审查)
+- [沟通渠道](#沟通渠道)
+
+---
+
+## 行为准则
+
+我们致力于维护一个友好、互相尊重的社区环境。请保持善意、建设性的态度,并善意地理解他人。任何形式的骚扰或歧视均不被接受。
+
+---
+
+## 贡献方式
+
+- **Bug 反馈** — 使用 Bug 报告模板提交 Issue。
+- **功能建议** — 使用功能请求模板提交 Issue,建议在开始实现前先进行讨论。
+- **代码贡献** — 修复 Bug 或实现新功能,参见下方工作流程。
+- **文档改进** — 完善 README、文档、代码注释或翻译。
+- **测试与验证** — 在新硬件、新渠道或新 LLM 提供商上运行 PicoClaw 并反馈结果。
+
+对于较大的新功能,请先提交 Issue 讨论设计方案,再动手写代码。这能避免无效投入,也确保与项目方向保持一致。
+
+---
+
+## 快速开始
+
+1. 在 GitHub 上 **Fork** 本仓库。
+2. 将你的 Fork **克隆**到本地:
+ ```bash
+ git clone https://github.com/<你的用户名>/picoclaw.git
+ cd picoclaw
+ ```
+3. 添加上游远程仓库:
+ ```bash
+ git remote add upstream https://github.com/sipeed/picoclaw.git
+ ```
+
+---
+
+## 开发环境配置
+
+### 前置依赖
+
+- Go 1.25 或更高版本
+- `make`
+
+### 构建
+
+```bash
+make build # 构建二进制文件(会先执行 go generate)
+make generate # 仅执行 go generate
+make check # 完整的提交前检查:deps + fmt + vet + test
+```
+
+### 运行测试
+
+```bash
+make test # 运行所有测试
+go test -run TestName -v ./pkg/session/ # 运行单个测试
+go test -bench=. -benchmem -run='^$' ./... # 运行基准测试
+```
+
+### 代码风格
+
+```bash
+make fmt # 格式化代码
+make vet # 静态分析
+make lint # 完整的 lint 检查
+```
+
+所有 CI 检查通过后 PR 才能被合并。推送代码前请先在本地运行 `make check`,提前发现问题。
+
+---
+
+## 提交修改
+
+### 分支管理
+
+始终从 `main` 分支切出,并在 PR 中以 `main` 为目标分支。不要直接向 `main` 或任何 `release/*` 分支推送代码:
+
+```bash
+git checkout main
+git pull upstream main
+git checkout -b 你的功能分支名
+```
+
+请使用描述性的分支名,例如:`fix/telegram-timeout`、`feat/ollama-provider`、`docs/contributing-guide`。
+
+### Commit 规范
+
+- 使用英文撰写清晰、简洁的 commit 信息。
+- 使用祈使句:写 "Add retry logic",而不是 "Added retry logic"。
+- 有关联 Issue 时请引用:`Fix session leak (#123)`。
+- 保持 commit 专注,每个 commit 只做一件事。
+- 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。
+- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写
+
+### 保持与上游同步
+
+提 PR 前,请将你的分支变基到上游 `main`:
+
+```bash
+git fetch upstream
+git rebase upstream/main
+```
+
+---
+
+## AI 辅助贡献
+
+PicoClaw 在很大程度上借助 AI 辅助开发,我们完全拥抱这种开发方式。但贡献者必须清楚地了解自己在使用 AI 工具时所承担的责任。
+
+### 必须披露 AI 使用情况
+
+每个 PR 都必须通过 PR 模板中的 **🤖 AI 代码生成** 部分披露 AI 参与情况,共分三个级别:
+
+| 级别 | 说明 |
+|---|---|
+| 🤖 完全由 AI 生成 | AI 编写代码,贡献者负责审查和验证 |
+| 🛠️ 主要由 AI 生成 | AI 起草,贡献者做了较大修改 |
+| 👨💻 主要由人工编写 | 贡献者主导,AI 仅提供辅助或未使用 AI |
+
+我们期望你诚实填写。三种级别均可接受,没有任何歧视——重要的是贡献的质量。
+
+### 你对提交的代码负全责
+
+使用 AI 生成代码并不能减轻你作为贡献者的责任。在提交含有 AI 生成代码的 PR 之前,你必须:
+
+- **逐行阅读并理解**生成的代码。
+- **在真实环境中测试**(参见 PR 模板中的测试环境部分)。
+- **检查安全问题** — AI 模型可能生成存在安全隐患的代码(如路径穿越、注入攻击、凭据泄露等),请仔细审查。
+- **验证正确性** — AI 生成的逻辑可能听起来合理但实际上是错误的,请验证行为,而不仅仅是语法。
+
+如果明显可以看出贡献者没有阅读或测试 AI 生成的代码,该 PR 将被直接关闭,不予审查。
+
+### AI 生成代码的质量标准
+
+AI 生成的代码与人工编写的代码遵循**相同的质量要求**:
+
+- 必须通过所有 CI 检查(`make check`)。
+- 必须符合 Go 惯用写法,并与现有代码库的风格保持一致。
+- 不得引入不必要的抽象、死代码或过度设计。
+- 须在适当的地方包含或更新测试。
+
+### 安全审查
+
+AI 生成的代码需要格外仔细的安全审查。请特别关注以下方面:
+
+- 文件路径处理与沙箱逃逸(项目历史中的 commit `244eb0b` 就是真实案例)
+- channel 处理器和 tool 实现中的外部输入校验
+- 凭据或密钥的处理
+- 命令执行(`exec.Command`、shell 调用等)
+
+如果你不确定某段 AI 生成代码是否安全,请在 PR 中说明——审查者会帮助判断。
+
+---
+
+## Pull Request 流程
+
+### 提 PR 前的检查
+
+- [ ] 在本地运行 `make check` 并确认通过。
+- [ ] 完整填写 PR 模板,包括 AI 披露部分。
+- [ ] 在 PR 描述中关联相关 Issue。
+- [ ] 保持 PR 专注,避免将不相关的修改混在一起。
+
+### PR 模板各部分说明
+
+PR 模板要求填写:
+
+- **描述** — 这个改动做了什么,为什么要做?
+- **变更类型** — Bug 修复、新功能、文档或重构。
+- **AI 代码生成** — AI 参与情况披露(必填)。
+- **关联 Issue** — 此 PR 解决的 Issue 链接。
+- **技术背景** — 参考链接和设计理由(纯文档类 PR 可跳过)。
+- **测试环境** — 用于测试的硬件、操作系统、模型/提供商和渠道。
+- **验证证据** — 可选的日志或截图,用于证明改动有效。
+- **检查清单** — 自我审查确认。
+
+### PR 规模
+
+请尽量提交小而易于审查的 PR。一个涉及 5 个文件共 200 行改动的 PR,远比涉及 30 个文件共 2000 行改动的 PR 容易审查。如果你的功能较大,可以考虑将其拆分为一系列逻辑完整的小 PR。
+
+---
+
+## 分支策略
+
+### 长期分支
+
+- **`main`** — 活跃开发分支。所有功能 PR 均以 `main` 为目标。该分支受保护:禁止直接推送,合并前必须获得至少一名维护者的批准。
+- **`release/x.y`** — 稳定发布分支,在某个版本准备发布时从 `main` 切出。这些分支的保护级别高于 `main`。
+
+### 合并到 `main` 的前提条件
+
+PR 必须同时满足以下所有条件,才能被合并:
+
+1. **CI 全部通过** — 所有 GitHub Actions 工作流(lint、test、build)均为绿色。
+2. **获得审查者批准** — 至少一名维护者已批准该 PR。
+3. **无未解决的审查意见** — 所有审查讨论线程均已关闭。
+4. **PR 模板填写完整** — 包括 AI 披露和测试环境信息。
+
+### 谁可以合并
+
+只有维护者才能合并 PR。贡献者不能合并自己的 PR,即使拥有写权限也不行。
+
+### 合并策略
+
+为保持 `main` 历史清晰可读,我们对大多数 PR 使用 **Squash Merge**。每个合并的 PR 变为一个包含 PR 编号的单独 commit,例如:
+
+```
+feat: Add Ollama provider support (#491)
+```
+
+如果一个 PR 包含多个独立、结构清晰、能讲述完整故事的 commit,维护者可视情况使用普通 merge。
+
+### Release 分支
+
+当某个版本准备就绪时,维护者会从 `main` 切出 `release/x.y` 分支。此后:
+
+- **新功能不会被回溯(backport)。** Release 分支切出后,不再接收任何新功能。
+- **安全修复和关键 Bug 修复会被 cherry-pick 进来。** 若 `main` 上的某个修复属于安全漏洞、数据丢失或崩溃类问题,维护者会将相关 commit cherry-pick 到受影响的 `release/x.y` 分支,并发布补丁版本。
+
+如果你认为 `main` 上的某个修复应该被回溯到某个 release 分支,请在 PR 描述中注明,或单独开一个 Issue 说明。最终决定由维护者做出。
+
+Release 分支的保护级别高于 `main`,在任何情况下均不允许直接推送。
+
+---
+
+## 代码审查
+
+### 对贡献者的建议
+
+- 在合理时间内回复审查意见。如果需要更多时间,请告知。
+- 更新 PR 以响应反馈时,简要说明改动内容(例如:"按建议改用了 `sync.RWMutex`")。
+- 如果你不同意某条反馈,请礼貌地阐述你的理由——审查者也可能有判断失误的时候。
+- 审查开始后请不要 force push——这会让审查者难以追踪变化。请使用额外的 commit,维护者在合并时会进行 squash。
+
+### 对审查者的建议
+
+审查重点:
+
+1. **正确性** — 代码是否实现了其声称的功能?是否存在边界情况?
+2. **安全性** — 对 AI 生成代码、tool 实现和 channel 处理器尤其需要关注。
+3. **架构** — 实现方式是否与现有设计一致?
+4. **简洁性** — 是否有更简单的方案?是否引入了不必要的复杂度?
+5. **测试** — 改动是否有测试覆盖?现有测试是否仍然有意义?
+
+请给出建设性且具体的反馈。"如果两个 goroutine 同时调用这个函数可能会有竞态条件,建议在这里加一个 mutex" 远比 "这里看起来有问题" 更有帮助。
+
+### 审查者列表
+提交对应PR后,可以参考下表联系对应的审查人员沟通
+
+|Function| Reviewer|
+|--- |--- |
+|Provider|@yinwm |
+|Channel |@yinwm |
+|Agent |@lxowalle|
+|Tools |@lxowalle|
+|SKill ||
+|MCP ||
+|Optimization|@lxowalle|
+|Security||
+|AI CI |@imguoguo|
+|UX ||
+|Document||
+
+
+
+---
+
+## 沟通渠道
+
+- **GitHub Issues** — Bug 报告、功能建议、设计讨论。
+- **GitHub Discussions** — 一般性问题、想法交流、社区讨论。
+- **Pull Request 评论** — 与具体代码相关的反馈。
+- **Wechat&Discord** — 当你有至少一个已合并的PR后,我们会邀请你加入开发者交流群
+
+有疑问时,请先开 Issue 讨论,再动手写代码。这几乎没有成本,却能避免大量无效投入。
+
+---
+
+## 关于本项目的 AI 驱动起源
+
+PicoClaw 的架构在人工监督下,经由 AI 辅助完成了大量设计和实现工作。如果你发现某处看起来奇怪或过度设计,这可能是该过程留下的痕迹——欢迎提 Issue 讨论。
+
+我们相信,负责任地使用 AI 辅助开发能产生优秀的成果。我们同样相信,人类必须对自己提交的内容负责。这两点并不矛盾。
+
+感谢你的贡献!
diff --git a/Dockerfile b/Dockerfile
index 0360cfda6..480244127 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
-FROM golang:1.26.0-alpine AS builder
+FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git make
diff --git a/Makefile b/Makefile
index 68b817bbd..7526c01a5 100644
--- a/Makefile
+++ b/Makefile
@@ -11,10 +11,11 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date +%FT%T%z)
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
-LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w"
+INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal
+LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w"
# Go variables
-GO?=go
+GO?=CGO_ENABLED=0 go
GOFLAGS?=-v -tags stdjson
# Golangci-lint
@@ -24,6 +25,7 @@ GOLANGCI_LINT?=golangci-lint
INSTALL_PREFIX?=$(HOME)/.local
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
+INSTALL_TMP_SUFFIX=.new
# Workspace and Skills
PICOCLAW_HOME?=$(HOME)/.picoclaw
@@ -42,6 +44,8 @@ ifeq ($(UNAME_S),Linux)
ARCH=amd64
else ifeq ($(UNAME_M),aarch64)
ARCH=arm64
+ else ifeq ($(UNAME_M),armv81)
+ ARCH=arm64
else ifeq ($(UNAME_M),loongarch64)
ARCH=loong64
else ifeq ($(UNAME_M),riscv64)
@@ -91,6 +95,7 @@ build-all: generate
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
+ GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
@echo "All builds complete"
@@ -99,8 +104,10 @@ build-all: generate
install: build
@echo "Installing $(BINARY_NAME)..."
@mkdir -p $(INSTALL_BIN_DIR)
- @cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
- @chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
+ # Copy binary with temporary suffix to ensure atomic update
+ @cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
+ @chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
+ @mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
@echo "Installation complete!"
@@ -141,6 +148,10 @@ fmt:
lint: generate
@$(GOLANGCI_LINT) run
+## fix: Fix linting issues
+fix:
+ @$(GOLANGCI_LINT) run --fix
+
## deps: Download dependencies
deps:
@$(GO) mod download
@@ -166,7 +177,7 @@ help:
@echo " make [target]"
@echo ""
@echo "Targets:"
- @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /'
+ @grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}'
@echo ""
@echo "Examples:"
@echo " make build # Build for current platform"
diff --git a/README.fr.md b/README.fr.md
index 7199f7098..f1d4f848e 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -50,7 +50,7 @@
## 📢 Actualités
-2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/picoclaw_community_roadmap_260216.md) — nous avons hâte de vous accueillir !
+2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/ROADMAP.md) — nous avons hâte de vous accueillir !
2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw.
🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire.
@@ -171,6 +171,10 @@ vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc.
# 3. Compiler & Démarrer
docker compose --profile gateway up -d
+> [!TIP]
+> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
+
+
# 4. Voir les logs
docker compose logs -f picoclaw-gateway
@@ -217,12 +221,13 @@ picoclaw onboard
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
+ "request_timeout": 300,
"api_base": "https://api.openai.com/v1"
}
],
"agents": {
"defaults": {
- "model": "gpt4"
+ "model_name": "gpt4"
}
},
"channels": {
@@ -248,6 +253,9 @@ picoclaw onboard
}
```
+> **Nouveau** : Le format de configuration `model_list` permet d'ajouter des fournisseurs sans modifier le code. Voir [Configuration de Modèle](#configuration-de-modèle-model_list) pour plus de détails.
+> `request_timeout` est optionnel et s'exprime en secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le délai d'expiration par défaut (120s).
+
**3. Obtenir des Clés API**
* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
@@ -975,6 +983,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
```
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
+**Proxy/API personnalisée**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-...",
+ "request_timeout": 300
+}
+```
+
#### Équilibrage de Charge
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
diff --git a/README.ja.md b/README.ja.md
index bb0bdfb28..48fb89fe3 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -133,6 +133,10 @@ vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キ
# 3. ビルドと起動
docker compose --profile gateway up -d
+> [!TIP]
+> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
+
+
# 4. ログ確認
docker compose logs -f picoclaw-gateway
@@ -162,7 +166,7 @@ docker compose --profile gateway up -d
> [!TIP]
> `~/.picoclaw/config.json` に API キーを設定してください。
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
-> Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
+> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
**1. 初期化**
@@ -179,12 +183,13 @@ picoclaw onboard
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
+ "request_timeout": 300,
"api_base": "https://api.openai.com/v1"
}
],
"agents": {
"defaults": {
- "model": "gpt4"
+ "model_name": "gpt4"
}
},
"channels": {
@@ -193,14 +198,37 @@ picoclaw onboard
"token": "YOUR_TELEGRAM_BOT_TOKEN",
"allow_from": []
}
+ },
+ "tools": {
+ "web": {
+ "search": {
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
+ "max_results": 5
+ }
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
}
}
```
+> **新機能**: `model_list` 形式により、プロバイダーをコード変更なしで追加できます。詳細は [モデル設定](#モデル設定-model_list) を参照してください。
+> `request_timeout` は任意の秒単位設定です。省略または `<= 0` の場合、PicoClaw はデフォルトのタイムアウト(120秒)を使用します。
+
**3. API キーの取得**
-- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) · [Qwen](https://dashscope.console.aliyun.com)
-- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
+- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+- **Web 検索**(任意): [Tavily](https://tavily.com) - AI エージェント向けに最適化 (月 1000 リクエスト) · [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
@@ -894,6 +922,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
```
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
+**カスタムプロキシ/API**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-...",
+ "request_timeout": 300
+}
+```
+
#### ロードバランシング
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
@@ -985,7 +1024,7 @@ Discord: https://discord.gg/V4sAZ9XWpN
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
Web 検索を有効にするには:
-1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
+1. [https://tavily.com](https://tavily.com) (月 1000 クエリ無料) または [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
2. `~/.picoclaw/config.json` に追加:
```json
{
@@ -1023,5 +1062,6 @@ Web 検索を有効にするには:
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
+| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
diff --git a/README.md b/README.md
index 7bc7b1089..72a933b6f 100644
--- a/README.md
+++ b/README.md
@@ -12,9 +12,13 @@
+
+
+
- [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
+[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
+
---
@@ -42,16 +46,17 @@
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
>
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
+>
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
-
## 📢 News
-2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board!
-2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
+2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/ROADMAP.md) —we can’t wait to have you on board!
+
+2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
@@ -100,9 +105,12 @@
### 📱 Run on old Android Phones
+
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
+
1. **Install Termux** (Available on F-Droid or Google Play).
2. **Execute cmds**
+
```bash
# Note: Replace v0.1.1 with the latest version from the Releases page
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
@@ -110,6 +118,7 @@ chmod +x picoclaw-linux-arm64
pkg install proot
termux-chroot ./picoclaw-linux-arm64 onboard
```
+
And then follow the instructions in the "Quick Start" section to complete the configuration!
@@ -165,6 +174,10 @@ vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc.
# 3. Build & Start
docker compose --profile gateway up -d
+> [!TIP]
+> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
+
+
# 4. Check logs
docker compose logs -f picoclaw-gateway
@@ -194,7 +207,7 @@ docker compose --profile gateway up -d
> [!TIP]
> Set your API key in `~/.picoclaw/config.json`.
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
-> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
+> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
**1. Initialize**
@@ -209,7 +222,7 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "gpt4",
+ "model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@@ -219,7 +232,8 @@ picoclaw onboard
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
- "api_key": "your-api-key"
+ "api_key": "your-api-key",
+ "request_timeout": 300
},
{
"model_name": "claude-sonnet-4.6",
@@ -234,6 +248,11 @@ picoclaw onboard
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
+ "max_results": 5
+ },
"duckduckgo": {
"enabled": true,
"max_results": 5
@@ -244,11 +263,12 @@ picoclaw onboard
```
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
+> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
**3. Get API Keys**
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
-* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
+* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
> **Note**: See `config.example.json` for a complete configuration template.
@@ -323,7 +343,6 @@ picoclaw gateway
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
**3. Get your User ID**
-
* Discord Settings → Advanced → enable **Developer Mode**
* Right-click your avatar → **Copy User ID**
@@ -425,7 +444,6 @@ picoclaw gateway
```bash
picoclaw gateway
```
-
@@ -521,7 +539,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
* Go to WeCom Admin Console → App Management → Create App
* Copy **AgentId** and **Secret**
* Go to "My Company" page, copy **CorpID**
-
**2. Configure receive message**
* In App details, click "Receive Message" → "Set API"
@@ -605,23 +622,23 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f
}
```
-| Option | Default | Description |
-|--------|---------|-------------|
-| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
-| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
+| Option | Default | Description |
+| ----------------------- | ----------------------- | ----------------------------------------- |
+| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
+| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
#### Protected Tools
When `restrict_to_workspace: true`, the following tools are sandboxed:
-| Tool | Function | Restriction |
-|------|----------|-------------|
-| `read_file` | Read files | Only files within workspace |
-| `write_file` | Write files | Only files within workspace |
-| `list_dir` | List directories | Only directories within workspace |
-| `edit_file` | Edit files | Only files within workspace |
-| `append_file` | Append to files | Only files within workspace |
-| `exec` | Execute commands | Command paths must be within workspace |
+| Tool | Function | Restriction |
+| ------------- | ---------------- | -------------------------------------- |
+| `read_file` | Read files | Only files within workspace |
+| `write_file` | Write files | Only files within workspace |
+| `list_dir` | List directories | Only directories within workspace |
+| `edit_file` | Edit files | Only files within workspace |
+| `append_file` | Append to files | Only files within workspace |
+| `exec` | Execute commands | Command paths must be within workspace |
#### Additional Exec Protection
@@ -674,11 +691,11 @@ export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
The `restrict_to_workspace` setting applies consistently across all execution paths:
-| Execution Path | Security Boundary |
-|----------------|-------------------|
-| Main Agent | `restrict_to_workspace` ✅ |
+| Execution Path | Security Boundary |
+| ---------------- | ---------------------------- |
+| Main Agent | `restrict_to_workspace` ✅ |
| Subagent / Spawn | Inherits same restriction ✅ |
-| Heartbeat tasks | Inherits same restriction ✅ |
+| Heartbeat tasks | Inherits same restriction ✅ |
All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks.
@@ -704,21 +721,23 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
# Periodic Tasks
## Quick Tasks (respond directly)
+
- Report current time
## Long Tasks (use spawn for async)
+
- Search the web for AI news and summarize
- Check email and report important messages
```
**Key behaviors:**
-| Feature | Description |
-|---------|-------------|
-| **spawn** | Creates async subagent, doesn't block heartbeat |
-| **Independent context** | Subagent has its own context, no session history |
-| **message tool** | Subagent communicates with user directly via message tool |
-| **Non-blocking** | After spawning, heartbeat continues to next task |
+| Feature | Description |
+| ----------------------- | --------------------------------------------------------- |
+| **spawn** | Creates async subagent, doesn't block heartbeat |
+| **Independent context** | Subagent has its own context, no session history |
+| **message tool** | Subagent communicates with user directly via message tool |
+| **Non-blocking** | After spawning, heartbeat continues to next task |
#### How Subagent Communication Works
@@ -749,10 +768,10 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
}
```
-| Option | Default | Description |
-|--------|---------|-------------|
-| `enabled` | `true` | Enable/disable heartbeat |
-| `interval` | `30` | Check interval in minutes (min: 5) |
+| Option | Default | Description |
+| ---------- | ------- | ---------------------------------- |
+| `enabled` | `true` | Enable/disable heartbeat |
+| `interval` | `30` | Check interval in minutes (min: 5) |
**Environment variables:**
@@ -764,17 +783,17 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
> [!NOTE]
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
-| Provider | Purpose | Get API Key |
-| -------------------------- | --------------------------------------- | ------------------------------------------------------ |
-| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
-| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
-| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
-| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
-| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
-| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
+| Provider | Purpose | Get API Key |
+| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
+| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
+| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
-| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
-| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
+| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
### Model Configuration (model_list)
@@ -789,25 +808,25 @@ This design also enables **multi-agent support** with flexible provider selectio
#### 📋 All Supported Vendors
-| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
-|--------|----------------|------------------|----------|---------|
-| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
-| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
-| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
-| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
-| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
-| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
-| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
-| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
-| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
-| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
-| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
-| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
-| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
-| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
-| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
-| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
-| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
+| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
+| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
+| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
#### Basic Configuration
@@ -841,6 +860,7 @@ This design also enables **multi-agent support** with flexible provider selectio
#### Vendor-Specific Examples
**OpenAI**
+
```json
{
"model_name": "gpt-5.2",
@@ -850,6 +870,7 @@ This design also enables **multi-agent support** with flexible provider selectio
```
**智谱 AI (GLM)**
+
```json
{
"model_name": "glm-4.7",
@@ -859,6 +880,7 @@ This design also enables **multi-agent support** with flexible provider selectio
```
**DeepSeek**
+
```json
{
"model_name": "deepseek-chat",
@@ -868,6 +890,7 @@ This design also enables **multi-agent support** with flexible provider selectio
```
**Anthropic (with API key)**
+
```json
{
"model_name": "claude-sonnet-4.6",
@@ -875,9 +898,11 @@ This design also enables **multi-agent support** with flexible provider selectio
"api_key": "sk-ant-your-key"
}
```
+
> Run `picoclaw auth login --provider anthropic` to paste your API token.
**Ollama (local)**
+
```json
{
"model_name": "llama3",
@@ -886,12 +911,14 @@ This design also enables **multi-agent support** with flexible provider selectio
```
**Custom Proxy/API**
+
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
- "api_key": "sk-..."
+ "api_key": "sk-...",
+ "request_timeout": 300
}
```
@@ -923,6 +950,7 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
**Old Config (deprecated):**
+
```json
{
"providers": {
@@ -941,6 +969,7 @@ The old `providers` configuration is **deprecated** but still supported for back
```
**New Config (recommended):**
+
```json
{
"model_list": [
@@ -1105,19 +1134,19 @@ Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
PRs welcome! The codebase is intentionally small and readable. 🤗
-Roadmap coming soon...
+See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
-Developer group building, Entry Requirement: At least 1 Merged PR.
+Developer group building, join after your first merged PR!
User Groups:
-discord:
+discord:
## 🐛 Troubleshooting
-### Web search says "API 配置问题"
+### Web search says "API key configuration issue"
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
diff --git a/README.pt-br.md b/README.pt-br.md
index ec8fe8e1c..1dbee5201 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -50,7 +50,7 @@
## 📢 Novidades
-2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter você a bordo!
+2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/ROADMAP.md) — estamos ansiosos para ter você a bordo!
2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
@@ -172,6 +172,10 @@ vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc.
# 3. Build & Iniciar
docker compose --profile gateway up -d
+> [!TIP]
+> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
+
+
# 4. Ver logs
docker compose logs -f picoclaw-gateway
@@ -218,12 +222,13 @@ picoclaw onboard
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
+ "request_timeout": 300,
"api_base": "https://api.openai.com/v1"
}
],
"agents": {
"defaults": {
- "model": "gpt4"
+ "model_name": "gpt4"
}
},
"tools": {
@@ -242,6 +247,9 @@ picoclaw onboard
}
```
+> **Novo**: O formato de configuração `model_list` permite adicionar provedores sem alterar código. Veja [Configuração de Modelo](#configuração-de-modelo-model_list) para detalhes.
+> `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s).
+
**3. Obter API Keys**
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
@@ -969,6 +977,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
```
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
+**Proxy/API personalizada**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-...",
+ "request_timeout": 300
+}
+```
+
#### Balanceamento de Carga
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
diff --git a/README.vi.md b/README.vi.md
index 161842933..0dd4994c2 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -50,7 +50,7 @@
## 📢 Tin tức
-2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn!
+2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn!
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
@@ -152,6 +152,10 @@ vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
# 3. Build & Khởi động
docker compose --profile gateway up -d
+> [!TIP]
+> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
+
+
# 4. Xem logs
docker compose logs -f picoclaw-gateway
@@ -198,12 +202,13 @@ picoclaw onboard
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
+ "request_timeout": 300,
"api_base": "https://api.openai.com/v1"
}
],
"agents": {
"defaults": {
- "model": "gpt4"
+ "model_name": "gpt4"
}
},
"channels": {
@@ -216,6 +221,9 @@ picoclaw onboard
}
```
+> **Mới**: Định dạng cấu hình `model_list` cho phép thêm nhà cung cấp mà không cần thay đổi mã nguồn. Xem [Cấu hình Mô hình](#cấu-hình-mô-hình-model_list) để biết chi tiết.
+> `request_timeout` là tùy chọn và dùng đơn vị giây. Nếu bỏ qua hoặc đặt `<= 0`, PicoClaw sẽ dùng timeout mặc định (120s).
+
**3. Lấy API Key**
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
@@ -940,6 +948,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
```
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
+**Proxy/API tùy chỉnh**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-...",
+ "request_timeout": 300
+}
+```
+
#### Cân bằng Tải tải
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
diff --git a/README.zh.md b/README.zh.md
index ab896b6c0..8ce1ad2ee 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -14,7 +14,8 @@
- **中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
+**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
+
---
@@ -42,15 +43,16 @@
> [!CAUTION]
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
-> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
-> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
-> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
-> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
-> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
-
+>
+> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
+> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
+> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
+> - **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
+> - **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
## 📢 新闻 (News)
-2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
+
+2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/ROADMAP.md), 期待你的参与!
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
@@ -69,12 +71,12 @@
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
-| | OpenClaw | NanoBot | **PicoClaw** |
-| --- | --- | --- | --- |
-| **语言** | TypeScript | Python | **Go** |
-| **RAM** | >1GB | >100MB | **< 10MB** |
-| **启动时间**(0.8GHz core) | >500s | >30s | **<1s** |
-| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板****低至 $10** |
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
+| **语言** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB** |
+| **启动时间**(0.8GHz core) | >500s | >30s | **<1s** |
+| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板****低至 $10** |
@@ -101,9 +103,12 @@
### 📱 在手机上轻松运行
+
picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
+
1. 先去应用商店下载安装Termux
2. 打开后执行指令
+
```bash
# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
@@ -111,19 +116,17 @@ chmod +x picoclaw-linux-arm64
pkg install proot
termux-chroot ./picoclaw-linux-arm64 onboard
```
-然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
+
+然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
-
-
-
### 🐜 创新的低占用部署
PicoClaw 几乎可以部署在任何 Linux 设备上!
-* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
-* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
-* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4)
@@ -170,6 +173,9 @@ vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
# 3. 构建并启动
docker compose --profile gateway up -d
+> [!TIP]
+**Docker 用户**: 默认情况下, Gateway监听 `127.0.0.1`,这使得这个端口未暴露到容器外。如果你需要通过端口映射访问健康检查接口, 请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
+
# 4. 查看日志
docker compose logs -f picoclaw-gateway
@@ -202,7 +208,7 @@ docker compose --profile gateway up -d
> [!TIP]
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
-> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
+> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
**1. 初始化 (Initialize)**
@@ -218,7 +224,7 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "gpt4",
+ "model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@@ -228,7 +234,8 @@ picoclaw onboard
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
- "api_key": "your-api-key"
+ "api_key": "your-api-key",
+ "request_timeout": 300
},
{
"model_name": "claude-sonnet-4.6",
@@ -243,8 +250,9 @@ picoclaw onboard
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
- "duckduckgo": {
- "enabled": true,
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
"max_results": 5
}
},
@@ -253,15 +261,15 @@ picoclaw onboard
}
}
}
-
```
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
+> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。
**3. 获取 API Key**
* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
-* **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
+* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
> **注意**: 完整的配置模板请参考 `config.example.json`。
@@ -278,260 +286,28 @@ picoclaw agent -m "2+2 等于几?"
## 💬 聊天应用集成 (Chat Apps)
-通过 Telegram, Discord, 钉钉或企业微信与您的 PicoClaw 对话。
-
-| 渠道 | 设置难度 |
-| --- | --- |
-| **Telegram** | 简单 (仅需 token) |
-| **Discord** | 简单 (bot token + intents) |
-| **QQ** | 简单 (AppID + AppSecret) |
-| **钉钉 (DingTalk)** | 中等 (应用凭证) |
-| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) |
-
-
-Telegram (推荐)
-
-**1. 创建机器人**
-
-* 打开 Telegram,搜索 `@BotFather`
-* 发送 `/newbot`,按照提示操作
-* 复制 token
-
-**2. 配置**
-
-```json
-{
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
- }
- }
-}
-
-```
-
-> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。
-
-**3. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-Discord
-
-**1. 创建机器人**
-
-* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications)
-* Create an application → Bot → Add Bot
-* 复制 bot token
-
-**2. 开启 Intents**
-
-* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT**
-* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT**
-
-**3. 获取您的 User ID**
-
-* Discord 设置 → Advanced → 开启 **Developer Mode**
-* 右键点击您的头像 → **Copy User ID**
-
-**4. 配置**
-
-```json
-{
- "channels": {
- "discord": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"],
- "mention_only": false
- }
- }
-}
-
-```
-
-**5. 邀请机器人**
-
-* OAuth2 → URL Generator
-* Scopes: `bot`
-* Bot Permissions: `Send Messages`, `Read Message History`
-* 打开生成的邀请 URL,将机器人添加到您的服务器
-
-**6. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-QQ
-
-**1. 创建机器人**
-
-* 前往 [QQ 开放平台](https://q.qq.com/#)
-* 创建应用 → 获取 **AppID** 和 **AppSecret**
-
-**2. 配置**
-
-```json
-{
- "channels": {
- "qq": {
- "enabled": true,
- "app_id": "YOUR_APP_ID",
- "app_secret": "YOUR_APP_SECRET",
- "allow_from": []
- }
- }
-}
-
-```
-
-> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。
-
-**3. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-钉钉 (DingTalk)
-
-**1. 创建机器人**
-
-* 前往 [开放平台](https://open.dingtalk.com/)
-* 创建内部应用
-* 复制 Client ID 和 Client Secret
-
-**2. 配置**
-
-```json
-{
- "channels": {
- "dingtalk": {
- "enabled": true,
- "client_id": "YOUR_CLIENT_ID",
- "client_secret": "YOUR_CLIENT_SECRET",
- "allow_from": []
- }
- }
-}
-
-```
-
-> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。
-
-**3. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-企业微信 (WeCom)
-
-PicoClaw 支持两种企业微信集成方式:
-
-**选项1: 智能机器人 (WeCom Bot)** - 设置更简单,支持群聊
-**选项2: 自建应用 (WeCom App)** - 功能更丰富,支持主动推送消息
-
-详见 [企业微信自建应用配置指南](docs/wecom-app-configuration.md)。
-
-**快速设置 - 智能机器人:**
-
-**1. 创建机器人**
-
-* 前往企业微信管理后台 → 群聊 → 添加群机器人
-* 复制 Webhook URL (格式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
-
-**2. 配置**
-
-```json
-{
- "channels": {
- "wecom": {
- "enabled": true,
- "token": "YOUR_TOKEN",
- "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
- "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
- "webhook_host": "0.0.0.0",
- "webhook_port": 18793,
- "webhook_path": "/webhook/wecom",
- "allow_from": []
- }
- }
-}
-```
-
-**快速设置 - 自建应用:**
-
-**1. 创建应用**
-
-* 前往企业微信管理后台 → 应用管理 → 创建应用
-* 复制 **AgentId** 和 **Secret**
-* 前往"我的企业"页面,复制 **CorpID**
-
-**2. 配置接收消息**
-
-* 在应用详情页,点击"接收消息" → "设置API"
-* 设置 URL 为 `http://your-server:18792/webhook/wecom-app`
-* 生成 **Token** 和 **EncodingAESKey**
-
-**3. 配置**
-
-```json
-{
- "channels": {
- "wecom_app": {
- "enabled": true,
- "corp_id": "wwxxxxxxxxxxxxxxxx",
- "corp_secret": "YOUR_CORP_SECRET",
- "agent_id": 1000002,
- "token": "YOUR_TOKEN",
- "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
- "webhook_host": "0.0.0.0",
- "webhook_port": 18792,
- "webhook_path": "/webhook/wecom-app",
- "allow_from": []
- }
- }
-}
-```
-
-**4. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-> **注意**: 自建应用需要开放 18792 端口用于接收 Webhook 回调。生产环境建议使用反向代理配置 HTTPS。
-
-
+PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
+
+### 核心渠道
+
+| 渠道 | 设置难度 | 特性说明 | 文档链接 |
+| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
+| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
+| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
+| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
+| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
+| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) |
+| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
+| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
+| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
+| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
##
加入 Agent 社交网络
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
-**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
+\*\*阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
## ⚙️ 配置详解
@@ -567,7 +343,6 @@ PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md`
- Check my email for important messages
- Review my calendar for upcoming events
- Check the weather forecast
-
```
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
@@ -580,22 +355,23 @@ Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具
# Periodic Tasks
## Quick Tasks (respond directly)
+
- Report current time
## Long Tasks (use spawn for async)
+
- Search the web for AI news and summarize
- Check email and report important messages
-
```
**关键行为:**
-| 特性 | 描述 |
-| --- | --- |
-| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
-| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
+| 特性 | 描述 |
+| ---------------- | ---------------------------------------- |
+| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
+| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
-| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
+| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
#### 子 Agent 通信原理
@@ -625,35 +401,34 @@ Agent 读取 HEARTBEAT.md
"interval": 30
}
}
-
```
-| 选项 | 默认值 | 描述 |
-| --- | --- | --- |
-| `enabled` | `true` | 启用/禁用心跳 |
-| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
+| 选项 | 默认值 | 描述 |
+| ---------- | ------ | ---------------------------- |
+| `enabled` | `true` | 启用/禁用心跳 |
+| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
**环境变量:**
-* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
-* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
+- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
+- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
### 提供商 (Providers)
> [!NOTE]
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
-| 提供商 | 用途 | 获取 API Key |
-| --- | --- | --- |
-| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
-| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
-| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
-| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
-| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
-| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
-| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
-| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
-| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
+| 提供商 | 用途 | 获取 API Key |
+| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
+| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
### 模型配置 (model_list)
@@ -668,25 +443,25 @@ Agent 读取 HEARTBEAT.md
#### 📋 所有支持的厂商
-| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
-|------|-------------|---------------|------|--------------|
-| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
-| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
-| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
-| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
-| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) |
-| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
-| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
-| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
-| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
-| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
-| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
-| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
-| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
-| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
-| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
-| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
-| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
+| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
+| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
+| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
#### 基础配置示例
@@ -720,6 +495,7 @@ Agent 读取 HEARTBEAT.md
#### 各厂商配置示例
**OpenAI**
+
```json
{
"model_name": "gpt-5.2",
@@ -729,6 +505,7 @@ Agent 读取 HEARTBEAT.md
```
**智谱 AI (GLM)**
+
```json
{
"model_name": "glm-4.7",
@@ -738,6 +515,7 @@ Agent 读取 HEARTBEAT.md
```
**DeepSeek**
+
```json
{
"model_name": "deepseek-chat",
@@ -747,6 +525,7 @@ Agent 读取 HEARTBEAT.md
```
**Anthropic (使用 OAuth)**
+
```json
{
"model_name": "claude-sonnet-4.6",
@@ -754,9 +533,11 @@ Agent 读取 HEARTBEAT.md
"auth_method": "oauth"
}
```
+
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
**Ollama (本地)**
+
```json
{
"model_name": "llama3",
@@ -765,12 +546,14 @@ Agent 读取 HEARTBEAT.md
```
**自定义代理/API**
+
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
- "api_key": "sk-..."
+ "api_key": "sk-...",
+ "request_timeout": 300
}
```
@@ -802,6 +585,7 @@ Agent 读取 HEARTBEAT.md
旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
**旧配置(已弃用):**
+
```json
{
"providers": {
@@ -820,6 +604,7 @@ Agent 读取 HEARTBEAT.md
```
**新配置(推荐):**
+
```json
{
"model_list": [
@@ -844,7 +629,7 @@ Agent 读取 HEARTBEAT.md
**1. 获取 API key 和 base URL**
-* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
**2. 配置**
@@ -866,7 +651,6 @@ Agent 读取 HEARTBEAT.md
}
}
}
-
```
**3. 运行**
@@ -946,30 +730,29 @@ picoclaw agent -m "你好"
"interval": 30
}
}
-
```
## CLI 命令行参考
-| 命令 | 描述 |
-| --- | --- |
-| `picoclaw onboard` | 初始化配置和工作区 |
-| `picoclaw agent -m "..."` | 与 Agent 对话 |
-| `picoclaw agent` | 交互式聊天模式 |
-| `picoclaw gateway` | 启动网关 (Gateway) |
-| `picoclaw status` | 显示状态 |
-| `picoclaw cron list` | 列出所有定时任务 |
-| `picoclaw cron add ...` | 添加定时任务 |
+| 命令 | 描述 |
+| ------------------------- | ------------------ |
+| `picoclaw onboard` | 初始化配置和工作区 |
+| `picoclaw agent -m "..."` | 与 Agent 对话 |
+| `picoclaw agent` | 交互式聊天模式 |
+| `picoclaw gateway` | 启动网关 (Gateway) |
+| `picoclaw status` | 显示状态 |
+| `picoclaw cron list` | 列出所有定时任务 |
+| `picoclaw cron add ...` | 添加定时任务 |
### 定时任务 / 提醒 (Scheduled Tasks)
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
-* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
-* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
-* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
+- **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
+- **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
+- **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
@@ -983,7 +766,7 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
用户群组:
-Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
+Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
@@ -995,8 +778,9 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
启用网络搜索:
-1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
+1. 在 [https://tavily.com](https://tavily.com) (1000 次免费) 或 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (2000 次免费)
2. 添加到 `~/.picoclaw/config.json`:
+
```json
{
"tools": {
@@ -1013,11 +797,8 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
}
}
}
-
```
-
-
### 遇到内容过滤错误 (Content Filtering Errors)
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
@@ -1035,5 +816,5 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
+| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
-| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
\ No newline at end of file
diff --git a/assets/wechat.png b/assets/wechat.png
index 8fc41ea7d..776c07885 100644
Binary files a/assets/wechat.png and b/assets/wechat.png differ
diff --git a/cmd/picoclaw/cmd_cron.go b/cmd/picoclaw/cmd_cron.go
deleted file mode 100644
index 8c42bde06..000000000
--- a/cmd/picoclaw/cmd_cron.go
+++ /dev/null
@@ -1,227 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "time"
-
- "github.com/sipeed/picoclaw/pkg/cron"
-)
-
-func cronCmd() {
- if len(os.Args) < 3 {
- cronHelp()
- return
- }
-
- subcommand := os.Args[2]
-
- // Load config to get workspace path
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- return
- }
-
- cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
-
- switch subcommand {
- case "list":
- cronListCmd(cronStorePath)
- case "add":
- cronAddCmd(cronStorePath)
- case "remove":
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw cron remove ")
- return
- }
- cronRemoveCmd(cronStorePath, os.Args[3])
- case "enable":
- cronEnableCmd(cronStorePath, false)
- case "disable":
- cronEnableCmd(cronStorePath, true)
- default:
- fmt.Printf("Unknown cron command: %s\n", subcommand)
- cronHelp()
- }
-}
-
-func cronHelp() {
- fmt.Println("\nCron commands:")
- fmt.Println(" list List all scheduled jobs")
- fmt.Println(" add Add a new scheduled job")
- fmt.Println(" remove Remove a job by ID")
- fmt.Println(" enable Enable a job")
- fmt.Println(" disable Disable a job")
- fmt.Println()
- fmt.Println("Add options:")
- fmt.Println(" -n, --name Job name")
- fmt.Println(" -m, --message Message for agent")
- fmt.Println(" -e, --every Run every N seconds")
- fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
- fmt.Println(" -d, --deliver Deliver response to channel")
- fmt.Println(" --to Recipient for delivery")
- fmt.Println(" --channel Channel for delivery")
-}
-
-func cronListCmd(storePath string) {
- cs := cron.NewCronService(storePath, nil)
- jobs := cs.ListJobs(true) // Show all jobs, including disabled
-
- if len(jobs) == 0 {
- fmt.Println("No scheduled jobs.")
- return
- }
-
- fmt.Println("\nScheduled Jobs:")
- fmt.Println("----------------")
- for _, job := range jobs {
- var schedule string
- if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
- schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
- } else if job.Schedule.Kind == "cron" {
- schedule = job.Schedule.Expr
- } else {
- schedule = "one-time"
- }
-
- nextRun := "scheduled"
- if job.State.NextRunAtMS != nil {
- nextTime := time.UnixMilli(*job.State.NextRunAtMS)
- nextRun = nextTime.Format("2006-01-02 15:04")
- }
-
- status := "enabled"
- if !job.Enabled {
- status = "disabled"
- }
-
- fmt.Printf(" %s (%s)\n", job.Name, job.ID)
- fmt.Printf(" Schedule: %s\n", schedule)
- fmt.Printf(" Status: %s\n", status)
- fmt.Printf(" Next run: %s\n", nextRun)
- }
-}
-
-func cronAddCmd(storePath string) {
- name := ""
- message := ""
- var everySec *int64
- cronExpr := ""
- deliver := false
- channel := ""
- to := ""
-
- args := os.Args[3:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "-n", "--name":
- if i+1 < len(args) {
- name = args[i+1]
- i++
- }
- case "-m", "--message":
- if i+1 < len(args) {
- message = args[i+1]
- i++
- }
- case "-e", "--every":
- if i+1 < len(args) {
- var sec int64
- fmt.Sscanf(args[i+1], "%d", &sec)
- everySec = &sec
- i++
- }
- case "-c", "--cron":
- if i+1 < len(args) {
- cronExpr = args[i+1]
- i++
- }
- case "-d", "--deliver":
- deliver = true
- case "--to":
- if i+1 < len(args) {
- to = args[i+1]
- i++
- }
- case "--channel":
- if i+1 < len(args) {
- channel = args[i+1]
- i++
- }
- }
- }
-
- if name == "" {
- fmt.Println("Error: --name is required")
- return
- }
-
- if message == "" {
- fmt.Println("Error: --message is required")
- return
- }
-
- if everySec == nil && cronExpr == "" {
- fmt.Println("Error: Either --every or --cron must be specified")
- return
- }
-
- var schedule cron.CronSchedule
- if everySec != nil {
- everyMS := *everySec * 1000
- schedule = cron.CronSchedule{
- Kind: "every",
- EveryMS: &everyMS,
- }
- } else {
- schedule = cron.CronSchedule{
- Kind: "cron",
- Expr: cronExpr,
- }
- }
-
- cs := cron.NewCronService(storePath, nil)
- job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
- if err != nil {
- fmt.Printf("Error adding job: %v\n", err)
- return
- }
-
- fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
-}
-
-func cronRemoveCmd(storePath, jobID string) {
- cs := cron.NewCronService(storePath, nil)
- if cs.RemoveJob(jobID) {
- fmt.Printf("✓ Removed job %s\n", jobID)
- } else {
- fmt.Printf("✗ Job %s not found\n", jobID)
- }
-}
-
-func cronEnableCmd(storePath string, disable bool) {
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw cron enable/disable ")
- return
- }
-
- jobID := os.Args[3]
- cs := cron.NewCronService(storePath, nil)
- enabled := !disable
-
- job := cs.EnableJob(jobID, enabled)
- if job != nil {
- status := "enabled"
- if disable {
- status = "disabled"
- }
- fmt.Printf("✓ Job '%s' %s\n", job.Name, status)
- } else {
- fmt.Printf("✗ Job %s not found\n", jobID)
- }
-}
diff --git a/cmd/picoclaw/cmd_migrate.go b/cmd/picoclaw/cmd_migrate.go
deleted file mode 100644
index 86d4903ef..000000000
--- a/cmd/picoclaw/cmd_migrate.go
+++ /dev/null
@@ -1,81 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
-
-import (
- "fmt"
- "os"
-
- "github.com/sipeed/picoclaw/pkg/migrate"
-)
-
-func migrateCmd() {
- if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
- migrateHelp()
- return
- }
-
- opts := migrate.Options{}
-
- args := os.Args[2:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--dry-run":
- opts.DryRun = true
- case "--config-only":
- opts.ConfigOnly = true
- case "--workspace-only":
- opts.WorkspaceOnly = true
- case "--force":
- opts.Force = true
- case "--refresh":
- opts.Refresh = true
- case "--openclaw-home":
- if i+1 < len(args) {
- opts.OpenClawHome = args[i+1]
- i++
- }
- case "--picoclaw-home":
- if i+1 < len(args) {
- opts.PicoClawHome = args[i+1]
- i++
- }
- default:
- fmt.Printf("Unknown flag: %s\n", args[i])
- migrateHelp()
- os.Exit(1)
- }
- }
-
- result, err := migrate.Run(opts)
- if err != nil {
- fmt.Printf("Error: %v\n", err)
- os.Exit(1)
- }
-
- if !opts.DryRun {
- migrate.PrintSummary(result)
- }
-}
-
-func migrateHelp() {
- fmt.Println("\nMigrate from OpenClaw to PicoClaw")
- fmt.Println()
- fmt.Println("Usage: picoclaw migrate [options]")
- fmt.Println()
- fmt.Println("Options:")
- fmt.Println(" --dry-run Show what would be migrated without making changes")
- fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
- fmt.Println(" --config-only Only migrate config, skip workspace files")
- fmt.Println(" --workspace-only Only migrate workspace files, skip config")
- fmt.Println(" --force Skip confirmation prompts")
- fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
- fmt.Println(" --picoclaw-home Override PicoClaw home directory (default: ~/.picoclaw)")
- fmt.Println()
- fmt.Println("Examples:")
- fmt.Println(" picoclaw migrate Detect and migrate from OpenClaw")
- fmt.Println(" picoclaw migrate --dry-run Show what would be migrated")
- fmt.Println(" picoclaw migrate --refresh Re-sync workspace files")
- fmt.Println(" picoclaw migrate --force Migrate without confirmation")
-}
diff --git a/cmd/picoclaw/internal/agent/command.go b/cmd/picoclaw/internal/agent/command.go
new file mode 100644
index 000000000..47262fc85
--- /dev/null
+++ b/cmd/picoclaw/internal/agent/command.go
@@ -0,0 +1,30 @@
+package agent
+
+import (
+ "github.com/spf13/cobra"
+)
+
+func NewAgentCommand() *cobra.Command {
+ var (
+ message string
+ sessionKey string
+ model string
+ debug bool
+ )
+
+ cmd := &cobra.Command{
+ Use: "agent",
+ Short: "Interact with the agent directly",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return agentCmd(message, sessionKey, model, debug)
+ },
+ }
+
+ cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
+ cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)")
+ cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key")
+ cmd.Flags().StringVarP(&model, "model", "", "", "Model to use")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/agent/command_test.go b/cmd/picoclaw/internal/agent/command_test.go
new file mode 100644
index 000000000..1457d6a49
--- /dev/null
+++ b/cmd/picoclaw/internal/agent/command_test.go
@@ -0,0 +1,33 @@
+package agent
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewAgentCommand(t *testing.T) {
+ cmd := NewAgentCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "agent", cmd.Use)
+ assert.Equal(t, "Interact with the agent directly", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 0)
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ assert.True(t, cmd.HasFlags())
+
+ assert.NotNil(t, cmd.Flags().Lookup("debug"))
+ assert.NotNil(t, cmd.Flags().Lookup("message"))
+ assert.NotNil(t, cmd.Flags().Lookup("session"))
+ assert.NotNil(t, cmd.Flags().Lookup("model"))
+}
diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/internal/agent/helpers.go
similarity index 68%
rename from cmd/picoclaw/cmd_agent.go
rename to cmd/picoclaw/internal/agent/helpers.go
index 6d6ff935f..746e9755e 100644
--- a/cmd/picoclaw/cmd_agent.go
+++ b/cmd/picoclaw/internal/agent/helpers.go
@@ -1,7 +1,4 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
+package agent
import (
"bufio"
@@ -14,59 +11,40 @@ import (
"github.com/chzyer/readline"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
-func agentCmd() {
- message := ""
- sessionKey := "cli:default"
- modelOverride := ""
-
- args := os.Args[2:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--debug", "-d":
- logger.SetLevel(logger.DEBUG)
- fmt.Println("🔍 Debug mode enabled")
- case "-m", "--message":
- if i+1 < len(args) {
- message = args[i+1]
- i++
- }
- case "-s", "--session":
- if i+1 < len(args) {
- sessionKey = args[i+1]
- i++
- }
- case "--model", "-model":
- if i+1 < len(args) {
- modelOverride = args[i+1]
- i++
- }
- }
+func agentCmd(message, sessionKey, model string, debug bool) error {
+ if sessionKey == "" {
+ sessionKey = "cli:default"
}
- cfg, err := loadConfig()
+ if debug {
+ logger.SetLevel(logger.DEBUG)
+ fmt.Println("🔍 Debug mode enabled")
+ }
+
+ cfg, err := internal.LoadConfig()
if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("error loading config: %w", err)
}
- if modelOverride != "" {
- cfg.Agents.Defaults.Model = modelOverride
+ if model != "" {
+ cfg.Agents.Defaults.ModelName = model
}
provider, modelID, err := providers.CreateProvider(cfg)
if err != nil {
- fmt.Printf("Error creating provider: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("error creating provider: %w", err)
}
+
// Use the resolved model ID from provider creation
if modelID != "" {
- cfg.Agents.Defaults.Model = modelID
+ cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()
@@ -85,18 +63,20 @@ func agentCmd() {
ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
if err != nil {
- fmt.Printf("Error: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("error processing message: %w", err)
}
- fmt.Printf("\n%s %s\n", logo, response)
- } else {
- fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
- interactiveMode(agentLoop, sessionKey)
+ fmt.Printf("\n%s %s\n", internal.Logo, response)
+ return nil
}
+
+ fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", internal.Logo)
+ interactiveMode(agentLoop, sessionKey)
+
+ return nil
}
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
- prompt := fmt.Sprintf("%s You: ", logo)
+ prompt := fmt.Sprintf("%s You: ", internal.Logo)
rl, err := readline.NewEx(&readline.Config{
Prompt: prompt,
@@ -141,14 +121,14 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
continue
}
- fmt.Printf("\n%s %s\n\n", logo, response)
+ fmt.Printf("\n%s %s\n\n", internal.Logo, response)
}
}
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
reader := bufio.NewReader(os.Stdin)
for {
- fmt.Print(fmt.Sprintf("%s You: ", logo))
+ fmt.Print(fmt.Sprintf("%s You: ", internal.Logo))
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
@@ -176,6 +156,6 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
continue
}
- fmt.Printf("\n%s %s\n\n", logo, response)
+ fmt.Printf("\n%s %s\n\n", internal.Logo, response)
}
}
diff --git a/cmd/picoclaw/internal/auth/command.go b/cmd/picoclaw/internal/auth/command.go
new file mode 100644
index 000000000..12a0a3a8c
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/command.go
@@ -0,0 +1,22 @@
+package auth
+
+import "github.com/spf13/cobra"
+
+func NewAuthCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "auth",
+ Short: "Manage authentication (login, logout, status)",
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return cmd.Help()
+ },
+ }
+
+ cmd.AddCommand(
+ newLoginCommand(),
+ newLogoutCommand(),
+ newStatusCommand(),
+ newModelsCommand(),
+ )
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/auth/command_test.go b/cmd/picoclaw/internal/auth/command_test.go
new file mode 100644
index 000000000..48dc704dd
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/command_test.go
@@ -0,0 +1,55 @@
+package auth
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewAuthCommand(t *testing.T) {
+ cmd := NewAuthCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "auth", cmd.Use)
+ assert.Equal(t, "Manage authentication (login, logout, status)", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 0)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ assert.False(t, cmd.HasFlags())
+ assert.True(t, cmd.HasSubCommands())
+
+ allowedCommands := []string{
+ "login",
+ "logout",
+ "status",
+ "models",
+ }
+
+ subcommands := cmd.Commands()
+ assert.Len(t, subcommands, len(allowedCommands))
+
+ for _, subcmd := range subcommands {
+ found := slices.Contains(allowedCommands, subcmd.Name())
+ assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
+
+ assert.Len(t, subcmd.Aliases, 0)
+ assert.False(t, subcmd.Hidden)
+
+ assert.False(t, subcmd.HasSubCommands())
+
+ assert.Nil(t, subcmd.Run)
+ assert.NotNil(t, subcmd.RunE)
+
+ assert.Nil(t, subcmd.PersistentPreRun)
+ assert.Nil(t, subcmd.PersistentPostRun)
+ }
+}
diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/internal/auth/helpers.go
similarity index 65%
rename from cmd/picoclaw/cmd_auth.go
rename to cmd/picoclaw/internal/auth/helpers.go
index 729c56177..633ce8740 100644
--- a/cmd/picoclaw/cmd_auth.go
+++ b/cmd/picoclaw/internal/auth/helpers.go
@@ -1,7 +1,4 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
+package auth
import (
"encoding/json"
@@ -12,92 +9,28 @@ import (
"strings"
"time"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
-const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity"
-
-func authCmd() {
- if len(os.Args) < 3 {
- authHelp()
- return
- }
-
- switch os.Args[2] {
- case "login":
- authLoginCmd()
- case "logout":
- authLogoutCmd()
- case "status":
- authStatusCmd()
- case "models":
- authModelsCmd()
- default:
- fmt.Printf("Unknown auth command: %s\n", os.Args[2])
- authHelp()
- }
-}
-
-func authHelp() {
- fmt.Println("\nAuth commands:")
- fmt.Println(" login Login via OAuth or paste token")
- fmt.Println(" logout Remove stored credentials")
- fmt.Println(" status Show current auth status")
- fmt.Println(" models List available Antigravity models")
- fmt.Println()
- fmt.Println("Login options:")
- fmt.Println(" --provider Provider to login with (openai, anthropic, google-antigravity)")
- fmt.Println(" --device-code Use device code flow (for headless environments)")
- fmt.Println()
- fmt.Println("Examples:")
- fmt.Println(" picoclaw auth login --provider openai")
- fmt.Println(" picoclaw auth login --provider openai --device-code")
- fmt.Println(" picoclaw auth login --provider anthropic")
- fmt.Println(" picoclaw auth login --provider google-antigravity")
- fmt.Println(" picoclaw auth models")
- fmt.Println(" picoclaw auth logout --provider openai")
- fmt.Println(" picoclaw auth status")
-}
-
-func authLoginCmd() {
- provider := ""
- useDeviceCode := false
-
- args := os.Args[3:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--provider", "-p":
- if i+1 < len(args) {
- provider = args[i+1]
- i++
- }
- case "--device-code":
- useDeviceCode = true
- }
- }
-
- if provider == "" {
- fmt.Println("Error: --provider is required")
- fmt.Println(supportedProvidersMsg)
- return
- }
+const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
+func authLoginCmd(provider string, useDeviceCode bool) error {
switch provider {
case "openai":
- authLoginOpenAI(useDeviceCode)
+ return authLoginOpenAI(useDeviceCode)
case "anthropic":
- authLoginPasteToken(provider)
+ return authLoginPasteToken(provider)
case "google-antigravity", "antigravity":
- authLoginGoogleAntigravity()
+ return authLoginGoogleAntigravity()
default:
- fmt.Printf("Unsupported provider: %s\n", provider)
- fmt.Println(supportedProvidersMsg)
+ return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
}
}
-func authLoginOpenAI(useDeviceCode bool) {
+func authLoginOpenAI(useDeviceCode bool) error {
cfg := auth.OpenAIOAuthConfig()
var cred *auth.AuthCredential
@@ -110,16 +43,14 @@ func authLoginOpenAI(useDeviceCode bool) {
}
if err != nil {
- fmt.Printf("Login failed: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("login failed: %w", err)
}
if err = auth.SetCredential("openai", cred); err != nil {
- fmt.Printf("Failed to save credentials: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("failed to save credentials: %w", err)
}
- appCfg, err := loadConfig()
+ appCfg, err := internal.LoadConfig()
if err == nil {
// Update Providers (legacy format)
appCfg.Providers.OpenAI.AuthMethod = "oauth"
@@ -144,10 +75,10 @@ func authLoginOpenAI(useDeviceCode bool) {
}
// Update default model to use OpenAI
- appCfg.Agents.Defaults.Model = "gpt-5.2"
+ appCfg.Agents.Defaults.ModelName = "gpt-5.2"
- if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
- fmt.Printf("Warning: could not update config: %v\n", err)
+ if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
+ return fmt.Errorf("could not update config: %w", err)
}
}
@@ -156,15 +87,16 @@ func authLoginOpenAI(useDeviceCode bool) {
fmt.Printf("Account: %s\n", cred.AccountID)
}
fmt.Println("Default model set to: gpt-5.2")
+
+ return nil
}
-func authLoginGoogleAntigravity() {
+func authLoginGoogleAntigravity() error {
cfg := auth.GoogleAntigravityOAuthConfig()
cred, err := auth.LoginBrowser(cfg)
if err != nil {
- fmt.Printf("Login failed: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("login failed: %w", err)
}
cred.Provider = "google-antigravity"
@@ -189,11 +121,10 @@ func authLoginGoogleAntigravity() {
}
if err = auth.SetCredential("google-antigravity", cred); err != nil {
- fmt.Printf("Failed to save credentials: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("failed to save credentials: %w", err)
}
- appCfg, err := loadConfig()
+ appCfg, err := internal.LoadConfig()
if err == nil {
// Update Providers (legacy format, for backward compatibility)
appCfg.Providers.Antigravity.AuthMethod = "oauth"
@@ -218,9 +149,9 @@ func authLoginGoogleAntigravity() {
}
// Update default model
- appCfg.Agents.Defaults.Model = "gemini-flash"
+ appCfg.Agents.Defaults.ModelName = "gemini-flash"
- if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
+ if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err)
}
}
@@ -228,6 +159,8 @@ func authLoginGoogleAntigravity() {
fmt.Println("\n✓ Google Antigravity login successful!")
fmt.Println("Default model set to: gemini-flash")
fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
+
+ return nil
}
func fetchGoogleUserEmail(accessToken string) (string, error) {
@@ -258,19 +191,17 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
return userInfo.Email, nil
}
-func authLoginPasteToken(provider string) {
+func authLoginPasteToken(provider string) error {
cred, err := auth.LoginPasteToken(provider, os.Stdin)
if err != nil {
- fmt.Printf("Login failed: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("login failed: %w", err)
}
if err = auth.SetCredential(provider, cred); err != nil {
- fmt.Printf("Failed to save credentials: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("failed to save credentials: %w", err)
}
- appCfg, err := loadConfig()
+ appCfg, err := internal.LoadConfig()
if err == nil {
switch provider {
case "anthropic":
@@ -292,7 +223,7 @@ func authLoginPasteToken(provider string) {
})
}
// Update default model
- appCfg.Agents.Defaults.Model = "claude-sonnet-4.6"
+ appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
case "openai":
appCfg.Providers.OpenAI.AuthMethod = "token"
// Update ModelList
@@ -312,38 +243,29 @@ func authLoginPasteToken(provider string) {
})
}
// Update default model
- appCfg.Agents.Defaults.Model = "gpt-5.2"
+ appCfg.Agents.Defaults.ModelName = "gpt-5.2"
}
- if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
- fmt.Printf("Warning: could not update config: %v\n", err)
+ if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
+ return fmt.Errorf("could not update config: %w", err)
}
}
fmt.Printf("Token saved for %s!\n", provider)
- fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.Model)
-}
-func authLogoutCmd() {
- provider := ""
-
- args := os.Args[3:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--provider", "-p":
- if i+1 < len(args) {
- provider = args[i+1]
- i++
- }
- }
+ if appCfg != nil {
+ fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
}
+ return nil
+}
+
+func authLogoutCmd(provider string) error {
if provider != "" {
if err := auth.DeleteCredential(provider); err != nil {
- fmt.Printf("Failed to remove credentials: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("failed to remove credentials: %w", err)
}
- appCfg, err := loadConfig()
+ appCfg, err := internal.LoadConfig()
if err == nil {
// Clear AuthMethod in ModelList
for i := range appCfg.ModelList {
@@ -371,44 +293,46 @@ func authLogoutCmd() {
case "google-antigravity", "antigravity":
appCfg.Providers.Antigravity.AuthMethod = ""
}
- config.SaveConfig(getConfigPath(), appCfg)
+ config.SaveConfig(internal.GetConfigPath(), appCfg)
}
fmt.Printf("Logged out from %s\n", provider)
- } else {
- if err := auth.DeleteAllCredentials(); err != nil {
- fmt.Printf("Failed to remove credentials: %v\n", err)
- os.Exit(1)
- }
- appCfg, err := loadConfig()
- if err == nil {
- // Clear all AuthMethods in ModelList
- for i := range appCfg.ModelList {
- appCfg.ModelList[i].AuthMethod = ""
- }
- // Clear all AuthMethods in Providers (legacy)
- appCfg.Providers.OpenAI.AuthMethod = ""
- appCfg.Providers.Anthropic.AuthMethod = ""
- appCfg.Providers.Antigravity.AuthMethod = ""
- config.SaveConfig(getConfigPath(), appCfg)
- }
-
- fmt.Println("Logged out from all providers")
+ return nil
}
+
+ if err := auth.DeleteAllCredentials(); err != nil {
+ return fmt.Errorf("failed to remove credentials: %w", err)
+ }
+
+ appCfg, err := internal.LoadConfig()
+ if err == nil {
+ // Clear all AuthMethods in ModelList
+ for i := range appCfg.ModelList {
+ appCfg.ModelList[i].AuthMethod = ""
+ }
+ // Clear all AuthMethods in Providers (legacy)
+ appCfg.Providers.OpenAI.AuthMethod = ""
+ appCfg.Providers.Anthropic.AuthMethod = ""
+ appCfg.Providers.Antigravity.AuthMethod = ""
+ config.SaveConfig(internal.GetConfigPath(), appCfg)
+ }
+
+ fmt.Println("Logged out from all providers")
+
+ return nil
}
-func authStatusCmd() {
+func authStatusCmd() error {
store, err := auth.LoadStore()
if err != nil {
- fmt.Printf("Error loading auth store: %v\n", err)
- return
+ return fmt.Errorf("failed to load auth store: %w", err)
}
if len(store.Credentials) == 0 {
fmt.Println("No authenticated providers.")
fmt.Println("Run: picoclaw auth login --provider ")
- return
+ return nil
}
fmt.Println("\nAuthenticated Providers:")
@@ -437,14 +361,16 @@ func authStatusCmd() {
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
}
}
+
+ return nil
}
-func authModelsCmd() {
+func authModelsCmd() error {
cred, err := auth.GetCredential("google-antigravity")
if err != nil || cred == nil {
- fmt.Println("Not logged in to Google Antigravity.")
- fmt.Println("Run: picoclaw auth login --provider google-antigravity")
- return
+ return fmt.Errorf(
+ "not logged in to Google Antigravity.\nrun: picoclaw auth login --provider google-antigravity",
+ )
}
// Refresh token if needed
@@ -459,21 +385,18 @@ func authModelsCmd() {
projectID := cred.ProjectID
if projectID == "" {
- fmt.Println("No project ID stored. Try logging in again.")
- return
+ return fmt.Errorf("no project id stored. Try logging in again")
}
fmt.Printf("Fetching models for project: %s\n\n", projectID)
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
if err != nil {
- fmt.Printf("Error fetching models: %v\n", err)
- return
+ return fmt.Errorf("error fetching models: %w", err)
}
if len(models) == 0 {
- fmt.Println("No models available.")
- return
+ return fmt.Errorf("no models available")
}
fmt.Println("Available Antigravity Models:")
@@ -489,6 +412,8 @@ func authModelsCmd() {
}
fmt.Printf(" %s %s\n", status, name)
}
+
+ return nil
}
// isAntigravityModel checks if a model string belongs to antigravity provider
diff --git a/cmd/picoclaw/internal/auth/login.go b/cmd/picoclaw/internal/auth/login.go
new file mode 100644
index 000000000..9a6d28d2f
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/login.go
@@ -0,0 +1,25 @@
+package auth
+
+import "github.com/spf13/cobra"
+
+func newLoginCommand() *cobra.Command {
+ var (
+ provider string
+ useDeviceCode bool
+ )
+
+ cmd := &cobra.Command{
+ Use: "login",
+ Short: "Login via OAuth or paste token",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return authLoginCmd(provider, useDeviceCode)
+ },
+ }
+
+ cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
+ cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
+ _ = cmd.MarkFlagRequired("provider")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/auth/login_test.go b/cmd/picoclaw/internal/auth/login_test.go
new file mode 100644
index 000000000..d6a03c25b
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/login_test.go
@@ -0,0 +1,29 @@
+package auth
+
+import (
+ "testing"
+
+ "github.com/spf13/cobra"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewLoginSubCommand(t *testing.T) {
+ cmd := newLoginCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "Login via OAuth or paste token", cmd.Short)
+
+ assert.True(t, cmd.HasFlags())
+
+ assert.NotNil(t, cmd.Flags().Lookup("device-code"))
+
+ providerFlag := cmd.Flags().Lookup("provider")
+ require.NotNil(t, providerFlag)
+
+ val, found := providerFlag.Annotations[cobra.BashCompOneRequiredFlag]
+ require.True(t, found)
+ require.NotEmpty(t, val)
+ assert.Equal(t, "true", val[0])
+}
diff --git a/cmd/picoclaw/internal/auth/logout.go b/cmd/picoclaw/internal/auth/logout.go
new file mode 100644
index 000000000..384667524
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/logout.go
@@ -0,0 +1,20 @@
+package auth
+
+import "github.com/spf13/cobra"
+
+func newLogoutCommand() *cobra.Command {
+ var provider string
+
+ cmd := &cobra.Command{
+ Use: "logout",
+ Short: "Remove stored credentials",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return authLogoutCmd(provider)
+ },
+ }
+
+ cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to logout from (openai, anthropic); empty = all")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/auth/logout_test.go b/cmd/picoclaw/internal/auth/logout_test.go
new file mode 100644
index 000000000..c0f3a5e92
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/logout_test.go
@@ -0,0 +1,20 @@
+package auth
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewLogoutSubcommand(t *testing.T) {
+ cmd := newLogoutCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "Remove stored credentials", cmd.Short)
+
+ assert.True(t, cmd.HasFlags())
+
+ assert.NotNil(t, cmd.Flags().Lookup("provider"))
+}
diff --git a/cmd/picoclaw/internal/auth/models.go b/cmd/picoclaw/internal/auth/models.go
new file mode 100644
index 000000000..cabe6822c
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/models.go
@@ -0,0 +1,15 @@
+package auth
+
+import "github.com/spf13/cobra"
+
+func newModelsCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "models",
+ Short: "Show available models",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return authModelsCmd()
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/auth/models_test.go b/cmd/picoclaw/internal/auth/models_test.go
new file mode 100644
index 000000000..26ca67787
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/models_test.go
@@ -0,0 +1,19 @@
+package auth
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewModelsCommand(t *testing.T) {
+ cmd := newModelsCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "models", cmd.Use)
+ assert.Equal(t, "Show available models", cmd.Short)
+
+ assert.False(t, cmd.HasFlags())
+}
diff --git a/cmd/picoclaw/internal/auth/status.go b/cmd/picoclaw/internal/auth/status.go
new file mode 100644
index 000000000..ca3007d12
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/status.go
@@ -0,0 +1,16 @@
+package auth
+
+import "github.com/spf13/cobra"
+
+func newStatusCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "status",
+ Short: "Show current auth status",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return authStatusCmd()
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/auth/status_test.go b/cmd/picoclaw/internal/auth/status_test.go
new file mode 100644
index 000000000..7748ba502
--- /dev/null
+++ b/cmd/picoclaw/internal/auth/status_test.go
@@ -0,0 +1,18 @@
+package auth
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewStatusSubcommand(t *testing.T) {
+ cmd := newStatusCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "Show current auth status", cmd.Short)
+
+ assert.False(t, cmd.HasFlags())
+}
diff --git a/cmd/picoclaw/internal/cron/add.go b/cmd/picoclaw/internal/cron/add.go
new file mode 100644
index 000000000..947557d5a
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/add.go
@@ -0,0 +1,64 @@
+package cron
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/cron"
+)
+
+func newAddCommand(storePath func() string) *cobra.Command {
+ var (
+ name string
+ message string
+ every int64
+ cronExp string
+ deliver bool
+ channel string
+ to string
+ )
+
+ cmd := &cobra.Command{
+ Use: "add",
+ Short: "Add a new scheduled job",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ if every <= 0 && cronExp == "" {
+ return fmt.Errorf("either --every or --cron must be specified")
+ }
+
+ var schedule cron.CronSchedule
+ if every > 0 {
+ everyMS := every * 1000
+ schedule = cron.CronSchedule{Kind: "every", EveryMS: &everyMS}
+ } else {
+ schedule = cron.CronSchedule{Kind: "cron", Expr: cronExp}
+ }
+
+ cs := cron.NewCronService(storePath(), nil)
+ job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
+ if err != nil {
+ return fmt.Errorf("error adding job: %w", err)
+ }
+
+ fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
+
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVarP(&name, "name", "n", "", "Job name")
+ cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent")
+ cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
+ cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')")
+ cmd.Flags().BoolVarP(&deliver, "deliver", "d", false, "Deliver response to channel")
+ cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
+ cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")
+
+ _ = cmd.MarkFlagRequired("name")
+ _ = cmd.MarkFlagRequired("message")
+ cmd.MarkFlagsMutuallyExclusive("every", "cron")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/cron/add_test.go b/cmd/picoclaw/internal/cron/add_test.go
new file mode 100644
index 000000000..09701fab5
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/add_test.go
@@ -0,0 +1,57 @@
+package cron
+
+import (
+ "testing"
+
+ "github.com/spf13/cobra"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewAddSubcommand(t *testing.T) {
+ fn := func() string { return "" }
+ cmd := newAddCommand(fn)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "add", cmd.Use)
+ assert.Equal(t, "Add a new scheduled job", cmd.Short)
+
+ assert.True(t, cmd.HasFlags())
+
+ assert.NotNil(t, cmd.Flags().Lookup("every"))
+ assert.NotNil(t, cmd.Flags().Lookup("cron"))
+ assert.NotNil(t, cmd.Flags().Lookup("deliver"))
+ assert.NotNil(t, cmd.Flags().Lookup("to"))
+ assert.NotNil(t, cmd.Flags().Lookup("channel"))
+
+ nameFlag := cmd.Flags().Lookup("name")
+ require.NotNil(t, nameFlag)
+
+ messageFlag := cmd.Flags().Lookup("message")
+ require.NotNil(t, messageFlag)
+
+ val, found := nameFlag.Annotations[cobra.BashCompOneRequiredFlag]
+ require.True(t, found)
+ require.NotEmpty(t, val)
+ assert.Equal(t, "true", val[0])
+
+ val, found = messageFlag.Annotations[cobra.BashCompOneRequiredFlag]
+ require.True(t, found)
+ require.NotEmpty(t, val)
+ assert.Equal(t, "true", val[0])
+}
+
+func TestNewAddCommandEveryAndCronMutuallyExclusive(t *testing.T) {
+ cmd := newAddCommand(func() string { return "testing" })
+
+ cmd.SetArgs([]string{
+ "--name", "job",
+ "--message", "hello",
+ "--every", "10",
+ "--cron", "0 9 * * *",
+ })
+
+ err := cmd.Execute()
+ require.Error(t, err)
+}
diff --git a/cmd/picoclaw/internal/cron/command.go b/cmd/picoclaw/internal/cron/command.go
new file mode 100644
index 000000000..39f8ccf28
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/command.go
@@ -0,0 +1,44 @@
+package cron
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+)
+
+func NewCronCommand() *cobra.Command {
+ var storePath string
+
+ cmd := &cobra.Command{
+ Use: "cron",
+ Aliases: []string{"c"},
+ Short: "Manage scheduled tasks",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return cmd.Help()
+ },
+ // Resolve storePath at execution time so it reflects the current config
+ // and is shared across all subcommands.
+ PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
+ cfg, err := internal.LoadConfig()
+ if err != nil {
+ return fmt.Errorf("error loading config: %w", err)
+ }
+ storePath = filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
+ return nil
+ },
+ }
+
+ cmd.AddCommand(
+ newListCommand(func() string { return storePath }),
+ newAddCommand(func() string { return storePath }),
+ newRemoveCommand(func() string { return storePath }),
+ newEnableCommand(func() string { return storePath }),
+ newDisableCommand(func() string { return storePath }),
+ )
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/cron/command_test.go b/cmd/picoclaw/internal/cron/command_test.go
new file mode 100644
index 000000000..af2ac83ae
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/command_test.go
@@ -0,0 +1,58 @@
+package cron
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewCronCommand(t *testing.T) {
+ cmd := NewCronCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "Manage scheduled tasks", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 1)
+ assert.True(t, cmd.HasAlias("c"))
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.NotNil(t, cmd.PersistentPreRunE)
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ assert.True(t, cmd.HasSubCommands())
+
+ allowedCommands := []string{
+ "list",
+ "add",
+ "remove",
+ "enable",
+ "disable",
+ }
+
+ subcommands := cmd.Commands()
+ assert.Len(t, subcommands, len(allowedCommands))
+
+ for _, subcmd := range subcommands {
+ found := slices.Contains(allowedCommands, subcmd.Name())
+ assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
+
+ assert.Len(t, subcmd.Aliases, 0)
+ assert.False(t, subcmd.Hidden)
+
+ assert.False(t, subcmd.HasSubCommands())
+
+ assert.Nil(t, subcmd.Run)
+ assert.NotNil(t, subcmd.RunE)
+
+ assert.Nil(t, subcmd.PersistentPreRun)
+ assert.Nil(t, subcmd.PersistentPostRun)
+ }
+}
diff --git a/cmd/picoclaw/internal/cron/disable.go b/cmd/picoclaw/internal/cron/disable.go
new file mode 100644
index 000000000..a3670fd50
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/disable.go
@@ -0,0 +1,16 @@
+package cron
+
+import "github.com/spf13/cobra"
+
+func newDisableCommand(storePath func() string) *cobra.Command {
+ return &cobra.Command{
+ Use: "disable",
+ Short: "Disable a job",
+ Args: cobra.ExactArgs(1),
+ Example: `picoclaw cron disable 1`,
+ RunE: func(_ *cobra.Command, args []string) error {
+ cronSetJobEnabled(storePath(), args[0], false)
+ return nil
+ },
+ }
+}
diff --git a/cmd/picoclaw/internal/cron/disable_test.go b/cmd/picoclaw/internal/cron/disable_test.go
new file mode 100644
index 000000000..e5d2ff844
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/disable_test.go
@@ -0,0 +1,20 @@
+package cron
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDisableSubcommand(t *testing.T) {
+ fn := func() string { return "" }
+ cmd := newDisableCommand(fn)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "disable", cmd.Use)
+ assert.Equal(t, "Disable a job", cmd.Short)
+
+ assert.True(t, cmd.HasExample())
+}
diff --git a/cmd/picoclaw/internal/cron/enable.go b/cmd/picoclaw/internal/cron/enable.go
new file mode 100644
index 000000000..7f8b05233
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/enable.go
@@ -0,0 +1,16 @@
+package cron
+
+import "github.com/spf13/cobra"
+
+func newEnableCommand(storePath func() string) *cobra.Command {
+ return &cobra.Command{
+ Use: "enable",
+ Short: "Enable a job",
+ Args: cobra.ExactArgs(1),
+ Example: `picoclaw cron enable 1`,
+ RunE: func(_ *cobra.Command, args []string) error {
+ cronSetJobEnabled(storePath(), args[0], true)
+ return nil
+ },
+ }
+}
diff --git a/cmd/picoclaw/internal/cron/enable_test.go b/cmd/picoclaw/internal/cron/enable_test.go
new file mode 100644
index 000000000..85a2e01aa
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/enable_test.go
@@ -0,0 +1,20 @@
+package cron
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEnableSubcommand(t *testing.T) {
+ fn := func() string { return "" }
+ cmd := newEnableCommand(fn)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "enable", cmd.Use)
+ assert.Equal(t, "Enable a job", cmd.Short)
+
+ assert.True(t, cmd.HasExample())
+}
diff --git a/cmd/picoclaw/internal/cron/helpers.go b/cmd/picoclaw/internal/cron/helpers.go
new file mode 100644
index 000000000..88bdf1bf7
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/helpers.go
@@ -0,0 +1,66 @@
+package cron
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/cron"
+)
+
+func cronListCmd(storePath string) {
+ cs := cron.NewCronService(storePath, nil)
+ jobs := cs.ListJobs(true) // Show all jobs, including disabled
+
+ if len(jobs) == 0 {
+ fmt.Println("No scheduled jobs.")
+ return
+ }
+
+ fmt.Println("\nScheduled Jobs:")
+ fmt.Println("----------------")
+ for _, job := range jobs {
+ var schedule string
+ if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
+ schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
+ } else if job.Schedule.Kind == "cron" {
+ schedule = job.Schedule.Expr
+ } else {
+ schedule = "one-time"
+ }
+
+ nextRun := "scheduled"
+ if job.State.NextRunAtMS != nil {
+ nextTime := time.UnixMilli(*job.State.NextRunAtMS)
+ nextRun = nextTime.Format("2006-01-02 15:04")
+ }
+
+ status := "enabled"
+ if !job.Enabled {
+ status = "disabled"
+ }
+
+ fmt.Printf(" %s (%s)\n", job.Name, job.ID)
+ fmt.Printf(" Schedule: %s\n", schedule)
+ fmt.Printf(" Status: %s\n", status)
+ fmt.Printf(" Next run: %s\n", nextRun)
+ }
+}
+
+func cronRemoveCmd(storePath, jobID string) {
+ cs := cron.NewCronService(storePath, nil)
+ if cs.RemoveJob(jobID) {
+ fmt.Printf("✓ Removed job %s\n", jobID)
+ } else {
+ fmt.Printf("✗ Job %s not found\n", jobID)
+ }
+}
+
+func cronSetJobEnabled(storePath, jobID string, enabled bool) {
+ cs := cron.NewCronService(storePath, nil)
+ job := cs.EnableJob(jobID, enabled)
+ if job != nil {
+ fmt.Printf("✓ Job '%s' enabled\n", job.Name)
+ } else {
+ fmt.Printf("✗ Job %s not found\n", jobID)
+ }
+}
diff --git a/cmd/picoclaw/internal/cron/list.go b/cmd/picoclaw/internal/cron/list.go
new file mode 100644
index 000000000..854eb1a44
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/list.go
@@ -0,0 +1,17 @@
+package cron
+
+import "github.com/spf13/cobra"
+
+func newListCommand(storePath func() string) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List all scheduled jobs",
+ Args: cobra.NoArgs,
+ RunE: func(_ *cobra.Command, _ []string) error {
+ cronListCmd(storePath())
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/cron/list_test.go b/cmd/picoclaw/internal/cron/list_test.go
new file mode 100644
index 000000000..0b9d1bd59
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/list_test.go
@@ -0,0 +1,17 @@
+package cron
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewListSubcommand(t *testing.T) {
+ fn := func() string { return "" }
+ cmd := newListCommand(fn)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "List all scheduled jobs", cmd.Short)
+}
diff --git a/cmd/picoclaw/internal/cron/remove.go b/cmd/picoclaw/internal/cron/remove.go
new file mode 100644
index 000000000..5f1d1a04b
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/remove.go
@@ -0,0 +1,18 @@
+package cron
+
+import "github.com/spf13/cobra"
+
+func newRemoveCommand(storePath func() string) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "remove",
+ Short: "Remove a job by ID",
+ Args: cobra.ExactArgs(1),
+ Example: `picoclaw cron remove 1`,
+ RunE: func(_ *cobra.Command, args []string) error {
+ cronRemoveCmd(storePath(), args[0])
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/cron/remove_test.go b/cmd/picoclaw/internal/cron/remove_test.go
new file mode 100644
index 000000000..36121f370
--- /dev/null
+++ b/cmd/picoclaw/internal/cron/remove_test.go
@@ -0,0 +1,19 @@
+package cron
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewRemoveSubcommand(t *testing.T) {
+ fn := func() string { return "" }
+ cmd := newRemoveCommand(fn)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "Remove a job by ID", cmd.Short)
+
+ assert.True(t, cmd.HasExample())
+}
diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go
new file mode 100644
index 000000000..66a56f9ce
--- /dev/null
+++ b/cmd/picoclaw/internal/gateway/command.go
@@ -0,0 +1,23 @@
+package gateway
+
+import (
+ "github.com/spf13/cobra"
+)
+
+func NewGatewayCommand() *cobra.Command {
+ var debug bool
+
+ cmd := &cobra.Command{
+ Use: "gateway",
+ Aliases: []string{"g"},
+ Short: "Start picoclaw gateway",
+ Args: cobra.NoArgs,
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return gatewayCmd(debug)
+ },
+ }
+
+ cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/gateway/command_test.go b/cmd/picoclaw/internal/gateway/command_test.go
new file mode 100644
index 000000000..4d591ea67
--- /dev/null
+++ b/cmd/picoclaw/internal/gateway/command_test.go
@@ -0,0 +1,31 @@
+package gateway
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewGatewayCommand(t *testing.T) {
+ cmd := NewGatewayCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "gateway", cmd.Use)
+ assert.Equal(t, "Start picoclaw gateway", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 1)
+ assert.True(t, cmd.HasAlias("g"))
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.True(t, cmd.HasFlags())
+ assert.NotNil(t, cmd.Flags().Lookup("debug"))
+}
diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/internal/gateway/helpers.go
similarity index 87%
rename from cmd/picoclaw/cmd_gateway.go
rename to cmd/picoclaw/internal/gateway/helpers.go
index 9a3b6aa19..a06625dc9 100644
--- a/cmd/picoclaw/cmd_gateway.go
+++ b/cmd/picoclaw/internal/gateway/helpers.go
@@ -1,17 +1,17 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
+package gateway
import (
"context"
+ "errors"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
+ "strings"
"time"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
@@ -27,31 +27,25 @@ import (
"github.com/sipeed/picoclaw/pkg/voice"
)
-func gatewayCmd() {
- // Check for --debug flag
- args := os.Args[2:]
- for _, arg := range args {
- if arg == "--debug" || arg == "-d" {
- logger.SetLevel(logger.DEBUG)
- fmt.Println("🔍 Debug mode enabled")
- break
- }
+func gatewayCmd(debug bool) error {
+ if debug {
+ logger.SetLevel(logger.DEBUG)
+ fmt.Println("🔍 Debug mode enabled")
}
- cfg, err := loadConfig()
+ cfg, err := internal.LoadConfig()
if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("error loading config: %w", err)
}
provider, modelID, err := providers.CreateProvider(cfg)
if err != nil {
- fmt.Printf("Error creating provider: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("error creating provider: %w", err)
}
+
// Use the resolved model ID from provider creation
if modelID != "" {
- cfg.Agents.Defaults.Model = modelID
+ cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()
@@ -113,16 +107,24 @@ func gatewayCmd() {
channelManager, err := channels.NewManager(cfg, msgBus)
if err != nil {
- fmt.Printf("Error creating channel manager: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("error creating channel manager: %w", err)
}
// Inject channel manager into agent loop for command handling
agentLoop.SetChannelManager(channelManager)
var transcriber *voice.GroqTranscriber
- if cfg.Providers.Groq.APIKey != "" {
- transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey)
+ groqAPIKey := cfg.Providers.Groq.APIKey
+ if groqAPIKey == "" {
+ for _, mc := range cfg.ModelList {
+ if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
+ groqAPIKey = mc.APIKey
+ break
+ }
+ }
+ }
+ if groqAPIKey != "" {
+ transcriber = voice.NewGroqTranscriber(groqAPIKey)
logger.InfoC("voice", "Groq voice transcription enabled")
}
@@ -188,7 +190,7 @@ func gatewayCmd() {
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
go func() {
- if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
+ if err := healthServer.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
}
}()
@@ -201,6 +203,9 @@ func gatewayCmd() {
<-sigChan
fmt.Println("\nShutting down...")
+ if cp, ok := provider.(providers.StatefulProvider); ok {
+ cp.Close()
+ }
cancel()
healthServer.Stop(context.Background())
deviceService.Stop()
@@ -209,6 +214,8 @@ func gatewayCmd() {
agentLoop.Stop()
channelManager.StopAll(ctx)
fmt.Println("✓ Gateway stopped")
+
+ return nil
}
func setupCronTool(
diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go
new file mode 100644
index 000000000..1f52df5dd
--- /dev/null
+++ b/cmd/picoclaw/internal/helpers.go
@@ -0,0 +1,52 @@
+package internal
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+const Logo = "🦞"
+
+var (
+ version = "dev"
+ gitCommit string
+ buildTime string
+ goVersion string
+)
+
+func GetConfigPath() string {
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".picoclaw", "config.json")
+}
+
+func LoadConfig() (*config.Config, error) {
+ return config.LoadConfig(GetConfigPath())
+}
+
+// FormatVersion returns the version string with optional git commit
+func FormatVersion() string {
+ v := version
+ if gitCommit != "" {
+ v += fmt.Sprintf(" (git: %s)", gitCommit)
+ }
+ return v
+}
+
+// FormatBuildInfo returns build time and go version info
+func FormatBuildInfo() (string, string) {
+ build := buildTime
+ goVer := goVersion
+ if goVer == "" {
+ goVer = runtime.Version()
+ }
+ return build, goVer
+}
+
+// GetVersion returns the version string
+func GetVersion() string {
+ return version
+}
diff --git a/cmd/picoclaw/internal/helpers_test.go b/cmd/picoclaw/internal/helpers_test.go
new file mode 100644
index 000000000..9342d141d
--- /dev/null
+++ b/cmd/picoclaw/internal/helpers_test.go
@@ -0,0 +1,97 @@
+package internal
+
+import (
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetConfigPath(t *testing.T) {
+ t.Setenv("HOME", "/tmp/home")
+
+ got := GetConfigPath()
+ want := filepath.Join("/tmp/home", ".picoclaw", "config.json")
+
+ assert.Equal(t, want, got)
+}
+
+func TestFormatVersion_NoGitCommit(t *testing.T) {
+ oldVersion, oldGit := version, gitCommit
+ t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
+
+ version = "1.2.3"
+ gitCommit = ""
+
+ assert.Equal(t, "1.2.3", FormatVersion())
+}
+
+func TestFormatVersion_WithGitCommit(t *testing.T) {
+ oldVersion, oldGit := version, gitCommit
+ t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
+
+ version = "1.2.3"
+ gitCommit = "abc123"
+
+ assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
+}
+
+func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
+ oldBuildTime, oldGoVersion := buildTime, goVersion
+ t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
+
+ buildTime = "2026-02-20T00:00:00Z"
+ goVersion = "go1.23.0"
+
+ build, goVer := FormatBuildInfo()
+
+ assert.Equal(t, buildTime, build)
+ assert.Equal(t, goVersion, goVer)
+}
+
+func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
+ oldBuildTime, oldGoVersion := buildTime, goVersion
+ t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
+
+ buildTime = ""
+ goVersion = "go1.23.0"
+
+ build, goVer := FormatBuildInfo()
+
+ assert.Empty(t, build)
+ assert.Equal(t, goVersion, goVer)
+}
+
+func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
+ oldBuildTime, oldGoVersion := buildTime, goVersion
+ t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
+
+ buildTime = "x"
+ goVersion = ""
+
+ build, goVer := FormatBuildInfo()
+
+ assert.Equal(t, "x", build)
+ assert.Equal(t, runtime.Version(), goVer)
+}
+
+func TestGetConfigPath_Windows(t *testing.T) {
+ if runtime.GOOS != "windows" {
+ t.Skip("windows-specific HOME behavior varies; run on windows")
+ }
+
+ testUserProfilePath := `C:\Users\Test`
+ t.Setenv("USERPROFILE", testUserProfilePath)
+
+ got := GetConfigPath()
+ want := filepath.Join(testUserProfilePath, ".picoclaw", "config.json")
+
+ require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
+}
+
+func TestGetVersion(t *testing.T) {
+ assert.Equal(t, "dev", GetVersion())
+}
diff --git a/cmd/picoclaw/internal/migrate/command.go b/cmd/picoclaw/internal/migrate/command.go
new file mode 100644
index 000000000..fb1cee164
--- /dev/null
+++ b/cmd/picoclaw/internal/migrate/command.go
@@ -0,0 +1,48 @@
+package migrate
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/migrate"
+)
+
+func NewMigrateCommand() *cobra.Command {
+ var opts migrate.Options
+
+ cmd := &cobra.Command{
+ Use: "migrate",
+ Short: "Migrate from OpenClaw to PicoClaw",
+ Args: cobra.NoArgs,
+ Example: ` picoclaw migrate
+ picoclaw migrate --dry-run
+ picoclaw migrate --refresh
+ picoclaw migrate --force`,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ result, err := migrate.Run(opts)
+ if err != nil {
+ return err
+ }
+ if !opts.DryRun {
+ migrate.PrintSummary(result)
+ }
+ return nil
+ },
+ }
+
+ cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
+ "Show what would be migrated without making changes")
+ cmd.Flags().BoolVar(&opts.Refresh, "refresh", false,
+ "Re-sync workspace files from OpenClaw (repeatable)")
+ cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
+ "Only migrate config, skip workspace files")
+ cmd.Flags().BoolVar(&opts.WorkspaceOnly, "workspace-only", false,
+ "Only migrate workspace files, skip config")
+ cmd.Flags().BoolVar(&opts.Force, "force", false,
+ "Skip confirmation prompts")
+ cmd.Flags().StringVar(&opts.OpenClawHome, "openclaw-home", "",
+ "Override OpenClaw home directory (default: ~/.openclaw)")
+ cmd.Flags().StringVar(&opts.PicoClawHome, "picoclaw-home", "",
+ "Override PicoClaw home directory (default: ~/.picoclaw)")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/migrate/command_test.go b/cmd/picoclaw/internal/migrate/command_test.go
new file mode 100644
index 000000000..1948aa327
--- /dev/null
+++ b/cmd/picoclaw/internal/migrate/command_test.go
@@ -0,0 +1,38 @@
+package migrate
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewMigrateCommand(t *testing.T) {
+ cmd := NewMigrateCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "migrate", cmd.Use)
+ assert.Equal(t, "Migrate from OpenClaw to PicoClaw", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 0)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ assert.True(t, cmd.HasFlags())
+
+ assert.NotNil(t, cmd.Flags().Lookup("dry-run"))
+ assert.NotNil(t, cmd.Flags().Lookup("refresh"))
+ assert.NotNil(t, cmd.Flags().Lookup("config-only"))
+ assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
+ assert.NotNil(t, cmd.Flags().Lookup("force"))
+ assert.NotNil(t, cmd.Flags().Lookup("openclaw-home"))
+ assert.NotNil(t, cmd.Flags().Lookup("picoclaw-home"))
+}
diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go
new file mode 100644
index 000000000..ec1012959
--- /dev/null
+++ b/cmd/picoclaw/internal/onboard/command.go
@@ -0,0 +1,24 @@
+package onboard
+
+import (
+ "embed"
+
+ "github.com/spf13/cobra"
+)
+
+//go:generate cp -r ../../../../workspace .
+//go:embed workspace
+var embeddedFiles embed.FS
+
+func NewOnboardCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "onboard",
+ Aliases: []string{"o"},
+ Short: "Initialize picoclaw configuration and workspace",
+ Run: func(cmd *cobra.Command, args []string) {
+ onboard()
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/onboard/command_test.go b/cmd/picoclaw/internal/onboard/command_test.go
new file mode 100644
index 000000000..bc799a079
--- /dev/null
+++ b/cmd/picoclaw/internal/onboard/command_test.go
@@ -0,0 +1,29 @@
+package onboard
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewOnboardCommand(t *testing.T) {
+ cmd := NewOnboardCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "onboard", cmd.Use)
+ assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 1)
+ assert.True(t, cmd.HasAlias("o"))
+
+ assert.NotNil(t, cmd.Run)
+ assert.Nil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ assert.False(t, cmd.HasFlags())
+ assert.False(t, cmd.HasSubCommands())
+}
diff --git a/cmd/picoclaw/cmd_onboard.go b/cmd/picoclaw/internal/onboard/helpers.go
similarity index 90%
rename from cmd/picoclaw/cmd_onboard.go
rename to cmd/picoclaw/internal/onboard/helpers.go
index 1a9ebad61..4db8bdc8b 100644
--- a/cmd/picoclaw/cmd_onboard.go
+++ b/cmd/picoclaw/internal/onboard/helpers.go
@@ -1,24 +1,17 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
+package onboard
import (
- "embed"
"fmt"
"io/fs"
"os"
"path/filepath"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
-//go:generate cp -r ../../workspace .
-//go:embed workspace
-var embeddedFiles embed.FS
-
func onboard() {
- configPath := getConfigPath()
+ configPath := internal.GetConfigPath()
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Config already exists at %s\n", configPath)
@@ -40,7 +33,7 @@ func onboard() {
workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace)
- fmt.Printf("%s picoclaw is ready!\n", logo)
+ fmt.Printf("%s picoclaw is ready!\n", internal.Logo)
fmt.Println("\nNext steps:")
fmt.Println(" 1. Add your API key to", configPath)
fmt.Println("")
@@ -53,6 +46,13 @@ func onboard() {
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
}
+func createWorkspaceTemplates(workspace string) {
+ err := copyEmbeddedToTarget(workspace)
+ if err != nil {
+ fmt.Printf("Error copying workspace templates: %v\n", err)
+ }
+}
+
func copyEmbeddedToTarget(targetDir string) error {
// Ensure target directory exists
if err := os.MkdirAll(targetDir, 0o755); err != nil {
@@ -99,10 +99,3 @@ func copyEmbeddedToTarget(targetDir string) error {
return err
}
-
-func createWorkspaceTemplates(workspace string) {
- err := copyEmbeddedToTarget(workspace)
- if err != nil {
- fmt.Printf("Error copying workspace templates: %v\n", err)
- }
-}
diff --git a/cmd/picoclaw/internal/skills/command.go b/cmd/picoclaw/internal/skills/command.go
new file mode 100644
index 000000000..7f8bd011d
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/command.go
@@ -0,0 +1,79 @@
+package skills
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+type deps struct {
+ workspace string
+ installer *skills.SkillInstaller
+ skillsLoader *skills.SkillsLoader
+}
+
+func NewSkillsCommand() *cobra.Command {
+ var d deps
+
+ cmd := &cobra.Command{
+ Use: "skills",
+ Short: "Manage skills",
+ PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
+ cfg, err := internal.LoadConfig()
+ if err != nil {
+ return fmt.Errorf("error loading config: %w", err)
+ }
+
+ d.workspace = cfg.WorkspacePath()
+ d.installer = skills.NewSkillInstaller(d.workspace)
+
+ // get global config directory and builtin skills directory
+ globalDir := filepath.Dir(internal.GetConfigPath())
+ globalSkillsDir := filepath.Join(globalDir, "skills")
+ builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
+ d.skillsLoader = skills.NewSkillsLoader(d.workspace, globalSkillsDir, builtinSkillsDir)
+
+ return nil
+ },
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return cmd.Help()
+ },
+ }
+
+ installerFn := func() (*skills.SkillInstaller, error) {
+ if d.installer == nil {
+ return nil, fmt.Errorf("skills installer is not initialized")
+ }
+ return d.installer, nil
+ }
+
+ loaderFn := func() (*skills.SkillsLoader, error) {
+ if d.skillsLoader == nil {
+ return nil, fmt.Errorf("skills loader is not initialized")
+ }
+ return d.skillsLoader, nil
+ }
+
+ workspaceFn := func() (string, error) {
+ if d.workspace == "" {
+ return "", fmt.Errorf("workspace is not initialized")
+ }
+ return d.workspace, nil
+ }
+
+ cmd.AddCommand(
+ newListCommand(loaderFn),
+ newInstallCommand(installerFn),
+ newInstallBuiltinCommand(workspaceFn),
+ newListBuiltinCommand(),
+ newRemoveCommand(installerFn),
+ newSearchCommand(installerFn),
+ newShowCommand(loaderFn),
+ )
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/command_test.go b/cmd/picoclaw/internal/skills/command_test.go
new file mode 100644
index 000000000..0917d1384
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/command_test.go
@@ -0,0 +1,28 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewSkillsCommand(t *testing.T) {
+ cmd := NewSkillsCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "skills", cmd.Use)
+ assert.Equal(t, "Manage skills", cmd.Short)
+
+ assert.Len(t, cmd.Aliases, 0)
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.NotNil(t, cmd.PersistentPreRunE)
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+}
diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/internal/skills/helpers.go
similarity index 72%
rename from cmd/picoclaw/cmd_skills.go
rename to cmd/picoclaw/internal/skills/helpers.go
index 0814494b3..439b81a4f 100644
--- a/cmd/picoclaw/cmd_skills.go
+++ b/cmd/picoclaw/internal/skills/helpers.go
@@ -1,40 +1,20 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
+package skills
import (
"context"
"fmt"
+ "io"
"os"
"path/filepath"
"strings"
"time"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
)
-func skillsHelp() {
- fmt.Println("\nSkills commands:")
- fmt.Println(" list List installed skills")
- fmt.Println(" install Install skill from GitHub")
- fmt.Println(" install-builtin Install all builtin skills to workspace")
- fmt.Println(" list-builtin List available builtin skills")
- fmt.Println(" remove Remove installed skill")
- fmt.Println(" search Search available skills")
- fmt.Println(" show Show skill details")
- fmt.Println()
- fmt.Println("Examples:")
- fmt.Println(" picoclaw skills list")
- fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
- fmt.Println(" picoclaw skills install-builtin")
- fmt.Println(" picoclaw skills list-builtin")
- fmt.Println(" picoclaw skills remove weather")
- fmt.Println(" picoclaw skills install --registry clawhub github")
-}
-
func skillsListCmd(loader *skills.SkillsLoader) {
allSkills := loader.ListSkills()
@@ -53,53 +33,31 @@ func skillsListCmd(loader *skills.SkillsLoader) {
}
}
-func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) {
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw skills install ")
- fmt.Println(" picoclaw skills install --registry ")
- return
- }
-
- // Check for --registry flag.
- if os.Args[3] == "--registry" {
- if len(os.Args) < 6 {
- fmt.Println("Usage: picoclaw skills install --registry ")
- fmt.Println("Example: picoclaw skills install --registry clawhub github")
- return
- }
- registryName := os.Args[4]
- slug := os.Args[5]
- skillsInstallFromRegistry(cfg, registryName, slug)
- return
- }
-
- // Default: install from GitHub (backward compatible).
- repo := os.Args[3]
+func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
fmt.Printf("Installing skill from %s...\n", repo)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
- fmt.Printf("\u2717 Failed to install skill: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("failed to install skill: %w", err)
}
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
+
+ return nil
}
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
-func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
+func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
err := utils.ValidateSkillIdentifier(registryName)
if err != nil {
- fmt.Printf("\u2717 Invalid registry name: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("✗ invalid registry name: %w", err)
}
err = utils.ValidateSkillIdentifier(slug)
if err != nil {
- fmt.Printf("\u2717 Invalid slug: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("✗ invalid slug: %w", err)
}
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
@@ -111,24 +69,21 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
registry := registryMgr.GetRegistry(registryName)
if registry == nil {
- fmt.Printf("\u2717 Registry '%s' not found or not enabled. Check your config.json.\n", registryName)
- os.Exit(1)
+ return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
}
workspace := cfg.WorkspacePath()
targetDir := filepath.Join(workspace, "skills", slug)
if _, err = os.Stat(targetDir); err == nil {
- fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
- os.Exit(1)
+ return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
- fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
}
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
@@ -137,8 +92,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
if rmErr != nil {
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
}
- fmt.Printf("\u2717 Failed to install skill: %v\n", err)
- os.Exit(1)
+ return fmt.Errorf("✗ failed to install skill: %w", err)
}
if result.IsMalwareBlocked {
@@ -146,8 +100,8 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
if rmErr != nil {
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
}
- fmt.Printf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
- os.Exit(1)
+
+ return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
}
if result.IsSuspicious {
@@ -158,6 +112,8 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
if result.Summary != "" {
fmt.Printf(" %s\n", result.Summary)
}
+
+ return nil
}
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
@@ -208,7 +164,7 @@ func skillsInstallBuiltinCmd(workspace string) {
}
func skillsListBuiltinCmd() {
- cfg, err := loadConfig()
+ cfg, err := internal.LoadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return
@@ -303,3 +259,37 @@ func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
fmt.Println("----------------------")
fmt.Println(content)
}
+
+func copyDirectory(src, dst string) error {
+ return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ relPath, err := filepath.Rel(src, path)
+ if err != nil {
+ return err
+ }
+
+ dstPath := filepath.Join(dst, relPath)
+
+ if info.IsDir() {
+ return os.MkdirAll(dstPath, info.Mode())
+ }
+
+ srcFile, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer srcFile.Close()
+
+ dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
+ if err != nil {
+ return err
+ }
+ defer dstFile.Close()
+
+ _, err = io.Copy(dstFile, srcFile)
+ return err
+ })
+}
diff --git a/cmd/picoclaw/internal/skills/install.go b/cmd/picoclaw/internal/skills/install.go
new file mode 100644
index 000000000..a30f68632
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/install.go
@@ -0,0 +1,58 @@
+package skills
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
+ var registry string
+
+ cmd := &cobra.Command{
+ Use: "install",
+ Short: "Install skill from GitHub",
+ Example: `
+picoclaw skills install sipeed/picoclaw-skills/weather
+picoclaw skills install --registry clawhub github
+`,
+ Args: func(cmd *cobra.Command, args []string) error {
+ if registry != "" {
+ if len(args) != 2 {
+ return fmt.Errorf("when --registry is set, exactly 2 arguments are required: ")
+ }
+ return nil
+ }
+
+ if len(args) != 1 {
+ return fmt.Errorf("exactly 1 argument is required: ")
+ }
+
+ return nil
+ },
+ RunE: func(_ *cobra.Command, args []string) error {
+ installer, err := installerFn()
+ if err != nil {
+ return err
+ }
+
+ if registry != "" {
+ cfg, err := internal.LoadConfig()
+ if err != nil {
+ return err
+ }
+
+ return skillsInstallFromRegistry(cfg, args[0], args[1])
+ }
+
+ return skillsInstallCmd(installer, args[0])
+ },
+ }
+
+ cmd.Flags().StringVar(®istry, "registry", "", "Install from registry: --registry ")
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/install_test.go b/cmd/picoclaw/internal/skills/install_test.go
new file mode 100644
index 000000000..97787a986
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/install_test.go
@@ -0,0 +1,28 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewInstallSubcommand(t *testing.T) {
+ cmd := newInstallCommand(nil)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "install", cmd.Use)
+ assert.Equal(t, "Install skill from GitHub", cmd.Short)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.True(t, cmd.HasFlags())
+ assert.NotNil(t, cmd.Flags().Lookup("registry"))
+
+ assert.Len(t, cmd.Aliases, 0)
+}
diff --git a/cmd/picoclaw/internal/skills/installbuiltin.go b/cmd/picoclaw/internal/skills/installbuiltin.go
new file mode 100644
index 000000000..d4b7c6a9f
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/installbuiltin.go
@@ -0,0 +1,21 @@
+package skills
+
+import "github.com/spf13/cobra"
+
+func newInstallBuiltinCommand(workspaceFn func() (string, error)) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "install-builtin",
+ Short: "Install all builtin skills to workspace",
+ Example: `picoclaw skills install-builtin`,
+ RunE: func(_ *cobra.Command, _ []string) error {
+ workspace, err := workspaceFn()
+ if err != nil {
+ return err
+ }
+ skillsInstallBuiltinCmd(workspace)
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/installbuiltin_test.go b/cmd/picoclaw/internal/skills/installbuiltin_test.go
new file mode 100644
index 000000000..ea65907e3
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/installbuiltin_test.go
@@ -0,0 +1,27 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewInstallbuiltinSubcommand(t *testing.T) {
+ cmd := newInstallBuiltinCommand(nil)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "install-builtin", cmd.Use)
+ assert.Equal(t, "Install all builtin skills to workspace", cmd.Short)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Len(t, cmd.Aliases, 0)
+}
diff --git a/cmd/picoclaw/internal/skills/list.go b/cmd/picoclaw/internal/skills/list.go
new file mode 100644
index 000000000..7d89ff8ed
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/list.go
@@ -0,0 +1,25 @@
+package skills
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+func newListCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List installed skills",
+ Example: `picoclaw skills list`,
+ RunE: func(_ *cobra.Command, _ []string) error {
+ loader, err := loaderFn()
+ if err != nil {
+ return err
+ }
+ skillsListCmd(loader)
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/list_test.go b/cmd/picoclaw/internal/skills/list_test.go
new file mode 100644
index 000000000..9947ce7aa
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/list_test.go
@@ -0,0 +1,27 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewListSubcommand(t *testing.T) {
+ cmd := newListCommand(nil)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "list", cmd.Use)
+ assert.Equal(t, "List installed skills", cmd.Short)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Len(t, cmd.Aliases, 0)
+}
diff --git a/cmd/picoclaw/internal/skills/listbuiltin.go b/cmd/picoclaw/internal/skills/listbuiltin.go
new file mode 100644
index 000000000..a3efb8d83
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/listbuiltin.go
@@ -0,0 +1,16 @@
+package skills
+
+import "github.com/spf13/cobra"
+
+func newListBuiltinCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list-builtin",
+ Short: "List available builtin skills",
+ Example: `picoclaw skills list-builtin`,
+ Run: func(_ *cobra.Command, _ []string) {
+ skillsListBuiltinCmd()
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/listbuiltin_test.go b/cmd/picoclaw/internal/skills/listbuiltin_test.go
new file mode 100644
index 000000000..d4f45a436
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/listbuiltin_test.go
@@ -0,0 +1,26 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewListbuiltinSubcommand(t *testing.T) {
+ cmd := newListBuiltinCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "list-builtin", cmd.Use)
+ assert.Equal(t, "List available builtin skills", cmd.Short)
+
+ assert.NotNil(t, cmd.Run)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Len(t, cmd.Aliases, 0)
+}
diff --git a/cmd/picoclaw/internal/skills/remove.go b/cmd/picoclaw/internal/skills/remove.go
new file mode 100644
index 000000000..cd7d3a8b4
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/remove.go
@@ -0,0 +1,27 @@
+package skills
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "remove",
+ Aliases: []string{"rm", "uninstall"},
+ Short: "Remove installed skill",
+ Args: cobra.ExactArgs(1),
+ Example: `picoclaw skills remove weather`,
+ RunE: func(_ *cobra.Command, args []string) error {
+ installer, err := installerFn()
+ if err != nil {
+ return err
+ }
+ skillsRemoveCmd(installer, args[0])
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/remove_test.go b/cmd/picoclaw/internal/skills/remove_test.go
new file mode 100644
index 000000000..b4c79760c
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/remove_test.go
@@ -0,0 +1,29 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewRemoveSubcommand(t *testing.T) {
+ cmd := newRemoveCommand(nil)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "remove", cmd.Use)
+ assert.Equal(t, "Remove installed skill", cmd.Short)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Len(t, cmd.Aliases, 2)
+ assert.True(t, cmd.HasAlias("rm"))
+ assert.True(t, cmd.HasAlias("uninstall"))
+}
diff --git a/cmd/picoclaw/internal/skills/search.go b/cmd/picoclaw/internal/skills/search.go
new file mode 100644
index 000000000..53bc99109
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/search.go
@@ -0,0 +1,24 @@
+package skills
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+func newSearchCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "search",
+ Short: "Search available skills",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ installer, err := installerFn()
+ if err != nil {
+ return err
+ }
+ skillsSearchCmd(installer)
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/search_test.go b/cmd/picoclaw/internal/skills/search_test.go
new file mode 100644
index 000000000..19f63a9ff
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/search_test.go
@@ -0,0 +1,25 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewSearchSubcommand(t *testing.T) {
+ cmd := newSearchCommand(nil)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "search", cmd.Use)
+ assert.Equal(t, "Search available skills", cmd.Short)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.False(t, cmd.HasSubCommands())
+ assert.False(t, cmd.HasFlags())
+
+ assert.Len(t, cmd.Aliases, 0)
+}
diff --git a/cmd/picoclaw/internal/skills/show.go b/cmd/picoclaw/internal/skills/show.go
new file mode 100644
index 000000000..e484f3f28
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/show.go
@@ -0,0 +1,26 @@
+package skills
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+func newShowCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "show",
+ Short: "Show skill details",
+ Args: cobra.ExactArgs(1),
+ Example: `picoclaw skills show weather`,
+ RunE: func(_ *cobra.Command, args []string) error {
+ loader, err := loaderFn()
+ if err != nil {
+ return err
+ }
+ skillsShowCmd(loader, args[0])
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/skills/show_test.go b/cmd/picoclaw/internal/skills/show_test.go
new file mode 100644
index 000000000..5858d2790
--- /dev/null
+++ b/cmd/picoclaw/internal/skills/show_test.go
@@ -0,0 +1,27 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewShowSubcommand(t *testing.T) {
+ cmd := newShowCommand(nil)
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "show", cmd.Use)
+ assert.Equal(t, "Show skill details", cmd.Short)
+
+ assert.Nil(t, cmd.Run)
+ assert.NotNil(t, cmd.RunE)
+
+ assert.True(t, cmd.HasExample())
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Len(t, cmd.Aliases, 0)
+}
diff --git a/cmd/picoclaw/internal/status/command.go b/cmd/picoclaw/internal/status/command.go
new file mode 100644
index 000000000..9303ae2ec
--- /dev/null
+++ b/cmd/picoclaw/internal/status/command.go
@@ -0,0 +1,18 @@
+package status
+
+import (
+ "github.com/spf13/cobra"
+)
+
+func NewStatusCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "status",
+ Aliases: []string{"s"},
+ Short: "Show picoclaw status",
+ Run: func(cmd *cobra.Command, args []string) {
+ statusCmd()
+ },
+ }
+
+ return cmd
+}
diff --git a/cmd/picoclaw/internal/status/command_test.go b/cmd/picoclaw/internal/status/command_test.go
new file mode 100644
index 000000000..974b4ea3d
--- /dev/null
+++ b/cmd/picoclaw/internal/status/command_test.go
@@ -0,0 +1,29 @@
+package status
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewStatusCommand(t *testing.T) {
+ cmd := NewStatusCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "status", cmd.Use)
+
+ assert.Len(t, cmd.Aliases, 1)
+ assert.True(t, cmd.HasAlias("s"))
+
+ assert.Equal(t, "Show picoclaw status", cmd.Short)
+
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.NotNil(t, cmd.Run)
+ assert.Nil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+}
diff --git a/cmd/picoclaw/cmd_status.go b/cmd/picoclaw/internal/status/helpers.go
similarity index 88%
rename from cmd/picoclaw/cmd_status.go
rename to cmd/picoclaw/internal/status/helpers.go
index 07296784e..ab28f4885 100644
--- a/cmd/picoclaw/cmd_status.go
+++ b/cmd/picoclaw/internal/status/helpers.go
@@ -1,27 +1,25 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-
-package main
+package status
import (
"fmt"
"os"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/auth"
)
func statusCmd() {
- cfg, err := loadConfig()
+ cfg, err := internal.LoadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return
}
- configPath := getConfigPath()
+ configPath := internal.GetConfigPath()
- fmt.Printf("%s picoclaw Status\n", logo)
- fmt.Printf("Version: %s\n", formatVersion())
- build, _ := formatBuildInfo()
+ fmt.Printf("%s picoclaw Status\n", internal.Logo)
+ fmt.Printf("Version: %s\n", internal.FormatVersion())
+ build, _ := internal.FormatBuildInfo()
if build != "" {
fmt.Printf("Build: %s\n", build)
}
@@ -41,7 +39,7 @@ func statusCmd() {
}
if _, err := os.Stat(configPath); err == nil {
- fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
+ fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
diff --git a/cmd/picoclaw/internal/version/command.go b/cmd/picoclaw/internal/version/command.go
new file mode 100644
index 000000000..1cf686671
--- /dev/null
+++ b/cmd/picoclaw/internal/version/command.go
@@ -0,0 +1,33 @@
+package version
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+)
+
+func NewVersionCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "version",
+ Aliases: []string{"v"},
+ Short: "Show version information",
+ Run: func(_ *cobra.Command, _ []string) {
+ printVersion()
+ },
+ }
+
+ return cmd
+}
+
+func printVersion() {
+ fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
+ build, goVer := internal.FormatBuildInfo()
+ if build != "" {
+ fmt.Printf(" Build: %s\n", build)
+ }
+ if goVer != "" {
+ fmt.Printf(" Go: %s\n", goVer)
+ }
+}
diff --git a/cmd/picoclaw/internal/version/command_test.go b/cmd/picoclaw/internal/version/command_test.go
new file mode 100644
index 000000000..f08a4d1ea
--- /dev/null
+++ b/cmd/picoclaw/internal/version/command_test.go
@@ -0,0 +1,31 @@
+package version
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewVersionCommand(t *testing.T) {
+ cmd := NewVersionCommand()
+
+ require.NotNil(t, cmd)
+
+ assert.Equal(t, "version", cmd.Use)
+
+ assert.Len(t, cmd.Aliases, 1)
+ assert.True(t, cmd.HasAlias("v"))
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Equal(t, "Show version information", cmd.Short)
+
+ assert.False(t, cmd.HasSubCommands())
+
+ assert.NotNil(t, cmd.Run)
+ assert.Nil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+}
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 1e4b393f8..6db69c990 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -8,192 +8,49 @@ package main
import (
"fmt"
- "io"
"os"
- "path/filepath"
- "runtime"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/skills"
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
)
-var (
- version = "dev"
- gitCommit string
- buildTime string
- goVersion string
-)
+func NewPicoclawCommand() *cobra.Command {
+ short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
-const logo = "🦞"
-
-// formatVersion returns the version string with optional git commit
-func formatVersion() string {
- v := version
- if gitCommit != "" {
- v += fmt.Sprintf(" (git: %s)", gitCommit)
+ cmd := &cobra.Command{
+ Use: "picoclaw",
+ Short: short,
+ Example: "picoclaw list",
}
- return v
-}
-// formatBuildInfo returns build time and go version info
-func formatBuildInfo() (build string, goVer string) {
- if buildTime != "" {
- build = buildTime
- }
- goVer = goVersion
- if goVer == "" {
- goVer = runtime.Version()
- }
- return
-}
+ cmd.AddCommand(
+ onboard.NewOnboardCommand(),
+ agent.NewAgentCommand(),
+ auth.NewAuthCommand(),
+ gateway.NewGatewayCommand(),
+ status.NewStatusCommand(),
+ cron.NewCronCommand(),
+ migrate.NewMigrateCommand(),
+ skills.NewSkillsCommand(),
+ version.NewVersionCommand(),
+ )
-func printVersion() {
- fmt.Printf("%s picoclaw %s\n", logo, formatVersion())
- build, goVer := formatBuildInfo()
- if build != "" {
- fmt.Printf(" Build: %s\n", build)
- }
- if goVer != "" {
- fmt.Printf(" Go: %s\n", goVer)
- }
-}
-
-func copyDirectory(src, dst string) error {
- return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
-
- relPath, err := filepath.Rel(src, path)
- if err != nil {
- return err
- }
-
- dstPath := filepath.Join(dst, relPath)
-
- if info.IsDir() {
- return os.MkdirAll(dstPath, info.Mode())
- }
-
- srcFile, err := os.Open(path)
- if err != nil {
- return err
- }
- defer srcFile.Close()
-
- dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
- if err != nil {
- return err
- }
- defer dstFile.Close()
-
- _, err = io.Copy(dstFile, srcFile)
- return err
- })
+ return cmd
}
func main() {
- if len(os.Args) < 2 {
- printHelp()
- os.Exit(1)
- }
-
- command := os.Args[1]
-
- switch command {
- case "onboard":
- onboard()
- case "agent":
- agentCmd()
- case "gateway":
- gatewayCmd()
- case "status":
- statusCmd()
- case "migrate":
- migrateCmd()
- case "auth":
- authCmd()
- case "cron":
- cronCmd()
- case "skills":
- if len(os.Args) < 3 {
- skillsHelp()
- return
- }
-
- subcommand := os.Args[2]
-
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- os.Exit(1)
- }
-
- workspace := cfg.WorkspacePath()
- installer := skills.NewSkillInstaller(workspace)
- // 获取全局配置目录和内置 skills 目录
- globalDir := filepath.Dir(getConfigPath())
- globalSkillsDir := filepath.Join(globalDir, "skills")
- builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
- skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
-
- switch subcommand {
- case "list":
- skillsListCmd(skillsLoader)
- case "install":
- skillsInstallCmd(installer, cfg)
- case "remove", "uninstall":
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw skills remove ")
- return
- }
- skillsRemoveCmd(installer, os.Args[3])
- case "install-builtin":
- skillsInstallBuiltinCmd(workspace)
- case "list-builtin":
- skillsListBuiltinCmd()
- case "search":
- skillsSearchCmd(installer)
- case "show":
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw skills show ")
- return
- }
- skillsShowCmd(skillsLoader, os.Args[3])
- default:
- fmt.Printf("Unknown skills command: %s\n", subcommand)
- skillsHelp()
- }
- case "version", "--version", "-v":
- printVersion()
- default:
- fmt.Printf("Unknown command: %s\n", command)
- printHelp()
+ cmd := NewPicoclawCommand()
+ if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}
-
-func printHelp() {
- fmt.Printf("%s picoclaw - Personal AI Assistant v%s\n\n", logo, version)
- fmt.Println("Usage: picoclaw ")
- fmt.Println()
- fmt.Println("Commands:")
- fmt.Println(" onboard Initialize picoclaw configuration and workspace")
- fmt.Println(" agent Interact with the agent directly")
- fmt.Println(" auth Manage authentication (login, logout, status)")
- fmt.Println(" gateway Start picoclaw gateway")
- fmt.Println(" status Show picoclaw status")
- fmt.Println(" cron Manage scheduled tasks")
- fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
- fmt.Println(" skills Manage skills (install, list, remove)")
- fmt.Println(" version Show version information")
-}
-
-func getConfigPath() string {
- home, _ := os.UserHomeDir()
- return filepath.Join(home, ".picoclaw", "config.json")
-}
-
-func loadConfig() (*config.Config, error) {
- return config.LoadConfig(getConfigPath())
-}
diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go
new file mode 100644
index 000000000..3740ba358
--- /dev/null
+++ b/cmd/picoclaw/main_test.go
@@ -0,0 +1,56 @@
+package main
+
+import (
+ "fmt"
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+)
+
+func TestNewPicoclawCommand(t *testing.T) {
+ cmd := NewPicoclawCommand()
+
+ require.NotNil(t, cmd)
+
+ short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
+
+ assert.Equal(t, "picoclaw", cmd.Use)
+ assert.Equal(t, short, cmd.Short)
+
+ assert.True(t, cmd.HasSubCommands())
+ assert.True(t, cmd.HasAvailableSubCommands())
+
+ assert.False(t, cmd.HasFlags())
+
+ assert.Nil(t, cmd.Run)
+ assert.Nil(t, cmd.RunE)
+
+ assert.Nil(t, cmd.PersistentPreRun)
+ assert.Nil(t, cmd.PersistentPostRun)
+
+ allowedCommands := []string{
+ "agent",
+ "auth",
+ "cron",
+ "gateway",
+ "migrate",
+ "onboard",
+ "skills",
+ "status",
+ "version",
+ }
+
+ subcommands := cmd.Commands()
+ assert.Len(t, subcommands, len(allowedCommands))
+
+ for _, subcmd := range subcommands {
+ found := slices.Contains(allowedCommands, subcmd.Name())
+ assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
+
+ assert.False(t, subcmd.Hidden)
+ }
+}
diff --git a/config/config.example.json b/config/config.example.json
index 77a8c0683..9575039f8 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -3,7 +3,7 @@
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
- "model": "gpt4",
+ "model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@@ -196,6 +196,10 @@
"volcengine": {
"api_key": "",
"api_base": ""
+ },
+ "mistral": {
+ "api_key": "",
+ "api_base": "https://api.mistral.ai/v1"
}
},
"tools": {
@@ -213,7 +217,8 @@
"enabled": false,
"api_key": "pplx-xxx",
"max_results": 5
- }
+ },
+ "proxy": ""
},
"cron": {
"exec_timeout_minutes": 5
@@ -243,7 +248,7 @@
"monitor_usb": true
},
"gateway": {
- "host": "0.0.0.0",
+ "host": "127.0.0.1",
"port": 18790
}
}
diff --git a/docs/channels/dingtalk/README.zh.md b/docs/channels/dingtalk/README.zh.md
new file mode 100644
index 000000000..1e445d0b0
--- /dev/null
+++ b/docs/channels/dingtalk/README.zh.md
@@ -0,0 +1,33 @@
+# 钉钉
+
+钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------- | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用钉钉频道 |
+| client_id | string | 是 | 钉钉应用的 Client ID |
+| client_secret | string | 是 | 钉钉应用的 Client Secret |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [钉钉开放平台](https://open.dingtalk.com/)
+2. 创建一个企业内部应用
+3. 从应用设置中获取 Client ID 和 Client Secret
+4. 配置OAuth和事件订阅(如需要)
+5. 将 Client ID 和 Client Secret 填入配置文件中
diff --git a/docs/channels/discord/README.zh.md b/docs/channels/discord/README.zh.md
new file mode 100644
index 000000000..5b597eced
--- /dev/null
+++ b/docs/channels/discord/README.zh.md
@@ -0,0 +1,35 @@
+# Discord
+
+Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"],
+ "mention_only": false
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 Discord 频道 |
+| token | string | 是 | Discord 机器人 Token |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+| mention_only | bool | 否 | 是否仅响应提及机器人的消息 |
+
+## 设置流程
+
+1. 前往 [Discord 开发者门户](https://discord.com/developers/applications) 创建一个新的应用
+2. 启用 Intents:
+ - Message Content Intent
+ - Server Members Intent
+3. 获取 Bot Token
+4. 将 Bot Token 填入配置文件中
+5. 邀请机器人加入服务器并授予必要权限(例如发送消息、读取消息历史等)
diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md
new file mode 100644
index 000000000..310827723
--- /dev/null
+++ b/docs/channels/feishu/README.zh.md
@@ -0,0 +1,37 @@
+# 飞书
+
+飞书(国际版名称:Lark)是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用飞书频道 |
+| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) |
+| app_secret | string | 是 | 飞书应用的 App Secret |
+| encrypt_key | string | 否 | 事件回调加密密钥 |
+| verification_token | string | 否 | 用于Webhook事件验证的Token |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [飞书开放平台](https://open.feishu.cn/)创建应用程序
+2. 获取 App ID 和 App Secret
+3. 配置事件订阅和Webhook URL
+4. 设置加密(可选,生产环境建议启用)
+5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md
new file mode 100644
index 000000000..fd3aa80da
--- /dev/null
+++ b/docs/channels/line/README.zh.md
@@ -0,0 +1,41 @@
+# Line
+
+PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| -------------------- | ------ | ---- | ------------------------------------------ |
+| enabled | bool | 是 | 是否启用 LINE Channel |
+| channel_secret | string | 是 | LINE Messaging API 的 Channel Secret |
+| channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token |
+| webhook_host | string | 是 | Webhook 监听的主机地址 (通常为 0.0.0.0) |
+| webhook_port | int | 是 | Webhook 监听的端口 (默认为 18791) |
+| webhook_path | string | 是 | Webhook 的路径 (默认为 /webhook/line) |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel
+2. 获取 Channel Secret 和 Channel Access Token
+3. 配置Webhook:
+ - Line要求Webhook必须使用HTTPS协议,因此需要部署一个支持HTTPS的服务器,或者使用反向代理工具如ngrok将本地服务器暴露到公网
+ - 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`
+ - 启用 Webhook 并验证 URL
+4. 将 Channel Secret 和 Channel Access Token 填入配置文件中
diff --git a/docs/channels/maixcam/README.zh.md b/docs/channels/maixcam/README.zh.md
new file mode 100644
index 000000000..8d53d4bef
--- /dev/null
+++ b/docs/channels/maixcam/README.zh.md
@@ -0,0 +1,31 @@
+# MaixCam
+
+MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信,支持边缘 AI 部署场景。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true,
+ "server_address": "0.0.0.0:8899",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| -------------- | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 MaixCam 频道 |
+| server_address | string | 是 | TCP 服务器监听地址和端口 |
+| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 |
+
+## 使用场景
+
+MaixCam 通道使 PicoClaw 能够作为边缘设备的 AI 后端运行:
+
+- **智能监控** :MaixCAM 发送图像帧,PicoClaw 通过视觉模型进行分析
+- **物联网控制** :设备发送传感器数据,PicoClaw 协调响应
+- **离线AI** :在本地网络部署 PicoClaw 实现低延迟推理
diff --git a/docs/channels/onebot/README.zh.md b/docs/channels/onebot/README.zh.md
new file mode 100644
index 000000000..6195f1c98
--- /dev/null
+++ b/docs/channels/onebot/README.zh.md
@@ -0,0 +1,31 @@
+# OneBot
+
+OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器人实现(例如 go-cqhttp、Mirai)提供了统一的接口。它使用 WebSocket 进行通信。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://localhost:8080",
+ "access_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 OneBot 频道 |
+| ws_url | string | 是 | OneBot 服务器的 WebSocket URL |
+| access_token | string | 否 | 连接 OneBot 服务器的访问令牌 |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 部署一个 OneBot 兼容的实现(例如napcat)
+2. 配置 OneBot 实现以启用 WebSocket 服务并设置访问令牌(如果需要)
+3. 将 WebSocket URL 和访问令牌填入配置文件中
diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md
new file mode 100644
index 000000000..bd774960f
--- /dev/null
+++ b/docs/channels/qq/README.zh.md
@@ -0,0 +1,32 @@
+# QQ
+
+PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------- | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 QQ Channel |
+| app_id | string | 是 | QQ 机器人应用的 App ID |
+| app_secret | string | 是 | QQ 机器人应用的 App Secret |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [QQ 开放平台](https://q.qq.com/) 创建一个机器人
+2. 通过仪表盘获取 App ID 和 App Secret
+3. 开启机器人沙箱模式, 将用户和群添加到沙箱中
+4. 将 App ID 和 App Secret 填入配置文件中
diff --git a/docs/channels/slack/README.zh.md b/docs/channels/slack/README.zh.md
new file mode 100644
index 000000000..58ebcb566
--- /dev/null
+++ b/docs/channels/slack/README.zh.md
@@ -0,0 +1,33 @@
+# Slack
+
+Slack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的 Socket Mode 实现实时双向通信,无需配置公开的 Webhook 端点。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "bot_token": "xoxb-...",
+ "app_token": "xapp-...",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------- | ------ | ---- | -------------------------------------------------------- |
+| enabled | bool | 是 | 是否启用 Slack 频道 |
+| bot_token | string | 是 | Slack 机器人的 Bot User OAuth Token (以 xoxb- 开头) |
+| app_token | string | 是 | Slack 应用的 Socket Mode App Level Token (以 xapp- 开头) |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [Slack API](https://api.slack.com/) 创建一个新的 Slack 应用
+2. 启用 Socket Mode 并获取 App Level Token
+3. 添加 Bot Token Scopes(例如`chat:write`、`im:history`等)
+4. 安装应用到工作区并获取 Bot User OAuth Token
+5. 将 Bot Token 和 App Token 填入配置文件中
diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md
new file mode 100644
index 000000000..d453c68fa
--- /dev/null
+++ b/docs/channels/telegram/README.zh.md
@@ -0,0 +1,33 @@
+# Telegram
+
+Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、通过 Groq Whisper 进行语音转录以及内置命令处理器。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
+ "allow_from": ["123456789"],
+ "proxy": ""
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------- | ------ | ---- | --------------------------------------------------------- |
+| enabled | bool | 是 | 是否启用 Telegram 频道 |
+| token | string | 是 | Telegram 机器人 API Token |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) |
+
+## 设置流程
+
+1. 在 Telegram 中搜索 `@BotFather`
+2. 发送 `/newbot` 命令并按照提示创建新机器人
+3. 获取 HTTP API Token
+4. 将 Token 填入配置文件中
+5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID)
diff --git a/docs/channels/wecom/wecom_app/README.zh.md b/docs/channels/wecom/wecom_app/README.zh.md
new file mode 100644
index 000000000..1e6a0e2b3
--- /dev/null
+++ b/docs/channels/wecom/wecom_app/README.zh.md
@@ -0,0 +1,47 @@
+# 企业微信自建应用
+
+企业微信自建应用是指企业在企业微信中创建的应用,主要用于企业内部使用。通过企业微信自建应用,企业可以实现与员工的高效沟通和协作,提高工作效率。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx",
+ "corp_secret": "YOUR_CORP_SECRET",
+ "agent_id": 1000002,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------------- | ------ | ---- | ---------------------------------------- |
+| corp_id | string | 是 | 企业 ID |
+| corp_secret | string | 是 | 应用程序密钥 |
+| agent_id | int | 是 | 应用程序代理 ID |
+| token | string | 是 | 回调验证令牌 |
+| encoding_aes_key | string | 是 | 43 字符 AES 密钥 |
+| webhook_host | string | 否 | HTTP 服务器绑定地址 |
+| webhook_port | int | 否 | HTTP 服务器端口(默认:18792) |
+| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) |
+| allow_from | array | 否 | 用户 ID 白名单 |
+| reply_timeout | int | 否 | 回复超时时间(秒) |
+
+## 设置流程
+
+1. 登录 [企业微信管理后台](https://work.weixin.qq.com/)
+2. 进入“应用管理” -> “创建应用”
+3. 获取企业 ID (CorpID) 和应用 Secret
+4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey
+5. 设置回调 URL 为 `http://:/webhook/wecom-app`
+6. 将 CorpID, Secret, AgentID 等信息填入配置文件
diff --git a/docs/channels/wecom/wecom_bot/README.zh.md b/docs/channels/wecom/wecom_bot/README.zh.md
new file mode 100644
index 000000000..c4bb1c87e
--- /dev/null
+++ b/docs/channels/wecom/wecom_bot/README.zh.md
@@ -0,0 +1,41 @@
+# 企业微信机器人
+
+企业微信机器人是企业微信提供的一种快速接入方式,可以通过 Webhook URL 接收消息。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------------- | ------ | ---- | -------------------------------------------- |
+| token | string | 是 | 签名验证代币 |
+| encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 |
+| webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL |
+| webhook_host | string | 否 | HTTP 服务器绑定地址(默认:0.0.0.0) |
+| webhook_port | int | 否 | HTTP 服务器端口(默认:18793) |
+| webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom) |
+| allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) |
+| reply_timeout | int | 否 | 回复超时时间(单位:秒,默认值:5) |
+
+## 设置流程
+
+1. 在企业微信群中添加机器人
+2. 获取 Webhook URL
+3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey
+4. 将相关信息填入配置文件
diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md
index 589dfc043..0d4af719c 100644
--- a/docs/migration/model-list-migration.md
+++ b/docs/migration/model-list-migration.md
@@ -117,6 +117,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
| `rpm` | No | Requests per minute limit |
| `max_tokens_field` | No | Field name for max tokens |
+| `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` |
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
diff --git a/docs/picoclaw_community_roadmap_260216.md b/docs/picoclaw_community_roadmap_260216.md
deleted file mode 100644
index 95de768c6..000000000
--- a/docs/picoclaw_community_roadmap_260216.md
+++ /dev/null
@@ -1,112 +0,0 @@
-## 🚀 Join the PicoClaw Journey: Call for Community Volunteers & Roadmap Reveal
-
-**Hello, PicoClaw Community!**
-
-First, a massive thank you to everyone for your enthusiasm and PR contributions. It is because of you that PicoClaw continues to iterate and evolve so rapidly. Thanks to the simplicity and accessibility of the **Go language**, we’ve seen a non-stop stream of high-quality PRs!
-
-PicoClaw is growing much faster than we anticipated. As we are currently in the midst of the **Chinese New Year holiday**, we are looking to recruit community volunteers to help us maintain this incredible momentum.
-
-This document outlines the specific volunteer roles we need right now and provides a look at our upcoming **Roadmap**.
-
-### 🎁 Community Perks
-
-To show our appreciation, developers who officially join our community operations will receive:
-
-* **Exclusive AI Hardware:** Our upcoming, unreleased AI device.
-* **Token Discounts:** Potential discounts on LLM tokens (currently in negotiations with major providers).
-
-### 🎥 Calling All Content Creators!
-
-Not a developer? You can still help! We welcome users to post **PicoClaw reviews or tutorials**.
-
-* **Twitter:** Use the tag **#picoclaw** and mention **@SipeedIO**.
-* **Bilibili:** Mention **@Sipeed矽速科技** or send us a DM.
-We will be rewarding high-quality content creators with the same perks as our community developers!
-
----
-
-## 🛠️ Urgent Volunteer Roles
-
-We are looking for experts in the following areas:
-
-1. **Issue/PR Reviewers**
-* **The Mission:** With PRs and Issues exploding in volume, we need help with initial triage, evaluation, and merging.
-* **Focus:** Preliminary merging and community health. Efficiency optimization and security audits will be handled by specialized roles.
-
-
-2. **Resource Optimization Experts**
-* **The Mission:** Rapid growth has introduced dependencies that are making PicoClaw a bit "heavy." We want to keep it lean.
-* **Focus:** Analyzing resource growth between releases and trimming redundancy.
-* **Priority:** **RAM usage optimization** > Binary size reduction.
-
-
-3. **Security Audit & Bug Fixes**
-* **The Mission:** Due to the "vibe coding" nature of our early stages, we need a thorough review of network security and AI permission management.
-* **Focus:** Auditing the codebase for vulnerabilities and implementing robust fixes.
-
-
-4. **Documentation & DX (Developer Experience)**
-* **The Mission:** Our current README is a bit outdated. We need "step-by-step" guides that even beginners can follow.
-* **Focus:** Creating clear, user-friendly documentation for both setup and development.
-
-
-5. **AI-Powered CI/CD Optimization**
-* **The Mission:** PicoClaw started as a "vibe coding" experiment; now we want to use AI to manage it.
-* **Focus:** Automating builds with AI and exploring AI-driven issue resolution.
-
-**How to Apply:** > If you are interested in any of the roles above, please send an email to support@sipeed.com with the subject line: [Apply: PicoClaw Expert Volunteer] + Your Desired Role.
-Please include a brief introduction and any relevant experience or portfolio links. We will review all applications and grant project permissions to selected contributors!
-
----
-
-## 📍 The Roadmap
-
-Interested in a specific feature? You can "claim" these tasks and start building:
-
-###
-* **Provider:**
- * **Provider Refactor:** Currently being handled by **@Daming** (ETA: 5 days)
- * You can still submit code; Daming will merge it into the new implementation.
-* **Channels:**
- * Support for OneBot, additional platforms
- * attachments (images, audio, video, files).
-* **Skills:**
- * Implementing `find_skill` to discover tools via [ClawhHub](https://clawhub.ai) and other platforms.
-* **Operations:** * MCP Support.
- * Android operations (e.g., botdrop).
- * Browser automation via CDP or ActionBook.
-
-
-* **Multi-Agent Ecosystem:**
- * **Basic Model-Agent**
- * **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens).
- * **Swarm Mode.**
- * **AIEOS Integration.**
-
-
-* **Branding:**
- * **Logo**: We need a cute logo! We’re leaning toward a **Mantis Shrimp**—small, but packs a legendary punch!
-
-
-We have officially created these tasks as GitHub Issues, all marked with the roadmap tag.
-This list will be updated continuously as we progress.
-If you would like to claim a task, please feel free to start a conversation by commenting directly on the corresponding issue!
-
----
-
-## 🤝 How to Join
-
-**Everything is open to your creativity!** If you have a wild idea, just PR it.
-
-1. **The Fast Track:** Once you have at least **one merged PR**, you are eligible to join our **Developer Discord** to help plan the future of PicoClaw.
-2. **The Application Track:** If you haven’t submitted a PR yet but want to dive in, email **support@sipeed.com** with the subject:
-> `[Apply Join PicoClaw Dev Group] + Your GitHub Account`
-> Include the role you're interested in and any evidence of your development experience.
-
-
-
-### Looking Ahead
-
-Powered by PicoClaw, we are crafting a Swarm AI Assistant to transform your environment into a seamless network of personal stewards. By automating the friction of daily life, we empower you to transcend the ordinary and freely explore your creative potential.
-
-**Finally, Happy Chinese New Year to everyone!** May PicoClaw gallop forward in this **Year of the Horse!** 🐎
diff --git a/go.mod b/go.mod
index 1f88639c8..98e20d07d 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
github.com/slack-go/slack v0.17.3
+ github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
golang.org/x/oauth2 v0.35.0
@@ -22,7 +23,9 @@ require (
require (
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 0e95bf5cd..abbb11cd6 100644
--- a/go.sum
+++ b/go.sum
@@ -25,6 +25,7 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -72,6 +73,8 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
@@ -108,8 +111,14 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -151,6 +160,7 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
diff --git a/pkg/agent/context.go b/pkg/agent/context.go
index a9db5afdd..b7c6e1108 100644
--- a/pkg/agent/context.go
+++ b/pkg/agent/context.go
@@ -1,24 +1,38 @@
package agent
import (
+ "errors"
"fmt"
+ "io/fs"
"os"
"path/filepath"
"runtime"
"strings"
+ "sync"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills"
- "github.com/sipeed/picoclaw/pkg/tools"
)
type ContextBuilder struct {
workspace string
skillsLoader *skills.SkillsLoader
memory *MemoryStore
- tools *tools.ToolRegistry // Direct reference to tool registry
+
+ // Cache for system prompt to avoid rebuilding on every call.
+ // This fixes issue #607: repeated reprocessing of the entire context.
+ // The cache auto-invalidates when workspace source files change (mtime check).
+ systemPromptMutex sync.RWMutex
+ cachedSystemPrompt string
+ cachedAt time.Time // max observed mtime across tracked paths at cache build time
+
+ // existedAtCache tracks which source file paths existed the last time the
+ // cache was built. This lets sourceFilesChanged detect files that are newly
+ // created (didn't exist at cache time, now exist) or deleted (existed at
+ // cache time, now gone) — both of which should trigger a cache rebuild.
+ existedAtCache map[string]bool
}
func getGlobalConfigDir() string {
@@ -43,69 +57,29 @@ func NewContextBuilder(workspace string) *ContextBuilder {
}
}
-// SetToolsRegistry sets the tools registry for dynamic tool summary generation.
-func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
- cb.tools = registry
-}
-
func (cb *ContextBuilder) getIdentity() string {
- now := time.Now().Format("2006-01-02 15:04 (Monday)")
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
- runtime := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
-
- // Build tools section dynamically
- toolsSection := cb.buildToolsSection()
return fmt.Sprintf(`# picoclaw 🦞
You are picoclaw, a helpful AI assistant.
-## Current Time
-%s
-
-## Runtime
-%s
-
## Workspace
Your workspace is at: %s
- Memory: %s/memory/MEMORY.md
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
- Skills: %s/skills/{skill-name}/SKILL.md
-%s
-
## Important Rules
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
-3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`,
- now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
-}
+3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
-func (cb *ContextBuilder) buildToolsSection() string {
- if cb.tools == nil {
- return ""
- }
-
- summaries := cb.tools.GetSummaries()
- if len(summaries) == 0 {
- return ""
- }
-
- var sb strings.Builder
- sb.WriteString("## Available Tools\n\n")
- sb.WriteString(
- "**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n",
- )
- sb.WriteString("You have access to the following tools:\n\n")
- for _, s := range summaries {
- sb.WriteString(s)
- sb.WriteString("\n")
- }
-
- return sb.String()
+4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`,
+ workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
}
func (cb *ContextBuilder) BuildSystemPrompt() string {
@@ -140,6 +114,226 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
return strings.Join(parts, "\n\n---\n\n")
}
+// BuildSystemPromptWithCache returns the cached system prompt if available
+// and source files haven't changed, otherwise builds and caches it.
+// Source file changes are detected via mtime checks (cheap stat calls).
+func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
+ // Try read lock first — fast path when cache is valid
+ cb.systemPromptMutex.RLock()
+ if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
+ result := cb.cachedSystemPrompt
+ cb.systemPromptMutex.RUnlock()
+ return result
+ }
+ cb.systemPromptMutex.RUnlock()
+
+ // Acquire write lock for building
+ cb.systemPromptMutex.Lock()
+ defer cb.systemPromptMutex.Unlock()
+
+ // Double-check: another goroutine may have rebuilt while we waited
+ if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
+ return cb.cachedSystemPrompt
+ }
+
+ // Snapshot the baseline (existence + max mtime) BEFORE building the prompt.
+ // This way cachedAt reflects the pre-build state: if a file is modified
+ // during BuildSystemPrompt, its new mtime will be > baseline.maxMtime,
+ // so the next sourceFilesChangedLocked check will correctly trigger a
+ // rebuild. The alternative (baseline after build) risks caching stale
+ // content with a too-new baseline, making the staleness invisible.
+ baseline := cb.buildCacheBaseline()
+ prompt := cb.BuildSystemPrompt()
+ cb.cachedSystemPrompt = prompt
+ cb.cachedAt = baseline.maxMtime
+ cb.existedAtCache = baseline.existed
+
+ logger.DebugCF("agent", "System prompt cached",
+ map[string]any{
+ "length": len(prompt),
+ })
+
+ return prompt
+}
+
+// InvalidateCache clears the cached system prompt.
+// Normally not needed because the cache auto-invalidates via mtime checks,
+// but this is useful for tests or explicit reload commands.
+func (cb *ContextBuilder) InvalidateCache() {
+ cb.systemPromptMutex.Lock()
+ defer cb.systemPromptMutex.Unlock()
+
+ cb.cachedSystemPrompt = ""
+ cb.cachedAt = time.Time{}
+ cb.existedAtCache = nil
+
+ logger.DebugCF("agent", "System prompt cache invalidated", nil)
+}
+
+// sourcePaths returns the workspace source file paths tracked for cache
+// invalidation (bootstrap files + memory). The skills directory is handled
+// separately in sourceFilesChangedLocked because it requires both directory-
+// level and recursive file-level mtime checks.
+func (cb *ContextBuilder) sourcePaths() []string {
+ return []string{
+ filepath.Join(cb.workspace, "AGENTS.md"),
+ filepath.Join(cb.workspace, "SOUL.md"),
+ filepath.Join(cb.workspace, "USER.md"),
+ filepath.Join(cb.workspace, "IDENTITY.md"),
+ filepath.Join(cb.workspace, "memory", "MEMORY.md"),
+ }
+}
+
+// cacheBaseline holds the file existence snapshot and the latest observed
+// mtime across all tracked paths. Used as the cache reference point.
+type cacheBaseline struct {
+ existed map[string]bool
+ maxMtime time.Time
+}
+
+// buildCacheBaseline records which tracked paths currently exist and computes
+// the latest mtime across all tracked files + skills directory contents.
+// Called under write lock when the cache is built.
+func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
+ skillsDir := filepath.Join(cb.workspace, "skills")
+
+ // All paths whose existence we track: source files + skills dir.
+ allPaths := append(cb.sourcePaths(), skillsDir)
+
+ existed := make(map[string]bool, len(allPaths))
+ var maxMtime time.Time
+
+ for _, p := range allPaths {
+ info, err := os.Stat(p)
+ existed[p] = err == nil
+ if err == nil && info.ModTime().After(maxMtime) {
+ maxMtime = info.ModTime()
+ }
+ }
+
+ // Walk skills files to capture their mtimes too.
+ // Use os.Stat (not d.Info) to match the stat method used in
+ // fileChangedSince / skillFilesModifiedSince for consistency.
+ _ = filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
+ if walkErr == nil && !d.IsDir() {
+ if info, err := os.Stat(path); err == nil && info.ModTime().After(maxMtime) {
+ maxMtime = info.ModTime()
+ }
+ }
+ return nil
+ })
+
+ // If no tracked files exist yet (empty workspace), maxMtime is zero.
+ // Use a very old non-zero time so that:
+ // 1. cachedAt.IsZero() won't trigger perpetual rebuilds.
+ // 2. Any real file created afterwards has mtime > cachedAt, so it
+ // will be detected by fileChangedSince (unlike time.Now() which
+ // could race with a file whose mtime <= Now).
+ if maxMtime.IsZero() {
+ maxMtime = time.Unix(1, 0)
+ }
+
+ return cacheBaseline{existed: existed, maxMtime: maxMtime}
+}
+
+// sourceFilesChangedLocked checks whether any workspace source file has been
+// modified, created, or deleted since the cache was last built.
+//
+// IMPORTANT: The caller MUST hold at least a read lock on systemPromptMutex.
+// Go's sync.RWMutex is not reentrant, so this function must NOT acquire the
+// lock itself (it would deadlock when called from BuildSystemPromptWithCache
+// which already holds RLock or Lock).
+func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
+ if cb.cachedAt.IsZero() {
+ return true
+ }
+
+ // Check tracked source files (bootstrap + memory).
+ for _, p := range cb.sourcePaths() {
+ if cb.fileChangedSince(p) {
+ return true
+ }
+ }
+
+ // --- Skills directory (handled separately from sourcePaths) ---
+ //
+ // 1. Creation/deletion: tracked via existedAtCache, same as bootstrap files.
+ skillsDir := filepath.Join(cb.workspace, "skills")
+ if cb.fileChangedSince(skillsDir) {
+ return true
+ }
+
+ // 2. Structural changes (add/remove entries inside the dir) are reflected
+ // in the directory's own mtime, which fileChangedSince already checks.
+ //
+ // 3. Content-only edits to files inside skills/ do NOT update the parent
+ // directory mtime on most filesystems, so we recursively walk to check
+ // individual file mtimes at any nesting depth.
+ if skillFilesModifiedSince(skillsDir, cb.cachedAt) {
+ return true
+ }
+
+ return false
+}
+
+// fileChangedSince returns true if a tracked source file has been modified,
+// newly created, or deleted since the cache was built.
+//
+// Four cases:
+// - existed at cache time, exists now -> check mtime
+// - existed at cache time, gone now -> changed (deleted)
+// - absent at cache time, exists now -> changed (created)
+// - absent at cache time, gone now -> no change
+func (cb *ContextBuilder) fileChangedSince(path string) bool {
+ // Defensive: if existedAtCache was never initialized, treat as changed
+ // so the cache rebuilds rather than silently serving stale data.
+ if cb.existedAtCache == nil {
+ return true
+ }
+
+ existedBefore := cb.existedAtCache[path]
+ info, err := os.Stat(path)
+ existsNow := err == nil
+
+ if existedBefore != existsNow {
+ return true // file was created or deleted
+ }
+ if !existsNow {
+ return false // didn't exist before, doesn't exist now
+ }
+ return info.ModTime().After(cb.cachedAt)
+}
+
+// errWalkStop is a sentinel error used to stop filepath.WalkDir early.
+// Using a dedicated error (instead of fs.SkipAll) makes the early-exit
+// intent explicit and avoids the nilerr linter warning that would fire
+// if the callback returned nil when its err parameter is non-nil.
+var errWalkStop = errors.New("walk stop")
+
+// skillFilesModifiedSince recursively walks the skills directory and checks
+// whether any file was modified after t. This catches content-only edits at
+// any nesting depth (e.g. skills/name/docs/extra.md) that don't update
+// parent directory mtimes.
+func skillFilesModifiedSince(skillsDir string, t time.Time) bool {
+ changed := false
+ err := filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
+ if walkErr == nil && !d.IsDir() {
+ if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(t) {
+ changed = true
+ return errWalkStop // stop walking
+ }
+ }
+ return nil
+ })
+ // errWalkStop is expected (early exit on first changed file).
+ // os.IsNotExist means the skills dir doesn't exist yet — not an error.
+ // Any other error is unexpected and worth logging.
+ if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) {
+ logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()})
+ }
+ return changed
+}
+
func (cb *ContextBuilder) LoadBootstrapFiles() string {
bootstrapFiles := []string{
"AGENTS.md",
@@ -159,6 +353,28 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
return sb.String()
}
+// buildDynamicContext returns a short dynamic context string with per-request info.
+// This changes every request (time, session) so it is NOT part of the cached prompt.
+// LLM-side KV cache reuse is achieved by each provider adapter's native mechanism:
+// - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block
+// - OpenAI / Codex: prompt_cache_key for prefix-based caching
+//
+// See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
+// See: https://platform.openai.com/docs/guides/prompt-caching
+func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string {
+ now := time.Now().Format("2006-01-02 15:04 (Monday)")
+ rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
+
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt)
+
+ if channel != "" && chatID != "" {
+ fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
+ }
+
+ return sb.String()
+}
+
func (cb *ContextBuilder) BuildMessages(
history []providers.Message,
summary string,
@@ -168,23 +384,65 @@ func (cb *ContextBuilder) BuildMessages(
) []providers.Message {
messages := []providers.Message{}
- systemPrompt := cb.BuildSystemPrompt()
+ // The static part (identity, bootstrap, skills, memory) is cached locally to
+ // avoid repeated file I/O and string building on every call (fixes issue #607).
+ // Dynamic parts (time, session, summary) are appended per request.
+ // Everything is sent as a single system message for provider compatibility:
+ // - Anthropic adapter extracts messages[0] (Role=="system") and maps its content
+ // to the top-level "system" parameter in the Messages API request. A single
+ // contiguous system block makes this extraction straightforward.
+ // - Codex maps only the first system message to its instructions field.
+ // - OpenAI-compat passes messages through as-is.
+ staticPrompt := cb.BuildSystemPromptWithCache()
- // Add Current Session info if provided
- if channel != "" && chatID != "" {
- systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
+ // Build short dynamic context (time, runtime, session) — changes per request
+ dynamicCtx := cb.buildDynamicContext(channel, chatID)
+
+ // Compose a single system message: static (cached) + dynamic + optional summary.
+ // Keeping all system content in one message ensures every provider adapter can
+ // extract it correctly (Anthropic adapter -> top-level system param,
+ // Codex -> instructions field).
+ //
+ // SystemParts carries the same content as structured blocks so that
+ // cache-aware adapters (Anthropic) can set per-block cache_control.
+ // The static block is marked "ephemeral" — its prefix hash is stable
+ // across requests, enabling LLM-side KV cache reuse.
+ stringParts := []string{staticPrompt, dynamicCtx}
+
+ contentBlocks := []providers.ContentBlock{
+ {Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}},
+ {Type: "text", Text: dynamicCtx},
}
- // Log system prompt summary for debugging (debug mode only)
+ if summary != "" {
+ summaryText := fmt.Sprintf(
+ "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
+ "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
+ summary)
+ stringParts = append(stringParts, summaryText)
+ contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText})
+ }
+
+ fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
+
+ // Log system prompt summary for debugging (debug mode only).
+ // Read cachedSystemPrompt under lock to avoid a data race with
+ // concurrent InvalidateCache / BuildSystemPromptWithCache writes.
+ cb.systemPromptMutex.RLock()
+ isCached := cb.cachedSystemPrompt != ""
+ cb.systemPromptMutex.RUnlock()
+
logger.DebugCF("agent", "System prompt built",
map[string]any{
- "total_chars": len(systemPrompt),
- "total_lines": strings.Count(systemPrompt, "\n") + 1,
- "section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
+ "static_chars": len(staticPrompt),
+ "dynamic_chars": len(dynamicCtx),
+ "total_chars": len(fullSystemPrompt),
+ "has_summary": summary != "",
+ "cached": isCached,
})
// Log preview of system prompt (avoid logging huge content)
- preview := systemPrompt
+ preview := fullSystemPrompt
if len(preview) > 500 {
preview = preview[:500] + "... (truncated)"
}
@@ -193,19 +451,21 @@ func (cb *ContextBuilder) BuildMessages(
"preview": preview,
})
- if summary != "" {
- systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
- }
-
history = sanitizeHistoryForProvider(history)
+ // Single system message containing all context — compatible with all providers.
+ // SystemParts enables cache-aware adapters to set per-block cache_control;
+ // Content is the concatenated fallback for adapters that don't read SystemParts.
messages = append(messages, providers.Message{
- Role: "system",
- Content: systemPrompt,
+ Role: "system",
+ Content: fullSystemPrompt,
+ SystemParts: contentBlocks,
})
+ // Add conversation history
messages = append(messages, history...)
+ // Add current user message
if strings.TrimSpace(currentMessage) != "" {
messages = append(messages, providers.Message{
Role: "user",
@@ -224,13 +484,32 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
sanitized := make([]providers.Message, 0, len(history))
for _, msg := range history {
switch msg.Role {
+ case "system":
+ // Drop system messages from history. BuildMessages always
+ // constructs its own single system message (static + dynamic +
+ // summary); extra system messages would break providers that
+ // only accept one (Anthropic, Codex).
+ logger.DebugCF("agent", "Dropping system message from history", map[string]any{})
+ continue
+
case "tool":
if len(sanitized) == 0 {
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{})
continue
}
- last := sanitized[len(sanitized)-1]
- if last.Role != "assistant" || len(last.ToolCalls) == 0 {
+ // Walk backwards to find the nearest assistant message,
+ // skipping over any preceding tool messages (multi-tool-call case).
+ foundAssistant := false
+ for i := len(sanitized) - 1; i >= 0; i-- {
+ if sanitized[i].Role == "tool" {
+ continue
+ }
+ if sanitized[i].Role == "assistant" && len(sanitized[i].ToolCalls) > 0 {
+ foundAssistant = true
+ }
+ break
+ }
+ if !foundAssistant {
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
continue
}
@@ -288,25 +567,6 @@ func (cb *ContextBuilder) AddAssistantMessage(
return messages
}
-func (cb *ContextBuilder) loadSkills() string {
- allSkills := cb.skillsLoader.ListSkills()
- if len(allSkills) == 0 {
- return ""
- }
-
- var skillNames []string
- for _, s := range allSkills {
- skillNames = append(skillNames, s.Name)
- }
-
- content := cb.skillsLoader.LoadSkillsForContext(skillNames)
- if content == "" {
- return ""
- }
-
- return "# Skill Definitions\n\n" + content
-}
-
// GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills()
diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go
new file mode 100644
index 000000000..ba70d4c0d
--- /dev/null
+++ b/pkg/agent/context_cache_test.go
@@ -0,0 +1,513 @@
+package agent
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// setupWorkspace creates a temporary workspace with standard directories and optional files.
+// Returns the tmpDir path; caller should defer os.RemoveAll(tmpDir).
+func setupWorkspace(t *testing.T, files map[string]string) string {
+ t.Helper()
+ tmpDir, err := os.MkdirTemp("", "picoclaw-test-*")
+ if err != nil {
+ t.Fatal(err)
+ }
+ os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
+ os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
+ for name, content := range files {
+ dir := filepath.Dir(filepath.Join(tmpDir, name))
+ os.MkdirAll(dir, 0o755)
+ if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return tmpDir
+}
+
+// TestSingleSystemMessage verifies that BuildMessages always produces exactly one
+// system message regardless of summary/history variations.
+// Fix: multiple system messages break Anthropic (top-level system param) and
+// Codex (only reads last system message as instructions).
+func TestSingleSystemMessage(t *testing.T) {
+ tmpDir := setupWorkspace(t, map[string]string{
+ "IDENTITY.md": "# Identity\nTest agent.",
+ })
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ tests := []struct {
+ name string
+ history []providers.Message
+ summary string
+ message string
+ }{
+ {
+ name: "no summary, no history",
+ summary: "",
+ message: "hello",
+ },
+ {
+ name: "with summary",
+ summary: "Previous conversation discussed X",
+ message: "hello",
+ },
+ {
+ name: "with history and summary",
+ history: []providers.Message{
+ {Role: "user", Content: "hi"},
+ {Role: "assistant", Content: "hello"},
+ },
+ summary: strings.Repeat("Long summary text. ", 50),
+ message: "new message",
+ },
+ {
+ name: "system message in history is filtered",
+ history: []providers.Message{
+ {Role: "system", Content: "stale system prompt from previous session"},
+ {Role: "user", Content: "hi"},
+ {Role: "assistant", Content: "hello"},
+ },
+ summary: "",
+ message: "new message",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1")
+
+ systemCount := 0
+ for _, m := range msgs {
+ if m.Role == "system" {
+ systemCount++
+ }
+ }
+ if systemCount != 1 {
+ t.Errorf("expected exactly 1 system message, got %d", systemCount)
+ }
+ if msgs[0].Role != "system" {
+ t.Errorf("first message should be system, got %s", msgs[0].Role)
+ }
+ if msgs[len(msgs)-1].Role != "user" {
+ t.Errorf("last message should be user, got %s", msgs[len(msgs)-1].Role)
+ }
+
+ // System message must contain identity (static) and time (dynamic)
+ sys := msgs[0].Content
+ if !strings.Contains(sys, "picoclaw") {
+ t.Error("system message missing identity")
+ }
+ if !strings.Contains(sys, "Current Time") {
+ t.Error("system message missing dynamic time context")
+ }
+
+ // Summary handling
+ if tt.summary != "" {
+ if !strings.Contains(sys, "CONTEXT_SUMMARY:") {
+ t.Error("summary present but CONTEXT_SUMMARY prefix missing")
+ }
+ if !strings.Contains(sys, tt.summary[:20]) {
+ t.Error("summary content not found in system message")
+ }
+ } else {
+ if strings.Contains(sys, "CONTEXT_SUMMARY:") {
+ t.Error("CONTEXT_SUMMARY should not appear without summary")
+ }
+ }
+ })
+ }
+}
+
+// TestMtimeAutoInvalidation verifies that the cache detects source file changes
+// via mtime without requiring explicit InvalidateCache().
+// Fix: original implementation had no auto-invalidation — edits to bootstrap files,
+// memory, or skills were invisible until process restart.
+func TestMtimeAutoInvalidation(t *testing.T) {
+ tests := []struct {
+ name string
+ file string // relative path inside workspace
+ contentV1 string
+ contentV2 string
+ checkField string // substring to verify in rebuilt prompt
+ }{
+ {
+ name: "bootstrap file change",
+ file: "IDENTITY.md",
+ contentV1: "# Original Identity",
+ contentV2: "# Updated Identity",
+ checkField: "Updated Identity",
+ },
+ {
+ name: "memory file change",
+ file: "memory/MEMORY.md",
+ contentV1: "# Memory\nUser likes Go.",
+ contentV2: "# Memory\nUser likes Rust.",
+ checkField: "User likes Rust",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmpDir := setupWorkspace(t, map[string]string{tt.file: tt.contentV1})
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ sp1 := cb.BuildSystemPromptWithCache()
+
+ // Overwrite file and set future mtime to ensure detection.
+ // Use 2s offset for filesystem mtime resolution safety (some FS
+ // have 1s or coarser granularity, especially in CI containers).
+ fullPath := filepath.Join(tmpDir, tt.file)
+ os.WriteFile(fullPath, []byte(tt.contentV2), 0o644)
+ future := time.Now().Add(2 * time.Second)
+ os.Chtimes(fullPath, future, future)
+
+ // Verify sourceFilesChangedLocked detects the mtime change
+ cb.systemPromptMutex.RLock()
+ changed := cb.sourceFilesChangedLocked()
+ cb.systemPromptMutex.RUnlock()
+ if !changed {
+ t.Fatalf("sourceFilesChangedLocked() should detect %s change", tt.file)
+ }
+
+ // Should auto-rebuild without explicit InvalidateCache()
+ sp2 := cb.BuildSystemPromptWithCache()
+ if sp1 == sp2 {
+ t.Errorf("cache not rebuilt after %s change", tt.file)
+ }
+ if !strings.Contains(sp2, tt.checkField) {
+ t.Errorf("rebuilt prompt missing expected content %q", tt.checkField)
+ }
+ })
+ }
+
+ // Skills directory mtime change
+ t.Run("skills dir change", func(t *testing.T) {
+ tmpDir := setupWorkspace(t, nil)
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+ _ = cb.BuildSystemPromptWithCache() // populate cache
+
+ // Touch skills directory (simulate new skill installed)
+ skillsDir := filepath.Join(tmpDir, "skills")
+ future := time.Now().Add(2 * time.Second)
+ os.Chtimes(skillsDir, future, future)
+
+ // Verify sourceFilesChangedLocked detects it (cache is rebuilt)
+ // We confirm by checking internal state: a second call should rebuild.
+ cb.systemPromptMutex.RLock()
+ changed := cb.sourceFilesChangedLocked()
+ cb.systemPromptMutex.RUnlock()
+ if !changed {
+ t.Error("sourceFilesChangedLocked() should detect skills dir mtime change")
+ }
+ })
+}
+
+// TestExplicitInvalidateCache verifies that InvalidateCache() forces a rebuild
+// even when source files haven't changed (useful for tests and reload commands).
+func TestExplicitInvalidateCache(t *testing.T) {
+ tmpDir := setupWorkspace(t, map[string]string{
+ "IDENTITY.md": "# Test Identity",
+ })
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ sp1 := cb.BuildSystemPromptWithCache()
+ cb.InvalidateCache()
+ sp2 := cb.BuildSystemPromptWithCache()
+
+ if sp1 != sp2 {
+ t.Error("prompt should be identical after invalidate+rebuild when files unchanged")
+ }
+
+ // Verify cachedAt was reset
+ cb.InvalidateCache()
+ cb.systemPromptMutex.RLock()
+ if !cb.cachedAt.IsZero() {
+ t.Error("cachedAt should be zero after InvalidateCache()")
+ }
+ cb.systemPromptMutex.RUnlock()
+}
+
+// TestCacheStability verifies that the static prompt is stable across repeated calls
+// when no files change (regression test for issue #607).
+func TestCacheStability(t *testing.T) {
+ tmpDir := setupWorkspace(t, map[string]string{
+ "IDENTITY.md": "# Identity\nContent",
+ "SOUL.md": "# Soul\nContent",
+ })
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ results := make([]string, 5)
+ for i := range results {
+ results[i] = cb.BuildSystemPromptWithCache()
+ }
+ for i := 1; i < len(results); i++ {
+ if results[i] != results[0] {
+ t.Errorf("cached prompt changed between call 0 and %d", i)
+ }
+ }
+
+ // Static prompt must NOT contain per-request data
+ if strings.Contains(results[0], "Current Time") {
+ t.Error("static cached prompt should not contain time (added dynamically)")
+ }
+}
+
+// TestNewFileCreationInvalidatesCache verifies that creating a source file that
+// did not exist when the cache was built triggers a cache rebuild.
+// This catches the "from nothing to something" edge case that the old
+// modifiedSince (return false on stat error) would miss.
+func TestNewFileCreationInvalidatesCache(t *testing.T) {
+ tests := []struct {
+ name string
+ file string // relative path inside workspace
+ content string
+ checkField string // substring to verify in rebuilt prompt
+ }{
+ {
+ name: "new bootstrap file",
+ file: "SOUL.md",
+ content: "# Soul\nBe kind and helpful.",
+ checkField: "Be kind and helpful",
+ },
+ {
+ name: "new memory file",
+ file: "memory/MEMORY.md",
+ content: "# Memory\nUser prefers dark mode.",
+ checkField: "User prefers dark mode",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Start with an empty workspace (no bootstrap/memory files)
+ tmpDir := setupWorkspace(t, nil)
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ // Populate cache — file does not exist yet
+ sp1 := cb.BuildSystemPromptWithCache()
+ if strings.Contains(sp1, tt.checkField) {
+ t.Fatalf("prompt should not contain %q before file is created", tt.checkField)
+ }
+
+ // Create the file after cache was built
+ fullPath := filepath.Join(tmpDir, tt.file)
+ os.MkdirAll(filepath.Dir(fullPath), 0o755)
+ if err := os.WriteFile(fullPath, []byte(tt.content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // Set future mtime to guarantee detection
+ future := time.Now().Add(2 * time.Second)
+ os.Chtimes(fullPath, future, future)
+
+ // Cache should auto-invalidate because file went from absent -> present
+ sp2 := cb.BuildSystemPromptWithCache()
+ if !strings.Contains(sp2, tt.checkField) {
+ t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField)
+ }
+ })
+ }
+}
+
+// TestSkillFileContentChange verifies that modifying a skill file's content
+// (not just the directory structure) invalidates the cache.
+// This is the scenario where directory mtime alone is insufficient — on most
+// filesystems, editing a file inside a directory does NOT update the parent
+// directory's mtime.
+func TestSkillFileContentChange(t *testing.T) {
+ skillMD := `---
+name: test-skill
+description: "A test skill"
+---
+# Test Skill v1
+Original content.`
+
+ tmpDir := setupWorkspace(t, map[string]string{
+ "skills/test-skill/SKILL.md": skillMD,
+ })
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ // Populate cache
+ sp1 := cb.BuildSystemPromptWithCache()
+ _ = sp1 // cache is warm
+
+ // Modify the skill file content (without touching the skills/ directory)
+ updatedSkillMD := `---
+name: test-skill
+description: "An updated test skill"
+---
+# Test Skill v2
+Updated content.`
+
+ skillPath := filepath.Join(tmpDir, "skills", "test-skill", "SKILL.md")
+ if err := os.WriteFile(skillPath, []byte(updatedSkillMD), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // Set future mtime on the skill file only (NOT the directory)
+ future := time.Now().Add(2 * time.Second)
+ os.Chtimes(skillPath, future, future)
+
+ // Verify that sourceFilesChangedLocked detects the content change
+ cb.systemPromptMutex.RLock()
+ changed := cb.sourceFilesChangedLocked()
+ cb.systemPromptMutex.RUnlock()
+ if !changed {
+ t.Error("sourceFilesChangedLocked() should detect skill file content change")
+ }
+
+ // Verify cache is actually rebuilt with new content
+ sp2 := cb.BuildSystemPromptWithCache()
+ if sp1 == sp2 && strings.Contains(sp1, "test-skill") {
+ // If the skill appeared in the prompt and the prompt didn't change,
+ // the cache was not invalidated.
+ t.Error("cache should be invalidated when skill file content changes")
+ }
+}
+
+// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
+// can safely call BuildSystemPromptWithCache concurrently without producing
+// empty results, panics, or data races.
+// Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache
+func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
+ tmpDir := setupWorkspace(t, map[string]string{
+ "IDENTITY.md": "# Identity\nConcurrency test agent.",
+ "SOUL.md": "# Soul\nBe helpful.",
+ "memory/MEMORY.md": "# Memory\nUser prefers Go.",
+ "skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo",
+ })
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ const goroutines = 20
+ const iterations = 50
+
+ var wg sync.WaitGroup
+ errs := make(chan string, goroutines*iterations)
+
+ for g := 0; g < goroutines; g++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ for i := 0; i < iterations; i++ {
+ result := cb.BuildSystemPromptWithCache()
+ if result == "" {
+ errs <- "empty prompt returned"
+ return
+ }
+ if !strings.Contains(result, "picoclaw") {
+ errs <- "prompt missing identity"
+ return
+ }
+
+ // Also exercise BuildMessages concurrently
+ msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat")
+ if len(msgs) < 2 {
+ errs <- "BuildMessages returned fewer than 2 messages"
+ return
+ }
+ if msgs[0].Role != "system" {
+ errs <- "first message not system"
+ return
+ }
+
+ // Occasionally invalidate to exercise the write path
+ if i%10 == 0 {
+ cb.InvalidateCache()
+ }
+ }
+ }(g)
+ }
+
+ wg.Wait()
+ close(errs)
+
+ for errMsg := range errs {
+ t.Errorf("concurrent access error: %s", errMsg)
+ }
+}
+
+// BenchmarkBuildMessagesWithCache measures caching performance.
+
+// TestEmptyWorkspaceBaselineDetectsNewFiles verifies that when the cache is
+// built on an empty workspace (no tracked files exist), creating a file
+// afterwards still triggers cache invalidation. This validates the
+// time.Unix(1, 0) fallback for maxMtime: any real file's mtime is after epoch,
+// so fileChangedSince correctly detects the absent -> present transition AND
+// the mtime comparison succeeds even without artificially inflated Chtimes.
+func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) {
+ // Empty workspace: no bootstrap files, no memory, no skills content.
+ tmpDir := setupWorkspace(t, nil)
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ // Build cache — all tracked files are absent, maxMtime falls back to epoch.
+ sp1 := cb.BuildSystemPromptWithCache()
+
+ // Create a bootstrap file with natural mtime (no Chtimes manipulation).
+ // The file's mtime should be the current wall-clock time, which is
+ // strictly after time.Unix(1, 0).
+ soulPath := filepath.Join(tmpDir, "SOUL.md")
+ if err := os.WriteFile(soulPath, []byte("# Soul\nNewly created."), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Cache should detect the new file via existedAtCache (absent -> present).
+ cb.systemPromptMutex.RLock()
+ changed := cb.sourceFilesChangedLocked()
+ cb.systemPromptMutex.RUnlock()
+ if !changed {
+ t.Fatal("sourceFilesChangedLocked should detect newly created file on empty workspace")
+ }
+
+ sp2 := cb.BuildSystemPromptWithCache()
+ if !strings.Contains(sp2, "Newly created") {
+ t.Error("rebuilt prompt should contain new file content")
+ }
+ if sp1 == sp2 {
+ t.Error("cache should have been invalidated after file creation")
+ }
+}
+
+// BenchmarkBuildMessagesWithCache measures caching performance.
+func BenchmarkBuildMessagesWithCache(b *testing.B) {
+ tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*")
+ defer os.RemoveAll(tmpDir)
+
+ os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
+ os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
+ for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} {
+ os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644)
+ }
+
+ cb := NewContextBuilder(tmpDir)
+ history := []providers.Message{
+ {Role: "user", Content: "previous message"},
+ {Role: "assistant", Content: "previous response"},
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test")
+ }
+}
diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go
new file mode 100644
index 000000000..e023c9c30
--- /dev/null
+++ b/pkg/agent/context_test.go
@@ -0,0 +1,209 @@
+package agent
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func msg(role, content string) providers.Message {
+ return providers.Message{Role: role, Content: content}
+}
+
+func assistantWithTools(toolIDs ...string) providers.Message {
+ calls := make([]providers.ToolCall, len(toolIDs))
+ for i, id := range toolIDs {
+ calls[i] = providers.ToolCall{ID: id, Type: "function"}
+ }
+ return providers.Message{Role: "assistant", ToolCalls: calls}
+}
+
+func toolResult(id string) providers.Message {
+ return providers.Message{Role: "tool", Content: "result", ToolCallID: id}
+}
+
+func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) {
+ result := sanitizeHistoryForProvider(nil)
+ if len(result) != 0 {
+ t.Fatalf("expected empty, got %d messages", len(result))
+ }
+
+ result = sanitizeHistoryForProvider([]providers.Message{})
+ if len(result) != 0 {
+ t.Fatalf("expected empty, got %d messages", len(result))
+ }
+}
+
+func TestSanitizeHistoryForProvider_SingleToolCall(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "hello"),
+ assistantWithTools("A"),
+ toolResult("A"),
+ msg("assistant", "done"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 4 {
+ t.Fatalf("expected 4 messages, got %d", len(result))
+ }
+ assertRoles(t, result, "user", "assistant", "tool", "assistant")
+}
+
+func TestSanitizeHistoryForProvider_MultiToolCalls(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "do two things"),
+ assistantWithTools("A", "B"),
+ toolResult("A"),
+ toolResult("B"),
+ msg("assistant", "both done"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 5 {
+ t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant")
+}
+
+func TestSanitizeHistoryForProvider_AssistantToolCallAfterPlainAssistant(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "hi"),
+ msg("assistant", "thinking"),
+ assistantWithTools("A"),
+ toolResult("A"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 2 {
+ t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "assistant")
+}
+
+func TestSanitizeHistoryForProvider_OrphanedLeadingTool(t *testing.T) {
+ history := []providers.Message{
+ toolResult("A"),
+ msg("user", "hello"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 1 {
+ t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user")
+}
+
+func TestSanitizeHistoryForProvider_ToolAfterUserDropped(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "hello"),
+ toolResult("A"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 1 {
+ t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user")
+}
+
+func TestSanitizeHistoryForProvider_ToolAfterAssistantNoToolCalls(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "hello"),
+ msg("assistant", "hi"),
+ toolResult("A"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 2 {
+ t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "assistant")
+}
+
+func TestSanitizeHistoryForProvider_AssistantToolCallAtStart(t *testing.T) {
+ history := []providers.Message{
+ assistantWithTools("A"),
+ toolResult("A"),
+ msg("user", "hello"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 1 {
+ t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user")
+}
+
+func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "do two things"),
+ assistantWithTools("A", "B"),
+ toolResult("A"),
+ toolResult("B"),
+ msg("assistant", "done"),
+ msg("user", "hi"),
+ assistantWithTools("C"),
+ toolResult("C"),
+ msg("assistant", "done again"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 9 {
+ t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "user", "assistant", "tool", "assistant")
+}
+
+func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "start"),
+ assistantWithTools("A", "B"),
+ toolResult("A"),
+ toolResult("B"),
+ assistantWithTools("C", "D"),
+ toolResult("C"),
+ toolResult("D"),
+ msg("assistant", "all done"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 8 {
+ t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "tool", "tool", "assistant")
+}
+
+func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "hello"),
+ msg("assistant", "hi"),
+ msg("user", "how are you"),
+ msg("assistant", "fine"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 4 {
+ t.Fatalf("expected 4 messages, got %d", len(result))
+ }
+ assertRoles(t, result, "user", "assistant", "user", "assistant")
+}
+
+func roles(msgs []providers.Message) []string {
+ r := make([]string, len(msgs))
+ for i, m := range msgs {
+ r[i] = m.Role
+ }
+ return r
+}
+
+func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
+ t.Helper()
+ if len(msgs) != len(expected) {
+ t.Fatalf("role count mismatch: got %v, want %v", roles(msgs), expected)
+ }
+ for i, exp := range expected {
+ if msgs[i].Role != exp {
+ t.Errorf("message[%d]: got role %q, want %q", i, msgs[i].Role, exp)
+ }
+ }
+}
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
index dfbef9fbc..a6fd365c7 100644
--- a/pkg/agent/instance.go
+++ b/pkg/agent/instance.go
@@ -59,7 +59,6 @@ func NewAgentInstance(
sessionsManager := session.NewSessionManager(sessionsDir)
contextBuilder := NewContextBuilder(workspace)
- contextBuilder.SetToolsRegistry(toolsRegistry)
agentID := routing.DefaultAgentID
agentName := ""
@@ -133,7 +132,7 @@ func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefau
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
return strings.TrimSpace(agentCfg.Model.Primary)
}
- return defaults.Model
+ return defaults.GetModelName()
}
// resolveAgentFallbacks resolves the fallback models for an agent.
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index b36f4a0c4..693f2227b 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -97,15 +97,20 @@ func registerSharedTools(
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
+ TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
+ TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
+ TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
+ TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
+ Proxy: cfg.Tools.Web.Proxy,
}); searchTool != nil {
agent.Tools.Register(searchTool)
}
- agent.Tools.Register(tools.NewWebFetchTool(50000))
+ agent.Tools.Register(tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy))
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
agent.Tools.Register(tools.NewI2CTool())
@@ -144,9 +149,6 @@ func registerSharedTools(
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
})
agent.Tools.Register(spawnTool)
-
- // Update context builder with the complete tools registry
- agent.ContextBuilder.SetToolsRegistry(agent.Tools)
}
}
@@ -519,8 +521,9 @@ func (al *AgentLoop) runLLMIteration(
fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{
- "max_tokens": agent.MaxTokens,
- "temperature": agent.Temperature,
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
+ "prompt_cache_key": agent.ID,
})
},
)
@@ -535,8 +538,9 @@ func (al *AgentLoop) runLLMIteration(
return fbResult.Response, nil
}
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
- "max_tokens": agent.MaxTokens,
- "temperature": agent.Temperature,
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
+ "prompt_cache_key": agent.ID,
})
}
@@ -622,8 +626,9 @@ func (al *AgentLoop) runLLMIteration(
// Build assistant message with tool calls
assistantMsg := providers.Message{
- Role: "assistant",
- Content: response.Content,
+ Role: "assistant",
+ Content: response.Content,
+ ReasoningContent: response.ReasoningContent,
}
for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments)
@@ -754,13 +759,7 @@ func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, c
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
go func() {
defer al.summarizing.Delete(summarizeKey)
- if !constants.IsInternalChannel(channel) {
- al.bus.PublishOutbound(bus.OutboundMessage{
- Channel: channel,
- ChatID: chatID,
- Content: "Memory threshold reached. Optimizing conversation history...",
- })
- }
+ logger.Debug("Memory threshold reached. Optimizing conversation history...")
al.summarizeSession(agent, sessionKey)
}()
}
@@ -794,7 +793,7 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
droppedCount := mid
keptConversation := conversation[mid:]
- newHistory := make([]providers.Message, 0)
+ newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1)
// Append compression note to the original system prompt instead of adding a new system message
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
@@ -956,8 +955,9 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
nil,
agent.Model,
map[string]any{
- "max_tokens": 1024,
- "temperature": 0.3,
+ "max_tokens": 1024,
+ "temperature": 0.3,
+ "prompt_cache_key": agent.ID,
},
)
if err == nil {
@@ -1006,8 +1006,9 @@ func (al *AgentLoop) summarizeBatch(
nil,
agent.Model,
map[string]any{
- "max_tokens": 1024,
- "temperature": 0.3,
+ "max_tokens": 1024,
+ "temperature": 0.3,
+ "prompt_cache_key": agent.ID,
},
)
if err != nil {
diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go
index cf8c1c9c4..ba757ffd4 100644
--- a/pkg/auth/oauth.go
+++ b/pkg/auth/oauth.go
@@ -156,7 +156,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
case manualInput := <-manualCh:
if manualInput == "" {
- return nil, fmt.Errorf("manual input cancelled")
+ return nil, fmt.Errorf("manual input canceled")
}
// Extract code from URL if it's a full URL
code := manualInput
diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go
index 20f3b267c..f6faa3373 100644
--- a/pkg/channels/discord.go
+++ b/pkg/channels/discord.go
@@ -233,7 +233,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
if localPath != "" {
localFiles = append(localFiles, localPath)
- transcribedText := ""
+ var transcribedText string
if c.transcriber != nil && c.transcriber.IsAvailable() {
ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout)
result, err := c.transcriber.Transcribe(ctx, localPath)
diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go
index cee8ad9d3..4576a11ce 100644
--- a/pkg/channels/onebot.go
+++ b/pkg/channels/onebot.go
@@ -174,7 +174,10 @@ func (c *OneBotChannel) connect() error {
header["Authorization"] = []string{"Bearer " + c.config.AccessToken}
}
- conn, _, err := dialer.Dial(c.config.WSUrl, header)
+ conn, resp, err := dialer.Dial(c.config.WSUrl, header)
+ if resp != nil {
+ resp.Body.Close()
+ }
if err != nil {
return err
}
@@ -310,7 +313,7 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D
case <-time.After(timeout):
return nil, fmt.Errorf("API request %s timed out after %v", action, timeout)
case <-c.ctx.Done():
- return nil, fmt.Errorf("context cancelled")
+ return nil, fmt.Errorf("context canceled")
}
}
@@ -695,7 +698,6 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
textParts = append(textParts, "[forward message]")
default:
-
}
}
diff --git a/pkg/channels/qq.go b/pkg/channels/qq.go
index e66cac533..b10776db6 100644
--- a/pkg/channels/qq.go
+++ b/pkg/channels/qq.go
@@ -47,31 +47,31 @@ func (c *QQChannel) Start(ctx context.Context) error {
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
- // 创建 token source
+ // create token source
credentials := &token.QQBotCredentials{
AppID: c.config.AppID,
AppSecret: c.config.AppSecret,
}
c.tokenSource = token.NewQQBotTokenSource(credentials)
- // 创建子 context
+ // create child context
c.ctx, c.cancel = context.WithCancel(ctx)
- // 启动自动刷新 token 协程
+ // start auto-refresh token goroutine
if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {
return fmt.Errorf("failed to start token refresh: %w", err)
}
- // 初始化 OpenAPI 客户端
+ // initialize OpenAPI client
c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)
- // 注册事件处理器
+ // register event handlers
intent := event.RegisterHandlers(
c.handleC2CMessage(),
c.handleGroupATMessage(),
)
- // 获取 WebSocket 接入点
+ // get WebSocket endpoint
wsInfo, err := c.api.WS(c.ctx, nil, "")
if err != nil {
return fmt.Errorf("failed to get websocket info: %w", err)
@@ -81,10 +81,10 @@ func (c *QQChannel) Start(ctx context.Context) error {
"shards": wsInfo.Shards,
})
- // 创建并保存 sessionManager
+ // create and save sessionManager
c.sessionManager = botgo.NewSessionManager()
- // 在 goroutine 中启动 WebSocket 连接,避免阻塞
+ // start WebSocket connection in goroutine to avoid blocking
go func() {
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
logger.ErrorCF("qq", "WebSocket session error", map[string]any{
@@ -116,12 +116,12 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return fmt.Errorf("QQ bot not running")
}
- // 构造消息
+ // construct message
msgToCreate := &dto.MessageToCreate{
Content: msg.Content,
}
- // C2C 消息发送
+ // send C2C message
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
if err != nil {
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
@@ -133,15 +133,15 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return nil
}
-// handleC2CMessage 处理 QQ 私聊消息
+// handleC2CMessage handles QQ private messages
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
- // 去重检查
+ // deduplication check
if c.isDuplicate(data.ID) {
return nil
}
- // 提取用户信息
+ // extract user info
var senderID string
if data.Author != nil && data.Author.ID != "" {
senderID = data.Author.ID
@@ -150,7 +150,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return nil
}
- // 提取消息内容
+ // extract message content
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty message, ignoring")
@@ -162,7 +162,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
"length": len(content),
})
- // 转发到消息总线
+ // forward to message bus
metadata := map[string]string{
"message_id": data.ID,
"peer_kind": "direct",
@@ -175,15 +175,15 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
}
}
-// handleGroupATMessage 处理群@消息
+// handleGroupATMessage handles group @messages
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
- // 去重检查
+ // deduplication check
if c.isDuplicate(data.ID) {
return nil
}
- // 提取用户信息
+ // extract user info
var senderID string
if data.Author != nil && data.Author.ID != "" {
senderID = data.Author.ID
@@ -192,7 +192,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return nil
}
- // 提取消息内容(去掉 @ 机器人部分)
+ // extract message content (remove @bot part)
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty group message, ignoring")
@@ -205,7 +205,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
"length": len(content),
})
- // 转发到消息总线(使用 GroupID 作为 ChatID)
+ // forward to message bus (use GroupID as ChatID)
metadata := map[string]string{
"message_id": data.ID,
"group_id": data.GroupID,
@@ -219,7 +219,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
}
}
-// isDuplicate 检查消息是否重复
+// isDuplicate checks if message is duplicate
func (c *QQChannel) isDuplicate(messageID string) bool {
c.mu.Lock()
defer c.mu.Unlock()
@@ -230,9 +230,9 @@ func (c *QQChannel) isDuplicate(messageID string) bool {
c.processedIDs[messageID] = true
- // 简单清理:限制 map 大小
+ // simple cleanup: limit map size
if len(c.processedIDs) > 10000 {
- // 清空一半
+ // clear half
count := 0
for id := range c.processedIDs {
if count >= 5000 {
diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go
index f7359cd6d..cfb731b16 100644
--- a/pkg/channels/slack.go
+++ b/pkg/channels/slack.go
@@ -200,7 +200,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
return
}
- // 检查白名单,避免为被拒绝的用户下载附件
+ // check allowlist to avoid downloading attachments for rejected users
if !c.IsAllowed(ev.User) {
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
"user_id": ev.User,
@@ -232,9 +232,9 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
content = c.stripBotMention(content)
var mediaPaths []string
- localFiles := []string{} // 跟踪需要清理的本地文件
+ localFiles := []string{} // track local files that need cleanup
- // 确保临时文件在函数返回时被清理
+ // ensure temp files are cleaned up when function returns
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
@@ -439,5 +439,5 @@ func parseSlackChatID(chatID string) (channelID, threadTS string) {
if len(parts) > 1 {
threadTS = parts[1]
}
- return
+ return channelID, threadTS
}
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
index a0a1c8d0a..6592d9bc0 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -23,6 +23,19 @@ import (
"github.com/sipeed/picoclaw/pkg/voice"
)
+var (
+ reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
+ reBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
+ reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
+ reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
+ reBoldUnder = regexp.MustCompile(`__(.+?)__`)
+ reItalic = regexp.MustCompile(`_([^_]+)_`)
+ reStrike = regexp.MustCompile(`~~(.+?)~~`)
+ reListItem = regexp.MustCompile(`^[-*]\s+`)
+ reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
+ reInlineCode = regexp.MustCompile("`([^`]+)`")
+)
+
type TelegramChannel struct {
*BaseChannel
bot *telego.Bot
@@ -208,7 +221,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
}
- // 检查白名单,避免为被拒绝的用户下载附件
+ // check allowlist to avoid downloading attachments for rejected users
if !c.IsAllowed(senderID) {
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
"user_id": senderID,
@@ -221,9 +234,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
content := ""
mediaPaths := []string{}
- localFiles := []string{} // 跟踪需要清理的本地文件
+ localFiles := []string{} // track local files that need cleanup
- // 确保临时文件在函数返回时被清理
+ // ensure temp files are cleaned up when function returns
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
@@ -265,7 +278,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
localFiles = append(localFiles, voicePath)
mediaPaths = append(mediaPaths, voicePath)
- transcribedText := ""
+ var transcribedText string
if c.transcriber != nil && c.transcriber.IsAvailable() {
transcriberCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
@@ -431,19 +444,18 @@ func markdownToTelegramHTML(text string) string {
inlineCodes := extractInlineCodes(text)
text = inlineCodes.text
- text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1")
+ text = reHeading.ReplaceAllString(text, "$1")
- text = regexp.MustCompile(`^>\s*(.*)$`).ReplaceAllString(text, "$1")
+ text = reBlockquote.ReplaceAllString(text, "$1")
text = escapeHTML(text)
- text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, `$1`)
+ text = reLink.ReplaceAllString(text, `$1`)
- text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "$1")
+ text = reBoldStar.ReplaceAllString(text, "$1")
- text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "$1")
+ text = reBoldUnder.ReplaceAllString(text, "$1")
- reItalic := regexp.MustCompile(`_([^_]+)_`)
text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
match := reItalic.FindStringSubmatch(s)
if len(match) < 2 {
@@ -452,9 +464,9 @@ func markdownToTelegramHTML(text string) string {
return "" + match[1] + ""
})
- text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "$1")
+ text = reStrike.ReplaceAllString(text, "$1")
- text = regexp.MustCompile(`^[-*]\s+`).ReplaceAllString(text, "• ")
+ text = reListItem.ReplaceAllString(text, "• ")
for i, code := range inlineCodes.codes {
escaped := escapeHTML(code)
@@ -479,8 +491,7 @@ type codeBlockMatch struct {
}
func extractCodeBlocks(text string) codeBlockMatch {
- re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
- matches := re.FindAllStringSubmatch(text, -1)
+ matches := reCodeBlock.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches))
for _, match := range matches {
@@ -488,7 +499,7 @@ func extractCodeBlocks(text string) codeBlockMatch {
}
i := 0
- text = re.ReplaceAllStringFunc(text, func(m string) string {
+ text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
placeholder := fmt.Sprintf("\x00CB%d\x00", i)
i++
return placeholder
@@ -503,8 +514,7 @@ type inlineCodeMatch struct {
}
func extractInlineCodes(text string) inlineCodeMatch {
- re := regexp.MustCompile("`([^`]+)`")
- matches := re.FindAllStringSubmatch(text, -1)
+ matches := reInlineCode.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches))
for _, match := range matches {
@@ -512,7 +522,7 @@ func extractInlineCodes(text string) inlineCodeMatch {
}
i := 0
- text = re.ReplaceAllStringFunc(text, func(m string) string {
+ text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
placeholder := fmt.Sprintf("\x00IC%d\x00", i)
i++
return placeholder
diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go
index a084b641b..f28434f46 100644
--- a/pkg/channels/telegram_commands.go
+++ b/pkg/channels/telegram_commands.go
@@ -81,7 +81,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error {
switch args {
case "model":
response = fmt.Sprintf("Current Model: %s (Provider: %s)",
- c.config.Agents.Defaults.Model,
+ c.config.Agents.Defaults.GetModelName(),
c.config.Agents.Defaults.Provider)
case "channel":
response = "Current Channel: telegram"
@@ -120,7 +120,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error {
provider = "configured default"
}
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
- c.config.Agents.Defaults.Model, provider)
+ c.config.Agents.Defaults.GetModelName(), provider)
case "channels":
var enabled []string
diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom_app.go
index 715c48707..302603445 100644
--- a/pkg/channels/wecom_app.go
+++ b/pkg/channels/wecom_app.go
@@ -571,61 +571,6 @@ func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, user
return nil
}
-// sendMarkdownMessage sends a markdown message to a user
-func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, userID, content string) error {
- apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
-
- msg := WeComMarkdownMessage{
- ToUser: userID,
- MsgType: "markdown",
- AgentID: c.config.AgentID,
- }
- msg.Markdown.Content = content
-
- jsonData, err := json.Marshal(msg)
- if err != nil {
- return fmt.Errorf("failed to marshal message: %w", err)
- }
-
- // Use configurable timeout (default 5 seconds)
- timeout := c.config.ReplyTimeout
- if timeout <= 0 {
- timeout = 5
- }
-
- reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
- defer cancel()
-
- req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
- if err != nil {
- return fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("Content-Type", "application/json")
-
- client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
- resp, err := client.Do(req)
- if err != nil {
- return fmt.Errorf("failed to send message: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return fmt.Errorf("failed to read response: %w", err)
- }
-
- var sendResp WeComSendMessageResponse
- if err := json.Unmarshal(body, &sendResp); err != nil {
- return fmt.Errorf("failed to parse response: %w", err)
- }
-
- if sendResp.ErrCode != 0 {
- return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
- }
-
- return nil
-}
-
// handleHealth handles health check requests
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
status := map[string]any{
diff --git a/pkg/channels/whatsapp.go b/pkg/channels/whatsapp.go
index 958d850bb..2dc4017ac 100644
--- a/pkg/channels/whatsapp.go
+++ b/pkg/channels/whatsapp.go
@@ -41,7 +41,10 @@ func (c *WhatsAppChannel) Start(ctx context.Context) error {
dialer := websocket.DefaultDialer
dialer.HandshakeTimeout = 10 * time.Second
- conn, _, err := dialer.Dial(c.url, nil)
+ conn, resp, err := dialer.Dial(c.url, nil)
+ if resp != nil {
+ resp.Body.Close()
+ }
if err != nil {
return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err)
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 20556011a..16559a2df 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -170,7 +170,8 @@ type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
- Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
+ ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
+ Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
@@ -179,6 +180,15 @@ type AgentDefaults struct {
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
}
+// GetModelName returns the effective model name for the agent defaults.
+// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
+func (d *AgentDefaults) GetModelName() string {
+ if d.ModelName != "" {
+ return d.ModelName
+ }
+ return d.Model
+}
+
type ChannelsConfig struct {
WhatsApp WhatsAppConfig `json:"whatsapp"`
Telegram TelegramConfig `json:"telegram"`
@@ -324,6 +334,7 @@ type ProvidersConfig struct {
GitHubCopilot ProviderConfig `json:"github_copilot"`
Antigravity ProviderConfig `json:"antigravity"`
Qwen ProviderConfig `json:"qwen"`
+ Mistral ProviderConfig `json:"mistral"`
}
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@@ -345,7 +356,8 @@ func (p ProvidersConfig) IsEmpty() bool {
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
- p.Qwen.APIKey == "" && p.Qwen.APIBase == ""
+ p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
+ p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
}
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
@@ -359,11 +371,12 @@ func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
}
type ProviderConfig struct {
- APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
- APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
- AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
- ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
+ APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
+ APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
+ RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"`
+ AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
+ ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
}
type OpenAIProviderConfig struct {
@@ -394,6 +407,7 @@ type ModelConfig struct {
// Optional optimizations
RPM int `json:"rpm,omitempty"` // Requests per minute limit
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
+ RequestTimeout int `json:"request_timeout,omitempty"`
}
// Validate checks if the ModelConfig has all required fields.
@@ -418,6 +432,13 @@ type BraveConfig struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
+type TavilyConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
+}
+
type DuckDuckGoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
@@ -431,8 +452,12 @@ type PerplexityConfig struct {
type WebToolsConfig struct {
Brave BraveConfig `json:"brave"`
+ Tavily TavilyConfig `json:"tavily"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
Perplexity PerplexityConfig `json:"perplexity"`
+ // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
+ // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
}
type CronToolsConfig struct {
@@ -489,6 +514,20 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
+ // Pre-scan the JSON to check how many model_list entries the user provided.
+ // Go's JSON decoder reuses existing slice backing-array elements rather than
+ // zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
+ // would silently inherit values from the DefaultConfig template at the same
+ // index position. We only reset cfg.ModelList when the user actually provides
+ // entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
+ var tmp Config
+ if err := json.Unmarshal(data, &tmp); err != nil {
+ return nil, err
+ }
+ if len(tmp.ModelList) > 0 {
+ cfg.ModelList = nil
+ }
+
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
@@ -636,7 +675,8 @@ func (c *Config) HasProvidersConfig() bool {
v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
- v.Qwen.APIKey != "" || v.Qwen.APIBase != ""
+ v.Qwen.APIKey != "" || v.Qwen.APIBase != "" ||
+ v.Mistral.APIKey != "" || v.Mistral.APIBase != ""
}
// ValidateModelList validates all ModelConfig entries in the model_list.
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 0898217d6..bf56b7f34 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -246,7 +246,7 @@ func TestDefaultConfig_Temperature(t *testing.T) {
func TestDefaultConfig_Gateway(t *testing.T) {
cfg := DefaultConfig()
- if cfg.Gateway.Host != "0.0.0.0" {
+ if cfg.Gateway.Host != "127.0.0.1" {
t.Error("Gateway host should have default value")
}
if cfg.Gateway.Port == 0 {
@@ -343,7 +343,7 @@ func TestConfig_Complete(t *testing.T) {
if cfg.Agents.Defaults.MaxToolIterations == 0 {
t.Error("MaxToolIterations should not be zero")
}
- if cfg.Gateway.Host != "0.0.0.0" {
+ if cfg.Gateway.Host != "127.0.0.1" {
t.Error("Gateway host should have default value")
}
if cfg.Gateway.Port == 0 {
@@ -392,3 +392,33 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
t.Fatal("OpenAI codex web search should be false when disabled in config file")
}
}
+
+func TestLoadConfig_WebToolsProxy(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+ configJSON := `{
+ "agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
+ "model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}],
+ "tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
+}`
+ if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
+ t.Fatalf("os.WriteFile() error: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error: %v", err)
+ }
+ if cfg.Tools.Web.Proxy != "http://127.0.0.1:7890" {
+ t.Fatalf("Tools.Web.Proxy = %q, want %q", cfg.Tools.Web.Proxy, "http://127.0.0.1:7890")
+ }
+}
+
+// TestDefaultConfig_DMScope verifies the default dm_scope value
+func TestDefaultConfig_DMScope(t *testing.T) {
+ cfg := DefaultConfig()
+
+ if cfg.Session.DMScope != "per-channel-peer" {
+ t.Errorf("Session.DMScope = %q, want 'per-channel-peer'", cfg.Session.DMScope)
+ }
+}
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 7654326e7..cf799140d 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -21,7 +21,7 @@ func DefaultConfig() *Config {
},
Bindings: []AgentBinding{},
Session: SessionConfig{
- DMScope: "main",
+ DMScope: "per-channel-peer",
},
Channels: ChannelsConfig{
WhatsApp: WhatsAppConfig{
@@ -255,6 +255,14 @@ func DefaultConfig() *Config {
APIKey: "ollama",
},
+ // Mistral AI - https://console.mistral.ai/api-keys
+ {
+ ModelName: "mistral-small",
+ Model: "mistral/mistral-small-latest",
+ APIBase: "https://api.mistral.ai/v1",
+ APIKey: "",
+ },
+
// VLLM (local) - http://localhost:8000
{
ModelName: "local-model",
@@ -264,11 +272,12 @@ func DefaultConfig() *Config {
},
},
Gateway: GatewayConfig{
- Host: "0.0.0.0",
+ Host: "127.0.0.1",
Port: 18790,
},
Tools: ToolsConfig{
Web: WebToolsConfig{
+ Proxy: "",
Brave: BraveConfig{
Enabled: false,
APIKey: "",
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index 689e2312f..5deb09270 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -41,7 +41,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
// Get user's configured provider and model
userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
- userModel := cfg.Agents.Defaults.Model
+ userModel := cfg.Agents.Defaults.GetModelName()
p := cfg.Providers
@@ -60,12 +60,13 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "openai",
- Model: "openai/gpt-5.2",
- APIKey: p.OpenAI.APIKey,
- APIBase: p.OpenAI.APIBase,
- Proxy: p.OpenAI.Proxy,
- AuthMethod: p.OpenAI.AuthMethod,
+ ModelName: "openai",
+ Model: "openai/gpt-5.2",
+ APIKey: p.OpenAI.APIKey,
+ APIBase: p.OpenAI.APIBase,
+ Proxy: p.OpenAI.Proxy,
+ RequestTimeout: p.OpenAI.RequestTimeout,
+ AuthMethod: p.OpenAI.AuthMethod,
}, true
},
},
@@ -77,12 +78,13 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "anthropic",
- Model: "anthropic/claude-sonnet-4.6",
- APIKey: p.Anthropic.APIKey,
- APIBase: p.Anthropic.APIBase,
- Proxy: p.Anthropic.Proxy,
- AuthMethod: p.Anthropic.AuthMethod,
+ ModelName: "anthropic",
+ Model: "anthropic/claude-sonnet-4.6",
+ APIKey: p.Anthropic.APIKey,
+ APIBase: p.Anthropic.APIBase,
+ Proxy: p.Anthropic.Proxy,
+ RequestTimeout: p.Anthropic.RequestTimeout,
+ AuthMethod: p.Anthropic.AuthMethod,
}, true
},
},
@@ -94,11 +96,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "openrouter",
- Model: "openrouter/auto",
- APIKey: p.OpenRouter.APIKey,
- APIBase: p.OpenRouter.APIBase,
- Proxy: p.OpenRouter.Proxy,
+ ModelName: "openrouter",
+ Model: "openrouter/auto",
+ APIKey: p.OpenRouter.APIKey,
+ APIBase: p.OpenRouter.APIBase,
+ Proxy: p.OpenRouter.Proxy,
+ RequestTimeout: p.OpenRouter.RequestTimeout,
}, true
},
},
@@ -110,11 +113,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "groq",
- Model: "groq/llama-3.1-70b-versatile",
- APIKey: p.Groq.APIKey,
- APIBase: p.Groq.APIBase,
- Proxy: p.Groq.Proxy,
+ ModelName: "groq",
+ Model: "groq/llama-3.1-70b-versatile",
+ APIKey: p.Groq.APIKey,
+ APIBase: p.Groq.APIBase,
+ Proxy: p.Groq.Proxy,
+ RequestTimeout: p.Groq.RequestTimeout,
}, true
},
},
@@ -126,11 +130,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "zhipu",
- Model: "zhipu/glm-4",
- APIKey: p.Zhipu.APIKey,
- APIBase: p.Zhipu.APIBase,
- Proxy: p.Zhipu.Proxy,
+ ModelName: "zhipu",
+ Model: "zhipu/glm-4",
+ APIKey: p.Zhipu.APIKey,
+ APIBase: p.Zhipu.APIBase,
+ Proxy: p.Zhipu.Proxy,
+ RequestTimeout: p.Zhipu.RequestTimeout,
}, true
},
},
@@ -142,11 +147,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "vllm",
- Model: "vllm/auto",
- APIKey: p.VLLM.APIKey,
- APIBase: p.VLLM.APIBase,
- Proxy: p.VLLM.Proxy,
+ ModelName: "vllm",
+ Model: "vllm/auto",
+ APIKey: p.VLLM.APIKey,
+ APIBase: p.VLLM.APIBase,
+ Proxy: p.VLLM.Proxy,
+ RequestTimeout: p.VLLM.RequestTimeout,
}, true
},
},
@@ -158,11 +164,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "gemini",
- Model: "gemini/gemini-pro",
- APIKey: p.Gemini.APIKey,
- APIBase: p.Gemini.APIBase,
- Proxy: p.Gemini.Proxy,
+ ModelName: "gemini",
+ Model: "gemini/gemini-pro",
+ APIKey: p.Gemini.APIKey,
+ APIBase: p.Gemini.APIBase,
+ Proxy: p.Gemini.Proxy,
+ RequestTimeout: p.Gemini.RequestTimeout,
}, true
},
},
@@ -174,11 +181,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "nvidia",
- Model: "nvidia/meta/llama-3.1-8b-instruct",
- APIKey: p.Nvidia.APIKey,
- APIBase: p.Nvidia.APIBase,
- Proxy: p.Nvidia.Proxy,
+ ModelName: "nvidia",
+ Model: "nvidia/meta/llama-3.1-8b-instruct",
+ APIKey: p.Nvidia.APIKey,
+ APIBase: p.Nvidia.APIBase,
+ Proxy: p.Nvidia.Proxy,
+ RequestTimeout: p.Nvidia.RequestTimeout,
}, true
},
},
@@ -190,11 +198,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "ollama",
- Model: "ollama/llama3",
- APIKey: p.Ollama.APIKey,
- APIBase: p.Ollama.APIBase,
- Proxy: p.Ollama.Proxy,
+ ModelName: "ollama",
+ Model: "ollama/llama3",
+ APIKey: p.Ollama.APIKey,
+ APIBase: p.Ollama.APIBase,
+ Proxy: p.Ollama.Proxy,
+ RequestTimeout: p.Ollama.RequestTimeout,
}, true
},
},
@@ -206,11 +215,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "moonshot",
- Model: "moonshot/kimi",
- APIKey: p.Moonshot.APIKey,
- APIBase: p.Moonshot.APIBase,
- Proxy: p.Moonshot.Proxy,
+ ModelName: "moonshot",
+ Model: "moonshot/kimi",
+ APIKey: p.Moonshot.APIKey,
+ APIBase: p.Moonshot.APIBase,
+ Proxy: p.Moonshot.Proxy,
+ RequestTimeout: p.Moonshot.RequestTimeout,
}, true
},
},
@@ -222,11 +232,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "shengsuanyun",
- Model: "shengsuanyun/auto",
- APIKey: p.ShengSuanYun.APIKey,
- APIBase: p.ShengSuanYun.APIBase,
- Proxy: p.ShengSuanYun.Proxy,
+ ModelName: "shengsuanyun",
+ Model: "shengsuanyun/auto",
+ APIKey: p.ShengSuanYun.APIKey,
+ APIBase: p.ShengSuanYun.APIBase,
+ Proxy: p.ShengSuanYun.Proxy,
+ RequestTimeout: p.ShengSuanYun.RequestTimeout,
}, true
},
},
@@ -238,11 +249,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "deepseek",
- Model: "deepseek/deepseek-chat",
- APIKey: p.DeepSeek.APIKey,
- APIBase: p.DeepSeek.APIBase,
- Proxy: p.DeepSeek.Proxy,
+ ModelName: "deepseek",
+ Model: "deepseek/deepseek-chat",
+ APIKey: p.DeepSeek.APIKey,
+ APIBase: p.DeepSeek.APIBase,
+ Proxy: p.DeepSeek.Proxy,
+ RequestTimeout: p.DeepSeek.RequestTimeout,
}, true
},
},
@@ -254,11 +266,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "cerebras",
- Model: "cerebras/llama-3.3-70b",
- APIKey: p.Cerebras.APIKey,
- APIBase: p.Cerebras.APIBase,
- Proxy: p.Cerebras.Proxy,
+ ModelName: "cerebras",
+ Model: "cerebras/llama-3.3-70b",
+ APIKey: p.Cerebras.APIKey,
+ APIBase: p.Cerebras.APIBase,
+ Proxy: p.Cerebras.Proxy,
+ RequestTimeout: p.Cerebras.RequestTimeout,
}, true
},
},
@@ -270,11 +283,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "volcengine",
- Model: "volcengine/doubao-pro",
- APIKey: p.VolcEngine.APIKey,
- APIBase: p.VolcEngine.APIBase,
- Proxy: p.VolcEngine.Proxy,
+ ModelName: "volcengine",
+ Model: "volcengine/doubao-pro",
+ APIKey: p.VolcEngine.APIKey,
+ APIBase: p.VolcEngine.APIBase,
+ Proxy: p.VolcEngine.Proxy,
+ RequestTimeout: p.VolcEngine.RequestTimeout,
}, true
},
},
@@ -316,11 +330,29 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "qwen",
- Model: "qwen/qwen-max",
- APIKey: p.Qwen.APIKey,
- APIBase: p.Qwen.APIBase,
- Proxy: p.Qwen.Proxy,
+ ModelName: "qwen",
+ Model: "qwen/qwen-max",
+ APIKey: p.Qwen.APIKey,
+ APIBase: p.Qwen.APIBase,
+ Proxy: p.Qwen.Proxy,
+ RequestTimeout: p.Qwen.RequestTimeout,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"mistral"},
+ protocol: "mistral",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "mistral",
+ Model: "mistral/mistral-small-latest",
+ APIKey: p.Mistral.APIKey,
+ APIBase: p.Mistral.APIBase,
+ Proxy: p.Mistral.Proxy,
+ RequestTimeout: p.Mistral.RequestTimeout,
}, true
},
},
diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go
index 1e8139e68..db8f4657d 100644
--- a/pkg/config/migration_test.go
+++ b/pkg/config/migration_test.go
@@ -131,14 +131,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
Antigravity: ProviderConfig{AuthMethod: "oauth"},
Qwen: ProviderConfig{APIKey: "key17"},
+ Mistral: ProviderConfig{APIKey: "key18"},
},
}
result := ConvertProvidersToModelList(cfg)
- // All 17 providers should be converted
- if len(result) != 17 {
- t.Errorf("len(result) = %d, want 17", len(result))
+ // All 18 providers should be converted
+ if len(result) != 18 {
+ t.Errorf("len(result) = %d, want 18", len(result))
}
}
@@ -165,6 +166,27 @@ func TestConvertProvidersToModelList_Proxy(t *testing.T) {
}
}
+func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
+ cfg := &Config{
+ Providers: ProvidersConfig{
+ Ollama: ProviderConfig{
+ APIKey: "ollama-key",
+ RequestTimeout: 300,
+ },
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ if result[0].RequestTimeout != 300 {
+ t.Errorf("RequestTimeout = %d, want %d", result[0].RequestTimeout, 300)
+ }
+}
+
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go
index 3c411dc0f..084f50a82 100644
--- a/pkg/config/model_config_test.go
+++ b/pkg/config/model_config_test.go
@@ -6,6 +6,7 @@
package config
import (
+ "encoding/json"
"strings"
"sync"
"testing"
@@ -114,6 +115,137 @@ func TestGetModelConfig_Concurrent(t *testing.T) {
}
}
+func TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) {
+ tests := []struct {
+ name string
+ defaults AgentDefaults
+ wantName string
+ }{
+ {
+ name: "new model_name field only",
+ defaults: AgentDefaults{ModelName: "new-model"},
+ wantName: "new-model",
+ },
+ {
+ name: "old model field only",
+ defaults: AgentDefaults{Model: "legacy-model"},
+ wantName: "legacy-model",
+ },
+ {
+ name: "both fields - model_name takes precedence",
+ defaults: AgentDefaults{ModelName: "new-model", Model: "old-model"},
+ wantName: "new-model",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.defaults.GetModelName(); got != tt.wantName {
+ t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
+ }
+ })
+ }
+}
+
+func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
+ tests := []struct {
+ name string
+ json string
+ wantName string
+ }{
+ {
+ name: "new model_name field",
+ json: `{"model_name": "gpt4"}`,
+ wantName: "gpt4",
+ },
+ {
+ name: "old model field",
+ json: `{"model": "gpt4"}`,
+ wantName: "gpt4",
+ },
+ {
+ name: "both fields - model_name wins",
+ json: `{"model_name": "new", "model": "old"}`,
+ wantName: "new",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var defaults AgentDefaults
+ if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil {
+ t.Fatalf("Unmarshal error: %v", err)
+ }
+ if got := defaults.GetModelName(); got != tt.wantName {
+ t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
+ }
+ })
+ }
+}
+
+func TestFullConfig_JSON_BackwardCompat(t *testing.T) {
+ // Test complete config with both old and new formats
+ oldFormat := `{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "gpt4",
+ "max_tokens": 4096
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-4o",
+ "api_key": "test-key"
+ }
+ ]
+ }`
+
+ newFormat := `{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model_name": "gpt4",
+ "max_tokens": 4096
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-4o",
+ "api_key": "test-key"
+ }
+ ]
+ }`
+
+ for name, jsonStr := range map[string]string{
+ "old format (model)": oldFormat,
+ "new format (model_name)": newFormat,
+ } {
+ t.Run(name, func(t *testing.T) {
+ cfg := &Config{}
+ if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil {
+ t.Fatalf("Unmarshal error: %v", err)
+ }
+
+ // Check that GetModelName returns correct value
+ if got := cfg.Agents.Defaults.GetModelName(); got != "gpt4" {
+ t.Errorf("GetModelName() = %q, want %q", got, "gpt4")
+ }
+
+ // Check that GetModelConfig works
+ modelCfg, err := cfg.GetModelConfig("gpt4")
+ if err != nil {
+ t.Fatalf("GetModelConfig error: %v", err)
+ }
+ if modelCfg.Model != "openai/gpt-4o" {
+ t.Errorf("Model = %q, want %q", modelCfg.Model, "openai/gpt-4o")
+ }
+ })
+ }
+}
+
func TestModelConfig_Validate(t *testing.T) {
tests := []struct {
name string
@@ -233,3 +365,38 @@ func TestConfig_ValidateModelList(t *testing.T) {
})
}
}
+
+func TestModelConfig_RequestTimeoutParsing(t *testing.T) {
+ jsonData := `{
+ "model_name": "slow-local",
+ "model": "openai/local-model",
+ "api_base": "http://localhost:11434/v1",
+ "request_timeout": 300
+ }`
+
+ var cfg ModelConfig
+ if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if cfg.RequestTimeout != 300 {
+ t.Fatalf("RequestTimeout = %d, want 300", cfg.RequestTimeout)
+ }
+}
+
+func TestModelConfig_RequestTimeoutDefaultZeroValue(t *testing.T) {
+ jsonData := `{
+ "model_name": "default-timeout",
+ "model": "openai/gpt-4o",
+ "api_key": "test-key"
+ }`
+
+ var cfg ModelConfig
+ if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if cfg.RequestTimeout != 0 {
+ t.Fatalf("RequestTimeout = %d, want 0", cfg.RequestTimeout)
+ }
+}
diff --git a/pkg/devices/sources/usb_linux.go b/pkg/devices/sources/usb_linux.go
index be0193cfb..2bb38941f 100644
--- a/pkg/devices/sources/usb_linux.go
+++ b/pkg/devices/sources/usb_linux.go
@@ -35,9 +35,8 @@ var usbClassToCapability = map[string]string{
}
type USBMonitor struct {
- cmd *exec.Cmd
- cancel context.CancelFunc
- mu sync.Mutex
+ cmd *exec.Cmd
+ mu sync.Mutex
}
func NewUSBMonitor() *USBMonitor {
diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go
index 75d6248b9..e05a9fdbf 100644
--- a/pkg/heartbeat/service.go
+++ b/pkg/heartbeat/service.go
@@ -166,7 +166,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
}
if handler == nil {
- hs.logError("Heartbeat handler not configured")
+ hs.logErrorf("Heartbeat handler not configured")
return
}
@@ -175,23 +175,23 @@ func (hs *HeartbeatService) executeHeartbeat() {
channel, chatID := hs.parseLastChannel(lastChannel)
// Debug log for channel resolution
- hs.logInfo("Resolved channel: %s, chatID: %s (from lastChannel: %s)", channel, chatID, lastChannel)
+ hs.logInfof("Resolved channel: %s, chatID: %s (from lastChannel: %s)", channel, chatID, lastChannel)
result := handler(prompt, channel, chatID)
if result == nil {
- hs.logInfo("Heartbeat handler returned nil result")
+ hs.logInfof("Heartbeat handler returned nil result")
return
}
// Handle different result types
if result.IsError {
- hs.logError("Heartbeat error: %s", result.ForLLM)
+ hs.logErrorf("Heartbeat error: %s", result.ForLLM)
return
}
if result.Async {
- hs.logInfo("Async task started: %s", result.ForLLM)
+ hs.logInfof("Async task started: %s", result.ForLLM)
logger.InfoCF("heartbeat", "Async heartbeat task started",
map[string]any{
"message": result.ForLLM,
@@ -201,7 +201,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
// Check if silent
if result.Silent {
- hs.logInfo("Heartbeat OK - silent")
+ hs.logInfof("Heartbeat OK - silent")
return
}
@@ -212,7 +212,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
hs.sendResponse(result.ForLLM)
}
- hs.logInfo("Heartbeat completed: %s", result.ForLLM)
+ hs.logInfof("Heartbeat completed: %s", result.ForLLM)
}
// buildPrompt builds the heartbeat prompt from HEARTBEAT.md
@@ -225,7 +225,7 @@ func (hs *HeartbeatService) buildPrompt() string {
hs.createDefaultHeartbeatTemplate()
return ""
}
- hs.logError("Error reading HEARTBEAT.md: %v", err)
+ hs.logErrorf("Error reading HEARTBEAT.md: %v", err)
return ""
}
@@ -276,9 +276,9 @@ Add your heartbeat tasks below this line:
`
if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
- hs.logError("Failed to create default HEARTBEAT.md: %v", err)
+ hs.logErrorf("Failed to create default HEARTBEAT.md: %v", err)
} else {
- hs.logInfo("Created default HEARTBEAT.md template")
+ hs.logInfof("Created default HEARTBEAT.md template")
}
}
@@ -289,14 +289,14 @@ func (hs *HeartbeatService) sendResponse(response string) {
hs.mu.RUnlock()
if msgBus == nil {
- hs.logInfo("No message bus configured, heartbeat result not sent")
+ hs.logInfof("No message bus configured, heartbeat result not sent")
return
}
// Get last channel from state
lastChannel := hs.state.GetLastChannel()
if lastChannel == "" {
- hs.logInfo("No last channel recorded, heartbeat result not sent")
+ hs.logInfof("No last channel recorded, heartbeat result not sent")
return
}
@@ -313,7 +313,7 @@ func (hs *HeartbeatService) sendResponse(response string) {
Content: response,
})
- hs.logInfo("Heartbeat result sent to %s", platform)
+ hs.logInfof("Heartbeat result sent to %s", platform)
}
// parseLastChannel parses the last channel string into platform and userID.
@@ -326,7 +326,7 @@ func (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, user
// Parse channel format: "platform:user_id" (e.g., "telegram:123456")
parts := strings.SplitN(lastChannel, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
- hs.logError("Invalid last channel format: %s", lastChannel)
+ hs.logErrorf("Invalid last channel format: %s", lastChannel)
return "", ""
}
@@ -334,25 +334,25 @@ func (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, user
// Skip internal channels
if constants.IsInternalChannel(platform) {
- hs.logInfo("Skipping internal channel: %s", platform)
+ hs.logInfof("Skipping internal channel: %s", platform)
return "", ""
}
return platform, userID
}
-// logInfo logs an informational message to the heartbeat log
-func (hs *HeartbeatService) logInfo(format string, args ...any) {
- hs.log("INFO", format, args...)
+// logInfof logs an informational message to the heartbeat log
+func (hs *HeartbeatService) logInfof(format string, args ...any) {
+ hs.logf("INFO", format, args...)
}
-// logError logs an error message to the heartbeat log
-func (hs *HeartbeatService) logError(format string, args ...any) {
- hs.log("ERROR", format, args...)
+// logErrorf logs an error message to the heartbeat log
+func (hs *HeartbeatService) logErrorf(format string, args ...any) {
+ hs.logf("ERROR", format, args...)
}
-// log writes a message to the heartbeat log file
-func (hs *HeartbeatService) log(level, format string, args ...any) {
+// logf writes a message to the heartbeat log file
+func (hs *HeartbeatService) logf(level, format string, args ...any) {
logFile := filepath.Join(hs.workspace, "heartbeat.log")
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
diff --git a/pkg/heartbeat/service_test.go b/pkg/heartbeat/service_test.go
index a4dfa7a72..a7aef8c3a 100644
--- a/pkg/heartbeat/service_test.go
+++ b/pkg/heartbeat/service_test.go
@@ -191,7 +191,7 @@ func TestLogPath(t *testing.T) {
hs := NewHeartbeatService(tmpDir, 30, true)
// Write a log entry
- hs.log("INFO", "Test log entry")
+ hs.logf("INFO", "Test log entry")
// Verify log file exists at workspace root
expectedLogPath := filepath.Join(tmpDir, "heartbeat.log")
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
index 54de66bf9..56dc87a53 100644
--- a/pkg/logger/logger.go
+++ b/pkg/logger/logger.go
@@ -119,13 +119,15 @@ func logMessage(level LogLevel, component string, message string, fields map[str
if logger.file != nil {
jsonData, err := json.Marshal(entry)
if err == nil {
- logger.file.WriteString(string(jsonData) + "\n")
+ logger.file.Write(append(jsonData, '\n'))
}
}
var fieldStr string
if len(fields) > 0 {
fieldStr = " " + formatFields(fields)
+ } else {
+ fieldStr = ""
}
logLine := fmt.Sprintf("[%s] [%s]%s %s%s",
@@ -151,7 +153,7 @@ func formatComponent(component string) string {
}
func formatFields(fields map[string]any) string {
- var parts []string
+ parts := make([]string, 0, len(fields))
for k, v := range fields {
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
}
diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go
index 2237a1429..869b39827 100644
--- a/pkg/migrate/config.go
+++ b/pkg/migrate/config.go
@@ -22,6 +22,7 @@ var supportedProviders = map[string]bool{
"qwen": true,
"deepseek": true,
"github_copilot": true,
+ "mistral": true,
}
var supportedChannels = map[string]bool{
@@ -72,7 +73,10 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
if agents, ok := getMap(data, "agents"); ok {
if defaults, ok := getMap(agents, "defaults"); ok {
- if v, ok := getString(defaults, "model"); ok {
+ // Prefer model_name, fallback to model for backward compatibility
+ if v, ok := getString(defaults, "model_name"); ok {
+ cfg.Agents.Defaults.ModelName = v
+ } else if v, ok := getString(defaults, "model"); ok {
cfg.Agents.Defaults.Model = v
}
if v, ok := getFloat(defaults, "max_tokens"); ok {
diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go
index 35f6b8f62..9162174c9 100644
--- a/pkg/providers/anthropic/provider.go
+++ b/pkg/providers/anthropic/provider.go
@@ -113,7 +113,20 @@ func buildParams(
for _, msg := range messages {
switch msg.Role {
case "system":
- system = append(system, anthropic.TextBlockParam{Text: msg.Content})
+ // Prefer structured SystemParts for per-block cache_control.
+ // This enables LLM-side KV cache reuse: the static block's prefix
+ // hash stays stable across requests while dynamic parts change freely.
+ if len(msg.SystemParts) > 0 {
+ for _, part := range msg.SystemParts {
+ block := anthropic.TextBlockParam{Text: part.Text}
+ if part.CacheControl != nil && part.CacheControl.Type == "ephemeral" {
+ block.CacheControl = anthropic.NewCacheControlEphemeralParam()
+ }
+ system = append(system, block)
+ }
+ } else {
+ system = append(system, anthropic.TextBlockParam{Text: msg.Content})
+ }
case "user":
if msg.ToolCallID != "" {
anthropicMessages = append(anthropicMessages,
diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go
index cff67c88c..d4ee528b7 100644
--- a/pkg/providers/antigravity_provider.go
+++ b/pkg/providers/antigravity_provider.go
@@ -404,64 +404,6 @@ type antigravityJSONResponse struct {
} `json:"usageMetadata"`
}
-func (p *AntigravityProvider) parseJSONResponse(body []byte) (*LLMResponse, error) {
- var resp antigravityJSONResponse
- if err := json.Unmarshal(body, &resp); err != nil {
- return nil, fmt.Errorf("parsing antigravity response: %w", err)
- }
-
- if len(resp.Candidates) == 0 {
- return nil, fmt.Errorf("antigravity: no candidates in response")
- }
-
- candidate := resp.Candidates[0]
- var contentParts []string
- var toolCalls []ToolCall
-
- for _, part := range candidate.Content.Parts {
- if part.Text != "" {
- contentParts = append(contentParts, part.Text)
- }
- if part.FunctionCall != nil {
- argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
- toolCalls = append(toolCalls, ToolCall{
- ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
- Name: part.FunctionCall.Name,
- Arguments: part.FunctionCall.Args,
- Function: &FunctionCall{
- Name: part.FunctionCall.Name,
- Arguments: string(argumentsJSON),
- ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
- },
- })
- }
- }
-
- finishReason := "stop"
- if len(toolCalls) > 0 {
- finishReason = "tool_calls"
- }
- if candidate.FinishReason == "MAX_TOKENS" {
- finishReason = "length"
- }
-
- var usage *UsageInfo
- if resp.UsageMetadata.TotalTokenCount > 0 {
- usage = &UsageInfo{
- PromptTokens: resp.UsageMetadata.PromptTokenCount,
- CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
- TotalTokens: resp.UsageMetadata.TotalTokenCount,
- }
- }
-
- return &LLMResponse{
- Content: strings.Join(contentParts, ""),
- ToolCalls: toolCalls,
- FinishReason: finishReason,
- Usage: usage,
- }, nil
-}
-
func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
var contentParts []string
var toolCalls []ToolCall
diff --git a/pkg/providers/codex_cli_credentials_test.go b/pkg/providers/codex_cli_credentials_test.go
index 43b21700a..1e88c1120 100644
--- a/pkg/providers/codex_cli_credentials_test.go
+++ b/pkg/providers/codex_cli_credentials_test.go
@@ -43,12 +43,18 @@ func TestReadCodexCliCredentials_Valid(t *testing.T) {
}
}
+// readCodexCliCredentialsErr calls ReadCodexCliCredentials and returns only the
+// error, for tests that only need to assert on failure.
+func readCodexCliCredentialsErr() error {
+ _, _, _, err := ReadCodexCliCredentials() //nolint:dogsled
+ return err
+}
+
func TestReadCodexCliCredentials_MissingFile(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("CODEX_HOME", tmpDir)
- _, _, _, err := ReadCodexCliCredentials()
- if err == nil {
+ if err := readCodexCliCredentialsErr(); err == nil {
t.Fatal("expected error for missing auth.json")
}
}
@@ -64,8 +70,7 @@ func TestReadCodexCliCredentials_EmptyToken(t *testing.T) {
t.Setenv("CODEX_HOME", tmpDir)
- _, _, _, err := ReadCodexCliCredentials()
- if err == nil {
+ if err := readCodexCliCredentialsErr(); err == nil {
t.Fatal("expected error for empty access_token")
}
}
@@ -80,8 +85,7 @@ func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) {
t.Setenv("CODEX_HOME", tmpDir)
- _, _, _, err := ReadCodexCliCredentials()
- if err == nil {
+ if err := readCodexCliCredentialsErr(); err == nil {
t.Fatal("expected error for invalid JSON")
}
}
diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go
index ecc983642..dcc740ba4 100644
--- a/pkg/providers/codex_provider.go
+++ b/pkg/providers/codex_provider.go
@@ -106,8 +106,8 @@ func (p *CodexProvider) Chat(
if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" {
evtResp := evt.Response
if evtResp.ID != "" {
- copy := evtResp
- resp = ©
+ evtRespCopy := evtResp
+ resp = &evtRespCopy
}
}
}
@@ -208,6 +208,11 @@ func buildCodexParams(
for _, msg := range messages {
switch msg.Role {
case "system":
+ // Use the full concatenated system prompt (static + dynamic + summary)
+ // as instructions. This keeps behavior consistent with Anthropic and
+ // OpenAI-compat adapters where the complete system context lives in
+ // one place. Prefix caching is handled by prompt_cache_key below,
+ // not by splitting content across instructions vs input messages.
instructions = msg.Content
case "user":
if msg.ToolCallID != "" {
@@ -289,6 +294,13 @@ func buildCodexParams(
params.Instructions = openai.Opt(defaultCodexInstructions)
}
+ // Prompt caching: pass a stable cache key so OpenAI can bucket requests
+ // and reuse prefix KV cache across calls with the same key.
+ // See: https://platform.openai.com/docs/guides/prompt-caching
+ if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
+ params.PromptCacheKey = openai.Opt(cacheKey)
+ }
+
if len(tools) > 0 || enableWebSearch {
params.Tools = translateToolsForCodex(tools, enableWebSearch)
}
diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go
index b6f1b5e21..11af14da4 100644
--- a/pkg/providers/factory.go
+++ b/pkg/providers/factory.go
@@ -36,7 +36,7 @@ type providerSelection struct {
}
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
- model := cfg.Agents.Defaults.Model
+ model := cfg.Agents.Defaults.GetModelName()
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
lowerModel := strings.ToLower(model)
@@ -172,6 +172,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
sel.model = "deepseek-chat"
}
}
+ case "mistral":
+ if cfg.Providers.Mistral.APIKey != "" {
+ sel.apiKey = cfg.Providers.Mistral.APIKey
+ sel.apiBase = cfg.Providers.Mistral.APIBase
+ sel.proxy = cfg.Providers.Mistral.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.mistral.ai/v1"
+ }
+ }
case "github_copilot", "copilot":
sel.providerType = providerTypeGitHubCopilot
if cfg.Providers.GitHubCopilot.APIBase != "" {
@@ -275,6 +284,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
if sel.apiBase == "" {
sel.apiBase = "http://localhost:11434/v1"
}
+ case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "":
+ sel.apiKey = cfg.Providers.Mistral.APIKey
+ sel.apiBase = cfg.Providers.Mistral.APIBase
+ sel.proxy = cfg.Providers.Mistral.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.mistral.ai/v1"
+ }
case cfg.Providers.VLLM.APIBase != "":
sel.apiKey = cfg.Providers.VLLM.APIKey
sel.apiBase = cfg.Providers.VLLM.APIBase
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index 74fe8a36c..53f7a08a0 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -84,11 +84,17 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
- return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
+ cfg.APIKey,
+ apiBase,
+ cfg.Proxy,
+ cfg.MaxTokensField,
+ cfg.RequestTimeout,
+ ), modelID, nil
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
- "volcengine", "vllm", "qwen":
+ "volcengine", "vllm", "qwen", "mistral":
// All other OpenAI-compatible HTTP providers
if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@@ -97,7 +103,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
- return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
+ cfg.APIKey,
+ apiBase,
+ cfg.Proxy,
+ cfg.MaxTokensField,
+ cfg.RequestTimeout,
+ ), modelID, nil
case "anthropic":
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
@@ -116,7 +128,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if cfg.APIKey == "" {
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
}
- return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
+ cfg.APIKey,
+ apiBase,
+ cfg.Proxy,
+ cfg.MaxTokensField,
+ cfg.RequestTimeout,
+ ), modelID, nil
case "antigravity":
return NewAntigravityProvider(), modelID, nil
@@ -186,6 +204,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
case "vllm":
return "http://localhost:8000/v1"
+ case "mistral":
+ return "https://api.mistral.ai/v1"
default:
return ""
}
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index 6b133101a..e0c0eddef 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -6,7 +6,11 @@
package providers
import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
"testing"
+ "time"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -247,3 +251,42 @@ func TestCreateProviderFromConfig_EmptyModel(t *testing.T) {
t.Fatal("CreateProviderFromConfig() expected error for empty model")
}
}
+
+func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(1500 * time.Millisecond)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`))
+ }))
+ defer server.Close()
+
+ cfg := &config.ModelConfig{
+ ModelName: "test-timeout",
+ Model: "openai/gpt-4o",
+ APIBase: server.URL,
+ RequestTimeout: 1,
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if modelID != "gpt-4o" {
+ t.Fatalf("modelID = %q, want %q", modelID, "gpt-4o")
+ }
+
+ _, err = provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ modelID,
+ nil,
+ )
+ if err == nil {
+ t.Fatal("Chat() expected timeout error, got nil")
+ }
+ errMsg := err.Error()
+ if !strings.Contains(errMsg, "context deadline exceeded") && !strings.Contains(errMsg, "Client.Timeout exceeded") {
+ t.Fatalf("Chat() error = %q, want timeout-related error", errMsg)
+ }
+}
diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go
index e872c672e..ebba054ef 100644
--- a/pkg/providers/fallback_test.go
+++ b/pkg/providers/fallback_test.go
@@ -17,12 +17,6 @@ func successRun(content string) func(ctx context.Context, provider, model string
}
}
-func failRun(err error) func(ctx context.Context, provider, model string) (*LLMResponse, error) {
- return func(ctx context.Context, provider, model string) (*LLMResponse, error) {
- return nil, err
- }
-}
-
func TestFallback_SingleCandidate_Success(t *testing.T) {
ct := NewCooldownTracker()
fc := NewFallbackChain(ct)
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index 6124881f7..3fb15db2f 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -4,60 +4,83 @@ import (
"context"
"encoding/json"
"fmt"
+ "sync"
copilot "github.com/github/copilot-sdk/go"
)
type GitHubCopilotProvider struct {
uri string
- connectMode string // `stdio` or `grpc``
+ connectMode string // "stdio" or "grpc"
+ client *copilot.Client
session *copilot.Session
+
+ mu sync.Mutex
}
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
- var session *copilot.Session
if connectMode == "" {
connectMode = "grpc"
}
- switch connectMode {
+ switch connectMode {
case "stdio":
- // todo
+ // TODO:
+ return nil, fmt.Errorf("stdio mode not implemented")
case "grpc":
client := copilot.NewClient(&copilot.ClientOptions{
CLIUrl: uri,
})
if err := client.Start(context.Background()); err != nil {
return nil, fmt.Errorf(
- "Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details",
+ "can't connect to Github Copilot: %w; `https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server` for details",
+ err,
)
}
- defer client.Stop()
- session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
+
+ session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{
Model: model,
Hooks: &copilot.SessionHooks{},
})
+ if err != nil {
+ client.Stop()
+ return nil, fmt.Errorf("create session failed: %w", err)
+ }
+ return &GitHubCopilotProvider{
+ uri: uri,
+ connectMode: connectMode,
+ client: client,
+ session: session,
+ }, nil
+ default:
+ return nil, fmt.Errorf("unknown connect mode: %s", connectMode)
+ }
+}
+
+func (p *GitHubCopilotProvider) Close() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.client != nil {
+ p.client.Stop()
+ p.client = nil
+ p.session = nil
}
-
- return &GitHubCopilotProvider{
- uri: uri,
- connectMode: connectMode,
- session: session,
- }, nil
}
-// Chat sends a chat request to GitHub Copilot
func (p *GitHubCopilotProvider) Chat(
- ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
) (*LLMResponse, error) {
type tempMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
out := make([]tempMessage, 0, len(messages))
-
for _, msg := range messages {
out = append(out, tempMessage{
Role: msg.Role,
@@ -65,12 +88,30 @@ func (p *GitHubCopilotProvider) Chat(
})
}
- fullcontent, _ := json.Marshal(out)
+ fullcontent, err := json.Marshal(out)
+ if err != nil {
+ return nil, fmt.Errorf("marshal messages: %w", err)
+ }
+ p.mu.Lock()
+ session := p.session
+ p.mu.Unlock()
- content, _ := p.session.Send(ctx, copilot.MessageOptions{
+ if session == nil {
+ return nil, fmt.Errorf("provider closed")
+ }
+
+ resp, _ := session.SendAndWait(ctx, copilot.MessageOptions{
Prompt: string(fullcontent),
})
+ if resp == nil {
+ return nil, fmt.Errorf("empty response from copilot")
+ }
+ if resp.Data.Content == nil {
+ return nil, fmt.Errorf("no content in copilot response")
+ }
+ content := *resp.Data.Content
+
return &LLMResponse{
FinishReason: "stop",
Content: content,
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index d0c4344f3..5c328f418 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -8,6 +8,7 @@ package providers
import (
"context"
+ "time"
"github.com/sipeed/picoclaw/pkg/providers/openai_compat"
)
@@ -23,8 +24,21 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
}
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0)
+}
+
+func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
+ apiKey, apiBase, proxy, maxTokensField string,
+ requestTimeoutSeconds int,
+) *HTTPProvider {
return &HTTPProvider{
- delegate: openai_compat.NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField),
+ delegate: openai_compat.NewProvider(
+ apiKey,
+ apiBase,
+ proxy,
+ openai_compat.WithMaxTokensField(maxTokensField),
+ openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
+ ),
}
}
diff --git a/pkg/providers/legacy_provider.go b/pkg/providers/legacy_provider.go
index eb13cec65..23f137538 100644
--- a/pkg/providers/legacy_provider.go
+++ b/pkg/providers/legacy_provider.go
@@ -16,7 +16,7 @@ import (
// The old providers config is automatically converted to model_list during config loading.
// Returns the provider, the model ID to use, and any error.
func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
- model := cfg.Agents.Defaults.Model
+ model := cfg.Agents.Defaults.GetModelName()
// Ensure model_list is populated (should be done by LoadConfig, but handle edge cases)
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index b8528953a..7dace71f2 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -34,13 +34,27 @@ type Provider struct {
httpClient *http.Client
}
-func NewProvider(apiKey, apiBase, proxy string) *Provider {
- return NewProviderWithMaxTokensField(apiKey, apiBase, proxy, "")
+type Option func(*Provider)
+
+const defaultRequestTimeout = 120 * time.Second
+
+func WithMaxTokensField(maxTokensField string) Option {
+ return func(p *Provider) {
+ p.maxTokensField = maxTokensField
+ }
}
-func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
+func WithRequestTimeout(timeout time.Duration) Option {
+ return func(p *Provider) {
+ if timeout > 0 {
+ p.httpClient.Timeout = timeout
+ }
+ }
+}
+
+func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
client := &http.Client{
- Timeout: 120 * time.Second,
+ Timeout: defaultRequestTimeout,
}
if proxy != "" {
@@ -54,12 +68,36 @@ func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string
}
}
- return &Provider{
- apiKey: apiKey,
- apiBase: strings.TrimRight(apiBase, "/"),
- maxTokensField: maxTokensField,
- httpClient: client,
+ p := &Provider{
+ apiKey: apiKey,
+ apiBase: strings.TrimRight(apiBase, "/"),
+ httpClient: client,
}
+
+ for _, opt := range opts {
+ if opt != nil {
+ opt(p)
+ }
+ }
+
+ return p
+}
+
+func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
+ return NewProvider(apiKey, apiBase, proxy, WithMaxTokensField(maxTokensField))
+}
+
+func NewProviderWithMaxTokensFieldAndTimeout(
+ apiKey, apiBase, proxy, maxTokensField string,
+ requestTimeoutSeconds int,
+) *Provider {
+ return NewProvider(
+ apiKey,
+ apiBase,
+ proxy,
+ WithMaxTokensField(maxTokensField),
+ WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
+ )
}
func (p *Provider) Chat(
@@ -77,7 +115,7 @@ func (p *Provider) Chat(
requestBody := map[string]any{
"model": model,
- "messages": messages,
+ "messages": stripSystemParts(messages),
}
if len(tools) > 0 {
@@ -111,6 +149,18 @@ func (p *Provider) Chat(
}
}
+ // Prompt caching: pass a stable cache key so OpenAI can bucket requests
+ // with the same key and reuse prefix KV cache across calls.
+ // The key is typically the agent ID — stable per agent, shared across requests.
+ // See: https://platform.openai.com/docs/guides/prompt-caching
+ // Prompt caching is only supported by OpenAI-native endpoints.
+ // Gemini and other providers reject unknown fields, so skip for non-OpenAI APIs.
+ if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
+ if !strings.Contains(p.apiBase, "generativelanguage.googleapis.com") {
+ requestBody["prompt_cache_key"] = cacheKey
+ }
+ }
+
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
@@ -148,8 +198,9 @@ func parseResponse(body []byte) (*LLMResponse, error) {
var apiResponse struct {
Choices []struct {
Message struct {
- Content string `json:"content"`
- ToolCalls []struct {
+ Content string `json:"content"`
+ ReasoningContent string `json:"reasoning_content"`
+ ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
@@ -221,13 +272,40 @@ func parseResponse(body []byte) (*LLMResponse, error) {
}
return &LLMResponse{
- Content: choice.Message.Content,
- ToolCalls: toolCalls,
- FinishReason: choice.FinishReason,
- Usage: apiResponse.Usage,
+ Content: choice.Message.Content,
+ ReasoningContent: choice.Message.ReasoningContent,
+ ToolCalls: toolCalls,
+ FinishReason: choice.FinishReason,
+ Usage: apiResponse.Usage,
}, nil
}
+// openaiMessage is the wire-format message for OpenAI-compatible APIs.
+// It mirrors protocoltypes.Message but omits SystemParts, which is an
+// internal field that would be unknown to third-party endpoints.
+type openaiMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
+}
+
+// stripSystemParts converts []Message to []openaiMessage, dropping the
+// SystemParts field so it doesn't leak into the JSON payload sent to
+// OpenAI-compatible APIs (some strict endpoints reject unknown fields).
+func stripSystemParts(messages []Message) []openaiMessage {
+ out := make([]openaiMessage, len(messages))
+ for i, m := range messages {
+ out[i] = openaiMessage{
+ Role: m.Role,
+ Content: m.Content,
+ ToolCalls: m.ToolCalls,
+ ToolCallID: m.ToolCallID,
+ }
+ }
+ return out
+}
+
func normalizeModel(model, apiBase string) string {
idx := strings.Index(model, "/")
if idx == -1 {
@@ -240,7 +318,7 @@ func normalizeModel(model, apiBase string) string {
prefix := strings.ToLower(model[:idx])
switch prefix {
- case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu":
+ case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral":
return model[idx+1:]
default:
return model
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
index 42f9d42ab..7247fea3e 100644
--- a/pkg/providers/openai_compat/provider_test.go
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"net/url"
"testing"
+ "time"
)
func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
@@ -101,6 +102,50 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) {
}
}
+func TestProviderChat_ParsesReasoningContent(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := map[string]any{
+ "choices": []map[string]any{
+ {
+ "message": map[string]any{
+ "content": "The answer is 2",
+ "reasoning_content": "Let me think step by step... 1+1=2",
+ "tool_calls": []map[string]any{
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": map[string]any{
+ "name": "calculator",
+ "arguments": "{\"expr\":\"1+1\"}",
+ },
+ },
+ },
+ },
+ "finish_reason": "tool_calls",
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL, "")
+ out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "1+1=?"}}, nil, "kimi-k2.5", nil)
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if out.ReasoningContent != "Let me think step by step... 1+1=2" {
+ t.Fatalf("ReasoningContent = %q, want %q", out.ReasoningContent, "Let me think step by step... 1+1=2")
+ }
+ if out.Content != "The answer is 2" {
+ t.Fatalf("Content = %q, want %q", out.Content, "The answer is 2")
+ }
+ if len(out.ToolCalls) != 1 {
+ t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
+ }
+}
+
func TestProviderChat_HTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request", http.StatusBadRequest)
@@ -281,3 +326,38 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
}
}
+
+func TestProvider_RequestTimeoutDefault(t *testing.T) {
+ p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", 0)
+ if p.httpClient.Timeout != defaultRequestTimeout {
+ t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
+ }
+}
+
+func TestProvider_RequestTimeoutOverride(t *testing.T) {
+ p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", 300)
+ if p.httpClient.Timeout != 300*time.Second {
+ t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
+ }
+}
+
+func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) {
+ p := NewProvider("key", "https://example.com/v1", "", WithMaxTokensField("max_completion_tokens"))
+ if p.maxTokensField != "max_completion_tokens" {
+ t.Fatalf("maxTokensField = %q, want %q", p.maxTokensField, "max_completion_tokens")
+ }
+}
+
+func TestProvider_FunctionalOptionRequestTimeout(t *testing.T) {
+ p := NewProvider("key", "https://example.com/v1", "", WithRequestTimeout(45*time.Second))
+ if p.httpClient.Timeout != 45*time.Second {
+ t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, 45*time.Second)
+ }
+}
+
+func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) {
+ p := NewProvider("key", "https://example.com/v1", "", WithRequestTimeout(-1*time.Second))
+ if p.httpClient.Timeout != defaultRequestTimeout {
+ t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
+ }
+}
diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go
index 3a089ca47..33f052c5a 100644
--- a/pkg/providers/protocoltypes/types.go
+++ b/pkg/providers/protocoltypes/types.go
@@ -4,8 +4,8 @@ type ToolCall struct {
ID string `json:"id"`
Type string `json:"type,omitempty"`
Function *FunctionCall `json:"function,omitempty"`
- Name string `json:"name,omitempty"`
- Arguments map[string]any `json:"arguments,omitempty"`
+ Name string `json:"-"`
+ Arguments map[string]any `json:"-"`
ThoughtSignature string `json:"-"` // Internal use only
ExtraContent *ExtraContent `json:"extra_content,omitempty"`
}
@@ -25,10 +25,11 @@ type FunctionCall struct {
}
type LLMResponse struct {
- Content string `json:"content"`
- ToolCalls []ToolCall `json:"tool_calls,omitempty"`
- FinishReason string `json:"finish_reason"`
- Usage *UsageInfo `json:"usage,omitempty"`
+ Content string `json:"content"`
+ ReasoningContent string `json:"reasoning_content,omitempty"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ FinishReason string `json:"finish_reason"`
+ Usage *UsageInfo `json:"usage,omitempty"`
}
type UsageInfo struct {
@@ -37,11 +38,28 @@ type UsageInfo struct {
TotalTokens int `json:"total_tokens"`
}
+// CacheControl marks a content block for LLM-side prefix caching.
+// Currently only "ephemeral" is supported (used by Anthropic).
+type CacheControl struct {
+ Type string `json:"type"` // "ephemeral"
+}
+
+// ContentBlock represents a structured segment of a system message.
+// Adapters that understand SystemParts can use these blocks to set
+// per-block cache control (e.g. Anthropic's cache_control: ephemeral).
+type ContentBlock struct {
+ Type string `json:"type"` // "text"
+ Text string `json:"text"`
+ CacheControl *CacheControl `json:"cache_control,omitempty"`
+}
+
type Message struct {
- Role string `json:"role"`
- Content string `json:"content"`
- ToolCalls []ToolCall `json:"tool_calls,omitempty"`
- ToolCallID string `json:"tool_call_id,omitempty"`
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ReasoningContent string `json:"reasoning_content,omitempty"`
+ SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
}
type ToolDefinition struct {
diff --git a/pkg/providers/types.go b/pkg/providers/types.go
index f711e7803..f0c168bc6 100644
--- a/pkg/providers/types.go
+++ b/pkg/providers/types.go
@@ -17,6 +17,8 @@ type (
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
ExtraContent = protocoltypes.ExtraContent
GoogleExtra = protocoltypes.GoogleExtra
+ ContentBlock = protocoltypes.ContentBlock
+ CacheControl = protocoltypes.CacheControl
)
type LLMProvider interface {
@@ -30,6 +32,11 @@ type LLMProvider interface {
GetDefaultModel() string
}
+type StatefulProvider interface {
+ LLMProvider
+ Close()
+}
+
// FailoverReason classifies why an LLM request failed for fallback decisions.
type FailoverReason string
diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go
index 3210509df..f9b5705f1 100644
--- a/pkg/skills/installer.go
+++ b/pkg/skills/installer.go
@@ -9,6 +9,8 @@ import (
"os"
"path/filepath"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/utils"
)
type SkillInstaller struct {
@@ -44,7 +46,7 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
return fmt.Errorf("failed to create request: %w", err)
}
- resp, err := client.Do(req)
+ resp, err := utils.DoRequestWithRetry(client, req)
if err != nil {
return fmt.Errorf("failed to fetch skill: %w", err)
}
@@ -94,7 +96,7 @@ func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableS
return nil, fmt.Errorf("failed to create request: %w", err)
}
- resp, err := client.Do(req)
+ resp, err := utils.DoRequestWithRetry(client, req)
if err != nil {
return nil, fmt.Errorf("failed to fetch skills list: %w", err)
}
diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go
index eb0d5f322..67d3e70e0 100644
--- a/pkg/skills/loader.go
+++ b/pkg/skills/loader.go
@@ -13,7 +13,11 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
)
-var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
+var (
+ namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
+ reFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
+ reStripFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
+)
const (
MaxNameLength = 64
@@ -55,9 +59,9 @@ func (info SkillInfo) validate() error {
type SkillsLoader struct {
workspace string
- workspaceSkills string // workspace skills (项目级别)
- globalSkills string // 全局 skills (~/.picoclaw/skills)
- builtinSkills string // 内置 skills
+ workspaceSkills string // workspace skills (project-level)
+ globalSkills string // global skills (~/.picoclaw/skills)
+ builtinSkills string // builtin skills
}
func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader {
@@ -71,118 +75,56 @@ func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string
func (sl *SkillsLoader) ListSkills() []SkillInfo {
skills := make([]SkillInfo, 0)
+ seen := make(map[string]bool)
- if sl.workspaceSkills != "" {
- if dirs, err := os.ReadDir(sl.workspaceSkills); err == nil {
- for _, dir := range dirs {
- if dir.IsDir() {
- skillFile := filepath.Join(sl.workspaceSkills, dir.Name(), "SKILL.md")
- if _, err := os.Stat(skillFile); err == nil {
- info := SkillInfo{
- Name: dir.Name(),
- Path: skillFile,
- Source: "workspace",
- }
- metadata := sl.getSkillMetadata(skillFile)
- if metadata != nil {
- info.Description = metadata.Description
- info.Name = metadata.Name
- }
- if err := info.validate(); err != nil {
- slog.Warn("invalid skill from workspace", "name", info.Name, "error", err)
- continue
- }
- skills = append(skills, info)
- }
- }
+ addSkills := func(dir, source string) {
+ if dir == "" {
+ return
+ }
+ dirs, err := os.ReadDir(dir)
+ if err != nil {
+ return
+ }
+ for _, d := range dirs {
+ if !d.IsDir() {
+ continue
}
+ skillFile := filepath.Join(dir, d.Name(), "SKILL.md")
+ if _, err := os.Stat(skillFile); err != nil {
+ continue
+ }
+ info := SkillInfo{
+ Name: d.Name(),
+ Path: skillFile,
+ Source: source,
+ }
+ metadata := sl.getSkillMetadata(skillFile)
+ if metadata != nil {
+ info.Description = metadata.Description
+ info.Name = metadata.Name
+ }
+ if err := info.validate(); err != nil {
+ slog.Warn("invalid skill from "+source, "name", info.Name, "error", err)
+ continue
+ }
+ if seen[info.Name] {
+ continue
+ }
+ seen[info.Name] = true
+ skills = append(skills, info)
}
}
- // 全局 skills (~/.picoclaw/skills) - 被 workspace skills 覆盖
- if sl.globalSkills != "" {
- if dirs, err := os.ReadDir(sl.globalSkills); err == nil {
- for _, dir := range dirs {
- if dir.IsDir() {
- skillFile := filepath.Join(sl.globalSkills, dir.Name(), "SKILL.md")
- if _, err := os.Stat(skillFile); err == nil {
- // 检查是否已被 workspace skills 覆盖
- exists := false
- for _, s := range skills {
- if s.Name == dir.Name() && s.Source == "workspace" {
- exists = true
- break
- }
- }
- if exists {
- continue
- }
-
- info := SkillInfo{
- Name: dir.Name(),
- Path: skillFile,
- Source: "global",
- }
- metadata := sl.getSkillMetadata(skillFile)
- if metadata != nil {
- info.Description = metadata.Description
- info.Name = metadata.Name
- }
- if err := info.validate(); err != nil {
- slog.Warn("invalid skill from global", "name", info.Name, "error", err)
- continue
- }
- skills = append(skills, info)
- }
- }
- }
- }
- }
-
- if sl.builtinSkills != "" {
- if dirs, err := os.ReadDir(sl.builtinSkills); err == nil {
- for _, dir := range dirs {
- if dir.IsDir() {
- skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md")
- if _, err := os.Stat(skillFile); err == nil {
- // 检查是否已被 workspace 或 global skills 覆盖
- exists := false
- for _, s := range skills {
- if s.Name == dir.Name() && (s.Source == "workspace" || s.Source == "global") {
- exists = true
- break
- }
- }
- if exists {
- continue
- }
-
- info := SkillInfo{
- Name: dir.Name(),
- Path: skillFile,
- Source: "builtin",
- }
- metadata := sl.getSkillMetadata(skillFile)
- if metadata != nil {
- info.Description = metadata.Description
- info.Name = metadata.Name
- }
- if err := info.validate(); err != nil {
- slog.Warn("invalid skill from builtin", "name", info.Name, "error", err)
- continue
- }
- skills = append(skills, info)
- }
- }
- }
- }
- }
+ // Priority: workspace > global > builtin
+ addSkills(sl.workspaceSkills, "workspace")
+ addSkills(sl.globalSkills, "global")
+ addSkills(sl.builtinSkills, "builtin")
return skills
}
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
- // 1. 优先从 workspace skills 加载(项目级别)
+ // 1. load from workspace skills first (project-level)
if sl.workspaceSkills != "" {
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
@@ -190,7 +132,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
}
}
- // 2. 其次从全局 skills 加载 (~/.picoclaw/skills)
+ // 2. then load from global skills (~/.picoclaw/skills)
if sl.globalSkills != "" {
skillFile := filepath.Join(sl.globalSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
@@ -198,7 +140,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
}
}
- // 3. 最后从内置 skills 加载
+ // 3. finally load from builtin skills
if sl.builtinSkills != "" {
skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
@@ -319,10 +261,7 @@ func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
func (sl *SkillsLoader) extractFrontmatter(content string) string {
// Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
- // (?s) enables DOTALL so . matches newlines;
- // ^--- at start, then ... --- at start of line, honoring all three line ending types
- re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
- match := re.FindStringSubmatch(content)
+ match := reFrontmatter.FindStringSubmatch(content)
if len(match) > 1 {
return match[1]
}
@@ -330,12 +269,7 @@ func (sl *SkillsLoader) extractFrontmatter(content string) string {
}
func (sl *SkillsLoader) stripFrontmatter(content string) string {
- // Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
- // (?s) enables DOTALL so . matches newlines;
- // ^--- at start, then ... --- at start of line, honoring all three line ending types
- // Match zero or more trailing line endings after closing --- (handles both with and without blank lines)
- re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
- return re.ReplaceAllString(content, "")
+ return reStripFrontmatter.ReplaceAllString(content, "")
}
func escapeXML(s string) string {
diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go
index c109e5923..7f9c70f91 100644
--- a/pkg/skills/loader_test.go
+++ b/pkg/skills/loader_test.go
@@ -1,6 +1,8 @@
package skills
import (
+ "os"
+ "path/filepath"
"os"
"path/filepath"
"testing"
@@ -366,6 +368,168 @@ No frontmatter here`,
},
}
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Extract frontmatter
+ frontmatter := sl.extractFrontmatter(tc.content)
+ assert.NotEmpty(t, frontmatter, "Frontmatter should be extracted for %s line endings", tc.lineEndingType)
+
+ // Parse YAML to get name and description (parseSimpleYAML now handles all line ending types)
+ yamlMeta := sl.parseSimpleYAML(frontmatter)
+ assert.Equal(
+ t,
+ tc.expectedName,
+ yamlMeta["name"],
+ "Name should be correctly parsed from frontmatter with %s line endings",
+ tc.lineEndingType,
+ )
+ assert.Equal(
+ t,
+ tc.expectedDesc,
+ yamlMeta["description"],
+ "Description should be correctly parsed from frontmatter with %s line endings",
+ tc.lineEndingType,
+ )
+ })
+ }
+}
+
+// createSkillDir creates a skill directory with a SKILL.md file containing the given frontmatter.
+func createSkillDir(t *testing.T, base, dirName, name, description string) {
+ t.Helper()
+ dir := filepath.Join(base, dirName)
+ require.NoError(t, os.MkdirAll(dir, 0o755))
+ content := "---\nname: " + name + "\ndescription: " + description + "\n---\n\n# " + name
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644))
+}
+
+func TestListSkillsWorkspaceOverridesGlobal(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+
+ createSkillDir(t, filepath.Join(ws, "skills"), "my-skill", "my-skill", "workspace version")
+ createSkillDir(t, global, "my-skill", "my-skill", "global version")
+
+ sl := NewSkillsLoader(ws, global, "")
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "workspace", skills[0].Source)
+ assert.Equal(t, "workspace version", skills[0].Description)
+}
+
+func TestListSkillsGlobalOverridesBuiltin(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+ builtin := filepath.Join(tmp, "builtin")
+
+ createSkillDir(t, global, "my-skill", "my-skill", "global version")
+ createSkillDir(t, builtin, "my-skill", "my-skill", "builtin version")
+
+ sl := NewSkillsLoader(ws, global, builtin)
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "global", skills[0].Source)
+ assert.Equal(t, "global version", skills[0].Description)
+}
+
+func TestListSkillsMetadataNameDedup(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+
+ // Different directory names but same metadata name
+ createSkillDir(t, filepath.Join(ws, "skills"), "dir-a", "shared-name", "workspace version")
+ createSkillDir(t, global, "dir-b", "shared-name", "global version")
+
+ sl := NewSkillsLoader(ws, global, "")
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "shared-name", skills[0].Name)
+ assert.Equal(t, "workspace", skills[0].Source)
+}
+
+func TestListSkillsMultipleDistinctSkills(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+ builtin := filepath.Join(tmp, "builtin")
+
+ createSkillDir(t, filepath.Join(ws, "skills"), "skill-a", "skill-a", "desc a")
+ createSkillDir(t, global, "skill-b", "skill-b", "desc b")
+ createSkillDir(t, builtin, "skill-c", "skill-c", "desc c")
+
+ sl := NewSkillsLoader(ws, global, builtin)
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 3)
+ names := map[string]string{}
+ for _, s := range skills {
+ names[s.Name] = s.Source
+ }
+ assert.Equal(t, "workspace", names["skill-a"])
+ assert.Equal(t, "global", names["skill-b"])
+ assert.Equal(t, "builtin", names["skill-c"])
+}
+
+func TestListSkillsInvalidSkillSkipped(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+
+ // Invalid name (underscore)
+ createSkillDir(t, filepath.Join(ws, "skills"), "bad_skill", "bad_skill", "desc")
+ // Valid skill
+ createSkillDir(t, global, "good-skill", "good-skill", "desc")
+
+ sl := NewSkillsLoader(ws, global, "")
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "good-skill", skills[0].Name)
+}
+
+func TestListSkillsEmptyAndNonexistentDirs(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ emptyDir := filepath.Join(tmp, "empty")
+ require.NoError(t, os.MkdirAll(emptyDir, 0o755))
+
+ sl := NewSkillsLoader(ws, emptyDir, filepath.Join(tmp, "nonexistent"))
+ skills := sl.ListSkills()
+
+ assert.Empty(t, skills)
+}
+
+func TestListSkillsDirWithoutSkillMD(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+
+ // Directory exists but has no SKILL.md
+ require.NoError(t, os.MkdirAll(filepath.Join(global, "no-skillmd"), 0o755))
+ // Valid skill alongside
+ createSkillDir(t, global, "real-skill", "real-skill", "desc")
+
+ sl := NewSkillsLoader(ws, global, "")
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "real-skill", skills[0].Name)
+}
+
+func TestStripFrontmatter(t *testing.T) {
+ sl := &SkillsLoader{}
+
+ testcases := []struct {
+ name string
+ content string
+ expectedContent string
+ lineEndingType string
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := loader.extractFrontmatter(tt.content)
diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go
index c28ca6ca2..d3ab267bf 100644
--- a/pkg/tools/edit.go
+++ b/pkg/tools/edit.go
@@ -2,24 +2,27 @@ package tools
import (
"context"
+ "errors"
"fmt"
- "os"
+ "io/fs"
"strings"
)
// EditFileTool edits a file by replacing old_text with new_text.
// The old_text must exist exactly in the file.
type EditFileTool struct {
- allowedDir string
- restrict bool
+ fs fileSystem
}
// NewEditFileTool creates a new EditFileTool with optional directory restriction.
-func NewEditFileTool(allowedDir string, restrict bool) *EditFileTool {
- return &EditFileTool{
- allowedDir: allowedDir,
- restrict: restrict,
+func NewEditFileTool(workspace string, restrict bool) *EditFileTool {
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
}
+ return &EditFileTool{fs: fs}
}
func (t *EditFileTool) Name() string {
@@ -67,49 +70,24 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("new_text is required")
}
- resolvedPath, err := validatePath(path, t.allowedDir, t.restrict)
- if err != nil {
+ if err := editFile(t.fs, path, oldText, newText); err != nil {
return ErrorResult(err.Error())
}
-
- if _, err = os.Stat(resolvedPath); os.IsNotExist(err) {
- return ErrorResult(fmt.Sprintf("file not found: %s", path))
- }
-
- content, err := os.ReadFile(resolvedPath)
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
- }
-
- contentStr := string(content)
-
- if !strings.Contains(contentStr, oldText) {
- return ErrorResult("old_text not found in file. Make sure it matches exactly")
- }
-
- count := strings.Count(contentStr, oldText)
- if count > 1 {
- return ErrorResult(
- fmt.Sprintf("old_text appears %d times. Please provide more context to make it unique", count),
- )
- }
-
- newContent := strings.Replace(contentStr, oldText, newText, 1)
-
- if err := os.WriteFile(resolvedPath, []byte(newContent), 0o644); err != nil {
- return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
- }
-
return SilentResult(fmt.Sprintf("File edited: %s", path))
}
type AppendFileTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool {
- return &AppendFileTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &AppendFileTool{fs: fs}
}
func (t *AppendFileTool) Name() string {
@@ -148,20 +126,52 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool
return ErrorResult("content is required")
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
- if err != nil {
+ if err := appendFile(t.fs, path, content); err != nil {
return ErrorResult(err.Error())
}
-
- f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to open file: %v", err))
- }
- defer f.Close()
-
- if _, err := f.WriteString(content); err != nil {
- return ErrorResult(fmt.Sprintf("failed to append to file: %v", err))
- }
-
return SilentResult(fmt.Sprintf("Appended to %s", path))
}
+
+// editFile reads the file via sysFs, performs the replacement, and writes back.
+// It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes.
+func editFile(sysFs fileSystem, path, oldText, newText string) error {
+ content, err := sysFs.ReadFile(path)
+ if err != nil {
+ return err
+ }
+
+ newContent, err := replaceEditContent(content, oldText, newText)
+ if err != nil {
+ return err
+ }
+
+ return sysFs.WriteFile(path, newContent)
+}
+
+// appendFile reads the existing content (if any) via sysFs, appends new content, and writes back.
+func appendFile(sysFs fileSystem, path, appendContent string) error {
+ content, err := sysFs.ReadFile(path)
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return err
+ }
+
+ newContent := append(content, []byte(appendContent)...)
+ return sysFs.WriteFile(path, newContent)
+}
+
+// replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText.
+func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) {
+ contentStr := string(content)
+
+ if !strings.Contains(contentStr, oldText) {
+ return nil, fmt.Errorf("old_text not found in file. Make sure it matches exactly")
+ }
+
+ count := strings.Count(contentStr, oldText)
+ if count > 1 {
+ return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count)
+ }
+
+ newContent := strings.Replace(contentStr, oldText, newText, 1)
+ return []byte(newContent), nil
+}
diff --git a/pkg/tools/edit_test.go b/pkg/tools/edit_test.go
index 6780dd9f6..83a7e778c 100644
--- a/pkg/tools/edit_test.go
+++ b/pkg/tools/edit_test.go
@@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"testing"
+
+ "github.com/stretchr/testify/assert"
)
// TestEditTool_EditFile_Success verifies successful file editing
@@ -151,14 +153,18 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
result := tool.Execute(ctx, args)
// Should return error result
- if !result.IsError {
- t.Errorf("Expected error when path is outside allowed directory")
- }
+ assert.True(t, result.IsError, "Expected error when path is outside allowed directory")
// Should mention outside allowed directory
- if !strings.Contains(result.ForLLM, "outside") && !strings.Contains(result.ForUser, "outside") {
- t.Errorf("Expected 'outside allowed' message, got ForLLM: %s", result.ForLLM)
- }
+ // Note: ErrorResult only sets ForLLM by default, so ForUser might be empty.
+ // We check ForLLM as it's the primary error channel.
+ assert.True(
+ t,
+ strings.Contains(result.ForLLM, "outside") || strings.Contains(result.ForLLM, "access denied") ||
+ strings.Contains(result.ForLLM, "escapes"),
+ "Expected 'outside allowed' or 'access denied' message, got ForLLM: %s",
+ result.ForLLM,
+ )
}
// TestEditTool_EditFile_MissingPath verifies error handling for missing path
@@ -287,3 +293,145 @@ func TestEditTool_AppendFile_MissingContent(t *testing.T) {
t.Errorf("Expected error when content is missing")
}
}
+
+// TestReplaceEditContent verifies the helper function replaceEditContent
+func TestReplaceEditContent(t *testing.T) {
+ tests := []struct {
+ name string
+ content []byte
+ oldText string
+ newText string
+ expected []byte
+ expectError bool
+ }{
+ {
+ name: "successful replacement",
+ content: []byte("hello world"),
+ oldText: "world",
+ newText: "universe",
+ expected: []byte("hello universe"),
+ expectError: false,
+ },
+ {
+ name: "old text not found",
+ content: []byte("hello world"),
+ oldText: "golang",
+ newText: "rust",
+ expected: nil,
+ expectError: true,
+ },
+ {
+ name: "multiple matches found",
+ content: []byte("test text test"),
+ oldText: "test",
+ newText: "done",
+ expected: nil,
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := replaceEditContent(tt.content, tt.oldText, tt.newText)
+ if tt.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.expected, result)
+ }
+ })
+ }
+}
+
+// TestAppendFileTool_AppendToNonExistent_Restricted verifies that AppendFileTool in restricted mode
+// can append to a file that does not yet exist — it should silently create the file.
+// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFileWithRW + rootRW.
+func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewAppendFileTool(workspace, true)
+ ctx := context.Background()
+
+ args := map[string]any{
+ "path": "brand_new_file.txt",
+ "content": "first content",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(
+ t,
+ result.IsError,
+ "Expected success when appending to non-existent file in restricted mode, got: %s",
+ result.ForLLM,
+ )
+
+ // Verify the file was created with correct content
+ data, err := os.ReadFile(filepath.Join(workspace, "brand_new_file.txt"))
+ assert.NoError(t, err)
+ assert.Equal(t, "first content", string(data))
+}
+
+// TestAppendFileTool_Restricted_Success verifies that AppendFileTool in restricted mode
+// correctly appends to an existing file within the sandbox.
+func TestAppendFileTool_Restricted_Success(t *testing.T) {
+ workspace := t.TempDir()
+ testFile := "existing.txt"
+ err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0o644)
+ assert.NoError(t, err)
+
+ tool := NewAppendFileTool(workspace, true)
+ ctx := context.Background()
+ args := map[string]any{
+ "path": testFile,
+ "content": " appended",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
+ assert.True(t, result.Silent)
+
+ data, err := os.ReadFile(filepath.Join(workspace, testFile))
+ assert.NoError(t, err)
+ assert.Equal(t, "initial appended", string(data))
+}
+
+// TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode
+// correctly edits a file using the single-open editFileInRoot path.
+func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {
+ workspace := t.TempDir()
+ testFile := "edit_target.txt"
+ err := os.WriteFile(filepath.Join(workspace, testFile), []byte("Hello World"), 0o644)
+ assert.NoError(t, err)
+
+ tool := NewEditFileTool(workspace, true)
+ ctx := context.Background()
+ args := map[string]any{
+ "path": testFile,
+ "old_text": "World",
+ "new_text": "Go",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
+ assert.True(t, result.Silent)
+
+ data, err := os.ReadFile(filepath.Join(workspace, testFile))
+ assert.NoError(t, err)
+ assert.Equal(t, "Hello Go", string(data))
+}
+
+// TestEditFileTool_Restricted_FileNotFound verifies that editFileInRoot returns a proper
+// error message when the target file does not exist.
+func TestEditFileTool_Restricted_FileNotFound(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewEditFileTool(workspace, true)
+ ctx := context.Background()
+ args := map[string]any{
+ "path": "no_such_file.txt",
+ "old_text": "old",
+ "new_text": "new",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "not found")
+}
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index 1bf50906e..37db8b4ae 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -3,15 +3,17 @@ package tools
import (
"context"
"fmt"
+ "io/fs"
"os"
"path/filepath"
"strings"
+ "time"
)
// validatePath ensures the given path is within the workspace if restrict is true.
func validatePath(path, workspace string, restrict bool) (string, error) {
if workspace == "" {
- return path, nil
+ return path, fmt.Errorf("workspace is not defined")
}
absWorkspace, err := filepath.Abs(workspace)
@@ -76,16 +78,21 @@ func resolveExistingAncestor(path string) (string, error) {
func isWithinWorkspace(candidate, workspace string) bool {
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))
- return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
+ return err == nil && filepath.IsLocal(rel)
}
type ReadFileTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool {
- return &ReadFileTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &ReadFileTool{fs: fs}
}
func (t *ReadFileTool) Name() string {
@@ -115,26 +122,25 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("path is required")
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
+ content, err := t.fs.ReadFile(path)
if err != nil {
return ErrorResult(err.Error())
}
-
- content, err := os.ReadFile(resolvedPath)
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
- }
-
return NewToolResult(string(content))
}
type WriteFileTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool {
- return &WriteFileTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &WriteFileTool{fs: fs}
}
func (t *WriteFileTool) Name() string {
@@ -173,30 +179,25 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
return ErrorResult("content is required")
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
- if err != nil {
+ if err := t.fs.WriteFile(path, []byte(content)); err != nil {
return ErrorResult(err.Error())
}
- dir := filepath.Dir(resolvedPath)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return ErrorResult(fmt.Sprintf("failed to create directory: %v", err))
- }
-
- if err := os.WriteFile(resolvedPath, []byte(content), 0o644); err != nil {
- return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
- }
-
return SilentResult(fmt.Sprintf("File written: %s", path))
}
type ListDirTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewListDirTool(workspace string, restrict bool) *ListDirTool {
- return &ListDirTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &ListDirTool{fs: fs}
}
func (t *ListDirTool) Name() string {
@@ -226,24 +227,179 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes
path = "."
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
- if err != nil {
- return ErrorResult(err.Error())
- }
-
- entries, err := os.ReadDir(resolvedPath)
+ entries, err := t.fs.ReadDir(path)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
}
+ return formatDirEntries(entries)
+}
- result := ""
+func formatDirEntries(entries []os.DirEntry) *ToolResult {
+ var result strings.Builder
for _, entry := range entries {
if entry.IsDir() {
- result += "DIR: " + entry.Name() + "\n"
+ result.WriteString("DIR: " + entry.Name() + "\n")
} else {
- result += "FILE: " + entry.Name() + "\n"
+ result.WriteString("FILE: " + entry.Name() + "\n")
+ }
+ }
+ return NewToolResult(result.String())
+}
+
+// fileSystem abstracts reading, writing, and listing files, allowing both
+// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface.
+type fileSystem interface {
+ ReadFile(path string) ([]byte, error)
+ WriteFile(path string, data []byte) error
+ ReadDir(path string) ([]os.DirEntry, error)
+}
+
+// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
+type hostFs struct{}
+
+func (h *hostFs) ReadFile(path string) ([]byte, error) {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, fmt.Errorf("failed to read file: file not found: %w", err)
+ }
+ if os.IsPermission(err) {
+ return nil, fmt.Errorf("failed to read file: access denied: %w", err)
+ }
+ return nil, fmt.Errorf("failed to read file: %w", err)
+ }
+ return content, nil
+}
+
+func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
+ return os.ReadDir(path)
+}
+
+func (h *hostFs) WriteFile(path string, data []byte) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("failed to create parent directories: %w", err)
+ }
+
+ // We use a "write-then-rename" pattern here to ensure an atomic write.
+ // This prevents the target file from being left in a truncated or partial state
+ // if the operation is interrupted, as the rename operation is atomic on Linux.
+ tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
+ if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
+ os.Remove(tmpPath) // Ensure cleanup of partial/empty temp file
+ return fmt.Errorf("failed to write temp file: %w", err)
+ }
+
+ if err := os.Rename(tmpPath, path); err != nil {
+ os.Remove(tmpPath)
+ return fmt.Errorf("failed to replace original file: %w", err)
+ }
+ return nil
+}
+
+// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
+type sandboxFs struct {
+ workspace string
+}
+
+func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error {
+ if r.workspace == "" {
+ return fmt.Errorf("workspace is not defined")
+ }
+
+ root, err := os.OpenRoot(r.workspace)
+ if err != nil {
+ return fmt.Errorf("failed to open workspace: %w", err)
+ }
+ defer root.Close()
+
+ relPath, err := getSafeRelPath(r.workspace, path)
+ if err != nil {
+ return err
+ }
+
+ return fn(root, relPath)
+}
+
+func (r *sandboxFs) ReadFile(path string) ([]byte, error) {
+ var content []byte
+ err := r.execute(path, func(root *os.Root, relPath string) error {
+ fileContent, err := root.ReadFile(relPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("failed to read file: file not found: %w", err)
+ }
+ // os.Root returns "escapes from parent" for paths outside the root
+ if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") ||
+ strings.Contains(err.Error(), "permission denied") {
+ return fmt.Errorf("failed to read file: access denied: %w", err)
+ }
+ return fmt.Errorf("failed to read file: %w", err)
+ }
+ content = fileContent
+ return nil
+ })
+ return content, err
+}
+
+func (r *sandboxFs) WriteFile(path string, data []byte) error {
+ return r.execute(path, func(root *os.Root, relPath string) error {
+ dir := filepath.Dir(relPath)
+ if dir != "." && dir != "/" {
+ if err := root.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("failed to create parent directories: %w", err)
+ }
+ }
+
+ // We use a "write-then-rename" pattern here to ensure an atomic write.
+ // This prevents the target file from being left in a truncated or partial state
+ // if the operation is interrupted, as the rename operation is atomic on Linux.
+ tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano())
+
+ if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil {
+ root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file
+ return fmt.Errorf("failed to write to temp file: %w", err)
+ }
+
+ if err := root.Rename(tmpRelPath, relPath); err != nil {
+ root.Remove(tmpRelPath)
+ return fmt.Errorf("failed to rename temp file over target: %w", err)
+ }
+ return nil
+ })
+}
+
+func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) {
+ var entries []os.DirEntry
+ err := r.execute(path, func(root *os.Root, relPath string) error {
+ dirEntries, err := fs.ReadDir(root.FS(), relPath)
+ if err != nil {
+ return err
+ }
+ entries = dirEntries
+ return nil
+ })
+ return entries, err
+}
+
+// Helper to get a safe relative path for os.Root usage
+func getSafeRelPath(workspace, path string) (string, error) {
+ if workspace == "" {
+ return "", fmt.Errorf("workspace is not defined")
+ }
+
+ rel := filepath.Clean(path)
+ if filepath.IsAbs(rel) {
+ var err error
+ rel, err = filepath.Rel(workspace, rel)
+ if err != nil {
+ return "", fmt.Errorf("failed to calculate relative path: %w", err)
}
}
- return NewToolResult(result)
+ if !filepath.IsLocal(rel) {
+ return "", fmt.Errorf("path escapes workspace: %s", path)
+ }
+
+ return rel, nil
}
diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go
index 5daa3dcea..6f896e22d 100644
--- a/pkg/tools/filesystem_test.go
+++ b/pkg/tools/filesystem_test.go
@@ -2,10 +2,13 @@ package tools
import (
"context"
+ "io"
"os"
"path/filepath"
"strings"
"testing"
+
+ "github.com/stretchr/testify/assert"
)
// TestFilesystemTool_ReadFile_Success verifies successful file reading
@@ -14,7 +17,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("test content"), 0o644)
- tool := &ReadFileTool{}
+ tool := NewReadFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@@ -41,7 +44,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
- tool := &ReadFileTool{}
+ tool := NewReadFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": "/nonexistent_file_12345.txt",
@@ -84,7 +87,7 @@ func TestFilesystemTool_WriteFile_Success(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "newfile.txt")
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@@ -123,7 +126,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "subdir", "newfile.txt")
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@@ -149,7 +152,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path
func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
args := map[string]any{
"content": "test",
@@ -165,7 +168,7 @@ func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content
func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": "/tmp/test.txt",
@@ -192,7 +195,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644)
os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755)
- tool := &ListDirTool{}
+ tool := NewListDirTool("", false)
ctx := context.Background()
args := map[string]any{
"path": tmpDir,
@@ -216,7 +219,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory
func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
- tool := &ListDirTool{}
+ tool := NewListDirTool("", false)
ctx := context.Background()
args := map[string]any{
"path": "/nonexistent_directory_12345",
@@ -237,7 +240,7 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory
func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
- tool := &ListDirTool{}
+ tool := NewListDirTool("", false)
ctx := context.Background()
args := map[string]any{}
@@ -275,7 +278,211 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
if !result.IsError {
t.Fatalf("expected symlink escape to be blocked")
}
- if !strings.Contains(result.ForLLM, "symlink resolves outside workspace") {
+ // os.Root might return different errors depending on platform/implementation
+ // but it definitely should error.
+ // Our wrapper returns "access denied or file not found"
+ if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") &&
+ !strings.Contains(result.ForLLM, "no such file") {
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
}
}
+
+func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
+ tool := NewReadFileTool("", true) // restrict=true but workspace=""
+
+ // Try to read a sensitive file (simulated by a temp file outside workspace)
+ tmpDir := t.TempDir()
+ secretFile := filepath.Join(tmpDir, "shadow")
+ os.WriteFile(secretFile, []byte("secret data"), 0o600)
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": secretFile,
+ })
+
+ // We EXPECT IsError=true (access blocked due to empty workspace)
+ assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
+
+ // Verify it failed for the right reason
+ assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
+}
+
+// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
+// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path.
+func TestRootMkdirAll(t *testing.T) {
+ workspace := t.TempDir()
+ root, err := os.OpenRoot(workspace)
+ if err != nil {
+ t.Fatalf("failed to open root: %v", err)
+ }
+ defer root.Close()
+
+ // Case 1: Single directory
+ err = root.MkdirAll("dir1", 0o755)
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(workspace, "dir1"))
+ assert.NoError(t, err)
+
+ // Case 2: Deeply nested directory
+ err = root.MkdirAll("a/b/c/d", 0o755)
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(workspace, "a/b/c/d"))
+ assert.NoError(t, err)
+
+ // Case 3: Already exists — must be idempotent
+ err = root.MkdirAll("a/b/c/d", 0o755)
+ assert.NoError(t, err)
+
+ // Case 4: A regular file blocks directory creation — must error
+ err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644)
+ assert.NoError(t, err)
+ err = root.MkdirAll("file_exists", 0o755)
+ assert.Error(t, err, "expected error when a file exists at the directory path")
+}
+
+func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewWriteFileTool(workspace, true)
+ ctx := context.Background()
+
+ testFile := "deep/nested/path/to/file.txt"
+ content := "deep content"
+ args := map[string]any{
+ "path": testFile,
+ "content": content,
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
+
+ // Verify file content
+ actualPath := filepath.Join(workspace, testFile)
+ data, err := os.ReadFile(actualPath)
+ assert.NoError(t, err)
+ assert.Equal(t, content, string(data))
+}
+
+// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors.
+func TestHostRW_Read_PermissionDenied(t *testing.T) {
+ if os.Getuid() == 0 {
+ t.Skip("skipping permission test: running as root")
+ }
+ tmpDir := t.TempDir()
+ protected := filepath.Join(tmpDir, "protected.txt")
+ err := os.WriteFile(protected, []byte("secret"), 0o000)
+ assert.NoError(t, err)
+ defer os.Chmod(protected, 0o644) // ensure cleanup
+
+ _, err = (&hostFs{}).ReadFile(protected)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "access denied")
+}
+
+// TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path.
+func TestHostRW_Read_Directory(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ _, err := (&hostFs{}).ReadFile(tmpDir)
+ assert.Error(t, err, "expected error when reading a directory as a file")
+}
+
+// TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory.
+func TestRootRW_Read_Directory(t *testing.T) {
+ workspace := t.TempDir()
+ root, err := os.OpenRoot(workspace)
+ assert.NoError(t, err)
+ defer root.Close()
+
+ // Create a subdirectory
+ err = root.Mkdir("subdir", 0o755)
+ assert.NoError(t, err)
+
+ _, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir")
+ assert.Error(t, err, "expected error when reading a directory as a file")
+}
+
+// TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically.
+func TestHostRW_Write_ParentDirMissing(t *testing.T) {
+ tmpDir := t.TempDir()
+ target := filepath.Join(tmpDir, "a", "b", "c", "file.txt")
+
+ err := (&hostFs{}).WriteFile(target, []byte("hello"))
+ assert.NoError(t, err)
+
+ data, err := os.ReadFile(target)
+ assert.NoError(t, err)
+ assert.Equal(t, "hello", string(data))
+}
+
+// TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates
+// nested parent directories automatically within the sandbox.
+func TestRootRW_Write_ParentDirMissing(t *testing.T) {
+ workspace := t.TempDir()
+
+ relPath := "x/y/z/file.txt"
+ err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested"))
+ assert.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(workspace, relPath))
+ assert.NoError(t, err)
+ assert.Equal(t, "nested", string(data))
+}
+
+// TestHostRW_Write verifies the hostRW.Write helper function
+func TestHostRW_Write(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "atomic_test.txt")
+ testData := []byte("atomic test content")
+
+ err := (&hostFs{}).WriteFile(testFile, testData)
+ assert.NoError(t, err)
+
+ content, err := os.ReadFile(testFile)
+ assert.NoError(t, err)
+ assert.Equal(t, testData, content)
+
+ // Verify it overwrites correctly
+ newData := []byte("new atomic content")
+ err = (&hostFs{}).WriteFile(testFile, newData)
+ assert.NoError(t, err)
+
+ content, err = os.ReadFile(testFile)
+ assert.NoError(t, err)
+ assert.Equal(t, newData, content)
+}
+
+// TestRootRW_Write verifies the rootRW.Write helper function
+func TestRootRW_Write(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ relPath := "atomic_root_test.txt"
+ testData := []byte("atomic root test content")
+
+ erw := &sandboxFs{workspace: tmpDir}
+ err := erw.WriteFile(relPath, testData)
+ assert.NoError(t, err)
+
+ root, err := os.OpenRoot(tmpDir)
+ assert.NoError(t, err)
+ defer root.Close()
+
+ f, err := root.Open(relPath)
+ assert.NoError(t, err)
+ defer f.Close()
+
+ content, err := io.ReadAll(f)
+ assert.NoError(t, err)
+ assert.Equal(t, testData, content)
+
+ // Verify it overwrites correctly
+ newData := []byte("new root atomic content")
+ err = erw.WriteFile(relPath, newData)
+ assert.NoError(t, err)
+
+ f2, err := root.Open(relPath)
+ assert.NoError(t, err)
+ defer f2.Close()
+
+ content, err = io.ReadAll(f2)
+ assert.NoError(t, err)
+ assert.Equal(t, newData, content)
+}
diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go
index 0387a26d3..779b1d5a7 100644
--- a/pkg/tools/i2c.go
+++ b/pkg/tools/i2c.go
@@ -117,13 +117,19 @@ func (t *I2CTool) detect() *ToolResult {
return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result)))
}
+// Helper functions for I2C operations (used by platform-specific implementations)
+
// isValidBusID checks that a bus identifier is a simple number (prevents path injection)
+//
+//nolint:unused // Used by i2c_linux.go
func isValidBusID(id string) bool {
matched, _ := regexp.MatchString(`^\d+$`, id)
return matched
}
// parseI2CAddress extracts and validates an I2C address from args
+//
+//nolint:unused // Used by i2c_linux.go
func parseI2CAddress(args map[string]any) (int, *ToolResult) {
addrFloat, ok := args["address"].(float64)
if !ok {
@@ -137,6 +143,8 @@ func parseI2CAddress(args map[string]any) (int, *ToolResult) {
}
// parseI2CBus extracts and validates an I2C bus from args
+//
+//nolint:unused // Used by i2c_linux.go
func parseI2CBus(args map[string]any) (string, *ToolResult) {
bus, ok := args["bus"].(string)
if !ok || bus == "" {
diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go
index 6ecb8ae7c..d37a093a8 100644
--- a/pkg/tools/registry.go
+++ b/pkg/tools/registry.go
@@ -3,6 +3,7 @@ package tools
import (
"context"
"fmt"
+ "sort"
"sync"
"time"
@@ -107,13 +108,27 @@ func (r *ToolRegistry) ExecuteWithContext(
return result
}
+// sortedToolNames returns tool names in sorted order for deterministic iteration.
+// This is critical for KV cache stability: non-deterministic map iteration would
+// produce different system prompts and tool definitions on each call, invalidating
+// the LLM's prefix cache even when no tools have changed.
+func (r *ToolRegistry) sortedToolNames() []string {
+ names := make([]string, 0, len(r.tools))
+ for name := range r.tools {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ return names
+}
+
func (r *ToolRegistry) GetDefinitions() []map[string]any {
r.mu.RLock()
defer r.mu.RUnlock()
- definitions := make([]map[string]any, 0, len(r.tools))
- for _, tool := range r.tools {
- definitions = append(definitions, ToolToSchema(tool))
+ sorted := r.sortedToolNames()
+ definitions := make([]map[string]any, 0, len(sorted))
+ for _, name := range sorted {
+ definitions = append(definitions, ToolToSchema(r.tools[name]))
}
return definitions
}
@@ -124,8 +139,10 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
- definitions := make([]providers.ToolDefinition, 0, len(r.tools))
- for _, tool := range r.tools {
+ sorted := r.sortedToolNames()
+ definitions := make([]providers.ToolDefinition, 0, len(sorted))
+ for _, name := range sorted {
+ tool := r.tools[name]
schema := ToolToSchema(tool)
// Safely extract nested values with type checks
@@ -155,11 +172,7 @@ func (r *ToolRegistry) List() []string {
r.mu.RLock()
defer r.mu.RUnlock()
- names := make([]string, 0, len(r.tools))
- for name := range r.tools {
- names = append(names, name)
- }
- return names
+ return r.sortedToolNames()
}
// Count returns the number of registered tools.
@@ -175,8 +188,10 @@ func (r *ToolRegistry) GetSummaries() []string {
r.mu.RLock()
defer r.mu.RUnlock()
- summaries := make([]string, 0, len(r.tools))
- for _, tool := range r.tools {
+ sorted := r.sortedToolNames()
+ summaries := make([]string, 0, len(sorted))
+ for _, name := range sorted {
+ tool := r.tools[name]
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description()))
}
return summaries
diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go
new file mode 100644
index 000000000..8ae13b20c
--- /dev/null
+++ b/pkg/tools/registry_test.go
@@ -0,0 +1,350 @@
+package tools
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// --- mock types ---
+
+type mockRegistryTool struct {
+ name string
+ desc string
+ params map[string]any
+ result *ToolResult
+}
+
+func (m *mockRegistryTool) Name() string { return m.name }
+func (m *mockRegistryTool) Description() string { return m.desc }
+func (m *mockRegistryTool) Parameters() map[string]any { return m.params }
+func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
+ return m.result
+}
+
+type mockCtxTool struct {
+ mockRegistryTool
+ channel string
+ chatID string
+}
+
+func (m *mockCtxTool) SetContext(channel, chatID string) {
+ m.channel = channel
+ m.chatID = chatID
+}
+
+type mockAsyncRegistryTool struct {
+ mockRegistryTool
+ cb AsyncCallback
+}
+
+func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) {
+ m.cb = cb
+}
+
+// --- helpers ---
+
+func newMockTool(name, desc string) *mockRegistryTool {
+ return &mockRegistryTool{
+ name: name,
+ desc: desc,
+ params: map[string]any{"type": "object"},
+ result: SilentResult("ok"),
+ }
+}
+
+// --- tests ---
+
+func TestNewToolRegistry(t *testing.T) {
+ r := NewToolRegistry()
+ if r.Count() != 0 {
+ t.Errorf("expected empty registry, got count %d", r.Count())
+ }
+ if len(r.List()) != 0 {
+ t.Errorf("expected empty list, got %v", r.List())
+ }
+}
+
+func TestToolRegistry_RegisterAndGet(t *testing.T) {
+ r := NewToolRegistry()
+ tool := newMockTool("echo", "echoes input")
+ r.Register(tool)
+
+ got, ok := r.Get("echo")
+ if !ok {
+ t.Fatal("expected to find registered tool")
+ }
+ if got.Name() != "echo" {
+ t.Errorf("expected name 'echo', got %q", got.Name())
+ }
+}
+
+func TestToolRegistry_Get_NotFound(t *testing.T) {
+ r := NewToolRegistry()
+ _, ok := r.Get("nonexistent")
+ if ok {
+ t.Error("expected ok=false for unregistered tool")
+ }
+}
+
+func TestToolRegistry_RegisterOverwrite(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("dup", "first"))
+ r.Register(newMockTool("dup", "second"))
+
+ if r.Count() != 1 {
+ t.Errorf("expected count 1 after overwrite, got %d", r.Count())
+ }
+ tool, _ := r.Get("dup")
+ if tool.Description() != "second" {
+ t.Errorf("expected overwritten description 'second', got %q", tool.Description())
+ }
+}
+
+func TestToolRegistry_Execute_Success(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(&mockRegistryTool{
+ name: "greet",
+ desc: "says hello",
+ params: map[string]any{},
+ result: SilentResult("hello"),
+ })
+
+ result := r.Execute(context.Background(), "greet", nil)
+ if result.IsError {
+ t.Errorf("expected success, got error: %s", result.ForLLM)
+ }
+ if result.ForLLM != "hello" {
+ t.Errorf("expected ForLLM 'hello', got %q", result.ForLLM)
+ }
+}
+
+func TestToolRegistry_Execute_NotFound(t *testing.T) {
+ r := NewToolRegistry()
+ result := r.Execute(context.Background(), "missing", nil)
+ if !result.IsError {
+ t.Error("expected error for missing tool")
+ }
+ if !strings.Contains(result.ForLLM, "not found") {
+ t.Errorf("expected 'not found' in error, got %q", result.ForLLM)
+ }
+ if result.Err == nil {
+ t.Error("expected Err to be set via WithError")
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
+ r := NewToolRegistry()
+ ct := &mockCtxTool{
+ mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
+ }
+ r.Register(ct)
+
+ r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil)
+
+ if ct.channel != "telegram" {
+ t.Errorf("expected channel 'telegram', got %q", ct.channel)
+ }
+ if ct.chatID != "chat-42" {
+ t.Errorf("expected chatID 'chat-42', got %q", ct.chatID)
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) {
+ r := NewToolRegistry()
+ ct := &mockCtxTool{
+ mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
+ }
+ r.Register(ct)
+
+ r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil)
+
+ if ct.channel != "" || ct.chatID != "" {
+ t.Error("SetContext should not be called with empty channel/chatID")
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
+ r := NewToolRegistry()
+ at := &mockAsyncRegistryTool{
+ mockRegistryTool: *newMockTool("async_tool", "async work"),
+ }
+ at.result = AsyncResult("started")
+ r.Register(at)
+
+ called := false
+ cb := func(_ context.Context, _ *ToolResult) { called = true }
+
+ result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb)
+ if at.cb == nil {
+ t.Error("expected SetCallback to have been called")
+ }
+ if !result.Async {
+ t.Error("expected async result")
+ }
+
+ at.cb(context.Background(), SilentResult("done"))
+ if !called {
+ t.Error("expected callback to be invoked")
+ }
+}
+
+func TestToolRegistry_GetDefinitions(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("alpha", "tool A"))
+
+ defs := r.GetDefinitions()
+ if len(defs) != 1 {
+ t.Fatalf("expected 1 definition, got %d", len(defs))
+ }
+ if defs[0]["type"] != "function" {
+ t.Errorf("expected type 'function', got %v", defs[0]["type"])
+ }
+ fn, ok := defs[0]["function"].(map[string]any)
+ if !ok {
+ t.Fatal("expected 'function' key to be a map")
+ }
+ if fn["name"] != "alpha" {
+ t.Errorf("expected name 'alpha', got %v", fn["name"])
+ }
+ if fn["description"] != "tool A" {
+ t.Errorf("expected description 'tool A', got %v", fn["description"])
+ }
+}
+
+func TestToolRegistry_ToProviderDefs(t *testing.T) {
+ r := NewToolRegistry()
+ params := map[string]any{"type": "object", "properties": map[string]any{}}
+ r.Register(&mockRegistryTool{
+ name: "beta",
+ desc: "tool B",
+ params: params,
+ result: SilentResult("ok"),
+ })
+
+ defs := r.ToProviderDefs()
+ if len(defs) != 1 {
+ t.Fatalf("expected 1 provider def, got %d", len(defs))
+ }
+
+ want := providers.ToolDefinition{
+ Type: "function",
+ Function: providers.ToolFunctionDefinition{
+ Name: "beta",
+ Description: "tool B",
+ Parameters: params,
+ },
+ }
+ got := defs[0]
+ if got.Type != want.Type {
+ t.Errorf("Type: want %q, got %q", want.Type, got.Type)
+ }
+ if got.Function.Name != want.Function.Name {
+ t.Errorf("Name: want %q, got %q", want.Function.Name, got.Function.Name)
+ }
+ if got.Function.Description != want.Function.Description {
+ t.Errorf("Description: want %q, got %q", want.Function.Description, got.Function.Description)
+ }
+}
+
+func TestToolRegistry_List(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("x", ""))
+ r.Register(newMockTool("y", ""))
+
+ names := r.List()
+ if len(names) != 2 {
+ t.Fatalf("expected 2 names, got %d", len(names))
+ }
+
+ nameSet := map[string]bool{}
+ for _, n := range names {
+ nameSet[n] = true
+ }
+ if !nameSet["x"] || !nameSet["y"] {
+ t.Errorf("expected names {x, y}, got %v", names)
+ }
+}
+
+func TestToolRegistry_Count(t *testing.T) {
+ r := NewToolRegistry()
+ if r.Count() != 0 {
+ t.Errorf("expected 0, got %d", r.Count())
+ }
+
+ r.Register(newMockTool("a", ""))
+ r.Register(newMockTool("b", ""))
+ if r.Count() != 2 {
+ t.Errorf("expected 2, got %d", r.Count())
+ }
+
+ r.Register(newMockTool("a", "replaced"))
+ if r.Count() != 2 {
+ t.Errorf("expected 2 after overwrite, got %d", r.Count())
+ }
+}
+
+func TestToolRegistry_GetSummaries(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("read_file", "Reads a file"))
+
+ summaries := r.GetSummaries()
+ if len(summaries) != 1 {
+ t.Fatalf("expected 1 summary, got %d", len(summaries))
+ }
+ if !strings.Contains(summaries[0], "`read_file`") {
+ t.Errorf("expected backtick-quoted name in summary, got %q", summaries[0])
+ }
+ if !strings.Contains(summaries[0], "Reads a file") {
+ t.Errorf("expected description in summary, got %q", summaries[0])
+ }
+}
+
+func TestToolToSchema(t *testing.T) {
+ tool := newMockTool("demo", "demo tool")
+ schema := ToolToSchema(tool)
+
+ if schema["type"] != "function" {
+ t.Errorf("expected type 'function', got %v", schema["type"])
+ }
+ fn, ok := schema["function"].(map[string]any)
+ if !ok {
+ t.Fatal("expected 'function' to be a map")
+ }
+ if fn["name"] != "demo" {
+ t.Errorf("expected name 'demo', got %v", fn["name"])
+ }
+ if fn["description"] != "demo tool" {
+ t.Errorf("expected description 'demo tool', got %v", fn["description"])
+ }
+ if fn["parameters"] == nil {
+ t.Error("expected parameters to be set")
+ }
+}
+
+func TestToolRegistry_ConcurrentAccess(t *testing.T) {
+ r := NewToolRegistry()
+ var wg sync.WaitGroup
+
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ name := string(rune('A' + n%26))
+ r.Register(newMockTool(name, "concurrent"))
+ r.Get(name)
+ r.Count()
+ r.List()
+ r.GetDefinitions()
+ }(i)
+ }
+
+ wg.Wait()
+
+ if r.Count() == 0 {
+ t.Error("expected tools to be registered after concurrent access")
+ }
+}
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index a1ee0b6e1..ad1664b5b 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -76,11 +76,11 @@ func NewExecTool(workingDir string, restrict bool) *ExecTool {
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool {
denyPatterns := make([]*regexp.Regexp, 0)
- enableDenyPatterns := true
if config != nil {
execConfig := config.Tools.Exec
- enableDenyPatterns = execConfig.EnableDenyPatterns
+ enableDenyPatterns := execConfig.EnableDenyPatterns
if enableDenyPatterns {
+ denyPatterns = append(denyPatterns, defaultDenyPatterns...)
if len(execConfig.CustomDenyPatterns) > 0 {
fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns)
for _, pattern := range execConfig.CustomDenyPatterns {
@@ -91,8 +91,6 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
}
denyPatterns = append(denyPatterns, re)
}
- } else {
- denyPatterns = append(denyPatterns, defaultDenyPatterns...)
}
} else {
// If deny patterns are disabled, we won't add any patterns, allowing all commands.
diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go
index 73d385cb0..8b166b41f 100644
--- a/pkg/tools/spawn.go
+++ b/pkg/tools/spawn.go
@@ -3,6 +3,7 @@ package tools
import (
"context"
"fmt"
+ "strings"
)
type SpawnTool struct {
@@ -66,8 +67,8 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
task, ok := args["task"].(string)
- if !ok {
- return ErrorResult("task is required")
+ if !ok || strings.TrimSpace(task) == "" {
+ return ErrorResult("task is required and must be a non-empty string")
}
label, _ := args["label"].(string)
diff --git a/pkg/tools/spawn_test.go b/pkg/tools/spawn_test.go
new file mode 100644
index 000000000..0646c82a9
--- /dev/null
+++ b/pkg/tools/spawn_test.go
@@ -0,0 +1,79 @@
+package tools
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
+ provider := &MockLLMProvider{}
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
+ tool := NewSpawnTool(manager)
+
+ ctx := context.Background()
+
+ tests := []struct {
+ name string
+ args map[string]any
+ }{
+ {"empty string", map[string]any{"task": ""}},
+ {"whitespace only", map[string]any{"task": " "}},
+ {"tabs and newlines", map[string]any{"task": "\t\n "}},
+ {"missing task key", map[string]any{"label": "test"}},
+ {"wrong type", map[string]any{"task": 123}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tool.Execute(ctx, tt.args)
+ if result == nil {
+ t.Fatal("Result should not be nil")
+ }
+ if !result.IsError {
+ t.Error("Expected error for invalid task parameter")
+ }
+ if !strings.Contains(result.ForLLM, "task is required") {
+ t.Errorf("Error message should mention 'task is required', got: %s", result.ForLLM)
+ }
+ })
+ }
+}
+
+func TestSpawnTool_Execute_ValidTask(t *testing.T) {
+ provider := &MockLLMProvider{}
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
+ tool := NewSpawnTool(manager)
+
+ ctx := context.Background()
+ args := map[string]any{
+ "task": "Write a haiku about coding",
+ "label": "haiku-task",
+ }
+
+ result := tool.Execute(ctx, args)
+ if result == nil {
+ t.Fatal("Result should not be nil")
+ }
+ if result.IsError {
+ t.Errorf("Expected success for valid task, got error: %s", result.ForLLM)
+ }
+ if !result.Async {
+ t.Error("SpawnTool should return async result")
+ }
+}
+
+func TestSpawnTool_Execute_NilManager(t *testing.T) {
+ tool := NewSpawnTool(nil)
+
+ ctx := context.Background()
+ args := map[string]any{"task": "test task"}
+
+ result := tool.Execute(ctx, args)
+ if !result.IsError {
+ t.Error("Expected error for nil manager")
+ }
+ if !strings.Contains(result.ForLLM, "Subagent manager not configured") {
+ t.Errorf("Error message should mention manager not configured, got: %s", result.ForLLM)
+ }
+}
diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go
index d6a88a5b0..0ca17e84f 100644
--- a/pkg/tools/spi.go
+++ b/pkg/tools/spi.go
@@ -119,7 +119,11 @@ func (t *SPITool) list() *ToolResult {
return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result)))
}
+// Helper function for SPI operations (used by platform-specific implementations)
+
// parseSPIArgs extracts and validates common SPI parameters
+//
+//nolint:unused // Used by spi_linux.go
func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) {
dev, ok := args["device"].(string)
if !ok || dev == "" {
diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go
index 91ebff636..ad371a649 100644
--- a/pkg/tools/subagent.go
+++ b/pkg/tools/subagent.go
@@ -132,12 +132,12 @@ After completing the task, provide a clear summary of what was done.`
},
}
- // Check if context is already cancelled before starting
+ // Check if context is already canceled before starting
select {
case <-ctx.Done():
sm.mu.Lock()
- task.Status = "cancelled"
- task.Result = "Task cancelled before execution"
+ task.Status = "canceled"
+ task.Result = "Task canceled before execution"
sm.mu.Unlock()
return
default:
@@ -185,10 +185,10 @@ After completing the task, provide a clear summary of what was done.`
if err != nil {
task.Status = "failed"
task.Result = fmt.Sprintf("Error: %v", err)
- // Check if it was cancelled
+ // Check if it was canceled
if ctx.Err() != nil {
- task.Status = "cancelled"
- task.Result = "Task cancelled during execution"
+ task.Status = "canceled"
+ task.Result = "Task canceled during execution"
}
result = &ToolResult{
ForLLM: task.Result,
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index 301e00daf..8ba2a723a 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -1,6 +1,7 @@
package tools
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
@@ -16,12 +17,63 @@ const (
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
+// Pre-compiled regexes for HTML text extraction
+var (
+ reScript = regexp.MustCompile(``)
- result := re.ReplaceAllLiteralString(htmlContent, "")
- re = regexp.MustCompile(`