refactor: replace moderate-diff Go files with upstream, add fork extensions

Replace ~30 more Go source files with upstream versions and re-add
fork-only functionality via appended code or _ext.go files.

Key files aligned: providers/types.go, state/state.go, tools/base.go,
tools/registry.go, channels/*, config/defaults.go, providers/*.

Conflict metrics: 105 → 34 files, 646 → 315 markers (-51%)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-13 09:48:58 +09:00
parent 9caa237639
commit 9f93158218
30 changed files with 574 additions and 776 deletions

View file

@ -1,6 +1,7 @@
package ui package ui
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -67,6 +68,7 @@ func Run() error {
root := tview.NewFlex().SetDirection(tview.FlexRow) root := tview.NewFlex().SetDirection(tview.FlexRow)
root.AddItem(bannerView(), 6, 0, false) root.AddItem(bannerView(), 6, 0, false)
root.AddItem(state.pages, 0, 1, true) root.AddItem(state.pages, 0, 1, true)
root.AddItem(footerView(), 1, 0, false)
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil { if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
return err return err
@ -102,7 +104,7 @@ func (s *appState) pop() {
} }
func (s *appState) mainMenu() tview.Primitive { func (s *appState) mainMenu() tview.Primitive {
menu := NewMenu("Config Menu", nil) menu := NewMenu("Menu", nil)
refreshMainMenu(menu, s) refreshMainMenu(menu, s)
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() { switch event.Key() {
@ -110,10 +112,7 @@ func (s *appState) mainMenu() tview.Primitive {
s.requestExit() s.requestExit()
return nil return nil
} }
if event.Rune() == 'q' {
s.requestExit()
return nil
}
return event return event
}) })
@ -131,6 +130,32 @@ func (s *appState) refreshMenu(name string, menu *Menu) {
} }
} }
func (s *appState) countChannels() (enabled int, total int) {
c := s.config.Channels
entries := []bool{
c.Telegram.Enabled,
c.Discord.Enabled,
c.QQ.Enabled,
c.MaixCam.Enabled,
c.WhatsApp.Enabled,
c.Feishu.Enabled,
c.DingTalk.Enabled,
c.Slack.Enabled,
c.Matrix.Enabled,
c.LINE.Enabled,
c.OneBot.Enabled,
c.WeCom.Enabled,
c.WeComApp.Enabled,
}
total = len(entries)
for _, v := range entries {
if v {
enabled++
}
}
return enabled, total
}
func refreshMainMenuIfPresent(s *appState) { func refreshMainMenuIfPresent(s *appState) {
if menu, ok := s.menus["main"]; ok { if menu, ok := s.menus["main"]; ok {
refreshMainMenu(menu, s) refreshMainMenu(menu, s)
@ -141,6 +166,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
selectedModel := s.selectedModelName() selectedModel := s.selectedModelName()
modelReady := selectedModel != "" modelReady := selectedModel != ""
channelReady := s.hasEnabledChannel() channelReady := s.hasEnabledChannel()
enabledCount, totalChannels := s.countChannels()
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning() gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
gatewayLabel := "Start Gateway" gatewayLabel := "Start Gateway"
@ -153,7 +179,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
items := []MenuItem{ items := []MenuItem{
{ {
Label: rootModelLabel(selectedModel), Label: rootModelLabel(selectedModel),
Description: rootModelDescription(selectedModel), Description: rootModelDescription(),
Action: func() { Action: func() {
s.push("model", s.modelMenu()) s.push("model", s.modelMenu())
}, },
@ -167,7 +193,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
}, },
{ {
Label: rootChannelLabel(channelReady), Label: rootChannelLabel(channelReady),
Description: rootChannelDescription(channelReady), Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
Action: func() { Action: func() {
s.push("channel", s.channelMenu()) s.push("channel", s.channelMenu())
}, },
@ -311,16 +337,13 @@ func (s *appState) selectedModelName() string {
func rootModelLabel(selected string) string { func rootModelLabel(selected string) string {
if selected == "" { if selected == "" {
return "Model (no model selected)" return "Model (None)"
} }
return "Model (" + selected + ")" return "Model (" + selected + ")"
} }
func rootModelDescription(selected string) string { func rootModelDescription() string {
if selected == "" { return "Using SPACE to choose your model"
return "no model selected"
}
return "selected"
} }
func rootChannelLabel(valid bool) string { func rootChannelLabel(valid bool) string {
@ -330,13 +353,6 @@ func rootChannelLabel(valid bool) string {
return "Channel" return "Channel"
} }
func rootChannelDescription(valid bool) string {
if !valid {
return "no channel enabled"
}
return "enabled"
}
func (s *appState) startTalk() { func (s *appState) startTalk() {
if !s.isActiveModelValid() { if !s.isActiveModelValid() {
s.showMessage("Model required", "Select a valid model before starting talk") s.showMessage("Model required", "Select a valid model before starting talk")

View file

@ -2,6 +2,7 @@ package onboard
import ( import (
"fmt" "fmt"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
@ -9,30 +10,6 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
var workspaceTemplates = map[string]string{
"AGENTS.md": `# Agent Instructions
You are a helpful AI assistant. Be concise, accurate, and friendly.
`,
"IDENTITY.md": `# Identity
## Name
PicoClaw 🦞
`,
"SOUL.md": `# Soul
I am picoclaw, a lightweight AI assistant powered by AI.
`,
"USER.md": `# User
Information about user goes here.
`,
"memory/MEMORY.md": `# Long-term Memory
This file stores important information that should persist across sessions.
`,
}
func onboard() { func onboard() {
configPath := internal.GetConfigPath() configPath := internal.GetConfigPath()
@ -77,19 +54,48 @@ func createWorkspaceTemplates(workspace string) {
} }
func copyEmbeddedToTarget(targetDir string) error { func copyEmbeddedToTarget(targetDir string) error {
// Ensure target directory exists
if err := os.MkdirAll(targetDir, 0o755); err != nil { if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("failed to create target directory: %w", err) return fmt.Errorf("Failed to create target directory: %w", err)
} }
for relPath, content := range workspaceTemplates { // Walk through all files in embed.FS
targetPath := filepath.Join(targetDir, relPath) err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip directories
if d.IsDir() {
return nil
}
// Read embedded file
data, err := embeddedFiles.ReadFile(path)
if err != nil {
return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
}
new_path, err := filepath.Rel("workspace", path)
if err != nil {
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
}
// Build target file path
targetPath := filepath.Join(targetDir, new_path)
// Ensure target file's directory exists
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", filepath.Dir(targetPath), err) return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
}
if err := os.WriteFile(targetPath, []byte(content), 0o644); err != nil {
return fmt.Errorf("failed to write file %s: %w", targetPath, err)
} }
// Write file
if err := os.WriteFile(targetPath, data, 0o644); err != nil {
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
} }
return nil return nil
})
return err
} }

View file

@ -13,9 +13,7 @@ import (
// AgentRegistry manages multiple agent instances and routes messages to them. // AgentRegistry manages multiple agent instances and routes messages to them.
type AgentRegistry struct { type AgentRegistry struct {
agents map[string]*AgentInstance agents map[string]*AgentInstance
resolver *routing.RouteResolver resolver *routing.RouteResolver
mu sync.RWMutex mu sync.RWMutex
} }
@ -26,7 +24,6 @@ func NewAgentRegistry(
) *AgentRegistry { ) *AgentRegistry {
registry := &AgentRegistry{ registry := &AgentRegistry{
agents: make(map[string]*AgentInstance), agents: make(map[string]*AgentInstance),
resolver: routing.NewRouteResolver(cfg), resolver: routing.NewRouteResolver(cfg),
} }
@ -34,7 +31,6 @@ func NewAgentRegistry(
if len(agentConfigs) == 0 { if len(agentConfigs) == 0 {
implicitAgent := &config.AgentConfig{ implicitAgent := &config.AgentConfig{
ID: "main", ID: "main",
Default: true, Default: true,
} }
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider) instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
@ -49,11 +45,8 @@ func NewAgentRegistry(
logger.InfoCF("agent", "Registered agent", logger.InfoCF("agent", "Registered agent",
map[string]any{ map[string]any{
"agent_id": id, "agent_id": id,
"name": ac.Name, "name": ac.Name,
"workspace": instance.Workspace, "workspace": instance.Workspace,
"model": instance.Model, "model": instance.Model,
}) })
} }
@ -121,6 +114,18 @@ func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) {
} }
} }
// Close releases resources held by all registered agents.
func (r *AgentRegistry) Close() {
r.mu.RLock()
defer r.mu.RUnlock()
for _, agent := range r.agents {
if err := agent.Close(); err != nil {
logger.WarnCF("agent", "Failed to close agent",
map[string]any{"agent_id": agent.ID, "error": err.Error()})
}
}
}
// GetDefaultAgent returns the default agent instance. // GetDefaultAgent returns the default agent instance.
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
r.mu.RLock() r.mu.RLock()

View file

@ -45,6 +45,14 @@ type DiscordChannel struct {
} }
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
discordgo.Logger = logger.NewLogger("discord").
WithLevels(map[int]logger.LogLevel{
discordgo.LogError: logger.ERROR,
discordgo.LogWarning: logger.WARN,
discordgo.LogInformational: logger.INFO,
discordgo.LogDebug: logger.DEBUG,
}).Log
session, err := discordgo.New("Bot " + cfg.Token) session, err := discordgo.New("Bot " + cfg.Token)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create discord session: %w", err) return nil, fmt.Errorf("failed to create discord session: %w", err)
@ -134,7 +142,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return nil return nil
} }
return c.sendChunk(ctx, channelID, msg.Content) return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID)
} }
// SendMedia implements the channels.MediaSender interface. // SendMedia implements the channels.MediaSender interface.
@ -232,42 +240,6 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes
} }
} }
// SendWithID implements channels.MessageSenderWithID.
// It sends a message and returns the platform message ID.
func (c *DiscordChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) {
if !c.IsRunning() {
return "", channels.ErrNotRunning
}
if chatID == "" {
return "", fmt.Errorf("channel ID is empty")
}
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
type result struct {
id string
err error
}
done := make(chan result, 1)
go func() {
msg, err := c.session.ChannelMessageSend(chatID, content)
if err != nil {
done <- result{"", fmt.Errorf("discord send: %w", channels.ErrTemporary)}
} else {
done <- result{msg.ID, nil}
}
}()
select {
case r := <-done:
return r.id, r.err
case <-sendCtx.Done():
return "", sendCtx.Err()
}
}
// EditMessage implements channels.MessageEditor. // EditMessage implements channels.MessageEditor.
func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
_, err := c.session.ChannelMessageEdit(chatID, messageID, content) _, err := c.session.ChannelMessageEdit(chatID, messageID, content)
@ -295,14 +267,29 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st
return msg.ID, nil return msg.ID, nil
} }
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error {
// Use the passed ctx for timeout control // Use the passed ctx for timeout control
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel() defer cancel()
done := make(chan error, 1) done := make(chan error, 1)
go func() { go func() {
_, err := c.session.ChannelMessageSend(channelID, content) var err error
// If we have an ID, we send the message as "Reply"
if replyToID != "" {
_, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
Content: content,
Reference: &discordgo.MessageReference{
MessageID: replyToID,
ChannelID: channelID,
},
})
} else {
// Otherwise, we send a normal message
_, err = c.session.ChannelMessageSend(channelID, content)
}
done <- err done <- err
}() }()

View file

@ -4,11 +4,10 @@ package feishu
import ( import (
"context" "context"
"crypto/rand"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math/big" "math/rand"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -201,18 +200,13 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
// Get emoji list from config // Get emoji list from config
emojiList := c.config.RandomReactionEmoji emojiList := c.config.RandomReactionEmoji
var chosenEmoji string
if len(emojiList) == 0 { if len(emojiList) == 0 {
// Default to "Pin" if no config // Default to "Pin" if no config
emojiList = []string{"Pin"} chosenEmoji = "Pin"
}
// Randomly choose one from the list using crypto/rand for better distribution
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(emojiList))))
var chosenEmoji string
if err != nil {
chosenEmoji = emojiList[0]
} else { } else {
chosenEmoji = emojiList[idx.Int64()] idx := rand.Intn(len(emojiList))
chosenEmoji = emojiList[idx]
} }
req := larkim.NewCreateMessageReactionReqBuilder(). req := larkim.NewCreateMessageReactionReqBuilder().

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"html" "html"
"io"
"mime" "mime"
"net/url" "net/url"
"os" "os"
@ -13,6 +14,9 @@ import (
"sync" "sync"
"time" "time"
"github.com/gomarkdown/markdown"
mdhtml "github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@ -268,6 +272,12 @@ func (c *MatrixChannel) Stop(ctx context.Context) error {
return nil return nil
} }
func markdownToHTML(md string) string {
p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags})
return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer)))
}
func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() { if !c.IsRunning() {
return channels.ErrNotRunning return channels.ErrNotRunning
@ -283,16 +293,22 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return nil return nil
} }
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content))
MsgType: event.MsgText,
Body: content,
})
if err != nil { if err != nil {
return fmt.Errorf("matrix send: %w", channels.ErrTemporary) return fmt.Errorf("matrix send: %w", channels.ErrTemporary)
} }
return nil return nil
} }
func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent {
mc := &event.MessageEventContent{MsgType: event.MsgText, Body: text}
if c.config.MessageFormat != "plain" {
mc.Format = event.FormatHTML
mc.FormattedBody = markdownToHTML(text)
}
return mc
}
// SendMedia implements channels.MediaSender. // SendMedia implements channels.MediaSender.
func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() { if !c.IsRunning() {
@ -482,10 +498,7 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI
return fmt.Errorf("matrix message ID is empty") return fmt.Errorf("matrix message ID is empty")
} }
editContent := &event.MessageEventContent{ editContent := c.messageContent(content)
MsgType: event.MsgText,
Body: content,
}
editContent.SetEdit(id.EventID(messageID)) editContent.SetEdit(id.EventID(messageID))
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent) _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent)
@ -714,17 +727,23 @@ func (c *MatrixChannel) downloadMedia(
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second) reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
defer cancel() defer cancel()
data, err := c.client.DownloadBytes(reqCtx, parsed) resp, err := c.client.Download(reqCtx, parsed)
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close()
reader := resp.Body
readerClose := func() error { return nil }
// Encrypted attachments put URL in msgEvt.File and require client-side decryption. // Encrypted attachments put URL in msgEvt.File and require client-side decryption.
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" { if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
err = msgEvt.File.DecryptInPlace(data) if err = msgEvt.File.PrepareForDecryption(); err != nil {
if err != nil {
return "", fmt.Errorf("decrypt matrix media: %w", err) return "", fmt.Errorf("decrypt matrix media: %w", err)
} }
decryptReader := msgEvt.File.DecryptStream(resp.Body)
reader = decryptReader
readerClose = decryptReader.Close
} }
label := matrixMediaLabel(msgEvt, mediaKind) label := matrixMediaLabel(msgEvt, mediaKind)
@ -737,14 +756,28 @@ func (c *MatrixChannel) downloadMedia(
if err != nil { if err != nil {
return "", err return "", err
} }
defer tmp.Close() tmpPath := tmp.Name()
cleanup := true
defer func() {
_ = tmp.Close()
if cleanup {
_ = os.Remove(tmpPath)
}
}()
if _, err = tmp.Write(data); err != nil { _, err = io.Copy(tmp, reader)
_ = os.Remove(tmp.Name()) if err != nil {
return "", err
}
if err = readerClose(); err != nil {
return "", fmt.Errorf("decrypt matrix media: %w", err)
}
if err = tmp.Close(); err != nil {
return "", err return "", err
} }
return tmp.Name(), nil cleanup = false
return tmpPath, nil
} }
func matrixContentType(msgEvt *event.MessageEventContent) string { func matrixContentType(msgEvt *event.MessageEventContent) string {

View file

@ -150,26 +150,6 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return c.broadcastToSession(msg.ChatID, outMsg) return c.broadcastToSession(msg.ChatID, outMsg)
} }
// SendWithID implements channels.MessageSenderWithID.
// It sends a message and returns a generated message ID.
func (c *PicoChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) {
if !c.IsRunning() {
return "", channels.ErrNotRunning
}
msgID := uuid.New().String()
outMsg := newMessage(TypeMessageCreate, map[string]any{
"content": content,
"message_id": msgID,
})
if err := c.broadcastToSession(chatID, outMsg); err != nil {
return "", err
}
return msgID, nil
}
// EditMessage implements channels.MessageEditor. // EditMessage implements channels.MessageEditor.
func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
outMsg := newMessage(TypeMessageUpdate, map[string]any{ outMsg := newMessage(TypeMessageUpdate, map[string]any{

View file

@ -122,7 +122,11 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
slack.MsgOptionText(msg.Content, false), slack.MsgOptionText(msg.Content, false),
} }
if threadTS != "" { if msg.ReplyToMessageID != "" && threadTS == "" {
// Answer to the message by creating a Thread under it
opts = append(opts, slack.MsgOptionTS(msg.ReplyToMessageID))
} else if threadTS != "" {
// If we are already in a thread, continue in the thread
opts = append(opts, slack.MsgOptionTS(threadTS)) opts = append(opts, slack.MsgOptionTS(threadTS))
} }
@ -183,7 +187,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
title = filename title = filename
} }
_, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{ _, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{
Channel: channelID, Channel: channelID,
File: localPath, File: localPath,
Filename: filename, Filename: filename,
@ -303,17 +307,16 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
Timestamp: messageTS, Timestamp: messageTS,
}) })
var contentBuf strings.Builder content := ev.Text
contentBuf.WriteString(c.stripBotMention(ev.Text)) content = c.stripBotMention(content)
// In non-DM channels, apply group trigger filtering // In non-DM channels, apply group trigger filtering
if !strings.HasPrefix(channelID, "D") { if !strings.HasPrefix(channelID, "D") {
respond, cleaned := c.ShouldRespondInGroup(false, contentBuf.String()) respond, cleaned := c.ShouldRespondInGroup(false, content)
if !respond { if !respond {
return return
} }
contentBuf.Reset() content = cleaned
contentBuf.WriteString(cleaned)
} }
var mediaPaths []string var mediaPaths []string
@ -341,11 +344,10 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
continue continue
} }
mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name)) mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))
fmt.Fprintf(&contentBuf, "\n[file: %s]", file.Name) content += fmt.Sprintf("\n[file: %s]", file.Name)
} }
} }
content := contentBuf.String()
if strings.TrimSpace(content) == "" { if strings.TrimSpace(content) == "" {
return return
} }

View file

@ -34,7 +34,6 @@ func DefaultConfig() *Config {
Temperature: nil, // nil means use provider default Temperature: nil, // nil means use provider default
MaxToolIterations: 50, MaxToolIterations: 50,
SummarizeMessageThreshold: 20, SummarizeMessageThreshold: 20,
TaskReminderInterval: 5,
SummarizeTokenPercent: 75, SummarizeTokenPercent: 75,
}, },
}, },
@ -55,8 +54,6 @@ func DefaultConfig() *Config {
Token: "", Token: "",
AllowFrom: FlexibleStringSlice{}, AllowFrom: FlexibleStringSlice{},
Typing: TypingConfig{Enabled: true}, Typing: TypingConfig{Enabled: true},
SubagentThreadID: 0,
HeartbeatThreadID: 0,
Placeholder: PlaceholderConfig{ Placeholder: PlaceholderConfig{
Enabled: true, Enabled: true,
Text: "Thinking... 💭", Text: "Thinking... 💭",
@ -87,6 +84,7 @@ func DefaultConfig() *Config {
AppID: "", AppID: "",
AppSecret: "", AppSecret: "",
AllowFrom: FlexibleStringSlice{}, AllowFrom: FlexibleStringSlice{},
MaxMessageLength: 2000,
}, },
DingTalk: DingTalkConfig{ DingTalk: DingTalkConfig{
Enabled: false, Enabled: false,
@ -196,8 +194,8 @@ func DefaultConfig() *Config {
// OpenAI - https://platform.openai.com/api-keys // OpenAI - https://platform.openai.com/api-keys
{ {
ModelName: "gpt-5.2", ModelName: "gpt-5.4",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.4",
APIBase: "https://api.openai.com/v1", APIBase: "https://api.openai.com/v1",
APIKey: "", APIKey: "",
}, },
@ -258,8 +256,8 @@ func DefaultConfig() *Config {
APIKey: "", APIKey: "",
}, },
{ {
ModelName: "openrouter-gpt-5.2", ModelName: "openrouter-gpt-5.4",
Model: "openrouter/openai/gpt-5.2", Model: "openrouter/openai/gpt-5.4",
APIBase: "https://openrouter.ai/api/v1", APIBase: "https://openrouter.ai/api/v1",
APIKey: "", APIKey: "",
}, },
@ -289,6 +287,12 @@ func DefaultConfig() *Config {
}, },
// Volcengine (火山引擎) - https://console.volcengine.com/ark // Volcengine (火山引擎) - https://console.volcengine.com/ark
{
ModelName: "ark-code-latest",
Model: "volcengine/ark-code-latest",
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
APIKey: "",
},
{ {
ModelName: "doubao-pro", ModelName: "doubao-pro",
Model: "volcengine/doubao-pro-32k", Model: "volcengine/doubao-pro-32k",
@ -313,8 +317,8 @@ func DefaultConfig() *Config {
// GitHub Copilot - https://github.com/settings/tokens // GitHub Copilot - https://github.com/settings/tokens
{ {
ModelName: "copilot-gpt-5.2", ModelName: "copilot-gpt-5.4",
Model: "github-copilot/gpt-5.2", Model: "github-copilot/gpt-5.4",
APIBase: "http://localhost:4321", APIBase: "http://localhost:4321",
AuthMethod: "oauth", AuthMethod: "oauth",
}, },
@ -349,6 +353,22 @@ func DefaultConfig() *Config {
APIKey: "", APIKey: "",
}, },
// Minimax - https://api.minimaxi.com/
{
ModelName: "MiniMax-M2.5",
Model: "minimax/MiniMax-M2.5",
APIBase: "https://api.minimaxi.com/v1",
APIKey: "",
},
// LongCat - https://longcat.chat/platform
{
ModelName: "LongCat-Flash-Thinking",
Model: "longcat/LongCat-Flash-Thinking",
APIBase: "https://api.longcat.chat/openai",
APIKey: "",
},
// VLLM (local) - http://localhost:8000 // VLLM (local) - http://localhost:8000
{ {
ModelName: "local-model", ModelName: "local-model",
@ -378,6 +398,13 @@ func DefaultConfig() *Config {
Brave: BraveConfig{ Brave: BraveConfig{
Enabled: false, Enabled: false,
APIKey: "", APIKey: "",
APIKeys: nil,
MaxResults: 5,
},
Tavily: TavilyConfig{
Enabled: false,
APIKey: "",
APIKeys: nil,
MaxResults: 5, MaxResults: 5,
}, },
DuckDuckGo: DuckDuckGoConfig{ DuckDuckGo: DuckDuckGoConfig{
@ -387,6 +414,7 @@ func DefaultConfig() *Config {
Perplexity: PerplexityConfig{ Perplexity: PerplexityConfig{
Enabled: false, Enabled: false,
APIKey: "", APIKey: "",
APIKeys: nil,
MaxResults: 5, MaxResults: 5,
}, },
SearXNG: SearXNGConfig{ SearXNG: SearXNGConfig{
@ -439,6 +467,13 @@ func DefaultConfig() *Config {
ToolConfig: ToolConfig{ ToolConfig: ToolConfig{
Enabled: false, Enabled: false,
}, },
Discovery: ToolDiscoveryConfig{
Enabled: false,
TTL: 5,
MaxSearchResults: 5,
UseBM25: true,
UseRegex: false,
},
Servers: map[string]MCPServerConfig{}, Servers: map[string]MCPServerConfig{},
}, },
AppendFile: ToolConfig{ AppendFile: ToolConfig{
@ -464,6 +499,7 @@ func DefaultConfig() *Config {
}, },
ReadFile: ReadFileToolConfig{ ReadFile: ReadFileToolConfig{
Enabled: true, Enabled: true,
MaxReadFileSize: 64 * 1024, // 64KB
}, },
Spawn: ToolConfig{ Spawn: ToolConfig{
Enabled: true, Enabled: true,
@ -489,5 +525,14 @@ func DefaultConfig() *Config {
Enabled: false, Enabled: false,
MonitorUSB: true, MonitorUSB: true,
}, },
Voice: VoiceConfig{
EchoTranscription: false,
},
BuildInfo: BuildInfo{
Version: Version,
GitCommit: GitCommit,
BuildTime: BuildTime,
GoVersion: GoVersion,
},
} }
} }

View file

@ -735,12 +735,14 @@ type WebToolsConfig struct {
type BraveConfig struct { type BraveConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
APIKeys []string `json:"api_keys"`
MaxResults int `json:"max_results"` MaxResults int `json:"max_results"`
} }
type TavilyConfig struct { type TavilyConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
APIKeys []string `json:"api_keys"`
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
MaxResults int `json:"max_results"` MaxResults int `json:"max_results"`
} }
@ -753,6 +755,7 @@ type DuckDuckGoConfig struct {
type PerplexityConfig struct { type PerplexityConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
APIKeys []string `json:"api_keys"`
MaxResults int `json:"max_results"` MaxResults int `json:"max_results"`
} }
@ -1082,6 +1085,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig {
Brave: config.BraveConfig{ Brave: config.BraveConfig{
Enabled: c.Web.Brave.Enabled, Enabled: c.Web.Brave.Enabled,
APIKey: c.Web.Brave.APIKey, APIKey: c.Web.Brave.APIKey,
APIKeys: c.Web.Brave.APIKeys,
MaxResults: c.Web.Brave.MaxResults, MaxResults: c.Web.Brave.MaxResults,
}, },
Tavily: config.TavilyConfig{ Tavily: config.TavilyConfig{

View file

@ -297,7 +297,7 @@ func (p *AntigravityProvider) buildRequest(
if t.Type != "function" { if t.Type != "function" {
continue continue
} }
params := sanitizeSchemaForGemini(t.Function.ParametersMap()) params := sanitizeSchemaForGemini(t.Function.Parameters)
funcDecls = append(funcDecls, antigravityFuncDecl{ funcDecls = append(funcDecls, antigravityFuncDecl{
Name: t.Function.Name, Name: t.Function.Name,
Description: t.Function.Description, Description: t.Function.Description,
@ -340,13 +340,17 @@ func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) {
thoughtSignature = tc.Function.ThoughtSignature thoughtSignature = tc.Function.ThoughtSignature
} }
if len(args) == 0 && tc.Function != nil && len(tc.Function.Arguments) > 0 {
args = cloneToolArgs(tc.Function.Arguments)
}
if args == nil { if args == nil {
args = map[string]any{} args = map[string]any{}
} }
if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" {
var parsed map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil {
args = parsed
}
}
return name, args, thoughtSignature return name, args, thoughtSignature
} }
@ -432,13 +436,14 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
contentParts = append(contentParts, part.Text) contentParts = append(contentParts, part.Text)
} }
if part.FunctionCall != nil { if part.FunctionCall != nil {
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
toolCalls = append(toolCalls, ToolCall{ toolCalls = append(toolCalls, ToolCall{
ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()), ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
Name: part.FunctionCall.Name, Name: part.FunctionCall.Name,
Arguments: part.FunctionCall.Args, Arguments: part.FunctionCall.Args,
Function: &FunctionCall{ Function: &FunctionCall{
Name: part.FunctionCall.Name, Name: part.FunctionCall.Name,
Arguments: cloneToolArgs(part.FunctionCall.Args), Arguments: string(argumentsJSON),
ThoughtSignature: extractPartThoughtSignature( ThoughtSignature: extractPartThoughtSignature(
part.ThoughtSignature, part.ThoughtSignature,
part.ThoughtSignatureSnake, part.ThoughtSignatureSnake,

View file

@ -100,45 +100,12 @@ func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDe
} }
if len(tools) > 0 { if len(tools) > 0 {
parts = append(parts, p.buildToolsPrompt(tools)) parts = append(parts, buildCLIToolsPrompt(tools))
} }
return strings.Join(parts, "\n\n") return strings.Join(parts, "\n\n")
} }
// buildToolsPrompt creates the tool definitions section for the system prompt.
func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
var sb strings.Builder
sb.WriteString("## Available Tools\n\n")
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
sb.WriteString("```json\n")
sb.WriteString(
`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
)
sb.WriteString("\n```\n\n")
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
sb.WriteString("### Tool Definitions:\n\n")
for _, tool := range tools {
if tool.Type != "function" {
continue
}
sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name))
if tool.Function.Description != "" {
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
}
if len(tool.Function.Parameters) > 0 {
sb.WriteString("Parameters:\n```json\n")
sb.Write(tool.Function.Parameters)
sb.WriteString("\n```\n")
}
sb.WriteString("\n")
}
return sb.String()
}
// parseClaudeCliResponse parses the JSON output from the claude CLI. // parseClaudeCliResponse parses the JSON output from the claude CLI.
func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) { func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) {
var resp claudeCliJSONResponse var resp claudeCliJSONResponse

View file

@ -115,7 +115,7 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio
} }
if len(tools) > 0 { if len(tools) > 0 {
sb.WriteString(p.buildToolsPrompt(tools)) sb.WriteString(buildCLIToolsPrompt(tools))
sb.WriteString("\n\n") sb.WriteString("\n\n")
} }
@ -128,39 +128,6 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio
return sb.String() return sb.String()
} }
// buildToolsPrompt creates a tool definitions section for the prompt.
func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
var sb strings.Builder
sb.WriteString("## Available Tools\n\n")
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
sb.WriteString("```json\n")
sb.WriteString(
`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
)
sb.WriteString("\n```\n\n")
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
sb.WriteString("### Tool Definitions:\n\n")
for _, tool := range tools {
if tool.Type != "function" {
continue
}
sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name))
if tool.Function.Description != "" {
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
}
if len(tool.Function.Parameters) > 0 {
sb.WriteString("Parameters:\n```json\n")
sb.Write(tool.Function.Parameters)
sb.WriteString("\n```\n")
}
sb.WriteString("\n")
}
return sb.String()
}
// codexEvent represents a single JSONL event from `codex exec --json`. // codexEvent represents a single JSONL event from `codex exec --json`.
type codexEvent struct { type codexEvent struct {
Type string `json:"type"` Type string `json:"type"`

View file

@ -16,7 +16,7 @@ import (
) )
const ( const (
codexDefaultModel = "gpt-5.2" codexDefaultModel = "gpt-5.3-codex"
codexDefaultInstructions = "You are Codex, a coding assistant." codexDefaultInstructions = "You are Codex, a coding assistant."
) )
@ -317,21 +317,21 @@ func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool)
return "", "", false return "", "", false
} }
args := tc.Arguments if len(tc.Arguments) > 0 {
if len(args) == 0 && tc.Function != nil { argsJSON, err := json.Marshal(tc.Arguments)
args = tc.Function.Arguments
}
if len(args) == 0 {
return name, "{}", true
}
argsJSON, err := json.Marshal(args)
if err != nil { if err != nil {
return "", "", false return "", "", false
} }
return name, string(argsJSON), true return name, string(argsJSON), true
} }
if tc.Function != nil && tc.Function.Arguments != "" {
return name, tc.Function.Arguments, true
}
return name, "{}", true
}
func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam { func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam {
capHint := len(tools) capHint := len(tools)
if enableWebSearch { if enableWebSearch {
@ -345,13 +345,9 @@ func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []resp
if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") { if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") {
continue continue
} }
params := t.Function.ParametersMap()
if params == nil {
params = map[string]any{}
}
ft := responses.FunctionToolParam{ ft := responses.FunctionToolParam{
Name: t.Function.Name, Name: t.Function.Name,
Parameters: params, Parameters: t.Function.Parameters,
Strict: openai.Opt(false), Strict: openai.Opt(false),
} }
if t.Function.Description != "" { if t.Function.Description != "" {
@ -386,10 +382,6 @@ func parseCodexResponse(resp *responses.Response) *LLMResponse {
ID: item.CallID, ID: item.CallID,
Name: item.Name, Name: item.Name,
Arguments: args, Arguments: args,
Function: &FunctionCall{
Name: item.Name,
Arguments: cloneToolArgs(args),
},
}) })
} }
} }

View file

@ -36,13 +36,14 @@ type providerSelection struct {
} }
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
return resolveProviderSelectionByName(cfg, strings.ToLower(cfg.Agents.Defaults.Provider))
}
func resolveProviderSelectionByName(cfg *config.Config, providerName string) (providerSelection, error) {
model := cfg.Agents.Defaults.GetModelName() model := cfg.Agents.Defaults.GetModelName()
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
lowerModel := strings.ToLower(model) lowerModel := strings.ToLower(model)
if providerName == "" && model == "" {
return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty")
}
sel := providerSelection{ sel := providerSelection{
providerType: providerTypeHTTPCompat, providerType: providerTypeHTTPCompat,
model: model, model: model,
@ -211,6 +212,24 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
sel.apiBase = "https://api.mistral.ai/v1" sel.apiBase = "https://api.mistral.ai/v1"
} }
} }
case "minimax":
if cfg.Providers.Minimax.APIKey != "" {
sel.apiKey = cfg.Providers.Minimax.APIKey
sel.apiBase = cfg.Providers.Minimax.APIBase
sel.proxy = cfg.Providers.Minimax.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.minimaxi.com/v1"
}
}
case "longcat":
if cfg.Providers.LongCat.APIKey != "" {
sel.apiKey = cfg.Providers.LongCat.APIKey
sel.apiBase = cfg.Providers.LongCat.APIBase
sel.proxy = cfg.Providers.LongCat.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.longcat.chat/openai"
}
}
case "github_copilot", "copilot": case "github_copilot", "copilot":
sel.providerType = providerTypeGitHubCopilot sel.providerType = providerTypeGitHubCopilot
if cfg.Providers.GitHubCopilot.APIBase != "" { if cfg.Providers.GitHubCopilot.APIBase != "" {
@ -328,6 +347,13 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
if sel.apiBase == "" { if sel.apiBase == "" {
sel.apiBase = "https://api.mistral.ai/v1" sel.apiBase = "https://api.mistral.ai/v1"
} }
case (strings.Contains(lowerModel, "minimax") || strings.HasPrefix(model, "minimax/")) && cfg.Providers.Minimax.APIKey != "":
sel.apiKey = cfg.Providers.Minimax.APIKey
sel.apiBase = cfg.Providers.Minimax.APIBase
sel.proxy = cfg.Providers.Minimax.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.minimaxi.com/v1"
}
case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "": case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "":
sel.apiKey = cfg.Providers.Avian.APIKey sel.apiKey = cfg.Providers.Avian.APIKey
sel.apiBase = cfg.Providers.Avian.APIBase sel.apiBase = cfg.Providers.Avian.APIBase
@ -335,6 +361,13 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
if sel.apiBase == "" { if sel.apiBase == "" {
sel.apiBase = "https://api.avian.io/v1" sel.apiBase = "https://api.avian.io/v1"
} }
case (strings.Contains(lowerModel, "longcat") || strings.HasPrefix(model, "longcat/")) && cfg.Providers.LongCat.APIKey != "":
sel.apiKey = cfg.Providers.LongCat.APIKey
sel.apiBase = cfg.Providers.LongCat.APIBase
sel.proxy = cfg.Providers.LongCat.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.longcat.chat/openai"
}
case cfg.Providers.VLLM.APIBase != "": case cfg.Providers.VLLM.APIBase != "":
sel.apiKey = cfg.Providers.VLLM.APIKey sel.apiKey = cfg.Providers.VLLM.APIKey
sel.apiBase = cfg.Providers.VLLM.APIBase sel.apiBase = cfg.Providers.VLLM.APIBase
@ -365,31 +398,3 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
return sel, nil return sel, nil
} }
// CreateProviderByName creates a provider for the given explicit provider name.
// Used by the fallback chain to resolve cross-provider candidates.
func CreateProviderByName(cfg *config.Config, providerName string) (LLMProvider, error) {
sel, err := resolveProviderSelectionByName(cfg, strings.ToLower(providerName))
if err != nil {
return nil, err
}
switch sel.providerType {
case providerTypeClaudeAuth:
return createClaudeAuthProvider()
case providerTypeCodexAuth:
return createCodexAuthProvider()
case providerTypeCodexCLIToken:
c := NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource())
c.enableWebSearch = sel.enableWebSearch
return c, nil
case providerTypeClaudeCLI:
return NewClaudeCliProvider(sel.workspace), nil
case providerTypeCodexCLI:
return NewCodexCliProvider(sel.workspace), nil
case providerTypeGitHubCopilot:
return NewGitHubCopilotProvider(sel.apiBase, sel.connectMode, sel.model)
default:
return NewHTTPProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
}
}

View file

@ -8,10 +8,8 @@ package providers
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers/openai_compat"
) )
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
@ -86,33 +84,18 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" { if apiBase == "" {
apiBase = getDefaultAPIBase(protocol) apiBase = getDefaultAPIBase(protocol)
} }
return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
openai_compat.WithMaxTokensField(cfg.MaxTokensField), cfg.APIKey,
openai_compat.WithStream(boolDefault(cfg.Stream, false)), apiBase,
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second), cfg.Proxy,
openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)), cfg.MaxTokensField,
), modelID, nil cfg.RequestTimeout,
case "minimax":
// MiniMax uses a non-standard endpoint path and defaults to SSE streaming.
if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for minimax protocol")
}
apiBase := cfg.APIBase
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy,
openai_compat.WithEndpointPath("/text/chatcompletion_v2"),
openai_compat.WithMaxTokensField(cfg.MaxTokensField),
openai_compat.WithStream(boolDefault(cfg.Stream, true)),
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second),
openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)),
), modelID, nil ), modelID, nil
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian": "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
"minimax", "longcat":
// All other OpenAI-compatible HTTP providers // All other OpenAI-compatible HTTP providers
if cfg.APIKey == "" && cfg.APIBase == "" { if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@ -121,11 +104,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" { if apiBase == "" {
apiBase = getDefaultAPIBase(protocol) apiBase = getDefaultAPIBase(protocol)
} }
return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
openai_compat.WithMaxTokensField(cfg.MaxTokensField), cfg.APIKey,
openai_compat.WithStream(boolDefault(cfg.Stream, false)), apiBase,
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second), cfg.Proxy,
openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)), cfg.MaxTokensField,
cfg.RequestTimeout,
), modelID, nil ), modelID, nil
case "anthropic": case "anthropic":
@ -145,10 +129,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if cfg.APIKey == "" { if cfg.APIKey == "" {
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
} }
return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
openai_compat.WithMaxTokensField(cfg.MaxTokensField), cfg.APIKey,
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second), apiBase,
openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)), cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout,
), modelID, nil ), modelID, nil
case "antigravity": case "antigravity":
@ -223,30 +209,15 @@ func getDefaultAPIBase(protocol string) string {
return "https://dashscope.aliyuncs.com/compatible-mode/v1" return "https://dashscope.aliyuncs.com/compatible-mode/v1"
case "vllm": case "vllm":
return "http://localhost:8000/v1" return "http://localhost:8000/v1"
case "minimax":
return "https://api.minimax.io/v1"
case "mistral": case "mistral":
return "https://api.mistral.ai/v1" return "https://api.mistral.ai/v1"
case "avian": case "avian":
return "https://api.avian.io/v1" return "https://api.avian.io/v1"
case "minimax":
return "https://api.minimaxi.com/v1"
case "longcat":
return "https://api.longcat.chat/openai"
default: default:
return "" return ""
} }
} }
// rpmToMinInterval converts a requests-per-minute limit to a minimum interval
// between consecutive requests. Returns 0 (no throttle) when rpm <= 0.
func rpmToMinInterval(rpm int) time.Duration {
if rpm <= 0 {
return 0
}
return time.Minute / time.Duration(rpm)
}
// boolDefault dereferences a *bool, returning def when nil.
func boolDefault(p *bool, def bool) bool {
if p != nil {
return *p
}
return def
}

View file

@ -42,12 +42,6 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
} }
} }
func NewHTTPProviderWithOptions(apiKey, apiBase, proxy string, opts ...openai_compat.Option) *HTTPProvider {
return &HTTPProvider{
delegate: openai_compat.NewProvider(apiKey, apiBase, proxy, opts...),
}
}
func (p *HTTPProvider) Chat( func (p *HTTPProvider) Chat(
ctx context.Context, ctx context.Context,
messages []Message, messages []Message,
@ -55,38 +49,9 @@ func (p *HTTPProvider) Chat(
model string, model string,
options map[string]any, options map[string]any,
) (*LLMResponse, error) { ) (*LLMResponse, error) {
resp, err := p.delegate.Chat(ctx, messages, tools, model, options) return p.delegate.Chat(ctx, messages, tools, model, options)
if err != nil {
return nil, err
}
// If provider returned no structured tool_calls but Content has XML
// tool call blocks (e.g. <ns:toolcall>), parse them as a fallback.
if len(resp.ToolCalls) == 0 {
if xmlCalls := extractXMLToolCalls(resp.Content); len(xmlCalls) > 0 {
resp.ToolCalls = xmlCalls
}
}
// Strip XML tool call artifacts from Content regardless.
resp.Content = stripXMLToolCalls(resp.Content)
return resp, nil
} }
func (p *HTTPProvider) GetDefaultModel() string { func (p *HTTPProvider) GetDefaultModel() string {
return "" return ""
} }
// CanStream returns true when the underlying provider uses SSE streaming.
func (p *HTTPProvider) CanStream() bool {
return p.delegate.CanStream()
}
// ChatStream opens an SSE stream and returns a channel of StreamEvent.
func (p *HTTPProvider) ChatStream(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
) (<-chan StreamEvent, error) {
return p.delegate.ChatStream(ctx, messages, tools, model, options)
}

View file

@ -5,32 +5,74 @@
package providers package providers
import (
"encoding/json"
"fmt"
"strings"
)
// buildCLIToolsPrompt creates the tool definitions section for a CLI provider system prompt.
func buildCLIToolsPrompt(tools []ToolDefinition) string {
var sb strings.Builder
sb.WriteString("## Available Tools\n\n")
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
sb.WriteString("```json\n")
sb.WriteString(
`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
)
sb.WriteString("\n```\n\n")
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
sb.WriteString("### Tool Definitions:\n\n")
for _, tool := range tools {
if tool.Type != "function" {
continue
}
sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name))
if tool.Function.Description != "" {
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
}
if len(tool.Function.Parameters) > 0 {
paramsJSON, _ := json.Marshal(tool.Function.Parameters)
sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON)))
}
sb.WriteString("\n")
}
return sb.String()
}
// NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated. // NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated.
// It handles cases where Name/Arguments might be in different locations (top-level vs Function) // It handles cases where Name/Arguments might be in different locations (top-level vs Function)
// and ensures both are populated consistently. // and ensures both are populated consistently.
func NormalizeToolCall(tc ToolCall) ToolCall { func NormalizeToolCall(tc ToolCall) ToolCall {
normalized := tc normalized := tc
// Ensure Name is populated from Function if not set. // Ensure Name is populated from Function if not set
if normalized.Name == "" && normalized.Function != nil { if normalized.Name == "" && normalized.Function != nil {
normalized.Name = normalized.Function.Name normalized.Name = normalized.Function.Name
} }
// Ensure Arguments is not nil. // Ensure Arguments is not nil
if normalized.Arguments == nil { if normalized.Arguments == nil {
normalized.Arguments = map[string]any{} normalized.Arguments = map[string]any{}
} }
// Populate top-level arguments from Function arguments when needed. // Parse Arguments from Function.Arguments if not already set
if len(normalized.Arguments) == 0 && normalized.Function != nil && len(normalized.Function.Arguments) > 0 { if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" {
normalized.Arguments = cloneToolArgs(normalized.Function.Arguments) var parsed map[string]any
if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil {
normalized.Arguments = parsed
}
} }
// Ensure Function is populated with consistent values. // Ensure Function is populated with consistent values
argsJSON, _ := json.Marshal(normalized.Arguments)
if normalized.Function == nil { if normalized.Function == nil {
normalized.Function = &FunctionCall{ normalized.Function = &FunctionCall{
Name: normalized.Name, Name: normalized.Name,
Arguments: cloneToolArgs(normalized.Arguments), Arguments: string(argsJSON),
} }
} else { } else {
if normalized.Function.Name == "" { if normalized.Function.Name == "" {
@ -39,21 +81,10 @@ func NormalizeToolCall(tc ToolCall) ToolCall {
if normalized.Name == "" { if normalized.Name == "" {
normalized.Name = normalized.Function.Name normalized.Name = normalized.Function.Name
} }
if len(normalized.Function.Arguments) == 0 { if normalized.Function.Arguments == "" {
normalized.Function.Arguments = cloneToolArgs(normalized.Arguments) normalized.Function.Arguments = string(argsJSON)
} }
} }
return normalized return normalized
} }
func cloneToolArgs(src map[string]any) map[string]any {
if len(src) == 0 {
return map[string]any{}
}
dst := make(map[string]any, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}

View file

@ -2,9 +2,10 @@ package providers
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"encoding/json"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
) )
@ -18,8 +19,6 @@ type (
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
ExtraContent = protocoltypes.ExtraContent ExtraContent = protocoltypes.ExtraContent
GoogleExtra = protocoltypes.GoogleExtra GoogleExtra = protocoltypes.GoogleExtra
StreamEvent = protocoltypes.StreamEvent
StreamToolCallDelta = protocoltypes.StreamToolCallDelta
ContentBlock = protocoltypes.ContentBlock ContentBlock = protocoltypes.ContentBlock
CacheControl = protocoltypes.CacheControl CacheControl = protocoltypes.CacheControl
) )
@ -84,6 +83,12 @@ func (e *FailoverError) IsRetriable() bool {
return e.Reason != FailoverFormat return e.Reason != FailoverFormat
} }
// ModelConfig holds primary model and fallback list.
type ModelConfig struct {
Primary string
Fallbacks []string
}
// StreamingProvider extends LLMProvider with SSE channel-based streaming. // StreamingProvider extends LLMProvider with SSE channel-based streaming.
// Use a type assertion to check if a provider supports streaming: // Use a type assertion to check if a provider supports streaming:
// //
@ -91,21 +96,23 @@ func (e *FailoverError) IsRetriable() bool {
type StreamingProvider interface { type StreamingProvider interface {
LLMProvider LLMProvider
CanStream() bool CanStream() bool
ChatStream( ChatStream(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any) (<-chan StreamEvent, error)
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
) (<-chan StreamEvent, error)
} }
// ModelConfig holds primary model and fallback list. // FallbackCandidate represents a model that can be tried if the primary model fails.
type ModelConfig struct { type FallbackCandidate struct {
Primary string ModelName string
Fallbacks []string Model string
Protocol string
Provider LLMProvider
Options map[string]any
} }
func MustMarshalParameters(params map[string]any) json.RawMessage { // UnmarshalArguments is a helper to parse FunctionCall.Arguments from json.RawMessage.
return protocoltypes.MustMarshalParameters(params) func UnmarshalArguments(raw json.RawMessage) (map[string]any, error) {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
return m, nil
} }

View file

@ -99,36 +99,6 @@ func (sm *Manager) SetLastChannel(channel string) error {
return nil return nil
} }
// SetLastHeartbeatTarget atomically updates the last heartbeat target and saves the state.
func (sm *Manager) SetLastHeartbeatTarget(target string) error {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.state.LastHeartbeatTarget = target
sm.state.Timestamp = time.Now()
if err := sm.saveAtomic(); err != nil {
return fmt.Errorf("failed to save state atomically: %w", err)
}
return nil
}
// SetHeartbeatTarget atomically updates the explicit heartbeat target and saves the state.
func (sm *Manager) SetHeartbeatTarget(target string) error {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.state.HeartbeatTarget = target
sm.state.Timestamp = time.Now()
if err := sm.saveAtomic(); err != nil {
return fmt.Errorf("failed to save state atomically: %w", err)
}
return nil
}
// SetLastChatID atomically updates the last chat ID and saves the state. // SetLastChatID atomically updates the last chat ID and saves the state.
func (sm *Manager) SetLastChatID(chatID string) error { func (sm *Manager) SetLastChatID(chatID string) error {
sm.mu.Lock() sm.mu.Lock()
@ -153,20 +123,6 @@ func (sm *Manager) GetLastChannel() string {
return sm.state.LastChannel return sm.state.LastChannel
} }
// GetLastHeartbeatTarget returns the last heartbeat target from the state.
func (sm *Manager) GetLastHeartbeatTarget() string {
sm.mu.RLock()
defer sm.mu.RUnlock()
return sm.state.LastHeartbeatTarget
}
// GetHeartbeatTarget returns the explicit heartbeat target from the state.
func (sm *Manager) GetHeartbeatTarget() string {
sm.mu.RLock()
defer sm.mu.RUnlock()
return sm.state.HeartbeatTarget
}
// GetLastChatID returns the last chat ID from the state. // GetLastChatID returns the last chat ID from the state.
func (sm *Manager) GetLastChatID() string { func (sm *Manager) GetLastChatID() string {
sm.mu.RLock() sm.mu.RLock()
@ -217,3 +173,47 @@ func (sm *Manager) load() error {
return nil return nil
} }
// SetLastHeartbeatTarget atomically updates the last heartbeat target and saves the state.
func (sm *Manager) SetLastHeartbeatTarget(target string) error {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.state.LastHeartbeatTarget = target
sm.state.Timestamp = time.Now()
if err := sm.saveAtomic(); err != nil {
return fmt.Errorf("failed to save state atomically: %w", err)
}
return nil
}
// SetHeartbeatTarget atomically updates the explicit heartbeat target and saves the state.
func (sm *Manager) SetHeartbeatTarget(target string) error {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.state.HeartbeatTarget = target
sm.state.Timestamp = time.Now()
if err := sm.saveAtomic(); err != nil {
return fmt.Errorf("failed to save state atomically: %w", err)
}
return nil
}
// GetLastHeartbeatTarget returns the last heartbeat target from the state.
func (sm *Manager) GetLastHeartbeatTarget() string {
sm.mu.RLock()
defer sm.mu.RUnlock()
return sm.state.LastHeartbeatTarget
}
// GetHeartbeatTarget returns the explicit heartbeat target from the state.
func (sm *Manager) GetHeartbeatTarget() string {
sm.mu.RLock()
defer sm.mu.RUnlock()
return sm.state.HeartbeatTarget
}

View file

@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"regexp"
"strings" "strings"
) )
@ -15,17 +16,12 @@ type EditFileTool struct {
} }
// NewEditFileTool creates a new EditFileTool with optional directory restriction. // NewEditFileTool creates a new EditFileTool with optional directory restriction.
func NewEditFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *EditFileTool {
func NewEditFileTool(workspace string, restrict bool) *EditFileTool { var patterns []*regexp.Regexp
var fs fileSystem if len(allowPaths) > 0 {
patterns = allowPaths[0]
if restrict {
fs = &sandboxFs{workspace: workspace}
} else {
fs = &hostFs{}
} }
return &EditFileTool{fs: buildFs(workspace, restrict, patterns)}
return &EditFileTool{fs: fs}
} }
func (t *EditFileTool) Name() string { func (t *EditFileTool) Name() string {
@ -42,17 +38,14 @@ func (t *EditFileTool) Parameters() map[string]any {
"properties": map[string]any{ "properties": map[string]any{
"path": map[string]any{ "path": map[string]any{
"type": "string", "type": "string",
"description": "The file path to edit", "description": "The file path to edit",
}, },
"old_text": map[string]any{ "old_text": map[string]any{
"type": "string", "type": "string",
"description": "The exact text to find and replace", "description": "The exact text to find and replace",
}, },
"new_text": map[string]any{ "new_text": map[string]any{
"type": "string", "type": "string",
"description": "The text to replace with", "description": "The text to replace with",
}, },
}, },
@ -76,7 +69,7 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("new_text is required") return ErrorResult("new_text is required")
} }
if err := editFile(resolveFS(ctx, t.fs, path), path, oldText, newText); err != nil { if err := editFile(t.fs, path, oldText, newText); err != nil {
return ErrorResult(err.Error()) return ErrorResult(err.Error())
} }
return SilentResult(fmt.Sprintf("File edited: %s", path)) return SilentResult(fmt.Sprintf("File edited: %s", path))
@ -86,16 +79,12 @@ type AppendFileTool struct {
fs fileSystem fs fileSystem
} }
func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool { func NewAppendFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *AppendFileTool {
var fs fileSystem var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
if restrict { patterns = allowPaths[0]
fs = &sandboxFs{workspace: workspace}
} else {
fs = &hostFs{}
} }
return &AppendFileTool{fs: buildFs(workspace, restrict, patterns)}
return &AppendFileTool{fs: fs}
} }
func (t *AppendFileTool) Name() string { func (t *AppendFileTool) Name() string {
@ -112,12 +101,10 @@ func (t *AppendFileTool) Parameters() map[string]any {
"properties": map[string]any{ "properties": map[string]any{
"path": map[string]any{ "path": map[string]any{
"type": "string", "type": "string",
"description": "The file path to append to", "description": "The file path to append to",
}, },
"content": map[string]any{ "content": map[string]any{
"type": "string", "type": "string",
"description": "The content to append", "description": "The content to append",
}, },
}, },
@ -136,7 +123,7 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool
return ErrorResult("content is required") return ErrorResult("content is required")
} }
if err := appendFile(resolveFS(ctx, t.fs, path), path, content); err != nil { if err := appendFile(t.fs, path, content); err != nil {
return ErrorResult(err.Error()) return ErrorResult(err.Error())
} }
return SilentResult(fmt.Sprintf("Appended to %s", path)) return SilentResult(fmt.Sprintf("Appended to %s", path))

View file

@ -30,41 +30,32 @@ func (t *I2CTool) Parameters() map[string]any {
"properties": map[string]any{ "properties": map[string]any{
"action": map[string]any{ "action": map[string]any{
"type": "string", "type": "string",
"enum": []string{"detect", "scan", "read", "write"}, "enum": []string{"detect", "scan", "read", "write"},
"description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)", "description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)",
}, },
"bus": map[string]any{ "bus": map[string]any{
"type": "string", "type": "string",
"description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.", "description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.",
}, },
"address": map[string]any{ "address": map[string]any{
"type": "integer", "type": "integer",
"description": "7-bit I2C device address (0x03-0x77). Required for read/write.", "description": "7-bit I2C device address (0x03-0x77). Required for read/write.",
}, },
"register": map[string]any{ "register": map[string]any{
"type": "integer", "type": "integer",
"description": "Register address to read from or write to. If set, sends register byte before read/write.", "description": "Register address to read from or write to. If set, sends register byte before read/write.",
}, },
"data": map[string]any{ "data": map[string]any{
"type": "array", "type": "array",
"items": map[string]any{"type": "integer"}, "items": map[string]any{"type": "integer"},
"description": "Bytes to write (0-255 each). Required for write action.", "description": "Bytes to write (0-255 each). Required for write action.",
}, },
"length": map[string]any{ "length": map[string]any{
"type": "integer", "type": "integer",
"description": "Number of bytes to read (1-256). Default: 1. Used with read action.", "description": "Number of bytes to read (1-256). Default: 1. Used with read action.",
}, },
"confirm": map[string]any{ "confirm": map[string]any{
"type": "boolean", "type": "boolean",
"description": "Must be true for write operations. Safety guard to prevent accidental writes.", "description": "Must be true for write operations. Safety guard to prevent accidental writes.",
}, },
}, },
@ -111,7 +102,6 @@ func (t *I2CTool) detect() *ToolResult {
type busInfo struct { type busInfo struct {
Path string `json:"path"` Path string `json:"path"`
Bus string `json:"bus"` Bus string `json:"bus"`
} }

View file

@ -97,12 +97,10 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
hasQuick := funcs&i2cFuncSmbusQuick != 0 hasQuick := funcs&i2cFuncSmbusQuick != 0
hasReadByte := funcs&i2cFuncSmbusReadByte != 0 hasReadByte := funcs&i2cFuncSmbusReadByte != 0
if !hasQuick && !hasReadByte { if !hasQuick && !hasReadByte {
return ErrorResult( return ErrorResult(
fmt.Sprintf( fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath),
"I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely",
devPath,
),
) )
} }
@ -125,6 +123,7 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
} }
continue continue
} }
if smbusProbe(fd, addr, hasQuick) { if smbusProbe(fd, addr, hasQuick) {
found = append(found, deviceEntry{ found = append(found, deviceEntry{
Address: fmt.Sprintf("0x%02x", addr), Address: fmt.Sprintf("0x%02x", addr),
@ -144,7 +143,7 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result))) return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result)))
} }
// readDevice reads bytes from an I2C device, optionally at a specific register. // readDevice reads bytes from an I2C device, optionally at a specific register
func (t *I2CTool) readDevice(args map[string]any) *ToolResult { func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
bus, errResult := parseI2CBus(args) bus, errResult := parseI2CBus(args)
if errResult != nil { if errResult != nil {
@ -214,14 +213,12 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
return SilentResult(string(result)) return SilentResult(string(result))
} }
// writeDevice writes bytes to an I2C device, optionally at a specific register. // writeDevice writes bytes to an I2C device, optionally at a specific register
func (t *I2CTool) writeDevice(args map[string]any) *ToolResult { func (t *I2CTool) writeDevice(args map[string]any) *ToolResult {
confirm, _ := args["confirm"].(bool) confirm, _ := args["confirm"].(bool)
if !confirm { if !confirm {
return ErrorResult( return ErrorResult(
"write operations require confirm: true." + "write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.",
" Please confirm with the user before writing to I2C devices," +
" as incorrect writes can misconfigure hardware.",
) )
} }

View file

@ -3,18 +3,14 @@ package tools
import ( import (
"context" "context"
"fmt" "fmt"
"sync/atomic"
) )
type SendCallback func(channel, chatID, content string) error type SendCallback func(channel, chatID, content string) error
type MessageTool struct { type MessageTool struct {
sendCallback SendCallback sendCallback SendCallback
sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round
defaultChannel string
defaultChatID string
sentInRound bool // Tracks whether a message was sent in the current processing round
} }
func NewMessageTool() *MessageTool { func NewMessageTool() *MessageTool {
@ -35,17 +31,14 @@ func (t *MessageTool) Parameters() map[string]any {
"properties": map[string]any{ "properties": map[string]any{
"content": map[string]any{ "content": map[string]any{
"type": "string", "type": "string",
"description": "The message content to send", "description": "The message content to send",
}, },
"channel": map[string]any{ "channel": map[string]any{
"type": "string", "type": "string",
"description": "Optional: target channel (telegram, whatsapp, etc.)", "description": "Optional: target channel (telegram, whatsapp, etc.)",
}, },
"chat_id": map[string]any{ "chat_id": map[string]any{
"type": "string", "type": "string",
"description": "Optional: target chat/user ID", "description": "Optional: target chat/user ID",
}, },
}, },
@ -53,17 +46,15 @@ func (t *MessageTool) Parameters() map[string]any {
} }
} }
func (t *MessageTool) SetContext(channel, chatID string) { // ResetSentInRound resets the per-round send tracker.
t.defaultChannel = channel // Called by the agent loop at the start of each inbound message processing round.
func (t *MessageTool) ResetSentInRound() {
t.defaultChatID = chatID t.sentInRound.Store(false)
t.sentInRound = false // Reset send tracking for new processing round
} }
// HasSentInRound returns true if the message tool sent a message during the current round. // HasSentInRound returns true if the message tool sent a message during the current round.
func (t *MessageTool) HasSentInRound() bool { func (t *MessageTool) HasSentInRound() bool {
return t.sentInRound return t.sentInRound.Load()
} }
func (t *MessageTool) SetSendCallback(callback SendCallback) { func (t *MessageTool) SetSendCallback(callback SendCallback) {
@ -80,10 +71,10 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
chatID, _ := args["chat_id"].(string) chatID, _ := args["chat_id"].(string)
if channel == "" { if channel == "" {
channel = t.defaultChannel channel = ToolChannel(ctx)
} }
if chatID == "" { if chatID == "" {
chatID = t.defaultChatID chatID = ToolChatID(ctx)
} }
if channel == "" || chatID == "" { if channel == "" || chatID == "" {
@ -97,15 +88,12 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
if err := t.sendCallback(channel, chatID, content); err != nil { if err := t.sendCallback(channel, chatID, content); err != nil {
return &ToolResult{ return &ToolResult{
ForLLM: fmt.Sprintf("sending message: %v", err), ForLLM: fmt.Sprintf("sending message: %v", err),
IsError: true, IsError: true,
Err: err, Err: err,
} }
} }
t.sentInRound = true t.sentInRound.Store(true)
// Silent: user already received the message directly // Silent: user already received the message directly
return &ToolResult{ return &ToolResult{
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),

View file

@ -2,10 +2,8 @@ package tools
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"sort" "sort"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -14,20 +12,6 @@ import (
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
// NormalizeToolName keeps only lowercase ASCII letters.
// "read_file" → "readfile", "ReadFile" → "readfile", "read-file" → "readfile".
func NormalizeToolName(s string) string {
var b strings.Builder
for _, r := range s {
if r >= 'A' && r <= 'Z' {
b.WriteRune(r + 32)
} else if r >= 'a' && r <= 'z' {
b.WriteRune(r)
}
}
return b.String()
}
type ToolEntry struct { type ToolEntry struct {
Tool Tool Tool Tool
IsCore bool IsCore bool
@ -155,9 +139,10 @@ func (r *ToolRegistry) SnapshotHiddenTools() HiddenToolSnapshot {
func (r *ToolRegistry) Get(name string) (Tool, bool) { func (r *ToolRegistry) Get(name string) (Tool, bool) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
entry, ok := r.tools[name]
// Exact match first if !ok {
if entry, ok := r.tools[name]; ok { return nil, false
}
// Hidden tools with expired TTL are not callable. // Hidden tools with expired TTL are not callable.
if !entry.IsCore && entry.TTL <= 0 { if !entry.IsCore && entry.TTL <= 0 {
return nil, false return nil, false
@ -165,20 +150,6 @@ func (r *ToolRegistry) Get(name string) (Tool, bool) {
return entry.Tool, true return entry.Tool, true
} }
// Fork extension: fuzzy fallback — normalize and compare
// (handles "readfile" → "read_file" etc.)
norm := NormalizeToolName(name)
for _, entry := range r.tools {
if entry.IsCore || entry.TTL > 0 {
if NormalizeToolName(entry.Tool.Name()) == norm {
return entry.Tool, true
}
}
}
return nil, false
}
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult { func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult {
return r.ExecuteWithContext(ctx, name, args, "", "", nil) return r.ExecuteWithContext(ctx, name, args, "", "", nil)
} }
@ -213,11 +184,6 @@ func (r *ToolRegistry) ExecuteWithContext(
// Always inject — tools validate what they require. // Always inject — tools validate what they require.
ctx = WithToolContext(ctx, channel, chatID) ctx = WithToolContext(ctx, channel, chatID)
// Legacy ContextualTool support (fork-only, prefer ctx-based injection above)
if contextualTool, ok := tool.(ContextualTool); ok && channel != "" && chatID != "" {
contextualTool.SetContext(channel, chatID)
}
// If tool implements AsyncExecutor and callback is provided, use ExecuteAsync. // If tool implements AsyncExecutor and callback is provided, use ExecuteAsync.
// The callback is a call parameter, not mutable state on the tool instance. // The callback is a call parameter, not mutable state on the tool instance.
var result *ToolResult var result *ToolResult
@ -228,14 +194,6 @@ func (r *ToolRegistry) ExecuteWithContext(
"tool": name, "tool": name,
}) })
result = asyncExec.ExecuteAsync(ctx, args, asyncCallback) result = asyncExec.ExecuteAsync(ctx, args, asyncCallback)
} else if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil {
// Legacy AsyncTool support (fork-only, prefer AsyncExecutor above)
asyncTool.SetCallback(asyncCallback)
logger.DebugCF("tool", "Async callback injected (legacy)",
map[string]any{
"tool": name,
})
result = tool.Execute(ctx, args)
} else { } else {
result = tool.Execute(ctx, args) result = tool.Execute(ctx, args)
} }
@ -293,7 +251,7 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any {
continue continue
} }
definitions = append(definitions, ToolToSchema(entry.Tool)) definitions = append(definitions, ToolToSchema(r.tools[name].Tool))
} }
return definitions return definitions
} }
@ -325,19 +283,12 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
desc, _ := fn["description"].(string) desc, _ := fn["description"].(string)
params, _ := fn["parameters"].(map[string]any) params, _ := fn["parameters"].(map[string]any)
paramsRaw := json.RawMessage(`{}`)
if len(params) > 0 {
if payload, err := json.Marshal(params); err == nil {
paramsRaw = payload
}
}
definitions = append(definitions, providers.ToolDefinition{ definitions = append(definitions, providers.ToolDefinition{
Type: "function", Type: "function",
Function: providers.ToolFunctionDefinition{ Function: providers.ToolFunctionDefinition{
Name: name, Name: name,
Description: desc, Description: desc,
Parameters: paramsRaw, Parameters: params,
}, },
}) })
} }
@ -374,8 +325,7 @@ func (r *ToolRegistry) GetSummaries() []string {
continue continue
} }
hint := buildParamHint(entry.Tool.Parameters()) summaries = append(summaries, fmt.Sprintf("- `%s` - %s", entry.Tool.Name(), entry.Tool.Description()))
summaries = append(summaries, fmt.Sprintf("- `%s`%s - %s", entry.Tool.Name(), hint, entry.Tool.Description()))
} }
return summaries return summaries
} }

View file

@ -3,10 +3,7 @@
package tools package tools
import ( import (
"os"
"os/exec" "os/exec"
"strconv"
"strings"
"syscall" "syscall"
) )
@ -21,6 +18,7 @@ func terminateProcessTree(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil { if cmd == nil || cmd.Process == nil {
return nil return nil
} }
pid := cmd.Process.Pid pid := cmd.Process.Pid
if pid <= 0 { if pid <= 0 {
return nil return nil
@ -28,58 +26,7 @@ func terminateProcessTree(cmd *exec.Cmd) error {
// Kill the entire process group spawned by the shell command. // Kill the entire process group spawned by the shell command.
_ = syscall.Kill(-pid, syscall.SIGKILL) _ = syscall.Kill(-pid, syscall.SIGKILL)
// Some shells/background jobs may still leave descendants around
// briefly; aggressively walk /proc and kill child processes too.
killDescendants(pid)
// Fallback kill on the shell process itself. // Fallback kill on the shell process itself.
_ = cmd.Process.Kill() _ = cmd.Process.Kill()
return nil return nil
} }
func killDescendants(ppid int) {
if ppid <= 0 {
return
}
entries, err := os.ReadDir("/proc")
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() {
continue
}
childPID, err := strconv.Atoi(e.Name())
if err != nil || childPID <= 0 || childPID == ppid {
continue
}
statPath := "/proc/" + e.Name() + "/stat"
data, err := os.ReadFile(statPath)
if err != nil {
continue
}
// /proc/<pid>/stat: pid (comm) state ppid ...
raw := string(data)
end := strings.LastIndex(raw, ")")
if end == -1 || end+2 >= len(raw) {
continue
}
fields := strings.Fields(raw[end+2:])
if len(fields) < 2 {
continue
}
parent, err := strconv.Atoi(fields[1])
if err != nil || parent != ppid {
continue
}
// Recurse first, then kill child process/group.
killDescendants(childPID)
_ = syscall.Kill(-childPID, syscall.SIGKILL)
_ = syscall.Kill(childPID, syscall.SIGKILL)
}
}

View file

@ -20,9 +20,7 @@ import (
// so all registries configured in config are available for installation. // so all registries configured in config are available for installation.
type InstallSkillTool struct { type InstallSkillTool struct {
registryMgr *skills.RegistryManager registryMgr *skills.RegistryManager
workspace string workspace string
mu sync.Mutex mu sync.Mutex
} }
@ -32,9 +30,7 @@ type InstallSkillTool struct {
func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool { func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool {
return &InstallSkillTool{ return &InstallSkillTool{
registryMgr: registryMgr, registryMgr: registryMgr,
workspace: workspace, workspace: workspace,
mu: sync.Mutex{}, mu: sync.Mutex{},
} }
} }
@ -53,22 +49,18 @@ func (t *InstallSkillTool) Parameters() map[string]any {
"properties": map[string]any{ "properties": map[string]any{
"slug": map[string]any{ "slug": map[string]any{
"type": "string", "type": "string",
"description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')", "description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')",
}, },
"version": map[string]any{ "version": map[string]any{
"type": "string", "type": "string",
"description": "Specific version to install (optional, defaults to latest)", "description": "Specific version to install (optional, defaults to latest)",
}, },
"registry": map[string]any{ "registry": map[string]any{
"type": "string", "type": "string",
"description": "Registry to install from (required, e.g., 'clawhub')", "description": "Registry to install from (required, e.g., 'clawhub')",
}, },
"force": map[string]any{ "force": map[string]any{
"type": "boolean", "type": "boolean",
"description": "Force reinstall if skill already exists (default false)", "description": "Force reinstall if skill already exists (default false)",
}, },
}, },
@ -132,9 +124,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
logger.ErrorCF("tool", "Failed to remove partial install", logger.ErrorCF("tool", "Failed to remove partial install",
map[string]any{ map[string]any{
"tool": "install_skill", "tool": "install_skill",
"target_dir": targetDir, "target_dir": targetDir,
"error": rmErr.Error(), "error": rmErr.Error(),
}) })
} }
@ -148,9 +138,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
logger.ErrorCF("tool", "Failed to remove partial install", logger.ErrorCF("tool", "Failed to remove partial install",
map[string]any{ map[string]any{
"tool": "install_skill", "tool": "install_skill",
"target_dir": targetDir, "target_dir": targetDir,
"error": rmErr.Error(), "error": rmErr.Error(),
}) })
} }
@ -162,15 +150,10 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
logger.ErrorCF("tool", "Failed to write origin metadata", logger.ErrorCF("tool", "Failed to write origin metadata",
map[string]any{ map[string]any{
"tool": "install_skill", "tool": "install_skill",
"error": err.Error(), "error": err.Error(),
"target": targetDir, "target": targetDir,
"registry": registry.Name(), "registry": registry.Name(),
"slug": slug, "slug": slug,
"version": result.Version, "version": result.Version,
}) })
_ = err _ = err
@ -195,26 +178,18 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
// originMeta tracks which registry a skill was installed from. // originMeta tracks which registry a skill was installed from.
type originMeta struct { type originMeta struct {
Version int `json:"version"` Version int `json:"version"`
Registry string `json:"registry"` Registry string `json:"registry"`
Slug string `json:"slug"` Slug string `json:"slug"`
InstalledVersion string `json:"installed_version"` InstalledVersion string `json:"installed_version"`
InstalledAt int64 `json:"installed_at"` InstalledAt int64 `json:"installed_at"`
} }
func writeOriginMeta(targetDir, registryName, slug, version string) error { func writeOriginMeta(targetDir, registryName, slug, version string) error {
meta := originMeta{ meta := originMeta{
Version: 1, Version: 1,
Registry: registryName, Registry: registryName,
Slug: slug, Slug: slug,
InstalledVersion: version, InstalledVersion: version,
InstalledAt: time.Now().UnixMilli(), InstalledAt: time.Now().UnixMilli(),
} }

View file

@ -11,7 +11,6 @@ import (
// FindSkillsTool allows the LLM agent to search for installable skills from registries. // FindSkillsTool allows the LLM agent to search for installable skills from registries.
type FindSkillsTool struct { type FindSkillsTool struct {
registryMgr *skills.RegistryManager registryMgr *skills.RegistryManager
cache *skills.SearchCache cache *skills.SearchCache
} }
@ -21,7 +20,6 @@ type FindSkillsTool struct {
func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool { func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool {
return &FindSkillsTool{ return &FindSkillsTool{
registryMgr: registryMgr, registryMgr: registryMgr,
cache: cache, cache: cache,
} }
} }
@ -40,16 +38,12 @@ func (t *FindSkillsTool) Parameters() map[string]any {
"properties": map[string]any{ "properties": map[string]any{
"query": map[string]any{ "query": map[string]any{
"type": "string", "type": "string",
"description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')", "description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')",
}, },
"limit": map[string]any{ "limit": map[string]any{
"type": "integer", "type": "integer",
"description": "Maximum number of results to return (1-20, default 5)", "description": "Maximum number of results to return (1-20, default 5)",
"minimum": 1.0, "minimum": 1.0,
"maximum": 20.0, "maximum": 20.0,
}, },
}, },

View file

@ -30,46 +30,36 @@ func (t *SPITool) Parameters() map[string]any {
"properties": map[string]any{ "properties": map[string]any{
"action": map[string]any{ "action": map[string]any{
"type": "string", "type": "string",
"enum": []string{"list", "transfer", "read"}, "enum": []string{"list", "transfer", "read"},
"description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)", "description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)",
}, },
"device": map[string]any{ "device": map[string]any{
"type": "string", "type": "string",
"description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.", "description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.",
}, },
"speed": map[string]any{ "speed": map[string]any{
"type": "integer", "type": "integer",
"description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).", "description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).",
}, },
"mode": map[string]any{ "mode": map[string]any{
"type": "integer", "type": "integer",
"description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.", "description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.",
}, },
"bits": map[string]any{ "bits": map[string]any{
"type": "integer", "type": "integer",
"description": "Bits per word. Default: 8.", "description": "Bits per word. Default: 8.",
}, },
"data": map[string]any{ "data": map[string]any{
"type": "array", "type": "array",
"items": map[string]any{"type": "integer"}, "items": map[string]any{"type": "integer"},
"description": "Bytes to send (0-255 each). Required for transfer action.", "description": "Bytes to send (0-255 each). Required for transfer action.",
}, },
"length": map[string]any{ "length": map[string]any{
"type": "integer", "type": "integer",
"description": "Number of bytes to read (1-4096). Required for read action.", "description": "Number of bytes to read (1-4096). Required for read action.",
}, },
"confirm": map[string]any{ "confirm": map[string]any{
"type": "boolean", "type": "boolean",
"description": "Must be true for transfer operations. Safety guard to prevent accidental writes.", "description": "Must be true for transfer operations. Safety guard to prevent accidental writes.",
}, },
}, },
@ -114,7 +104,6 @@ func (t *SPITool) list() *ToolResult {
type devInfo struct { type devInfo struct {
Path string `json:"path"` Path string `json:"path"`
Device string `json:"device"` Device string `json:"device"`
} }

View file

@ -34,10 +34,8 @@ type spiTransfer struct {
pad uint8 pad uint8
} }
// configureSPI opens an SPI device and sets mode, bits per word, and speed. // configureSPI opens an SPI device and sets mode, bits per word, and speed
func configureSPI( func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) {
devPath string, mode uint8, bits uint8, speed uint32,
) (int, *ToolResult) {
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
if err != nil { if err != nil {
return -1, ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err)) return -1, ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err))
@ -67,13 +65,12 @@ func configureSPI(
return fd, nil return fd, nil
} }
// transfer performs a full-duplex SPI transfer. // transfer performs a full-duplex SPI transfer
func (t *SPITool) transfer(args map[string]any) *ToolResult { func (t *SPITool) transfer(args map[string]any) *ToolResult {
confirm, _ := args["confirm"].(bool) confirm, _ := args["confirm"].(bool)
if !confirm { if !confirm {
return ErrorResult( return ErrorResult(
"transfer operations require confirm: true." + "transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.",
" Please confirm with the user before sending data to SPI devices.",
) )
} }
@ -111,6 +108,7 @@ func (t *SPITool) transfer(args map[string]any) *ToolResult {
defer syscall.Close(fd) defer syscall.Close(fd)
rxBuf := make([]byte, len(txBuf)) rxBuf := make([]byte, len(txBuf))
xfer := spiTransfer{ xfer := spiTransfer{
txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))),
rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))), rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))),
@ -143,7 +141,7 @@ func (t *SPITool) transfer(args map[string]any) *ToolResult {
return SilentResult(string(result)) return SilentResult(string(result))
} }
// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed). // readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed)
func (t *SPITool) readDevice(args map[string]any) *ToolResult { func (t *SPITool) readDevice(args map[string]any) *ToolResult {
dev, speed, mode, bits, errMsg := parseSPIArgs(args) dev, speed, mode, bits, errMsg := parseSPIArgs(args)
if errMsg != "" { if errMsg != "" {
@ -167,6 +165,7 @@ func (t *SPITool) readDevice(args map[string]any) *ToolResult {
txBuf := make([]byte, length) // zeros txBuf := make([]byte, length) // zeros
rxBuf := make([]byte, length) rxBuf := make([]byte, length)
xfer := spiTransfer{ xfer := spiTransfer{
txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))),
rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))), rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))),