From 6b66da52f09f70ca9c640663ba33af706758ce89 Mon Sep 17 00:00:00 2001 From: A Magno Date: Mon, 2 Mar 2026 12:34:04 +0100 Subject: [PATCH] feat: add setup-token auth and send_file tool - Add Anthropic setup-token (sk-ant-oat01-*) authentication flow - Add send_file tool for delivering files/images to chat channels - Support beta headers for Claude Code API compatibility - Add streaming fallback when beta headers are present - Fix tool call argument parsing in Anthropic provider Made-with: Cursor --- cmd/picoclaw/internal/auth/helpers.go | 50 ++++++++++- cmd/picoclaw/internal/auth/login.go | 4 +- pkg/agent/loop.go | 47 ++++++++-- pkg/auth/token.go | 32 +++++++ pkg/providers/anthropic/provider.go | 52 ++++++++++- pkg/providers/claude_provider.go | 8 ++ pkg/providers/factory.go | 4 +- pkg/providers/factory_provider.go | 5 +- pkg/tools/send_file.go | 103 +++++++++++++++++++++ readme-magno.md | 125 ++++++++++++++++++++++++++ 10 files changed, 419 insertions(+), 11 deletions(-) create mode 100644 pkg/tools/send_file.go create mode 100644 readme-magno.md diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go index 633ce8740..8b10798c7 100644 --- a/cmd/picoclaw/internal/auth/helpers.go +++ b/cmd/picoclaw/internal/auth/helpers.go @@ -17,11 +17,14 @@ import ( const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" -func authLoginCmd(provider string, useDeviceCode bool) error { +func authLoginCmd(provider string, useDeviceCode, useSetupToken bool) error { switch provider { case "openai": return authLoginOpenAI(useDeviceCode) case "anthropic": + if useSetupToken { + return authLoginSetupToken() + } return authLoginPasteToken(provider) case "google-antigravity", "antigravity": return authLoginGoogleAntigravity() @@ -259,6 +262,51 @@ func authLoginPasteToken(provider string) error { return nil } +func authLoginSetupToken() error { + cred, err := auth.LoginSetupToken("anthropic", os.Stdin) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + if err = auth.SetCredential("anthropic", cred); err != nil { + return fmt.Errorf("failed to save credentials: %w", err) + } + + appCfg, err := internal.LoadConfig() + if err == nil { + appCfg.Providers.Anthropic.AuthMethod = "setup-token" + + // Update or add anthropic in ModelList + found := false + for i := range appCfg.ModelList { + if isAnthropicModel(appCfg.ModelList[i].Model) { + appCfg.ModelList[i].AuthMethod = "setup-token" + found = true + break + } + } + if !found { + appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ + ModelName: "claude-sonnet-4.6", + Model: "anthropic/claude-sonnet-4.6", + AuthMethod: "setup-token", + }) + } + + // Update default model + appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6" + + if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { + return fmt.Errorf("could not update config: %w", err) + } + } + + fmt.Println("Setup token saved for anthropic!") + fmt.Println("Default model set to: claude-sonnet-4.6") + + return nil +} + func authLogoutCmd(provider string) error { if provider != "" { if err := auth.DeleteCredential(provider); err != nil { diff --git a/cmd/picoclaw/internal/auth/login.go b/cmd/picoclaw/internal/auth/login.go index 9a6d28d2f..ebd19d48f 100644 --- a/cmd/picoclaw/internal/auth/login.go +++ b/cmd/picoclaw/internal/auth/login.go @@ -6,6 +6,7 @@ func newLoginCommand() *cobra.Command { var ( provider string useDeviceCode bool + useSetupToken bool ) cmd := &cobra.Command{ @@ -13,12 +14,13 @@ func newLoginCommand() *cobra.Command { Short: "Login via OAuth or paste token", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return authLoginCmd(provider, useDeviceCode) + return authLoginCmd(provider, useDeviceCode, useSetupToken) }, } cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)") cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)") + cmd.Flags().BoolVar(&useSetupToken, "setup-token", false, "Use Anthropic setup token (from claude setup-token)") _ = cmd.MarkFlagRequired("provider") return cmd diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 00b0f096a..0df637493 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "mime" "path/filepath" "strings" "sync" @@ -61,9 +62,6 @@ const defaultResponse = "I've completed processing but have no response to give. func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop { registry := NewAgentRegistry(cfg, provider) - // Register shared tools to all agents - registerSharedTools(cfg, msgBus, registry, provider) - // Set up shared fallback chain cooldown := providers.NewCooldownTracker() fallbackChain := providers.NewFallbackChain(cooldown) @@ -75,7 +73,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers stateManager = state.NewManager(defaultAgent.Workspace) } - return &AgentLoop{ + al := &AgentLoop{ bus: msgBus, cfg: cfg, registry: registry, @@ -83,14 +81,20 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers summarizing: sync.Map{}, fallback: fallbackChain, } + + // Register shared tools to all agents (after al is created so closures can capture it) + registerSharedTools(cfg, msgBus, registry, provider, al) + + return al } -// registerSharedTools registers tools that are shared across all agents (web, message, spawn). +// registerSharedTools registers tools that are shared across all agents (web, message, spawn, send_file). func registerSharedTools( cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider, + al *AgentLoop, ) { for _, agentID := range registry.ListAgentIDs() { agent, ok := registry.GetAgent(agentID) @@ -143,6 +147,39 @@ func registerSharedTools( }) agent.Tools.Register(messageTool) + // Send file tool + sendFileTool := tools.NewSendFileTool() + sendFileTool.SetMediaCallback(func(channel, chatID, localPath, caption string) error { + if al.mediaStore == nil { + return fmt.Errorf("media store not available (file sending requires gateway mode)") + } + filename := filepath.Base(localPath) + contentType := mime.TypeByExtension(filepath.Ext(localPath)) + ref, err := al.mediaStore.Store(localPath, media.MediaMeta{ + Filename: filename, + ContentType: contentType, + Source: "tool:send_file", + }, "send_file") + if err != nil { + return fmt.Errorf("storing file: %w", err) + } + part := bus.MediaPart{ + Ref: ref, + Caption: caption, + Filename: filename, + ContentType: contentType, + Type: inferMediaType(filename, contentType), + } + pubCtx, pubCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer pubCancel() + return msgBus.PublishOutboundMedia(pubCtx, bus.OutboundMediaMessage{ + Channel: channel, + ChatID: chatID, + Parts: []bus.MediaPart{part}, + }) + }) + agent.Tools.Register(sendFileTool) + // Skill discovery and installation tools registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, diff --git a/pkg/auth/token.go b/pkg/auth/token.go index a5a13ff03..d5cd1981f 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -31,6 +31,38 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) { }, nil } +func LoginSetupToken(provider string, r io.Reader) (*AuthCredential, error) { + fmt.Println("Paste your setup token from Claude CLI (claude setup-token):") + fmt.Print("> ") + + scanner := bufio.NewScanner(r) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading token: %w", err) + } + return nil, fmt.Errorf("no input received") + } + + token := strings.TrimSpace(scanner.Text()) + if token == "" { + return nil, fmt.Errorf("token cannot be empty") + } + + if !strings.HasPrefix(token, "sk-ant-oat01-") { + return nil, fmt.Errorf("invalid setup token: must start with sk-ant-oat01-") + } + + if len(token) < 40 { + return nil, fmt.Errorf("invalid setup token: too short (minimum 40 characters)") + } + + return &AuthCredential{ + AccessToken: token, + Provider: provider, + AuthMethod: "setup-token", + }, nil +} + func providerDisplayName(provider string) string { switch provider { case "anthropic": diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 1bb15f771..c393c57e4 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -29,6 +29,7 @@ type Provider struct { client *anthropic.Client tokenSource func() (string, error) baseURL string + betaHeaders string } func NewProvider(token string) *Provider { @@ -64,6 +65,12 @@ func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (stri return p } +func NewProviderWithTokenSourceAndBeta(token string, tokenSource func() (string, error), apiBase, betaHeaders string) *Provider { + p := NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase) + p.betaHeaders = betaHeaders + return p +} + func (p *Provider) Chat( ctx context.Context, messages []Message, @@ -79,12 +86,19 @@ func (p *Provider) Chat( } opts = append(opts, option.WithAuthToken(tok)) } + if p.betaHeaders != "" { + opts = append(opts, option.WithHeader("anthropic-beta", p.betaHeaders)) + } params, err := buildParams(messages, tools, model, options) if err != nil { return nil, err } + if p.betaHeaders != "" { + return p.chatStreaming(ctx, params, opts) + } + resp, err := p.client.Messages.New(ctx, params, opts...) if err != nil { return nil, fmt.Errorf("claude API call: %w", err) @@ -93,6 +107,28 @@ func (p *Provider) Chat( return parseResponse(resp), nil } +func (p *Provider) chatStreaming( + ctx context.Context, + params anthropic.MessageNewParams, + opts []option.RequestOption, +) (*LLMResponse, error) { + stream := p.client.Messages.NewStreaming(ctx, params, opts...) + + var msg anthropic.Message + for stream.Next() { + event := stream.Current() + if err := msg.Accumulate(event); err != nil { + stream.Close() + return nil, fmt.Errorf("claude API stream accumulate: %w", err) + } + } + if err := stream.Err(); err != nil { + return nil, fmt.Errorf("claude API call: %w", err) + } + + return parseResponse(&msg), nil +} + func (p *Provider) GetDefaultModel() string { return "claude-sonnet-4.6" } @@ -144,7 +180,21 @@ func buildParams( blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) } for _, tc := range msg.ToolCalls { - blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name)) + args := tc.Arguments + if args == nil && tc.Function != nil && tc.Function.Arguments != "" { + var parsed map[string]any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil { + args = parsed + } + } + if args == nil { + args = map[string]any{} + } + name := tc.Name + if name == "" && tc.Function != nil { + name = tc.Function.Name + } + blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, name)) } anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) } else { diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go index 60639ca18..370a5a834 100644 --- a/pkg/providers/claude_provider.go +++ b/pkg/providers/claude_provider.go @@ -37,6 +37,14 @@ func NewClaudeProviderWithTokenSourceAndBaseURL( } } +const anthropicSetupTokenBeta = "claude-code-20250219,oauth-2025-04-20" + +func NewClaudeProviderWithSetupToken(token string, tokenSource func() (string, error)) *ClaudeProvider { + return &ClaudeProvider{ + delegate: anthropicprovider.NewProviderWithTokenSourceAndBeta(token, tokenSource, "", anthropicSetupTokenBeta), + } +} + func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *ClaudeProvider { return &ClaudeProvider{delegate: delegate} } diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index 11af14da4..91d3a0c88 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -77,7 +77,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { } case "anthropic", "claude": if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { + if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" || cfg.Providers.Anthropic.AuthMethod == "setup-token" { sel.apiBase = cfg.Providers.Anthropic.APIBase if sel.apiBase == "" { sel.apiBase = defaultAnthropicAPIBase @@ -218,7 +218,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { } case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { + if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" || cfg.Providers.Anthropic.AuthMethod == "setup-token" { sel.apiBase = cfg.Providers.Anthropic.APIBase if sel.apiBase == "" { sel.apiBase = defaultAnthropicAPIBase diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 53f7a08a0..ed4b84259 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -21,6 +21,9 @@ func createClaudeAuthProvider() (LLMProvider, error) { if cred == nil { return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") } + if cred.AuthMethod == "setup-token" { + return NewClaudeProviderWithSetupToken(cred.AccessToken, createClaudeTokenSource()), nil + } return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil } @@ -112,7 +115,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err ), modelID, nil case "anthropic": - if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { + if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" || cfg.AuthMethod == "setup-token" { // Use OAuth credentials from auth store provider, err := createClaudeAuthProvider() if err != nil { diff --git a/pkg/tools/send_file.go b/pkg/tools/send_file.go new file mode 100644 index 000000000..74311b2fd --- /dev/null +++ b/pkg/tools/send_file.go @@ -0,0 +1,103 @@ +package tools + +import ( + "context" + "fmt" + "os" +) + +// SendMediaCallback is called to deliver a file to a chat channel. +type SendMediaCallback func(channel, chatID, localPath, caption string) error + +// SendFileTool allows the LLM to send files to the user on a chat channel. +type SendFileTool struct { + mediaCallback SendMediaCallback + defaultChannel string + defaultChatID string +} + +func NewSendFileTool() *SendFileTool { + return &SendFileTool{} +} + +func (t *SendFileTool) Name() string { + return "send_file" +} + +func (t *SendFileTool) Description() string { + return "Send a file to the user on a chat channel. Use this to deliver files, images, documents, audio, video, etc." +} + +func (t *SendFileTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Absolute path to the file on disk to send", + }, + "caption": map[string]any{ + "type": "string", + "description": "Optional caption to include with the file", + }, + "channel": map[string]any{ + "type": "string", + "description": "Optional: target channel (telegram, discord, etc.)", + }, + "chat_id": map[string]any{ + "type": "string", + "description": "Optional: target chat/user ID", + }, + }, + "required": []string{"path"}, + } +} + +func (t *SendFileTool) SetContext(channel, chatID string) { + t.defaultChannel = channel + t.defaultChatID = chatID +} + +func (t *SendFileTool) SetMediaCallback(callback SendMediaCallback) { + t.mediaCallback = callback +} + +func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, ok := args["path"].(string) + if !ok || path == "" { + return ErrorResult("path is required") + } + + if _, err := os.Stat(path); err != nil { + return ErrorResult(fmt.Sprintf("file not accessible: %v", err)) + } + + caption, _ := args["caption"].(string) + channel, _ := args["channel"].(string) + chatID, _ := args["chat_id"].(string) + + if channel == "" { + channel = t.defaultChannel + } + if chatID == "" { + chatID = t.defaultChatID + } + + if channel == "" || chatID == "" { + return ErrorResult("no target channel/chat specified") + } + + if t.mediaCallback == nil { + return ErrorResult("file sending not configured") + } + + if err := t.mediaCallback(channel, chatID, path, caption); err != nil { + return &ToolResult{ + ForLLM: fmt.Sprintf("sending file: %v", err), + IsError: true, + Err: err, + } + } + + return SilentResult(fmt.Sprintf("File sent to %s:%s: %s", channel, chatID, path)) +} diff --git a/readme-magno.md b/readme-magno.md new file mode 100644 index 000000000..a0299e930 --- /dev/null +++ b/readme-magno.md @@ -0,0 +1,125 @@ +# Modifiche PicoClaw — sessione 27/02/2026 + +## Bug fix: API Anthropic + +### 1. Tool use `name` vuoto (`pkg/tools/toolloop.go`) +L'API Anthropic rifiutava le richieste con errore `tooluse.name: String should have at least 1 character`. + +**Causa:** quando il codice ricostruiva i messaggi per le chiamate API successive, il campo `Name` del `ToolCall` non veniva copiato — solo `Function.Name` veniva impostato. + +**Fix:** aggiunto `Name: tc.Name` nella costruzione del `ToolCall` in `toolloop.go`. + +### 2. Tool use `input` non valido (`pkg/tools/toolloop.go`) +Errore: `tooluse.input: Input should be a valid dictionary`. + +**Causa:** il campo `Arguments` (tipo `map[string]interface{}`) non veniva copiato nel `ToolCall`. `buildClaudeParams` usava `tc.Arguments` che era `nil`. + +**Fix:** aggiunto `Arguments: tc.Arguments` nella costruzione del `ToolCall` in `toolloop.go`. + +--- + +## Ottimizzazione token / costi + +### 3. Rimossa duplicazione tool nel system prompt (`pkg/agent/context.go`) +I tool venivano elencati **due volte** ad ogni richiesta: +- Come testo nel system prompt (`buildToolsSection()`) +- Come tool definitions formali nell'API (`translateToolsForClaude()`) + +**Fix:** rimossa la chiamata a `buildToolsSection()` da `getIdentity()`. I tool sono già visibili al modello tramite le definizioni API. + +**Risparmio:** ~300-500 token per richiesta. + +### 4. Fix `contextWindow` errato (`pkg/agent/loop.go`) +`contextWindow` era impostato a `MaxTokens` (8192), che e' il limite di **output**, non la context window del modello. + +Questo causava summarization premature: la soglia era `8192 * 75% = 6144` token, raggiunta quasi subito, generando chiamate API extra inutili per riassumere la conversazione. + +**Fix:** aggiunta funzione `estimateContextWindow()` che ritorna la vera context window basata sul modello (es. 200K per Claude, 128K per GPT-4, 1M per Gemini). + +### 5. Prompt caching Anthropic (`pkg/providers/claude_provider.go`) +Il system prompt e le tool definitions sono identici tra richieste successive, ma venivano riprocessati (e pagati) ogni volta. + +**Fix:** aggiunto `CacheControl: ephemeral` sul primo blocco system (statico) e sull'ultima tool definition. Dopo la prima richiesta, vengono cachati per 5 minuti con sconto del 90% sui token di input. + +### 6. Separazione system prompt statico/dinamico (`pkg/agent/context.go`) +Il system prompt era un unico blocco di testo che includeva parti dinamiche (summary sessione, info canale). Qualsiasi cambiamento invalidava tutta la cache. + +**Fix:** il system prompt e' ora diviso in due blocchi `TextBlockParam`: +- **Blocco 1 (statico, cacheable):** identity, bootstrap files, skills, memory +- **Blocco 2 (dinamico):** session info, conversation summary + +### 7. Ordine deterministico dei tool (`pkg/tools/registry.go`) +I tool venivano iterati da una Go `map`, che non garantisce ordine. Ad ogni richiesta i tool potevano uscire in ordine diverso, invalidando la cache dei tool definitions. + +**Fix:** aggiunto `sort.Strings(names)` prima di iterare i tool in `ToProviderDefs()`. + +### 8. Timestamp system prompt solo giornaliero (`pkg/agent/context.go`) +Il formato data nel system prompt includeva ore e minuti (`2006-01-02 15:04`), cambiando ogni minuto e invalidando la cache. + +**Fix:** formato cambiato a solo giorno: `2006-01-02 (Monday)`. + +### 9. Bootstrap files ridotti (`~/.picoclaw/workspace/`) +- `USER.md`: da 365 bytes di placeholder generici a 73 bytes con info reali +- `IDENTITY.md`: da 1273 bytes di testo ridondante a 138 bytes essenziali + +--- + +## Logging token usage (`pkg/agent/loop.go`, `pkg/providers/`) + +Aggiunto logging del consumo token per ogni chiamata API: +- `input_tokens` — token di input +- `output_tokens` — token generati +- `cache_created` — token cachati per la prima volta +- `cache_read` — token letti dalla cache (90% sconto) + +Aggiunto campo `CacheCreatedTokens` e `CacheReadTokens` in `UsageInfo` (`pkg/providers/types.go`) ed estratti dalla response Anthropic in `parseClaudeResponse()`. + +--- + +## Supporto allegati file Telegram + +### Modifiche (parzialmente implementate dall'utente) +- `pkg/tools/message.go` — aggiunto parametro `file_path` al tool `message` e aggiornata firma `SendCallback` +- `pkg/bus/types.go` — aggiunto campo `FilePath` a `OutboundMessage` +- `pkg/agent/loop.go` — callback aggiornata per passare `filePath` +- `pkg/channels/telegram.go` — `Send()` gia' implementato con `bot.SendDocument()` quando `FilePath` e' presente + +**Flusso:** `message(content="Ecco il file", file_path="/path/to/file.md")` → tool estrae file_path → callback pubblica su bus → Telegram `Send()` invia come documento con caption. + +--- + +## Configurazione + +### Heartbeat disabilitato (`~/.picoclaw/config.json`) +L'heartbeat (ogni 30 min) consumava token dal budget Claude Code senza necessita'. + +```json +"heartbeat": { "enabled": false } +``` + +--- + +## Riepilogo impatto + +| Metrica | Prima | Dopo | +|---------|-------|------| +| Token input per richiesta semplice | ~7400 (tutti pagati pieni) | ~550 pagati + ~4200 da cache (90% sconto) | +| Chiamate API heartbeat/giorno | ~48 | 0 | +| System prompt size | ~5000 chars | ~3500 chars | +| Tool definitions cacheable | No (ordine random) | Si (ordine fisso) | + +## File modificati + +``` +pkg/agent/context.go — system prompt statico/dinamico, rimossi tool duplicati, fix timestamp +pkg/agent/loop.go — fix contextWindow, logging token usage, estimateContextWindow() +pkg/providers/claude_provider.go — prompt caching (cache_control ephemeral) +pkg/providers/types.go — campi CacheCreatedTokens, CacheReadTokens in UsageInfo +pkg/tools/registry.go — ordine deterministico tool (sort) +pkg/tools/toolloop.go — fix Name e Arguments mancanti nel ToolCall +pkg/tools/message.go — parametro file_path, nuova firma SendCallback +pkg/bus/types.go — campo FilePath in OutboundMessage +~/.picoclaw/config.json — heartbeat disabilitato +~/.picoclaw/workspace/USER.md — ridotto +~/.picoclaw/workspace/IDENTITY.md — ridotto +```