Compare commits

..

200 Commits

Author SHA1 Message Date
Guoguo 64282c3764 ci(release): split tag creation and release into separate workflows
- Add `create-tag.yml`: creates annotated tag at a specified commit or
  latest main HEAD, with duplicate tag and commit validation
- Simplify `release.yml`: only accepts existing tags, removes create_tag
  toggle, validates tag via GitHub API before checkout
- Always checkout main branch (fetch-depth: 0 fetches full history),
  then create tag at the specified commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 19:01:47 -07:00
wenjie 71c877a67f refactor(web): switch dashboard auth from tokens to passwords (#2608)
- replace token-based launcher auth with password-based login and sessions
- migrate legacy launcher_token values into bcrypt-backed password storage
- add one-shot local auto-login bootstrap
- update config UI, i18n strings, docs, and auth-related tests
2026-04-21 18:04:15 +08:00
肆月 a5379d5fff feat(feishu): Add group chat trigger and random emoji response frontend configuration (#2607)
- 添加 group_trigger.mention_only 开关配置(群聊仅提及时响应)
- 添加 random_reaction_emoji 数组配置(自定义表情回应列表)
- 更新中英文国际化翻译
2026-04-21 18:01:16 +08:00
Guoguo 6ca7311273 feat(agent): add context usage ring indicator and /context command (#2537)
Add a context window usage indicator to the web chat UI and a /context
slash command that works across all channels.

Backend:
- Add computeContextUsage() estimating history + system + tool tokens
- Attach ContextUsage to outbound messages via the pico WebSocket protocol
- Add /context command showing context stats as formatted text
- Add EstimateSystemTokens() on ContextBuilder for system prompt estimation

Frontend:
- Add ContextUsageRing component (SVG ring + hover/tap popover)
- Show usage percentage, token counts, and compression threshold
- Hover on desktop (150ms leave delay), tap on mobile
- "View Details" sends /context with 1s cooldown
- i18n support (en/zh) for popover labels

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 16:30:02 +08:00
LC 9c3dc0ee3a fix(auth): canonicalize Google Antigravity provider and enhance credential management (#2599)
* fix(auth): canonicalize Google Antigravity provider and enhance credential management

* fix(auth): improve error handling in credential storage tests

* fix(auth): stabilize canonical provider merge precedence
2026-04-21 16:28:29 +08:00
LC b798fa4b7b docs: update documentation for Gemini native protocol (#2601)
* docs: update documentation for Gemini native protocol

* docs: fix capitalization and grammar of Gemini
2026-04-21 16:15:09 +08:00
wenjie ba6992234f feat(web): support list editing for channel array fields (#2595)
Add reusable channel array list controls and parsing utilities for channel forms.
Normalize channel string-array payloads in the backend, including pasted values,
numeric IDs, hidden characters, duplicates, and empty clears.
Also allow FlexibleStringSlice to unmarshal null values and cover the new behavior
with backend and config tests.
2026-04-21 16:04:28 +08:00
wenjie dcb4b67e00 fix(web): clean up restored chat transcripts and optimize chat UI (#2605)
Filter raw tool messages from session history and avoid duplicate summaries for visible message-tool output. Preserve final assistant replies after tool delivery and add coverage for visible transcript counts.

Also refine the chat UI with collapsible reasoning blocks, send shortcut hints, command-style user messages, stable scroll gutters, and updated i18n strings.
2026-04-21 11:52:58 +08:00
sky5454 329e68e017 refactor(agent): Agent Looper refactor phase2, restructure pipeline and rename loop files to agent (#2585)
* refactor(agent): introduce interfaces for MessageBus and ChannelManager

Phase 2 of loop.go refactor — dependency inversion using adapter pattern.

- Add interfaces.MessageBus and interfaces.ChannelManager interfaces
- Create adapters/messagebus.go wrapping *bus.MessageBus
- Create adapters/channelmanager.go wrapping *channels.Manager
- Update AgentLoop to use interfaces instead of concrete types
- Update registerSharedTools to accept interfaces.MessageBus

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(agent): restructure pipeline and rename loop files

Pipeline refactoring:
- Split pipeline.go (1400 lines) into focused files:
  - pipeline_setup.go (~115 lines): SetupTurn method
  - pipeline_llm.go (~519 lines): CallLLM method
  - pipeline_execute.go (~693 lines): ExecuteTools method
  - pipeline_finalize.go (~78 lines): Finalize method
- Pipeline struct and NewPipeline remain in pipeline.go (~39 lines)

Agent file renaming:
- Rename loop_*.go to agent_*.go for consistent naming:
  - loop.go -> agent.go, loop_message.go -> agent_message.go, etc.
- Merge turn.go + turn_exec.go into turn_state.go
- Rename loop_turn.go -> turn_coord.go

Documentation:
- Update docs/pipeline-restructuring-plan.md
- Add docs/agent-rename-plan.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(agent): code format  fixed

* refactor(agent): code test file added/renamed

* docs(agent): update agent refactor docs

* fix(agent): fix agent hardAbortX

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 10:55:50 +08:00
Guoguo 4e2f80b79a docs: update wechat qrcode (#2604) 2026-04-21 09:37:31 +08:00
lxowalle 6421f146a9 Revert "Feat/channel tool feedback animation (#2569)" (#2596)
This reverts commit e556a816e4.
2026-04-20 18:30:29 +08:00
lxowalle e556a816e4 Feat/channel tool feedback animation (#2569)
* feat(channels): unify tool feedback animation across discord telegram and feishu

* fix(tool-feedback): unify fallback and single-message delivery

* fix(channels): finalize tool feedback in place

* fix ci

* feat: improve tool feedback
2026-04-20 15:20:26 +08:00
wenjie 8461c996e5 chore(web): update linting and router dependencies (#2592)
Bump TanStack Router, ESLint, React Hooks plugin, TypeScript ESLint, and Prettier packages. Disable the react-hooks/set-state-in-effect rule in the frontend ESLint config.
2026-04-20 11:18:42 +08:00
Guoguo 74c98a5acf refactor(web): secure Pico websocket access behind launcher auth (#2545)
* refactor(web): secure Pico websocket access behind launcher auth

- stop exposing the raw Pico token to the frontend
- add /api/pico/info for non-secret Pico connection metadata
- proxy /pico/ws through the launcher with same-origin and dashboard auth checks
- inject the upstream Pico websocket protocol server-side
- update frontend chat connection flow and Vite websocket proxy path
- refresh related docs and tests

* fix(web): improve Pico URL and origin handling behind proxies

- read client scheme from X-Forwarded-Proto and RFC 7239 Forwarded
- derive client-visible ports from forwarded host information
- add coverage for HTTPS origins without explicit ports
- verify behavior when proxies omit forwarded protocol headers

* fix(web): stop pinning Pico WebSocket origins during setup

- remove request-origin seeding from `EnsurePicoChannel`
- keep `allow_origins` empty by default for auto-configured Pico channels
- relax launcher Pico WebSocket proxy origin validation
- update Pico backend tests for the new setup and proxy behavior
2026-04-20 11:17:42 +08:00
wenjie f8190f04b7 fix(web): stop pinning Pico WebSocket origins during setup
- remove request-origin seeding from `EnsurePicoChannel`
- keep `allow_origins` empty by default for auto-configured Pico channels
- relax launcher Pico WebSocket proxy origin validation
- update Pico backend tests for the new setup and proxy behavior
2026-04-20 10:11:03 +08:00
wenjie d002e1517b fix(web): improve Pico URL and origin handling behind proxies
- read client scheme from X-Forwarded-Proto and RFC 7239 Forwarded
- derive client-visible ports from forwarded host information
- add coverage for HTTPS origins without explicit ports
- verify behavior when proxies omit forwarded protocol headers
2026-04-20 10:11:03 +08:00
wenjie 4b76196e2c refactor(web): secure Pico websocket access behind launcher auth
- stop exposing the raw Pico token to the frontend
- add /api/pico/info for non-secret Pico connection metadata
- proxy /pico/ws through the launcher with same-origin and dashboard auth checks
- inject the upstream Pico websocket protocol server-side
- update frontend chat connection flow and Vite websocket proxy path
- refresh related docs and tests
2026-04-20 10:11:03 +08:00
美電球 6126ede963 Merge pull request #2566 from lc6464/refactor/providers-tools-layout
refactor(providers,tools): reorganize packages and facades
2026-04-17 22:51:49 +08:00
美電球 9fe678247f docs: add session and routing documentation (#2571) 2026-04-17 21:25:18 +08:00
Guoguo 15a3560533 docs: reorganize docs by type and add layout guidance (#2567)
* refactor(docs): reorganize docs by type and locale

* chore(docs): add docs layout lint target and contributor guidance

Introduce a lint-docs script and Makefile target for common
documentation naming and placement checks. Expand docs/README.md
with layout and translation conventions, and update CONTRIBUTING.md
to point contributors to the new docs guidance and validation step.

* docs: add section index pages and fix localized doc links

- add reader navigation to docs/README.md
- add index pages for guides, reference, operations, security, architecture, and migration
- update localized project README links to prefer existing translated docs

* docs: fix broken wecom link in Malay README
2026-04-17 18:51:07 +08:00
wenjie 2708c834d0 build(deps): patch gomarkdown and upgrade shadcn (#2568) 2026-04-17 15:40:23 +08:00
lc6464 743cd3602b fix(tools): centralize shared LLM note constants 2026-04-17 14:31:43 +08:00
lc6464 9b4efddd9b fix(providers,tools): address linter issues after reorg 2026-04-17 14:16:18 +08:00
wenjie 16d174e124 docs: fix broken wecom link in Malay README 2026-04-17 14:05:57 +08:00
wenjie 610f68adcf docs: add section index pages and fix localized doc links
- add reader navigation to docs/README.md
- add index pages for guides, reference, operations, security, architecture, and migration
- update localized project README links to prefer existing translated docs
2026-04-17 14:00:45 +08:00
wenjie de3d042d1b chore(docs): add docs layout lint target and contributor guidance
Introduce a lint-docs script and Makefile target for common
documentation naming and placement checks. Expand docs/README.md
with layout and translation conventions, and update CONTRIBUTING.md
to point contributors to the new docs guidance and validation step.
2026-04-17 14:00:45 +08:00
wenjie 4e1ceee62e refactor(docs): reorganize docs by type and locale 2026-04-17 14:00:45 +08:00
lc6464 4c133dc2d9 refactor(tools): reorganize tool packages and facades 2026-04-17 13:44:31 +08:00
daming大铭 0da962c4b4 Merge pull request #2564 from sky5454/looper_refactor
Looper refactor: Split the monolithic 4384-line `loop.go` into 12 focused source files as Phase 1 of the agent refactor.
2026-04-17 13:18:11 +08:00
lc6464 ee634dc8db refactor(providers): reorganize provider packages and facades 2026-04-17 12:42:03 +08:00
sky5454 b0d3f19a6a docs(agent-refactor): document loop.go file split
Add loop-split.md explaining the 12-file split of the original
4384-line loop.go, covering the file map, extraction method,
and future phase 2 plans.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:22:37 +08:00
sky5454 12d5421c26 refactor(agent): split loop.go into focused sub-packages
Break up the monolithic 4384-line loop.go into 12 focused files:
- loop.go: core AgentLoop struct and main Run loop
- loop_turn.go: turn execution logic (runTurn, askSideQuestion, etc.)
- loop_utils.go: pure utility functions (formatters, helpers)
- loop_init.go: constructor and tool registration
- loop_message.go: message handling (processMessage, routing)
- loop_command.go: command processing (/use, /btw, etc.)
- loop_mcp.go: MCP runtime management
- loop_event.go: event/hook system helpers
- loop_media.go: media resolution and artifact handling
- loop_outbound.go: response publishing
- loop_transcribe.go: audio transcription
- loop_steering.go: steering queue and continuation
- loop_inject.go: setter injection methods

No functional changes - pure code movement with updated imports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:17:12 +08:00
dependabot[bot] 72f30c58e9 build(deps-dev): bump @types/node from 25.5.0 to 25.6.0 in /web/frontend (#2562)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.5.0 to 25.6.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 10:30:11 +08:00
dependabot[bot] 235cb11beb build(deps-dev): bump globals from 17.4.0 to 17.5.0 in /web/frontend (#2561)
Bumps [globals](https://github.com/sindresorhus/globals) from 17.4.0 to 17.5.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.4.0...v17.5.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 10:24:18 +08:00
dependabot[bot] 74856d3747 build(deps): bump @tanstack/react-query in /web/frontend (#2560)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.97.0 to 5.99.0.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.99.0/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.99.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 10:23:07 +08:00
dependabot[bot] c36a48cf4b build(deps): bump react-i18next from 17.0.2 to 17.0.3 in /web/frontend (#2559)
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.0.2 to 17.0.3.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.2...v17.0.3)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 17.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 10:20:32 +08:00
dependabot[bot] e77c4eba3e build(deps): bump maunium.net/go/mautrix from 0.26.4 to 0.27.0 (#2557)
Bumps [maunium.net/go/mautrix](https://github.com/mautrix/go) from 0.26.4 to 0.27.0.
- [Release notes](https://github.com/mautrix/go/releases)
- [Changelog](https://github.com/mautrix/go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mautrix/go/compare/v0.26.4...v0.27.0)

---
updated-dependencies:
- dependency-name: maunium.net/go/mautrix
  dependency-version: 0.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 10:14:08 +08:00
dependabot[bot] d73897da8e build(deps): bump @tanstack/react-router in /web/frontend (#2555)
Bumps [@tanstack/react-router](https://github.com/TanStack/router/tree/HEAD/packages/react-router) from 1.168.8 to 1.168.22.
- [Release notes](https://github.com/TanStack/router/releases)
- [Changelog](https://github.com/TanStack/router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/TanStack/router/commits/@tanstack/react-router@1.168.22/packages/react-router)

---
updated-dependencies:
- dependency-name: "@tanstack/react-router"
  dependency-version: 1.168.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 10:11:47 +08:00
dependabot[bot] 9c97442f7c build(deps): bump go.mau.fi/util from 0.9.7 to 0.9.8 (#2553)
Bumps [go.mau.fi/util](https://github.com/mautrix/go-util) from 0.9.7 to 0.9.8.
- [Release notes](https://github.com/mautrix/go-util/releases)
- [Changelog](https://github.com/mautrix/go-util/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mautrix/go-util/compare/v0.9.7...v0.9.8)

---
updated-dependencies:
- dependency-name: go.mau.fi/util
  dependency-version: 0.9.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 10:09:16 +08:00
dependabot[bot] 6375440152 build(deps): bump pnpm/action-setup from 4 to 6 (#2552)
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 4 to 6.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v4...v6)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 10:07:10 +08:00
daming大铭 928a27359f Merge pull request #2549 from lc6464/gateway-auth-no-browser
feat(auth): add no-browser option for OAuth login
2026-04-17 09:54:03 +08:00
daming大铭 ba08d52351 Merge pull request #2474 from srcrs/fix-cron-independent-sessions
fix(cron): make each job execution use an independent session
2026-04-16 23:52:29 +08:00
daming大铭 b1475122da Merge pull request #2547 from lc6464/chore/issue-2538-network-fallback
feat(network): improve network error classification and fallback handling
2026-04-16 23:17:20 +08:00
lc6464 ffd30d7db7 fix(auth): improve no-browser OAuth login 2026-04-16 23:01:28 +08:00
daming大铭 eb24269651 Merge pull request #2503 from cytown/loop
refactor: make agent loop support parallel and update docs
2026-04-16 22:47:34 +08:00
lc6464 2b844778ff refactor(tests): extract common logic for fallback error handling into a helper function 2026-04-16 22:45:31 +08:00
lc6464 ab019d3f18 feat(auth): add no-browser option for OAuth login 2026-04-16 22:19:34 +08:00
LC 7aa2d672ce fix(network): classify timeout errors as FailoverTimeout
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-16 22:00:13 +08:00
lc6464 c3f4000817 feat(network): implement network error classification and fallback handling 2026-04-16 19:59:37 +08:00
wenjie 7fdc9c7b64 fix(web): support proxies in SearXNG and web fetch (#2542)
Propagate the configured HTTP client and proxy settings to the
SearXNG search provider.

Allow web_fetch to connect to the configured proxy as the first hop
without bypassing the existing private-host checks for redirect
targets and fetched URLs.

Add tests for loopback proxy fetches and SearXNG proxy propagation.
2026-04-16 17:15:47 +08:00
wenjie 7f56ca8cc6 feat(web): refactor tools page into tabbed library and web search settings (#2539)
- split the tools page into focused components and a shared hook
- add separate Tool Library and Web Search tabs
- refresh web search settings layout and localized copy
- make provider expansion keyboard accessible
- restore wrapping for long tool names in library cards
- allow custom styling for KeyInput
2026-04-16 17:14:35 +08:00
Cytown f5e779e22e refactor: make agent loop support parallel and update docs 2026-04-16 14:43:15 +08:00
lxowalle e22b4e1eee feat(agent): support btw side questions (#2532) 2026-04-16 10:53:09 +08:00
wenjie a8d0b03515 fix(web): save channel configs with nested channel_list patches (#2530)
Persist channel settings through the current channel_list schema, keeping common
channel fields at the top level and channel-specific fields under settings.
Return common fields and default config shapes from channel config endpoints, and
add coverage for nested patches, missing channel defaults, and secret handling.
2026-04-16 10:30:16 +08:00
wenjie f32b303d2a fix(web): avoid resetting web search draft on config refetch (#2536) 2026-04-16 10:26:18 +08:00
BeaconCat f1b659e5ef membench: add LLM-as-Judge evaluation mode (#2484)
* membench: add LLM-as-Judge evaluation mode

Add --eval-mode=llm to membench for LLM-based answer generation and
semantic scoring via an OpenAI-compatible API endpoint.

New files:
- llm_client.go: generic OpenAI-compatible chat completion client
  with support for API key, configurable timeout, and optional
  chat_template_kwargs (for llama.cpp thinking models)
- eval_llm.go: LLM answer generation + LLM-as-Judge scoring for
  both legacy and seahorse retrieval modes

Changes to main.go:
- --eval-mode flag (token|llm) to select evaluation strategy
- --api-base, --api-key, --model flags with env var fallback
  (MEMBENCH_API_BASE, MEMBENCH_API_KEY, MEMBENCH_MODEL)
- --no-thinking flag for llama.cpp + Qwen thinking models
- --limit flag to cap QA questions per sample for quick testing

* style: fix golangci-lint formatting (gofmt + golines)

* fix: address Copilot review feedback

- Validate --model is required for LLM eval mode
- Use rune-based truncation to preserve valid UTF-8
- Precompute totalQA count outside inner loop
- Log SearchMessages errors instead of silently skipping

* fix: address Copilot review round 2

- Validate --eval-mode accepts only 'token' or 'llm'
- Normalize base URL to avoid /v1/v1 duplication
- Separate token/LLM results for correct PrintComparison labeling
- Log ExpandMessages errors instead of silently ignoring
- Short-circuit with 0 scores when no context retrieved (match token eval)
- Add --timeout flag wired to LLMClientOptions.Timeout

* fix: address review P1+P2 — sort alignment, failure sentinel, score parser

- P1: Replace hand-rolled sortByRank with sort.Slice (ascending, best
  first) matching eval.go's EvalSeahorse — ensures BudgetTruncate keeps
  best-ranked messages when truncation occurs
- P2: Use -1.0 sentinel for LLM API failures and parse errors, distinct
  from genuine 0.0 score; aggregateMetrics skips -1.0 entries for F1
  averaging while still counting HitRate
- P2: Use regexp \b([1-5])\b for judge score extraction instead of
  first-digit scan — avoids misparses on '5/5', 'Score: 3' etc.

* fix: address Copilot review round 2

- Fix F1/HitRate weighted aggregation: track ValidF1Count separately so
  computeModeAgg weights F1 by valid scores only, not TotalQuestions
- No-context retrieval failure uses 0.0 (genuine bad score) instead of
  -1.0 sentinel (reserved for API/parse failures)
- Validate --timeout > 0 to prevent disabling HTTP timeouts

* fix: remove hardcoded /v1 from API base URL

Users now provide the full versioned path in --api-base (e.g. /v1, /v4).
Code only appends /chat/completions. Default changed to
http://127.0.0.1:8080/v1 for backward compatibility.

* fix: address Copilot review round 3

- ValidF1Count=0 when all scores are sentinel (no forced =1)
- Backward compat: old eval JSON without ValidF1Count falls back to
  TotalQuestions in computeModeAgg
- Skip empty section in PrintComparison when tokenResults is empty
- Update --api-base flag help to document /v1 default and version path
- Add sentinel aggregation unit tests (partial, all, weighted)

* feat: add --retries flag with exponential backoff for transient LLM errors

Retry on timeout, 5xx, and 429 (rate limit) with 1s/2s/4s backoff.
Default 3 retries, configurable via --retries. Context cancellation
is respected between retries.

* fix: address Copilot review round 4

- runReport splits results by mode suffix into token/llm for PrintComparison
- backward compat fallback (ValidF1Count=0 -> TotalQuestions) only for
  non-LLM modes; LLM modes keep ValidF1Count=0 when all scores sentinel
- MaxRetries==0 means no retry; only negative falls back to default 3
- truncateStr uses []rune to avoid cutting multi-byte UTF-8 characters
- Complete() returns error on empty LLM response (vs silent empty string)

* feat: --no-thinking adapts to llama.cpp, Ollama, and GLM backends

Send all three disable-thinking fields simultaneously:
- chat_template_kwargs.enable_thinking=false (llama.cpp, GLM)
- think=false (Ollama 0.9+)
- thinking.type=disabled (GLM/Zhipu)
Each backend picks the field it recognizes and ignores the rest.
Also bumps max_tokens from 512 to 2048 for thinking models.

* feat: mixed model eval + concurrent QA workers

- Add --judge-model, --judge-api-base, --judge-api-key flags for separate judge model
- Add --concurrency flag (default 1) with semaphore-based goroutine pool
- Add reasoning_content fallback for GLM/DeepSeek style responses
- Prepend /no_think to system prompt for Ollama /v1 compatibility
- Reduce default MaxTokens from 2048 to 512 (answers are 1-3 sentences)
- Extract evalQAWorker and buildSeahorseContext for shared concurrent logic

---------

Co-authored-by: BeaconCat <BeaconCat@users.noreply.github.com>
2026-04-15 21:15:17 +08:00
美電球 ead2dc9699 Merge pull request #2524 from SiYue-ZO/feature/sogou-web-search-default
Add configurable Sogou-backed web search
2026-04-15 20:50:53 +08:00
wenjie 7bd11181a6 fix(agent): preserve reused tool call IDs across turns (#2528)
Scope tool result deduplication to each assistant tool-call block so providers
that reuse call IDs across separate turns do not lose valid tool results. Also
drop invalid empty tool call IDs and orphaned tool messages after validation.
2026-04-15 20:18:09 +08:00
daming大铭 100e576609 Merge pull request #2529 from lc6464/feat/web-code-highlight
feat(web): add markdown syntax highlighting for chat and skills
2026-04-15 18:50:40 +08:00
SiYue-ZO 2784223ad5 Make web search auto-switch with UI language
Default the sample web search provider to auto, route Sogou vs DuckDuckGo dynamically based on query/UI language, and sync frontend language changes back to the backend so Current Service and runtime selection stay aligned.
2026-04-15 18:45:28 +08:00
lc6464 5a2e7795cd refactor(web): improve theme style element management in useHighlightTheme hook 2026-04-15 18:30:43 +08:00
lc6464 acbe654674 chore(web): move app providers out of main entry 2026-04-15 17:36:22 +08:00
lc6464 389f492d8c refactor(web): use official highlight themes for markdown 2026-04-15 17:19:48 +08:00
lc6464 25ac563406 feat(web): add syntax highlighting for markdown code blocks 2026-04-15 14:54:13 +08:00
Mauro bb14a5c7cc Merge pull request #2525 from afjcjsbx/fix/vision-unsupported-media-stuck
fix(agent): recover after image-input-unsupported failures
2026-04-15 07:54:33 +02:00
SiYue-ZO bb953b788b test(api): fix web tools lint issues 2026-04-15 13:35:39 +08:00
SiYue-ZO 75e93b5189 Merge remote-tracking branch 'upstream/main' into feature/sogou-web-search-default
# Conflicts:
#	pkg/tools/web.go
#	pkg/tools/web_test.go
2026-04-15 13:28:05 +08:00
SiYue-ZO 0b84f0ae0a fix(web): address sogou search review feedback 2026-04-15 13:03:06 +08:00
Cytown d0ff24aa87 remove useless backend output for platform-token (#2500) 2026-04-15 11:38:47 +08:00
wenjie 51ab3b1385 fix(web): restore chat composer disabled-state messaging and clean up code (#2526) 2026-04-15 11:24:27 +08:00
lxowalle 773a94c414 fix(web_search): validate missing API key/URL directly in Search methods (#2517) 2026-04-15 09:55:05 +08:00
肆月 bf6d4fd997 feat(web): show disabled reasons in tooltips when buttons are disabled (#2430)
* feat(web): show disabled reasons in tooltips when buttons are disabled

- Add disabled reason tooltips for model card actions (set default, delete)
- Add disabled reason tooltips for marketplace skill card install button
- Add disabled reason display for chat input when disabled
- Add internationalization support for all disabled reasons (en/zh)
- Model card: Show specific reasons when set-default or delete buttons are disabled
- Marketplace skill card: Show specific reasons when install button is disabled
- Chat composer: Show reason text below input when input is disabled

* fix: show disabled action reasons via tooltips

* fix(web): restore accessible labels for model action tooltips
2026-04-15 09:49:45 +08:00
afjcjsbx e60a687387 fix lint 2026-04-14 22:35:02 +02:00
afjcjsbx 7824bc715f add test 2026-04-14 22:31:30 +02:00
afjcjsbx d3d639cb7d fix lint 2026-04-14 22:21:33 +02:00
afjcjsbx 1245f2ddf6 fix(agent): recover after image-input-unsupported failures 2026-04-14 22:15:28 +02:00
srcrs d8e7a6129f fix(cron): add blank line between default and localmodule imports for gci
gci linter requires a blank line separating import sections (default vs
localmodule). Missing separator caused CI failure.
2026-04-15 02:07:35 +08:00
美電球 c0fadc5918 Merge pull request #2523 from lc6464/feat/web-chat-disabled-reasons-hint
feat(web): show disabled chat reasons in composer
2026-04-15 00:42:55 +08:00
美電球 b52eb58f03 Merge pull request #2514 from lc6464/fix/issue-2488-host-binding
feat(launcher): add host overrides for launcher and gateway
2026-04-14 23:48:24 +08:00
lc6464 0bb9bedc44 fix(web): address latest Copilot review points 2026-04-14 23:39:59 +08:00
SiYue-ZO dcf21ef11c Fix provider return formatting for golines 2026-04-14 23:26:40 +08:00
lc6464 79f87d151e fix(web): show localhost entry only for local binds 2026-04-14 23:24:14 +08:00
SiYue-ZO 824e800d70 Fix Sogou user agent formatting for linter 2026-04-14 23:22:37 +08:00
SiYue-ZO 9ded7933f0 Fix golines formatting for web search changes 2026-04-14 23:16:23 +08:00
SiYue-ZO 93977bf348 Add configurable Sogou-backed web search 2026-04-14 22:58:07 +08:00
lc6464 d4313b5e5f feat(web): show disabled chat reasons in composer 2026-04-14 22:22:30 +08:00
Caize Wu 08fc305d5e Merge pull request #2518 from imguoguo/update-wechat-qr
docs: update wechat qrcode
2026-04-14 17:34:06 +08:00
Guoguo 8ca89c49ab docs: update wechat qrcode 2026-04-14 02:30:26 -07:00
lc6464 24382271d6 fix(web): align wildcard advertise IP preference 2026-04-14 15:17:27 +08:00
lxowalle 0425cd4d77 refactor skills registries and add GitHub-backed skill discovery (#2442)
* refactor skills registries and add GitHub-backed skill discovery

* fix ci

* fix command error

* fix default skills install registry behavior

* fix github registry URL parsing and versioned skill links

* fix skills registry config compatibility and URL installs

* * fix lint

* fix deprecated github base url compatibility

* fix skills registry yaml and github default branch handling

* fix github skills registry fallback and install metadata

* fix cli skills install origin metadata

* fix clawhub registry env compatibility

* fix skills registry config merge compatibility

* fix skill install metadata consistency and onboard template copy

* fix yaml overrides for default skills registries

* fix install_skill registry metadata normalization

* fix github skill URL parsing for slash branch names

* fix skills registry install/search validation and github URLs

* fix github skill URL host validation

* fix install_skill validation for invalid registry archives

* fix redundant skills registry names in saved config

* fix github blob skill URL installs and metadata links

* fix github registry URL scheme validation

* fix v0 skills migration preserving github registry defaults

* fix github blob skill install directory resolution

* fix install_skill rollback on origin metadata write failure

* fix github skill URL validation and registry JSON merging

* fix github registry target resolution and metadata links

* fix install_skill force reinstall rollback

* fix skills config compatibility and legacy security overlays

* fix ci
2026-04-14 15:14:16 +08:00
lc6464 ae195831bb fix: resolve PR2514 lint regressions 2026-04-14 14:49:23 +08:00
lc6464 93bf871bd2 fix(launcher): refine console host display 2026-04-14 14:04:37 +08:00
lc6464 d4d652b455 feat(host): complete launcher and gateway multi-host binding support
- add shared netbind planning for strict tcp4/tcp6 bind semantics
- support launcher/gateway host env overrides and launcher-to-gateway forwarding
- cover host binding and forwarding with network and subprocess env tests
2026-04-14 14:04:36 +08:00
lc6464 7b38d437ba feat(launcher): support multi-host bind and strict host semantics 2026-04-14 14:03:24 +08:00
lc6464 e7b3654313 fix(host): modernize default host selection order 2026-04-14 14:03:23 +08:00
lc6464 448027c02a fix(host): align launcher and gateway host normalization semantics 2026-04-14 14:03:22 +08:00
lc6464 4e977367c2 feat(launcher): add host overrides for launcher and gateway 2026-04-14 14:00:54 +08:00
daming大铭 df9124b824 Merge pull request #2249 from alexhoshina/refactor-inbound-context-routing-session
Refactor inbound context routing session
2026-04-14 12:45:34 +08:00
美電球 08283dde61 Merge pull request #2489 from afjcjsbx/fix/mcp-reload-discovery-tools
fix(agent): reinitialize MCP and discovery tools after reload
2026-04-14 11:54:47 +08:00
wenjie f82fe5a2ec ci: use pnpm/action-setup and sync README install steps (#2512)
* ci(workflows): use pnpm/action-setup in build and release pipelines

Replace the corepack-based pnpm setup with pnpm/action-setup
and pin pnpm to v10.33.0 in the create_dmg, nightly, and
release GitHub Actions workflows.

* docs(readme): update pnpm setup instructions across translated READMEs
2026-04-14 10:44:47 +08:00
wenjie 64c3542b91 fix(updater): retry release fetches (#2511) 2026-04-14 10:44:21 +08:00
Hoshina 93f69a98ba merge: integrate main channel config changes 2026-04-14 00:34:17 +08:00
美電球 04e99a1264 Merge pull request #2508 from cytown/channel2
fix some bugs:
2026-04-14 00:19:15 +08:00
Cytown f16bade919 fix some bugs:
Fix hiddenValues in manager_channel.go — use comma-ok type assertions to avoid panics                               │
  Add GetDecoded() error handling in weixin.go saveWeixinConfig for consistency with wecom.go                         │
  Fix stray quotes in docs/configuration.md JSON examples                                                             │
  Add V2→V3 migration section to docs/config-versioning.md
  Fix feishu init with 32bit wrong signature cause build fail
2026-04-14 00:15:35 +08:00
daming大铭 cbd38dfd28 Merge pull request #2481 from cytown/channel
refactor(config):  make config.Channel to multiple instance support
2026-04-13 23:41:32 +08:00
美電球 aa1d7c55be Merge pull request #2507 from cytown/allow
bug fix for allowFrom contains empty string
2026-04-13 23:39:54 +08:00
Cytown 036f65b179 bug fix for allowFrom contains empty string 2026-04-13 23:34:44 +08:00
Hoshina 69ff6909e1 merge: integrate main seahorse context changes 2026-04-13 23:02:38 +08:00
Hoshina c5c5ea22d6 fix(session): address review regressions 2026-04-13 22:51:44 +08:00
daming大铭 7db2e7d579 Merge pull request #2495 from liuy/feat/seahorse-clear
feat(agent): /clear clears seahorse DB
2026-04-13 22:28:33 +08:00
Cytown 667fc85d54 refactor(config): make config.Channel to multiple instance support
add new field type to Channel struct
config.channels refactor to channel_list
update config version to 3
update the docs
2026-04-13 22:21:21 +08:00
taonyx 2e149f44dd Merge pull request #2497 from wj-xiao/build/split-core-builds
build(release): move Android bundle publishing into GoReleaser
2026-04-13 14:25:17 +08:00
Hoshina 0c6ad33a9c merge: integrate main into refactor-inbound-context-routing-session 2026-04-13 13:25:07 +08:00
Hoshina 0f23535165 fix(runtime): address session promotion and steering regressions 2026-04-13 12:35:27 +08:00
wenjie 6a870cb260 ci(build): remove unused Node.js and pnpm setup from core build workflow 2026-04-13 11:56:43 +08:00
wenjie d73a0e89b4 build(release): move Android bundle publishing into GoReleaser
- build the Android universal bundle from GoReleaser hooks
- attach the bundle as a release asset
- remove the separate post-release upload step
- simplify Make targets around cross-platform builds
2026-04-13 11:52:35 +08:00
Liu Yuan 4532627f71 test(seahorse): add TestTriggerMigration for old-DB trigger upgrade path
Verifies that databases created with the old buggy FTS5 DELETE trigger
body are correctly migrated by runSchema: the old trigger causes DELETE
to fail, and after re-running runSchema (which drops and recreates the
triggers with the corrected body) DELETE works normally.
2026-04-13 11:37:50 +08:00
Liu Yuan b8819bdbff fix(seahorse): drop/recreate FTS5 triggers so existing DBs get corrected bodies
`CREATE TRIGGER IF NOT EXISTS` does not replace an existing trigger body.
On databases created with the old (buggy) DELETE-FROM-FTS syntax, the
bad trigger body persisted after code updates. Now we explicitly DROP
each trigger before CREATE, so any existing DB gets the corrected body
on next startup — no manual DB deletion required.
2026-04-13 11:29:02 +08:00
wenjie ea2107e8a9 build(release): split core builds from release-only artifacts
- add a dedicated build-release-artifacts target for Android bundle packaging
- switch CI and release workflows to Corepack-managed pnpm with cache support
- pin the frontend pnpm version and make dependency installs deterministic
- inject version metadata into launcher binaries in GoReleaser
- update build documentation to reflect the new workflow
2026-04-13 11:23:55 +08:00
Liu Yuan f7e768152e feat(agent): /clear now clears seahorse DB in addition to JSONL
- Add Clear(ctx, sessionKey) to ContextManager interface
- Implement Clear for legacy (JSONL) and seahorse (DB + JSONL)
- Add Engine.ClearSession + Store.ClearConversation
- Fix FTS5 DELETE trigger syntax in schema (was using wrong
  external-content FTS5 syntax; now uses standard DELETE FROM)
- Fix ClearSession to skip sessions never ingested (was creating
  blank conversations record via GetOrCreateConversation)
- Simplify summary_parents DELETE into single OR statement
- Add TestStoreClearConversation unit test
2026-04-13 11:04:45 +08:00
Guoguo 2b2bc26f8e docs: fix Conventional Commits links in CONTRIBUTING files (#2494)
- CONTRIBUTING.md: change link from zh-hans to en locale
- CONTRIBUTING.zh.md: fix NBSP causing surrounding text to be absorbed into the link
- Both files now use proper markdown link syntax
2026-04-13 10:46:17 +08:00
afjcjsbx 815e43e3ef fix(agent): reinitialize MCP and discovery tools after reload 2026-04-12 21:37:19 +02:00
daming大铭 6d03791929 Merge pull request #2475 from lc6464/fix/issue-2448-separate-thought-message
feat(gemini,pico): separate thought messages and add native Gemini provider
2026-04-12 19:20:19 +08:00
daming大铭 18d35c7d5d Merge pull request #2486 from sky5454/main
build: add Android arm64 cross-compile support
2026-04-12 18:58:37 +08:00
sky5454 681b2a258b build: address PR review — fix Android launcher flags, systray tag, rename target 2026-04-12 18:50:52 +08:00
dataCenter430 b6617a4b17 feat(cli): structured terminal UI for PicoClaw CLI like modern CLIs (#2229)
* feat(cli): add boxed help/error UI with no-color support

* fix: CI testing error

* fix: lint errors

* fix linter error

* fix: address review
2026-04-12 18:44:24 +08:00
sky5454 168b6bec58 build(android): ci build added 2026-04-12 18:35:05 +08:00
sky5454 080f532d82 build: add Android arm64 cross-compile support
- Add build-android-arm64, build-launcher-android-arm64, build-all-android
  targets to Makefile and web/Makefile
- Use -tags stdjson (no goolm) for Android; CGO_ENABLED=0 throughout
- Output staged as build/android-staging/arm64-v8a/libpicoclaw{,-web}.so
  for JNI consumption; zip packaging handled by CI
- Exclude Matrix channel from android builds (channel_matrix.go) to avoid
  modernc.org/sqlite CGO dependency
- Exclude systray from android builds; use headless stub instead
  (systray.go / systray_stub_nocgo.go)
2026-04-12 17:41:10 +08:00
srcrs 2b73978c5f fix(cron): add agent: prefix to session key so resolveScopeKey preserves it
Cron session keys "agent:cron-{id}-{uuid}" were being silently ignored by
resolveScopeKey, which only recognizes keys prefixed with "agent:". This
caused multiple executions of the same job to share a session. Also
switch from timestamp to UUID to avoid collisions in concurrent scenarios.
2026-04-11 23:27:07 +08:00
lc6464 6fbd7e0a3f fix(gemini): align thoughtSignature and stream tool IDs 2026-04-11 12:02:58 +08:00
lc6464 e9f55d776d fix(review): address copilot backpressure and SSE parse feedback 2026-04-11 11:18:41 +08:00
lc6464 86917faa9b fix(ci): resolve lint header casing and fallback test routing 2026-04-11 02:23:35 +08:00
lc6464 b73caebe6f fix(chat): improve thought readability in dark mode 2026-04-11 01:44:39 +08:00
lc6464 cbae69ad64 fix(gemini): honor pro-model thinking constraints 2026-04-11 01:38:13 +08:00
lc6464 83e93ca572 fix(gemini): align thinking-off and system prompt semantics 2026-04-11 01:15:38 +08:00
lc6464 459e78c076 fix(gemini): harden dedicated provider compatibility 2026-04-11 00:50:24 +08:00
srcrs 36b9693d31 fix(cron): make each job execution use an independent session
Previously all executions of the same cron job reused the session key
"cron-{jobID}", causing conversation history to accumulate across runs.
Now each run gets a unique key "cron-{jobID}-{timestamp}", preventing
cross-execution interference.
2026-04-10 23:49:31 +08:00
lc6464 c8bac699fe fix(pico): separate thought and normal messages 2026-04-10 20:23:12 +08:00
Guoguo 748ac58dd1 fix(chat): keep tool-call summary and assistant output in sync (#2449)
* fix(chat): keep tool summaries and assistant output together

* fix(pico): stream assistant text between tool calls

* fix(pico): avoid duplicate final websocket message

* fix(review): align tool feedback reconstruction with runtime behavior

* style(lint): satisfy gci and golines for review fixes

* fix(agent): gate pico interim publish for internal turns
2026-04-10 15:08:30 +08:00
winterfx 187189ad4a fix(seahorse): sanitize user input for FTS5 MATCH queries (#2436)
User input containing FTS5 operators (-, +, *, OR, NOT, :, quotes,
parentheses) could cause query errors or unexpected search results.
Wrap each token in double quotes to force literal matching while
preserving user-quoted phrases.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 11:59:50 +08:00
wenjie d9977715a3 fix(launcher): align react and react-dom versions (#2467)
Pin react and react-dom to 19.2.5 to avoid runtime crashes caused by a version mismatch.
Refresh the pnpm lockfile to keep frontend dependencies in sync.
2026-04-10 11:13:05 +08:00
wenjie 795ec9af05 fix(launcher): fall back to token auth on unsupported platforms (#2466)
Handle platforms where the dashboard password store is unavailable
by treating legacy token auth as initialized, rejecting password
setup, and adding platform-specific store stubs and tests.
2026-04-10 11:12:54 +08:00
dependabot[bot] 7788ed4677 build(deps): bump github.com/modelcontextprotocol/go-sdk (#2455)
Bumps [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) from 1.4.1 to 1.5.0.
- [Release notes](https://github.com/modelcontextprotocol/go-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/go-sdk/compare/v1.4.1...v1.5.0)

---
updated-dependencies:
- dependency-name: github.com/modelcontextprotocol/go-sdk
  dependency-version: 1.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:46:45 +08:00
dependabot[bot] e58f00b0c1 build(deps): bump shadcn from 4.1.2 to 4.2.0 in /web/frontend (#2459)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.1.2 to 4.2.0.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.2.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:28:03 +08:00
dependabot[bot] f1fe2db7ac build(deps): bump @tanstack/react-query in /web/frontend (#2458)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.96.1 to 5.97.0.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.97.0/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.97.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:27:18 +08:00
dependabot[bot] 19493140eb build(deps): bump react from 19.2.4 to 19.2.5 in /web/frontend (#2456)
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.4 to 19.2.5.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:19:39 +08:00
dependabot[bot] c6d15da1ea build(deps): bump golang.org/x/sys from 0.42.0 to 0.43.0 (#2450)
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.42.0 to 0.43.0.
- [Commits](https://github.com/golang/sys/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:18:25 +08:00
dependabot[bot] 484070736d build(deps): bump jotai from 2.19.0 to 2.19.1 in /web/frontend (#2452)
Bumps [jotai](https://github.com/pmndrs/jotai) from 2.19.0 to 2.19.1.
- [Release notes](https://github.com/pmndrs/jotai/releases)
- [Commits](https://github.com/pmndrs/jotai/compare/v2.19.0...v2.19.1)

---
updated-dependencies:
- dependency-name: jotai
  dependency-version: 2.19.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:13:42 +08:00
dependabot[bot] 0e57a446dc build(deps-dev): bump vite from 8.0.3 to 8.0.8 in /web/frontend (#2451)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.8.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:13:13 +08:00
Mauro 491418775b fix(gateway): log startup errors before exit (#2414)
* fix(gateway): log startup errors before exit

* preserve deferred startup failure logging
2026-04-10 10:10:45 +08:00
Mauro 282ebcd956 Merge pull request #2457 from sipeed/dependabot/go_modules/modernc.org/sqlite-1.48.2
build(deps): bump modernc.org/sqlite from 1.48.0 to 1.48.2
2026-04-09 22:14:55 +02:00
Mauro dde61365d4 Merge pull request #2420 from lahuman/docs/tool-escape-semantics
docs(tool): use provider-agnostic JSON escaping guidance
2026-04-09 20:50:06 +02:00
Mauro d7d4374617 Merge pull request #2453 from sipeed/dependabot/go_modules/github.com/aws/aws-sdk-go-v2/config-1.32.14
build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.32.12 to 1.32.14
2026-04-09 20:42:54 +02:00
Mauro d03d519c6d Merge pull request #2454 from sipeed/dependabot/go_modules/github.com/mymmrac/telego-1.8.0
build(deps): bump github.com/mymmrac/telego from 1.7.0 to 1.8.0
2026-04-09 20:42:07 +02:00
dependabot[bot] 919e9eb645 build(deps): bump modernc.org/sqlite from 1.48.0 to 1.48.2
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.48.0 to 1.48.2.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.48.0...v1.48.2)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.48.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 17:18:28 +00:00
dependabot[bot] 01a33bbb61 build(deps): bump github.com/mymmrac/telego from 1.7.0 to 1.8.0
Bumps [github.com/mymmrac/telego](https://github.com/mymmrac/telego) from 1.7.0 to 1.8.0.
- [Release notes](https://github.com/mymmrac/telego/releases)
- [Commits](https://github.com/mymmrac/telego/compare/v1.7.0...v1.8.0)

---
updated-dependencies:
- dependency-name: github.com/mymmrac/telego
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 17:18:19 +00:00
dependabot[bot] c71cd1eede build(deps): bump github.com/aws/aws-sdk-go-v2/config
Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.32.12 to 1.32.14.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.12...config/v1.32.14)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.32.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 17:18:14 +00:00
lc6464 bd88385923 fix(agent): gate pico interim publish for internal turns 2026-04-10 00:19:45 +08:00
lc6464 58f634b582 style(lint): satisfy gci and golines for review fixes 2026-04-10 00:02:20 +08:00
lc6464 bd13092831 fix(review): align tool feedback reconstruction with runtime behavior 2026-04-09 23:52:02 +08:00
lc6464 9982ee29a8 fix(pico): avoid duplicate final websocket message 2026-04-09 22:59:36 +08:00
lc6464 2aeed8fb3a fix(pico): stream assistant text between tool calls 2026-04-09 22:32:35 +08:00
lc6464 5b596ed2f0 fix(chat): keep tool summaries and assistant output together 2026-04-09 22:16:36 +08:00
Mauro 20d3522069 Merge pull request #2418 from lahuman/docs/korean-readme
docs: add Korean README translation
2026-04-09 11:25:15 +02:00
Guoguo 5e44a99410 fix(docker): run self-built images as root for parity with release (#2435)
The self-built docker/Dockerfile and docker/Dockerfile.heavy created a
dedicated picoclaw user (uid 1000) and stored config at
/home/picoclaw/.picoclaw, while the released images from
Dockerfile.goreleaser (and Dockerfile.full) run as root at
/root/.picoclaw. Both docker-compose files mount ./data:/root/.picoclaw,
so self-built images silently broke when used with the shared compose.

Drop the picoclaw user switch and align both Dockerfiles on root +
/root/.picoclaw. Dockerfile also adopts the release entrypoint.sh so
first-run behavior matches between self-built and release tags.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:53:52 +08:00
wenjie a9720daa45 fix(test): skip TestPrepareCommand_AppliesUserEnv on unsupported operating systems (#2434) 2026-04-09 10:14:08 +08:00
k a2f02e4b18 Revert "test(agent): remove unused respondWithMediaHook field"
This reverts commit 087e355885.
2026-04-09 07:47:42 +09:00
sky5454 06023c79fa feat(launcher): standard HTTP login/setup/logout flow for dashboard, frontend and backend impl. and fix windows pid lock for ws (#2339)
* feat(launcher): replace token-in-logs auth with standard HTTP login flow

## Problem

Previously users had to find the one-time token from console logs or
log files to access the dashboard - a non-standard, error-prone workflow
with no clear path for changing credentials.

## Solution: standard HTTP API login with bcrypt-backed password store

### Auth flow (new)
1. First run: browser opens, session guard detects uninitialized state,
   redirects to /launcher-setup
2. User sets a password (min 8 chars) via POST /api/auth/setup {password, confirm},
   bcrypt(cost=12) hash stored in ~/.picoclaw/launcher-auth.db (SQLite)
3. Subsequent logins: POST /api/auth/login {password}, HttpOnly cookie
   picoclaw_launcher_auth (HMAC-SHA256 signed, 7-day expiry)
4. 401 on any API call, frontend redirects to /launcher-login
5. Logout: POST /api/auth/logout, cookie cleared, redirect to login

### Backend changes
- web/backend/api/auth.go: renamed Token to Password; added handleSetup;
  launcherAuthStatusResponse now includes Initialized bool; PasswordStore
  interface wires bcrypt store into handlers
- web/backend/dashboardauth/: new package - Store with New(dir) / Open(path);
  SetPassword (bcrypt cost=12), VerifyPassword, IsInitialized
  - sql.go: all DB-layer constants (DBFilename, sqliteDriver, bcryptCost,
    four SQL query strings) - compile-time constants, zero runtime overhead
- web/backend/middleware/launcher_dashboard_auth.go: /launcher-setup and
  /api/auth/setup added to public paths
- web/backend/main.go:
  - dashboardauth.New(picoHome) replaces manual path construction
  - maskSecret(): suffix only revealed when >=5 chars hidden (length >= 12),
    preventing 8-char minimum passwords from leaking their tail
- web/backend/main_test.go: TestMaskSecret updated with boundary cases

### Forward-compatibility: pkg/credential integration

If the dashboard password is later reused as the enc:// passphrase,
the bcrypt hash in launcher-auth.db becomes an offline oracle.
Recommended mitigation (not yet implemented): derive two independent
subkeys via HKDF before use:

  bcrypt(HKDF(password, info="picoclaw-dashboard-login-v1"))  stored in DB
  HKDF(password, info="picoclaw-credential-enc-v1")           passed to PassphraseProvider

This isolates the two domains: cracking the bcrypt hash yields only the
login subkey, which is computationally independent of the enc:// subkey.

* fix(auth): replace wastedassign ok := false with var ok bool

* refactor(tray): remove copy-token clipboard feature

Dashboard login now uses standard web auth (bcrypt + session cookie).
The system tray 'Copy dashboard token' menu item is no longer needed.

- Delete tray_offers_copy.go and tray_offers_copy_stub.go
- Remove mCopyTok menu item and clipboard handler from systray.go
- Remove launcherDashboardTokenForClipboard var from main.go
- Remove MenuCopyToken/MenuCopyTokenHint keys from i18n.go

* feat(launcher-ui): standard HTTP login/setup/logout flow for dashboard

Replaces the previous "find token in logs" workflow with a proper
browser-based authentication UI backed by the new /api/auth/* endpoints.

### New pages
- /launcher-setup: first-run password initialization form (password +
  confirm, min 8 chars); calls POST /api/auth/setup; redirects to login
  on success
- /launcher-login: standard password login form; calls POST /api/auth/login;
  sets HttpOnly session cookie on success

### Session guard (src/routes/__root.tsx)
A useEffect on every non-auth page load calls GET /api/auth/status:
- initialized=false  -> redirect to /launcher-setup
- authenticated=false -> redirect to /launcher-login
This ensures the setup/login UI is shown even when the ?token= URL
mechanism auto-logs in (first-run case).

### Logout button (src/components/app-header.tsx)
IconLogout button added to the header with a confirm AlertDialog;
calls POST /api/auth/logout then redirects to /launcher-login.

### API layer
- src/api/launcher-auth.ts: LauncherAuthStatus gains initialized bool;
  postLauncherDashboardSetup() added; LauncherAuthTokenHelp removed
- src/api/http.ts: 401 guard uses isLauncherAuthPathname() (covers both
  /launcher-login and /launcher-setup) to prevent redirect loops
- src/lib/launcher-login-path.ts: isLauncherSetupPathname() and
  isLauncherAuthPathname() added

### Routing
- src/routeTree.gen.ts: /launcher-setup route registered throughout
- src/routes/launcher-login.tsx: tokenHelp UI removed; useEffect added
  to redirect to setup when initialized=false

### i18n
- en.json / zh.json: launcherSetup block added; launcherLogin keys
  updated to use passwordLabel/passwordPlaceholder

* fix(lint): ts lint fixed 1

* fix(auth): detail auth error handle

* fix(login):  frontend web auth error handle

* fix(frontend): auth error handler 5xx
2026-04-08 21:43:51 +08:00
美電球 3e3b6aed90 fix(tools): message tool no longer suppresses reply to originating chat (#2180)
When the message tool sent to a different chat (e.g., a group), the
agent's final response to the originating chat was incorrectly skipped
because HasSentInRound() was a simple bool that didn't distinguish
targets. Replace with HasSentTo(channel, chatID) that tracks all
send targets per round and only suppresses when the target matches.

Fixes cross-conversation message causing "Processing..." to hang.
2026-04-08 21:40:12 +08:00
k 087e355885 test(agent): remove unused respondWithMediaHook field 2026-04-08 19:53:11 +09:00
k 1dc25e7cf5 test(agent): remove unused respondWithMediaHook field 2026-04-08 19:44:07 +09:00
k 8f7eae8b37 docs(tool): use provider-agnostic JSON escaping guidance 2026-04-08 14:19:11 +09:00
k 862421b146 docs: add Korean README translation 2026-04-08 13:42:57 +09:00
Hoshina 296077eabf fix(session): restore thread and legacy compatibility 2026-04-08 00:32:53 +08:00
Hoshina a827d01d7c test(channels): normalize manager outbound test message 2026-04-07 23:09:26 +08:00
Hoshina 27db03e5ca fix(config): migrate legacy bindings and optimize session resolve 2026-04-07 22:57:10 +08:00
Hoshina 3d60385958 refactor(session): tighten legacy boundary and tool context 2026-04-07 22:39:46 +08:00
Hoshina 9f23ec22d6 refactor(agent): normalize dispatch and outbound turn metadata 2026-04-07 22:12:23 +08:00
Hoshina e32a209683 Merge branch 'main' into refactor-inbound-context-routing-session
# Conflicts:
#	pkg/agent/eventbus_test.go
#	pkg/agent/loop.go
#	pkg/bus/bus.go
#	pkg/bus/types.go
#	pkg/channels/pico/pico.go
#	pkg/channels/telegram/telegram.go
#	pkg/config/config.go
#	web/backend/api/session.go
#	web/backend/api/session_test.go
2026-04-07 21:41:02 +08:00
Hoshina 528c57dda0 refactor(channels): merge non-web fixes from main 2026-04-07 21:19:11 +08:00
Hoshina e6e724a827 refactor(config): reconcile defaults with main 2026-04-07 21:19:06 +08:00
Hoshina 718a5e7c75 refactor(runtime): merge bus context and handled tool delivery 2026-04-07 21:05:53 +08:00
Hoshina 168b75ae21 style(lint): fix config and qq formatting 2026-04-01 22:51:28 +08:00
Hoshina bef17d6453 feat(routing): add ordered dispatch rules 2026-04-01 22:13:04 +08:00
Hoshina 82bfe0d9a0 docs(config): remove legacy bindings guide 2026-04-01 21:34:49 +08:00
Hoshina 19a01d4264 refactor(routing): remove legacy bindings config 2026-04-01 21:34:39 +08:00
Hoshina 3a9d1fc6fd test(channels): update inbound context assertions 2026-04-01 21:34:24 +08:00
Hoshina 53482a17bc refactor(web): resolve pico sessions from scope metadata 2026-04-01 20:57:15 +08:00
Hoshina 59dee895fc refactor(runtime): drop non-session legacy context compatibility 2026-04-01 20:56:48 +08:00
Hoshina ca9652e120 refactor(session): replace dm scope with dimensions policy 2026-04-01 17:19:50 +08:00
Hoshina 3957e2cc72 feat(session): persist scope metadata and aliases 2026-04-01 16:25:05 +08:00
Hoshina bb2167e3f3 feat(event): log turn context fields 2026-04-01 15:46:35 +08:00
Hoshina e0ceea91f6 refactor(context): carry route and scope through runtime 2026-04-01 15:23:36 +08:00
Hoshina 79de00f7f3 refactor(agent): carry inbound context through events and hooks 2026-04-01 14:37:43 +08:00
Hoshina fcab3a1b7c refactor(routing): move session allocation out of router 2026-04-01 14:26:12 +08:00
Hoshina 2095ec8700 refactor(agent): route using inbound context 2026-04-01 14:08:44 +08:00
Hoshina 963ed07d69 refactor(channels): emit inbound context in secondary adapters 2026-04-01 13:58:31 +08:00
Hoshina cf11ff70c3 refactor(channels): emit inbound context in primary adapters 2026-04-01 13:50:24 +08:00
Hoshina 9cfa3c3ba6 refactor(inbound): add inbound context compatibility bridge 2026-04-01 13:35:18 +08:00
smallwhite 89af3b2511 fix(tools): message tool no longer suppresses reply to originating chat
When the message tool sent to a different chat (e.g., a group), the
agent's final response to the originating chat was incorrectly skipped
because HasSentInRound() was a simple bool that didn't distinguish
targets. Replace with HasSentTo(channel, chatID) that tracks all
send targets per round and only suppresses when the target matches.

Fixes cross-conversation message causing "Processing..." to hang.
2026-03-30 15:06:22 +08:00
636 changed files with 47167 additions and 14125 deletions
+1 -1
View File
@@ -16,5 +16,5 @@ jobs:
with:
go-version-file: go.mod
- name: Build
- name: Build core binaries
run: make build-all
+60
View File
@@ -0,0 +1,60 @@
name: Create Tag
on:
workflow_dispatch:
inputs:
tag:
description: "Tag name (required, e.g. v0.2.0)"
required: true
type: string
commit:
description: "Target commit SHA (leave empty for latest main)"
required: false
type: string
default: ""
jobs:
create-tag:
name: Create Git Tag
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: main
- name: Validate commit exists
if: ${{ inputs.commit != '' }}
shell: bash
run: |
if ! git cat-file -t "${{ inputs.commit }}" &>/dev/null; then
echo "::error::Commit '${{ inputs.commit }}' does not exist."
exit 1
fi
- name: Check tag does not already exist
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then
echo "::error::Tag '${{ inputs.tag }}' already exists."
exit 1
fi
- name: Create and push tag
shell: bash
run: |
TARGET="${{ inputs.commit || 'HEAD' }}"
COMMIT_SHA=$(git rev-parse "$TARGET")
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "${{ inputs.tag }}" "$COMMIT_SHA" -m "Release ${{ inputs.tag }}"
git push origin "${{ inputs.tag }}"
echo "### Tag Created" >> "$GITHUB_STEP_SUMMARY"
echo "- **Tag:** \`${{ inputs.tag }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- **Commit:** \`${COMMIT_SHA}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- **Branch:** \`$(git branch -r --contains "$COMMIT_SHA" | head -1 | xargs)\`" >> "$GITHUB_STEP_SUMMARY"
+18 -9
View File
@@ -17,29 +17,38 @@ jobs:
with:
ref: main
# 1. 安装指定版本的 Go (可选,但推荐)
# 1. Install Go from go.mod
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
# 2. 安装 pnpm
- name: Install pnpm
run: brew install pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.33.0
run_install: false
# 3. 运行你的 Makefile 编译二进制文件
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: web/frontend/pnpm-lock.yaml
# 3. Build the application bundle
- name: Build with Make
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
# 4. 签名
# 4. Apply ad-hoc signing
- name: Ad-hoc Sign
run: codesign --force --deep --sign - "build/PicoClaw Launcher.app"
# 5. 安装打包工具
# 5. Install the DMG packaging tool
- name: Install create-dmg
run: brew install create-dmg
# 6. 执行打包命令
# 6. Create the DMG
- name: Create DMG
run: |
mkdir -p dist
@@ -54,7 +63,7 @@ jobs:
"dist/picoclaw-${{ matrix.arch }}.dmg" \
"build/PicoClaw Launcher.app"
# 7. 上传文件到 GitHub Artifacts (供你下载)
# 7. Upload the DMG as a GitHub artifact
- name: Upload DMG
uses: actions/upload-artifact@v7
with:
+13 -5
View File
@@ -47,13 +47,18 @@ jobs:
with:
go-version-file: go.mod
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.33.0
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Setup pnpm
run: corepack enable && corepack prepare pnpm@latest --activate
cache: pnpm
cache-dependency-path: web/frontend/pnpm-lock.yaml
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -75,6 +80,9 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Install zip
run: sudo apt-get install -y zip
- name: Create local tag for GoReleaser
run: git tag "${{ steps.version.outputs.version }}"
@@ -90,6 +98,7 @@ jobs:
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
INCLUDE_ANDROID_BUNDLE: "true"
NIGHTLY_BUILD: "true"
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
@@ -123,7 +132,7 @@ jobs:
# Collect release artifacts from goreleaser dist/
ASSETS=()
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt build/picoclaw-android-universal.zip; do
[ -f "$f" ] && ASSETS+=("$f")
done
@@ -135,4 +144,3 @@ jobs:
--prerelease \
--latest=false \
"${ASSETS[@]}"
+24 -27
View File
@@ -1,10 +1,10 @@
name: Create Tag and Release
name: Release
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag (required, e.g. v0.2.0)"
description: "Existing tag to release (e.g. v0.2.0)"
required: true
type: string
prerelease:
@@ -24,35 +24,23 @@ on:
default: true
jobs:
create-tag:
name: Create Git Tag
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Create and push tag
shell: bash
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"
git push origin "$RELEASE_TAG"
release:
name: GoReleaser Release
needs: create-tag
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Verify tag exists
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then
echo "::error::Tag '${{ inputs.tag }}' does not exist. Create it first using the 'Create Tag' workflow."
exit 1
fi
- name: Checkout tag
uses: actions/checkout@v6
with:
@@ -65,13 +53,18 @@ jobs:
with:
go-version-file: go.mod
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.33.0
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Setup pnpm
run: corepack enable && corepack prepare pnpm@latest --activate
cache: pnpm
cache-dependency-path: web/frontend/pnpm-lock.yaml
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -93,6 +86,9 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Install zip
run: sudo apt-get install -y zip
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
@@ -104,6 +100,7 @@ jobs:
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
INCLUDE_ANDROID_BUNDLE: "true"
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
+14 -5
View File
@@ -9,11 +9,10 @@ git:
before:
hooks:
- go mod tidy
- go generate ./...
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
- go install github.com/tc-hib/go-winres@latest
- go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
- sh -c 'cd web/frontend && CI=true pnpm install --frozen-lockfile && pnpm build:backend'
- sh -c 'GOBIN="$(go env GOPATH)/bin"; mkdir -p "$GOBIN"; go install github.com/tc-hib/go-winres@v0.3.3 && "$GOBIN/go-winres" make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}'
- sh -c 'if [ "${INCLUDE_ANDROID_BUNDLE:-}" = "true" ]; then make build-android-bundle; fi'
builds:
- id: picoclaw
@@ -27,7 +26,7 @@ builds:
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
goos:
- linux
- windows
@@ -67,6 +66,10 @@ builds:
- stdjson
ldflags:
- -s -w
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
goos:
- linux
- windows
@@ -106,6 +109,10 @@ builds:
- stdjson
ldflags:
- -s -w
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
goos:
- linux
- windows
@@ -245,6 +252,8 @@ changelog:
release:
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
extra_files:
- glob: ./build/picoclaw-android-universal.zip
footer: >-
---
+6 -3
View File
@@ -35,6 +35,8 @@ We are committed to maintaining a welcoming and respectful community. Be kind, c
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.
For documentation contributions, prefer the layout and naming conventions in [`docs/README.md`](docs/README.md). Run `make lint-docs` after adding or moving Markdown files to catch common consistency issues early.
---
## Getting Started
@@ -64,7 +66,7 @@ For substantial new features, please open an issue first to discuss the design b
```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
make check # Full pre-commit check: deps + fmt + vet + test + docs consistency checks
```
### Running Tests
@@ -81,9 +83,10 @@ go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
make fmt # Format code
make vet # Static analysis
make lint # Full linter run
make lint-docs # Check common documentation layout and naming conventions
```
All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early.
All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early, including the common docs consistency checks from `make lint-docs`.
---
@@ -108,7 +111,7 @@ Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider
- 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/
- Refer to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
### Keeping Up to Date
+43 -5
View File
@@ -1,4 +1,4 @@
.PHONY: all build install uninstall clean help test
.PHONY: all build install uninstall clean help test build-all lint-docs
# Build variables
BINARY_NAME=picoclaw
@@ -205,11 +205,44 @@ build-linux-mipsle: generate
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
## build-android-arm64: Build core for Android ARM64
build-android-arm64: generate
@echo "Building for android/arm64..."
@mkdir -p $(BUILD_DIR)
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-android-arm64"
## build-launcher-android-arm64: Build launcher for Android ARM64
build-launcher-android-arm64:
@echo "Building picoclaw-launcher for android/arm64..."
@mkdir -p $(BUILD_DIR)
@$(MAKE) -C web build-android-arm64 \
OUTPUT_ANDROID_ARM64="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" \
GO='$(GO)' \
LDFLAGS='$(LDFLAGS)'
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64"
## build-android-bundle: Build core and launcher for all Android architectures and package as universal zip
build-android-bundle: generate
@echo "Building core for all Android architectures..."
@mkdir -p $(BUILD_DIR)
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
@echo "Building launcher for Android arm64..."
@$(MAKE) build-launcher-android-arm64
@echo "Staging JNI libs..."
@rm -rf $(BUILD_DIR)/android-staging
@mkdir -p $(BUILD_DIR)/android-staging/arm64-v8a
@cp $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw.so
@cp $(BUILD_DIR)/picoclaw-launcher-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw-web.so
@cd $(BUILD_DIR)/android-staging && zip -r ../picoclaw-android-universal.zip .
@rm -rf $(BUILD_DIR)/android-staging
@echo "All Android builds complete: $(BUILD_DIR)/picoclaw-android-universal.zip"
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
build-pi-zero: build-linux-arm build-linux-arm64
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
## build-all: Build picoclaw for all platforms
## build-all: Build the picoclaw core binary for all Makefile-managed platforms
build-all: generate
@echo "Building for multiple platforms..."
@mkdir -p $(BUILD_DIR)
@@ -226,7 +259,7 @@ build-all: generate
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
@echo "All builds complete"
@echo "Core builds complete"
## install: Install picoclaw to system and copy builtin skills
install: build
@@ -275,9 +308,14 @@ test: generate
fmt:
@$(GOLANGCI_LINT) fmt
## lint-docs: Check common documentation layout and naming conventions
lint-docs:
@./scripts/lint-docs.sh
## lint: Run linters
lint:
@$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
@./scripts/lint-docs.sh
## fix: Fix linting issues
fix:
@@ -293,8 +331,8 @@ update-deps:
@$(GO) get -u ./...
@$(GO) mod tidy
## check: Run vet, fmt, and verify dependencies
check: deps fmt vet test
## check: Run deps, fmt, vet, tests, and docs consistency checks
check: deps fmt vet test lint-docs
## run: Build and run picoclaw
run: build
+51 -33
View File
@@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | **English**
[中文](docs/project/README.zh.md) | [日本語](docs/project/README.ja.md) | [한국어](docs/project/README.ko.md) | [Português](docs/project/README.pt-br.md) | [Tiếng Việt](docs/project/README.vi.md) | [Français](docs/project/README.fr.md) | [Italiano](docs/project/README.it.md) | [Bahasa Indonesia](docs/project/README.id.md) | [Malay](docs/project/README.ms.md) | **English**
</div>
@@ -112,7 +112,7 @@ _*Recent builds may use 10-20MB due to rapid PR merges. Resource optimization is
</div>
> **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
> **[Hardware Compatibility List](docs/guides/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
<p align="center">
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
@@ -164,22 +164,32 @@ Alternatively, download the binary for your platform from the [GitHub Releases](
### Build from source (for development)
Prerequisites:
- Go 1.25+
- Node.js 22+ and pnpm 10.33.0+ for Web UI / launcher builds
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
# Build core binary
# Install frontend dependencies
(cd web/frontend && pnpm install --frozen-lockfile)
# Build the core binary for the current platform
make build
# Build Web UI Launcher (required for WebUI mode)
# Build the Web UI Launcher (required for WebUI mode)
make build-launcher
# Build for multiple platforms
# Build core binaries for all Makefile-managed platforms
make build-all
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
# Build for Raspberry Pi Zero 2 W
# 32-bit: make build-linux-arm
# 64-bit: make build-linux-arm64
make build-pi-zero
# Build and install
@@ -215,7 +225,7 @@ picoclaw-launcher
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
</p>
**Getting started:**
**Getting started:**
Open the WebUI, then: **1)** Configure a Provider (add your LLM API key) -> **2)** Configure a Channel (e.g., Telegram) -> **3)** Start the Gateway -> **4)** Chat!
@@ -293,12 +303,13 @@ picoclaw-launcher-tui
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**Getting started:**
**Getting started:**
Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat!
For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io).
<a id="-run-on-old-android-phones"></a>
### 📱 Android
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw.
@@ -368,8 +379,8 @@ This creates `~/.picoclaw/config.json` and the workspace directory.
```
> See `config/config.example.json` in the repo for a complete configuration template with all available options.
>
> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security_configuration.md` for more details.
>
> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security/security_configuration.md` for more details.
**3. Chat**
@@ -448,7 +459,7 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
}
```
For full provider configuration details, see [Providers & Models](docs/providers.md).
For full provider configuration details, see [Providers & Models](docs/guides/providers.md).
</details>
@@ -460,8 +471,8 @@ Talk to your PicoClaw through 18+ messaging platforms:
|---------|-------|----------|------|
| **Telegram** | Easy (bot token) | Long polling | [Guide](docs/channels/telegram/README.md) |
| **Discord** | Easy (bot token + intents) | WebSocket | [Guide](docs/channels/discord/README.md) |
| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/chat-apps.md#whatsapp) |
| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/chat-apps.md#weixin) |
| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/guides/chat-apps.md#whatsapp) |
| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/guides/chat-apps.md#weixin) |
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) |
| **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) |
@@ -470,7 +481,7 @@ Talk to your PicoClaw through 18+ messaging platforms:
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
| **WeCom** | Easy (QR login or manual) | WebSocket | [Guide](docs/channels/wecom/README.md) |
| **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) |
| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/chat-apps.md#irc) |
| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/guides/chat-apps.md#irc) |
| **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) |
| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) |
| **Pico** | Easy (enable) | Native protocol | Built-in |
@@ -478,9 +489,9 @@ Talk to your PicoClaw through 18+ messaging platforms:
> All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server.
> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/configuration.md#gateway-log-level) for details.
> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/guides/configuration.md#gateway-log-level) for details.
For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md).
For detailed channel setup instructions, see [Chat Apps Configuration](docs/guides/chat-apps.md).
## 🔧 Tools
@@ -500,7 +511,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
### ⚙️ Other Tools
PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/tools_configuration.md) for details.
PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/reference/tools_configuration.md) for details.
## 🎯 Skills
@@ -513,7 +524,7 @@ picoclaw skills search "web scraping"
picoclaw skills install <skill-name>
```
**Configure ClawHub token** (optional, for higher rate limits):
**Configure skill registries**:
Add to your `config.json`:
```json
@@ -523,6 +534,11 @@ Add to your `config.json`:
"registries": {
"clawhub": {
"auth_token": "your-clawhub-token"
},
"github": {
"base_url": "https://github.com",
"auth_token": "your-github-token",
"proxy": ""
}
}
}
@@ -530,7 +546,9 @@ Add to your `config.json`:
}
```
For more details, see [Tools Configuration - Skills](docs/tools_configuration.md#skills-tool).
`tools.skills.github.*` is deprecated. Use `tools.skills.registries.github.*` instead.
For more details, see [Tools Configuration - Skills](docs/reference/tools_configuration.md#skills-tool).
## 🔗 MCP (Model Context Protocol)
@@ -553,7 +571,7 @@ PicoClaw natively supports [MCP](https://modelcontextprotocol.io/) — connect a
}
```
For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/tools_configuration.md#mcp-tool).
For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/reference/tools_configuration.md#mcp-tool).
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
@@ -590,7 +608,7 @@ PicoClaw supports scheduled reminders and recurring tasks through the `cron` too
* **Recurring tasks**: "Remind me every 2 hours" -> triggers every 2 hours
* **Cron expressions**: "Remind me at 9am daily" -> uses cron expression
See [docs/cron.md](docs/cron.md) for current schedule types, execution modes, command-job gates, and persistence details.
See [docs/reference/cron.md](docs/reference/cron.md) for current schedule types, execution modes, command-job gates, and persistence details.
## 📚 Documentation
@@ -598,18 +616,18 @@ For detailed guides beyond this README:
| Topic | Description |
|-------|-------------|
| [Docker & Quick Start](docs/docker.md) | Docker Compose setup, Launcher/Agent modes |
| [Chat Apps](docs/chat-apps.md) | All 17+ channel setup guides |
| [Configuration](docs/configuration.md) | Environment variables, workspace layout, security sandbox |
| [Scheduled Tasks and Cron Jobs](docs/cron.md) | Cron schedule types, deliver modes, command gates, job storage |
| [Providers & Models](docs/providers.md) | 30+ LLM providers, model routing, model_list configuration |
| [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
| [Hooks](docs/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks |
| [Steering](docs/steering.md) | Inject messages into a running agent loop between tool calls |
| [SubTurn](docs/subturn.md) | Subagent coordination, concurrency control, lifecycle |
| [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions |
| [Tools Configuration](docs/tools_configuration.md) | Per-tool enable/disable, exec policies, MCP, Skills |
| [Hardware Compatibility](docs/hardware-compatibility.md) | Tested boards, minimum requirements |
| [Docker & Quick Start](docs/guides/docker.md) | Docker Compose setup, Launcher/Agent modes |
| [Chat Apps](docs/guides/chat-apps.md) | All 17+ channel setup guides |
| [Configuration](docs/guides/configuration.md) | Environment variables, workspace layout, security sandbox |
| [Scheduled Tasks and Cron Jobs](docs/reference/cron.md) | Cron schedule types, deliver modes, command gates, job storage |
| [Providers & Models](docs/guides/providers.md) | 30+ LLM providers, model routing, model_list configuration |
| [Spawn & Async Tasks](docs/guides/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
| [Hooks](docs/architecture/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks |
| [Steering](docs/architecture/steering.md) | Inject messages into a running agent loop between tool calls |
| [SubTurn](docs/architecture/subturn.md) | Subagent coordination, concurrency control, lifecycle |
| [Troubleshooting](docs/operations/troubleshooting.md) | Common issues and solutions |
| [Tools Configuration](docs/reference/tools_configuration.md) | Per-tool enable/disable, exec policies, MCP, Skills |
| [Hardware Compatibility](docs/guides/hardware-compatibility.md) | Tested boards, minimum requirements |
## 🤝 Contribute & Roadmap
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 356 KiB

+74 -28
View File
@@ -36,6 +36,7 @@ type AggMetrics struct {
OverallHitRate float64 `json:"overallHitRate"`
ByCategory map[int]*CatMetrics `json:"byCategory"`
TotalQuestions int `json:"totalQuestions"`
ValidF1Count int `json:"validF1Count"`
}
// CatMetrics holds metrics for a single category.
@@ -43,6 +44,7 @@ type CatMetrics struct {
F1 float64 `json:"f1"`
HitRate float64 `json:"hitRate"`
QuestionCount int `json:"questionCount"`
ValidF1Count int `json:"validF1Count"`
}
// EvalLegacy evaluates using legacy session store (raw history + budget truncation).
@@ -201,38 +203,64 @@ func EvalSeahorse(
// aggregateMetrics computes overall and per-category metrics.
func aggregateMetrics(qaResults []QAResult) AggMetrics {
byCat := map[int]*CatMetrics{}
type catAccum struct {
f1Sum float64
f1Count int
hitRateSum float64
hitRateCount int
}
byCatAcc := map[int]*catAccum{}
totalF1 := 0.0
totalHitRate := 0.0
validF1Count := 0
for _, qr := range qaResults {
totalF1 += qr.TokenF1
totalHitRate += qr.HitRate
cat, ok := byCat[qr.Category]
if !ok {
cat = &CatMetrics{}
byCat[qr.Category] = cat
// Skip sentinel -1.0 scores (LLM API/parse failures) from F1 averaging.
if qr.TokenF1 >= 0 {
totalF1 += qr.TokenF1
validF1Count++
}
cat.F1 += qr.TokenF1
cat.HitRate += qr.HitRate
cat.QuestionCount++
totalHitRate += qr.HitRate
acc, ok := byCatAcc[qr.Category]
if !ok {
acc = &catAccum{}
byCatAcc[qr.Category] = acc
}
if qr.TokenF1 >= 0 {
acc.f1Sum += qr.TokenF1
acc.f1Count++
}
acc.hitRateSum += qr.HitRate
acc.hitRateCount++
}
n := len(qaResults)
if n == 0 {
n = 1
nHit := len(qaResults)
if nHit == 0 {
nHit = 1
}
agg := AggMetrics{
OverallF1: totalF1 / float64(n),
OverallHitRate: totalHitRate / float64(n),
byCat := map[int]*CatMetrics{}
for cat, acc := range byCatAcc {
cm := &CatMetrics{
QuestionCount: acc.hitRateCount,
ValidF1Count: acc.f1Count,
}
if acc.f1Count > 0 {
cm.F1 = acc.f1Sum / float64(acc.f1Count)
}
if acc.hitRateCount > 0 {
cm.HitRate = acc.hitRateSum / float64(acc.hitRateCount)
}
byCat[cat] = cm
}
var overallF1 float64
if validF1Count > 0 {
overallF1 = totalF1 / float64(validF1Count)
}
return AggMetrics{
OverallF1: overallF1,
OverallHitRate: totalHitRate / float64(nHit),
ByCategory: byCat,
TotalQuestions: len(qaResults),
ValidF1Count: validF1Count,
}
for _, cat := range agg.ByCategory {
if cat.QuestionCount > 0 {
cat.F1 /= float64(cat.QuestionCount)
cat.HitRate /= float64(cat.QuestionCount)
}
}
return agg
}
// SaveResults writes per-sample eval results to JSON files.
@@ -277,27 +305,43 @@ func SaveAggregated(results []EvalResult, outDir string) error {
func computeModeAgg(results []EvalResult) AggMetrics {
agg := AggMetrics{ByCategory: map[int]*CatMetrics{}}
for _, r := range results {
agg.OverallF1 += r.Agg.OverallF1 * float64(r.Agg.TotalQuestions)
// Backward compat: old eval JSON (token mode) without ValidF1Count → use TotalQuestions.
// LLM modes may legitimately have ValidF1Count==0 (all failures).
vf1 := r.Agg.ValidF1Count
if vf1 == 0 && r.Agg.TotalQuestions > 0 && !strings.HasSuffix(r.Mode, "-llm") {
vf1 = r.Agg.TotalQuestions
}
agg.OverallF1 += r.Agg.OverallF1 * float64(vf1)
agg.OverallHitRate += r.Agg.OverallHitRate * float64(r.Agg.TotalQuestions)
agg.TotalQuestions += r.Agg.TotalQuestions
agg.ValidF1Count += vf1
for cat, cm := range r.Agg.ByCategory {
existing, ok := agg.ByCategory[cat]
if !ok {
existing = &CatMetrics{}
agg.ByCategory[cat] = existing
}
existing.F1 += cm.F1 * float64(cm.QuestionCount)
cvf1 := cm.ValidF1Count
if cvf1 == 0 && cm.QuestionCount > 0 && !strings.HasSuffix(r.Mode, "-llm") {
cvf1 = cm.QuestionCount
}
existing.F1 += cm.F1 * float64(cvf1)
existing.HitRate += cm.HitRate * float64(cm.QuestionCount)
existing.QuestionCount += cm.QuestionCount
existing.ValidF1Count += cvf1
}
}
if agg.ValidF1Count > 0 {
agg.OverallF1 /= float64(agg.ValidF1Count)
}
if agg.TotalQuestions > 0 {
agg.OverallF1 /= float64(agg.TotalQuestions)
agg.OverallHitRate /= float64(agg.TotalQuestions)
}
for _, cat := range agg.ByCategory {
if cat.ValidF1Count > 0 {
cat.F1 /= float64(cat.ValidF1Count)
}
if cat.QuestionCount > 0 {
cat.F1 /= float64(cat.QuestionCount)
cat.HitRate /= float64(cat.QuestionCount)
}
}
@@ -359,7 +403,9 @@ func printSection(title string, results []EvalResult) {
// PrintComparison outputs a human-readable comparison table to stdout.
func PrintComparison(results []EvalResult, llmResults []EvalResult) {
printSection("No LLM generation", results)
if len(results) > 0 {
printSection("No LLM generation", results)
}
if len(llmResults) > 0 {
printSection("With LLM", llmResults)
}
+346
View File
@@ -0,0 +1,346 @@
package main
import (
"context"
"fmt"
"log"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"github.com/sipeed/picoclaw/pkg/seahorse"
)
const answerSystemPrompt = `You are a helpful assistant. Given conversation context, answer the question concisely and accurately. If the answer is not in the context, say "I don't know". Answer in 1-3 sentences maximum.`
const judgeSystemPrompt = `You are an impartial judge evaluating answer quality.
Compare the candidate answer against the reference answer.
Consider semantic equivalence different wording expressing the same meaning should score high.
Output ONLY a single integer score from 1 to 5:
1 = completely wrong or irrelevant
2 = partially related but mostly incorrect
3 = partially correct, missing key details
4 = mostly correct with minor omissions
5 = fully correct, semantically equivalent
Output ONLY the number, nothing else.`
// generateAnswer asks the LLM to answer a question given retrieved context.
func generateAnswer(ctx context.Context, client *LLMClient, contextText, question string) (string, error) {
// Truncate context to avoid exceeding model limits while preserving valid UTF-8.
contextRunes := []rune(contextText)
if len(contextRunes) > 6000 {
contextText = string(contextRunes[:6000]) + "\n... [truncated]"
}
userPrompt := fmt.Sprintf("## Conversation Context\n\n%s\n\n## Question\n\n%s", contextText, question)
return client.Complete(ctx, answerSystemPrompt, userPrompt)
}
// scoreRe matches the first standalone integer 1-5 in the judge response.
var scoreRe = regexp.MustCompile(`\b([1-5])\b`)
// judgeAnswer asks the LLM to score the candidate answer vs the gold answer.
// Returns a score from 0.0 to 1.0, or -1.0 on parse failure.
func judgeAnswer(
ctx context.Context,
judgeClient *LLMClient,
question, goldAnswer, candidateAnswer string,
) (float64, error) {
userPrompt := fmt.Sprintf(
"Question: %s\n\nReference Answer: %s\n\nCandidate Answer: %s\n\nScore:",
question, goldAnswer, candidateAnswer,
)
response, err := judgeClient.Complete(ctx, judgeSystemPrompt, userPrompt)
if err != nil {
return -1.0, err
}
response = strings.TrimSpace(response)
if m := scoreRe.FindStringSubmatch(response); len(m) == 2 {
score, _ := strconv.Atoi(m[1])
return float64(score-1) / 4.0, nil // Normalize 1-5 to 0.0-1.0
}
log.Printf("WARNING: could not parse judge score from: %q, returning -1", response)
return -1.0, nil
}
// qaWork describes one QA evaluation unit.
type qaWork struct {
sampleID string
qaIndex int
globalIndex int
totalQA int
qa *LocomoQA
contextText string
sample *LocomoSample
}
// qaResult collects one QA evaluation output.
type qaResultOut struct {
index int // position in the flat QA list for ordering
result QAResult
answer string
score float64
}
// evalQAWorker processes a single QA item: generate answer + judge score.
func evalQAWorker(
ctx context.Context,
w qaWork,
answerClient, judgeClient *LLMClient,
logPrefix string,
) qaResultOut {
llmAnswer, err := generateAnswer(ctx, answerClient, w.contextText, w.qa.Question)
if err != nil {
log.Printf("WARN: LLM generation failed for sample %s Q%d: %v", w.sampleID, w.qaIndex, err)
llmAnswer = ""
}
score := -1.0
if llmAnswer != "" {
score, err = judgeAnswer(ctx, judgeClient, w.qa.Question, w.qa.AnswerString(), llmAnswer)
if err != nil {
log.Printf("WARN: LLM judge failed for sample %s Q%d: %v", w.sampleID, w.qaIndex, err)
}
}
hitRate := RecallHitRate(w.qa.Evidence, w.sample, w.contextText)
log.Printf("[%s] sample=%s q=%d/%d score=%.2f answer=%q",
logPrefix, w.sampleID, w.globalIndex, w.totalQA, score, truncateStr(llmAnswer, 80))
return qaResultOut{
index: w.globalIndex,
result: QAResult{
Question: w.qa.Question,
Category: w.qa.Category,
GoldAnswer: w.qa.AnswerString(),
TokenF1: score,
HitRate: hitRate,
},
answer: llmAnswer,
score: score,
}
}
// EvalLegacyLLM evaluates legacy store using LLM generation + LLM-as-Judge.
func EvalLegacyLLM(
ctx context.Context,
samples []LocomoSample,
legacy *LegacyStore,
budgetTokens int,
answerClient, judgeClient *LLMClient,
concurrency int,
) []EvalResult {
if concurrency < 1 {
concurrency = 1
}
totalQA := countTotalQA(samples)
results := make([]EvalResult, 0, len(samples))
for si := range samples {
sample := &samples[si]
history := legacy.GetHistory(sample.SampleID)
allContent := make([]string, 0, len(history))
for _, msg := range history {
allContent = append(allContent, msg.Content)
}
truncated, _ := BudgetTruncate(allContent, budgetTokens)
contextText := StringListToContent(truncated)
qaResults := make([]QAResult, len(sample.QA))
if concurrency <= 1 {
for qi := range sample.QA {
out := evalQAWorker(ctx, qaWork{
sampleID: sample.SampleID, qaIndex: qi,
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
qa: &sample.QA[qi], contextText: contextText, sample: sample,
}, answerClient, judgeClient, "legacy-llm")
qaResults[qi] = out.result
}
} else {
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
for qi := range sample.QA {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
out := evalQAWorker(ctx, qaWork{
sampleID: sample.SampleID, qaIndex: qi,
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
qa: &sample.QA[qi], contextText: contextText, sample: sample,
}, answerClient, judgeClient, "legacy-llm")
qaResults[qi] = out.result // safe: each goroutine writes distinct index
}()
}
wg.Wait()
}
results = append(results, EvalResult{
Mode: "legacy-llm",
SampleID: sample.SampleID,
QAResults: qaResults,
Agg: aggregateMetrics(qaResults),
})
}
return results
}
// buildSeahorseContext retrieves context for a seahorse QA item.
func buildSeahorseContext(
ctx context.Context,
ir *SeahorseIngestResult,
sample *LocomoSample,
qa *LocomoQA,
budgetTokens int,
) string {
store := ir.Engine.GetRetrieval().Store()
retrieval := ir.Engine.GetRetrieval()
convID := ir.ConvMap[sample.SampleID]
keywords := ExtractKeywords(qa.Question)
bestRank := map[int64]float64{}
for _, kw := range keywords {
searchResults, err := store.SearchMessages(ctx, seahorse.SearchInput{
Pattern: kw,
ConversationID: convID,
Limit: 20,
})
if err != nil {
continue
}
for _, sr := range searchResults {
if sr.MessageID > 0 {
if prev, ok := bestRank[sr.MessageID]; !ok || sr.Rank < prev {
bestRank[sr.MessageID] = sr.Rank
}
}
}
}
messageIDs := make([]int64, 0, len(bestRank))
for id := range bestRank {
messageIDs = append(messageIDs, id)
}
sort.Slice(messageIDs, func(i, j int) bool {
return bestRank[messageIDs[i]] < bestRank[messageIDs[j]]
})
var contentParts []string
if len(messageIDs) > 0 {
expandResult, err := retrieval.ExpandMessages(ctx, messageIDs)
if err == nil {
for _, msg := range expandResult.Messages {
contentParts = append(contentParts, msg.Content)
}
}
}
if len(contentParts) == 0 {
return ""
}
truncated, _ := BudgetTruncate(contentParts, budgetTokens)
return StringListToContent(truncated)
}
// EvalSeahorseLLM evaluates seahorse retrieval using LLM generation + LLM-as-Judge.
func EvalSeahorseLLM(
ctx context.Context,
samples []LocomoSample,
ir *SeahorseIngestResult,
budgetTokens int,
answerClient, judgeClient *LLMClient,
concurrency int,
) []EvalResult {
if concurrency < 1 {
concurrency = 1
}
totalQA := countTotalQA(samples)
results := make([]EvalResult, 0, len(samples))
for si := range samples {
sample := &samples[si]
if _, ok := ir.ConvMap[sample.SampleID]; !ok {
log.Printf("WARN: no conversation ID for sample %s", sample.SampleID)
continue
}
qaResults := make([]QAResult, len(sample.QA))
evalOne := func(qi int) {
qa := &sample.QA[qi]
contextText := buildSeahorseContext(ctx, ir, sample, qa, budgetTokens)
if contextText == "" {
qaResults[qi] = QAResult{
Question: qa.Question,
Category: qa.Category,
GoldAnswer: qa.AnswerString(),
TokenF1: 0.0,
HitRate: 0.0,
}
log.Printf("[seahorse-llm] sample=%s q=%d/%d score=0.00 answer=(no context)",
sample.SampleID, si*len(sample.QA)+qi+1, totalQA)
return
}
out := evalQAWorker(ctx, qaWork{
sampleID: sample.SampleID, qaIndex: qi,
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
qa: qa, contextText: contextText, sample: sample,
}, answerClient, judgeClient, "seahorse-llm")
qaResults[qi] = out.result
}
if concurrency <= 1 {
for qi := range sample.QA {
evalOne(qi)
}
} else {
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
for qi := range sample.QA {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
evalOne(qi)
}()
}
wg.Wait()
}
results = append(results, EvalResult{
Mode: "seahorse-llm",
SampleID: sample.SampleID,
QAResults: qaResults,
Agg: aggregateMetrics(qaResults),
})
}
return results
}
func countTotalQA(samples []LocomoSample) int {
n := 0
for i := range samples {
n += len(samples[i].QA)
}
return n
}
func truncateStr(s string, maxLen int) string {
s = strings.ReplaceAll(s, "\n", " ")
runes := []rune(s)
if len(runes) > maxLen {
return string(runes[:maxLen]) + "..."
}
return s
}
+78
View File
@@ -102,3 +102,81 @@ func TestComputeModeAgg(t *testing.T) {
t.Errorf("TotalQuestions = %d, want 10", got.TotalQuestions)
}
}
func TestAggregateMetricsSentinel(t *testing.T) {
qa := []QAResult{
{Category: 1, TokenF1: 0.8, HitRate: 0.5},
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
{Category: 1, TokenF1: 0.4, HitRate: 0.7},
}
agg := aggregateMetrics(qa)
if agg.ValidF1Count != 2 {
t.Errorf("ValidF1Count = %d, want 2", agg.ValidF1Count)
}
if agg.TotalQuestions != 3 {
t.Errorf("TotalQuestions = %d, want 3", agg.TotalQuestions)
}
wantF1 := (0.8 + 0.4) / 2.0
if math.Abs(agg.OverallF1-wantF1) > 1e-9 {
t.Errorf("OverallF1 = %.6f, want %.6f", agg.OverallF1, wantF1)
}
wantHR := (0.5 + 0.3 + 0.7) / 3.0
if math.Abs(agg.OverallHitRate-wantHR) > 1e-9 {
t.Errorf("OverallHitRate = %.6f, want %.6f", agg.OverallHitRate, wantHR)
}
}
func TestAggregateMetricsAllSentinel(t *testing.T) {
qa := []QAResult{
{Category: 1, TokenF1: -1.0, HitRate: 0.5},
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
}
agg := aggregateMetrics(qa)
if agg.ValidF1Count != 0 {
t.Errorf("ValidF1Count = %d, want 0", agg.ValidF1Count)
}
if agg.OverallF1 != 0 {
t.Errorf("OverallF1 = %.6f, want 0", agg.OverallF1)
}
}
func TestComputeModeAggSentinelWeighting(t *testing.T) {
results := []EvalResult{
{
Mode: "test",
SampleID: "s1",
QAResults: []QAResult{
{Category: 1, TokenF1: 0.8, HitRate: 0.5},
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
},
},
{
Mode: "test",
SampleID: "s2",
QAResults: []QAResult{
{Category: 1, TokenF1: 0.4, HitRate: 0.6},
{Category: 1, TokenF1: 0.6, HitRate: 0.8},
},
},
}
for i := range results {
results[i].Agg = aggregateMetrics(results[i].QAResults)
}
got := computeModeAgg(results)
// s1: ValidF1Count=1, F1=0.8; s2: ValidF1Count=2, F1=0.5
// Weighted: (0.8*1 + 0.5*2) / 3 = 1.8/3 = 0.6
wantF1 := 0.6
if math.Abs(got.OverallF1-wantF1) > 1e-9 {
t.Errorf("OverallF1 = %.6f, want %.6f", got.OverallF1, wantF1)
}
if got.ValidF1Count != 3 {
t.Errorf("ValidF1Count = %d, want 3", got.ValidF1Count)
}
if got.TotalQuestions != 4 {
t.Errorf("TotalQuestions = %d, want 4", got.TotalQuestions)
}
}
+198
View File
@@ -0,0 +1,198 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
)
// LLMClient wraps an OpenAI-compatible chat completion endpoint.
type LLMClient struct {
BaseURL string
Model string
APIKey string
NoThinking bool // send chat_template_kwargs to disable thinking (llama.cpp specific)
MaxRetries int // max retry attempts for transient errors (0 = no retry)
Client *http.Client
}
// LLMClientOptions configures the LLM client.
type LLMClientOptions struct {
BaseURL string
Model string
APIKey string
Timeout time.Duration
NoThinking bool
MaxRetries int // max retry attempts (default 3)
}
// NewLLMClient creates a client for an OpenAI-compatible chat completion API.
func NewLLMClient(opts LLMClientOptions) *LLMClient {
if opts.Timeout == 0 {
opts.Timeout = 120 * time.Second
}
maxRetries := opts.MaxRetries
if maxRetries < 0 {
maxRetries = 3
}
return &LLMClient{
BaseURL: strings.TrimRight(opts.BaseURL, "/"),
Model: opts.Model,
APIKey: opts.APIKey,
NoThinking: opts.NoThinking,
MaxRetries: maxRetries,
Client: &http.Client{
Timeout: opts.Timeout,
},
}
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"` // llama.cpp
Think *bool `json:"think,omitempty"` // Ollama
Thinking map[string]any `json:"thinking,omitempty"` // GLM (智谱)
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
} `json:"message"`
} `json:"choices"`
}
// Complete sends a chat completion request and returns the assistant's reply.
func (c *LLMClient) Complete(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
sysContent := systemPrompt
if c.NoThinking && sysContent != "" {
// Prepend /no_think tag — works with Ollama /v1 endpoint and
// Qwen chat templates where the JSON think field is ignored.
sysContent = "/no_think\n" + sysContent
}
messages := []chatMessage{}
if sysContent != "" {
messages = append(messages, chatMessage{Role: "system", Content: sysContent})
}
messages = append(messages, chatMessage{Role: "user", Content: userPrompt})
body := chatRequest{
Model: c.Model,
Messages: messages,
Temperature: 0.1,
MaxTokens: 512,
}
if c.NoThinking {
// llama.cpp: chat_template_kwargs
body.ChatTemplateKwargs = map[string]any{
"enable_thinking": false,
}
// Ollama (0.9+): think field
thinkFalse := false
body.Think = &thinkFalse
// GLM (智谱): thinking field
body.Thinking = map[string]any{
"type": "disabled",
}
}
jsonBody, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
endpoint := strings.TrimRight(c.BaseURL, "/") + "/chat/completions"
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.APIKey)
}
var respBody []byte
var lastErr error
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second // 1s, 2s, 4s, ...
log.Printf("LLM retry %d/%d after %v: %v", attempt, c.MaxRetries, backoff, lastErr)
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(backoff):
}
// Rebuild request (body reader is consumed)
req, err = http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.APIKey)
}
}
var resp *http.Response
resp, lastErr = c.Client.Do(req)
if lastErr != nil {
continue // network/timeout error → retry
}
respBody, lastErr = io.ReadAll(resp.Body)
resp.Body.Close()
if lastErr != nil {
continue
}
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
lastErr = fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
continue // rate limit or server error → retry
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
}
lastErr = nil
break
}
if lastErr != nil {
return "", fmt.Errorf("after %d retries: %w", c.MaxRetries, lastErr)
}
var chatResp chatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("no choices in response")
}
content := strings.TrimSpace(chatResp.Choices[0].Message.Content)
// Strip any residual <think>...</think> blocks
if idx := strings.Index(content, "</think>"); idx >= 0 {
content = strings.TrimSpace(content[idx+len("</think>"):])
}
// Fallback: GLM/DeepSeek put thinking output in reasoning_content when thinking is enabled
if content == "" && chatResp.Choices[0].Message.ReasoningContent != "" {
content = strings.TrimSpace(chatResp.Choices[0].Message.ReasoningContent)
}
if content == "" {
return "", fmt.Errorf("empty LLM response")
}
return content, nil
}
+166 -13
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
@@ -15,10 +16,22 @@ import (
)
var (
flagData string
flagOut string
flagMode string
flagBudget int
flagData string
flagOut string
flagMode string
flagBudget int
flagEvalMode string
flagAPIBase string
flagAPIKey string
flagModel string
flagNoThinking bool
flagLimit int
flagTimeout int
flagRetries int
flagJudgeModel string
flagJudgeAPIBase string
flagJudgeAPIKey string
flagConcurrency int
)
func main() {
@@ -48,6 +61,22 @@ func main() {
evalCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
evalCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to evaluate: legacy, seahorse, or all")
evalCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
evalCmd.Flags().
StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)")
evalCmd.Flags().
StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)")
evalCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)")
evalCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)")
evalCmd.Flags().
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
evalCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
evalCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
evalCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
evalCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)")
evalCmd.Flags().
StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)")
evalCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)")
evalCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations")
reportCmd := &cobra.Command{
Use: "report",
@@ -65,6 +94,22 @@ func main() {
runCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
runCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to run: legacy, seahorse, or all")
runCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
runCmd.Flags().
StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)")
runCmd.Flags().
StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)")
runCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)")
runCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)")
runCmd.Flags().
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
runCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
runCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
runCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
runCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)")
runCmd.Flags().
StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)")
runCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)")
runCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations")
rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd)
@@ -136,7 +181,50 @@ func runEval(cmd *cobra.Command, args []string) error {
}
log.Printf("Loaded %d samples", len(samples))
var allResults []EvalResult
if flagLimit > 0 {
for i := range samples {
if len(samples[i].QA) > flagLimit {
samples[i].QA = samples[i].QA[:flagLimit]
}
}
log.Printf("Limited to %d QA per sample", flagLimit)
}
evalMode := strings.ToLower(strings.TrimSpace(flagEvalMode))
var useLLM bool
switch evalMode {
case "token":
useLLM = false
case "llm":
useLLM = true
default:
return fmt.Errorf("invalid --eval-mode %q: must be token or llm", flagEvalMode)
}
var answerClient, judgeClient *LLMClient
if useLLM {
opts, err := buildLLMOptions()
if err != nil {
return err
}
answerClient = NewLLMClient(opts)
judgeClient = answerClient // default: same client
if flagJudgeModel != "" {
jOpts := opts // copy base settings
jOpts.Model = flagJudgeModel
if flagJudgeAPIBase != "" {
jOpts.BaseURL = flagJudgeAPIBase
}
if flagJudgeAPIKey != "" {
jOpts.APIKey = flagJudgeAPIKey
}
judgeClient = NewLLMClient(jOpts)
log.Printf("Judge model: model=%s base=%s no-thinking=%v", jOpts.Model, jOpts.BaseURL, jOpts.NoThinking)
}
log.Printf("LLM eval mode: model=%s base=%s no-thinking=%v concurrency=%d",
opts.Model, opts.BaseURL, opts.NoThinking, flagConcurrency)
}
var tokenResults, llmResults []EvalResult
for _, mode := range modes {
switch mode {
@@ -145,21 +233,34 @@ func runEval(cmd *cobra.Command, args []string) error {
for i := range samples {
legacy.IngestSample(&samples[i])
}
results := EvalLegacy(ctx, samples, legacy, flagBudget)
allResults = append(allResults, results...)
log.Printf("legacy: evaluated %d samples", len(results))
if useLLM {
results := EvalLegacyLLM(ctx, samples, legacy, flagBudget, answerClient, judgeClient, flagConcurrency)
llmResults = append(llmResults, results...)
log.Printf("legacy-llm: evaluated %d samples", len(results))
} else {
results := EvalLegacy(ctx, samples, legacy, flagBudget)
tokenResults = append(tokenResults, results...)
log.Printf("legacy: evaluated %d samples", len(results))
}
case "seahorse":
dbPath := filepath.Join(flagOut, "seahorse.db")
ir, err := IngestSeahorse(ctx, samples, dbPath)
if err != nil {
return fmt.Errorf("ingest seahorse: %w", err)
}
results := EvalSeahorse(ctx, samples, ir, flagBudget)
allResults = append(allResults, results...)
log.Printf("seahorse: evaluated %d samples", len(results))
if useLLM {
results := EvalSeahorseLLM(ctx, samples, ir, flagBudget, answerClient, judgeClient, flagConcurrency)
llmResults = append(llmResults, results...)
log.Printf("seahorse-llm: evaluated %d samples", len(results))
} else {
results := EvalSeahorse(ctx, samples, ir, flagBudget)
tokenResults = append(tokenResults, results...)
log.Printf("seahorse: evaluated %d samples", len(results))
}
}
}
allResults := append(tokenResults, llmResults...)
if err := SaveResults(allResults, flagOut); err != nil {
return fmt.Errorf("save results: %w", err)
}
@@ -167,7 +268,7 @@ func runEval(cmd *cobra.Command, args []string) error {
return fmt.Errorf("save aggregated: %w", err)
}
PrintComparison(allResults, nil)
PrintComparison(tokenResults, llmResults)
return nil
}
@@ -199,10 +300,62 @@ func runReport(cmd *cobra.Command, args []string) error {
return fmt.Errorf("no eval results found in %s", flagOut)
}
PrintComparison(allResults, nil)
var tokenResults, llmResults []EvalResult
for _, r := range allResults {
if strings.HasSuffix(r.Mode, "-llm") {
llmResults = append(llmResults, r)
} else {
tokenResults = append(tokenResults, r)
}
}
PrintComparison(tokenResults, llmResults)
return nil
}
func runAll(cmd *cobra.Command, args []string) error {
return runEval(cmd, args)
}
// envOrFlag returns the flag value if non-empty, otherwise falls back to the
// environment variable.
func envOrFlag(flag, envKey string) string {
if flag != "" {
return flag
}
return os.Getenv(envKey)
}
// buildLLMOptions resolves LLM client configuration from flags and environment
// variables. Flag values take precedence over environment variables.
//
// Environment variables:
//
// MEMBENCH_API_BASE OpenAI-compatible base URL (default http://127.0.0.1:8080/v1)
// MEMBENCH_API_KEY Bearer token for the endpoint
// MEMBENCH_MODEL Model name to send in the request
func buildLLMOptions() (LLMClientOptions, error) {
base := envOrFlag(flagAPIBase, "MEMBENCH_API_BASE")
if base == "" {
base = "http://127.0.0.1:8080/v1"
}
model := envOrFlag(flagModel, "MEMBENCH_MODEL")
if model == "" {
return LLMClientOptions{}, fmt.Errorf(
"--model or MEMBENCH_MODEL is required for LLM eval mode",
)
}
apiKey := envOrFlag(flagAPIKey, "MEMBENCH_API_KEY")
if flagTimeout <= 0 {
return LLMClientOptions{}, fmt.Errorf("--timeout must be > 0, got %d", flagTimeout)
}
return LLMClientOptions{
BaseURL: base,
Model: model,
APIKey: apiKey,
NoThinking: flagNoThinking,
Timeout: time.Duration(flagTimeout) * time.Second,
MaxRetries: flagRetries,
}, nil
}
+8 -8
View File
@@ -17,24 +17,24 @@ import (
)
const (
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity, antigravity"
defaultAnthropicModel = "claude-sonnet-4.6"
)
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool, noBrowser bool) error {
switch provider {
case "openai":
return authLoginOpenAI(useDeviceCode)
return authLoginOpenAI(useDeviceCode, noBrowser)
case "anthropic":
return authLoginAnthropic(useOauth)
case "google-antigravity", "antigravity":
return authLoginGoogleAntigravity()
return authLoginGoogleAntigravity(noBrowser)
default:
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
}
}
func authLoginOpenAI(useDeviceCode bool) error {
func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
cfg := auth.OpenAIOAuthConfig()
var cred *auth.AuthCredential
@@ -43,7 +43,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
if useDeviceCode {
cred, err = auth.LoginDeviceCode(cfg)
} else {
cred, err = auth.LoginBrowser(cfg)
cred, err = auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
}
if err != nil {
@@ -92,10 +92,10 @@ func authLoginOpenAI(useDeviceCode bool) error {
return nil
}
func authLoginGoogleAntigravity() error {
func authLoginGoogleAntigravity(noBrowser bool) error {
cfg := auth.GoogleAntigravityOAuthConfig()
cred, err := auth.LoginBrowser(cfg)
cred, err := auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
+6 -2
View File
@@ -7,6 +7,7 @@ func newLoginCommand() *cobra.Command {
provider string
useDeviceCode bool
useOauth bool
noBrowser bool
)
cmd := &cobra.Command{
@@ -14,12 +15,15 @@ func newLoginCommand() *cobra.Command {
Short: "Login via OAuth or paste token",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return authLoginCmd(provider, useDeviceCode, useOauth)
return authLoginCmd(provider, useDeviceCode, useOauth, noBrowser)
},
}
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
cmd.Flags().StringVarP(
&provider, "provider", "p", "", "Provider to login with (openai, anthropic, google-antigravity, antigravity)",
)
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Do not auto-open a browser during OAuth login")
cmd.Flags().BoolVar(
&useOauth, "setup-token", false,
"Use setup-token flow for Anthropic (from `claude setup-token`)",
+1
View File
@@ -18,6 +18,7 @@ func TestNewLoginSubCommand(t *testing.T) {
assert.True(t, cmd.HasFlags())
assert.NotNil(t, cmd.Flags().Lookup("device-code"))
assert.NotNil(t, cmd.Flags().Lookup("no-browser"))
providerFlag := cmd.Flags().Lookup("provider")
require.NotNil(t, providerFlag)
+85
View File
@@ -1,12 +1,53 @@
package auth
import (
"bytes"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pkgauth "github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
func captureAuthStdout(t *testing.T, fn func()) string {
t.Helper()
oldStdout := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w
t.Cleanup(func() {
os.Stdout = oldStdout
})
fn()
require.NoError(t, w.Close())
os.Stdout = oldStdout
var buf bytes.Buffer
_, err = io.Copy(&buf, r)
require.NoError(t, err)
require.NoError(t, r.Close())
return buf.String()
}
func setAuthStatusTestHome(t *testing.T) string {
t.Helper()
tmpDir := t.TempDir()
t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw"))
return tmpDir
}
func TestNewStatusSubcommand(t *testing.T) {
cmd := newStatusCommand()
@@ -16,3 +57,47 @@ func TestNewStatusSubcommand(t *testing.T) {
assert.False(t, cmd.HasFlags())
}
func TestAuthStatusCmdShowsCanonicalGoogleAntigravityAfterLegacyRefresh(t *testing.T) {
tmpDir := setAuthStatusTestHome(t)
legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC)
legacyStore := map[string]any{
"credentials": map[string]any{
"antigravity": map[string]any{
"access_token": "legacy-token",
"expires_at": legacyExpiry.Format(time.RFC3339),
"provider": "antigravity",
"auth_method": "oauth",
"project_id": "legacy-project",
},
},
}
data, err := json.Marshal(legacyStore)
require.NoError(t, err)
authPath := filepath.Join(tmpDir, ".picoclaw", "auth.json")
require.NoError(t, os.MkdirAll(filepath.Dir(authPath), 0o755))
require.NoError(t, os.WriteFile(authPath, data, 0o600))
refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC)
err = pkgauth.SetCredential("google-antigravity", &pkgauth.AuthCredential{
AccessToken: "fresh-token",
ExpiresAt: refreshedExpiry,
Provider: "google-antigravity",
AuthMethod: "oauth",
ProjectID: "fresh-project",
})
require.NoError(t, err)
output := captureAuthStdout(t, func() {
require.NoError(t, authStatusCmd())
})
assert.Contains(t, output, "\nAuthenticated Providers:")
assert.Contains(t, output, "\n google-antigravity:\n")
assert.NotContains(t, output, "\n antigravity:\n")
assert.Contains(t, output, " Project: fresh-project")
assert.Contains(t, output, " Expires: 2026-04-16 12:30")
assert.Equal(t, 1, strings.Count(output, ":\n Method: oauth"))
}
+26 -5
View File
@@ -19,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
const (
@@ -155,11 +156,31 @@ func defaultWeComQRFlowOptions(timeout time.Duration) wecomQRFlowOptions {
}
func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) {
cfg.Channels.WeCom.Enabled = true
cfg.Channels.WeCom.BotID = botInfo.BotID
cfg.Channels.WeCom.SetSecret(botInfo.Secret)
if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" {
cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL
bc := cfg.Channels.GetByType(config.ChannelWeCom)
if bc == nil {
bc = &config.Channel{Type: config.ChannelWeCom}
cfg.Channels["wecom"] = bc
}
bc.Enabled = true
decoded, err := bc.GetDecoded()
if err != nil {
logger.ErrorCF("wecom", "failed to decode WeCom settings", map[string]any{
"error": err.Error(),
})
return
}
wecomCfg, ok := decoded.(*config.WeComSettings)
if !ok {
logger.ErrorCF("wecom", "unexpected WeCom settings type", map[string]any{
"got": fmt.Sprintf("%T", decoded),
})
return
}
wecomCfg.BotID = botInfo.BotID
wecomCfg.Secret = *config.NewSecureString(botInfo.Secret)
if strings.TrimSpace(wecomCfg.WebSocketURL) == "" {
wecomCfg.WebSocketURL = wecomDefaultWebSocketURL
}
}
+19 -9
View File
@@ -112,17 +112,23 @@ func TestPollWeComQRCodeResult(t *testing.T) {
func TestApplyWeComAuthResult(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Channels.WeCom.WebSocketURL = ""
require.NoError(t, config.InitChannelList(cfg.Channels))
wecom := cfg.Channels["wecom"]
t.Logf("wecom: %+v", wecom)
decoded, err := wecom.GetDecoded()
require.NoError(t, err)
weCfg := decoded.(*config.WeComSettings)
weCfg.WebSocketURL = ""
applyWeComAuthResult(cfg, wecomQRBotInfo{
BotID: "bot-1",
Secret: "secret-1",
})
assert.True(t, cfg.Channels.WeCom.Enabled)
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String())
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
assert.True(t, wecom.Enabled)
assert.Equal(t, "bot-1", weCfg.BotID)
assert.Equal(t, "secret-1", weCfg.Secret.String())
assert.Equal(t, wecomDefaultWebSocketURL, weCfg.WebSocketURL)
}
func TestAuthWeComCmdWithScanner(t *testing.T) {
@@ -149,9 +155,13 @@ func TestAuthWeComCmdWithScanner(t *testing.T) {
cfg, err := config.LoadConfig(internal.GetConfigPath())
require.NoError(t, err)
assert.True(t, cfg.Channels.WeCom.Enabled)
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String())
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
wecom := cfg.Channels["wecom"]
decoded, err := wecom.GetDecoded()
require.NoError(t, err)
weCfg := decoded.(*config.WeComSettings)
assert.True(t, wecom.Enabled)
assert.Equal(t, "bot-1", weCfg.BotID)
assert.Equal(t, "secret-1", weCfg.Secret.String())
assert.Equal(t, wecomDefaultWebSocketURL, weCfg.WebSocketURL)
assert.Contains(t, output.String(), "WeCom connected.")
}
+17 -7
View File
@@ -95,14 +95,24 @@ func saveWeixinConfig(token, baseURL, proxy string) error {
return fmt.Errorf("failed to load config: %w", err)
}
cfg.Channels.Weixin.Enabled = true
cfg.Channels.Weixin.SetToken(token)
const defaultBase = "https://ilinkai.weixin.qq.com/"
if baseURL != "" && baseURL != defaultBase {
cfg.Channels.Weixin.BaseURL = baseURL
bc := cfg.Channels.GetByType(config.ChannelWeixin)
if bc == nil {
bc = &config.Channel{Type: config.ChannelWeixin}
cfg.Channels[config.ChannelWeixin] = bc
}
if proxy != "" {
cfg.Channels.Weixin.Proxy = proxy
bc.Enabled = true
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
if weixinCfg, ok := decoded.(*config.WeixinSettings); ok {
weixinCfg.Token = *config.NewSecureString(token)
const defaultBase = "https://ilinkai.weixin.qq.com/"
if baseURL != "" && baseURL != defaultBase {
weixinCfg.BaseURL = baseURL
}
if proxy != "" {
weixinCfg.Proxy = proxy
}
}
}
return config.SaveConfig(cfgPath, cfg)
+147
View File
@@ -0,0 +1,147 @@
// Package cliui renders human-oriented CLI output: bordered panels and columns
// on wide interactive terminals. Layout (boxes/columns) is independent of ANSI
// color: use --no-color or NO_COLOR to disable colors only; narrow or non-TTY
// stdout falls back to plain line-oriented output.
package cliui
import (
"os"
"sync"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"golang.org/x/term"
)
// Minimum terminal width (columns) for bordered / structured layout.
// Below this, plain line-oriented output is used so boxes do not wrap badly.
const minWidthFancy = 88
// Minimum width to lay out some views in two columns (e.g. status providers).
const minWidthColumns = 104
var initMu sync.Mutex
// Init configures lipgloss for this process. When disableAnsiColors is true
// (e.g. --no-color, NO_COLOR, or TERM=dumb), only color is turned off; Unicode
// borders still render when UseFancyLayout() is true.
func Init(disableAnsiColors bool) {
initMu.Lock()
defer initMu.Unlock()
if disableAnsiColors {
lipgloss.SetColorProfile(termenv.Ascii)
return
}
lipgloss.SetColorProfile(termenv.EnvColorProfile())
}
// StdoutWidth returns the terminal width or a sane default if unknown.
func StdoutWidth() int {
w, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || w < 20 {
return 80
}
return w
}
// UseFancyLayout is true when styled boxes/columns should be used.
func UseFancyLayout() bool {
if !term.IsTerminal(int(os.Stdout.Fd())) {
return false
}
return StdoutWidth() >= minWidthFancy
}
// UseColumnLayout is true when a second content column is viable.
func UseColumnLayout() bool {
return UseFancyLayout() && StdoutWidth() >= minWidthColumns
}
// InnerWidth is the target content width inside borders/margins.
func InnerWidth() int {
w := StdoutWidth()
// Rounded border + horizontal padding (lipgloss borders ~= 2 cols each side + padding).
const borderBudget = 8
if w > borderBudget+48 {
return w - borderBudget
}
return 48
}
// StderrWidth returns stderr terminal width or a sane default.
func StderrWidth() int {
w, _, err := term.GetSize(int(os.Stderr.Fd()))
if err != nil || w < 20 {
return 80
}
return w
}
// UseFancyStderr is true when stderr can show boxed errors without ugly wraps.
func UseFancyStderr() bool {
if !term.IsTerminal(int(os.Stderr.Fd())) {
return false
}
return StderrWidth() >= minWidthFancy
}
// InnerStderrWidth mirrors InnerWidth but for stderr.
func InnerStderrWidth() int {
w := StderrWidth()
const borderBudget = 8
if w > borderBudget+48 {
return w - borderBudget
}
return 48
}
var (
accentBlue = lipgloss.Color("#3E5DB9")
accentRed = lipgloss.Color("#D54646")
colorMuted = lipgloss.Color("#6B6B6B")
colorOK = lipgloss.Color("#2E7D32")
)
func borderStyle() lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accentBlue).
Padding(0, 1)
}
func titleBarStyle() lipgloss.Style {
return lipgloss.NewStyle().
Foreground(accentRed).
Bold(true)
}
func mutedStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(colorMuted)
}
func bodyStyle() lipgloss.Style {
return lipgloss.NewStyle()
}
func kvKeyStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
}
func kvValStyle() lipgloss.Style {
return lipgloss.NewStyle()
}
// helpIntroStyle is the top tagline (PicoClaw blue, matches ASCII banner left side).
func helpIntroStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
}
// helpIdentStyle is the left column for commands and flags (blue identifiers).
func helpIdentStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
}
// helpPlaceholderStyle highlights <placeholders> in usage lines (red accent).
func helpPlaceholderStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentRed).Bold(true)
}
+180
View File
@@ -0,0 +1,180 @@
package cliui
import (
"testing"
flag "github.com/spf13/pflag"
)
func init() {
// Disable ANSI colors in tests so output is predictable plain text.
Init(true)
}
// ---------------------------------------------------------------------------
// showErrHint
// ---------------------------------------------------------------------------
func TestShowErrHint(t *testing.T) {
cases := []struct {
msg string
want bool
}{
// Cobra flag errors — should show hint
{"unknown flag: --foo", true},
{"unknown shorthand flag: 'f' in -f", true},
{"flag needs an argument: --output", true},
{"required flag(s) \"model\" not set", true},
// Generic invalid-argument errors — should show hint
{"invalid argument \"abc\" for --count", true},
// required flag errors — should show hint
{"required flag(s) \"model\" not set", true},
// usage: in message — should show hint
{"bad input\nusage: picoclaw ...", true},
// Should NOT false-positive on broad words
{"connection flagged by remote", false},
{"feature flag not set", false},
{"invalid API key provided", false},
{"authentication required", false},
// Unrelated messages — no hint
{"something went wrong", false},
{"network timeout", false},
}
for _, tc := range cases {
got := showErrHint(tc.msg)
if got != tc.want {
t.Errorf("showErrHint(%q) = %v, want %v", tc.msg, got, tc.want)
}
}
}
// ---------------------------------------------------------------------------
// styleUsageTokens
// ---------------------------------------------------------------------------
func TestStyleUsageTokensContainsTokens(t *testing.T) {
cases := []struct {
input string
contains []string // substrings that must appear in plain output
}{
{
"picoclaw agent <message>",
[]string{"picoclaw agent", "<message>"},
},
{
"picoclaw [command] [flags]",
[]string{"picoclaw", "[command]", "[flags]"},
},
{
"picoclaw",
[]string{"picoclaw"},
},
{
"cmd <arg1> [--flag]",
[]string{"cmd", "<arg1>", "[--flag]"},
},
}
for _, tc := range cases {
out := styleUsageTokens(tc.input)
for _, sub := range tc.contains {
if !containsStripped(out, sub) {
t.Errorf("styleUsageTokens(%q): output %q does not contain %q", tc.input, out, sub)
}
}
}
}
// containsStripped checks whether plain contains sub after stripping ANSI escapes.
// Since Init(true) sets Ascii profile, lipgloss emits no escape codes in tests,
// so this is just a plain substring check.
func containsStripped(plain, sub string) bool {
return len(plain) >= len(sub) && findSubstring(plain, sub)
}
func findSubstring(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
// ---------------------------------------------------------------------------
// collectFlagRows
// ---------------------------------------------------------------------------
func TestCollectFlagRows_Empty(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
rows := collectFlagRows(fs)
if len(rows) != 0 {
t.Fatalf("expected 0 rows for empty FlagSet, got %d", len(rows))
}
}
func TestCollectFlagRows_BasicFlags(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.String("output", "", "output file path")
fs.Bool("verbose", false, "enable verbose mode")
fs.Int("count", 1, "number of items")
rows := collectFlagRows(fs)
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
// Rows must be sorted alphabetically by flag name.
names := make([]string, 0, len(rows))
for _, r := range rows {
names = append(names, r[0])
}
if names[0] > names[1] || names[1] > names[2] {
t.Errorf("rows not sorted: %v", names)
}
}
func TestCollectFlagRows_Shorthand(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.StringP("model", "m", "", "model name")
rows := collectFlagRows(fs)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
left := rows[0][0]
if !findSubstring(left, "-m") || !findSubstring(left, "--model") {
t.Errorf("expected shorthand and long form in %q", left)
}
}
func TestCollectFlagRows_HiddenFlagsExcluded(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.String("visible", "", "this shows up")
hidden := fs.String("hidden", "", "this should not show up")
_ = hidden
_ = fs.MarkHidden("hidden")
rows := collectFlagRows(fs)
if len(rows) != 1 {
t.Fatalf("expected 1 row (hidden excluded), got %d", len(rows))
}
if !findSubstring(rows[0][0], "visible") {
t.Errorf("expected visible flag in rows, got %q", rows[0][0])
}
}
func TestCollectFlagRows_UsageInRightColumn(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.String("format", "json", "output format: json or text")
rows := collectFlagRows(fs)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0][1] != "output format: json or text" {
t.Errorf("expected usage in right column, got %q", rows[0][1])
}
}
+298
View File
@@ -0,0 +1,298 @@
package cliui
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
flag "github.com/spf13/pflag"
)
// RenderCommandHelp builds Ruff-style sectioned, two-column help when
// UseFancyLayout(); otherwise plain Cobra-style text.
func RenderCommandHelp(c *cobra.Command) string {
if !UseFancyLayout() {
return plainCommandHelp(c)
}
syncFlags(c)
var b strings.Builder
head, sub := helpIntro(c)
if head != "" {
b.WriteString(helpIntroStyle().Render(head))
b.WriteString("\n")
}
if sub != "" {
b.WriteString(mutedStyle().Render(sub))
b.WriteString("\n")
}
if head != "" || sub != "" {
b.WriteString("\n")
}
inner := InnerWidth()
contentW := inner - 6
if contentW < 36 {
contentW = 36
}
// Usage
usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
b.WriteString(sectionPanel("Usage", usageBody, inner))
b.WriteString("\n")
// Examples
if ex := strings.TrimSpace(c.Example); ex != "" {
exBody := bodyStyle().Width(contentW).Render(ex)
b.WriteString(sectionPanel("Examples", exBody, inner))
b.WriteString("\n")
}
// Subcommands
subs := visibleSubcommands(c)
if len(subs) > 0 {
rows := make([][2]string, 0, len(subs))
for _, sub := range subs {
left := sub.Name()
if a := sub.Aliases; len(a) > 0 {
left += " (" + strings.Join(a, ", ") + ")"
}
rows = append(rows, [2]string{left, sub.Short})
}
b.WriteString(sectionPanel("Commands", renderTwoColPairs(rows, contentW), inner))
b.WriteString("\n")
}
// Local options
local := c.LocalFlags()
opts := collectFlagRows(local)
if len(opts) > 0 {
title := "Options"
if !c.HasParent() {
title = "Flags"
}
b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), inner))
b.WriteString("\n")
}
// Global (inherited) options
if c.HasAvailableInheritedFlags() {
inh := collectFlagRows(c.InheritedFlags())
if len(inh) > 0 {
b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), inner))
b.WriteString("\n")
}
}
return b.String()
}
// RenderCommandQuickRef prints the same Usage / Flags / Global sections as help,
// for embedding after errors (stderr). outerW is typically InnerStderrWidth().
func RenderCommandQuickRef(c *cobra.Command, outerW int) string {
if c == nil || outerW < 40 {
return ""
}
syncFlags(c)
contentW := outerW - 6
if contentW < 36 {
contentW = 36
}
var b strings.Builder
usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
b.WriteString(sectionPanel("Usage", usageBody, outerW))
b.WriteString("\n")
if len(c.Aliases) > 0 {
al := "Aliases: " + strings.Join(c.Aliases, ", ")
alBody := mutedStyle().MaxWidth(contentW).Render(al)
b.WriteString(sectionPanel("Aliases", alBody, outerW))
b.WriteString("\n")
}
opts := collectFlagRows(c.LocalFlags())
if len(opts) > 0 {
title := "Options"
if !c.HasParent() {
title = "Flags"
}
b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), outerW))
b.WriteString("\n")
}
if c.HasAvailableInheritedFlags() {
inh := collectFlagRows(c.InheritedFlags())
if len(inh) > 0 {
b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), outerW))
b.WriteString("\n")
}
}
return b.String()
}
func syncFlags(c *cobra.Command) {
_ = c.LocalFlags()
if c.HasAvailableInheritedFlags() {
_ = c.InheritedFlags()
}
}
func plainCommandHelp(c *cobra.Command) string {
desc := c.Long
if desc == "" {
desc = c.Short
}
desc = strings.TrimRight(desc, " \t\n\r")
var b strings.Builder
if desc != "" {
fmt.Fprintln(&b, desc)
fmt.Fprintln(&b)
}
if c.Runnable() || c.HasSubCommands() {
b.WriteString(c.UsageString())
}
return b.String()
}
func helpIntro(c *cobra.Command) (head, sub string) {
head = strings.TrimSpace(c.Short)
long := strings.TrimSpace(c.Long)
if long == "" || long == head {
return head, ""
}
lines := strings.Split(long, "\n")
var rest []string
for i, ln := range lines {
ln = strings.TrimSpace(ln)
if ln == "" {
continue
}
if i == 0 && ln == head {
continue
}
rest = append(rest, ln)
}
sub = strings.Join(rest, "\n")
return head, sub
}
func visibleSubcommands(c *cobra.Command) []*cobra.Command {
var out []*cobra.Command
for _, sub := range c.Commands() {
if sub.Hidden {
continue
}
out = append(out, sub)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out
}
func sectionPanel(title, body string, width int) string {
head := titleBarStyle().Render(title) + "\n\n"
return borderStyle().Width(width).Render(head + body)
}
// styleUsageTokens highlights PicoClaw-blue command tokens and red <placeholders>/[groups].
func styleUsageTokens(s string) string {
var b strings.Builder
for len(s) > 0 {
ia := strings.Index(s, "<")
ib := strings.Index(s, "[")
next, kind := -1, 0 // 1 = angle, 2 = bracket
switch {
case ia >= 0 && (ib < 0 || ia < ib):
next, kind = ia, 1
case ib >= 0:
next, kind = ib, 2
}
if next < 0 {
b.WriteString(helpIdentStyle().Render(s))
break
}
if next > 0 {
b.WriteString(helpIdentStyle().Render(s[:next]))
}
s = s[next:]
if kind == 1 {
j := strings.Index(s, ">")
if j < 0 {
b.WriteString(helpIdentStyle().Render(s))
break
}
b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
s = s[j+1:]
continue
}
j := strings.Index(s, "]")
if j < 0 {
b.WriteString(helpIdentStyle().Render(s))
break
}
b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
s = s[j+1:]
}
return b.String()
}
func collectFlagRows(fs *flag.FlagSet) [][2]string {
var names []string
seen := map[string][2]string{}
fs.VisitAll(func(f *flag.Flag) {
if f.Hidden {
return
}
left := formatFlagLeft(f)
right := f.Usage
if f.Deprecated != "" {
right += " (deprecated: " + f.Deprecated + ")"
}
names = append(names, f.Name)
seen[f.Name] = [2]string{left, right}
})
sort.Strings(names)
rows := make([][2]string, 0, len(names))
for _, n := range names {
rows = append(rows, seen[n])
}
return rows
}
func formatFlagLeft(f *flag.Flag) string {
if len(f.Shorthand) > 0 {
return "-" + f.Shorthand + ", --" + f.Name
}
return "--" + f.Name
}
func renderTwoColPairs(rows [][2]string, contentW int) string {
if len(rows) == 0 {
return ""
}
leftW := 0
for _, r := range rows {
if w := lipgloss.Width(r[0]); w > leftW {
leftW = w
}
}
const minLeft, maxLeft = 16, 34
if leftW < minLeft {
leftW = minLeft
}
if leftW > maxLeft {
leftW = maxLeft
}
gap := " "
rightW := contentW - leftW - lipgloss.Width(gap)
if rightW < 24 {
rightW = 24
}
var b strings.Builder
for _, r := range rows {
left := helpIdentStyle().Width(leftW).Align(lipgloss.Left).Render(r[0])
right := bodyStyle().Width(rightW).Render(strings.TrimSpace(r[1]))
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, gap, right))
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}
+75
View File
@@ -0,0 +1,75 @@
package cliui
import (
"strings"
"github.com/spf13/cobra"
)
// FormatCLIError formats errors with the same boxed sections as help. When ctx
// is the command that was running when the error occurred, Usage / Flags panels
// are appended so styling matches picoclaw -h.
func FormatCLIError(msg string, ctx *cobra.Command) string {
msg = strings.TrimRight(msg, "\n")
if !UseFancyStderr() {
s := "Error: " + msg + "\n"
if ctx != nil && showErrHint(msg) {
s += "\n" + plainCommandHelp(ctx)
}
return s
}
w := InnerStderrWidth()
contentW := w - 6
if contentW < 36 {
contentW = 36
}
title := titleBarStyle().Render("Error") + "\n\n"
paras := strings.Split(msg, "\n")
var body strings.Builder
for i, p := range paras {
p = strings.TrimRight(p, " ")
if p == "" {
continue
}
st := bodyStyle().Width(contentW)
if i > 0 {
body.WriteString("\n")
}
if i == 0 {
body.WriteString(st.Render(p))
} else {
body.WriteString(mutedStyle().Width(contentW).Render(p))
}
}
foot := ""
if showErrHint(msg) {
if ctx != nil {
foot = "\n\n" + mutedStyle().Width(contentW).
Render("Full command help: "+ctx.CommandPath()+" --help")
} else {
foot = "\n\n" + mutedStyle().Width(contentW).
Render("Tip: picoclaw --help · picoclaw <command> --help")
}
}
out := borderStyle().Width(w).Render(title+body.String()+foot) + "\n"
if ctx != nil && showErrHint(msg) {
if ref := RenderCommandQuickRef(ctx, w); ref != "" {
out += "\n" + ref
}
}
return out
}
func showErrHint(msg string) bool {
m := strings.ToLower(msg)
return strings.Contains(m, "unknown flag") ||
strings.Contains(m, "unknown shorthand flag") ||
strings.Contains(m, "flag needs an argument") ||
strings.Contains(m, "invalid argument") ||
strings.Contains(m, "required flag") ||
strings.Contains(m, "usage:")
}
+110
View File
@@ -0,0 +1,110 @@
package cliui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// PrintOnboardComplete prints the post-onboard “ready” message and next steps.
func PrintOnboardComplete(logo string, encrypt bool, configPath string) {
if !UseFancyLayout() {
printOnboardPlain(logo, encrypt, configPath)
return
}
printOnboardFancy(logo, encrypt, configPath)
}
func printOnboardPlain(logo string, encrypt bool, configPath string) {
fmt.Printf("\n%s picoclaw is ready!\n", logo)
fmt.Println("\nNext steps:")
if encrypt {
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
fmt.Println("")
fmt.Println(" 2. Add your API key to", configPath)
} else {
fmt.Println(" 1. Add your API key to", configPath)
}
fmt.Println("")
fmt.Println(" Recommended:")
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
fmt.Println(" - Ollama: https://ollama.com (local, free)")
fmt.Println("")
fmt.Println(" See README.md for 17+ supported providers.")
fmt.Println("")
if encrypt {
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
} else {
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
}
}
func printOnboardFancy(logo string, encrypt bool, configPath string) {
inner := InnerWidth()
box := borderStyle().MaxWidth(inner + 8)
ready := titleBarStyle().Render(logo+" picoclaw is ready!") + "\n"
fmt.Println()
fmt.Println(box.Width(inner).Render(strings.TrimSpace(ready)))
fmt.Println()
steps := buildOnboardingSteps(encrypt, configPath)
rec := recommendedBlock()
chat := chatStep(encrypt)
if UseColumnLayout() {
leftW := min(inner/2-2, 52)
rightW := inner - leftW - 4
if rightW < 36 {
rightW = 36
}
leftBlock := borderStyle().MaxWidth(leftW + 8).Width(leftW).
Render(titleBarStyle().Render("Next steps") + "\n\n" + bodyStyle().Width(leftW).Render(steps))
rightBlock := borderStyle().MaxWidth(rightW + 8).Width(rightW).
Render(mutedStyle().Bold(true).Render("Recommended") + "\n\n" + bodyStyle().Width(rightW).Render(rec))
gap := strings.Repeat(" ", 2)
fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, leftBlock, gap, rightBlock))
fmt.Println()
full := borderStyle().Width(inner).Render(bodyStyle().Width(inner - 4).Render(chat))
fmt.Println(full)
return
}
// Same order as plain output: numbered steps → recommended → chat line.
next := titleBarStyle().Render("Next steps") + "\n\n" +
bodyStyle().Width(inner-4).Render(steps+"\n\n"+rec+"\n\n"+chat)
fmt.Println(borderStyle().Width(inner).Render(next))
}
func buildOnboardingSteps(encrypt bool, configPath string) string {
var b strings.Builder
if encrypt {
b.WriteString("1. Set your encryption passphrase before starting picoclaw:\n")
b.WriteString(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS\n")
b.WriteString(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd\n\n")
b.WriteString("2. Add your API key to\n ")
b.WriteString(configPath)
b.WriteString("\n")
} else {
b.WriteString("1. Add your API key to\n ")
b.WriteString(configPath)
b.WriteString("\n")
}
return b.String()
}
func recommendedBlock() string {
return "• OpenRouter: https://openrouter.ai/keys\n (access 100+ models)\n\n" +
"• Ollama: https://ollama.com\n (local, free)\n\n" +
"See README.md for 17+ supported providers."
}
func chatStep(encrypt bool) string {
if encrypt {
return "3. Chat:\n picoclaw agent -m \"Hello!\""
}
return "2. Chat:\n picoclaw agent -m \"Hello!\""
}
+168
View File
@@ -0,0 +1,168 @@
package cliui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// ProviderRow holds one provider's display name and status value.
type ProviderRow struct {
Name string
Val string
}
// StatusReport is a structured status view for PrintStatus.
type StatusReport struct {
Logo string
Version string
Build string
ConfigPath string
ConfigOK bool
WorkspacePath string
WorkspaceOK bool
Model string
Providers []ProviderRow
OAuthLines []string // each full line "provider (method): state"
}
// PrintStatus renders picoclaw status (plain or fancy).
func PrintStatus(r StatusReport) {
if !UseFancyLayout() {
printStatusPlain(r)
return
}
printStatusFancy(r)
}
func printStatusPlain(r StatusReport) {
fmt.Printf("%s picoclaw Status\n", r.Logo)
fmt.Printf("Version: %s\n", r.Version)
if r.Build != "" {
fmt.Printf("Build: %s\n", r.Build)
}
fmt.Println()
printPathLine("Config", r.ConfigPath, r.ConfigOK)
printPathLine("Workspace", r.WorkspacePath, r.WorkspaceOK)
if r.ConfigOK {
fmt.Printf("Model: %s\n", r.Model)
for _, p := range r.Providers {
fmt.Printf("%s: %s\n", p.Name, p.Val)
}
if len(r.OAuthLines) > 0 {
fmt.Println("\nOAuth/Token Auth:")
for _, line := range r.OAuthLines {
fmt.Printf(" %s\n", line)
}
}
}
}
func printPathLine(label, path string, ok bool) {
mark := "✗"
if ok {
mark = "✓"
}
fmt.Println(label+":", path, mark)
}
func printStatusFancy(r StatusReport) {
inner := InnerWidth()
topBox := borderStyle().Width(inner)
var head strings.Builder
head.WriteString(titleBarStyle().Render(r.Logo + " picoclaw Status"))
head.WriteString("\n\n")
head.WriteString(kvKeyStyle().Render("Version") + " " + kvValStyle().Render(r.Version))
if r.Build != "" {
head.WriteString("\n")
head.WriteString(kvKeyStyle().Render("Build") + " " + kvValStyle().Render(r.Build))
}
fmt.Println(topBox.Render(head.String()))
fmt.Println()
if UseColumnLayout() && len(r.Providers) > 0 && r.ConfigOK {
leftW := (inner - 2) / 2
rightW := inner - leftW - 2
pathsNarrow := pathStatusPanel(r, leftW)
prov := providerTablePanel(r, rightW)
gap := strings.Repeat(" ", 2)
fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, pathsNarrow, gap, prov))
} else {
fmt.Println(pathStatusPanel(r, inner))
if len(r.Providers) > 0 && r.ConfigOK {
fmt.Println(providerTablePanel(r, inner))
}
}
if len(r.OAuthLines) > 0 && r.ConfigOK {
var ob strings.Builder
ob.WriteString(titleBarStyle().Render("OAuth / token auth") + "\n\n")
for _, line := range r.OAuthLines {
ob.WriteString(" • " + line + "\n")
}
fmt.Println()
fmt.Println(borderStyle().Width(inner).Render(ob.String()))
}
}
func pathStatusPanel(r StatusReport, inner int) string {
cfgMark := statusMark(r.ConfigOK)
wsMark := statusMark(r.WorkspaceOK)
var b strings.Builder
b.WriteString(kvKeyStyle().Render("Config") + "\n")
b.WriteString(mutedStyle().Render(r.ConfigPath))
b.WriteString(" " + cfgMark + "\n\n")
b.WriteString(kvKeyStyle().Render("Workspace") + "\n")
b.WriteString(mutedStyle().Render(r.WorkspacePath))
b.WriteString(" " + wsMark + "\n")
if r.ConfigOK {
b.WriteString("\n")
b.WriteString(kvKeyStyle().Render("Model") + " " + kvValStyle().Render(r.Model))
}
return borderStyle().Width(inner).Render(b.String())
}
func statusMark(ok bool) string {
if ok {
return lipgloss.NewStyle().Foreground(colorOK).Render("✓")
}
return lipgloss.NewStyle().Foreground(accentRed).Render("✗")
}
func providerTablePanel(r StatusReport, colW int) string {
if len(r.Providers) == 0 {
return ""
}
keyW := min(22, colW/3)
if keyW < 14 {
keyW = 14
}
valW := colW - keyW - 3
if valW < 12 {
valW = 12
}
var b strings.Builder
b.WriteString(titleBarStyle().Render("Providers & local") + "\n\n")
for _, p := range r.Providers {
k := lipgloss.NewStyle().Foreground(accentBlue).Bold(true).Width(keyW).Render(p.Name)
v := styleProviderVal(p.Val).Width(valW).Render(p.Val)
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, k, " ", v))
b.WriteString("\n")
}
return borderStyle().Width(colW).Render(strings.TrimRight(b.String(), "\n"))
}
func styleProviderVal(s string) lipgloss.Style {
if s == "✓" || strings.HasPrefix(s, "✓ ") {
return lipgloss.NewStyle().Foreground(colorOK)
}
if s == "not set" {
return mutedStyle()
}
return lipgloss.NewStyle()
}
+61
View File
@@ -0,0 +1,61 @@
package cliui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// PrintVersion prints version, optional build info, and Go toolchain line.
func PrintVersion(logo, versionLine string, build, goVer string) {
if !UseFancyLayout() {
fmt.Printf("%s %s\n", logo, versionLine)
if build != "" {
fmt.Printf(" Build: %s\n", build)
}
if goVer != "" {
fmt.Printf(" Go: %s\n", goVer)
}
return
}
inner := InnerWidth()
box := borderStyle().Width(inner)
if UseColumnLayout() {
leftCol := kvKeyStyle().Width(12).Align(lipgloss.Right)
rightW := inner - 16
rightStyle := kvValStyle().Width(rightW)
rows := [][]string{
{leftCol.Render("Version"), rightStyle.Render(versionLine)},
}
if build != "" {
rows = append(rows, []string{leftCol.Render("Build"), rightStyle.Render(build)})
}
if goVer != "" {
rows = append(rows, []string{leftCol.Render("Go"), rightStyle.Render(goVer)})
}
var body strings.Builder
for _, r := range rows {
body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, r[0], " ", r[1]))
body.WriteString("\n")
}
header := titleBarStyle().Render(logo+" picoclaw") + "\n\n"
fmt.Println(box.Render(header + body.String()))
return
}
var lines []string
lines = append(lines, titleBarStyle().Render(logo+" picoclaw"))
lines = append(lines, "")
lines = append(lines, kvKeyStyle().Render("Version")+" "+kvValStyle().Render(versionLine))
if build != "" {
lines = append(lines, kvKeyStyle().Render("Build")+" "+kvValStyle().Render(build))
}
if goVer != "" {
lines = append(lines, kvKeyStyle().Render("Go")+" "+kvValStyle().Render(goVer))
}
fmt.Println(box.Render(strings.Join(lines, "\n")))
}
+40 -1
View File
@@ -2,19 +2,34 @@ package gateway
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/gateway"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/netbind"
"github.com/sipeed/picoclaw/pkg/utils"
)
func resolveGatewayHostOverride(explicit bool, host string) (string, error) {
if !explicit {
return "", nil
}
normalized, err := netbind.NormalizeHostInput(host)
if err != nil {
return "", fmt.Errorf("invalid --host value: %w", err)
}
return normalized, nil
}
func NewGatewayCommand() *cobra.Command {
var debug bool
var noTruncate bool
var allowEmpty bool
var host string
cmd := &cobra.Command{
Use: "gateway",
@@ -33,7 +48,25 @@ func NewGatewayCommand() *cobra.Command {
return nil
},
RunE: func(_ *cobra.Command, _ []string) error {
RunE: func(cmd *cobra.Command, _ []string) error {
resolvedHost, err := resolveGatewayHostOverride(cmd.Flags().Changed("host"), host)
if err != nil {
return err
}
if resolvedHost != "" {
prevHost, hadPrev := os.LookupEnv(config.EnvGatewayHost)
if err := os.Setenv(config.EnvGatewayHost, resolvedHost); err != nil {
return fmt.Errorf("failed to set %s: %w", config.EnvGatewayHost, err)
}
defer func() {
if hadPrev {
_ = os.Setenv(config.EnvGatewayHost, prevHost)
return
}
_ = os.Unsetenv(config.EnvGatewayHost)
}()
}
return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
},
}
@@ -47,6 +80,12 @@ func NewGatewayCommand() *cobra.Command {
false,
"Continue starting even when no default model is configured",
)
cmd.Flags().StringVar(
&host,
"host",
"",
"Host address for gateway binding (overrides gateway.host for this run)",
)
return cmd
}
@@ -29,4 +29,38 @@ func TestNewGatewayCommand(t *testing.T) {
assert.True(t, cmd.HasFlags())
assert.NotNil(t, cmd.Flags().Lookup("debug"))
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
assert.NotNil(t, cmd.Flags().Lookup("host"))
}
func TestResolveGatewayHostOverride(t *testing.T) {
tests := []struct {
name string
explicit bool
host string
wantHost string
wantErr bool
}{
{name: "implicit empty host is allowed", explicit: false, host: "", wantHost: "", wantErr: false},
{name: "explicit empty host rejected", explicit: true, host: " ", wantHost: "", wantErr: true},
{name: "explicit localhost kept", explicit: true, host: " localhost ", wantHost: "localhost", wantErr: false},
{
name: "explicit multi host normalized",
explicit: true,
host: " [::1] , 127.0.0.1 ",
wantHost: "::1,127.0.0.1",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveGatewayHostOverride(tt.explicit, tt.host)
if (err != nil) != tt.wantErr {
t.Fatalf("resolveGatewayHostOverride() err = %v, wantErr %t", err, tt.wantErr)
}
if got != tt.wantHost {
t.Fatalf("resolveGatewayHostOverride() host = %q, want %q", got, tt.wantHost)
}
})
}
}
+5 -23
View File
@@ -9,6 +9,7 @@ import (
"golang.org/x/term"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/credential"
)
@@ -79,29 +80,7 @@ func onboard(encrypt bool) {
workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace)
fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
fmt.Println("\nNext steps:")
if encrypt {
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
fmt.Println("")
fmt.Println(" 2. Add your API key to", configPath)
} else {
fmt.Println(" 1. Add your API key to", configPath)
}
fmt.Println("")
fmt.Println(" Recommended:")
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
fmt.Println(" - Ollama: https://ollama.com (local, free)")
fmt.Println("")
fmt.Println(" See README.md for 17+ supported providers.")
fmt.Println("")
if encrypt {
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
} else {
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
}
cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath)
}
// promptPassphrase reads the encryption passphrase twice from the terminal
@@ -193,6 +172,9 @@ func copyEmbeddedToTarget(targetDir string) error {
if err != nil {
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
}
if new_path == "AGENTS.md" || new_path == "IDENTITY.md" {
return nil
}
// Build target file path
targetPath := filepath.Join(targetDir, new_path)
+2 -19
View File
@@ -12,7 +12,6 @@ import (
type deps struct {
workspace string
installer *skills.SkillInstaller
skillsLoader *skills.SkillsLoader
}
@@ -29,15 +28,6 @@ func NewSkillsCommand() *cobra.Command {
}
d.workspace = cfg.WorkspacePath()
installer, err := skills.NewSkillInstaller(
d.workspace,
cfg.Tools.Skills.Github.Token.String(),
cfg.Tools.Skills.Github.Proxy,
)
if err != nil {
return fmt.Errorf("error creating skills installer: %w", err)
}
d.installer = installer
// get global config directory and builtin skills directory
globalDir := filepath.Dir(internal.GetConfigPath())
@@ -52,13 +42,6 @@ func NewSkillsCommand() *cobra.Command {
},
}
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")
@@ -75,10 +58,10 @@ func NewSkillsCommand() *cobra.Command {
cmd.AddCommand(
newListCommand(loaderFn),
newInstallCommand(installerFn),
newInstallCommand(),
newInstallBuiltinCommand(workspaceFn),
newListBuiltinCommand(),
newRemoveCommand(installerFn),
newRemoveCommand(),
newSearchCommand(),
newShowCommand(loaderFn),
)
+91 -66
View File
@@ -2,6 +2,7 @@ package skills
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
@@ -11,12 +12,23 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
)
const skillsSearchMaxResults = 20
type installedSkillOriginMeta struct {
Version int `json:"version"`
OriginKind string `json:"origin_kind,omitempty"`
Registry string `json:"registry,omitempty"`
Slug string `json:"slug,omitempty"`
RegistryURL string `json:"registry_url,omitempty"`
InstalledVersion string `json:"installed_version,omitempty"`
InstalledAt int64 `json:"installed_at"`
}
func skillsListCmd(loader *skills.SkillsLoader) {
allSkills := loader.ListSkills()
@@ -35,61 +47,32 @@ func skillsListCmd(loader *skills.SkillsLoader) {
}
}
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 {
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) error {
func skillsInstallFromRegistry(cfg *config.Config, registryName, target string) error {
err := utils.ValidateSkillIdentifier(registryName)
if err != nil {
return fmt.Errorf("✗ invalid registry name: %w", err)
}
err = utils.ValidateSkillIdentifier(slug)
if err != nil {
return fmt.Errorf("✗ invalid slug: %w", err)
}
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig{
Enabled: clawHubConfig.Enabled,
BaseURL: clawHubConfig.BaseURL,
AuthToken: clawHubConfig.AuthToken.String(),
SearchPath: clawHubConfig.SearchPath,
SkillsPath: clawHubConfig.SkillsPath,
DownloadPath: clawHubConfig.DownloadPath,
Timeout: clawHubConfig.Timeout,
MaxZipSize: clawHubConfig.MaxZipSize,
MaxResponseSize: clawHubConfig.MaxResponseSize,
},
})
registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
registry := registryMgr.GetRegistry(registryName)
if registry == nil {
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
}
dirName, err := registry.ResolveInstallDirName(target)
if err != nil {
return fmt.Errorf("✗ invalid install target %q: %w", target, err)
}
fmt.Printf("Installing skill '%s' from %s registry...\n", target, registryName)
workspace := cfg.WorkspacePath()
targetDir := filepath.Join(workspace, "skills", slug)
targetDir := filepath.Join(workspace, "skills", dirName)
if _, err = os.Stat(targetDir); err == nil {
return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir)
return fmt.Errorf("\u2717 skill '%s' already installed at %s", dirName, targetDir)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
@@ -99,7 +82,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
}
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
result, err := registry.DownloadAndInstall(ctx, target, "", targetDir)
if err != nil {
rmErr := os.RemoveAll(targetDir)
if rmErr != nil {
@@ -114,14 +97,34 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
}
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", target)
}
if result.IsSuspicious {
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug)
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", target)
}
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version)
if !workspaceHasValidSkillDirectory(workspace, dirName) {
_ = os.RemoveAll(targetDir)
return fmt.Errorf("✗ failed to install skill: registry archive for %q is not a valid skill", target)
}
normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, target, result.Version)
installedAt := time.Now().UnixMilli()
if err := writeInstalledSkillOriginMeta(targetDir, installedSkillOriginMeta{
Version: 1,
OriginKind: "third_party",
Registry: registry.Name(),
Slug: normalizedSlug,
RegistryURL: registryURL,
InstalledVersion: result.Version,
InstalledAt: installedAt,
}); err != nil {
_ = os.RemoveAll(targetDir)
return fmt.Errorf("✗ failed to persist skill metadata: %w", err)
}
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", dirName, result.Version)
if result.Summary != "" {
fmt.Printf(" %s\n", result.Summary)
}
@@ -129,15 +132,51 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
return nil
}
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
fmt.Printf("Removing skill '%s'...\n", skillName)
if err := installer.Uninstall(skillName); err != nil {
fmt.Printf("✗ Failed to remove skill: %v\n", err)
os.Exit(1)
func writeInstalledSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error {
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
}
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
func workspaceHasValidSkillDirectory(workspace, directory string) bool {
loader := skills.NewSkillsLoader(workspace, "", "")
for _, skill := range loader.ListSkills() {
if skill.Source != "workspace" {
continue
}
if filepath.Base(filepath.Dir(skill.Path)) == directory {
return true
}
}
return false
}
func skillsRemoveFromWorkspace(workspace string, toolsConfig config.SkillsToolsConfig, skillName string) error {
name := strings.TrimSpace(skillName)
name = strings.Trim(name, "/")
if name == "" {
return fmt.Errorf("skill name is required")
}
if strings.Contains(name, "/") {
dirName, err := skills.GitHubInstallDirNameFromToolsConfig(toolsConfig, name)
if err != nil || dirName == "" {
return fmt.Errorf("invalid skill name %q", skillName)
}
name = dirName
}
if name == "." || name == ".." {
return fmt.Errorf("invalid skill name %q", skillName)
}
skillDir := filepath.Join(workspace, "skills", name)
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
return fmt.Errorf("skill '%s' not found", name)
}
if err := os.RemoveAll(skillDir); err != nil {
return fmt.Errorf("failed to remove skill '%s': %w", name, err)
}
return nil
}
func skillsInstallBuiltinCmd(workspace string) {
@@ -237,21 +276,7 @@ func skillsSearchCmd(query string) {
return
}
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig{
Enabled: clawHubConfig.Enabled,
BaseURL: clawHubConfig.BaseURL,
AuthToken: clawHubConfig.AuthToken.String(),
SearchPath: clawHubConfig.SearchPath,
SkillsPath: clawHubConfig.SkillsPath,
DownloadPath: clawHubConfig.DownloadPath,
Timeout: clawHubConfig.Timeout,
MaxZipSize: clawHubConfig.MaxZipSize,
MaxResponseSize: clawHubConfig.MaxResponseSize,
},
})
registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -0,0 +1,191 @@
package skills
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestSkillsInstallFromRegistryWritesOriginMetadata(t *testing.T) {
workspace := t.TempDir()
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = workspace
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/repos/foo/bar":
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"}))
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
assert.Equal(t, "ref=master", r.URL.RawQuery)
require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{{
"type": "file",
"name": "SKILL.md",
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
}}))
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
_, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
require.True(t, ok)
githubRegistry.BaseURL = server.URL
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
target := server.URL + "/foo/bar/tree/master/.agents/skills/pr-review"
require.NoError(t, skillsInstallFromRegistry(cfg, "github", target))
metaPath := filepath.Join(workspace, "skills", "pr-review", ".skill-origin.json")
data, err := os.ReadFile(metaPath)
require.NoError(t, err)
var meta installedSkillOriginMeta
require.NoError(t, json.Unmarshal(data, &meta))
assert.Equal(t, "third_party", meta.OriginKind)
assert.Equal(t, "github", meta.Registry)
assert.Equal(t, "foo/bar/.agents/skills/pr-review", meta.Slug)
assert.Equal(t, server.URL+"/foo/bar/tree/master/.agents/skills/pr-review", meta.RegistryURL)
assert.Equal(t, "master", meta.InstalledVersion)
assert.NotZero(t, meta.InstalledAt)
}
func TestSkillsInstallFromRegistryRejectsInvalidSkillArchive(t *testing.T) {
workspace := t.TempDir()
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = workspace
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/repos/foo/bar":
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"}))
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{{
"type": "file",
"name": "SKILL.md",
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
}}))
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
_, _ = w.Write([]byte("---\nname: bad_skill\ndescription: Invalid skill name\n---\n# Invalid\n"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
require.True(t, ok)
githubRegistry.BaseURL = server.URL
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
target := server.URL + "/foo/bar/tree/master/.agents/skills/pr-review"
err := skillsInstallFromRegistry(cfg, "github", target)
require.Error(t, err)
assert.Contains(t, err.Error(), "is not a valid skill")
_, statErr := os.Stat(filepath.Join(workspace, "skills", "pr-review"))
assert.True(t, os.IsNotExist(statErr))
}
func TestSkillsRemoveFromWorkspaceRejectsDotTarget(t *testing.T) {
workspace := t.TempDir()
skillsDir := filepath.Join(workspace, "skills")
require.NoError(t, os.MkdirAll(skillsDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(skillsDir, "keep.txt"), []byte("keep"), 0o644))
err := skillsRemoveFromWorkspace(workspace, config.DefaultConfig().Tools.Skills, ".")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid skill name")
_, statErr := os.Stat(skillsDir)
assert.NoError(t, statErr)
_, fileErr := os.Stat(filepath.Join(skillsDir, "keep.txt"))
assert.NoError(t, fileErr)
}
func TestSkillsRemoveFromWorkspaceUsesLastPathSegment(t *testing.T) {
workspace := t.TempDir()
targetDir := filepath.Join(workspace, "skills", "pr-review")
require.NoError(t, os.MkdirAll(targetDir, 0o755))
err := skillsRemoveFromWorkspace(
workspace,
config.DefaultConfig().Tools.Skills,
"https://github.com/foo/bar/tree/main/.agents/skills/pr-review",
)
require.NoError(t, err)
_, statErr := os.Stat(targetDir)
assert.True(t, os.IsNotExist(statErr))
}
func TestSkillsRemoveFromWorkspaceSupportsRepoRootGitHubBlobURL(t *testing.T) {
workspace := t.TempDir()
targetDir := filepath.Join(workspace, "skills", "bar")
require.NoError(t, os.MkdirAll(targetDir, 0o755))
err := skillsRemoveFromWorkspace(
workspace,
config.DefaultConfig().Tools.Skills,
"https://github.com/foo/bar/blob/feature/skills-registry/SKILL.md",
)
require.NoError(t, err)
_, statErr := os.Stat(targetDir)
assert.True(t, os.IsNotExist(statErr))
}
func TestSkillsRemoveFromWorkspaceSupportsGitHubEnterpriseURL(t *testing.T) {
workspace := t.TempDir()
targetDir := filepath.Join(workspace, "skills", "pr-review")
require.NoError(t, os.MkdirAll(targetDir, 0o755))
cfg := config.DefaultConfig()
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
require.True(t, ok)
githubRegistry.BaseURL = "https://ghe.example.com/git"
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
err := skillsRemoveFromWorkspace(
workspace,
cfg.Tools.Skills,
"https://ghe.example.com/git/foo/bar/tree/main/.agents/skills/pr-review",
)
require.NoError(t, err)
_, statErr := os.Stat(targetDir)
assert.True(t, os.IsNotExist(statErr))
}
func TestSkillsRemoveFromWorkspaceDoesNotRequireEnabledGitHubRegistry(t *testing.T) {
workspace := t.TempDir()
targetDir := filepath.Join(workspace, "skills", "pr-review")
require.NoError(t, os.MkdirAll(targetDir, 0o755))
cfg := config.DefaultConfig()
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
require.True(t, ok)
githubRegistry.Enabled = false
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
err := skillsRemoveFromWorkspace(
workspace,
cfg.Tools.Skills,
"https://github.com/foo/bar/tree/main/.agents/skills/pr-review",
)
require.NoError(t, err)
_, statErr := os.Stat(targetDir)
assert.True(t, os.IsNotExist(statErr))
}
+4 -11
View File
@@ -6,15 +6,14 @@ import (
"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 {
func newInstallCommand() *cobra.Command {
var registry string
cmd := &cobra.Command{
Use: "install",
Short: "Install skill from GitHub",
Short: "Install skill from GitHub or a registry",
Example: `
picoclaw skills install sipeed/picoclaw-skills/weather
picoclaw skills install --registry clawhub github
@@ -34,21 +33,15 @@ picoclaw skills install --registry clawhub github
return nil
},
RunE: func(_ *cobra.Command, args []string) error {
installer, err := installerFn()
cfg, err := internal.LoadConfig()
if err != nil {
return err
}
if registry != "" {
cfg, err := internal.LoadConfig()
if err != nil {
return err
}
return skillsInstallFromRegistry(cfg, registry, args[0])
}
return skillsInstallCmd(installer, args[0])
return skillsInstallFromRegistry(cfg, "github", args[0])
},
}
+3 -3
View File
@@ -8,12 +8,12 @@ import (
)
func TestNewInstallSubcommand(t *testing.T) {
cmd := newInstallCommand(nil)
cmd := newInstallCommand()
require.NotNil(t, cmd)
assert.Equal(t, "install", cmd.Use)
assert.Equal(t, "Install skill from GitHub", cmd.Short)
assert.Equal(t, "Install skill from GitHub or a registry", cmd.Short)
assert.Nil(t, cmd.Run)
assert.NotNil(t, cmd.RunE)
@@ -79,7 +79,7 @@ func TestInstallCommandArgs(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newInstallCommand(nil)
cmd := newInstallCommand()
if tt.registry != "" {
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
+4 -5
View File
@@ -3,10 +3,10 @@ package skills
import (
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
)
func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
func newRemoveCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm", "uninstall"},
@@ -14,12 +14,11 @@ func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra
Args: cobra.ExactArgs(1),
Example: `picoclaw skills remove weather`,
RunE: func(_ *cobra.Command, args []string) error {
installer, err := installerFn()
cfg, err := internal.LoadConfig()
if err != nil {
return err
}
skillsRemoveCmd(installer, args[0])
return nil
return skillsRemoveFromWorkspace(cfg.WorkspacePath(), cfg.Tools.Skills, args[0])
},
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
func TestNewRemoveSubcommand(t *testing.T) {
cmd := newRemoveCommand(nil)
cmd := newRemoveCommand()
require.NotNil(t, cmd)
+107 -23
View File
@@ -3,8 +3,10 @@ package status
import (
"fmt"
"os"
"strings"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -17,43 +19,125 @@ func statusCmd() {
}
configPath := internal.GetConfigPath()
fmt.Printf("%s picoclaw Status\n", internal.Logo)
fmt.Printf("Version: %s\n", config.FormatVersion())
build, _ := config.FormatBuildInfo()
if build != "" {
fmt.Printf("Build: %s\n", build)
}
fmt.Println()
if _, err := os.Stat(configPath); err == nil {
fmt.Println("Config:", configPath, "✓")
} else {
fmt.Println("Config:", configPath, "✗")
}
_, configStatErr := os.Stat(configPath)
configOK := configStatErr == nil
workspace := cfg.WorkspacePath()
if _, err := os.Stat(workspace); err == nil {
fmt.Println("Workspace:", workspace, "✓")
} else {
fmt.Println("Workspace:", workspace, "✗")
_, wsErr := os.Stat(workspace)
wsOK := wsErr == nil
report := cliui.StatusReport{
Logo: internal.Logo,
Version: config.FormatVersion(),
Build: build,
ConfigPath: configPath,
ConfigOK: configOK,
WorkspacePath: workspace,
WorkspaceOK: wsOK,
Model: cfg.Agents.Defaults.GetModelName(),
}
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
if configOK {
// PicoClaw moved to a model-centric configuration (model_list). Status should
// not depend on a legacy cfg.Providers field (which may not exist under some
// build tags). We infer provider availability from model_list entries.
hasProtocolKey := func(protocol string) bool {
prefix := protocol + "/"
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if strings.HasPrefix(m.Model, prefix) && m.APIKey() != "" {
return true
}
}
return false
}
findLocalModelBase := func(modelName string) (string, bool) {
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if m.ModelName == modelName && m.APIBase != "" {
return m.APIBase, true
}
}
return "", false
}
findProtocolBase := func(protocol string) (string, bool) {
prefix := protocol + "/"
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if strings.HasPrefix(m.Model, prefix) && m.APIBase != "" {
return m.APIBase, true
}
}
return "", false
}
hasOpenRouter := hasProtocolKey("openrouter")
hasAnthropic := hasProtocolKey("anthropic")
hasOpenAI := hasProtocolKey("openai")
hasGemini := hasProtocolKey("gemini")
hasZhipu := hasProtocolKey("zhipu")
hasQwen := hasProtocolKey("qwen")
hasGroq := hasProtocolKey("groq")
hasMoonshot := hasProtocolKey("moonshot")
hasDeepSeek := hasProtocolKey("deepseek")
hasVolcEngine := hasProtocolKey("volcengine")
hasNvidia := hasProtocolKey("nvidia")
// Local endpoints: allow both the special reserved name and protocol-based entries.
vllmBase, hasVLLM := findLocalModelBase("local-model")
if !hasVLLM {
vllmBase, hasVLLM = findProtocolBase("vllm")
}
ollamaBase, hasOllama := findProtocolBase("ollama")
val := func(enabled bool, extra ...string) string {
if enabled {
if len(extra) > 0 && extra[0] != "" {
return "✓ " + extra[0]
}
return "✓"
}
return "not set"
}
report.Providers = []cliui.ProviderRow{
{Name: "OpenRouter API", Val: val(hasOpenRouter)},
{Name: "Anthropic API", Val: val(hasAnthropic)},
{Name: "OpenAI API", Val: val(hasOpenAI)},
{Name: "Gemini API", Val: val(hasGemini)},
{Name: "Zhipu API", Val: val(hasZhipu)},
{Name: "Qwen API", Val: val(hasQwen)},
{Name: "Groq API", Val: val(hasGroq)},
{Name: "Moonshot API", Val: val(hasMoonshot)},
{Name: "DeepSeek API", Val: val(hasDeepSeek)},
{Name: "VolcEngine API", Val: val(hasVolcEngine)},
{Name: "Nvidia API", Val: val(hasNvidia)},
{Name: "vLLM / local", Val: val(hasVLLM, vllmBase)},
{Name: "Ollama", Val: val(hasOllama, ollamaBase)},
}
store, _ := auth.LoadStore()
if store != nil && len(store.Credentials) > 0 {
fmt.Println("\nOAuth/Token Auth:")
for provider, cred := range store.Credentials {
status := "authenticated"
st := "authenticated"
if cred.IsExpired() {
status = "expired"
st = "expired"
} else if cred.NeedsRefresh() {
status = "needs refresh"
st = "needs refresh"
}
fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
report.OAuthLines = append(report.OAuthLines,
fmt.Sprintf("%s (%s): %s", provider, cred.AuthMethod, st))
}
}
}
cliui.PrintStatus(report)
}
+2 -9
View File
@@ -1,11 +1,10 @@
package version
import (
"fmt"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -23,12 +22,6 @@ func NewVersionCommand() *cobra.Command {
}
func printVersion() {
fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
build, goVer := config.FormatBuildInfo()
if build != "" {
fmt.Printf(" Build: %s\n", build)
}
if goVer != "" {
fmt.Printf(" Go: %s\n", goVer)
}
cliui.PrintVersion(internal.Logo, "picoclaw "+config.FormatVersion(), build, goVer)
}
+72 -12
View File
@@ -16,6 +16,7 @@ import (
"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/cliui"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
@@ -28,15 +29,57 @@ import (
"github.com/sipeed/picoclaw/pkg/updater"
)
var rootNoColor bool
func syncCliUIColor(root *cobra.Command) {
no, _ := root.PersistentFlags().GetBool("no-color")
cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
}
// earlyColorDisabled matches lipgloss/banner behavior from env and argv before Cobra parses flags.
func earlyColorDisabled() bool {
if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" {
return true
}
for i := 1; i < len(os.Args); i++ {
arg := os.Args[i]
if arg == "--no-color" || arg == "--no-color=true" || arg == "--no-color=1" {
return true
}
}
return false
}
func NewPicoclawCommand() *cobra.Command {
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
long := fmt.Sprintf(`%s PicoClaw is a lightweight personal AI assistant.
Version: %s`, internal.Logo, config.FormatVersion())
cmd := &cobra.Command{
Use: "picoclaw",
Short: short,
Example: "picoclaw version",
Use: "picoclaw",
Short: short,
Long: long,
Example: `picoclaw version
picoclaw onboard
picoclaw --no-color status`,
SilenceErrors: true,
// Avoid plain UsageString() on stderr/stdout when a command fails; cliui
// renders matching panels on stderr instead.
SilenceUsage: true,
PersistentPreRun: func(c *cobra.Command, _ []string) {
syncCliUIColor(c.Root())
},
}
cmd.PersistentFlags().BoolVar(&rootNoColor, "no-color", false,
"Disable colors (boxed layout unchanged)")
cmd.SetHelpFunc(func(c *cobra.Command, _ []string) {
syncCliUIColor(c.Root())
fmt.Fprint(c.OutOrStdout(), cliui.RenderCommandHelp(c))
})
cmd.AddCommand(
onboard.NewOnboardCommand(),
agent.NewAgentCommand(),
@@ -65,17 +108,31 @@ const (
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"\033[0m\r\n"
plainBanner = "\r\n" +
"██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
"██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
"██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
"██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
"██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
"╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"\r\n"
)
func main() {
fmt.Printf("%s", banner)
cliui.Init(earlyColorDisabled())
tz_env := os.Getenv("TZ")
if tz_env != "" {
fmt.Println("TZ environment:", tz_env)
zoneinfo_env := os.Getenv("ZONEINFO")
fmt.Println("ZONEINFO environment:", zoneinfo_env)
loc, err := time.LoadLocation(tz_env)
if earlyColorDisabled() {
fmt.Print(plainBanner)
} else {
fmt.Printf("%s", banner)
}
tzEnv := os.Getenv("TZ")
if tzEnv != "" {
fmt.Println("TZ environment:", tzEnv)
zoneinfoEnv := os.Getenv("ZONEINFO")
fmt.Println("ZONEINFO environment:", zoneinfoEnv)
loc, err := time.LoadLocation(tzEnv)
if err != nil {
fmt.Println("Error loading time zone:", err)
} else {
@@ -85,7 +142,10 @@ func main() {
}
cmd := NewPicoclawCommand()
if err := cmd.Execute(); err != nil {
last, err := cmd.ExecuteC()
if err != nil {
syncCliUIColor(cmd)
fmt.Fprint(os.Stderr, cliui.FormatCLIError(err.Error(), last))
os.Exit(1)
}
}
+6 -3
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -17,20 +18,22 @@ func TestNewPicoclawCommand(t *testing.T) {
require.NotNil(t, cmd)
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
longHas := strings.Contains(cmd.Long, config.FormatVersion())
assert.Equal(t, "picoclaw", cmd.Use)
assert.Equal(t, short, cmd.Short)
assert.True(t, longHas)
assert.True(t, cmd.HasSubCommands())
assert.True(t, cmd.HasAvailableSubCommands())
assert.False(t, cmd.HasFlags())
assert.True(t, cmd.PersistentFlags().Lookup("no-color") != nil)
assert.Nil(t, cmd.Run)
assert.Nil(t, cmd.RunE)
assert.Nil(t, cmd.PersistentPreRun)
assert.NotNil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
allowedCommands := []string{
+14 -2
View File
@@ -269,10 +269,15 @@
"base_url": "",
"max_results": 0
},
"duckduckgo": {
"provider": "auto",
"sogou": {
"enabled": true,
"max_results": 5
},
"duckduckgo": {
"enabled": false,
"max_results": 5
},
"perplexity": {
"enabled": false,
"api_key": "pplx-xxx",
@@ -382,9 +387,16 @@
"timeout": 0,
"max_zip_size": 0,
"max_response_size": 0
},
"github": {
"enabled": true,
"base_url": "https://github.com",
"auth_token": "",
"proxy": "http://127.0.0.1:7891"
}
},
"github": {
"base_url": "https://github.com",
"proxy": "http://127.0.0.1:7891",
"token": ""
},
@@ -465,7 +477,7 @@
},
"gateway": {
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
"host": "127.0.0.1",
"host": "localhost",
"port": 18790,
"hot_reload": false,
"log_level": "fatal"
+4 -13
View File
@@ -26,18 +26,9 @@ RUN apk add --no-cache ca-certificates tzdata curl
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost:18790/health || exit 1
# Copy binary
# Copy binary and first-run entrypoint (same as release image).
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Create non-root user and group
RUN addgroup -g 1000 picoclaw && \
adduser -D -u 1000 -G picoclaw picoclaw
# Switch to non-root user
USER picoclaw
# Run onboard to create initial directories and config
RUN /usr/local/bin/picoclaw onboard
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]
ENTRYPOINT ["/entrypoint.sh"]
+2 -9
View File
@@ -48,20 +48,13 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
# Reuse existing node user (UID/GID 1000) — rename to picoclaw
RUN deluser node 2>/dev/null; delgroup node 2>/dev/null; \
addgroup -g 1000 picoclaw 2>/dev/null; \
adduser -D -u 1000 -G picoclaw -h /home/picoclaw picoclaw 2>/dev/null || true
USER picoclaw
# Run onboard to create initial directories and config
RUN /usr/local/bin/picoclaw onboard
# Copy default workspace
COPY --chown=picoclaw:picoclaw workspace/ /home/picoclaw/.picoclaw/workspace/
COPY workspace/ /root/.picoclaw/workspace/
VOLUME /home/picoclaw/.picoclaw/workspace
VOLUME /root/.picoclaw/workspace
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]
+132
View File
@@ -0,0 +1,132 @@
# PicoClaw Documentation
PicoClaw documentation is organized by document type first and language second.
This file describes the recommended documentation layout, how translated files should be named, and what `make lint-docs` currently checks locally.
These conventions are intended as contributor guidance for new or moved docs. Existing docs may still have historical exceptions, and `make lint-docs` only checks a common subset of the patterns described here.
## Reader Navigation
If you are browsing docs rather than reorganizing them, start with these directory indexes:
- [Guides](guides/README.md): setup, configuration, provider, and workflow guides.
- [Reference](reference/README.md): precise configuration and behavior reference.
- [Operations](operations/README.md): debugging and troubleshooting material.
- [Security](security/README.md): security-focused guides and controls.
- [Architecture](architecture/README.md): implementation notes and internal design docs.
- [Migration](migration/README.md): upgrade and migration notes.
For channel-specific setup, start with [Chat Apps Configuration](guides/chat-apps.md) and then drill into `docs/channels/<name>/README.md` as needed.
## Principles
- Choose the document type directory first. Do not create language buckets such as `docs/zh/` or `docs/fr/`.
- Keep each translated document next to its English source document.
- Use English as the base filename with no locale suffix.
- Use lowercase locale suffixes for translations, for example `configuration.zh.md` or `README.pt-br.md`.
- Keep module-specific docs next to the code they describe instead of moving them into `docs/`.
## Recommended Directories
- `README.md`: English project entry document at the repository root.
- `docs/project/`: translated project entry documents such as `README.zh.md` and `CONTRIBUTING.zh.md`.
- `docs/guides/`: setup and usage guides.
- `docs/reference/`: reference material and detailed configuration docs.
- `docs/operations/`: debugging and troubleshooting docs.
- `docs/security/`: security-related documentation.
- `docs/architecture/`: architecture and internal design notes.
- `docs/channels/`: channel-specific integration guides.
- `docs/design/`: design proposals and investigations.
- `docs/migration/`: migration notes.
## Recommended Naming
- English documents use the base filename:
- `README.md`
- `configuration.md`
- Translations use `.<locale>.md`:
- `README.zh.md`
- `configuration.fr.md`
- `README.pt-br.md`
- Code-adjacent translated READMEs follow the same rule:
- `pkg/audio/asr/README.zh.md`
- `pkg/isolation/README.zh.md`
## Common Patterns To Avoid
- Root-level translated entry docs such as `README.zh.md` or `CONTRIBUTING.fr.md`
- Use `docs/project/README.zh.md` or `docs/project/CONTRIBUTING.fr.md` instead.
- Language directories under `docs/` such as `docs/zh/`, `docs/ZH/`, `docs/ja/`, or `docs/fr/`
- Use `docs/<type>/<name>.<locale>.md` instead.
- Nested locale buckets such as `docs/guides/zh/configuration.md` or `docs/channels/telegram/zh/README.md`
- Keep translations beside the English source file instead.
- Legacy translation filenames such as `README_zh.md` or `README_CN.md`
- Use `README.zh.md`.
- Non-canonical locale suffixes such as `configuration_zh.md` or `configuration.ZH.md`
- Use lowercase `.<locale>.md`, for example `configuration.zh.md`.
## Translation Placement
- For docs under `docs/guides`, `docs/reference`, `docs/operations`, `docs/security`, `docs/architecture`, `docs/channels`, and `docs/migration`, keep translations beside the English source file.
- For project entry translations, keep translated files in `docs/project/` and keep the English source in the repository root.
- In most cases, each translated file should have an English source document:
- `docs/guides/configuration.zh.md` usually sits beside `docs/guides/configuration.md`
- `docs/project/README.zh.md` usually corresponds to `README.md`
- Exception: `docs/design/` may contain locale-specific working notes without an English source document. The naming rules still apply there.
## Code-Adjacent Docs
Keep documentation next to the implementation when it primarily describes a package, command, example, or subproject.
Examples:
- `pkg/**/README.md`
- `cmd/**/README.md`
- `web/README.md`
- `examples/**/README.md`
These files still follow the same translation naming rules.
## Adding a New Document
1. Pick the correct document type directory.
2. Create the English source file first.
3. Add translated siblings after the English source exists when that source is part of the same docs set.
4. Update links from existing docs when the new doc becomes a navigation target.
5. Run `make lint-docs` locally when adding or moving docs.
## Examples
- New setup guide:
- `docs/guides/launcher-setup.md`
- `docs/guides/launcher-setup.zh.md`
- New security guide:
- `docs/security/token-rotation.md`
- New translated package README:
- `pkg/channels/README.zh.md`
## Validation
Run:
```bash
make lint-docs
```
The local docs linter currently checks these common cases:
- no root-level translated `README` or `CONTRIBUTING` files
- no `docs/<locale>/` language buckets, regardless of case
- no nested locale buckets under typed docs directories
- no legacy `README_*.md` filenames
- no non-canonical translation-like filenames such as `_zh.md` or `.ZH.md`
- no extra Markdown files directly under `docs/` except `docs/README.md`
- every translated Markdown file has a matching English source file
- except for locale-specific working notes under `docs/design/`
`make lint-docs` is a local consistency check for common naming and placement mistakes. It helps contributors stay close to the recommended layout, but it is not intended to describe every acceptable documentation pattern in the repository.
When a check fails, `make lint-docs` prints the failing path, the reason, and a suggested fix.
If you change these recommendations or want the local linter to reflect them more closely, update this file and `scripts/lint-docs.sh` together.
+12
View File
@@ -0,0 +1,12 @@
# Architecture
Internal architecture notes for major runtime mechanisms and subsystem design.
- [Steering](steering.md): injecting messages into a running agent loop between tool calls.
- [SubTurn Mechanism](subturn.md): sub-agent coordination, concurrency control, and lifecycle handling.
- [Session System](session-system.md): session scope allocation, JSONL persistence, alias compatibility, and migration. ([ZH](session-system.zh.md))
- [Routing System](routing-system.md): agent dispatch, session policy selection, and light/heavy model routing. ([ZH](routing-system.zh.md))
- [Hook System Guide](hooks/README.md): current hook architecture and protocol details.
- [Agent Refactor](agent-refactor/README.md): notes and checkpoints for the agent refactor work.
For proposal-style or exploratory docs, also see [`../design/`](../design/).
@@ -0,0 +1,100 @@
# Agent File Rename Plan
## Goal
Unify `pkg/agent/` package file naming to resolve the `loop_*` prefix naming confusion and unclear responsibility boundaries.
## Change Overview
### File Renames (12 files)
| Original | New | Description |
|----------|-----|-------------|
| `loop.go` | `agent.go` | AgentLoop main body + lifecycle methods |
| `loop_message.go` | `agent_message.go` | Message handling and routing |
| `loop_outbound.go` | `agent_outbound.go` | Response publishing |
| `loop_event.go` | `agent_event.go` | Event system |
| `loop_command.go` | `agent_command.go` | Command processing |
| `loop_steering.go` | `agent_steering.go` | Steering message handling |
| `loop_transcribe.go` | `agent_transcribe.go` | Audio transcription |
| `loop_media.go` | `agent_media.go` | Media processing |
| `loop_mcp.go` | `agent_mcp.go` | MCP initialization |
| `loop_utils.go` | `agent_utils.go` | Utility functions |
| `loop_inject.go` | `agent_inject.go` | Dependency injection |
| `loop_turn.go` | `turn_coord.go` | Turn coordinator |
### File Merges (2 → 1)
| Original | New | Description |
|----------|-----|-------------|
| `turn.go` + `turn_exec.go` | `turn_state.go` | Turn-related type definitions |
## Final File Structure
```
pkg/agent/
├── agent.go # AgentLoop + Run/Stop/Close lifecycle
├── agent_message.go # Message processing
├── agent_outbound.go # Response publishing
├── agent_event.go # Event system
├── agent_command.go # Command processing
├── agent_steering.go # Steering
├── agent_transcribe.go # Transcription
├── agent_media.go # Media processing
├── agent_mcp.go # MCP
├── agent_utils.go # Utility functions
├── agent_inject.go # Dependency injection
├── turn_coord.go # runTurn + coordinator
├── turn_state.go # turnState + turnExecution + Control + ToolControl + LLMPhase
├── pipeline.go # Pipeline struct + NewPipeline
├── pipeline_setup.go
├── pipeline_llm.go
├── pipeline_execute.go
└── pipeline_finalize.go
```
## Naming Convention
| Prefix | Content | Example |
|--------|---------|---------|
| `agent_*` | AgentLoop method files | `agent_message.go`, `agent_event.go` |
| `turn_*` | Turn lifecycle related | `turn_coord.go`, `turn_state.go` |
| `pipeline_*` | Pipeline methods | `pipeline_setup.go`, `pipeline_llm.go` |
| `context_*` | Context management | `context_manager.go`, `context_legacy.go` |
| `hook_*` | Hook system | `hook_process.go`, `hook_mount.go` |
## Architecture Layers
```
┌─────────────────────────────────────────────────────────┐
│ AgentLoop (agent.go) │
│ - Message loop Run/Stop/Close │
│ - Dependency injection (agent_inject.go) │
│ - Message routing (agent_message.go) │
│ - Response publishing (agent_outbound.go) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Turn Coordinator (turn_coord.go) │
│ - runTurn(): main coordinator │
│ - abortTurn(): abort │
│ - askSideQuestion(): side question │
│ - selectCandidates(): model selection │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Pipeline (pipeline_*.go) │
│ - SetupTurn(): initialization │
│ - CallLLM(): LLM call │
│ - ExecuteTools(): tool execution │
│ - Finalize(): finalization │
└─────────────────────────────────────────────────────────┘
```
## Verification Results
- ✅ `go build ./pkg/agent/...` - Pass
- ✅ `go vet ./pkg/agent/...` - No warnings
- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - Pass
@@ -0,0 +1,100 @@
# Agent 文件重命名计划
## 目标
统一 `pkg/agent/` 包的文件命名,解决 `loop_*` 前缀命名混乱、职责边界不清晰的问题。
## 变更概览
### 文件重命名(12 个)
| 原文件 | 新文件 | 说明 |
|--------|--------|------|
| `loop.go` | `agent.go` | AgentLoop 主体 + 生命周期方法 |
| `loop_message.go` | `agent_message.go` | 消息处理和路由 |
| `loop_outbound.go` | `agent_outbound.go` | 响应发布 |
| `loop_event.go` | `agent_event.go` | 事件系统 |
| `loop_command.go` | `agent_command.go` | 命令处理 |
| `loop_steering.go` | `agent_steering.go` | Steering 消息处理 |
| `loop_transcribe.go` | `agent_transcribe.go` | 音频转录 |
| `loop_media.go` | `agent_media.go` | 媒体处理 |
| `loop_mcp.go` | `agent_mcp.go` | MCP 初始化 |
| `loop_utils.go` | `agent_utils.go` | 工具函数 |
| `loop_inject.go` | `agent_inject.go` | 依赖注入 |
| `loop_turn.go` | `turn_coord.go` | Turn 协调器 |
### 文件合并(2 → 1
| 原文件 | 新文件 | 说明 |
|--------|--------|------|
| `turn.go` + `turn_exec.go` | `turn_state.go` | Turn 相关类型定义 |
## 最终文件结构
```
pkg/agent/
├── agent.go # AgentLoop + Run/Stop/Close 生命周期
├── agent_message.go # 消息处理
├── agent_outbound.go # 响应发布
├── agent_event.go # 事件系统
├── agent_command.go # 命令处理
├── agent_steering.go # Steering
├── agent_transcribe.go # 转录
├── agent_media.go # 媒体处理
├── agent_mcp.go # MCP
├── agent_utils.go # 工具函数
├── agent_inject.go # 依赖注入
├── turn_coord.go # runTurn + 协调器
├── turn_state.go # turnState + turnExecution + Control + ToolControl + LLMPhase
├── pipeline.go # Pipeline struct + NewPipeline
├── pipeline_setup.go
├── pipeline_llm.go
├── pipeline_execute.go
└── pipeline_finalize.go
```
## 命名约定
| 前缀 | 内容 | 示例 |
|------|------|------|
| `agent_*` | AgentLoop 的方法文件 | `agent_message.go`, `agent_event.go` |
| `turn_*` | Turn 生命周期相关 | `turn_coord.go`, `turn_state.go` |
| `pipeline_*` | Pipeline 方法 | `pipeline_setup.go`, `pipeline_llm.go` |
| `context_*` | 上下文管理 | `context_manager.go`, `context_legacy.go` |
| `hook_*` | Hook 系统 | `hook_process.go`, `hook_mount.go` |
## 架构层次
```
┌─────────────────────────────────────────────────────────┐
│ AgentLoop (agent.go) │
│ - 消息循环 Run/Stop/Close │
│ - 依赖注入 (agent_inject.go) │
│ - 消息路由 (agent_message.go) │
│ - 响应发布 (agent_outbound.go) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Turn Coordinator (turn_coord.go) │
│ - runTurn(): 主协调器 │
│ - abortTurn(): 中止 │
│ - askSideQuestion(): 侧问 │
│ - selectCandidates(): 模型选择 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Pipeline (pipeline_*.go) │
│ - SetupTurn(): 初始化 │
│ - CallLLM(): LLM 调用 │
│ - ExecuteTools(): 工具执行 │
│ - Finalize(): 终结 │
└─────────────────────────────────────────────────────────┘
```
## 验证结果
- ✅ `go build ./pkg/agent/...` - 通过
- ✅ `go vet ./pkg/agent/...` - 无警告
- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - 通过
@@ -0,0 +1,77 @@
# AgentLoop File Split
> **Note:** This document describes the file split that was completed in a previous phase. The `loop_*` naming has since been renamed to `agent_*` and `turn_*`. See [agent-rename-plan.md](./agent-rename-plan.md) for the current file structure.
## Overview
The `pkg/agent/loop.go` file (originally 4384 lines) has been split into 12 focused source files. This is a pure refactoring with no behavioral changes.
## Goals
- Reduce cognitive load when navigating agent loop code
- Enable parallel work by decoupling concerns
- Maintain all existing functionality and tests
- Keep imports minimal per file
## Original File Map (Renamed in Phase 2)
| Old File | New File | Responsibility |
|----------|----------|----------------|
| `loop.go` | `agent.go` | Core `AgentLoop` struct, `Run`, `Stop`, `Close` |
| `loop_turn.go` | `turn_coord.go` + `pipeline_*.go` | Turn execution: coordinator + Pipeline methods |
| `loop_utils.go` | `agent_utils.go` | Standalone utility functions |
| `loop_init.go` | `agent_init.go` | `NewAgentLoop` constructor and tool registration |
| `loop_message.go` | `agent_message.go` | Message handling and routing |
| `loop_command.go` | `agent_command.go` | Command processing |
| `loop_mcp.go` | `agent_mcp.go` | MCP runtime |
| `loop_event.go` | `agent_event.go` | Event system helpers |
| `loop_media.go` | `agent_media.go` | Media resolution |
| `loop_outbound.go` | `agent_outbound.go` | Response publishing |
| `loop_transcribe.go` | `agent_transcribe.go` | Audio transcription |
| `loop_steering.go` | `agent_steering.go` | Steering queue |
| `loop_inject.go` | `agent_inject.go` | Setter injection |
## Current File Structure
See [agent-rename-plan.md](./agent-rename-plan.md) for the complete current file structure.
## Phase 2: Rename and Pipeline Restructuring
Phase 2 completed the following:
1. **File renaming**: All `loop_*` files renamed to `agent_*` or `turn_*`
2. **Turn state merging**: `turn.go` + `turn_exec.go``turn_state.go`
3. **Pipeline extraction**: Split large `runTurn` into Pipeline methods
### Pipeline Architecture
The Pipeline methods provide structured turn execution:
| Method | File | Responsibility |
|--------|------|----------------|
| `SetupTurn()` | `pipeline_setup.go` | History assembly, message building, candidate selection |
| `CallLLM()` | `pipeline_llm.go` | PreLLM hooks, fallback, retry, AfterLLM hooks |
| `ExecuteTools()` | `pipeline_execute.go` | Tool execution with hooks |
| `Finalize()` | `pipeline_finalize.go` | Session persistence, compression |
## Core Principles Applied
### 1. Same Package, Independent Files
All files belong to the `agent` package and compile together. This preserves the original visibility rules.
### 2. No Logic Changes
All functions were moved verbatim. The extraction preserved behavioral equivalence.
### 3. Shared Types in turn_state.go
The `turnState`, `turnExecution`, `Control`, `ToolControl`, and `LLMPhase` types are centralized in `turn_state.go`.
## Testing
All existing tests pass. The 5 failing tests (`TestGlobalSkillFileContentChange` and 4 Seahorse tests) are pre-existing failures unrelated to this refactor.
Build status: `go build ./pkg/agent/...` passes with no errors.
## See Also
- [agent-rename-plan.md](./agent-rename-plan.md) — Current file naming convention
- [context.md](context.md) — context management and session handling
@@ -0,0 +1,68 @@
# Pipeline Restructuring Plan
## Goal
Split `agent/pipeline.go` (~1400 lines) into multiple logical files, organizing code by responsibility.
## Final File Structure
```
pkg/agent/
├── pipeline.go # Pipeline struct + NewPipeline (~39 lines)
├── pipeline_setup.go # SetupTurn method (~115 lines)
├── pipeline_llm.go # CallLLM method (~519 lines)
├── pipeline_execute.go # ExecuteTools method (~693 lines)
└── pipeline_finalize.go # Finalize method (~78 lines)
```
## Actual Line Counts
| File | Lines |
|------|-------|
| `pipeline.go` | 39 |
| `pipeline_setup.go` | 115 |
| `pipeline_llm.go` | 519 |
| `pipeline_execute.go` | 693 |
| `pipeline_finalize.go` | 78 |
| **Total** | **1444** |
## Responsibility Matrix
| File | Method | Responsibility |
|------|--------|----------------|
| `pipeline.go` | `Pipeline` struct, `NewPipeline()` | Pipeline dependency container |
| `pipeline_setup.go` | `SetupTurn()` | Turn initialization: history assembly, message building, candidate selection |
| `pipeline_llm.go` | `CallLLM()` | LLM call: PreLLM hooks, fallback, retry, AfterLLM hooks |
| `pipeline_execute.go` | `ExecuteTools()` | Tool execution: BeforeTool/ApproveTool/AfterTool hooks, media sending, steering handling |
| `pipeline_finalize.go` | `Finalize()` | Turn finalization: session save, compression, status setting |
## Relationship Between Pipeline and Turn Coordinator
```
AgentLoop (agent.go)
├── runAgentLoop() ──────────────────┐
│ │
│ ┌───────────────────────────────▼───────────────────────────────┐
│ │ Turn Coordinator (turn_coord.go) │
│ │ │
│ │ runTurn() { │
│ │ exec = pipeline.SetupTurn() │
│ │ loop { │
│ │ ctrl = pipeline.CallLLM() ──► Pipeline (pipeline_*.go) │
│ │ if ctrl == ToolLoop { │
│ │ toolCtrl = pipeline.ExecuteTools() │
│ │ } │
│ │ } │
│ │ return pipeline.Finalize() │
│ │ } │
│ └─────────────────────────────────────────────────────────────┘
└── Publish response (agent_outbound.go)
```
## Verification Results
- ✅ `go build ./pkg/agent/...` - Pass
- ✅ `go vet ./pkg/agent/...` - No warnings
- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - Pass
@@ -0,0 +1,68 @@
# Pipeline 重构文档
## 目标
`agent/pipeline.go` (1400行) 拆分为多个逻辑文件,代码按职责组织。
## 最终文件结构
```
pkg/agent/
├── pipeline.go # Pipeline struct + NewPipeline (~39行)
├── pipeline_setup.go # SetupTurn 方法 (~115行)
├── pipeline_llm.go # CallLLM 方法 (~519行)
├── pipeline_execute.go # ExecuteTools 方法 (~693行)
└── pipeline_finalize.go # Finalize 方法 (~78行)
```
## 实际行数
| 文件 | 行数 |
|------|------|
| `pipeline.go` | 39 |
| `pipeline_setup.go` | 115 |
| `pipeline_llm.go` | 519 |
| `pipeline_execute.go` | 693 |
| `pipeline_finalize.go` | 78 |
| **总计** | **1444** |
## 职责说明
| 文件 | 方法 | 职责 |
|------|------|------|
| `pipeline.go` | `Pipeline` struct, `NewPipeline()` | Pipeline 依赖容器 |
| `pipeline_setup.go` | `SetupTurn()` | Turn 初始化:历史组装、消息构建、候选人选择 |
| `pipeline_llm.go` | `CallLLM()` | LLM 调用:PreLLM hook、fallback、重试、AfterLLM hook |
| `pipeline_execute.go` | `ExecuteTools()` | 工具执行:BeforeTool/ApproveTool/AfterTool hook、媒体发送、steering 处理 |
| `pipeline_finalize.go` | `Finalize()` | Turn 终结:会话保存、压缩、状态设置 |
## Pipeline 与 Turn Coordinator 的关系
```
AgentLoop (agent.go)
├── runAgentLoop() ──────────────────┐
│ │
│ ┌───────────────────────────────▼───────────────────────────────┐
│ │ Turn Coordinator (turn_coord.go) │
│ │ │
│ │ runTurn() { │
│ │ exec = pipeline.SetupTurn() │
│ │ loop { │
│ │ ctrl = pipeline.CallLLM() ──► Pipeline (pipeline_*.go) │
│ │ if ctrl == ToolLoop { │
│ │ toolCtrl = pipeline.ExecuteTools() │
│ │ } │
│ │ } │
│ │ return pipeline.Finalize() │
│ │ } │
│ └─────────────────────────────────────────────────────────────┘
└── 发布响应 (agent_outbound.go)
```
## 验证结果
- ✅ `go build ./pkg/agent/...` - 通过
- ✅ `go vet ./pkg/agent/...` - 无警告
- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - 通过
+282
View File
@@ -0,0 +1,282 @@
# Routing System
> Back to [README](../README.md)
In PicoClaw, the runtime "routing system" is not just one decision.
It is the combined pipeline that decides:
1. which agent handles an inbound message
2. which session dimensions should isolate that conversation
3. whether the turn should use the agent's primary model or a configured light model
This document covers the runtime path in `pkg/routing` and its integration in `pkg/agent`.
It does not describe the launcher's HTTP `ServeMux` routes or the frontend's TanStack Router files under `web/`.
## Routing Layers
| Layer | Files | Responsibility |
| --- | --- | --- |
| Agent dispatch | `pkg/routing/route.go`, `pkg/routing/agent_id.go` | Choose the target agent for the inbound message. |
| Session policy selection | `pkg/routing/route.go` | Decide which dimensions should define session isolation for that routed turn. |
| Model routing | `pkg/routing/router.go`, `pkg/routing/features.go`, `pkg/routing/classifier.go` | Choose between the primary model and a configured light model based on message complexity. |
| Runtime integration | `pkg/agent/registry.go`, `pkg/agent/agent_message.go`, `pkg/agent/turn_coord.go` | Apply the route result, allocate session scope, and select model candidates before provider execution. |
## End-To-End Flow
The normal path for a user message is:
```text
InboundMessage
-> NormalizeInboundContext
-> RouteResolver.ResolveRoute(...)
-> session.AllocateRouteSession(...)
-> ensureSessionMetadata(...)
-> Router.SelectModel(...)
-> provider execution
```
The first half answers "who should handle this message and what session does it belong to".
The second half answers "which model tier should that agent use for this turn".
## Agent Dispatch
`routing.RouteResolver` turns a normalized `bus.InboundContext` into a `ResolvedRoute`:
```go
type ResolvedRoute struct {
AgentID string
Channel string
AccountID string
SessionPolicy SessionPolicy
MatchedBy string
}
```
`MatchedBy` is a debugging aid.
Typical values are:
- `default`
- `dispatch.rule`
- `dispatch.rule:<rule-name>`
## Dispatch Input View
Before matching rules, the resolver builds a normalized `dispatchView`.
Each field is normalized to the exact shape expected by rule matching.
| Selector field | Runtime shape |
| --- | --- |
| `channel` | lowercased channel name |
| `account` | normalized account ID |
| `space` | `<space_type>:<space_id>` |
| `chat` | `<chat_type>:<chat_id>` |
| `topic` | `topic:<topic_id>` |
| `sender` | lowercased canonical sender ID |
| `mentioned` | boolean copied from inbound context |
This means dispatch rules must match the normalized shape, for example:
```json
{
"agents": {
"dispatch": {
"rules": [
{
"name": "support-group",
"agent": "support",
"when": {
"channel": "telegram",
"chat": "group:-100123"
}
},
{
"name": "slack-mentions",
"agent": "support",
"when": {
"channel": "slack",
"space": "workspace:t001",
"mentioned": true
}
}
]
}
}
}
```
## Dispatch Algorithm
`ResolveRoute(...)` follows this sequence:
1. Normalize `channel` and `account`.
2. Clone `session.identity_links` from config.
3. Build the normalized dispatch view.
4. Scan `agents.dispatch.rules` in order.
5. Skip rules with no constraints at all.
6. Return the first rule whose selector fields all match exactly.
7. If no rule matches, fall back to the default agent.
Important consequences:
- first match wins
- there is no score or priority field beyond list order
- invalid target agent IDs fall back to the default agent
- sender matching can see canonical identities produced by `identity_links`
## Default Agent Resolution
If no dispatch rule wins, or if a rule points at an unknown agent, the resolver picks a default agent using this order:
1. the agent marked `default: true`
2. otherwise the first entry in `agents.list`
3. otherwise implicit `main`
Both agent IDs and account IDs are normalized through the helpers in `pkg/routing/agent_id.go`.
## Session Policy Handoff
Agent dispatch does not directly build a session key.
Instead it emits a `SessionPolicy`:
```go
type SessionPolicy struct {
Dimensions []string
IdentityLinks map[string][]string
}
```
The dimensions come from:
- global `session.dimensions`
- or `dispatch_rule.session_dimensions` when the matching rule overrides them
Only these dimension names survive normalization:
- `space`
- `chat`
- `topic`
- `sender`
Invalid or duplicated entries are silently dropped.
`pkg/session/AllocateRouteSession(...)` then turns that policy into:
- a structured `SessionScope`
- a canonical routed session key
- legacy compatibility aliases
So the routing package owns "what should isolate this conversation", while the session package owns "how that isolation becomes keys and durable storage".
## Identity Links
`session.identity_links` is shared between dispatch and session allocation.
That is intentional: a sender canonicalized for routing should also map to the same session identity.
Without that symmetry, the system could route two messages to the same agent but still fragment their history into different sessions.
## Model Routing
The second routing stage decides whether a turn can use a cheaper or faster light model.
Config shape:
```json
{
"routing": {
"enabled": true,
"light_model": "gemini-2.0-flash",
"threshold": 0.35
}
}
```
`pkg/routing.Router` compares the current turn against structural features and returns:
- chosen model name
- whether the light model was used
- computed complexity score
If the score is below the threshold, the light model wins.
Otherwise the agent's primary model is used.
At runtime this only matters when the agent actually has light-model candidates configured; otherwise execution stays on the primary candidate set.
## Complexity Features
`ExtractFeatures(...)` computes a language-agnostic feature vector:
| Feature | Meaning |
| --- | --- |
| `TokenEstimate` | Approximate token count; CJK runes count more accurately than a flat rune split. |
| `CodeBlockCount` | Number of fenced code blocks in the current message. |
| `RecentToolCalls` | Tool-call count across the last six history entries. |
| `ConversationDepth` | Total history length. |
| `HasAttachments` | Detects embedded media or common media URL/file extensions. |
This is intentionally structural rather than keyword-based, so the router behaves the same across languages.
## RuleClassifier Scoring
The current classifier is `RuleClassifier`.
It uses a weighted sum capped to `[0, 1]`.
| Signal | Score |
| --- | --- |
| attachments present | `1.00` |
| token estimate `> 200` | `0.35` |
| token estimate `> 50` | `0.15` |
| code block present | `0.40` |
| recent tool calls `> 3` | `0.25` |
| recent tool calls `1..3` | `0.10` |
| conversation depth `> 10` | `0.10` |
The default threshold is `0.35`.
That makes the following behavior intentional:
- trivial chat stays on the light model
- code tasks usually jump to the heavy model immediately
- attachments always force the heavy model
- long, plain-text prompts cross the heavy-model boundary at the default threshold
## Runtime Integration
Agent dispatch and model routing happen in different places:
- `pkg/agent/registry.go` owns `RouteResolver`
- `pkg/agent/agent_message.go` resolves the route and allocates session scope
- `pkg/agent/turn_coord.go:selectCandidates` calls `agent.Router.SelectModel(...)`
When the light model is selected, the agent loop swaps to `agent.LightCandidates`.
When it is not selected, execution stays on the agent's primary provider candidate set.
## Explicit Session Keys
One nuance sits just outside `pkg/routing` but matters for the full routing story.
After a route is allocated, `pkg/agent/agent_utils.go:resolveScopeKey` preserves an explicit incoming session key when the caller already supplied:
- an opaque canonical key
- a legacy `agent:...` key
That makes manual system flows, tests, and compatibility paths deterministic even when the normal routed scope would have produced a different key.
## What This Document Does Not Cover
The repository also contains two unrelated route systems:
- backend HTTP routes registered in `web/backend/api/router.go`
- frontend file routes under `web/frontend/src/routes/`
Those are launcher implementation details.
They are separate from the runtime routing system described here.
## Related Files
- `pkg/routing/route.go`
- `pkg/routing/router.go`
- `pkg/routing/classifier.go`
- `pkg/routing/features.go`
- `pkg/routing/agent_id.go`
- `pkg/session/allocator.go`
- `pkg/agent/registry.go`
- `pkg/agent/agent_message.go`
- `pkg/agent/turn_coord.go`
+281
View File
@@ -0,0 +1,281 @@
# 路由系统
> 返回 [README](../README.md)
在 PicoClaw 里,“路由系统”不是单一判断。
它实际上是组合起来的一条运行时决策链,负责决定:
1. 哪个 agent 来处理一条入站消息
2. 这条消息应该落在哪种 session 隔离维度下
3. 这一轮该使用 agent 的主模型,还是配置中的轻量模型
本文覆盖 `pkg/routing` 及其在 `pkg/agent` 中的集成方式。
它不讨论 `web/` 目录下 launcher 的 HTTP `ServeMux` 路由,也不讨论前端 TanStack Router 文件路由。
## 路由分层
| 层次 | 文件 | 作用 |
| --- | --- | --- |
| Agent 分发 | `pkg/routing/route.go``pkg/routing/agent_id.go` | 为入站消息选择目标 agent。 |
| Session 策略选择 | `pkg/routing/route.go` | 决定该 turn 的会话隔离维度。 |
| 模型路由 | `pkg/routing/router.go``pkg/routing/features.go``pkg/routing/classifier.go` | 根据消息复杂度在主模型和轻量模型之间做选择。 |
| 运行时集成 | `pkg/agent/registry.go``pkg/agent/loop_message.go``pkg/agent/loop_turn.go` | 应用 route 结果、分配 session scope,并在真正调用 provider 前选出模型候选集。 |
## 端到端流程
普通用户消息的路径如下:
```text
InboundMessage
-> NormalizeInboundContext
-> RouteResolver.ResolveRoute(...)
-> session.AllocateRouteSession(...)
-> ensureSessionMetadata(...)
-> Router.SelectModel(...)
-> provider execution
```
前半段回答的是“谁来处理,以及属于哪段会话”。
后半段回答的是“这个 agent 这一轮该走哪一档模型”。
## Agent 分发
`routing.RouteResolver` 会把归一化后的 `bus.InboundContext` 转成 `ResolvedRoute`
```go
type ResolvedRoute struct {
AgentID string
Channel string
AccountID string
SessionPolicy SessionPolicy
MatchedBy string
}
```
`MatchedBy` 主要用于日志和调试,常见值包括:
- `default`
- `dispatch.rule`
- `dispatch.rule:<rule-name>`
## Dispatch 输入视图
真正做规则匹配前,resolver 会先构造一个归一化后的 `dispatchView`
每个字段都会变成规则匹配所期待的固定形状。
| Selector 字段 | 运行时形状 |
| --- | --- |
| `channel` | 小写 channel 名称 |
| `account` | 归一化后的 account ID |
| `space` | `<space_type>:<space_id>` |
| `chat` | `<chat_type>:<chat_id>` |
| `topic` | `topic:<topic_id>` |
| `sender` | 小写 canonical sender ID |
| `mentioned` | 直接来自 inbound context 的布尔值 |
这意味着 dispatch rule 必须写成归一化后的形状,例如:
```json
{
"agents": {
"dispatch": {
"rules": [
{
"name": "support-group",
"agent": "support",
"when": {
"channel": "telegram",
"chat": "group:-100123"
}
},
{
"name": "slack-mentions",
"agent": "support",
"when": {
"channel": "slack",
"space": "workspace:t001",
"mentioned": true
}
}
]
}
}
}
```
## Dispatch 算法
`ResolveRoute(...)` 的流程是:
1. 归一化 `channel``account`
2. 从配置复制 `session.identity_links`
3. 构建归一化后的 dispatch view。
4. 按顺序扫描 `agents.dispatch.rules`
5. 没有任何约束条件的 rule 会被跳过。
6. 第一个所有 selector 字段都精确匹配的 rule 胜出。
7. 如果没有 rule 匹配,则回退到默认 agent。
这带来几个重要结论:
- 第一条命中的规则优先,没有额外 priority 字段
- rule 顺序本身就是优先级
- 指向无效 agent 的 rule 最终会回退到默认 agent
- sender 匹配看到的是经过 `identity_links` 归一化后的身份
## 默认 Agent 解析
如果没有 dispatch rule 命中,或者 rule 指向了不存在的 agent,resolver 会按以下顺序选择默认 agent:
1. `default: true` 的 agent
2. 否则取 `agents.list` 的第一项
3. 如果配置里没有 agent,则使用隐式 `main`
Agent ID 和 Account ID 都会经过 `pkg/routing/agent_id.go` 中的归一化逻辑。
## Session 策略交接
Agent 分发本身不会直接生成 session key。
它只会产出一个 `SessionPolicy`
```go
type SessionPolicy struct {
Dimensions []string
IdentityLinks map[string][]string
}
```
维度来源有两种:
- 全局 `session.dimensions`
- 如果命中的 dispatch rule 指定了 `session_dimensions`,则用 rule 覆盖
最终只有这些维度名会被保留下来:
- `space`
- `chat`
- `topic`
- `sender`
非法项或重复项会被静默丢弃。
随后 `pkg/session/AllocateRouteSession(...)` 再把这份策略转成:
- 结构化 `SessionScope`
- canonical routed session key
- legacy 兼容 alias
所以可以把职责边界理解为:
- `pkg/routing` 决定“这段对话应该按什么维度隔离”
- `pkg/session` 决定“这些维度如何变成 key 和持久化状态”
## Identity Links
`session.identity_links` 会同时被 dispatch 和 session allocation 使用。
这是刻意保持一致的设计:如果某个 sender 在路由阶段已经被规范化,那么 session 阶段也应该落到同一个身份上。
否则就会出现“消息路由到了同一个 agent,但上下文仍被拆成多个 session”的问题。
## 模型路由
第二阶段路由决定这一轮能否使用更便宜或更快的轻量模型。
配置形状如下:
```json
{
"routing": {
"enabled": true,
"light_model": "gemini-2.0-flash",
"threshold": 0.35
}
}
```
`pkg/routing.Router` 会根据当前 turn 的结构特征,返回:
- 选中的模型名
- 是否使用了 light model
- 复杂度分数
当分数低于阈值时,走轻量模型;否则仍使用 agent 的主模型。
但在运行时,只有当 agent 实际配置了 light-model candidates 时,这个判断才会产生效果;否则仍会停留在主模型候选集上。
## 复杂度特征
`ExtractFeatures(...)` 会计算一个与自然语言内容无关、偏结构化的特征向量:
| 特征 | 含义 |
| --- | --- |
| `TokenEstimate` | 估算 token 数;对 CJK 文本比简单 rune 平分更准确。 |
| `CodeBlockCount` | 当前消息中 fenced code block 的数量。 |
| `RecentToolCalls` | 最近 6 条历史消息中的 tool call 总数。 |
| `ConversationDepth` | 整体历史长度。 |
| `HasAttachments` | 是否检测到嵌入媒体或常见媒体 URL / 文件扩展名。 |
这样做的目的,是让模型路由不依赖关键词,从而在不同语言下都保持一致行为。
## RuleClassifier 评分
当前分类器是 `RuleClassifier`,使用加权求和并把结果截断到 `[0, 1]`
| 信号 | 分值 |
| --- | --- |
| 存在附件 | `1.00` |
| token 估计 `> 200` | `0.35` |
| token 估计 `> 50` | `0.15` |
| 存在代码块 | `0.40` |
| 最近 tool calls `> 3` | `0.25` |
| 最近 tool calls `1..3` | `0.10` |
| 会话深度 `> 10` | `0.10` |
默认阈值是 `0.35`
这意味着以下行为是刻意设计出来的:
- 很轻的闲聊仍走轻量模型
- 编码类请求通常会立刻切到重模型
- 带附件的请求一定走重模型
- 很长的纯文本请求在默认阈值下也会跨过重模型边界
## 运行时集成
Agent 分发和模型路由发生在不同位置:
- `pkg/agent/registry.go` 持有 `RouteResolver`
- `pkg/agent/loop_message.go` 负责 resolve route 并分配 session scope
- `pkg/agent/loop_turn.go:selectCandidates` 调用 `agent.Router.SelectModel(...)`
当 light model 被选中时,agent loop 会切换到 `agent.LightCandidates`
如果没有被选中,则继续使用 agent 的主 provider 候选集。
## 显式 Session Key
还有一个不在 `pkg/routing` 内部、但对整体“路由语义”很重要的细节。
在 route 分配完成后,`pkg/agent/loop_utils.go:resolveScopeKey` 会优先保留调用方显式传入的 session key,只要它属于以下格式之一:
- 不透明 canonical key
- legacy `agent:...` key
这样一来,手工系统流、测试和兼容路径即使在正常路由 scope 会生成不同 key 的情况下,仍然能保持确定性。
## 本文不覆盖的内容
仓库里还存在两套和这里无关的“route”系统:
- `web/backend/api/router.go` 注册的后端 HTTP 路由
- `web/frontend/src/routes/` 下的前端文件路由
它们属于 launcher 的实现细节,和本文描述的运行时路由系统是两回事。
## 相关文件
- `pkg/routing/route.go`
- `pkg/routing/router.go`
- `pkg/routing/classifier.go`
- `pkg/routing/features.go`
- `pkg/routing/agent_id.go`
- `pkg/session/allocator.go`
- `pkg/agent/registry.go`
- `pkg/agent/loop_message.go`
- `pkg/agent/loop_turn.go`
+255
View File
@@ -0,0 +1,255 @@
# Session System
> Back to [README](../README.md)
This document describes the runtime session system used by PicoClaw to:
- map inbound messages onto stable conversation scopes
- persist message history and summaries
- preserve compatibility with legacy `agent:...` session keys while the runtime uses opaque canonical keys
This document covers the core runtime path in `pkg/session`, `pkg/memory`, and `pkg/agent`.
It does not describe launcher login cookies or dashboard authentication sessions in `web/backend/middleware`.
## Responsibilities
The session system has four jobs:
1. Decide which messages should share the same conversation context.
2. Persist that context durably across turns and restarts.
3. Expose a small `SessionStore` interface to the agent loop.
4. Keep older session-key formats working during storage and routing migrations.
## Main Components
| Layer | Files | Responsibility |
| --- | --- | --- |
| Session contract | `pkg/session/session_store.go` | Defines the `SessionStore` interface used by the agent loop. |
| Legacy backend | `pkg/session/manager.go` | Stores one JSON file per session. Still used as a fallback. |
| Session adapter | `pkg/session/jsonl_backend.go` | Adapts `pkg/memory.Store` to `SessionStore`, including alias and scope metadata support. |
| Durable storage | `pkg/memory/jsonl.go` | Append-only JSONL storage plus `.meta.json` sidecar metadata. |
| Scope and key building | `pkg/session/scope.go`, `pkg/session/key.go`, `pkg/session/allocator.go` | Builds structured scopes, opaque canonical keys, and legacy aliases from routing results. |
| Runtime integration | `pkg/agent/instance.go`, `pkg/agent/agent.go`, `pkg/agent/agent_message.go` | Initializes the store, allocates session scope, and persists metadata before turns run. |
## Session Data Model
The structured session identity is represented by `session.SessionScope`:
| Field | Meaning |
| --- | --- |
| `Version` | Schema version. Current value is `ScopeVersionV1`. |
| `AgentID` | Routed agent handling the turn. |
| `Channel` | Normalized inbound channel name. |
| `Account` | Normalized account or bot identifier. |
| `Dimensions` | Ordered list of active partition dimensions such as `chat` or `sender`. |
| `Values` | Concrete normalized values for each selected dimension. |
Only four dimensions are currently recognized by the allocator:
- `space`
- `chat`
- `topic`
- `sender`
The default config uses:
```json
{
"session": {
"dimensions": ["chat"]
}
}
```
That means one shared conversation per chat unless a dispatch rule overrides it.
## Canonical Keys And Legacy Aliases
The runtime now prefers opaque canonical keys:
```text
sk_v1_<sha256>
```
These keys are built from a canonical scope signature in `pkg/session/key.go`.
The goal is to make storage keys stable while decoupling them from any specific legacy text format.
For compatibility, the allocator also emits legacy aliases such as:
```text
agent:main:direct:user123
agent:main:slack:channel:c001
agent:main:pico:direct:pico:session-123
```
These aliases matter because older sessions, tests, and some tools still refer to the legacy shape.
The JSONL backend resolves aliases back to the canonical key before reads and writes.
The agent loop also preserves explicit incoming session keys when the caller already supplied one of the recognized explicit formats:
- opaque canonical key
- legacy `agent:...` key
That behavior lives in `pkg/agent/agent_utils.go:resolveScopeKey`.
## Allocation Flow
The end-to-end flow for a normal inbound message is:
```text
InboundMessage
-> RouteResolver.ResolveRoute(...)
-> session.AllocateRouteSession(...)
-> resolveScopeKey(...)
-> ensureSessionMetadata(...)
-> AgentLoop turn execution
-> SessionStore read/write operations
```
More concretely:
1. `pkg/agent/agent_message.go` resolves the agent route from normalized inbound context.
2. `session.AllocateRouteSession` converts the route's `SessionPolicy` plus inbound context into a structured `SessionScope`.
3. The allocator builds:
- `SessionKey`: canonical routed session key
- `SessionAliases`: compatibility aliases for that routed scope
- `MainSessionKey`: agent-level main session key
- `MainAliases`: legacy alias for the main session
4. `runAgentLoop` persists scope metadata and aliases through `ensureSessionMetadata`.
5. During later reads or writes, `JSONLBackend.ResolveSessionKey` maps aliases back onto the canonical key.
The main session key is separate from routed chat sessions.
It is mainly used for agent-level or system-style flows that need one stable per-agent conversation, for example `processSystemMessage`.
## Scope Construction Rules
`pkg/session/allocator.go` builds scope values from normalized inbound context.
Important rules:
- `space` becomes `<space_type>:<space_id>`
- `chat` becomes `<chat_type>:<chat_id>`
- `topic` becomes `topic:<topic_id>`
- `sender` is canonicalized through `session.identity_links` before being stored
There are two special cases worth calling out.
### Telegram forum isolation
Telegram forum topics must stay isolated even when the configured dimensions only mention `chat`.
To preserve that behavior, the allocator appends `/<topic_id>` to the `chat` value for Telegram forum messages unless `topic` is already an explicit dimension.
Example:
```text
group:-1001234567890/42
group:-1001234567890/99
```
Those produce different session keys.
### Identity links
`session.identity_links` lets multiple sender identifiers collapse into one canonical identity.
Both dispatch matching and session allocation use that mapping so that the same person can keep one conversation even if their raw sender IDs differ across channels or accounts.
## Storage Format
The default runtime backend is `pkg/memory.JSONLStore`, wrapped by `session.JSONLBackend`.
Each session uses two files:
```text
{sanitized_key}.jsonl
{sanitized_key}.meta.json
```
The files store:
- `.jsonl`: one `providers.Message` per line, append-only
- `.meta.json`: summary, timestamps, line counts, logical truncation offset, scope, aliases
`SessionMeta` currently includes:
- `Key`
- `Summary`
- `Skip`
- `Count`
- `CreatedAt`
- `UpdatedAt`
- `Scope`
- `Aliases`
## Write And Crash Semantics
The JSONL store is designed around append-first durability and stale-over-loss recovery:
- `AddMessage` and `AddFullMessage` append one JSON line, `fsync`, then update metadata.
- `TruncateHistory` is logical first: it only advances `meta.Skip`.
- `Compact` physically rewrites the JSONL file to remove skipped lines.
- `SetHistory` and `Compact` write metadata before rewriting JSONL so a crash may temporarily expose old data, but should not lose data.
- Corrupt JSONL lines are skipped during reads instead of failing the entire session.
`JSONLBackend.Save` maps onto `store.Compact(...)`.
In other words, `Save` is no longer "flush dirty memory to disk"; it is now "reclaim dead lines after logical truncation".
## Concurrency Model
`pkg/memory.JSONLStore` uses a fixed 64-shard mutex array keyed by session hash.
That gives per-session serialization without keeping an unbounded mutex map in memory.
The legacy `SessionManager` uses a single in-memory map guarded by an RW mutex.
Both backends satisfy the same `SessionStore` interface, which is why the agent loop does not need storage-specific code.
## Compatibility And Migration
`pkg/agent/instance.go:initSessionStore` prefers the JSONL backend.
Startup sequence:
1. Create `memory.NewJSONLStore(dir)`.
2. Run `memory.MigrateFromJSON(...)` to import legacy `.json` sessions.
3. Wrap the store with `session.NewJSONLBackend(store)`.
4. If JSONL initialization or migration fails, fall back to `session.NewSessionManager(dir)`.
This fallback is intentional: a partial migration would be worse than staying on the legacy store for one run.
### Alias promotion
When canonical metadata is first created, `EnsureSessionMetadata` may promote history from a non-empty legacy alias into the canonical session.
That promotion only happens when the canonical session is still empty, so active canonical history is not overwritten.
This is how the system preserves old histories such as:
- legacy direct-message keys
- older Pico direct-session keys
while moving the runtime onto opaque canonical keys.
## Other SessionStore Implementations
`pkg/agent/subturn.go` defines an `ephemeralSessionStore`.
It satisfies the same `SessionStore` interface, but keeps data in memory only and is destroyed when the sub-turn ends.
That lets SubTurn reuse the same session-facing APIs without writing child-session history into the parent's durable storage.
## Operational Consumers
The session system is consumed by more than the agent loop:
- `web/backend/api/session.go` reads JSONL metadata and legacy JSON sessions to expose session history in the launcher UI.
- `pkg/agent/steering.go` can recover scope metadata for active steering flows.
- tooling and tests can still refer to legacy aliases because alias resolution is handled below the agent loop.
## Related Files
- `pkg/session/session_store.go`
- `pkg/session/manager.go`
- `pkg/session/jsonl_backend.go`
- `pkg/session/scope.go`
- `pkg/session/key.go`
- `pkg/session/allocator.go`
- `pkg/memory/jsonl.go`
- `pkg/agent/instance.go`
- `pkg/agent/agent.go`
- `pkg/agent/agent_message.go`
+254
View File
@@ -0,0 +1,254 @@
# Session 系统
> 返回 [README](../README.md)
本文说明 PicoClaw 运行时的 Session 系统如何完成以下事情:
- 把入站消息映射到稳定的会话作用域
- 持久化消息历史与摘要
- 在运行时使用不透明 canonical key 的同时,继续兼容旧的 `agent:...` session key
本文覆盖 `pkg/session``pkg/memory``pkg/agent` 中的核心运行时链路。
它不讨论 `web/backend/middleware` 中 launcher 登录 Cookie 或 dashboard 鉴权 session。
## 职责
Session 系统承担四件事:
1. 决定哪些消息应该共享同一段上下文。
2. 让这段上下文能跨 turn、跨进程重启持久存在。
3. 向 agent loop 暴露一个足够小的 `SessionStore` 抽象。
4. 在存储层和路由层迁移期间继续兼容旧 session key。
## 主要组件
| 层次 | 文件 | 作用 |
| --- | --- | --- |
| Session 抽象 | `pkg/session/session_store.go` | 定义 agent loop 依赖的 `SessionStore` 接口。 |
| 旧后端 | `pkg/session/manager.go` | 每个 session 一个 JSON 文件的旧实现,仍作为回退方案保留。 |
| Session 适配层 | `pkg/session/jsonl_backend.go` | 把 `pkg/memory.Store` 适配成 `SessionStore`,并支持 alias 与 scope metadata。 |
| 持久化存储 | `pkg/memory/jsonl.go` | Append-only JSONL 存储与 `.meta.json` 元数据侧文件。 |
| Scope / Key 构建 | `pkg/session/scope.go``pkg/session/key.go``pkg/session/allocator.go` | 从路由结果生成结构化 scope、不透明 canonical key 和 legacy alias。 |
| 运行时集成 | `pkg/agent/instance.go``pkg/agent/loop.go``pkg/agent/loop_message.go` | 初始化存储、分配 session scope,并在 turn 执行前落 metadata。 |
## Session 数据模型
结构化的会话身份由 `session.SessionScope` 表示:
| 字段 | 含义 |
| --- | --- |
| `Version` | Scope 模式版本,当前为 `ScopeVersionV1`。 |
| `AgentID` | 处理该 turn 的路由 agent。 |
| `Channel` | 归一化后的入站 channel 名称。 |
| `Account` | 归一化后的 bot / account 标识。 |
| `Dimensions` | 当前启用的隔离维度顺序,例如 `chat``sender`。 |
| `Values` | 每个维度对应的具体归一化值。 |
Allocator 当前只识别四个维度:
- `space`
- `chat`
- `topic`
- `sender`
默认配置是:
```json
{
"session": {
"dimensions": ["chat"]
}
}
```
也就是默认按 chat 共享上下文;如果 dispatch rule 覆盖了维度,则以 rule 为准。
## Canonical Key 与 Legacy Alias
运行时现在优先使用不透明 canonical key
```text
sk_v1_<sha256>
```
它由 `pkg/session/key.go` 中的 scope signature 计算得到。
这样可以让存储 key 稳定,同时不再把持久化格式和某一种旧文本 key 绑定死。
为了兼容旧数据,allocator 还会生成 legacy alias,例如:
```text
agent:main:direct:user123
agent:main:slack:channel:c001
agent:main:pico:direct:pico:session-123
```
这些 alias 很重要,因为旧 session、部分测试以及某些工具仍然会引用这种格式。
JSONL backend 会在读写前先把 alias 解析回 canonical key。
此外,如果调用方已经显式传入了受支持的 session keyagent loop 会保留它,不强行改成新分配的 routed key。
这条逻辑在 `pkg/agent/loop_utils.go:resolveScopeKey` 中:
- 不透明 canonical key
- legacy `agent:...` key
都属于“显式 key”。
## 分配流程
普通入站消息的完整链路如下:
```text
InboundMessage
-> RouteResolver.ResolveRoute(...)
-> session.AllocateRouteSession(...)
-> resolveScopeKey(...)
-> ensureSessionMetadata(...)
-> AgentLoop turn 执行
-> SessionStore 读写
```
具体来说:
1. `pkg/agent/loop_message.go` 先用归一化后的 inbound context 解析 agent route。
2. `session.AllocateRouteSession` 把 route 的 `SessionPolicy` 和 inbound context 组合成结构化 `SessionScope`
3. Allocator 会生成:
- `SessionKey`:当前路由会话的 canonical key
- `SessionAliases`:该路由会话的兼容 alias
- `MainSessionKey`agent 级主会话 key
- `MainAliases`:主会话对应的 legacy alias
4. `runAgentLoop` 通过 `ensureSessionMetadata` 持久化 scope metadata 和 alias。
5. 后续读写时,`JSONLBackend.ResolveSessionKey` 会先把 alias 映射回 canonical key。
`MainSessionKey` 和普通聊天会话是分开的。
它主要服务于 agent 级、系统级的上下文场景,比如 `processSystemMessage`
## Scope 构建规则
`pkg/session/allocator.go` 会从归一化后的 inbound context 生成 scope 值。
关键规则如下:
- `space` 变成 `<space_type>:<space_id>`
- `chat` 变成 `<chat_type>:<chat_id>`
- `topic` 变成 `topic:<topic_id>`
- `sender` 会先经过 `session.identity_links` 归一化再写入
其中有两个需要单独记住的特殊规则。
### Telegram forum 隔离
Telegram forum topic 必须默认保持隔离,即使配置只写了 `chat` 维度。
为此,如果消息来自 Telegram forum 且策略里没有显式包含 `topic`allocator 会把 `/<topic_id>` 拼到 `chat` 值后面。
例如:
```text
group:-1001234567890/42
group:-1001234567890/99
```
这两者会得到不同的 session key。
### Identity links
`session.identity_links` 可以把多个 sender 标识折叠为一个 canonical identity。
dispatch 匹配和 session 分配都会使用这套映射,因此同一个人即使跨 channel 或 account 使用不同原始 sender ID,也可以继续落到同一段上下文里。
## 存储格式
默认运行时后端是 `pkg/memory.JSONLStore`,外面包了一层 `session.JSONLBackend`
每个 session 使用两类文件:
```text
{sanitized_key}.jsonl
{sanitized_key}.meta.json
```
各自保存:
- `.jsonl`:一行一个 `providers.Message`append-only
- `.meta.json`:摘要、时间戳、行数、逻辑截断偏移、scope、aliases
`SessionMeta` 当前包含:
- `Key`
- `Summary`
- `Skip`
- `Count`
- `CreatedAt`
- `UpdatedAt`
- `Scope`
- `Aliases`
## 写入与崩溃语义
JSONL store 的设计核心是“追加优先、宁可暂时读到旧数据也不要丢数据”:
- `AddMessage` / `AddFullMessage` 先追加一行 JSON,再 `fsync`,最后更新 metadata。
- `TruncateHistory` 先做逻辑截断,本质上只是推进 `meta.Skip`
- `Compact` 才会真正重写 JSONL 文件,把被跳过的旧行物理移除。
- `SetHistory``Compact` 都会先写 metadata 再改写 JSONL;如果中途崩溃,最多短时间暴露旧数据,不应丢数据。
- 读取 JSONL 时如果碰到损坏行,会跳过该行,而不是让整个 session 读取失败。
`JSONLBackend.Save` 对应到底层的 `store.Compact(...)`
也就是说,`Save` 在新实现里不再是“把内存脏数据刷盘”,而是“在逻辑截断后回收无效行占用的磁盘空间”。
## 并发模型
`pkg/memory.JSONLStore` 使用固定 64 分片 mutex,按 session key 的 hash 做串行化。
这样既能做到“按 session 串行”,又不会因为 session 数量增长而把 mutex map 做成无界结构。
旧的 `SessionManager` 则是一个内存 map 加 RW mutex。
这两个实现都满足同一个 `SessionStore` 接口,所以 agent loop 不需要写任何存储后端特化逻辑。
## 兼容与迁移
`pkg/agent/instance.go:initSessionStore` 会优先初始化 JSONL 后端。
启动过程如下:
1. 创建 `memory.NewJSONLStore(dir)`
2. 执行 `memory.MigrateFromJSON(...)`,把旧 `.json` session 迁入新格式。
3. 用 `session.NewJSONLBackend(store)` 包装。
4. 如果 JSONL 初始化或迁移失败,则回退到 `session.NewSessionManager(dir)`
这个回退是刻意设计的:做一半的迁移,比整轮继续使用旧后端更危险。
### Alias 提升
第一次为 canonical key 建 metadata 时,`EnsureSessionMetadata` 会尝试把某个非空 legacy alias 的历史提升到 canonical session。
但这件事只会在 canonical session 仍然为空时发生,因此不会覆盖已经存在的 canonical 历史。
这保证了系统在迁移到 opaque key 的同时,仍能保留旧历史,例如:
- 旧的 direct-message key
- 旧的 Pico direct-session key
## 其他 SessionStore 实现
`pkg/agent/subturn.go` 里定义了 `ephemeralSessionStore`
它同样实现 `SessionStore`,但只存在于内存里,在 sub-turn 结束时销毁。
这样 SubTurn 就能复用相同的 session 接口,而不会把子任务历史写进父会话的持久存储。
## 运行时消费者
Session 系统不只被 agent loop 使用:
- `web/backend/api/session.go` 会读取 JSONL metadata 和旧 JSON session,并把历史暴露给 launcher UI。
- `pkg/agent/steering.go` 可以在 steering 场景下恢复 scope metadata。
- 因为 alias 解析发生在 agent loop 之下,测试和工具仍然可以继续使用 legacy alias。
## 相关文件
- `pkg/session/session_store.go`
- `pkg/session/manager.go`
- `pkg/session/jsonl_backend.go`
- `pkg/session/scope.go`
- `pkg/session/key.go`
- `pkg/session/allocator.go`
- `pkg/memory/jsonl.go`
- `pkg/agent/instance.go`
- `pkg/agent/loop.go`
- `pkg/agent/loop_message.go`
@@ -170,13 +170,19 @@ This is saved to the session via `AddFullMessage` and sent to the model, so it i
## Automatic bus drain
When the agent loop (`Run()`) starts processing a message, it spawns a background goroutine that keeps consuming new inbound messages from the bus. These messages are automatically redirected into the steering queue via `Steer()`. This means:
When the agent loop (`Run()`) starts, it reads inbound messages from a shared message bus. The routing logic determines how each message is handled:
- Users on any channel (Telegram, Discord, etc.) don't need to do anything special — their messages are automatically captured as steering when the agent is busy
- Audio messages are transcribed before being steered, so the agent receives text. If transcription fails, the original (non-transcribed) message is steered as-is
- Only messages that resolve to the **same steering scope** as the active turn are redirected. Messages for other chats/sessions are requeued onto the inbound bus so they can be processed normally
- `system` inbound messages are not treated as steering input
- When `processMessage` finishes, the drain goroutine is canceled and normal message consumption resumes
1. **No active turn for the message's session** — the message is dispatched to a **worker goroutine** that processes the full turn (LLM calls, tool execution, steering drain)
2. **An active turn already exists for the same session** — the message is enqueued directly into that session's **steering queue** via `enqueueSteeringMessage`. No background drain goroutine is needed
3. **Non-routable message** (e.g. `system`) — processed synchronously in the main loop
This design enables **parallel processing of messages from different sessions** while keeping same-session messages strictly sequential. Key implications:
- Messages from different users/channels are processed **concurrently** (up to `max_parallel_turns`)
- Messages from the same session are **serialized** — subsequent messages go to the steering queue
- Users don't need to do anything special — their messages are automatically captured as steering when the agent is busy for their session
- Audio messages are transcribed within the worker that processes the turn, so the agent receives text
- `system` inbound messages are processed immediately and do not trigger steering
## Steering with media
@@ -112,13 +112,17 @@ When the parent task is forcefully aborted (e.g., user interrupts with `/stop`):
## Agent Loop Integration
### Bus Draining During Processing
### Message Routing and Steering
When a message enters the `Run()` loop, the agent starts a `drainBusToSteering` goroutine before calling `processMessage`. This goroutine runs concurrently with the entire processing lifecycle and continuously consumes any new inbound messages from the bus, redirecting them into the **steering queue** instead of dropping them.
When a message enters the `Run()` loop, the agent determines whether to start a new worker or enqueue to steering:
This ensures that if a user sends a follow-up message while the agent is processing (including during SubTurn execution), the message is not lost — it will be picked up between tool call iterations via `dequeueSteeringMessages`.
- If **no active turn** exists for the message's session key, the session is atomically reserved and a **worker goroutine** is spawned. The worker processes the full turn lifecycle: `processMessage` → tool execution → steering drain → `Continue` for queued messages.
- If an **active turn already exists** for the same session, the message is enqueued directly into that session's steering queue. It will be picked up by the existing worker's steering drain loop.
The drain goroutine stops automatically when `processMessage` returns (via a cancellable context).
This ensures that:
- Messages from **different sessions** are processed **in parallel** (up to `max_parallel_turns` concurrent workers)
- Messages from the **same session** are strictly **serialized** — they go to the steering queue and are processed sequentially within the active turn
- No background drain goroutine is needed; steering is handled by the worker itself after processing
### Pending Result Polling
@@ -129,7 +133,7 @@ The agent loop polls for async SubTurn results at two points per iteration:
### Turn State Tracking
All active root turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns.
All active turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). A reservation sentinel is stored atomically via `LoadOrStore` before the worker starts, then replaced with the real `*turnState` when `runTurn` registers. This prevents a TOCTOU race where multiple messages for the same session could spawn concurrent workers. The sentinel is cleaned up by the worker's deferred cleanup. This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns.
## Event Bus Integration
@@ -181,10 +185,10 @@ Creates a new spawner instance for the given AgentLoop. Pass the returned value
### Continue
```go
func (al *AgentLoop) Continue(ctx context.Context, sessionKey string) error
func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error)
```
Resumes an idle agent turn by injecting any queued steering messages as a new LLM iteration. Used when the agent is waiting and a deferred steering message needs to be processed without a new inbound message arriving.
Resumes an idle agent turn by dequeuing steering messages for the given session and running them through the agent loop. Returns the response string if processing occurred, or empty string if no steering messages were pending. Uses session-aware active turn checking — it only blocks if a turn is active for the *same* session, not for unrelated sessions.
## Context Propagation
+3 -2
View File
@@ -1,4 +1,4 @@
> Retour au [README](../../../README.fr.md)
> Retour au [README](../../project/README.fr.md)
# DingTalk
@@ -8,9 +8,10 @@ DingTalk est la plateforme de communication d'entreprise d'Alibaba, très popula
```json
{
"channels": {
"channel_list": {
"dingtalk": {
"enabled": true,
"type": "dingtalk",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
+3 -2
View File
@@ -1,4 +1,4 @@
> [README](../../../README.ja.md) に戻る
> [README](../../project/README.ja.md) に戻る
# DingTalk
@@ -8,9 +8,10 @@ DingTalkはアリババの企業向けコミュニケーションプラットフ
```json
{
"channels": {
"channel_list": {
"dingtalk": {
"enabled": true,
"type": "dingtalk",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
+2 -1
View File
@@ -8,9 +8,10 @@ DingTalk is Alibaba's enterprise communication platform, widely used in Chinese
```json
{
"channels": {
"channel_list": {
"dingtalk": {
"enabled": true,
"type": "dingtalk",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
+3 -2
View File
@@ -1,4 +1,4 @@
> Voltar ao [README](../../../README.pt-br.md)
> Voltar ao [README](../../project/README.pt-br.md)
# DingTalk
@@ -8,9 +8,10 @@ DingTalk é a plataforma de comunicação empresarial da Alibaba, amplamente uti
```json
{
"channels": {
"channel_list": {
"dingtalk": {
"enabled": true,
"type": "dingtalk",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
+3 -2
View File
@@ -1,4 +1,4 @@
> Quay lại [README](../../../README.vi.md)
> Quay lại [README](../../project/README.vi.md)
# DingTalk
@@ -8,9 +8,10 @@ DingTalk là nền tảng giao tiếp doanh nghiệp của Alibaba, được s
```json
{
"channels": {
"channel_list": {
"dingtalk": {
"enabled": true,
"type": "dingtalk",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
+3 -2
View File
@@ -1,4 +1,4 @@
> 返回 [README](../../../README.zh.md)
> 返回 [README](../../project/README.zh.md)
# 钉钉
@@ -8,9 +8,10 @@
```json
{
"channels": {
"channel_list": {
"dingtalk": {
"enabled": true,
"type": "dingtalk",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
+3 -2
View File
@@ -1,4 +1,4 @@
> Retour au [README](../../../README.fr.md)
> Retour au [README](../../project/README.fr.md)
# Discord
@@ -8,9 +8,10 @@ Discord est une application gratuite de chat vocal, vidéo et textuel conçue po
```json
{
"channels": {
"channel_list": {
"discord": {
"enabled": true,
"type": "discord",
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"group_trigger": {
+3 -2
View File
@@ -1,4 +1,4 @@
> [README](../../../README.ja.md) に戻る
> [README](../../project/README.ja.md) に戻る
# Discord
@@ -8,9 +8,10 @@ Discord はコミュニティ向けに設計された無料の音声・ビデオ
```json
{
"channels": {
"channel_list": {
"discord": {
"enabled": true,
"type": "discord",
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"group_trigger": {
+2 -1
View File
@@ -8,9 +8,10 @@ Discord is a free voice, video, and text chat application designed for communiti
```json
{
"channels": {
"channel_list": {
"discord": {
"enabled": true,
"type": "discord",
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"group_trigger": {
+3 -2
View File
@@ -1,4 +1,4 @@
> Voltar ao [README](../../../README.pt-br.md)
> Voltar ao [README](../../project/README.pt-br.md)
# Discord
@@ -8,9 +8,10 @@ Discord é um aplicativo gratuito de chat de voz, vídeo e texto projetado para
```json
{
"channels": {
"channel_list": {
"discord": {
"enabled": true,
"type": "discord",
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"group_trigger": {
+3 -2
View File
@@ -1,4 +1,4 @@
> Quay lại [README](../../../README.vi.md)
> Quay lại [README](../../project/README.vi.md)
# Discord
@@ -8,9 +8,10 @@ Discord là ứng dụng chat thoại, video và văn bản miễn phí được
```json
{
"channels": {
"channel_list": {
"discord": {
"enabled": true,
"type": "discord",
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"group_trigger": {
+3 -2
View File
@@ -1,4 +1,4 @@
> 返回 [README](../../../README.zh.md)
> 返回 [README](../../project/README.zh.md)
# Discord
@@ -8,9 +8,10 @@ Discord 是一个专为社区设计的免费语音、视频和文本聊天应用
```json
{
"channels": {
"channel_list": {
"discord": {
"enabled": true,
"type": "discord",
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"group_trigger": {
+3 -2
View File
@@ -1,4 +1,4 @@
> Retour au [README](../../../README.fr.md)
> Retour au [README](../../project/README.fr.md)
# Feishu
@@ -8,9 +8,10 @@ Feishu (nom international : Lark) est une plateforme de collaboration d'entrepri
```json
{
"channels": {
"channel_list": {
"feishu": {
"enabled": true,
"type": "feishu",
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
+3 -2
View File
@@ -1,4 +1,4 @@
> [README](../../../README.ja.md) に戻る
> [README](../../project/README.ja.md) に戻る
# 飛書(Feishu
@@ -8,9 +8,10 @@
```json
{
"channels": {
"channel_list": {
"feishu": {
"enabled": true,
"type": "feishu",
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
+2 -1
View File
@@ -8,9 +8,10 @@ Feishu (international name: Lark) is an enterprise collaboration platform by Byt
```json
{
"channels": {
"channel_list": {
"feishu": {
"enabled": true,
"type": "feishu",
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
+3 -2
View File
@@ -1,4 +1,4 @@
> Voltar ao [README](../../../README.pt-br.md)
> Voltar ao [README](../../project/README.pt-br.md)
# Feishu
@@ -8,9 +8,10 @@ Feishu (nome internacional: Lark) é uma plataforma de colaboração empresarial
```json
{
"channels": {
"channel_list": {
"feishu": {
"enabled": true,
"type": "feishu",
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
+3 -2
View File
@@ -1,4 +1,4 @@
> Quay lại [README](../../../README.vi.md)
> Quay lại [README](../../project/README.vi.md)
# Feishu
@@ -8,9 +8,10 @@ Feishu (tên quốc tế: Lark) là nền tảng cộng tác doanh nghiệp củ
```json
{
"channels": {
"channel_list": {
"feishu": {
"enabled": true,
"type": "feishu",
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
+3 -2
View File
@@ -1,4 +1,4 @@
> 返回 [README](../../../README.zh.md)
> 返回 [README](../../project/README.zh.md)
# 飞书
@@ -8,9 +8,10 @@
```json
{
"channels": {
"channel_list": {
"feishu": {
"enabled": true,
"type": "feishu",
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
+3 -2
View File
@@ -1,4 +1,4 @@
> Retour au [README](../../../README.fr.md)
> Retour au [README](../../project/README.fr.md)
# Line
@@ -8,9 +8,10 @@ PicoClaw prend en charge LINE via l'API LINE Messaging avec des callbacks webhoo
```json
{
"channels": {
"channel_list": {
"line": {
"enabled": true,
"type": "line",
"channel_secret": "YOUR_CHANNEL_SECRET",
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
"webhook_path": "/webhook/line",
+3 -2
View File
@@ -1,4 +1,4 @@
> [README](../../../README.ja.md) に戻る
> [README](../../project/README.ja.md) に戻る
# Line
@@ -8,9 +8,10 @@ PicoClaw は LINE Messaging API と Webhook コールバックを通じて LINE
```json
{
"channels": {
"channel_list": {
"line": {
"enabled": true,
"type": "line",
"channel_secret": "YOUR_CHANNEL_SECRET",
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
"webhook_path": "/webhook/line",
+2 -1
View File
@@ -8,9 +8,10 @@ PicoClaw supports LINE through the LINE Messaging API with webhook callbacks.
```json
{
"channels": {
"channel_list": {
"line": {
"enabled": true,
"type": "line",
"channel_secret": "YOUR_CHANNEL_SECRET",
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
"webhook_path": "/webhook/line",
+3 -2
View File
@@ -1,4 +1,4 @@
> Voltar ao [README](../../../README.pt-br.md)
> Voltar ao [README](../../project/README.pt-br.md)
# Line
@@ -8,9 +8,10 @@ O PicoClaw suporta o LINE por meio da LINE Messaging API com callbacks de webhoo
```json
{
"channels": {
"channel_list": {
"line": {
"enabled": true,
"type": "line",
"channel_secret": "YOUR_CHANNEL_SECRET",
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
"webhook_path": "/webhook/line",
+3 -2
View File
@@ -1,4 +1,4 @@
> Quay lại [README](../../../README.vi.md)
> Quay lại [README](../../project/README.vi.md)
# Line
@@ -8,9 +8,10 @@ PicoClaw hỗ trợ LINE thông qua LINE Messaging API kết hợp với webhook
```json
{
"channels": {
"channel_list": {
"line": {
"enabled": true,
"type": "line",
"channel_secret": "YOUR_CHANNEL_SECRET",
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
"webhook_path": "/webhook/line",
+3 -2
View File
@@ -1,4 +1,4 @@
> 返回 [README](../../../README.zh.md)
> 返回 [README](../../project/README.zh.md)
# Line
@@ -8,9 +8,10 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的
```json
{
"channels": {
"channel_list": {
"line": {
"enabled": true,
"type": "line",
"channel_secret": "YOUR_CHANNEL_SECRET",
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
"webhook_path": "/webhook/line",
+3 -2
View File
@@ -1,4 +1,4 @@
> Retour au [README](../../../README.fr.md)
> Retour au [README](../../project/README.fr.md)
# MaixCam
@@ -8,9 +8,10 @@ MaixCam est un canal dédié à la connexion aux caméras AI Sipeed MaixCAM et M
```json
{
"channels": {
"channel_list": {
"maixcam": {
"enabled": true,
"type": "maixcam",
"host": "0.0.0.0",
"port": 18790,
"allow_from": []
+3 -2
View File
@@ -1,4 +1,4 @@
> [README](../../../README.ja.md) に戻る
> [README](../../project/README.ja.md) に戻る
# MaixCam
@@ -8,9 +8,10 @@ MaixCam は、Sipeed MaixCAM および MaixCAM2 AI カメラデバイスへの
```json
{
"channels": {
"channel_list": {
"maixcam": {
"enabled": true,
"type": "maixcam",
"host": "0.0.0.0",
"port": 18790,
"allow_from": []
+2 -1
View File
@@ -8,9 +8,10 @@ MaixCam is a dedicated channel for connecting to Sipeed MaixCAM and MaixCAM2 AI
```json
{
"channels": {
"channel_list": {
"maixcam": {
"enabled": true,
"type": "maixcam",
"host": "0.0.0.0",
"port": 18790,
"allow_from": []
+3 -2
View File
@@ -1,4 +1,4 @@
> Voltar ao [README](../../../README.pt-br.md)
> Voltar ao [README](../../project/README.pt-br.md)
# MaixCam
@@ -8,9 +8,10 @@ MaixCam é um canal dedicado para conectar dispositivos de câmera AI Sipeed Mai
```json
{
"channels": {
"channel_list": {
"maixcam": {
"enabled": true,
"type": "maixcam",
"host": "0.0.0.0",
"port": 18790,
"allow_from": []
+3 -2
View File
@@ -1,4 +1,4 @@
> Quay lại [README](../../../README.vi.md)
> Quay lại [README](../../project/README.vi.md)
# MaixCam
@@ -8,9 +8,10 @@ MaixCam là kênh chuyên dụng để kết nối với các thiết bị camer
```json
{
"channels": {
"channel_list": {
"maixcam": {
"enabled": true,
"type": "maixcam",
"host": "0.0.0.0",
"port": 18790,
"allow_from": []
+3 -2
View File
@@ -1,4 +1,4 @@
> 返回 [README](../../../README.zh.md)
> 返回 [README](../../project/README.zh.md)
# MaixCam
@@ -8,9 +8,10 @@ MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的
```json
{
"channels": {
"channel_list": {
"maixcam": {
"enabled": true,
"type": "maixcam",
"host": "0.0.0.0",
"port": 18790,
"allow_from": []
+3 -2
View File
@@ -1,4 +1,4 @@
> Retour au [README](../../../README.fr.md)
> Retour au [README](../../project/README.fr.md)
# Guide de configuration du canal Matrix
@@ -8,9 +8,10 @@ Ajoutez ceci à `config.json` :
```json
{
"channels": {
"channel_list": {
"matrix": {
"enabled": true,
"type": "matrix",
"homeserver": "https://matrix.org",
"user_id": "@your-bot:matrix.org",
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
+3 -2
View File
@@ -1,4 +1,4 @@
> [README](../../../README.ja.md) に戻る
> [README](../../project/README.ja.md) に戻る
# Matrix チャンネル設定ガイド
@@ -8,9 +8,10 @@
```json
{
"channels": {
"channel_list": {
"matrix": {
"enabled": true,
"type": "matrix",
"homeserver": "https://matrix.org",
"user_id": "@your-bot:matrix.org",
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
+2 -1
View File
@@ -8,9 +8,10 @@ Add this to `config.json`:
```json
{
"channels": {
"channel_list": {
"matrix": {
"enabled": true,
"type": "matrix",
"homeserver": "https://matrix.org",
"user_id": "@your-bot:matrix.org",
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",

Some files were not shown because too many files have changed in this diff Show More