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
+
+
🤖 Models
+
+
+
+
📡 Channels
+
Telegram
+
Discord
+
Slack
+
WeCom
+
+
+
+
+
🧠 Agent Defaults
+
+
+ +
+
+
📁 Workspace
+
+
Restrict to workspace
+
Allow read outside workspace
+
+
+
+
Raw JSON Config +
+
+
+ +
+
+
+
+ + + + + + \ 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