Merge pull request #31 from dj-oyu/refactor/upstream-compat
refactor: upstream merge + hook-based fork isolation
This commit is contained in:
commit
87f9e6b255
377 changed files with 51667 additions and 18898 deletions
138
.github/workflows/nightly.yml
vendored
Normal file
138
.github/workflows/nightly.yml
vendored
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
name: Nightly Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
nightly:
|
||||||
|
name: Nightly Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Compute version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
DATE=$(date -u +%Y%m%d)
|
||||||
|
SHA=$(git rev-parse --short=8 HEAD)
|
||||||
|
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
|
||||||
|
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
|
||||||
|
VERSION="v0.0.0-nightly.${DATE}.${SHA}"
|
||||||
|
else
|
||||||
|
VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMPARE_URL="https://github.com/${{ github.repository }}/commits/main"
|
||||||
|
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then
|
||||||
|
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...main"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Setup Go from go.mod
|
||||||
|
id: setup-go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: docker.io
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create local tag for GoReleaser
|
||||||
|
run: git tag "${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
distribution: goreleaser
|
||||||
|
version: ~> v2
|
||||||
|
args: release --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||||
|
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||||
|
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||||
|
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
|
||||||
|
NIGHTLY_BUILD: "true"
|
||||||
|
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||||
|
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||||
|
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||||
|
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||||
|
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||||
|
|
||||||
|
- name: Update nightly release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
CHANGELOG='${{ steps.version.outputs.changelog }}'
|
||||||
|
NOTES=$(cat <<EOF
|
||||||
|
Nightly build for **${VERSION}**
|
||||||
|
|
||||||
|
This is an automated build and may be unstable. Use with caution.
|
||||||
|
|
||||||
|
${CHANGELOG}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete existing nightly release and tag
|
||||||
|
gh release delete nightly --cleanup-tag -y 2>/dev/null || true
|
||||||
|
|
||||||
|
# Force-update nightly tag to current HEAD
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -fa nightly -m "Nightly build ${VERSION}"
|
||||||
|
git push origin nightly
|
||||||
|
|
||||||
|
# Collect release artifacts from goreleaser dist/
|
||||||
|
ASSETS=()
|
||||||
|
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do
|
||||||
|
[ -f "$f" ] && ASSETS+=("$f")
|
||||||
|
done
|
||||||
|
|
||||||
|
# Create nightly release (prerelease, NOT latest)
|
||||||
|
gh release create nightly \
|
||||||
|
--title "Nightly Build" \
|
||||||
|
--notes "$NOTES" \
|
||||||
|
--target "${{ github.sha }}" \
|
||||||
|
--prerelease \
|
||||||
|
--latest=false \
|
||||||
|
"${ASSETS[@]}"
|
||||||
|
|
||||||
21
.github/workflows/pr.yml
vendored
21
.github/workflows/pr.yml
vendored
|
|
@ -1,7 +1,7 @@
|
||||||
name: PR
|
name: PR
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request: {}
|
pull_request: { }
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
|
|
@ -19,6 +19,9 @@ jobs:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: cd pkg/miniapp/frontend && bun install
|
||||||
|
|
||||||
- name: Run go generate
|
- name: Run go generate
|
||||||
run: go generate ./...
|
run: go generate ./...
|
||||||
|
|
||||||
|
|
@ -61,23 +64,11 @@ jobs:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Install frontend deps
|
||||||
uses: actions/setup-node@v6
|
run: cd pkg/miniapp/frontend && bun install
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Run go generate
|
- name: Run go generate
|
||||||
run: go generate ./...
|
run: go generate ./...
|
||||||
|
|
||||||
- name: Run frontend tests
|
|
||||||
run: |
|
|
||||||
pnpm --dir pkg/miniapp/frontend install --frozen-lockfile
|
|
||||||
pnpm --dir pkg/miniapp/frontend test
|
|
||||||
|
|
||||||
- name: Run go test
|
- name: Run go test
|
||||||
run: go test ./...
|
run: go test ./...
|
||||||
|
|
|
||||||
13
.github/workflows/release.yml
vendored
13
.github/workflows/release.yml
vendored
|
|
@ -65,6 +65,14 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
|
@ -96,6 +104,11 @@ jobs:
|
||||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||||
|
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||||
|
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||||
|
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||||
|
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||||
|
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||||
|
|
||||||
- name: Apply release flags
|
- name: Apply release flags
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -47,14 +47,12 @@ docs/plans/
|
||||||
|
|
||||||
# Added by goreleaser init:
|
# Added by goreleaser init:
|
||||||
dist/
|
dist/
|
||||||
!pkg/miniapp/static/dist/
|
*.vite/
|
||||||
!pkg/miniapp/static/dist/**
|
|
||||||
|
|
||||||
# Windows Application Icon/Resource
|
# Windows Application Icon/Resource
|
||||||
*.syso
|
*.syso
|
||||||
|
|
||||||
|
# Keep embedded backend dist directory placeholder in VCS
|
||||||
# Frontend dependencies
|
!web/backend/dist/
|
||||||
node_modules/
|
web/backend/dist/*
|
||||||
|
!web/backend/dist/.gitkeep
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
- go generate ./...
|
- go generate ./...
|
||||||
|
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
|
||||||
- go install github.com/tc-hib/go-winres@latest
|
- go install github.com/tc-hib/go-winres@latest
|
||||||
- go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
- go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
|
|
@ -17,10 +18,10 @@ builds:
|
||||||
- stdjson
|
- stdjson
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }}
|
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }}
|
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }}
|
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }}
|
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
|
|
@ -32,9 +33,13 @@ builds:
|
||||||
- riscv64
|
- riscv64
|
||||||
- loong64
|
- loong64
|
||||||
- arm
|
- arm
|
||||||
|
- s390x
|
||||||
|
- mipsle
|
||||||
goarm:
|
goarm:
|
||||||
- "6"
|
- "6"
|
||||||
- "7"
|
- "7"
|
||||||
|
gomips:
|
||||||
|
- softfloat
|
||||||
main: ./cmd/picoclaw
|
main: ./cmd/picoclaw
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
|
|
@ -59,10 +64,14 @@ builds:
|
||||||
- riscv64
|
- riscv64
|
||||||
- loong64
|
- loong64
|
||||||
- arm
|
- arm
|
||||||
|
- s390x
|
||||||
|
- mipsle
|
||||||
goarm:
|
goarm:
|
||||||
- "6"
|
- "6"
|
||||||
- "7"
|
- "7"
|
||||||
main: ./cmd/picoclaw-launcher
|
gomips:
|
||||||
|
- softfloat
|
||||||
|
main: ./web/backend
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
|
@ -86,9 +95,13 @@ builds:
|
||||||
- riscv64
|
- riscv64
|
||||||
- loong64
|
- loong64
|
||||||
- arm
|
- arm
|
||||||
|
- s390x
|
||||||
|
- mipsle
|
||||||
goarm:
|
goarm:
|
||||||
- "6"
|
- "6"
|
||||||
- "7"
|
- "7"
|
||||||
|
gomips:
|
||||||
|
- softfloat
|
||||||
main: ./cmd/picoclaw-launcher-tui
|
main: ./cmd/picoclaw-launcher-tui
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
|
|
@ -103,15 +116,49 @@ dockers_v2:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
images:
|
images:
|
||||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||||
- "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}"
|
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||||
tags:
|
tags:
|
||||||
- "{{ .Tag }}"
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
|
||||||
- "latest"
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
|
||||||
platforms:
|
platforms:
|
||||||
- linux/amd64
|
- linux/amd64
|
||||||
- linux/arm64
|
- linux/arm64
|
||||||
- linux/riscv64
|
- linux/riscv64
|
||||||
|
|
||||||
|
- id: picoclaw-launcher
|
||||||
|
dockerfile: docker/Dockerfile.goreleaser.launcher
|
||||||
|
ids:
|
||||||
|
- picoclaw
|
||||||
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
|
images:
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||||
|
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||||
|
tags:
|
||||||
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}'
|
||||||
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
|
- linux/riscv64
|
||||||
|
|
||||||
|
notarize:
|
||||||
|
macos:
|
||||||
|
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
|
||||||
|
ids:
|
||||||
|
- picoclaw
|
||||||
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
|
sign:
|
||||||
|
certificate: "{{.Env.MACOS_SIGN_P12}}"
|
||||||
|
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
|
||||||
|
notarize:
|
||||||
|
issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}"
|
||||||
|
key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}"
|
||||||
|
key: "{{.Env.MACOS_NOTARY_KEY}}"
|
||||||
|
wait: true
|
||||||
|
timeout: 20m
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- formats: [tar.gz]
|
- formats: [tar.gz]
|
||||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||||
|
|
@ -129,7 +176,7 @@ archives:
|
||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
builds:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
- picoclaw-launcher
|
- picoclaw-launcher
|
||||||
- picoclaw-launcher-tui
|
- picoclaw-launcher-tui
|
||||||
|
|
@ -149,6 +196,11 @@ nfpms:
|
||||||
- rpm
|
- rpm
|
||||||
- deb
|
- deb
|
||||||
bindir: /usr/bin
|
bindir: /usr/bin
|
||||||
|
contents:
|
||||||
|
- src: web/picoclaw-launcher.desktop
|
||||||
|
dst: /usr/share/applications/picoclaw-launcher.desktop
|
||||||
|
- src: web/picoclaw-launcher.png
|
||||||
|
dst: /usr/share/icons/hicolor/512x512/apps/picoclaw-launcher.png
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
|
|
@ -163,6 +215,7 @@ changelog:
|
||||||
# lzma: true
|
# lzma: true
|
||||||
|
|
||||||
release:
|
release:
|
||||||
|
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
||||||
footer: >-
|
footer: >-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
20
Makefile
20
Makefile
|
|
@ -11,8 +11,8 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||||
BUILD_TIME=$(shell date +%FT%T%z)
|
BUILD_TIME=$(shell date +%FT%T%z)
|
||||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||||
INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal
|
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||||
LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w"
|
LDFLAGS=-ldflags "-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w"
|
||||||
|
|
||||||
# Go variables
|
# Go variables
|
||||||
GO?=CGO_ENABLED=0 go
|
GO?=CGO_ENABLED=0 go
|
||||||
|
|
@ -110,12 +110,18 @@ build: generate
|
||||||
@$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
@$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BINARY_PATH)"
|
@echo "Build complete: $(BINARY_PATH)"
|
||||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
@if [ "$(PLATFORM)" = "linux" ] || [ "$(PLATFORM)" = "darwin" ]; then \
|
|
||||||
echo "Install to /usr/local/bin:"; \
|
## build-launcher: Build the picoclaw-launcher (web console) binary
|
||||||
echo " sudo install -m 755 $(BINARY_PATH) /usr/local/bin/$(BINARY_NAME)"; \
|
build-launcher:
|
||||||
else \
|
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
||||||
echo "Install hint skipped: /usr/local/bin command is for Linux/macOS (current: $(PLATFORM))."; \
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
@if [ ! -f web/backend/dist/index.html ]; then \
|
||||||
|
echo "Building frontend..."; \
|
||||||
|
cd web/frontend && pnpm install && pnpm build:backend; \
|
||||||
fi
|
fi
|
||||||
|
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
||||||
|
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
||||||
|
|
||||||
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||||
build-whatsapp-native: generate
|
build-whatsapp-native: generate
|
||||||
|
|
|
||||||
63
README.fr.md
63
README.fr.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
|
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
|
||||||
|
|
||||||
|
|
@ -206,9 +206,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 Démarrage Rapide
|
### 🚀 Démarrage Rapide
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Configurez votre clé API dans `~/.picoclaw/config.json`.
|
> Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenez des clés API : [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement l'[API Tavily](https://tavily.com) (1000 requêtes gratuites/mois) ou l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois).
|
||||||
> Obtenir des clés API : [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> La recherche web est **optionnelle** — obtenez gratuitement l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois) ou utilisez le repli automatique intégré.
|
|
||||||
|
|
||||||
**1. Initialiser**
|
**1. Initialiser**
|
||||||
|
|
||||||
|
|
@ -222,8 +220,13 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"request_timeout": 300,
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
|
|
@ -231,7 +234,7 @@ picoclaw onboard
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt4"
|
"model_name": "gpt-5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
|
|
@ -649,7 +652,6 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.
|
||||||
├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min)
|
├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min)
|
||||||
├── IDENTITY.md # Identité de l'Agent
|
├── IDENTITY.md # Identité de l'Agent
|
||||||
├── SOUL.md # Âme de l'Agent
|
├── SOUL.md # Âme de l'Agent
|
||||||
├── TOOLS.md # Description des outils
|
|
||||||
└── USER.md # Préférences utilisateur
|
└── USER.md # Préférences utilisateur
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -978,8 +980,10 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
|
||||||
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
|
||||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
|
||||||
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -989,8 +993,13 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1006,7 +1015,7 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1017,8 +1026,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -1061,14 +1079,14 @@ Configurez plusieurs points de terminaison pour le même nom de modèle—PicoCl
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1200,6 +1218,13 @@ Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assu
|
||||||
| Service | Offre Gratuite | Cas d'Utilisation |
|
| Service | Offre Gratuite | Cas d'Utilisation |
|
||||||
| ---------------- | -------------------- | ------------------------------------- |
|
| ---------------- | -------------------- | ------------------------------------- |
|
||||||
| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/mois | Idéal pour les utilisateurs chinois |
|
| **Volcengine CodingPlan** | 9,9¥/premier mois | Idéal pour les utilisateurs chinois, multiples modèles SOTA (Doubao, DeepSeek, etc.) |
|
||||||
|
| **Zhipu** | 200K tokens/mois | Convient aux utilisateurs chinois |
|
||||||
| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
|
| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
|
||||||
| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
|
| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
63
README.ja.md
63
README.ja.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
||||||
|
|
||||||
|
|
@ -168,9 +168,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 クイックスタート(ネイティブ)
|
### 🚀 クイックスタート(ネイティブ)
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> `~/.picoclaw/config.json` に API キーを設定してください。
|
> `~/.picoclaw/config.json` に API キーを設定してください。API キーの取得先: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は **任意** です — 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)。
|
||||||
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
|
|
||||||
|
|
||||||
**1. 初期化**
|
**1. 初期化**
|
||||||
|
|
||||||
|
|
@ -184,8 +182,13 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"request_timeout": 300,
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
|
|
@ -193,7 +196,7 @@ picoclaw onboard
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt4"
|
"model_name": "gpt-5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
|
|
@ -610,7 +613,6 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
|
||||||
├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認)
|
├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認)
|
||||||
├── IDENTITY.md # エージェントのアイデンティティ
|
├── IDENTITY.md # エージェントのアイデンティティ
|
||||||
├── SOUL.md # エージェントのソウル
|
├── SOUL.md # エージェントのソウル
|
||||||
├── TOOLS.md # ツールの説明
|
|
||||||
└── USER.md # ユーザー設定
|
└── USER.md # ユーザー設定
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -919,8 +921,10 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
|
||||||
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
|
||||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
|
||||||
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
|
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -930,8 +934,13 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -947,7 +956,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -958,8 +967,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -1002,14 +1020,14 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1120,9 +1138,16 @@ Web 検索を有効にするには:
|
||||||
| サービス | 無料枠 | ユースケース |
|
| サービス | 無料枠 | ユースケース |
|
||||||
|---------|--------|------------|
|
|---------|--------|------------|
|
||||||
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
|
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
|
||||||
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
| **Volcengine CodingPlan** | 9.9元/初月 | 中国ユーザーに最適、複数のSOTAモデル(Doubao、DeepSeek等) |
|
||||||
|
| **Zhipu** | 月 200K トークン | 中国ユーザーに適している |
|
||||||
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
||||||
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
||||||
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
||||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||||
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
78
README.md
78
README.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
|
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
|
||||||
|
|
||||||
|
|
@ -194,6 +194,19 @@ docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
docker compose -f docker/docker-compose.yml --profile gateway down
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Launcher Mode (Web Console)
|
||||||
|
|
||||||
|
The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> The web console does not yet support authentication. Avoid exposing it to the public internet.
|
||||||
|
|
||||||
### Agent Mode (One-shot)
|
### Agent Mode (One-shot)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -214,9 +227,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 Quick Start
|
### 🚀 Quick Start
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Set your API key in `~/.picoclaw/config.json`.
|
> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
|
||||||
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month), [SearXNG](https://github.com/searxng/searxng) (free, self-hosted) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
|
||||||
|
|
||||||
**1. Initialize**
|
**1. Initialize**
|
||||||
|
|
||||||
|
|
@ -231,7 +242,7 @@ picoclaw onboard
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt-5.4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
|
|
@ -239,8 +250,13 @@ picoclaw onboard
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "your-api-key",
|
"api_key": "your-api-key",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
|
|
@ -774,7 +790,6 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
||||||
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
|
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
|
||||||
├── IDENTITY.md # Agent identity
|
├── IDENTITY.md # Agent identity
|
||||||
├── SOUL.md # Agent soul
|
├── SOUL.md # Agent soul
|
||||||
├── TOOLS.md # Tool descriptions
|
|
||||||
└── USER.md # User preferences
|
└── USER.md # User preferences
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1018,9 +1033,11 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||||
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
||||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
||||||
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
||||||
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -1030,8 +1047,13 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1047,7 +1069,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1059,8 +1081,18 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -1139,14 +1171,14 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1486,8 +1518,16 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
||||||
| Service | Free Tier | Use Case |
|
| Service | Free Tier | Use Case |
|
||||||
| ---------------- | ------------------------ | ------------------------------------- |
|
| ---------------- | ------------------------ | ------------------------------------- |
|
||||||
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) |
|
||||||
|
| **Zhipu** | 200K tokens/month | Suitable for Chinese users |
|
||||||
| **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
|
| **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
|
||||||
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
|
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
|
||||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||||
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
||||||
|
| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
|
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
|
||||||
|
|
||||||
|
|
@ -207,9 +207,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 Início Rápido
|
### 🚀 Início Rápido
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Configure sua API key em `~/.picoclaw/config.json`.
|
> Configure sua API key em `~/.picoclaw/config.json`. Obtenha API keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Busca web é **opcional** — obtenha a [API Tavily](https://tavily.com) gratuita (1000 consultas grátis/mês) ou a [Brave Search API](https://brave.com/search/api) (2000 consultas grátis/mês).
|
||||||
> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas grátis/mês) ou use o fallback automático integrado.
|
|
||||||
|
|
||||||
**1. Inicializar**
|
**1. Inicializar**
|
||||||
|
|
||||||
|
|
@ -223,8 +221,13 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"request_timeout": 300,
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
|
|
@ -232,7 +235,7 @@ picoclaw onboard
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model_name": "gpt4"
|
"model_name": "gpt-5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
@ -645,7 +648,6 @@ O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/worksp
|
||||||
├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
|
├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
|
||||||
├── IDENTITY.md # Identidade do Agente
|
├── IDENTITY.md # Identidade do Agente
|
||||||
├── SOUL.md # Alma do Agente
|
├── SOUL.md # Alma do Agente
|
||||||
├── TOOLS.md # Descrição das ferramentas
|
|
||||||
└── USER.md # Preferencias do usuario
|
└── USER.md # Preferencias do usuario
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -974,8 +976,10 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
|
||||||
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
|
||||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
|
||||||
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -985,8 +989,13 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1002,7 +1011,7 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1013,8 +1022,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -1057,14 +1075,14 @@ Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-r
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1196,7 +1214,14 @@ Isso acontece quando outra instância do bot está em execução. Certifique-se
|
||||||
| Serviço | Plano Gratuito | Caso de Uso |
|
| Serviço | Plano Gratuito | Caso de Uso |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/mês | Melhor para usuários chineses |
|
| **Volcengine CodingPlan** | ¥9,9/primeiro mês | Ideal para usuários chineses, múltiplos modelos SOTA (Doubao, DeepSeek, etc.) |
|
||||||
|
| **Zhipu** | 200K tokens/mês | Adequado para usuários chineses |
|
||||||
| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
|
| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
|
||||||
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
|
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
|
||||||
| **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) |
|
| **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
61
README.vi.md
61
README.vi.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
||||||
|
|
||||||
|
|
@ -187,9 +187,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 Bắt đầu nhanh
|
### 🚀 Bắt đầu nhanh
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Thiết lập API key trong `~/.picoclaw/config.json`.
|
> Thiết lập API key trong `~/.picoclaw/config.json`. Lấy API key: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Tìm kiếm web là **tùy chọn** — lấy [Tavily API](https://tavily.com) miễn phí (1000 truy vấn/tháng) hoặc [Brave Search API](https://brave.com/search/api) (2000 truy vấn/tháng).
|
||||||
> Lấy API key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> Tìm kiếm web là **tùy chọn** — lấy [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn.
|
|
||||||
|
|
||||||
**1. Khởi tạo**
|
**1. Khởi tạo**
|
||||||
|
|
||||||
|
|
@ -203,8 +201,13 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"request_timeout": 300,
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
|
|
@ -617,7 +620,6 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định:
|
||||||
├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
|
├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
|
||||||
├── IDENTITY.md # Danh tính Agent
|
├── IDENTITY.md # Danh tính Agent
|
||||||
├── SOUL.md # Tâm hồn/Tính cách Agent
|
├── SOUL.md # Tâm hồn/Tính cách Agent
|
||||||
├── TOOLS.md # Mô tả công cụ
|
|
||||||
└── USER.md # Tùy chọn người dùng
|
└── USER.md # Tùy chọn người dùng
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -943,8 +945,10 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
|
||||||
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
|
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
|
||||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
|
||||||
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
|
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -954,8 +958,13 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -971,7 +980,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -982,8 +991,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VolcEngine (Doubao)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -1026,14 +1044,14 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -1165,6 +1183,13 @@ Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt
|
||||||
| Dịch vụ | Gói miễn phí | Trường hợp sử dụng |
|
| Dịch vụ | Gói miễn phí | Trường hợp sử dụng |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) |
|
| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) |
|
||||||
| **Zhipu** | 200K tokens/tháng | Tốt nhất cho người dùng Trung Quốc |
|
| **Volcengine CodingPlan** | ¥9.9/tháng đầu | Tốt nhất cho người dùng Trung Quốc, nhiều mô hình SOTA (Doubao, DeepSeek, v.v.) |
|
||||||
|
| **Zhipu** | 200K tokens/tháng | Phù hợp cho người dùng Trung Quốc |
|
||||||
| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web |
|
| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web |
|
||||||
| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
|
| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
65
README.zh.md
65
README.zh.md
|
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
||||||
|
|
||||||
|
|
@ -208,9 +208,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
### 🚀 快速开始
|
### 🚀 快速开始
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
|
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。
|
||||||
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
|
||||||
> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
|
|
||||||
|
|
||||||
**1. 初始化 (Initialize)**
|
**1. 初始化 (Initialize)**
|
||||||
|
|
||||||
|
|
@ -226,7 +224,7 @@ picoclaw onboard
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt-5.4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
|
|
@ -234,8 +232,13 @@ picoclaw onboard
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "your-api-key",
|
"api_key": "your-api-key",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
|
|
@ -365,7 +368,6 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
||||||
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
|
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
|
||||||
├── IDENTITY.md # Agent 身份设定
|
├── IDENTITY.md # Agent 身份设定
|
||||||
├── SOUL.md # Agent 灵魂/性格
|
├── SOUL.md # Agent 灵魂/性格
|
||||||
├── TOOLS.md # 工具描述
|
|
||||||
└── USER.md # 用户偏好
|
└── USER.md # 用户偏好
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -515,8 +517,10 @@ Agent 读取 HEARTBEAT.md
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
||||||
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
|
| **火山引擎(Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
|
||||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
|
||||||
|
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -526,8 +530,13 @@ Agent 读取 HEARTBEAT.md
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "ark-code-latest",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "volcengine/ark-code-latest",
|
||||||
|
"api_key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key"
|
"api_key": "sk-your-openai-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -543,7 +552,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -555,8 +564,18 @@ Agent 读取 HEARTBEAT.md
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**火山引擎(Doubao)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "ark-code-latest",
|
||||||
|
"model": "volcengine/ark-code-latest",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -622,14 +641,14 @@ Agent 读取 HEARTBEAT.md
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api1.example.com/v1",
|
"api_base": "https://api1.example.com/v1",
|
||||||
"api_key": "sk-key1"
|
"api_key": "sk-key1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_base": "https://api2.example.com/v1",
|
"api_base": "https://api2.example.com/v1",
|
||||||
"api_key": "sk-key2"
|
"api_key": "sk-key2"
|
||||||
}
|
}
|
||||||
|
|
@ -875,7 +894,15 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||||
| 服务 | 免费层级 | 适用场景 |
|
| 服务 | 免费层级 | 适用场景 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
||||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
| **火山引擎 CodingPlan** | 9.9 元/首月 | 最适合国内用户,多种 SOTA 模型(豆包、DeepSeek 等) |
|
||||||
|
| **智谱 (Zhipu)** | 200K tokens/月 | 适合中国用户 |
|
||||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||||
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
||||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||||
|
| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
||||||
|
</div>
|
||||||
|
|
|
||||||
BIN
assets/logo.webp
Normal file
BIN
assets/logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 386 KiB After Width: | Height: | Size: 345 KiB |
|
|
@ -1,6 +1,7 @@
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -67,6 +68,7 @@ func Run() error {
|
||||||
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||||
root.AddItem(bannerView(), 6, 0, false)
|
root.AddItem(bannerView(), 6, 0, false)
|
||||||
root.AddItem(state.pages, 0, 1, true)
|
root.AddItem(state.pages, 0, 1, true)
|
||||||
|
root.AddItem(footerView(), 1, 0, false)
|
||||||
|
|
||||||
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -102,7 +104,7 @@ func (s *appState) pop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *appState) mainMenu() tview.Primitive {
|
func (s *appState) mainMenu() tview.Primitive {
|
||||||
menu := NewMenu("Config Menu", nil)
|
menu := NewMenu("Menu", nil)
|
||||||
refreshMainMenu(menu, s)
|
refreshMainMenu(menu, s)
|
||||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
switch event.Key() {
|
switch event.Key() {
|
||||||
|
|
@ -110,10 +112,7 @@ func (s *appState) mainMenu() tview.Primitive {
|
||||||
s.requestExit()
|
s.requestExit()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if event.Rune() == 'q' {
|
|
||||||
s.requestExit()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
return event
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -131,6 +130,32 @@ func (s *appState) refreshMenu(name string, menu *Menu) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *appState) countChannels() (enabled int, total int) {
|
||||||
|
c := s.config.Channels
|
||||||
|
entries := []bool{
|
||||||
|
c.Telegram.Enabled,
|
||||||
|
c.Discord.Enabled,
|
||||||
|
c.QQ.Enabled,
|
||||||
|
c.MaixCam.Enabled,
|
||||||
|
c.WhatsApp.Enabled,
|
||||||
|
c.Feishu.Enabled,
|
||||||
|
c.DingTalk.Enabled,
|
||||||
|
c.Slack.Enabled,
|
||||||
|
c.Matrix.Enabled,
|
||||||
|
c.LINE.Enabled,
|
||||||
|
c.OneBot.Enabled,
|
||||||
|
c.WeCom.Enabled,
|
||||||
|
c.WeComApp.Enabled,
|
||||||
|
}
|
||||||
|
total = len(entries)
|
||||||
|
for _, v := range entries {
|
||||||
|
if v {
|
||||||
|
enabled++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enabled, total
|
||||||
|
}
|
||||||
|
|
||||||
func refreshMainMenuIfPresent(s *appState) {
|
func refreshMainMenuIfPresent(s *appState) {
|
||||||
if menu, ok := s.menus["main"]; ok {
|
if menu, ok := s.menus["main"]; ok {
|
||||||
refreshMainMenu(menu, s)
|
refreshMainMenu(menu, s)
|
||||||
|
|
@ -141,6 +166,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
||||||
selectedModel := s.selectedModelName()
|
selectedModel := s.selectedModelName()
|
||||||
modelReady := selectedModel != ""
|
modelReady := selectedModel != ""
|
||||||
channelReady := s.hasEnabledChannel()
|
channelReady := s.hasEnabledChannel()
|
||||||
|
enabledCount, totalChannels := s.countChannels()
|
||||||
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
||||||
|
|
||||||
gatewayLabel := "Start Gateway"
|
gatewayLabel := "Start Gateway"
|
||||||
|
|
@ -153,7 +179,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
||||||
items := []MenuItem{
|
items := []MenuItem{
|
||||||
{
|
{
|
||||||
Label: rootModelLabel(selectedModel),
|
Label: rootModelLabel(selectedModel),
|
||||||
Description: rootModelDescription(selectedModel),
|
Description: rootModelDescription(),
|
||||||
Action: func() {
|
Action: func() {
|
||||||
s.push("model", s.modelMenu())
|
s.push("model", s.modelMenu())
|
||||||
},
|
},
|
||||||
|
|
@ -167,7 +193,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Label: rootChannelLabel(channelReady),
|
Label: rootChannelLabel(channelReady),
|
||||||
Description: rootChannelDescription(channelReady),
|
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
|
||||||
Action: func() {
|
Action: func() {
|
||||||
s.push("channel", s.channelMenu())
|
s.push("channel", s.channelMenu())
|
||||||
},
|
},
|
||||||
|
|
@ -311,16 +337,13 @@ func (s *appState) selectedModelName() string {
|
||||||
|
|
||||||
func rootModelLabel(selected string) string {
|
func rootModelLabel(selected string) string {
|
||||||
if selected == "" {
|
if selected == "" {
|
||||||
return "Model (no model selected)"
|
return "Model (None)"
|
||||||
}
|
}
|
||||||
return "Model (" + selected + ")"
|
return "Model (" + selected + ")"
|
||||||
}
|
}
|
||||||
|
|
||||||
func rootModelDescription(selected string) string {
|
func rootModelDescription() string {
|
||||||
if selected == "" {
|
return "Using SPACE to choose your model"
|
||||||
return "no model selected"
|
|
||||||
}
|
|
||||||
return "selected"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func rootChannelLabel(valid bool) string {
|
func rootChannelLabel(valid bool) string {
|
||||||
|
|
@ -330,13 +353,6 @@ func rootChannelLabel(valid bool) string {
|
||||||
return "Channel"
|
return "Channel"
|
||||||
}
|
}
|
||||||
|
|
||||||
func rootChannelDescription(valid bool) string {
|
|
||||||
if !valid {
|
|
||||||
return "no channel enabled"
|
|
||||||
}
|
|
||||||
return "enabled"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) startTalk() {
|
func (s *appState) startTalk() {
|
||||||
if !s.isActiveModelValid() {
|
if !s.isActiveModelValid() {
|
||||||
s.showMessage("Model required", "Select a valid model before starting talk")
|
s.showMessage("Model required", "Select a valid model before starting talk")
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import (
|
||||||
|
|
||||||
func (s *appState) buildChannelMenuItems() []MenuItem {
|
func (s *appState) buildChannelMenuItems() []MenuItem {
|
||||||
return []MenuItem{
|
return []MenuItem{
|
||||||
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
|
||||||
channelItem(
|
channelItem(
|
||||||
"Telegram",
|
"Telegram",
|
||||||
"Telegram bot settings",
|
"Telegram bot settings",
|
||||||
|
|
@ -101,10 +100,6 @@ func (s *appState) channelMenu() tview.Primitive {
|
||||||
s.pop()
|
s.pop()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if event.Rune() == 'q' {
|
|
||||||
s.pop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
return event
|
||||||
})
|
})
|
||||||
return menu
|
return menu
|
||||||
|
|
|
||||||
|
|
@ -14,23 +14,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *appState) modelMenu() tview.Primitive {
|
func (s *appState) modelMenu() tview.Primitive {
|
||||||
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
||||||
items = append(items,
|
|
||||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
|
||||||
MenuItem{
|
|
||||||
Label: "Add model",
|
|
||||||
Description: "Append a new model entry",
|
|
||||||
Action: func() {
|
|
||||||
s.addModel(
|
|
||||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
|
||||||
)
|
|
||||||
s.push(
|
|
||||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
|
||||||
s.modelForm(len(s.config.ModelList)-1),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
for i := range s.config.ModelList {
|
for i := range s.config.ModelList {
|
||||||
index := i
|
index := i
|
||||||
|
|
@ -57,6 +41,23 @@ func (s *appState) modelMenu() tview.Primitive {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Add model entry appended at the end so the models map to rows 1..N
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{
|
||||||
|
Label: "**Add model**",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
newName := s.nextAvailableModelName("new-model")
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
||||||
|
)
|
||||||
|
s.push(
|
||||||
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
s.modelForm(len(s.config.ModelList)-1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
menu := NewMenu("Models", items)
|
menu := NewMenu("Models", items)
|
||||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
|
@ -64,14 +65,11 @@ func (s *appState) modelMenu() tview.Primitive {
|
||||||
s.pop()
|
s.pop()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if event.Rune() == 'q' {
|
|
||||||
s.pop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if event.Rune() == ' ' {
|
if event.Rune() == ' ' {
|
||||||
row, _ := menu.GetSelection()
|
row, _ := menu.GetSelection()
|
||||||
if row > 0 && row <= len(s.config.ModelList) {
|
if row >= 0 && row < len(s.config.ModelList) {
|
||||||
model := s.config.ModelList[row-1]
|
model := s.config.ModelList[row]
|
||||||
if !isModelValid(model) {
|
if !isModelValid(model) {
|
||||||
s.showMessage(
|
s.showMessage(
|
||||||
"Invalid model",
|
"Invalid model",
|
||||||
|
|
@ -95,12 +93,23 @@ func (s *appState) modelForm(index int) tview.Primitive {
|
||||||
model := &s.config.ModelList[index]
|
model := &s.config.ModelList[index]
|
||||||
form := tview.NewForm()
|
form := tview.NewForm()
|
||||||
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||||
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
|
||||||
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
|
||||||
|
|
||||||
addInput(form, "Model Name", model.ModelName, func(value string) {
|
addInput(form, "Model Name", model.ModelName, func(value string) {
|
||||||
|
if value == "" {
|
||||||
|
s.showMessage("Invalid model name", "Model Name cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.modelNameExists(value, index) {
|
||||||
|
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldName := model.ModelName
|
||||||
model.ModelName = value
|
model.ModelName = value
|
||||||
|
if s.config.Agents.Defaults.Model == oldName {
|
||||||
|
s.config.Agents.Defaults.Model = value
|
||||||
|
}
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||||
refreshMainMenuIfPresent(s)
|
refreshMainMenuIfPresent(s)
|
||||||
if menu, ok := s.menus["model"]; ok {
|
if menu, ok := s.menus["model"]; ok {
|
||||||
refreshModelMenuFromState(menu, s)
|
refreshModelMenuFromState(menu, s)
|
||||||
|
|
@ -158,7 +167,21 @@ func (s *appState) modelForm(index int) tview.Primitive {
|
||||||
})
|
})
|
||||||
|
|
||||||
form.AddButton("Delete", func() {
|
form.AddButton("Delete", func() {
|
||||||
s.deleteModel(index)
|
pageName := "confirm-delete-model"
|
||||||
|
if s.pages.HasPage(pageName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText("Are you sure you want to delete this model?").
|
||||||
|
AddButtons([]string{"Cancel", "Delete"}).
|
||||||
|
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
|
s.pages.RemovePage(pageName)
|
||||||
|
if buttonLabel == "Delete" {
|
||||||
|
s.deleteModel(index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
modal.SetTitle("Confirm Delete").SetBorder(true)
|
||||||
|
s.pages.AddPage(pageName, modal, true, true)
|
||||||
})
|
})
|
||||||
form.AddButton("Test", func() {
|
form.AddButton("Test", func() {
|
||||||
s.testModel(model)
|
s.testModel(model)
|
||||||
|
|
@ -215,7 +238,7 @@ func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
||||||
|
|
||||||
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
||||||
for i, model := range models {
|
for i, model := range models {
|
||||||
row := i + 1
|
row := i
|
||||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
isValid := isModelValid(model)
|
isValid := isModelValid(model)
|
||||||
if model.ModelName == currentModel && currentModel != "" {
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
|
@ -234,23 +257,7 @@ func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.M
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||||
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
||||||
items = append(items,
|
|
||||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
|
||||||
MenuItem{
|
|
||||||
Label: "Add model",
|
|
||||||
Description: "Append a new model entry",
|
|
||||||
Action: func() {
|
|
||||||
s.addModel(
|
|
||||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
|
||||||
)
|
|
||||||
s.push(
|
|
||||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
|
||||||
s.modelForm(len(s.config.ModelList)-1),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
for i := range s.config.ModelList {
|
for i := range s.config.ModelList {
|
||||||
index := i
|
index := i
|
||||||
|
|
@ -277,6 +284,19 @@ func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{
|
||||||
|
Label: "**Add Model**",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
newName := s.nextAvailableModelName("new-model")
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
||||||
|
)
|
||||||
|
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
menu.applyItems(items)
|
menu.applyItems(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,6 +307,38 @@ func isModelValid(model picoclawconfig.ModelConfig) bool {
|
||||||
return hasKey && hasModel
|
return hasKey && hasModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *appState) modelNameExists(name string, excludeIndex int) bool {
|
||||||
|
target := strings.TrimSpace(name)
|
||||||
|
if target == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range s.config.ModelList {
|
||||||
|
if i == excludeIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) nextAvailableModelName(base string) string {
|
||||||
|
name := strings.TrimSpace(base)
|
||||||
|
if name == "" {
|
||||||
|
name = "new-model"
|
||||||
|
}
|
||||||
|
if !s.modelNameExists(name, -1) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
for i := 2; ; i++ {
|
||||||
|
candidate := fmt.Sprintf("%s-%d", name, i)
|
||||||
|
if !s.modelNameExists(candidate, -1) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||||
if model == nil {
|
if model == nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,15 @@ func bannerView() *tview.TextView {
|
||||||
text.SetBorder(false)
|
text.SetBorder(false)
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
|
||||||
|
|
||||||
|
func footerView() *tview.TextView {
|
||||||
|
text := tview.NewTextView()
|
||||||
|
text.SetTextAlign(tview.AlignCenter)
|
||||||
|
text.SetText(footerText)
|
||||||
|
text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
|
||||||
|
text.SetTextColor(tview.Styles.PrimaryTextColor)
|
||||||
|
text.SetBorder(false)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,10 @@ import (
|
||||||
|
|
||||||
func NewAgentCommand() *cobra.Command {
|
func NewAgentCommand() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
message string
|
message string
|
||||||
sessionKey string
|
sessionKey string
|
||||||
model string
|
model string
|
||||||
debug bool
|
debug bool
|
||||||
orchestrationEnabled bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
@ -18,7 +17,7 @@ func NewAgentCommand() *cobra.Command {
|
||||||
Short: "Interact with the agent directly",
|
Short: "Interact with the agent directly",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return agentCmd(message, sessionKey, model, debug, orchestrationEnabled)
|
return agentCmd(message, sessionKey, model, debug)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,7 +25,6 @@ func NewAgentCommand() *cobra.Command {
|
||||||
cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)")
|
cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)")
|
||||||
cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key")
|
cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key")
|
||||||
cmd.Flags().StringVarP(&model, "model", "", "", "Model to use")
|
cmd.Flags().StringVarP(&model, "model", "", "", "Model to use")
|
||||||
cmd.Flags().BoolVar(&orchestrationEnabled, "orchestration", false, "Enable orchestration mode")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func agentCmd(message, sessionKey, model string, debug, orchestrationEnabled bool) error {
|
func agentCmd(message, sessionKey, model string, debug bool) error {
|
||||||
if sessionKey == "" {
|
if sessionKey == "" {
|
||||||
sessionKey = "cli:default"
|
sessionKey = "cli:default"
|
||||||
}
|
}
|
||||||
|
|
@ -37,10 +37,6 @@ func agentCmd(message, sessionKey, model string, debug, orchestrationEnabled boo
|
||||||
cfg.Agents.Defaults.ModelName = model
|
cfg.Agents.Defaults.ModelName = model
|
||||||
}
|
}
|
||||||
|
|
||||||
if orchestrationEnabled {
|
|
||||||
cfg.Agents.Defaults.Orchestration = true
|
|
||||||
}
|
|
||||||
|
|
||||||
provider, modelID, err := providers.CreateProvider(cfg)
|
provider, modelID, err := providers.CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating provider: %w", err)
|
return fmt.Errorf("error creating provider: %w", err)
|
||||||
|
|
@ -54,6 +50,7 @@ func agentCmd(message, sessionKey, model string, debug, orchestrationEnabled boo
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
defer msgBus.Close()
|
defer msgBus.Close()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
defer agentLoop.Close()
|
||||||
|
|
||||||
// Print agent startup info (only for interactive mode)
|
// Print agent startup info (only for interactive mode)
|
||||||
startupInfo := agentLoop.GetStartupInfo()
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
|
|
|
||||||
|
|
@ -72,14 +72,14 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
// If no openai in ModelList, add it
|
// If no openai in ModelList, add it
|
||||||
if !foundOpenAI {
|
if !foundOpenAI {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
ModelName: "gpt-5.2",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update default model to use OpenAI
|
// Update default model to use OpenAI
|
||||||
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
|
||||||
|
|
||||||
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
return fmt.Errorf("could not update config: %w", err)
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
|
|
@ -90,7 +90,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
if cred.AccountID != "" {
|
if cred.AccountID != "" {
|
||||||
fmt.Printf("Account: %s\n", cred.AccountID)
|
fmt.Printf("Account: %s\n", cred.AccountID)
|
||||||
}
|
}
|
||||||
fmt.Println("Default model set to: gpt-5.2")
|
fmt.Println("Default model set to: gpt-5.4")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -318,13 +318,13 @@ func authLoginPasteToken(provider string) error {
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
ModelName: "gpt-5.2",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "token",
|
AuthMethod: "token",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Update default model
|
// Update default model
|
||||||
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
|
||||||
}
|
}
|
||||||
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
return fmt.Errorf("could not update config: %w", err)
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,46 @@
|
||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewGatewayCommand() *cobra.Command {
|
func NewGatewayCommand() *cobra.Command {
|
||||||
var (
|
var debug bool
|
||||||
debug bool
|
var noTruncate bool
|
||||||
orchestration bool
|
var orchestration bool
|
||||||
enableStats bool
|
var enableStats bool
|
||||||
)
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "gateway",
|
Use: "gateway",
|
||||||
Aliases: []string{"g"},
|
Aliases: []string{"g"},
|
||||||
Short: "Start picoclaw gateway",
|
Short: "Start picoclaw gateway",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
|
PreRunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
if noTruncate && !debug {
|
||||||
|
return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if noTruncate {
|
||||||
|
utils.SetDisableTruncation(true)
|
||||||
|
logger.Info("String truncation is globally disabled via 'no-truncate' flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
return gatewayCmd(debug, orchestration, enableStats)
|
return gatewayCmd(debug, orchestration, enableStats)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||||
|
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
|
||||||
cmd.Flags().BoolVar(&orchestration, "orchestration", false, "Enable subagent orchestration")
|
cmd.Flags().BoolVar(&orchestration, "orchestration", false, "Enable subagent orchestration")
|
||||||
cmd.Flags().BoolVar(&enableStats, "stats", false, "Enable stats tracking")
|
cmd.Flags().BoolVar(&enableStats, "stats", false, "Enable stats collection")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,14 @@
|
||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Logo = "🦞"
|
const Logo = "🦞"
|
||||||
|
|
||||||
var (
|
|
||||||
version = "dev"
|
|
||||||
gitCommit string
|
|
||||||
buildTime string
|
|
||||||
goVersion string
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetPicoclawHome returns the picoclaw home directory.
|
// GetPicoclawHome returns the picoclaw home directory.
|
||||||
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||||
func GetPicoclawHome() string {
|
func GetPicoclawHome() string {
|
||||||
|
|
@ -40,25 +31,19 @@ func LoadConfig() (*config.Config, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatVersion returns the version string with optional git commit
|
// FormatVersion returns the version string with optional git commit
|
||||||
|
// Deprecated: Use pkg/config.FormatVersion instead
|
||||||
func FormatVersion() string {
|
func FormatVersion() string {
|
||||||
v := version
|
return config.FormatVersion()
|
||||||
if gitCommit != "" {
|
|
||||||
v += fmt.Sprintf(" (git: %s)", gitCommit)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatBuildInfo returns build time and go version info
|
// FormatBuildInfo returns build time and go version info
|
||||||
|
// Deprecated: Use pkg/config.FormatBuildInfo instead
|
||||||
func FormatBuildInfo() (string, string) {
|
func FormatBuildInfo() (string, string) {
|
||||||
build := buildTime
|
return config.FormatBuildInfo()
|
||||||
goVer := goVersion
|
|
||||||
if goVer == "" {
|
|
||||||
goVer = runtime.Version()
|
|
||||||
}
|
|
||||||
return build, goVer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVersion returns the version string
|
// GetVersion returns the version string
|
||||||
|
// Deprecated: Use pkg/config.GetVersion instead
|
||||||
func GetVersion() string {
|
func GetVersion() string {
|
||||||
return version
|
return config.GetVersion()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
83
cmd/picoclaw/internal/helpers_ext_test.go
Normal file
83
cmd/picoclaw/internal/helpers_ext_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := config.Version, config.GitCommit
|
||||||
|
t.Cleanup(func() { config.Version, config.GitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
config.Version = "1.2.3"
|
||||||
|
config.GitCommit = ""
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatVersion_WithGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := config.Version, config.GitCommit
|
||||||
|
t.Cleanup(func() { config.Version, config.GitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
config.Version = "1.2.3"
|
||||||
|
config.GitCommit = "abc123"
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := config.BuildTime, config.GoVersion
|
||||||
|
t.Cleanup(func() { config.BuildTime, config.GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
config.BuildTime = "2026-02-20T00:00:00Z"
|
||||||
|
config.GoVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, config.BuildTime, build)
|
||||||
|
assert.Equal(t, config.GoVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := config.BuildTime, config.GoVersion
|
||||||
|
t.Cleanup(func() { config.BuildTime, config.GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
config.BuildTime = ""
|
||||||
|
config.GoVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Empty(t, build)
|
||||||
|
assert.Equal(t, config.GoVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := config.BuildTime, config.GoVersion
|
||||||
|
t.Cleanup(func() { config.BuildTime, config.GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
config.BuildTime = "x"
|
||||||
|
config.GoVersion = ""
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, "x", build)
|
||||||
|
assert.Equal(t, runtime.Version(), goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVersion(t *testing.T) {
|
||||||
|
assert.Equal(t, "dev", GetVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfigPath_WithEnv(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json")
|
||||||
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := "/tmp/custom/config.json"
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
@ -40,65 +40,6 @@ func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
|
||||||
oldVersion, oldGit := version, gitCommit
|
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
|
||||||
|
|
||||||
version = "1.2.3"
|
|
||||||
gitCommit = ""
|
|
||||||
|
|
||||||
assert.Equal(t, "1.2.3", FormatVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatVersion_WithGitCommit(t *testing.T) {
|
|
||||||
oldVersion, oldGit := version, gitCommit
|
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
|
||||||
|
|
||||||
version = "1.2.3"
|
|
||||||
gitCommit = "abc123"
|
|
||||||
|
|
||||||
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
|
|
||||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
|
||||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
|
||||||
|
|
||||||
buildTime = "2026-02-20T00:00:00Z"
|
|
||||||
goVersion = "go1.23.0"
|
|
||||||
|
|
||||||
build, goVer := FormatBuildInfo()
|
|
||||||
|
|
||||||
assert.Equal(t, buildTime, build)
|
|
||||||
assert.Equal(t, goVersion, goVer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
|
|
||||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
|
||||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
|
||||||
|
|
||||||
buildTime = ""
|
|
||||||
goVersion = "go1.23.0"
|
|
||||||
|
|
||||||
build, goVer := FormatBuildInfo()
|
|
||||||
|
|
||||||
assert.Empty(t, build)
|
|
||||||
assert.Equal(t, goVersion, goVer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
|
|
||||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
|
||||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
|
||||||
|
|
||||||
buildTime = "x"
|
|
||||||
goVersion = ""
|
|
||||||
|
|
||||||
build, goVer := FormatBuildInfo()
|
|
||||||
|
|
||||||
assert.Equal(t, "x", build)
|
|
||||||
assert.Equal(t, runtime.Version(), goVer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetConfigPath_Windows(t *testing.T) {
|
func TestGetConfigPath_Windows(t *testing.T) {
|
||||||
if runtime.GOOS != "windows" {
|
if runtime.GOOS != "windows" {
|
||||||
t.Skip("windows-specific HOME behavior varies; run on windows")
|
t.Skip("windows-specific HOME behavior varies; run on windows")
|
||||||
|
|
@ -112,17 +53,3 @@ func TestGetConfigPath_Windows(t *testing.T) {
|
||||||
|
|
||||||
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetVersion(t *testing.T) {
|
|
||||||
assert.Equal(t, "dev", GetVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetConfigPath_WithEnv(t *testing.T) {
|
|
||||||
t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json")
|
|
||||||
t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred
|
|
||||||
|
|
||||||
got := GetConfigPath()
|
|
||||||
want := "/tmp/custom/config.json"
|
|
||||||
|
|
||||||
assert.Equal(t, want, got)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
package onboard
|
package onboard
|
||||||
|
|
||||||
import "github.com/spf13/cobra"
|
import (
|
||||||
|
"embed"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate cp -r ../../../../workspace .
|
||||||
|
//go:embed workspace
|
||||||
|
var embeddedFiles embed.FS
|
||||||
|
|
||||||
func NewOnboardCommand() *cobra.Command {
|
func NewOnboardCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package onboard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
|
@ -9,30 +10,6 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var workspaceTemplates = map[string]string{
|
|
||||||
"AGENTS.md": `# Agent Instructions
|
|
||||||
|
|
||||||
You are a helpful AI assistant. Be concise, accurate, and friendly.
|
|
||||||
`,
|
|
||||||
"IDENTITY.md": `# Identity
|
|
||||||
|
|
||||||
## Name
|
|
||||||
PicoClaw 🦞
|
|
||||||
`,
|
|
||||||
"SOUL.md": `# Soul
|
|
||||||
|
|
||||||
I am picoclaw, a lightweight AI assistant powered by AI.
|
|
||||||
`,
|
|
||||||
"USER.md": `# User
|
|
||||||
|
|
||||||
Information about user goes here.
|
|
||||||
`,
|
|
||||||
"memory/MEMORY.md": `# Long-term Memory
|
|
||||||
|
|
||||||
This file stores important information that should persist across sessions.
|
|
||||||
`,
|
|
||||||
}
|
|
||||||
|
|
||||||
func onboard() {
|
func onboard() {
|
||||||
configPath := internal.GetConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
|
|
@ -77,19 +54,48 @@ func createWorkspaceTemplates(workspace string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyEmbeddedToTarget(targetDir string) error {
|
func copyEmbeddedToTarget(targetDir string) error {
|
||||||
|
// Ensure target directory exists
|
||||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create target directory: %w", err)
|
return fmt.Errorf("Failed to create target directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for relPath, content := range workspaceTemplates {
|
// Walk through all files in embed.FS
|
||||||
targetPath := filepath.Join(targetDir, relPath)
|
err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read embedded file
|
||||||
|
data, err := embeddedFiles.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
new_path, err := filepath.Rel("workspace", path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build target file path
|
||||||
|
targetPath := filepath.Join(targetDir, new_path)
|
||||||
|
|
||||||
|
// Ensure target file's directory exists
|
||||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create directory %s: %w", filepath.Dir(targetPath), err)
|
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(targetPath, []byte(content), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write file %s: %w", targetPath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
// Write file
|
||||||
|
if err := os.WriteFile(targetPath, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func statusCmd() {
|
func statusCmd() {
|
||||||
|
|
@ -18,8 +19,8 @@ func statusCmd() {
|
||||||
configPath := internal.GetConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
||||||
fmt.Printf("Version: %s\n", internal.FormatVersion())
|
fmt.Printf("Version: %s\n", config.FormatVersion())
|
||||||
build, _ := internal.FormatBuildInfo()
|
build, _ := config.FormatBuildInfo()
|
||||||
if build != "" {
|
if build != "" {
|
||||||
fmt.Printf("Build: %s\n", build)
|
fmt.Printf("Build: %s\n", build)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewVersionCommand() *cobra.Command {
|
func NewVersionCommand() *cobra.Command {
|
||||||
|
|
@ -22,8 +23,8 @@ func NewVersionCommand() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
func printVersion() {
|
func printVersion() {
|
||||||
fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
|
fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
|
||||||
build, goVer := internal.FormatBuildInfo()
|
build, goVer := config.FormatBuildInfo()
|
||||||
if build != "" {
|
if build != "" {
|
||||||
fmt.Printf(" Build: %s\n", build)
|
fmt.Printf(" Build: %s\n", build)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,16 @@ import (
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPicoclawCommand() *cobra.Command {
|
func NewPicoclawCommand() *cobra.Command {
|
||||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "picoclaw",
|
Use: "picoclaw",
|
||||||
Short: short,
|
Short: short,
|
||||||
Example: "picoclaw list",
|
Example: "picoclaw version",
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewPicoclawCommand(t *testing.T) {
|
func TestNewPicoclawCommand(t *testing.T) {
|
||||||
|
|
@ -16,7 +17,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||||
|
|
||||||
assert.Equal(t, "picoclaw", cmd.Use)
|
assert.Equal(t, "picoclaw", cmd.Use)
|
||||||
assert.Equal(t, short, cmd.Short)
|
assert.Equal(t, short, cmd.Short)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"restrict_to_workspace": true,
|
"restrict_to_workspace": true,
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt-5.4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20,
|
"max_tool_iterations": 20,
|
||||||
|
|
@ -13,8 +13,8 @@
|
||||||
},
|
},
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
},
|
},
|
||||||
|
|
@ -36,14 +36,19 @@
|
||||||
"api_key": "sk-your-deepseek-key"
|
"api_key": "sk-your-deepseek-key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "loadbalanced-gpt4",
|
"model_name": "longcat",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "longcat/LongCat-Flash-Thinking",
|
||||||
|
"api_key": "your-longcat-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "loadbalanced-gpt-5.4",
|
||||||
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key1",
|
"api_key": "sk-key1",
|
||||||
"api_base": "https://api1.example.com/v1"
|
"api_base": "https://api1.example.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "loadbalanced-gpt4",
|
"model_name": "loadbalanced-gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key2",
|
"api_key": "sk-key2",
|
||||||
"api_base": "https://api2.example.com/v1"
|
"api_base": "https://api2.example.com/v1"
|
||||||
}
|
}
|
||||||
|
|
@ -194,8 +199,13 @@
|
||||||
"nickserv_password": "",
|
"nickserv_password": "",
|
||||||
"sasl_user": "",
|
"sasl_user": "",
|
||||||
"sasl_password": "",
|
"sasl_password": "",
|
||||||
"channels": ["#mychannel"],
|
"channels": [
|
||||||
"request_caps": ["server-time", "message-tags"],
|
"#mychannel"
|
||||||
|
],
|
||||||
|
"request_caps": [
|
||||||
|
"server-time",
|
||||||
|
"message-tags"
|
||||||
|
],
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
"mention_only": true
|
"mention_only": true
|
||||||
|
|
@ -269,6 +279,10 @@
|
||||||
"avian": {
|
"avian": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_base": "https://api.avian.io/v1"
|
"api_base": "https://api.avian.io/v1"
|
||||||
|
},
|
||||||
|
"longcat": {
|
||||||
|
"api_key": "",
|
||||||
|
"api_base": "https://api.longcat.chat/openai"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
@ -279,6 +293,9 @@
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"api_keys": [
|
||||||
|
"YOUR_BRAVE_API_KEY"
|
||||||
|
],
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"tavily": {
|
"tavily": {
|
||||||
|
|
@ -293,7 +310,10 @@
|
||||||
},
|
},
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "",
|
"api_key": "pplx-xxx",
|
||||||
|
"api_keys": [
|
||||||
|
"pplx-xxx"
|
||||||
|
],
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"searxng": {
|
"searxng": {
|
||||||
|
|
@ -316,6 +336,13 @@
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"discovery": {
|
||||||
|
"enabled": false,
|
||||||
|
"ttl": 5,
|
||||||
|
"max_search_results": 5,
|
||||||
|
"use_bm25": true,
|
||||||
|
"use_regex": false
|
||||||
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"context7": {
|
"context7": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -459,6 +486,9 @@
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"monitor_usb": true
|
"monitor_usb": true
|
||||||
},
|
},
|
||||||
|
"voice": {
|
||||||
|
"echo_transcription": false
|
||||||
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 18790
|
"port": 18790
|
||||||
|
|
|
||||||
12
docker/Dockerfile.goreleaser.launcher
Normal file
12
docker/Dockerfile.goreleaser.launcher
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||||
|
COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
||||||
|
COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui
|
||||||
|
|
||||||
|
ENTRYPOINT ["picoclaw-launcher"]
|
||||||
|
CMD ["-public", "-no-browser"]
|
||||||
|
|
@ -19,7 +19,7 @@ services:
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Gateway (Long-running Bot)
|
# PicoClaw Gateway (Long-running Bot)
|
||||||
# docker compose -f docker/docker-compose.yml up picoclaw-gateway
|
# docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-gateway:
|
picoclaw-gateway:
|
||||||
image: docker.io/sipeed/picoclaw:latest
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
|
|
@ -32,3 +32,21 @@ services:
|
||||||
# - "host.docker.internal:host-gateway"
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/root/.picoclaw
|
- ./data:/root/.picoclaw
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# PicoClaw Launcher (Web Console + Gateway)
|
||||||
|
# docker compose -f docker/docker-compose.yml --profile launcher up
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
picoclaw-launcher:
|
||||||
|
image: docker.io/sipeed/picoclaw:launcher
|
||||||
|
container_name: picoclaw-launcher
|
||||||
|
restart: on-failure
|
||||||
|
profiles:
|
||||||
|
- launcher
|
||||||
|
environment:
|
||||||
|
- PICOCLAW_GATEWAY_HOST=0.0.0.0
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:18800:18800"
|
||||||
|
- "127.0.0.1:18790:18790"
|
||||||
|
volumes:
|
||||||
|
- ./data:/root/.picoclaw
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ Add this to `config.json`:
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"text": "Thinking..."
|
"text": "Thinking..."
|
||||||
},
|
},
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": "",
|
||||||
|
"message_format": "richtext"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -42,10 +43,12 @@ Add this to `config.json`:
|
||||||
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
|
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
|
||||||
| placeholder | object | No | Placeholder message config |
|
| placeholder | object | No | Placeholder message config |
|
||||||
| reasoning_channel_id | string | No | Target channel for reasoning output |
|
| reasoning_channel_id | string | No | Target channel for reasoning output |
|
||||||
|
| message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only |
|
||||||
|
|
||||||
## 3. Currently Supported
|
## 3. Currently Supported
|
||||||
|
|
||||||
- Text message send/receive
|
- Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.)
|
||||||
|
- Configurable message format (`richtext` / `plain`)
|
||||||
- Incoming image/audio/video/file download (MediaStore first, local path fallback)
|
- Incoming image/audio/video/file download (MediaStore first, local path fallback)
|
||||||
- Incoming audio normalization into existing transcription flow (`[audio: ...]`)
|
- Incoming audio normalization into existing transcription flow (`[audio: ...]`)
|
||||||
- Outgoing image/audio/video/file upload and send
|
- Outgoing image/audio/video/file upload and send
|
||||||
|
|
|
||||||
33
docs/debug.md
Normal file
33
docs/debug.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Debugging PicoClaw
|
||||||
|
|
||||||
|
PicoClaw performs multiple complex interactions under the hood for every single request it receives—from routing messages and evaluating complexity, to executing tools and adapting to model failures. Being able to see exactly what is happening is crucial, not just for troubleshooting potential issues, but also for truly understanding how the agent operates.
|
||||||
|
## Starting PicoClaw in Debug Mode
|
||||||
|
|
||||||
|
To get detailed information about what the agent is doing (LLM requests, tool calls, message routing), you can start the PicoClaw gateway with the debug flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway --debug
|
||||||
|
# or
|
||||||
|
picoclaw gateway -d
|
||||||
|
```
|
||||||
|
|
||||||
|
In this mode, the system will format the logs extensively and display previews of system prompts and tool execution results.
|
||||||
|
|
||||||
|
## Disabling Log Truncation (Full Logs)
|
||||||
|
|
||||||
|
By default, PicoClaw truncates very long strings (such as the *System Prompt* or large JSON output results) in the debug logs to keep the console readable.
|
||||||
|
|
||||||
|
If you need to inspect the complete output of a command or the exact payload sent to the LLM model, you can use the `--no-truncate` flag.
|
||||||
|
|
||||||
|
**Note:** This flag *only* works when combined with the `--debug` mode.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway --debug --no-truncate
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
When this flag is active, the global truncation function is disabled. This is extremely useful for:
|
||||||
|
|
||||||
|
* Verifying the exact syntax of the messages sent to the provider.
|
||||||
|
* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`.
|
||||||
|
* Debugging the session history saved in memory.
|
||||||
|
|
@ -66,7 +66,7 @@ Problem: Agent needs to know both `provider` and `model`, adding complexity.
|
||||||
Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
||||||
|
|
||||||
1. **Model-centric**: Users care about models, not providers
|
1. **Model-centric**: Users care about models, not providers
|
||||||
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4.6`
|
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4.6`
|
||||||
3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
|
3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
|
||||||
|
|
||||||
### 2.2 New Configuration Structure
|
### 2.2 New Configuration Structure
|
||||||
|
|
@ -81,8 +81,8 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
||||||
"api_key": "sk-xxx"
|
"api_key": "sk-xxx"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-xxx"
|
"api_key": "sk-xxx"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -128,7 +128,7 @@ type Config struct {
|
||||||
type ModelConfig struct {
|
type ModelConfig struct {
|
||||||
// Required
|
// Required
|
||||||
ModelName string `json:"model_name"` // user-facing name (alias)
|
ModelName string `json:"model_name"` // user-facing name (alias)
|
||||||
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.2
|
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.4
|
||||||
|
|
||||||
// Common config
|
// Common config
|
||||||
APIBase string `json:"api_base,omitempty"`
|
APIBase string `json:"api_base,omitempty"`
|
||||||
|
|
@ -180,7 +180,7 @@ Identify protocol via prefix in `model` field:
|
||||||
"model": "deepseek-chat"
|
"model": "deepseek-chat"
|
||||||
},
|
},
|
||||||
"coder": {
|
"coder": {
|
||||||
"model": "gpt-5.2",
|
"model": "gpt-5.4",
|
||||||
"system_prompt": "You are a coding assistant..."
|
"system_prompt": "You are a coding assistant..."
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
|
|
@ -200,7 +200,7 @@ Each Agent only needs to specify `model` (corresponds to `model_name` in `model_
|
||||||
model_list:
|
model_list:
|
||||||
- model_name: gpt-4o
|
- model_name: gpt-4o
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: openai/gpt-5.2
|
model: openai/gpt-5.4
|
||||||
api_key: xxx
|
api_key: xxx
|
||||||
- model_name: my-custom
|
- model_name: my-custom
|
||||||
litellm_params:
|
litellm_params:
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ The new `model_list` configuration offers several advantages:
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "gpt-5.2"
|
"model": "gpt-5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ The new `model_list` configuration offers several advantages:
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
},
|
},
|
||||||
|
|
@ -82,7 +82,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
|
|
||||||
| Prefix | Description | Example |
|
| Prefix | Description | Example |
|
||||||
|--------|-------------|---------|
|
|--------|-------------|---------|
|
||||||
| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
|
| `openai/` | OpenAI API (default) | `openai/gpt-5.4` |
|
||||||
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
|
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
|
||||||
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
|
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
|
||||||
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
|
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
|
||||||
|
|
@ -109,7 +109,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
| Field | Required | Description |
|
| Field | Required | Description |
|
||||||
|-------|----------|-------------|
|
|-------|----------|-------------|
|
||||||
| `model_name` | Yes | User-facing alias for the model |
|
| `model_name` | Yes | User-facing alias for the model |
|
||||||
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) |
|
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) |
|
||||||
| `api_base` | No | API endpoint URL |
|
| `api_base` | No | API endpoint URL |
|
||||||
| `api_key` | No* | API authentication key |
|
| `api_key` | No* | API authentication key |
|
||||||
| `proxy` | No | HTTP proxy URL |
|
| `proxy` | No | HTTP proxy URL |
|
||||||
|
|
@ -130,19 +130,19 @@ Configure multiple endpoints for the same model to distribute load:
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key1",
|
"api_key": "sk-key1",
|
||||||
"api_base": "https://api1.example.com/v1"
|
"api_base": "https://api1.example.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key2",
|
"api_key": "sk-key2",
|
||||||
"api_base": "https://api2.example.com/v1"
|
"api_base": "https://api2.example.com/v1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.4",
|
||||||
"api_key": "sk-key3",
|
"api_key": "sk-key3",
|
||||||
"api_base": "https://api3.example.com/v1"
|
"api_base": "https://api3.example.com/v1"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,21 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": { ... },
|
"web": {
|
||||||
"mcp": { ... },
|
...
|
||||||
"exec": { ... },
|
},
|
||||||
"cron": { ... },
|
"mcp": {
|
||||||
"skills": { ... }
|
...
|
||||||
|
},
|
||||||
|
"exec": {
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"cron": {
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
...
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -23,7 +33,7 @@ Web tools are used for web search and fetching.
|
||||||
### Brave
|
### Brave
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ------------- | ------ | ------- | ------------------------- |
|
|---------------|--------|---------|---------------------------|
|
||||||
| `enabled` | bool | false | Enable Brave search |
|
| `enabled` | bool | false | Enable Brave search |
|
||||||
| `api_key` | string | - | Brave Search API key |
|
| `api_key` | string | - | Brave Search API key |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
@ -31,14 +41,14 @@ Web tools are used for web search and fetching.
|
||||||
### DuckDuckGo
|
### DuckDuckGo
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ------------- | ---- | ------- | ------------------------- |
|
|---------------|------|---------|---------------------------|
|
||||||
| `enabled` | bool | true | Enable DuckDuckGo search |
|
| `enabled` | bool | true | Enable DuckDuckGo search |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
### Perplexity
|
### Perplexity
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ------------- | ------ | ------- | ------------------------- |
|
|---------------|--------|---------|---------------------------|
|
||||||
| `enabled` | bool | false | Enable Perplexity search |
|
| `enabled` | bool | false | Enable Perplexity search |
|
||||||
| `api_key` | string | - | Perplexity API key |
|
| `api_key` | string | - | Perplexity API key |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
@ -48,7 +58,7 @@ Web tools are used for web search and fetching.
|
||||||
The exec tool is used to execute shell commands.
|
The exec tool is used to execute shell commands.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ---------------------- | ----- | ------- | ------------------------------------------ |
|
|------------------------|-------|---------|--------------------------------------------|
|
||||||
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
||||||
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
||||||
|
|
||||||
|
|
@ -81,7 +91,10 @@ By default, PicoClaw blocks the following dangerous commands:
|
||||||
"tools": {
|
"tools": {
|
||||||
"exec": {
|
"exec": {
|
||||||
"enable_deny_patterns": true,
|
"enable_deny_patterns": true,
|
||||||
"custom_deny_patterns": ["\\brm\\s+-r\\b", "\\bkillall\\s+python"]
|
"custom_deny_patterns": [
|
||||||
|
"\\brm\\s+-r\\b",
|
||||||
|
"\\bkillall\\s+python"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -92,24 +105,47 @@ By default, PicoClaw blocks the following dangerous commands:
|
||||||
The cron tool is used for scheduling periodic tasks.
|
The cron tool is used for scheduling periodic tasks.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ---------------------- | ---- | ------- | ---------------------------------------------- |
|
|------------------------|------|---------|------------------------------------------------|
|
||||||
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
||||||
|
|
||||||
## MCP Tool
|
## MCP Tool
|
||||||
|
|
||||||
The MCP tool enables integration with external Model Context Protocol servers.
|
The MCP tool enables integration with external Model Context Protocol servers.
|
||||||
|
|
||||||
|
### Tool Discovery (Lazy Loading)
|
||||||
|
|
||||||
|
When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window
|
||||||
|
and increase API costs. The **Discovery** feature solves this by keeping MCP tools *hidden* by default.
|
||||||
|
|
||||||
|
Instead of loading all tools, the LLM is provided with a lightweight search tool (using BM25 keyword matching or Regex).
|
||||||
|
When the LLM needs a specific capability, it searches the hidden library. Matching tools are then temporarily "unlocked"
|
||||||
|
and injected into the context for a configured number of turns (`ttl`).
|
||||||
|
|
||||||
### Global Config
|
### Global Config
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| --------- | ------ | ------- | ----------------------------------- |
|
|-------------|--------|---------|----------------------------------------------|
|
||||||
| `enabled` | bool | false | Enable MCP integration globally |
|
| `enabled` | bool | false | Enable MCP integration globally |
|
||||||
| `servers` | object | `{}` | Map of server name to server config |
|
| `discovery` | object | `{}` | Configuration for Tool Discovery (see below) |
|
||||||
|
| `servers` | object | `{}` | Map of server name to server config |
|
||||||
|
|
||||||
|
### Discovery Config (`discovery`)
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|----------------------|------|---------|-----------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `enabled` | bool | false | If true, MCP tools are hidden and loaded on-demand via search. If false, all tools are loaded |
|
||||||
|
| `ttl` | int | 5 | Number of conversational turns a discovered tool remains unlocked |
|
||||||
|
| `max_search_results` | int | 5 | Maximum number of tools returned per search query |
|
||||||
|
| `use_bm25` | bool | true | Enable the natural language/keyword search tool (`tool_search_tool_bm25`). **Warning**: consumes more resources than regex search |
|
||||||
|
| `use_regex` | bool | false | Enable the regex pattern search tool (`tool_search_tool_regex`) |
|
||||||
|
|
||||||
|
> **Note:** If `discovery.enabled` is `true`, you MUST enable at least one search engine (`use_bm25` or `use_regex`),
|
||||||
|
> otherwise the application will fail to start.
|
||||||
|
|
||||||
### Per-Server Config
|
### Per-Server Config
|
||||||
|
|
||||||
| Config | Type | Required | Description |
|
| Config | Type | Required | Description |
|
||||||
| ---------- | ------ | -------- | ------------------------------------------ |
|
|------------|--------|----------|--------------------------------------------|
|
||||||
| `enabled` | bool | yes | Enable this MCP server |
|
| `enabled` | bool | yes | Enable this MCP server |
|
||||||
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
|
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
|
||||||
| `command` | string | stdio | Executable command for stdio transport |
|
| `command` | string | stdio | Executable command for stdio transport |
|
||||||
|
|
@ -122,8 +158,8 @@ The MCP tool enables integration with external Model Context Protocol servers.
|
||||||
### Transport Behavior
|
### Transport Behavior
|
||||||
|
|
||||||
- If `type` is omitted, transport is auto-detected:
|
- If `type` is omitted, transport is auto-detected:
|
||||||
- `url` is set → `sse`
|
- `url` is set → `sse`
|
||||||
- `command` is set → `stdio`
|
- `command` is set → `stdio`
|
||||||
- `http` and `sse` both use `url` + optional `headers`.
|
- `http` and `sse` both use `url` + optional `headers`.
|
||||||
- `env` and `env_file` are only applied to `stdio` servers.
|
- `env` and `env_file` are only applied to `stdio` servers.
|
||||||
|
|
||||||
|
|
@ -140,7 +176,11 @@ The MCP tool enables integration with external Model Context Protocol servers.
|
||||||
"filesystem": {
|
"filesystem": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
"/tmp"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -170,20 +210,76 @@ The MCP tool enables integration with external Model Context Protocol servers.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 3) Massive MCP setup with Tool Discovery enabled
|
||||||
|
|
||||||
|
*In this example, the LLM will only see the `tool_search_tool_bm25`. It will search and unlock Github or Postgres tools
|
||||||
|
dynamically only when requested by the user.*
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"discovery": {
|
||||||
|
"enabled": true,
|
||||||
|
"ttl": 5,
|
||||||
|
"max_search_results": 5,
|
||||||
|
"use_bm25": true,
|
||||||
|
"use_regex": false
|
||||||
|
},
|
||||||
|
"servers": {
|
||||||
|
"github": {
|
||||||
|
"enabled": true,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-github"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postgres": {
|
||||||
|
"enabled": true,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-postgres",
|
||||||
|
"postgresql://user:password@localhost/dbname"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"slack": {
|
||||||
|
"enabled": true,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-slack"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
|
||||||
|
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Skills Tool
|
## Skills Tool
|
||||||
|
|
||||||
The skills tool configures skill discovery and installation via registries like ClawHub.
|
The skills tool configures skill discovery and installation via registries like ClawHub.
|
||||||
|
|
||||||
### Registries
|
### Registries
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ---------------------------------- | ------ | -------------------- | ----------------------- |
|
|------------------------------------|--------|----------------------|----------------------------------------------|
|
||||||
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
||||||
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
||||||
| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
|
| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
|
||||||
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
|
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
|
||||||
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
|
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
|
||||||
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
|
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
|
||||||
|
|
||||||
### Configuration Example
|
### Configuration Example
|
||||||
|
|
||||||
|
|
@ -217,4 +313,5 @@ For example:
|
||||||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||||
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
||||||
|
|
||||||
Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than environment variables.
|
Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than
|
||||||
|
environment variables.
|
||||||
|
|
|
||||||
3
go.mod
3
go.mod
|
|
@ -10,6 +10,7 @@ require (
|
||||||
github.com/chzyer/readline v1.5.1
|
github.com/chzyer/readline v1.5.1
|
||||||
github.com/ergochat/irc-go v0.5.0
|
github.com/ergochat/irc-go v0.5.0
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/h2non/filetype v1.1.3
|
github.com/h2non/filetype v1.1.3
|
||||||
|
|
@ -28,6 +29,7 @@ require (
|
||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.35.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
maunium.net/go/mautrix v0.26.3
|
maunium.net/go/mautrix v0.26.3
|
||||||
modernc.org/sqlite v1.46.1
|
modernc.org/sqlite v1.46.1
|
||||||
)
|
)
|
||||||
|
|
@ -59,7 +61,6 @@ require (
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||||
golang.org/x/term v0.40.0 // indirect
|
golang.org/x/term v0.40.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -79,6 +79,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
|
@ -269,8 +271,6 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
|
||||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -12,103 +12,70 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupWorkspace creates a temporary workspace with standard directories and optional files.
|
// setupWorkspace creates a temporary workspace with standard directories and optional files.
|
||||||
|
|
||||||
// Returns the tmpDir path; caller should defer os.RemoveAll(tmpDir).
|
// Returns the tmpDir path; caller should defer os.RemoveAll(tmpDir).
|
||||||
|
|
||||||
func setupWorkspace(t *testing.T, files map[string]string) string {
|
func setupWorkspace(t *testing.T, files map[string]string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
tmpDir, err := os.MkdirTemp("", "picoclaw-test-*")
|
tmpDir, err := os.MkdirTemp("", "picoclaw-test-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
|
os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
|
||||||
|
|
||||||
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
|
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
|
||||||
|
|
||||||
for name, content := range files {
|
for name, content := range files {
|
||||||
dir := filepath.Dir(filepath.Join(tmpDir, name))
|
dir := filepath.Dir(filepath.Join(tmpDir, name))
|
||||||
|
|
||||||
os.MkdirAll(dir, 0o755)
|
os.MkdirAll(dir, 0o755)
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tmpDir
|
return tmpDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSingleSystemMessage verifies that BuildMessages always produces exactly one
|
// TestSingleSystemMessage verifies that BuildMessages always produces exactly one
|
||||||
|
|
||||||
// system message regardless of summary/history variations.
|
// system message regardless of summary/history variations.
|
||||||
|
|
||||||
// Fix: multiple system messages break Anthropic (top-level system param) and
|
// Fix: multiple system messages break Anthropic (top-level system param) and
|
||||||
|
|
||||||
// Codex (only reads last system message as instructions).
|
// Codex (only reads last system message as instructions).
|
||||||
|
|
||||||
func TestSingleSystemMessage(t *testing.T) {
|
func TestSingleSystemMessage(t *testing.T) {
|
||||||
tmpDir := setupWorkspace(t, map[string]string{
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
"IDENTITY.md": "# Identity\nTest agent.",
|
"IDENTITY.md": "# Identity\nTest agent.",
|
||||||
})
|
})
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cb := NewContextBuilder(tmpDir)
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
||||||
history []providers.Message
|
history []providers.Message
|
||||||
|
|
||||||
summary string
|
summary string
|
||||||
|
|
||||||
message string
|
message string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no summary, no history",
|
name: "no summary, no history",
|
||||||
|
|
||||||
summary: "",
|
summary: "",
|
||||||
|
|
||||||
message: "hello",
|
message: "hello",
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "with summary",
|
name: "with summary",
|
||||||
|
|
||||||
summary: "Previous conversation discussed X",
|
summary: "Previous conversation discussed X",
|
||||||
|
|
||||||
message: "hello",
|
message: "hello",
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "with history and summary",
|
name: "with history and summary",
|
||||||
|
|
||||||
history: []providers.Message{
|
history: []providers.Message{
|
||||||
{Role: "user", Content: "hi"},
|
{Role: "user", Content: "hi"},
|
||||||
|
|
||||||
{Role: "assistant", Content: "hello"},
|
{Role: "assistant", Content: "hello"},
|
||||||
},
|
},
|
||||||
|
|
||||||
summary: strings.Repeat("Long summary text. ", 50),
|
summary: strings.Repeat("Long summary text. ", 50),
|
||||||
|
|
||||||
message: "new message",
|
message: "new message",
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "system message in history is filtered",
|
name: "system message in history is filtered",
|
||||||
|
|
||||||
history: []providers.Message{
|
history: []providers.Message{
|
||||||
{Role: "system", Content: "stale system prompt from previous session"},
|
{Role: "system", Content: "stale system prompt from previous session"},
|
||||||
|
|
||||||
{Role: "user", Content: "hi"},
|
{Role: "user", Content: "hi"},
|
||||||
|
|
||||||
{Role: "assistant", Content: "hello"},
|
{Role: "assistant", Content: "hello"},
|
||||||
},
|
},
|
||||||
|
|
||||||
summary: "",
|
summary: "",
|
||||||
|
|
||||||
message: "new message",
|
message: "new message",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -118,44 +85,35 @@ func TestSingleSystemMessage(t *testing.T) {
|
||||||
msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1")
|
msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1")
|
||||||
|
|
||||||
systemCount := 0
|
systemCount := 0
|
||||||
|
|
||||||
for _, m := range msgs {
|
for _, m := range msgs {
|
||||||
if m.Role == "system" {
|
if m.Role == "system" {
|
||||||
systemCount++
|
systemCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if systemCount != 1 {
|
if systemCount != 1 {
|
||||||
t.Errorf("expected exactly 1 system message, got %d", systemCount)
|
t.Errorf("expected exactly 1 system message, got %d", systemCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
if msgs[0].Role != "system" {
|
if msgs[0].Role != "system" {
|
||||||
t.Errorf("first message should be system, got %s", msgs[0].Role)
|
t.Errorf("first message should be system, got %s", msgs[0].Role)
|
||||||
}
|
}
|
||||||
|
|
||||||
if msgs[len(msgs)-1].Role != "user" {
|
if msgs[len(msgs)-1].Role != "user" {
|
||||||
t.Errorf("last message should be user, got %s", msgs[len(msgs)-1].Role)
|
t.Errorf("last message should be user, got %s", msgs[len(msgs)-1].Role)
|
||||||
}
|
}
|
||||||
|
|
||||||
// System message must contain identity (static) and time (dynamic)
|
// System message must contain identity (static) and time (dynamic)
|
||||||
|
|
||||||
sys := msgs[0].Content
|
sys := msgs[0].Content
|
||||||
|
|
||||||
if !strings.Contains(sys, "picoclaw") {
|
if !strings.Contains(sys, "picoclaw") {
|
||||||
t.Error("system message missing identity")
|
t.Error("system message missing identity")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(sys, "Current Time") {
|
if !strings.Contains(sys, "Current Time") {
|
||||||
t.Error("system message missing dynamic time context")
|
t.Error("system message missing dynamic time context")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary handling
|
// Summary handling
|
||||||
|
|
||||||
if tt.summary != "" {
|
if tt.summary != "" {
|
||||||
if !strings.Contains(sys, "CONTEXT_SUMMARY:") {
|
if !strings.Contains(sys, "CONTEXT_SUMMARY:") {
|
||||||
t.Error("summary present but CONTEXT_SUMMARY prefix missing")
|
t.Error("summary present but CONTEXT_SUMMARY prefix missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(sys, tt.summary[:20]) {
|
if !strings.Contains(sys, tt.summary[:20]) {
|
||||||
t.Error("summary content not found in system message")
|
t.Error("summary content not found in system message")
|
||||||
}
|
}
|
||||||
|
|
@ -169,46 +127,29 @@ func TestSingleSystemMessage(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMtimeAutoInvalidation verifies that the cache detects source file changes
|
// TestMtimeAutoInvalidation verifies that the cache detects source file changes
|
||||||
|
|
||||||
// via mtime without requiring explicit InvalidateCache().
|
// via mtime without requiring explicit InvalidateCache().
|
||||||
|
|
||||||
// Fix: original implementation had no auto-invalidation — edits to bootstrap files,
|
// Fix: original implementation had no auto-invalidation — edits to bootstrap files,
|
||||||
|
|
||||||
// memory, or skills were invisible until process restart.
|
// memory, or skills were invisible until process restart.
|
||||||
|
|
||||||
func TestMtimeAutoInvalidation(t *testing.T) {
|
func TestMtimeAutoInvalidation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
file string // relative path inside workspace
|
||||||
file string // relative path inside workspace
|
contentV1 string
|
||||||
|
contentV2 string
|
||||||
contentV1 string
|
|
||||||
|
|
||||||
contentV2 string
|
|
||||||
|
|
||||||
checkField string // substring to verify in rebuilt prompt
|
checkField string // substring to verify in rebuilt prompt
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "bootstrap file change",
|
name: "bootstrap file change",
|
||||||
|
file: "IDENTITY.md",
|
||||||
file: "IDENTITY.md",
|
contentV1: "# Original Identity",
|
||||||
|
contentV2: "# Updated Identity",
|
||||||
contentV1: "# Original Identity",
|
|
||||||
|
|
||||||
contentV2: "# Updated Identity",
|
|
||||||
|
|
||||||
checkField: "Updated Identity",
|
checkField: "Updated Identity",
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "memory file change",
|
name: "memory file change",
|
||||||
|
file: "memory/MEMORY.md",
|
||||||
file: "memory/MEMORY.md",
|
contentV1: "# Memory\nUser likes Go.",
|
||||||
|
contentV2: "# Memory\nUser likes Rust.",
|
||||||
contentV1: "# Memory\nUser likes Go.",
|
|
||||||
|
|
||||||
contentV2: "# Memory\nUser likes Rust.",
|
|
||||||
|
|
||||||
checkField: "User likes Rust",
|
checkField: "User likes Rust",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -216,7 +157,6 @@ func TestMtimeAutoInvalidation(t *testing.T) {
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
tmpDir := setupWorkspace(t, map[string]string{tt.file: tt.contentV1})
|
tmpDir := setupWorkspace(t, map[string]string{tt.file: tt.contentV1})
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cb := NewContextBuilder(tmpDir)
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
@ -224,39 +164,26 @@ func TestMtimeAutoInvalidation(t *testing.T) {
|
||||||
sp1 := cb.BuildSystemPromptWithCache()
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
// Overwrite file and set future mtime to ensure detection.
|
// Overwrite file and set future mtime to ensure detection.
|
||||||
|
|
||||||
// Use 2s offset for filesystem mtime resolution safety (some FS
|
// Use 2s offset for filesystem mtime resolution safety (some FS
|
||||||
|
|
||||||
// have 1s or coarser granularity, especially in CI containers).
|
// have 1s or coarser granularity, especially in CI containers).
|
||||||
|
|
||||||
fullPath := filepath.Join(tmpDir, tt.file)
|
fullPath := filepath.Join(tmpDir, tt.file)
|
||||||
|
|
||||||
os.WriteFile(fullPath, []byte(tt.contentV2), 0o644)
|
os.WriteFile(fullPath, []byte(tt.contentV2), 0o644)
|
||||||
|
|
||||||
future := time.Now().Add(2 * time.Second)
|
future := time.Now().Add(2 * time.Second)
|
||||||
|
|
||||||
os.Chtimes(fullPath, future, future)
|
os.Chtimes(fullPath, future, future)
|
||||||
|
|
||||||
// Verify sourceFilesChangedLocked detects the mtime change
|
// Verify sourceFilesChangedLocked detects the mtime change
|
||||||
|
|
||||||
cb.systemPromptMutex.RLock()
|
cb.systemPromptMutex.RLock()
|
||||||
|
|
||||||
changed := cb.sourceFilesChangedLocked()
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
|
||||||
cb.systemPromptMutex.RUnlock()
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
|
||||||
if !changed {
|
if !changed {
|
||||||
t.Fatalf("sourceFilesChangedLocked() should detect %s change", tt.file)
|
t.Fatalf("sourceFilesChangedLocked() should detect %s change", tt.file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should auto-rebuild without explicit InvalidateCache()
|
// Should auto-rebuild without explicit InvalidateCache()
|
||||||
|
|
||||||
sp2 := cb.BuildSystemPromptWithCache()
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
if sp1 == sp2 {
|
if sp1 == sp2 {
|
||||||
t.Errorf("cache not rebuilt after %s change", tt.file)
|
t.Errorf("cache not rebuilt after %s change", tt.file)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(sp2, tt.checkField) {
|
if !strings.Contains(sp2, tt.checkField) {
|
||||||
t.Errorf("rebuilt prompt missing expected content %q", tt.checkField)
|
t.Errorf("rebuilt prompt missing expected content %q", tt.checkField)
|
||||||
}
|
}
|
||||||
|
|
@ -264,34 +191,23 @@ func TestMtimeAutoInvalidation(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skills directory mtime change
|
// Skills directory mtime change
|
||||||
|
|
||||||
t.Run("skills dir change", func(t *testing.T) {
|
t.Run("skills dir change", func(t *testing.T) {
|
||||||
tmpDir := setupWorkspace(t, nil)
|
tmpDir := setupWorkspace(t, nil)
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cb := NewContextBuilder(tmpDir)
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
_ = cb.BuildSystemPromptWithCache() // populate cache
|
_ = cb.BuildSystemPromptWithCache() // populate cache
|
||||||
|
|
||||||
// Touch skills directory (simulate new skill installed)
|
// Touch skills directory (simulate new skill installed)
|
||||||
|
|
||||||
skillsDir := filepath.Join(tmpDir, "skills")
|
skillsDir := filepath.Join(tmpDir, "skills")
|
||||||
|
|
||||||
future := time.Now().Add(2 * time.Second)
|
future := time.Now().Add(2 * time.Second)
|
||||||
|
|
||||||
os.Chtimes(skillsDir, future, future)
|
os.Chtimes(skillsDir, future, future)
|
||||||
|
|
||||||
// Verify sourceFilesChangedLocked detects it (cache is rebuilt)
|
// Verify sourceFilesChangedLocked detects it (cache is rebuilt)
|
||||||
|
|
||||||
// We confirm by checking internal state: a second call should rebuild.
|
// We confirm by checking internal state: a second call should rebuild.
|
||||||
|
|
||||||
cb.systemPromptMutex.RLock()
|
cb.systemPromptMutex.RLock()
|
||||||
|
|
||||||
changed := cb.sourceFilesChangedLocked()
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
|
||||||
cb.systemPromptMutex.RUnlock()
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
|
||||||
if !changed {
|
if !changed {
|
||||||
t.Error("sourceFilesChangedLocked() should detect skills dir mtime change")
|
t.Error("sourceFilesChangedLocked() should detect skills dir mtime change")
|
||||||
}
|
}
|
||||||
|
|
@ -299,22 +215,17 @@ func TestMtimeAutoInvalidation(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestExplicitInvalidateCache verifies that InvalidateCache() forces a rebuild
|
// TestExplicitInvalidateCache verifies that InvalidateCache() forces a rebuild
|
||||||
|
|
||||||
// even when source files haven't changed (useful for tests and reload commands).
|
// even when source files haven't changed (useful for tests and reload commands).
|
||||||
|
|
||||||
func TestExplicitInvalidateCache(t *testing.T) {
|
func TestExplicitInvalidateCache(t *testing.T) {
|
||||||
tmpDir := setupWorkspace(t, map[string]string{
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
"IDENTITY.md": "# Test Identity",
|
"IDENTITY.md": "# Test Identity",
|
||||||
})
|
})
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cb := NewContextBuilder(tmpDir)
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
sp1 := cb.BuildSystemPromptWithCache()
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
cb.InvalidateCache()
|
cb.InvalidateCache()
|
||||||
|
|
||||||
sp2 := cb.BuildSystemPromptWithCache()
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
if sp1 != sp2 {
|
if sp1 != sp2 {
|
||||||
|
|
@ -322,39 +233,29 @@ func TestExplicitInvalidateCache(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify cachedAt was reset
|
// Verify cachedAt was reset
|
||||||
|
|
||||||
cb.InvalidateCache()
|
cb.InvalidateCache()
|
||||||
|
|
||||||
cb.systemPromptMutex.RLock()
|
cb.systemPromptMutex.RLock()
|
||||||
|
|
||||||
if !cb.cachedAt.IsZero() {
|
if !cb.cachedAt.IsZero() {
|
||||||
t.Error("cachedAt should be zero after InvalidateCache()")
|
t.Error("cachedAt should be zero after InvalidateCache()")
|
||||||
}
|
}
|
||||||
|
|
||||||
cb.systemPromptMutex.RUnlock()
|
cb.systemPromptMutex.RUnlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCacheStability verifies that the static prompt is stable across repeated calls
|
// TestCacheStability verifies that the static prompt is stable across repeated calls
|
||||||
|
|
||||||
// when no files change (regression test for issue #607).
|
// when no files change (regression test for issue #607).
|
||||||
|
|
||||||
func TestCacheStability(t *testing.T) {
|
func TestCacheStability(t *testing.T) {
|
||||||
tmpDir := setupWorkspace(t, map[string]string{
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
"IDENTITY.md": "# Identity\nContent",
|
"IDENTITY.md": "# Identity\nContent",
|
||||||
|
"SOUL.md": "# Soul\nContent",
|
||||||
"SOUL.md": "# Soul\nContent",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cb := NewContextBuilder(tmpDir)
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
results := make([]string, 5)
|
results := make([]string, 5)
|
||||||
|
|
||||||
for i := range results {
|
for i := range results {
|
||||||
results[i] = cb.BuildSystemPromptWithCache()
|
results[i] = cb.BuildSystemPromptWithCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 1; i < len(results); i++ {
|
for i := 1; i < len(results); i++ {
|
||||||
if results[i] != results[0] {
|
if results[i] != results[0] {
|
||||||
t.Errorf("cached prompt changed between call 0 and %d", i)
|
t.Errorf("cached prompt changed between call 0 and %d", i)
|
||||||
|
|
@ -362,47 +263,32 @@ func TestCacheStability(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static prompt must NOT contain per-request data
|
// Static prompt must NOT contain per-request data
|
||||||
|
|
||||||
if strings.Contains(results[0], "Current Time") {
|
if strings.Contains(results[0], "Current Time") {
|
||||||
t.Error("static cached prompt should not contain time (added dynamically)")
|
t.Error("static cached prompt should not contain time (added dynamically)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewFileCreationInvalidatesCache verifies that creating a source file that
|
// TestNewFileCreationInvalidatesCache verifies that creating a source file that
|
||||||
|
|
||||||
// did not exist when the cache was built triggers a cache rebuild.
|
// did not exist when the cache was built triggers a cache rebuild.
|
||||||
|
|
||||||
// This catches the "from nothing to something" edge case that the old
|
// This catches the "from nothing to something" edge case that the old
|
||||||
|
|
||||||
// modifiedSince (return false on stat error) would miss.
|
// modifiedSince (return false on stat error) would miss.
|
||||||
|
|
||||||
func TestNewFileCreationInvalidatesCache(t *testing.T) {
|
func TestNewFileCreationInvalidatesCache(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
file string // relative path inside workspace
|
||||||
file string // relative path inside workspace
|
content string
|
||||||
|
|
||||||
content string
|
|
||||||
|
|
||||||
checkField string // substring to verify in rebuilt prompt
|
checkField string // substring to verify in rebuilt prompt
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "new bootstrap file",
|
name: "new bootstrap file",
|
||||||
|
file: "SOUL.md",
|
||||||
file: "SOUL.md",
|
content: "# Soul\nBe kind and helpful.",
|
||||||
|
|
||||||
content: "# Soul\nBe kind and helpful.",
|
|
||||||
|
|
||||||
checkField: "Be kind and helpful",
|
checkField: "Be kind and helpful",
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "new memory file",
|
name: "new memory file",
|
||||||
|
file: "memory/MEMORY.md",
|
||||||
file: "memory/MEMORY.md",
|
content: "# Memory\nUser prefers dark mode.",
|
||||||
|
|
||||||
content: "# Memory\nUser prefers dark mode.",
|
|
||||||
|
|
||||||
checkField: "User prefers dark mode",
|
checkField: "User prefers dark mode",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -410,41 +296,29 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) {
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Start with an empty workspace (no bootstrap/memory files)
|
// Start with an empty workspace (no bootstrap/memory files)
|
||||||
|
|
||||||
tmpDir := setupWorkspace(t, nil)
|
tmpDir := setupWorkspace(t, nil)
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cb := NewContextBuilder(tmpDir)
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
// Populate cache — file does not exist yet
|
// Populate cache — file does not exist yet
|
||||||
|
|
||||||
sp1 := cb.BuildSystemPromptWithCache()
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
if strings.Contains(sp1, tt.checkField) {
|
if strings.Contains(sp1, tt.checkField) {
|
||||||
t.Fatalf("prompt should not contain %q before file is created", tt.checkField)
|
t.Fatalf("prompt should not contain %q before file is created", tt.checkField)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the file after cache was built
|
// Create the file after cache was built
|
||||||
|
|
||||||
fullPath := filepath.Join(tmpDir, tt.file)
|
fullPath := filepath.Join(tmpDir, tt.file)
|
||||||
|
|
||||||
os.MkdirAll(filepath.Dir(fullPath), 0o755)
|
os.MkdirAll(filepath.Dir(fullPath), 0o755)
|
||||||
|
|
||||||
if err := os.WriteFile(fullPath, []byte(tt.content), 0o644); err != nil {
|
if err := os.WriteFile(fullPath, []byte(tt.content), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set future mtime to guarantee detection
|
// Set future mtime to guarantee detection
|
||||||
|
|
||||||
future := time.Now().Add(2 * time.Second)
|
future := time.Now().Add(2 * time.Second)
|
||||||
|
|
||||||
os.Chtimes(fullPath, future, future)
|
os.Chtimes(fullPath, future, future)
|
||||||
|
|
||||||
// Cache should auto-invalidate because file went from absent -> present
|
// Cache should auto-invalidate because file went from absent -> present
|
||||||
|
|
||||||
sp2 := cb.BuildSystemPromptWithCache()
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
if !strings.Contains(sp2, tt.checkField) {
|
if !strings.Contains(sp2, tt.checkField) {
|
||||||
t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField)
|
t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField)
|
||||||
}
|
}
|
||||||
|
|
@ -453,89 +327,58 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSkillFileContentChange verifies that modifying a skill file's content
|
// TestSkillFileContentChange verifies that modifying a skill file's content
|
||||||
|
|
||||||
// (not just the directory structure) invalidates the cache.
|
// (not just the directory structure) invalidates the cache.
|
||||||
|
|
||||||
// This is the scenario where directory mtime alone is insufficient — on most
|
// This is the scenario where directory mtime alone is insufficient — on most
|
||||||
|
|
||||||
// filesystems, editing a file inside a directory does NOT update the parent
|
// filesystems, editing a file inside a directory does NOT update the parent
|
||||||
|
|
||||||
// directory's mtime.
|
// directory's mtime.
|
||||||
|
|
||||||
func TestSkillFileContentChange(t *testing.T) {
|
func TestSkillFileContentChange(t *testing.T) {
|
||||||
skillMD := `---
|
skillMD := `---
|
||||||
|
|
||||||
name: test-skill
|
name: test-skill
|
||||||
|
|
||||||
description: "A test skill"
|
description: "A test skill"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Test Skill v1
|
# Test Skill v1
|
||||||
|
|
||||||
Original content.`
|
Original content.`
|
||||||
|
|
||||||
tmpDir := setupWorkspace(t, map[string]string{
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
"skills/test-skill/SKILL.md": skillMD,
|
"skills/test-skill/SKILL.md": skillMD,
|
||||||
})
|
})
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cb := NewContextBuilder(tmpDir)
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
// Populate cache
|
// Populate cache
|
||||||
|
|
||||||
sp1 := cb.BuildSystemPromptWithCache()
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
_ = sp1 // cache is warm
|
_ = sp1 // cache is warm
|
||||||
|
|
||||||
// Modify the skill file content (without touching the skills/ directory)
|
// Modify the skill file content (without touching the skills/ directory)
|
||||||
|
|
||||||
updatedSkillMD := `---
|
updatedSkillMD := `---
|
||||||
|
|
||||||
name: test-skill
|
name: test-skill
|
||||||
|
|
||||||
description: "An updated test skill"
|
description: "An updated test skill"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Test Skill v2
|
# Test Skill v2
|
||||||
|
|
||||||
Updated content.`
|
Updated content.`
|
||||||
|
|
||||||
skillPath := filepath.Join(tmpDir, "skills", "test-skill", "SKILL.md")
|
skillPath := filepath.Join(tmpDir, "skills", "test-skill", "SKILL.md")
|
||||||
|
|
||||||
if err := os.WriteFile(skillPath, []byte(updatedSkillMD), 0o644); err != nil {
|
if err := os.WriteFile(skillPath, []byte(updatedSkillMD), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set future mtime on the skill file only (NOT the directory)
|
// Set future mtime on the skill file only (NOT the directory)
|
||||||
|
|
||||||
future := time.Now().Add(2 * time.Second)
|
future := time.Now().Add(2 * time.Second)
|
||||||
|
|
||||||
os.Chtimes(skillPath, future, future)
|
os.Chtimes(skillPath, future, future)
|
||||||
|
|
||||||
// Verify that sourceFilesChangedLocked detects the content change
|
// Verify that sourceFilesChangedLocked detects the content change
|
||||||
|
|
||||||
cb.systemPromptMutex.RLock()
|
cb.systemPromptMutex.RLock()
|
||||||
|
|
||||||
changed := cb.sourceFilesChangedLocked()
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
|
||||||
cb.systemPromptMutex.RUnlock()
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
|
||||||
if !changed {
|
if !changed {
|
||||||
t.Error("sourceFilesChangedLocked() should detect skill file content change")
|
t.Error("sourceFilesChangedLocked() should detect skill file content change")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify cache is actually rebuilt with new content
|
// Verify cache is actually rebuilt with new content
|
||||||
|
|
||||||
sp2 := cb.BuildSystemPromptWithCache()
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
if sp1 == sp2 && strings.Contains(sp1, "test-skill") {
|
if sp1 == sp2 && strings.Contains(sp1, "test-skill") {
|
||||||
// If the skill appeared in the prompt and the prompt didn't change,
|
// If the skill appeared in the prompt and the prompt didn't change,
|
||||||
|
|
||||||
// the cache was not invalidated.
|
// the cache was not invalidated.
|
||||||
|
|
||||||
t.Error("cache should be invalidated when skill file content changes")
|
t.Error("cache should be invalidated when skill file content changes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -697,75 +540,53 @@ description: delete-me-v1
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
|
// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
|
||||||
|
|
||||||
// can safely call BuildSystemPromptWithCache concurrently without producing
|
// can safely call BuildSystemPromptWithCache concurrently without producing
|
||||||
|
|
||||||
// empty results, panics, or data races.
|
// empty results, panics, or data races.
|
||||||
|
|
||||||
// Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache
|
// Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache
|
||||||
|
|
||||||
func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
||||||
tmpDir := setupWorkspace(t, map[string]string{
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
"IDENTITY.md": "# Identity\nConcurrency test agent.",
|
"IDENTITY.md": "# Identity\nConcurrency test agent.",
|
||||||
|
"SOUL.md": "# Soul\nBe helpful.",
|
||||||
"SOUL.md": "# Soul\nBe helpful.",
|
"memory/MEMORY.md": "# Memory\nUser prefers Go.",
|
||||||
|
|
||||||
"memory/MEMORY.md": "# Memory\nUser prefers Go.",
|
|
||||||
|
|
||||||
"skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo",
|
"skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo",
|
||||||
})
|
})
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cb := NewContextBuilder(tmpDir)
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
const goroutines = 20
|
const goroutines = 20
|
||||||
|
|
||||||
const iterations = 50
|
const iterations = 50
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
errs := make(chan string, goroutines*iterations)
|
errs := make(chan string, goroutines*iterations)
|
||||||
|
|
||||||
for g := range goroutines {
|
for g := range goroutines {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
|
|
||||||
go func(id int) {
|
go func(id int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
for i := range iterations {
|
for i := range iterations {
|
||||||
result := cb.BuildSystemPromptWithCache()
|
result := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
if result == "" {
|
if result == "" {
|
||||||
errs <- "empty prompt returned"
|
errs <- "empty prompt returned"
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(result, "picoclaw") {
|
if !strings.Contains(result, "picoclaw") {
|
||||||
errs <- "prompt missing identity"
|
errs <- "prompt missing identity"
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also exercise BuildMessages concurrently
|
// Also exercise BuildMessages concurrently
|
||||||
|
|
||||||
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat")
|
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat")
|
||||||
|
|
||||||
if len(msgs) < 2 {
|
if len(msgs) < 2 {
|
||||||
errs <- "BuildMessages returned fewer than 2 messages"
|
errs <- "BuildMessages returned fewer than 2 messages"
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if msgs[0].Role != "system" {
|
if msgs[0].Role != "system" {
|
||||||
errs <- "first message not system"
|
errs <- "first message not system"
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Occasionally invalidate to exercise the write path
|
// Occasionally invalidate to exercise the write path
|
||||||
|
|
||||||
if i%10 == 0 {
|
if i%10 == 0 {
|
||||||
cb.InvalidateCache()
|
cb.InvalidateCache()
|
||||||
}
|
}
|
||||||
|
|
@ -774,7 +595,6 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
close(errs)
|
close(errs)
|
||||||
|
|
||||||
for errMsg := range errs {
|
for errMsg := range errs {
|
||||||
|
|
@ -785,90 +605,64 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
||||||
// BenchmarkBuildMessagesWithCache measures caching performance.
|
// BenchmarkBuildMessagesWithCache measures caching performance.
|
||||||
|
|
||||||
// TestEmptyWorkspaceBaselineDetectsNewFiles verifies that when the cache is
|
// TestEmptyWorkspaceBaselineDetectsNewFiles verifies that when the cache is
|
||||||
|
|
||||||
// built on an empty workspace (no tracked files exist), creating a file
|
// built on an empty workspace (no tracked files exist), creating a file
|
||||||
|
|
||||||
// afterwards still triggers cache invalidation. This validates the
|
// afterwards still triggers cache invalidation. This validates the
|
||||||
|
|
||||||
// time.Unix(1, 0) fallback for maxMtime: any real file's mtime is after epoch,
|
// time.Unix(1, 0) fallback for maxMtime: any real file's mtime is after epoch,
|
||||||
|
|
||||||
// so fileChangedSince correctly detects the absent -> present transition AND
|
// so fileChangedSince correctly detects the absent -> present transition AND
|
||||||
|
|
||||||
// the mtime comparison succeeds even without artificially inflated Chtimes.
|
// the mtime comparison succeeds even without artificially inflated Chtimes.
|
||||||
|
|
||||||
func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) {
|
func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) {
|
||||||
// Empty workspace: no bootstrap files, no memory, no skills content.
|
// Empty workspace: no bootstrap files, no memory, no skills content.
|
||||||
|
|
||||||
tmpDir := setupWorkspace(t, nil)
|
tmpDir := setupWorkspace(t, nil)
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cb := NewContextBuilder(tmpDir)
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
// Build cache — all tracked files are absent, maxMtime falls back to epoch.
|
// Build cache — all tracked files are absent, maxMtime falls back to epoch.
|
||||||
|
|
||||||
sp1 := cb.BuildSystemPromptWithCache()
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
// Create a bootstrap file with natural mtime (no Chtimes manipulation).
|
// Create a bootstrap file with natural mtime (no Chtimes manipulation).
|
||||||
|
|
||||||
// The file's mtime should be the current wall-clock time, which is
|
// The file's mtime should be the current wall-clock time, which is
|
||||||
|
|
||||||
// strictly after time.Unix(1, 0).
|
// strictly after time.Unix(1, 0).
|
||||||
|
|
||||||
soulPath := filepath.Join(tmpDir, "SOUL.md")
|
soulPath := filepath.Join(tmpDir, "SOUL.md")
|
||||||
|
|
||||||
if err := os.WriteFile(soulPath, []byte("# Soul\nNewly created."), 0o644); err != nil {
|
if err := os.WriteFile(soulPath, []byte("# Soul\nNewly created."), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache should detect the new file via existedAtCache (absent -> present).
|
// Cache should detect the new file via existedAtCache (absent -> present).
|
||||||
|
|
||||||
cb.systemPromptMutex.RLock()
|
cb.systemPromptMutex.RLock()
|
||||||
|
|
||||||
changed := cb.sourceFilesChangedLocked()
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
|
||||||
cb.systemPromptMutex.RUnlock()
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
|
||||||
if !changed {
|
if !changed {
|
||||||
t.Fatal("sourceFilesChangedLocked should detect newly created file on empty workspace")
|
t.Fatal("sourceFilesChangedLocked should detect newly created file on empty workspace")
|
||||||
}
|
}
|
||||||
|
|
||||||
sp2 := cb.BuildSystemPromptWithCache()
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
if !strings.Contains(sp2, "Newly created") {
|
if !strings.Contains(sp2, "Newly created") {
|
||||||
t.Error("rebuilt prompt should contain new file content")
|
t.Error("rebuilt prompt should contain new file content")
|
||||||
}
|
}
|
||||||
|
|
||||||
if sp1 == sp2 {
|
if sp1 == sp2 {
|
||||||
t.Error("cache should have been invalidated after file creation")
|
t.Error("cache should have been invalidated after file creation")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BenchmarkBuildMessagesWithCache measures caching performance.
|
// BenchmarkBuildMessagesWithCache measures caching performance.
|
||||||
|
|
||||||
func BenchmarkBuildMessagesWithCache(b *testing.B) {
|
func BenchmarkBuildMessagesWithCache(b *testing.B) {
|
||||||
tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*")
|
tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*")
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
|
os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
|
||||||
|
|
||||||
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
|
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
|
||||||
|
|
||||||
for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} {
|
for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} {
|
||||||
os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644)
|
os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
cb := NewContextBuilder(tmpDir)
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
history := []providers.Message{
|
history := []providers.Message{
|
||||||
{Role: "user", Content: "previous message"},
|
{Role: "user", Content: "previous message"},
|
||||||
|
|
||||||
{Role: "assistant", Content: "previous response"},
|
{Role: "assistant", Content: "previous response"},
|
||||||
}
|
}
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
|
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
_ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test")
|
_ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,9 @@ func msg(role, content string) providers.Message {
|
||||||
|
|
||||||
func assistantWithTools(toolIDs ...string) providers.Message {
|
func assistantWithTools(toolIDs ...string) providers.Message {
|
||||||
calls := make([]providers.ToolCall, len(toolIDs))
|
calls := make([]providers.ToolCall, len(toolIDs))
|
||||||
|
|
||||||
for i, id := range toolIDs {
|
for i, id := range toolIDs {
|
||||||
calls[i] = providers.ToolCall{ID: id, Type: "function"}
|
calls[i] = providers.ToolCall{ID: id, Type: "function"}
|
||||||
}
|
}
|
||||||
|
|
||||||
return providers.Message{Role: "assistant", ToolCalls: calls}
|
return providers.Message{Role: "assistant", ToolCalls: calls}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,13 +24,11 @@ func toolResult(id string) providers.Message {
|
||||||
|
|
||||||
func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) {
|
func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) {
|
||||||
result := sanitizeHistoryForProvider(nil)
|
result := sanitizeHistoryForProvider(nil)
|
||||||
|
|
||||||
if len(result) != 0 {
|
if len(result) != 0 {
|
||||||
t.Fatalf("expected empty, got %d messages", len(result))
|
t.Fatalf("expected empty, got %d messages", len(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
result = sanitizeHistoryForProvider([]providers.Message{})
|
result = sanitizeHistoryForProvider([]providers.Message{})
|
||||||
|
|
||||||
if len(result) != 0 {
|
if len(result) != 0 {
|
||||||
t.Fatalf("expected empty, got %d messages", len(result))
|
t.Fatalf("expected empty, got %d messages", len(result))
|
||||||
}
|
}
|
||||||
|
|
@ -41,228 +37,170 @@ func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) {
|
||||||
func TestSanitizeHistoryForProvider_SingleToolCall(t *testing.T) {
|
func TestSanitizeHistoryForProvider_SingleToolCall(t *testing.T) {
|
||||||
history := []providers.Message{
|
history := []providers.Message{
|
||||||
msg("user", "hello"),
|
msg("user", "hello"),
|
||||||
|
|
||||||
assistantWithTools("A"),
|
assistantWithTools("A"),
|
||||||
|
|
||||||
toolResult("A"),
|
toolResult("A"),
|
||||||
|
|
||||||
msg("assistant", "done"),
|
msg("assistant", "done"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := sanitizeHistoryForProvider(history)
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
|
||||||
if len(result) != 4 {
|
if len(result) != 4 {
|
||||||
t.Fatalf("expected 4 messages, got %d", len(result))
|
t.Fatalf("expected 4 messages, got %d", len(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
assertRoles(t, result, "user", "assistant", "tool", "assistant")
|
assertRoles(t, result, "user", "assistant", "tool", "assistant")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeHistoryForProvider_MultiToolCalls(t *testing.T) {
|
func TestSanitizeHistoryForProvider_MultiToolCalls(t *testing.T) {
|
||||||
history := []providers.Message{
|
history := []providers.Message{
|
||||||
msg("user", "do two things"),
|
msg("user", "do two things"),
|
||||||
|
|
||||||
assistantWithTools("A", "B"),
|
assistantWithTools("A", "B"),
|
||||||
|
|
||||||
toolResult("A"),
|
toolResult("A"),
|
||||||
|
|
||||||
toolResult("B"),
|
toolResult("B"),
|
||||||
|
|
||||||
msg("assistant", "both done"),
|
msg("assistant", "both done"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := sanitizeHistoryForProvider(history)
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
|
||||||
if len(result) != 5 {
|
if len(result) != 5 {
|
||||||
t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result))
|
t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant")
|
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeHistoryForProvider_AssistantToolCallAfterPlainAssistant(t *testing.T) {
|
func TestSanitizeHistoryForProvider_AssistantToolCallAfterPlainAssistant(t *testing.T) {
|
||||||
history := []providers.Message{
|
history := []providers.Message{
|
||||||
msg("user", "hi"),
|
msg("user", "hi"),
|
||||||
|
|
||||||
msg("assistant", "thinking"),
|
msg("assistant", "thinking"),
|
||||||
|
|
||||||
assistantWithTools("A"),
|
assistantWithTools("A"),
|
||||||
|
|
||||||
toolResult("A"),
|
toolResult("A"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := sanitizeHistoryForProvider(history)
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
|
||||||
if len(result) != 2 {
|
if len(result) != 2 {
|
||||||
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
|
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
assertRoles(t, result, "user", "assistant")
|
assertRoles(t, result, "user", "assistant")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeHistoryForProvider_OrphanedLeadingTool(t *testing.T) {
|
func TestSanitizeHistoryForProvider_OrphanedLeadingTool(t *testing.T) {
|
||||||
history := []providers.Message{
|
history := []providers.Message{
|
||||||
toolResult("A"),
|
toolResult("A"),
|
||||||
|
|
||||||
msg("user", "hello"),
|
msg("user", "hello"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := sanitizeHistoryForProvider(history)
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
|
||||||
if len(result) != 1 {
|
if len(result) != 1 {
|
||||||
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
|
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
assertRoles(t, result, "user")
|
assertRoles(t, result, "user")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeHistoryForProvider_ToolAfterUserDropped(t *testing.T) {
|
func TestSanitizeHistoryForProvider_ToolAfterUserDropped(t *testing.T) {
|
||||||
history := []providers.Message{
|
history := []providers.Message{
|
||||||
msg("user", "hello"),
|
msg("user", "hello"),
|
||||||
|
|
||||||
toolResult("A"),
|
toolResult("A"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := sanitizeHistoryForProvider(history)
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
|
||||||
if len(result) != 1 {
|
if len(result) != 1 {
|
||||||
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
|
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
assertRoles(t, result, "user")
|
assertRoles(t, result, "user")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeHistoryForProvider_ToolAfterAssistantNoToolCalls(t *testing.T) {
|
func TestSanitizeHistoryForProvider_ToolAfterAssistantNoToolCalls(t *testing.T) {
|
||||||
history := []providers.Message{
|
history := []providers.Message{
|
||||||
msg("user", "hello"),
|
msg("user", "hello"),
|
||||||
|
|
||||||
msg("assistant", "hi"),
|
msg("assistant", "hi"),
|
||||||
|
|
||||||
toolResult("A"),
|
toolResult("A"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := sanitizeHistoryForProvider(history)
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
|
||||||
if len(result) != 2 {
|
if len(result) != 2 {
|
||||||
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
|
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
assertRoles(t, result, "user", "assistant")
|
assertRoles(t, result, "user", "assistant")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeHistoryForProvider_AssistantToolCallAtStart(t *testing.T) {
|
func TestSanitizeHistoryForProvider_AssistantToolCallAtStart(t *testing.T) {
|
||||||
history := []providers.Message{
|
history := []providers.Message{
|
||||||
assistantWithTools("A"),
|
assistantWithTools("A"),
|
||||||
|
|
||||||
toolResult("A"),
|
toolResult("A"),
|
||||||
|
|
||||||
msg("user", "hello"),
|
msg("user", "hello"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := sanitizeHistoryForProvider(history)
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
|
||||||
if len(result) != 1 {
|
if len(result) != 1 {
|
||||||
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
|
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
assertRoles(t, result, "user")
|
assertRoles(t, result, "user")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) {
|
func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) {
|
||||||
history := []providers.Message{
|
history := []providers.Message{
|
||||||
msg("user", "do two things"),
|
msg("user", "do two things"),
|
||||||
|
|
||||||
assistantWithTools("A", "B"),
|
assistantWithTools("A", "B"),
|
||||||
|
|
||||||
toolResult("A"),
|
toolResult("A"),
|
||||||
|
|
||||||
toolResult("B"),
|
toolResult("B"),
|
||||||
|
|
||||||
msg("assistant", "done"),
|
msg("assistant", "done"),
|
||||||
|
|
||||||
msg("user", "hi"),
|
msg("user", "hi"),
|
||||||
|
|
||||||
assistantWithTools("C"),
|
assistantWithTools("C"),
|
||||||
|
|
||||||
toolResult("C"),
|
toolResult("C"),
|
||||||
|
|
||||||
msg("assistant", "done again"),
|
msg("assistant", "done again"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := sanitizeHistoryForProvider(history)
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
|
||||||
if len(result) != 9 {
|
if len(result) != 9 {
|
||||||
t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
|
t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "user", "assistant", "tool", "assistant")
|
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "user", "assistant", "tool", "assistant")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) {
|
func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) {
|
||||||
history := []providers.Message{
|
history := []providers.Message{
|
||||||
msg("user", "start"),
|
msg("user", "start"),
|
||||||
|
|
||||||
assistantWithTools("A", "B"),
|
assistantWithTools("A", "B"),
|
||||||
|
|
||||||
toolResult("A"),
|
toolResult("A"),
|
||||||
|
|
||||||
toolResult("B"),
|
toolResult("B"),
|
||||||
|
|
||||||
assistantWithTools("C", "D"),
|
assistantWithTools("C", "D"),
|
||||||
|
|
||||||
toolResult("C"),
|
toolResult("C"),
|
||||||
|
|
||||||
toolResult("D"),
|
toolResult("D"),
|
||||||
|
|
||||||
msg("assistant", "all done"),
|
msg("assistant", "all done"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := sanitizeHistoryForProvider(history)
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
|
||||||
if len(result) != 8 {
|
if len(result) != 8 {
|
||||||
t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result))
|
t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "tool", "tool", "assistant")
|
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "tool", "tool", "assistant")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) {
|
func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) {
|
||||||
history := []providers.Message{
|
history := []providers.Message{
|
||||||
msg("user", "hello"),
|
msg("user", "hello"),
|
||||||
|
|
||||||
msg("assistant", "hi"),
|
msg("assistant", "hi"),
|
||||||
|
|
||||||
msg("user", "how are you"),
|
msg("user", "how are you"),
|
||||||
|
|
||||||
msg("assistant", "fine"),
|
msg("assistant", "fine"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := sanitizeHistoryForProvider(history)
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
|
||||||
if len(result) != 4 {
|
if len(result) != 4 {
|
||||||
t.Fatalf("expected 4 messages, got %d", len(result))
|
t.Fatalf("expected 4 messages, got %d", len(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
assertRoles(t, result, "user", "assistant", "user", "assistant")
|
assertRoles(t, result, "user", "assistant", "user", "assistant")
|
||||||
}
|
}
|
||||||
|
|
||||||
func roles(msgs []providers.Message) []string {
|
func roles(msgs []providers.Message) []string {
|
||||||
r := make([]string, len(msgs))
|
r := make([]string, len(msgs))
|
||||||
|
|
||||||
for i, m := range msgs {
|
for i, m := range msgs {
|
||||||
r[i] = m.Role
|
r[i] = m.Role
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
|
func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if len(msgs) != len(expected) {
|
if len(msgs) != len(expected) {
|
||||||
t.Fatalf("role count mismatch: got %v, want %v", roles(msgs), expected)
|
t.Fatalf("role count mismatch: got %v, want %v", roles(msgs), expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, exp := range expected {
|
for i, exp := range expected {
|
||||||
if msgs[i].Role != exp {
|
if msgs[i].Role != exp {
|
||||||
t.Errorf("message[%d]: got role %q, want %q", i, msgs[i].Role, exp)
|
t.Errorf("message[%d]: got role %q, want %q", i, msgs[i].Role, exp)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
|
@ -17,126 +18,120 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentInstance represents a fully configured agent with its own workspace,
|
// AgentInstance represents a fully configured agent with its own workspace,
|
||||||
|
|
||||||
// session manager, context builder, and tool registry.
|
// session manager, context builder, and tool registry.
|
||||||
|
|
||||||
type AgentInstance struct {
|
type AgentInstance struct {
|
||||||
ID string
|
ID string
|
||||||
|
Name string
|
||||||
|
Model string
|
||||||
|
Fallbacks []string
|
||||||
|
Workspace string
|
||||||
|
MaxIterations int
|
||||||
|
TaskReminderInterval int
|
||||||
|
MaxTokens int
|
||||||
|
Temperature float64
|
||||||
|
ThinkingLevel ThinkingLevel
|
||||||
|
ContextWindow int
|
||||||
|
SummarizeMessageThreshold int
|
||||||
|
SummarizeTokenPercent int
|
||||||
|
Provider providers.LLMProvider
|
||||||
|
Sessions *session.LegacyAdapter
|
||||||
|
ContextBuilder *ContextBuilder
|
||||||
|
Tools *tools.ToolRegistry
|
||||||
|
Subagents *config.SubagentsConfig
|
||||||
|
SkillsFilter []string
|
||||||
|
Candidates []providers.FallbackCandidate
|
||||||
|
PlanModel string
|
||||||
|
PlanFallbacks []string
|
||||||
|
PlanCandidates []providers.FallbackCandidate
|
||||||
|
|
||||||
Name string
|
// Router is non-nil when model routing is configured and the light model
|
||||||
|
// was successfully resolved. It scores each incoming message and decides
|
||||||
Model string
|
// whether to route to LightCandidates or stay with Candidates.
|
||||||
|
Router *routing.Router
|
||||||
Fallbacks []string
|
// LightCandidates holds the resolved provider candidates for the light model.
|
||||||
|
// Pre-computed at agent creation to avoid repeated model_list lookups at runtime.
|
||||||
Workspace string
|
LightCandidates []providers.FallbackCandidate
|
||||||
|
|
||||||
MaxIterations int
|
|
||||||
|
|
||||||
TaskReminderInterval int
|
|
||||||
|
|
||||||
MaxTokens int
|
|
||||||
|
|
||||||
Temperature float64
|
|
||||||
|
|
||||||
ContextWindow int
|
|
||||||
|
|
||||||
Provider providers.LLMProvider
|
|
||||||
|
|
||||||
Sessions *session.LegacyAdapter
|
|
||||||
|
|
||||||
ContextBuilder *ContextBuilder
|
|
||||||
|
|
||||||
Tools *tools.ToolRegistry
|
|
||||||
|
|
||||||
Subagents *config.SubagentsConfig
|
|
||||||
|
|
||||||
SkillsFilter []string
|
|
||||||
|
|
||||||
Candidates []providers.FallbackCandidate
|
|
||||||
|
|
||||||
PlanModel string
|
|
||||||
|
|
||||||
PlanFallbacks []string
|
|
||||||
|
|
||||||
PlanCandidates []providers.FallbackCandidate
|
|
||||||
|
|
||||||
// SubagentMgr is set during registerSharedTools when orchestration is enabled.
|
// SubagentMgr is set during registerSharedTools when orchestration is enabled.
|
||||||
|
|
||||||
// Used by runAgentLoop to wait for spawned subagents before worktree cleanup.
|
// Used by runAgentLoop to wait for spawned subagents before worktree cleanup.
|
||||||
|
|
||||||
SubagentMgr *tools.SubagentManager
|
SubagentMgr *tools.SubagentManager
|
||||||
|
|
||||||
// Interview staleness tracking: consecutive turns where MEMORY.md was not updated.
|
// Interview staleness tracking: consecutive turns where MEMORY.md was not updated.
|
||||||
|
|
||||||
interviewStaleCount int
|
interviewStaleCount int
|
||||||
|
interviewMemoryLen int
|
||||||
interviewMemoryLen int
|
|
||||||
|
|
||||||
// Per-session worktree isolation
|
// Per-session worktree isolation
|
||||||
|
worktrees map[string]*git.WorktreeInfo // sessionKey → worktree
|
||||||
worktrees map[string]*git.WorktreeInfo // sessionKey → worktree
|
|
||||||
|
|
||||||
worktreeMu sync.RWMutex
|
worktreeMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentInstance creates an agent instance from config.
|
// Close releases resources held by the agent instance.
|
||||||
|
// If the provider implements StatefulProvider, its Close method is called.
|
||||||
|
func (ai *AgentInstance) Close() error {
|
||||||
|
if sp, ok := ai.Provider.(providers.StatefulProvider); ok {
|
||||||
|
sp.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAgentInstance creates an agent instance from config.
|
||||||
func NewAgentInstance(
|
func NewAgentInstance(
|
||||||
agentCfg *config.AgentConfig,
|
agentCfg *config.AgentConfig,
|
||||||
|
|
||||||
defaults *config.AgentDefaults,
|
defaults *config.AgentDefaults,
|
||||||
|
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
|
||||||
provider providers.LLMProvider,
|
provider providers.LLMProvider,
|
||||||
) *AgentInstance {
|
) *AgentInstance {
|
||||||
workspace := resolveAgentWorkspace(agentCfg, defaults)
|
workspace := resolveAgentWorkspace(agentCfg, defaults)
|
||||||
|
|
||||||
os.MkdirAll(workspace, 0o755)
|
os.MkdirAll(workspace, 0o755)
|
||||||
|
|
||||||
model := resolveAgentModel(agentCfg, defaults)
|
model := resolveAgentModel(agentCfg, defaults)
|
||||||
|
|
||||||
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
||||||
|
|
||||||
restrict := defaults.RestrictToWorkspace
|
restrict := defaults.RestrictToWorkspace
|
||||||
|
readRestrict := restrict && !defaults.AllowReadOutsideWorkspace
|
||||||
|
|
||||||
|
// Compile path whitelist patterns from config.
|
||||||
|
allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths)
|
||||||
|
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
||||||
|
|
||||||
toolsRegistry := tools.NewToolRegistry()
|
toolsRegistry := tools.NewToolRegistry()
|
||||||
|
|
||||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
|
if cfg.Tools.IsToolEnabled("read_file") {
|
||||||
|
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
|
||||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
|
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
|
||||||
|
}
|
||||||
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
|
if cfg.Tools.IsToolEnabled("write_file") {
|
||||||
|
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
||||||
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
}
|
||||||
if err != nil {
|
if cfg.Tools.IsToolEnabled("list_dir") {
|
||||||
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("exec") {
|
||||||
|
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
||||||
|
}
|
||||||
|
toolsRegistry.Register(execTool)
|
||||||
}
|
}
|
||||||
|
|
||||||
toolsRegistry.Register(execTool)
|
if cfg.Tools.IsToolEnabled("edit_file") {
|
||||||
|
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
||||||
toolsRegistry.Register(tools.NewBgMonitorTool(execTool))
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("append_file") {
|
||||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
|
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
|
|
||||||
|
|
||||||
toolsRegistry.Register(tools.NewLogsTool())
|
toolsRegistry.Register(tools.NewLogsTool())
|
||||||
|
|
||||||
toolsRegistry.Register(tools.NewGitPushTool())
|
toolsRegistry.Register(tools.NewGitPushTool())
|
||||||
|
|
||||||
toolsRegistry.Register(tools.NewCreatePRTool())
|
toolsRegistry.Register(tools.NewCreatePRTool())
|
||||||
|
|
||||||
dbPath := filepath.Join(workspace, "sessions.db")
|
dbPath := filepath.Join(workspace, "sessions.db")
|
||||||
|
|
||||||
store, err := session.OpenSQLiteStore(dbPath)
|
store, err := session.OpenSQLiteStore(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("open session store: %v", err)
|
log.Fatalf("open session store: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonDir := filepath.Join(workspace, "sessions")
|
jsonDir := filepath.Join(workspace, "sessions")
|
||||||
|
|
||||||
if n, merr := session.MigrateJSONSessions(jsonDir, store); merr != nil {
|
if n, merr := session.MigrateJSONSessions(jsonDir, store); merr != nil {
|
||||||
log.Printf("session migration: %d migrated, error: %v", n, merr)
|
log.Printf("session migration: %d migrated, error: %v", n, merr)
|
||||||
} else if n > 0 {
|
} else if n > 0 {
|
||||||
|
|
@ -151,28 +146,25 @@ func NewAgentInstance(
|
||||||
|
|
||||||
sessionsManager := session.NewLegacyAdapter(store)
|
sessionsManager := session.NewLegacyAdapter(store)
|
||||||
|
|
||||||
contextBuilder := NewContextBuilder(workspace)
|
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
|
||||||
|
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
|
||||||
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
|
||||||
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
|
||||||
|
)
|
||||||
|
|
||||||
agentID := routing.DefaultAgentID
|
agentID := routing.DefaultAgentID
|
||||||
|
|
||||||
agentName := ""
|
agentName := ""
|
||||||
|
|
||||||
var subagents *config.SubagentsConfig
|
var subagents *config.SubagentsConfig
|
||||||
|
|
||||||
var skillsFilter []string
|
var skillsFilter []string
|
||||||
|
|
||||||
if agentCfg != nil {
|
if agentCfg != nil {
|
||||||
agentID = routing.NormalizeAgentID(agentCfg.ID)
|
agentID = routing.NormalizeAgentID(agentCfg.ID)
|
||||||
|
|
||||||
agentName = agentCfg.Name
|
agentName = agentCfg.Name
|
||||||
|
|
||||||
subagents = agentCfg.Subagents
|
subagents = agentCfg.Subagents
|
||||||
|
|
||||||
skillsFilter = agentCfg.Skills
|
skillsFilter = agentCfg.Skills
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply defaults.Orchestration: if the flag is set, ensure orchestration is enabled.
|
// Apply defaults.Orchestration: if the flag is set, ensure orchestration is enabled.
|
||||||
|
|
||||||
if defaults.Orchestration {
|
if defaults.Orchestration {
|
||||||
if subagents == nil {
|
if subagents == nil {
|
||||||
subagents = &config.SubagentsConfig{Enabled: true}
|
subagents = &config.SubagentsConfig{Enabled: true}
|
||||||
|
|
@ -182,54 +174,59 @@ func NewAgentInstance(
|
||||||
}
|
}
|
||||||
|
|
||||||
maxIter := defaults.MaxToolIterations
|
maxIter := defaults.MaxToolIterations
|
||||||
|
|
||||||
if maxIter == 0 {
|
if maxIter == 0 {
|
||||||
maxIter = 20
|
maxIter = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
reminderInterval := defaults.TaskReminderInterval
|
reminderInterval := defaults.TaskReminderInterval
|
||||||
|
|
||||||
if reminderInterval == 0 {
|
if reminderInterval == 0 {
|
||||||
reminderInterval = 5
|
reminderInterval = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
maxTokens := defaults.MaxTokens
|
maxTokens := defaults.MaxTokens
|
||||||
|
|
||||||
if maxTokens == 0 {
|
if maxTokens == 0 {
|
||||||
maxTokens = 8192
|
maxTokens = 8192
|
||||||
}
|
}
|
||||||
|
|
||||||
temperature := 0.7
|
temperature := 0.7
|
||||||
|
|
||||||
if defaults.Temperature != nil {
|
if defaults.Temperature != nil {
|
||||||
temperature = *defaults.Temperature
|
temperature = *defaults.Temperature
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve fallback candidates
|
var thinkingLevelStr string
|
||||||
|
if mc, err := cfg.GetModelConfig(model); err == nil {
|
||||||
|
thinkingLevelStr = mc.ThinkingLevel
|
||||||
|
}
|
||||||
|
thinkingLevel := parseThinkingLevel(thinkingLevelStr)
|
||||||
|
|
||||||
modelCfg := providers.ModelConfig{
|
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
|
||||||
Primary: model,
|
if summarizeMessageThreshold == 0 {
|
||||||
|
summarizeMessageThreshold = 20
|
||||||
Fallbacks: fallbacks,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
summarizeTokenPercent := defaults.SummarizeTokenPercent
|
||||||
|
if summarizeTokenPercent == 0 {
|
||||||
|
summarizeTokenPercent = 75
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve fallback candidates
|
||||||
|
modelCfg := providers.ModelConfig{
|
||||||
|
Primary: model,
|
||||||
|
Fallbacks: fallbacks,
|
||||||
|
}
|
||||||
resolveFromModelList := func(raw string) (string, bool) {
|
resolveFromModelList := func(raw string) (string, bool) {
|
||||||
ensureProtocol := func(model string) string {
|
ensureProtocol := func(model string) string {
|
||||||
model = strings.TrimSpace(model)
|
model = strings.TrimSpace(model)
|
||||||
|
|
||||||
if model == "" {
|
if model == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(model, "/") {
|
if strings.Contains(model, "/") {
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|
||||||
return "openai/" + model
|
return "openai/" + model
|
||||||
}
|
}
|
||||||
|
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
|
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
@ -241,17 +238,13 @@ func NewAgentInstance(
|
||||||
|
|
||||||
for i := range cfg.ModelList {
|
for i := range cfg.ModelList {
|
||||||
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
|
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
|
||||||
|
|
||||||
if fullModel == "" {
|
if fullModel == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if fullModel == raw {
|
if fullModel == raw {
|
||||||
return ensureProtocol(fullModel), true
|
return ensureProtocol(fullModel), true
|
||||||
}
|
}
|
||||||
|
|
||||||
_, modelID := providers.ExtractProtocol(fullModel)
|
_, modelID := providers.ExtractProtocol(fullModel)
|
||||||
|
|
||||||
if modelID == raw {
|
if modelID == raw {
|
||||||
return ensureProtocol(fullModel), true
|
return ensureProtocol(fullModel), true
|
||||||
}
|
}
|
||||||
|
|
@ -264,76 +257,73 @@ func NewAgentInstance(
|
||||||
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
|
||||||
// Resolve plan model (for interviewing/review phases)
|
// Resolve plan model (for interviewing/review phases)
|
||||||
|
|
||||||
planModel := resolvePlanModel(agentCfg, defaults)
|
planModel := resolvePlanModel(agentCfg, defaults)
|
||||||
|
|
||||||
planFallbacks := resolvePlanFallbacks(agentCfg, defaults)
|
planFallbacks := resolvePlanFallbacks(agentCfg, defaults)
|
||||||
|
|
||||||
var planCandidates []providers.FallbackCandidate
|
var planCandidates []providers.FallbackCandidate
|
||||||
|
|
||||||
if planModel != "" {
|
if planModel != "" {
|
||||||
planModelCfg := providers.ModelConfig{
|
planModelCfg := providers.ModelConfig{
|
||||||
Primary: planModel,
|
Primary: planModel,
|
||||||
|
|
||||||
Fallbacks: planFallbacks,
|
Fallbacks: planFallbacks,
|
||||||
}
|
}
|
||||||
|
|
||||||
planCandidates = providers.ResolveCandidates(planModelCfg, defaults.Provider)
|
planCandidates = providers.ResolveCandidates(planModelCfg, defaults.Provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Model routing setup: pre-resolve light model candidates at creation time
|
||||||
|
// to avoid repeated model_list lookups on every incoming message.
|
||||||
|
var router *routing.Router
|
||||||
|
var lightCandidates []providers.FallbackCandidate
|
||||||
|
if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" {
|
||||||
|
lightModelCfg := providers.ModelConfig{Primary: rc.LightModel}
|
||||||
|
resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
if len(resolved) > 0 {
|
||||||
|
router = routing.New(routing.RouterConfig{
|
||||||
|
LightModel: rc.LightModel,
|
||||||
|
Threshold: rc.Threshold,
|
||||||
|
})
|
||||||
|
lightCandidates = resolved
|
||||||
|
} else {
|
||||||
|
log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q",
|
||||||
|
rc.LightModel, agentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Startup cleanup: prune orphaned worktrees
|
// Startup cleanup: prune orphaned worktrees
|
||||||
|
|
||||||
worktreesDir := filepath.Join(workspace, ".worktrees")
|
worktreesDir := filepath.Join(workspace, ".worktrees")
|
||||||
|
|
||||||
if repoRoot := git.FindRepoRoot(workspace); repoRoot != "" {
|
if repoRoot := git.FindRepoRoot(workspace); repoRoot != "" {
|
||||||
git.PruneOrphaned(repoRoot, worktreesDir)
|
git.PruneOrphaned(repoRoot, worktreesDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AgentInstance{
|
return &AgentInstance{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
|
Name: agentName,
|
||||||
Name: agentName,
|
Model: model,
|
||||||
|
Fallbacks: fallbacks,
|
||||||
Model: model,
|
Workspace: workspace,
|
||||||
|
MaxIterations: maxIter,
|
||||||
Fallbacks: fallbacks,
|
TaskReminderInterval: reminderInterval,
|
||||||
|
MaxTokens: maxTokens,
|
||||||
Workspace: workspace,
|
Temperature: temperature,
|
||||||
|
ThinkingLevel: thinkingLevel,
|
||||||
MaxIterations: maxIter,
|
ContextWindow: maxTokens,
|
||||||
|
SummarizeMessageThreshold: summarizeMessageThreshold,
|
||||||
TaskReminderInterval: reminderInterval,
|
SummarizeTokenPercent: summarizeTokenPercent,
|
||||||
|
Provider: provider,
|
||||||
MaxTokens: maxTokens,
|
Sessions: sessionsManager,
|
||||||
|
ContextBuilder: contextBuilder,
|
||||||
Temperature: temperature,
|
Tools: toolsRegistry,
|
||||||
|
Subagents: subagents,
|
||||||
ContextWindow: maxTokens,
|
SkillsFilter: skillsFilter,
|
||||||
|
Candidates: candidates,
|
||||||
Provider: provider,
|
PlanModel: planModel,
|
||||||
|
PlanFallbacks: planFallbacks,
|
||||||
Sessions: sessionsManager,
|
PlanCandidates: planCandidates,
|
||||||
|
Router: router,
|
||||||
ContextBuilder: contextBuilder,
|
LightCandidates: lightCandidates,
|
||||||
|
|
||||||
Tools: toolsRegistry,
|
|
||||||
|
|
||||||
Subagents: subagents,
|
|
||||||
|
|
||||||
SkillsFilter: skillsFilter,
|
|
||||||
|
|
||||||
Candidates: candidates,
|
|
||||||
|
|
||||||
PlanModel: planModel,
|
|
||||||
|
|
||||||
PlanFallbacks: planFallbacks,
|
|
||||||
|
|
||||||
PlanCandidates: planCandidates,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAgentWorkspace determines the workspace directory for an agent.
|
// resolveAgentWorkspace determines the workspace directory for an agent.
|
||||||
|
|
||||||
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
||||||
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
||||||
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
||||||
|
|
@ -344,75 +334,58 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD
|
||||||
}
|
}
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
|
|
||||||
id := routing.NormalizeAgentID(agentCfg.ID)
|
id := routing.NormalizeAgentID(agentCfg.ID)
|
||||||
|
|
||||||
return filepath.Join(home, ".picoclaw", "workspace-"+id)
|
return filepath.Join(home, ".picoclaw", "workspace-"+id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAgentModel resolves the primary model for an agent.
|
// resolveAgentModel resolves the primary model for an agent.
|
||||||
|
|
||||||
func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
||||||
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
|
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
|
||||||
return strings.TrimSpace(agentCfg.Model.Primary)
|
return strings.TrimSpace(agentCfg.Model.Primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaults.GetModelName()
|
return defaults.GetModelName()
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAgentFallbacks resolves the fallback models for an agent.
|
// resolveAgentFallbacks resolves the fallback models for an agent.
|
||||||
|
|
||||||
func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
|
func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
|
||||||
if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil {
|
if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil {
|
||||||
return agentCfg.Model.Fallbacks
|
return agentCfg.Model.Fallbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaults.ModelFallbacks
|
return defaults.ModelFallbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePlanModel resolves the plan model for an agent (used during interviewing/review phases).
|
// resolvePlanModel resolves the plan model for an agent (used during interviewing/review phases).
|
||||||
|
|
||||||
func resolvePlanModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
func resolvePlanModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
||||||
if agentCfg != nil && agentCfg.PlanModel != nil && strings.TrimSpace(agentCfg.PlanModel.Primary) != "" {
|
if agentCfg != nil && agentCfg.PlanModel != nil && strings.TrimSpace(agentCfg.PlanModel.Primary) != "" {
|
||||||
return strings.TrimSpace(agentCfg.PlanModel.Primary)
|
return strings.TrimSpace(agentCfg.PlanModel.Primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaults.PlanModel
|
return defaults.PlanModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePlanFallbacks resolves the plan model fallbacks for an agent.
|
// resolvePlanFallbacks resolves the plan model fallbacks for an agent.
|
||||||
|
|
||||||
func resolvePlanFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
|
func resolvePlanFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
|
||||||
if agentCfg != nil && agentCfg.PlanModel != nil && agentCfg.PlanModel.Fallbacks != nil {
|
if agentCfg != nil && agentCfg.PlanModel != nil && agentCfg.PlanModel.Fallbacks != nil {
|
||||||
return agentCfg.PlanModel.Fallbacks
|
return agentCfg.PlanModel.Fallbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaults.PlanModelFallbacks
|
return defaults.PlanModelFallbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActivateWorktree creates a worktree for a session.
|
// ActivateWorktree creates a worktree for a session.
|
||||||
|
|
||||||
// projectDir is the git repository to create the worktree in.
|
// projectDir is the git repository to create the worktree in.
|
||||||
|
|
||||||
// If empty, falls back to ai.Workspace.
|
// If empty, falls back to ai.Workspace.
|
||||||
|
|
||||||
// Worktree path: <workspace>/.worktrees/<branch-basename>/
|
// Worktree path: <workspace>/.worktrees/<branch-basename>/
|
||||||
|
|
||||||
func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName, projectDir string) (*git.WorktreeInfo, error) {
|
func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName, projectDir string) (*git.WorktreeInfo, error) {
|
||||||
if projectDir == "" {
|
if projectDir == "" {
|
||||||
projectDir = ai.Workspace
|
projectDir = ai.Workspace
|
||||||
}
|
}
|
||||||
|
|
||||||
repoRoot := git.FindRepoRoot(projectDir)
|
repoRoot := git.FindRepoRoot(projectDir)
|
||||||
|
|
||||||
if repoRoot == "" {
|
if repoRoot == "" {
|
||||||
return nil, fmt.Errorf("directory is not a git repository: %s", projectDir)
|
return nil, fmt.Errorf("directory is not a git repository: %s", projectDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
branchName := git.SanitizeBranchName(taskName)
|
branchName := git.SanitizeBranchName(taskName)
|
||||||
|
|
||||||
baseName := git.BranchBaseName(branchName)
|
baseName := git.BranchBaseName(branchName)
|
||||||
|
|
||||||
wtPath := filepath.Join(ai.Workspace, ".worktrees", baseName)
|
wtPath := filepath.Join(ai.Workspace, ".worktrees", baseName)
|
||||||
|
|
||||||
wt, err := git.CreateWorktree(repoRoot, wtPath, branchName)
|
wt, err := git.CreateWorktree(repoRoot, wtPath, branchName)
|
||||||
|
|
@ -421,29 +394,22 @@ func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName, projectDir strin
|
||||||
}
|
}
|
||||||
|
|
||||||
ai.worktreeMu.Lock()
|
ai.worktreeMu.Lock()
|
||||||
|
|
||||||
if ai.worktrees == nil {
|
if ai.worktrees == nil {
|
||||||
ai.worktrees = make(map[string]*git.WorktreeInfo)
|
ai.worktrees = make(map[string]*git.WorktreeInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
ai.worktrees[sessionKey] = wt
|
ai.worktrees[sessionKey] = wt
|
||||||
|
|
||||||
ai.worktreeMu.Unlock()
|
ai.worktreeMu.Unlock()
|
||||||
|
|
||||||
return wt, nil
|
return wt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeactivateWorktree safe-disposes the session's worktree.
|
// DeactivateWorktree safe-disposes the session's worktree.
|
||||||
|
|
||||||
func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discard bool) (*git.DisposeResult, error) {
|
func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discard bool) (*git.DisposeResult, error) {
|
||||||
ai.worktreeMu.Lock()
|
ai.worktreeMu.Lock()
|
||||||
|
|
||||||
wt, ok := ai.worktrees[sessionKey]
|
wt, ok := ai.worktrees[sessionKey]
|
||||||
|
|
||||||
if ok {
|
if ok {
|
||||||
delete(ai.worktrees, sessionKey)
|
delete(ai.worktrees, sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
ai.worktreeMu.Unlock()
|
ai.worktreeMu.Unlock()
|
||||||
|
|
||||||
if !ok || wt == nil {
|
if !ok || wt == nil {
|
||||||
|
|
@ -451,72 +417,70 @@ func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discar
|
||||||
}
|
}
|
||||||
|
|
||||||
repoRoot := git.FindRepoRoot(ai.Workspace)
|
repoRoot := git.FindRepoRoot(ai.Workspace)
|
||||||
|
|
||||||
if repoRoot == "" {
|
if repoRoot == "" {
|
||||||
return nil, fmt.Errorf("workspace is not a git repository")
|
return nil, fmt.Errorf("workspace is not a git repository")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Even on discard, SafeDispose auto-commits first for safety
|
// Even on discard, SafeDispose auto-commits first for safety
|
||||||
|
|
||||||
if commitMsg != "" && git.HasUncommittedChanges(wt.Path) {
|
if commitMsg != "" && git.HasUncommittedChanges(wt.Path) {
|
||||||
_ = git.AutoCommit(wt.Path, commitMsg)
|
_ = git.AutoCommit(wt.Path, commitMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := git.SafeDispose(repoRoot, wt)
|
result := git.SafeDispose(repoRoot, wt)
|
||||||
|
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorktree returns the session's active worktree, or nil.
|
// GetWorktree returns the session's active worktree, or nil.
|
||||||
|
|
||||||
func (ai *AgentInstance) GetWorktree(sessionKey string) *git.WorktreeInfo {
|
func (ai *AgentInstance) GetWorktree(sessionKey string) *git.WorktreeInfo {
|
||||||
ai.worktreeMu.RLock()
|
ai.worktreeMu.RLock()
|
||||||
|
|
||||||
defer ai.worktreeMu.RUnlock()
|
defer ai.worktreeMu.RUnlock()
|
||||||
|
|
||||||
return ai.worktrees[sessionKey]
|
return ai.worktrees[sessionKey]
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsInWorktree returns true if the session has an active worktree.
|
// IsInWorktree returns true if the session has an active worktree.
|
||||||
|
|
||||||
func (ai *AgentInstance) IsInWorktree(sessionKey string) bool {
|
func (ai *AgentInstance) IsInWorktree(sessionKey string) bool {
|
||||||
return ai.GetWorktree(sessionKey) != nil
|
return ai.GetWorktree(sessionKey) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveWorkspace returns worktree path for session, or original Workspace.
|
// EffectiveWorkspace returns worktree path for session, or original Workspace.
|
||||||
|
|
||||||
func (ai *AgentInstance) EffectiveWorkspace(sessionKey string) string {
|
func (ai *AgentInstance) EffectiveWorkspace(sessionKey string) string {
|
||||||
if wt := ai.GetWorktree(sessionKey); wt != nil {
|
if wt := ai.GetWorktree(sessionKey); wt != nil {
|
||||||
return wt.Path
|
return wt.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
return ai.Workspace
|
return ai.Workspace
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorktreeBranch returns the branch name for the session's worktree, or "".
|
// GetWorktreeBranch returns the branch name for the session's worktree, or "".
|
||||||
|
|
||||||
func (ai *AgentInstance) GetWorktreeBranch(sessionKey string) string {
|
func (ai *AgentInstance) GetWorktreeBranch(sessionKey string) string {
|
||||||
if wt := ai.GetWorktree(sessionKey); wt != nil {
|
if wt := ai.GetWorktree(sessionKey); wt != nil {
|
||||||
return wt.Branch
|
return wt.Branch
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compilePatterns(patterns []string) []*regexp.Regexp {
|
||||||
|
compiled := make([]*regexp.Regexp, 0, len(patterns))
|
||||||
|
for _, p := range patterns {
|
||||||
|
re, err := regexp.Compile(p)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: invalid path pattern %q: %v\n", p, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
compiled = append(compiled, re)
|
||||||
|
}
|
||||||
|
return compiled
|
||||||
|
}
|
||||||
|
|
||||||
func expandHome(path string) string {
|
func expandHome(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
if path[0] == '~' {
|
if path[0] == '~' {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
|
|
||||||
if len(path) > 1 && path[1] == '/' {
|
if len(path) > 1 && path[1] == '/' {
|
||||||
return home + path[1:]
|
return home + path[1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
return home
|
return home
|
||||||
}
|
}
|
||||||
|
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
pkg/agent/instance_ext_test.go
Normal file
53
pkg/agent/instance_ext_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
|
||||||
|
Model: "glm-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "glm-5",
|
||||||
|
|
||||||
|
Model: "glm-5",
|
||||||
|
|
||||||
|
APIBase: "https://api.z.ai/api/coding/paas/v4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if len(agent.Candidates) != 1 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent.Candidates[0].Provider != "openai" {
|
||||||
|
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent.Candidates[0].Model != "glm-5" {
|
||||||
|
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,35 +12,28 @@ func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
Model: "test-model",
|
MaxTokens: 1234,
|
||||||
|
|
||||||
MaxTokens: 1234,
|
|
||||||
|
|
||||||
MaxToolIterations: 5,
|
MaxToolIterations: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
configuredTemp := 1.0
|
configuredTemp := 1.0
|
||||||
|
|
||||||
cfg.Agents.Defaults.Temperature = &configuredTemp
|
cfg.Agents.Defaults.Temperature = &configuredTemp
|
||||||
|
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
|
|
||||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
if agent.MaxTokens != 1234 {
|
if agent.MaxTokens != 1234 {
|
||||||
t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234)
|
t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234)
|
||||||
}
|
}
|
||||||
|
|
||||||
if agent.Temperature != 1.0 {
|
if agent.Temperature != 1.0 {
|
||||||
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 1.0)
|
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 1.0)
|
||||||
}
|
}
|
||||||
|
|
@ -51,29 +44,23 @@ func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
Model: "test-model",
|
MaxTokens: 1234,
|
||||||
|
|
||||||
MaxTokens: 1234,
|
|
||||||
|
|
||||||
MaxToolIterations: 5,
|
MaxToolIterations: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
configuredTemp := 0.0
|
configuredTemp := 0.0
|
||||||
|
|
||||||
cfg.Agents.Defaults.Temperature = &configuredTemp
|
cfg.Agents.Defaults.Temperature = &configuredTemp
|
||||||
|
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
|
|
||||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
if agent.Temperature != 0.0 {
|
if agent.Temperature != 0.0 {
|
||||||
|
|
@ -86,25 +73,20 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
Model: "test-model",
|
MaxTokens: 1234,
|
||||||
|
|
||||||
MaxTokens: 1234,
|
|
||||||
|
|
||||||
MaxToolIterations: 5,
|
MaxToolIterations: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
|
|
||||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
if agent.Temperature != 0.7 {
|
if agent.Temperature != 0.7 {
|
||||||
|
|
@ -113,91 +95,68 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
tests := []struct {
|
||||||
if err != nil {
|
name string
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
aliasName string
|
||||||
}
|
modelName string
|
||||||
|
apiBase string
|
||||||
defer os.RemoveAll(tmpDir)
|
wantProvider string
|
||||||
|
wantModel string
|
||||||
cfg := &config.Config{
|
}{
|
||||||
Agents: config.AgentsConfig{
|
{
|
||||||
Defaults: config.AgentDefaults{
|
name: "alias with provider prefix",
|
||||||
Workspace: tmpDir,
|
aliasName: "step-3.5-flash",
|
||||||
|
modelName: "openrouter/stepfun/step-3.5-flash:free",
|
||||||
Model: "step-3.5-flash",
|
apiBase: "https://openrouter.ai/api/v1",
|
||||||
},
|
wantProvider: "openrouter",
|
||||||
|
wantModel: "stepfun/step-3.5-flash:free",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
ModelList: []config.ModelConfig{
|
name: "alias without provider prefix",
|
||||||
{
|
aliasName: "glm-5",
|
||||||
ModelName: "step-3.5-flash",
|
modelName: "glm-5",
|
||||||
|
apiBase: "https://api.z.ai/api/coding/paas/v4",
|
||||||
Model: "openrouter/stepfun/step-3.5-flash:free",
|
wantProvider: "openai",
|
||||||
|
wantModel: "glm-5",
|
||||||
APIBase: "https://openrouter.ai/api/v1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
provider := &mockProvider{}
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: tt.aliasName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: tt.aliasName,
|
||||||
|
Model: tt.modelName,
|
||||||
|
APIBase: tt.apiBase,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if len(agent.Candidates) != 1 {
|
provider := &mockProvider{}
|
||||||
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
}
|
|
||||||
|
|
||||||
if agent.Candidates[0].Provider != "openrouter" {
|
if len(agent.Candidates) != 1 {
|
||||||
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter")
|
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||||
}
|
}
|
||||||
|
if agent.Candidates[0].Provider != tt.wantProvider {
|
||||||
if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" {
|
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, tt.wantProvider)
|
||||||
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free")
|
}
|
||||||
}
|
if agent.Candidates[0].Model != tt.wantModel {
|
||||||
}
|
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel)
|
||||||
|
}
|
||||||
func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
|
})
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer os.RemoveAll(tmpDir)
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
Agents: config.AgentsConfig{
|
|
||||||
Defaults: config.AgentDefaults{
|
|
||||||
Workspace: tmpDir,
|
|
||||||
|
|
||||||
Model: "glm-5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
ModelList: []config.ModelConfig{
|
|
||||||
{
|
|
||||||
ModelName: "glm-5",
|
|
||||||
|
|
||||||
Model: "glm-5",
|
|
||||||
|
|
||||||
APIBase: "https://api.z.ai/api/coding/paas/v4",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
provider := &mockProvider{}
|
|
||||||
|
|
||||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
|
||||||
|
|
||||||
if len(agent.Candidates) != 1 {
|
|
||||||
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
|
||||||
}
|
|
||||||
|
|
||||||
if agent.Candidates[0].Provider != "openai" {
|
|
||||||
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
|
|
||||||
}
|
|
||||||
|
|
||||||
if agent.Candidates[0].Model != "glm-5" {
|
|
||||||
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5386
pkg/agent/loop.go
5386
pkg/agent/loop.go
File diff suppressed because it is too large
Load diff
717
pkg/agent/loop_commands.go
Normal file
717
pkg/agent/loop_commands.go
Normal file
|
|
@ -0,0 +1,717 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
|
||||||
|
content := strings.TrimSpace(msg.Content)
|
||||||
|
|
||||||
|
if !strings.HasPrefix(content, "/") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Fields(content)
|
||||||
|
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := parts[0]
|
||||||
|
|
||||||
|
args := parts[1:]
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "/show":
|
||||||
|
|
||||||
|
if len(args) < 1 {
|
||||||
|
return "Usage: /show [model|channel|agents]", true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "model":
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if defaultAgent == nil {
|
||||||
|
return "No default agent configured", true
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Current model: %s", defaultAgent.Model), true
|
||||||
|
|
||||||
|
case "channel":
|
||||||
|
|
||||||
|
return fmt.Sprintf("Current channel: %s", msg.Channel), true
|
||||||
|
|
||||||
|
case "agents":
|
||||||
|
|
||||||
|
agentIDs := al.registry.ListAgentIDs()
|
||||||
|
|
||||||
|
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
|
||||||
|
|
||||||
|
default:
|
||||||
|
|
||||||
|
return fmt.Sprintf("Unknown show target: %s", args[0]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
case "/list":
|
||||||
|
|
||||||
|
if len(args) < 1 {
|
||||||
|
return "Usage: /list [models|channels|agents]", true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "models":
|
||||||
|
|
||||||
|
return "Available models: configured in config.json per agent", true
|
||||||
|
|
||||||
|
case "channels":
|
||||||
|
|
||||||
|
if al.channelManager == nil {
|
||||||
|
return "Channel manager not initialized", true
|
||||||
|
}
|
||||||
|
|
||||||
|
channels := al.channelManager.GetEnabledChannels()
|
||||||
|
|
||||||
|
if len(channels) == 0 {
|
||||||
|
return "No channels enabled", true
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true
|
||||||
|
|
||||||
|
case "agents":
|
||||||
|
|
||||||
|
agentIDs := al.registry.ListAgentIDs()
|
||||||
|
|
||||||
|
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
|
||||||
|
|
||||||
|
default:
|
||||||
|
|
||||||
|
return fmt.Sprintf("Unknown list target: %s", args[0]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
case "/switch":
|
||||||
|
|
||||||
|
if len(args) < 3 || args[1] != "to" {
|
||||||
|
return "Usage: /switch [model|channel] to <name>", true
|
||||||
|
}
|
||||||
|
|
||||||
|
target := args[0]
|
||||||
|
|
||||||
|
value := args[2]
|
||||||
|
|
||||||
|
switch target {
|
||||||
|
case "model":
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if defaultAgent == nil {
|
||||||
|
return "No default agent configured", true
|
||||||
|
}
|
||||||
|
|
||||||
|
oldModel := defaultAgent.Model
|
||||||
|
|
||||||
|
defaultAgent.Model = value
|
||||||
|
|
||||||
|
return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
|
||||||
|
|
||||||
|
case "channel":
|
||||||
|
|
||||||
|
if al.channelManager == nil {
|
||||||
|
return "Channel manager not initialized", true
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
|
||||||
|
return fmt.Sprintf("Channel '%s' not found or not enabled", value), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Switched target channel to %s", value), true
|
||||||
|
|
||||||
|
default:
|
||||||
|
|
||||||
|
return fmt.Sprintf("Unknown switch target: %s", target), true
|
||||||
|
}
|
||||||
|
|
||||||
|
case "/session":
|
||||||
|
|
||||||
|
return al.handleSessionCommand(args, msg.SessionKey), true
|
||||||
|
|
||||||
|
case "/skills":
|
||||||
|
|
||||||
|
return al.handleSkillsCommand(), true
|
||||||
|
|
||||||
|
case "/plan":
|
||||||
|
|
||||||
|
resp, handled := al.handlePlanCommand(args, msg.SessionKey)
|
||||||
|
|
||||||
|
if handled {
|
||||||
|
al.notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, handled
|
||||||
|
|
||||||
|
case "/heartbeat":
|
||||||
|
|
||||||
|
resp, handled := al.handleHeartbeatCommand(args, msg)
|
||||||
|
|
||||||
|
if handled {
|
||||||
|
al.notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, handled
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleHeartbeatCommand(args []string, msg bus.InboundMessage) (string, bool) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return "Usage: /heartbeat thread [here|off|<thread_id>]", true
|
||||||
|
}
|
||||||
|
|
||||||
|
if args[0] != "thread" {
|
||||||
|
return "Usage: /heartbeat thread [here|off|<thread_id>]", true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) < 2 {
|
||||||
|
return "Usage: /heartbeat thread [here|off|<thread_id>]", true
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Channel != "telegram" {
|
||||||
|
return "/heartbeat thread is only supported from Telegram chats.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
baseChatID, currentThreadID := splitChatAndThread(msg.ChatID)
|
||||||
|
|
||||||
|
if baseChatID == "" {
|
||||||
|
return "Unable to detect Telegram chat ID for heartbeat routing.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
arg := strings.ToLower(strings.TrimSpace(args[1]))
|
||||||
|
|
||||||
|
var threadID int
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch arg {
|
||||||
|
case "off", "disable", "clear":
|
||||||
|
|
||||||
|
threadID = 0
|
||||||
|
|
||||||
|
case "here", "this":
|
||||||
|
|
||||||
|
if currentThreadID <= 0 {
|
||||||
|
return "Current Telegram message is not in a thread. Usage: /heartbeat thread <thread_id>", true
|
||||||
|
}
|
||||||
|
|
||||||
|
threadID = currentThreadID
|
||||||
|
|
||||||
|
default:
|
||||||
|
|
||||||
|
threadID, err = strconv.Atoi(arg)
|
||||||
|
|
||||||
|
if err != nil || threadID < 0 {
|
||||||
|
return "Usage: /heartbeat thread [here|off|<thread_id>]", true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
al.cfg.Channels.Telegram.HeartbeatThreadID = threadID
|
||||||
|
|
||||||
|
if al.state != nil {
|
||||||
|
_ = al.state.SetHeartbeatTarget(fmt.Sprintf("telegram:%s", baseChatID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if al.onHeartbeatThreadUpdate != nil {
|
||||||
|
al.onHeartbeatThreadUpdate(threadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if al.saveConfig != nil {
|
||||||
|
if err := al.saveConfig(al.cfg); err != nil {
|
||||||
|
return fmt.Sprintf("Failed to persist config.json: %v", err), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if threadID == 0 {
|
||||||
|
return fmt.Sprintf("Heartbeat thread routing disabled for chat %s and saved to config.json.", baseChatID), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Heartbeat thread set to %d for chat %s and saved to config.json.", threadID, baseChatID), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitChatAndThread(chatID string) (baseChatID string, threadID int) {
|
||||||
|
baseChatID = strings.TrimSpace(chatID)
|
||||||
|
|
||||||
|
if baseChatID == "" {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if slash := strings.Index(baseChatID, "/"); slash >= 0 {
|
||||||
|
threadPart := strings.TrimSpace(baseChatID[slash+1:])
|
||||||
|
|
||||||
|
baseChatID = strings.TrimSpace(baseChatID[:slash])
|
||||||
|
|
||||||
|
if tid, err := strconv.Atoi(threadPart); err == nil && tid > 0 {
|
||||||
|
threadID = tid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseChatID, threadID
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSessionCommand dispatches /session subcommands.
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleSessionCommand(args []string, sessionKey string) string {
|
||||||
|
sub := ""
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
sub = strings.ToLower(strings.TrimSpace(args[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sub {
|
||||||
|
case "list":
|
||||||
|
|
||||||
|
return al.handleSessionList()
|
||||||
|
|
||||||
|
case "graph":
|
||||||
|
|
||||||
|
return al.handleSessionGraph()
|
||||||
|
|
||||||
|
case "fork":
|
||||||
|
|
||||||
|
return al.handleSessionFork(args[1:], sessionKey)
|
||||||
|
|
||||||
|
case "reset":
|
||||||
|
|
||||||
|
if al.stats == nil {
|
||||||
|
return "Stats tracking is disabled."
|
||||||
|
}
|
||||||
|
|
||||||
|
al.stats.Reset()
|
||||||
|
|
||||||
|
return "Session statistics have been reset."
|
||||||
|
|
||||||
|
default:
|
||||||
|
|
||||||
|
return al.handleSessionStats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleSessionStats() string {
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
store := agent.Sessions.Store()
|
||||||
|
|
||||||
|
// Session DAG summary
|
||||||
|
|
||||||
|
sessions, _ := store.List(nil)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, "Sessions: %d in store\n", len(sessions))
|
||||||
|
|
||||||
|
if len(sessions) > 0 {
|
||||||
|
active, completed := 0, 0
|
||||||
|
|
||||||
|
for _, s := range sessions {
|
||||||
|
switch s.Status {
|
||||||
|
case "active":
|
||||||
|
|
||||||
|
active++
|
||||||
|
|
||||||
|
case "completed":
|
||||||
|
|
||||||
|
completed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, " active=%d completed=%d\n", active, completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\nUse: /session list | graph | fork [label]\n")
|
||||||
|
|
||||||
|
// Token stats if available
|
||||||
|
|
||||||
|
if al.stats != nil {
|
||||||
|
s := al.stats.GetStats()
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb,
|
||||||
|
|
||||||
|
"\nToken Stats — Today (%s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)\n"+
|
||||||
|
|
||||||
|
"All time (since %s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)",
|
||||||
|
|
||||||
|
s.Today.Date,
|
||||||
|
|
||||||
|
s.Today.Prompts,
|
||||||
|
|
||||||
|
s.Today.Requests,
|
||||||
|
|
||||||
|
stats.FormatTokenCount(s.Today.TotalTokens),
|
||||||
|
|
||||||
|
stats.FormatTokenCount(s.Today.PromptTokens),
|
||||||
|
|
||||||
|
stats.FormatTokenCount(s.Today.CompletionTokens),
|
||||||
|
|
||||||
|
s.Since.Format("2006-01-02"),
|
||||||
|
|
||||||
|
s.TotalPrompts,
|
||||||
|
|
||||||
|
s.TotalRequests,
|
||||||
|
|
||||||
|
stats.FormatTokenCount(s.TotalTokens),
|
||||||
|
|
||||||
|
stats.FormatTokenCount(s.TotalPromptTokens),
|
||||||
|
|
||||||
|
stats.FormatTokenCount(s.TotalCompletionTokens),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortSessionKey truncates long session keys for display.
|
||||||
|
|
||||||
|
func shortSessionKey(key string) string {
|
||||||
|
parts := strings.Split(key, ":")
|
||||||
|
|
||||||
|
if len(parts) > 2 {
|
||||||
|
return strings.Join(parts[2:], ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleSessionList() string {
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
store := agent.Sessions.Store()
|
||||||
|
|
||||||
|
sessions, err := store.List(nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("Error listing sessions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sessions) == 0 {
|
||||||
|
return "No sessions in store."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, "Sessions (%d)\n", len(sessions))
|
||||||
|
|
||||||
|
for _, s := range sessions {
|
||||||
|
age := time.Since(s.UpdatedAt).Truncate(time.Second)
|
||||||
|
|
||||||
|
label := s.Label
|
||||||
|
|
||||||
|
if label == "" {
|
||||||
|
label = shortSessionKey(s.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := ""
|
||||||
|
|
||||||
|
if s.ParentKey != "" {
|
||||||
|
parent = " parent=" + shortSessionKey(s.ParentKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, "- %s [%s] (%s) turns=%d%s\n",
|
||||||
|
|
||||||
|
label, s.Status, age, s.TurnCount, parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleSessionGraph() string {
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
store := agent.Sessions.Store()
|
||||||
|
|
||||||
|
sessions, err := store.List(nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("Error listing sessions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sessions) == 0 {
|
||||||
|
return "No sessions in store."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build parent→children map and find roots
|
||||||
|
|
||||||
|
byKey := make(map[string]*session.SessionInfo, len(sessions))
|
||||||
|
|
||||||
|
children := make(map[string][]string)
|
||||||
|
|
||||||
|
var roots []string
|
||||||
|
|
||||||
|
for _, s := range sessions {
|
||||||
|
byKey[s.Key] = s
|
||||||
|
|
||||||
|
if s.ParentKey == "" {
|
||||||
|
roots = append(roots, s.Key)
|
||||||
|
} else {
|
||||||
|
children[s.ParentKey] = append(children[s.ParentKey], s.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString("Session Graph\n")
|
||||||
|
|
||||||
|
for i, root := range roots {
|
||||||
|
last := i == len(roots)-1
|
||||||
|
|
||||||
|
printSessionTree(&sb, root, byKey, children, "", last)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func printSessionTree(
|
||||||
|
sb *strings.Builder,
|
||||||
|
key string,
|
||||||
|
byKey map[string]*session.SessionInfo,
|
||||||
|
children map[string][]string,
|
||||||
|
prefix string,
|
||||||
|
last bool,
|
||||||
|
) {
|
||||||
|
s := byKey[key]
|
||||||
|
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := "├── "
|
||||||
|
|
||||||
|
if last {
|
||||||
|
connector = "└── "
|
||||||
|
}
|
||||||
|
|
||||||
|
icon := "●"
|
||||||
|
|
||||||
|
if s.Status == "completed" {
|
||||||
|
icon = "✓"
|
||||||
|
}
|
||||||
|
|
||||||
|
label := s.Label
|
||||||
|
|
||||||
|
if label == "" {
|
||||||
|
label = shortSessionKey(s.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(sb, "%s%s%s %s (turns=%d)\n", prefix, connector, icon, label, s.TurnCount)
|
||||||
|
|
||||||
|
childPrefix := prefix + "│ "
|
||||||
|
|
||||||
|
if last {
|
||||||
|
childPrefix = prefix + " "
|
||||||
|
}
|
||||||
|
|
||||||
|
kids := children[key]
|
||||||
|
|
||||||
|
for i, childKey := range kids {
|
||||||
|
printSessionTree(sb, childKey, byKey, children, childPrefix, i == len(kids)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleSessionFork(args []string, sessionKey string) string {
|
||||||
|
if sessionKey == "" {
|
||||||
|
return "Cannot fork: no active session key."
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
store := agent.Sessions.Store()
|
||||||
|
|
||||||
|
label := "fork"
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
label = strings.Join(args, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
childKey := sessionKey + ":fork:" + time.Now().Format("20060102T150405")
|
||||||
|
|
||||||
|
err := store.Fork(sessionKey, childKey, &session.CreateOpts{Label: label})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("Fork failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"Forked session\n parent: %s\n child: %s",
|
||||||
|
shortSessionKey(sessionKey),
|
||||||
|
shortSessionKey(childKey),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionGraphNode struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
|
||||||
|
Label string `json:"label"`
|
||||||
|
|
||||||
|
Status string `json:"status"`
|
||||||
|
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
|
||||||
|
ParentKey string `json:"parent_key"`
|
||||||
|
|
||||||
|
ForkTurnID string `json:"fork_turn_id"`
|
||||||
|
|
||||||
|
TurnCount int `json:"turn_count"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionGraph returns all sessions as a flat list of graph nodes.
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetSessionGraph() []SessionGraphNode {
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
store := agent.Sessions.Store()
|
||||||
|
|
||||||
|
sessions, err := store.List(nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := make([]SessionGraphNode, 0, len(sessions))
|
||||||
|
|
||||||
|
for _, s := range sessions {
|
||||||
|
nodes = append(nodes, SessionGraphNode{
|
||||||
|
Key: s.Key,
|
||||||
|
|
||||||
|
Label: s.Label,
|
||||||
|
|
||||||
|
Status: s.Status,
|
||||||
|
|
||||||
|
Summary: s.Summary,
|
||||||
|
|
||||||
|
ParentKey: s.ParentKey,
|
||||||
|
|
||||||
|
ForkTurnID: s.ForkTurnID,
|
||||||
|
|
||||||
|
TurnCount: s.TurnCount,
|
||||||
|
|
||||||
|
CreatedAt: s.CreatedAt,
|
||||||
|
|
||||||
|
UpdatedAt: s.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandSkillCommand detects "/skill <name> [message]" and returns:
|
||||||
|
|
||||||
|
// - expanded: full content with SKILL.md injected (for LLM)
|
||||||
|
|
||||||
|
// - compact: skill name tag + user message only (for history)
|
||||||
|
|
||||||
|
// - ok: whether expansion happened
|
||||||
|
|
||||||
|
func (al *AgentLoop) expandSkillCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) {
|
||||||
|
content := strings.TrimSpace(msg.Content)
|
||||||
|
|
||||||
|
if !strings.HasPrefix(content, "/skill ") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse: /skill <name> [message]
|
||||||
|
|
||||||
|
rest := strings.TrimSpace(content[7:]) // len("/skill ") == 7
|
||||||
|
|
||||||
|
parts := strings.SplitN(rest, " ", 2)
|
||||||
|
|
||||||
|
if len(parts) == 0 || parts[0] == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
skillName := parts[0]
|
||||||
|
|
||||||
|
userMessage := ""
|
||||||
|
|
||||||
|
if len(parts) > 1 {
|
||||||
|
userMessage = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if agent == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
skillContent, found := agent.ContextBuilder.LoadSkill(skillName)
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := fmt.Sprintf("[Skill: %s]", skillName)
|
||||||
|
|
||||||
|
// Build expanded message: skill instructions + user message (for LLM)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(tag)
|
||||||
|
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
|
sb.WriteString(skillContent)
|
||||||
|
|
||||||
|
if userMessage != "" {
|
||||||
|
sb.WriteString("\n\n---\n\n")
|
||||||
|
|
||||||
|
sb.WriteString(userMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build compact form: skill name tag + user message only (for history)
|
||||||
|
|
||||||
|
compactForm := tag
|
||||||
|
|
||||||
|
if userMessage != "" {
|
||||||
|
compactForm = tag + "\n" + userMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String(), compactForm, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSkillsCommand lists all available skills.
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleSkillsCommand() string {
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if agent == nil {
|
||||||
|
return "No agent configured."
|
||||||
|
}
|
||||||
|
|
||||||
|
skillsList := agent.ContextBuilder.ListSkills()
|
||||||
|
|
||||||
|
if len(skillsList) == 0 {
|
||||||
|
return "No skills available.\nAdd skills to your workspace/skills/ directory."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString("Available Skills\n\n")
|
||||||
|
|
||||||
|
for _, s := range skillsList {
|
||||||
|
fmt.Fprintf(&sb, "**%s** (%s)\n", s.Name, s.Source)
|
||||||
|
|
||||||
|
if s.Description != "" {
|
||||||
|
fmt.Fprintf(&sb, "```\n%s\n```\n", s.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\nUse: /skill <name> [message]")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
2883
pkg/agent/loop_ext_test.go
Normal file
2883
pkg/agent/loop_ext_test.go
Normal file
File diff suppressed because it is too large
Load diff
521
pkg/agent/loop_hooks.go
Normal file
521
pkg/agent/loop_hooks.go
Normal file
|
|
@ -0,0 +1,521 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/orch"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// iterationHooks contains callbacks that extend the core LLM iteration loop.
|
||||||
|
// All fields are initialized to no-op defaults by buildHooks, so callers
|
||||||
|
// never need nil checks.
|
||||||
|
type iterationHooks struct {
|
||||||
|
// OnIterationStart is called at the top of each iteration.
|
||||||
|
// Returns an optional user-role message to inject (e.g. user intervention).
|
||||||
|
OnIterationStart func(iteration int) (interventionMsg string)
|
||||||
|
|
||||||
|
// FilterTools is called after building provider tool definitions,
|
||||||
|
// before the LLM call. Returns a (possibly filtered) slice.
|
||||||
|
FilterTools func(defs []providers.ToolDefinition) []providers.ToolDefinition
|
||||||
|
|
||||||
|
// SetupStreaming is called before each LLM call to set up streaming
|
||||||
|
// preview. Returns an onChunk callback and a cleanup function.
|
||||||
|
SetupStreaming func() (onChunk func(accumulated, reasoning string), cleanup func())
|
||||||
|
|
||||||
|
// SelectModel overrides the model and candidates for this call.
|
||||||
|
// Returns empty string to use defaults.
|
||||||
|
SelectModel func() (model string, candidates []providers.FallbackCandidate)
|
||||||
|
|
||||||
|
// OnPreLLMCall is called just before the LLM call (e.g. orch state reporting).
|
||||||
|
OnPreLLMCall func()
|
||||||
|
|
||||||
|
// OnNoToolCalls is called when the LLM returns no tool calls.
|
||||||
|
// Returns an optional nudge message and whether to continue the loop.
|
||||||
|
OnNoToolCalls func(content string, iteration int) (nudge string, continueLoop bool)
|
||||||
|
|
||||||
|
// FilterToolCalls is called after normalizing tool calls, before execution.
|
||||||
|
// Returns the filtered calls and an optional rejection message.
|
||||||
|
FilterToolCalls func(calls []providers.ToolCall) (filtered []providers.ToolCall, rejectionMsg string)
|
||||||
|
|
||||||
|
// OnPreToolExec is called before each tool execution.
|
||||||
|
// Returns an async callback (may be nil).
|
||||||
|
OnPreToolExec func(ctx context.Context, tc providers.ToolCall) tools.AsyncCallback
|
||||||
|
|
||||||
|
// OnToolExecDone is called after each tool execution with the result.
|
||||||
|
OnToolExecDone func(tc providers.ToolCall, result *tools.ToolResult, duration time.Duration)
|
||||||
|
|
||||||
|
// OnToolsProcessed is called after all tool calls in an iteration
|
||||||
|
// have been logged and their results built.
|
||||||
|
OnToolsProcessed func(ctx context.Context, iteration int, toolCalls []providers.ToolCall)
|
||||||
|
|
||||||
|
// InjectReminders is called at the end of each iteration to append
|
||||||
|
// fork-specific reminder messages (task, plan, orch, subagent questions).
|
||||||
|
InjectReminders func(iteration int, messages *[]providers.Message, lastBlocker string)
|
||||||
|
|
||||||
|
// RefreshSystemPrompt is called at the end of each iteration to
|
||||||
|
// rebuild the system prompt after tool execution may have changed state.
|
||||||
|
RefreshSystemPrompt func(messages []providers.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultHooks returns an iterationHooks with all fields set to no-ops.
|
||||||
|
func defaultHooks() iterationHooks {
|
||||||
|
return iterationHooks{
|
||||||
|
OnIterationStart: func(int) string { return "" },
|
||||||
|
FilterTools: func(d []providers.ToolDefinition) []providers.ToolDefinition { return d },
|
||||||
|
SetupStreaming: func() (func(string, string), func()) { return nil, nil },
|
||||||
|
SelectModel: func() (string, []providers.FallbackCandidate) { return "", nil },
|
||||||
|
OnPreLLMCall: func() {},
|
||||||
|
OnNoToolCalls: func(string, int) (string, bool) { return "", false },
|
||||||
|
FilterToolCalls: func(c []providers.ToolCall) ([]providers.ToolCall, string) { return c, "" },
|
||||||
|
OnPreToolExec: func(context.Context, providers.ToolCall) tools.AsyncCallback { return nil },
|
||||||
|
OnToolExecDone: func(providers.ToolCall, *tools.ToolResult, time.Duration) {},
|
||||||
|
OnToolsProcessed: func(context.Context, int, []providers.ToolCall) {},
|
||||||
|
InjectReminders: func(int, *[]providers.Message, string) {},
|
||||||
|
RefreshSystemPrompt: func([]providers.Message) {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHooks constructs the hook set based on the current agent state.
|
||||||
|
// All fork-specific logic is wired here; the core loop only calls hooks.
|
||||||
|
func (al *AgentLoop) buildHooks(
|
||||||
|
agent *AgentInstance,
|
||||||
|
opts processOptions,
|
||||||
|
task *activeTask,
|
||||||
|
planSnapshot string,
|
||||||
|
) iterationHooks {
|
||||||
|
h := defaultHooks()
|
||||||
|
isBackground := opts.TaskID != ""
|
||||||
|
|
||||||
|
// ── Task tracking ──
|
||||||
|
if task != nil {
|
||||||
|
h.OnIterationStart = func(iteration int) string {
|
||||||
|
task.mu.Lock()
|
||||||
|
task.Iteration = iteration
|
||||||
|
task.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-task.interrupt:
|
||||||
|
logger.InfoCF("agent", "User intervention injected",
|
||||||
|
map[string]any{"agent_id": agent.ID, "iteration": iteration})
|
||||||
|
return "[User Intervention] " + msg
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.OnToolExecDone = func(tc providers.ToolCall, result *tools.ToolResult, duration time.Duration) {
|
||||||
|
updateToolLogResult(task, tc, result, duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plan mode ──
|
||||||
|
if planSnapshot != "" {
|
||||||
|
preUnchecked := -1
|
||||||
|
if planSnapshot == "executing" {
|
||||||
|
preUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]")
|
||||||
|
}
|
||||||
|
planMarkNudged := false
|
||||||
|
|
||||||
|
if isPlanPreExecution(planSnapshot) {
|
||||||
|
h.FilterTools = func(defs []providers.ToolDefinition) []providers.ToolDefinition {
|
||||||
|
return filterInterviewTools(defs)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.FilterToolCalls = func(calls []providers.ToolCall) ([]providers.ToolCall, string) {
|
||||||
|
allowed := calls[:0]
|
||||||
|
var rejected []string
|
||||||
|
for _, tc := range calls {
|
||||||
|
if isToolAllowedDuringInterview(tc.Name, tc.Arguments) {
|
||||||
|
allowed = append(allowed, tc)
|
||||||
|
} else {
|
||||||
|
rejected = append(rejected, tc.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rejected) > 0 {
|
||||||
|
logger.InfoCF("agent", "Interview mode: rejected tool calls",
|
||||||
|
map[string]any{"agent_id": agent.ID, "rejected": rejected})
|
||||||
|
}
|
||||||
|
return allowed, interviewRejectMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.OnNoToolCalls = func(content string, iteration int) (string, bool) {
|
||||||
|
if preUnchecked <= 0 || planMarkNudged || planSnapshot != "executing" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
curUnchecked := strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]")
|
||||||
|
if curUnchecked <= 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
planMarkNudged = true
|
||||||
|
|
||||||
|
var nudge string
|
||||||
|
if curUnchecked == preUnchecked {
|
||||||
|
nudge = fmt.Sprintf("[System] %d unchecked steps remain in MEMORY.md and "+
|
||||||
|
"none were marked [x] during this session. "+
|
||||||
|
"If you completed any steps, use edit_file to mark them [x] now. "+
|
||||||
|
"If steps are still in progress, continue working on them.", curUnchecked)
|
||||||
|
} else {
|
||||||
|
nudge = fmt.Sprintf("[System] Progress recorded. %d unchecked steps remain. "+
|
||||||
|
"Continue working on the next step.", curUnchecked)
|
||||||
|
}
|
||||||
|
logger.InfoCF("agent", "Nudging plan execution: continue plan steps",
|
||||||
|
map[string]any{"agent_id": agent.ID, "iteration": iteration, "unchecked": curUnchecked})
|
||||||
|
return nudge, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan model selection
|
||||||
|
if isPlanPreExecution(planSnapshot) && agent.PlanModel != "" {
|
||||||
|
h.SelectModel = func() (string, []providers.FallbackCandidate) {
|
||||||
|
logger.InfoCF("agent", "Using plan model",
|
||||||
|
map[string]any{"agent_id": agent.ID, "plan_model": agent.PlanModel})
|
||||||
|
return agent.PlanModel, agent.PlanCandidates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Streaming ──
|
||||||
|
if !constants.IsInternalChannel(opts.Channel) {
|
||||||
|
h.SetupStreaming = func() (func(string, string), func()) {
|
||||||
|
return al.setupStreamingHook(opts, task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Orchestration ──
|
||||||
|
if al.orchReporter != orch.Noop {
|
||||||
|
h.OnPreLLMCall = func() {
|
||||||
|
al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateWaiting, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap OnPreToolExec to add orch state reporting
|
||||||
|
h.OnPreToolExec = func(ctx context.Context, tc providers.ToolCall) tools.AsyncCallback {
|
||||||
|
al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateToolCall, tc.Name)
|
||||||
|
return al.buildAsyncCallback(opts, tc.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Even without orch, we still need async callback
|
||||||
|
h.OnPreToolExec = func(ctx context.Context, tc providers.ToolCall) tools.AsyncCallback {
|
||||||
|
return al.buildAsyncCallback(opts, tc.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool status + session touch ──
|
||||||
|
if !constants.IsInternalChannel(opts.Channel) && task != nil {
|
||||||
|
h.OnToolsProcessed = func(ctx context.Context, iteration int, toolCalls []providers.ToolCall) {
|
||||||
|
al.publishToolStatus(ctx, agent, opts, task, iteration, isBackground, toolCalls)
|
||||||
|
al.recordSessionTouches(agent, opts, toolCalls)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Session touch without status publishing
|
||||||
|
h.OnToolsProcessed = func(ctx context.Context, iteration int, toolCalls []providers.ToolCall) {
|
||||||
|
al.recordSessionTouches(agent, opts, toolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reminder injection (task + plan + orch + subagent questions) ──
|
||||||
|
h.InjectReminders = al.buildReminderInjector(agent, opts, task, planSnapshot)
|
||||||
|
|
||||||
|
// ── System prompt refresh ──
|
||||||
|
h.RefreshSystemPrompt = func(messages []providers.Message) {
|
||||||
|
if touchDir := al.sessions.GetTouchDir(opts.SessionKey); touchDir != "" {
|
||||||
|
agent.ContextBuilder.SetWorkDir(filepath.Join(agent.Workspace, touchDir))
|
||||||
|
}
|
||||||
|
if newPrompt := agent.ContextBuilder.BuildSystemPrompt(); len(messages) > 0 &&
|
||||||
|
messages[0].Content != newPrompt {
|
||||||
|
messages[0].Content = newPrompt
|
||||||
|
al.lastSystemPrompt.Store(newPrompt)
|
||||||
|
al.promptDirty.Store(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hook helper implementations ──
|
||||||
|
|
||||||
|
// setupStreamingHook creates a streaming display goroutine and returns
|
||||||
|
// the onChunk callback and cleanup function.
|
||||||
|
func (al *AgentLoop) setupStreamingHook(opts processOptions, task *activeTask) (func(string, string), func()) {
|
||||||
|
type streamUpdate struct{ accumulated, reasoning string }
|
||||||
|
|
||||||
|
streamCh := make(chan streamUpdate, 1)
|
||||||
|
streamDone := make(chan struct{})
|
||||||
|
|
||||||
|
ctx := context.Background() // outlive the caller's context for flush
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(streamDone)
|
||||||
|
for up := range streamCh {
|
||||||
|
display := buildStreamingDisplay(up.accumulated, up.reasoning)
|
||||||
|
outMsg := bus.OutboundMessage{
|
||||||
|
Channel: opts.Channel,
|
||||||
|
ChatID: opts.ChatID,
|
||||||
|
Content: display,
|
||||||
|
}
|
||||||
|
if opts.Background && opts.TaskID != "" {
|
||||||
|
outMsg.IsTaskStatus = true
|
||||||
|
outMsg.TaskID = opts.TaskID
|
||||||
|
} else {
|
||||||
|
outMsg.IsStatus = true
|
||||||
|
}
|
||||||
|
_ = al.bus.PublishOutbound(ctx, outMsg)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
onChunk := func(accumulated, reasoning string) {
|
||||||
|
if task != nil {
|
||||||
|
task.streamedChunks = true
|
||||||
|
}
|
||||||
|
up := streamUpdate{accumulated, reasoning}
|
||||||
|
select {
|
||||||
|
case streamCh <- up:
|
||||||
|
default:
|
||||||
|
select {
|
||||||
|
case <-streamCh:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case streamCh <- up:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
close(streamCh)
|
||||||
|
<-streamDone
|
||||||
|
}
|
||||||
|
|
||||||
|
return onChunk, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAsyncCallback creates the async tool callback that publishes
|
||||||
|
// results as system inbound messages.
|
||||||
|
func (al *AgentLoop) buildAsyncCallback(opts processOptions, toolName string) tools.AsyncCallback {
|
||||||
|
return func(_ context.Context, result *tools.ToolResult) {
|
||||||
|
content := result.ForLLM
|
||||||
|
if content == "" {
|
||||||
|
content = result.ForUser
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Async tool completed, publishing to conductor",
|
||||||
|
map[string]any{"tool": toolName, "content_len": len(content), "is_error": result.IsError})
|
||||||
|
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
|
||||||
|
_ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{
|
||||||
|
Channel: "system",
|
||||||
|
SenderID: fmt.Sprintf("async:%s", toolName),
|
||||||
|
ChatID: fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID),
|
||||||
|
Content: fmt.Sprintf("Async tool '%s' completed.\n\nResult:\n%s", toolName, content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateToolLogResult updates the task's tool log entry with execution result.
|
||||||
|
func updateToolLogResult(task *activeTask, tc providers.ToolCall, result *tools.ToolResult, duration time.Duration) {
|
||||||
|
task.mu.Lock()
|
||||||
|
defer task.mu.Unlock()
|
||||||
|
|
||||||
|
// Walk backward to find the matching pending entry
|
||||||
|
for i := len(task.toolLog) - 1; i >= 0; i-- {
|
||||||
|
if task.toolLog[i].Result == "\u23F3" {
|
||||||
|
if result.IsError || result.Err != nil {
|
||||||
|
task.toolLog[i].Result = fmt.Sprintf("\u2717 %.1fs", duration.Seconds())
|
||||||
|
if result.Err != nil {
|
||||||
|
task.toolLog[i].ErrDetail = utils.Truncate(result.Err.Error(), 300)
|
||||||
|
} else if result.ForLLM != "" {
|
||||||
|
lines := strings.Split(strings.TrimSpace(result.ForLLM), "\n")
|
||||||
|
start := len(lines) - 3
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
task.toolLog[i].ErrDetail = utils.Truncate(
|
||||||
|
strings.Join(lines[start:], "\n"), 300)
|
||||||
|
}
|
||||||
|
entry := task.toolLog[i]
|
||||||
|
task.lastError = &entry
|
||||||
|
} else {
|
||||||
|
task.toolLog[i].Result = fmt.Sprintf("\u2713 %.1fs", duration.Seconds())
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishToolStatus adds pending entries to the tool log and publishes
|
||||||
|
// a rich status update via the message bus.
|
||||||
|
func (al *AgentLoop) publishToolStatus(
|
||||||
|
ctx context.Context,
|
||||||
|
agent *AgentInstance,
|
||||||
|
opts processOptions,
|
||||||
|
task *activeTask,
|
||||||
|
iteration int,
|
||||||
|
isBackground bool,
|
||||||
|
toolCalls []providers.ToolCall,
|
||||||
|
) {
|
||||||
|
task.mu.Lock()
|
||||||
|
for _, tc := range toolCalls {
|
||||||
|
task.toolLog = append(task.toolLog, toolLogEntry{
|
||||||
|
Name: fmt.Sprintf("[%d] %s", iteration, tc.Name),
|
||||||
|
ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace),
|
||||||
|
Result: "\u23F3",
|
||||||
|
})
|
||||||
|
if task.projectDir == "" && tc.Name == "exec" {
|
||||||
|
task.projectDir = extractExecProjectDir(tc.Arguments)
|
||||||
|
}
|
||||||
|
switch tc.Name {
|
||||||
|
case "read_file", "write_file", "edit_file", "append_file", "list_dir":
|
||||||
|
if p, _ := tc.Arguments["path"].(string); p != "" {
|
||||||
|
if rel := fileParentRelDir(p, agent.Workspace); rel != "" {
|
||||||
|
if task.fileCommonDir == "" {
|
||||||
|
task.fileCommonDir = rel
|
||||||
|
} else {
|
||||||
|
task.fileCommonDir = commonDirPrefix(task.fileCommonDir, rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.mu.Unlock()
|
||||||
|
|
||||||
|
statusContent := buildRichStatus(task, isBackground, agent.Workspace)
|
||||||
|
if isBackground {
|
||||||
|
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
|
Channel: opts.Channel,
|
||||||
|
ChatID: opts.ChatID,
|
||||||
|
Content: statusContent,
|
||||||
|
IsTaskStatus: true,
|
||||||
|
TaskID: opts.TaskID,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
|
Channel: opts.Channel,
|
||||||
|
ChatID: opts.ChatID,
|
||||||
|
Content: statusContent,
|
||||||
|
IsStatus: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordSessionTouches records session activity for heartbeat/plan coordination.
|
||||||
|
func (al *AgentLoop) recordSessionTouches(
|
||||||
|
agent *AgentInstance,
|
||||||
|
opts processOptions,
|
||||||
|
toolCalls []providers.ToolCall,
|
||||||
|
) {
|
||||||
|
for _, tc := range toolCalls {
|
||||||
|
var detectedDir string
|
||||||
|
if tc.Name == "exec" {
|
||||||
|
detectedDir = extractExecProjectDir(tc.Arguments)
|
||||||
|
}
|
||||||
|
if detectedDir == "" {
|
||||||
|
switch tc.Name {
|
||||||
|
case "read_file", "write_file", "edit_file", "append_file", "list_dir":
|
||||||
|
if p, _ := tc.Arguments["path"].(string); p != "" {
|
||||||
|
detectedDir = fileParentRelDir(p, agent.Workspace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if detectedDir != "" {
|
||||||
|
meta := &TouchMeta{
|
||||||
|
ProjectPath: agent.ContextBuilder.GetPlanWorkDir(),
|
||||||
|
Purpose: utils.Truncate(opts.UserMessage, 80),
|
||||||
|
Branch: agent.GetWorktreeBranch(opts.SessionKey),
|
||||||
|
}
|
||||||
|
if meta.ProjectPath == "" {
|
||||||
|
meta.ProjectPath = agent.Workspace
|
||||||
|
}
|
||||||
|
al.sessions.Touch(opts.SessionKey, opts.Channel, opts.ChatID, detectedDir, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildReminderInjector returns a function that injects all end-of-iteration
|
||||||
|
// reminder messages: task reminders, plan reminders, orch nudges, and
|
||||||
|
// pending subagent questions.
|
||||||
|
func (al *AgentLoop) buildReminderInjector(
|
||||||
|
agent *AgentInstance,
|
||||||
|
opts processOptions,
|
||||||
|
task *activeTask,
|
||||||
|
planSnapshot string,
|
||||||
|
) func(int, *[]providers.Message, string) {
|
||||||
|
lastReminderIdx := -1
|
||||||
|
|
||||||
|
return func(iteration int, messages *[]providers.Message, lastBlocker string) {
|
||||||
|
// Task reminder
|
||||||
|
if shouldInjectReminder(iteration, agent.TaskReminderInterval) && !opts.NoHistory {
|
||||||
|
if lastReminderIdx >= 0 && lastReminderIdx < len(*messages) {
|
||||||
|
*messages = append((*messages)[:lastReminderIdx], (*messages)[lastReminderIdx+1:]...)
|
||||||
|
}
|
||||||
|
reminderMsg := buildTaskReminder(opts.UserMessage, lastBlocker)
|
||||||
|
*messages = append(*messages, reminderMsg)
|
||||||
|
lastReminderIdx = len(*messages) - 1
|
||||||
|
logger.DebugCF("agent", "Injected task reminder",
|
||||||
|
map[string]any{"agent_id": agent.ID, "iteration": iteration, "has_blocker": lastBlocker != ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan reminder
|
||||||
|
if iteration > 1 && isPlanPreExecution(planSnapshot) {
|
||||||
|
if reminder, ok := buildPlanReminder(planSnapshot); ok {
|
||||||
|
*messages = append(*messages, reminder)
|
||||||
|
logger.DebugCF("agent", "Injected plan reminder",
|
||||||
|
map[string]any{"agent_id": agent.ID, "iteration": iteration, "plan_status": planSnapshot})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orch nudge
|
||||||
|
if planSnapshot == "executing" && agent.Subagents != nil && agent.Subagents.Enabled {
|
||||||
|
if reminder, ok := buildOrchReminder(iteration); ok {
|
||||||
|
*messages = append(*messages, reminder)
|
||||||
|
logger.DebugCF("agent", "Injected orchestration nudge",
|
||||||
|
map[string]any{"agent_id": agent.ID, "iteration": iteration})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subagent questions/plan reviews
|
||||||
|
if agent.SubagentMgr != nil {
|
||||||
|
for _, q := range agent.SubagentMgr.PendingQuestions() {
|
||||||
|
var content string
|
||||||
|
switch q.Type {
|
||||||
|
case "plan_review":
|
||||||
|
content = fmt.Sprintf(
|
||||||
|
"[Subagent %s submitted a plan for review]:\n%s\nRespond using the review_subagent_plan tool with task_id=%q.",
|
||||||
|
q.TaskID,
|
||||||
|
q.Content,
|
||||||
|
q.TaskID,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
content = fmt.Sprintf(
|
||||||
|
"[Subagent %s asks]: %s\nRespond using the answer_subagent tool with task_id=%q.",
|
||||||
|
q.TaskID, q.Content, q.TaskID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
*messages = append(*messages, providers.Message{Role: "user", Content: content})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool log trim
|
||||||
|
if task != nil {
|
||||||
|
task.mu.Lock()
|
||||||
|
if len(task.toolLog) > maxToolLogEntries {
|
||||||
|
task.toolLog = task.toolLog[len(task.toolLog)-maxToolLogEntries:]
|
||||||
|
}
|
||||||
|
task.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
281
pkg/agent/loop_info.go
Normal file
281
pkg/agent/loop_info.go
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/stats"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetStartupInfo() map[string]any {
|
||||||
|
info := make(map[string]any)
|
||||||
|
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if agent == nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools info
|
||||||
|
|
||||||
|
toolsList := agent.Tools.List()
|
||||||
|
|
||||||
|
toolsMap := map[string]any{
|
||||||
|
"count": len(toolsList),
|
||||||
|
|
||||||
|
"names": toolsList,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report web search provider if registered
|
||||||
|
|
||||||
|
if t, ok := agent.Tools.Get("web_search"); ok {
|
||||||
|
if wst, ok := t.(*tools.WebSearchTool); ok {
|
||||||
|
toolsMap["web_search_provider"] = wst.ProviderName()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info["tools"] = toolsMap
|
||||||
|
|
||||||
|
// Skills info
|
||||||
|
|
||||||
|
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
|
||||||
|
|
||||||
|
// Agents info
|
||||||
|
|
||||||
|
info["agents"] = map[string]any{
|
||||||
|
"count": len(al.registry.ListAgentIDs()),
|
||||||
|
|
||||||
|
"ids": al.registry.ListAgentIDs(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSkills returns all available skills from the default agent.
|
||||||
|
|
||||||
|
func (al *AgentLoop) ListSkills() []skills.SkillInfo {
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if agent == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent.ContextBuilder.ListSkills()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlanInfo returns plan state from the default agent's memory store.
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetPlanInfo() (hasPlan bool, status string, currentPhase, totalPhases int, display string, memory string) {
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if agent == nil {
|
||||||
|
return false, "", 0, 0, "No agent available.", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
mem := agent.ContextBuilder.Memory()
|
||||||
|
|
||||||
|
if mem == nil {
|
||||||
|
return false, "", 0, 0, "No memory store.", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPlan = mem.HasActivePlan()
|
||||||
|
|
||||||
|
status = mem.GetPlanStatus()
|
||||||
|
|
||||||
|
currentPhase = mem.GetCurrentPhase()
|
||||||
|
|
||||||
|
totalPhases = mem.GetTotalPhases()
|
||||||
|
|
||||||
|
display = mem.FormatPlanDisplay()
|
||||||
|
|
||||||
|
memory = mem.ReadLongTerm()
|
||||||
|
|
||||||
|
return hasPlan, status, currentPhase, totalPhases, display, memory
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlanStatus returns the current plan status ("interviewing", "executing", "review", etc.) or "".
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetPlanStatus() string {
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if agent == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent.ContextBuilder.GetPlanStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlanPhases returns structured phase/step data from the default agent's plan.
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetPlanPhases() []PlanPhase {
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if agent == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mem := agent.ContextBuilder.Memory()
|
||||||
|
|
||||||
|
if mem == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mem.GetPlanPhases()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveSessions returns currently active sessions for the mini app API.
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetActiveSessions() []SessionEntry {
|
||||||
|
return al.sessions.ListActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionStats returns the current session statistics snapshot, or nil if stats tracking is disabled.
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetSessionStats() *stats.Stats {
|
||||||
|
if al.stats == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s := al.stats.GetStats()
|
||||||
|
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContextInfo returns the bootstrap file resolution and directory context for the default agent.
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetContextInfo() (workDir, planWorkDir, workspace string, bootstrap []BootstrapFileInfo) {
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if agent == nil {
|
||||||
|
return "", "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace = agent.Workspace
|
||||||
|
|
||||||
|
planWorkDir = agent.ContextBuilder.GetPlanWorkDir()
|
||||||
|
|
||||||
|
// Use the most recent active session's touch_dir (tool-detected project directory)
|
||||||
|
|
||||||
|
if active := al.sessions.ListActive(); len(active) > 0 && active[0].TouchDir != "" {
|
||||||
|
workDir = active[0].TouchDir
|
||||||
|
} else {
|
||||||
|
workDir = agent.ContextBuilder.workDir
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap = agent.ContextBuilder.ResolveBootstrapPaths()
|
||||||
|
|
||||||
|
return workDir, planWorkDir, workspace, bootstrap
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemPrompt returns the system prompt last sent to the LLM.
|
||||||
|
|
||||||
|
// If the prompt is dirty (state changed since last capture), it rebuilds
|
||||||
|
|
||||||
|
// from current state. Falls back to building if no LLM call has occurred yet.
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetSystemPrompt() string {
|
||||||
|
if !al.promptDirty.Load() {
|
||||||
|
if v := al.lastSystemPrompt.Load(); v != nil {
|
||||||
|
return v.(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild from current state
|
||||||
|
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if agent == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := agent.ContextBuilder.BuildSystemPrompt()
|
||||||
|
|
||||||
|
al.lastSystemPrompt.Store(prompt)
|
||||||
|
|
||||||
|
al.promptDirty.Store(false)
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatMessagesForLog formats messages for logging
|
||||||
|
|
||||||
|
func formatMessagesForLog(messages []providers.Message) string {
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString("[\n")
|
||||||
|
|
||||||
|
for i, msg := range messages {
|
||||||
|
fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role)
|
||||||
|
|
||||||
|
if len(msg.ToolCalls) > 0 {
|
||||||
|
sb.WriteString(" ToolCalls:\n")
|
||||||
|
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
|
||||||
|
|
||||||
|
args := tc.Arguments
|
||||||
|
|
||||||
|
if len(args) == 0 && tc.Function != nil {
|
||||||
|
args = tc.Function.Arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
argsJSON, _ := json.Marshal(args)
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(string(argsJSON), 200))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Content != "" {
|
||||||
|
content := utils.Truncate(msg.Content, 200)
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, " Content: %s\n", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.ToolCallID != "" {
|
||||||
|
fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("]")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatToolsForLog formats tool definitions for logging
|
||||||
|
|
||||||
|
func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
|
||||||
|
if len(toolDefs) == 0 {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString("[\n")
|
||||||
|
|
||||||
|
for i, tool := range toolDefs {
|
||||||
|
fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description)
|
||||||
|
|
||||||
|
if len(tool.Function.Parameters) > 0 {
|
||||||
|
fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(string(tool.Function.Parameters), 200))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("]")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
184
pkg/agent/loop_mcp.go
Normal file
184
pkg/agent/loop_mcp.go
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/mcp"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mcpRuntime struct {
|
||||||
|
initOnce sync.Once
|
||||||
|
mu sync.Mutex
|
||||||
|
manager *mcp.Manager
|
||||||
|
initErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) setManager(manager *mcp.Manager) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.manager = manager
|
||||||
|
r.initErr = nil
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) setInitErr(err error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.initErr = err
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) getInitErr() error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.initErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) takeManager() *mcp.Manager {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
manager := r.manager
|
||||||
|
r.manager = nil
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mcpRuntime) hasManager() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.manager != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct
|
||||||
|
// agent mode share the same initialization path.
|
||||||
|
func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||||
|
if !al.cfg.Tools.IsToolEnabled("mcp") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
al.mcp.initOnce.Do(func() {
|
||||||
|
mcpManager := mcp.NewManager()
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
workspacePath := al.cfg.WorkspacePath()
|
||||||
|
if defaultAgent != nil && defaultAgent.Workspace != "" {
|
||||||
|
workspacePath = defaultAgent.Workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
|
||||||
|
map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
if closeErr := mcpManager.Close(); closeErr != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to close MCP manager",
|
||||||
|
map[string]any{
|
||||||
|
"error": closeErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register MCP tools for all agents
|
||||||
|
servers := mcpManager.GetServers()
|
||||||
|
uniqueTools := 0
|
||||||
|
totalRegistrations := 0
|
||||||
|
agentIDs := al.registry.ListAgentIDs()
|
||||||
|
agentCount := len(agentIDs)
|
||||||
|
|
||||||
|
for serverName, conn := range servers {
|
||||||
|
uniqueTools += len(conn.Tools)
|
||||||
|
for _, tool := range conn.Tools {
|
||||||
|
for _, agentID := range agentIDs {
|
||||||
|
agent, ok := al.registry.GetAgent(agentID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
||||||
|
|
||||||
|
if al.cfg.Tools.MCP.Discovery.Enabled {
|
||||||
|
agent.Tools.RegisterHidden(mcpTool)
|
||||||
|
} else {
|
||||||
|
agent.Tools.Register(mcpTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRegistrations++
|
||||||
|
logger.DebugCF("agent", "Registered MCP tool",
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agentID,
|
||||||
|
"server": serverName,
|
||||||
|
"tool": tool.Name,
|
||||||
|
"name": mcpTool.Name(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.InfoCF("agent", "MCP tools registered successfully",
|
||||||
|
map[string]any{
|
||||||
|
"server_count": len(servers),
|
||||||
|
"unique_tools": uniqueTools,
|
||||||
|
"total_registrations": totalRegistrations,
|
||||||
|
"agent_count": agentCount,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initializes Discovery Tools only if enabled by configuration
|
||||||
|
if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled {
|
||||||
|
useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25
|
||||||
|
useRegex := al.cfg.Tools.MCP.Discovery.UseRegex
|
||||||
|
|
||||||
|
// Fail fast: If discovery is enabled but no search method is turned on
|
||||||
|
if !useBM25 && !useRegex {
|
||||||
|
al.mcp.setInitErr(fmt.Errorf(
|
||||||
|
"tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration",
|
||||||
|
))
|
||||||
|
if closeErr := mcpManager.Close(); closeErr != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to close MCP manager",
|
||||||
|
map[string]any{
|
||||||
|
"error": closeErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := al.cfg.Tools.MCP.Discovery.TTL
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 5 // Default value
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults
|
||||||
|
if maxSearchResults <= 0 {
|
||||||
|
maxSearchResults = 5 // Default value
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Initializing tool discovery", map[string]any{
|
||||||
|
"bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults,
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, agentID := range agentIDs {
|
||||||
|
agent, ok := al.registry.GetAgent(agentID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if useRegex {
|
||||||
|
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
|
||||||
|
}
|
||||||
|
if useBM25 {
|
||||||
|
agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
al.mcp.setManager(mcpManager)
|
||||||
|
})
|
||||||
|
|
||||||
|
return al.mcp.getInitErr()
|
||||||
|
}
|
||||||
340
pkg/agent/loop_orch.go
Normal file
340
pkg/agent/loop_orch.go
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/orch"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (al *AgentLoop) reporter() orch.AgentReporter {
|
||||||
|
if al.orchReporter == nil {
|
||||||
|
return orch.Noop
|
||||||
|
}
|
||||||
|
|
||||||
|
return al.orchReporter
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOrchReporter wires a Broadcaster as the active reporter.
|
||||||
|
|
||||||
|
// Called from cmd_gateway.go when --orchestration is set.
|
||||||
|
|
||||||
|
// --orchestration なし → 呼ばれない → reporter() は Noop を返す。
|
||||||
|
|
||||||
|
func (al *AgentLoop) SetOrchReporter(b *orch.Broadcaster) {
|
||||||
|
al.orchBroadcaster = b
|
||||||
|
|
||||||
|
al.orchReporter = b
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrchBroadcaster returns the concrete Broadcaster for miniapp wiring.
|
||||||
|
|
||||||
|
// Returns nil when orchestration is disabled.
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetOrchBroadcaster() *orch.Broadcaster {
|
||||||
|
return al.orchBroadcaster
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) notifyStateChange() {
|
||||||
|
al.promptDirty.Store(true)
|
||||||
|
|
||||||
|
if al.OnStateChange != nil {
|
||||||
|
al.OnStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
||||||
|
if msg.Channel != "system" {
|
||||||
|
return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Processing system message",
|
||||||
|
|
||||||
|
map[string]any{
|
||||||
|
"sender_id": msg.SenderID,
|
||||||
|
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parse origin channel from chat_id (format: "channel:chat_id")
|
||||||
|
|
||||||
|
var originChannel, originChatID string
|
||||||
|
|
||||||
|
if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
|
||||||
|
originChannel = msg.ChatID[:idx]
|
||||||
|
|
||||||
|
originChatID = msg.ChatID[idx+1:]
|
||||||
|
} else {
|
||||||
|
originChannel = "cli"
|
||||||
|
|
||||||
|
originChatID = msg.ChatID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract subagent result from message content
|
||||||
|
|
||||||
|
// Format: "Task 'label' completed.\n\nResult:\n<actual content>"
|
||||||
|
|
||||||
|
content := msg.Content
|
||||||
|
|
||||||
|
if idx := strings.Index(content, "Result:\n"); idx >= 0 {
|
||||||
|
content = content[idx+8:] // Extract just the result part
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip internal channels - only log, don't send to user
|
||||||
|
|
||||||
|
if constants.IsInternalChannel(originChannel) {
|
||||||
|
logger.InfoCF("agent", "Subagent completed (internal channel)",
|
||||||
|
|
||||||
|
map[string]any{
|
||||||
|
"sender_id": msg.SenderID,
|
||||||
|
|
||||||
|
"content_len": len(content),
|
||||||
|
|
||||||
|
"channel": originChannel,
|
||||||
|
})
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject subagent result into session history without running a full LLM loop.
|
||||||
|
|
||||||
|
// The conductor will see the result on its next turn. This avoids:
|
||||||
|
|
||||||
|
// - Flooding the chat with a response for every subagent completion
|
||||||
|
|
||||||
|
// - Consuming the Telegram "Thinking..." placeholder
|
||||||
|
|
||||||
|
// - Wasting LLM tokens on processing each result individually
|
||||||
|
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if agent == nil {
|
||||||
|
return "", fmt.Errorf("no default agent for system message")
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
|
||||||
|
|
||||||
|
historyMsg := fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content)
|
||||||
|
|
||||||
|
// Write as TurnReport to the store for DAG tracking, with legacy fallback.
|
||||||
|
|
||||||
|
subagentSessionKey := routing.BuildSubagentSessionKey(extractTaskID(msg.SenderID))
|
||||||
|
|
||||||
|
store := agent.Sessions.Store()
|
||||||
|
|
||||||
|
reportTurn := &session.Turn{
|
||||||
|
Kind: session.TurnReport,
|
||||||
|
|
||||||
|
OriginKey: subagentSessionKey,
|
||||||
|
|
||||||
|
Author: msg.SenderID,
|
||||||
|
|
||||||
|
Messages: []providers.Message{{Role: "user", Content: historyMsg}},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Append(sessionKey, reportTurn); err != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to record report turn, falling back to legacy",
|
||||||
|
|
||||||
|
map[string]any{"error": err.Error()})
|
||||||
|
|
||||||
|
agent.Sessions.AddMessage(sessionKey, "user", historyMsg)
|
||||||
|
|
||||||
|
agent.Sessions.MarkDirty(sessionKey)
|
||||||
|
} else {
|
||||||
|
// Update in-memory cache so conductor sees the message on next turn.
|
||||||
|
|
||||||
|
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: historyMsg})
|
||||||
|
|
||||||
|
agent.Sessions.AdvanceStored(sessionKey, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a brief notification (SkipPlaceholder to avoid corrupting status messages)
|
||||||
|
|
||||||
|
label := msg.SenderID
|
||||||
|
|
||||||
|
if idx := strings.LastIndex(label, ":"); idx >= 0 {
|
||||||
|
label = label[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
notification := formatSubagentCompletion(label, msg.Metadata)
|
||||||
|
|
||||||
|
subagentThreadID := 0
|
||||||
|
|
||||||
|
if al.cfg != nil {
|
||||||
|
subagentThreadID = al.cfg.Channels.Telegram.SubagentThreadID
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyChatID := al.withTelegramThread(originChannel, originChatID, subagentThreadID)
|
||||||
|
|
||||||
|
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
|
Channel: originChannel,
|
||||||
|
|
||||||
|
ChatID: notifyChatID,
|
||||||
|
|
||||||
|
Content: notification,
|
||||||
|
|
||||||
|
SkipPlaceholder: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Subagent result injected into session history",
|
||||||
|
|
||||||
|
map[string]any{
|
||||||
|
"sender_id": msg.SenderID,
|
||||||
|
|
||||||
|
"session_key": sessionKey,
|
||||||
|
|
||||||
|
"content_len": len(content),
|
||||||
|
})
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTaskID extracts the task ID from a sender ID like "subagent:subagent-1".
|
||||||
|
|
||||||
|
func extractTaskID(senderID string) string {
|
||||||
|
if idx := strings.LastIndex(senderID, ":"); idx >= 0 {
|
||||||
|
return senderID[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return senderID
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatSubagentCompletion builds the user-facing notification for a completed subagent.
|
||||||
|
|
||||||
|
// If metadata contains duration_ms and tool_calls it produces e.g.:
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// "📋 scout-1 completed (3.2s, 5 tool calls)."
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// Without metadata it falls back to the plain "📋 scout-1 completed." format.
|
||||||
|
|
||||||
|
func formatSubagentCompletion(label string, metadata map[string]string) string {
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
return fmt.Sprintf("📋 %s completed.", label)
|
||||||
|
}
|
||||||
|
|
||||||
|
durationMs, _ := strconv.ParseInt(metadata["duration_ms"], 10, 64)
|
||||||
|
|
||||||
|
toolCalls, _ := strconv.Atoi(metadata["tool_calls"])
|
||||||
|
|
||||||
|
if durationMs <= 0 && toolCalls <= 0 {
|
||||||
|
return fmt.Sprintf("📋 %s completed.", label)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]string, 0, 2)
|
||||||
|
|
||||||
|
if durationMs > 0 {
|
||||||
|
parts = append(parts, formatDurationMs(durationMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
if toolCalls > 0 {
|
||||||
|
if toolCalls == 1 {
|
||||||
|
parts = append(parts, "1 tool call")
|
||||||
|
} else {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d tool calls", toolCalls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("📋 %s completed (%s).", label, strings.Join(parts, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDurationMs converts milliseconds to a human-readable duration string.
|
||||||
|
|
||||||
|
// Examples: 800 → "0.8s", 1200 → "1.2s", 65000 → "1m5s", 3661000 → "61m1s".
|
||||||
|
|
||||||
|
func formatDurationMs(ms int64) string {
|
||||||
|
if ms < 1000 {
|
||||||
|
return fmt.Sprintf("%dms", ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSec := ms / 1000
|
||||||
|
|
||||||
|
if totalSec < 60 {
|
||||||
|
tenths := (ms % 1000) / 100
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d.%ds", totalSec, tenths)
|
||||||
|
}
|
||||||
|
|
||||||
|
mins := totalSec / 60
|
||||||
|
|
||||||
|
sec := totalSec % 60
|
||||||
|
|
||||||
|
if sec == 0 {
|
||||||
|
return fmt.Sprintf("%dm", mins)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%dm%ds", mins, sec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOrchReminder returns a reminder to use spawn/subagent during plan execution.
|
||||||
|
|
||||||
|
// Fires on first iteration and every 3rd iteration to reinforce delegation behavior.
|
||||||
|
|
||||||
|
func buildOrchReminder(iteration int) (providers.Message, bool) {
|
||||||
|
if iteration != 1 && iteration%3 != 0 {
|
||||||
|
return providers.Message{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
content := `[System] ORCHESTRATION mode active. You MUST delegate plan steps to subagents.
|
||||||
|
|
||||||
|
Use spawn (non-blocking, returns immediately) or subagent (blocking, waits for result).
|
||||||
|
|
||||||
|
Do NOT implement steps inline unless they are a single trivial tool call.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
To delegate, call the tool with JSON arguments:
|
||||||
|
|
||||||
|
Tool: spawn Arguments: {"task": "...", "preset": "scout", "label": "..."}
|
||||||
|
|
||||||
|
Tool: subagent Arguments: {"task": "...", "label": "..."}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Spawn multiple independent steps in parallel for maximum throughput.`
|
||||||
|
|
||||||
|
return providers.Message{Role: "user", Content: content}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
|
if msg.Peer.Kind == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
peerID := msg.Peer.ID
|
||||||
|
|
||||||
|
if peerID == "" {
|
||||||
|
if msg.Peer.Kind == "direct" {
|
||||||
|
peerID = msg.SenderID
|
||||||
|
} else {
|
||||||
|
peerID = msg.ChatID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
||||||
|
|
||||||
|
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
|
parentKind := msg.Metadata["parent_peer_kind"]
|
||||||
|
|
||||||
|
parentID := msg.Metadata["parent_peer_id"]
|
||||||
|
|
||||||
|
if parentKind == "" || parentID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &routing.RoutePeer{Kind: parentKind, ID: parentID}
|
||||||
|
}
|
||||||
674
pkg/agent/loop_plan.go
Normal file
674
pkg/agent/loop_plan.go
Normal file
|
|
@ -0,0 +1,674 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/git"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/orch"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// interviewRejectMessage is the fixed rejection text injected when tool calls
|
||||||
|
|
||||||
|
// are blocked during the interview phase. It is deliberately short to avoid
|
||||||
|
|
||||||
|
// wasting tokens, and ends with a purpose reminder to steer the LLM back.
|
||||||
|
|
||||||
|
const interviewRejectMessage = "[System] Tool call rejected. " +
|
||||||
|
|
||||||
|
"You are in interview mode — ask the user questions and update MEMORY.md. " +
|
||||||
|
|
||||||
|
"Do not execute, edit, or write project files."
|
||||||
|
|
||||||
|
// buildPlanReminder returns a reminder message for plan pre-execution states
|
||||||
|
|
||||||
|
// (interviewing / review) to keep the AI focused on the interview workflow
|
||||||
|
|
||||||
|
// during tool-call iterations.
|
||||||
|
|
||||||
|
func buildPlanReminder(planStatus string) (providers.Message, bool) {
|
||||||
|
var content string
|
||||||
|
|
||||||
|
switch planStatus {
|
||||||
|
case "interviewing":
|
||||||
|
|
||||||
|
content = "[System] You are interviewing the user to build a plan. " +
|
||||||
|
|
||||||
|
"Ask clarifying questions and save findings to ## Context in memory/MEMORY.md using edit_file. " +
|
||||||
|
|
||||||
|
"When you have enough information, write ## Phase sections with `- [ ]` checkbox steps, and ## Commands section. " +
|
||||||
|
|
||||||
|
"Then change > Status: to review. Do NOT set it to executing."
|
||||||
|
|
||||||
|
case "review":
|
||||||
|
|
||||||
|
content = "[System] The plan is under review. " +
|
||||||
|
|
||||||
|
"Wait for the user to approve or request changes. Do not proceed with execution."
|
||||||
|
|
||||||
|
default:
|
||||||
|
|
||||||
|
return providers.Message{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers.Message{Role: "user", Content: content}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string, bool) {
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if agent == nil {
|
||||||
|
return "No agent configured.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
// /plan — show current plan
|
||||||
|
|
||||||
|
return agent.ContextBuilder.FormatPlanDisplay(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := args[0]
|
||||||
|
|
||||||
|
switch sub {
|
||||||
|
case "clear":
|
||||||
|
|
||||||
|
if agent.ContextBuilder.ReadMemory() == "" {
|
||||||
|
return "No active plan to clear.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate worktree on plan clear
|
||||||
|
|
||||||
|
if sessionKey != "" {
|
||||||
|
agent.DeactivateWorktree(sessionKey, "", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := agent.ContextBuilder.ClearMemory(); err != nil {
|
||||||
|
return fmt.Sprintf("Error clearing plan: %v", err), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Plan cleared.", true
|
||||||
|
|
||||||
|
case "done":
|
||||||
|
|
||||||
|
if !agent.ContextBuilder.HasActivePlan() {
|
||||||
|
return "No active plan.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) < 2 {
|
||||||
|
return "Usage: /plan done <step number>", true
|
||||||
|
}
|
||||||
|
|
||||||
|
stepNum, err := strconv.Atoi(args[1])
|
||||||
|
|
||||||
|
if err != nil || stepNum < 1 {
|
||||||
|
return "Step number must be a positive integer.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
phase := agent.ContextBuilder.GetCurrentPhase()
|
||||||
|
|
||||||
|
if err := agent.ContextBuilder.MarkStep(phase, stepNum); err != nil {
|
||||||
|
return fmt.Sprintf("Error: %v", err), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Marked step %d in phase %d as done.", stepNum, phase), true
|
||||||
|
|
||||||
|
case "add":
|
||||||
|
|
||||||
|
if !agent.ContextBuilder.HasActivePlan() {
|
||||||
|
return "No active plan.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) < 2 {
|
||||||
|
return "Usage: /plan add <step description>", true
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := strings.Join(args[1:], " ")
|
||||||
|
|
||||||
|
phase := agent.ContextBuilder.GetCurrentPhase()
|
||||||
|
|
||||||
|
if err := agent.ContextBuilder.AddStep(phase, desc); err != nil {
|
||||||
|
return fmt.Sprintf("Error: %v", err), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Added step to phase %d: %s", phase, desc), true
|
||||||
|
|
||||||
|
case "start":
|
||||||
|
|
||||||
|
if !agent.ContextBuilder.HasActivePlan() {
|
||||||
|
return "No active plan.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
status := agent.ContextBuilder.GetPlanStatus()
|
||||||
|
|
||||||
|
if status == "executing" {
|
||||||
|
return "Plan is already executing.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "interviewing" && status != "review" {
|
||||||
|
return fmt.Sprintf("Cannot start from status %q.", status), true
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent.ContextBuilder.GetTotalPhases() == 0 {
|
||||||
|
return "Cannot start: no phases defined yet. Complete the interview first.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil {
|
||||||
|
return fmt.Sprintf("Error: %v", err), true
|
||||||
|
}
|
||||||
|
|
||||||
|
al.reporter().ReportStateChange(sessionKey, orch.AgentStatePlanExecuting, "")
|
||||||
|
|
||||||
|
al.planStartPending = true
|
||||||
|
|
||||||
|
clearHistory := len(args) > 1 && args[1] == "clear"
|
||||||
|
|
||||||
|
al.planClearHistory = clearHistory
|
||||||
|
|
||||||
|
if clearHistory {
|
||||||
|
return "Plan approved. Executing with clean history.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Plan approved. Executing.", true
|
||||||
|
|
||||||
|
case "next":
|
||||||
|
|
||||||
|
if !agent.ContextBuilder.HasActivePlan() {
|
||||||
|
return "No active plan.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := agent.ContextBuilder.AdvancePhase(); err != nil {
|
||||||
|
return fmt.Sprintf("Error: %v", err), true
|
||||||
|
}
|
||||||
|
|
||||||
|
phase := agent.ContextBuilder.GetCurrentPhase()
|
||||||
|
|
||||||
|
return fmt.Sprintf("Advanced to phase %d.", phase), true
|
||||||
|
|
||||||
|
case "worktrees":
|
||||||
|
|
||||||
|
return al.handlePlanWorktreesCommand(agent, args[1:]), true
|
||||||
|
|
||||||
|
default:
|
||||||
|
|
||||||
|
// /plan <task description> — start new plan
|
||||||
|
|
||||||
|
// Block if a plan is already active (fast-path error).
|
||||||
|
|
||||||
|
if agent.ContextBuilder.HasActivePlan() {
|
||||||
|
return "A plan is already active. Use /plan clear first.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not handled here — let the message flow to the LLM queue.
|
||||||
|
|
||||||
|
// expandPlanCommand will write the seed and rewrite the content.
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) handlePlanWorktreesCommand(agent *AgentInstance, args []string) string {
|
||||||
|
repoRoot := git.FindRepoRoot(agent.Workspace)
|
||||||
|
|
||||||
|
if repoRoot == "" {
|
||||||
|
return "Workspace is not a git repository."
|
||||||
|
}
|
||||||
|
|
||||||
|
worktreesDir := filepath.Join(agent.Workspace, ".worktrees")
|
||||||
|
|
||||||
|
sub := "list"
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
sub = strings.ToLower(strings.TrimSpace(args[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sub {
|
||||||
|
case "", "list":
|
||||||
|
|
||||||
|
items, err := git.ListManagedWorktrees(repoRoot, worktreesDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("Error listing worktrees: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "No active worktrees in workspace/.worktrees."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString("Active worktrees\n\n")
|
||||||
|
|
||||||
|
for _, wt := range items {
|
||||||
|
status := "clean"
|
||||||
|
|
||||||
|
if wt.HasUncommitted {
|
||||||
|
status = "dirty"
|
||||||
|
}
|
||||||
|
|
||||||
|
last := "(no commits)"
|
||||||
|
|
||||||
|
if wt.LastCommitHash != "" {
|
||||||
|
if wt.LastCommitAge != "" {
|
||||||
|
last = fmt.Sprintf("%s %s (%s)", wt.LastCommitHash, wt.LastCommitSubject, wt.LastCommitAge)
|
||||||
|
} else {
|
||||||
|
last = fmt.Sprintf("%s %s", wt.LastCommitHash, wt.LastCommitSubject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, "- %s\n branch: %s\n status: %s\n last: %s\n", wt.Name, wt.Branch, status, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\nCommands:\n")
|
||||||
|
|
||||||
|
sb.WriteString("/plan worktrees inspect <name>\n")
|
||||||
|
|
||||||
|
sb.WriteString("/plan worktrees merge <name>\n")
|
||||||
|
|
||||||
|
sb.WriteString("/plan worktrees dispose <name> [force]")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
|
||||||
|
case "inspect":
|
||||||
|
|
||||||
|
if len(args) < 2 {
|
||||||
|
return "Usage: /plan worktrees inspect <name>"
|
||||||
|
}
|
||||||
|
|
||||||
|
name := args[1]
|
||||||
|
|
||||||
|
wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, git.ErrInvalidWorktreeName) {
|
||||||
|
return "Invalid worktree name."
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, git.ErrWorktreeNotFound) {
|
||||||
|
return fmt.Sprintf("Worktree %q not found.", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Error inspecting worktree %q: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusOut, _ := git.WorktreeStatusShort(wt.Path)
|
||||||
|
|
||||||
|
diffOut, _ := git.WorktreeDiffStat(wt.Path)
|
||||||
|
|
||||||
|
logOut, _ := git.WorktreeRecentLog(wt.Path, 10)
|
||||||
|
|
||||||
|
if statusOut == "" {
|
||||||
|
statusOut = "(clean)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, "Worktree: %s\nBranch: %s\nDirty: %t\n", wt.Name, wt.Branch, wt.HasUncommitted)
|
||||||
|
|
||||||
|
if wt.LastCommitHash != "" {
|
||||||
|
fmt.Fprintf(&sb, "Last commit: %s %s", wt.LastCommitHash, wt.LastCommitSubject)
|
||||||
|
|
||||||
|
if wt.LastCommitAge != "" {
|
||||||
|
fmt.Fprintf(&sb, " (%s)", wt.LastCommitAge)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\nStatus:\n```\n")
|
||||||
|
|
||||||
|
sb.WriteString(statusOut)
|
||||||
|
|
||||||
|
sb.WriteString("\n```\n")
|
||||||
|
|
||||||
|
if diffOut != "" {
|
||||||
|
sb.WriteString("\nDiff (stat):\n```\n")
|
||||||
|
|
||||||
|
sb.WriteString(diffOut)
|
||||||
|
|
||||||
|
sb.WriteString("\n```\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if logOut != "" {
|
||||||
|
sb.WriteString("\nRecent commits:\n```\n")
|
||||||
|
|
||||||
|
sb.WriteString(logOut)
|
||||||
|
|
||||||
|
sb.WriteString("\n```")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
|
||||||
|
case "merge":
|
||||||
|
|
||||||
|
if len(args) < 2 {
|
||||||
|
return "Usage: /plan worktrees merge <name>"
|
||||||
|
}
|
||||||
|
|
||||||
|
name := args[1]
|
||||||
|
|
||||||
|
res, base, err := git.MergeManagedWorktree(repoRoot, worktreesDir, name, "")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, git.ErrInvalidWorktreeName) {
|
||||||
|
return "Invalid worktree name."
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, git.ErrWorktreeNotFound) {
|
||||||
|
return fmt.Sprintf("Worktree %q not found.", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Error merging worktree %q: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Conflict {
|
||||||
|
return fmt.Sprintf("Merge conflict while merging `%s` into `%s`. Merge was aborted.", res.Branch, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Merged {
|
||||||
|
return fmt.Sprintf("Merged `%s` into `%s`.", res.Branch, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("No merge was performed for `%s`.", name)
|
||||||
|
|
||||||
|
case "dispose":
|
||||||
|
|
||||||
|
if len(args) < 2 {
|
||||||
|
return "Usage: /plan worktrees dispose <name> [force]"
|
||||||
|
}
|
||||||
|
|
||||||
|
name := args[1]
|
||||||
|
|
||||||
|
force := len(args) > 2 && strings.EqualFold(args[2], "force")
|
||||||
|
|
||||||
|
wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, git.ErrInvalidWorktreeName) {
|
||||||
|
return "Invalid worktree name."
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, git.ErrWorktreeNotFound) {
|
||||||
|
return fmt.Sprintf("Worktree %q not found.", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Error disposing worktree %q: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wt.HasUncommitted && !force {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
|
||||||
|
"Worktree `%s` has uncommitted changes. Re-run with `/plan worktrees dispose %s force` to confirm.",
|
||||||
|
|
||||||
|
name,
|
||||||
|
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := git.DisposeManagedWorktree(repoRoot, worktreesDir, name, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("Error disposing worktree %q: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := []string{fmt.Sprintf("Disposed worktree `%s` (branch `%s`).", name, res.Branch)}
|
||||||
|
|
||||||
|
if res.AutoCommitted {
|
||||||
|
parts = append(parts, "Uncommitted changes were auto-committed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.CommitsAhead > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("Branch has %d unique commit(s); branch was kept.", res.CommitsAhead))
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.BranchDeleted {
|
||||||
|
parts = append(parts, "Branch was deleted (no unique commits).")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Usage: /plan worktrees [list|inspect <name>|merge <name>|dispose <name> [force]]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlanPreExecution returns true if the plan is in a pre-execution state
|
||||||
|
|
||||||
|
// (interviewing or review) where tool restrictions and iteration caps apply.
|
||||||
|
|
||||||
|
func isPlanPreExecution(status string) bool {
|
||||||
|
return status == "interviewing" || status == "review"
|
||||||
|
}
|
||||||
|
|
||||||
|
// interviewAllowedTools is the single source of truth for tool names that may
|
||||||
|
|
||||||
|
// be sent to the LLM (and subsequently invoked) during the interview phase.
|
||||||
|
|
||||||
|
// filterInterviewTools uses this to strip tool *definitions* before the LLM call,
|
||||||
|
|
||||||
|
// while isToolAllowedDuringInterview adds argument-level checks as a second gate.
|
||||||
|
|
||||||
|
var interviewAllowedTools = map[string]bool{
|
||||||
|
"readfile": true,
|
||||||
|
|
||||||
|
"listdir": true,
|
||||||
|
|
||||||
|
"websearch": true,
|
||||||
|
|
||||||
|
"webfetch": true,
|
||||||
|
|
||||||
|
"message": true,
|
||||||
|
|
||||||
|
"editfile": true,
|
||||||
|
|
||||||
|
"appendfile": true,
|
||||||
|
|
||||||
|
"writefile": true,
|
||||||
|
|
||||||
|
"exec": true,
|
||||||
|
|
||||||
|
"logs": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterInterviewTools removes tool definitions that are not in the
|
||||||
|
|
||||||
|
// interviewAllowedTools whitelist, reducing token usage and preventing the
|
||||||
|
|
||||||
|
// LLM from attempting disallowed tool calls during the interview phase.
|
||||||
|
|
||||||
|
func filterInterviewTools(defs []providers.ToolDefinition) []providers.ToolDefinition {
|
||||||
|
filtered := make([]providers.ToolDefinition, 0, len(defs))
|
||||||
|
|
||||||
|
for _, d := range defs {
|
||||||
|
if interviewAllowedTools[tools.NormalizeToolName(d.Function.Name)] {
|
||||||
|
filtered = append(filtered, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// isToolAllowedDuringInterview checks whether a tool call is permitted while the
|
||||||
|
|
||||||
|
// plan is in a pre-execution state. Uses the shared interviewAllowedTools map for
|
||||||
|
|
||||||
|
// name-level gating, then applies argument-level constraints for write-type tools
|
||||||
|
|
||||||
|
// (MEMORY.md only) and exec (read-only commands only).
|
||||||
|
|
||||||
|
func isToolAllowedDuringInterview(toolName string, args map[string]any) bool {
|
||||||
|
norm := tools.NormalizeToolName(toolName)
|
||||||
|
|
||||||
|
if !interviewAllowedTools[norm] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Argument-level constraints
|
||||||
|
|
||||||
|
switch norm {
|
||||||
|
case "editfile", "appendfile", "writefile":
|
||||||
|
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
|
||||||
|
return strings.HasSuffix(path, "MEMORY.md")
|
||||||
|
|
||||||
|
case "exec":
|
||||||
|
|
||||||
|
cmd, _ := args["command"].(string)
|
||||||
|
|
||||||
|
return isReadOnlyCommand(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isReadOnlyCommand returns true when cmd is a safe, read-only shell command
|
||||||
|
|
||||||
|
// that an LLM may run during the interview phase.
|
||||||
|
|
||||||
|
func isReadOnlyCommand(cmd string) bool {
|
||||||
|
cmd = strings.TrimSpace(cmd)
|
||||||
|
|
||||||
|
if cmd == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject write operators anywhere in the command
|
||||||
|
|
||||||
|
for _, op := range []string{">", ">>", "| tee "} {
|
||||||
|
if strings.Contains(cmd, op) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject path traversal (defense in depth; ExecTool.guardCommand also enforces workspace restriction)
|
||||||
|
|
||||||
|
if strings.Contains(cmd, "..") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block absolute paths in arguments (allow "cd /path && cmd" which is stripped later)
|
||||||
|
|
||||||
|
for _, field := range strings.Fields(cmd) {
|
||||||
|
if strings.HasPrefix(field, "/") && !strings.HasPrefix(cmd, "cd ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip "cd /path &&" prefix (LLM habit)
|
||||||
|
|
||||||
|
if strings.HasPrefix(cmd, "cd ") {
|
||||||
|
if idx := strings.Index(cmd, "&&"); idx >= 0 {
|
||||||
|
cmd = strings.TrimSpace(cmd[idx+2:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Fields(cmd)
|
||||||
|
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
first := filepath.Base(fields[0])
|
||||||
|
|
||||||
|
switch first {
|
||||||
|
case "find", "ls", "cat", "head", "tail", "grep", "rg",
|
||||||
|
|
||||||
|
"tree", "wc", "file", "which", "pwd",
|
||||||
|
|
||||||
|
"uname", "df", "du", "stat", "realpath", "dirname",
|
||||||
|
|
||||||
|
"basename", "date":
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWriteTool returns true if the tool can modify files.
|
||||||
|
|
||||||
|
func isWriteTool(name string) bool {
|
||||||
|
switch tools.NormalizeToolName(name) {
|
||||||
|
case "writefile", "editfile", "appendfile", "exec":
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandPlanCommand detects "/plan <task>" (new plan start) and:
|
||||||
|
|
||||||
|
// - writes the interview seed to MEMORY.md
|
||||||
|
|
||||||
|
// - rewrites the message content for the LLM
|
||||||
|
|
||||||
|
// - returns a compact form for session history
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// This follows the same pattern as expandSkillCommand: the message is
|
||||||
|
|
||||||
|
// rewritten before reaching the LLM, so the AI sees the task description
|
||||||
|
|
||||||
|
// while the system prompt contains the interview guide.
|
||||||
|
|
||||||
|
func (al *AgentLoop) expandPlanCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) {
|
||||||
|
content := strings.TrimSpace(msg.Content)
|
||||||
|
|
||||||
|
if !strings.HasPrefix(content, "/plan ") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
task := strings.TrimSpace(content[6:]) // len("/plan ") == 6
|
||||||
|
|
||||||
|
if task == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known subcommands are handled by handlePlanCommand (fast path).
|
||||||
|
|
||||||
|
firstWord := strings.Fields(task)[0]
|
||||||
|
|
||||||
|
switch firstWord {
|
||||||
|
case "clear", "done", "add", "start", "next", "worktrees":
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
|
||||||
|
if agent == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a plan is already active, don't expand — handleCommand will
|
||||||
|
|
||||||
|
// catch it and return the error on the fast path.
|
||||||
|
|
||||||
|
if agent.ContextBuilder.HasActivePlan() {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the interview seed
|
||||||
|
|
||||||
|
seed := BuildInterviewSeed(task, agent.Workspace)
|
||||||
|
|
||||||
|
if err := agent.ContextBuilder.WriteMemory(seed); err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
al.notifyStateChange()
|
||||||
|
|
||||||
|
// Expanded: the task description goes to LLM.
|
||||||
|
|
||||||
|
// The system prompt already contains the interview guide.
|
||||||
|
|
||||||
|
expanded = task
|
||||||
|
|
||||||
|
compact = fmt.Sprintf("[Plan: %s]", utils.Truncate(task, 80))
|
||||||
|
|
||||||
|
return expanded, compact, true
|
||||||
|
}
|
||||||
393
pkg/agent/loop_session.go
Normal file
393
pkg/agent/loop_session.go
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionSemaphore is a per-session mutex using a buffered channel.
|
||||||
|
|
||||||
|
type sessionSemaphore struct {
|
||||||
|
ch chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSessionSemaphore() *sessionSemaphore {
|
||||||
|
s := &sessionSemaphore{ch: make(chan struct{}, 1)}
|
||||||
|
|
||||||
|
s.ch <- struct{}{} // initially unlocked
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) gcLoop() {
|
||||||
|
ticker := time.NewTicker(30 * time.Minute)
|
||||||
|
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
|
||||||
|
al.gcSessionLocks()
|
||||||
|
|
||||||
|
case <-al.done:
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gcSessionLocks removes unlocked (idle) sessionSemaphore entries from the map.
|
||||||
|
|
||||||
|
func (al *AgentLoop) gcSessionLocks() {
|
||||||
|
al.sessionLocks.Range(func(key, val any) bool {
|
||||||
|
sem := val.(*sessionSemaphore)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-sem.ch:
|
||||||
|
|
||||||
|
// Was unlocked — safe to remove
|
||||||
|
|
||||||
|
al.sessionLocks.Delete(key)
|
||||||
|
|
||||||
|
default:
|
||||||
|
|
||||||
|
// Currently locked — in use, keep
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) acquireSessionLock(ctx context.Context, sessionKey string) bool {
|
||||||
|
val, _ := al.sessionLocks.LoadOrStore(sessionKey, newSessionSemaphore())
|
||||||
|
|
||||||
|
sem := val.(*sessionSemaphore)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-sem.ch:
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
case <-ctx.Done():
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseSessionLock releases the per-session semaphore.
|
||||||
|
|
||||||
|
func (al *AgentLoop) releaseSessionLock(sessionKey string) {
|
||||||
|
if val, ok := al.sessionLocks.Load(sessionKey); ok {
|
||||||
|
sem := val.(*sessionSemaphore)
|
||||||
|
|
||||||
|
sem.ch <- struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
|
||||||
|
newHistory := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
|
||||||
|
tokenEstimate := al.estimateTokens(newHistory)
|
||||||
|
|
||||||
|
threshold := agent.ContextWindow * 75 / 100
|
||||||
|
|
||||||
|
if len(newHistory) > 20 || tokenEstimate > threshold {
|
||||||
|
summarizeKey := agent.ID + ":" + sessionKey
|
||||||
|
|
||||||
|
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
|
||||||
|
go func() {
|
||||||
|
defer al.summarizing.Delete(summarizeKey)
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Memory threshold reached, optimizing conversation history",
|
||||||
|
|
||||||
|
map[string]any{
|
||||||
|
"session_key": sessionKey,
|
||||||
|
|
||||||
|
"history_len": len(newHistory),
|
||||||
|
|
||||||
|
"token_estimate": tokenEstimate,
|
||||||
|
})
|
||||||
|
|
||||||
|
al.summarizeSession(agent, sessionKey)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// forceCompression aggressively reduces context when the limit is hit.
|
||||||
|
|
||||||
|
// It drops the oldest 50% of messages (keeping system prompt and last user message).
|
||||||
|
|
||||||
|
func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
|
||||||
|
history := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
|
||||||
|
if len(history) <= 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep system prompt (usually [0]) and the very last message (user's trigger)
|
||||||
|
|
||||||
|
// We want to drop the oldest half of the *conversation*
|
||||||
|
|
||||||
|
// Assuming [0] is system, [1:] is conversation
|
||||||
|
|
||||||
|
conversation := history[1 : len(history)-1]
|
||||||
|
|
||||||
|
if len(conversation) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to find the mid-point of the conversation
|
||||||
|
|
||||||
|
mid := len(conversation) / 2
|
||||||
|
|
||||||
|
// New history structure:
|
||||||
|
|
||||||
|
// 1. System Prompt (with compression note appended)
|
||||||
|
|
||||||
|
// 2. Second half of conversation
|
||||||
|
|
||||||
|
// 3. Last message
|
||||||
|
|
||||||
|
droppedCount := mid
|
||||||
|
|
||||||
|
keptConversation := conversation[mid:]
|
||||||
|
|
||||||
|
newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1)
|
||||||
|
|
||||||
|
// Append compression note to the original system prompt instead of adding a new system message
|
||||||
|
|
||||||
|
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
|
||||||
|
|
||||||
|
compressionNote := fmt.Sprintf(
|
||||||
|
|
||||||
|
"\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]",
|
||||||
|
|
||||||
|
droppedCount,
|
||||||
|
)
|
||||||
|
|
||||||
|
enhancedSystemPrompt := history[0]
|
||||||
|
|
||||||
|
enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
|
||||||
|
|
||||||
|
newHistory = append(newHistory, enhancedSystemPrompt)
|
||||||
|
|
||||||
|
newHistory = append(newHistory, keptConversation...)
|
||||||
|
|
||||||
|
newHistory = append(newHistory, history[len(history)-1]) // Last message
|
||||||
|
|
||||||
|
// Update session
|
||||||
|
|
||||||
|
agent.Sessions.SetHistory(sessionKey, newHistory)
|
||||||
|
|
||||||
|
agent.Sessions.Save(sessionKey)
|
||||||
|
|
||||||
|
logger.WarnCF("agent", "Forced compression executed", map[string]any{
|
||||||
|
"session_key": sessionKey,
|
||||||
|
|
||||||
|
"dropped_msgs": droppedCount,
|
||||||
|
|
||||||
|
"new_count": len(newHistory),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
history := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
|
||||||
|
summary := agent.Sessions.GetSummary(sessionKey)
|
||||||
|
|
||||||
|
// Keep last 4 messages for continuity
|
||||||
|
|
||||||
|
if len(history) <= 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toSummarize := history[:len(history)-4]
|
||||||
|
|
||||||
|
// Oversized Message Guard
|
||||||
|
|
||||||
|
maxMessageTokens := agent.ContextWindow / 2
|
||||||
|
|
||||||
|
validMessages := make([]providers.Message, 0)
|
||||||
|
|
||||||
|
omitted := false
|
||||||
|
|
||||||
|
for _, m := range toSummarize {
|
||||||
|
if m.Role != "user" && m.Role != "assistant" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msgTokens := len(m.Content) / 2
|
||||||
|
|
||||||
|
if msgTokens > maxMessageTokens {
|
||||||
|
omitted = true
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
validMessages = append(validMessages, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(validMessages) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-Part Summarization
|
||||||
|
|
||||||
|
var finalSummary string
|
||||||
|
|
||||||
|
if len(validMessages) > 10 {
|
||||||
|
mid := len(validMessages) / 2
|
||||||
|
|
||||||
|
part1 := validMessages[:mid]
|
||||||
|
|
||||||
|
part2 := validMessages[mid:]
|
||||||
|
|
||||||
|
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
|
||||||
|
|
||||||
|
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
|
||||||
|
|
||||||
|
mergePrompt := fmt.Sprintf(
|
||||||
|
|
||||||
|
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
|
||||||
|
|
||||||
|
s1,
|
||||||
|
|
||||||
|
s2,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := agent.Provider.Chat(
|
||||||
|
|
||||||
|
ctx,
|
||||||
|
|
||||||
|
[]providers.Message{{Role: "user", Content: mergePrompt}},
|
||||||
|
|
||||||
|
nil,
|
||||||
|
|
||||||
|
agent.Model,
|
||||||
|
|
||||||
|
map[string]any{
|
||||||
|
"max_tokens": 1024,
|
||||||
|
|
||||||
|
"temperature": 0.3,
|
||||||
|
|
||||||
|
"prompt_cache_key": agent.ID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
finalSummary = resp.Content
|
||||||
|
} else {
|
||||||
|
finalSummary = s1 + " " + s2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if omitted && finalSummary != "" {
|
||||||
|
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalSummary != "" {
|
||||||
|
if err := agent.Sessions.CompactOldTurns(sessionKey, 4, finalSummary); err != nil {
|
||||||
|
logger.ErrorCF("agent", "CompactOldTurns failed, falling back",
|
||||||
|
|
||||||
|
map[string]any{"error": err.Error()})
|
||||||
|
|
||||||
|
agent.Sessions.SetSummary(sessionKey, finalSummary)
|
||||||
|
|
||||||
|
agent.Sessions.TruncateHistory(sessionKey, 4)
|
||||||
|
|
||||||
|
agent.Sessions.Save(sessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeBatch summarizes a batch of messages.
|
||||||
|
|
||||||
|
func (al *AgentLoop) summarizeBatch(
|
||||||
|
ctx context.Context,
|
||||||
|
|
||||||
|
agent *AgentInstance,
|
||||||
|
|
||||||
|
batch []providers.Message,
|
||||||
|
|
||||||
|
existingSummary string,
|
||||||
|
) (string, error) {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n")
|
||||||
|
|
||||||
|
if agent.ContextBuilder.HasActivePlan() {
|
||||||
|
sb.WriteString("Note: Active plan in MEMORY.md. Preserve plan progress references.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingSummary != "" {
|
||||||
|
sb.WriteString("Existing context: ")
|
||||||
|
|
||||||
|
sb.WriteString(existingSummary)
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\nCONVERSATION:\n")
|
||||||
|
|
||||||
|
for _, m := range batch {
|
||||||
|
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := sb.String()
|
||||||
|
|
||||||
|
response, err := agent.Provider.Chat(
|
||||||
|
|
||||||
|
ctx,
|
||||||
|
|
||||||
|
[]providers.Message{{Role: "user", Content: prompt}},
|
||||||
|
|
||||||
|
nil,
|
||||||
|
|
||||||
|
agent.Model,
|
||||||
|
|
||||||
|
map[string]any{
|
||||||
|
"max_tokens": 1024,
|
||||||
|
|
||||||
|
"temperature": 0.3,
|
||||||
|
|
||||||
|
"prompt_cache_key": agent.ID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// estimateTokens estimates the number of tokens in a message list.
|
||||||
|
|
||||||
|
// Uses a safe heuristic of 2.5 characters per token to account for CJK and other
|
||||||
|
|
||||||
|
// overheads better than the previous 3 chars/token.
|
||||||
|
|
||||||
|
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
|
||||||
|
totalChars := 0
|
||||||
|
|
||||||
|
for _, m := range messages {
|
||||||
|
totalChars += utf8.RuneCountInString(m.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.5 chars per token = totalChars * 2 / 5
|
||||||
|
|
||||||
|
return totalChars * 2 / 5
|
||||||
|
}
|
||||||
317
pkg/agent/loop_streaming.go
Normal file
317
pkg/agent/loop_streaming.go
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) {
|
||||||
|
if reasoningContent == "" || channelName == "" || channelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check context cancellation before attempting to publish,
|
||||||
|
|
||||||
|
// since PublishOutbound's select may race between send and ctx.Done().
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a short timeout so the goroutine does not block indefinitely when
|
||||||
|
|
||||||
|
// the outbound bus is full. Reasoning output is best-effort; dropping it
|
||||||
|
|
||||||
|
// is acceptable to avoid goroutine accumulation.
|
||||||
|
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
|
||||||
|
defer pubCancel()
|
||||||
|
|
||||||
|
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
|
Channel: channelName,
|
||||||
|
|
||||||
|
ChatID: channelID,
|
||||||
|
|
||||||
|
Content: reasoningContent,
|
||||||
|
}); err != nil {
|
||||||
|
// Treat context.DeadlineExceeded / context.Canceled as expected
|
||||||
|
|
||||||
|
// (bus full under load, or parent canceled). Check the error
|
||||||
|
|
||||||
|
// itself rather than ctx.Err(), because pubCtx may time out
|
||||||
|
|
||||||
|
// (5 s) while the parent ctx is still active.
|
||||||
|
|
||||||
|
// Also treat ErrBusClosed as expected — it occurs during normal
|
||||||
|
|
||||||
|
// shutdown when the bus is closed before all goroutines finish.
|
||||||
|
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
|
||||||
|
|
||||||
|
errors.Is(err, bus.ErrBusClosed) {
|
||||||
|
logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{
|
||||||
|
"channel": channelName,
|
||||||
|
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{
|
||||||
|
"channel": channelName,
|
||||||
|
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamingReasoningLines is the number of lines reserved for reasoning
|
||||||
|
|
||||||
|
// in the streaming display. The remaining lines go to content.
|
||||||
|
|
||||||
|
const streamingReasoningLines = 6
|
||||||
|
|
||||||
|
// buildStreamingDisplay builds a fixed-height status bubble for streaming.
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// Layout when reasoning is active (reasoning only or both):
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// 🧠 Thinking...
|
||||||
|
|
||||||
|
// ━━━━━━━━━━
|
||||||
|
|
||||||
|
// <reasoning tail — streamingReasoningLines lines>
|
||||||
|
|
||||||
|
// ━━━━━━━━━━
|
||||||
|
|
||||||
|
// <content tail — remaining lines> (or blank if content is empty)
|
||||||
|
|
||||||
|
// █
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// Layout when no reasoning (content only):
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// <content tail — streamingDisplayLines lines>
|
||||||
|
|
||||||
|
// █
|
||||||
|
|
||||||
|
func buildStreamingDisplay(content, reasoning string) string {
|
||||||
|
if reasoning == "" {
|
||||||
|
// No reasoning — full window for content.
|
||||||
|
|
||||||
|
return utils.TailPad(content, streamingDisplayLines, maxEntryLineWidth) + " \u2589"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Header
|
||||||
|
|
||||||
|
if content == "" {
|
||||||
|
sb.WriteString("\U0001f9e0 Thinking...\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\U0001f9e0 Thought, now responding...\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(statusSeparator)
|
||||||
|
|
||||||
|
// Reasoning window
|
||||||
|
|
||||||
|
headerLines := 2 // header + separator
|
||||||
|
|
||||||
|
footerLines := 1 // separator before content
|
||||||
|
|
||||||
|
contentLines := streamingDisplayLines - headerLines - footerLines - streamingReasoningLines
|
||||||
|
|
||||||
|
if contentLines < 3 {
|
||||||
|
contentLines = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
rLines := streamingDisplayLines - headerLines - footerLines - contentLines
|
||||||
|
|
||||||
|
sb.WriteString(utils.TailPad(reasoning, rLines, maxEntryLineWidth))
|
||||||
|
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
|
||||||
|
sb.WriteString(statusSeparator)
|
||||||
|
|
||||||
|
// Content window (may be blank padding if content hasn't started)
|
||||||
|
|
||||||
|
sb.WriteString(utils.TailPad(content, contentLines, maxEntryLineWidth))
|
||||||
|
|
||||||
|
sb.WriteString(" \u2589")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeStreamWithRepetitionDetection reads StreamEvents from ch, accumulates
|
||||||
|
|
||||||
|
// content and tool calls, and runs repetition detection every checkInterval runes.
|
||||||
|
|
||||||
|
// If repetition is detected, cancelFn is called to abort the HTTP request and
|
||||||
|
|
||||||
|
// the function returns the partial response with detected=true.
|
||||||
|
|
||||||
|
func consumeStreamWithRepetitionDetection(
|
||||||
|
ch <-chan protocoltypes.StreamEvent,
|
||||||
|
|
||||||
|
cancelFn context.CancelFunc,
|
||||||
|
|
||||||
|
checkInterval int,
|
||||||
|
|
||||||
|
onChunk func(content, reasoning string),
|
||||||
|
) (*providers.LLMResponse, bool, error) {
|
||||||
|
var content strings.Builder
|
||||||
|
|
||||||
|
var reasoning strings.Builder
|
||||||
|
|
||||||
|
var toolCalls []streamToolCallAcc
|
||||||
|
|
||||||
|
var finishReason string
|
||||||
|
|
||||||
|
var usage *providers.UsageInfo
|
||||||
|
|
||||||
|
runesSinceLastCheck := 0
|
||||||
|
|
||||||
|
for ev := range ch {
|
||||||
|
if ev.Err != nil {
|
||||||
|
return nil, false, ev.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := false
|
||||||
|
|
||||||
|
if ev.ContentDelta != "" {
|
||||||
|
content.WriteString(ev.ContentDelta)
|
||||||
|
|
||||||
|
runesSinceLastCheck += utf8.RuneCountInString(ev.ContentDelta)
|
||||||
|
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ev.ReasoningDelta != "" {
|
||||||
|
reasoning.WriteString(ev.ReasoningDelta)
|
||||||
|
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated && onChunk != nil {
|
||||||
|
onChunk(content.String(), reasoning.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if ev.FinishReason != "" {
|
||||||
|
finishReason = ev.FinishReason
|
||||||
|
}
|
||||||
|
|
||||||
|
if ev.Usage != nil {
|
||||||
|
usage = ev.Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range ev.ToolCallDeltas {
|
||||||
|
for len(toolCalls) <= tc.Index {
|
||||||
|
toolCalls = append(toolCalls, streamToolCallAcc{})
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.ID != "" {
|
||||||
|
toolCalls[tc.Index].id = tc.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.Name != "" {
|
||||||
|
toolCalls[tc.Index].name = tc.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls[tc.Index].args.WriteString(tc.ArgumentsDelta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run repetition detection periodically on accumulated content.
|
||||||
|
|
||||||
|
if runesSinceLastCheck >= checkInterval && content.Len() > 2000 {
|
||||||
|
runesSinceLastCheck = 0
|
||||||
|
|
||||||
|
if utils.DetectRepetitionLoop(content.String()) {
|
||||||
|
cancelFn()
|
||||||
|
|
||||||
|
// Drain remaining events so the producer goroutine can exit.
|
||||||
|
|
||||||
|
for range ch {
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildAccumulatedResponse(content.String(), reasoning.String(), toolCalls, finishReason, usage)
|
||||||
|
|
||||||
|
return resp, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildAccumulatedResponse(content.String(), reasoning.String(), toolCalls, finishReason, usage)
|
||||||
|
|
||||||
|
return resp, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamToolCallAcc accumulates streamed tool call fragments.
|
||||||
|
|
||||||
|
type streamToolCallAcc struct {
|
||||||
|
id string
|
||||||
|
|
||||||
|
name string
|
||||||
|
|
||||||
|
args strings.Builder
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAccumulatedResponse constructs an LLMResponse from accumulated stream data.
|
||||||
|
|
||||||
|
func buildAccumulatedResponse(
|
||||||
|
content, reasoning string,
|
||||||
|
|
||||||
|
toolCalls []streamToolCallAcc,
|
||||||
|
|
||||||
|
finishReason string,
|
||||||
|
|
||||||
|
usage *providers.UsageInfo,
|
||||||
|
) *providers.LLMResponse {
|
||||||
|
resp := &providers.LLMResponse{
|
||||||
|
Content: content,
|
||||||
|
|
||||||
|
Reasoning: reasoning,
|
||||||
|
|
||||||
|
FinishReason: finishReason,
|
||||||
|
|
||||||
|
Usage: usage,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range toolCalls {
|
||||||
|
arguments := make(map[string]any)
|
||||||
|
|
||||||
|
argStr := tc.args.String()
|
||||||
|
|
||||||
|
if argStr != "" {
|
||||||
|
if err := json.Unmarshal([]byte(argStr), &arguments); err != nil {
|
||||||
|
arguments["raw"] = argStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.ToolCalls = append(resp.ToolCalls, providers.ToolCall{
|
||||||
|
ID: tc.id,
|
||||||
|
|
||||||
|
Name: tc.name,
|
||||||
|
|
||||||
|
Arguments: arguments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
701
pkg/agent/loop_task.go
Normal file
701
pkg/agent/loop_task.go
Normal file
|
|
@ -0,0 +1,701 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// activeTask tracks a running agent task for live status and intervention.
|
||||||
|
|
||||||
|
type activeTask struct {
|
||||||
|
Description string
|
||||||
|
|
||||||
|
Result string // LLM response summary for completion notification
|
||||||
|
|
||||||
|
Iteration int
|
||||||
|
|
||||||
|
MaxIter int
|
||||||
|
|
||||||
|
StartedAt time.Time
|
||||||
|
|
||||||
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
interrupt chan string // buffered 1, for user message injection
|
||||||
|
|
||||||
|
toolLog []toolLogEntry
|
||||||
|
|
||||||
|
lastError *toolLogEntry // sticky: most recent error, persists across iterations
|
||||||
|
|
||||||
|
projectDir string // detected from exec cd target (authoritative)
|
||||||
|
|
||||||
|
fileCommonDir string // LCP of file paths relative to workspace (fallback)
|
||||||
|
|
||||||
|
streamedChunks bool // true after onChunk fires at least once
|
||||||
|
|
||||||
|
messageContent string // last content sent by the message tool (for inclusion in completion)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolLogEntry records a single tool call for the live terminal view.
|
||||||
|
|
||||||
|
type toolLogEntry struct {
|
||||||
|
Name string
|
||||||
|
|
||||||
|
ArgsSnip string // first ~80 chars of args
|
||||||
|
|
||||||
|
Result string // "✓ 4.9s" or "✗ 3.2s"
|
||||||
|
|
||||||
|
ErrDetail string // non-empty on error — e.g. "Exit code: exit status 1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxToolLogEntries limits the sliding window of tool log entries
|
||||||
|
|
||||||
|
// kept in memory and displayed in status messages.
|
||||||
|
|
||||||
|
const maxToolLogEntries = 5
|
||||||
|
|
||||||
|
// Task reminder constants and helpers.
|
||||||
|
|
||||||
|
const (
|
||||||
|
taskReminderMaxChars = 500
|
||||||
|
|
||||||
|
blockerMaxChars = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
func shouldInjectReminder(iteration, interval int) bool {
|
||||||
|
if interval <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return iteration > 1 && iteration%interval == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTaskReminder(userMessage string, lastBlocker string) providers.Message {
|
||||||
|
truncatedTask := utils.Truncate(userMessage, taskReminderMaxChars)
|
||||||
|
|
||||||
|
var content string
|
||||||
|
|
||||||
|
if lastBlocker != "" {
|
||||||
|
truncatedBlocker := utils.Truncate(lastBlocker, blockerMaxChars)
|
||||||
|
|
||||||
|
content = fmt.Sprintf(
|
||||||
|
"[TASK REMINDER]\nOriginal task:\n---\n%s\n---\nLast blocker:\n---\n%s\n---\nFix the blocker if essential, or find an alternative. If all steps are complete, move on.",
|
||||||
|
truncatedTask,
|
||||||
|
truncatedBlocker,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
content = fmt.Sprintf(
|
||||||
|
"[TASK REMINDER]\nOriginal task:\n---\n%s\n---\nIf all steps of the original task are complete, move on. Otherwise, continue with the next step.",
|
||||||
|
truncatedTask,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cdPrefixPattern matches "cd /some/path && " at the start of a shell command.
|
||||||
|
|
||||||
|
// Group 1 captures the target directory path.
|
||||||
|
|
||||||
|
var cdPrefixPattern = regexp.MustCompile(`^cd\s+(\S+)\s*&&\s*`)
|
||||||
|
|
||||||
|
// optFlagPattern matches option flags like --verbose, -v, --timeout=60, -q.
|
||||||
|
|
||||||
|
// Only standalone flags are removed; flags whose value is the next positional
|
||||||
|
|
||||||
|
// argument (e.g. "-A 20") are kept because removing them would lose context.
|
||||||
|
|
||||||
|
var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`)
|
||||||
|
|
||||||
|
// extractExecProjectDir extracts the basename of an exec cd target.
|
||||||
|
|
||||||
|
// Returns "" if the command has no cd prefix.
|
||||||
|
|
||||||
|
func extractExecProjectDir(args map[string]any) string {
|
||||||
|
cmd, _ := args["command"].(string)
|
||||||
|
|
||||||
|
if cmd == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
m := cdPrefixPattern.FindStringSubmatch(cmd)
|
||||||
|
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
cdPath := strings.TrimRight(m[1], "/\\")
|
||||||
|
|
||||||
|
if idx := strings.LastIndex(cdPath, "/"); idx >= 0 {
|
||||||
|
return cdPath[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := strings.LastIndex(cdPath, "\\"); idx >= 0 {
|
||||||
|
return cdPath[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return cdPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileParentRelDir returns the parent directory of a file path, relative to
|
||||||
|
|
||||||
|
// workspace. Returns "" if the path is not under workspace or has no parent.
|
||||||
|
|
||||||
|
func fileParentRelDir(filePath, workspace string) string {
|
||||||
|
ws := strings.TrimRight(workspace, "/\\")
|
||||||
|
|
||||||
|
if ws == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := strings.TrimPrefix(filePath, ws)
|
||||||
|
|
||||||
|
if rest == filePath {
|
||||||
|
return "" // not under workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
rest = strings.TrimLeft(rest, "/\\")
|
||||||
|
|
||||||
|
// Remove the filename — keep only the directory part
|
||||||
|
|
||||||
|
if idx := strings.LastIndexAny(rest, "/\\"); idx >= 0 {
|
||||||
|
return rest[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
return "" // file is directly under workspace, no meaningful dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// commonDirPrefix computes the longest common directory prefix of two
|
||||||
|
|
||||||
|
// slash-separated paths. Returns "" if there is no common component.
|
||||||
|
|
||||||
|
func commonDirPrefix(a, b string) string {
|
||||||
|
partsA := strings.Split(a, "/")
|
||||||
|
|
||||||
|
partsB := strings.Split(b, "/")
|
||||||
|
|
||||||
|
n := len(partsA)
|
||||||
|
|
||||||
|
if len(partsB) < n {
|
||||||
|
n = len(partsB)
|
||||||
|
}
|
||||||
|
|
||||||
|
common := 0
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if partsA[i] != partsB[i] {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
common = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if common == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(partsA[:common], "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayProjectDir returns the project directory name for status display.
|
||||||
|
|
||||||
|
// Prefers the authoritative exec-based projectDir; falls back to the
|
||||||
|
|
||||||
|
// basename of the file-based common directory.
|
||||||
|
|
||||||
|
func displayProjectDir(task *activeTask) string {
|
||||||
|
if task.projectDir != "" {
|
||||||
|
return task.projectDir
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.fileCommonDir != "" {
|
||||||
|
dir := task.fileCommonDir
|
||||||
|
|
||||||
|
if idx := strings.LastIndex(dir, "/"); idx >= 0 {
|
||||||
|
return dir[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildArgsSnippet produces a human-friendly snippet for the tool log.
|
||||||
|
|
||||||
|
// For exec: extracts the command and strips the leading "cd <workspace> && ".
|
||||||
|
|
||||||
|
// For file tools: extracts the path and strips the workspace prefix.
|
||||||
|
|
||||||
|
// Falls back to raw JSON truncation.
|
||||||
|
|
||||||
|
func buildArgsSnippet(toolName string, args map[string]any, workspace string) string {
|
||||||
|
switch toolName {
|
||||||
|
case "exec":
|
||||||
|
|
||||||
|
cmd, _ := args["command"].(string)
|
||||||
|
|
||||||
|
if cmd == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = cdPrefixPattern.ReplaceAllString(cmd, "")
|
||||||
|
|
||||||
|
cmd = optFlagPattern.ReplaceAllString(cmd, "")
|
||||||
|
|
||||||
|
return utils.Truncate(cmd, 80)
|
||||||
|
|
||||||
|
case "read_file", "write_file", "edit_file", "append_file", "list_dir":
|
||||||
|
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if workspace != "" {
|
||||||
|
path = strings.TrimPrefix(path, workspace)
|
||||||
|
|
||||||
|
path = strings.TrimPrefix(path, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prioritize filename: if path is too long, show "…/filename"
|
||||||
|
|
||||||
|
const maxPath = 60
|
||||||
|
|
||||||
|
if runes := []rune(path); len(runes) > maxPath {
|
||||||
|
// Find last slash to extract filename
|
||||||
|
|
||||||
|
if lastSlash := strings.LastIndex(path, "/"); lastSlash >= 0 {
|
||||||
|
filename := path[lastSlash:] // includes "/"
|
||||||
|
|
||||||
|
dirBudget := maxPath - len([]rune(filename)) - 1 // 1 for "…"
|
||||||
|
|
||||||
|
if dirBudget > 0 {
|
||||||
|
dir := []rune(path[:lastSlash])
|
||||||
|
|
||||||
|
if len(dir) > dirBudget {
|
||||||
|
dir = dir[:dirBudget]
|
||||||
|
}
|
||||||
|
|
||||||
|
path = string(dir) + "\u2026" + filename
|
||||||
|
} else {
|
||||||
|
path = "\u2026" + filename
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
path = utils.Truncate(path, maxPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: raw JSON truncated
|
||||||
|
|
||||||
|
argsJSON, _ := json.Marshal(args)
|
||||||
|
|
||||||
|
return utils.Truncate(string(argsJSON), 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxEntryLineWidth is the max rune count for a single-line log entry.
|
||||||
|
|
||||||
|
// Telegram chat bubbles on mobile are roughly 40-45 chars wide.
|
||||||
|
|
||||||
|
const maxEntryLineWidth = 42
|
||||||
|
|
||||||
|
// isFileToolEntry returns true if the entry name contains a file-operation tool.
|
||||||
|
|
||||||
|
func isFileToolEntry(name string) bool {
|
||||||
|
for _, t := range []string{"read_file", "write_file", "edit_file", "append_file", "list_dir"} {
|
||||||
|
if strings.Contains(name, t) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatCompactEntry formats a finished tool log entry as a fixed single line.
|
||||||
|
|
||||||
|
// The result marker (✓/✗) is always shown at the end regardless of truncation.
|
||||||
|
|
||||||
|
// File tools omit duration (always near-instant); paths truncate from the
|
||||||
|
|
||||||
|
// start so the filename is always visible.
|
||||||
|
|
||||||
|
func formatCompactEntry(entry toolLogEntry) string {
|
||||||
|
result := entry.Result
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
result = "\u23F3" // ⏳
|
||||||
|
}
|
||||||
|
|
||||||
|
// File tools: strip duration, keep only marker (✓/✗/⏳)
|
||||||
|
|
||||||
|
isFile := isFileToolEntry(entry.Name)
|
||||||
|
|
||||||
|
if isFile {
|
||||||
|
if r := []rune(result); len(r) > 0 {
|
||||||
|
result = string(r[0:1]) // just the symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget for ArgsSnip: total - name - " " - " " - result
|
||||||
|
|
||||||
|
nameLen := utf8.RuneCountInString(entry.Name)
|
||||||
|
|
||||||
|
resultLen := utf8.RuneCountInString(result)
|
||||||
|
|
||||||
|
argsBudget := maxEntryLineWidth - nameLen - 1 - 1 - resultLen
|
||||||
|
|
||||||
|
args := entry.ArgsSnip
|
||||||
|
|
||||||
|
if args != "" && argsBudget > 3 {
|
||||||
|
argsRunes := []rune(args)
|
||||||
|
|
||||||
|
if len(argsRunes) > argsBudget {
|
||||||
|
// Paths: truncate from the start, keeping the filename visible
|
||||||
|
|
||||||
|
if strings.Contains(args, "/") {
|
||||||
|
args = "\u2026" + string(argsRunes[len(argsRunes)-argsBudget+1:])
|
||||||
|
} else {
|
||||||
|
args = string(argsRunes[:argsBudget-1]) + "\u2026"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.Grow(len(entry.Name) + 1 + len(args) + 1 + len(result))
|
||||||
|
|
||||||
|
sb.WriteString(entry.Name)
|
||||||
|
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
|
||||||
|
sb.WriteString(args)
|
||||||
|
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
|
||||||
|
sb.WriteString(result)
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// No room for args or args empty
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.Grow(len(entry.Name) + 1 + len(result))
|
||||||
|
|
||||||
|
sb.WriteString(entry.Name)
|
||||||
|
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
|
||||||
|
sb.WriteString(result)
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatLatestEntry formats the latest entry command without its result marker.
|
||||||
|
|
||||||
|
// Since the result goes on the next line, the full width is available for the command.
|
||||||
|
|
||||||
|
func formatLatestEntry(entry toolLogEntry) string {
|
||||||
|
nameLen := utf8.RuneCountInString(entry.Name)
|
||||||
|
|
||||||
|
argsBudget := maxEntryLineWidth - nameLen - 1 // name + space + args (no result)
|
||||||
|
|
||||||
|
args := entry.ArgsSnip
|
||||||
|
|
||||||
|
if args != "" && argsBudget > 3 {
|
||||||
|
argsRunes := []rune(args)
|
||||||
|
|
||||||
|
if len(argsRunes) > argsBudget {
|
||||||
|
if strings.Contains(args, "/") {
|
||||||
|
args = "\u2026" + string(argsRunes[len(argsRunes)-argsBudget+1:])
|
||||||
|
} else {
|
||||||
|
args = string(argsRunes[:argsBudget-1]) + "\u2026"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.Grow(len(entry.Name) + 1 + len(args))
|
||||||
|
|
||||||
|
sb.WriteString(entry.Name)
|
||||||
|
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
|
||||||
|
sb.WriteString(args)
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// compressRepeats reduces runs of 3+ identical non-alphanumeric, non-space
|
||||||
|
|
||||||
|
// characters to just 2. e.g. "======" → "==", "---" → "--".
|
||||||
|
|
||||||
|
func compressRepeats(s string) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
|
||||||
|
if len(runes) < 3 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.Grow(len(s))
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
|
||||||
|
for i < len(runes) {
|
||||||
|
r := runes[i]
|
||||||
|
|
||||||
|
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && !unicode.IsSpace(r) {
|
||||||
|
j := i + 1
|
||||||
|
|
||||||
|
for j < len(runes) && runes[j] == r {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
|
||||||
|
if j-i >= 3 {
|
||||||
|
sb.WriteRune(r)
|
||||||
|
|
||||||
|
sb.WriteRune(r)
|
||||||
|
|
||||||
|
i = j
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteRune(r)
|
||||||
|
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display layout constants.
|
||||||
|
|
||||||
|
const (
|
||||||
|
displayPastEntries = 4 // number of compact 1-line past entries
|
||||||
|
|
||||||
|
displayErrorLines = 5 // content lines inside the error code block
|
||||||
|
|
||||||
|
statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n"
|
||||||
|
|
||||||
|
streamingDisplayLines = 17 // line count matching buildRichStatus output
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildRichStatus builds a fixed-height terminal-like status display.
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// Layout (always the same number of lines):
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// 🔄 Task in progress (N/M) header
|
||||||
|
|
||||||
|
// 📁 workspace-path header
|
||||||
|
|
||||||
|
// ━━━━━━━━━━ separator
|
||||||
|
|
||||||
|
// [N] compact-past-1 ✓ Xs past (1 line each)
|
||||||
|
|
||||||
|
// [N] compact-past-2 ✗ Xs past
|
||||||
|
|
||||||
|
// [N] compact-past-3 ✓ Xs past
|
||||||
|
|
||||||
|
// [N] compact-past-4 ✓ Xs past
|
||||||
|
|
||||||
|
// [N] latest-command latest (no result, wider args)
|
||||||
|
|
||||||
|
// ⏳ latest result
|
||||||
|
|
||||||
|
// reserved
|
||||||
|
|
||||||
|
// ``` error fence
|
||||||
|
|
||||||
|
// err-line / placeholder error body (5 lines)
|
||||||
|
|
||||||
|
// ``` error fence
|
||||||
|
|
||||||
|
// ↩️ Reply to intervene footer (background only)
|
||||||
|
|
||||||
|
func buildRichStatus(task *activeTask, isBackground bool, workspace string) string {
|
||||||
|
task.mu.Lock()
|
||||||
|
|
||||||
|
defer task.mu.Unlock()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// --- Header ---
|
||||||
|
|
||||||
|
sb.WriteString("\U0001F504 Task in progress (")
|
||||||
|
|
||||||
|
sb.WriteString(strconv.Itoa(task.Iteration))
|
||||||
|
|
||||||
|
sb.WriteByte('/')
|
||||||
|
|
||||||
|
sb.WriteString(strconv.Itoa(task.MaxIter))
|
||||||
|
|
||||||
|
sb.WriteString(")\n")
|
||||||
|
|
||||||
|
// Project directory: exec cd (authoritative) → file LCP → workspace basename
|
||||||
|
|
||||||
|
sb.WriteString("\U0001F4C1 ")
|
||||||
|
|
||||||
|
if dir := displayProjectDir(task); dir != "" {
|
||||||
|
sb.WriteString(dir)
|
||||||
|
} else if workspace != "" {
|
||||||
|
project := strings.TrimRight(workspace, "/\\")
|
||||||
|
|
||||||
|
if idx := strings.LastIndex(project, "/"); idx >= 0 {
|
||||||
|
project = project[idx+1:]
|
||||||
|
} else if idx := strings.LastIndex(project, "\\"); idx >= 0 {
|
||||||
|
project = project[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
|
||||||
|
sb.WriteString(statusSeparator)
|
||||||
|
|
||||||
|
// --- Task entries (displayPastEntries + 2 lines for latest) ---
|
||||||
|
|
||||||
|
entries := task.toolLog
|
||||||
|
|
||||||
|
if len(entries) > maxToolLogEntries {
|
||||||
|
entries = entries[len(entries)-maxToolLogEntries:]
|
||||||
|
}
|
||||||
|
|
||||||
|
var pastEntries []toolLogEntry
|
||||||
|
|
||||||
|
var latest *toolLogEntry
|
||||||
|
|
||||||
|
if len(entries) > 0 {
|
||||||
|
latest = &entries[len(entries)-1]
|
||||||
|
|
||||||
|
if len(entries) > 1 {
|
||||||
|
start := len(entries) - 1 - displayPastEntries
|
||||||
|
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pastEntries = entries[start : len(entries)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past entries: exactly displayPastEntries lines (pad if fewer)
|
||||||
|
|
||||||
|
for i := 0; i < displayPastEntries; i++ {
|
||||||
|
if i < len(pastEntries) {
|
||||||
|
sb.WriteString(formatCompactEntry(pastEntries[i]))
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\u2800")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest entry: command on one line, result on next
|
||||||
|
|
||||||
|
if latest != nil {
|
||||||
|
sb.WriteString(formatLatestEntry(*latest))
|
||||||
|
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
|
||||||
|
sb.WriteString(" ")
|
||||||
|
|
||||||
|
if latest.Result != "" {
|
||||||
|
sb.WriteString(latest.Result)
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\u23F3")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\u23F3 waiting...\n")
|
||||||
|
|
||||||
|
sb.WriteString("\u2800\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved (1 line)
|
||||||
|
|
||||||
|
sb.WriteString("\u2800\n")
|
||||||
|
|
||||||
|
// --- Error region (code fence, no separator) ---
|
||||||
|
|
||||||
|
sb.WriteString("```\n")
|
||||||
|
|
||||||
|
errEntry := task.lastError
|
||||||
|
|
||||||
|
if errEntry != nil {
|
||||||
|
sb.WriteString("\u274C ")
|
||||||
|
|
||||||
|
sb.WriteString(formatCompactEntry(*errEntry))
|
||||||
|
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
|
||||||
|
var detailLines []string
|
||||||
|
|
||||||
|
if errEntry.ErrDetail != "" {
|
||||||
|
detailLines = strings.Split(errEntry.ErrDetail, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < displayErrorLines-1; i++ {
|
||||||
|
if i < len(detailLines) {
|
||||||
|
line := compressRepeats(detailLines[i])
|
||||||
|
|
||||||
|
if runes := []rune(line); len(runes) > maxEntryLineWidth {
|
||||||
|
line = string(runes[:maxEntryLineWidth-1]) + "\u2026"
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(line)
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\u2800")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\u2714 No errors\n")
|
||||||
|
|
||||||
|
for i := 0; i < displayErrorLines-1; i++ {
|
||||||
|
sb.WriteString("\u2800\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("```\n")
|
||||||
|
|
||||||
|
if isBackground {
|
||||||
|
sb.WriteString("\u21A9\uFE0F Reply to intervene")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
1178
pkg/agent/memory.go
1178
pkg/agent/memory.go
File diff suppressed because it is too large
Load diff
728
pkg/agent/memory_ext.go
Normal file
728
pkg/agent/memory_ext.go
Normal file
|
|
@ -0,0 +1,728 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cache types for MemoryStore.
|
||||||
|
|
||||||
|
type longTermFileCache struct {
|
||||||
|
loaded bool
|
||||||
|
exists bool
|
||||||
|
modTime time.Time
|
||||||
|
size int64
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
|
||||||
|
type parsedPlanStateCache struct {
|
||||||
|
loaded bool
|
||||||
|
sourceContent string
|
||||||
|
state parsedPlanState
|
||||||
|
}
|
||||||
|
|
||||||
|
type parsedPlanState struct {
|
||||||
|
content string
|
||||||
|
hasActivePlan bool
|
||||||
|
status string
|
||||||
|
currentPhase int
|
||||||
|
totalPhases int
|
||||||
|
workDir string
|
||||||
|
taskName string
|
||||||
|
phases []PlanPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateCache clears all in-memory caches for MEMORY.md content and parsed plan state.
|
||||||
|
func (ms *MemoryStore) InvalidateCache() {
|
||||||
|
ms.cacheMu.Lock()
|
||||||
|
defer ms.cacheMu.Unlock()
|
||||||
|
ms.longTermCache = longTermFileCache{}
|
||||||
|
ms.parsedPlanCache = parsedPlanStateCache{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *MemoryStore) readLongTermCached() string {
|
||||||
|
info, err := os.Stat(ms.memoryFile)
|
||||||
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ms.cacheMu.RLock()
|
||||||
|
cachedMissing := ms.longTermCache.loaded && !ms.longTermCache.exists
|
||||||
|
ms.cacheMu.RUnlock()
|
||||||
|
if cachedMissing {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ms.cacheMu.Lock()
|
||||||
|
ms.longTermCache = longTermFileCache{loaded: true, exists: false}
|
||||||
|
ms.parsedPlanCache = parsedPlanStateCache{}
|
||||||
|
ms.cacheMu.Unlock()
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
modTime := info.ModTime()
|
||||||
|
size := info.Size()
|
||||||
|
|
||||||
|
ms.cacheMu.RLock()
|
||||||
|
if ms.longTermCache.loaded &&
|
||||||
|
ms.longTermCache.exists &&
|
||||||
|
ms.longTermCache.modTime.Equal(modTime) &&
|
||||||
|
ms.longTermCache.size == size {
|
||||||
|
content := ms.longTermCache.content
|
||||||
|
ms.cacheMu.RUnlock()
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
ms.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(ms.memoryFile)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
ms.cacheMu.Lock()
|
||||||
|
ms.longTermCache = longTermFileCache{loaded: true, exists: false}
|
||||||
|
ms.parsedPlanCache = parsedPlanStateCache{}
|
||||||
|
ms.cacheMu.Unlock()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
ms.cacheMu.Lock()
|
||||||
|
ms.longTermCache = longTermFileCache{
|
||||||
|
loaded: true,
|
||||||
|
exists: true,
|
||||||
|
modTime: modTime,
|
||||||
|
size: size,
|
||||||
|
content: content,
|
||||||
|
}
|
||||||
|
if ms.parsedPlanCache.loaded && ms.parsedPlanCache.sourceContent != content {
|
||||||
|
ms.parsedPlanCache = parsedPlanStateCache{}
|
||||||
|
}
|
||||||
|
ms.cacheMu.Unlock()
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *MemoryStore) getParsedPlanState() parsedPlanState {
|
||||||
|
content := ms.ReadLongTerm()
|
||||||
|
|
||||||
|
ms.cacheMu.RLock()
|
||||||
|
if ms.parsedPlanCache.loaded && ms.parsedPlanCache.sourceContent == content {
|
||||||
|
state := ms.parsedPlanCache.state
|
||||||
|
ms.cacheMu.RUnlock()
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
ms.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
state := ms.parsePlanState(content)
|
||||||
|
|
||||||
|
ms.cacheMu.Lock()
|
||||||
|
if !ms.parsedPlanCache.loaded || ms.parsedPlanCache.sourceContent != content {
|
||||||
|
ms.parsedPlanCache = parsedPlanStateCache{
|
||||||
|
loaded: true,
|
||||||
|
sourceContent: content,
|
||||||
|
state: state,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state = ms.parsedPlanCache.state
|
||||||
|
}
|
||||||
|
ms.cacheMu.Unlock()
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *MemoryStore) parsePlanState(content string) parsedPlanState {
|
||||||
|
state := parsedPlanState{content: content}
|
||||||
|
if content == "" || !reActivePlan.MatchString(content) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
state.hasActivePlan = true
|
||||||
|
if m := reStatus.FindStringSubmatch(content); len(m) >= 2 {
|
||||||
|
state.status = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
if m := rePhase.FindStringSubmatch(content); len(m) >= 2 {
|
||||||
|
state.currentPhase, _ = strconv.Atoi(m[1])
|
||||||
|
}
|
||||||
|
state.totalPhases = maxPhaseNumber(content)
|
||||||
|
if m := reWorkDir.FindStringSubmatch(content); len(m) >= 2 {
|
||||||
|
state.workDir = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
if m := reTaskLine.FindStringSubmatch(content); len(m) >= 2 {
|
||||||
|
state.taskName = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
state.phases = ms.getPlanPhasesFrom(content)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePlanPhases(phases []PlanPhase) []PlanPhase {
|
||||||
|
if len(phases) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]PlanPhase, 0, len(phases))
|
||||||
|
for _, p := range phases {
|
||||||
|
phase := PlanPhase{Number: p.Number, Title: p.Title}
|
||||||
|
if len(p.Steps) > 0 {
|
||||||
|
phase.Steps = append([]PlanStep(nil), p.Steps...)
|
||||||
|
}
|
||||||
|
result = append(result, phase)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearLongTerm removes the long-term memory file.
|
||||||
|
func (ms *MemoryStore) ClearLongTerm() error {
|
||||||
|
if err := os.Remove(ms.memoryFile); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ms.InvalidateCache()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Plan state query methods ----------
|
||||||
|
|
||||||
|
var (
|
||||||
|
reActivePlan = regexp.MustCompile(`(?m)^# Active Plan`)
|
||||||
|
reStatus = regexp.MustCompile(`(?m)^> Status:\s*(.+)`)
|
||||||
|
rePhase = regexp.MustCompile(`(?m)^> Phase:\s*(\d+)`)
|
||||||
|
rePhaseHeader = regexp.MustCompile(`(?m)^## Phase (\d+):\s*(.*)`)
|
||||||
|
reWorkDir = regexp.MustCompile(`(?m)^> WorkDir:\s*(.+)`)
|
||||||
|
reTaskLine = regexp.MustCompile(`(?m)^> Task:\s*(.+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// HasActivePlan returns true if MEMORY.md contains an active plan.
|
||||||
|
func (ms *MemoryStore) HasActivePlan() bool {
|
||||||
|
return ms.getParsedPlanState().hasActivePlan
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlanStatus returns the plan status: "interviewing", "executing", or "".
|
||||||
|
func (ms *MemoryStore) GetPlanStatus() string {
|
||||||
|
return ms.getParsedPlanState().status
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentPhase returns the current phase number from "> Phase: N".
|
||||||
|
func (ms *MemoryStore) GetCurrentPhase() int {
|
||||||
|
return ms.getParsedPlanState().currentPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTotalPhases returns the total number of phases (max ## Phase N).
|
||||||
|
func (ms *MemoryStore) GetTotalPhases() int {
|
||||||
|
return ms.getParsedPlanState().totalPhases
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPlanComplete returns true if all steps in all phases are [x].
|
||||||
|
func (ms *MemoryStore) IsPlanComplete() bool {
|
||||||
|
phases := ms.getParsedPlanState().phases
|
||||||
|
if len(phases) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasSteps := false
|
||||||
|
for _, p := range phases {
|
||||||
|
for _, s := range p.Steps {
|
||||||
|
hasSteps = true
|
||||||
|
if !s.Done {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasSteps
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCurrentPhaseComplete returns true if all steps in the current phase are [x].
|
||||||
|
func (ms *MemoryStore) IsCurrentPhaseComplete() bool {
|
||||||
|
state := ms.getParsedPlanState()
|
||||||
|
if state.currentPhase == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range state.phases {
|
||||||
|
if p.Number == state.currentPhase {
|
||||||
|
if len(p.Steps) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, s := range p.Steps {
|
||||||
|
if !s.Done {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *MemoryStore) extractPhaseContent(content string, phase int) string {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
inPhase := false
|
||||||
|
var result []string
|
||||||
|
phasePrefix := fmt.Sprintf("## Phase %d:", phase)
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, phasePrefix) {
|
||||||
|
inPhase = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inPhase {
|
||||||
|
if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result = append(result, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(result, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanPhase represents a phase with its steps, for structured API output.
|
||||||
|
type PlanPhase struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Steps []PlanStep `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanStep represents a single step within a phase.
|
||||||
|
type PlanStep struct {
|
||||||
|
Index int `json:"index"` // 1-based within the phase
|
||||||
|
Description string `json:"description"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlanPhases parses MEMORY.md and returns all phases with their steps.
|
||||||
|
func (ms *MemoryStore) GetPlanPhases() []PlanPhase {
|
||||||
|
return clonePlanPhases(ms.getParsedPlanState().phases)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *MemoryStore) getPlanPhasesFrom(content string) []PlanPhase {
|
||||||
|
if !reActivePlan.MatchString(content) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
totalPhases := maxPhaseNumber(content)
|
||||||
|
phases := make([]PlanPhase, 0, totalPhases)
|
||||||
|
for p := 1; p <= totalPhases; p++ {
|
||||||
|
title := ms.getPhaseTitle(content, p)
|
||||||
|
phaseContent := ms.extractPhaseContent(content, p)
|
||||||
|
var steps []PlanStep
|
||||||
|
stepIdx := 0
|
||||||
|
for _, line := range strings.Split(phaseContent, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "- [x] ") {
|
||||||
|
stepIdx++
|
||||||
|
steps = append(steps, PlanStep{Index: stepIdx, Description: line[6:], Done: true})
|
||||||
|
} else if strings.HasPrefix(line, "- [ ] ") {
|
||||||
|
stepIdx++
|
||||||
|
steps = append(steps, PlanStep{Index: stepIdx, Description: line[6:], Done: false})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phases = append(phases, PlanPhase{Number: p, Title: title, Steps: steps})
|
||||||
|
}
|
||||||
|
return phases
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Plan mutation methods ----------
|
||||||
|
|
||||||
|
// SetStatus sets the plan status (interviewing or executing).
|
||||||
|
func (ms *MemoryStore) SetStatus(status string) error {
|
||||||
|
content := ms.ReadLongTerm()
|
||||||
|
if m := reStatus.FindString(content); m != "" {
|
||||||
|
content = strings.Replace(content, m, "> Status: "+status, 1)
|
||||||
|
}
|
||||||
|
return ms.WriteLongTerm(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvancePhase increments the current phase number by 1.
|
||||||
|
func (ms *MemoryStore) AdvancePhase() error {
|
||||||
|
content := ms.ReadLongTerm()
|
||||||
|
m := rePhase.FindStringSubmatch(content)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return fmt.Errorf("no phase marker found")
|
||||||
|
}
|
||||||
|
current, _ := strconv.Atoi(m[1])
|
||||||
|
next := current + 1
|
||||||
|
content = strings.Replace(content, m[0], fmt.Sprintf("> Phase: %d", next), 1)
|
||||||
|
return ms.WriteLongTerm(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPhase sets the current phase number to n.
|
||||||
|
func (ms *MemoryStore) SetPhase(n int) error {
|
||||||
|
content := ms.ReadLongTerm()
|
||||||
|
m := rePhase.FindString(content)
|
||||||
|
if m == "" {
|
||||||
|
return fmt.Errorf("no phase marker found")
|
||||||
|
}
|
||||||
|
content = strings.Replace(content, m, fmt.Sprintf("> Phase: %d", n), 1)
|
||||||
|
return ms.WriteLongTerm(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkStep marks the nth step (1-based) in the given phase as done [x].
|
||||||
|
func (ms *MemoryStore) MarkStep(phase, step int) error {
|
||||||
|
content := ms.ReadLongTerm()
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
phasePrefix := fmt.Sprintf("## Phase %d:", phase)
|
||||||
|
inPhase := false
|
||||||
|
stepCount := 0
|
||||||
|
for i, line := range lines {
|
||||||
|
if strings.HasPrefix(line, phasePrefix) {
|
||||||
|
inPhase = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inPhase {
|
||||||
|
if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "- [ ] ") {
|
||||||
|
stepCount++
|
||||||
|
if stepCount == step {
|
||||||
|
lines[i] = strings.Replace(line, "- [ ] ", "- [x] ", 1)
|
||||||
|
return ms.WriteLongTerm(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("step %d not found in phase %d", step, phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddStep appends a new step to the given phase.
|
||||||
|
func (ms *MemoryStore) AddStep(phase int, desc string) error {
|
||||||
|
content := ms.ReadLongTerm()
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
phasePrefix := fmt.Sprintf("## Phase %d:", phase)
|
||||||
|
inPhase := false
|
||||||
|
insertIdx := -1
|
||||||
|
for i, line := range lines {
|
||||||
|
if strings.HasPrefix(line, phasePrefix) {
|
||||||
|
inPhase = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inPhase {
|
||||||
|
if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") {
|
||||||
|
insertIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "- [") {
|
||||||
|
insertIdx = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if insertIdx < 0 {
|
||||||
|
if inPhase {
|
||||||
|
insertIdx = len(lines)
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("phase %d not found", phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newStep := "- [ ] " + desc
|
||||||
|
newLines := make([]string, 0, len(lines)+1)
|
||||||
|
newLines = append(newLines, lines[:insertIdx]...)
|
||||||
|
newLines = append(newLines, newStep)
|
||||||
|
newLines = append(newLines, lines[insertIdx:]...)
|
||||||
|
return ms.WriteLongTerm(strings.Join(newLines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePlanStructure checks that the plan has valid structure for
|
||||||
|
// transitioning out of the interview phase.
|
||||||
|
func (ms *MemoryStore) ValidatePlanStructure() error {
|
||||||
|
content := ms.ReadLongTerm()
|
||||||
|
if !reActivePlan.MatchString(content) {
|
||||||
|
return fmt.Errorf("missing '# Active Plan' header")
|
||||||
|
}
|
||||||
|
if !reStatus.MatchString(content) {
|
||||||
|
return fmt.Errorf("missing '> Status:' line")
|
||||||
|
}
|
||||||
|
if !rePhase.MatchString(content) {
|
||||||
|
return fmt.Errorf("missing '> Phase:' line")
|
||||||
|
}
|
||||||
|
phases := ms.getPlanPhasesFrom(content)
|
||||||
|
if len(phases) == 0 {
|
||||||
|
return fmt.Errorf("no '## Phase N:' sections found")
|
||||||
|
}
|
||||||
|
for _, p := range phases {
|
||||||
|
if len(p.Steps) == 0 {
|
||||||
|
return fmt.Errorf("Phase %d has no checkbox steps (use '- [ ] ...')", p.Number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Selective injection methods ----------
|
||||||
|
|
||||||
|
// GetPlanWorkDir returns the WorkDir from the plan metadata, or "".
|
||||||
|
func (ms *MemoryStore) GetPlanWorkDir() string {
|
||||||
|
return ms.getParsedPlanState().workDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlanTaskName returns the task description from the plan metadata, or "".
|
||||||
|
func (ms *MemoryStore) GetPlanTaskName() string {
|
||||||
|
return ms.getParsedPlanState().taskName
|
||||||
|
}
|
||||||
|
|
||||||
|
const interviewSeedTemplate = `# Active Plan
|
||||||
|
|
||||||
|
> Task: %s
|
||||||
|
> WorkDir: %s
|
||||||
|
> Status: interviewing
|
||||||
|
> Phase: 1
|
||||||
|
`
|
||||||
|
|
||||||
|
// BuildInterviewSeed creates the initial plan seed for a given task description.
|
||||||
|
func BuildInterviewSeed(task, workDir string) string {
|
||||||
|
return fmt.Sprintf(interviewSeedTemplate, task, workDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInterviewContext returns context for injection during the interviewing phase.
|
||||||
|
func (ms *MemoryStore) GetInterviewContext() string {
|
||||||
|
return ms.getInterviewContextFrom(ms.ReadLongTerm())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *MemoryStore) getInterviewContextFrom(content string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## Active Plan (interviewing)\n\n")
|
||||||
|
sb.WriteString(content)
|
||||||
|
sb.WriteString("\n\n### Interview Guide\n")
|
||||||
|
sb.WriteString("Ask about:\n")
|
||||||
|
sb.WriteString("- Goals and success criteria\n")
|
||||||
|
sb.WriteString("- Constraints (time, budget, platform)\n")
|
||||||
|
sb.WriteString("- Environment (OS, language, runtime versions)\n")
|
||||||
|
sb.WriteString("- Tooling preferences (test framework, linter, formatter, CI)\n")
|
||||||
|
sb.WriteString("- Key commands the user already runs (build, test, deploy)\n")
|
||||||
|
sb.WriteString("\n### Rules\n")
|
||||||
|
sb.WriteString(
|
||||||
|
"- NEVER remove or overwrite the header block (`# Active Plan`, `> Task:`, `> Status:`, `> Phase:` lines). The system parses these to track state.\n",
|
||||||
|
)
|
||||||
|
sb.WriteString(
|
||||||
|
"- After each answer, use edit_file to append findings to the ## Context section of memory/MEMORY.md.\n",
|
||||||
|
)
|
||||||
|
sb.WriteString(
|
||||||
|
"- When you have enough information, use edit_file to add ## Phase, ## Commands, and ## Context sections BELOW the header block.\n",
|
||||||
|
)
|
||||||
|
sb.WriteString(
|
||||||
|
"- Each step MUST use checkbox syntax: `- [ ] description`. The system parses checkboxes to track progress.\n",
|
||||||
|
)
|
||||||
|
sb.WriteString("- Organize into 2-5 phases with 3-5 steps each.\n")
|
||||||
|
sb.WriteString(
|
||||||
|
"- After writing Phases, change `> Status: interviewing` to `> Status: review` via edit_file. The user must approve with /plan start before execution begins.\n",
|
||||||
|
)
|
||||||
|
sb.WriteString("\n### Target Format (MANDATORY — system parses this exact structure)\n\n")
|
||||||
|
sb.WriteString("# Active Plan\n")
|
||||||
|
sb.WriteString("> Task: <description>\n")
|
||||||
|
sb.WriteString("> WorkDir: <path>\n")
|
||||||
|
sb.WriteString("> Status: interviewing\n")
|
||||||
|
sb.WriteString("> Phase: 1\n\n")
|
||||||
|
sb.WriteString("## Phase 1: <title>\n")
|
||||||
|
sb.WriteString("- [ ] Step description\n")
|
||||||
|
sb.WriteString("- [ ] Step description\n\n")
|
||||||
|
sb.WriteString("## Phase 2: <title>\n")
|
||||||
|
sb.WriteString("- [ ] Step description\n")
|
||||||
|
sb.WriteString("- [ ] Step description\n\n")
|
||||||
|
sb.WriteString("## Commands\n")
|
||||||
|
sb.WriteString("build: <project-specific build command>\n")
|
||||||
|
sb.WriteString("test: <project-specific test command>\n")
|
||||||
|
sb.WriteString("lint: <project-specific lint command>\n\n")
|
||||||
|
sb.WriteString("## Context\n")
|
||||||
|
sb.WriteString("<collected requirements, decisions, environment>\n")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReviewContext returns context for injection during the review phase.
|
||||||
|
func (ms *MemoryStore) GetReviewContext() string {
|
||||||
|
return ms.getReviewContextFrom(ms.ReadLongTerm())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *MemoryStore) getReviewContextFrom(content string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## Active Plan (awaiting approval)\n\n")
|
||||||
|
sb.WriteString(content)
|
||||||
|
sb.WriteString("\n\nThe plan is awaiting user approval.\n")
|
||||||
|
sb.WriteString("- If the user requests changes, update memory/MEMORY.md via edit_file.\n")
|
||||||
|
sb.WriteString("- Do NOT change Status yourself. The user will run /plan start to approve.\n")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlanContext returns context for injection during the executing phase.
|
||||||
|
func (ms *MemoryStore) GetPlanContext() string {
|
||||||
|
return ms.getPlanContextFrom(ms.ReadLongTerm())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *MemoryStore) getPlanContextFrom(content string) string {
|
||||||
|
var currentPhase int
|
||||||
|
if m := rePhase.FindStringSubmatch(content); len(m) >= 2 {
|
||||||
|
currentPhase, _ = strconv.Atoi(m[1])
|
||||||
|
}
|
||||||
|
totalPhases := maxPhaseNumber(content)
|
||||||
|
taskLine := ""
|
||||||
|
if m := reTaskLine.FindStringSubmatch(content); len(m) >= 2 {
|
||||||
|
taskLine = strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## Active Plan\n")
|
||||||
|
fmt.Fprintf(&sb, "Task: %s | Phase %d/%d\n", taskLine, currentPhase, totalPhases)
|
||||||
|
|
||||||
|
for p := 1; p < currentPhase; p++ {
|
||||||
|
title := ms.getPhaseTitle(content, p)
|
||||||
|
fmt.Fprintf(&sb, "Done: Phase %d (%s)\n", p, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentPhase > 0 {
|
||||||
|
title := ms.getPhaseTitle(content, currentPhase)
|
||||||
|
fmt.Fprintf(&sb, "### Current: Phase %d — %s\n", currentPhase, title)
|
||||||
|
phaseContent := ms.extractPhaseContent(content, currentPhase)
|
||||||
|
sb.WriteString(strings.TrimSpace(phaseContent))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if commandsContent := ms.extractCommandsSection(content); commandsContent != "" {
|
||||||
|
sb.WriteString("### Commands\n")
|
||||||
|
sb.WriteString(commandsContent)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if contextContent := ms.extractContextSection(content); contextContent != "" {
|
||||||
|
sb.WriteString("### Context\n")
|
||||||
|
sb.WriteString(contextContent)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if orchContent := ms.extractSection(content, "Orchestration"); orchContent != "" {
|
||||||
|
sb.WriteString("### Orchestration\n")
|
||||||
|
sb.WriteString(orchContent)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxPhaseNumber(content string) int {
|
||||||
|
matches := rePhaseHeader.FindAllStringSubmatch(content, -1)
|
||||||
|
maxN := 0
|
||||||
|
for _, m := range matches {
|
||||||
|
if len(m) >= 2 {
|
||||||
|
n, _ := strconv.Atoi(m[1])
|
||||||
|
if n > maxN {
|
||||||
|
maxN = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxN
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *MemoryStore) getPhaseTitle(content string, phase int) string {
|
||||||
|
matches := rePhaseHeader.FindAllStringSubmatch(content, -1)
|
||||||
|
for _, m := range matches {
|
||||||
|
if len(m) >= 3 {
|
||||||
|
n, _ := strconv.Atoi(m[1])
|
||||||
|
if n == phase {
|
||||||
|
return strings.TrimSpace(m[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *MemoryStore) extractSection(content, name string) string {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
prefix := "## " + name
|
||||||
|
inSection := false
|
||||||
|
var result []string
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, prefix) {
|
||||||
|
inSection = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inSection {
|
||||||
|
if strings.HasPrefix(line, "## ") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result = append(result, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Join(result, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *MemoryStore) extractContextSection(content string) string {
|
||||||
|
return ms.extractSection(content, "Context")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *MemoryStore) extractCommandsSection(content string) string {
|
||||||
|
return ms.extractSection(content, "Commands")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatPlanDisplay returns a user-facing display of the full plan with emoji indicators.
|
||||||
|
func (ms *MemoryStore) FormatPlanDisplay() string {
|
||||||
|
state := ms.getParsedPlanState()
|
||||||
|
if !state.hasActivePlan {
|
||||||
|
return "No active plan."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Plan: %s\n", state.taskName))
|
||||||
|
sb.WriteString(fmt.Sprintf("Status: %s | Phase %d/%d\n\n", state.status, state.currentPhase, len(state.phases)))
|
||||||
|
|
||||||
|
for _, p := range state.phases {
|
||||||
|
var emoji string
|
||||||
|
if p.Number < state.currentPhase {
|
||||||
|
emoji = "\u2705"
|
||||||
|
} else if p.Number == state.currentPhase {
|
||||||
|
emoji = "\u25B6\uFE0F"
|
||||||
|
} else {
|
||||||
|
emoji = "\u23F3"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%s Phase %d: %s\n", emoji, p.Number, p.Title))
|
||||||
|
if p.Number <= state.currentPhase {
|
||||||
|
for _, s := range p.Steps {
|
||||||
|
if s.Done {
|
||||||
|
sb.WriteString(" \u2611 " + s.Description + "\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString(" \u2610 " + s.Description + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if commandsContent := ms.extractCommandsSection(state.content); commandsContent != "" {
|
||||||
|
sb.WriteString("\nCommands:\n")
|
||||||
|
for _, line := range strings.Split(commandsContent, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
sb.WriteString(" " + line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if contextContent := ms.extractContextSection(state.content); contextContent != "" {
|
||||||
|
sb.WriteString("\nContext: " + contextContent + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- GetMemoryContext (plan-aware) ----------
|
||||||
|
|
||||||
|
func (ms *MemoryStore) getMemoryContextPlanAware() string {
|
||||||
|
var parts []string
|
||||||
|
state := ms.getParsedPlanState()
|
||||||
|
longTerm := state.content
|
||||||
|
|
||||||
|
if longTerm != "" {
|
||||||
|
if state.hasActivePlan {
|
||||||
|
switch state.status {
|
||||||
|
case "interviewing":
|
||||||
|
parts = append(parts, ms.getInterviewContextFrom(longTerm))
|
||||||
|
case "review":
|
||||||
|
parts = append(parts, ms.getReviewContextFrom(longTerm))
|
||||||
|
default:
|
||||||
|
parts = append(parts, ms.getPlanContextFrom(longTerm))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts = append(parts, "## Long-term Memory\n\n"+longTerm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress daily notes when a plan is active to save context
|
||||||
|
if !state.hasActivePlan {
|
||||||
|
recentNotes := ms.GetRecentDailyNotes(3)
|
||||||
|
if recentNotes != "" {
|
||||||
|
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n\n---\n\n")
|
||||||
|
}
|
||||||
|
|
@ -10,18 +10,13 @@ type mockProvider struct{}
|
||||||
|
|
||||||
func (m *mockProvider) Chat(
|
func (m *mockProvider) Chat(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|
||||||
messages []providers.Message,
|
messages []providers.Message,
|
||||||
|
|
||||||
tools []providers.ToolDefinition,
|
tools []providers.ToolDefinition,
|
||||||
|
|
||||||
model string,
|
model string,
|
||||||
|
|
||||||
opts map[string]any,
|
opts map[string]any,
|
||||||
) (*providers.LLMResponse, error) {
|
) (*providers.LLMResponse, error) {
|
||||||
return &providers.LLMResponse{
|
return &providers.LLMResponse{
|
||||||
Content: "Mock response",
|
Content: "Mock response",
|
||||||
|
|
||||||
ToolCalls: []providers.ToolCall{},
|
ToolCalls: []providers.ToolCall{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,62 +11,43 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentRegistry manages multiple agent instances and routes messages to them.
|
// AgentRegistry manages multiple agent instances and routes messages to them.
|
||||||
|
|
||||||
type AgentRegistry struct {
|
type AgentRegistry struct {
|
||||||
agents map[string]*AgentInstance
|
agents map[string]*AgentInstance
|
||||||
|
|
||||||
resolver *routing.RouteResolver
|
resolver *routing.RouteResolver
|
||||||
|
mu sync.RWMutex
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentRegistry creates a registry from config, instantiating all agents.
|
// NewAgentRegistry creates a registry from config, instantiating all agents.
|
||||||
|
|
||||||
func NewAgentRegistry(
|
func NewAgentRegistry(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
|
||||||
provider providers.LLMProvider,
|
provider providers.LLMProvider,
|
||||||
) *AgentRegistry {
|
) *AgentRegistry {
|
||||||
registry := &AgentRegistry{
|
registry := &AgentRegistry{
|
||||||
agents: make(map[string]*AgentInstance),
|
agents: make(map[string]*AgentInstance),
|
||||||
|
|
||||||
resolver: routing.NewRouteResolver(cfg),
|
resolver: routing.NewRouteResolver(cfg),
|
||||||
}
|
}
|
||||||
|
|
||||||
agentConfigs := cfg.Agents.List
|
agentConfigs := cfg.Agents.List
|
||||||
|
|
||||||
if len(agentConfigs) == 0 {
|
if len(agentConfigs) == 0 {
|
||||||
implicitAgent := &config.AgentConfig{
|
implicitAgent := &config.AgentConfig{
|
||||||
ID: "main",
|
ID: "main",
|
||||||
|
|
||||||
Default: true,
|
Default: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
|
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
registry.agents["main"] = instance
|
registry.agents["main"] = instance
|
||||||
|
|
||||||
logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil)
|
logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil)
|
||||||
} else {
|
} else {
|
||||||
for i := range agentConfigs {
|
for i := range agentConfigs {
|
||||||
ac := &agentConfigs[i]
|
ac := &agentConfigs[i]
|
||||||
|
|
||||||
id := routing.NormalizeAgentID(ac.ID)
|
id := routing.NormalizeAgentID(ac.ID)
|
||||||
|
|
||||||
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
|
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
registry.agents[id] = instance
|
registry.agents[id] = instance
|
||||||
|
|
||||||
logger.InfoCF("agent", "Registered agent",
|
logger.InfoCF("agent", "Registered agent",
|
||||||
|
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": id,
|
"agent_id": id,
|
||||||
|
"name": ac.Name,
|
||||||
"name": ac.Name,
|
|
||||||
|
|
||||||
"workspace": instance.Workspace,
|
"workspace": instance.Workspace,
|
||||||
|
"model": instance.Model,
|
||||||
"model": instance.Model,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,66 +56,48 @@ func NewAgentRegistry(
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAgent returns the agent instance for a given ID.
|
// GetAgent returns the agent instance for a given ID.
|
||||||
|
|
||||||
func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) {
|
func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
id := routing.NormalizeAgentID(agentID)
|
id := routing.NormalizeAgentID(agentID)
|
||||||
|
|
||||||
agent, ok := r.agents[id]
|
agent, ok := r.agents[id]
|
||||||
|
|
||||||
return agent, ok
|
return agent, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveRoute determines which agent handles the message.
|
// ResolveRoute determines which agent handles the message.
|
||||||
|
|
||||||
func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute {
|
func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute {
|
||||||
return r.resolver.ResolveRoute(input)
|
return r.resolver.ResolveRoute(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAgentIDs returns all registered agent IDs.
|
// ListAgentIDs returns all registered agent IDs.
|
||||||
|
|
||||||
func (r *AgentRegistry) ListAgentIDs() []string {
|
func (r *AgentRegistry) ListAgentIDs() []string {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
ids := make([]string, 0, len(r.agents))
|
ids := make([]string, 0, len(r.agents))
|
||||||
|
|
||||||
for id := range r.agents {
|
for id := range r.agents {
|
||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID.
|
// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID.
|
||||||
|
|
||||||
func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {
|
func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {
|
||||||
parent, ok := r.GetAgent(parentAgentID)
|
parent, ok := r.GetAgent(parentAgentID)
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if parent.Subagents == nil || parent.Subagents.AllowAgents == nil {
|
if parent.Subagents == nil || parent.Subagents.AllowAgents == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
targetNorm := routing.NormalizeAgentID(targetAgentID)
|
targetNorm := routing.NormalizeAgentID(targetAgentID)
|
||||||
|
|
||||||
for _, allowed := range parent.Subagents.AllowAgents {
|
for _, allowed := range parent.Subagents.AllowAgents {
|
||||||
if allowed == "*" {
|
if allowed == "*" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if routing.NormalizeAgentID(allowed) == targetNorm {
|
if routing.NormalizeAgentID(allowed) == targetNorm {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,20 +114,27 @@ func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAgent returns the default agent instance.
|
// Close releases resources held by all registered agents.
|
||||||
|
func (r *AgentRegistry) Close() {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
for _, agent := range r.agents {
|
||||||
|
if err := agent.Close(); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to close agent",
|
||||||
|
map[string]any{"agent_id": agent.ID, "error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAgent returns the default agent instance.
|
||||||
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
if agent, ok := r.agents["main"]; ok {
|
if agent, ok := r.agents["main"]; ok {
|
||||||
return agent
|
return agent
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, agent := range r.agents {
|
for _, agent := range r.agents {
|
||||||
return agent
|
return agent
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,9 @@ type mockRegistryProvider struct{}
|
||||||
|
|
||||||
func (m *mockRegistryProvider) Chat(
|
func (m *mockRegistryProvider) Chat(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|
||||||
messages []providers.Message,
|
messages []providers.Message,
|
||||||
|
|
||||||
tools []providers.ToolDefinition,
|
tools []providers.ToolDefinition,
|
||||||
|
|
||||||
model string,
|
model string,
|
||||||
|
|
||||||
options map[string]any,
|
options map[string]any,
|
||||||
) (*providers.LLMResponse, error) {
|
) (*providers.LLMResponse, error) {
|
||||||
return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil
|
return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil
|
||||||
|
|
@ -32,15 +28,11 @@ func testCfg(agents []config.AgentConfig) *config.Config {
|
||||||
return &config.Config{
|
return &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: "/tmp/picoclaw-test-registry",
|
Workspace: "/tmp/picoclaw-test-registry",
|
||||||
|
Model: "gpt-4",
|
||||||
Model: "gpt-4",
|
MaxTokens: 8192,
|
||||||
|
|
||||||
MaxTokens: 8192,
|
|
||||||
|
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
},
|
},
|
||||||
|
|
||||||
List: agents,
|
List: agents,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -48,21 +40,17 @@ func testCfg(agents []config.AgentConfig) *config.Config {
|
||||||
|
|
||||||
func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
|
func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
|
||||||
cfg := testCfg(nil)
|
cfg := testCfg(nil)
|
||||||
|
|
||||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
ids := registry.ListAgentIDs()
|
ids := registry.ListAgentIDs()
|
||||||
|
|
||||||
if len(ids) != 1 || ids[0] != "main" {
|
if len(ids) != 1 || ids[0] != "main" {
|
||||||
t.Errorf("expected implicit main agent, got %v", ids)
|
t.Errorf("expected implicit main agent, got %v", ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
agent, ok := registry.GetAgent("main")
|
agent, ok := registry.GetAgent("main")
|
||||||
|
|
||||||
if !ok || agent == nil {
|
if !ok || agent == nil {
|
||||||
t.Fatal("expected to find 'main' agent")
|
t.Fatal("expected to find 'main' agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
if agent.ID != "main" {
|
if agent.ID != "main" {
|
||||||
t.Errorf("agent.ID = %q, want 'main'", agent.ID)
|
t.Errorf("agent.ID = %q, want 'main'", agent.ID)
|
||||||
}
|
}
|
||||||
|
|
@ -71,30 +59,24 @@ func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
|
||||||
func TestNewAgentRegistry_ExplicitAgents(t *testing.T) {
|
func TestNewAgentRegistry_ExplicitAgents(t *testing.T) {
|
||||||
cfg := testCfg([]config.AgentConfig{
|
cfg := testCfg([]config.AgentConfig{
|
||||||
{ID: "sales", Default: true, Name: "Sales Bot"},
|
{ID: "sales", Default: true, Name: "Sales Bot"},
|
||||||
|
|
||||||
{ID: "support", Name: "Support Bot"},
|
{ID: "support", Name: "Support Bot"},
|
||||||
})
|
})
|
||||||
|
|
||||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
ids := registry.ListAgentIDs()
|
ids := registry.ListAgentIDs()
|
||||||
|
|
||||||
if len(ids) != 2 {
|
if len(ids) != 2 {
|
||||||
t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids)
|
t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
sales, ok := registry.GetAgent("sales")
|
sales, ok := registry.GetAgent("sales")
|
||||||
|
|
||||||
if !ok || sales == nil {
|
if !ok || sales == nil {
|
||||||
t.Fatal("expected to find 'sales' agent")
|
t.Fatal("expected to find 'sales' agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
if sales.Name != "Sales Bot" {
|
if sales.Name != "Sales Bot" {
|
||||||
t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name)
|
t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
support, ok := registry.GetAgent("support")
|
support, ok := registry.GetAgent("support")
|
||||||
|
|
||||||
if !ok || support == nil {
|
if !ok || support == nil {
|
||||||
t.Fatal("expected to find 'support' agent")
|
t.Fatal("expected to find 'support' agent")
|
||||||
}
|
}
|
||||||
|
|
@ -104,15 +86,12 @@ func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
|
||||||
cfg := testCfg([]config.AgentConfig{
|
cfg := testCfg([]config.AgentConfig{
|
||||||
{ID: "my-agent", Default: true},
|
{ID: "my-agent", Default: true},
|
||||||
})
|
})
|
||||||
|
|
||||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
agent, ok := registry.GetAgent("My-Agent")
|
agent, ok := registry.GetAgent("My-Agent")
|
||||||
|
|
||||||
if !ok || agent == nil {
|
if !ok || agent == nil {
|
||||||
t.Fatal("expected to find agent with normalized ID")
|
t.Fatal("expected to find agent with normalized ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
if agent.ID != "my-agent" {
|
if agent.ID != "my-agent" {
|
||||||
t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID)
|
t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID)
|
||||||
}
|
}
|
||||||
|
|
@ -121,16 +100,12 @@ func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
|
||||||
func TestAgentRegistry_GetDefaultAgent(t *testing.T) {
|
func TestAgentRegistry_GetDefaultAgent(t *testing.T) {
|
||||||
cfg := testCfg([]config.AgentConfig{
|
cfg := testCfg([]config.AgentConfig{
|
||||||
{ID: "alpha"},
|
{ID: "alpha"},
|
||||||
|
|
||||||
{ID: "beta", Default: true},
|
{ID: "beta", Default: true},
|
||||||
})
|
})
|
||||||
|
|
||||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
// GetDefaultAgent first checks for "main", then returns any
|
// GetDefaultAgent first checks for "main", then returns any
|
||||||
|
|
||||||
agent := registry.GetDefaultAgent()
|
agent := registry.GetDefaultAgent()
|
||||||
|
|
||||||
if agent == nil {
|
if agent == nil {
|
||||||
t.Fatal("expected a default agent")
|
t.Fatal("expected a default agent")
|
||||||
}
|
}
|
||||||
|
|
@ -139,36 +114,27 @@ func TestAgentRegistry_GetDefaultAgent(t *testing.T) {
|
||||||
func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
|
func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
|
||||||
cfg := testCfg([]config.AgentConfig{
|
cfg := testCfg([]config.AgentConfig{
|
||||||
{
|
{
|
||||||
ID: "parent",
|
ID: "parent",
|
||||||
|
|
||||||
Default: true,
|
Default: true,
|
||||||
|
|
||||||
Subagents: &config.SubagentsConfig{
|
Subagents: &config.SubagentsConfig{
|
||||||
AllowAgents: []string{"child1", "child2"},
|
AllowAgents: []string{"child1", "child2"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{ID: "child1"},
|
{ID: "child1"},
|
||||||
|
|
||||||
{ID: "child2"},
|
{ID: "child2"},
|
||||||
|
|
||||||
{ID: "restricted"},
|
{ID: "restricted"},
|
||||||
})
|
})
|
||||||
|
|
||||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
if !registry.CanSpawnSubagent("parent", "child1") {
|
if !registry.CanSpawnSubagent("parent", "child1") {
|
||||||
t.Error("expected parent to be allowed to spawn child1")
|
t.Error("expected parent to be allowed to spawn child1")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !registry.CanSpawnSubagent("parent", "child2") {
|
if !registry.CanSpawnSubagent("parent", "child2") {
|
||||||
t.Error("expected parent to be allowed to spawn child2")
|
t.Error("expected parent to be allowed to spawn child2")
|
||||||
}
|
}
|
||||||
|
|
||||||
if registry.CanSpawnSubagent("parent", "restricted") {
|
if registry.CanSpawnSubagent("parent", "restricted") {
|
||||||
t.Error("expected parent to NOT be allowed to spawn restricted")
|
t.Error("expected parent to NOT be allowed to spawn restricted")
|
||||||
}
|
}
|
||||||
|
|
||||||
if registry.CanSpawnSubagent("child1", "child2") {
|
if registry.CanSpawnSubagent("child1", "child2") {
|
||||||
t.Error("expected child1 to NOT be allowed to spawn (no subagents config)")
|
t.Error("expected child1 to NOT be allowed to spawn (no subagents config)")
|
||||||
}
|
}
|
||||||
|
|
@ -177,24 +143,19 @@ func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
|
||||||
func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
|
func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
|
||||||
cfg := testCfg([]config.AgentConfig{
|
cfg := testCfg([]config.AgentConfig{
|
||||||
{
|
{
|
||||||
ID: "admin",
|
ID: "admin",
|
||||||
|
|
||||||
Default: true,
|
Default: true,
|
||||||
|
|
||||||
Subagents: &config.SubagentsConfig{
|
Subagents: &config.SubagentsConfig{
|
||||||
AllowAgents: []string{"*"},
|
AllowAgents: []string{"*"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{ID: "any-agent"},
|
{ID: "any-agent"},
|
||||||
})
|
})
|
||||||
|
|
||||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
if !registry.CanSpawnSubagent("admin", "any-agent") {
|
if !registry.CanSpawnSubagent("admin", "any-agent") {
|
||||||
t.Error("expected wildcard to allow spawning any agent")
|
t.Error("expected wildcard to allow spawning any agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !registry.CanSpawnSubagent("admin", "nonexistent") {
|
if !registry.CanSpawnSubagent("admin", "nonexistent") {
|
||||||
t.Error("expected wildcard to allow spawning even nonexistent agents")
|
t.Error("expected wildcard to allow spawning even nonexistent agents")
|
||||||
}
|
}
|
||||||
|
|
@ -202,15 +163,12 @@ func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
|
||||||
|
|
||||||
func TestAgentInstance_Model(t *testing.T) {
|
func TestAgentInstance_Model(t *testing.T) {
|
||||||
model := &config.AgentModelConfig{Primary: "claude-opus"}
|
model := &config.AgentModelConfig{Primary: "claude-opus"}
|
||||||
|
|
||||||
cfg := testCfg([]config.AgentConfig{
|
cfg := testCfg([]config.AgentConfig{
|
||||||
{ID: "custom", Default: true, Model: model},
|
{ID: "custom", Default: true, Model: model},
|
||||||
})
|
})
|
||||||
|
|
||||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
agent, _ := registry.GetAgent("custom")
|
agent, _ := registry.GetAgent("custom")
|
||||||
|
|
||||||
if agent.Model != "claude-opus" {
|
if agent.Model != "claude-opus" {
|
||||||
t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model)
|
t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model)
|
||||||
}
|
}
|
||||||
|
|
@ -220,13 +178,10 @@ func TestAgentInstance_FallbackInheritance(t *testing.T) {
|
||||||
cfg := testCfg([]config.AgentConfig{
|
cfg := testCfg([]config.AgentConfig{
|
||||||
{ID: "inherit", Default: true},
|
{ID: "inherit", Default: true},
|
||||||
})
|
})
|
||||||
|
|
||||||
cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"}
|
cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"}
|
||||||
|
|
||||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
agent, _ := registry.GetAgent("inherit")
|
agent, _ := registry.GetAgent("inherit")
|
||||||
|
|
||||||
if len(agent.Fallbacks) != 2 {
|
if len(agent.Fallbacks) != 2 {
|
||||||
t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks))
|
t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks))
|
||||||
}
|
}
|
||||||
|
|
@ -234,22 +189,16 @@ func TestAgentInstance_FallbackInheritance(t *testing.T) {
|
||||||
|
|
||||||
func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
|
func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
|
||||||
model := &config.AgentModelConfig{
|
model := &config.AgentModelConfig{
|
||||||
Primary: "gpt-4",
|
Primary: "gpt-4",
|
||||||
|
|
||||||
Fallbacks: []string{}, // explicitly empty = disable
|
Fallbacks: []string{}, // explicitly empty = disable
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := testCfg([]config.AgentConfig{
|
cfg := testCfg([]config.AgentConfig{
|
||||||
{ID: "no-fallback", Default: true, Model: model},
|
{ID: "no-fallback", Default: true, Model: model},
|
||||||
})
|
})
|
||||||
|
|
||||||
cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"}
|
cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"}
|
||||||
|
|
||||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||||
|
|
||||||
agent, _ := registry.GetAgent("no-fallback")
|
agent, _ := registry.GetAgent("no-fallback")
|
||||||
|
|
||||||
if len(agent.Fallbacks) != 0 {
|
if len(agent.Fallbacks) != 0 {
|
||||||
t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks)
|
t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,15 @@ type InboundMessage struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type OutboundMessage struct {
|
type OutboundMessage struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
IsStatus bool `json:"is_status,omitempty"`
|
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
|
||||||
IsTaskStatus bool `json:"is_task_status,omitempty"`
|
IsStatus bool `json:"is_status,omitempty"`
|
||||||
TaskID string `json:"task_id,omitempty"`
|
IsTaskStatus bool `json:"is_task_status,omitempty"`
|
||||||
Final bool `json:"final,omitempty"` // Finalize: send as permanent message, not draft
|
TaskID string `json:"task_id,omitempty"`
|
||||||
SkipPlaceholder bool `json:"skip_placeholder,omitempty"`
|
Final bool `json:"final,omitempty"` // Finalize: send as permanent message, not draft
|
||||||
|
SkipPlaceholder bool `json:"skip_placeholder,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MediaPart describes a single media attachment to send.
|
// MediaPart describes a single media attachment to send.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -32,6 +33,9 @@ func init() {
|
||||||
uniqueIDPrefix = hex.EncodeToString(b[:])
|
uniqueIDPrefix = hex.EncodeToString(b[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// audioAnnotationRe matches audio/voice annotations injected by channels (e.g. [voice], [audio: file.ogg]).
|
||||||
|
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
|
||||||
|
|
||||||
// uniqueID generates a process-unique ID using a random prefix and an atomic counter.
|
// uniqueID generates a process-unique ID using a random prefix and an atomic counter.
|
||||||
// This ID is intended for internal correlation (e.g. media scope keys) and is NOT
|
// This ID is intended for internal correlation (e.g. media scope keys) and is NOT
|
||||||
// cryptographically secure — it must not be used in contexts where unpredictability matters.
|
// cryptographically secure — it must not be used in contexts where unpredictability matters.
|
||||||
|
|
@ -285,10 +289,10 @@ func (c *BaseChannel) HandleMessage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Placeholder — independent pipeline.
|
// Placeholder — independent pipeline.
|
||||||
// Skip for DraftSender channels: the streaming draft bubble serves
|
// Skip when the message contains audio: the agent will send the
|
||||||
// as the placeholder, and sendMessage on final response replaces it.
|
// placeholder after transcription completes, so the user sees
|
||||||
// Sending both a placeholder AND drafts causes duplicate chat bubbles.
|
// "Thinking…" only once the voice has been processed.
|
||||||
if _, isDrafter := c.owner.(DraftSender); !isDrafter {
|
if !audioAnnotationRe.MatchString(content) {
|
||||||
if pc, ok := c.owner.(PlaceholderCapable); ok {
|
if pc, ok := c.owner.(PlaceholderCapable); ok {
|
||||||
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
|
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
|
||||||
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
|
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
|
|
||||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
||||||
|
dinglog "github.com/open-dingtalk/dingtalk-stream-sdk-go/logger"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
|
@ -39,6 +40,9 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
|
||||||
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
|
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the logger for the Stream SDK
|
||||||
|
dinglog.SetLogger(logger.NewLogger("dingtalk"))
|
||||||
|
|
||||||
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
|
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
|
||||||
channels.WithMaxMessageLength(20000),
|
channels.WithMaxMessageLength(20000),
|
||||||
channels.WithGroupTrigger(cfg.GroupTrigger),
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,14 @@ type DiscordChannel struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
|
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
|
||||||
|
discordgo.Logger = logger.NewLogger("discord").
|
||||||
|
WithLevels(map[int]logger.LogLevel{
|
||||||
|
discordgo.LogError: logger.ERROR,
|
||||||
|
discordgo.LogWarning: logger.WARN,
|
||||||
|
discordgo.LogInformational: logger.INFO,
|
||||||
|
discordgo.LogDebug: logger.DEBUG,
|
||||||
|
}).Log
|
||||||
|
|
||||||
session, err := discordgo.New("Bot " + cfg.Token)
|
session, err := discordgo.New("Bot " + cfg.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
||||||
|
|
@ -134,7 +142,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.sendChunk(ctx, channelID, msg.Content)
|
return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMedia implements the channels.MediaSender interface.
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
|
|
@ -232,42 +240,6 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendWithID implements channels.MessageSenderWithID.
|
|
||||||
// It sends a message and returns the platform message ID.
|
|
||||||
func (c *DiscordChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) {
|
|
||||||
if !c.IsRunning() {
|
|
||||||
return "", channels.ErrNotRunning
|
|
||||||
}
|
|
||||||
|
|
||||||
if chatID == "" {
|
|
||||||
return "", fmt.Errorf("channel ID is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
type result struct {
|
|
||||||
id string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
done := make(chan result, 1)
|
|
||||||
go func() {
|
|
||||||
msg, err := c.session.ChannelMessageSend(chatID, content)
|
|
||||||
if err != nil {
|
|
||||||
done <- result{"", fmt.Errorf("discord send: %w", channels.ErrTemporary)}
|
|
||||||
} else {
|
|
||||||
done <- result{msg.ID, nil}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case r := <-done:
|
|
||||||
return r.id, r.err
|
|
||||||
case <-sendCtx.Done():
|
|
||||||
return "", sendCtx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// EditMessage implements channels.MessageEditor.
|
// EditMessage implements channels.MessageEditor.
|
||||||
func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||||
_, err := c.session.ChannelMessageEdit(chatID, messageID, content)
|
_, err := c.session.ChannelMessageEdit(chatID, messageID, content)
|
||||||
|
|
@ -295,14 +267,29 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st
|
||||||
return msg.ID, nil
|
return msg.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
|
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error {
|
||||||
// Use the passed ctx for timeout control
|
// Use the passed ctx for timeout control
|
||||||
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
_, err := c.session.ChannelMessageSend(channelID, content)
|
var err error
|
||||||
|
|
||||||
|
// If we have an ID, we send the message as "Reply"
|
||||||
|
if replyToID != "" {
|
||||||
|
_, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
|
||||||
|
Content: content,
|
||||||
|
Reference: &discordgo.MessageReference{
|
||||||
|
MessageID: replyToID,
|
||||||
|
ChannelID: channelID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Otherwise, we send a normal message
|
||||||
|
_, err = c.session.ChannelMessageSend(channelID, content)
|
||||||
|
}
|
||||||
|
|
||||||
done <- err
|
done <- err
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,10 @@ package feishu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/big"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -201,18 +200,13 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
|
||||||
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
||||||
// Get emoji list from config
|
// Get emoji list from config
|
||||||
emojiList := c.config.RandomReactionEmoji
|
emojiList := c.config.RandomReactionEmoji
|
||||||
|
var chosenEmoji string
|
||||||
if len(emojiList) == 0 {
|
if len(emojiList) == 0 {
|
||||||
// Default to "Pin" if no config
|
// Default to "Pin" if no config
|
||||||
emojiList = []string{"Pin"}
|
chosenEmoji = "Pin"
|
||||||
}
|
|
||||||
|
|
||||||
// Randomly choose one from the list using crypto/rand for better distribution
|
|
||||||
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(emojiList))))
|
|
||||||
var chosenEmoji string
|
|
||||||
if err != nil {
|
|
||||||
chosenEmoji = emojiList[0]
|
|
||||||
} else {
|
} else {
|
||||||
chosenEmoji = emojiList[idx.Int64()]
|
idx := rand.Intn(len(emojiList))
|
||||||
|
chosenEmoji = emojiList[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
req := larkim.NewCreateMessageReactionReqBuilder().
|
req := larkim.NewCreateMessageReactionReqBuilder().
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,6 @@ type ReactionCapable interface {
|
||||||
ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)
|
ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageSenderWithID — channels that can send a message and return its platform-specific ID.
|
|
||||||
// Used by Manager to track status/task messages for later editing.
|
|
||||||
type MessageSenderWithID interface {
|
|
||||||
SendWithID(ctx context.Context, chatID string, content string) (messageID string, err error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlaceholderCapable — channels that can send a placeholder message
|
// PlaceholderCapable — channels that can send a placeholder message
|
||||||
// (e.g. "Thinking... 💭") that will later be edited to the actual response.
|
// (e.g. "Thinking... 💭") that will later be edited to the actual response.
|
||||||
// The channel MUST also implement MessageEditor for the placeholder to be useful.
|
// The channel MUST also implement MessageEditor for the placeholder to be useful.
|
||||||
|
|
@ -41,13 +35,6 @@ type PlaceholderCapable interface {
|
||||||
SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)
|
SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DraftSender — channels that can send progressive draft messages.
|
|
||||||
// Used for streaming LLM output without the "edited" indicator.
|
|
||||||
// draftID must be non-zero and consistent across updates for the same draft.
|
|
||||||
type DraftSender interface {
|
|
||||||
SendDraft(ctx context.Context, chatID string, draftID int, content string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlaceholderRecorder is injected into channels by Manager.
|
// PlaceholderRecorder is injected into channels by Manager.
|
||||||
// Channels call these methods on inbound to register typing/placeholder state.
|
// Channels call these methods on inbound to register typing/placeholder state.
|
||||||
// Manager uses the registered state on outbound to stop typing and edit placeholders.
|
// Manager uses the registered state on outbound to stop typing and edit placeholders.
|
||||||
|
|
|
||||||
16
pkg/channels/interfaces_ext.go
Normal file
16
pkg/channels/interfaces_ext.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// MessageSenderWithID — channels that can send a message and return its platform-specific ID.
|
||||||
|
// Used by Manager to track status/task messages for later editing.
|
||||||
|
type MessageSenderWithID interface {
|
||||||
|
SendWithID(ctx context.Context, chatID string, content string) (messageID string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DraftSender — channels that can send progressive draft messages.
|
||||||
|
// Used for streaming LLM output without the "edited" indicator.
|
||||||
|
// draftID must be non-zero and consistent across updates for the same draft.
|
||||||
|
type DraftSender interface {
|
||||||
|
SendDraft(ctx context.Context, chatID string, draftID int, content string) error
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,10 @@ const (
|
||||||
lineBotInfoEndpoint = lineAPIBase + "/info"
|
lineBotInfoEndpoint = lineAPIBase + "/info"
|
||||||
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
|
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
|
||||||
lineReplyTokenMaxAge = 25 * time.Second
|
lineReplyTokenMaxAge = 25 * time.Second
|
||||||
|
|
||||||
|
// Limit request body to prevent memory exhaustion (DoS).
|
||||||
|
// LINE webhook payloads are typically a few KB; 1 MiB is generous.
|
||||||
|
maxWebhookBodySize = 1 << 20 // 1 MiB
|
||||||
)
|
)
|
||||||
|
|
||||||
type replyTokenEntry struct {
|
type replyTokenEntry struct {
|
||||||
|
|
@ -166,7 +170,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("line", "Failed to read request body", map[string]any{
|
logger.ErrorCF("line", "Failed to read request body", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -174,6 +178,11 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if int64(len(body)) > maxWebhookBodySize {
|
||||||
|
logger.WarnC("line", "Webhook request body too large, rejected")
|
||||||
|
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
signature := r.Header.Get("X-Line-Signature")
|
signature := r.Header.Get("X-Line-Signature")
|
||||||
if !c.verifySignature(body, signature) {
|
if !c.verifySignature(body, signature) {
|
||||||
|
|
|
||||||
81
pkg/channels/line/line_test.go
Normal file
81
pkg/channels/line/line_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package line
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWebhookRejectsOversizedBody(t *testing.T) {
|
||||||
|
ch := &LINEChannel{}
|
||||||
|
|
||||||
|
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.webhookHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookAcceptsMaxBodySize(t *testing.T) {
|
||||||
|
ch := &LINEChannel{}
|
||||||
|
|
||||||
|
body := bytes.Repeat([]byte("A"), maxWebhookBodySize)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.webhookHandler(rec, req)
|
||||||
|
|
||||||
|
// Missing signature should be rejected, but the body size should not trigger 413.
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
|
||||||
|
ch := &LINEChannel{}
|
||||||
|
|
||||||
|
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
|
||||||
|
req.Header.Set("X-Line-Signature", "invalidsignature")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.webhookHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookRejectsNonPostMethod(t *testing.T) {
|
||||||
|
ch := &LINEChannel{}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.webhookHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookRejectsInvalidSignature(t *testing.T) {
|
||||||
|
ch := &LINEChannel{}
|
||||||
|
|
||||||
|
body := `{"events":[]}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
|
||||||
|
req.Header.Set("X-Line-Signature", "invalidsignature")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.webhookHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -79,6 +79,7 @@ var channelRateConfig = map[string]float64{
|
||||||
"slack": 1,
|
"slack": 1,
|
||||||
"matrix": 2,
|
"matrix": 2,
|
||||||
"line": 10,
|
"line": 10,
|
||||||
|
"qq": 5,
|
||||||
"irc": 2,
|
"irc": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,6 +119,27 @@ func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
|
||||||
m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()})
|
m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendPlaceholder sends a "Thinking..." placeholder for the given channel/chatID
|
||||||
|
// and records it for later editing. Returns true if a placeholder was sent.
|
||||||
|
func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
ch, ok := m.channels[channel]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pc, ok := ch.(PlaceholderCapable)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
phID, err := pc.SendPlaceholder(ctx, chatID)
|
||||||
|
if err != nil || phID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m.RecordPlaceholder(channel, chatID, phID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// RecordTypingStop registers a typing stop function for later invocation.
|
// RecordTypingStop registers a typing stop function for later invocation.
|
||||||
// Implements PlaceholderRecorder.
|
// Implements PlaceholderRecorder.
|
||||||
//
|
//
|
||||||
|
|
@ -127,12 +149,12 @@ func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
|
||||||
// consuming the *new* message's typing entry.
|
// consuming the *new* message's typing entry.
|
||||||
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
|
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
|
||||||
key := channel + ":" + chatID
|
key := channel + ":" + chatID
|
||||||
if v, loaded := m.typingStops.Load(key); loaded {
|
entry := typingEntry{stop: stop, createdAt: time.Now()}
|
||||||
if entry, ok := v.(typingEntry); ok {
|
if previous, loaded := m.typingStops.Swap(key, entry); loaded {
|
||||||
entry.stop() // idempotent
|
if oldEntry, ok := previous.(typingEntry); ok && oldEntry.stop != nil {
|
||||||
|
oldEntry.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordReactionUndo registers a reaction undo function for later invocation.
|
// RecordReactionUndo registers a reaction undo function for later invocation.
|
||||||
|
|
@ -1122,6 +1144,39 @@ func (m *Manager) UnregisterChannel(name string) {
|
||||||
delete(m.channels, name)
|
delete(m.channels, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendMessage sends an outbound message synchronously through the channel
|
||||||
|
// worker's rate limiter and retry logic. It blocks until the message is
|
||||||
|
// delivered (or all retries are exhausted), which preserves ordering when
|
||||||
|
// a subsequent operation depends on the message having been sent.
|
||||||
|
func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
m.mu.RLock()
|
||||||
|
_, exists := m.channels[msg.Channel]
|
||||||
|
w, wExists := m.workers[msg.Channel]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("channel %s not found", msg.Channel)
|
||||||
|
}
|
||||||
|
if !wExists || w == nil {
|
||||||
|
return fmt.Errorf("channel %s has no active worker", msg.Channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxLen := 0
|
||||||
|
if mlp, ok := w.ch.(MessageLengthProvider); ok {
|
||||||
|
maxLen = mlp.MaxMessageLength()
|
||||||
|
}
|
||||||
|
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
|
||||||
|
for _, chunk := range SplitMessage(msg.Content, maxLen) {
|
||||||
|
chunkMsg := msg
|
||||||
|
chunkMsg.Content = chunk
|
||||||
|
m.sendWithRetry(ctx, msg.Channel, w, chunkMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.sendWithRetry(ctx, msg.Channel, w, msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
|
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
_, exists := m.channels[channelName]
|
_, exists := m.channels[channelName]
|
||||||
|
|
|
||||||
895
pkg/channels/manager_ext_test.go
Normal file
895
pkg/channels/manager_ext_test.go
Normal file
|
|
@ -0,0 +1,895 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockEditorWithSendID implements MessageEditor and MessageSenderWithID.
|
||||||
|
type mockEditorWithSendID struct {
|
||||||
|
mockChannel
|
||||||
|
editFn func(ctx context.Context, chatID, messageID, content string) error
|
||||||
|
sendWithID func(ctx context.Context, chatID, content string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockEditorWithSendID) EditMessage(
|
||||||
|
ctx context.Context, chatID, messageID, content string,
|
||||||
|
) error {
|
||||||
|
return m.editFn(ctx, chatID, messageID, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockEditorWithSendID) SendWithID(ctx context.Context, chatID, content string) (string, error) {
|
||||||
|
return m.sendWithID(ctx, chatID, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusSend_EditsPlaceholder(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var editCalled bool
|
||||||
|
var editedContent string
|
||||||
|
|
||||||
|
ch := &mockEditorWithSendID{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, messageID, content string) error {
|
||||||
|
editCalled = true
|
||||||
|
editedContent = content
|
||||||
|
if messageID != "ph-42" {
|
||||||
|
t.Fatalf("expected messageID ph-42, got %s", messageID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
t.Fatal("SendWithID should not be called when placeholder exists")
|
||||||
|
return "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||||
|
|
||||||
|
m.RecordPlaceholder("test", "123", "ph-42")
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "status update 1", IsStatus: true}
|
||||||
|
m.handleStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if !editCalled {
|
||||||
|
t.Fatal("expected EditMessage to be called on placeholder")
|
||||||
|
}
|
||||||
|
if editedContent != "status update 1" {
|
||||||
|
t.Fatalf("expected content 'status update 1', got %s", editedContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusSend_EditsTrackedStatus(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var editCalled bool
|
||||||
|
|
||||||
|
ch := &mockEditorWithSendID{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, messageID, _ string) error {
|
||||||
|
editCalled = true
|
||||||
|
if messageID != "status-99" {
|
||||||
|
t.Fatalf("expected messageID status-99, got %s", messageID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
t.Fatal("SendWithID should not be called when statusMsgID exists")
|
||||||
|
return "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||||
|
|
||||||
|
m.statusMsgIDs.Store("test:123", statusMsgEntry{messageID: "status-99", createdAt: time.Now()})
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "update 2", IsStatus: true}
|
||||||
|
m.handleStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if !editCalled {
|
||||||
|
t.Fatal("expected EditMessage to be called on tracked status message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusSend_SendsNewAndTracks(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var sendWithIDCalled bool
|
||||||
|
|
||||||
|
ch := &mockEditorWithSendID{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
sendWithID: func(_ context.Context, chatID, content string) (string, error) {
|
||||||
|
sendWithIDCalled = true
|
||||||
|
if chatID != "123" {
|
||||||
|
t.Fatalf("expected chatID 123, got %s", chatID)
|
||||||
|
}
|
||||||
|
return "new-msg-1", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "first status", IsStatus: true}
|
||||||
|
m.handleStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if !sendWithIDCalled {
|
||||||
|
t.Fatal("expected SendWithID to be called")
|
||||||
|
}
|
||||||
|
|
||||||
|
v, ok := m.statusMsgIDs.Load("test:123")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected statusMsgIDs to contain tracked entry")
|
||||||
|
}
|
||||||
|
entry := v.(statusMsgEntry)
|
||||||
|
if entry.messageID != "new-msg-1" {
|
||||||
|
t.Fatalf("expected messageID new-msg-1, got %s", entry.messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTaskStatusSend_EditsExisting(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var editCalled bool
|
||||||
|
|
||||||
|
ch := &mockEditorWithSendID{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, messageID, content string) error {
|
||||||
|
editCalled = true
|
||||||
|
if messageID != "task-msg-1" {
|
||||||
|
t.Fatalf("expected messageID task-msg-1, got %s", messageID)
|
||||||
|
}
|
||||||
|
if content != "task progress 50%" {
|
||||||
|
t.Fatalf("expected content 'task progress 50%%', got %s", content)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
t.Fatal("SendWithID should not be called when task message exists")
|
||||||
|
return "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||||
|
|
||||||
|
m.taskMsgIDs.Store(
|
||||||
|
taskStatusKey("test", "123", "task-abc"),
|
||||||
|
statusMsgEntry{messageID: "task-msg-1", createdAt: time.Now()},
|
||||||
|
)
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
Content: "task progress 50%",
|
||||||
|
IsTaskStatus: true,
|
||||||
|
TaskID: "task-abc",
|
||||||
|
}
|
||||||
|
m.handleTaskStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if !editCalled {
|
||||||
|
t.Fatal("expected EditMessage to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTaskStatusSend_SendsNewAndTracks(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var sendWithIDCalled bool
|
||||||
|
|
||||||
|
ch := &mockEditorWithSendID{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error { return nil },
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
sendWithIDCalled = true
|
||||||
|
return "new-task-msg", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
Content: "task started",
|
||||||
|
IsTaskStatus: true,
|
||||||
|
TaskID: "task-xyz",
|
||||||
|
}
|
||||||
|
m.handleTaskStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if !sendWithIDCalled {
|
||||||
|
t.Fatal("expected SendWithID to be called")
|
||||||
|
}
|
||||||
|
|
||||||
|
v, ok := m.taskMsgIDs.Load(taskStatusKey("test", "123", "task-xyz"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected taskMsgIDs to contain tracked entry")
|
||||||
|
}
|
||||||
|
entry := v.(statusMsgEntry)
|
||||||
|
if entry.messageID != "new-task-msg" {
|
||||||
|
t.Fatalf("expected messageID new-task-msg, got %s", entry.messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTaskStatusSend_FallbackToSend(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var sendCalled bool
|
||||||
|
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
|
||||||
|
sendCalled = true
|
||||||
|
if msg.Content != "task status" {
|
||||||
|
t.Fatalf("expected content 'task status', got %s", msg.Content)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
Content: "task status",
|
||||||
|
IsTaskStatus: true,
|
||||||
|
TaskID: "task-fallback",
|
||||||
|
}
|
||||||
|
m.handleTaskStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if !sendCalled {
|
||||||
|
t.Fatal("expected fallback Send to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreSend_EditsStatusMessage(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var editCalled bool
|
||||||
|
|
||||||
|
ch := &mockMessageEditor{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, messageID, _ string) error {
|
||||||
|
editCalled = true
|
||||||
|
if messageID != "status-msg-77" {
|
||||||
|
t.Fatalf("expected messageID status-msg-77, got %s", messageID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.statusMsgIDs.Store("test:123", statusMsgEntry{messageID: "status-msg-77", createdAt: time.Now()})
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"}
|
||||||
|
edited := m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
|
||||||
|
if !edited {
|
||||||
|
t.Fatal("expected preSend to return true (status message edited)")
|
||||||
|
}
|
||||||
|
if !editCalled {
|
||||||
|
t.Fatal("expected EditMessage to be called")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, loaded := m.statusMsgIDs.Load("test:123"); loaded {
|
||||||
|
t.Fatal("expected statusMsgIDs entry to be deleted after preSend")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWorker_RoutesStatusMessages(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
var regularSendCount atomic.Int32
|
||||||
|
var sendWithIDCount atomic.Int32
|
||||||
|
|
||||||
|
ch := &mockEditorWithSendID{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
regularSendCount.Add(1)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
sendWithIDCount.Add(1)
|
||||||
|
return "tracked-1", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
queue: make(chan bus.OutboundMessage, 10),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go m.runWorker(ctx, "test", w)
|
||||||
|
|
||||||
|
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "status", IsStatus: true}
|
||||||
|
|
||||||
|
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "2", Content: "task", IsTaskStatus: true, TaskID: "t1"}
|
||||||
|
|
||||||
|
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "3", Content: "hello"}
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
if regularSendCount.Load() != 1 {
|
||||||
|
t.Fatalf("expected 1 regular Send call, got %d", regularSendCount.Load())
|
||||||
|
}
|
||||||
|
if sendWithIDCount.Load() != 2 {
|
||||||
|
t.Fatalf("expected 2 SendWithID calls (status + task), got %d", sendWithIDCount.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusMsgTTLJanitor(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
m.statusMsgIDs.Store("test:old", statusMsgEntry{
|
||||||
|
messageID: "old-status",
|
||||||
|
createdAt: time.Now().Add(-10 * time.Minute),
|
||||||
|
})
|
||||||
|
m.taskMsgIDs.Store("task-old", statusMsgEntry{
|
||||||
|
messageID: "old-task",
|
||||||
|
createdAt: time.Now().Add(-60 * time.Minute),
|
||||||
|
})
|
||||||
|
|
||||||
|
m.statusMsgIDs.Store("test:fresh", statusMsgEntry{
|
||||||
|
messageID: "fresh-status",
|
||||||
|
createdAt: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
m.statusMsgIDs.Range(func(key, value any) bool {
|
||||||
|
if entry, ok := value.(statusMsgEntry); ok {
|
||||||
|
if now.Sub(entry.createdAt) > statusMsgTTL {
|
||||||
|
m.statusMsgIDs.Delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
m.taskMsgIDs.Range(func(key, value any) bool {
|
||||||
|
if entry, ok := value.(statusMsgEntry); ok {
|
||||||
|
if now.Sub(entry.createdAt) > taskMsgTTL {
|
||||||
|
m.taskMsgIDs.Delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, loaded := m.statusMsgIDs.Load("test:old"); loaded {
|
||||||
|
t.Fatal("expected old status entry to be evicted")
|
||||||
|
}
|
||||||
|
if _, loaded := m.taskMsgIDs.Load("task-old"); loaded {
|
||||||
|
t.Fatal("expected old task entry to be evicted")
|
||||||
|
}
|
||||||
|
if _, loaded := m.statusMsgIDs.Load("test:fresh"); !loaded {
|
||||||
|
t.Fatal("expected fresh status entry to survive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockDraftSender implements DraftSender + MessageSenderWithID + MessageEditor.
|
||||||
|
type mockDraftSender struct {
|
||||||
|
mockChannel
|
||||||
|
draftFn func(ctx context.Context, chatID string, draftID int, content string) error
|
||||||
|
editFn func(ctx context.Context, chatID, messageID, content string) error
|
||||||
|
sendWithID func(ctx context.Context, chatID, content string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDraftSender) EditMessage(
|
||||||
|
ctx context.Context, chatID, messageID, content string,
|
||||||
|
) error {
|
||||||
|
return m.editFn(ctx, chatID, messageID, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDraftSender) SendDraft(ctx context.Context, chatID string, draftID int, content string) error {
|
||||||
|
return m.draftFn(ctx, chatID, draftID, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDraftSender) SendWithID(ctx context.Context, chatID, content string) (string, error) {
|
||||||
|
return m.sendWithID(ctx, chatID, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusSend_UsesDraftSender(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var draftCalled bool
|
||||||
|
var draftContent string
|
||||||
|
var draftDID int
|
||||||
|
|
||||||
|
ch := &mockDraftSender{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
draftFn: func(_ context.Context, chatID string, draftID int, content string) error {
|
||||||
|
draftCalled = true
|
||||||
|
draftContent = content
|
||||||
|
draftDID = draftID
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error {
|
||||||
|
t.Fatal("EditMessage should not be called when draft succeeds")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
t.Fatal("SendWithID should not be called when draft succeeds")
|
||||||
|
return "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "streaming preview", IsStatus: true}
|
||||||
|
m.handleStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if !draftCalled {
|
||||||
|
t.Fatal("expected SendDraft to be called")
|
||||||
|
}
|
||||||
|
if draftContent != "streaming preview" {
|
||||||
|
t.Fatalf("expected draft content 'streaming preview', got %s", draftContent)
|
||||||
|
}
|
||||||
|
if draftDID == 0 {
|
||||||
|
t.Fatal("expected non-zero draftID")
|
||||||
|
}
|
||||||
|
|
||||||
|
draftCalled = false
|
||||||
|
var secondDID int
|
||||||
|
ch.draftFn = func(_ context.Context, _ string, draftID int, _ string) error {
|
||||||
|
draftCalled = true
|
||||||
|
secondDID = draftID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
msg.Content = "streaming preview updated"
|
||||||
|
m.handleStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if !draftCalled {
|
||||||
|
t.Fatal("expected SendDraft to be called again")
|
||||||
|
}
|
||||||
|
if secondDID != draftDID {
|
||||||
|
t.Fatalf("expected same draftID %d, got %d", draftDID, secondDID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusSend_DraftFails_FallsToEdit(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var editCalled bool
|
||||||
|
|
||||||
|
ch := &mockDraftSender{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
draftFn: func(_ context.Context, _ string, _ int, _ string) error {
|
||||||
|
return fmt.Errorf("draft not supported in group")
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error {
|
||||||
|
editCalled = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
return "msg-1", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "preview", IsStatus: true}
|
||||||
|
m.handleStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if editCalled {
|
||||||
|
t.Fatal("expected EditMessage NOT to be called (no placeholder)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusSend_DraftFailure_DoesNotClobberTrackedMessageID(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var sendWithIDCount int
|
||||||
|
var editCount int
|
||||||
|
var editedMessageID string
|
||||||
|
|
||||||
|
ch := &mockDraftSender{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
draftFn: func(_ context.Context, _ string, _ int, _ string) error {
|
||||||
|
return fmt.Errorf("draft unsupported")
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, messageID, _ string) error {
|
||||||
|
editCount++
|
||||||
|
editedMessageID = messageID
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
sendWithIDCount++
|
||||||
|
return "msg-1", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "group-main", Content: "preview-1", IsStatus: true}
|
||||||
|
m.handleStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
msg.Content = "preview-2"
|
||||||
|
m.handleStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if sendWithIDCount != 1 {
|
||||||
|
t.Fatalf("expected SendWithID to be called once, got %d", sendWithIDCount)
|
||||||
|
}
|
||||||
|
if editCount != 1 {
|
||||||
|
t.Fatalf("expected EditMessage to be called once, got %d", editCount)
|
||||||
|
}
|
||||||
|
if editedMessageID != "msg-1" {
|
||||||
|
t.Fatalf("expected EditMessage target msg-1, got %s", editedMessageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTaskStatusSend_UsesDraftSender(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var draftCalled bool
|
||||||
|
|
||||||
|
ch := &mockDraftSender{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
draftFn: func(_ context.Context, _ string, _ int, _ string) error {
|
||||||
|
draftCalled = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error {
|
||||||
|
t.Fatal("EditMessage should not be called when draft succeeds")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
t.Fatal("SendWithID should not be called when draft succeeds")
|
||||||
|
return "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
Content: "task progress 50%",
|
||||||
|
IsTaskStatus: true,
|
||||||
|
TaskID: "task-draft",
|
||||||
|
}
|
||||||
|
m.handleTaskStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if !draftCalled {
|
||||||
|
t.Fatal("expected SendDraft to be called for task status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTaskStatusSend_Final_UpdatesDraftInPlace(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var draftUpdateCalled bool
|
||||||
|
var draftUpdateDraftID int
|
||||||
|
var draftUpdateContent string
|
||||||
|
|
||||||
|
ch := &mockDraftSender{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
t.Fatal("Send should not be called when draft update succeeds")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
draftFn: func(_ context.Context, chatID string, draftID int, content string) error {
|
||||||
|
draftUpdateCalled = true
|
||||||
|
draftUpdateDraftID = draftID
|
||||||
|
draftUpdateContent = content
|
||||||
|
if chatID != "123" {
|
||||||
|
t.Fatalf("expected chatID 123, got %s", chatID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error { return nil },
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
t.Fatal("SendWithID should not be called when draft update succeeds")
|
||||||
|
return "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||||
|
|
||||||
|
m.taskMsgIDs.Store(taskStatusKey("test", "123", "task-final"), statusMsgEntry{draftID: 42, createdAt: time.Now()})
|
||||||
|
m.statusEditTimes.Store(taskStatusKey("test", "123", "task-final"), time.Now())
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
Content: "task completed",
|
||||||
|
IsTaskStatus: true,
|
||||||
|
TaskID: "task-final",
|
||||||
|
Final: true,
|
||||||
|
}
|
||||||
|
m.handleTaskStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if !draftUpdateCalled {
|
||||||
|
t.Fatal("expected SendDraft to update draft with final content")
|
||||||
|
}
|
||||||
|
if draftUpdateDraftID != 42 {
|
||||||
|
t.Fatalf("expected draftID 42, got %d", draftUpdateDraftID)
|
||||||
|
}
|
||||||
|
if draftUpdateContent != "task completed" {
|
||||||
|
t.Fatalf("expected draft content 'task completed', got %q", draftUpdateContent)
|
||||||
|
}
|
||||||
|
if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "123", "task-final")); loaded {
|
||||||
|
t.Fatal("expected taskMsgIDs entry to be deleted for final task status")
|
||||||
|
}
|
||||||
|
if _, loaded := m.statusEditTimes.Load(taskStatusKey("test", "123", "task-final")); loaded {
|
||||||
|
t.Fatal("expected statusEditTimes entry to be deleted for final task status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTaskStatusSend_DraftStreaming_IsolatedByChatThread(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
type draftCall struct {
|
||||||
|
chatID string
|
||||||
|
draftID int
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
calls := make([]draftCall, 0, 2)
|
||||||
|
|
||||||
|
ch := &mockDraftSender{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
draftFn: func(_ context.Context, chatID string, draftID int, content string) error {
|
||||||
|
calls = append(calls, draftCall{chatID: chatID, draftID: draftID, content: content})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error { return nil },
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||||
|
|
||||||
|
msgA := bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "-100/10",
|
||||||
|
Content: "A:10%",
|
||||||
|
IsTaskStatus: true,
|
||||||
|
TaskID: "shared-task",
|
||||||
|
}
|
||||||
|
msgB := bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "-100/20",
|
||||||
|
Content: "B:10%",
|
||||||
|
IsTaskStatus: true,
|
||||||
|
TaskID: "shared-task",
|
||||||
|
}
|
||||||
|
|
||||||
|
m.handleTaskStatusSend(context.Background(), "test", w, msgA)
|
||||||
|
m.handleTaskStatusSend(context.Background(), "test", w, msgB)
|
||||||
|
|
||||||
|
if len(calls) != 2 {
|
||||||
|
t.Fatalf("expected 2 SendDraft calls, got %d", len(calls))
|
||||||
|
}
|
||||||
|
if calls[0].chatID == calls[1].chatID {
|
||||||
|
t.Fatalf("expected different chat threads, got %q and %q", calls[0].chatID, calls[1].chatID)
|
||||||
|
}
|
||||||
|
if calls[0].draftID == calls[1].draftID {
|
||||||
|
t.Fatalf("expected distinct draft IDs per thread key, both got %d", calls[0].draftID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "-100/10", "shared-task")); !loaded {
|
||||||
|
t.Fatal("expected taskMsgIDs entry for thread A")
|
||||||
|
}
|
||||||
|
if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "-100/20", "shared-task")); !loaded {
|
||||||
|
t.Fatal("expected taskMsgIDs entry for thread B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTaskStatusSend_DraftFailure_DoesNotClobberTrackedMessageID(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var sendWithIDCount int
|
||||||
|
var editCount int
|
||||||
|
var editedMessageID string
|
||||||
|
|
||||||
|
ch := &mockDraftSender{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
draftFn: func(_ context.Context, _ string, _ int, _ string) error {
|
||||||
|
return fmt.Errorf("draft unsupported")
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, messageID, _ string) error {
|
||||||
|
editCount++
|
||||||
|
editedMessageID = messageID
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
sendWithIDCount++
|
||||||
|
return "task-msg-1", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "group-main",
|
||||||
|
Content: "task-10%",
|
||||||
|
IsTaskStatus: true,
|
||||||
|
TaskID: "task-1",
|
||||||
|
}
|
||||||
|
m.handleTaskStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
msg.Content = "task-20%"
|
||||||
|
m.handleTaskStatusSend(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if sendWithIDCount != 1 {
|
||||||
|
t.Fatalf("expected SendWithID to be called once, got %d", sendWithIDCount)
|
||||||
|
}
|
||||||
|
if editCount != 1 {
|
||||||
|
t.Fatalf("expected EditMessage to be called once, got %d", editCount)
|
||||||
|
}
|
||||||
|
if editedMessageID != "task-msg-1" {
|
||||||
|
t.Fatalf("expected EditMessage target task-msg-1, got %s", editedMessageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreSend_ClearsDraftState(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
m.statusMsgIDs.Store("test:123", statusMsgEntry{draftID: 42, createdAt: time.Now()})
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"}
|
||||||
|
edited := m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
|
||||||
|
if edited {
|
||||||
|
t.Fatal("expected preSend to return false for draft-based status (sendMessage replaces draft)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, loaded := m.statusMsgIDs.Load("test:123"); loaded {
|
||||||
|
t.Fatal("expected draft status entry to be deleted after preSend")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDraftID_Stable(t *testing.T) {
|
||||||
|
id1 := generateDraftID("telegram:123")
|
||||||
|
id2 := generateDraftID("telegram:123")
|
||||||
|
if id1 != id2 {
|
||||||
|
t.Fatalf("expected stable draft ID, got %d vs %d", id1, id2)
|
||||||
|
}
|
||||||
|
if id1 == 0 {
|
||||||
|
t.Fatal("expected non-zero draft ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
id3 := generateDraftID("telegram:456")
|
||||||
|
if id1 == id3 {
|
||||||
|
t.Fatalf("expected different draft IDs for different keys, both got %d", id1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreSend_DismissesDraftBeforeSend verifies that preSend explicitly
|
||||||
|
// dismisses a draft-based status bubble (via SendDraft with empty text)
|
||||||
|
// before proceeding to send the permanent message. This prevents ghost
|
||||||
|
// draft bubbles when a user message arrives between the last draft update
|
||||||
|
// and the final sendMessage.
|
||||||
|
// TestPreSend_DismissesDraftBeforeSend verifies that preSend explicitly
|
||||||
|
// dismisses a draft-based status bubble (via SendDraft with empty text)
|
||||||
|
// before proceeding to send the permanent message. This prevents ghost
|
||||||
|
// draft bubbles when a user message arrives between the last draft update
|
||||||
|
// and the final sendMessage.
|
||||||
|
func TestPreSend_DismissesDraftBeforeSend(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
var dismissCalled bool
|
||||||
|
var dismissContent string
|
||||||
|
|
||||||
|
ch := &mockDraftSender{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
draftFn: func(_ context.Context, _ string, _ int, content string) error {
|
||||||
|
dismissCalled = true
|
||||||
|
dismissContent = content
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error { return nil },
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
m.statusMsgIDs.Store("test:123", statusMsgEntry{draftID: 42, createdAt: time.Now()})
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"}
|
||||||
|
edited := m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
|
||||||
|
if edited {
|
||||||
|
t.Fatal("expected preSend to return false for draft-based status")
|
||||||
|
}
|
||||||
|
if !dismissCalled {
|
||||||
|
t.Fatal("expected preSend to call SendDraft to dismiss the draft")
|
||||||
|
}
|
||||||
|
if dismissContent != "" {
|
||||||
|
t.Fatalf("expected empty dismiss content, got %q", dismissContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordTypingStop_CleansUpOldEntry verifies that recording a new
|
||||||
|
// typing stop function calls the previous stop first.
|
||||||
|
// TestRecordTypingStop_CleansUpOldEntry verifies that recording a new
|
||||||
|
// typing stop function calls the previous stop first.
|
||||||
|
func TestRecordTypingStop_CleansUpOldEntry(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
var oldStopped atomic.Bool
|
||||||
|
|
||||||
|
m.RecordTypingStop("tg", "42", func() { oldStopped.Store(true) })
|
||||||
|
|
||||||
|
m.RecordTypingStop("tg", "42", func() {})
|
||||||
|
|
||||||
|
if !oldStopped.Load() {
|
||||||
|
t.Fatal("expected old typing stop to be called when new entry is recorded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordReactionUndo_CleansUpOldEntry verifies that recording a new
|
||||||
|
// reaction undo function calls the previous undo first.
|
||||||
|
// TestRecordReactionUndo_CleansUpOldEntry verifies that recording a new
|
||||||
|
// reaction undo function calls the previous undo first.
|
||||||
|
func TestRecordReactionUndo_CleansUpOldEntry(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
var oldUndone atomic.Bool
|
||||||
|
|
||||||
|
m.RecordReactionUndo("tg", "42", func() { oldUndone.Store(true) })
|
||||||
|
|
||||||
|
m.RecordReactionUndo("tg", "42", func() {})
|
||||||
|
|
||||||
|
if !oldUndone.Load() {
|
||||||
|
t.Fatal("expected old reaction undo to be called when new entry is recorded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreSend_DraftDismiss_ClearsEditTimes verifies that dismissing a draft
|
||||||
|
// in preSend also clears the statusEditTimes entry for that key, preventing
|
||||||
|
// stale throttle state from affecting the next processing cycle.
|
||||||
|
// TestPreSend_DraftDismiss_ClearsEditTimes verifies that dismissing a draft
|
||||||
|
// in preSend also clears the statusEditTimes entry for that key, preventing
|
||||||
|
// stale throttle state from affecting the next processing cycle.
|
||||||
|
func TestPreSend_DraftDismiss_ClearsEditTimes(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
ch := &mockDraftSender{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
|
||||||
|
},
|
||||||
|
draftFn: func(_ context.Context, _ string, _ int, _ string) error { return nil },
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error { return nil },
|
||||||
|
sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
key := "test:123"
|
||||||
|
m.statusMsgIDs.Store(key, statusMsgEntry{draftID: 42, createdAt: time.Now()})
|
||||||
|
m.statusEditTimes.Store(key, time.Now())
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final"}
|
||||||
|
m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
|
||||||
|
if _, loaded := m.statusEditTimes.Load(key); loaded {
|
||||||
|
t.Fatal("expected statusEditTimes to be cleared after draft dismiss")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -13,6 +14,9 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gomarkdown/markdown"
|
||||||
|
mdhtml "github.com/gomarkdown/markdown/html"
|
||||||
|
"github.com/gomarkdown/markdown/parser"
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
@ -268,6 +272,12 @@ func (c *MatrixChannel) Stop(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markdownToHTML(md string) string {
|
||||||
|
p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
|
||||||
|
renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags})
|
||||||
|
return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer)))
|
||||||
|
}
|
||||||
|
|
||||||
func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
|
|
@ -283,16 +293,22 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
|
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content))
|
||||||
MsgType: event.MsgText,
|
|
||||||
Body: content,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("matrix send: %w", channels.ErrTemporary)
|
return fmt.Errorf("matrix send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent {
|
||||||
|
mc := &event.MessageEventContent{MsgType: event.MsgText, Body: text}
|
||||||
|
if c.config.MessageFormat != "plain" {
|
||||||
|
mc.Format = event.FormatHTML
|
||||||
|
mc.FormattedBody = markdownToHTML(text)
|
||||||
|
}
|
||||||
|
return mc
|
||||||
|
}
|
||||||
|
|
||||||
// SendMedia implements channels.MediaSender.
|
// SendMedia implements channels.MediaSender.
|
||||||
func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
|
|
@ -482,10 +498,7 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI
|
||||||
return fmt.Errorf("matrix message ID is empty")
|
return fmt.Errorf("matrix message ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
editContent := &event.MessageEventContent{
|
editContent := c.messageContent(content)
|
||||||
MsgType: event.MsgText,
|
|
||||||
Body: content,
|
|
||||||
}
|
|
||||||
editContent.SetEdit(id.EventID(messageID))
|
editContent.SetEdit(id.EventID(messageID))
|
||||||
|
|
||||||
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent)
|
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent)
|
||||||
|
|
@ -714,17 +727,23 @@ func (c *MatrixChannel) downloadMedia(
|
||||||
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
|
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
data, err := c.client.DownloadBytes(reqCtx, parsed)
|
resp, err := c.client.Download(reqCtx, parsed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
reader := resp.Body
|
||||||
|
readerClose := func() error { return nil }
|
||||||
|
|
||||||
// Encrypted attachments put URL in msgEvt.File and require client-side decryption.
|
// Encrypted attachments put URL in msgEvt.File and require client-side decryption.
|
||||||
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
|
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
|
||||||
err = msgEvt.File.DecryptInPlace(data)
|
if err = msgEvt.File.PrepareForDecryption(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
||||||
}
|
}
|
||||||
|
decryptReader := msgEvt.File.DecryptStream(resp.Body)
|
||||||
|
reader = decryptReader
|
||||||
|
readerClose = decryptReader.Close
|
||||||
}
|
}
|
||||||
|
|
||||||
label := matrixMediaLabel(msgEvt, mediaKind)
|
label := matrixMediaLabel(msgEvt, mediaKind)
|
||||||
|
|
@ -737,14 +756,28 @@ func (c *MatrixChannel) downloadMedia(
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer tmp.Close()
|
tmpPath := tmp.Name()
|
||||||
|
cleanup := true
|
||||||
|
defer func() {
|
||||||
|
_ = tmp.Close()
|
||||||
|
if cleanup {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if _, err = tmp.Write(data); err != nil {
|
_, err = io.Copy(tmp, reader)
|
||||||
_ = os.Remove(tmp.Name())
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err = readerClose(); err != nil {
|
||||||
|
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
||||||
|
}
|
||||||
|
if err = tmp.Close(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tmp.Name(), nil
|
cleanup = false
|
||||||
|
return tmpPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func matrixContentType(msgEvt *event.MessageEventContent) string {
|
func matrixContentType(msgEvt *event.MessageEventContent) string {
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,19 @@ package matrix
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMatrixLocalpartMentionRegexp(t *testing.T) {
|
func TestMatrixLocalpartMentionRegexp(t *testing.T) {
|
||||||
|
|
@ -194,6 +199,50 @@ func TestMatrixMediaExt(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDownloadMedia_WritesResponseToTempFile(t *testing.T) {
|
||||||
|
const wantBody = "matrix-media-payload"
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasSuffix(r.URL.Path, "/_matrix/client/v1/media/download/matrix.test/abc123") {
|
||||||
|
t.Fatalf("unexpected download path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write([]byte(wantBody))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := mautrix.NewClient(server.URL, id.UserID("@picoclaw:matrix.test"), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewClient: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := &MatrixChannel{client: client}
|
||||||
|
msg := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgImage,
|
||||||
|
Body: "image.png",
|
||||||
|
URL: id.ContentURIString("mxc://matrix.test/abc123"),
|
||||||
|
Info: &event.FileInfo{MimeType: "image/png"},
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := ch.downloadMedia(context.Background(), msg, "image")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("downloadMedia: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
if ext := filepath.Ext(path); ext != ".png" {
|
||||||
|
t.Fatalf("temp file extension=%q want=.png", ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != wantBody {
|
||||||
|
t.Fatalf("file contents=%q want=%q", string(got), wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
|
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
|
||||||
ch := &MatrixChannel{}
|
ch := &MatrixChannel{}
|
||||||
msg := &event.MessageEventContent{
|
msg := &event.MessageEventContent{
|
||||||
|
|
@ -289,3 +338,50 @@ func TestMatrixOutboundContent(t *testing.T) {
|
||||||
t.Fatalf("unexpected fallback body: %q", noCaption.Body)
|
t.Fatalf("unexpected fallback body: %q", noCaption.Body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarkdownToHTML(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
{"bold", "**hello**", "<strong>hello</strong>"},
|
||||||
|
{"italic", "_world_", "<em>world</em>"},
|
||||||
|
{"header", "### Title", "<h3"},
|
||||||
|
{"code block", "```\nfoo()\n```", "<code>"},
|
||||||
|
{"inline code", "`x`", "<code>x</code>"},
|
||||||
|
{"plain text", "just text", "just text"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := markdownToHTML(tt.input)
|
||||||
|
if !strings.Contains(got, tt.contains) {
|
||||||
|
t.Fatalf("markdownToHTML(%q) = %q, want it to contain %q", tt.input, got, tt.contains)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageContent(t *testing.T) {
|
||||||
|
richtext := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "richtext"}}
|
||||||
|
plain := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "plain"}}
|
||||||
|
defaultt := &MatrixChannel{config: config.MatrixConfig{}}
|
||||||
|
|
||||||
|
for _, c := range []*MatrixChannel{richtext, defaultt} {
|
||||||
|
mc := c.messageContent("**hi**")
|
||||||
|
if mc.Format != event.FormatHTML {
|
||||||
|
t.Errorf("format %q: expected FormatHTML, got %q", c.config.MessageFormat, mc.Format)
|
||||||
|
}
|
||||||
|
if !strings.Contains(mc.FormattedBody, "<strong>hi</strong>") {
|
||||||
|
t.Errorf("format %q: FormattedBody %q missing <strong>", c.config.MessageFormat, mc.FormattedBody)
|
||||||
|
}
|
||||||
|
if mc.Body != "**hi**" {
|
||||||
|
t.Errorf("format %q: Body should remain plain, got %q", c.config.MessageFormat, mc.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mc := plain.messageContent("**hi**")
|
||||||
|
if mc.Format != "" || mc.FormattedBody != "" {
|
||||||
|
t.Errorf("plain: expected no formatting, got format=%q formattedBody=%q", mc.Format, mc.FormattedBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,26 +150,6 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
return c.broadcastToSession(msg.ChatID, outMsg)
|
return c.broadcastToSession(msg.ChatID, outMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendWithID implements channels.MessageSenderWithID.
|
|
||||||
// It sends a message and returns a generated message ID.
|
|
||||||
func (c *PicoChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) {
|
|
||||||
if !c.IsRunning() {
|
|
||||||
return "", channels.ErrNotRunning
|
|
||||||
}
|
|
||||||
|
|
||||||
msgID := uuid.New().String()
|
|
||||||
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
|
||||||
"content": content,
|
|
||||||
"message_id": msgID,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := c.broadcastToSession(chatID, outMsg); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return msgID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EditMessage implements channels.MessageEditor.
|
// EditMessage implements channels.MessageEditor.
|
||||||
func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||||
outMsg := newMessage(TypeMessageUpdate, map[string]any{
|
outMsg := newMessage(TypeMessageUpdate, map[string]any{
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ package qq
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tencent-connect/botgo"
|
"github.com/tencent-connect/botgo"
|
||||||
|
|
@ -20,6 +23,14 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dedupTTL = 5 * time.Minute
|
||||||
|
dedupInterval = 60 * time.Second
|
||||||
|
dedupMaxSize = 10000 // hard cap on dedup map entries
|
||||||
|
typingResend = 8 * time.Second
|
||||||
|
typingSeconds = 10
|
||||||
|
)
|
||||||
|
|
||||||
type QQChannel struct {
|
type QQChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
config config.QQConfig
|
config config.QQConfig
|
||||||
|
|
@ -28,20 +39,37 @@ type QQChannel struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
sessionManager botgo.SessionManager
|
sessionManager botgo.SessionManager
|
||||||
processedIDs map[string]bool
|
|
||||||
mu sync.RWMutex
|
// Chat routing: track whether a chatID is group or direct.
|
||||||
|
chatType sync.Map // chatID → "group" | "direct"
|
||||||
|
|
||||||
|
// Passive reply: store last inbound message ID per chat.
|
||||||
|
lastMsgID sync.Map // chatID → string
|
||||||
|
|
||||||
|
// msg_seq: per-chat atomic counter for multi-part replies.
|
||||||
|
msgSeqCounters sync.Map // chatID → *atomic.Uint64
|
||||||
|
|
||||||
|
// Time-based dedup replacing the unbounded map.
|
||||||
|
dedup map[string]time.Time
|
||||||
|
muDedup sync.Mutex
|
||||||
|
|
||||||
|
// done is closed on Stop to shut down the dedup janitor.
|
||||||
|
done chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
|
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
|
||||||
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
|
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
|
||||||
|
channels.WithMaxMessageLength(cfg.MaxMessageLength),
|
||||||
channels.WithGroupTrigger(cfg.GroupTrigger),
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
)
|
)
|
||||||
|
|
||||||
return &QQChannel{
|
return &QQChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
processedIDs: make(map[string]bool),
|
dedup: make(map[string]time.Time),
|
||||||
|
done: make(chan struct{}),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,8 +78,13 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
return fmt.Errorf("QQ app_id and app_secret not configured")
|
return fmt.Errorf("QQ app_id and app_secret not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
botgo.SetLogger(logger.NewLogger("botgo"))
|
||||||
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
|
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
|
||||||
|
|
||||||
|
// Reinitialize shutdown signal for clean restart.
|
||||||
|
c.done = make(chan struct{})
|
||||||
|
c.stopOnce = sync.Once{}
|
||||||
|
|
||||||
// create token source
|
// create token source
|
||||||
credentials := &token.QQBotCredentials{
|
credentials := &token.QQBotCredentials{
|
||||||
AppID: c.config.AppID,
|
AppID: c.config.AppID,
|
||||||
|
|
@ -99,6 +132,15 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// start dedup janitor goroutine
|
||||||
|
go c.dedupJanitor()
|
||||||
|
|
||||||
|
// Pre-register reasoning_channel_id as group chat if configured,
|
||||||
|
// so outbound-only destinations are routed correctly.
|
||||||
|
if c.config.ReasoningChannelID != "" {
|
||||||
|
c.chatType.Store(c.config.ReasoningChannelID, "group")
|
||||||
|
}
|
||||||
|
|
||||||
c.SetRunning(true)
|
c.SetRunning(true)
|
||||||
logger.InfoC("qq", "QQ bot started successfully")
|
logger.InfoC("qq", "QQ bot started successfully")
|
||||||
|
|
||||||
|
|
@ -109,6 +151,9 @@ func (c *QQChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("qq", "Stopping QQ bot")
|
logger.InfoC("qq", "Stopping QQ bot")
|
||||||
c.SetRunning(false)
|
c.SetRunning(false)
|
||||||
|
|
||||||
|
// Signal the dedup janitor to stop (idempotent).
|
||||||
|
c.stopOnce.Do(func() { close(c.done) })
|
||||||
|
|
||||||
if c.cancel != nil {
|
if c.cancel != nil {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
}
|
}
|
||||||
|
|
@ -116,21 +161,82 @@ func (c *QQChannel) Stop(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getChatKind returns the chat type for a given chatID ("group" or "direct").
|
||||||
|
// Unknown chatIDs default to "group" and log a warning, since QQ group IDs are
|
||||||
|
// more common as outbound-only destinations (e.g. reasoning_channel_id).
|
||||||
|
func (c *QQChannel) getChatKind(chatID string) string {
|
||||||
|
if v, ok := c.chatType.Load(chatID); ok {
|
||||||
|
if k, ok := v.(string); ok {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.DebugCF("qq", "Unknown chat type for chatID, defaulting to group", map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
})
|
||||||
|
return "group"
|
||||||
|
}
|
||||||
|
|
||||||
func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
// construct message
|
chatKind := c.getChatKind(msg.ChatID)
|
||||||
|
|
||||||
|
// Build message with content.
|
||||||
msgToCreate := &dto.MessageToCreate{
|
msgToCreate := &dto.MessageToCreate{
|
||||||
Content: msg.Content,
|
Content: msg.Content,
|
||||||
|
MsgType: dto.TextMsg,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Markdown message type if enabled in config.
|
||||||
|
if c.config.SendMarkdown {
|
||||||
|
msgToCreate.MsgType = dto.MarkdownMsg
|
||||||
|
msgToCreate.Markdown = &dto.Markdown{
|
||||||
|
Content: msg.Content,
|
||||||
|
}
|
||||||
|
// Clear plain content to avoid sending duplicate text.
|
||||||
|
msgToCreate.Content = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach passive reply msg_id and msg_seq if available.
|
||||||
|
if v, ok := c.lastMsgID.Load(msg.ChatID); ok {
|
||||||
|
if msgID, ok := v.(string); ok && msgID != "" {
|
||||||
|
msgToCreate.MsgID = msgID
|
||||||
|
|
||||||
|
// Increment msg_seq atomically for multi-part replies.
|
||||||
|
if counterVal, ok := c.msgSeqCounters.Load(msg.ChatID); ok {
|
||||||
|
if counter, ok := counterVal.(*atomic.Uint64); ok {
|
||||||
|
seq := counter.Add(1)
|
||||||
|
msgToCreate.MsgSeq = uint32(seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize URLs in group messages to avoid QQ's URL blacklist rejection.
|
||||||
|
if chatKind == "group" {
|
||||||
|
if msgToCreate.Content != "" {
|
||||||
|
msgToCreate.Content = sanitizeURLs(msgToCreate.Content)
|
||||||
|
}
|
||||||
|
if msgToCreate.Markdown != nil && msgToCreate.Markdown.Content != "" {
|
||||||
|
msgToCreate.Markdown.Content = sanitizeURLs(msgToCreate.Markdown.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route to group or C2C.
|
||||||
|
var err error
|
||||||
|
if chatKind == "group" {
|
||||||
|
_, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate)
|
||||||
|
} else {
|
||||||
|
_, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// send C2C message
|
|
||||||
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
|
logger.ErrorCF("qq", "Failed to send message", map[string]any{
|
||||||
"error": err.Error(),
|
"chat_id": msg.ChatID,
|
||||||
|
"chat_kind": chatKind,
|
||||||
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return fmt.Errorf("qq send: %w", channels.ErrTemporary)
|
return fmt.Errorf("qq send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +244,150 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleC2CMessage handles QQ private messages
|
// StartTyping implements channels.TypingCapable.
|
||||||
|
// It sends an InputNotify (msg_type=6) immediately and re-sends every 8 seconds.
|
||||||
|
// The returned stop function is idempotent and cancels the goroutine.
|
||||||
|
func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
|
// We need a stored msg_id for passive InputNotify; skip if none available.
|
||||||
|
v, ok := c.lastMsgID.Load(chatID)
|
||||||
|
if !ok {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
msgID, ok := v.(string)
|
||||||
|
if !ok || msgID == "" {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
chatKind := c.getChatKind(chatID)
|
||||||
|
|
||||||
|
sendTyping := func(sendCtx context.Context) {
|
||||||
|
typingMsg := &dto.MessageToCreate{
|
||||||
|
MsgType: dto.InputNotifyMsg,
|
||||||
|
MsgID: msgID,
|
||||||
|
InputNotify: &dto.InputNotify{
|
||||||
|
InputType: 1,
|
||||||
|
InputSecond: typingSeconds,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if chatKind == "group" {
|
||||||
|
_, err = c.api.PostGroupMessage(sendCtx, chatID, typingMsg)
|
||||||
|
} else {
|
||||||
|
_, err = c.api.PostC2CMessage(sendCtx, chatID, typingMsg)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("qq", "Failed to send typing indicator", map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send immediately.
|
||||||
|
sendTyping(c.ctx)
|
||||||
|
|
||||||
|
typingCtx, cancel := context.WithCancel(c.ctx)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(typingResend)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-typingCtx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
sendTyping(typingCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return cancel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
|
// QQ RichMediaMessage requires an HTTP/HTTPS URL — local file paths are not supported.
|
||||||
|
// If part.Ref is already an http(s) URL it is used directly; otherwise we try
|
||||||
|
// the media store, and skip with a warning if the resolved path is not an HTTP URL.
|
||||||
|
func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
chatKind := c.getChatKind(msg.ChatID)
|
||||||
|
|
||||||
|
for _, part := range msg.Parts {
|
||||||
|
// If the ref is already an HTTP(S) URL, use it directly.
|
||||||
|
mediaURL := part.Ref
|
||||||
|
if !isHTTPURL(mediaURL) {
|
||||||
|
// Try resolving through media store.
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, no media store available", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := store.Resolve(part.Ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isHTTPURL(resolved) {
|
||||||
|
logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, local files not supported", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
"resolved": resolved,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaURL = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file.
|
||||||
|
var fileType uint64
|
||||||
|
switch part.Type {
|
||||||
|
case "image":
|
||||||
|
fileType = 1
|
||||||
|
case "video":
|
||||||
|
fileType = 2
|
||||||
|
case "audio":
|
||||||
|
fileType = 3
|
||||||
|
default:
|
||||||
|
fileType = 4 // file
|
||||||
|
}
|
||||||
|
|
||||||
|
richMedia := &dto.RichMediaMessage{
|
||||||
|
FileType: fileType,
|
||||||
|
URL: mediaURL,
|
||||||
|
SrvSendMsg: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendErr error
|
||||||
|
if chatKind == "group" {
|
||||||
|
_, sendErr = c.api.PostGroupMessage(ctx, msg.ChatID, richMedia)
|
||||||
|
} else {
|
||||||
|
_, sendErr = c.api.PostC2CMessage(ctx, msg.ChatID, richMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sendErr != nil {
|
||||||
|
logger.ErrorCF("qq", "Failed to send media", map[string]any{
|
||||||
|
"type": part.Type,
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"error": sendErr.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("qq send media: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleC2CMessage handles QQ private messages.
|
||||||
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
||||||
// deduplication check
|
// deduplication check
|
||||||
|
|
@ -167,7 +416,13 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
"length": len(content),
|
"length": len(content),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 转发到消息总线
|
// Store chat routing context.
|
||||||
|
c.chatType.Store(senderID, "direct")
|
||||||
|
c.lastMsgID.Store(senderID, data.ID)
|
||||||
|
|
||||||
|
// Reset msg_seq counter for new inbound message.
|
||||||
|
c.msgSeqCounters.Store(senderID, new(atomic.Uint64))
|
||||||
|
|
||||||
metadata := map[string]string{}
|
metadata := map[string]string{}
|
||||||
|
|
||||||
sender := bus.SenderInfo{
|
sender := bus.SenderInfo{
|
||||||
|
|
@ -195,7 +450,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGroupATMessage handles QQ group @ messages
|
// handleGroupATMessage handles QQ group @ messages.
|
||||||
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
||||||
// deduplication check
|
// deduplication check
|
||||||
|
|
@ -232,7 +487,13 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
"length": len(content),
|
"length": len(content),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 转发到消息总线(使用 GroupID 作为 ChatID)
|
// Store chat routing context using GroupID as chatID.
|
||||||
|
c.chatType.Store(data.GroupID, "group")
|
||||||
|
c.lastMsgID.Store(data.GroupID, data.ID)
|
||||||
|
|
||||||
|
// Reset msg_seq counter for new inbound message.
|
||||||
|
c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64))
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"group_id": data.GroupID,
|
"group_id": data.GroupID,
|
||||||
}
|
}
|
||||||
|
|
@ -262,29 +523,102 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDuplicate 检查消息是否重复
|
// isDuplicate checks whether a message has been seen within the TTL window.
|
||||||
|
// It also enforces a hard cap on map size by evicting oldest entries.
|
||||||
func (c *QQChannel) isDuplicate(messageID string) bool {
|
func (c *QQChannel) isDuplicate(messageID string) bool {
|
||||||
c.mu.Lock()
|
c.muDedup.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.muDedup.Unlock()
|
||||||
|
|
||||||
if c.processedIDs[messageID] {
|
if ts, exists := c.dedup[messageID]; exists && time.Since(ts) < dedupTTL {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
c.processedIDs[messageID] = true
|
// Enforce hard cap: evict oldest entries when at capacity.
|
||||||
|
if len(c.dedup) >= dedupMaxSize {
|
||||||
// 简单清理:限制 map 大小
|
var oldestID string
|
||||||
if len(c.processedIDs) > 10000 {
|
var oldestTS time.Time
|
||||||
// 清空一半
|
for id, ts := range c.dedup {
|
||||||
count := 0
|
if oldestID == "" || ts.Before(oldestTS) {
|
||||||
for id := range c.processedIDs {
|
oldestID = id
|
||||||
if count >= 5000 {
|
oldestTS = ts
|
||||||
break
|
|
||||||
}
|
}
|
||||||
delete(c.processedIDs, id)
|
}
|
||||||
count++
|
if oldestID != "" {
|
||||||
|
delete(c.dedup, oldestID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.dedup[messageID] = time.Now()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dedupJanitor periodically evicts expired entries from the dedup map.
|
||||||
|
func (c *QQChannel) dedupJanitor() {
|
||||||
|
ticker := time.NewTicker(dedupInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// Collect expired keys under read-like scan.
|
||||||
|
c.muDedup.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
var expired []string
|
||||||
|
for id, ts := range c.dedup {
|
||||||
|
if now.Sub(ts) >= dedupTTL {
|
||||||
|
expired = append(expired, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range expired {
|
||||||
|
delete(c.dedup, id)
|
||||||
|
}
|
||||||
|
c.muDedup.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHTTPURL returns true if s starts with http:// or https://.
|
||||||
|
func isHTTPURL(s string) bool {
|
||||||
|
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlPattern matches URLs with explicit http(s):// scheme.
|
||||||
|
// Only scheme-prefixed URLs are matched to avoid false positives on bare text
|
||||||
|
// like version numbers (e.g., "1.2.3") or domain-like fragments.
|
||||||
|
var urlPattern = regexp.MustCompile(
|
||||||
|
`(?i)` +
|
||||||
|
`https?://` + // required scheme
|
||||||
|
`(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+` + // domain parts
|
||||||
|
`[a-zA-Z]{2,}` + // TLD
|
||||||
|
`(?:[/?#]\S*)?`, // optional path/query/fragment
|
||||||
|
)
|
||||||
|
|
||||||
|
// sanitizeURLs replaces dots in URL domains with "。" (fullwidth period)
|
||||||
|
// to prevent QQ's URL blacklist from rejecting the message.
|
||||||
|
func sanitizeURLs(text string) string {
|
||||||
|
return urlPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
// Split into scheme + rest (scheme is always present).
|
||||||
|
idx := strings.Index(match, "://")
|
||||||
|
scheme := match[:idx+3]
|
||||||
|
rest := match[idx+3:]
|
||||||
|
|
||||||
|
// Find where the domain ends (first / ? or #).
|
||||||
|
domainEnd := len(rest)
|
||||||
|
for i, ch := range rest {
|
||||||
|
if ch == '/' || ch == '?' || ch == '#' {
|
||||||
|
domainEnd = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := rest[:domainEnd]
|
||||||
|
path := rest[domainEnd:]
|
||||||
|
|
||||||
|
// Replace dots in domain only.
|
||||||
|
domain = strings.ReplaceAll(domain, ".", "。")
|
||||||
|
|
||||||
|
return scheme + domain + path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,11 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
slack.MsgOptionText(msg.Content, false),
|
slack.MsgOptionText(msg.Content, false),
|
||||||
}
|
}
|
||||||
|
|
||||||
if threadTS != "" {
|
if msg.ReplyToMessageID != "" && threadTS == "" {
|
||||||
|
// Answer to the message by creating a Thread under it
|
||||||
|
opts = append(opts, slack.MsgOptionTS(msg.ReplyToMessageID))
|
||||||
|
} else if threadTS != "" {
|
||||||
|
// If we are already in a thread, continue in the thread
|
||||||
opts = append(opts, slack.MsgOptionTS(threadTS))
|
opts = append(opts, slack.MsgOptionTS(threadTS))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,7 +187,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
||||||
title = filename
|
title = filename
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{
|
_, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{
|
||||||
Channel: channelID,
|
Channel: channelID,
|
||||||
File: localPath,
|
File: localPath,
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
|
|
@ -303,17 +307,16 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
Timestamp: messageTS,
|
Timestamp: messageTS,
|
||||||
})
|
})
|
||||||
|
|
||||||
var contentBuf strings.Builder
|
content := ev.Text
|
||||||
contentBuf.WriteString(c.stripBotMention(ev.Text))
|
content = c.stripBotMention(content)
|
||||||
|
|
||||||
// In non-DM channels, apply group trigger filtering
|
// In non-DM channels, apply group trigger filtering
|
||||||
if !strings.HasPrefix(channelID, "D") {
|
if !strings.HasPrefix(channelID, "D") {
|
||||||
respond, cleaned := c.ShouldRespondInGroup(false, contentBuf.String())
|
respond, cleaned := c.ShouldRespondInGroup(false, content)
|
||||||
if !respond {
|
if !respond {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
contentBuf.Reset()
|
content = cleaned
|
||||||
contentBuf.WriteString(cleaned)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var mediaPaths []string
|
var mediaPaths []string
|
||||||
|
|
@ -341,11 +344,10 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))
|
mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))
|
||||||
fmt.Fprintf(&contentBuf, "\n[file: %s]", file.Name)
|
content += fmt.Sprintf("\n[file: %s]", file.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
content := contentBuf.String()
|
|
||||||
if strings.TrimSpace(content) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
||||||
if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" {
|
if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" {
|
||||||
opts = append(opts, telego.WithAPIServer(baseURL))
|
opts = append(opts, telego.WithAPIServer(baseURL))
|
||||||
}
|
}
|
||||||
|
opts = append(opts, telego.WithLogger(logger.NewLogger("telego")))
|
||||||
|
|
||||||
bot, err := telego.NewBot(telegramCfg.Token, opts...)
|
bot, err := telego.NewBot(telegramCfg.Token, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -168,7 +169,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
chatID, threadID, err := parseChatID(msg.ChatID)
|
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||||
}
|
}
|
||||||
|
|
@ -180,6 +181,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
|
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
|
||||||
// so msg.Content is guaranteed to be within that limit. We still need to
|
// so msg.Content is guaranteed to be within that limit. We still need to
|
||||||
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
|
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
|
||||||
|
replyToID := msg.ReplyToMessageID
|
||||||
queue := []string{msg.Content}
|
queue := []string{msg.Content}
|
||||||
for len(queue) > 0 {
|
for len(queue) > 0 {
|
||||||
chunk := queue[0]
|
chunk := queue[0]
|
||||||
|
|
@ -200,9 +202,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.sendHTMLChunk(ctx, chatID, htmlContent, chunk); err != nil {
|
if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk, replyToID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Only the first chunk should be a reply; subsequent chunks are normal messages.
|
||||||
|
replyToID = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -210,11 +214,19 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
|
|
||||||
// sendHTMLChunk sends a single HTML message, falling back to the original
|
// sendHTMLChunk sends a single HTML message, falling back to the original
|
||||||
// markdown as plain text on parse failure so users never see raw HTML tags.
|
// markdown as plain text on parse failure so users never see raw HTML tags.
|
||||||
func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent, mdFallback string) error {
|
func (c *TelegramChannel) sendHTMLChunk(
|
||||||
|
ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string, replyToID string,
|
||||||
|
) error {
|
||||||
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
||||||
tgMsg.ParseMode = telego.ModeHTML
|
tgMsg.ParseMode = telego.ModeHTML
|
||||||
if threadID != 0 {
|
tgMsg.MessageThreadID = threadID
|
||||||
tgMsg.MessageThreadID = threadID
|
|
||||||
|
if replyToID != "" {
|
||||||
|
if mid, parseErr := strconv.Atoi(replyToID); parseErr == nil {
|
||||||
|
tgMsg.ReplyParameters = &telego.ReplyParameters{
|
||||||
|
MessageID: mid,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
|
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
|
||||||
|
|
@ -230,54 +242,21 @@ func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlC
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendWithID implements channels.MessageSenderWithID.
|
|
||||||
// It sends a message and returns the platform message ID.
|
|
||||||
func (c *TelegramChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) {
|
|
||||||
if !c.IsRunning() {
|
|
||||||
return "", channels.ErrNotRunning
|
|
||||||
}
|
|
||||||
|
|
||||||
cid, tid, err := parseChatID(chatID)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
htmlContent := markdownToTelegramHTML(content)
|
|
||||||
tgMsg := tu.Message(tu.ID(cid), htmlContent)
|
|
||||||
tgMsg.ParseMode = telego.ModeHTML
|
|
||||||
if tid != 0 {
|
|
||||||
tgMsg.MessageThreadID = tid
|
|
||||||
}
|
|
||||||
|
|
||||||
sent, err := c.bot.SendMessage(ctx, tgMsg)
|
|
||||||
if err != nil {
|
|
||||||
// Fallback to plain text
|
|
||||||
tgMsg.ParseMode = ""
|
|
||||||
sent, err = c.bot.SendMessage(ctx, tgMsg)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%d", sent.MessageID), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTyping implements channels.TypingCapable.
|
// StartTyping implements channels.TypingCapable.
|
||||||
// It sends ChatAction(typing) immediately and then repeats every 4 seconds
|
// It sends ChatAction(typing) immediately and then repeats every 4 seconds
|
||||||
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
|
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
|
||||||
// The returned stop function is idempotent and cancels the goroutine.
|
// The returned stop function is idempotent and cancels the goroutine.
|
||||||
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
cid, tid, err := parseChatID(chatID)
|
cid, threadID, err := parseTelegramChatID(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return func() {}, err
|
return func() {}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
||||||
|
action.MessageThreadID = threadID
|
||||||
|
|
||||||
// Send the first typing action immediately
|
// Send the first typing action immediately
|
||||||
firstAction := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
_ = c.bot.SendChatAction(ctx, action)
|
||||||
if tid != 0 {
|
|
||||||
firstAction.MessageThreadID = tid
|
|
||||||
}
|
|
||||||
_ = c.bot.SendChatAction(ctx, firstAction)
|
|
||||||
|
|
||||||
typingCtx, cancel := context.WithCancel(ctx)
|
typingCtx, cancel := context.WithCancel(ctx)
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -288,11 +267,9 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
|
||||||
case <-typingCtx.Done():
|
case <-typingCtx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
||||||
if tid != 0 {
|
a.MessageThreadID = threadID
|
||||||
action.MessageThreadID = tid
|
_ = c.bot.SendChatAction(typingCtx, a)
|
||||||
}
|
|
||||||
_ = c.bot.SendChatAction(typingCtx, action)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
@ -302,7 +279,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
|
||||||
|
|
||||||
// EditMessage implements channels.MessageEditor.
|
// EditMessage implements channels.MessageEditor.
|
||||||
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||||
cid, _, err := parseChatID(chatID)
|
cid, _, err := parseTelegramChatID(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -331,16 +308,14 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
|
||||||
text = "Thinking... 💭"
|
text = "Thinking... 💭"
|
||||||
}
|
}
|
||||||
|
|
||||||
cid, tid, err := parseChatID(chatID)
|
cid, threadID, err := parseTelegramChatID(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
params := tu.Message(tu.ID(cid), text)
|
phMsg := tu.Message(tu.ID(cid), text)
|
||||||
if tid != 0 {
|
phMsg.MessageThreadID = threadID
|
||||||
params.MessageThreadID = tid
|
pMsg, err := c.bot.SendMessage(ctx, phMsg)
|
||||||
}
|
|
||||||
pMsg, err := c.bot.SendMessage(ctx, params)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -348,44 +323,13 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
|
||||||
return fmt.Sprintf("%d", pMsg.MessageID), nil
|
return fmt.Sprintf("%d", pMsg.MessageID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendDraft implements channels.DraftSender.
|
|
||||||
// It uses Telegram Bot API's sendMessageDraft for progressive message streaming
|
|
||||||
// without the "edited" indicator. In groups, draft is used for dedicated topics only.
|
|
||||||
func (c *TelegramChannel) SendDraft(ctx context.Context, chatID string, draftID int, content string) error {
|
|
||||||
if !c.IsRunning() {
|
|
||||||
return channels.ErrNotRunning
|
|
||||||
}
|
|
||||||
cid, tid, err := parseChatID(chatID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed)
|
|
||||||
}
|
|
||||||
if !isLikelyPrivateChatID(cid) && tid == 0 {
|
|
||||||
return fmt.Errorf("telegram draft unsupported for non-threaded group chat: %w", channels.ErrSendFailed)
|
|
||||||
}
|
|
||||||
htmlContent := markdownToTelegramHTML(content)
|
|
||||||
params := &telego.SendMessageDraftParams{
|
|
||||||
ChatID: cid,
|
|
||||||
MessageThreadID: tid,
|
|
||||||
DraftID: draftID,
|
|
||||||
Text: htmlContent,
|
|
||||||
ParseMode: telego.ModeHTML,
|
|
||||||
}
|
|
||||||
if err = c.bot.SendMessageDraft(ctx, params); err != nil {
|
|
||||||
// HTML parse failure — retry as plain text
|
|
||||||
params.ParseMode = ""
|
|
||||||
params.Text = content
|
|
||||||
return c.bot.SendMessageDraft(ctx, params)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendMedia implements the channels.MediaSender interface.
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
chatID, threadID, err := parseChatID(msg.ChatID)
|
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||||
}
|
}
|
||||||
|
|
@ -492,12 +436,11 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
|
|
||||||
chatID := message.Chat.ID
|
chatID := message.Chat.ID
|
||||||
c.chatIDs[platformID] = chatID
|
c.chatIDs[platformID] = chatID
|
||||||
threadID := message.MessageThreadID
|
|
||||||
|
|
||||||
content := ""
|
content := ""
|
||||||
mediaPaths := []string{}
|
mediaPaths := []string{}
|
||||||
|
|
||||||
chatIDStr := formatChatID(chatID, threadID)
|
chatIDStr := fmt.Sprintf("%d", chatID)
|
||||||
messageIDStr := fmt.Sprintf("%d", message.MessageID)
|
messageIDStr := fmt.Sprintf("%d", message.MessageID)
|
||||||
scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr)
|
scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr)
|
||||||
|
|
||||||
|
|
@ -589,21 +532,28 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
content = cleaned
|
content = cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("telegram", "Received message", map[string]any{
|
// For forum topics, embed the thread ID as "chatID/threadID" so replies
|
||||||
"sender_id": sender.CanonicalID,
|
// route to the correct topic and each topic gets its own session.
|
||||||
"chat_id": fmt.Sprintf("%d", chatID),
|
// Only forum groups (IsForum) are handled; regular group reply threads
|
||||||
"thread_id": threadID,
|
// must share one session per group.
|
||||||
"chat_route": chatIDStr,
|
compositeChatID := fmt.Sprintf("%d", chatID)
|
||||||
"preview": utils.Truncate(content, 50),
|
threadID := message.MessageThreadID
|
||||||
})
|
if message.Chat.IsForum && threadID != 0 {
|
||||||
|
compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID)
|
||||||
|
}
|
||||||
|
|
||||||
// Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable
|
logger.DebugCF("telegram", "Received message", map[string]any{
|
||||||
|
"sender_id": sender.CanonicalID,
|
||||||
|
"chat_id": compositeChatID,
|
||||||
|
"thread_id": threadID,
|
||||||
|
"preview": utils.Truncate(content, 50),
|
||||||
|
})
|
||||||
|
|
||||||
peerKind := "direct"
|
peerKind := "direct"
|
||||||
peerID := fmt.Sprintf("%d", user.ID)
|
peerID := fmt.Sprintf("%d", user.ID)
|
||||||
if message.Chat.Type != "private" {
|
if message.Chat.Type != "private" {
|
||||||
peerKind = "group"
|
peerKind = "group"
|
||||||
peerID = fmt.Sprintf("%d", chatID)
|
peerID = compositeChatID
|
||||||
}
|
}
|
||||||
|
|
||||||
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
||||||
|
|
@ -616,11 +566,17 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set parent_peer metadata for per-topic agent binding.
|
||||||
|
if message.Chat.IsForum && threadID != 0 {
|
||||||
|
metadata["parent_peer_kind"] = "topic"
|
||||||
|
metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID)
|
||||||
|
}
|
||||||
|
|
||||||
c.HandleMessage(c.ctx,
|
c.HandleMessage(c.ctx,
|
||||||
peer,
|
peer,
|
||||||
messageID,
|
messageID,
|
||||||
platformID,
|
platformID,
|
||||||
chatIDStr,
|
compositeChatID,
|
||||||
content,
|
content,
|
||||||
mediaPaths,
|
mediaPaths,
|
||||||
metadata,
|
metadata,
|
||||||
|
|
@ -668,50 +624,25 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string)
|
||||||
return c.downloadFileWithInfo(file, ext)
|
return c.downloadFileWithInfo(file, ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseChatID(chatIDStr string) (int64, int, error) {
|
// parseTelegramChatID splits "chatID/threadID" into its components.
|
||||||
trimmed := strings.TrimSpace(chatIDStr)
|
// Returns threadID=0 when no "/" is present (non-forum messages).
|
||||||
if trimmed == "" {
|
func parseTelegramChatID(chatID string) (int64, int, error) {
|
||||||
return 0, 0, fmt.Errorf("empty chat ID")
|
idx := strings.Index(chatID, "/")
|
||||||
|
if idx == -1 {
|
||||||
|
cid, err := strconv.ParseInt(chatID, 10, 64)
|
||||||
|
return cid, 0, err
|
||||||
}
|
}
|
||||||
|
cid, err := strconv.ParseInt(chatID[:idx], 10, 64)
|
||||||
parts := strings.Split(trimmed, "/")
|
|
||||||
if len(parts) > 2 {
|
|
||||||
return 0, 0, fmt.Errorf("invalid chat ID format: %q", chatIDStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
cid, err := strconv.ParseInt(parts[0], 10, 64)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, fmt.Errorf("invalid chat ID %q: %w", parts[0], err)
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
|
tid, err := strconv.Atoi(chatID[idx+1:])
|
||||||
tid := 0
|
if err != nil {
|
||||||
if len(parts) == 2 {
|
return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err)
|
||||||
if parts[1] == "" {
|
|
||||||
return 0, 0, fmt.Errorf("invalid thread ID in %q", chatIDStr)
|
|
||||||
}
|
|
||||||
tid, err = strconv.Atoi(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("invalid thread ID %q: %w", parts[1], err)
|
|
||||||
}
|
|
||||||
if tid < 0 {
|
|
||||||
return 0, 0, fmt.Errorf("thread ID must be non-negative: %d", tid)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cid, tid, nil
|
return cid, tid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatChatID(chatID int64, threadID int) string {
|
|
||||||
if threadID != 0 {
|
|
||||||
return fmt.Sprintf("%d/%d", chatID, threadID)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d", chatID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isLikelyPrivateChatID(chatID int64) bool {
|
|
||||||
return chatID > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func markdownToTelegramHTML(text string) string {
|
func markdownToTelegramHTML(text string) string {
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
85
pkg/channels/telegram/telegram_ext.go
Normal file
85
pkg/channels/telegram/telegram_ext.go
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
tu "github.com/mymmrac/telego/telegoutil"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendWithID implements channels.MessageSenderWithID.
|
||||||
|
// It sends a message and returns the platform message ID.
|
||||||
|
func (c *TelegramChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return "", channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
cid, tid, err := parseTelegramChatID(chatID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlContent := markdownToTelegramHTML(content)
|
||||||
|
tgMsg := tu.Message(tu.ID(cid), htmlContent)
|
||||||
|
tgMsg.ParseMode = telego.ModeHTML
|
||||||
|
tgMsg.MessageThreadID = tid
|
||||||
|
|
||||||
|
sent, err := c.bot.SendMessage(ctx, tgMsg)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to plain text
|
||||||
|
tgMsg.ParseMode = ""
|
||||||
|
sent, err = c.bot.SendMessage(ctx, tgMsg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d", sent.MessageID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDraft implements channels.DraftSender.
|
||||||
|
// It uses Telegram Bot API's sendMessageDraft for progressive message streaming
|
||||||
|
// without the "edited" indicator. In groups, draft is used for dedicated topics only.
|
||||||
|
func (c *TelegramChannel) SendDraft(ctx context.Context, chatID string, draftID int, content string) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
cid, tid, err := parseTelegramChatID(chatID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
if !isLikelyPrivateChatID(cid) && tid == 0 {
|
||||||
|
return fmt.Errorf("telegram draft unsupported for non-threaded group chat: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
htmlContent := markdownToTelegramHTML(content)
|
||||||
|
params := &telego.SendMessageDraftParams{
|
||||||
|
ChatID: cid,
|
||||||
|
MessageThreadID: tid,
|
||||||
|
DraftID: draftID,
|
||||||
|
Text: htmlContent,
|
||||||
|
ParseMode: telego.ModeHTML,
|
||||||
|
}
|
||||||
|
if err = c.bot.SendMessageDraft(ctx, params); err != nil {
|
||||||
|
// HTML parse failure — retry as plain text
|
||||||
|
params.ParseMode = ""
|
||||||
|
params.Text = content
|
||||||
|
return c.bot.SendMessageDraft(ctx, params)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatChatID formats a chat ID with optional thread ID as "chatID/threadID".
|
||||||
|
func formatChatID(chatID int64, threadID int) string {
|
||||||
|
if threadID != 0 {
|
||||||
|
return fmt.Sprintf("%d/%d", chatID, threadID)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLikelyPrivateChatID returns true for positive chat IDs (private chats).
|
||||||
|
func isLikelyPrivateChatID(chatID int64) bool {
|
||||||
|
return chatID > 0
|
||||||
|
}
|
||||||
54
pkg/channels/telegram/telegram_ext_test.go
Normal file
54
pkg/channels/telegram/telegram_ext_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseTelegramChatID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantCID int64
|
||||||
|
wantTID int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "plain private", input: "12345", wantCID: 12345, wantTID: 0},
|
||||||
|
{name: "group topic", input: "-100123/45", wantCID: -100123, wantTID: 45},
|
||||||
|
{name: "topic zero", input: "-100/0", wantCID: -100, wantTID: 0},
|
||||||
|
{name: "bad chat", input: "abc/def", wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
gotCID, gotTID, err := parseTelegramChatID(tc.input)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("parseTelegramChatID(%q) expected error, got nil", tc.input)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseTelegramChatID(%q) unexpected error: %v", tc.input, err)
|
||||||
|
}
|
||||||
|
if gotCID != tc.wantCID || gotTID != tc.wantTID {
|
||||||
|
t.Fatalf(
|
||||||
|
"parseTelegramChatID(%q) = (%d, %d), want (%d, %d)",
|
||||||
|
tc.input,
|
||||||
|
gotCID,
|
||||||
|
gotTID,
|
||||||
|
tc.wantCID,
|
||||||
|
tc.wantTID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatChatID(t *testing.T) {
|
||||||
|
if got := formatChatID(-100, 42); got != "-100/42" {
|
||||||
|
t.Fatalf("formatChatID(-100, 42) = %q, want %q", got, "-100/42")
|
||||||
|
}
|
||||||
|
if got := formatChatID(12345, 0); got != "12345" {
|
||||||
|
t.Fatalf("formatChatID(12345, 0) = %q, want %q", got, "12345")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,50 +1,462 @@
|
||||||
package telegram
|
package telegram
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
func TestParseChatID(t *testing.T) {
|
"github.com/mymmrac/telego"
|
||||||
tests := []struct {
|
ta "github.com/mymmrac/telego/telegoapi"
|
||||||
name string
|
"github.com/stretchr/testify/assert"
|
||||||
input string
|
"github.com/stretchr/testify/require"
|
||||||
wantCID int64
|
|
||||||
wantTID int
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{name: "plain private", input: "12345", wantCID: 12345, wantTID: 0},
|
|
||||||
{name: "group topic", input: "-100123/45", wantCID: -100123, wantTID: 45},
|
|
||||||
{name: "trim spaces", input: " -100200/7 ", wantCID: -100200, wantTID: 7},
|
|
||||||
{name: "topic zero", input: "-100/0", wantCID: -100, wantTID: 0},
|
|
||||||
{name: "empty", input: "", wantErr: true},
|
|
||||||
{name: "bad chat", input: "abc/def", wantErr: true},
|
|
||||||
{name: "missing topic", input: "-100/", wantErr: true},
|
|
||||||
{name: "too many parts", input: "-100/1/2", wantErr: true},
|
|
||||||
{name: "negative topic", input: "-100/-1", wantErr: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
gotCID, gotTID, err := parseChatID(tc.input)
|
)
|
||||||
if tc.wantErr {
|
|
||||||
if err == nil {
|
const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc"
|
||||||
t.Fatalf("parseChatID(%q) expected error, got nil", tc.input)
|
|
||||||
}
|
// stubCaller implements ta.Caller for testing.
|
||||||
return
|
type stubCaller struct {
|
||||||
}
|
calls []stubCall
|
||||||
if err != nil {
|
callFn func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error)
|
||||||
t.Fatalf("parseChatID(%q) unexpected error: %v", tc.input, err)
|
}
|
||||||
}
|
|
||||||
if gotCID != tc.wantCID || gotTID != tc.wantTID {
|
type stubCall struct {
|
||||||
t.Fatalf("parseChatID(%q) = (%d, %d), want (%d, %d)", tc.input, gotCID, gotTID, tc.wantCID, tc.wantTID)
|
URL string
|
||||||
}
|
Data *ta.RequestData
|
||||||
})
|
}
|
||||||
|
|
||||||
|
func (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
s.calls = append(s.calls, stubCall{URL: url, Data: data})
|
||||||
|
return s.callFn(ctx, url, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubConstructor implements ta.RequestConstructor for testing.
|
||||||
|
type stubConstructor struct{}
|
||||||
|
|
||||||
|
func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) {
|
||||||
|
return &ta.RequestData{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubConstructor) MultipartRequest(
|
||||||
|
parameters map[string]string,
|
||||||
|
files map[string]ta.NamedReader,
|
||||||
|
) (*ta.RequestData, error) {
|
||||||
|
return &ta.RequestData{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// successResponse returns a ta.Response that telego will treat as a successful SendMessage.
|
||||||
|
func successResponse(t *testing.T) *ta.Response {
|
||||||
|
t.Helper()
|
||||||
|
msg := &telego.Message{MessageID: 1}
|
||||||
|
b, err := json.Marshal(msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return &ta.Response{Ok: true, Result: b}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
|
||||||
|
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
bot, err := telego.NewBot(testToken,
|
||||||
|
telego.WithAPICaller(caller),
|
||||||
|
telego.WithRequestConstructor(&stubConstructor{}),
|
||||||
|
telego.WithDiscardLogger(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
base := channels.NewBaseChannel("telegram", nil, nil, nil,
|
||||||
|
channels.WithMaxMessageLength(4000),
|
||||||
|
)
|
||||||
|
base.SetRunning(true)
|
||||||
|
|
||||||
|
return &TelegramChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
bot: bot,
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatChatID(t *testing.T) {
|
func TestSend_EmptyContent(t *testing.T) {
|
||||||
if got := formatChatID(-100, 42); got != "-100/42" {
|
caller := &stubCaller{
|
||||||
t.Fatalf("formatChatID(-100, 42) = %q, want %q", got, "-100/42")
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
}
|
t.Fatal("SendMessage should not be called for empty content")
|
||||||
if got := formatChatID(12345, 0); got != "12345" {
|
return nil, nil
|
||||||
t.Fatalf("formatChatID(12345, 0) = %q, want %q", got, "12345")
|
},
|
||||||
}
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Empty(t, caller.calls, "no API calls should be made for empty content")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_ShortMessage_SingleCall(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return successResponse(t), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: "Hello, world!",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_LongMessage_SingleCall(t *testing.T) {
|
||||||
|
// With WithMaxMessageLength(4000), the Manager pre-splits messages before
|
||||||
|
// they reach Send(). A message at exactly 4000 chars should go through
|
||||||
|
// as a single SendMessage call (no re-split needed since HTML expansion
|
||||||
|
// won't exceed 4096 for plain text).
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return successResponse(t), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
longContent := strings.Repeat("a", 4000)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: longContent,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, caller.calls, 1, "pre-split message within limit should result in one SendMessage call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_HTMLFallback_PerChunk(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
callCount++
|
||||||
|
// Fail on odd calls (HTML attempt), succeed on even calls (plain text fallback)
|
||||||
|
if callCount%2 == 1 {
|
||||||
|
return nil, errors.New("Bad Request: can't parse entities")
|
||||||
|
}
|
||||||
|
return successResponse(t), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: "Hello **world**",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
// One short message → 1 HTML attempt (fail) + 1 plain text fallback (success) = 2 calls
|
||||||
|
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text fallback")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_HTMLFallback_BothFail(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return nil, errors.New("send failed")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: "Hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, channels.ErrTemporary), "error should wrap ErrTemporary")
|
||||||
|
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text attempt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {
|
||||||
|
// With a long message that gets split into 2 chunks, if both HTML and
|
||||||
|
// plain text fail on the first chunk, Send should return early.
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return nil, errors.New("send failed")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
longContent := strings.Repeat("x", 4001)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: longContent,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
// Should fail on the first chunk (2 calls: HTML + fallback), never reaching the second chunk.
|
||||||
|
assert.Equal(t, 2, len(caller.calls), "should stop after first chunk fails both HTML and plain text")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return successResponse(t), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
// Create markdown whose length is <= 4000 but whose HTML expansion is much longer.
|
||||||
|
// "**a** " (6 chars) becomes "<b>a</b> " (9 chars) in HTML, so repeating it many times
|
||||||
|
// yields HTML that exceeds Telegram's limit while markdown stays within it.
|
||||||
|
markdownContent := strings.Repeat("**a** ", 600) // 3600 chars markdown, HTML ~5400+ chars
|
||||||
|
assert.LessOrEqual(t, len([]rune(markdownContent)), 4000, "markdown content must not exceed chunk size")
|
||||||
|
|
||||||
|
htmlExpanded := markdownToTelegramHTML(markdownContent)
|
||||||
|
assert.Greater(
|
||||||
|
t, len([]rune(htmlExpanded)), 4096,
|
||||||
|
"HTML expansion must exceed Telegram limit for this test to be meaningful",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: markdownContent,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Greater(
|
||||||
|
t, len(caller.calls), 1,
|
||||||
|
"markdown-short but HTML-long message should be split into multiple SendMessage calls",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_NotRunning(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
t.Fatal("should not be called")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
ch.SetRunning(false)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: "Hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, channels.ErrNotRunning)
|
||||||
|
assert.Empty(t, caller.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_InvalidChatID(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
t.Fatal("should not be called")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "not-a-number",
|
||||||
|
Content: "Hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed")
|
||||||
|
assert.Empty(t, caller.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_Plain(t *testing.T) {
|
||||||
|
cid, tid, err := parseTelegramChatID("12345")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(12345), cid)
|
||||||
|
assert.Equal(t, 0, tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_NegativeGroup(t *testing.T) {
|
||||||
|
cid, tid, err := parseTelegramChatID("-1001234567890")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(-1001234567890), cid)
|
||||||
|
assert.Equal(t, 0, tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_WithThreadID(t *testing.T) {
|
||||||
|
cid, tid, err := parseTelegramChatID("-1001234567890/42")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(-1001234567890), cid)
|
||||||
|
assert.Equal(t, 42, tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_GeneralTopic(t *testing.T) {
|
||||||
|
cid, tid, err := parseTelegramChatID("-100123/1")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(-100123), cid)
|
||||||
|
assert.Equal(t, 1, tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_Invalid(t *testing.T) {
|
||||||
|
_, _, err := parseTelegramChatID("not-a-number")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_InvalidThreadID(t *testing.T) {
|
||||||
|
_, _, err := parseTelegramChatID("-100123/not-a-thread")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid thread ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_WithForumThreadID(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return successResponse(t), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "-1001234567890/42",
|
||||||
|
Content: "Hello from topic",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, caller.calls, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: "hello from topic",
|
||||||
|
MessageID: 10,
|
||||||
|
MessageThreadID: 42,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: -1001234567890,
|
||||||
|
Type: "supergroup",
|
||||||
|
IsForum: true,
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 7,
|
||||||
|
FirstName: "Alice",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
require.True(t, ok, "expected inbound message")
|
||||||
|
|
||||||
|
// Composite chatID should include thread ID
|
||||||
|
assert.Equal(t, "-1001234567890/42", inbound.ChatID)
|
||||||
|
|
||||||
|
// Peer ID should include thread ID for session key isolation
|
||||||
|
assert.Equal(t, "group", inbound.Peer.Kind)
|
||||||
|
assert.Equal(t, "-1001234567890/42", inbound.Peer.ID)
|
||||||
|
|
||||||
|
// Parent peer metadata should be set for agent binding
|
||||||
|
assert.Equal(t, "topic", inbound.Metadata["parent_peer_kind"])
|
||||||
|
assert.Equal(t, "42", inbound.Metadata["parent_peer_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: "regular group message",
|
||||||
|
MessageID: 11,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: -100999,
|
||||||
|
Type: "group",
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 8,
|
||||||
|
FirstName: "Bob",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
// Plain chatID without thread suffix
|
||||||
|
assert.Equal(t, "-100999", inbound.ChatID)
|
||||||
|
|
||||||
|
// Peer ID should be raw chat ID (no thread suffix)
|
||||||
|
assert.Equal(t, "group", inbound.Peer.Kind)
|
||||||
|
assert.Equal(t, "-100999", inbound.Peer.ID)
|
||||||
|
|
||||||
|
// No parent peer metadata
|
||||||
|
assert.Empty(t, inbound.Metadata["parent_peer_kind"])
|
||||||
|
assert.Empty(t, inbound.Metadata["parent_peer_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// In regular groups, reply threads set MessageThreadID to the original
|
||||||
|
// message ID. This should NOT trigger per-thread session isolation.
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: "reply in thread",
|
||||||
|
MessageID: 20,
|
||||||
|
MessageThreadID: 15,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: -100999,
|
||||||
|
Type: "supergroup",
|
||||||
|
IsForum: false,
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 9,
|
||||||
|
FirstName: "Carol",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
// chatID should NOT include thread suffix for non-forum groups
|
||||||
|
assert.Equal(t, "-100999", inbound.ChatID)
|
||||||
|
|
||||||
|
// Peer ID should be raw chat ID (shared session for whole group)
|
||||||
|
assert.Equal(t, "group", inbound.Peer.Kind)
|
||||||
|
assert.Equal(t, "-100999", inbound.Peer.ID)
|
||||||
|
|
||||||
|
// No parent peer metadata
|
||||||
|
assert.Empty(t, inbound.Metadata["parent_peer_kind"])
|
||||||
|
assert.Empty(t, inbound.Metadata["parent_peer_id"])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("empty token skips verification", func(t *testing.T) {
|
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
|
||||||
cfgEmpty := config.WeComAppConfig{
|
cfgEmpty := config.WeComAppConfig{
|
||||||
CorpID: "test_corp_id",
|
CorpID: "test_corp_id",
|
||||||
CorpSecret: "test_secret",
|
CorpSecret: "test_secret",
|
||||||
|
|
@ -218,8 +218,8 @@ func TestWeComAppVerifySignature(t *testing.T) {
|
||||||
}
|
}
|
||||||
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
|
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
|
||||||
|
|
||||||
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
|
if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
|
||||||
t.Error("empty token should skip verification and return true")
|
t.Error("empty token should reject verification (fail-closed)")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -189,8 +189,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("empty token skips verification", func(t *testing.T) {
|
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
|
||||||
// Create a channel manually with empty token to test the behavior
|
|
||||||
cfgEmpty := config.WeComConfig{
|
cfgEmpty := config.WeComConfig{
|
||||||
Token: "",
|
Token: "",
|
||||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
|
@ -199,8 +198,8 @@ func TestWeComBotVerifySignature(t *testing.T) {
|
||||||
config: cfgEmpty,
|
config: cfgEmpty,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
|
if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
|
||||||
t.Error("empty token should skip verification and return true")
|
t.Error("empty token should reject verification (fail-closed)")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ func computeSignature(token, timestamp, nonce, encrypt string) string {
|
||||||
// This is a common function used by both WeCom Bot and WeCom App
|
// This is a common function used by both WeCom Bot and WeCom App
|
||||||
func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
|
func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return true // Skip verification if token is not set
|
return false
|
||||||
}
|
}
|
||||||
return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature
|
return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,6 @@ func BuiltinDefinitions() []Definition {
|
||||||
listCommand(),
|
listCommand(),
|
||||||
switchCommand(),
|
switchCommand(),
|
||||||
checkCommand(),
|
checkCommand(),
|
||||||
|
clearCommand(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
pkg/commands/cmd_clear.go
Normal file
20
pkg/commands/cmd_clear.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func clearCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "clear",
|
||||||
|
Description: "Clear the chat history",
|
||||||
|
Usage: "/clear",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.ClearHistory == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
if err := rt.ClearHistory(); err != nil {
|
||||||
|
return req.Reply("Failed to clear chat history: " + err.Error())
|
||||||
|
}
|
||||||
|
return req.Reply("Chat history cleared!")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,4 +13,5 @@ type Runtime struct {
|
||||||
GetEnabledChannels func() []string
|
GetEnabledChannels func() []string
|
||||||
SwitchModel func(value string) (oldModel string, err error)
|
SwitchModel func(value string) (oldModel string, err error)
|
||||||
SwitchChannel func(value string) error
|
SwitchChannel func(value string) error
|
||||||
|
ClearHistory func() error
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
|
|
@ -16,6 +17,8 @@ var rrCounter atomic.Uint64
|
||||||
|
|
||||||
// FlexibleStringSlice is a []string that also accepts JSON numbers,
|
// FlexibleStringSlice is a []string that also accepts JSON numbers,
|
||||||
// so allow_from can contain both "123" and 123.
|
// so allow_from can contain both "123" and 123.
|
||||||
|
// It also supports parsing comma-separated strings from environment variables,
|
||||||
|
// including both English (,) and Chinese (,) commas.
|
||||||
type FlexibleStringSlice []string
|
type FlexibleStringSlice []string
|
||||||
|
|
||||||
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||||
|
|
@ -47,6 +50,30 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
|
||||||
|
// It handles comma-separated values with both English (,) and Chinese (,) commas.
|
||||||
|
func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
|
||||||
|
if len(text) == 0 {
|
||||||
|
*f = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s := string(text)
|
||||||
|
// Replace Chinese comma with English comma, then split
|
||||||
|
s = strings.ReplaceAll(s, ",", ",")
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
|
||||||
|
result := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
result = append(result, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*f = result
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Agents AgentsConfig `json:"agents"`
|
Agents AgentsConfig `json:"agents"`
|
||||||
Bindings []AgentBinding `json:"bindings,omitempty"`
|
Bindings []AgentBinding `json:"bindings,omitempty"`
|
||||||
|
|
@ -58,6 +85,17 @@ type Config struct {
|
||||||
Tools ToolsConfig `json:"tools"`
|
Tools ToolsConfig `json:"tools"`
|
||||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||||
Devices DevicesConfig `json:"devices"`
|
Devices DevicesConfig `json:"devices"`
|
||||||
|
Voice VoiceConfig `json:"voice"`
|
||||||
|
// BuildInfo contains build-time version information
|
||||||
|
BuildInfo BuildInfo `json:"build_info,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildInfo contains build-time version information
|
||||||
|
type BuildInfo struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
GitCommit string `json:"git_commit"`
|
||||||
|
BuildTime string `json:"build_time"`
|
||||||
|
GoVersion string `json:"go_version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for Config
|
// MarshalJSON implements custom JSON marshaling for Config
|
||||||
|
|
@ -141,7 +179,7 @@ type AgentConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubagentsConfig struct {
|
type SubagentsConfig struct {
|
||||||
Enabled bool `json:"enabled,omitempty"`
|
Enabled bool `json:"enabled,omitempty"` // Fork-only: gate orchestration
|
||||||
AllowAgents []string `json:"allow_agents,omitempty"`
|
AllowAgents []string `json:"allow_agents,omitempty"`
|
||||||
Model *AgentModelConfig `json:"model,omitempty"`
|
Model *AgentModelConfig `json:"model,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -189,18 +227,18 @@ type AgentDefaults struct {
|
||||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
||||||
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
||||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
|
||||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
|
||||||
PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"`
|
PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"`
|
||||||
PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"`
|
PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"`
|
||||||
|
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||||
|
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||||
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||||
TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"`
|
|
||||||
Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"`
|
|
||||||
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
|
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
|
||||||
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
|
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
|
||||||
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
|
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
|
||||||
|
TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"`
|
||||||
|
Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"`
|
||||||
Routing *RoutingConfig `json:"routing,omitempty"`
|
Routing *RoutingConfig `json:"routing,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,19 +304,20 @@ type WhatsAppConfig struct {
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TelegramConfig struct {
|
type TelegramConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||||
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
|
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
|
||||||
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||||
WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
|
||||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
Typing TypingConfig `json:"typing,omitempty"`
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
|
||||||
|
WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"`
|
||||||
SubagentThreadID int `json:"subagent_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_SUBAGENT_THREAD_ID"`
|
SubagentThreadID int `json:"subagent_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_SUBAGENT_THREAD_ID"`
|
||||||
HeartbeatThreadID int `json:"heartbeat_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_HEARTBEAT_THREAD_ID"`
|
HeartbeatThreadID int `json:"heartbeat_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_HEARTBEAT_THREAD_ID"`
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeishuConfig struct {
|
type FeishuConfig struct {
|
||||||
|
|
@ -320,6 +359,8 @@ type QQConfig struct {
|
||||||
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
||||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"`
|
||||||
|
SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,16 +385,17 @@ type SlackConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type MatrixConfig struct {
|
type MatrixConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
|
||||||
Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
|
Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
|
||||||
UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
|
UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
|
||||||
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
|
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
|
||||||
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
|
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
|
||||||
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
|
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
|
MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
|
||||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LINEConfig struct {
|
type LINEConfig struct {
|
||||||
|
|
@ -467,6 +509,10 @@ type DevicesConfig struct {
|
||||||
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
|
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VoiceConfig struct {
|
||||||
|
EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProvidersConfig struct {
|
type ProvidersConfig struct {
|
||||||
Anthropic ProviderConfig `json:"anthropic"`
|
Anthropic ProviderConfig `json:"anthropic"`
|
||||||
OpenAI OpenAIProviderConfig `json:"openai"`
|
OpenAI OpenAIProviderConfig `json:"openai"`
|
||||||
|
|
@ -489,6 +535,8 @@ type ProvidersConfig struct {
|
||||||
Qwen ProviderConfig `json:"qwen"`
|
Qwen ProviderConfig `json:"qwen"`
|
||||||
Mistral ProviderConfig `json:"mistral"`
|
Mistral ProviderConfig `json:"mistral"`
|
||||||
Avian ProviderConfig `json:"avian"`
|
Avian ProviderConfig `json:"avian"`
|
||||||
|
Minimax ProviderConfig `json:"minimax"`
|
||||||
|
LongCat ProviderConfig `json:"longcat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||||
|
|
@ -514,7 +562,9 @@ func (p ProvidersConfig) IsEmpty() bool {
|
||||||
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
||||||
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
|
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
|
||||||
p.Avian.APIKey == "" && p.Avian.APIBase == ""
|
p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
|
||||||
|
p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
|
||||||
|
p.LongCat.APIKey == "" && p.LongCat.APIBase == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||||
|
|
@ -560,12 +610,13 @@ type ModelConfig struct {
|
||||||
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
|
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
|
||||||
ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc
|
ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc
|
||||||
Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
|
Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
|
||||||
|
|
||||||
// Optional optimizations
|
// Optional optimizations
|
||||||
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
||||||
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
||||||
Stream *bool `json:"stream,omitempty"` // Use SSE streaming (default: protocol-dependent)
|
|
||||||
RequestTimeout int `json:"request_timeout,omitempty"`
|
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||||
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
|
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
|
||||||
|
Stream *bool `json:"stream,omitempty"` // Use SSE streaming (default: protocol-dependent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the ModelConfig has all required fields.
|
// Validate checks if the ModelConfig has all required fields.
|
||||||
|
|
@ -584,21 +635,31 @@ type GatewayConfig struct {
|
||||||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToolDiscoveryConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
|
||||||
|
TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`
|
||||||
|
MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"`
|
||||||
|
UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"`
|
||||||
|
UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"`
|
||||||
|
}
|
||||||
|
|
||||||
type ToolConfig struct {
|
type ToolConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"ENABLED"`
|
Enabled bool `json:"enabled" env:"ENABLED"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BraveConfig struct {
|
type BraveConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TavilyConfig struct {
|
type TavilyConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
|
||||||
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
|
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DuckDuckGoConfig struct {
|
type DuckDuckGoConfig struct {
|
||||||
|
|
@ -607,9 +668,10 @@ type DuckDuckGoConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PerplexityConfig struct {
|
type PerplexityConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearXNGConfig struct {
|
type SearXNGConfig struct {
|
||||||
|
|
@ -650,6 +712,7 @@ type CronToolsConfig struct {
|
||||||
type ExecConfig struct {
|
type ExecConfig struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
|
||||||
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
|
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
|
||||||
|
AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"`
|
||||||
CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
|
CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
|
||||||
CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
|
CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
|
||||||
TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
|
TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
|
||||||
|
|
@ -668,6 +731,11 @@ type MediaCleanupConfig struct {
|
||||||
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
|
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReadFileToolConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
MaxReadFileSize int `json:"max_read_file_size"`
|
||||||
|
}
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
||||||
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
|
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
|
||||||
|
|
@ -684,7 +752,7 @@ type ToolsConfig struct {
|
||||||
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
||||||
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
||||||
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
||||||
ReadFile ToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
||||||
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
||||||
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
||||||
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
||||||
|
|
@ -736,7 +804,8 @@ type MCPServerConfig struct {
|
||||||
|
|
||||||
// MCPConfig defines configuration for all MCP servers
|
// MCPConfig defines configuration for all MCP servers
|
||||||
type MCPConfig struct {
|
type MCPConfig struct {
|
||||||
ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
|
||||||
|
Discovery ToolDiscoveryConfig ` json:"discovery"`
|
||||||
// Servers is a map of server name to server configuration
|
// Servers is a map of server name to server configuration
|
||||||
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
|
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -936,6 +1005,29 @@ func (c *Config) ValidateModelList() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func MergeAPIKeys(apiKey string, apiKeys []string) []string {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var all []string
|
||||||
|
|
||||||
|
if k := strings.TrimSpace(apiKey); k != "" {
|
||||||
|
if _, exists := seen[k]; !exists {
|
||||||
|
seen[k] = struct{}{}
|
||||||
|
all = append(all, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, k := range apiKeys {
|
||||||
|
if trimmed := strings.TrimSpace(k); trimmed != "" {
|
||||||
|
if _, exists := seen[trimmed]; !exists {
|
||||||
|
seen[trimmed] = struct{}{}
|
||||||
|
all = append(all, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
switch name {
|
switch name {
|
||||||
case "web":
|
case "web":
|
||||||
|
|
|
||||||
123
pkg/config/config_ext_test.go
Normal file
123
pkg/config/config_ext_test.go
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgentDefaults_PlanModel_StringParse(t *testing.T) {
|
||||||
|
jsonData := `{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/.picoclaw/workspace",
|
||||||
|
"model": "glm-4.7",
|
||||||
|
"plan_model": "anthropic/claude-sonnet-4-6",
|
||||||
|
"plan_model_fallbacks": ["openai/gpt-4o"],
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"max_tool_iterations": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Agents.Defaults.PlanModel != "anthropic/claude-sonnet-4-6" {
|
||||||
|
t.Errorf("PlanModel = %q, want 'anthropic/claude-sonnet-4-6'", cfg.Agents.Defaults.PlanModel)
|
||||||
|
}
|
||||||
|
if len(cfg.Agents.Defaults.PlanModelFallbacks) != 1 ||
|
||||||
|
cfg.Agents.Defaults.PlanModelFallbacks[0] != "openai/gpt-4o" {
|
||||||
|
t.Errorf("PlanModelFallbacks = %v, want [openai/gpt-4o]", cfg.Agents.Defaults.PlanModelFallbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentConfig_PlanModel_ObjectParse(t *testing.T) {
|
||||||
|
jsonData := `{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/.picoclaw/workspace",
|
||||||
|
"model": "glm-4.7",
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"max_tool_iterations": 20
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "main",
|
||||||
|
"plan_model": "anthropic/claude-sonnet-4-6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "advanced",
|
||||||
|
"plan_model": {
|
||||||
|
"primary": "anthropic/claude-opus-4",
|
||||||
|
"fallbacks": ["anthropic/claude-sonnet-4-6"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Agents.List) != 2 {
|
||||||
|
t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List))
|
||||||
|
}
|
||||||
|
|
||||||
|
main := cfg.Agents.List[0]
|
||||||
|
if main.PlanModel == nil || main.PlanModel.Primary != "anthropic/claude-sonnet-4-6" {
|
||||||
|
t.Errorf("main.PlanModel = %+v, want primary 'anthropic/claude-sonnet-4-6'", main.PlanModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
adv := cfg.Agents.List[1]
|
||||||
|
if adv.PlanModel == nil || adv.PlanModel.Primary != "anthropic/claude-opus-4" {
|
||||||
|
t.Errorf("advanced.PlanModel = %+v, want primary 'anthropic/claude-opus-4'", adv.PlanModel)
|
||||||
|
}
|
||||||
|
if len(adv.PlanModel.Fallbacks) != 1 || adv.PlanModel.Fallbacks[0] != "anthropic/claude-sonnet-4-6" {
|
||||||
|
t.Errorf("advanced.PlanModel.Fallbacks = %v", adv.PlanModel.Fallbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentConfig_PlanModel_OverridesDefaults(t *testing.T) {
|
||||||
|
jsonData := `{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/.picoclaw/workspace",
|
||||||
|
"model": "glm-4.7",
|
||||||
|
"plan_model": "default-plan-model",
|
||||||
|
"plan_model_fallbacks": ["default-fallback"],
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"max_tool_iterations": 20
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "custom",
|
||||||
|
"plan_model": {
|
||||||
|
"primary": "custom-plan-model",
|
||||||
|
"fallbacks": ["custom-fallback"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
custom := cfg.Agents.List[0]
|
||||||
|
if custom.PlanModel == nil || custom.PlanModel.Primary != "custom-plan-model" {
|
||||||
|
t.Errorf("custom.PlanModel.Primary = %v, want 'custom-plan-model'", custom.PlanModel)
|
||||||
|
}
|
||||||
|
if len(custom.PlanModel.Fallbacks) != 1 || custom.PlanModel.Fallbacks[0] != "custom-fallback" {
|
||||||
|
t.Errorf("custom.PlanModel.Fallbacks = %v, want [custom-fallback]", custom.PlanModel.Fallbacks)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Agents.Defaults.PlanModel != "default-plan-model" {
|
||||||
|
t.Errorf("defaults.PlanModel = %q, want 'default-plan-model'", cfg.Agents.Defaults.PlanModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -296,7 +296,7 @@ func TestDefaultConfig_WebTools(t *testing.T) {
|
||||||
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
||||||
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
||||||
}
|
}
|
||||||
if cfg.Tools.Web.Brave.APIKey != "" {
|
if len(cfg.Tools.Web.Brave.APIKeys) != 0 {
|
||||||
t.Error("Brave API key should be empty by default")
|
t.Error("Brave API key should be empty by default")
|
||||||
}
|
}
|
||||||
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
||||||
|
|
@ -384,6 +384,13 @@ func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if !cfg.Tools.Exec.AllowRemote {
|
||||||
|
t.Fatal("DefaultConfig().Tools.Exec.AllowRemote should be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
|
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
configPath := filepath.Join(dir, "config.json")
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
|
@ -400,6 +407,22 @@ func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
if err := os.WriteFile(configPath, []byte(`{"tools":{"exec":{"enable_deny_patterns":true}}}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error: %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.Tools.Exec.AllowRemote {
|
||||||
|
t.Fatal("tools.exec.allow_remote should remain true when unset in config file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
|
func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
configPath := filepath.Join(dir, "config.json")
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
|
@ -416,133 +439,12 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAgentDefaults_PlanModel_StringParse(t *testing.T) {
|
|
||||||
jsonData := `{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"workspace": "~/.picoclaw/workspace",
|
|
||||||
"model": "glm-4.7",
|
|
||||||
"plan_model": "anthropic/claude-sonnet-4-6",
|
|
||||||
"plan_model_fallbacks": ["openai/gpt-4o"],
|
|
||||||
"max_tokens": 8192,
|
|
||||||
"max_tool_iterations": 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
|
||||||
t.Fatalf("unmarshal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Agents.Defaults.PlanModel != "anthropic/claude-sonnet-4-6" {
|
|
||||||
t.Errorf("PlanModel = %q, want 'anthropic/claude-sonnet-4-6'", cfg.Agents.Defaults.PlanModel)
|
|
||||||
}
|
|
||||||
if len(cfg.Agents.Defaults.PlanModelFallbacks) != 1 ||
|
|
||||||
cfg.Agents.Defaults.PlanModelFallbacks[0] != "openai/gpt-4o" {
|
|
||||||
t.Errorf("PlanModelFallbacks = %v, want [openai/gpt-4o]", cfg.Agents.Defaults.PlanModelFallbacks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentConfig_PlanModel_ObjectParse(t *testing.T) {
|
|
||||||
jsonData := `{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"workspace": "~/.picoclaw/workspace",
|
|
||||||
"model": "glm-4.7",
|
|
||||||
"max_tokens": 8192,
|
|
||||||
"max_tool_iterations": 20
|
|
||||||
},
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"id": "main",
|
|
||||||
"plan_model": "anthropic/claude-sonnet-4-6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "advanced",
|
|
||||||
"plan_model": {
|
|
||||||
"primary": "anthropic/claude-opus-4",
|
|
||||||
"fallbacks": ["anthropic/claude-sonnet-4-6"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
|
||||||
t.Fatalf("unmarshal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cfg.Agents.List) != 2 {
|
|
||||||
t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List))
|
|
||||||
}
|
|
||||||
|
|
||||||
// String form
|
|
||||||
main := cfg.Agents.List[0]
|
|
||||||
if main.PlanModel == nil || main.PlanModel.Primary != "anthropic/claude-sonnet-4-6" {
|
|
||||||
t.Errorf("main.PlanModel = %+v, want primary 'anthropic/claude-sonnet-4-6'", main.PlanModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Object form with fallbacks
|
|
||||||
adv := cfg.Agents.List[1]
|
|
||||||
if adv.PlanModel == nil || adv.PlanModel.Primary != "anthropic/claude-opus-4" {
|
|
||||||
t.Errorf("advanced.PlanModel = %+v, want primary 'anthropic/claude-opus-4'", adv.PlanModel)
|
|
||||||
}
|
|
||||||
if len(adv.PlanModel.Fallbacks) != 1 || adv.PlanModel.Fallbacks[0] != "anthropic/claude-sonnet-4-6" {
|
|
||||||
t.Errorf("advanced.PlanModel.Fallbacks = %v", adv.PlanModel.Fallbacks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentConfig_PlanModel_OverridesDefaults(t *testing.T) {
|
|
||||||
jsonData := `{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"workspace": "~/.picoclaw/workspace",
|
|
||||||
"model": "glm-4.7",
|
|
||||||
"plan_model": "default-plan-model",
|
|
||||||
"plan_model_fallbacks": ["default-fallback"],
|
|
||||||
"max_tokens": 8192,
|
|
||||||
"max_tool_iterations": 20
|
|
||||||
},
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"id": "custom",
|
|
||||||
"plan_model": {
|
|
||||||
"primary": "custom-plan-model",
|
|
||||||
"fallbacks": ["custom-fallback"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
|
||||||
t.Fatalf("unmarshal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent-level plan_model should override defaults
|
|
||||||
custom := cfg.Agents.List[0]
|
|
||||||
if custom.PlanModel == nil || custom.PlanModel.Primary != "custom-plan-model" {
|
|
||||||
t.Errorf("custom.PlanModel.Primary = %v, want 'custom-plan-model'", custom.PlanModel)
|
|
||||||
}
|
|
||||||
if len(custom.PlanModel.Fallbacks) != 1 || custom.PlanModel.Fallbacks[0] != "custom-fallback" {
|
|
||||||
t.Errorf("custom.PlanModel.Fallbacks = %v, want [custom-fallback]", custom.PlanModel.Fallbacks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defaults should still be intact
|
|
||||||
if cfg.Agents.Defaults.PlanModel != "default-plan-model" {
|
|
||||||
t.Errorf("defaults.PlanModel = %q, want 'default-plan-model'", cfg.Agents.Defaults.PlanModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfig_WebToolsProxy(t *testing.T) {
|
func TestLoadConfig_WebToolsProxy(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
configPath := filepath.Join(tmpDir, "config.json")
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
configJSON := `{
|
configJSON := `{
|
||||||
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
|
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
|
||||||
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}],
|
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.4","api_key":"x"}],
|
||||||
"tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
|
"tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
|
||||||
}`
|
}`
|
||||||
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
|
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
|
||||||
|
|
@ -603,3 +505,119 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
|
||||||
t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want)
|
t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators
|
||||||
|
func TestFlexibleStringSlice_UnmarshalText(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "English commas only",
|
||||||
|
input: "123,456,789",
|
||||||
|
expected: []string{"123", "456", "789"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Chinese commas only",
|
||||||
|
input: "123,456,789",
|
||||||
|
expected: []string{"123", "456", "789"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed English and Chinese commas",
|
||||||
|
input: "123,456,789",
|
||||||
|
expected: []string{"123", "456", "789"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Single value",
|
||||||
|
input: "123",
|
||||||
|
expected: []string{"123"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Values with whitespace",
|
||||||
|
input: " 123 , 456 , 789 ",
|
||||||
|
expected: []string{"123", "456", "789"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty string",
|
||||||
|
input: "",
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Only commas - English",
|
||||||
|
input: ",,",
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Only commas - Chinese",
|
||||||
|
input: ",,",
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed commas with empty parts",
|
||||||
|
input: "123,,456,,789",
|
||||||
|
expected: []string{"123", "456", "789"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Complex mixed values",
|
||||||
|
input: "user1@example.com,user2@test.com, admin@domain.org",
|
||||||
|
expected: []string{"user1@example.com", "user2@test.com", "admin@domain.org"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var f FlexibleStringSlice
|
||||||
|
err := f.UnmarshalText([]byte(tt.input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnmarshalText(%q) error = %v", tt.input, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.expected == nil {
|
||||||
|
if f != nil {
|
||||||
|
t.Errorf("UnmarshalText(%q) = %v, want nil", tt.input, f)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(f) != len(tt.expected) {
|
||||||
|
t.Errorf("UnmarshalText(%q) length = %d, want %d", tt.input, len(f), len(tt.expected))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, v := range tt.expected {
|
||||||
|
if f[i] != v {
|
||||||
|
t.Errorf("UnmarshalText(%q)[%d] = %q, want %q", tt.input, i, f[i], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency tests nil vs empty slice behavior
|
||||||
|
func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) {
|
||||||
|
t.Run("Empty string returns nil", func(t *testing.T) {
|
||||||
|
var f FlexibleStringSlice
|
||||||
|
err := f.UnmarshalText([]byte(""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnmarshalText error = %v", err)
|
||||||
|
}
|
||||||
|
if f != nil {
|
||||||
|
t.Errorf("Empty string should return nil, got %v", f)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Commas only returns empty slice", func(t *testing.T) {
|
||||||
|
var f FlexibleStringSlice
|
||||||
|
err := f.UnmarshalText([]byte(",,,"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnmarshalText error = %v", err)
|
||||||
|
}
|
||||||
|
if f == nil {
|
||||||
|
t.Error("Commas only should return empty slice, not nil")
|
||||||
|
}
|
||||||
|
if len(f) != 0 {
|
||||||
|
t.Errorf("Expected empty slice, got %v", f)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ func DefaultConfig() *Config {
|
||||||
Temperature: nil, // nil means use provider default
|
Temperature: nil, // nil means use provider default
|
||||||
MaxToolIterations: 50,
|
MaxToolIterations: 50,
|
||||||
SummarizeMessageThreshold: 20,
|
SummarizeMessageThreshold: 20,
|
||||||
TaskReminderInterval: 5,
|
|
||||||
SummarizeTokenPercent: 75,
|
SummarizeTokenPercent: 75,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -51,12 +50,10 @@ func DefaultConfig() *Config {
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
Telegram: TelegramConfig{
|
Telegram: TelegramConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Token: "",
|
Token: "",
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
Typing: TypingConfig{Enabled: true},
|
Typing: TypingConfig{Enabled: true},
|
||||||
SubagentThreadID: 0,
|
|
||||||
HeartbeatThreadID: 0,
|
|
||||||
Placeholder: PlaceholderConfig{
|
Placeholder: PlaceholderConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Text: "Thinking... 💭",
|
Text: "Thinking... 💭",
|
||||||
|
|
@ -83,10 +80,11 @@ func DefaultConfig() *Config {
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
QQ: QQConfig{
|
QQ: QQConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
AppID: "",
|
AppID: "",
|
||||||
AppSecret: "",
|
AppSecret: "",
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
MaxMessageLength: 2000,
|
||||||
},
|
},
|
||||||
DingTalk: DingTalkConfig{
|
DingTalk: DingTalkConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
|
@ -196,8 +194,8 @@ func DefaultConfig() *Config {
|
||||||
|
|
||||||
// OpenAI - https://platform.openai.com/api-keys
|
// OpenAI - https://platform.openai.com/api-keys
|
||||||
{
|
{
|
||||||
ModelName: "gpt-5.2",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
APIBase: "https://api.openai.com/v1",
|
APIBase: "https://api.openai.com/v1",
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
|
|
@ -258,8 +256,8 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "openrouter-gpt-5.2",
|
ModelName: "openrouter-gpt-5.4",
|
||||||
Model: "openrouter/openai/gpt-5.2",
|
Model: "openrouter/openai/gpt-5.4",
|
||||||
APIBase: "https://openrouter.ai/api/v1",
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
|
|
@ -289,6 +287,12 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
||||||
|
{
|
||||||
|
ModelName: "ark-code-latest",
|
||||||
|
Model: "volcengine/ark-code-latest",
|
||||||
|
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ModelName: "doubao-pro",
|
ModelName: "doubao-pro",
|
||||||
Model: "volcengine/doubao-pro-32k",
|
Model: "volcengine/doubao-pro-32k",
|
||||||
|
|
@ -313,8 +317,8 @@ func DefaultConfig() *Config {
|
||||||
|
|
||||||
// GitHub Copilot - https://github.com/settings/tokens
|
// GitHub Copilot - https://github.com/settings/tokens
|
||||||
{
|
{
|
||||||
ModelName: "copilot-gpt-5.2",
|
ModelName: "copilot-gpt-5.4",
|
||||||
Model: "github-copilot/gpt-5.2",
|
Model: "github-copilot/gpt-5.4",
|
||||||
APIBase: "http://localhost:4321",
|
APIBase: "http://localhost:4321",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
},
|
},
|
||||||
|
|
@ -349,6 +353,22 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Minimax - https://api.minimaxi.com/
|
||||||
|
{
|
||||||
|
ModelName: "MiniMax-M2.5",
|
||||||
|
Model: "minimax/MiniMax-M2.5",
|
||||||
|
APIBase: "https://api.minimaxi.com/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
// LongCat - https://longcat.chat/platform
|
||||||
|
{
|
||||||
|
ModelName: "LongCat-Flash-Thinking",
|
||||||
|
Model: "longcat/LongCat-Flash-Thinking",
|
||||||
|
APIBase: "https://api.longcat.chat/openai",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
// VLLM (local) - http://localhost:8000
|
// VLLM (local) - http://localhost:8000
|
||||||
{
|
{
|
||||||
ModelName: "local-model",
|
ModelName: "local-model",
|
||||||
|
|
@ -378,6 +398,13 @@ func DefaultConfig() *Config {
|
||||||
Brave: BraveConfig{
|
Brave: BraveConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
|
APIKeys: nil,
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
|
Tavily: TavilyConfig{
|
||||||
|
Enabled: false,
|
||||||
|
APIKey: "",
|
||||||
|
APIKeys: nil,
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
DuckDuckGo: DuckDuckGoConfig{
|
DuckDuckGo: DuckDuckGoConfig{
|
||||||
|
|
@ -387,6 +414,7 @@ func DefaultConfig() *Config {
|
||||||
Perplexity: PerplexityConfig{
|
Perplexity: PerplexityConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
|
APIKeys: nil,
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
SearXNG: SearXNGConfig{
|
SearXNG: SearXNGConfig{
|
||||||
|
|
@ -413,6 +441,7 @@ func DefaultConfig() *Config {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
EnableDenyPatterns: true,
|
EnableDenyPatterns: true,
|
||||||
|
AllowRemote: true,
|
||||||
TimeoutSeconds: 60,
|
TimeoutSeconds: 60,
|
||||||
},
|
},
|
||||||
Skills: SkillsToolsConfig{
|
Skills: SkillsToolsConfig{
|
||||||
|
|
@ -438,6 +467,13 @@ func DefaultConfig() *Config {
|
||||||
ToolConfig: ToolConfig{
|
ToolConfig: ToolConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
},
|
},
|
||||||
|
Discovery: ToolDiscoveryConfig{
|
||||||
|
Enabled: false,
|
||||||
|
TTL: 5,
|
||||||
|
MaxSearchResults: 5,
|
||||||
|
UseBM25: true,
|
||||||
|
UseRegex: false,
|
||||||
|
},
|
||||||
Servers: map[string]MCPServerConfig{},
|
Servers: map[string]MCPServerConfig{},
|
||||||
},
|
},
|
||||||
AppendFile: ToolConfig{
|
AppendFile: ToolConfig{
|
||||||
|
|
@ -461,8 +497,9 @@ func DefaultConfig() *Config {
|
||||||
Message: ToolConfig{
|
Message: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
ReadFile: ToolConfig{
|
ReadFile: ReadFileToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
MaxReadFileSize: 64 * 1024, // 64KB
|
||||||
},
|
},
|
||||||
Spawn: ToolConfig{
|
Spawn: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
@ -488,5 +525,14 @@ func DefaultConfig() *Config {
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
MonitorUSB: true,
|
MonitorUSB: true,
|
||||||
},
|
},
|
||||||
|
Voice: VoiceConfig{
|
||||||
|
EchoTranscription: false,
|
||||||
|
},
|
||||||
|
BuildInfo: BuildInfo{
|
||||||
|
Version: Version,
|
||||||
|
GitCommit: GitCommit,
|
||||||
|
BuildTime: BuildTime,
|
||||||
|
GoVersion: GoVersion,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
|
|
||||||
p := cfg.Providers
|
p := cfg.Providers
|
||||||
|
|
||||||
result := make([]ModelConfig, 0, 20)
|
var result []ModelConfig
|
||||||
|
|
||||||
// Track if we've applied the legacy model name fix (only for first provider)
|
// Track if we've applied the legacy model name fix (only for first provider)
|
||||||
legacyModelNameApplied := false
|
legacyModelNameApplied := false
|
||||||
|
|
@ -61,7 +61,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "openai",
|
ModelName: "openai",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
APIKey: p.OpenAI.APIKey,
|
APIKey: p.OpenAI.APIKey,
|
||||||
APIBase: p.OpenAI.APIBase,
|
APIBase: p.OpenAI.APIBase,
|
||||||
Proxy: p.OpenAI.Proxy,
|
Proxy: p.OpenAI.Proxy,
|
||||||
|
|
@ -335,7 +335,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
}
|
}
|
||||||
return ModelConfig{
|
return ModelConfig{
|
||||||
ModelName: "github-copilot",
|
ModelName: "github-copilot",
|
||||||
Model: "github-copilot/gpt-5.2",
|
Model: "github-copilot/gpt-5.4",
|
||||||
APIBase: p.GitHubCopilot.APIBase,
|
APIBase: p.GitHubCopilot.APIBase,
|
||||||
ConnectMode: p.GitHubCopilot.ConnectMode,
|
ConnectMode: p.GitHubCopilot.ConnectMode,
|
||||||
}, true
|
}, true
|
||||||
|
|
@ -407,6 +407,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"longcat"},
|
||||||
|
protocol: "longcat",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "longcat",
|
||||||
|
Model: "longcat/LongCat-Flash-Thinking",
|
||||||
|
APIKey: p.LongCat.APIKey,
|
||||||
|
APIBase: p.LongCat.APIBase,
|
||||||
|
Proxy: p.LongCat.Proxy,
|
||||||
|
RequestTimeout: p.LongCat.RequestTimeout,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each provider migration
|
// Process each provider migration
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
|
||||||
if result[0].ModelName != "openai" {
|
if result[0].ModelName != "openai" {
|
||||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
|
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
|
||||||
}
|
}
|
||||||
if result[0].Model != "openai/gpt-5.2" {
|
if result[0].Model != "openai/gpt-5.4" {
|
||||||
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.2")
|
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.4")
|
||||||
}
|
}
|
||||||
if result[0].APIKey != "sk-test-key" {
|
if result[0].APIKey != "sk-test-key" {
|
||||||
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
|
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
|
||||||
|
|
@ -162,14 +162,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||||
Qwen: ProviderConfig{APIKey: "key17"},
|
Qwen: ProviderConfig{APIKey: "key17"},
|
||||||
Mistral: ProviderConfig{APIKey: "key18"},
|
Mistral: ProviderConfig{APIKey: "key18"},
|
||||||
Avian: ProviderConfig{APIKey: "key19"},
|
Avian: ProviderConfig{APIKey: "key19"},
|
||||||
|
LongCat: ProviderConfig{APIKey: "key-longcat"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ConvertProvidersToModelList(cfg)
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
// All 21 providers should be converted
|
// All 22 providers should be converted
|
||||||
if len(result) != 21 {
|
if len(result) != 22 {
|
||||||
t.Errorf("len(result) = %d, want 21", len(result))
|
t.Errorf("len(result) = %d, want 22", len(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -383,8 +384,8 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes
|
||||||
for _, mc := range result {
|
for _, mc := range result {
|
||||||
switch mc.ModelName {
|
switch mc.ModelName {
|
||||||
case "openai":
|
case "openai":
|
||||||
if mc.Model != "openai/gpt-5.2" {
|
if mc.Model != "openai/gpt-5.4" {
|
||||||
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.2")
|
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.4")
|
||||||
}
|
}
|
||||||
case "deepseek":
|
case "deepseek":
|
||||||
if mc.Model != "deepseek/deepseek-reasoner" {
|
if mc.Model != "deepseek/deepseek-reasoner" {
|
||||||
|
|
@ -557,9 +558,9 @@ func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
|
||||||
// Tests for buildModelWithProtocol helper function
|
// Tests for buildModelWithProtocol helper function
|
||||||
|
|
||||||
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
|
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
|
||||||
result := buildModelWithProtocol("openai", "gpt-5.2")
|
result := buildModelWithProtocol("openai", "gpt-5.4")
|
||||||
if result != "openai/gpt-5.2" {
|
if result != "openai/gpt-5.4" {
|
||||||
t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2")
|
t.Errorf("buildModelWithProtocol(openai, gpt-5.4) = %q, want %q", result, "openai/gpt-5.4")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
44
pkg/config/version.go
Normal file
44
pkg/config/version.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build-time variables injected via ldflags during build process.
|
||||||
|
// These are set by the Makefile or .goreleaser.yaml using the -X flag:
|
||||||
|
//
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.Version=<version>
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.GitCommit=<commit>
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.BuildTime=<timestamp>
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.GoVersion=<go-version>
|
||||||
|
var (
|
||||||
|
Version = "dev" // Default value when not built with ldflags
|
||||||
|
GitCommit string // Git commit SHA (short)
|
||||||
|
BuildTime string // Build timestamp in RFC3339 format
|
||||||
|
GoVersion string // Go version used for building
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatVersion returns the version string with optional git commit
|
||||||
|
func FormatVersion() string {
|
||||||
|
v := Version
|
||||||
|
if GitCommit != "" {
|
||||||
|
v += fmt.Sprintf(" (git: %s)", GitCommit)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatBuildInfo returns build time and go version info
|
||||||
|
func FormatBuildInfo() (string, string) {
|
||||||
|
build := BuildTime
|
||||||
|
goVer := GoVersion
|
||||||
|
if goVer == "" {
|
||||||
|
goVer = runtime.Version()
|
||||||
|
}
|
||||||
|
return build, goVer
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersion returns the version string
|
||||||
|
func GetVersion() string {
|
||||||
|
return Version
|
||||||
|
}
|
||||||
92
pkg/config/version_test.go
Normal file
92
pkg/config/version_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := Version, GitCommit
|
||||||
|
t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
Version = "1.2.3"
|
||||||
|
GitCommit = ""
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatVersion_WithGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := Version, GitCommit
|
||||||
|
t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
Version = "1.2.3"
|
||||||
|
GitCommit = "abc123"
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := BuildTime, GoVersion
|
||||||
|
t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
BuildTime = "2026-02-20T00:00:00Z"
|
||||||
|
GoVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, BuildTime, build)
|
||||||
|
assert.Equal(t, GoVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := BuildTime, GoVersion
|
||||||
|
t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
BuildTime = ""
|
||||||
|
GoVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Empty(t, build)
|
||||||
|
assert.Equal(t, GoVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := BuildTime, GoVersion
|
||||||
|
t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
BuildTime = "x"
|
||||||
|
GoVersion = ""
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, "x", build)
|
||||||
|
assert.Equal(t, runtime.Version(), goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVersion(t *testing.T) {
|
||||||
|
oldVersion := Version
|
||||||
|
t.Cleanup(func() { Version = oldVersion })
|
||||||
|
|
||||||
|
Version = "dev"
|
||||||
|
assert.Equal(t, "dev", GetVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVersion_Custom(t *testing.T) {
|
||||||
|
oldVersion := Version
|
||||||
|
t.Cleanup(func() { Version = oldVersion })
|
||||||
|
|
||||||
|
Version = "v1.0.0"
|
||||||
|
assert.Equal(t, "v1.0.0", GetVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersion_DefaultIsDev(t *testing.T) {
|
||||||
|
// Reset to default values
|
||||||
|
oldVersion := Version
|
||||||
|
Version = "dev"
|
||||||
|
t.Cleanup(func() { Version = oldVersion })
|
||||||
|
|
||||||
|
assert.Equal(t, "dev", Version)
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,6 @@ package health
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
|
@ -13,7 +12,6 @@ import (
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
server *http.Server
|
server *http.Server
|
||||||
mux *http.ServeMux
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ready bool
|
ready bool
|
||||||
checks map[string]Check
|
checks map[string]Check
|
||||||
|
|
@ -36,7 +34,6 @@ type StatusResponse struct {
|
||||||
func NewServer(host string, port int) *Server {
|
func NewServer(host string, port int) *Server {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
s := &Server{
|
s := &Server{
|
||||||
mux: mux,
|
|
||||||
ready: false,
|
ready: false,
|
||||||
checks: make(map[string]Check),
|
checks: make(map[string]Check),
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
|
|
@ -49,34 +46,13 @@ func NewServer(host string, port int) *Server {
|
||||||
s.server = &http.Server{
|
s.server = &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: 5 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mux returns the underlying ServeMux so additional routes can be registered.
|
|
||||||
func (s *Server) Mux() *http.ServeMux {
|
|
||||||
return s.mux
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTLS starts the server with TLS using the provided certificate and key files.
|
|
||||||
func (s *Server) StartTLS(certFile, keyFile string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
s.ready = true
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load TLS cert: %w", err)
|
|
||||||
}
|
|
||||||
s.server.TLSConfig = &tls.Config{
|
|
||||||
Certificates: []tls.Certificate{cert},
|
|
||||||
}
|
|
||||||
return s.server.ListenAndServeTLS("", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) Start() error {
|
func (s *Server) Start() error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.ready = true
|
s.ready = true
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue