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
This commit is contained in:
A Magno 2026-03-02 12:34:04 +01:00
parent 26d1b8e374
commit 6b66da52f0
10 changed files with 419 additions and 11 deletions

View file

@ -17,11 +17,14 @@ import (
const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" 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 { switch provider {
case "openai": case "openai":
return authLoginOpenAI(useDeviceCode) return authLoginOpenAI(useDeviceCode)
case "anthropic": case "anthropic":
if useSetupToken {
return authLoginSetupToken()
}
return authLoginPasteToken(provider) return authLoginPasteToken(provider)
case "google-antigravity", "antigravity": case "google-antigravity", "antigravity":
return authLoginGoogleAntigravity() return authLoginGoogleAntigravity()
@ -259,6 +262,51 @@ func authLoginPasteToken(provider string) error {
return nil 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 { func authLogoutCmd(provider string) error {
if provider != "" { if provider != "" {
if err := auth.DeleteCredential(provider); err != nil { if err := auth.DeleteCredential(provider); err != nil {

View file

@ -6,6 +6,7 @@ func newLoginCommand() *cobra.Command {
var ( var (
provider string provider string
useDeviceCode bool useDeviceCode bool
useSetupToken bool
) )
cmd := &cobra.Command{ cmd := &cobra.Command{
@ -13,12 +14,13 @@ func newLoginCommand() *cobra.Command {
Short: "Login via OAuth or paste token", Short: "Login via OAuth or paste token",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { 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().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(&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") _ = cmd.MarkFlagRequired("provider")
return cmd return cmd

View file

@ -11,6 +11,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"mime"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "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 { func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
registry := NewAgentRegistry(cfg, provider) registry := NewAgentRegistry(cfg, provider)
// Register shared tools to all agents
registerSharedTools(cfg, msgBus, registry, provider)
// Set up shared fallback chain // Set up shared fallback chain
cooldown := providers.NewCooldownTracker() cooldown := providers.NewCooldownTracker()
fallbackChain := providers.NewFallbackChain(cooldown) fallbackChain := providers.NewFallbackChain(cooldown)
@ -75,7 +73,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
stateManager = state.NewManager(defaultAgent.Workspace) stateManager = state.NewManager(defaultAgent.Workspace)
} }
return &AgentLoop{ al := &AgentLoop{
bus: msgBus, bus: msgBus,
cfg: cfg, cfg: cfg,
registry: registry, registry: registry,
@ -83,14 +81,20 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
summarizing: sync.Map{}, summarizing: sync.Map{},
fallback: fallbackChain, 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( func registerSharedTools(
cfg *config.Config, cfg *config.Config,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
registry *AgentRegistry, registry *AgentRegistry,
provider providers.LLMProvider, provider providers.LLMProvider,
al *AgentLoop,
) { ) {
for _, agentID := range registry.ListAgentIDs() { for _, agentID := range registry.ListAgentIDs() {
agent, ok := registry.GetAgent(agentID) agent, ok := registry.GetAgent(agentID)
@ -143,6 +147,39 @@ func registerSharedTools(
}) })
agent.Tools.Register(messageTool) 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 // Skill discovery and installation tools
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,

View file

@ -31,6 +31,38 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) {
}, nil }, 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 { func providerDisplayName(provider string) string {
switch provider { switch provider {
case "anthropic": case "anthropic":

View file

@ -29,6 +29,7 @@ type Provider struct {
client *anthropic.Client client *anthropic.Client
tokenSource func() (string, error) tokenSource func() (string, error)
baseURL string baseURL string
betaHeaders string
} }
func NewProvider(token string) *Provider { func NewProvider(token string) *Provider {
@ -64,6 +65,12 @@ func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (stri
return p 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( func (p *Provider) Chat(
ctx context.Context, ctx context.Context,
messages []Message, messages []Message,
@ -79,12 +86,19 @@ func (p *Provider) Chat(
} }
opts = append(opts, option.WithAuthToken(tok)) 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) params, err := buildParams(messages, tools, model, options)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if p.betaHeaders != "" {
return p.chatStreaming(ctx, params, opts)
}
resp, err := p.client.Messages.New(ctx, params, opts...) resp, err := p.client.Messages.New(ctx, params, opts...)
if err != nil { if err != nil {
return nil, fmt.Errorf("claude API call: %w", err) return nil, fmt.Errorf("claude API call: %w", err)
@ -93,6 +107,28 @@ func (p *Provider) Chat(
return parseResponse(resp), nil 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 { func (p *Provider) GetDefaultModel() string {
return "claude-sonnet-4.6" return "claude-sonnet-4.6"
} }
@ -144,7 +180,21 @@ func buildParams(
blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
} }
for _, tc := range msg.ToolCalls { 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...)) anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
} else { } else {

View file

@ -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 { func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *ClaudeProvider {
return &ClaudeProvider{delegate: delegate} return &ClaudeProvider{delegate: delegate}
} }

View file

@ -77,7 +77,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
} }
case "anthropic", "claude": case "anthropic", "claude":
if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { 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 sel.apiBase = cfg.Providers.Anthropic.APIBase
if sel.apiBase == "" { if sel.apiBase == "" {
sel.apiBase = defaultAnthropicAPIBase sel.apiBase = defaultAnthropicAPIBase
@ -218,7 +218,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
} }
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) &&
(cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): (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 sel.apiBase = cfg.Providers.Anthropic.APIBase
if sel.apiBase == "" { if sel.apiBase == "" {
sel.apiBase = defaultAnthropicAPIBase sel.apiBase = defaultAnthropicAPIBase

View file

@ -21,6 +21,9 @@ func createClaudeAuthProvider() (LLMProvider, error) {
if cred == nil { if cred == nil {
return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") 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 return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
} }
@ -112,7 +115,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
), modelID, nil ), modelID, nil
case "anthropic": 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 // Use OAuth credentials from auth store
provider, err := createClaudeAuthProvider() provider, err := createClaudeAuthProvider()
if err != nil { if err != nil {

103
pkg/tools/send_file.go Normal file
View file

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

125
readme-magno.md Normal file
View file

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