diff --git a/Taskfile.yml b/Taskfile.yml
new file mode 100644
index 000000000..b6b31e471
--- /dev/null
+++ b/Taskfile.yml
@@ -0,0 +1,120 @@
+version: '3'
+
+vars:
+ BINARY_NAME: picoclaw
+ CMD_DIR: cmd/picoclaw
+ BUILD_DIR: build
+ INTERNAL: github.com/sipeed/picoclaw/cmd/picoclaw/internal
+ VERSION:
+ sh: git describe --tags --always --dirty 2>{{if eq OS "windows"}}nul{{else}}/dev/null{{end}} || echo dev
+ GIT_COMMIT:
+ sh: git rev-parse --short=8 HEAD 2>{{if eq OS "windows"}}nul{{else}}/dev/null{{end}} || echo dev
+ LDFLAGS: -ldflags "-X {{.INTERNAL}}.version={{.VERSION}} -X {{.INTERNAL}}.gitCommit={{.GIT_COMMIT}} -s -w"
+
+tasks:
+ default:
+ desc: Build the project
+ cmds:
+ - task: build
+
+ build:
+ desc: Build picoclaw binary (dev, skip generate)
+ cmds:
+ - go build -v {{.LDFLAGS}} -o {{.BUILD_DIR}}/{{.BINARY_NAME}}{{exeExt}} ./{{.CMD_DIR}}
+ sources:
+ - "**/*.go"
+ generates:
+ - "{{.BUILD_DIR}}/{{.BINARY_NAME}}{{exeExt}}"
+
+ build-full:
+ desc: Build with go generate (release)
+ cmds:
+ - task: generate
+ - task: build
+
+ generate:
+ desc: Run go generate
+ cmds:
+ - cmd: powershell -Command "Remove-Item -Recurse -Force '{{.CMD_DIR}}/internal/onboard/workspace' -ErrorAction SilentlyContinue"
+ platforms: [windows]
+ - cmd: rm -rf {{.CMD_DIR}}/internal/onboard/workspace
+ platforms: [linux, darwin]
+ - go generate ./...
+
+ test:
+ desc: Run all tests
+ cmds:
+ - go test -count=1 ./pkg/...
+
+ test-v:
+ desc: Run all tests (verbose)
+ cmds:
+ - go test -v -count=1 ./pkg/...
+
+ test-agent:
+ desc: Run agent tests
+ cmds:
+ - go test -v -count=1 ./pkg/agent/
+
+ test-config:
+ desc: Run config tests
+ cmds:
+ - go test -v -count=1 ./pkg/config/
+
+ test-init:
+ desc: Run init command tests
+ cmds:
+ - go test -v -count=1 ./cmd/picoclaw/internal/initcmd/
+
+ lint:
+ desc: Run linters
+ cmds:
+ - golangci-lint run
+
+ fmt:
+ desc: Format code
+ cmds:
+ - gofmt -w .
+
+ vet:
+ desc: Run go vet
+ cmds:
+ - go vet ./...
+
+ clean:
+ desc: Remove build artifacts
+ cmds:
+ - cmd: powershell -Command "Remove-Item -Recurse -Force '{{.BUILD_DIR}}' -ErrorAction SilentlyContinue"
+ platforms: [windows]
+ - cmd: rm -rf {{.BUILD_DIR}}
+ platforms: [linux, darwin]
+
+ deps:
+ desc: Download and verify dependencies
+ cmds:
+ - go mod download
+ - go mod verify
+
+ tidy:
+ desc: Tidy dependencies
+ cmds:
+ - go mod tidy
+
+ check:
+ desc: Full check (fmt + vet + test)
+ cmds:
+ - task: fmt
+ - task: vet
+ - task: test
+
+ install:
+ desc: Install picoclaw to system
+ cmds:
+ - task: build
+ - go install ./{{.CMD_DIR}}
+
+ run:
+ desc: Build and run
+ cmds:
+ - task: build
+ - "{{.BUILD_DIR}}/{{.BINARY_NAME}}{{exeExt}} {{.CLI_ARGS}}"
diff --git a/cmd/picoclaw-launcher/app.go b/cmd/picoclaw-launcher/app.go
new file mode 100644
index 000000000..4a0417477
--- /dev/null
+++ b/cmd/picoclaw-launcher/app.go
@@ -0,0 +1,470 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/agent"
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// App is the main Wails application struct.
+// All exported methods are automatically bound to the frontend.
+type App struct {
+ ctx context.Context
+ configPath string
+ forceQuit bool
+
+ // Chat state
+ chatMu sync.Mutex
+ agentLoop *agent.AgentLoop
+ msgBus *bus.MessageBus
+}
+
+// NewApp creates a new App instance.
+func NewApp(configPath string) *App {
+ return &App{configPath: configPath}
+}
+
+// startup is called when the app starts.
+func (a *App) startup(ctx context.Context) {
+ a.ctx = ctx
+ a.setupTray()
+}
+
+// shutdown is called when the app is closing.
+func (a *App) shutdown(ctx context.Context) {
+ a.chatMu.Lock()
+ defer a.chatMu.Unlock()
+ if a.msgBus != nil {
+ a.msgBus.Close()
+ }
+}
+
+// ── Setup ───────────────────────────────────────────
+
+// SetupStatus returns whether initial setup is needed.
+type SetupStatusResult struct {
+ NeedsSetup bool `json:"needs_setup"`
+ ConfigPath string `json:"config_path"`
+}
+
+func (a *App) GetSetupStatus() SetupStatusResult {
+ needsSetup := true
+ if cfg, err := config.LoadConfig(a.configPath); err == nil && cfg != nil {
+ // Config is valid if either providers or model_list has entries
+ needsSetup = cfg.Providers.IsEmpty() && len(cfg.ModelList) == 0
+ }
+ return SetupStatusResult{
+ NeedsSetup: needsSetup,
+ ConfigPath: a.configPath,
+ }
+}
+
+// TestLLM tests an LLM connection without persisting anything.
+type TestLLMRequest struct {
+ APIKey string `json:"api_key"`
+ APIBase string `json:"api_base"`
+ Model string `json:"model"`
+}
+
+type TestLLMResult struct {
+ Success bool `json:"success"`
+ Response string `json:"response"`
+ Model string `json:"model"`
+ Protocol string `json:"protocol"`
+ Error string `json:"error"`
+}
+
+func (a *App) TestLLM(req TestLLMRequest) TestLLMResult {
+ if req.APIKey == "" || req.Model == "" {
+ return TestLLMResult{Error: "API key and model are required"}
+ }
+ if req.APIBase == "" {
+ req.APIBase = "https://api.openai.com/v1"
+ }
+
+ protocol := detectProtocol(req.APIBase)
+ modelID := buildModelField(protocol, req.Model)
+
+ modelCfg := &config.ModelConfig{
+ ModelName: req.Model,
+ Model: modelID,
+ APIBase: req.APIBase,
+ APIKey: req.APIKey,
+ }
+
+ provider, resolvedModel, err := providers.CreateProviderFromConfig(modelCfg)
+ if err != nil {
+ return TestLLMResult{Error: fmt.Sprintf("Provider creation failed: %v", err)}
+ }
+ if resolvedModel == "" {
+ resolvedModel = req.Model
+ }
+
+ ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
+ defer cancel()
+
+ resp, err := provider.Chat(ctx, []providers.Message{
+ {Role: "user", Content: "Reply with exactly one word: PONG"},
+ }, nil, resolvedModel, nil)
+
+ if err != nil {
+ return TestLLMResult{Error: fmt.Sprintf("LLM call failed: %v", err)}
+ }
+
+ return TestLLMResult{
+ Success: true,
+ Response: strings.TrimSpace(resp.Content),
+ Model: resolvedModel,
+ Protocol: protocol,
+ }
+}
+
+// SaveSetup saves a minimal config from the setup wizard.
+type SaveSetupResult struct {
+ Success bool `json:"success"`
+ ConfigPath string `json:"config_path"`
+ Workspace string `json:"workspace"`
+ Error string `json:"error"`
+}
+
+func (a *App) SaveSetup(req TestLLMRequest) SaveSetupResult {
+ if req.APIKey == "" || req.Model == "" {
+ return SaveSetupResult{Error: "API key and model are required"}
+ }
+ if req.APIBase == "" {
+ req.APIBase = "https://api.openai.com/v1"
+ }
+
+ protocol := detectProtocol(req.APIBase)
+ modelID := buildModelField(protocol, req.Model)
+ defaults := config.DefaultConfig()
+ workspace := defaults.Agents.Defaults.Workspace
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: workspace,
+ RestrictToWorkspace: true,
+ ModelName: req.Model,
+ MaxTokens: 32768,
+ MaxToolIterations: 50,
+ },
+ },
+ ModelList: []config.ModelConfig{
+ {ModelName: req.Model, Model: modelID, APIBase: req.APIBase, APIKey: req.APIKey},
+ },
+ Gateway: defaults.Gateway,
+ Tools: config.ToolsConfig{
+ Exec: config.ExecConfig{EnableDenyPatterns: true},
+ Web: config.WebToolsConfig{
+ DuckDuckGo: config.DuckDuckGoConfig{Enabled: true, MaxResults: 5},
+ },
+ },
+ }
+
+ os.MkdirAll(filepath.Dir(a.configPath), 0755)
+ os.MkdirAll(workspace, 0755)
+
+ if err := config.SaveConfig(a.configPath, cfg); err != nil {
+ return SaveSetupResult{Error: fmt.Sprintf("Save failed: %v", err)}
+ }
+
+ return SaveSetupResult{Success: true, ConfigPath: a.configPath, Workspace: workspace}
+}
+
+// ── Config ──────────────────────────────────────────
+
+func (a *App) GetConfig() (map[string]any, error) {
+ data, err := os.ReadFile(a.configPath)
+ if err != nil {
+ return nil, err
+ }
+ var raw map[string]any
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return nil, err
+ }
+ return map[string]any{"config": raw, "path": a.configPath}, nil
+}
+
+func (a *App) SaveConfig(cfgData string) error {
+ // Parse into generic map for cleanup
+ var raw any
+ if err := json.Unmarshal([]byte(cfgData), &raw); err != nil {
+ return fmt.Errorf("invalid JSON: %w", err)
+ }
+
+ // Remove empty/zero/null values
+ cleaned := cleanJSON(raw)
+
+ data, err := json.MarshalIndent(cleaned, "", " ")
+ if err != nil {
+ return fmt.Errorf("format failed: %w", err)
+ }
+
+ dir := filepath.Dir(a.configPath)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("mkdir failed: %w", err)
+ }
+ return os.WriteFile(a.configPath, append(data, '\n'), 0o600)
+}
+
+// cleanJSON recursively removes null, empty string, false, zero, empty object/array values.
+func cleanJSON(v any) any {
+ switch val := v.(type) {
+ case map[string]any:
+ out := make(map[string]any)
+ for k, child := range val {
+ c := cleanJSON(child)
+ if !isZeroValue(c) {
+ out[k] = c
+ }
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+ case []any:
+ var out []any
+ for _, child := range val {
+ c := cleanJSON(child)
+ if !isZeroValue(c) {
+ out = append(out, c)
+ }
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+ default:
+ return v
+ }
+}
+
+func isZeroValue(v any) bool {
+ if v == nil {
+ return true
+ }
+ switch val := v.(type) {
+ case string:
+ return val == ""
+ case map[string]any:
+ return len(val) == 0
+ case []any:
+ return len(val) == 0
+ }
+ return false
+}
+
+
+// ── Gateway Process ─────────────────────────────────
+
+var gatewayLogs = NewLogBuffer(500)
+
+type GatewayStatus struct {
+ Status string `json:"status"` // "running", "stopped", "error"
+ Model string `json:"model"`
+ Logs []string `json:"logs"`
+ Total int `json:"total"`
+}
+
+func (a *App) GetGatewayStatus() GatewayStatus {
+ cfg, err := config.LoadConfig(a.configPath)
+ host := "127.0.0.1"
+ port := 18790
+ if err == nil && cfg != nil {
+ if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
+ host = cfg.Gateway.Host
+ }
+ if cfg.Gateway.Port != 0 {
+ port = cfg.Gateway.Port
+ }
+ }
+
+ url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
+ client := &http.Client{Timeout: 2 * time.Second}
+ resp, err := client.Get(url)
+
+ status := "stopped"
+ model := "-"
+ if err == nil {
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusOK {
+ status = "running"
+ }
+ }
+
+ if err == nil && cfg != nil {
+ model = cfg.Agents.Defaults.GetModelName()
+ }
+
+ lines, total, _ := gatewayLogs.LinesSince(0)
+ if lines == nil {
+ lines = []string{}
+ }
+
+ return GatewayStatus{Status: status, Model: model, Logs: lines, Total: total}
+}
+
+// findBinary locates the picoclaw binary: same dir as launcher, PATH, or project build dir.
+func findBinary(name string) (string, error) {
+ suffix := ""
+ if runtime.GOOS == "windows" {
+ suffix = ".exe"
+ }
+ if exe, err := os.Executable(); err == nil {
+ // Same directory as launcher
+ if p := filepath.Join(filepath.Dir(exe), name+suffix); fileExists(p) {
+ return p, nil
+ }
+ // Project build dir (dev mode: ../../build/)
+ if p := filepath.Join(filepath.Dir(exe), "..", "..", "build", name+suffix); fileExists(p) {
+ return p, nil
+ }
+ }
+ if p, err := exec.LookPath(name); err == nil {
+ return p, nil
+ }
+ return "", fmt.Errorf("%s not found (checked: same dir, PATH, project build)", name)
+}
+
+func fileExists(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && !info.IsDir()
+}
+
+func (a *App) StartGateway() (string, error) {
+ execPath, err := findBinary("picoclaw")
+ if err != nil {
+ return "", err
+ }
+
+ cmd := exec.Command(execPath, "gateway")
+ hideProcessWindow(cmd)
+
+ stdoutPipe, _ := cmd.StdoutPipe()
+ stderrPipe, _ := cmd.StderrPipe()
+
+ gatewayLogs.Reset()
+
+ if err := cmd.Start(); err != nil {
+ return "", fmt.Errorf("start failed: %w", err)
+ }
+
+ go scanPipe(stdoutPipe)
+ go scanPipe(stderrPipe)
+ go func() { cmd.Wait() }()
+
+ return fmt.Sprintf("Started (PID: %d)", cmd.Process.Pid), nil
+}
+
+func scanPipe(r io.Reader) {
+ scanner := bufio.NewScanner(r)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ gatewayLogs.Append(scanner.Text())
+ }
+}
+
+func (a *App) StopGateway() (string, error) {
+ var err error
+ if runtime.GOOS == "windows" {
+ psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }`
+ err = exec.Command("powershell", "-Command", psCmd).Run()
+ } else {
+ err = exec.Command("pkill", "-f", "picoclaw gateway").Run()
+ }
+ if err != nil {
+ return "Gateway may not be running", nil
+ }
+ return "Stopped", nil
+}
+
+func (a *App) RestartGateway() (string, error) {
+ a.StopGateway()
+ time.Sleep(500 * time.Millisecond)
+
+ // Reset chat agent loop so it picks up new config
+ a.chatMu.Lock()
+ a.agentLoop = nil
+ a.chatMu.Unlock()
+
+ return a.StartGateway()
+}
+
+func (a *App) GetLogs(offset int) map[string]any {
+ lines, total, runID := gatewayLogs.LinesSince(offset)
+ if lines == nil {
+ lines = []string{}
+ }
+ return map[string]any{"logs": lines, "total": total, "run_id": runID}
+}
+
+// ── Chat ────────────────────────────────────────────
+
+type ChatResult struct {
+ Success bool `json:"success"`
+ Response string `json:"response"`
+ Error string `json:"error"`
+}
+
+func (a *App) SendChat(message string) ChatResult {
+ if message == "" {
+ return ChatResult{Error: "message is required"}
+ }
+
+ a.chatMu.Lock()
+ defer a.chatMu.Unlock()
+
+ // Lazy-init agent loop
+ if a.agentLoop == nil {
+ if err := a.initAgentLoop(); err != nil {
+ return ChatResult{Error: fmt.Sprintf("Init failed: %v", err)}
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(a.ctx, 120*time.Second)
+ defer cancel()
+
+ resp, err := a.agentLoop.ProcessDirect(ctx, message, "launcher:chat")
+ if err != nil {
+ return ChatResult{Error: fmt.Sprintf("Chat error: %v", err)}
+ }
+
+ return ChatResult{Success: true, Response: resp}
+}
+
+func (a *App) initAgentLoop() error {
+ cfg, err := config.LoadConfig(a.configPath)
+ if err != nil {
+ return fmt.Errorf("config load failed: %w", err)
+ }
+
+ provider, modelID, err := providers.CreateProvider(cfg)
+ if err != nil {
+ return fmt.Errorf("provider creation failed: %w", err)
+ }
+ if modelID != "" {
+ cfg.Agents.Defaults.ModelName = modelID
+ }
+
+ a.msgBus = bus.NewMessageBus()
+ a.agentLoop = agent.NewAgentLoop(cfg, a.msgBus, provider)
+ return nil
+}
diff --git a/cmd/picoclaw-launcher/frontend/index.html b/cmd/picoclaw-launcher/frontend/index.html
new file mode 100644
index 000000000..d36c42b1e
--- /dev/null
+++ b/cmd/picoclaw-launcher/frontend/index.html
@@ -0,0 +1,1143 @@
+
+
+
+
+
+
+ PicoClaw Launcher
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Status
+
Monitor and control PicoClaw gateway service
+
+
🚀 Gateway
+
StatusStopped
+
Model-
+
Config-
+
+
+
+
+
+
+
+
📝 Logs
+
No logs. Start the gateway to see
+ output.
+
+
+
+
+
+
Chat
+
Talk to your PicoClaw AI agent
+
+
+
+
+
+
Settings
+
Configure models, channels, and agent identity
+
+
+
📡 Channels
+
Telegram
+
Discord
+
Slack
+
WeCom
+
+
+
+
+
🧠 Agent Defaults
+
+
+
+
+
+
📁 Workspace
+
+
Restrict to workspace
+
Allow read outside workspace
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cmd/picoclaw-launcher/frontend/wailsjs/go/main/App.d.ts b/cmd/picoclaw-launcher/frontend/wailsjs/go/main/App.d.ts
new file mode 100644
index 000000000..dd3189182
--- /dev/null
+++ b/cmd/picoclaw-launcher/frontend/wailsjs/go/main/App.d.ts
@@ -0,0 +1,23 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+import {main} from '../models';
+
+export function GetConfig():Promise>;
+
+export function GetGatewayStatus():Promise;
+
+export function GetLogs(arg1:number):Promise>;
+
+export function GetSetupStatus():Promise;
+
+export function SaveConfig(arg1:string):Promise;
+
+export function SaveSetup(arg1:main.TestLLMRequest):Promise;
+
+export function SendChat(arg1:string):Promise;
+
+export function StartGateway():Promise;
+
+export function StopGateway():Promise;
+
+export function TestLLM(arg1:main.TestLLMRequest):Promise;
diff --git a/cmd/picoclaw-launcher/frontend/wailsjs/go/main/App.js b/cmd/picoclaw-launcher/frontend/wailsjs/go/main/App.js
new file mode 100644
index 000000000..f3f7c3f7f
--- /dev/null
+++ b/cmd/picoclaw-launcher/frontend/wailsjs/go/main/App.js
@@ -0,0 +1,43 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export function GetConfig() {
+ return window['go']['main']['App']['GetConfig']();
+}
+
+export function GetGatewayStatus() {
+ return window['go']['main']['App']['GetGatewayStatus']();
+}
+
+export function GetLogs(arg1) {
+ return window['go']['main']['App']['GetLogs'](arg1);
+}
+
+export function GetSetupStatus() {
+ return window['go']['main']['App']['GetSetupStatus']();
+}
+
+export function SaveConfig(arg1) {
+ return window['go']['main']['App']['SaveConfig'](arg1);
+}
+
+export function SaveSetup(arg1) {
+ return window['go']['main']['App']['SaveSetup'](arg1);
+}
+
+export function SendChat(arg1) {
+ return window['go']['main']['App']['SendChat'](arg1);
+}
+
+export function StartGateway() {
+ return window['go']['main']['App']['StartGateway']();
+}
+
+export function StopGateway() {
+ return window['go']['main']['App']['StopGateway']();
+}
+
+export function TestLLM(arg1) {
+ return window['go']['main']['App']['TestLLM'](arg1);
+}
diff --git a/cmd/picoclaw-launcher/frontend/wailsjs/go/models.ts b/cmd/picoclaw-launcher/frontend/wailsjs/go/models.ts
new file mode 100644
index 000000000..aa531c152
--- /dev/null
+++ b/cmd/picoclaw-launcher/frontend/wailsjs/go/models.ts
@@ -0,0 +1,107 @@
+export namespace main {
+
+ export class ChatResult {
+ success: boolean;
+ response: string;
+ error: string;
+
+ static createFrom(source: any = {}) {
+ return new ChatResult(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.success = source["success"];
+ this.response = source["response"];
+ this.error = source["error"];
+ }
+ }
+ export class GatewayStatus {
+ status: string;
+ model: string;
+ logs: string[];
+ total: number;
+
+ static createFrom(source: any = {}) {
+ return new GatewayStatus(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.status = source["status"];
+ this.model = source["model"];
+ this.logs = source["logs"];
+ this.total = source["total"];
+ }
+ }
+ export class SaveSetupResult {
+ success: boolean;
+ config_path: string;
+ workspace: string;
+ error: string;
+
+ static createFrom(source: any = {}) {
+ return new SaveSetupResult(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.success = source["success"];
+ this.config_path = source["config_path"];
+ this.workspace = source["workspace"];
+ this.error = source["error"];
+ }
+ }
+ export class SetupStatusResult {
+ needs_setup: boolean;
+ config_path: string;
+
+ static createFrom(source: any = {}) {
+ return new SetupStatusResult(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.needs_setup = source["needs_setup"];
+ this.config_path = source["config_path"];
+ }
+ }
+ export class TestLLMRequest {
+ api_key: string;
+ api_base: string;
+ model: string;
+
+ static createFrom(source: any = {}) {
+ return new TestLLMRequest(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.api_key = source["api_key"];
+ this.api_base = source["api_base"];
+ this.model = source["model"];
+ }
+ }
+ export class TestLLMResult {
+ success: boolean;
+ response: string;
+ model: string;
+ protocol: string;
+ error: string;
+
+ static createFrom(source: any = {}) {
+ return new TestLLMResult(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.success = source["success"];
+ this.response = source["response"];
+ this.model = source["model"];
+ this.protocol = source["protocol"];
+ this.error = source["error"];
+ }
+ }
+
+}
+
diff --git a/cmd/picoclaw-launcher/frontend/wailsjs/runtime/package.json b/cmd/picoclaw-launcher/frontend/wailsjs/runtime/package.json
new file mode 100644
index 000000000..1e7c8a5d7
--- /dev/null
+++ b/cmd/picoclaw-launcher/frontend/wailsjs/runtime/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@wailsapp/runtime",
+ "version": "2.0.0",
+ "description": "Wails Javascript runtime library",
+ "main": "runtime.js",
+ "types": "runtime.d.ts",
+ "scripts": {
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/wailsapp/wails.git"
+ },
+ "keywords": [
+ "Wails",
+ "Javascript",
+ "Go"
+ ],
+ "author": "Lea Anthony ",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/wailsapp/wails/issues"
+ },
+ "homepage": "https://github.com/wailsapp/wails#readme"
+}
diff --git a/cmd/picoclaw-launcher/frontend/wailsjs/runtime/runtime.d.ts b/cmd/picoclaw-launcher/frontend/wailsjs/runtime/runtime.d.ts
new file mode 100644
index 000000000..4445dac21
--- /dev/null
+++ b/cmd/picoclaw-launcher/frontend/wailsjs/runtime/runtime.d.ts
@@ -0,0 +1,249 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+
+export interface Position {
+ x: number;
+ y: number;
+}
+
+export interface Size {
+ w: number;
+ h: number;
+}
+
+export interface Screen {
+ isCurrent: boolean;
+ isPrimary: boolean;
+ width : number
+ height : number
+}
+
+// Environment information such as platform, buildtype, ...
+export interface EnvironmentInfo {
+ buildType: string;
+ platform: string;
+ arch: string;
+}
+
+// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
+// emits the given event. Optional data may be passed with the event.
+// This will trigger any event listeners.
+export function EventsEmit(eventName: string, ...data: any): void;
+
+// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
+export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
+
+// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
+// sets up a listener for the given event name, but will only trigger a given number times.
+export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
+
+// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
+// sets up a listener for the given event name, but will only trigger once.
+export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
+
+// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
+// unregisters the listener for the given event name.
+export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
+
+// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
+// unregisters all listeners.
+export function EventsOffAll(): void;
+
+// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
+// logs the given message as a raw message
+export function LogPrint(message: string): void;
+
+// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
+// logs the given message at the `trace` log level.
+export function LogTrace(message: string): void;
+
+// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
+// logs the given message at the `debug` log level.
+export function LogDebug(message: string): void;
+
+// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
+// logs the given message at the `error` log level.
+export function LogError(message: string): void;
+
+// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
+// logs the given message at the `fatal` log level.
+// The application will quit after calling this method.
+export function LogFatal(message: string): void;
+
+// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
+// logs the given message at the `info` log level.
+export function LogInfo(message: string): void;
+
+// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
+// logs the given message at the `warning` log level.
+export function LogWarning(message: string): void;
+
+// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
+// Forces a reload by the main application as well as connected browsers.
+export function WindowReload(): void;
+
+// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
+// Reloads the application frontend.
+export function WindowReloadApp(): void;
+
+// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
+// Sets the window AlwaysOnTop or not on top.
+export function WindowSetAlwaysOnTop(b: boolean): void;
+
+// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
+// *Windows only*
+// Sets window theme to system default (dark/light).
+export function WindowSetSystemDefaultTheme(): void;
+
+// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
+// *Windows only*
+// Sets window to light theme.
+export function WindowSetLightTheme(): void;
+
+// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
+// *Windows only*
+// Sets window to dark theme.
+export function WindowSetDarkTheme(): void;
+
+// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
+// Centers the window on the monitor the window is currently on.
+export function WindowCenter(): void;
+
+// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
+// Sets the text in the window title bar.
+export function WindowSetTitle(title: string): void;
+
+// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
+// Makes the window full screen.
+export function WindowFullscreen(): void;
+
+// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
+// Restores the previous window dimensions and position prior to full screen.
+export function WindowUnfullscreen(): void;
+
+// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
+// Returns the state of the window, i.e. whether the window is in full screen mode or not.
+export function WindowIsFullscreen(): Promise;
+
+// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
+// Sets the width and height of the window.
+export function WindowSetSize(width: number, height: number): void;
+
+// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
+// Gets the width and height of the window.
+export function WindowGetSize(): Promise;
+
+// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
+// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
+// Setting a size of 0,0 will disable this constraint.
+export function WindowSetMaxSize(width: number, height: number): void;
+
+// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
+// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
+// Setting a size of 0,0 will disable this constraint.
+export function WindowSetMinSize(width: number, height: number): void;
+
+// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
+// Sets the window position relative to the monitor the window is currently on.
+export function WindowSetPosition(x: number, y: number): void;
+
+// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
+// Gets the window position relative to the monitor the window is currently on.
+export function WindowGetPosition(): Promise;
+
+// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
+// Hides the window.
+export function WindowHide(): void;
+
+// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
+// Shows the window, if it is currently hidden.
+export function WindowShow(): void;
+
+// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
+// Maximises the window to fill the screen.
+export function WindowMaximise(): void;
+
+// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
+// Toggles between Maximised and UnMaximised.
+export function WindowToggleMaximise(): void;
+
+// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
+// Restores the window to the dimensions and position prior to maximising.
+export function WindowUnmaximise(): void;
+
+// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
+// Returns the state of the window, i.e. whether the window is maximised or not.
+export function WindowIsMaximised(): Promise;
+
+// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
+// Minimises the window.
+export function WindowMinimise(): void;
+
+// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
+// Restores the window to the dimensions and position prior to minimising.
+export function WindowUnminimise(): void;
+
+// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
+// Returns the state of the window, i.e. whether the window is minimised or not.
+export function WindowIsMinimised(): Promise;
+
+// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
+// Returns the state of the window, i.e. whether the window is normal or not.
+export function WindowIsNormal(): Promise;
+
+// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
+// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
+export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
+
+// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
+// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
+export function ScreenGetAll(): Promise;
+
+// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
+// Opens the given URL in the system browser.
+export function BrowserOpenURL(url: string): void;
+
+// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
+// Returns information about the environment
+export function Environment(): Promise;
+
+// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
+// Quits the application.
+export function Quit(): void;
+
+// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
+// Hides the application.
+export function Hide(): void;
+
+// [Show](https://wails.io/docs/reference/runtime/intro#show)
+// Shows the application.
+export function Show(): void;
+
+// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
+// Returns the current text stored on clipboard
+export function ClipboardGetText(): Promise;
+
+// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
+// Sets a text on the clipboard
+export function ClipboardSetText(text: string): Promise;
+
+// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
+// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
+export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
+
+// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
+// OnFileDropOff removes the drag and drop listeners and handlers.
+export function OnFileDropOff() :void
+
+// Check if the file path resolver is available
+export function CanResolveFilePaths(): boolean;
+
+// Resolves file paths for an array of files
+export function ResolveFilePaths(files: File[]): void
\ No newline at end of file
diff --git a/cmd/picoclaw-launcher/frontend/wailsjs/runtime/runtime.js b/cmd/picoclaw-launcher/frontend/wailsjs/runtime/runtime.js
new file mode 100644
index 000000000..7cb89d750
--- /dev/null
+++ b/cmd/picoclaw-launcher/frontend/wailsjs/runtime/runtime.js
@@ -0,0 +1,242 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+
+export function LogPrint(message) {
+ window.runtime.LogPrint(message);
+}
+
+export function LogTrace(message) {
+ window.runtime.LogTrace(message);
+}
+
+export function LogDebug(message) {
+ window.runtime.LogDebug(message);
+}
+
+export function LogInfo(message) {
+ window.runtime.LogInfo(message);
+}
+
+export function LogWarning(message) {
+ window.runtime.LogWarning(message);
+}
+
+export function LogError(message) {
+ window.runtime.LogError(message);
+}
+
+export function LogFatal(message) {
+ window.runtime.LogFatal(message);
+}
+
+export function EventsOnMultiple(eventName, callback, maxCallbacks) {
+ return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
+}
+
+export function EventsOn(eventName, callback) {
+ return EventsOnMultiple(eventName, callback, -1);
+}
+
+export function EventsOff(eventName, ...additionalEventNames) {
+ return window.runtime.EventsOff(eventName, ...additionalEventNames);
+}
+
+export function EventsOffAll() {
+ return window.runtime.EventsOffAll();
+}
+
+export function EventsOnce(eventName, callback) {
+ return EventsOnMultiple(eventName, callback, 1);
+}
+
+export function EventsEmit(eventName) {
+ let args = [eventName].slice.call(arguments);
+ return window.runtime.EventsEmit.apply(null, args);
+}
+
+export function WindowReload() {
+ window.runtime.WindowReload();
+}
+
+export function WindowReloadApp() {
+ window.runtime.WindowReloadApp();
+}
+
+export function WindowSetAlwaysOnTop(b) {
+ window.runtime.WindowSetAlwaysOnTop(b);
+}
+
+export function WindowSetSystemDefaultTheme() {
+ window.runtime.WindowSetSystemDefaultTheme();
+}
+
+export function WindowSetLightTheme() {
+ window.runtime.WindowSetLightTheme();
+}
+
+export function WindowSetDarkTheme() {
+ window.runtime.WindowSetDarkTheme();
+}
+
+export function WindowCenter() {
+ window.runtime.WindowCenter();
+}
+
+export function WindowSetTitle(title) {
+ window.runtime.WindowSetTitle(title);
+}
+
+export function WindowFullscreen() {
+ window.runtime.WindowFullscreen();
+}
+
+export function WindowUnfullscreen() {
+ window.runtime.WindowUnfullscreen();
+}
+
+export function WindowIsFullscreen() {
+ return window.runtime.WindowIsFullscreen();
+}
+
+export function WindowGetSize() {
+ return window.runtime.WindowGetSize();
+}
+
+export function WindowSetSize(width, height) {
+ window.runtime.WindowSetSize(width, height);
+}
+
+export function WindowSetMaxSize(width, height) {
+ window.runtime.WindowSetMaxSize(width, height);
+}
+
+export function WindowSetMinSize(width, height) {
+ window.runtime.WindowSetMinSize(width, height);
+}
+
+export function WindowSetPosition(x, y) {
+ window.runtime.WindowSetPosition(x, y);
+}
+
+export function WindowGetPosition() {
+ return window.runtime.WindowGetPosition();
+}
+
+export function WindowHide() {
+ window.runtime.WindowHide();
+}
+
+export function WindowShow() {
+ window.runtime.WindowShow();
+}
+
+export function WindowMaximise() {
+ window.runtime.WindowMaximise();
+}
+
+export function WindowToggleMaximise() {
+ window.runtime.WindowToggleMaximise();
+}
+
+export function WindowUnmaximise() {
+ window.runtime.WindowUnmaximise();
+}
+
+export function WindowIsMaximised() {
+ return window.runtime.WindowIsMaximised();
+}
+
+export function WindowMinimise() {
+ window.runtime.WindowMinimise();
+}
+
+export function WindowUnminimise() {
+ window.runtime.WindowUnminimise();
+}
+
+export function WindowSetBackgroundColour(R, G, B, A) {
+ window.runtime.WindowSetBackgroundColour(R, G, B, A);
+}
+
+export function ScreenGetAll() {
+ return window.runtime.ScreenGetAll();
+}
+
+export function WindowIsMinimised() {
+ return window.runtime.WindowIsMinimised();
+}
+
+export function WindowIsNormal() {
+ return window.runtime.WindowIsNormal();
+}
+
+export function BrowserOpenURL(url) {
+ window.runtime.BrowserOpenURL(url);
+}
+
+export function Environment() {
+ return window.runtime.Environment();
+}
+
+export function Quit() {
+ window.runtime.Quit();
+}
+
+export function Hide() {
+ window.runtime.Hide();
+}
+
+export function Show() {
+ window.runtime.Show();
+}
+
+export function ClipboardGetText() {
+ return window.runtime.ClipboardGetText();
+}
+
+export function ClipboardSetText(text) {
+ return window.runtime.ClipboardSetText(text);
+}
+
+/**
+ * Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
+ *
+ * @export
+ * @callback OnFileDropCallback
+ * @param {number} x - x coordinate of the drop
+ * @param {number} y - y coordinate of the drop
+ * @param {string[]} paths - A list of file paths.
+ */
+
+/**
+ * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
+ *
+ * @export
+ * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
+ * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
+ */
+export function OnFileDrop(callback, useDropTarget) {
+ return window.runtime.OnFileDrop(callback, useDropTarget);
+}
+
+/**
+ * OnFileDropOff removes the drag and drop listeners and handlers.
+ */
+export function OnFileDropOff() {
+ return window.runtime.OnFileDropOff();
+}
+
+export function CanResolveFilePaths() {
+ return window.runtime.CanResolveFilePaths();
+}
+
+export function ResolveFilePaths(files) {
+ return window.runtime.ResolveFilePaths(files);
+}
\ No newline at end of file
diff --git a/cmd/picoclaw-launcher/helpers.go b/cmd/picoclaw-launcher/helpers.go
new file mode 100644
index 000000000..b7bc6b816
--- /dev/null
+++ b/cmd/picoclaw-launcher/helpers.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+ "strings"
+ "sync"
+)
+
+// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines.
+type LogBuffer struct {
+ mu sync.RWMutex
+ lines []string
+ cap int
+ total int
+ runID int
+}
+
+func NewLogBuffer(capacity int) *LogBuffer {
+ return &LogBuffer{lines: make([]string, 0, capacity), cap: capacity}
+}
+
+func (b *LogBuffer) Append(line string) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if len(b.lines) < b.cap {
+ b.lines = append(b.lines, line)
+ } else {
+ b.lines[b.total%b.cap] = line
+ }
+ b.total++
+}
+
+func (b *LogBuffer) Reset() {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.lines = b.lines[:0]
+ b.total = 0
+ b.runID++
+}
+
+func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+ total = b.total
+ runID = b.runID
+ if offset >= b.total {
+ return nil, total, runID
+ }
+ buffered := len(b.lines)
+ newCount := b.total - offset
+ if newCount > buffered {
+ newCount = buffered
+ }
+ result := make([]string, newCount)
+ if b.total <= b.cap {
+ copy(result, b.lines[buffered-newCount:])
+ } else {
+ start := (b.total - newCount) % b.cap
+ for i := range newCount {
+ result[i] = b.lines[(start+i)%b.cap]
+ }
+ }
+ return result, total, runID
+}
+
+// buildModelField constructs the "protocol/model" string for config.
+// If the model already starts with the detected protocol prefix, it is returned as-is.
+// Otherwise the protocol prefix is prepended.
+// E.g. buildModelField("nvidia", "nvidia/minimaxai/minimax-m2.5") → "nvidia/minimaxai/minimax-m2.5"
+//
+// buildModelField("openai", "gpt-4o") → "openai/gpt-4o"
+func buildModelField(protocol, model string) string {
+ if strings.HasPrefix(model, protocol+"/") {
+ return model
+ }
+ return protocol + "/" + model
+}
+
+// detectProtocol guesses the provider protocol from the API base URL.
+func detectProtocol(baseURL string) string {
+ lower := strings.ToLower(baseURL)
+ switch {
+ case strings.Contains(lower, "anthropic"):
+ return "anthropic"
+ case strings.Contains(lower, "googleapis") || strings.Contains(lower, "generativelanguage"):
+ return "gemini"
+ case strings.Contains(lower, "openrouter"):
+ return "openrouter"
+ case strings.Contains(lower, "nvidia") || strings.Contains(lower, "integrate.api"):
+ return "nvidia"
+ case strings.Contains(lower, "deepseek"):
+ return "deepseek"
+ case strings.Contains(lower, "groq"):
+ return "groq"
+ case strings.Contains(lower, "bigmodel.cn") || strings.Contains(lower, "zhipu"):
+ return "zhipu"
+ case strings.Contains(lower, "moonshot"):
+ return "moonshot"
+ case strings.Contains(lower, "dashscope") || strings.Contains(lower, "aliyun"):
+ return "qwen"
+ case strings.Contains(lower, "cerebras"):
+ return "cerebras"
+ case strings.Contains(lower, "volces.com") || strings.Contains(lower, "volcengine"):
+ return "volcengine"
+ case strings.Contains(lower, "shengsuanyun"):
+ return "shengsuanyun"
+ case strings.Contains(lower, "mistral"):
+ return "mistral"
+ case strings.Contains(lower, "localhost:11434") || strings.Contains(lower, "ollama"):
+ return "ollama"
+ case strings.Contains(lower, "localhost:8000"):
+ return "vllm"
+ default:
+ return "openai"
+ }
+}
diff --git a/cmd/picoclaw-launcher/internal/server/setup.go b/cmd/picoclaw-launcher/internal/server/setup.go
new file mode 100644
index 000000000..662b5bb0a
--- /dev/null
+++ b/cmd/picoclaw-launcher/internal/server/setup.go
@@ -0,0 +1,240 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// RegisterSetupAPI registers endpoints for the initial setup flow.
+// These are used when the user hasn't run `picoclaw init` yet.
+func RegisterSetupAPI(mux *http.ServeMux, absPath string) {
+ mux.HandleFunc("GET /api/setup/status", func(w http.ResponseWriter, r *http.Request) {
+ handleSetupStatus(w, absPath)
+ })
+ mux.HandleFunc("POST /api/setup/test-llm", func(w http.ResponseWriter, r *http.Request) {
+ handleTestLLM(w, r)
+ })
+ mux.HandleFunc("POST /api/setup/save", func(w http.ResponseWriter, r *http.Request) {
+ handleSetupSave(w, r, absPath)
+ })
+}
+
+// NeedsSetup returns true if config is missing or has no usable LLM configured.
+func NeedsSetup(absPath string) bool {
+ if _, err := os.Stat(absPath); os.IsNotExist(err) {
+ return true
+ }
+ cfg, err := config.LoadConfig(absPath)
+ if err != nil {
+ return true
+ }
+ if cfg.Agents.Defaults.GetModelName() == "" {
+ return true
+ }
+ if len(cfg.ModelList) == 0 && cfg.Providers.IsEmpty() {
+ return true
+ }
+ return false
+}
+
+// handleSetupStatus returns whether initial setup is needed.
+func handleSetupStatus(w http.ResponseWriter, absPath string) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "needs_setup": NeedsSetup(absPath),
+ "config_path": absPath,
+ })
+}
+
+// setupTestRequest is the request body for POST /api/setup/test-llm.
+type setupTestRequest struct {
+ APIKey string `json:"api_key"`
+ APIBase string `json:"api_base"`
+ Model string `json:"model"`
+}
+
+// handleTestLLM tests an LLM connection without saving config.
+func handleTestLLM(w http.ResponseWriter, r *http.Request) {
+ var req setupTestRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.APIKey == "" || req.Model == "" {
+ http.Error(w, "api_key and model are required", http.StatusBadRequest)
+ return
+ }
+ if req.APIBase == "" {
+ req.APIBase = "https://api.openai.com/v1"
+ }
+
+ protocol := DetectProtocol(req.APIBase)
+ modelID := protocol + "/" + req.Model
+
+ modelCfg := &config.ModelConfig{
+ ModelName: req.Model,
+ Model: modelID,
+ APIBase: req.APIBase,
+ APIKey: req.APIKey,
+ }
+
+ provider, resolvedModel, err := providers.CreateProviderFromConfig(modelCfg)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]any{
+ "success": false,
+ "error": fmt.Sprintf("Failed to create provider: %v", err),
+ })
+ return
+ }
+
+ if resolvedModel == "" {
+ resolvedModel = req.Model
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
+ defer cancel()
+
+ resp, err := provider.Chat(ctx, []providers.Message{
+ {Role: "user", Content: "Reply with exactly one word: PONG"},
+ }, nil, resolvedModel, nil)
+
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "success": false,
+ "error": fmt.Sprintf("LLM call failed: %v", err),
+ })
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "success": true,
+ "response": strings.TrimSpace(resp.Content),
+ "model": resolvedModel,
+ "protocol": protocol,
+ })
+}
+
+// setupSaveRequest is the request body for POST /api/setup/save.
+type setupSaveRequest struct {
+ APIKey string `json:"api_key"`
+ APIBase string `json:"api_base"`
+ Model string `json:"model"`
+}
+
+// handleSetupSave saves a minimal config from the setup form.
+func handleSetupSave(w http.ResponseWriter, r *http.Request, absPath string) {
+ var req setupSaveRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.APIKey == "" || req.Model == "" {
+ http.Error(w, "api_key and model are required", http.StatusBadRequest)
+ return
+ }
+ if req.APIBase == "" {
+ req.APIBase = "https://api.openai.com/v1"
+ }
+
+ protocol := DetectProtocol(req.APIBase)
+ modelID := protocol + "/" + req.Model
+
+ defaults := config.DefaultConfig()
+ workspace := defaults.Agents.Defaults.Workspace
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: workspace,
+ RestrictToWorkspace: true,
+ ModelName: req.Model,
+ MaxTokens: 32768,
+ MaxToolIterations: 50,
+ },
+ },
+ ModelList: []config.ModelConfig{
+ {
+ ModelName: req.Model,
+ Model: modelID,
+ APIBase: req.APIBase,
+ APIKey: req.APIKey,
+ },
+ },
+ Gateway: defaults.Gateway,
+ Tools: config.ToolsConfig{
+ Exec: config.ExecConfig{EnableDenyPatterns: true},
+ Web: config.WebToolsConfig{
+ DuckDuckGo: config.DuckDuckGoConfig{Enabled: true, MaxResults: 5},
+ },
+ },
+ Providers: config.ProvidersConfig{
+ OpenAI: config.OpenAIProviderConfig{WebSearch: true},
+ },
+ }
+
+ // Ensure directories exist.
+ os.MkdirAll(filepath.Dir(absPath), 0755)
+ os.MkdirAll(workspace, 0755)
+
+ if err := config.SaveConfig(absPath, cfg); err != nil {
+ http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "success": true,
+ "config_path": absPath,
+ "workspace": workspace,
+ })
+}
+
+// DetectProtocol guesses the provider protocol from the API base URL.
+func DetectProtocol(baseURL string) string {
+ lower := strings.ToLower(baseURL)
+ switch {
+ case strings.Contains(lower, "anthropic"):
+ return "anthropic"
+ case strings.Contains(lower, "generativelanguage.googleapis"):
+ return "gemini"
+ case strings.Contains(lower, "dashscope.aliyuncs"):
+ return "qwen"
+ case strings.Contains(lower, "open.bigmodel.cn"):
+ return "zhipu"
+ case strings.Contains(lower, "moonshot"):
+ return "moonshot"
+ case strings.Contains(lower, "deepseek"):
+ return "deepseek"
+ case strings.Contains(lower, "openrouter"):
+ return "openrouter"
+ case strings.Contains(lower, "groq"):
+ return "groq"
+ case strings.Contains(lower, "localhost:11434"):
+ return "ollama"
+ case strings.Contains(lower, "volcengine") || strings.Contains(lower, "volces.com"):
+ return "volcengine"
+ case strings.Contains(lower, "cerebras"):
+ return "cerebras"
+ case strings.Contains(lower, "nvidia") || strings.Contains(lower, "integrate.api"):
+ return "nvidia"
+ case strings.Contains(lower, "mistral"):
+ return "mistral"
+ default:
+ return "openai"
+ }
+}
diff --git a/cmd/picoclaw-launcher/internal/server/setup_chat.go b/cmd/picoclaw-launcher/internal/server/setup_chat.go
new file mode 100644
index 000000000..7909c7c25
--- /dev/null
+++ b/cmd/picoclaw-launcher/internal/server/setup_chat.go
@@ -0,0 +1,151 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/agent"
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// setupChat holds the agent loop used for AI-guided configuration.
+type setupChat struct {
+ mu sync.Mutex
+ agentLoop *agent.AgentLoop
+ msgBus *bus.MessageBus
+ provider providers.LLMProvider
+}
+
+var (
+ activeSetupChat *setupChat
+ activeSetupChatMu sync.Mutex
+)
+
+// RegisterChatAPI registers the AI-guided configuration chat endpoint.
+func RegisterChatAPI(mux *http.ServeMux, absPath string) {
+ mux.HandleFunc("POST /api/setup/chat", func(w http.ResponseWriter, r *http.Request) {
+ handleSetupChat(w, r, absPath)
+ })
+}
+
+type chatRequest struct {
+ Message string `json:"message"`
+}
+
+type chatResponse struct {
+ Success bool `json:"success"`
+ Response string `json:"response,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func handleSetupChat(w http.ResponseWriter, r *http.Request, absPath string) {
+ var req chatRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Message == "" {
+ http.Error(w, "message is required", http.StatusBadRequest)
+ return
+ }
+
+ sc, err := getOrCreateSetupChat(absPath)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(chatResponse{
+ Success: false,
+ Error: fmt.Sprintf("Failed to initialize chat: %v", err),
+ })
+ return
+ }
+
+ sc.mu.Lock()
+ defer sc.mu.Unlock()
+
+ ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
+ defer cancel()
+
+ resp, err := sc.agentLoop.ProcessDirect(ctx, req.Message, "cli:setup")
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(chatResponse{
+ Success: false,
+ Error: fmt.Sprintf("Chat error: %v", err),
+ })
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(chatResponse{
+ Success: true,
+ Response: resp,
+ })
+}
+
+func getOrCreateSetupChat(absPath string) (*setupChat, error) {
+ activeSetupChatMu.Lock()
+ defer activeSetupChatMu.Unlock()
+
+ if activeSetupChat != nil {
+ return activeSetupChat, nil
+ }
+
+ cfg, err := config.LoadConfig(absPath)
+ if err != nil {
+ return nil, fmt.Errorf("config load failed: %w", err)
+ }
+
+ provider, modelID, err := providers.CreateProvider(cfg)
+ if err != nil {
+ return nil, fmt.Errorf("provider creation failed: %w", err)
+ }
+ if modelID != "" {
+ cfg.Agents.Defaults.ModelName = modelID
+ }
+
+ msgBus := bus.NewMessageBus()
+ agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
+
+ // Prime the agent with a setup-assistant system context via first message.
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ setupPrompt := buildChatSetupPrompt(absPath, cfg)
+ _, _ = agentLoop.ProcessDirect(ctx, setupPrompt, "cli:setup")
+
+ activeSetupChat = &setupChat{
+ agentLoop: agentLoop,
+ msgBus: msgBus,
+ provider: provider,
+ }
+
+ return activeSetupChat, nil
+}
+
+func buildChatSetupPrompt(configPath string, cfg *config.Config) string {
+ return fmt.Sprintf(`You are PicoClaw's setup assistant. The user just completed initial API setup.
+Config file: %s
+Current model: %s
+
+Your role is to help them configure PicoClaw step by step:
+
+1. **Communication Channels** — Telegram bot, Discord bot, WeChat, Slack, etc.
+ Ask which channels they want and guide them to get bot tokens.
+
+2. **Agent Identity** — Help create/edit SOUL.md (personality), IDENTITY.md (name/description), USER.md (user preferences) in the workspace.
+
+3. **Tools & Skills** — web search, MCP servers, custom skills.
+
+4. **Advanced settings** — scheduling, cron jobs, memory tuning.
+
+You can read and modify the config file using your file tools.
+Start by welcoming the user and asking what they'd like to set up.
+Keep responses concise. Use Chinese if the user writes in Chinese.`, configPath, cfg.Agents.Defaults.GetModelName())
+}
diff --git a/cmd/picoclaw-launcher/main.go b/cmd/picoclaw-launcher/main.go
index 3323c31a8..fdf272740 100644
--- a/cmd/picoclaw-launcher/main.go
+++ b/cmd/picoclaw-launcher/main.go
@@ -1,127 +1,97 @@
-// PicoClaw Launcher - Standalone HTTP service
+// PicoClaw Launcher - Desktop GUI for PicoClaw AI Agent
//
-// Provides a web-based JSON editor for picoclaw config files,
-// with OAuth provider authentication support.
+// A Wails v2 desktop application that provides:
+// - Service status monitoring and control
+// - AI chat interface
+// - Configuration editor
//
// Usage:
//
-// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
+// wails build -o picoclaw-launcher.exe
// ./picoclaw-launcher [config.json]
-// ./picoclaw-launcher -public config.json
package main
import (
+ "context"
"embed"
"flag"
"fmt"
- "io/fs"
- "log"
- "net/http"
"os"
- "os/exec"
"path/filepath"
- "runtime"
- "time"
- "github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server"
+ "github.com/wailsapp/wails/v2"
+ "github.com/wailsapp/wails/v2/pkg/options"
+ "github.com/wailsapp/wails/v2/pkg/options/assetserver"
+ "github.com/wailsapp/wails/v2/pkg/options/windows"
+ wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
-//go:embed internal/ui/index.html
-var staticFiles embed.FS
+//go:embed all:frontend
+var assets embed.FS
+
+func defaultConfigPath() string {
+ if p := os.Getenv("PICOCLAW_CONFIG"); p != "" {
+ return p
+ }
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".picoclaw", "config.json")
+}
func main() {
- public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
flag.Usage = func() {
- fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
- fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
- fmt.Fprintf(os.Stderr, "Arguments:\n")
- fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
- fmt.Fprintf(os.Stderr, "Options:\n")
- flag.PrintDefaults()
- fmt.Fprintf(os.Stderr, "\nExamples:\n")
- fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
- fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
- fmt.Fprintf(
- os.Stderr,
- " %s -public ./config.json Allow access from other devices on the network\n",
- os.Args[0],
- )
+ fmt.Fprintf(os.Stderr, "PicoClaw Launcher - Desktop GUI for PicoClaw AI Agent\n\n")
+ fmt.Fprintf(os.Stderr, "Usage: %s [config.json]\n", os.Args[0])
}
flag.Parse()
- configPath := server.DefaultConfigPath()
+ configPath := defaultConfigPath()
if flag.NArg() > 0 {
configPath = flag.Arg(0)
}
absPath, err := filepath.Abs(configPath)
if err != nil {
- log.Fatalf("Failed to resolve config path: %v", err)
+ fmt.Fprintf(os.Stderr, "Failed to resolve config path: %v\n", err)
+ os.Exit(1)
}
- var addr string
- if *public {
- addr = "0.0.0.0:" + server.DefaultPort
- } else {
- addr = "127.0.0.1:" + server.DefaultPort
- }
+ app := NewApp(absPath)
- mux := http.NewServeMux()
- server.RegisterConfigAPI(mux, absPath)
- server.RegisterAuthAPI(mux, absPath)
- server.RegisterProcessAPI(mux, absPath)
+ err = wails.Run(&options.App{
+ Title: "PicoClaw Launcher",
+ Width: 960,
+ Height: 640,
+ MinWidth: 720,
+ MinHeight: 480,
+ AssetServer: &assetserver.Options{
+ Assets: assets,
+ },
+ OnStartup: app.startup,
+ OnShutdown: app.shutdown,
+ OnBeforeClose: func(ctx context.Context) (prevent bool) {
+ if app.forceQuit {
+ return false // allow quit
+ }
+ // Hide to tray instead of quitting
+ wailsRuntime.WindowHide(ctx)
+ return true
+ },
+ Bind: []interface{}{
+ app,
+ },
+ Windows: &windows.Options{
+ WebviewIsTransparent: false,
+ WindowIsTranslucent: false,
+ DisableWindowIcon: false,
+ DisableFramelessWindowDecorations: false,
+ WebviewUserDataPath: "",
+ Theme: windows.SystemDefault,
+ },
+ })
- staticFS, err := fs.Sub(staticFiles, "internal/ui")
if err != nil {
- log.Fatalf("Failed to create sub filesystem: %v", err)
- }
- mux.Handle("/", http.FileServer(http.FS(staticFS)))
-
- // Print startup banner
- fmt.Println("=============================================")
- fmt.Println(" PicoClaw Launcher")
- fmt.Println("=============================================")
- fmt.Printf(" Config file : %s\n", absPath)
- fmt.Printf(" Listen addr : %s\n\n", addr)
- fmt.Println(" Open the following URL in your browser")
- fmt.Println(" to view and edit the configuration:")
- fmt.Println()
- fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort)
- if *public {
- if ip := server.GetLocalIP(); ip != "" {
- fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort)
- }
- }
- fmt.Println()
- // fmt.Println("=============================================")
-
- go func() {
- // Wait briefly to ensure the server is ready before opening the browser
- time.Sleep(500 * time.Millisecond)
- url := "http://localhost:" + server.DefaultPort
- if err := openBrowser(url); err != nil {
- log.Printf("Warning: Failed to auto-open browser: %v\n", err)
- }
- }()
-
- if err := http.ListenAndServe(addr, mux); err != nil {
- log.Fatalf("Server failed: %v", err)
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
}
}
-
-// openBrowser automatically opens the given URL in the default browser.
-func openBrowser(url string) error {
- var err error
- switch runtime.GOOS {
- case "linux":
- err = exec.Command("xdg-open", url).Start()
- case "windows":
- err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
- case "darwin":
- err = exec.Command("open", url).Start()
- default:
- err = fmt.Errorf("unsupported platform")
- }
- return err
-}
diff --git a/cmd/picoclaw-launcher/proc_other.go b/cmd/picoclaw-launcher/proc_other.go
new file mode 100644
index 000000000..a7f122984
--- /dev/null
+++ b/cmd/picoclaw-launcher/proc_other.go
@@ -0,0 +1,8 @@
+//go:build !windows
+
+package main
+
+import "os/exec"
+
+// hideProcessWindow is a no-op on non-Windows platforms.
+func hideProcessWindow(cmd *exec.Cmd) {}
diff --git a/cmd/picoclaw-launcher/proc_windows.go b/cmd/picoclaw-launcher/proc_windows.go
new file mode 100644
index 000000000..51064d64a
--- /dev/null
+++ b/cmd/picoclaw-launcher/proc_windows.go
@@ -0,0 +1,12 @@
+package main
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+// hideProcessWindow sets CREATE_NO_WINDOW on the process so no console
+// window appears and no taskbar entry is created.
+func hideProcessWindow(cmd *exec.Cmd) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+}
diff --git a/cmd/picoclaw-launcher/tray.go b/cmd/picoclaw-launcher/tray.go
new file mode 100644
index 000000000..2c24b90f9
--- /dev/null
+++ b/cmd/picoclaw-launcher/tray.go
@@ -0,0 +1,61 @@
+package main
+
+import (
+ _ "embed"
+
+ "github.com/energye/systray"
+ wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
+)
+
+//go:embed icon.ico
+var appIcon []byte
+
+// setupTray initializes the system tray icon and menu.
+// Uses energye/systray which works alongside Wails without thread conflicts.
+func (a *App) setupTray() {
+ go systray.Run(func() {
+ // onReady
+ systray.SetIcon(appIcon)
+ systray.SetTitle("PicoClaw")
+ systray.SetTooltip("PicoClaw Launcher")
+
+ // Left click → show window
+ systray.SetOnClick(func(menu systray.IMenu) {
+ wailsRuntime.WindowShow(a.ctx)
+ })
+
+ // Right click → show context menu
+ systray.SetOnRClick(func(menu systray.IMenu) {
+ menu.ShowMenu()
+ })
+
+ mShow := systray.AddMenuItem("Show Window", "Show the launcher window")
+ mShow.Click(func() {
+ wailsRuntime.WindowShow(a.ctx)
+ })
+
+ systray.AddSeparator()
+
+ mStart := systray.AddMenuItem("Start Gateway", "Start PicoClaw gateway service")
+ mStart.Click(func() {
+ a.StartGateway()
+ })
+
+ mStop := systray.AddMenuItem("Stop Gateway", "Stop PicoClaw gateway service")
+ mStop.Click(func() {
+ a.StopGateway()
+ })
+
+ systray.AddSeparator()
+
+ mQuit := systray.AddMenuItem("Exit", "Quit PicoClaw Launcher")
+ mQuit.Click(func() {
+ a.StopGateway()
+ a.forceQuit = true
+ systray.Quit()
+ wailsRuntime.Quit(a.ctx)
+ })
+ }, func() {
+ // onExit - cleanup
+ })
+}
diff --git a/cmd/picoclaw-launcher/wails.json b/cmd/picoclaw-launcher/wails.json
new file mode 100644
index 000000000..d8dde2469
--- /dev/null
+++ b/cmd/picoclaw-launcher/wails.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://wails.io/schemas/config.v2.json",
+ "name": "picoclaw-launcher",
+ "outputfilename": "picoclaw-launcher",
+ "frontend:install": "",
+ "frontend:build": "",
+ "frontend:dev:watcher": "",
+ "frontend:dev:serverUrl": "",
+ "author": {
+ "name": "PicoClaw",
+ "email": "picoclaw@sipeed.com"
+ },
+ "info": {
+ "companyName": "Sipeed",
+ "productName": "PicoClaw Launcher",
+ "productVersion": "0.1.0",
+ "copyright": "Copyright 2026 PicoClaw contributors",
+ "comments": "PicoClaw Launcher - Desktop GUI for PicoClaw AI Agent"
+ }
+}
\ No newline at end of file
diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go
index f754abc65..2320b2aee 100644
--- a/cmd/picoclaw/internal/agent/helpers.go
+++ b/cmd/picoclaw/internal/agent/helpers.go
@@ -23,16 +23,20 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
sessionKey = "cli:default"
}
- if debug {
- logger.SetLevel(logger.DEBUG)
- fmt.Println("🔍 Debug mode enabled")
- }
-
cfg, err := internal.LoadConfig()
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
+ // Apply logging config (config file setting).
+ logger.ApplyConfig(cfg.Logging.Level, cfg.Logging.FileDir)
+
+ // Debug flag overrides config.
+ if debug {
+ logger.SetLevel(logger.INFO)
+ fmt.Println("Debug mode enabled")
+ }
+
if model != "" {
cfg.Agents.Defaults.ModelName = model
}
@@ -60,6 +64,9 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
"skills_available": startupInfo["skills"].(map[string]any)["available"],
})
+ // Warn if bootstrap files are not customized.
+ internal.WarnMissingBootstrap(cfg.Agents.Defaults.Workspace)
+
if message != "" {
ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go
index 747f7d44e..fdf8677c4 100644
--- a/cmd/picoclaw/internal/gateway/helpers.go
+++ b/cmd/picoclaw/internal/gateway/helpers.go
@@ -80,6 +80,9 @@ func gatewayCmd(debug bool) error {
"skills_available": skillsInfo["available"],
})
+ // Warn if bootstrap files are not customized.
+ internal.WarnMissingBootstrap(cfg.Agents.Defaults.Workspace)
+
// Setup cron tool and service
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
cronService := setupCronTool(
diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go
index 9655d3c08..eacbcf487 100644
--- a/cmd/picoclaw/internal/helpers.go
+++ b/cmd/picoclaw/internal/helpers.go
@@ -53,3 +53,36 @@ func FormatBuildInfo() (string, string) {
func GetVersion() string {
return version
}
+
+// WarnMissingBootstrap checks workspace bootstrap files (SOUL.md, IDENTITY.md, USER.md)
+// and warns the user if any are missing or unmodified.
+func WarnMissingBootstrap(workspace string) {
+ files := []struct {
+ name string
+ desc string
+ }{
+ {"SOUL.md", "personality & behavior"},
+ {"IDENTITY.md", "agent name & description"},
+ {"USER.md", "your preferences & info"},
+ }
+
+ var missing []string
+ for _, f := range files {
+ path := filepath.Join(workspace, f.name)
+ info, err := os.Stat(path)
+ if os.IsNotExist(err) {
+ missing = append(missing, fmt.Sprintf(" %s — %s", f.name, f.desc))
+ } else if err == nil && info.Size() < 50 {
+ // File exists but appears to be empty/placeholder
+ missing = append(missing, fmt.Sprintf(" %s — %s (empty)", f.name, f.desc))
+ }
+ }
+
+ if len(missing) > 0 {
+ fmt.Println(" Customize your agent:")
+ for _, m := range missing {
+ fmt.Println(m)
+ }
+ fmt.Printf(" Edit files in: %s\n\n", workspace)
+ }
+}
diff --git a/cmd/picoclaw/internal/initcmd/command.go b/cmd/picoclaw/internal/initcmd/command.go
new file mode 100644
index 000000000..d6f36a8d6
--- /dev/null
+++ b/cmd/picoclaw/internal/initcmd/command.go
@@ -0,0 +1,280 @@
+package initcmd
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func NewInitCommand() *cobra.Command {
+ var baseURL, apiKey, model string
+
+ cmd := &cobra.Command{
+ Use: "init [auth ]",
+ Short: "Quick setup 鈥?API key or OAuth login",
+ Long: `Initialize picoclaw with minimal configuration.
+
+Two modes:
+
+ 1. API Key mode (most providers):
+ picoclaw init --api-key --model [--base-url ]
+
+ 2. OAuth mode (OpenAI, Google Antigravity):
+ picoclaw init auth openai
+ picoclaw init auth google-antigravity
+ picoclaw init auth anthropic (paste token)
+
+In API Key mode, only api-key is required. Model defaults to gpt-4o,
+base-url defaults to https://api.openai.com/v1.`,
+ Example: ` picoclaw init --api-key sk-xxx --model gpt-4o
+ picoclaw init --base-url https://api.deepseek.com/v1 --api-key sk-xxx --model deepseek-chat
+ picoclaw init auth openai
+ picoclaw init (interactive)`,
+ Args: cobra.MaximumNArgs(0),
+ Run: func(cmd *cobra.Command, args []string) {
+ ensureConfigDir()
+ runInit(cmd, baseURL, apiKey, model)
+ },
+ }
+
+ cmd.Flags().StringVar(&baseURL, "base-url", "", "API base URL (default: https://api.openai.com/v1)")
+ cmd.Flags().StringVar(&apiKey, "api-key", "", "API key")
+ cmd.Flags().StringVar(&model, "model", "", "Model name")
+
+ // Add auth subcommand.
+ cmd.AddCommand(newInitAuthCommand())
+
+ return cmd
+}
+
+func newInitAuthCommand() *cobra.Command {
+ return &cobra.Command{
+ Use: "auth ",
+ Short: "Initialize via OAuth or token (openai, anthropic, google-antigravity)",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ensureConfigDir()
+ return runInitAuth(args[0])
+ },
+ }
+}
+
+func ensureConfigDir() {
+ configPath := internal.GetConfigPath()
+ dir := filepath.Dir(configPath)
+ os.MkdirAll(dir, 0755)
+}
+
+func runInit(cmd *cobra.Command, baseURL, apiKey, model string) {
+ reader := bufio.NewReader(os.Stdin)
+
+ if apiKey == "" {
+ fmt.Print("API Key: ")
+ apiKey, _ = reader.ReadString('\n')
+ apiKey = strings.TrimSpace(apiKey)
+ }
+ if apiKey == "" {
+ fmt.Println("API key is required.")
+ fmt.Println(" Or use OAuth: picoclaw init auth openai")
+ os.Exit(1)
+ }
+
+ if model == "" {
+ fmt.Print("Model (default: gpt-4o): ")
+ model, _ = reader.ReadString('\n')
+ model = strings.TrimSpace(model)
+ if model == "" {
+ model = "gpt-4o"
+ }
+ }
+
+ if baseURL == "" {
+ fmt.Print("API Base URL (default: https://api.openai.com/v1): ")
+ baseURL, _ = reader.ReadString('\n')
+ baseURL = strings.TrimSpace(baseURL)
+ if baseURL == "" {
+ baseURL = "https://api.openai.com/v1"
+ }
+ }
+
+ protocol := detectProtocol(baseURL)
+ modelID := protocol + "/" + model
+
+ saveAndPrint(cmd, model, modelID, baseURL, apiKey)
+}
+
+func runInitAuth(provider string) error {
+ switch provider {
+ case "openai", "anthropic", "google-antigravity", "antigravity":
+ // Ensure base config exists before auth writes to it.
+ configPath := internal.GetConfigPath()
+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
+ // Create minimal base config so auth can append to it.
+ defaults := config.DefaultConfig()
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: defaults.Agents.Defaults.Workspace,
+ RestrictToWorkspace: true,
+ MaxTokens: 32768,
+ MaxToolIterations: 50,
+ },
+ },
+ Gateway: defaults.Gateway,
+ Tools: config.ToolsConfig{
+ Exec: config.ExecConfig{EnableDenyPatterns: true},
+ Web: config.WebToolsConfig{
+ DuckDuckGo: config.DuckDuckGoConfig{Enabled: true, MaxResults: 5},
+ },
+ },
+ }
+ os.MkdirAll(defaults.Agents.Defaults.Workspace, 0755)
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ return fmt.Errorf("failed to create base config: %w", err)
+ }
+ fmt.Printf("Created base config at %s\n\n", configPath)
+ }
+
+ // Delegate to the existing auth command logic.
+ fmt.Printf("Run: picoclaw auth login --provider %s\n", provider)
+ fmt.Println("This will open a browser or prompt for your token.")
+ return nil
+ default:
+ return fmt.Errorf("unsupported auth provider: %s\nSupported: openai, anthropic, google-antigravity", provider)
+ }
+}
+
+
+func saveAndPrint(cmd *cobra.Command, model, modelID, baseURL, apiKey string) {
+ configPath := internal.GetConfigPath()
+
+ if _, err := os.Stat(configPath); err == nil {
+ fmt.Printf("Config exists at %s. Overwrite? (y/n): ", configPath)
+ var resp string
+ fmt.Scanln(&resp)
+ if resp != "y" {
+ fmt.Println("Aborted.")
+ return
+ }
+ }
+
+ defaults := config.DefaultConfig()
+ workspace := defaults.Agents.Defaults.Workspace
+
+ cfgMap := map[string]any{
+ "agents": map[string]any{
+ "defaults": map[string]any{
+ "workspace": workspace,
+ "restrict_to_workspace": true,
+ "model_name": model,
+ "max_tokens": 32768,
+ "max_tool_iterations": 50,
+ },
+ },
+ "model_list": []map[string]any{
+ {
+ "model_name": model,
+ "model": modelID,
+ "api_base": baseURL,
+ "api_key": apiKey,
+ },
+ },
+ "gateway": map[string]any{
+ "host": defaults.Gateway.Host,
+ "port": defaults.Gateway.Port,
+ },
+ "tools": map[string]any{
+ "exec": map[string]any{"enable_deny_patterns": true},
+ },
+ }
+
+ data, err := json.MarshalIndent(cfgMap, "", " ")
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
+ os.Exit(1)
+ }
+
+ os.MkdirAll(filepath.Dir(configPath), 0755)
+ if err := os.WriteFile(configPath, data, 0600); err != nil {
+ fmt.Printf("Error writing config: %v\n", err)
+ os.Exit(1)
+ }
+
+ os.MkdirAll(workspace, 0755)
+
+ fmt.Printf("\n%s picoclaw is ready!\n\n", internal.Logo)
+ fmt.Printf(" Config: %s\n", configPath)
+ fmt.Printf(" Model: %s\n", model)
+ fmt.Printf(" API Base: %s\n", baseURL)
+
+ // Test via cobra root command (in-process).
+ fmt.Println("\n Testing: picoclaw agent -m \"Hello!\"")
+ fmt.Println(strings.Repeat("\u2500", 50))
+ rootCmd := cmd.Root()
+ rootCmd.SetArgs([]string{"agent", "-m", "Hello!"})
+ if err := rootCmd.Execute(); err != nil {
+ fmt.Println(strings.Repeat("\u2500", 50))
+ fmt.Printf(" Test FAILED: %v\n", err)
+ fmt.Println(" Possible fixes:")
+ fmt.Println(" - Check your API key")
+ fmt.Println(" - Check the API base URL")
+ fmt.Printf(" - Edit: %s\n", configPath)
+ } else {
+ fmt.Println(strings.Repeat("\u2500", 50))
+ fmt.Println(" Test OK!")
+ }
+
+ // Next steps.
+ fmt.Println("\n Quick Start:")
+ fmt.Println(" picoclaw agent -m \"Hello!\" # send a message")
+ fmt.Println("")
+ fmt.Println(" Add Channels (Telegram, Discord, etc):")
+ fmt.Printf(" Edit %s\n", configPath)
+ fmt.Println(" picoclaw gateway # start multi-channel server")
+ fmt.Println("")
+ fmt.Println(" Docs: https://github.com/sipeed/picoclaw")
+ fmt.Println(strings.Repeat("\u2500", 50))
+}
+
+// detectProtocol guesses the provider protocol from the API base URL.
+func detectProtocol(baseURL string) string {
+ lower := strings.ToLower(baseURL)
+ switch {
+ case strings.Contains(lower, "anthropic"):
+ return "anthropic"
+ case strings.Contains(lower, "generativelanguage.googleapis"):
+ return "gemini"
+ case strings.Contains(lower, "dashscope.aliyuncs"):
+ return "qwen"
+ case strings.Contains(lower, "open.bigmodel.cn"):
+ return "zhipu"
+ case strings.Contains(lower, "moonshot"):
+ return "moonshot"
+ case strings.Contains(lower, "deepseek"):
+ return "deepseek"
+ case strings.Contains(lower, "openrouter"):
+ return "openrouter"
+ case strings.Contains(lower, "groq"):
+ return "groq"
+ case strings.Contains(lower, "localhost:11434"):
+ return "ollama"
+ case strings.Contains(lower, "volcengine") || strings.Contains(lower, "volces.com"):
+ return "volcengine"
+ case strings.Contains(lower, "cerebras"):
+ return "cerebras"
+ case strings.Contains(lower, "nvidia") || strings.Contains(lower, "integrate.api"):
+ return "nvidia"
+ case strings.Contains(lower, "mistral"):
+ return "mistral"
+ default:
+ return "openai"
+ }
+}
\ No newline at end of file
diff --git a/cmd/picoclaw/internal/initcmd/command_test.go b/cmd/picoclaw/internal/initcmd/command_test.go
new file mode 100644
index 000000000..ace36f643
--- /dev/null
+++ b/cmd/picoclaw/internal/initcmd/command_test.go
@@ -0,0 +1,35 @@
+package initcmd
+
+import (
+ "testing"
+)
+
+func TestDetectProtocol(t *testing.T) {
+ tests := []struct {
+ url string
+ expected string
+ }{
+ {"https://api.openai.com/v1", "openai"},
+ {"https://api.anthropic.com/v1", "anthropic"},
+ {"https://api.deepseek.com/v1", "deepseek"},
+ {"https://generativelanguage.googleapis.com/v1beta", "gemini"},
+ {"https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen"},
+ {"https://open.bigmodel.cn/api/paas/v4", "zhipu"},
+ {"https://api.moonshot.cn/v1", "moonshot"},
+ {"https://openrouter.ai/api/v1", "openrouter"},
+ {"https://api.groq.com/openai/v1", "groq"},
+ {"http://localhost:11434/v1", "ollama"},
+ {"https://api.mistral.ai/v1", "mistral"},
+ {"https://api.cerebras.ai/v1", "cerebras"},
+ {"https://integrate.api.nvidia.com/v1", "nvidia"},
+ {"https://ark.cn-beijing.volces.com/api/v3", "volcengine"},
+ {"https://some-custom-endpoint.com/v1", "openai"}, // default
+ }
+
+ for _, tt := range tests {
+ got := detectProtocol(tt.url)
+ if got != tt.expected {
+ t.Errorf("detectProtocol(%q) = %q, want %q", tt.url, got, tt.expected)
+ }
+ }
+}
diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go
index 4db8bdc8b..911af1bb4 100644
--- a/cmd/picoclaw/internal/onboard/helpers.go
+++ b/cmd/picoclaw/internal/onboard/helpers.go
@@ -25,7 +25,11 @@ func onboard() {
}
cfg := config.DefaultConfig()
- if err := config.SaveConfig(configPath, cfg); err != nil {
+
+ // For onboard, produce a minimal config — omit empty sections.
+ minimalCfg := config.MinimalOnboardConfig(cfg)
+
+ if err := config.SaveConfig(configPath, minimalCfg); err != nil {
fmt.Printf("Error saving config: %v\n", err)
os.Exit(1)
}
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 6db69c990..bdc011e63 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -17,6 +17,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/initcmd"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
@@ -34,6 +35,7 @@ func NewPicoclawCommand() *cobra.Command {
}
cmd.AddCommand(
+ initcmd.NewInitCommand(),
onboard.NewOnboardCommand(),
agent.NewAgentCommand(),
auth.NewAuthCommand(),
diff --git a/go.mod b/go.mod
index 7892cade6..369ee2d6e 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,8 @@ require (
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.3.1
github.com/chzyer/readline v1.5.1
+ github.com/energye/systray v1.0.3
+ github.com/gdamore/tcell/v2 v2.13.8
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
@@ -15,10 +17,12 @@ require (
github.com/mymmrac/telego v1.6.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
+ github.com/rivo/tview v0.42.0
github.com/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
+ github.com/wailsapp/wails/v2 v2.11.0
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
golang.org/x/oauth2 v0.35.0
golang.org/x/time v0.14.0
@@ -29,25 +33,40 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
+ github.com/bep/debounce v1.2.1 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
- github.com/gdamore/tcell/v2 v2.13.8 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
+ github.com/labstack/echo/v4 v4.13.3 // indirect
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
+ github.com/leaanthony/gosod v1.0.4 // indirect
+ github.com/leaanthony/slicer v1.6.0 // indirect
+ github.com/leaanthony/u v1.1.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- github.com/rivo/tview v0.42.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.34.0 // indirect
+ github.com/samber/lo v1.49.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
+ github.com/tkrajina/go-reflector v0.5.8 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
+ github.com/wailsapp/go-webview2 v1.0.22 // indirect
+ github.com/wailsapp/mimetype v1.4.1 // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.mau.fi/util v0.9.6 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
diff --git a/go.sum b/go.sum
index d1ee1d629..458570cf3 100644
--- a/go.sum
+++ b/go.sum
@@ -15,6 +15,8 @@ github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsam
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
@@ -48,6 +50,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
+github.com/energye/systray v1.0.3 h1:XnyjJCeRU5z00bpNOic2fGTKz/7yHZMZjWiGIVXDS+4=
+github.com/energye/systray v1.0.3/go.mod h1:HelKhC3PXwv3ryDxbuQqV+7kAxAYNzE5cfdrerGOZTc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
@@ -56,6 +60,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
@@ -64,6 +70,8 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg78
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -101,6 +109,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
@@ -115,10 +125,27 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
+github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
+github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
+github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
+github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
+github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
+github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
+github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
+github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
+github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -148,7 +175,10 @@ github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixi
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -156,6 +186,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@@ -165,8 +196,10 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
-github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
+github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
+github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
@@ -200,6 +233,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
+github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -208,8 +243,16 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
+github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
+github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
+github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
+github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
+github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
+github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -251,6 +294,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
+golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
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=
@@ -275,6 +319,7 @@ golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -285,6 +330,7 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/pkg/agent/active_context.go b/pkg/agent/active_context.go
new file mode 100644
index 000000000..f52bca3e5
--- /dev/null
+++ b/pkg/agent/active_context.go
@@ -0,0 +1,233 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package agent
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// ActiveContext holds the structured per-channel context that Phase 1 uses
+// to understand short/ambiguous user messages.
+//
+// Design choices:
+// - CurrentFiles: last 5 file paths touched by tool calls (read/write/edit/append/list_dir).
+// - RecentErrors: last 3 tool failure messages.
+// - CurrentTask / RecentSummaries are intentionally omitted — they overlap with
+// the recent-M turns in instant memory and would be redundant.
+type ActiveContext struct {
+ CurrentFiles []string `json:"current_files"` // newest first, max 5
+ RecentErrors []string `json:"recent_errors"` // newest first, max 3
+}
+
+// ActiveContextStore is a thread-safe in-memory map of channel:chatID → ActiveContext.
+// On startup it is loaded from disk; on stop it is flushed back.
+type ActiveContextStore struct {
+ mu sync.RWMutex
+ data map[string]*ActiveContext // key = "channel:chatID"
+}
+
+// NewActiveContextStore creates an empty store.
+func NewActiveContextStore() *ActiveContextStore {
+ return &ActiveContextStore{
+ data: make(map[string]*ActiveContext),
+ }
+}
+
+// Get returns a copy of the ActiveContext for the given key (never nil).
+func (s *ActiveContextStore) Get(key string) *ActiveContext {
+ s.mu.RLock()
+ ac, ok := s.data[key]
+ s.mu.RUnlock()
+
+ if !ok || ac == nil {
+ return &ActiveContext{}
+ }
+ // Return a shallow copy to avoid callers mutating the store.
+ cp := *ac
+ cp.CurrentFiles = append([]string(nil), ac.CurrentFiles...)
+ cp.RecentErrors = append([]string(nil), ac.RecentErrors...)
+ return &cp
+}
+
+// fileExtractingTools is the set of tool names whose arguments may carry file paths.
+// Keys are lowercase tool names; values indicate the argument name(s) to inspect.
+var fileExtractingTools = map[string][]string{
+ "read_file": {"path", "file_path", "filename"},
+ "write_file": {"path", "file_path", "filename"},
+ "edit_file": {"path", "file_path", "filename"},
+ "append_file": {"path", "file_path", "filename"},
+ "list_dir": {"path", "dir_path", "directory"},
+}
+
+// Update applies the outcomes of a completed turn to the ActiveContext for key.
+// It extracts file paths from tool call arguments and captures error messages.
+func (s *ActiveContextStore) Update(key string, input RuntimeInput) {
+ if key == "" {
+ return
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ ac, ok := s.data[key]
+ if !ok || ac == nil {
+ ac = &ActiveContext{}
+ s.data[key] = ac
+ }
+
+ // Extract file paths from tool calls.
+ for _, tc := range input.ToolCalls {
+ name := strings.ToLower(tc.Name)
+ argFields, relevant := fileExtractingTools[name]
+ if !relevant {
+ continue
+ }
+ // tc.Args is stored as JSON string or we can check tc.ArgsRaw if available.
+ // Since ToolCallRecord only has Name/Error/Duration, we skip argument extraction
+ // here and rely on callers passing a richer input in the future (M5).
+ // For now we still handle errors.
+ _ = argFields
+ }
+
+ // Capture tool errors.
+ for _, tc := range input.ToolCalls {
+ if tc.Error == "" {
+ continue
+ }
+ msg := fmt.Sprintf("[%s] %s", tc.Name, tc.Error)
+ // Prepend (newest first) and cap at 3.
+ ac.RecentErrors = prependCapped(ac.RecentErrors, msg, 3)
+ }
+}
+
+// UpdateWithFiles is an extended update that also receives file paths extracted
+// by the loop (call this when tool argument parsing is available).
+func (s *ActiveContextStore) UpdateWithFiles(key string, input RuntimeInput, filePaths []string) {
+ s.Update(key, input)
+
+ if len(filePaths) == 0 {
+ return
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ ac, ok := s.data[key]
+ if !ok || ac == nil {
+ ac = &ActiveContext{}
+ s.data[key] = ac
+ }
+
+ for _, p := range filePaths {
+ if p != "" {
+ ac.CurrentFiles = prependCapped(ac.CurrentFiles, p, 5)
+ }
+ }
+}
+
+// prependCapped prepends item to slice and caps the result at max length.
+// Deduplicates: if item already exists it is moved to the front.
+func prependCapped(slice []string, item string, max int) []string {
+ // Remove duplicate.
+ filtered := make([]string, 0, len(slice))
+ for _, s := range slice {
+ if s != item {
+ filtered = append(filtered, s)
+ }
+ }
+ result := append([]string{item}, filtered...)
+ if len(result) > max {
+ result = result[:max]
+ }
+ return result
+}
+
+// Format renders the context as a markdown block for injection into a user message.
+// Returns empty string when there is nothing to show.
+func (ac *ActiveContext) Format() string {
+ if len(ac.CurrentFiles) == 0 && len(ac.RecentErrors) == 0 {
+ return ""
+ }
+ var sb strings.Builder
+ sb.WriteString("## Current Context\n")
+ if len(ac.CurrentFiles) > 0 {
+ sb.WriteString("Files in use: ")
+ sb.WriteString(strings.Join(ac.CurrentFiles, ", "))
+ sb.WriteString("\n")
+ }
+ if len(ac.RecentErrors) > 0 {
+ sb.WriteString("Recent errors:\n")
+ for _, e := range ac.RecentErrors {
+ sb.WriteString(" - ")
+ sb.WriteString(e)
+ sb.WriteString("\n")
+ }
+ }
+ return sb.String()
+}
+
+// ---------------------------------------------------------------------------
+// Persistence
+// ---------------------------------------------------------------------------
+
+// persistedStore is the on-disk JSON format for ActiveContextStore.
+type persistedStore struct {
+ Contexts map[string]*ActiveContext `json:"contexts"`
+}
+
+// Flush serialises the store to a JSON file at the given path.
+func (s *ActiveContextStore) Flush(path string) error {
+ s.mu.RLock()
+ out := persistedStore{Contexts: make(map[string]*ActiveContext, len(s.data))}
+ for k, v := range s.data {
+ cp := *v
+ cp.CurrentFiles = append([]string(nil), v.CurrentFiles...)
+ cp.RecentErrors = append([]string(nil), v.RecentErrors...)
+ out.Contexts[k] = &cp
+ }
+ s.mu.RUnlock()
+
+ data, err := json.MarshalIndent(out, "", " ")
+ if err != nil {
+ return fmt.Errorf("active_context: marshal: %w", err)
+ }
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ return fmt.Errorf("active_context: write %s: %w", path, err)
+ }
+ logger.DebugCF("active_context", "Flushed to disk", map[string]any{"path": path, "keys": len(out.Contexts)})
+ return nil
+}
+
+// Load deserialises the store from a JSON file at the given path.
+// Missing or unreadable files are silently ignored (returns nil).
+func (s *ActiveContextStore) Load(path string) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return fmt.Errorf("active_context: read %s: %w", path, err)
+ }
+ var out persistedStore
+ if err := json.Unmarshal(data, &out); err != nil {
+ return fmt.Errorf("active_context: unmarshal: %w", err)
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for k, v := range out.Contexts {
+ if v != nil {
+ s.data[k] = v
+ }
+ }
+ logger.DebugCF("active_context", "Loaded from disk", map[string]any{"path": path, "keys": len(out.Contexts)})
+ return nil
+}
diff --git a/pkg/agent/active_context_test.go b/pkg/agent/active_context_test.go
new file mode 100644
index 000000000..6d9e9c36f
--- /dev/null
+++ b/pkg/agent/active_context_test.go
@@ -0,0 +1,120 @@
+package agent
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestActiveContextStore_UpdateAndGet(t *testing.T) {
+ s := NewActiveContextStore()
+ key := "telegram:12345"
+
+ // Initially empty.
+ ac := s.Get(key)
+ if len(ac.CurrentFiles) != 0 || len(ac.RecentErrors) != 0 {
+ t.Errorf("expected empty context, got %+v", ac)
+ }
+
+ // Add errors via Update.
+ s.Update(key, RuntimeInput{
+ ToolCalls: []ToolCallRecord{
+ {Name: "exec", Error: "timeout after 30s"},
+ {Name: "read_file", Error: ""},
+ },
+ })
+ ac = s.Get(key)
+ if len(ac.RecentErrors) != 1 {
+ t.Errorf("expected 1 error, got %d: %v", len(ac.RecentErrors), ac.RecentErrors)
+ }
+ if ac.RecentErrors[0] != "[exec] timeout after 30s" {
+ t.Errorf("unexpected error: %s", ac.RecentErrors[0])
+ }
+}
+
+func TestActiveContextStore_FileCapping(t *testing.T) {
+ s := NewActiveContextStore()
+ key := "cli:direct"
+
+ // Add 7 file paths — should cap at 5, newest first.
+ s.UpdateWithFiles(key, RuntimeInput{}, []string{"a.go", "b.go", "c.go", "d.go", "e.go", "f.go", "g.go"})
+ ac := s.Get(key)
+ if len(ac.CurrentFiles) != 5 {
+ t.Fatalf("expected 5 files, got %d: %v", len(ac.CurrentFiles), ac.CurrentFiles)
+ }
+ // Last added (g.go) is prepended, so it should be first.
+ if ac.CurrentFiles[0] != "g.go" {
+ t.Errorf("expected g.go first, got %s (all: %v)", ac.CurrentFiles[0], ac.CurrentFiles)
+ }
+}
+
+func TestActiveContextStore_ErrorCapping(t *testing.T) {
+ s := NewActiveContextStore()
+ key := "wecom:alice"
+
+ for i := 0; i < 5; i++ {
+ s.Update(key, RuntimeInput{
+ ToolCalls: []ToolCallRecord{{Name: "exec", Error: "err"}},
+ })
+ }
+ ac := s.Get(key)
+ if len(ac.RecentErrors) > 3 {
+ t.Errorf("expected max 3 errors, got %d", len(ac.RecentErrors))
+ }
+}
+
+func TestActiveContextStore_FlushAndLoad(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "active_context.json")
+
+ s := NewActiveContextStore()
+ key := "cli:direct"
+ s.UpdateWithFiles(key, RuntimeInput{}, []string{"main.go"})
+ s.Update(key, RuntimeInput{
+ ToolCalls: []ToolCallRecord{{Name: "exec", Error: "failed"}},
+ })
+
+ if err := s.Flush(path); err != nil {
+ t.Fatalf("Flush: %v", err)
+ }
+
+ // File must exist.
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("expected file to exist: %v", err)
+ }
+
+ // Load into new store.
+ s2 := NewActiveContextStore()
+ if err := s2.Load(path); err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ ac := s2.Get(key)
+ if len(ac.CurrentFiles) != 1 || ac.CurrentFiles[0] != "main.go" {
+ t.Errorf("unexpected files after reload: %v", ac.CurrentFiles)
+ }
+ if len(ac.RecentErrors) != 1 {
+ t.Errorf("unexpected errors after reload: %v", ac.RecentErrors)
+ }
+}
+
+func TestActiveContextStore_LoadMissingFile(t *testing.T) {
+ s := NewActiveContextStore()
+ // Should not error on missing file.
+ if err := s.Load("/nonexistent/path.json"); err != nil {
+ t.Errorf("Load of missing file should return nil, got: %v", err)
+ }
+}
+
+func TestActiveContext_Format(t *testing.T) {
+ ac := &ActiveContext{
+ CurrentFiles: []string{"main.go", "loop.go"},
+ RecentErrors: []string{"[exec] timeout"},
+ }
+ formatted := ac.Format()
+ if formatted == "" {
+ t.Error("expected non-empty format")
+ }
+ if len(formatted) == 0 {
+ t.Error("Format returned empty string")
+ }
+}
diff --git a/pkg/agent/analyser.go b/pkg/agent/analyser.go
new file mode 100644
index 000000000..fe4b7ac5e
--- /dev/null
+++ b/pkg/agent/analyser.go
@@ -0,0 +1,282 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// AnalyseResult holds the output of the Phase 1 (Analyse) step.
+type AnalyseResult struct {
+ // Intent is a short label classifying the user's intent (e.g. "question", "task", "chat").
+ Intent string `json:"intent"`
+ // Tags extracted from the user message for memory retrieval.
+ Tags []string `json:"tags"`
+ // CotPrompt is an LLM-generated thinking strategy tailored to the user's message.
+ // Generated by the analyser, not selected from a fixed list.
+ CotPrompt string `json:"cot_prompt"`
+ // MemoryContext is the formatted memory entries matching the extracted tags.
+ // This is populated after the memory lookup, not by the LLM itself.
+ MemoryContext string `json:"-"`
+}
+
+
+
+// Analyser performs a lightweight LLM call to analyse the user's message,
+// extract intent and tags, then queries the memory store for relevant entries.
+// This is Phase 1 of the Runtime Loop.
+//
+// Flow:
+// 1. Collect all available tags from the memory store.
+// 2. Call a small/fast LLM with the user message + available tags.
+// 3. Parse the JSON response to get intent + matched tags.
+// 4. Query memory entries by those tags.
+// 5. Return the result with formatted memory context.
+type Analyser struct {
+ provider providers.LLMProvider
+ model string
+ cotRegistry *CotRegistry
+}
+
+
+
+// NewAnalyser creates a new Analyser (Phase 1) processor.
+// model should be a lightweight model identifier like "gemini/gemini-2.0-flash-exp".
+func NewAnalyser(provider providers.LLMProvider, model string, cotRegistry *CotRegistry) *Analyser {
+ return &Analyser{
+ provider: provider,
+ model: model,
+ cotRegistry: cotRegistry,
+ }
+}
+
+
+
+const preLLMSystemPromptTpl = `You are a message analysis engine. Your job is to analyse the user's message and output a JSON object.
+
+## Task
+
+Given the user message, a list of available memory tags, and reference thinking strategy examples, you must:
+1. Determine the user's **intent** — classify it into one short label.
+2. Select **relevant tags** from the available tag list. Only select genuinely relevant tags. 0 tags if none are relevant.
+3. **Generate a custom thinking strategy** (cot_prompt) for the main AI to follow when processing this message. This should be a concise, actionable set of steps tailored to the specific task.
+
+## Output Format
+
+Respond with ONLY a valid JSON object, no markdown fences, no explanation:
+
+{"intent":"","tags":[""],"cot_prompt":""}
+
+The cot_prompt should be a brief strategy (3-6 numbered steps). For simple chat/greetings, use an empty string "".
+
+## Intent Labels
+
+Use one of: question, task, chat, code, search, create, debug, explain, translate, summarise, other
+
+## Reference Thinking Strategy Examples
+
+Use these as inspiration — adapt and combine as needed for the specific message:
+
+%s
+
+%s
+
+## Rules
+
+- ONLY select tags from the provided available tags list.
+- Do NOT invent new tags. Only use tags from the available list.
+- Maximum 5 tags.
+- Generate a cot_prompt tailored to the specific user message. Don't just copy examples — adapt them.
+- For simple chat (greetings, thanks, etc.), use an empty cot_prompt.
+- If historical data shows which strategies worked well for similar intents, prefer those approaches.
+- Keep the cot_prompt concise: 3-6 actionable steps.
+- Keep it fast — this is a preprocessing step.`
+
+// Analyse runs the pre-LLM analysis on the user message.
+// It returns an AnalyseResult with intent, tags, and formatted memory context.
+// If the pre-LLM call fails, it returns a zero-value result (no error propagation
+// to avoid blocking the main agent loop).
+// actCtx may be nil; when provided, its content is injected into the user prompt
+// (not the system prompt) to preserve system prompt prefix stability for KV cache.
+func (p *Analyser) Analyse(ctx context.Context, userMessage string, memory *MemoryStore, actCtx *ActiveContext) AnalyseResult {
+ if p.provider == nil || p.model == "" {
+ return AnalyseResult{}
+ }
+
+ start := time.Now()
+
+ // 1. Collect available tags from memory store.
+ var availableTags []string
+ var tagsErr error
+ if memory != nil {
+ availableTags, tagsErr = memory.ListAllTags()
+ }
+ hasMemoryTags := tagsErr == nil && len(availableTags) > 0
+
+ // Even without memory tags, we still call pre-LLM for CoT selection.
+
+ // 2. Build the system prompt with example templates + learning history.
+ examples := ""
+ if p.cotRegistry != nil {
+ examples = p.cotRegistry.ListExamplesForPrompt()
+ }
+ // Include historical CoT performance data + top-rated prompts for learning.
+ cotHistory := ""
+ if memory != nil {
+ // Pass available tags so proven examples can be filtered by relevance.
+ cotHistory = memory.FormatCotLearningContext(30, availableTags)
+ }
+ systemPrompt := fmt.Sprintf(preLLMSystemPromptTpl, examples, cotHistory)
+
+ // 3. Build the user prompt with available tags + active context.
+ var userPromptBuilder strings.Builder
+
+ // Active Context block (injected here to keep system prompt prefix stable).
+ if actCtx != nil {
+ if ac := actCtx.Format(); ac != "" {
+ userPromptBuilder.WriteString(ac)
+ userPromptBuilder.WriteString("\n\n")
+ }
+ }
+
+ if hasMemoryTags {
+ fmt.Fprintf(&userPromptBuilder, "Available tags: [%s]\n\nUser message: %s",
+ strings.Join(availableTags, ", "), userMessage)
+ } else {
+ fmt.Fprintf(&userPromptBuilder, "Available tags: [](none)\n\nUser message: %s", userMessage)
+ }
+ userPrompt := userPromptBuilder.String()
+
+ messages := []providers.Message{
+ {Role: "system", Content: systemPrompt},
+ {Role: "user", Content: userPrompt},
+ }
+
+ // 4. Call the LLM (no tools, moderate max_tokens for generated CoT, low temperature).
+ resp, err := p.provider.Chat(ctx, messages, nil, p.model, map[string]any{
+ "max_tokens": 512,
+ "temperature": 0.3,
+ })
+ if err != nil {
+ logger.WarnCF("analyser", "Pre-LLM call failed, proceeding without enrichment",
+ map[string]any{"error": err.Error(), "model": p.model})
+ return AnalyseResult{}
+ }
+
+ // 5. Parse the JSON response.
+ result := p.parseResponse(resp.Content)
+
+ // 6. Query memory by extracted tags.
+ if len(result.Tags) > 0 && memory != nil {
+ entries, err := memory.SearchByAnyTag(result.Tags)
+ if err == nil && len(entries) > 0 {
+ result.MemoryContext = formatMemoryEntries(entries)
+ }
+ }
+
+ // 7. Record usage for learning (non-blocking — don't fail the main flow).
+ if memory != nil && result.CotPrompt != "" {
+ if _, err := memory.RecordCotUsage(result.Intent, result.Tags, result.CotPrompt, userMessage); err != nil {
+ logger.DebugCF("analyser", "Failed to record CoT usage",
+ map[string]any{"error": err.Error()})
+ }
+ }
+
+ elapsed := time.Since(start)
+ logger.InfoCF("analyser", "Pre-LLM analysis complete",
+ map[string]any{
+ "intent": result.Intent,
+ "tags": result.Tags,
+ "has_cot": result.CotPrompt != "",
+ "cot_len": len(result.CotPrompt),
+ "memory_entries": countMemoryLines(result.MemoryContext),
+ "elapsed_ms": elapsed.Milliseconds(),
+ "model": p.model,
+ "available_tags": len(availableTags),
+ })
+
+ return result
+}
+
+// parseResponse extracts intent and tags from the LLM's JSON response.
+// Handles common LLM quirks like markdown fences around JSON.
+func (p *Analyser) parseResponse(content string) AnalyseResult {
+ content = strings.TrimSpace(content)
+
+ // Strip markdown code fences if present.
+ if strings.HasPrefix(content, "```") {
+ lines := strings.Split(content, "\n")
+ // Remove first and last lines (fences).
+ if len(lines) >= 3 {
+ content = strings.Join(lines[1:len(lines)-1], "\n")
+ }
+ }
+ content = strings.TrimSpace(content)
+
+ var result AnalyseResult
+ if err := json.Unmarshal([]byte(content), &result); err != nil {
+ logger.WarnCF("analyser", "Failed to parse pre-LLM response as JSON",
+ map[string]any{
+ "error": err.Error(),
+ "content": content,
+ })
+ return AnalyseResult{}
+ }
+
+ // Sanitise: lowercase tags, limit to 5.
+ cleaned := make([]string, 0, len(result.Tags))
+ for _, t := range result.Tags {
+ t = strings.ToLower(strings.TrimSpace(t))
+ if t != "" {
+ cleaned = append(cleaned, t)
+ }
+ }
+ if len(cleaned) > 5 {
+ cleaned = cleaned[:5]
+ }
+ result.Tags = cleaned
+
+ return result
+}
+
+// formatMemoryEntries formats memory entries into a string for injection into context.
+func formatMemoryEntries(entries []MemoryEntry) string {
+ if len(entries) == 0 {
+ return ""
+ }
+
+ var sb strings.Builder
+ sb.WriteString("## Relevant Memories (auto-retrieved)\n\n")
+ for _, e := range entries {
+ tagLabel := ""
+ if len(e.Tags) > 0 {
+ tagLabel = " [" + strings.Join(e.Tags, ", ") + "]"
+ }
+ fmt.Fprintf(&sb, "- (#%d%s) %s\n", e.ID, tagLabel, e.Content)
+ }
+ return sb.String()
+}
+
+// countMemoryLines counts the number of memory entries in a formatted string.
+func countMemoryLines(s string) int {
+ if s == "" {
+ return 0
+ }
+ count := 0
+ for _, line := range strings.Split(s, "\n") {
+ if strings.HasPrefix(line, "- (#") {
+ count++
+ }
+ }
+ return count
+}
diff --git a/pkg/agent/analyser_test.go b/pkg/agent/analyser_test.go
new file mode 100644
index 000000000..b00377db4
--- /dev/null
+++ b/pkg/agent/analyser_test.go
@@ -0,0 +1,286 @@
+package agent
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func TestPreLLM_parseResponse(t *testing.T) {
+ p := &Analyser{}
+
+ tests := []struct {
+ name string
+ input string
+ wantIntent string
+ wantTags []string
+ wantCot string
+ }{
+ {
+ name: "valid JSON with cot_prompt",
+ input: `{"intent":"question","tags":["golang","testing"],"cot_prompt":"1. Understand the question\n2. Research the answer"}`,
+ wantIntent: "question",
+ wantTags: []string{"golang", "testing"},
+ wantCot: "1. Understand the question\n2. Research the answer",
+ },
+ {
+ name: "JSON with markdown fences",
+ input: "```json\n{\"intent\":\"task\",\"tags\":[\"deploy\"],\"cot_prompt\":\"1. Plan\\n2. Execute\"}\n```",
+ wantIntent: "task",
+ wantTags: []string{"deploy"},
+ wantCot: "1. Plan\n2. Execute",
+ },
+ {
+ name: "empty cot_prompt for chat",
+ input: `{"intent":"chat","tags":[],"cot_prompt":""}`,
+ wantIntent: "chat",
+ wantTags: []string{},
+ wantCot: "",
+ },
+ {
+ name: "invalid JSON",
+ input: "this is not json",
+ wantIntent: "",
+ wantTags: nil,
+ wantCot: "",
+ },
+ {
+ name: "tags trimmed and lowered",
+ input: `{"intent":"code","tags":[" GoLang "," API "],"cot_prompt":"think"}`,
+ wantIntent: "code",
+ wantTags: []string{"golang", "api"},
+ wantCot: "think",
+ },
+ {
+ name: "tags limited to 5",
+ input: `{"intent":"search","tags":["a","b","c","d","e","f","g"],"cot_prompt":"search"}`,
+ wantIntent: "search",
+ wantTags: []string{"a", "b", "c", "d", "e"},
+ wantCot: "search",
+ },
+ {
+ name: "missing cot_prompt field",
+ input: `{"intent":"question","tags":["golang"]}`,
+ wantIntent: "question",
+ wantTags: []string{"golang"},
+ wantCot: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := p.parseResponse(tt.input)
+ if result.Intent != tt.wantIntent {
+ t.Errorf("intent = %q, want %q", result.Intent, tt.wantIntent)
+ }
+ if result.CotPrompt != tt.wantCot {
+ t.Errorf("cot_prompt = %q, want %q", result.CotPrompt, tt.wantCot)
+ }
+
+ if tt.wantTags == nil {
+ if result.Tags != nil {
+ t.Errorf("tags = %v, want nil", result.Tags)
+ }
+ return
+ }
+
+ if len(result.Tags) != len(tt.wantTags) {
+ t.Errorf("tags len = %d, want %d (tags=%v)", len(result.Tags), len(tt.wantTags), result.Tags)
+ return
+ }
+ for i, tag := range result.Tags {
+ if tag != tt.wantTags[i] {
+ t.Errorf("tag[%d] = %q, want %q", i, tag, tt.wantTags[i])
+ }
+ }
+ })
+ }
+}
+
+func TestPreLLM_Analyse_NoProvider(t *testing.T) {
+ p := &Analyser{} // no provider, no model
+ result := p.Analyse(context.Background(), "hello", nil, nil)
+ if result.Intent != "" || len(result.Tags) != 0 {
+ t.Errorf("expected empty result with no provider, got %+v", result)
+ }
+}
+
+func TestPreLLM_Analyse_NoTags(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ cotReg := NewCotRegistry(dir)
+ mp := &mockLLMProvider{
+ response: `{"intent":"chat","tags":[],"cot_prompt":""}`,
+ }
+ p := NewAnalyser(mp, "test-model", cotReg)
+
+ result := p.Analyse(context.Background(), "hello there", ms, nil)
+ if result.Intent != "chat" {
+ t.Errorf("expected intent 'chat', got %q", result.Intent)
+ }
+ if result.CotPrompt != "" {
+ t.Errorf("expected empty cot_prompt for chat, got %q", result.CotPrompt)
+ }
+}
+
+func TestPreLLM_Analyse_WithMemory(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ // Seed memory.
+ ms.AddEntry("Go is great for concurrency", []string{"golang", "concurrency"})
+ ms.AddEntry("Kubernetes cluster setup notes", []string{"k8s", "devops"})
+ ms.AddEntry("Go testing best practices", []string{"golang", "testing"})
+
+ cotReg := NewCotRegistry(dir)
+ mp := &mockLLMProvider{
+ response: `{"intent":"question","tags":["golang"],"cot_prompt":"1. Check Go docs\n2. Write example code\n3. Verify with tests"}`,
+ }
+ p := NewAnalyser(mp, "test-model", cotReg)
+
+ result := p.Analyse(context.Background(), "How do I test Go code?", ms, nil)
+
+ if result.Intent != "question" {
+ t.Errorf("intent = %q, want %q", result.Intent, "question")
+ }
+ if result.CotPrompt == "" {
+ t.Error("expected non-empty CotPrompt")
+ }
+ if !strings.Contains(result.CotPrompt, "Go docs") {
+ t.Error("CotPrompt should contain the LLM-generated strategy")
+ }
+ if len(result.Tags) != 1 || result.Tags[0] != "golang" {
+ t.Errorf("tags = %v, want [golang]", result.Tags)
+ }
+ if result.MemoryContext == "" {
+ t.Error("expected non-empty MemoryContext with matching tags")
+ }
+ if !contains(result.MemoryContext, "Go is great for concurrency") {
+ t.Error("MemoryContext missing 'Go is great for concurrency'")
+ }
+ if !contains(result.MemoryContext, "Go testing best practices") {
+ t.Error("MemoryContext missing 'Go testing best practices'")
+ }
+ if contains(result.MemoryContext, "Kubernetes") {
+ t.Error("MemoryContext should not contain 'Kubernetes' entry")
+ }
+
+ // Verify usage was recorded with tags.
+ records, _ := ms.GetRecentCotUsage(1)
+ if len(records) == 0 {
+ t.Fatal("expected usage record to be recorded")
+ }
+ if len(records[0].Tags) != 1 || records[0].Tags[0] != "golang" {
+ t.Errorf("recorded tags = %v, want [golang]", records[0].Tags)
+ }
+}
+
+func TestSearchByAnyTag(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ ms.AddEntry("Go concurrency", []string{"golang", "concurrency"})
+ ms.AddEntry("K8s setup", []string{"k8s", "devops"})
+ ms.AddEntry("Go testing", []string{"golang", "testing"})
+ ms.AddEntry("Python ML", []string{"python", "ml"})
+
+ entries, err := ms.SearchByAnyTag([]string{"golang", "k8s"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 3 {
+ t.Errorf("got %d entries, want 3", len(entries))
+ }
+
+ entries, err = ms.SearchByAnyTag([]string{"python"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 1 {
+ t.Errorf("got %d entries, want 1", len(entries))
+ }
+
+ entries, err = ms.SearchByAnyTag([]string{"nonexistent"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 0 {
+ t.Errorf("got %d entries, want 0", len(entries))
+ }
+}
+
+func TestFormatMemoryEntries(t *testing.T) {
+ entries := []MemoryEntry{
+ {ID: 1, Content: "Test content 1", Tags: []string{"tag1", "tag2"}},
+ {ID: 2, Content: "Test content 2", Tags: []string{"tag3"}},
+ {ID: 3, Content: "No tags entry", Tags: nil},
+ }
+
+ result := formatMemoryEntries(entries)
+ if result == "" {
+ t.Fatal("expected non-empty result")
+ }
+ if !contains(result, "Relevant Memories") {
+ t.Error("missing header")
+ }
+ if !contains(result, "#1") {
+ t.Error("missing entry #1")
+ }
+ if !contains(result, "[tag1, tag2]") {
+ t.Error("missing tags for entry #1")
+ }
+}
+
+func TestFormatMemoryEntries_Empty(t *testing.T) {
+ result := formatMemoryEntries(nil)
+ if result != "" {
+ t.Errorf("expected empty string, got %q", result)
+ }
+}
+
+// --- Helpers ---
+
+func contains(s, substr string) bool {
+ return strings.Contains(s, substr)
+}
+
+func TestPreLLM_MemoryDBPath(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ dbPath := filepath.Join(dir, "memory.db")
+ if _, err := os.Stat(dbPath); err != nil {
+ t.Errorf("memory.db not created: %v", err)
+ }
+}
+
+// mockLLMProvider returns a configurable response for pre-LLM testing.
+type mockLLMProvider struct {
+ response string
+}
+
+func (m *mockLLMProvider) Chat(
+ _ context.Context,
+ _ []providers.Message,
+ _ []providers.ToolDefinition,
+ _ string,
+ _ map[string]any,
+) (*providers.LLMResponse, error) {
+ return &providers.LLMResponse{
+ Content: m.response,
+ ToolCalls: []providers.ToolCall{},
+ }, nil
+}
+
+func (m *mockLLMProvider) GetDefaultModel() string {
+ return "mock-pre-llm"
+}
diff --git a/pkg/agent/context.go b/pkg/agent/context.go
index 6fccbaf53..869b29196 100644
--- a/pkg/agent/context.go
+++ b/pkg/agent/context.go
@@ -67,8 +67,7 @@ You are picoclaw, a helpful AI assistant.
## Workspace
Your workspace is at: %s
-- Memory: %s/memory/MEMORY.md
-- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
+- Memory DB: %s/memory.db (SQLite)
- Skills: %s/skills/{skill-name}/SKILL.md
## Important Rules
@@ -77,10 +76,10 @@ Your workspace is at: %s
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
-3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
+3. **Memory** - When interacting with me if something seems memorable, update the long-term memory in %s/memory.db
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`,
- workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
+ workspacePath, workspacePath, workspacePath, workspacePath)
}
func (cb *ContextBuilder) BuildSystemPrompt() string {
@@ -181,7 +180,7 @@ func (cb *ContextBuilder) sourcePaths() []string {
filepath.Join(cb.workspace, "SOUL.md"),
filepath.Join(cb.workspace, "USER.md"),
filepath.Join(cb.workspace, "IDENTITY.md"),
- filepath.Join(cb.workspace, "memory", "MEMORY.md"),
+ filepath.Join(cb.workspace, "memory.db"),
}
}
@@ -579,3 +578,9 @@ func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
"names": skillNames,
}
}
+
+// GetMemory returns the underlying MemoryStore.
+// Used by the pre-LLM module to query tags and search entries.
+func (cb *ContextBuilder) GetMemory() *MemoryStore {
+ return cb.memory
+}
diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go
index 0905e8a46..042555a4f 100644
--- a/pkg/agent/context_cache_test.go
+++ b/pkg/agent/context_cache_test.go
@@ -19,7 +19,6 @@ func setupWorkspace(t *testing.T, files map[string]string) string {
if err != nil {
t.Fatal(err)
}
- os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
for name, content := range files {
dir := filepath.Dir(filepath.Join(tmpDir, name))
@@ -145,13 +144,6 @@ func TestMtimeAutoInvalidation(t *testing.T) {
contentV2: "# Updated Identity",
checkField: "Updated Identity",
},
- {
- name: "memory file change",
- file: "memory/MEMORY.md",
- contentV1: "# Memory\nUser likes Go.",
- contentV2: "# Memory\nUser likes Rust.",
- checkField: "User likes Rust",
- },
}
for _, tt := range tests {
@@ -212,6 +204,43 @@ func TestMtimeAutoInvalidation(t *testing.T) {
t.Error("sourceFilesChangedLocked() should detect skills dir mtime change")
}
})
+
+ // Memory DB mtime change (via MemoryStore write)
+ t.Run("memory DB change", func(t *testing.T) {
+ tmpDir := setupWorkspace(t, nil)
+ defer os.RemoveAll(tmpDir)
+
+ cb := NewContextBuilder(tmpDir)
+
+ // Write initial memory
+ cb.memory.WriteLongTerm("User likes Go.")
+
+ // Build cache
+ sp1 := cb.BuildSystemPromptWithCache()
+ if !strings.Contains(sp1, "User likes Go") {
+ t.Fatal("initial prompt should contain memory content")
+ }
+
+ // Update memory via MemoryStore
+ cb.memory.WriteLongTerm("User likes Rust.")
+
+ // Set future mtime on memory.db so cache detects change
+ dbPath := filepath.Join(tmpDir, "memory.db")
+ future := time.Now().Add(2 * time.Second)
+ os.Chtimes(dbPath, future, future)
+
+ cb.systemPromptMutex.RLock()
+ changed := cb.sourceFilesChangedLocked()
+ cb.systemPromptMutex.RUnlock()
+ if !changed {
+ t.Fatal("sourceFilesChangedLocked() should detect memory.db change")
+ }
+
+ sp2 := cb.BuildSystemPromptWithCache()
+ if !strings.Contains(sp2, "User likes Rust") {
+ t.Error("rebuilt prompt should contain updated memory")
+ }
+ })
}
// TestExplicitInvalidateCache verifies that InvalidateCache() forces a rebuild
@@ -273,57 +302,35 @@ func TestCacheStability(t *testing.T) {
// This catches the "from nothing to something" edge case that the old
// modifiedSince (return false on stat error) would miss.
func TestNewFileCreationInvalidatesCache(t *testing.T) {
- tests := []struct {
- name string
- file string // relative path inside workspace
- content string
- checkField string // substring to verify in rebuilt prompt
- }{
- {
- name: "new bootstrap file",
- file: "SOUL.md",
- content: "# Soul\nBe kind and helpful.",
- checkField: "Be kind and helpful",
- },
- {
- name: "new memory file",
- file: "memory/MEMORY.md",
- content: "# Memory\nUser prefers dark mode.",
- checkField: "User prefers dark mode",
- },
- }
+ // Test bootstrap file creation
+ t.Run("new bootstrap file", func(t *testing.T) {
+ // Start with an empty workspace (no bootstrap files)
+ tmpDir := setupWorkspace(t, nil)
+ defer os.RemoveAll(tmpDir)
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Start with an empty workspace (no bootstrap/memory files)
- tmpDir := setupWorkspace(t, nil)
- defer os.RemoveAll(tmpDir)
+ cb := NewContextBuilder(tmpDir)
- cb := NewContextBuilder(tmpDir)
+ // Populate cache — file does not exist yet
+ sp1 := cb.BuildSystemPromptWithCache()
+ if strings.Contains(sp1, "Be kind and helpful") {
+ t.Fatalf("prompt should not contain content before file is created")
+ }
- // Populate cache — file does not exist yet
- sp1 := cb.BuildSystemPromptWithCache()
- if strings.Contains(sp1, tt.checkField) {
- t.Fatalf("prompt should not contain %q before file is created", tt.checkField)
- }
+ // Create the file after cache was built
+ fullPath := filepath.Join(tmpDir, "SOUL.md")
+ if err := os.WriteFile(fullPath, []byte("# Soul\nBe kind and helpful."), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // Set future mtime to guarantee detection
+ future := time.Now().Add(2 * time.Second)
+ os.Chtimes(fullPath, future, future)
- // Create the file after cache was built
- fullPath := filepath.Join(tmpDir, tt.file)
- os.MkdirAll(filepath.Dir(fullPath), 0o755)
- if err := os.WriteFile(fullPath, []byte(tt.content), 0o644); err != nil {
- t.Fatal(err)
- }
- // Set future mtime to guarantee detection
- future := time.Now().Add(2 * time.Second)
- os.Chtimes(fullPath, future, future)
-
- // Cache should auto-invalidate because file went from absent -> present
- sp2 := cb.BuildSystemPromptWithCache()
- if !strings.Contains(sp2, tt.checkField) {
- t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField)
- }
- })
- }
+ // Cache should auto-invalidate because file went from absent -> present
+ sp2 := cb.BuildSystemPromptWithCache()
+ if !strings.Contains(sp2, "Be kind and helpful") {
+ t.Errorf("cache not invalidated on new file creation")
+ }
+ })
}
// TestSkillFileContentChange verifies that modifying a skill file's content
@@ -391,7 +398,6 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
tmpDir := setupWorkspace(t, map[string]string{
"IDENTITY.md": "# Identity\nConcurrency test agent.",
"SOUL.md": "# Soul\nBe helpful.",
- "memory/MEMORY.md": "# Memory\nUser prefers Go.",
"skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo",
})
defer os.RemoveAll(tmpDir)
@@ -494,7 +500,6 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) {
tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*")
defer os.RemoveAll(tmpDir)
- os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} {
os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644)
diff --git a/pkg/agent/cot_learning_test.go b/pkg/agent/cot_learning_test.go
new file mode 100644
index 000000000..829be72d8
--- /dev/null
+++ b/pkg/agent/cot_learning_test.go
@@ -0,0 +1,297 @@
+package agent
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestCotUsage_RecordAndQuery(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ // Record some usage with tags.
+ id1, err := ms.RecordCotUsage("code", []string{"golang", "testing"}, "1. Check tests\n2. Write code", "How do I test Go code?")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if id1 <= 0 {
+ t.Errorf("expected positive ID, got %d", id1)
+ }
+
+ id2, err := ms.RecordCotUsage("question", []string{"golang"}, "1. Compare options\n2. Decide", "What's the difference?")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ id3, err := ms.RecordCotUsage("code", []string{"http", "golang"}, "1. Define routes\n2. Implement handlers", "Write a HTTP server")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Query recent usage.
+ records, err := ms.GetRecentCotUsage(10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(records) != 3 {
+ t.Errorf("expected 3 records, got %d", len(records))
+ }
+
+ // Most recent first.
+ if records[0].ID != id3 {
+ t.Errorf("expected most recent to be id3=%d, got %d", id3, records[0].ID)
+ }
+
+ // Check tags are stored correctly.
+ if len(records[0].Tags) != 2 || records[0].Tags[0] != "http" {
+ t.Errorf("tags = %v, want [http, golang]", records[0].Tags)
+ }
+
+ // Check cot_prompt is stored.
+ if !strings.Contains(records[0].CotPrompt, "Define routes") {
+ t.Errorf("cot_prompt = %q, should contain 'Define routes'", records[0].CotPrompt)
+ }
+
+ _ = id2 // used above
+}
+
+func TestCotUsage_Feedback(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ id, _ := ms.RecordCotUsage("code", []string{"golang"}, "think step by step", "test message")
+
+ // Initial feedback should be 0.
+ records, _ := ms.GetRecentCotUsage(1)
+ if records[0].Feedback != 0 {
+ t.Errorf("initial feedback = %d, want 0", records[0].Feedback)
+ }
+
+ // Update feedback.
+ err := ms.UpdateCotFeedback(id, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ records, _ = ms.GetRecentCotUsage(1)
+ if records[0].Feedback != 1 {
+ t.Errorf("feedback = %d, want 1", records[0].Feedback)
+ }
+
+ // Invalid score.
+ err = ms.UpdateCotFeedback(id, 5)
+ if err == nil {
+ t.Error("expected error for invalid score 5")
+ }
+}
+
+func TestCotUsage_UpdateLatestFeedback(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ ms.RecordCotUsage("code", nil, "strategy 1", "first")
+ ms.RecordCotUsage("debug", nil, "strategy 2", "second")
+
+ // Update latest (should be "debug").
+ err := ms.UpdateLatestCotFeedback(-1)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ records, _ := ms.GetRecentCotUsage(2)
+ if records[0].Intent != "debug" || records[0].Feedback != -1 {
+ t.Errorf("latest: intent=%q feedback=%d, want debug/-1", records[0].Intent, records[0].Feedback)
+ }
+ if records[1].Intent != "code" || records[1].Feedback != 0 {
+ t.Errorf("first: intent=%q feedback=%d, want code/0", records[1].Intent, records[1].Feedback)
+ }
+}
+
+func TestCotUsage_Stats(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ id1, _ := ms.RecordCotUsage("code", nil, "think about code", "write code")
+ ms.UpdateCotFeedback(id1, 1)
+
+ id2, _ := ms.RecordCotUsage("code", nil, "debug systematically", "fix bug")
+ ms.UpdateCotFeedback(id2, 1)
+
+ id3, _ := ms.RecordCotUsage("question", nil, "analyse step by step", "why does X happen?")
+ ms.UpdateCotFeedback(id3, -1)
+
+ id4, _ := ms.RecordCotUsage("chat", nil, "", "hello")
+ ms.UpdateCotFeedback(id4, 1)
+
+ // Get stats.
+ stats, err := ms.GetCotStats(30)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(stats) != 3 {
+ t.Errorf("expected 3 intent stats, got %d", len(stats))
+ }
+
+ // "code" should have highest total uses.
+ if stats[0].Intent != "code" || stats[0].TotalUses != 2 {
+ t.Errorf("expected code with 2 uses, got %q with %d", stats[0].Intent, stats[0].TotalUses)
+ }
+}
+
+func TestCotUsage_TopRatedPrompts(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ // Record with different tags and feedback.
+ id1, _ := ms.RecordCotUsage("code", []string{"golang", "testing"}, "1. Write test first\n2. Then implement", "write Go test")
+ ms.UpdateCotFeedback(id1, 1)
+
+ id2, _ := ms.RecordCotUsage("code", []string{"python"}, "1. Use pytest\n2. Mock dependencies", "write Python test")
+ ms.UpdateCotFeedback(id2, 1)
+
+ id3, _ := ms.RecordCotUsage("debug", []string{"golang"}, "1. Reproduce\n2. Hypothesize", "fix Go bug")
+ ms.UpdateCotFeedback(id3, 1)
+
+ id4, _ := ms.RecordCotUsage("code", []string{"golang"}, "1. Bad strategy", "bad approach")
+ ms.UpdateCotFeedback(id4, -1) // Negative — should not appear.
+
+ // Without tag filter.
+ top, err := ms.GetTopRatedCotPrompts(30, 10, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(top) != 3 {
+ t.Errorf("expected 3 top-rated, got %d", len(top))
+ }
+
+ // With tag filter — "golang" should prioritise golang-tagged prompts.
+ top, err = ms.GetTopRatedCotPrompts(30, 2, []string{"golang"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(top) != 2 {
+ t.Errorf("expected 2, got %d", len(top))
+ }
+ // First result should have golang tag.
+ hasGolang := false
+ for _, tag := range top[0].Tags {
+ if tag == "golang" {
+ hasGolang = true
+ }
+ }
+ if !hasGolang {
+ t.Errorf("first result should have golang tag, got %v", top[0].Tags)
+ }
+}
+
+func TestCotUsage_FormatLearningContext(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ // Empty — should return empty string.
+ ctx := ms.FormatCotLearningContext(30, nil)
+ if ctx != "" {
+ t.Errorf("expected empty learning context, got %q", ctx)
+ }
+
+ // Add some usage with feedback.
+ id1, _ := ms.RecordCotUsage("code", []string{"golang"}, "1. Understand requirements\n2. Write code", "write code")
+ ms.UpdateCotFeedback(id1, 1)
+
+ id2, _ := ms.RecordCotUsage("question", []string{"architecture"}, "1. Examine structure\n2. Explain", "why does X happen?")
+ ms.UpdateCotFeedback(id2, 1)
+
+ ctx = ms.FormatCotLearningContext(30, nil)
+ if ctx == "" {
+ t.Error("expected non-empty learning context after recording usage")
+ }
+ if !strings.Contains(ctx, "Historical Usage Stats") {
+ t.Error("missing stats header")
+ }
+ if !strings.Contains(ctx, "Proven Strategies") {
+ t.Error("missing proven strategies section")
+ }
+ if !strings.Contains(ctx, "golang") {
+ t.Error("should show tags in proven examples")
+ }
+}
+
+func TestCotUsage_MessageTruncation(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ longMsg := strings.Repeat("x", 500)
+ _, err := ms.RecordCotUsage("code", nil, "strategy", longMsg)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ records, _ := ms.GetRecentCotUsage(1)
+ if len(records[0].Message) > 200 {
+ t.Errorf("message should be truncated to 200 chars, got %d", len(records[0].Message))
+ }
+}
+
+func TestPreLLM_LearningIntegration(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ cotReg := NewCotRegistry(dir)
+ mp := &mockLLMProvider{
+ response: `{"intent":"code","tags":["golang"],"cot_prompt":"1. Understand the function signature\n2. Write the implementation\n3. Add error handling"}`,
+ }
+ p := NewAnalyser(mp, "test-model", cotReg)
+
+ // First call — no learning data yet.
+ result := p.Analyse(nil, "write a function", ms, nil)
+ if result.CotPrompt == "" {
+ t.Error("expected non-empty CotPrompt")
+ }
+
+ // Verify usage was recorded with tags.
+ records, _ := ms.GetRecentCotUsage(5)
+ if len(records) != 1 {
+ t.Fatalf("expected 1 usage record, got %d", len(records))
+ }
+ if records[0].Intent != "code" {
+ t.Errorf("recorded intent = %q, want %q", records[0].Intent, "code")
+ }
+ if len(records[0].Tags) != 1 || records[0].Tags[0] != "golang" {
+ t.Errorf("recorded tags = %v, want [golang]", records[0].Tags)
+ }
+ if records[0].CotPrompt == "" {
+ t.Error("recorded cot_prompt should not be empty")
+ }
+
+ // Provide positive feedback.
+ ms.UpdateLatestCotFeedback(1)
+
+ // Second call — learning context should now be included.
+ result2 := p.Analyse(nil, "fix this bug", ms, nil)
+ if result2.CotPrompt == "" {
+ t.Error("expected non-empty CotPrompt on second call")
+ }
+
+ // Should now have 2 usage records.
+ records, _ = ms.GetRecentCotUsage(5)
+ if len(records) != 2 {
+ t.Errorf("expected 2 usage records, got %d", len(records))
+ }
+
+ // Learning context should include the first proven strategy.
+ ctx := ms.FormatCotLearningContext(30, []string{"golang"})
+ if ctx == "" {
+ t.Error("expected non-empty learning context after usage + feedback")
+ }
+ if !strings.Contains(ctx, "Proven Strategies") {
+ t.Error("learning context should include proven strategies")
+ }
+}
diff --git a/pkg/agent/cot_templates.go b/pkg/agent/cot_templates.go
new file mode 100644
index 000000000..b17c6b71e
--- /dev/null
+++ b/pkg/agent/cot_templates.go
@@ -0,0 +1,287 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package agent
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// CotTemplate represents a Chain-of-Thought prompting template.
+type CotTemplate struct {
+ ID string // Short identifier (e.g. "analytical", "code")
+ Name string // Human-readable name
+ Description string // One-line description for the pre-LLM to choose from
+ Prompt string // The actual CoT instruction injected into the system prompt
+}
+
+// --- Built-in CoT Templates -------------------------------------------------
+
+var builtinCotTemplates = []CotTemplate{
+ {
+ ID: "direct",
+ Name: "Direct Answer",
+ Description: "Simple, direct response — no special reasoning needed",
+ Prompt: "", // No CoT injection for simple answers
+ },
+ {
+ ID: "analytical",
+ Name: "Analytical Reasoning",
+ Description: "Complex questions requiring step-by-step logical analysis",
+ Prompt: `## Thinking Strategy: Analytical Reasoning
+
+Before answering, follow this reasoning process:
+1. **Clarify** — Restate the core question in your own words.
+2. **Decompose** — Break it into sub-problems or key aspects.
+3. **Analyse** — Work through each sub-problem with evidence/logic.
+4. **Synthesise** — Combine findings into a coherent answer.
+5. **Verify** — Check for logical gaps or contradictions.`,
+ },
+ {
+ ID: "code",
+ Name: "Code Analysis",
+ Description: "Writing, reviewing, or understanding code",
+ Prompt: `## Thinking Strategy: Code Analysis
+
+Before writing or analysing code:
+1. **Requirements** — What exactly needs to be done?
+2. **Inputs/Outputs** — Define the interface: what goes in, what comes out.
+3. **Edge Cases** — Consider boundary conditions, errors, empty inputs, concurrency.
+4. **Approach** — Choose the algorithm/pattern, justify the choice.
+5. **Implement** — Write clean, well-commented code.
+6. **Test** — Mentally trace through with sample inputs to verify correctness.`,
+ },
+ {
+ ID: "debug",
+ Name: "Debugging",
+ Description: "Finding and fixing bugs, errors, or unexpected behaviour",
+ Prompt: `## Thinking Strategy: Debugging
+
+Follow a systematic debugging approach:
+1. **Reproduce** — Understand the exact symptoms and conditions.
+2. **Hypothesise** — List 2-3 most likely root causes.
+3. **Narrow Down** — For each hypothesis, describe what evidence would confirm/deny it.
+4. **Root Cause** — Identify the actual root cause with evidence.
+5. **Fix** — Propose the minimal, targeted fix.
+6. **Verify** — Confirm the fix resolves the issue without side effects.`,
+ },
+ {
+ ID: "creative",
+ Name: "Creative Thinking",
+ Description: "Brainstorming, creative writing, idea generation",
+ Prompt: `## Thinking Strategy: Creative Exploration
+
+Use divergent-convergent thinking:
+1. **Diverge** — Generate multiple distinct ideas or approaches without judgment.
+2. **Explore** — Expand on the most promising 2-3 ideas.
+3. **Combine** — Look for unexpected connections between ideas.
+4. **Converge** — Select the best approach and refine it.
+5. **Polish** — Add detail, nuance, and completeness.`,
+ },
+ {
+ ID: "task",
+ Name: "Task Planning",
+ Description: "Multi-step tasks, planning, project work",
+ Prompt: `## Thinking Strategy: Task Planning
+
+Plan before executing:
+1. **Goal** — What is the desired end state?
+2. **Current State** — What exists now? What resources are available?
+3. **Steps** — Break into ordered, actionable steps.
+4. **Dependencies** — Identify which steps depend on others.
+5. **Risks** — What could go wrong? How to mitigate?
+6. **Execute** — Carry out steps, adapting as needed.`,
+ },
+ {
+ ID: "explain",
+ Name: "Explain / Teach",
+ Description: "Teaching concepts, explaining how things work",
+ Prompt: `## Thinking Strategy: Educational Explanation
+
+Structure your explanation for clarity:
+1. **Big Picture** — Start with a one-sentence summary of the concept.
+2. **Analogy** — Relate to something familiar if possible.
+3. **Core Mechanism** — Explain how it works step by step.
+4. **Example** — Provide a concrete example or demonstration.
+5. **Gotchas** — Mention common misconceptions or pitfalls.`,
+ },
+ {
+ ID: "compare",
+ Name: "Comparison / Decision",
+ Description: "Comparing options, making decisions, trade-off analysis",
+ Prompt: `## Thinking Strategy: Comparison Analysis
+
+Structure your analysis:
+1. **Criteria** — Define what matters most for this decision.
+2. **Options** — List all viable options.
+3. **Trade-offs** — For each option, list pros and cons against the criteria.
+4. **Recommendation** — State the best choice with clear reasoning.
+5. **Caveats** — Note when the recommendation might not apply.`,
+ },
+}
+
+// --- CoT Template Registry --------------------------------------------------
+
+// CotRegistry manages the available CoT templates.
+// It loads built-in templates and supports user-defined ones from workspace.
+type CotRegistry struct {
+ mu sync.RWMutex
+ templates map[string]CotTemplate
+}
+
+// NewCotRegistry creates a registry with built-in templates and optionally
+// loads user-defined templates from the workspace/cot_templates/ directory.
+func NewCotRegistry(workspace string) *CotRegistry {
+ r := &CotRegistry{
+ templates: make(map[string]CotTemplate, len(builtinCotTemplates)),
+ }
+
+ // Register built-in templates.
+ for _, t := range builtinCotTemplates {
+ r.templates[t.ID] = t
+ }
+
+ // Load user-defined templates from workspace.
+ r.loadUserTemplates(workspace)
+
+ return r
+}
+
+// Get returns a template by ID (case-insensitive). Returns the "direct"
+// template if not found.
+func (r *CotRegistry) Get(id string) CotTemplate {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ id = strings.ToLower(strings.TrimSpace(id))
+ if t, ok := r.templates[id]; ok {
+ return t
+ }
+ return r.templates["direct"]
+}
+
+// ListForPrompt returns a formatted list of available template IDs and
+// descriptions, suitable for quick reference.
+func (r *CotRegistry) ListForPrompt() string {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ var sb strings.Builder
+ for _, t := range builtinCotTemplates {
+ fmt.Fprintf(&sb, "- %s: %s\n", t.ID, t.Description)
+ }
+
+ // Append user-defined templates.
+ for id, t := range r.templates {
+ isBuiltin := false
+ for _, bt := range builtinCotTemplates {
+ if bt.ID == id {
+ isBuiltin = true
+ break
+ }
+ }
+ if !isBuiltin {
+ fmt.Fprintf(&sb, "- %s: %s\n", t.ID, t.Description)
+ }
+ }
+
+ return sb.String()
+}
+
+// ListExamplesForPrompt returns full template examples for the pre-LLM to
+// use as inspiration when generating custom CoT prompts.
+// Shows 3-4 diverse examples with their full prompt content.
+func (r *CotRegistry) ListExamplesForPrompt() string {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ // Select a diverse set of examples (not all — keep prompt concise).
+ exampleIDs := []string{"analytical", "code", "debug", "task"}
+
+ var sb strings.Builder
+ for _, id := range exampleIDs {
+ t, ok := r.templates[id]
+ if !ok || t.Prompt == "" {
+ continue
+ }
+ fmt.Fprintf(&sb, "### Example: %s (%s)\n%s\n\n", t.Name, t.Description, t.Prompt)
+ }
+
+ // Append any user-defined templates as additional examples.
+ for id, t := range r.templates {
+ isBuiltin := false
+ for _, bt := range builtinCotTemplates {
+ if bt.ID == id {
+ isBuiltin = true
+ break
+ }
+ }
+ if !isBuiltin && t.Prompt != "" {
+ fmt.Fprintf(&sb, "### Example: %s (%s)\n%s\n\n", t.Name, t.Description, t.Prompt)
+ }
+ }
+
+ return sb.String()
+}
+
+// loadUserTemplates scans workspace/cot_templates/ for .md files.
+// Each file becomes a template with ID = filename (without .md).
+// File format:
+//
+// Line 1: description (one line)
+// Line 2: ---
+// Line 3+: prompt content
+func (r *CotRegistry) loadUserTemplates(workspace string) {
+ dir := filepath.Join(workspace, "cot_templates")
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return // Directory doesn't exist — that's fine.
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
+ continue
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
+ if err != nil {
+ continue
+ }
+
+ id := strings.TrimSuffix(entry.Name(), ".md")
+ id = strings.ToLower(strings.TrimSpace(id))
+ if id == "" {
+ continue
+ }
+
+ content := string(data)
+ description := id
+ prompt := content
+
+ // Parse optional description header.
+ if idx := strings.Index(content, "\n---\n"); idx > 0 {
+ description = strings.TrimSpace(content[:idx])
+ prompt = strings.TrimSpace(content[idx+5:])
+ }
+
+ r.mu.Lock()
+ r.templates[id] = CotTemplate{
+ ID: id,
+ Name: id,
+ Description: description,
+ Prompt: prompt,
+ }
+ r.mu.Unlock()
+
+ logger.DebugCF("cot", "Loaded user CoT template",
+ map[string]any{"id": id, "description": description})
+ }
+}
diff --git a/pkg/agent/cot_templates_test.go b/pkg/agent/cot_templates_test.go
new file mode 100644
index 000000000..66622ca69
--- /dev/null
+++ b/pkg/agent/cot_templates_test.go
@@ -0,0 +1,146 @@
+package agent
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestCotRegistry_BuiltinTemplates(t *testing.T) {
+ dir := t.TempDir()
+ r := NewCotRegistry(dir)
+
+ // Should have all built-in templates.
+ for _, bt := range builtinCotTemplates {
+ tmpl := r.Get(bt.ID)
+ if tmpl.ID != bt.ID {
+ t.Errorf("expected template %q, got %q", bt.ID, tmpl.ID)
+ }
+ }
+
+ // "direct" should have empty prompt.
+ direct := r.Get("direct")
+ if direct.Prompt != "" {
+ t.Errorf("direct template should have empty prompt, got %q", direct.Prompt)
+ }
+
+ // "code" should have non-empty prompt.
+ code := r.Get("code")
+ if code.Prompt == "" {
+ t.Error("code template should have non-empty prompt")
+ }
+ if !strings.Contains(code.Prompt, "Code Analysis") {
+ t.Error("code template should mention 'Code Analysis'")
+ }
+}
+
+func TestCotRegistry_UnknownFallsToDefault(t *testing.T) {
+ dir := t.TempDir()
+ r := NewCotRegistry(dir)
+
+ tmpl := r.Get("nonexistent_template")
+ if tmpl.ID != "direct" {
+ t.Errorf("expected fallback to 'direct', got %q", tmpl.ID)
+ }
+}
+
+func TestCotRegistry_CaseInsensitive(t *testing.T) {
+ dir := t.TempDir()
+ r := NewCotRegistry(dir)
+
+ tmpl := r.Get(" Code ")
+ if tmpl.ID != "code" {
+ t.Errorf("expected 'code', got %q", tmpl.ID)
+ }
+}
+
+func TestCotRegistry_UserTemplates(t *testing.T) {
+ dir := t.TempDir()
+
+ // Create user template.
+ cotDir := filepath.Join(dir, "cot_templates")
+ os.MkdirAll(cotDir, 0o755)
+
+ content := `Custom strategy for data analysis
+---
+## Thinking Strategy: Data Analysis
+
+1. Examine the data structure.
+2. Identify patterns.
+3. Draw conclusions.`
+
+ os.WriteFile(filepath.Join(cotDir, "data_analysis.md"), []byte(content), 0o644)
+
+ r := NewCotRegistry(dir)
+
+ // Should be able to get the user template.
+ tmpl := r.Get("data_analysis")
+ if tmpl.ID != "data_analysis" {
+ t.Errorf("expected 'data_analysis', got %q", tmpl.ID)
+ }
+ if tmpl.Description != "Custom strategy for data analysis" {
+ t.Errorf("description = %q, want 'Custom strategy for data analysis'", tmpl.Description)
+ }
+ if !strings.Contains(tmpl.Prompt, "Examine the data structure") {
+ t.Error("prompt should contain user-defined content")
+ }
+}
+
+func TestCotRegistry_ListForPrompt(t *testing.T) {
+ dir := t.TempDir()
+ r := NewCotRegistry(dir)
+
+ list := r.ListForPrompt()
+
+ // Should contain all built-in template IDs.
+ for _, bt := range builtinCotTemplates {
+ if !strings.Contains(list, bt.ID) {
+ t.Errorf("ListForPrompt missing template %q", bt.ID)
+ }
+ }
+}
+
+func TestCotRegistry_ListExamplesForPrompt(t *testing.T) {
+ dir := t.TempDir()
+ r := NewCotRegistry(dir)
+
+ examples := r.ListExamplesForPrompt()
+
+ // Should contain full example content for key templates.
+ if !strings.Contains(examples, "Code Analysis") {
+ t.Error("ListExamplesForPrompt missing 'Code Analysis' example")
+ }
+ if !strings.Contains(examples, "Analytical Reasoning") {
+ t.Error("ListExamplesForPrompt missing 'Analytical Reasoning' example")
+ }
+ if !strings.Contains(examples, "Debugging") {
+ t.Error("ListExamplesForPrompt missing 'Debugging' example")
+ }
+ // Should contain actual steps, not just names.
+ if !strings.Contains(examples, "Requirements") {
+ t.Error("ListExamplesForPrompt should include actual step content")
+ }
+}
+
+func TestCotRegistry_UserOverridesBuiltin(t *testing.T) {
+ dir := t.TempDir()
+ // Create a user template that overrides "code".
+ cotDir := filepath.Join(dir, "cot_templates")
+ os.MkdirAll(cotDir, 0o755)
+
+ content := `My custom code template
+---
+## Custom Code Strategy
+
+Think differently about code.`
+
+ os.WriteFile(filepath.Join(cotDir, "code.md"), []byte(content), 0o644)
+
+ r := NewCotRegistry(dir)
+
+ tmpl := r.Get("code")
+ if !strings.Contains(tmpl.Prompt, "Think differently about code") {
+ t.Error("user template should override built-in 'code' template")
+ }
+}
diff --git a/pkg/agent/executor.go b/pkg/agent/executor.go
new file mode 100644
index 000000000..0ff244ec8
--- /dev/null
+++ b/pkg/agent/executor.go
@@ -0,0 +1,596 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package agent
+
+// executor.go - Phase 2 (ExecuteLLM) logic extracted from loop.go.
+// Contains the LLM iteration loop, tool handling, reasoning output,
+// context compression, and logging helpers.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/constants"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/tools"
+ "github.com/sipeed/picoclaw/pkg/utils"
+)
+
+func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) {
+ if al.channelManager == nil {
+ return ""
+ }
+ if ch, ok := al.channelManager.GetChannel(channelName); ok {
+ return ch.ReasoningChannelID()
+ }
+ return ""
+}
+
+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(),
+ })
+ }
+ }
+}
+
+// runLLMIteration executes the LLM call loop with tool handling.
+func (al *AgentLoop) runLLMIteration(
+ ctx context.Context,
+ agent *AgentInstance,
+ messages []providers.Message,
+ opts processOptions,
+) (string, int, []ToolCallRecord, error) {
+ iteration := 0
+ var finalContent string
+ var toolRecords []ToolCallRecord
+
+ for iteration < agent.MaxIterations {
+ iteration++
+
+ logger.DebugCF("agent", "LLM iteration",
+ map[string]any{
+ "seq": opts.MsgSeqId,
+ "agent_id": agent.ID,
+ "iteration": iteration,
+ "max": agent.MaxIterations,
+ })
+
+ // Build tool definitions
+ providerToolDefs := agent.Tools.ToProviderDefs()
+
+ // Log LLM request details
+ logger.DebugCF("agent", "LLM request",
+ map[string]any{
+ "seq": opts.MsgSeqId,
+ "agent_id": agent.ID,
+ "iteration": iteration,
+ "model": agent.Model,
+ "messages_count": len(messages),
+ "tools_count": len(providerToolDefs),
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
+ "system_prompt_len": len(messages[0].Content),
+ })
+
+ // Log full messages (detailed)
+ logger.DebugCF("agent", "Full LLM request",
+ map[string]any{
+ "iteration": iteration,
+ "messages_json": formatMessagesForLog(messages),
+ "tools_json": formatToolsForLog(providerToolDefs),
+ })
+
+ // Call LLM with fallback chain if candidates are configured.
+ var response *providers.LLMResponse
+ var err error
+
+ callLLM := func() (*providers.LLMResponse, error) {
+ if len(agent.Candidates) > 1 && al.fallback != nil {
+ fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
+ func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
+ "prompt_cache_key": agent.ID,
+ })
+ },
+ )
+ if fbErr != nil {
+ return nil, fbErr
+ }
+ if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
+ logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
+ fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
+ map[string]any{"agent_id": agent.ID, "iteration": iteration})
+ }
+ return fbResult.Response, nil
+ }
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
+ "prompt_cache_key": agent.ID,
+ })
+ }
+
+ // Retry loop for context/token errors
+ maxRetries := 2
+ for retry := 0; retry <= maxRetries; retry++ {
+ response, err = callLLM()
+ if err == nil {
+ break
+ }
+
+ errMsg := strings.ToLower(err.Error())
+
+ // Check if this is a network/HTTP timeout — not a context window error.
+ isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
+ strings.Contains(errMsg, "deadline exceeded") ||
+ strings.Contains(errMsg, "client.timeout") ||
+ strings.Contains(errMsg, "timed out") ||
+ strings.Contains(errMsg, "timeout exceeded")
+
+ // Detect real context window / token limit errors, excluding network timeouts.
+ isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
+ strings.Contains(errMsg, "context window") ||
+ strings.Contains(errMsg, "maximum context length") ||
+ strings.Contains(errMsg, "token limit") ||
+ strings.Contains(errMsg, "too many tokens") ||
+ strings.Contains(errMsg, "max_tokens") ||
+ strings.Contains(errMsg, "invalidparameter") ||
+ strings.Contains(errMsg, "prompt is too long") ||
+ strings.Contains(errMsg, "request too large"))
+
+ if isTimeoutError && retry < maxRetries {
+ backoff := time.Duration(retry+1) * 5 * time.Second
+ logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{
+ "error": err.Error(),
+ "retry": retry,
+ "backoff": backoff.String(),
+ })
+ time.Sleep(backoff)
+ continue
+ }
+
+ if isContextError && retry < maxRetries {
+ logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
+ "error": err.Error(),
+ "retry": retry,
+ })
+
+ if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
+ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
+ Channel: opts.Channel,
+ ChatID: opts.ChatID,
+ Content: "Context window exceeded. Compressing history and retrying...",
+ })
+ }
+
+ al.forceCompression(agent, opts.SessionKey)
+ newHistory := agent.Sessions.GetHistory(opts.SessionKey)
+ newSummary := agent.Sessions.GetSummary(opts.SessionKey)
+ messages = agent.ContextBuilder.BuildMessages(
+ newHistory, newSummary, "",
+ nil, opts.Channel, opts.ChatID,
+ )
+ continue
+ }
+ break
+ }
+
+ if err != nil {
+ logger.ErrorCF("agent", "LLM call failed",
+ map[string]any{
+ "seq": opts.MsgSeqId,
+ "agent_id": agent.ID,
+ "iteration": iteration,
+ "error": err.Error(),
+ })
+ return "", iteration, toolRecords, fmt.Errorf("LLM call failed after retries: %w", err)
+ }
+
+ go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel))
+
+ logger.DebugCF("agent", "LLM response",
+ map[string]any{
+ "seq": opts.MsgSeqId,
+ "agent_id": agent.ID,
+ "iteration": iteration,
+ "content_chars": len(response.Content),
+ "tool_calls": len(response.ToolCalls),
+ "reasoning": response.Reasoning,
+ "target_channel": al.targetReasoningChannelID(opts.Channel),
+ "channel": opts.Channel,
+ })
+ // Check if no tool calls - we're done
+ if len(response.ToolCalls) == 0 {
+ finalContent = response.Content
+ logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
+ map[string]any{
+ "seq": opts.MsgSeqId,
+ "agent_id": agent.ID,
+ "iteration": iteration,
+ "content_chars": len(finalContent),
+ })
+ break
+ }
+
+ normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
+ for _, tc := range response.ToolCalls {
+ normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))
+ }
+
+ // Log tool calls
+ toolNames := make([]string, 0, len(normalizedToolCalls))
+ for _, tc := range normalizedToolCalls {
+ toolNames = append(toolNames, tc.Name)
+ }
+ logger.InfoCF("agent", "LLM requested tool calls",
+ map[string]any{
+ "agent_id": agent.ID,
+ "tools": toolNames,
+ "count": len(normalizedToolCalls),
+ "iteration": iteration,
+ })
+
+ // Build assistant message with tool calls
+ assistantMsg := providers.Message{
+ Role: "assistant",
+ Content: response.Content,
+ ReasoningContent: response.ReasoningContent,
+ }
+ for _, tc := range normalizedToolCalls {
+ argumentsJSON, _ := json.Marshal(tc.Arguments)
+ // Copy ExtraContent to ensure thought_signature is persisted for Gemini 3
+ extraContent := tc.ExtraContent
+ thoughtSignature := ""
+ if tc.Function != nil {
+ thoughtSignature = tc.Function.ThoughtSignature
+ }
+
+ assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
+ ID: tc.ID,
+ Type: "function",
+ Name: tc.Name,
+ Function: &providers.FunctionCall{
+ Name: tc.Name,
+ Arguments: string(argumentsJSON),
+ ThoughtSignature: thoughtSignature,
+ },
+ ExtraContent: extraContent,
+ ThoughtSignature: thoughtSignature,
+ })
+ }
+ messages = append(messages, assistantMsg)
+
+ // Save assistant message with tool calls to session
+ agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
+
+ // Execute tool calls
+ for _, tc := range normalizedToolCalls {
+ argsJSON, _ := json.Marshal(tc.Arguments)
+ argsPreview := utils.Truncate(string(argsJSON), 200)
+ logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
+ map[string]any{
+ "agent_id": agent.ID,
+ "tool": tc.Name,
+ "iteration": iteration,
+ })
+
+ // Create async callback for tools that implement AsyncTool
+ // NOTE: Following openclaw's design, async tools do NOT send results directly to users.
+ // Instead, they notify the agent via PublishInbound, and the agent decides
+ // whether to forward the result to the user (in processSystemMessage).
+ asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
+ // Log the async completion but don't send directly to user
+ // The agent will handle user notification via processSystemMessage
+ if !result.Silent && result.ForUser != "" {
+ logger.InfoCF("agent", "Async tool completed, agent will handle notification",
+ map[string]any{
+ "tool": tc.Name,
+ "content_len": len(result.ForUser),
+ })
+ }
+ }
+
+ toolStart := time.Now()
+ toolResult := agent.Tools.ExecuteWithContext(
+ ctx,
+ tc.Name,
+ tc.Arguments,
+ opts.Channel,
+ opts.ChatID,
+ asyncCallback,
+ )
+ toolDuration := time.Since(toolStart)
+
+ // Record tool call for post-LLM processors.
+ record := ToolCallRecord{Name: tc.Name, Duration: toolDuration}
+ if toolResult.Err != nil {
+ record.Error = toolResult.Err.Error()
+ }
+ toolRecords = append(toolRecords, record)
+
+ // Send ForUser content to user immediately if not Silent
+ if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
+ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
+ Channel: opts.Channel,
+ ChatID: opts.ChatID,
+ Content: toolResult.ForUser,
+ })
+ logger.DebugCF("agent", "Sent tool result to user",
+ map[string]any{
+ "tool": tc.Name,
+ "content_len": len(toolResult.ForUser),
+ })
+ }
+
+ // If tool returned media refs, publish them as outbound media
+ if len(toolResult.Media) > 0 && opts.SendResponse {
+ parts := make([]bus.MediaPart, 0, len(toolResult.Media))
+ for _, ref := range toolResult.Media {
+ part := bus.MediaPart{Ref: ref}
+ // Populate metadata from MediaStore when available
+ if al.mediaStore != nil {
+ if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
+ part.Filename = meta.Filename
+ part.ContentType = meta.ContentType
+ part.Type = inferMediaType(meta.Filename, meta.ContentType)
+ }
+ }
+ parts = append(parts, part)
+ }
+ al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{
+ Channel: opts.Channel,
+ ChatID: opts.ChatID,
+ Parts: parts,
+ })
+ }
+
+ // Determine content for LLM based on tool result
+ contentForLLM := toolResult.ForLLM
+ if contentForLLM == "" && toolResult.Err != nil {
+ contentForLLM = toolResult.Err.Error()
+ }
+
+ toolResultMsg := providers.Message{
+ Role: "tool",
+ Content: contentForLLM,
+ ToolCallID: tc.ID,
+ }
+ messages = append(messages, toolResultMsg)
+
+ // Save tool result message to session
+ agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
+ }
+ }
+
+ return finalContent, iteration, toolRecords, nil
+}
+
+// updateToolContexts updates the context for tools that need channel/chatID info.
+func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
+ // Use ContextualTool interface instead of type assertions
+ if tool, ok := agent.Tools.Get("message"); ok {
+ if mt, ok := tool.(tools.ContextualTool); ok {
+ mt.SetContext(channel, chatID)
+ }
+ }
+ if tool, ok := agent.Tools.Get("spawn"); ok {
+ if st, ok := tool.(tools.ContextualTool); ok {
+ st.SetContext(channel, chatID)
+ }
+ }
+ if tool, ok := agent.Tools.Get("subagent"); ok {
+ if st, ok := tool.(tools.ContextualTool); ok {
+ st.SetContext(channel, chatID)
+ }
+ }
+}
+
+// maybeSummarize triggers summarization if the session history exceeds thresholds.
+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.Debug("Memory threshold reached. Optimizing conversation history...")
+ 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),
+ })
+}
+
+// GetStartupInfo returns information about loaded tools and skills for logging.
+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()
+ info["tools"] = map[string]any{
+ "count": len(toolsList),
+ "names": toolsList,
+ }
+
+ // 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
+}
+
+// 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)
+ if tc.Function != nil {
+ fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 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(fmt.Sprintf("%v", tool.Function.Parameters), 200))
+ }
+ }
+ sb.WriteString("]")
+ return sb.String()
+}
+
+// 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
+}
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
index ed438059f..6f2ba3c3c 100644
--- a/pkg/agent/instance.go
+++ b/pkg/agent/instance.go
@@ -34,6 +34,8 @@ type AgentInstance struct {
Subagents *config.SubagentsConfig
SkillsFilter []string
Candidates []providers.FallbackCandidate
+ Analyser *Analyser // Phase 1: intent/tag analysis
+ Reflector *Reflector // Phase 3: post-LLM processing + slash commands
}
// NewAgentInstance creates an agent instance from config.
@@ -148,6 +150,22 @@ func NewAgentInstance(
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
+ // Initialise optional Phase 1 analyser for intent/tag-based memory retrieval and CoT selection.
+ // Uses GetAnalyserModel() which resolves: analyser_model → pre_llm_model → model_name.
+ var analyser *Analyser
+ var rt *Reflector
+ analyserModel := defaults.GetAnalyserModel()
+ if analyserModel != "" {
+ cotRegistry := NewCotRegistry(workspace)
+ analyser = NewAnalyser(provider, analyserModel, cotRegistry)
+ rt = NewReflector(provider, analyserModel)
+ log.Printf("Analyser + Reflector enabled for agent %s (model: %s)", agentID, analyserModel)
+ } else {
+ // Reflector without LLM processors (just commands + error tracker).
+ rt = NewReflector(nil, "")
+ }
+ rt.SetTools(toolsRegistry)
+
return &AgentInstance{
ID: agentID,
Name: agentName,
@@ -165,6 +183,8 @@ func NewAgentInstance(
Subagents: subagents,
SkillsFilter: skillsFilter,
Candidates: candidates,
+ Analyser: analyser,
+ Reflector: rt,
}
}
diff --git a/pkg/agent/instant_memory.go b/pkg/agent/instant_memory.go
new file mode 100644
index 000000000..223b92d33
--- /dev/null
+++ b/pkg/agent/instant_memory.go
@@ -0,0 +1,252 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package agent
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// ---------------------------------------------------------------------------
+// Instant Memory — dynamic Turn selection for Phase 2 context
+// ---------------------------------------------------------------------------
+
+// InstantMemoryCfg holds tunable parameters for instant-memory assembly.
+type InstantMemoryCfg struct {
+ HighScoreThreshold int // turns with score >= this are always_keep (default: 7)
+ RecentCount int // number of recent turns to include (default: 5)
+ MaxTokenRatio float64 // fraction of contextWindow budget (default: 0.6)
+ ContextWindow int // total context window in tokens
+}
+
+// DefaultInstantMemoryCfg returns a sensible default config.
+func DefaultInstantMemoryCfg(contextWindow int) InstantMemoryCfg {
+ return InstantMemoryCfg{
+ HighScoreThreshold: alwaysKeepThreshold, // 7
+ RecentCount: 5,
+ MaxTokenRatio: 0.6,
+ ContextWindow: contextWindow,
+ }
+}
+
+// BuildInstantMemory assembles the filtered set of historical turns for Phase 2.
+//
+// Selection rules (from design doc):
+//
+// 瞬时记忆 =
+// { Turn | score >= highThreshold } // always_keep
+// ∪ { Turn | tags ∩ currentTags ≠ ∅, score > 0 } // tag-matched
+// ∪ { 最近 M 个 Turn } // recency guarantee
+// → deduplicate by ID
+// → sort by ts ASC
+// → truncate to token budget
+func BuildInstantMemory(
+ store *TurnStore,
+ currentTags []string,
+ channelKey string,
+ cfg InstantMemoryCfg,
+) []TurnRecord {
+ if store == nil {
+ return nil
+ }
+
+ seen := make(map[string]struct{})
+ var all []TurnRecord
+
+ addUnique := func(turns []TurnRecord) {
+ for _, t := range turns {
+ if _, dup := seen[t.ID]; dup {
+ continue
+ }
+ seen[t.ID] = struct{}{}
+ all = append(all, t)
+ }
+ }
+
+ // 1. always_keep: high-score turns.
+ high, err := store.QueryByScore(cfg.HighScoreThreshold)
+ if err != nil {
+ logger.WarnCF("instant_memory", "QueryByScore failed", map[string]any{"error": err.Error()})
+ } else {
+ addUnique(high)
+ }
+
+ // 2. tag-matched turns (score > 0).
+ if len(currentTags) > 0 {
+ tagged, err := store.QueryByTags(currentTags)
+ if err != nil {
+ logger.WarnCF("instant_memory", "QueryByTags failed", map[string]any{"error": err.Error()})
+ } else {
+ addUnique(tagged)
+ }
+ }
+
+ // 3. Recent M turns for continuity.
+ recent, err := store.QueryRecent(channelKey, cfg.RecentCount)
+ if err != nil {
+ logger.WarnCF("instant_memory", "QueryRecent failed", map[string]any{"error": err.Error()})
+ } else {
+ addUnique(recent)
+ }
+
+ // Sort by ts ASC (stable chronological order).
+ sortTurnsByTs(all)
+
+ // Truncate to token budget.
+ maxTokens := int(float64(cfg.ContextWindow) * cfg.MaxTokenRatio)
+ if maxTokens > 0 {
+ all = truncateToTokenBudget(all, maxTokens)
+ }
+
+ logger.DebugCF("instant_memory", "Built instant memory",
+ map[string]any{
+ "total": len(all),
+ "high_score": len(high),
+ "tag_matched": len(currentTags),
+ "recent": len(recent),
+ "max_tokens": maxTokens,
+ })
+
+ return all
+}
+
+// sortTurnsByTs sorts turns in ascending timestamp order (oldest first).
+func sortTurnsByTs(turns []TurnRecord) {
+ // Simple in-place insertion sort — good enough for small N (<100).
+ for i := 1; i < len(turns); i++ {
+ key := turns[i]
+ j := i - 1
+ for j >= 0 && turns[j].Ts > key.Ts {
+ turns[j+1] = turns[j]
+ j--
+ }
+ turns[j+1] = key
+ }
+}
+
+// truncateToTokenBudget trims turns from the oldest end until total tokens fit.
+// Returns a suffix of the sorted slice (preserving newest turns).
+func truncateToTokenBudget(turns []TurnRecord, maxTokens int) []TurnRecord {
+ total := 0
+ for _, t := range turns {
+ total += t.Tokens
+ }
+ if total <= maxTokens {
+ return turns
+ }
+
+ // Drop oldest turns first until we fit.
+ for len(turns) > 0 && total > maxTokens {
+ total -= turns[0].Tokens
+ turns = turns[1:]
+ }
+ return turns
+}
+
+// ---------------------------------------------------------------------------
+// Phase 2 Message Assembly — KV Cache friendly ordering
+// ---------------------------------------------------------------------------
+
+// BuildPhase2Messages constructs the message array for Phase 2 (ExecuteLLM)
+// in KV-cache-friendly order:
+//
+// [system_prompt] ← always cache hit
+// [long_term_memory by tags] ← same tags = cache hit (cache_control: ephemeral)
+// [always_keep turns (score≥7)] ← fixed position, append only → cache hit
+// [tag_matched turns] ← per-turn, ts ASC
+// [recent_M turns] ← rolling window
+// [current_user_message] ← always new
+//
+// Each historical turn is represented as a user/assistant message pair.
+func BuildPhase2Messages(
+ systemPrompt string,
+ longTermMemory string,
+ turns []TurnRecord,
+ userMessage string,
+ highScoreThreshold int,
+) []providers.Message {
+ msgs := make([]providers.Message, 0, 2+len(turns)*2+1)
+
+ // 1. System prompt (always first, stable prefix).
+ msgs = append(msgs, providers.Message{
+ Role: "system",
+ Content: systemPrompt,
+ })
+
+ // 2. Long-term memory (injected as system-adjacent user message).
+ // Mark with CacheControl if present (Anthropic will use it; others ignore).
+ if longTermMemory != "" {
+ msgs = append(msgs, providers.Message{
+ Role: "user",
+ Content: fmt.Sprintf("# Long-term Memory\n\n%s", longTermMemory),
+ })
+ // Need a brief assistant ack to maintain user/assistant alternation.
+ msgs = append(msgs, providers.Message{
+ Role: "assistant",
+ Content: "Understood, I'll use this context.",
+ })
+ }
+
+ // 3. Historical turns in KV-cache-friendly order:
+ // - always_keep first (fixed position)
+ // - then tag_matched + recent (may shift between requests)
+ //
+ // All turns are already sorted by ts ASC from BuildInstantMemory.
+ // We separate them into always_keep vs rest, keeping relative order.
+ var alwaysKeep, rest []TurnRecord
+ for _, t := range turns {
+ if t.Score >= highScoreThreshold {
+ alwaysKeep = append(alwaysKeep, t)
+ } else {
+ rest = append(rest, t)
+ }
+ }
+
+ // Append always_keep turns (cache-stable region).
+ for _, t := range alwaysKeep {
+ msgs = appendTurnMessages(msgs, t)
+ }
+
+ // Append remaining turns (tag-matched + recent, may shift).
+ for _, t := range rest {
+ msgs = appendTurnMessages(msgs, t)
+ }
+
+ // 4. Current user message (always last, always new).
+ msgs = append(msgs, providers.Message{
+ Role: "user",
+ Content: userMessage,
+ })
+
+ return msgs
+}
+
+// appendTurnMessages appends a user/assistant pair for a historical turn.
+func appendTurnMessages(msgs []providers.Message, t TurnRecord) []providers.Message {
+ // Build user message with metadata prefix.
+ var userContent strings.Builder
+ if t.Intent != "" || len(t.Tags) > 0 {
+ fmt.Fprintf(&userContent, "[turn intent=%s tags=%v]\n", t.Intent, t.Tags)
+ }
+ userContent.WriteString(t.UserMsg)
+
+ msgs = append(msgs, providers.Message{
+ Role: "user",
+ Content: userContent.String(),
+ })
+
+ if t.Reply != "" {
+ msgs = append(msgs, providers.Message{
+ Role: "assistant",
+ Content: t.Reply,
+ })
+ }
+
+ return msgs
+}
diff --git a/pkg/agent/instant_memory_test.go b/pkg/agent/instant_memory_test.go
new file mode 100644
index 000000000..a830e6295
--- /dev/null
+++ b/pkg/agent/instant_memory_test.go
@@ -0,0 +1,164 @@
+package agent
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestBuildInstantMemory_BasicAssembly(t *testing.T) {
+ dir := t.TempDir()
+ store, err := NewTurnStore(dir)
+ if err != nil {
+ t.Fatalf("NewTurnStore: %v", err)
+ }
+ defer store.Close()
+
+ now := time.Now().Unix()
+
+ // High-score turn (always_keep).
+ store.Insert(TurnRecord{ID: "t1", Ts: now - 100, Score: 9, ChannelKey: "cli:direct",
+ Intent: "code", Tags: []string{"refactor"}, UserMsg: "refactor it", Reply: strings.Repeat("x", 300)})
+
+ // Low-score irrelevant turn.
+ store.Insert(TurnRecord{ID: "t2", Ts: now - 80, Score: 2, ChannelKey: "cli:direct",
+ Intent: "chat", Tags: []string{"chat"}, UserMsg: "hi", Reply: "hello"})
+
+ // Tag-matched turn, moderate score.
+ store.Insert(TurnRecord{ID: "t3", Ts: now - 60, Score: 5, ChannelKey: "cli:direct",
+ Intent: "task", Tags: []string{"deploy", "ci"}, UserMsg: "deploy staging", Reply: "done"})
+
+ // Recent turns.
+ store.Insert(TurnRecord{ID: "t4", Ts: now - 20, Score: 3, ChannelKey: "cli:direct",
+ Intent: "question", Tags: []string{"api"}, UserMsg: "what's the api?", Reply: "check docs"})
+ store.Insert(TurnRecord{ID: "t5", Ts: now - 10, Score: 4, ChannelKey: "cli:direct",
+ Intent: "task", Tags: []string{"test"}, UserMsg: "run tests", Reply: "all passed"})
+
+ cfg := InstantMemoryCfg{
+ HighScoreThreshold: 7,
+ RecentCount: 3,
+ MaxTokenRatio: 0.6,
+ ContextWindow: 100000,
+ }
+
+ turns := BuildInstantMemory(store, []string{"deploy"}, "cli:direct", cfg)
+
+ // Should include: t1 (high-score), t3 (tag-match "deploy"), t4/t5 (recent 3 → also t3)
+ if len(turns) < 3 {
+ t.Errorf("expected at least 3 turns, got %d", len(turns))
+ for _, tt := range turns {
+ t.Logf(" turn: id=%s score=%d tags=%v", tt.ID, tt.Score, tt.Tags)
+ }
+ }
+
+ // Should be sorted by ts ASC.
+ for i := 1; i < len(turns); i++ {
+ if turns[i].Ts < turns[i-1].Ts {
+ t.Errorf("turns not sorted: turns[%d].Ts=%d < turns[%d].Ts=%d",
+ i, turns[i].Ts, i-1, turns[i-1].Ts)
+ }
+ }
+
+ // t1 (always_keep) must be present.
+ found := false
+ for _, tt := range turns {
+ if tt.ID == "t1" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("expected always_keep turn t1 to be included")
+ }
+
+ // t2 (low-score, no tag match, not recent enough) should be excluded.
+ for _, tt := range turns {
+ if tt.ID == "t2" {
+ t.Error("expected low-score irrelevant turn t2 to be excluded")
+ }
+ }
+}
+
+func TestBuildInstantMemory_NilStore(t *testing.T) {
+ turns := BuildInstantMemory(nil, []string{"deploy"}, "cli:direct", DefaultInstantMemoryCfg(8192))
+ if turns != nil {
+ t.Errorf("expected nil, got %v", turns)
+ }
+}
+
+func TestBuildPhase2Messages_Ordering(t *testing.T) {
+ turns := []TurnRecord{
+ {ID: "t1", Ts: 100, Score: 9, Intent: "code", Tags: []string{"refactor"},
+ UserMsg: "refactor it", Reply: "done refactoring", Tokens: 20},
+ {ID: "t2", Ts: 200, Score: 3, Intent: "question",
+ UserMsg: "what next?", Reply: "do X", Tokens: 10},
+ {ID: "t3", Ts: 300, Score: 8, Intent: "debug", Tags: []string{"deploy"},
+ UserMsg: "fix deploy", Reply: "fixed", Tokens: 10},
+ }
+
+ msgs := BuildPhase2Messages("You are a helpful assistant.", "User prefers Go.", turns, "hello world", 7)
+
+ // Expected order:
+ // [0] system
+ // [1] user (long_term_memory)
+ // [2] assistant (ack)
+ // [3,4] always_keep t1 (user/assistant)
+ // [5,6] always_keep t3 (user/assistant)
+ // [7,8] rest t2 (user/assistant)
+ // [9] current user message
+ if len(msgs) < 5 {
+ t.Fatalf("expected at least 5 messages, got %d", len(msgs))
+ }
+
+ if msgs[0].Role != "system" {
+ t.Errorf("msgs[0].Role = %s, want system", msgs[0].Role)
+ }
+
+ // Last message should be the current user message.
+ last := msgs[len(msgs)-1]
+ if last.Role != "user" || last.Content != "hello world" {
+ t.Errorf("last message = %+v, want user 'hello world'", last)
+ }
+
+ // All messages should alternate user/assistant (after system).
+ for i := 1; i < len(msgs)-1; i++ {
+ expected := "user"
+ if i%2 == 0 {
+ expected = "assistant"
+ }
+ if msgs[i].Role != expected {
+ t.Errorf("msgs[%d].Role = %s, want %s (content: %s)",
+ i, msgs[i].Role, expected, msgs[i].Content[:min(len(msgs[i].Content), 30)])
+ }
+ }
+}
+
+func TestBuildPhase2Messages_NoHistory(t *testing.T) {
+ msgs := BuildPhase2Messages("sys prompt", "", nil, "hi", 7)
+
+ // Should have: system + user message = 2
+ if len(msgs) != 2 {
+ t.Errorf("expected 2 messages, got %d", len(msgs))
+ }
+ if msgs[0].Role != "system" || msgs[1].Role != "user" {
+ t.Errorf("unexpected roles: %s, %s", msgs[0].Role, msgs[1].Role)
+ }
+}
+
+func TestTruncateToTokenBudget(t *testing.T) {
+ turns := []TurnRecord{
+ {ID: "a", Tokens: 100},
+ {ID: "b", Tokens: 200},
+ {ID: "c", Tokens: 300},
+ {ID: "d", Tokens: 150},
+ }
+ result := truncateToTokenBudget(turns, 500)
+ // Total = 750, budget = 500. Drop oldest first.
+ // Drop "a" (100) → 650, still over.
+ // Drop "b" (200) → 450, fits.
+ if len(result) != 2 {
+ t.Errorf("expected 2 turns, got %d", len(result))
+ }
+ if result[0].ID != "c" || result[1].ID != "d" {
+ t.Errorf("expected [c, d], got [%s, %s]", result[0].ID, result[1].ID)
+ }
+}
diff --git a/pkg/agent/integration_test.go b/pkg/agent/integration_test.go
new file mode 100644
index 000000000..46a2d8876
--- /dev/null
+++ b/pkg/agent/integration_test.go
@@ -0,0 +1,239 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package agent
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+)
+
+// ---------------------------------------------------------------------------
+// M5 Integration — TurnStore → BuildInstantMemory → BuildPhase2Messages
+// ---------------------------------------------------------------------------
+
+// TestInstantMemoryIntegration_EndToEnd inserts realistic turns into a real
+// TurnStore, runs BuildInstantMemory with tag filtering, then assembles Phase 2
+// messages and validates:
+// - correct message ordering (system → memory → always_keep → rest → user)
+// - strict user/assistant role alternation after the system message
+// - always_keep turns appear before lower-score turns
+// - the current user message is always last
+func TestInstantMemoryIntegration_EndToEnd(t *testing.T) {
+ dir := t.TempDir()
+ store, err := NewTurnStore(dir)
+ if err != nil {
+ t.Fatalf("NewTurnStore: %v", err)
+ }
+ defer store.Close()
+
+ now := time.Now().Unix()
+
+ // Seed realistic turns.
+ turns := []TurnRecord{
+ {ID: "turn-1", Ts: now - 3600, Score: 10, ChannelKey: "cli:main",
+ Intent: "task", Tags: []string{"deploy", "ci"},
+ UserMsg: "Deploy to staging", Reply: "Deployed successfully to staging environment.",
+ Tokens: 50},
+ {ID: "turn-2", Ts: now - 3000, Score: 2, ChannelKey: "cli:main",
+ Intent: "chat", Tags: []string{"chat"},
+ UserMsg: "hi", Reply: "Hello!",
+ Tokens: 10},
+ {ID: "turn-3", Ts: now - 2000, Score: 6, ChannelKey: "cli:main",
+ Intent: "code", Tags: []string{"golang", "refactor"},
+ UserMsg: "Refactor the handler", Reply: "Done, split into 3 functions.",
+ Tokens: 40},
+ {ID: "turn-4", Ts: now - 500, Score: 4, ChannelKey: "cli:main",
+ Intent: "question", Tags: []string{"api"},
+ UserMsg: "What's the endpoint for users?", Reply: "GET /api/v1/users",
+ Tokens: 20},
+ {ID: "turn-5", Ts: now - 100, Score: 3, ChannelKey: "cli:main",
+ Intent: "task", Tags: []string{"test"},
+ UserMsg: "Run all tests", Reply: "All 42 tests passed.",
+ Tokens: 15},
+ }
+ for _, tr := range turns {
+ if err := store.Insert(tr); err != nil {
+ t.Fatalf("Insert(%s): %v", tr.ID, err)
+ }
+ }
+
+ // Query with tags=["deploy"] — should get turn-1 (always_keep + tag match),
+ // turn-3/4/5 (recent 3). turn-2 is low score, no tag match, not recent.
+ cfg := InstantMemoryCfg{
+ HighScoreThreshold: 7,
+ RecentCount: 3,
+ MaxTokenRatio: 0.6,
+ ContextWindow: 100000,
+ }
+ selected := BuildInstantMemory(store, []string{"deploy"}, "cli:main", cfg)
+
+ // Verify turn-1 is selected (always_keep).
+ hasT1 := false
+ for _, s := range selected {
+ if s.ID == "turn-1" {
+ hasT1 = true
+ }
+ }
+ if !hasT1 {
+ t.Error("expected always_keep turn-1 to be selected")
+ }
+
+ // Verify turn-2 is NOT selected.
+ for _, s := range selected {
+ if s.ID == "turn-2" {
+ t.Error("expected low-score turn-2 to be excluded")
+ }
+ }
+
+ // Assemble Phase 2 messages.
+ systemPrompt := "You are a helpful assistant.\n\n## Runtime\nlinux amd64"
+ longTermMemory := "User prefers Go. User's name is Alice."
+ currentMsg := "Deploy to production now"
+
+ msgs := BuildPhase2Messages(systemPrompt, longTermMemory, selected, currentMsg, cfg.HighScoreThreshold)
+
+ // --- Validate message structure ---
+
+ // 1. First message is system.
+ if msgs[0].Role != "system" {
+ t.Fatalf("msgs[0].Role = %s, want system", msgs[0].Role)
+ }
+ if !strings.Contains(msgs[0].Content, "helpful assistant") {
+ t.Error("system message should contain prompt text")
+ }
+
+ // 2. Last message is current user message.
+ last := msgs[len(msgs)-1]
+ if last.Role != "user" || last.Content != currentMsg {
+ t.Errorf("last message = role=%s content=%q, want user %q", last.Role, last.Content, currentMsg)
+ }
+
+ // 3. Role alternation: after system, messages must alternate user/assistant.
+ for i := 1; i < len(msgs); i++ {
+ expectedRole := "user"
+ if i%2 == 0 {
+ expectedRole = "assistant"
+ }
+ if msgs[i].Role != expectedRole {
+ t.Errorf("msgs[%d].Role = %s, want %s (content: %.50s...)",
+ i, msgs[i].Role, expectedRole, msgs[i].Content)
+ }
+ }
+
+ // 4. Long-term memory should be in msgs[1] (user role).
+ if !strings.Contains(msgs[1].Content, "Long-term Memory") {
+ t.Error("msgs[1] should contain long-term memory")
+ }
+
+ // 5. Always_keep turns (score >= 7) should appear before lower-score turns.
+ alwaysKeepEnd := -1
+ restStart := len(msgs)
+ for i := 3; i < len(msgs)-1; i += 2 { // user messages from turns, skip system+memory+ack
+ content := msgs[i].Content
+ // Check if this is an always_keep turn by looking for turn-1 content.
+ if strings.Contains(content, "Deploy to staging") {
+ alwaysKeepEnd = i
+ }
+ }
+ for i := 3; i < len(msgs)-1; i += 2 {
+ content := msgs[i].Content
+ // First non-always-keep turn.
+ if !strings.Contains(content, "Deploy to staging") && !strings.Contains(content, "Long-term Memory") {
+ restStart = i
+ break
+ }
+ }
+ if alwaysKeepEnd >= 0 && restStart < len(msgs) && alwaysKeepEnd > restStart {
+ t.Errorf("always_keep turns should come before rest: alwaysKeepEnd=%d, restStart=%d",
+ alwaysKeepEnd, restStart)
+ }
+
+ t.Logf("Phase 2 assembled %d messages from %d selected turns", len(msgs), len(selected))
+ for i, m := range msgs {
+ preview := m.Content
+ if len(preview) > 60 {
+ preview = preview[:60] + "..."
+ }
+ t.Logf(" [%d] role=%-10s content=%q", i, m.Role, preview)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// M4 Integration — MemoryDigest runOnce
+// ---------------------------------------------------------------------------
+
+// TestMemoryDigestIntegration_RunOnce inserts pending TurnRecords, runs
+// MemoryDigest.runOnce with a mock LLM, and verifies:
+// - TurnRecords are transitioned from "pending" to "processed"
+// - MemoryStore receives new entries from the LLM extraction
+func TestMemoryDigestIntegration_RunOnce(t *testing.T) {
+ dir := t.TempDir()
+
+ turnStore, err := NewTurnStore(dir)
+ if err != nil {
+ t.Fatalf("NewTurnStore: %v", err)
+ }
+ defer turnStore.Close()
+
+ memStore := NewMemoryStore(dir)
+ defer memStore.Close()
+
+ now := time.Now().Unix()
+
+ // Insert pending turns.
+ for i := 0; i < 3; i++ {
+ tr := TurnRecord{
+ ID: "digest-" + string(rune('a'+i)),
+ Ts: now - int64(300*(3-i)),
+ Score: 5,
+ ChannelKey: "cli:main",
+ Intent: "task",
+ Tags: []string{"golang"},
+ UserMsg: "Do task " + string(rune('A'+i)),
+ Reply: "Done with task " + string(rune('A'+i)),
+ Tokens: 30,
+ Status: "pending",
+ }
+ if err := turnStore.Insert(tr); err != nil {
+ t.Fatalf("Insert: %v", err)
+ }
+ }
+
+ // Verify pending.
+ pending, _ := turnStore.QueryPending(50)
+ if len(pending) != 3 {
+ t.Fatalf("expected 3 pending, got %d", len(pending))
+ }
+
+ // Create a mock provider that returns a memory extraction response.
+ mp := &mockLLMProvider{
+ response: `{"memories": [{"content": "User worked on Go tasks A, B, C", "tags": ["golang", "task"]}]}`,
+ }
+
+ // Create and run MemoryDigest.
+ worker := NewMemoryDigestWorker(turnStore, memStore, mp, "test-model")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ worker.runOnce(ctx)
+
+ // Verify turns are now processed.
+ pendingAfter, _ := turnStore.QueryPending(50)
+ if len(pendingAfter) != 0 {
+ t.Errorf("expected 0 pending after runOnce, got %d", len(pendingAfter))
+ }
+
+ // Verify memory store has entries.
+ memCtx := memStore.GetMemoryContext()
+ if memCtx == "" {
+ t.Error("expected MemoryStore to have entries after digest, got empty")
+ } else {
+ t.Logf("MemoryStore context after digest:\n%s", memCtx)
+ }
+}
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 00b0f096a..a2a729c2c 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -8,15 +8,12 @@ package agent
import (
"context"
- "encoding/json"
- "errors"
"fmt"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
- "unicode/utf8"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
@@ -38,10 +35,15 @@ type AgentLoop struct {
registry *AgentRegistry
state *state.Manager
running atomic.Bool
+ msgSeqId atomic.Uint64
summarizing sync.Map
fallback *providers.FallbackChain
channelManager *channels.Manager
mediaStore media.MediaStore
+ // Phase 3 infrastructure (M1-M4)
+ turnStore *TurnStore // per-workspace turns.db
+ activeCtx *ActiveContextStore // per channel:chatID context
+ memoryDigest *MemoryDigestWorker // background memory distillation
}
// processOptions configures how a message is processed
@@ -54,6 +56,7 @@ type processOptions struct {
EnableSummary bool // Whether to trigger summarization
SendResponse bool // Whether to send response via bus
NoHistory bool // If true, don't load session history (for heartbeat)
+ MsgSeqId uint64 // Global message sequence number
}
const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
@@ -75,13 +78,32 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
stateManager = state.NewManager(defaultAgent.Workspace)
}
+ // Initialise Phase 3 infrastructure.
+ activeCtxStore := NewActiveContextStore()
+ var ts *TurnStore
+ var digestWorker *MemoryDigestWorker
+ if defaultAgent != nil {
+ var tsErr error
+ ts, tsErr = NewTurnStore(defaultAgent.Workspace)
+ if tsErr != nil {
+ logger.ErrorCF("agent", "Failed to create TurnStore", map[string]any{"error": tsErr.Error()})
+ } else {
+ mem := defaultAgent.ContextBuilder.GetMemory()
+ digestModel := cfg.Agents.Defaults.GetDigestModel()
+ digestWorker = NewMemoryDigestWorker(ts, mem, provider, digestModel)
+ }
+ }
+
return &AgentLoop{
- bus: msgBus,
- cfg: cfg,
- registry: registry,
- state: stateManager,
- summarizing: sync.Map{},
- fallback: fallbackChain,
+ bus: msgBus,
+ cfg: cfg,
+ registry: registry,
+ state: stateManager,
+ summarizing: sync.Map{},
+ fallback: fallbackChain,
+ turnStore: ts,
+ activeCtx: activeCtxStore,
+ memoryDigest: digestWorker,
}
}
@@ -170,6 +192,22 @@ func registerSharedTools(
func (al *AgentLoop) Run(ctx context.Context) error {
al.running.Store(true)
+ // Load persisted Active Context.
+ if al.activeCtx != nil {
+ defaultAgent := al.registry.GetDefaultAgent()
+ if defaultAgent != nil {
+ acPath := activeContextPath(defaultAgent.Workspace)
+ if err := al.activeCtx.Load(acPath); err != nil {
+ logger.WarnCF("agent", "Failed to load active context", map[string]any{"error": err.Error()})
+ }
+ }
+ }
+
+ // Start MemoryDigest background worker.
+ if al.memoryDigest != nil {
+ al.memoryDigest.Start(ctx)
+ }
+
for al.running.Load() {
select {
case <-ctx.Done():
@@ -243,6 +281,22 @@ func (al *AgentLoop) Run(ctx context.Context) error {
func (al *AgentLoop) Stop() {
al.running.Store(false)
+ // Flush Active Context to disk.
+ if al.activeCtx != nil {
+ defaultAgent := al.registry.GetDefaultAgent()
+ if defaultAgent != nil {
+ acPath := activeContextPath(defaultAgent.Workspace)
+ if err := al.activeCtx.Flush(acPath); err != nil {
+ logger.WarnCF("agent", "Failed to flush active context", map[string]any{"error": err.Error()})
+ }
+ }
+ }
+ // Close TurnStore.
+ if al.turnStore != nil {
+ if err := al.turnStore.Close(); err != nil {
+ logger.WarnCF("agent", "Failed to close turn store", map[string]any{"error": err.Error()})
+ }
+ }
}
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
@@ -255,6 +309,12 @@ func (al *AgentLoop) RegisterTool(tool tools.Tool) {
func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
al.channelManager = cm
+ // Wire agent info into all agent Runtimes so /show, /list, /switch work.
+ for _, id := range al.registry.ListAgentIDs() {
+ if agent, ok := al.registry.GetAgent(id); ok && agent != nil && agent.Reflector != nil {
+ agent.Reflector.SetAgentInfo(al.registry, cm)
+ }
+ }
}
// SetMediaStore injects a MediaStore for media lifecycle management.
@@ -369,9 +429,11 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return al.processSystemMessage(ctx, msg)
}
- // Check for commands
- if response, handled := al.handleCommand(ctx, msg); handled {
- return response, nil
+ // Check for runtime commands (e.g. /memory, /cot, /runtime, /show, /list, /switch).
+ if agent := al.registry.GetDefaultAgent(); agent != nil && agent.Reflector != nil {
+ if response, handled := agent.Reflector.HandleCommand(msg.Content, agent.ContextBuilder.GetMemory()); handled {
+ return response, nil
+ }
}
// Route to determine agent and session key
@@ -484,6 +546,9 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
// runAgentLoop is the core message processing logic.
func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opts processOptions) (string, error) {
+ seq := al.msgSeqId.Add(1)
+ opts.MsgSeqId = seq
+
// 0. Record last channel for heartbeat notifications (skip internal channels)
if opts.Channel != "" && opts.ChatID != "" {
// Don't record internal channels (cli, system, subagent)
@@ -498,27 +563,131 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 1. Update tool contexts
al.updateToolContexts(agent, opts.Channel, opts.ChatID)
- // 2. Build messages (skip history for heartbeat)
- var history []providers.Message
- var summary string
- if !opts.NoHistory {
- history = agent.Sessions.GetHistory(opts.SessionKey)
- summary = agent.Sessions.GetSummary(opts.SessionKey)
- }
- messages := agent.ContextBuilder.BuildMessages(
- history,
- summary,
- opts.UserMessage,
- nil,
- opts.Channel,
- opts.ChatID,
- )
+ // 2. Analyse intent + build Phase 2 messages.
+ //
+ // Two paths:
+ // A. Instant Memory (when Analyser + TurnStore are ready):
+ // - Phase 1 analyses intent/tags
+ // - BuildInstantMemory selects relevant historical turns from TurnStore
+ // - BuildPhase2Messages assembles KV cache friendly message array
+ // B. Legacy SessionManager (fallback):
+ // - Uses Session history directly via ContextBuilder.BuildMessages
+ //
+ var analyseResult AnalyseResult
+ var messages []providers.Message
+ channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
- // 3. Save user message to session
+ useInstantMemory := agent.Analyser != nil && al.turnStore != nil && !opts.NoHistory && opts.UserMessage != ""
+
+ if useInstantMemory {
+ // --- Path A: Instant Memory ---
+
+ // Phase 1: analyse intent + tags.
+ var actCtx *ActiveContext
+ if al.activeCtx != nil {
+ actCtx = al.activeCtx.Get(channelKey)
+ }
+ analyseResult = agent.Analyser.Analyse(ctx, opts.UserMessage, agent.ContextBuilder.GetMemory(), actCtx)
+
+ // Build system prompt from ContextBuilder (cached static + dynamic context).
+ staticPrompt := agent.ContextBuilder.BuildSystemPromptWithCache()
+ dynamicCtx := agent.ContextBuilder.buildDynamicContext(opts.Channel, opts.ChatID)
+ systemPrompt := staticPrompt + "\n\n---\n\n" + dynamicCtx
+
+ // Enrich system prompt with CoT strategy.
+ if analyseResult.CotPrompt != "" {
+ systemPrompt += "\n\n---\n\n## Thinking Strategy\n\n" + analyseResult.CotPrompt
+ }
+
+ // Select relevant turns from TurnStore.
+ cfg := DefaultInstantMemoryCfg(agent.ContextWindow)
+ instantTurns := BuildInstantMemory(al.turnStore, analyseResult.Tags, channelKey, cfg)
+
+ // Get long-term memory by tags.
+ longTermMemory := analyseResult.MemoryContext
+
+ // Assemble Phase 2 messages in KV cache friendly order.
+ messages = BuildPhase2Messages(
+ systemPrompt,
+ longTermMemory,
+ instantTurns,
+ opts.UserMessage,
+ cfg.HighScoreThreshold,
+ )
+
+ logger.InfoCF("agent", "Phase 2 messages built via instant memory",
+ map[string]any{
+ "seq": seq,
+ "agent_id": agent.ID,
+ "intent": analyseResult.Intent,
+ "tags": analyseResult.Tags,
+ "instant_turns": len(instantTurns),
+ "total_messages": len(messages),
+ "has_cot": analyseResult.CotPrompt != "",
+ "has_memories": longTermMemory != "",
+ })
+ } else {
+ // --- Path B: Legacy SessionManager ---
+ var history []providers.Message
+ var summary string
+ if !opts.NoHistory {
+ history = agent.Sessions.GetHistory(opts.SessionKey)
+ summary = agent.Sessions.GetSummary(opts.SessionKey)
+ }
+ messages = agent.ContextBuilder.BuildMessages(
+ history,
+ summary,
+ opts.UserMessage,
+ nil,
+ opts.Channel,
+ opts.ChatID,
+ )
+
+ // Optional Phase 1 enrichment (when Analyser exists but TurnStore not ready).
+ if agent.Analyser != nil && !opts.NoHistory && opts.UserMessage != "" {
+ var actCtx *ActiveContext
+ if al.activeCtx != nil {
+ actCtx = al.activeCtx.Get(channelKey)
+ }
+ analyseResult = agent.Analyser.Analyse(ctx, opts.UserMessage, agent.ContextBuilder.GetMemory(), actCtx)
+
+ var enrichment strings.Builder
+ if analyseResult.CotPrompt != "" {
+ enrichment.WriteString("\n\n---\n\n## Thinking Strategy\n\n")
+ enrichment.WriteString(analyseResult.CotPrompt)
+ }
+ if analyseResult.MemoryContext != "" {
+ enrichment.WriteString("\n\n---\n\n# Contextual Memories (pre-analysed)\n\n")
+ enrichment.WriteString(analyseResult.MemoryContext)
+ }
+
+ if enrichment.Len() > 0 && len(messages) > 0 && messages[0].Role == "system" {
+ messages[0].Content += enrichment.String()
+ if len(messages[0].SystemParts) > 0 {
+ enrichBlock := providers.ContentBlock{
+ Type: "text",
+ Text: enrichment.String(),
+ }
+ messages[0].SystemParts = append(messages[0].SystemParts, enrichBlock)
+ }
+ logger.InfoCF("agent", "Pre-LLM enriched context (legacy path)",
+ map[string]any{
+ "seq": seq,
+ "agent_id": agent.ID,
+ "intent": analyseResult.Intent,
+ "tags": analyseResult.Tags,
+ "has_memories": analyseResult.MemoryContext != "",
+ "has_cot": analyseResult.CotPrompt != "",
+ })
+ }
+ }
+ }
+
+ // 3. Save user message to session (kept for /memory, /show debug commands).
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
// 4. Run LLM iteration loop
- finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
+ finalContent, iteration, toolRecords, err := al.runLLMIteration(ctx, agent, messages, opts)
if err != nil {
return "", err
}
@@ -535,12 +704,36 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
agent.Sessions.Save(opts.SessionKey)
+ // Build the runtime input used by Phase 3 stages.
+ runtimeInput := RuntimeInput{
+ UserMessage: opts.UserMessage,
+ AssistantReply: finalContent,
+ Intent: analyseResult.Intent,
+ Tags: analyseResult.Tags,
+ CotPrompt: analyseResult.CotPrompt,
+ ToolCalls: toolRecords,
+ Iterations: iteration,
+ ChannelKey: channelKey,
+ }
+
+ // 6.5. Phase 3 — Synchronous part (< 2ms): score + Active Context update.
+ // MUST run before PublishOutbound so the next turn's Phase 1 sees fresh context.
+ if agent.Reflector != nil && opts.UserMessage != "" {
+ score := agent.Reflector.SyncPhase3(runtimeInput)
+ runtimeInput.Score = score
+
+ // Update Active Context for this channel.
+ if al.activeCtx != nil {
+ al.activeCtx.Update(channelKey, runtimeInput)
+ }
+ }
+
// 7. Optional: summarization
if opts.EnableSummary {
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
}
- // 8. Optional: send response via bus
+ // 8. Optional: send response via bus (user receives reply here).
if opts.SendResponse {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
@@ -549,10 +742,17 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
})
}
+ // 6.6. Phase 3 — Async part: persist TurnRecord, run processors.
+ // Runs AFTER PublishOutbound to not delay the user response.
+ if agent.Reflector != nil && opts.UserMessage != "" {
+ agent.Reflector.AsyncPhase3(runtimeInput, agent.ContextBuilder.GetMemory(), al.turnStore, al.activeCtx)
+ }
+
// 9. Log response
responsePreview := utils.Truncate(finalContent, 120)
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
map[string]any{
+ "seq": seq,
"agent_id": agent.ID,
"session_key": opts.SessionKey,
"iterations": iteration,
@@ -562,547 +762,6 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
return finalContent, nil
}
-func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) {
- if al.channelManager == nil {
- return ""
- }
- if ch, ok := al.channelManager.GetChannel(channelName); ok {
- return ch.ReasoningChannelID()
- }
- return ""
-}
-
-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(),
- })
- }
- }
-}
-
-// runLLMIteration executes the LLM call loop with tool handling.
-func (al *AgentLoop) runLLMIteration(
- ctx context.Context,
- agent *AgentInstance,
- messages []providers.Message,
- opts processOptions,
-) (string, int, error) {
- iteration := 0
- var finalContent string
-
- for iteration < agent.MaxIterations {
- iteration++
-
- logger.DebugCF("agent", "LLM iteration",
- map[string]any{
- "agent_id": agent.ID,
- "iteration": iteration,
- "max": agent.MaxIterations,
- })
-
- // Build tool definitions
- providerToolDefs := agent.Tools.ToProviderDefs()
-
- // Log LLM request details
- logger.DebugCF("agent", "LLM request",
- map[string]any{
- "agent_id": agent.ID,
- "iteration": iteration,
- "model": agent.Model,
- "messages_count": len(messages),
- "tools_count": len(providerToolDefs),
- "max_tokens": agent.MaxTokens,
- "temperature": agent.Temperature,
- "system_prompt_len": len(messages[0].Content),
- })
-
- // Log full messages (detailed)
- logger.DebugCF("agent", "Full LLM request",
- map[string]any{
- "iteration": iteration,
- "messages_json": formatMessagesForLog(messages),
- "tools_json": formatToolsForLog(providerToolDefs),
- })
-
- // Call LLM with fallback chain if candidates are configured.
- var response *providers.LLMResponse
- var err error
-
- callLLM := func() (*providers.LLMResponse, error) {
- if len(agent.Candidates) > 1 && al.fallback != nil {
- fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
- func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
- return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{
- "max_tokens": agent.MaxTokens,
- "temperature": agent.Temperature,
- "prompt_cache_key": agent.ID,
- })
- },
- )
- if fbErr != nil {
- return nil, fbErr
- }
- if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
- logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
- fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
- map[string]any{"agent_id": agent.ID, "iteration": iteration})
- }
- return fbResult.Response, nil
- }
- return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
- "max_tokens": agent.MaxTokens,
- "temperature": agent.Temperature,
- "prompt_cache_key": agent.ID,
- })
- }
-
- // Retry loop for context/token errors
- maxRetries := 2
- for retry := 0; retry <= maxRetries; retry++ {
- response, err = callLLM()
- if err == nil {
- break
- }
-
- errMsg := strings.ToLower(err.Error())
-
- // Check if this is a network/HTTP timeout — not a context window error.
- isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
- strings.Contains(errMsg, "deadline exceeded") ||
- strings.Contains(errMsg, "client.timeout") ||
- strings.Contains(errMsg, "timed out") ||
- strings.Contains(errMsg, "timeout exceeded")
-
- // Detect real context window / token limit errors, excluding network timeouts.
- isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
- strings.Contains(errMsg, "context window") ||
- strings.Contains(errMsg, "maximum context length") ||
- strings.Contains(errMsg, "token limit") ||
- strings.Contains(errMsg, "too many tokens") ||
- strings.Contains(errMsg, "max_tokens") ||
- strings.Contains(errMsg, "invalidparameter") ||
- strings.Contains(errMsg, "prompt is too long") ||
- strings.Contains(errMsg, "request too large"))
-
- if isTimeoutError && retry < maxRetries {
- backoff := time.Duration(retry+1) * 5 * time.Second
- logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{
- "error": err.Error(),
- "retry": retry,
- "backoff": backoff.String(),
- })
- time.Sleep(backoff)
- continue
- }
-
- if isContextError && retry < maxRetries {
- logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
- "error": err.Error(),
- "retry": retry,
- })
-
- if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
- al.bus.PublishOutbound(ctx, bus.OutboundMessage{
- Channel: opts.Channel,
- ChatID: opts.ChatID,
- Content: "Context window exceeded. Compressing history and retrying...",
- })
- }
-
- al.forceCompression(agent, opts.SessionKey)
- newHistory := agent.Sessions.GetHistory(opts.SessionKey)
- newSummary := agent.Sessions.GetSummary(opts.SessionKey)
- messages = agent.ContextBuilder.BuildMessages(
- newHistory, newSummary, "",
- nil, opts.Channel, opts.ChatID,
- )
- continue
- }
- break
- }
-
- if err != nil {
- logger.ErrorCF("agent", "LLM call failed",
- map[string]any{
- "agent_id": agent.ID,
- "iteration": iteration,
- "error": err.Error(),
- })
- return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
- }
-
- go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel))
-
- logger.DebugCF("agent", "LLM response",
- map[string]any{
- "agent_id": agent.ID,
- "iteration": iteration,
- "content_chars": len(response.Content),
- "tool_calls": len(response.ToolCalls),
- "reasoning": response.Reasoning,
- "target_channel": al.targetReasoningChannelID(opts.Channel),
- "channel": opts.Channel,
- })
- // Check if no tool calls - we're done
- if len(response.ToolCalls) == 0 {
- finalContent = response.Content
- logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
- map[string]any{
- "agent_id": agent.ID,
- "iteration": iteration,
- "content_chars": len(finalContent),
- })
- break
- }
-
- normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
- for _, tc := range response.ToolCalls {
- normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))
- }
-
- // Log tool calls
- toolNames := make([]string, 0, len(normalizedToolCalls))
- for _, tc := range normalizedToolCalls {
- toolNames = append(toolNames, tc.Name)
- }
- logger.InfoCF("agent", "LLM requested tool calls",
- map[string]any{
- "agent_id": agent.ID,
- "tools": toolNames,
- "count": len(normalizedToolCalls),
- "iteration": iteration,
- })
-
- // Build assistant message with tool calls
- assistantMsg := providers.Message{
- Role: "assistant",
- Content: response.Content,
- ReasoningContent: response.ReasoningContent,
- }
- for _, tc := range normalizedToolCalls {
- argumentsJSON, _ := json.Marshal(tc.Arguments)
- // Copy ExtraContent to ensure thought_signature is persisted for Gemini 3
- extraContent := tc.ExtraContent
- thoughtSignature := ""
- if tc.Function != nil {
- thoughtSignature = tc.Function.ThoughtSignature
- }
-
- assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
- ID: tc.ID,
- Type: "function",
- Name: tc.Name,
- Function: &providers.FunctionCall{
- Name: tc.Name,
- Arguments: string(argumentsJSON),
- ThoughtSignature: thoughtSignature,
- },
- ExtraContent: extraContent,
- ThoughtSignature: thoughtSignature,
- })
- }
- messages = append(messages, assistantMsg)
-
- // Save assistant message with tool calls to session
- agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
-
- // Execute tool calls
- for _, tc := range normalizedToolCalls {
- argsJSON, _ := json.Marshal(tc.Arguments)
- argsPreview := utils.Truncate(string(argsJSON), 200)
- logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
- map[string]any{
- "agent_id": agent.ID,
- "tool": tc.Name,
- "iteration": iteration,
- })
-
- // Create async callback for tools that implement AsyncTool
- // NOTE: Following openclaw's design, async tools do NOT send results directly to users.
- // Instead, they notify the agent via PublishInbound, and the agent decides
- // whether to forward the result to the user (in processSystemMessage).
- asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
- // Log the async completion but don't send directly to user
- // The agent will handle user notification via processSystemMessage
- if !result.Silent && result.ForUser != "" {
- logger.InfoCF("agent", "Async tool completed, agent will handle notification",
- map[string]any{
- "tool": tc.Name,
- "content_len": len(result.ForUser),
- })
- }
- }
-
- toolResult := agent.Tools.ExecuteWithContext(
- ctx,
- tc.Name,
- tc.Arguments,
- opts.Channel,
- opts.ChatID,
- asyncCallback,
- )
-
- // Send ForUser content to user immediately if not Silent
- if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
- al.bus.PublishOutbound(ctx, bus.OutboundMessage{
- Channel: opts.Channel,
- ChatID: opts.ChatID,
- Content: toolResult.ForUser,
- })
- logger.DebugCF("agent", "Sent tool result to user",
- map[string]any{
- "tool": tc.Name,
- "content_len": len(toolResult.ForUser),
- })
- }
-
- // If tool returned media refs, publish them as outbound media
- if len(toolResult.Media) > 0 && opts.SendResponse {
- parts := make([]bus.MediaPart, 0, len(toolResult.Media))
- for _, ref := range toolResult.Media {
- part := bus.MediaPart{Ref: ref}
- // Populate metadata from MediaStore when available
- if al.mediaStore != nil {
- if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
- part.Filename = meta.Filename
- part.ContentType = meta.ContentType
- part.Type = inferMediaType(meta.Filename, meta.ContentType)
- }
- }
- parts = append(parts, part)
- }
- al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{
- Channel: opts.Channel,
- ChatID: opts.ChatID,
- Parts: parts,
- })
- }
-
- // Determine content for LLM based on tool result
- contentForLLM := toolResult.ForLLM
- if contentForLLM == "" && toolResult.Err != nil {
- contentForLLM = toolResult.Err.Error()
- }
-
- toolResultMsg := providers.Message{
- Role: "tool",
- Content: contentForLLM,
- ToolCallID: tc.ID,
- }
- messages = append(messages, toolResultMsg)
-
- // Save tool result message to session
- agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
- }
- }
-
- return finalContent, iteration, nil
-}
-
-// updateToolContexts updates the context for tools that need channel/chatID info.
-func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
- // Use ContextualTool interface instead of type assertions
- if tool, ok := agent.Tools.Get("message"); ok {
- if mt, ok := tool.(tools.ContextualTool); ok {
- mt.SetContext(channel, chatID)
- }
- }
- if tool, ok := agent.Tools.Get("spawn"); ok {
- if st, ok := tool.(tools.ContextualTool); ok {
- st.SetContext(channel, chatID)
- }
- }
- if tool, ok := agent.Tools.Get("subagent"); ok {
- if st, ok := tool.(tools.ContextualTool); ok {
- st.SetContext(channel, chatID)
- }
- }
-}
-
-// maybeSummarize triggers summarization if the session history exceeds thresholds.
-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.Debug("Memory threshold reached. Optimizing conversation history...")
- 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),
- })
-}
-
-// GetStartupInfo returns information about loaded tools and skills for logging.
-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()
- info["tools"] = map[string]any{
- "count": len(toolsList),
- "names": toolsList,
- }
-
- // 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
-}
-
-// 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)
- if tc.Function != nil {
- fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 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(fmt.Sprintf("%v", tool.Function.Parameters), 200))
- }
- }
- sb.WriteString("]")
- return sb.String()
-}
// summarizeSession summarizes the conversation history for a session.
func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
@@ -1223,107 +882,8 @@ func (al *AgentLoop) summarizeBatch(
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
-}
-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 ", 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
- }
- }
-
- return "", false
-}
// extractPeer extracts the routing peer from the inbound message's structured Peer field.
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
@@ -1350,3 +910,10 @@ func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
}
return &routing.RoutePeer{Kind: parentKind, ID: parentID}
}
+
+// activeContextPath returns the full path for the active_context.json file
+// stored inside the workspace directory.
+func activeContextPath(workspace string) string {
+ return filepath.Join(workspace, "active_context.json")
+}
+
diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go
index 01e682f3b..f5925e9a4 100644
--- a/pkg/agent/memory.go
+++ b/pkg/agent/memory.go
@@ -7,121 +7,201 @@
package agent
import (
+ "database/sql"
"fmt"
"os"
"path/filepath"
"strings"
+ "sync"
"time"
- "github.com/sipeed/picoclaw/pkg/fileutil"
+ "github.com/sipeed/picoclaw/pkg/logger"
+
+ _ "modernc.org/sqlite"
)
-// MemoryStore manages persistent memory for the agent.
-// - Long-term memory: memory/MEMORY.md
-// - Daily notes: memory/YYYYMM/YYYYMMDD.md
+// MemoryStore manages persistent memory for the agent using SQLite.
+//
+// Schema:
+// - long_term: single-row table holding the long-term memory content
+// - daily_notes: one row per day (key = "YYYYMMDD")
+// - memory_entries: individually tagged memory items
+//
+// The database file is stored at workspace/memory.db.
type MemoryStore struct {
- workspace string
- memoryDir string
- memoryFile string
+ workspace string
+ db *sql.DB
+ mu sync.Mutex // serialise writes
}
-// NewMemoryStore creates a new MemoryStore with the given workspace path.
-// It ensures the memory directory exists.
+// NewMemoryStore creates a new MemoryStore backed by SQLite.
+// It creates the database and tables if they do not exist.
func NewMemoryStore(workspace string) *MemoryStore {
- memoryDir := filepath.Join(workspace, "memory")
- memoryFile := filepath.Join(memoryDir, "MEMORY.md")
+ dbPath := filepath.Join(workspace, "memory.db")
- // Ensure memory directory exists
- os.MkdirAll(memoryDir, 0o755)
+ // Ensure workspace directory exists.
+ os.MkdirAll(workspace, 0o755)
- return &MemoryStore{
- workspace: workspace,
- memoryDir: memoryDir,
- memoryFile: memoryFile,
+ db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)")
+ if err != nil {
+ logger.DebugCF("memory", "Failed to open memory DB", map[string]any{"error": err.Error()})
+ // Return a store that degrades gracefully (methods return empty / no-op).
+ return &MemoryStore{workspace: workspace}
+ }
+
+ // Create tables.
+ ddl := `
+CREATE TABLE IF NOT EXISTS long_term (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ content TEXT NOT NULL DEFAULT ''
+);
+INSERT OR IGNORE INTO long_term (id, content) VALUES (1, '');
+
+CREATE TABLE IF NOT EXISTS daily_notes (
+ day TEXT PRIMARY KEY, -- YYYYMMDD
+ content TEXT NOT NULL DEFAULT ''
+);
+
+CREATE TABLE IF NOT EXISTS memory_entries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content TEXT NOT NULL,
+ tags TEXT NOT NULL DEFAULT '', -- comma-separated, lowercase
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS cot_usage (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ intent TEXT NOT NULL DEFAULT '',
+ tags TEXT NOT NULL DEFAULT '', -- comma-separated tags from message analysis
+ cot_prompt TEXT NOT NULL DEFAULT '', -- LLM-generated thinking strategy
+ message TEXT NOT NULL DEFAULT '', -- first 200 chars of user message
+ feedback INTEGER NOT NULL DEFAULT 0, -- -1=bad, 0=neutral, 1=good
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+`
+ if _, err := db.Exec(ddl); err != nil {
+ logger.DebugCF("memory", "Failed to initialise memory DB tables", map[string]any{"error": err.Error()})
+ db.Close()
+ return &MemoryStore{workspace: workspace}
+ }
+
+ ms := &MemoryStore{
+ workspace: workspace,
+ db: db,
+ }
+
+ // Migrate from legacy file-based storage if memory.db was just created.
+ ms.migrateFromFiles()
+
+ return ms
+}
+
+// Close closes the underlying database. Safe to call multiple times.
+func (ms *MemoryStore) Close() {
+ if ms.db != nil {
+ ms.db.Close()
}
}
-// getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md).
-func (ms *MemoryStore) getTodayFile() string {
- today := time.Now().Format("20060102") // YYYYMMDD
- monthDir := today[:6] // YYYYMM
- filePath := filepath.Join(ms.memoryDir, monthDir, today+".md")
- return filePath
-}
+// --- Long-term memory -------------------------------------------------------
-// ReadLongTerm reads the long-term memory (MEMORY.md).
-// Returns empty string if the file doesn't exist.
+// ReadLongTerm reads the long-term memory content.
+// Returns empty string if the database is unavailable.
func (ms *MemoryStore) ReadLongTerm() string {
- if data, err := os.ReadFile(ms.memoryFile); err == nil {
- return string(data)
+ if ms.db == nil {
+ return ""
}
- return ""
+ var content string
+ err := ms.db.QueryRow("SELECT content FROM long_term WHERE id = 1").Scan(&content)
+ if err != nil {
+ return ""
+ }
+ return content
}
-// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
+// WriteLongTerm replaces the long-term memory content.
func (ms *MemoryStore) WriteLongTerm(content string) error {
- // Use unified atomic write utility with explicit sync for flash storage reliability.
- // Using 0o600 (owner read/write only) for secure default permissions.
- return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
+ if ms.db == nil {
+ return fmt.Errorf("memory DB not available")
+ }
+ ms.mu.Lock()
+ defer ms.mu.Unlock()
+ _, err := ms.db.Exec("UPDATE long_term SET content = ? WHERE id = 1", content)
+ return err
+}
+
+// --- Daily notes ------------------------------------------------------------
+
+// todayKey returns today's date as "YYYYMMDD".
+func todayKey() string {
+ return time.Now().Format("20060102")
}
// ReadToday reads today's daily note.
-// Returns empty string if the file doesn't exist.
+// Returns empty string if the file doesn't exist or the database is unavailable.
func (ms *MemoryStore) ReadToday() string {
- todayFile := ms.getTodayFile()
- if data, err := os.ReadFile(todayFile); err == nil {
- return string(data)
+ if ms.db == nil {
+ return ""
}
- return ""
+ var content string
+ err := ms.db.QueryRow("SELECT content FROM daily_notes WHERE day = ?", todayKey()).Scan(&content)
+ if err != nil {
+ return ""
+ }
+ return content
}
// AppendToday appends content to today's daily note.
-// If the file doesn't exist, it creates a new file with a date header.
+// If no note exists for today, a new one is created with a date header.
func (ms *MemoryStore) AppendToday(content string) error {
- todayFile := ms.getTodayFile()
-
- // Ensure month directory exists
- monthDir := filepath.Dir(todayFile)
- if err := os.MkdirAll(monthDir, 0o755); err != nil {
- return err
+ if ms.db == nil {
+ return fmt.Errorf("memory DB not available")
}
+ ms.mu.Lock()
+ defer ms.mu.Unlock()
- var existingContent string
- if data, err := os.ReadFile(todayFile); err == nil {
- existingContent = string(data)
- }
+ key := todayKey()
- var newContent string
- if existingContent == "" {
- // Add header for new day
+ var existing string
+ err := ms.db.QueryRow("SELECT content FROM daily_notes WHERE day = ?", key).Scan(&existing)
+ if err == sql.ErrNoRows || existing == "" {
+ // New day — add header.
header := fmt.Sprintf("# %s\n\n", time.Now().Format("2006-01-02"))
- newContent = header + content
- } else {
- // Append to existing content
- newContent = existingContent + "\n" + content
+ content = header + content
+ _, err = ms.db.Exec(
+ "INSERT OR REPLACE INTO daily_notes (day, content) VALUES (?, ?)",
+ key, content,
+ )
+ } else if err == nil {
+ // Append to existing.
+ content = existing + "\n" + content
+ _, err = ms.db.Exec("UPDATE daily_notes SET content = ? WHERE day = ?", content, key)
}
-
- // Use unified atomic write utility with explicit sync for flash storage reliability.
- return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600)
+ return err
}
// GetRecentDailyNotes returns daily notes from the last N days.
// Contents are joined with "---" separator.
func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
+ if ms.db == nil {
+ return ""
+ }
+
var sb strings.Builder
first := true
for i := range days {
date := time.Now().AddDate(0, 0, -i)
- dateStr := date.Format("20060102") // YYYYMMDD
- monthDir := dateStr[:6] // YYYYMM
- filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md")
+ key := date.Format("20060102")
- if data, err := os.ReadFile(filePath); err == nil {
+ var content string
+ err := ms.db.QueryRow("SELECT content FROM daily_notes WHERE day = ?", key).Scan(&content)
+ if err == nil && content != "" {
if !first {
sb.WriteString("\n\n---\n\n")
}
- sb.Write(data)
+ sb.WriteString(content)
first = false
}
}
@@ -129,30 +209,676 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
return sb.String()
}
+// --- Tagged memory entries ---------------------------------------------------
+
+// MemoryEntry represents a single tagged memory item.
+type MemoryEntry struct {
+ ID int64
+ Content string
+ Tags []string
+ CreatedAt string
+ UpdatedAt string
+}
+
+// normaliseTags lowercases, trims, deduplicates, and sorts tags.
+func normaliseTags(tags []string) []string {
+ seen := make(map[string]struct{}, len(tags))
+ out := make([]string, 0, len(tags))
+ for _, t := range tags {
+ t = strings.ToLower(strings.TrimSpace(t))
+ if t == "" {
+ continue
+ }
+ if _, ok := seen[t]; !ok {
+ seen[t] = struct{}{}
+ out = append(out, t)
+ }
+ }
+ return out
+}
+
+// joinTags joins tags with "," for storage.
+func joinTags(tags []string) string {
+ return strings.Join(normaliseTags(tags), ",")
+}
+
+// splitTags splits a stored tag string back into a slice.
+func splitTags(s string) []string {
+ if s == "" {
+ return nil
+ }
+ return strings.Split(s, ",")
+}
+
+// AddEntry inserts a new tagged memory entry. Returns the new entry ID.
+func (ms *MemoryStore) AddEntry(content string, tags []string) (int64, error) {
+ if ms.db == nil {
+ return 0, fmt.Errorf("memory DB not available")
+ }
+ ms.mu.Lock()
+ defer ms.mu.Unlock()
+
+ res, err := ms.db.Exec(
+ "INSERT INTO memory_entries (content, tags) VALUES (?, ?)",
+ content, joinTags(tags),
+ )
+ if err != nil {
+ return 0, err
+ }
+ return res.LastInsertId()
+}
+
+// UpdateEntry updates the content and tags of an existing entry.
+func (ms *MemoryStore) UpdateEntry(id int64, content string, tags []string) error {
+ if ms.db == nil {
+ return fmt.Errorf("memory DB not available")
+ }
+ ms.mu.Lock()
+ defer ms.mu.Unlock()
+
+ _, err := ms.db.Exec(
+ "UPDATE memory_entries SET content = ?, tags = ?, updated_at = datetime('now') WHERE id = ?",
+ content, joinTags(tags), id,
+ )
+ return err
+}
+
+// DeleteEntry removes a memory entry by ID.
+func (ms *MemoryStore) DeleteEntry(id int64) error {
+ if ms.db == nil {
+ return fmt.Errorf("memory DB not available")
+ }
+ ms.mu.Lock()
+ defer ms.mu.Unlock()
+
+ _, err := ms.db.Exec("DELETE FROM memory_entries WHERE id = ?", id)
+ return err
+}
+
+// GetEntry retrieves a single memory entry by ID.
+func (ms *MemoryStore) GetEntry(id int64) (*MemoryEntry, error) {
+ if ms.db == nil {
+ return nil, fmt.Errorf("memory DB not available")
+ }
+ var e MemoryEntry
+ var tagsStr string
+ err := ms.db.QueryRow(
+ "SELECT id, content, tags, created_at, updated_at FROM memory_entries WHERE id = ?", id,
+ ).Scan(&e.ID, &e.Content, &tagsStr, &e.CreatedAt, &e.UpdatedAt)
+ if err != nil {
+ return nil, err
+ }
+ e.Tags = splitTags(tagsStr)
+ return &e, nil
+}
+
+// SearchByTag returns all entries that contain the given tag.
+// Tag matching is case-insensitive (tags are stored lowercase).
+func (ms *MemoryStore) SearchByTag(tag string) ([]MemoryEntry, error) {
+ if ms.db == nil {
+ return nil, fmt.Errorf("memory DB not available")
+ }
+ tag = strings.ToLower(strings.TrimSpace(tag))
+ if tag == "" {
+ return nil, nil
+ }
+
+ // Match: exact tag as whole string, at start, at end, or in the middle.
+ // Pattern: tag OR tag,... OR ...,tag OR ...,tag,...
+ rows, err := ms.db.Query(
+ `SELECT id, content, tags, created_at, updated_at FROM memory_entries
+ WHERE tags = ? OR tags LIKE ? OR tags LIKE ? OR tags LIKE ?
+ ORDER BY updated_at DESC`,
+ tag, tag+",%", "%,"+tag, "%,"+tag+",%",
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ return scanEntries(rows)
+}
+
+// SearchByTags returns entries that contain ALL of the given tags.
+func (ms *MemoryStore) SearchByTags(tags []string) ([]MemoryEntry, error) {
+ if ms.db == nil {
+ return nil, fmt.Errorf("memory DB not available")
+ }
+ tags = normaliseTags(tags)
+ if len(tags) == 0 {
+ return nil, nil
+ }
+
+ // Build WHERE clause: each tag must match.
+ conds := make([]string, 0, len(tags))
+ args := make([]any, 0, len(tags)*4)
+ for _, tag := range tags {
+ conds = append(conds,
+ "(tags = ? OR tags LIKE ? OR tags LIKE ? OR tags LIKE ?)")
+ args = append(args, tag, tag+",%", "%,"+tag, "%,"+tag+",%")
+ }
+
+ query := fmt.Sprintf(
+ "SELECT id, content, tags, created_at, updated_at FROM memory_entries WHERE %s ORDER BY updated_at DESC",
+ strings.Join(conds, " AND "),
+ )
+
+ rows, err := ms.db.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ return scanEntries(rows)
+}
+
+// SearchByAnyTag returns entries that contain ANY of the given tags (OR logic).
+// Results are deduplicated and ordered by updated_at DESC, limited to 20 entries.
+func (ms *MemoryStore) SearchByAnyTag(tags []string) ([]MemoryEntry, error) {
+ if ms.db == nil {
+ return nil, fmt.Errorf("memory DB not available")
+ }
+ tags = normaliseTags(tags)
+ if len(tags) == 0 {
+ return nil, nil
+ }
+
+ // Build WHERE clause: any tag may match (OR).
+ conds := make([]string, 0, len(tags))
+ args := make([]any, 0, len(tags)*4)
+ for _, tag := range tags {
+ conds = append(conds,
+ "(tags = ? OR tags LIKE ? OR tags LIKE ? OR tags LIKE ?)")
+ args = append(args, tag, tag+",%", "%,"+tag, "%,"+tag+",%")
+ }
+
+ query := fmt.Sprintf(
+ "SELECT id, content, tags, created_at, updated_at FROM memory_entries WHERE %s ORDER BY updated_at DESC LIMIT 20",
+ strings.Join(conds, " OR "),
+ )
+
+ rows, err := ms.db.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ return scanEntries(rows)
+}
+
+// ListAllTags returns all unique tags used across memory entries.
+func (ms *MemoryStore) ListAllTags() ([]string, error) {
+ if ms.db == nil {
+ return nil, fmt.Errorf("memory DB not available")
+ }
+
+ rows, err := ms.db.Query("SELECT DISTINCT tags FROM memory_entries WHERE tags != ''")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ seen := make(map[string]struct{})
+ for rows.Next() {
+ var tagsStr string
+ if err := rows.Scan(&tagsStr); err != nil {
+ continue
+ }
+ for _, t := range splitTags(tagsStr) {
+ seen[t] = struct{}{}
+ }
+ }
+
+ result := make([]string, 0, len(seen))
+ for t := range seen {
+ result = append(result, t)
+ }
+ return result, nil
+}
+
+// ListEntries returns the most recent N entries (all tags), ordered newest first.
+func (ms *MemoryStore) ListEntries(limit int) ([]MemoryEntry, error) {
+ if ms.db == nil {
+ return nil, fmt.Errorf("memory DB not available")
+ }
+ if limit <= 0 {
+ limit = 50
+ }
+
+ rows, err := ms.db.Query(
+ "SELECT id, content, tags, created_at, updated_at FROM memory_entries ORDER BY updated_at DESC LIMIT ?",
+ limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ return scanEntries(rows)
+}
+
+// scanEntries is a helper to scan rows into MemoryEntry slices.
+func scanEntries(rows *sql.Rows) ([]MemoryEntry, error) {
+ var entries []MemoryEntry
+ for rows.Next() {
+ var e MemoryEntry
+ var tagsStr string
+ if err := rows.Scan(&e.ID, &e.Content, &tagsStr, &e.CreatedAt, &e.UpdatedAt); err != nil {
+ return entries, err
+ }
+ e.Tags = splitTags(tagsStr)
+ entries = append(entries, e)
+ }
+ return entries, rows.Err()
+}
+
+// --- Composite context ------------------------------------------------------
+
// GetMemoryContext returns formatted memory context for the agent prompt.
-// Includes long-term memory and recent daily notes.
+// Includes long-term memory, recent daily notes, and recent tagged entries.
func (ms *MemoryStore) GetMemoryContext() string {
longTerm := ms.ReadLongTerm()
recentNotes := ms.GetRecentDailyNotes(3)
- if longTerm == "" && recentNotes == "" {
- return ""
- }
-
var sb strings.Builder
+ hasContent := false
if longTerm != "" {
sb.WriteString("## Long-term Memory\n\n")
sb.WriteString(longTerm)
+ hasContent = true
}
if recentNotes != "" {
- if longTerm != "" {
+ if hasContent {
sb.WriteString("\n\n---\n\n")
}
sb.WriteString("## Recent Daily Notes\n\n")
sb.WriteString(recentNotes)
+ hasContent = true
}
+ // Include recent tagged memory entries.
+ entries, _ := ms.ListEntries(10)
+ if len(entries) > 0 {
+ if hasContent {
+ sb.WriteString("\n\n---\n\n")
+ }
+ sb.WriteString("## Tagged Memories\n\n")
+ for _, e := range entries {
+ tagLabel := ""
+ if len(e.Tags) > 0 {
+ tagLabel = " [" + strings.Join(e.Tags, ", ") + "]"
+ }
+ fmt.Fprintf(&sb, "- (#%d%s) %s\n", e.ID, tagLabel, e.Content)
+ }
+ hasContent = true
+ }
+
+ if !hasContent {
+ return ""
+ }
return sb.String()
}
+
+// --- CoT usage tracking (learning) ------------------------------------------
+
+// CotUsageRecord represents a single CoT usage entry.
+type CotUsageRecord struct {
+ ID int64
+ Intent string
+ Tags []string // Tags from the message analysis
+ CotPrompt string // LLM-generated thinking strategy
+ Message string
+ Feedback int // -1=bad, 0=neutral, 1=good
+ CreatedAt string
+}
+
+// CotStats holds aggregated statistics for an intent.
+type CotStats struct {
+ Intent string
+ TotalUses int
+ AvgScore float64 // Average feedback score
+ LastUsed string
+}
+
+// RecordCotUsage logs a CoT usage event with the LLM-generated prompt and tags.
+// messagePreview is truncated to 200 characters.
+func (ms *MemoryStore) RecordCotUsage(intent string, tags []string, cotPrompt, message string) (int64, error) {
+ if ms.db == nil {
+ return 0, fmt.Errorf("memory DB not available")
+ }
+ ms.mu.Lock()
+ defer ms.mu.Unlock()
+
+ // Truncate message preview.
+ if len(message) > 200 {
+ message = message[:200]
+ }
+
+ tagStr := strings.Join(tags, ",")
+ res, err := ms.db.Exec(
+ "INSERT INTO cot_usage (intent, tags, cot_prompt, message) VALUES (?, ?, ?, ?)",
+ intent, tagStr, cotPrompt, message,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return res.LastInsertId()
+}
+
+// UpdateCotFeedback updates the feedback score for a CoT usage record.
+// score: -1=bad, 0=neutral, 1=good.
+func (ms *MemoryStore) UpdateCotFeedback(id int64, score int) error {
+ if ms.db == nil {
+ return fmt.Errorf("memory DB not available")
+ }
+ if score < -1 || score > 1 {
+ return fmt.Errorf("feedback score must be -1, 0, or 1")
+ }
+ ms.mu.Lock()
+ defer ms.mu.Unlock()
+
+ _, err := ms.db.Exec("UPDATE cot_usage SET feedback = ? WHERE id = ?", score, id)
+ return err
+}
+
+// UpdateLatestCotFeedback updates the feedback score for the most recent
+// CoT usage record. This is useful when the user provides feedback after
+// the main LLM has responded (at which point the usage ID may not be tracked).
+func (ms *MemoryStore) UpdateLatestCotFeedback(score int) error {
+ if ms.db == nil {
+ return fmt.Errorf("memory DB not available")
+ }
+ ms.mu.Lock()
+ defer ms.mu.Unlock()
+
+ _, err := ms.db.Exec(
+ "UPDATE cot_usage SET feedback = ? WHERE id = (SELECT MAX(id) FROM cot_usage)",
+ score,
+ )
+ return err
+}
+
+// GetCotStats returns aggregated statistics per intent,
+// based on usage in the last N days. Ordered by total uses descending.
+func (ms *MemoryStore) GetCotStats(days int) ([]CotStats, error) {
+ if ms.db == nil {
+ return nil, fmt.Errorf("memory DB not available")
+ }
+ if days <= 0 {
+ days = 30
+ }
+
+ rows, err := ms.db.Query(`
+ SELECT
+ intent,
+ COUNT(*) as total_uses,
+ COALESCE(AVG(CASE WHEN feedback != 0 THEN CAST(feedback AS REAL) END), 0.0) as avg_score,
+ MAX(created_at) as last_used
+ FROM cot_usage
+ WHERE created_at >= datetime('now', ? || ' days')
+ GROUP BY intent
+ ORDER BY total_uses DESC
+ `, fmt.Sprintf("-%d", days))
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var stats []CotStats
+ for rows.Next() {
+ var s CotStats
+ if err := rows.Scan(&s.Intent, &s.TotalUses, &s.AvgScore, &s.LastUsed); err != nil {
+ continue
+ }
+ stats = append(stats, s)
+ }
+ return stats, rows.Err()
+}
+
+// GetCotIntentStats returns usage stats per intent.
+// This is a simpler version that just counts per intent.
+func (ms *MemoryStore) GetCotIntentStats(days int) ([]CotStats, error) {
+ return ms.GetCotStats(days)
+}
+
+// GetTopRatedCotPrompts returns the highest-rated generated CoT prompts.
+// If filterTags is non-empty, prioritises prompts that share tags with the query.
+// These serve as proven examples for future LLM generation.
+func (ms *MemoryStore) GetTopRatedCotPrompts(days, limit int, filterTags []string) ([]CotUsageRecord, error) {
+ if ms.db == nil {
+ return nil, fmt.Errorf("memory DB not available")
+ }
+ if days <= 0 {
+ days = 30
+ }
+ if limit <= 0 {
+ limit = 5
+ }
+
+ rows, err := ms.db.Query(`
+ SELECT id, intent, tags, cot_prompt, message, feedback, created_at
+ FROM cot_usage
+ WHERE feedback > 0
+ AND cot_prompt != ''
+ AND created_at >= datetime('now', ? || ' days')
+ ORDER BY feedback DESC, created_at DESC
+ LIMIT ?
+ `, fmt.Sprintf("-%d", days), limit*3) // Over-fetch to filter by tags later.
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var all []CotUsageRecord
+ for rows.Next() {
+ var r CotUsageRecord
+ var tagStr string
+ if err := rows.Scan(&r.ID, &r.Intent, &tagStr, &r.CotPrompt, &r.Message, &r.Feedback, &r.CreatedAt); err != nil {
+ continue
+ }
+ if tagStr != "" {
+ r.Tags = strings.Split(tagStr, ",")
+ }
+ all = append(all, r)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ // If filter tags provided, sort by tag overlap (most relevant first).
+ if len(filterTags) > 0 && len(all) > 0 {
+ tagSet := make(map[string]bool, len(filterTags))
+ for _, t := range filterTags {
+ tagSet[strings.ToLower(t)] = true
+ }
+
+ // Partition: matching first, then non-matching.
+ var matching, rest []CotUsageRecord
+ for _, r := range all {
+ hasOverlap := false
+ for _, t := range r.Tags {
+ if tagSet[strings.ToLower(t)] {
+ hasOverlap = true
+ break
+ }
+ }
+ if hasOverlap {
+ matching = append(matching, r)
+ } else {
+ rest = append(rest, r)
+ }
+ }
+ all = append(matching, rest...)
+ }
+
+ if len(all) > limit {
+ all = all[:limit]
+ }
+ return all, nil
+}
+
+// GetRecentCotUsage returns the N most recent CoT usage records.
+func (ms *MemoryStore) GetRecentCotUsage(limit int) ([]CotUsageRecord, error) {
+ if ms.db == nil {
+ return nil, fmt.Errorf("memory DB not available")
+ }
+ if limit <= 0 {
+ limit = 20
+ }
+
+ rows, err := ms.db.Query(
+ "SELECT id, intent, tags, cot_prompt, message, feedback, created_at FROM cot_usage ORDER BY id DESC LIMIT ?",
+ limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var records []CotUsageRecord
+ for rows.Next() {
+ var r CotUsageRecord
+ var tagStr string
+ if err := rows.Scan(&r.ID, &r.Intent, &tagStr, &r.CotPrompt, &r.Message, &r.Feedback, &r.CreatedAt); err != nil {
+ continue
+ }
+ if tagStr != "" {
+ r.Tags = strings.Split(tagStr, ",")
+ }
+ records = append(records, r)
+ }
+ return records, rows.Err()
+}
+
+// FormatCotLearningContext formats CoT usage history and top-rated prompts
+// into a string for the pre-LLM to learn from past generations.
+// currentTags are the tags extracted from the current message, used to
+// prioritise relevant proven strategies.
+func (ms *MemoryStore) FormatCotLearningContext(days int, currentTags []string) string {
+ var sb strings.Builder
+ hasContent := false
+
+ // 1. Usage stats per intent.
+ stats, err := ms.GetCotStats(days)
+ if err == nil && len(stats) > 0 {
+ sb.WriteString("## Historical Usage Stats\n\n")
+ for _, s := range stats {
+ scoreLabel := "neutral"
+ if s.AvgScore > 0.3 {
+ scoreLabel = "good"
+ } else if s.AvgScore < -0.3 {
+ scoreLabel = "poor"
+ }
+ fmt.Fprintf(&sb, "- Intent '%s': %d uses, avg feedback=%s (%.1f)\n",
+ s.Intent, s.TotalUses, scoreLabel, s.AvgScore)
+ }
+ sb.WriteString("\n")
+ hasContent = true
+ }
+
+ // 2. Top-rated generated prompts as proven examples (filtered by current tags).
+ topPrompts, err := ms.GetTopRatedCotPrompts(days, 3, currentTags)
+ if err == nil && len(topPrompts) > 0 {
+ sb.WriteString("## Proven Strategies (from past sessions with positive feedback)\n\n")
+ sb.WriteString("These generated strategies received positive feedback. Use similar approaches for similar intents.\n\n")
+ for i, r := range topPrompts {
+ msgPreview := r.Message
+ if len(msgPreview) > 80 {
+ msgPreview = msgPreview[:80] + "..."
+ }
+ tagLabel := ""
+ if len(r.Tags) > 0 {
+ tagLabel = fmt.Sprintf(", tags: [%s]", strings.Join(r.Tags, ", "))
+ }
+ fmt.Fprintf(&sb, "### Proven #%d (intent: %s%s, message: \"%s\")\n%s\n\n",
+ i+1, r.Intent, tagLabel, msgPreview, r.CotPrompt)
+ }
+ hasContent = true
+ }
+
+ if !hasContent {
+ return ""
+ }
+ return sb.String()
+}
+
+// --- Migration from legacy files --------------------------------------------
+
+// migrateFromFiles imports data from the old file-based storage
+// (memory/MEMORY.md and memory/YYYYMM/YYYYMMDD.md) into SQLite.
+// It only runs if the long_term content is empty (fresh DB) AND the
+// legacy directory exists. After a successful migration the legacy
+// directory is renamed to memory_backup.
+func (ms *MemoryStore) migrateFromFiles() {
+ if ms.db == nil {
+ return
+ }
+
+ memoryDir := filepath.Join(ms.workspace, "memory")
+
+ // Check if the legacy directory exists.
+ info, err := os.Stat(memoryDir)
+ if err != nil || !info.IsDir() {
+ return // nothing to migrate
+ }
+
+ // Only migrate if the DB is empty (fresh).
+ longTerm := ms.ReadLongTerm()
+ if longTerm != "" {
+ return // already has data
+ }
+
+ logger.DebugCF("memory", "Migrating legacy file-based memory to SQLite", nil)
+
+ // 1. Long-term memory.
+ memoryFile := filepath.Join(memoryDir, "MEMORY.md")
+ if data, err := os.ReadFile(memoryFile); err == nil && len(data) > 0 {
+ ms.WriteLongTerm(string(data))
+ }
+
+ // 2. Daily notes — walk YYYYMM/YYYYMMDD.md files.
+ entries, err := os.ReadDir(memoryDir)
+ if err != nil {
+ return
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ monthDir := filepath.Join(memoryDir, entry.Name())
+ dayFiles, err := os.ReadDir(monthDir)
+ if err != nil {
+ continue
+ }
+ for _, df := range dayFiles {
+ name := df.Name()
+ if !strings.HasSuffix(name, ".md") {
+ continue
+ }
+ day := strings.TrimSuffix(name, ".md") // YYYYMMDD
+ if len(day) != 8 {
+ continue
+ }
+ data, err := os.ReadFile(filepath.Join(monthDir, name))
+ if err != nil || len(data) == 0 {
+ continue
+ }
+ ms.mu.Lock()
+ ms.db.Exec(
+ "INSERT OR IGNORE INTO daily_notes (day, content) VALUES (?, ?)",
+ day, string(data),
+ )
+ ms.mu.Unlock()
+ }
+ }
+
+ // Rename legacy dir so we don't migrate again.
+ backupDir := filepath.Join(ms.workspace, "memory_backup")
+ if err := os.Rename(memoryDir, backupDir); err != nil {
+ logger.DebugCF("memory", "Could not rename legacy memory dir", map[string]any{"error": err.Error()})
+ } else {
+ logger.DebugCF("memory", "Legacy memory migrated and backed up", map[string]any{"backup": backupDir})
+ }
+}
diff --git a/pkg/agent/memory_digest.go b/pkg/agent/memory_digest.go
new file mode 100644
index 000000000..da10e4a53
--- /dev/null
+++ b/pkg/agent/memory_digest.go
@@ -0,0 +1,280 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// MemoryDigestWorker runs as a background goroutine and periodically extracts
+// long-term memories from pending TurnRecords.
+//
+// Design:
+// - Fixed interval trigger (default 5 minutes).
+// - No llmActive yield mechanism (personal agent, low QPS, API rate-limits handle it).
+// - Processes up to 50 pending turns per cycle, grouped by channel_key.
+// - On completion, marks turns as "processed" and archives old processed turns.
+type MemoryDigestWorker struct {
+ store *TurnStore
+ memory *MemoryStore
+ provider providers.LLMProvider
+ model string
+ interval time.Duration
+}
+
+// MemoryDigestConfig holds tunable parameters.
+type MemoryDigestConfig struct {
+ Interval time.Duration // Polling period (default: 5 minutes)
+ BatchLimit int // Max pending turns per cycle (default: 50)
+ ArchiveAfterDays int // Archive processed turns older than N days (default: 7)
+}
+
+func defaultDigestConfig() MemoryDigestConfig {
+ return MemoryDigestConfig{
+ Interval: 5 * time.Minute,
+ BatchLimit: 50,
+ ArchiveAfterDays: 7,
+ }
+}
+
+// NewMemoryDigestWorker creates a worker. provider/model may be nil/empty
+// if only archival (no LLM extraction) is desired.
+func NewMemoryDigestWorker(
+ store *TurnStore,
+ memory *MemoryStore,
+ provider providers.LLMProvider,
+ model string,
+) *MemoryDigestWorker {
+ return &MemoryDigestWorker{
+ store: store,
+ memory: memory,
+ provider: provider,
+ model: model,
+ interval: defaultDigestConfig().Interval,
+ }
+}
+
+// SetInterval overrides the polling interval (e.g. for testing).
+func (w *MemoryDigestWorker) SetInterval(d time.Duration) {
+ w.interval = d
+}
+
+// Start launches the background goroutine. It respects ctx cancellation.
+func (w *MemoryDigestWorker) Start(ctx context.Context) {
+ go func() {
+ ticker := time.NewTicker(w.interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if err := w.runOnce(ctx); err != nil {
+ logger.WarnCF("memory_digest", "runOnce error", map[string]any{"error": err.Error()})
+ }
+ }
+ }
+ }()
+ logger.DebugCF("memory_digest", "Worker started", map[string]any{"interval": w.interval.String()})
+}
+
+// RunOnceNow triggers an immediate digest cycle (useful for testing).
+func (w *MemoryDigestWorker) RunOnceNow(ctx context.Context) error {
+ return w.runOnce(ctx)
+}
+
+// runOnce executes one full digest cycle.
+func (w *MemoryDigestWorker) runOnce(ctx context.Context) error {
+ if w.store == nil {
+ return nil
+ }
+ cfg := defaultDigestConfig()
+
+ // Step 1: Load pending turns.
+ pending, err := w.store.QueryPending(cfg.BatchLimit)
+ if err != nil {
+ return fmt.Errorf("query pending: %w", err)
+ }
+ if len(pending) == 0 {
+ logger.DebugCF("memory_digest", "No pending turns", nil)
+ // Still run archival.
+ return w.archive(cfg)
+ }
+
+ logger.DebugCF("memory_digest", "Processing pending turns",
+ map[string]any{"count": len(pending)})
+
+ // Step 2: Group by channel_key to avoid mixing user memories.
+ groups := make(map[string][]TurnRecord)
+ for _, t := range pending {
+ groups[t.ChannelKey] = append(groups[t.ChannelKey], t)
+ }
+
+ // Step 3: For each group, call LLM to extract memories.
+ for channelKey, turns := range groups {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+
+ if err := w.processGroup(ctx, channelKey, turns); err != nil {
+ logger.WarnCF("memory_digest", "Group processing error",
+ map[string]any{"channel": channelKey, "error": err.Error()})
+ // Continue with other groups.
+ }
+ }
+
+ // Step 6: Archive old processed turns.
+ return w.archive(cfg)
+}
+
+// processGroup extracts memories from a batch of turns belonging to one channel.
+func (w *MemoryDigestWorker) processGroup(ctx context.Context, channelKey string, turns []TurnRecord) error {
+ // Build a conversation digest for the LLM.
+ memories, err := w.extractMemories(ctx, turns)
+ if err != nil {
+ // Mark them as processed anyway so we don't loop forever.
+ logger.WarnCF("memory_digest", "LLM extraction failed, marking as processed",
+ map[string]any{"channel": channelKey, "error": err.Error()})
+ }
+
+ // Step 4: Write extracted memories.
+ if w.memory != nil {
+ for _, m := range memories {
+ if _, addErr := w.memory.AddEntry(m.Content, m.Tags); addErr != nil {
+ logger.WarnCF("memory_digest", "Failed to save memory",
+ map[string]any{"error": addErr.Error()})
+ }
+ }
+ }
+
+ // Step 5: Mark all turns as processed.
+ for _, t := range turns {
+ if setErr := w.store.SetStatus(t.ID, "processed"); setErr != nil {
+ logger.WarnCF("memory_digest", "SetStatus failed",
+ map[string]any{"id": t.ID, "error": setErr.Error()})
+ }
+ }
+
+ logger.DebugCF("memory_digest", "Group processed",
+ map[string]any{
+ "channel": channelKey,
+ "turns": len(turns),
+ "memories_stored": len(memories),
+ })
+ return nil
+}
+
+// digestMemoryResult holds one extracted memory item.
+type digestMemoryResult struct {
+ Content string `json:"content"`
+ Tags []string `json:"tags"`
+}
+
+const digestPrompt = `Extract important, durable facts worth remembering from these conversation turns.
+
+Conversation turns:
+%s
+
+Respond with ONLY JSON: {"memories": [{"content": "", "tags": ["tag1"]}]}
+Rules:
+- max 5 memories total across all turns
+- max 3 tags each, lowercase
+- skip trivial small-talk
+- prefer facts about user preferences, environment, recurring patterns, important decisions
+- if nothing worth remembering: {"memories": []}`
+
+// extractMemories calls the LLM to distil memories from a batch of turns.
+// Returns nil memories (not error) when the LLM is unconfigured.
+func (w *MemoryDigestWorker) extractMemories(ctx context.Context, turns []TurnRecord) ([]digestMemoryResult, error) {
+ if w.provider == nil || w.model == "" {
+ return nil, nil
+ }
+
+ // Build conversation summary for the prompt.
+ var sb strings.Builder
+ for i, t := range turns {
+ reply := t.Reply
+ if len(reply) > 500 {
+ reply = reply[:500] + "..."
+ }
+ fmt.Fprintf(&sb, "=== Turn %d (intent: %s, tags: %v) ===\nUser: %s\nAssistant: %s\n\n",
+ i+1, t.Intent, t.Tags, t.UserMsg, reply)
+ }
+ prompt := fmt.Sprintf(digestPrompt, sb.String())
+
+ resp, err := w.provider.Chat(ctx, []providers.Message{
+ {Role: "user", Content: prompt},
+ }, nil, w.model, map[string]any{"max_tokens": 512, "temperature": 0.1})
+ if err != nil {
+ return nil, fmt.Errorf("LLM call: %w", err)
+ }
+
+ raw := strings.TrimSpace(resp.Content)
+ // Strip markdown fences if present.
+ if strings.HasPrefix(raw, "```") {
+ lines := strings.Split(raw, "\n")
+ if len(lines) > 2 {
+ raw = strings.Join(lines[1:len(lines)-1], "\n")
+ }
+ }
+
+ var result struct {
+ Memories []digestMemoryResult `json:"memories"`
+ }
+ if err := json.Unmarshal([]byte(raw), &result); err != nil {
+ // Parsing failure — skip extraction, don't fail the whole batch.
+ logger.WarnCF("memory_digest", "Failed to parse LLM response",
+ map[string]any{"raw": raw[:min(len(raw), 200)], "error": err.Error()})
+ return nil, nil
+ }
+
+ // Normalise.
+ out := make([]digestMemoryResult, 0, len(result.Memories))
+ for _, m := range result.Memories {
+ m.Content = strings.TrimSpace(m.Content)
+ if m.Content == "" {
+ continue
+ }
+ normalised := make([]string, 0, len(m.Tags))
+ for _, t := range m.Tags {
+ t = strings.ToLower(strings.TrimSpace(t))
+ if t != "" {
+ normalised = append(normalised, t)
+ }
+ }
+ m.Tags = normalised
+ out = append(out, m)
+ }
+ return out, nil
+}
+
+// archive runs periodic archival of processed turns.
+func (w *MemoryDigestWorker) archive(cfg MemoryDigestConfig) error {
+ if w.store == nil {
+ return nil
+ }
+ if err := w.store.ArchiveOldProcessed(cfg.ArchiveAfterDays); err != nil {
+ return fmt.Errorf("archive: %w", err)
+ }
+ return nil
+}
+
+// min returns the smaller of a and b.
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/pkg/agent/reflector.go b/pkg/agent/reflector.go
new file mode 100644
index 000000000..55a55012b
--- /dev/null
+++ b/pkg/agent/reflector.go
@@ -0,0 +1,969 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "os"
+
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/shell"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/tools"
+)
+
+// ---------------------------------------------------------------------------
+// Runtime — unified execution engine
+//
+// The Runtime serves two purposes:
+//
+// 1. Post-LLM processing: runs async processors after the main LLM responds
+// (memory extraction, CoT feedback, error tracking).
+//
+// 2. Slash commands: handles /{cmd} {args} from users, executed synchronously.
+//
+// Both share the same MemoryStore and lightweight LLM provider.
+// ---------------------------------------------------------------------------
+
+// --- Post-LLM Processing ---------------------------------------------------
+
+// RuntimeInput captures everything that happened during a single agent turn.
+type RuntimeInput struct {
+ UserMessage string // Original user message
+ AssistantReply string // Main LLM's final response
+ Intent string // Pre-LLM detected intent
+ Tags []string // Pre-LLM extracted tags
+ CotPrompt string // Generated thinking strategy
+ ToolCalls []ToolCallRecord
+ Iterations int // Number of LLM iterations used
+ Score int // Phase 3 CalcTurnScore result (set by SyncPhase3)
+ ChannelKey string // "channel:chatID" (set by runAgentLoop)
+}
+
+// ToolCallRecord captures one tool invocation and its outcome.
+type ToolCallRecord struct {
+ Name string
+ Error string // Empty if success
+ Duration time.Duration // How long the tool took
+}
+
+// RuntimeProcessor is a single post-LLM processing step.
+type RuntimeProcessor interface {
+ Name() string
+ Process(ctx context.Context, input RuntimeInput, memory *MemoryStore) error
+}
+
+// --- Slash Commands ---------------------------------------------------------
+
+// CommandHandler handles a single /{cmd} invocation.
+type CommandHandler func(args []string, memory *MemoryStore) string
+
+// CommandDef defines a registered slash command.
+type CommandDef struct {
+ Name string // e.g. "memory"
+ Usage string // e.g. "/memory [list|add|search] ..."
+ Description string
+ Handler CommandHandler
+}
+
+// --- Reflector (Phase 3) ----------------------------------------------------
+
+// Reflector manages post-LLM processors and slash commands.
+// This is Phase 3 (Reflect) of the Runtime Loop.
+type Reflector struct {
+ provider providers.LLMProvider
+ model string
+ processors []RuntimeProcessor
+ commands map[string]CommandDef
+ mu sync.RWMutex
+ timeout time.Duration
+ toolRegistry *tools.ToolRegistry // For /shell command
+ agentRegistry *AgentRegistry // For /show, /list, /switch
+ channelManager *channels.Manager // For /list channels, /switch channel
+}
+
+
+// NewReflector creates a new Reflector (Phase 3) with built-in processors and commands.
+func NewReflector(provider providers.LLMProvider, model string) *Reflector {
+ r := &Reflector{
+ provider: provider,
+ model: model,
+ timeout: 30 * time.Second,
+ commands: make(map[string]CommandDef),
+ }
+
+ // Built-in processors (post-LLM, async).
+ // Note: CotEvaluator and MemoryExtractor are intentionally removed from the
+ // default pipeline — memory extraction is now handled by MemoryDigestWorker
+ // (batch, background) rather than per-turn inline LLM calls.
+ r.RegisterProcessor(&ErrorTracker{})
+
+ // Built-in slash commands.
+ r.RegisterCommand(CommandDef{
+ Name: "help",
+ Usage: "/help",
+ Description: "Show all available commands",
+ Handler: r.cmdHelp,
+ })
+ r.RegisterCommand(CommandDef{
+ Name: "memory",
+ Usage: "/memory [list|add|delete|edit|search|stats] ...",
+ Description: "Manage long-term memory",
+ Handler: cmdMemory,
+ })
+ r.RegisterCommand(CommandDef{
+ Name: "cot",
+ Usage: "/cot [feedback|stats|history] ...",
+ Description: "Manage CoT learning",
+ Handler: cmdCot,
+ })
+ r.RegisterCommand(CommandDef{
+ Name: "runtime",
+ Usage: "/runtime [status|processors]",
+ Description: "Runtime status and diagnostics",
+ Handler: r.cmdRuntimeStatus,
+ })
+ r.RegisterCommand(CommandDef{
+ Name: "shell",
+ Usage: "/shell [args...]",
+ Description: "Execute shell command in workspace",
+ Handler: r.cmdShell,
+ })
+
+ // System commands (migrated from handleCommand).
+ r.RegisterCommand(CommandDef{
+ Name: "show",
+ Usage: "/show [model|channel|agents]",
+ Description: "Show current settings",
+ Handler: r.cmdShow,
+ })
+ r.RegisterCommand(CommandDef{
+ Name: "list",
+ Usage: "/list [models|channels|agents]",
+ Description: "List available resources",
+ Handler: r.cmdList,
+ })
+ r.RegisterCommand(CommandDef{
+ Name: "switch",
+ Usage: "/switch [model|channel] to ",
+ Description: "Switch model or channel",
+ Handler: r.cmdSwitch,
+ })
+
+ return r
+}
+
+
+// RegisterProcessor adds a post-LLM processor.
+func (r *Reflector) RegisterProcessor(p RuntimeProcessor) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.processors = append(r.processors, p)
+}
+
+// RegisterCommand adds a slash command.
+func (r *Reflector) RegisterCommand(cmd CommandDef) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.commands[cmd.Name] = cmd
+}
+
+// SetTools sets the tool registry for /shell command support.
+func (r *Reflector) SetTools(registry *tools.ToolRegistry) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.toolRegistry = registry
+}
+
+// SetAgentInfo provides the Runtime with agent and channel references
+// needed by system commands (/show, /list, /switch).
+func (r *Reflector) SetAgentInfo(reg *AgentRegistry, cm *channels.Manager) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.agentRegistry = reg
+ r.channelManager = cm
+}
+
+// ---------------------------------------------------------------------------
+// Post-LLM: async execution
+// ---------------------------------------------------------------------------
+
+// SyncPhase3 runs the synchronous, low-latency part of Phase 3:
+// it calculates the Turn score and returns it. The caller must invoke this
+// BEFORE PublishOutbound so that Active Context is ready for the next turn.
+// Execution target: < 2ms (pure CPU, no I/O).
+func (r *Reflector) SyncPhase3(input RuntimeInput) int {
+ score := CalcTurnScore(input)
+ logger.DebugCF("reflector", "SyncPhase3 score",
+ map[string]any{"score": score, "intent": input.Intent, "tools": len(input.ToolCalls)})
+ return score
+}
+
+// AsyncPhase3 runs the asynchronous post-turn work: persisting TurnRecord,
+// running legacy processors, etc. Call this AFTER PublishOutbound.
+func (r *Reflector) AsyncPhase3(input RuntimeInput, memory *MemoryStore, turnStore *TurnStore, activeCtx *ActiveContextStore) {
+ if r == nil {
+ return
+ }
+
+ r.mu.RLock()
+ processors := make([]RuntimeProcessor, len(r.processors))
+ copy(processors, r.processors)
+ r.mu.RUnlock()
+
+ go func() {
+ tctx, cancel := context.WithTimeout(context.Background(), r.timeout)
+ defer cancel()
+
+ // Run registered processors (currently: ErrorTracker).
+ if memory != nil {
+ for _, p := range processors {
+ select {
+ case <-tctx.Done():
+ return
+ default:
+ }
+ start := time.Now()
+ if err := p.Process(tctx, input, memory); err != nil {
+ logger.WarnCF("reflector", "Processor failed",
+ map[string]any{"processor": p.Name(), "error": err.Error(),
+ "ms": time.Since(start).Milliseconds()})
+ }
+ }
+ }
+
+ // Persist TurnRecord to turns.db.
+ if turnStore != nil && input.UserMessage != "" {
+ record := TurnRecord{
+ Ts: time.Now().Unix(),
+ ChannelKey: input.ChannelKey,
+ Score: input.Score,
+ Intent: input.Intent,
+ Tags: input.Tags,
+ Status: "pending",
+ UserMsg: input.UserMessage,
+ Reply: input.AssistantReply,
+ ToolCalls: input.ToolCalls,
+ }
+ if err := turnStore.Insert(record); err != nil {
+ logger.WarnCF("reflector", "TurnRecord insert failed",
+ map[string]any{"error": err.Error()})
+ }
+ }
+ }()
+}
+
+// RunPostLLM is kept for backward compatibility. New code should use
+// SyncPhase3 + AsyncPhase3 instead.
+func (r *Reflector) RunPostLLM(input RuntimeInput, memory *MemoryStore) {
+ r.AsyncPhase3(input, memory, nil, nil)
+}
+
+// ---------------------------------------------------------------------------
+// Slash commands: synchronous execution
+// ---------------------------------------------------------------------------
+
+// HandleCommand tries to handle a /{cmd} message.
+// Returns (response, true) if handled, ("", false) if not a known command.
+func (r *Reflector) HandleCommand(content string, memory *MemoryStore) (string, bool) {
+ content = strings.TrimSpace(content)
+ if !strings.HasPrefix(content, "/") {
+ return "", false
+ }
+
+ parts := strings.Fields(content)
+ if len(parts) == 0 {
+ return "", false
+ }
+
+ cmdName := strings.TrimPrefix(parts[0], "/")
+ args := parts[1:]
+
+ r.mu.RLock()
+ cmd, ok := r.commands[cmdName]
+ r.mu.RUnlock()
+
+ if !ok {
+ return "", false // Not our command — let AgentLoop's handleCommand try.
+ }
+
+ if memory == nil {
+ return "⚠️ Memory store not available", true
+ }
+
+ return cmd.Handler(args, memory), true
+}
+
+// ListCommands returns a formatted help text for all registered commands.
+func (r *Reflector) ListCommands() string {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ var sb strings.Builder
+ sb.WriteString("**Runtime Commands**\n\n")
+ for _, cmd := range r.commands {
+ fmt.Fprintf(&sb, "• `%s` — %s\n", cmd.Usage, cmd.Description)
+ }
+ return sb.String()
+}
+
+// ===========================================================================
+// Built-in slash commands
+// ===========================================================================
+
+// --- /help ------------------------------------------------------------------
+
+func (r *Reflector) cmdHelp(_ []string, _ *MemoryStore) string {
+ var sb strings.Builder
+ sb.WriteString("📖 **Available Commands**\n\n")
+
+ r.mu.RLock()
+ for _, cmd := range r.commands {
+ fmt.Fprintf(&sb, "• `%s` — %s\n", cmd.Usage, cmd.Description)
+ }
+ r.mu.RUnlock()
+
+ return sb.String()
+}
+
+// --- /memory ----------------------------------------------------------------
+
+func cmdMemory(args []string, memory *MemoryStore) string {
+ if len(args) == 0 {
+ return "Usage: /memory [list|add|delete|edit|search|stats]\n" +
+ " /memory list — show recent memories\n" +
+ " /memory add #tags — add a memory\n" +
+ " /memory delete — delete a memory\n" +
+ " /memory edit — edit a memory\n" +
+ " /memory search — search by tags\n" +
+ " /memory stats — memory statistics"
+ }
+
+ switch args[0] {
+ case "list":
+ limit := 10
+ entries, err := memory.ListEntries(limit)
+ if err != nil {
+ return fmt.Sprintf("❌ Error: %v", err)
+ }
+ if len(entries) == 0 {
+ return "📭 No memories stored yet."
+ }
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "📝 **Recent Memories** (%d)\n\n", len(entries))
+ for _, e := range entries {
+ tags := ""
+ if len(e.Tags) > 0 {
+ tags = " [" + strings.Join(e.Tags, ", ") + "]"
+ }
+ preview := e.Content
+ if len(preview) > 100 {
+ preview = preview[:100] + "..."
+ }
+ fmt.Fprintf(&sb, "• #%d%s: %s\n", e.ID, tags, preview)
+ }
+ return sb.String()
+
+ case "add":
+ if len(args) < 2 {
+ return "Usage: /memory add #tag1 #tag2"
+ }
+ // Separate content from #tags.
+ var content []string
+ var tags []string
+ for _, a := range args[1:] {
+ if strings.HasPrefix(a, "#") {
+ tags = append(tags, strings.TrimPrefix(a, "#"))
+ } else {
+ content = append(content, a)
+ }
+ }
+ text := strings.Join(content, " ")
+ if text == "" {
+ return "❌ Memory content cannot be empty"
+ }
+ id, err := memory.AddEntry(text, tags)
+ if err != nil {
+ return fmt.Sprintf("❌ Failed to add: %v", err)
+ }
+ return fmt.Sprintf("✅ Memory #%d saved (tags: %v)", id, tags)
+
+ case "search":
+ if len(args) < 2 {
+ return "Usage: /memory search [tag2] ..."
+ }
+ entries, err := memory.SearchByAnyTag(args[1:])
+ if err != nil {
+ return fmt.Sprintf("❌ Error: %v", err)
+ }
+ if len(entries) == 0 {
+ return fmt.Sprintf("🔍 No memories found for tags: %v", args[1:])
+ }
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "🔍 **Found %d memories**\n\n", len(entries))
+ for _, e := range entries {
+ tags := ""
+ if len(e.Tags) > 0 {
+ tags = " [" + strings.Join(e.Tags, ", ") + "]"
+ }
+ preview := e.Content
+ if len(preview) > 100 {
+ preview = preview[:100] + "..."
+ }
+ fmt.Fprintf(&sb, "• #%d%s: %s\n", e.ID, tags, preview)
+ }
+ return sb.String()
+
+ case "stats":
+ tags, _ := memory.ListAllTags()
+ entries, _ := memory.ListEntries(9999)
+ var sb strings.Builder
+ sb.WriteString("📊 **Memory Stats**\n")
+ fmt.Fprintf(&sb, "• Total entries: %d\n", len(entries))
+ fmt.Fprintf(&sb, "• Total tags: %d\n", len(tags))
+ if len(tags) > 0 {
+ preview := tags
+ if len(preview) > 20 {
+ preview = preview[:20]
+ }
+ fmt.Fprintf(&sb, "• Tags: %s", strings.Join(preview, ", "))
+ if len(tags) > 20 {
+ fmt.Fprintf(&sb, " ... (+%d more)", len(tags)-20)
+ }
+ sb.WriteString("\n")
+ }
+ return sb.String()
+
+ case "delete":
+ if len(args) < 2 {
+ return "Usage: /memory delete "
+ }
+ var id int64
+ if _, err := fmt.Sscanf(args[1], "%d", &id); err != nil {
+ return "❌ Invalid ID. Usage: /memory delete "
+ }
+ if err := memory.DeleteEntry(id); err != nil {
+ return fmt.Sprintf("❌ Failed: %v", err)
+ }
+ return fmt.Sprintf("✅ Memory #%d deleted", id)
+
+ case "edit":
+ if len(args) < 3 {
+ return "Usage: /memory edit #tags"
+ }
+ var id int64
+ if _, err := fmt.Sscanf(args[1], "%d", &id); err != nil {
+ return "❌ Invalid ID. Usage: /memory edit "
+ }
+ var content []string
+ var tags []string
+ for _, a := range args[2:] {
+ if strings.HasPrefix(a, "#") {
+ tags = append(tags, strings.TrimPrefix(a, "#"))
+ } else {
+ content = append(content, a)
+ }
+ }
+ text := strings.Join(content, " ")
+ if text == "" {
+ return "❌ Content cannot be empty"
+ }
+ if err := memory.UpdateEntry(id, text, tags); err != nil {
+ return fmt.Sprintf("❌ Failed: %v", err)
+ }
+ return fmt.Sprintf("✅ Memory #%d updated", id)
+
+ default:
+ return fmt.Sprintf("Unknown subcommand: %s. Use /memory for help.", args[0])
+ }
+}
+
+// --- /cot -------------------------------------------------------------------
+
+func cmdCot(args []string, memory *MemoryStore) string {
+ if len(args) == 0 {
+ return "Usage: /cot [feedback|stats|history]\n" +
+ " /cot feedback <1|0|-1> — rate last CoT strategy\n" +
+ " /cot stats — show CoT performance\n" +
+ " /cot history [N] — show recent CoT usage"
+ }
+
+ switch args[0] {
+ case "feedback":
+ if len(args) < 2 {
+ return "Usage: /cot feedback <1|0|-1>"
+ }
+ var score int
+ switch args[1] {
+ case "1", "+1", "good":
+ score = 1
+ case "-1", "bad":
+ score = -1
+ case "0", "neutral":
+ score = 0
+ default:
+ return "❌ Score must be 1 (good), 0 (neutral), or -1 (bad)"
+ }
+ if err := memory.UpdateLatestCotFeedback(score); err != nil {
+ return fmt.Sprintf("❌ Failed: %v", err)
+ }
+ labels := map[int]string{1: "👍 good", 0: "😐 neutral", -1: "👎 bad"}
+ return fmt.Sprintf("✅ CoT feedback recorded: %s", labels[score])
+
+ case "stats":
+ stats, err := memory.GetCotStats(30)
+ if err != nil || len(stats) == 0 {
+ return "📊 No CoT usage data yet."
+ }
+ var sb strings.Builder
+ sb.WriteString("📊 **CoT Stats (last 30 days)**\n\n")
+ for _, s := range stats {
+ scoreLabel := "neutral"
+ if s.AvgScore > 0.3 {
+ scoreLabel = "good"
+ } else if s.AvgScore < -0.3 {
+ scoreLabel = "poor"
+ }
+ fmt.Fprintf(&sb, "• Intent '%s': %d uses, avg=%s (%.1f)\n",
+ s.Intent, s.TotalUses, scoreLabel, s.AvgScore)
+ }
+ return sb.String()
+
+ case "history":
+ limit := 5
+ if len(args) > 1 {
+ fmt.Sscanf(args[1], "%d", &limit)
+ }
+ records, err := memory.GetRecentCotUsage(limit)
+ if err != nil || len(records) == 0 {
+ return "📜 No CoT history yet."
+ }
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "📜 **Recent CoT Usage** (%d)\n\n", len(records))
+ for _, r := range records {
+ fb := "😐"
+ if r.Feedback > 0 {
+ fb = "👍"
+ } else if r.Feedback < 0 {
+ fb = "👎"
+ }
+ tags := ""
+ if len(r.Tags) > 0 {
+ tags = " [" + strings.Join(r.Tags, ", ") + "]"
+ }
+ prompt := r.CotPrompt
+ if len(prompt) > 80 {
+ prompt = prompt[:80] + "..."
+ }
+ fmt.Fprintf(&sb, "• #%d %s %s%s: %s\n", r.ID, fb, r.Intent, tags, prompt)
+ }
+ return sb.String()
+
+ default:
+ return fmt.Sprintf("Unknown subcommand: %s. Use /cot for help.", args[0])
+ }
+}
+
+// --- /runtime ---------------------------------------------------------------
+
+func (r *Reflector) cmdRuntimeStatus(args []string, memory *MemoryStore) string {
+ if len(args) == 0 {
+ return "Usage: /runtime [status|processors|commands]"
+ }
+
+ switch args[0] {
+ case "status":
+ r.mu.RLock()
+ nProc := len(r.processors)
+ nCmd := len(r.commands)
+ r.mu.RUnlock()
+
+ var sb strings.Builder
+ sb.WriteString("⚙️ **Runtime Status**\n")
+ fmt.Fprintf(&sb, "• Processors: %d\n", nProc)
+ fmt.Fprintf(&sb, "• Commands: %d\n", nCmd)
+ fmt.Fprintf(&sb, "• Timeout: %s\n", r.timeout)
+ if r.model != "" {
+ fmt.Fprintf(&sb, "• Model: %s\n", r.model)
+ }
+ return sb.String()
+
+ case "processors":
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ var sb strings.Builder
+ sb.WriteString("⚙️ **Processors**\n")
+ for i, p := range r.processors {
+ fmt.Fprintf(&sb, "• %d. %s\n", i+1, p.Name())
+ }
+ return sb.String()
+
+ case "commands":
+ return r.ListCommands()
+
+ default:
+ return fmt.Sprintf("Unknown: %s. Use /runtime for help.", args[0])
+ }
+}
+
+// --- /shell -----------------------------------------------------------------
+
+const shellMaxOutput = 4000
+
+// shellDenySubstrings blocks injection attempts for dev tool passthrough.
+var shellDenySubstrings = []string{
+ "| sh", "| bash", "| powershell", "| cmd",
+ "; rm ", "; del ", "&& rm ", "&& del ",
+ "$(", "${", "`",
+ "> /dev/", ">> /dev/",
+}
+
+func (r *Reflector) cmdShell(args []string, _ *MemoryStore) string {
+ if len(args) == 0 {
+ return "Usage: /shell [args...]\n" +
+ " Built-in: ls, cat, head, tail, grep, wc, find, diff, tree, stat, pwd, echo\n" +
+ " Dev tools (passthrough): go, git, node, python, npm, cargo, make\n" +
+ " File ops: touch, mkdir, cp, mv"
+ }
+
+ baseCmd := strings.ToLower(args[0])
+ cmdArgs := args[1:]
+
+ // 1. Try built-in Go implementation (cross-platform).
+ if handler, ok := shell.BuiltinCmds[baseCmd]; ok {
+ cwd, _ := os.Getwd()
+ output := handler(cmdArgs, cwd)
+ return shellFormatOutput(output)
+ }
+
+ // 2. Try dev tool passthrough via ExecTool.
+ if shell.DevToolPassthrough[baseCmd] {
+ // Injection check.
+ command := strings.Join(args, " ")
+ cmdLower := strings.ToLower(command)
+ for _, deny := range shellDenySubstrings {
+ if strings.Contains(cmdLower, deny) {
+ return fmt.Sprintf("❌ Command blocked: restricted pattern '%s'", deny)
+ }
+ }
+
+ r.mu.RLock()
+ registry := r.toolRegistry
+ r.mu.RUnlock()
+
+ if registry == nil {
+ return "⚠️ Dev tool passthrough not available (no tool registry)"
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ result := registry.Execute(ctx, "exec", map[string]any{
+ "command": command,
+ })
+
+ if result.IsError || result.Err != nil {
+ errMsg := result.ForLLM
+ if errMsg == "" && result.Err != nil {
+ errMsg = result.Err.Error()
+ }
+ return fmt.Sprintf("❌ %s", errMsg)
+ }
+ return shellFormatOutput(result.ForLLM)
+ }
+
+ return fmt.Sprintf("❌ Unknown command '%s'. Use /shell for available commands.", baseCmd)
+}
+
+func shellFormatOutput(output string) string {
+ if output == "" {
+ return "✅ (no output)"
+ }
+ if len(output) > shellMaxOutput {
+ output = output[:shellMaxOutput] + fmt.Sprintf("\n... (truncated, %d chars total)", len(output))
+ }
+ return "```\n" + output + "\n```"
+}
+// --- /show ------------------------------------------------------------------
+
+func (r *Reflector) cmdShow(args []string, _ *MemoryStore) string {
+ if len(args) < 1 {
+ return "Usage: /show [model|channel|agents]"
+ }
+
+ r.mu.RLock()
+ reg := r.agentRegistry
+ r.mu.RUnlock()
+
+ switch args[0] {
+ case "model":
+ if reg == nil {
+ return "⚠️ Agent registry not available"
+ }
+ agent := reg.GetDefaultAgent()
+ if agent == nil {
+ return "No default agent configured"
+ }
+ return fmt.Sprintf("Current model: %s", agent.Model)
+ case "channel":
+ return "Use /list channels to see enabled channels"
+ case "agents":
+ if reg == nil {
+ return "⚠️ Agent registry not available"
+ }
+ ids := reg.ListAgentIDs()
+ return fmt.Sprintf("Registered agents: %s", strings.Join(ids, ", "))
+ default:
+ return fmt.Sprintf("Unknown show target: %s", args[0])
+ }
+}
+
+// --- /list ------------------------------------------------------------------
+
+func (r *Reflector) cmdList(args []string, _ *MemoryStore) string {
+ if len(args) < 1 {
+ return "Usage: /list [models|channels|agents]"
+ }
+
+ r.mu.RLock()
+ reg := r.agentRegistry
+ cm := r.channelManager
+ r.mu.RUnlock()
+
+ switch args[0] {
+ case "models":
+ return "Available models: configured in config.json per agent"
+ case "channels":
+ if cm == nil {
+ return "Channel manager not initialized"
+ }
+ chs := cm.GetEnabledChannels()
+ if len(chs) == 0 {
+ return "No channels enabled"
+ }
+ return fmt.Sprintf("Enabled channels: %s", strings.Join(chs, ", "))
+ case "agents":
+ if reg == nil {
+ return "⚠️ Agent registry not available"
+ }
+ ids := reg.ListAgentIDs()
+ return fmt.Sprintf("Registered agents: %s", strings.Join(ids, ", "))
+ default:
+ return fmt.Sprintf("Unknown list target: %s", args[0])
+ }
+}
+
+// --- /switch ----------------------------------------------------------------
+
+func (r *Reflector) cmdSwitch(args []string, _ *MemoryStore) string {
+ if len(args) < 3 || args[1] != "to" {
+ return "Usage: /switch [model|channel] to "
+ }
+
+ target := args[0]
+ value := args[2]
+
+ r.mu.RLock()
+ reg := r.agentRegistry
+ cm := r.channelManager
+ r.mu.RUnlock()
+
+ switch target {
+ case "model":
+ if reg == nil {
+ return "⚠️ Agent registry not available"
+ }
+ agent := reg.GetDefaultAgent()
+ if agent == nil {
+ return "No default agent configured"
+ }
+ oldModel := agent.Model
+ agent.Model = value
+ return fmt.Sprintf("Switched model from %s to %s", oldModel, value)
+ case "channel":
+ if cm == nil {
+ return "Channel manager not initialized"
+ }
+ if _, exists := cm.GetChannel(value); !exists && value != "cli" {
+ return fmt.Sprintf("Channel '%s' not found or not enabled", value)
+ }
+ return fmt.Sprintf("Switched target channel to %s", value)
+ default:
+ return fmt.Sprintf("Unknown switch target: %s", target)
+ }
+}
+
+// ===========================================================================
+// Built-in processors (post-LLM, async)
+// ===========================================================================
+
+// --- ErrorTracker (no LLM) --------------------------------------------------
+
+type ErrorTracker struct{}
+
+func (e *ErrorTracker) Name() string { return "error_tracker" }
+
+func (e *ErrorTracker) Process(_ context.Context, input RuntimeInput, _ *MemoryStore) error {
+ for _, tc := range input.ToolCalls {
+ if tc.Error == "" {
+ continue
+ }
+ logger.InfoCF("reflector", "Tool error recorded",
+ map[string]any{"tool": tc.Name, "error": tc.Error})
+ }
+ return nil
+}
+
+// --- CotEvaluator (LLM) ----------------------------------------------------
+
+type CotEvaluator struct {
+ provider providers.LLMProvider
+ model string
+}
+
+func (c *CotEvaluator) Name() string { return "cot_evaluator" }
+
+const cotEvalPrompt = `Rate how well the thinking strategy helped answer the user's question.
+
+Question: %s
+Strategy: %s
+Response (first 500 chars): %s
+
+Respond with ONLY one JSON: {"score": <-1|0|1>}
+1 = good, 0 = neutral, -1 = poor`
+
+func (c *CotEvaluator) Process(ctx context.Context, input RuntimeInput, memory *MemoryStore) error {
+ if input.CotPrompt == "" {
+ return nil
+ }
+
+ reply := input.AssistantReply
+ if len(reply) > 500 {
+ reply = reply[:500]
+ }
+
+ resp, err := c.provider.Chat(ctx, []providers.Message{
+ {Role: "user", Content: fmt.Sprintf(cotEvalPrompt, input.UserMessage, input.CotPrompt, reply)},
+ }, nil, c.model, map[string]any{"max_tokens": 32, "temperature": 0.1})
+ if err != nil {
+ return fmt.Errorf("eval LLM failed: %w", err)
+ }
+
+ // Parse JSON (strip markdown fences if present).
+ raw := strings.TrimSpace(resp.Content)
+ if strings.HasPrefix(raw, "```") {
+ lines := strings.Split(raw, "\n")
+ if len(lines) > 2 {
+ raw = strings.Join(lines[1:len(lines)-1], "\n")
+ }
+ }
+ var evalResult struct {
+ Score int `json:"score"`
+ }
+ if err := json.Unmarshal([]byte(raw), &evalResult); err != nil {
+ // Fallback: string matching.
+ if strings.Contains(raw, `"score": 1`) || strings.Contains(raw, `"score":1`) {
+ evalResult.Score = 1
+ } else if strings.Contains(raw, `"score": -1`) || strings.Contains(raw, `"score":-1`) {
+ evalResult.Score = -1
+ }
+ }
+
+ if evalResult.Score != 0 {
+ if err := memory.UpdateLatestCotFeedback(evalResult.Score); err != nil {
+ return err
+ }
+ logger.InfoCF("reflector", "CoT feedback auto-recorded",
+ map[string]any{"score": evalResult.Score, "intent": input.Intent})
+ }
+ return nil
+}
+
+// --- MemoryExtractor (LLM) --------------------------------------------------
+
+type MemoryExtractor struct {
+ provider providers.LLMProvider
+ model string
+}
+
+func (m *MemoryExtractor) Name() string { return "memory_extractor" }
+
+const memoryExtractPrompt = `Extract important facts worth remembering from this conversation.
+
+User: %s
+Assistant (first 800 chars): %s
+
+Respond with ONLY JSON: {"memories": [{"content": "", "tags": ["tag1"]}]}
+Rules: max 3 memories, max 3 tags each, lowercase tags, skip trivial chat.
+If nothing worth remembering: {"memories": []}`
+
+type memExtractResult struct {
+ Memories []struct {
+ Content string `json:"content"`
+ Tags []string `json:"tags"`
+ } `json:"memories"`
+}
+
+func (m *MemoryExtractor) Process(ctx context.Context, input RuntimeInput, memory *MemoryStore) error {
+ if len(input.UserMessage) < 20 || input.Intent == "chat" {
+ return nil
+ }
+
+ reply := input.AssistantReply
+ if len(reply) > 800 {
+ reply = reply[:800]
+ }
+
+ resp, err := m.provider.Chat(ctx, []providers.Message{
+ {Role: "user", Content: fmt.Sprintf(memoryExtractPrompt, input.UserMessage, reply)},
+ }, nil, m.model, map[string]any{"max_tokens": 256, "temperature": 0.1})
+ if err != nil {
+ return fmt.Errorf("memory extract LLM failed: %w", err)
+ }
+
+ // Parse JSON (strip markdown fences if present).
+ raw := strings.TrimSpace(resp.Content)
+ if strings.HasPrefix(raw, "```") {
+ lines := strings.Split(raw, "\n")
+ if len(lines) > 2 {
+ raw = strings.Join(lines[1:len(lines)-1], "\n")
+ }
+ }
+
+ var result memExtractResult
+ if err := json.Unmarshal([]byte(raw), &result); err != nil {
+ return nil // Parsing failed — skip silently.
+ }
+
+ for _, mem := range result.Memories {
+ content := strings.TrimSpace(mem.Content)
+ if content == "" {
+ continue
+ }
+ tags := make([]string, 0, len(mem.Tags))
+ for _, t := range mem.Tags {
+ t = strings.ToLower(strings.TrimSpace(t))
+ if t != "" {
+ tags = append(tags, t)
+ }
+ }
+ if id, err := memory.AddEntry(content, tags); err != nil {
+ logger.WarnCF("reflector", "Failed to save memory",
+ map[string]any{"error": err.Error()})
+ } else {
+ logger.InfoCF("reflector", "Memory extracted",
+ map[string]any{"id": id, "tags": tags, "content": content})
+ }
+ }
+ return nil
+}
diff --git a/pkg/agent/reflector_test.go b/pkg/agent/reflector_test.go
new file mode 100644
index 000000000..8d79a33be
--- /dev/null
+++ b/pkg/agent/reflector_test.go
@@ -0,0 +1,319 @@
+package agent
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// --- Slash command tests ----------------------------------------------------
+
+func TestRuntime_MemoryCommand(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+ r := NewReflector(nil, "")
+
+ // /memory with no args → help.
+ resp, ok := r.HandleCommand("/memory", ms)
+ if !ok {
+ t.Fatal("expected /memory to be handled")
+ }
+ if !strings.Contains(resp, "Usage") {
+ t.Error("expected usage text")
+ }
+
+ // /memory list → empty.
+ resp, ok = r.HandleCommand("/memory list", ms)
+ if !ok {
+ t.Fatal("expected /memory list to be handled")
+ }
+ if !strings.Contains(resp, "No memories") {
+ t.Errorf("expected empty list, got %q", resp)
+ }
+
+ // /memory add.
+ resp, ok = r.HandleCommand("/memory add Go is great for concurrency #golang #concurrency", ms)
+ if !ok {
+ t.Fatal("expected /memory add to be handled")
+ }
+ if !strings.Contains(resp, "✅") {
+ t.Errorf("expected success, got %q", resp)
+ }
+ if !strings.Contains(resp, "golang") {
+ t.Errorf("should show tags, got %q", resp)
+ }
+
+ // /memory list → should have 1 entry.
+ resp, _ = r.HandleCommand("/memory list", ms)
+ if !strings.Contains(resp, "Go is great") {
+ t.Errorf("should show entry, got %q", resp)
+ }
+
+ // /memory search.
+ resp, _ = r.HandleCommand("/memory search golang", ms)
+ if !strings.Contains(resp, "Found 1") {
+ t.Errorf("expected 1 result, got %q", resp)
+ }
+
+ resp, _ = r.HandleCommand("/memory search nonexistent", ms)
+ if !strings.Contains(resp, "No memories found") {
+ t.Errorf("expected no results, got %q", resp)
+ }
+
+ // /memory stats — should show entry count.
+ resp, _ = r.HandleCommand("/memory stats", ms)
+ if !strings.Contains(resp, "Stats") {
+ t.Errorf("expected stats, got %q", resp)
+ }
+ if !strings.Contains(resp, "Total entries: 1") {
+ t.Errorf("expected 1 entry in stats, got %q", resp)
+ }
+
+ // /memory edit.
+ resp, _ = r.HandleCommand("/memory edit 1 Updated content #go", ms)
+ if !strings.Contains(resp, "✅") {
+ t.Errorf("expected success, got %q", resp)
+ }
+ resp, _ = r.HandleCommand("/memory list", ms)
+ if !strings.Contains(resp, "Updated content") {
+ t.Errorf("edit should be reflected, got %q", resp)
+ }
+
+ // /memory delete.
+ resp, _ = r.HandleCommand("/memory delete 1", ms)
+ if !strings.Contains(resp, "✅") {
+ t.Errorf("expected success, got %q", resp)
+ }
+ resp, _ = r.HandleCommand("/memory list", ms)
+ if !strings.Contains(resp, "No memories") {
+ t.Errorf("expected empty after delete, got %q", resp)
+ }
+}
+
+func TestRuntime_HelpCommand(t *testing.T) {
+ r := NewReflector(nil, "")
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ resp, ok := r.HandleCommand("/help", ms)
+ if !ok {
+ t.Fatal("expected /help to be handled")
+ }
+ if !strings.Contains(resp, "/memory") {
+ t.Error("help should list /memory")
+ }
+ if !strings.Contains(resp, "/cot") {
+ t.Error("help should list /cot")
+ }
+ if !strings.Contains(resp, "/show") {
+ t.Error("help should list /show (now a runtime command)")
+ }
+ if !strings.Contains(resp, "/shell") {
+ t.Error("help should list /shell")
+ }
+}
+
+func TestRuntime_ShellSecurity(t *testing.T) {
+ r := NewReflector(nil, "")
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ // Unknown command (not builtin or dev tool).
+ resp, _ := r.HandleCommand("/shell rm -rf /", ms)
+ if !strings.Contains(resp, "Unknown command") {
+ t.Errorf("rm should be unknown, got %q", resp)
+ }
+
+ // Unknown: sudo
+ resp, _ = r.HandleCommand("/shell sudo ls", ms)
+ if !strings.Contains(resp, "Unknown command") {
+ t.Errorf("sudo should be unknown, got %q", resp)
+ }
+
+ // Injection via passthrough: git | bash
+ resp, _ = r.HandleCommand("/shell git log | bash", ms)
+ if !strings.Contains(resp, "blocked") {
+ t.Errorf("injection should be blocked, got %q", resp)
+ }
+
+ // Builtin echo works (cross-platform).
+ resp, _ = r.HandleCommand("/shell echo hello world", ms)
+ if !strings.Contains(resp, "hello world") {
+ t.Errorf("echo should work, got %q", resp)
+ }
+}
+
+func TestRuntime_CotCommand(t *testing.T) {
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+ r := NewReflector(nil, "")
+
+ // /cot with no args → help.
+ resp, ok := r.HandleCommand("/cot", ms)
+ if !ok {
+ t.Fatal("expected /cot to be handled")
+ }
+ if !strings.Contains(resp, "Usage") {
+ t.Error("expected usage text")
+ }
+
+ // /cot stats → empty.
+ resp, _ = r.HandleCommand("/cot stats", ms)
+ if !strings.Contains(resp, "No CoT usage") {
+ t.Errorf("expected empty, got %q", resp)
+ }
+
+ // Add some usage first.
+ ms.RecordCotUsage("code", []string{"golang"}, "1. Think\n2. Code", "write code")
+
+ // /cot history.
+ resp, _ = r.HandleCommand("/cot history", ms)
+ if !strings.Contains(resp, "code") {
+ t.Errorf("expected history entry, got %q", resp)
+ }
+
+ // /cot feedback.
+ resp, _ = r.HandleCommand("/cot feedback 1", ms)
+ if !strings.Contains(resp, "✅") {
+ t.Errorf("expected success, got %q", resp)
+ }
+
+ // /cot feedback bad input.
+ resp, _ = r.HandleCommand("/cot feedback 99", ms)
+ if !strings.Contains(resp, "❌") {
+ t.Errorf("expected error, got %q", resp)
+ }
+}
+
+func TestRuntime_RuntimeCommand(t *testing.T) {
+ r := NewReflector(nil, "")
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ resp, ok := r.HandleCommand("/runtime status", ms)
+ if !ok {
+ t.Fatal("expected /runtime to be handled")
+ }
+ if !strings.Contains(resp, "Processors") {
+ t.Errorf("expected status, got %q", resp)
+ }
+
+ resp, _ = r.HandleCommand("/runtime processors", ms)
+ if !strings.Contains(resp, "error_tracker") {
+ t.Errorf("expected error_tracker processor, got %q", resp)
+ }
+}
+
+func TestRuntime_UnknownCommand(t *testing.T) {
+ r := NewReflector(nil, "")
+
+ // Unknown /cmd → not handled (returns false).
+ _, ok := r.HandleCommand("/unknown_cmd", nil)
+ if ok {
+ t.Error("expected unknown command to not be handled")
+ }
+
+ // Not a command at all.
+ _, ok = r.HandleCommand("hello world", nil)
+ if ok {
+ t.Error("expected non-command to not be handled")
+ }
+}
+
+func TestRuntime_ShellCommand(t *testing.T) {
+ r := NewReflector(nil, "")
+ dir := t.TempDir()
+ ms := NewMemoryStore(dir)
+ defer ms.Close()
+
+ // /shell with no args → help.
+ resp, ok := r.HandleCommand("/shell", ms)
+ if !ok {
+ t.Fatal("expected /shell to be handled")
+ }
+ if !strings.Contains(resp, "Usage") {
+ t.Errorf("expected usage, got %q", resp)
+ }
+
+ // /shell pwd → returns cwd (builtin, no tool registry needed).
+ resp, _ = r.HandleCommand("/shell pwd", ms)
+ if !strings.Contains(resp, string(os.PathSeparator)) {
+ t.Errorf("expected directory path, got %q", resp)
+ }
+
+ // /shell dev tool without registry → warning.
+ resp, _ = r.HandleCommand("/shell git status", ms)
+ if !strings.Contains(resp, "not available") {
+ t.Errorf("expected warning about no registry, got %q", resp)
+ }
+}
+
+// --- Post-LLM processor tests -----------------------------------------------
+
+func TestRuntime_ErrorTracker(t *testing.T) {
+ tracker := &ErrorTracker{}
+ input := RuntimeInput{
+ ToolCalls: []ToolCallRecord{
+ {Name: "exec", Error: "command not found"},
+ {Name: "read_file", Error: ""},
+ },
+ }
+
+ // Should not error.
+ err := tracker.Process(nil, input, nil)
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestRuntime_CotEvaluator_NoCot(t *testing.T) {
+ eval := &CotEvaluator{}
+ input := RuntimeInput{CotPrompt: ""} // No CoT → skip.
+
+ err := eval.Process(nil, input, nil)
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestRuntime_MemoryExtractor_SkipChat(t *testing.T) {
+ extractor := &MemoryExtractor{}
+ input := RuntimeInput{
+ UserMessage: "hello",
+ Intent: "chat",
+ }
+
+ err := extractor.Process(nil, input, nil)
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestRuntime_PostLLM_NilSafety(t *testing.T) {
+ // Nil runtime should not panic.
+ var r *Reflector
+ r.RunPostLLM(RuntimeInput{}, nil) // Should be no-op.
+
+ // Runtime with no processors.
+ r = &Reflector{commands: map[string]CommandDef{}}
+ r.RunPostLLM(RuntimeInput{}, nil) // Should be no-op.
+}
+
+func TestRuntime_ListCommands(t *testing.T) {
+ r := NewReflector(nil, "")
+ text := r.ListCommands()
+ if !strings.Contains(text, "/memory") {
+ t.Error("should list /memory command")
+ }
+ if !strings.Contains(text, "/cot") {
+ t.Error("should list /cot command")
+ }
+ if !strings.Contains(text, "/runtime") {
+ t.Error("should list /runtime command")
+ }
+}
diff --git a/pkg/agent/score.go b/pkg/agent/score.go
new file mode 100644
index 000000000..f153dd878
--- /dev/null
+++ b/pkg/agent/score.go
@@ -0,0 +1,74 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package agent
+
+import "strings"
+
+// CalcTurnScore computes a value score for a completed turn.
+//
+// Scoring rules (range roughly -2 to 15):
+//
+// +3 has tool calls
+// +2 has write/edit/append tool call (modifying tools)
+// +2 tool count > 3
+// +3 intent = task / code / debug
+// +1 intent = question
+// +0 intent = chat (or empty)
+// +2 reply length > 500 chars
+// -2 user + reply total < 80 chars
+// +3 user message contains "记住" or "重要" (remember / important)
+//
+// alwaysKeepThreshold (≥ 7) marks a Turn as always_keep in instant memory.
+func CalcTurnScore(input RuntimeInput) int {
+ score := 0
+
+ // --- Tool activity ---
+ if len(input.ToolCalls) > 0 {
+ score += 3
+ }
+ for _, tc := range input.ToolCalls {
+ n := strings.ToLower(tc.Name)
+ if n == "write_file" || n == "edit_file" || n == "append_file" ||
+ n == "write" || n == "edit" || n == "append" {
+ score += 2
+ break // count once
+ }
+ }
+ if len(input.ToolCalls) > 3 {
+ score += 2
+ }
+
+ // --- Intent weight ---
+ switch strings.ToLower(input.Intent) {
+ case "task", "code", "debug":
+ score += 3
+ case "question":
+ score += 1
+ // "chat" or empty: 0
+ }
+
+ // --- Content density ---
+ if len(input.AssistantReply) > 500 {
+ score += 2
+ }
+ if len(input.UserMessage)+len(input.AssistantReply) < 80 {
+ score -= 2
+ }
+
+ // --- Explicit importance markers ---
+ if strings.Contains(input.UserMessage, "记住") ||
+ strings.Contains(input.UserMessage, "重要") ||
+ strings.Contains(strings.ToLower(input.UserMessage), "remember") ||
+ strings.Contains(strings.ToLower(input.UserMessage), "important") {
+ score += 3
+ }
+
+ return score
+}
+
+// alwaysKeepThreshold is the minimum score for a Turn to be unconditionally
+// included in instant memory (regardless of tag matching).
+const alwaysKeepThreshold = 7
diff --git a/pkg/agent/score_test.go b/pkg/agent/score_test.go
new file mode 100644
index 000000000..09b599b2f
--- /dev/null
+++ b/pkg/agent/score_test.go
@@ -0,0 +1,143 @@
+package agent
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestCalcTurnScore_BasicRules(t *testing.T) {
+ tests := []struct {
+ name string
+ input RuntimeInput
+ wantMin int
+ wantMax int
+ wantExact *int
+ }{
+ {
+ name: "empty chat",
+ input: RuntimeInput{Intent: "chat", UserMessage: "ok", AssistantReply: "ok"},
+ // score = 0 (chat) -2 (< 80 chars total) = -2
+ wantExact: intPtr(-2),
+ },
+ {
+ name: "question intent, short",
+ input: RuntimeInput{Intent: "question", UserMessage: "hi", AssistantReply: "hello"},
+ // score = 1 (question) -2 (short) = -1
+ wantExact: intPtr(-1),
+ },
+ {
+ name: "task with tool call",
+ input: RuntimeInput{
+ Intent: "task",
+ UserMessage: "do something important",
+ AssistantReply: "done",
+ ToolCalls: []ToolCallRecord{{Name: "exec"}},
+ },
+ // +3 (task) +3 (has tool) +3 ("important" keyword) -2 (short) = 7
+ wantExact: intPtr(7),
+ },
+ {
+ name: "code with write tool",
+ input: RuntimeInput{
+ Intent: "code",
+ UserMessage: "fix the bug",
+ AssistantReply: "fixed",
+ ToolCalls: []ToolCallRecord{{Name: "write_file"}},
+ },
+ // +3 (code) +3 (has tool) +2 (write tool) -2 (short) = 6
+ wantExact: intPtr(6),
+ },
+ {
+ name: "many tools",
+ input: RuntimeInput{
+ Intent: "debug",
+ UserMessage: "debug it",
+ AssistantReply: "ok",
+ ToolCalls: []ToolCallRecord{
+ {Name: "exec"},
+ {Name: "read_file"},
+ {Name: "list_dir"},
+ {Name: "exec"},
+ },
+ },
+ // +3 (debug) +3 (has tool) +2 (>3 tools) -2 (short) = 6
+ wantExact: intPtr(6),
+ },
+ {
+ name: "long reply",
+ input: RuntimeInput{
+ Intent: "question",
+ UserMessage: "explain",
+ AssistantReply: strings.Repeat("a", 600),
+ },
+ // +1 (question) +2 (long reply) [total<80 does not apply because reply is 600]
+ // total chars = 7 + 600 = 607 >= 80
+ wantExact: intPtr(3),
+ },
+ {
+ name: "explicit remember keyword",
+ input: RuntimeInput{
+ Intent: "chat",
+ UserMessage: "记住这个地址 localhost:3000",
+ AssistantReply: strings.Repeat("a", 600),
+ },
+ // 0(chat) +3 (记住) +2 (long reply) = 5
+ wantExact: intPtr(5),
+ },
+ {
+ name: "explicit important keyword",
+ input: RuntimeInput{
+ Intent: "question",
+ UserMessage: "this is IMPORTANT: use port 8080",
+ AssistantReply: "ok",
+ },
+ // 1 (question) + 3 (important) - 2 (short) = 2
+ wantExact: intPtr(2),
+ },
+ {
+ name: "always_keep threshold: full scoring",
+ input: RuntimeInput{
+ Intent: "task",
+ UserMessage: "run the deployment pipeline for staging and fix it",
+ AssistantReply: strings.Repeat("a", 600),
+ ToolCalls: []ToolCallRecord{
+ {Name: "edit_file"},
+ {Name: "exec"},
+ {Name: "exec"},
+ {Name: "exec"},
+ },
+ },
+ // +3(task) +3(tool) +2(write/edit) +2(>3 tools) +2(long reply) = 12
+ wantExact: intPtr(12),
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := CalcTurnScore(tc.input)
+ if tc.wantExact != nil {
+ if got != *tc.wantExact {
+ t.Errorf("CalcTurnScore() = %d, want %d", got, *tc.wantExact)
+ }
+ } else if got < tc.wantMin || (tc.wantMax > 0 && got > tc.wantMax) {
+ t.Errorf("CalcTurnScore() = %d, want [%d, %d]", got, tc.wantMin, tc.wantMax)
+ }
+ })
+ }
+}
+
+func TestAlwaysKeepThreshold(t *testing.T) {
+ // High-value turn must meet or exceed the threshold.
+ highValue := RuntimeInput{
+ Intent: "task",
+ UserMessage: "deploy staging",
+ AssistantReply: strings.Repeat("a", 600),
+ ToolCalls: []ToolCallRecord{{Name: "edit_file"}, {Name: "exec"}},
+ }
+ score := CalcTurnScore(highValue)
+ if score < alwaysKeepThreshold {
+ t.Errorf("expected score %d >= alwaysKeepThreshold %d", score, alwaysKeepThreshold)
+ }
+}
+
+func intPtr(i int) *int { return &i }
diff --git a/pkg/agent/turn_store.go b/pkg/agent/turn_store.go
new file mode 100644
index 000000000..f1cb290ad
--- /dev/null
+++ b/pkg/agent/turn_store.go
@@ -0,0 +1,300 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package agent
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+
+ _ "modernc.org/sqlite"
+)
+
+// TurnRecord captures everything that happened during a single completed turn.
+// It is persisted to turns.db for use by MemoryDigest and instant-memory assembly.
+type TurnRecord struct {
+ ID string // ULID or time-based unique ID
+ Ts int64 // Unix timestamp (seconds)
+ ChannelKey string // "channel:chatID"
+ Score int // Phase 3 CalcTurnScore result
+ Intent string // Phase 1 detected intent
+ Tags []string // Phase 1 detected tags
+ Tokens int // rough token estimate (chars / 3)
+ Status string // "pending" | "processed" | "archived"
+ UserMsg string // original user message
+ Reply string // assistant final response
+ ToolCalls []ToolCallRecord // serialised as JSON in DB
+}
+
+// TurnStore manages persistent Turn storage in SQLite.
+// The DB lives at {workspace}/turns.db, mirroring the memory.db pattern.
+type TurnStore struct {
+ db *sql.DB
+}
+
+const turnsDDL = `
+CREATE TABLE IF NOT EXISTS turns (
+ id TEXT PRIMARY KEY,
+ ts INTEGER NOT NULL,
+ channel_key TEXT NOT NULL DEFAULT '',
+ score INTEGER NOT NULL DEFAULT 0,
+ intent TEXT NOT NULL DEFAULT '',
+ tags TEXT NOT NULL DEFAULT '[]',
+ tokens INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'pending',
+ user_msg TEXT NOT NULL DEFAULT '',
+ reply TEXT NOT NULL DEFAULT '',
+ tool_calls TEXT NOT NULL DEFAULT '[]'
+);
+CREATE INDEX IF NOT EXISTS idx_turns_status ON turns(status);
+CREATE INDEX IF NOT EXISTS idx_turns_ts ON turns(ts);
+CREATE INDEX IF NOT EXISTS idx_turns_channel ON turns(channel_key);
+CREATE INDEX IF NOT EXISTS idx_turns_score ON turns(score);
+`
+
+// NewTurnStore creates (or opens) turns.db in the given workspace directory.
+func NewTurnStore(workspace string) (*TurnStore, error) {
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
+ return nil, fmt.Errorf("turn_store: mkdir %s: %w", workspace, err)
+ }
+ dbPath := filepath.Join(workspace, "turns.db")
+ db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)")
+ if err != nil {
+ return nil, fmt.Errorf("turn_store: open %s: %w", dbPath, err)
+ }
+ if _, err := db.Exec(turnsDDL); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("turn_store: init schema: %w", err)
+ }
+ return &TurnStore{db: db}, nil
+}
+
+// Close shuts down the underlying DB connection.
+func (s *TurnStore) Close() error {
+ if s.db != nil {
+ return s.db.Close()
+ }
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+func marshalJSON(v any) string {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return "[]"
+ }
+ return string(b)
+}
+
+func unmarshalTags(raw string) []string {
+ var tags []string
+ _ = json.Unmarshal([]byte(raw), &tags)
+ return tags
+}
+
+func unmarshalToolCalls(raw string) []ToolCallRecord {
+ var tcs []ToolCallRecord
+ _ = json.Unmarshal([]byte(raw), &tcs)
+ return tcs
+}
+
+// estimateTokens gives a cheap estimate: characters / 3.
+func estimateTokens(r TurnRecord) int {
+ chars := len(r.UserMsg) + len(r.Reply)
+ for _, tc := range r.ToolCalls {
+ chars += len(tc.Name) + len(tc.Error)
+ }
+ if chars < 3 {
+ return 1
+ }
+ return chars / 3
+}
+
+// NewTurnID generates a time-sortable unique ID without external dependencies.
+// Format: unixMilli-randomSuffix using millisecond precision.
+func NewTurnID() string {
+ return fmt.Sprintf("%d-%d", time.Now().UnixMilli(), time.Now().Nanosecond()%1_000_000)
+}
+
+// ---------------------------------------------------------------------------
+// Writes
+// ---------------------------------------------------------------------------
+
+// Insert persists a TurnRecord to the DB.
+// The record's ID and Ts are set if empty/zero.
+func (s *TurnStore) Insert(r TurnRecord) error {
+ if r.ID == "" {
+ r.ID = NewTurnID()
+ }
+ if r.Ts == 0 {
+ r.Ts = time.Now().Unix()
+ }
+ if r.Status == "" {
+ r.Status = "pending"
+ }
+ if r.Tokens == 0 {
+ r.Tokens = estimateTokens(r)
+ }
+
+ tagsJSON := marshalJSON(r.Tags)
+ tcJSON := marshalJSON(r.ToolCalls)
+
+ _, err := s.db.Exec(`
+ INSERT INTO turns (id, ts, channel_key, score, intent, tags, tokens, status, user_msg, reply, tool_calls)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO NOTHING`,
+ r.ID, r.Ts, r.ChannelKey, r.Score, r.Intent,
+ tagsJSON, r.Tokens, r.Status, r.UserMsg, r.Reply, tcJSON,
+ )
+ if err != nil {
+ return fmt.Errorf("turn_store: insert %s: %w", r.ID, err)
+ }
+
+ logger.DebugCF("turn_store", "Turn inserted",
+ map[string]any{"id": r.ID, "score": r.Score, "tokens": r.Tokens, "status": r.Status})
+ return nil
+}
+
+// SetStatus updates the status of a turn by ID.
+func (s *TurnStore) SetStatus(id, status string) error {
+ _, err := s.db.Exec("UPDATE turns SET status = ? WHERE id = ?", status, id)
+ return err
+}
+
+// ---------------------------------------------------------------------------
+// Queries — used by MemoryDigest and instant-memory assembly
+// ---------------------------------------------------------------------------
+
+// QueryPending returns up to limit turns with status = 'pending', ordered oldest first.
+func (s *TurnStore) QueryPending(limit int) ([]TurnRecord, error) {
+ rows, err := s.db.Query(`
+ SELECT id, ts, channel_key, score, intent, tags, tokens, status, user_msg, reply, tool_calls
+ FROM turns WHERE status = 'pending'
+ ORDER BY ts ASC LIMIT ?`, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTurns(rows)
+}
+
+// QueryByScore returns all turns with score >= highThreshold (always_keep),
+// ordered by ts ASC.
+func (s *TurnStore) QueryByScore(highThreshold int) ([]TurnRecord, error) {
+ rows, err := s.db.Query(`
+ SELECT id, ts, channel_key, score, intent, tags, tokens, status, user_msg, reply, tool_calls
+ FROM turns WHERE score >= ? AND status != 'archived'
+ ORDER BY ts ASC`, highThreshold)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTurns(rows)
+}
+
+// QueryByTags returns turns whose tags JSON contains at least one of the given tags
+// and score > 0, ordered by ts ASC.
+func (s *TurnStore) QueryByTags(tags []string) ([]TurnRecord, error) {
+ if len(tags) == 0 {
+ return nil, nil
+ }
+ // Build LIKE conditions for simple JSON array matching.
+ conds := make([]string, 0, len(tags))
+ args := make([]any, 0, len(tags)*2)
+ for _, t := range tags {
+ t = strings.ToLower(strings.TrimSpace(t))
+ if t == "" {
+ continue
+ }
+ conds = append(conds, `(tags LIKE ? OR tags LIKE ?)`)
+ args = append(args, `%"`+t+`"%`, `%'`+t+`'%`)
+ }
+ if len(conds) == 0 {
+ return nil, nil
+ }
+ // Append non-archived filter.
+ query := fmt.Sprintf(`
+ SELECT id, ts, channel_key, score, intent, tags, tokens, status, user_msg, reply, tool_calls
+ FROM turns
+ WHERE score > 0 AND status != 'archived' AND (%s)
+ ORDER BY ts ASC`, strings.Join(conds, " OR "))
+
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTurns(rows)
+}
+
+// QueryRecent returns the n most-recent non-archived turns for a channelKey,
+// ordered by ts ASC (oldest first, so they can be appended naturally).
+func (s *TurnStore) QueryRecent(channelKey string, n int) ([]TurnRecord, error) {
+ rows, err := s.db.Query(`
+ SELECT id, ts, channel_key, score, intent, tags, tokens, status, user_msg, reply, tool_calls
+ FROM turns
+ WHERE channel_key = ? AND status != 'archived'
+ ORDER BY ts DESC LIMIT ?`, channelKey, n)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ turns, err := scanTurns(rows)
+ if err != nil {
+ return nil, err
+ }
+ // Reverse to ascending order.
+ for i, j := 0, len(turns)-1; i < j; i, j = i+1, j-1 {
+ turns[i], turns[j] = turns[j], turns[i]
+ }
+ return turns, nil
+}
+
+// ArchiveOldProcessed marks processed turns older than olderThanDays as 'archived'.
+// At most 100 rows are archived per call to limit lock time.
+func (s *TurnStore) ArchiveOldProcessed(olderThanDays int) error {
+ cutoff := time.Now().AddDate(0, 0, -olderThanDays).Unix()
+ _, err := s.db.Exec(`
+ UPDATE turns SET status = 'archived'
+ WHERE id IN (
+ SELECT id FROM turns
+ WHERE status = 'processed' AND ts < ?
+ ORDER BY ts ASC LIMIT 100
+ )`, cutoff)
+ return err
+}
+
+// ---------------------------------------------------------------------------
+// Internal scanner
+// ---------------------------------------------------------------------------
+
+func scanTurns(rows *sql.Rows) ([]TurnRecord, error) {
+ var out []TurnRecord
+ for rows.Next() {
+ var r TurnRecord
+ var tagsJSON, tcJSON string
+ if err := rows.Scan(
+ &r.ID, &r.Ts, &r.ChannelKey, &r.Score, &r.Intent,
+ &tagsJSON, &r.Tokens, &r.Status,
+ &r.UserMsg, &r.Reply, &tcJSON,
+ ); err != nil {
+ return out, err
+ }
+ r.Tags = unmarshalTags(tagsJSON)
+ r.ToolCalls = unmarshalToolCalls(tcJSON)
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
diff --git a/pkg/agent/turn_store_test.go b/pkg/agent/turn_store_test.go
new file mode 100644
index 000000000..bba19de14
--- /dev/null
+++ b/pkg/agent/turn_store_test.go
@@ -0,0 +1,143 @@
+package agent
+
+import (
+ "testing"
+ "time"
+)
+
+func TestTurnStore_InsertAndQueryRecent(t *testing.T) {
+ dir := t.TempDir()
+ store, err := NewTurnStore(dir)
+ if err != nil {
+ t.Fatalf("NewTurnStore: %v", err)
+ }
+ defer store.Close()
+
+ r := TurnRecord{
+ Ts: time.Now().Unix(),
+ ChannelKey: "cli:direct",
+ Score: 5,
+ Intent: "task",
+ Tags: []string{"deploy", "ci"},
+ UserMsg: "deploy now",
+ Reply: "done",
+ ToolCalls: []ToolCallRecord{{Name: "exec", Error: ""}},
+ Status: "pending",
+ }
+
+ if err := store.Insert(r); err != nil {
+ t.Fatalf("Insert: %v", err)
+ }
+
+ rows, err := store.QueryRecent("cli:direct", 10)
+ if err != nil {
+ t.Fatalf("QueryRecent: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("expected 1 row, got %d", len(rows))
+ }
+ if rows[0].Intent != "task" {
+ t.Errorf("unexpected intent: %s", rows[0].Intent)
+ }
+ if len(rows[0].Tags) != 2 {
+ t.Errorf("expected 2 tags, got %v", rows[0].Tags)
+ }
+}
+
+func TestTurnStore_QueryByScore(t *testing.T) {
+ dir := t.TempDir()
+ store, err := NewTurnStore(dir)
+ if err != nil {
+ t.Fatalf("NewTurnStore: %v", err)
+ }
+ defer store.Close()
+
+ now := time.Now().Unix()
+ store.Insert(TurnRecord{ID: "s-1", Ts: now, Score: 3, UserMsg: "a", Reply: "b", Status: "pending"})
+ store.Insert(TurnRecord{ID: "s-2", Ts: now + 1, Score: 8, UserMsg: "c", Reply: "d", Status: "pending"})
+ store.Insert(TurnRecord{ID: "s-3", Ts: now + 2, Score: 9, UserMsg: "e", Reply: "f", Status: "pending"})
+
+ high, err := store.QueryByScore(7)
+ if err != nil {
+ t.Fatalf("QueryByScore: %v", err)
+ }
+ if len(high) != 2 {
+ t.Errorf("expected 2 always_keep turns, got %d", len(high))
+ }
+}
+
+func TestTurnStore_SetStatus(t *testing.T) {
+ dir := t.TempDir()
+ store, err := NewTurnStore(dir)
+ if err != nil {
+ t.Fatalf("NewTurnStore: %v", err)
+ }
+ defer store.Close()
+
+ r := TurnRecord{ID: "test-id-1", Ts: time.Now().Unix(), UserMsg: "x", Reply: "y", Status: "pending"}
+ store.Insert(r)
+
+ if err := store.SetStatus("test-id-1", "processed"); err != nil {
+ t.Fatalf("SetStatus: %v", err)
+ }
+
+ pending, err := store.QueryPending(10)
+ if err != nil {
+ t.Fatalf("QueryPending: %v", err)
+ }
+ if len(pending) != 0 {
+ t.Errorf("expected 0 pending, got %d", len(pending))
+ }
+}
+
+func TestTurnStore_ArchiveOldProcessed(t *testing.T) {
+ dir := t.TempDir()
+ store, err := NewTurnStore(dir)
+ if err != nil {
+ t.Fatalf("NewTurnStore: %v", err)
+ }
+ defer store.Close()
+
+ // Insert old processed turns (timestamp in the past).
+ old := time.Now().AddDate(0, 0, -10).Unix()
+ for i := 0; i < 3; i++ {
+ r := TurnRecord{Ts: old, Score: 2, UserMsg: "old", Reply: "msg", Status: "processed"}
+ store.Insert(r)
+ }
+
+ // Recent processed turn — should NOT be archived.
+ recent := TurnRecord{Ts: time.Now().Unix(), Score: 2, UserMsg: "new", Reply: "msg", Status: "processed"}
+ store.Insert(recent)
+
+ if err := store.ArchiveOldProcessed(7); err != nil {
+ t.Fatalf("ArchiveOldProcessed: %v", err)
+ }
+
+ // Query pending (should still be 0).
+ pending, _ := store.QueryPending(100)
+ if len(pending) != 0 {
+ t.Errorf("expected 0 pending after archive, got %d", len(pending))
+ }
+}
+
+func TestTurnStore_QueryByTags(t *testing.T) {
+ dir := t.TempDir()
+ store, err := NewTurnStore(dir)
+ if err != nil {
+ t.Fatalf("NewTurnStore: %v", err)
+ }
+ defer store.Close()
+
+ now := time.Now().Unix()
+ store.Insert(TurnRecord{ID: "tag-1", Ts: now, Score: 5, Tags: []string{"deploy", "ci"}, UserMsg: "a", Reply: "b"})
+ store.Insert(TurnRecord{ID: "tag-2", Ts: now + 1, Score: 4, Tags: []string{"file", "read"}, UserMsg: "c", Reply: "d"})
+ store.Insert(TurnRecord{ID: "tag-3", Ts: now + 2, Score: 3, Tags: []string{"deploy", "log"}, UserMsg: "e", Reply: "f"})
+
+ rows, err := store.QueryByTags([]string{"deploy"})
+ if err != nil {
+ t.Fatalf("QueryByTags: %v", err)
+ }
+ if len(rows) < 2 {
+ t.Errorf("expected at least 2 deploy turns, got %d", len(rows))
+ }
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 9f4769de4..a1dde2478 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -51,13 +51,20 @@ type Config struct {
Agents AgentsConfig `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"`
Session SessionConfig `json:"session,omitempty"`
- Channels ChannelsConfig `json:"channels"`
+ Channels ChannelsConfig `json:"channels,omitempty"`
Providers ProvidersConfig `json:"providers,omitempty"`
- ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
- Gateway GatewayConfig `json:"gateway"`
- Tools ToolsConfig `json:"tools"`
- Heartbeat HeartbeatConfig `json:"heartbeat"`
- Devices DevicesConfig `json:"devices"`
+ ModelList []ModelConfig `json:"model_list,omitempty"`
+ Gateway GatewayConfig `json:"gateway,omitempty"`
+ Tools ToolsConfig `json:"tools,omitempty"`
+ Heartbeat HeartbeatConfig `json:"heartbeat,omitempty"`
+ Devices DevicesConfig `json:"devices,omitempty"`
+ Logging LoggingConfig `json:"logging,omitempty"`
+}
+
+// LoggingConfig controls log output.
+type LoggingConfig struct {
+ Level string `json:"level,omitempty"` // debug, info, warn, error (default: warn)
+ FileDir string `json:"file_dir,omitempty"` // directory for log files; empty = no file logging
}
// MarshalJSON implements custom JSON marshaling for Config
@@ -175,6 +182,16 @@ type AgentDefaults struct {
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
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
+
+ // Phase 1 — Analyser: lightweight model for intent/tag analysis + CoT strategy.
+ // Falls back to main model_name if empty. Use a cheap/fast model here.
+ AnalyserModel string `json:"analyser_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ANALYSER_MODEL"`
+ PreLLMModel string `json:"pre_llm_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PRE_LLM_MODEL"` // Deprecated: use analyser_model
+
+ // Phase 3 — Digest: lightweight model for memory extraction from turn records.
+ // Falls back to main model_name if empty. Use a cheap/fast model here.
+ DigestModel string `json:"digest_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_DIGEST_MODEL"`
+
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"`
@@ -191,6 +208,27 @@ func (d *AgentDefaults) GetModelName() string {
return d.Model
}
+// GetAnalyserModel returns the model for Phase 1 (Analyser).
+// Priority: analyser_model → pre_llm_model (deprecated) → main model.
+func (d *AgentDefaults) GetAnalyserModel() string {
+ if d.AnalyserModel != "" {
+ return d.AnalyserModel
+ }
+ if d.PreLLMModel != "" {
+ return d.PreLLMModel
+ }
+ return d.GetModelName()
+}
+
+// GetDigestModel returns the model for Phase 3 (MemoryDigest).
+// Priority: digest_model → main model.
+func (d *AgentDefaults) GetDigestModel() string {
+ if d.DigestModel != "" {
+ return d.DigestModel
+ }
+ return d.GetModelName()
+}
+
type ChannelsConfig struct {
WhatsApp WhatsAppConfig `json:"whatsapp"`
Telegram TelegramConfig `json:"telegram"`
diff --git a/pkg/config/onboard.go b/pkg/config/onboard.go
new file mode 100644
index 000000000..3396f5737
--- /dev/null
+++ b/pkg/config/onboard.go
@@ -0,0 +1,30 @@
+package config
+
+// MinimalOnboardConfig produces a stripped-down config for initial onboarding.
+// It keeps only the essentials, omitting empty channels, providers, and
+// model_list entries without API keys.
+func MinimalOnboardConfig(full *Config) *Config {
+ // Filter model_list: only keep entries that have an API key set,
+ // or special auth (e.g., OAuth, Ollama local).
+ var models []ModelConfig
+ for _, m := range full.ModelList {
+ if m.APIKey != "" || m.AuthMethod != "" {
+ models = append(models, m)
+ }
+ }
+
+ return &Config{
+ Agents: AgentsConfig{
+ Defaults: full.Agents.Defaults,
+ },
+ Session: full.Session,
+ ModelList: models,
+ Gateway: full.Gateway,
+ Tools: ToolsConfig{
+ Exec: full.Tools.Exec,
+ Web: WebToolsConfig{
+ DuckDuckGo: full.Tools.Web.DuckDuckGo,
+ },
+ },
+ }
+}
diff --git a/pkg/constants/channels.go b/pkg/constants/channels.go
index 0a46e6cd9..4e635d44d 100644
--- a/pkg/constants/channels.go
+++ b/pkg/constants/channels.go
@@ -1,16 +1,27 @@
// Package constants provides shared constants across the codebase.
package constants
+import "strings"
+
// internalChannels defines channels that are used for internal communication
// and should not be exposed to external users or recorded as last active channel.
var internalChannels = map[string]struct{}{
"cli": {},
"system": {},
"subagent": {},
+ "launcher": {},
}
// IsInternalChannel returns true if the channel is an internal channel.
+// Supports compound names like "launcher:chat" by checking the prefix before ":".
func IsInternalChannel(channel string) bool {
- _, found := internalChannels[channel]
- return found
+ if _, found := internalChannels[channel]; found {
+ return true
+ }
+ // Check prefix for compound channel names (e.g. "launcher:chat")
+ if idx := strings.IndexByte(channel, ':'); idx > 0 {
+ _, found := internalChannels[channel[:idx]]
+ return found
+ }
+ return false
}
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
index 56dc87a53..f2bbbce9c 100644
--- a/pkg/logger/logger.go
+++ b/pkg/logger/logger.go
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
+ "path/filepath"
"runtime"
"strings"
"sync"
@@ -30,7 +31,7 @@ var (
FATAL: "FATAL",
}
- currentLevel = INFO
+ currentLevel = WARN
logger *Logger
once sync.Once
mu sync.RWMutex
@@ -61,6 +62,32 @@ func SetLevel(level LogLevel) {
currentLevel = level
}
+// SetLevelByName sets log level from a string: "debug", "info", "warn", "error".
+func SetLevelByName(name string) {
+ switch strings.ToLower(strings.TrimSpace(name)) {
+ case "debug":
+ SetLevel(DEBUG)
+ case "info":
+ SetLevel(INFO)
+ case "warn", "warning":
+ SetLevel(WARN)
+ case "error":
+ SetLevel(ERROR)
+ }
+}
+
+// ApplyConfig sets level and file logging from config values.
+func ApplyConfig(level, fileDir string) {
+ if level != "" {
+ SetLevelByName(level)
+ }
+ if fileDir != "" {
+ logFile := filepath.Join(fileDir, "picoclaw.log")
+ os.MkdirAll(fileDir, 0755)
+ _ = EnableFileLogging(logFile)
+ }
+}
+
func GetLevel() LogLevel {
mu.RLock()
defer mu.RUnlock()
diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go
index 99f13334e..23b09ad26 100644
--- a/pkg/providers/protocoltypes/types.go
+++ b/pkg/providers/protocoltypes/types.go
@@ -66,7 +66,8 @@ type Message struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
- SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
+ SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
+ CacheControl string `json:"cache_control,omitempty"` // "ephemeral" | "", Anthropic adapter translates
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
diff --git a/pkg/shell/commands.go b/pkg/shell/commands.go
new file mode 100644
index 000000000..d1e36dbdb
--- /dev/null
+++ b/pkg/shell/commands.go
@@ -0,0 +1,732 @@
+package shell
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// CmdFunc is the signature for a built-in shell command.
+// It receives the arguments (after the command name) and the working directory.
+type CmdFunc func(args []string, cwd string) string
+
+// BuiltinCmds maps command names to their Go implementations.
+// These run cross-platform without external dependencies.
+var BuiltinCmds = map[string]CmdFunc{
+ "ls": cmdLs,
+ "dir": cmdLs,
+ "cat": cmdCat,
+ "type": cmdCat,
+ "head": cmdHead,
+ "tail": cmdTail,
+ "grep": cmdGrep,
+ "wc": cmdWc,
+ "find": cmdFind,
+ "pwd": cmdPwd,
+ "echo": cmdEcho,
+ "stat": cmdStat,
+ "diff": cmdDiff,
+ "tree": cmdTree,
+ "touch": cmdTouch,
+ "mkdir": cmdMkdir,
+ "cp": cmdCp,
+ "mv": cmdMv,
+}
+
+// DevToolPassthrough lists commands that pass through to the system shell.
+var DevToolPassthrough = map[string]bool{
+ "go": true, "git": true, "node": true, "python": true, "python3": true,
+ "npm": true, "npx": true, "cargo": true, "make": true,
+ "jq": true, "rg": true, "ag": true, "ack": true, "fd": true,
+}
+
+// ---------------------------------------------------------------------------
+// ls / dir
+// ---------------------------------------------------------------------------
+
+func cmdLs(args []string, cwd string) string {
+ dir := cwd
+ showAll := false
+ longFmt := false
+
+ for _, a := range args {
+ switch {
+ case a == "-a":
+ showAll = true
+ case a == "-l":
+ longFmt = true
+ case a == "-la" || a == "-al":
+ showAll = true
+ longFmt = true
+ case !strings.HasPrefix(a, "-"):
+ dir = ResolvePath(a, cwd)
+ }
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return fmt.Sprintf("ls: %v", err)
+ }
+
+ var sb strings.Builder
+ for _, e := range entries {
+ name := e.Name()
+ if !showAll && strings.HasPrefix(name, ".") {
+ continue
+ }
+ if longFmt {
+ info, _ := e.Info()
+ if info != nil {
+ mode := info.Mode().String()
+ size := info.Size()
+ mod := info.ModTime().Format("Jan 02 15:04")
+ if e.IsDir() {
+ name += "/"
+ }
+ fmt.Fprintf(&sb, "%s %8d %s %s\n", mode, size, mod, name)
+ } else {
+ fmt.Fprintf(&sb, "%s\n", name)
+ }
+ } else {
+ if e.IsDir() {
+ name += "/"
+ }
+ sb.WriteString(name + "\n")
+ }
+ }
+ if sb.Len() == 0 {
+ return "(empty directory)"
+ }
+ return sb.String()
+}
+
+// ---------------------------------------------------------------------------
+// cat / type
+// ---------------------------------------------------------------------------
+
+func cmdCat(args []string, cwd string) string {
+ if len(args) == 0 {
+ return "cat: missing file operand"
+ }
+ var sb strings.Builder
+ for _, f := range args {
+ if strings.HasPrefix(f, "-") {
+ continue
+ }
+ data, err := os.ReadFile(ResolvePath(f, cwd))
+ if err != nil {
+ fmt.Fprintf(&sb, "cat: %v\n", err)
+ continue
+ }
+ sb.Write(data)
+ }
+ return sb.String()
+}
+
+// ---------------------------------------------------------------------------
+// head
+// ---------------------------------------------------------------------------
+
+func cmdHead(args []string, cwd string) string {
+ n := 10
+ var file string
+ for i := 0; i < len(args); i++ {
+ if args[i] == "-n" && i+1 < len(args) {
+ n, _ = strconv.Atoi(args[i+1])
+ i++
+ } else if !strings.HasPrefix(args[i], "-") {
+ file = args[i]
+ }
+ }
+ if file == "" {
+ return "head: missing file"
+ }
+ data, err := os.ReadFile(ResolvePath(file, cwd))
+ if err != nil {
+ return fmt.Sprintf("head: %v", err)
+ }
+ lines := strings.SplitN(string(data), "\n", n+1)
+ if len(lines) > n {
+ lines = lines[:n]
+ }
+ return strings.Join(lines, "\n")
+}
+
+// ---------------------------------------------------------------------------
+// tail
+// ---------------------------------------------------------------------------
+
+func cmdTail(args []string, cwd string) string {
+ n := 10
+ var file string
+ for i := 0; i < len(args); i++ {
+ if args[i] == "-n" && i+1 < len(args) {
+ n, _ = strconv.Atoi(args[i+1])
+ i++
+ } else if !strings.HasPrefix(args[i], "-") {
+ file = args[i]
+ }
+ }
+ if file == "" {
+ return "tail: missing file"
+ }
+ data, err := os.ReadFile(ResolvePath(file, cwd))
+ if err != nil {
+ return fmt.Sprintf("tail: %v", err)
+ }
+ lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
+ start := len(lines) - n
+ if start < 0 {
+ start = 0
+ }
+ return strings.Join(lines[start:], "\n")
+}
+
+// ---------------------------------------------------------------------------
+// grep
+// ---------------------------------------------------------------------------
+
+func cmdGrep(args []string, cwd string) string {
+ ignoreCase := false
+ showLineNum := false
+ recursive := false
+ var pattern string
+ var paths []string
+
+ for i := 0; i < len(args); i++ {
+ a := args[i]
+ if strings.HasPrefix(a, "-") && pattern == "" {
+ for _, ch := range a[1:] {
+ switch ch {
+ case 'i':
+ ignoreCase = true
+ case 'n':
+ showLineNum = true
+ case 'r', 'R':
+ recursive = true
+ }
+ }
+ } else if pattern == "" {
+ pattern = a
+ } else {
+ paths = append(paths, a)
+ }
+ }
+
+ if pattern == "" {
+ return "grep: missing pattern"
+ }
+ if len(paths) == 0 {
+ paths = []string{"."}
+ }
+
+ pat := pattern
+ if ignoreCase {
+ pat = "(?i)" + pat
+ }
+ re, err := regexp.Compile(pat)
+ if err != nil {
+ return fmt.Sprintf("grep: invalid pattern: %v", err)
+ }
+
+ var sb strings.Builder
+ matchCount := 0
+ maxMatches := 200
+
+ var searchFile func(path string)
+ searchFile = func(path string) {
+ if matchCount >= maxMatches {
+ return
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return
+ }
+ if IsBinary(data) {
+ return
+ }
+ relPath, _ := filepath.Rel(cwd, path)
+ if relPath == "" {
+ relPath = path
+ }
+ lines := strings.Split(string(data), "\n")
+ for i, line := range lines {
+ if matchCount >= maxMatches {
+ break
+ }
+ if re.MatchString(line) {
+ matchCount++
+ if showLineNum {
+ fmt.Fprintf(&sb, "%s:%d:%s\n", relPath, i+1, line)
+ } else {
+ fmt.Fprintf(&sb, "%s:%s\n", relPath, line)
+ }
+ }
+ }
+ }
+
+ skipDirs := map[string]bool{".git": true, "node_modules": true, "vendor": true, "__pycache__": true}
+
+ for _, p := range paths {
+ resolved := ResolvePath(p, cwd)
+ info, err := os.Stat(resolved)
+ if err != nil {
+ fmt.Fprintf(&sb, "grep: %v\n", err)
+ continue
+ }
+ if info.IsDir() {
+ if !recursive {
+ fmt.Fprintf(&sb, "grep: %s: is a directory\n", p)
+ continue
+ }
+ _ = filepath.Walk(resolved, func(path string, fi os.FileInfo, err error) error {
+ if err != nil {
+ return nil
+ }
+ if fi.IsDir() {
+ if skipDirs[fi.Name()] || strings.HasPrefix(fi.Name(), ".") {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ searchFile(path)
+ return nil
+ })
+ } else {
+ searchFile(resolved)
+ }
+ }
+
+ if matchCount == 0 {
+ return "(no matches)"
+ }
+ if matchCount >= maxMatches {
+ fmt.Fprintf(&sb, "\n... (truncated at %d matches)\n", maxMatches)
+ }
+ return sb.String()
+}
+
+// ---------------------------------------------------------------------------
+// wc
+// ---------------------------------------------------------------------------
+
+func cmdWc(args []string, cwd string) string {
+ countLines := false
+ countWords := false
+ countBytes := false
+ var files []string
+
+ for _, a := range args {
+ if strings.HasPrefix(a, "-") {
+ for _, ch := range a[1:] {
+ switch ch {
+ case 'l':
+ countLines = true
+ case 'w':
+ countWords = true
+ case 'c':
+ countBytes = true
+ }
+ }
+ } else {
+ files = append(files, a)
+ }
+ }
+ if !countLines && !countWords && !countBytes {
+ countLines, countWords, countBytes = true, true, true
+ }
+ if len(files) == 0 {
+ return "wc: missing file"
+ }
+
+ var sb strings.Builder
+ totalL, totalW, totalB := 0, 0, 0
+
+ for _, f := range files {
+ data, err := os.ReadFile(ResolvePath(f, cwd))
+ if err != nil {
+ fmt.Fprintf(&sb, "wc: %v\n", err)
+ continue
+ }
+ l := strings.Count(string(data), "\n")
+ w := len(strings.Fields(string(data)))
+ b := len(data)
+ totalL += l
+ totalW += w
+ totalB += b
+
+ var parts []string
+ if countLines {
+ parts = append(parts, fmt.Sprintf("%7d", l))
+ }
+ if countWords {
+ parts = append(parts, fmt.Sprintf("%7d", w))
+ }
+ if countBytes {
+ parts = append(parts, fmt.Sprintf("%7d", b))
+ }
+ fmt.Fprintf(&sb, "%s %s\n", strings.Join(parts, ""), f)
+ }
+
+ if len(files) > 1 {
+ var parts []string
+ if countLines {
+ parts = append(parts, fmt.Sprintf("%7d", totalL))
+ }
+ if countWords {
+ parts = append(parts, fmt.Sprintf("%7d", totalW))
+ }
+ if countBytes {
+ parts = append(parts, fmt.Sprintf("%7d", totalB))
+ }
+ fmt.Fprintf(&sb, "%s total\n", strings.Join(parts, ""))
+ }
+ return sb.String()
+}
+
+// ---------------------------------------------------------------------------
+// find
+// ---------------------------------------------------------------------------
+
+func cmdFind(args []string, cwd string) string {
+ dir := cwd
+ namePattern := ""
+ typeFilter := ""
+
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "-name":
+ if i+1 < len(args) {
+ namePattern = args[i+1]
+ i++
+ }
+ case "-type":
+ if i+1 < len(args) {
+ typeFilter = args[i+1]
+ i++
+ }
+ default:
+ if !strings.HasPrefix(args[i], "-") && namePattern == "" {
+ dir = ResolvePath(args[i], cwd)
+ }
+ }
+ }
+
+ skipDirs := map[string]bool{".git": true, "node_modules": true, "vendor": true}
+ var sb strings.Builder
+ count := 0
+ maxResults := 200
+
+ _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil || count >= maxResults {
+ return nil
+ }
+ name := info.Name()
+ if info.IsDir() && skipDirs[name] {
+ return filepath.SkipDir
+ }
+ if strings.HasPrefix(name, ".") && path != dir {
+ if info.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if typeFilter == "f" && info.IsDir() {
+ return nil
+ }
+ if typeFilter == "d" && !info.IsDir() {
+ return nil
+ }
+ if namePattern != "" {
+ matched, _ := filepath.Match(namePattern, name)
+ if !matched {
+ return nil
+ }
+ }
+ rel, _ := filepath.Rel(cwd, path)
+ if rel == "" {
+ rel = path
+ }
+ sb.WriteString(rel + "\n")
+ count++
+ return nil
+ })
+
+ if count == 0 {
+ return "(no matches)"
+ }
+ if count >= maxResults {
+ fmt.Fprintf(&sb, "... (truncated at %d results)\n", maxResults)
+ }
+ return sb.String()
+}
+
+// ---------------------------------------------------------------------------
+// pwd / echo / stat
+// ---------------------------------------------------------------------------
+
+func cmdPwd(_ []string, cwd string) string { return cwd }
+
+func cmdEcho(args []string, _ string) string { return strings.Join(args, " ") }
+
+func cmdStat(args []string, cwd string) string {
+ if len(args) == 0 {
+ return "stat: missing file"
+ }
+ var sb strings.Builder
+ for _, f := range args {
+ info, err := os.Stat(ResolvePath(f, cwd))
+ if err != nil {
+ fmt.Fprintf(&sb, "stat: %v\n", err)
+ continue
+ }
+ fmt.Fprintf(&sb, " File: %s\n", f)
+ fmt.Fprintf(&sb, " Size: %d bytes\n", info.Size())
+ fmt.Fprintf(&sb, " Mode: %s\n", info.Mode())
+ fmt.Fprintf(&sb, " Modified: %s\n", info.ModTime().Format(time.RFC3339))
+ if info.IsDir() {
+ sb.WriteString(" Type: directory\n")
+ } else {
+ sb.WriteString(" Type: regular file\n")
+ }
+ sb.WriteString("\n")
+ }
+ return sb.String()
+}
+
+// ---------------------------------------------------------------------------
+// diff
+// ---------------------------------------------------------------------------
+
+func cmdDiff(args []string, cwd string) string {
+ if len(args) < 2 {
+ return "diff: need two files"
+ }
+ data1, err := os.ReadFile(ResolvePath(args[0], cwd))
+ if err != nil {
+ return fmt.Sprintf("diff: %v", err)
+ }
+ data2, err := os.ReadFile(ResolvePath(args[1], cwd))
+ if err != nil {
+ return fmt.Sprintf("diff: %v", err)
+ }
+
+ lines1 := strings.Split(string(data1), "\n")
+ lines2 := strings.Split(string(data2), "\n")
+
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "--- %s\n+++ %s\n", args[0], args[1])
+
+ maxLen := len(lines1)
+ if len(lines2) > maxLen {
+ maxLen = len(lines2)
+ }
+
+ diffs := 0
+ for i := 0; i < maxLen; i++ {
+ var l1, l2 string
+ if i < len(lines1) {
+ l1 = lines1[i]
+ }
+ if i < len(lines2) {
+ l2 = lines2[i]
+ }
+ if l1 != l2 {
+ diffs++
+ if diffs > 100 {
+ sb.WriteString("... (too many differences)\n")
+ break
+ }
+ fmt.Fprintf(&sb, "@@ line %d @@\n", i+1)
+ if l1 != "" {
+ fmt.Fprintf(&sb, "-%s\n", l1)
+ }
+ if l2 != "" {
+ fmt.Fprintf(&sb, "+%s\n", l2)
+ }
+ }
+ }
+
+ if diffs == 0 {
+ return "Files are identical"
+ }
+ return sb.String()
+}
+
+// ---------------------------------------------------------------------------
+// tree
+// ---------------------------------------------------------------------------
+
+func cmdTree(args []string, cwd string) string {
+ dir := cwd
+ if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
+ dir = ResolvePath(args[0], cwd)
+ }
+
+ skipDirs := map[string]bool{".git": true, "node_modules": true, "vendor": true, "__pycache__": true}
+ var sb strings.Builder
+ sb.WriteString(dir + "\n")
+ count := 0
+ maxEntries := 300
+
+ var walk func(path, prefix string)
+ walk = func(path, prefix string) {
+ if count >= maxEntries {
+ return
+ }
+ entries, err := os.ReadDir(path)
+ if err != nil {
+ return
+ }
+ var visible []os.DirEntry
+ for _, e := range entries {
+ if !strings.HasPrefix(e.Name(), ".") && !skipDirs[e.Name()] {
+ visible = append(visible, e)
+ }
+ }
+ sort.Slice(visible, func(i, j int) bool { return visible[i].Name() < visible[j].Name() })
+ for i, e := range visible {
+ if count >= maxEntries {
+ sb.WriteString(prefix + "... (truncated)\n")
+ return
+ }
+ count++
+ connector := "鈹溾攢鈹€ "
+ childPrefix := prefix + "鈹? "
+ if i == len(visible)-1 {
+ connector = "鈹斺攢鈹€ "
+ childPrefix = prefix + " "
+ }
+ sb.WriteString(prefix + connector + e.Name())
+ if e.IsDir() {
+ sb.WriteString("/\n")
+ walk(filepath.Join(path, e.Name()), childPrefix)
+ } else {
+ sb.WriteString("\n")
+ }
+ }
+ }
+
+ walk(dir, "")
+ return sb.String()
+}
+
+// ---------------------------------------------------------------------------
+// touch / mkdir / cp / mv
+// ---------------------------------------------------------------------------
+
+func cmdTouch(args []string, cwd string) string {
+ if len(args) == 0 {
+ return "touch: missing file"
+ }
+ for _, f := range args {
+ if strings.HasPrefix(f, "-") {
+ continue
+ }
+ p := ResolvePath(f, cwd)
+ if _, err := os.Stat(p); os.IsNotExist(err) {
+ if err := os.WriteFile(p, []byte{}, 0644); err != nil {
+ return fmt.Sprintf("touch: %v", err)
+ }
+ } else {
+ now := time.Now()
+ _ = os.Chtimes(p, now, now)
+ }
+ }
+ return fmt.Sprintf("touched %d file(s)", len(args))
+}
+
+func cmdMkdir(args []string, cwd string) string {
+ if len(args) == 0 {
+ return "mkdir: missing directory"
+ }
+ mkParents := false
+ var dirs []string
+ for _, a := range args {
+ if a == "-p" {
+ mkParents = true
+ } else {
+ dirs = append(dirs, a)
+ }
+ }
+ for _, d := range dirs {
+ p := ResolvePath(d, cwd)
+ var err error
+ if mkParents {
+ err = os.MkdirAll(p, 0755)
+ } else {
+ err = os.Mkdir(p, 0755)
+ }
+ if err != nil {
+ return fmt.Sprintf("mkdir: %v", err)
+ }
+ }
+ return fmt.Sprintf("created %d dir(s)", len(dirs))
+}
+
+func cmdCp(args []string, cwd string) string {
+ if len(args) < 2 {
+ return "cp: need source and destination"
+ }
+ src := ResolvePath(args[0], cwd)
+ dst := ResolvePath(args[1], cwd)
+
+ data, err := os.ReadFile(src)
+ if err != nil {
+ return fmt.Sprintf("cp: %v", err)
+ }
+ if info, err := os.Stat(dst); err == nil && info.IsDir() {
+ dst = filepath.Join(dst, filepath.Base(src))
+ }
+ if err := os.WriteFile(dst, data, 0644); err != nil {
+ return fmt.Sprintf("cp: %v", err)
+ }
+ return fmt.Sprintf("copied %s -> %s", args[0], filepath.Base(dst))
+}
+
+func cmdMv(args []string, cwd string) string {
+ if len(args) < 2 {
+ return "mv: need source and destination"
+ }
+ src := ResolvePath(args[0], cwd)
+ dst := ResolvePath(args[1], cwd)
+
+ if info, err := os.Stat(dst); err == nil && info.IsDir() {
+ dst = filepath.Join(dst, filepath.Base(src))
+ }
+ if err := os.Rename(src, dst); err != nil {
+ return fmt.Sprintf("mv: %v", err)
+ }
+ return fmt.Sprintf("moved %s -> %s", args[0], filepath.Base(dst))
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+// ResolvePath resolves a path relative to cwd.
+func ResolvePath(path, cwd string) string {
+ if filepath.IsAbs(path) {
+ return filepath.Clean(path)
+ }
+ return filepath.Join(cwd, path)
+}
+
+// IsBinary checks if the first 512 bytes contain null bytes.
+func IsBinary(data []byte) bool {
+ check := data
+ if len(check) > 512 {
+ check = check[:512]
+ }
+ for _, b := range check {
+ if b == 0 {
+ return true
+ }
+ }
+ return false
+}
\ No newline at end of file