feat: Implement core agent logic, memory management, and a new Wails-based launcher with extensive testing and design documentation.

This commit is contained in:
mingzhi1 2026-03-03 10:06:27 +08:00
parent d5370c9605
commit bf32735a03
55 changed files with 10632 additions and 913 deletions

120
Taskfile.yml Normal file
View file

@ -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}}"

View file

@ -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
}

File diff suppressed because it is too large Load diff

View file

@ -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<Record<string, any>>;
export function GetGatewayStatus():Promise<main.GatewayStatus>;
export function GetLogs(arg1:number):Promise<Record<string, any>>;
export function GetSetupStatus():Promise<main.SetupStatusResult>;
export function SaveConfig(arg1:string):Promise<void>;
export function SaveSetup(arg1:main.TestLLMRequest):Promise<main.SaveSetupResult>;
export function SendChat(arg1:string):Promise<main.ChatResult>;
export function StartGateway():Promise<string>;
export function StopGateway():Promise<string>;
export function TestLLM(arg1:main.TestLLMRequest):Promise<main.TestLLMResult>;

View file

@ -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);
}

View file

@ -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"];
}
}
}

View file

@ -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 <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

View file

@ -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<boolean>;
// [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<Size>;
// [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<Position>;
// [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<boolean>;
// [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<boolean>;
// [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<boolean>;
// [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<Screen[]>;
// [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<EnvironmentInfo>;
// [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<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [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

View file

@ -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);
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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())
}

View file

@ -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
}

View file

@ -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) {}

View file

@ -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}
}

View file

@ -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
})
}

View file

@ -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"
}
}

View file

@ -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)

View file

@ -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(

View file

@ -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)
}
}

View file

@ -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 <provider>]",
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 <key> --model <model> [--base-url <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 <provider>",
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"
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}

View file

@ -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(),

23
go.mod
View file

@ -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

50
go.sum
View file

@ -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=

233
pkg/agent/active_context.go Normal file
View file

@ -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
}

View file

@ -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")
}
}

282
pkg/agent/analyser.go Normal file
View file

@ -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":"<intent_label>","tags":["<tag1>"],"cot_prompt":"<thinking_strategy_text>"}
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
}

286
pkg/agent/analyser_test.go Normal file
View file

@ -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"
}

View file

@ -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
}

View file

@ -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)

View file

@ -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")
}
}

287
pkg/agent/cot_templates.go Normal file
View file

@ -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})
}
}

View file

@ -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")
}
}

596
pkg/agent/executor.go Normal file
View file

@ -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
}

View file

@ -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,
}
}

252
pkg/agent/instant_memory.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)
}
}

File diff suppressed because it is too large Load diff

View file

@ -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})
}
}

280
pkg/agent/memory_digest.go Normal file
View file

@ -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": "<fact>", "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
}

969
pkg/agent/reflector.go Normal file
View file

@ -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 <cmd> [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 <name>",
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 <text> #tags — add a memory\n" +
" /memory delete <id> — delete a memory\n" +
" /memory edit <id> <text> — edit a memory\n" +
" /memory search <query> — 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 <text> #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 <tag1> [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 <id>"
}
var id int64
if _, err := fmt.Sscanf(args[1], "%d", &id); err != nil {
return "❌ Invalid ID. Usage: /memory delete <id>"
}
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 <id> <new content> #tags"
}
var id int64
if _, err := fmt.Sscanf(args[1], "%d", &id); err != nil {
return "❌ Invalid ID. Usage: /memory edit <id> <text>"
}
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 <command> [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 <name>"
}
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": "<fact>", "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
}

319
pkg/agent/reflector_test.go Normal file
View file

@ -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")
}
}

74
pkg/agent/score.go Normal file
View file

@ -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

143
pkg/agent/score_test.go Normal file
View file

@ -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 }

300
pkg/agent/turn_store.go Normal file
View file

@ -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()
}

View file

@ -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))
}
}

View file

@ -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"`

30
pkg/config/onboard.go Normal file
View file

@ -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,
},
},
}
}

View file

@ -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
}

View file

@ -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()

View file

@ -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"`
}

732
pkg/shell/commands.go Normal file
View file

@ -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
}