style: fix gofumpt/gci formatting and bodyclose lint warning
Run gofumpt and gci to fix import ordering and formatting across all files flagged by golangci-lint. Add nolint:bodyclose directive for streaming HTTP response (body is closed in goroutine). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d1a6aa921a
commit
0ef6d2d1a6
27 changed files with 182 additions and 194 deletions
|
|
@ -44,7 +44,7 @@ When results come back, synthesize and decide the next fork.`
|
||||||
|
|
||||||
type ContextBuilder struct {
|
type ContextBuilder struct {
|
||||||
workspace string
|
workspace string
|
||||||
workDir string // session-specific working directory (worktree or project subdir)
|
workDir string // session-specific working directory (worktree or project subdir)
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
memory *MemoryStore
|
memory *MemoryStore
|
||||||
tools *tools.ToolRegistry // Direct reference to tool registry
|
tools *tools.ToolRegistry // Direct reference to tool registry
|
||||||
|
|
|
||||||
|
|
@ -19,30 +19,30 @@ import (
|
||||||
// AgentInstance represents a fully configured agent with its own workspace,
|
// AgentInstance represents a fully configured agent with its own workspace,
|
||||||
// session manager, context builder, and tool registry.
|
// session manager, context builder, and tool registry.
|
||||||
type AgentInstance struct {
|
type AgentInstance struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Model string
|
Model string
|
||||||
Fallbacks []string
|
Fallbacks []string
|
||||||
Workspace string
|
Workspace string
|
||||||
MaxIterations int
|
MaxIterations int
|
||||||
TaskReminderInterval int
|
TaskReminderInterval int
|
||||||
MaxTokens int
|
MaxTokens int
|
||||||
Temperature float64
|
Temperature float64
|
||||||
ContextWindow int
|
ContextWindow int
|
||||||
Provider providers.LLMProvider
|
Provider providers.LLMProvider
|
||||||
Sessions *session.SessionManager
|
Sessions *session.SessionManager
|
||||||
ContextBuilder *ContextBuilder
|
ContextBuilder *ContextBuilder
|
||||||
Tools *tools.ToolRegistry
|
Tools *tools.ToolRegistry
|
||||||
Subagents *config.SubagentsConfig
|
Subagents *config.SubagentsConfig
|
||||||
SkillsFilter []string
|
SkillsFilter []string
|
||||||
Candidates []providers.FallbackCandidate
|
Candidates []providers.FallbackCandidate
|
||||||
PlanModel string
|
PlanModel string
|
||||||
PlanFallbacks []string
|
PlanFallbacks []string
|
||||||
PlanCandidates []providers.FallbackCandidate
|
PlanCandidates []providers.FallbackCandidate
|
||||||
|
|
||||||
// Interview staleness tracking: consecutive turns where MEMORY.md was not updated.
|
// Interview staleness tracking: consecutive turns where MEMORY.md was not updated.
|
||||||
interviewStaleCount int
|
interviewStaleCount int
|
||||||
interviewMemoryLen int
|
interviewMemoryLen int
|
||||||
|
|
||||||
// Per-session worktree isolation
|
// Per-session worktree isolation
|
||||||
worktrees map[string]*git.WorktreeInfo // sessionKey → worktree
|
worktrees map[string]*git.WorktreeInfo // sessionKey → worktree
|
||||||
|
|
@ -189,26 +189,26 @@ func NewAgentInstance(
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AgentInstance{
|
return &AgentInstance{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
Name: agentName,
|
Name: agentName,
|
||||||
Model: model,
|
Model: model,
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
Workspace: workspace,
|
Workspace: workspace,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
TaskReminderInterval: reminderInterval,
|
TaskReminderInterval: reminderInterval,
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
Temperature: temperature,
|
Temperature: temperature,
|
||||||
ContextWindow: maxTokens,
|
ContextWindow: maxTokens,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
Sessions: sessionsManager,
|
Sessions: sessionsManager,
|
||||||
ContextBuilder: contextBuilder,
|
ContextBuilder: contextBuilder,
|
||||||
Tools: toolsRegistry,
|
Tools: toolsRegistry,
|
||||||
Subagents: subagents,
|
Subagents: subagents,
|
||||||
SkillsFilter: skillsFilter,
|
SkillsFilter: skillsFilter,
|
||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
PlanModel: planModel,
|
PlanModel: planModel,
|
||||||
PlanFallbacks: planFallbacks,
|
PlanFallbacks: planFallbacks,
|
||||||
PlanCandidates: planCandidates,
|
PlanCandidates: planCandidates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,24 +41,24 @@ import (
|
||||||
|
|
||||||
// activeTask tracks a running agent task for live status and intervention.
|
// activeTask tracks a running agent task for live status and intervention.
|
||||||
type activeTask struct {
|
type activeTask struct {
|
||||||
Description string
|
Description string
|
||||||
Iteration int
|
Iteration int
|
||||||
MaxIter int
|
MaxIter int
|
||||||
StartedAt time.Time
|
StartedAt time.Time
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
interrupt chan string // buffered 1, for user message injection
|
interrupt chan string // buffered 1, for user message injection
|
||||||
toolLog []toolLogEntry
|
toolLog []toolLogEntry
|
||||||
lastError *toolLogEntry // sticky: most recent error, persists across iterations
|
lastError *toolLogEntry // sticky: most recent error, persists across iterations
|
||||||
projectDir string // detected from exec cd target (authoritative)
|
projectDir string // detected from exec cd target (authoritative)
|
||||||
fileCommonDir string // LCP of file paths relative to workspace (fallback)
|
fileCommonDir string // LCP of file paths relative to workspace (fallback)
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolLogEntry records a single tool call for the live terminal view.
|
// toolLogEntry records a single tool call for the live terminal view.
|
||||||
type toolLogEntry struct {
|
type toolLogEntry struct {
|
||||||
Name string
|
Name string
|
||||||
ArgsSnip string // first ~80 chars of args
|
ArgsSnip string // first ~80 chars of args
|
||||||
Result string // "✓ 4.9s" or "✗ 3.2s"
|
Result string // "✓ 4.9s" or "✗ 3.2s"
|
||||||
ErrDetail string // non-empty on error — e.g. "Exit code: exit status 1"
|
ErrDetail string // non-empty on error — e.g. "Exit code: exit status 1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,26 +78,26 @@ func newSessionSemaphore() *sessionSemaphore {
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentLoop struct {
|
type AgentLoop struct {
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
registry *AgentRegistry
|
registry *AgentRegistry
|
||||||
state *state.Manager
|
state *state.Manager
|
||||||
stats *stats.Tracker // nil when --stats not passed
|
stats *stats.Tracker // nil when --stats not passed
|
||||||
running atomic.Bool
|
running atomic.Bool
|
||||||
summarizing sync.Map
|
summarizing sync.Map
|
||||||
fallback *providers.FallbackChain
|
fallback *providers.FallbackChain
|
||||||
channelManager *channels.Manager
|
channelManager *channels.Manager
|
||||||
mediaStore media.MediaStore
|
mediaStore media.MediaStore
|
||||||
providerCache map[string]providers.LLMProvider
|
providerCache map[string]providers.LLMProvider
|
||||||
planStartPending bool // set by /plan start to trigger LLM execution
|
planStartPending bool // set by /plan start to trigger LLM execution
|
||||||
planClearHistory bool // set by /plan start clear to wipe history on transition
|
planClearHistory bool // set by /plan start clear to wipe history on transition
|
||||||
sessionLocks sync.Map // sessionKey → *sessionSemaphore
|
sessionLocks sync.Map // sessionKey → *sessionSemaphore
|
||||||
activeTasks sync.Map // sessionKey → *activeTask
|
activeTasks sync.Map // sessionKey → *activeTask
|
||||||
sessions *SessionTracker
|
sessions *SessionTracker
|
||||||
lastSystemPrompt atomic.Value // string — last system prompt sent to LLM
|
lastSystemPrompt atomic.Value // string — last system prompt sent to LLM
|
||||||
promptDirty atomic.Bool // true = rebuild needed on next GetSystemPrompt read
|
promptDirty atomic.Bool // true = rebuild needed on next GetSystemPrompt read
|
||||||
OnStateChange func() // called on plan/session/skills mutations
|
OnStateChange func() // called on plan/session/skills mutations
|
||||||
OnUserMessage func() // called when a real user message is processed
|
OnUserMessage func() // called when a real user message is processed
|
||||||
orchBroadcaster *orch.Broadcaster // nil when --orchestration not set
|
orchBroadcaster *orch.Broadcaster // nil when --orchestration not set
|
||||||
orchReporter orch.AgentReporter // always non-nil (Noop when disabled)
|
orchReporter orch.AgentReporter // always non-nil (Noop when disabled)
|
||||||
}
|
}
|
||||||
|
|
@ -601,8 +601,8 @@ func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, cha
|
||||||
DefaultResponse: defaultResponse,
|
DefaultResponse: defaultResponse,
|
||||||
EnableSummary: false,
|
EnableSummary: false,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
NoHistory: true, // Don't load session history for heartbeat
|
NoHistory: true, // Don't load session history for heartbeat
|
||||||
Background: true, // Enable live task notifications on Telegram
|
Background: true, // Enable live task notifications on Telegram
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -976,8 +976,8 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
if removedCount > 0 {
|
if removedCount > 0 {
|
||||||
logger.WarnCF("agent", "Sanitized session history: removed orphaned messages",
|
logger.WarnCF("agent", "Sanitized session history: removed orphaned messages",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"session_key": opts.SessionKey,
|
"session_key": opts.SessionKey,
|
||||||
"removed_count": removedCount,
|
"removed_count": removedCount,
|
||||||
})
|
})
|
||||||
// Persist the sanitized history
|
// Persist the sanitized history
|
||||||
agent.Sessions.SetHistory(opts.SessionKey, history)
|
agent.Sessions.SetHistory(opts.SessionKey, history)
|
||||||
|
|
@ -1174,8 +1174,10 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task reminder constants and helpers.
|
// Task reminder constants and helpers.
|
||||||
const taskReminderMaxChars = 500
|
const (
|
||||||
const blockerMaxChars = 200
|
taskReminderMaxChars = 500
|
||||||
|
blockerMaxChars = 200
|
||||||
|
)
|
||||||
|
|
||||||
func shouldInjectReminder(iteration, interval int) bool {
|
func shouldInjectReminder(iteration, interval int) bool {
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
|
|
@ -1361,7 +1363,7 @@ func buildArgsSnippet(toolName string, args map[string]interface{}, workspace st
|
||||||
if runes := []rune(path); len(runes) > maxPath {
|
if runes := []rune(path); len(runes) > maxPath {
|
||||||
// Find last slash to extract filename
|
// Find last slash to extract filename
|
||||||
if lastSlash := strings.LastIndex(path, "/"); lastSlash >= 0 {
|
if lastSlash := strings.LastIndex(path, "/"); lastSlash >= 0 {
|
||||||
filename := path[lastSlash:] // includes "/"
|
filename := path[lastSlash:] // includes "/"
|
||||||
dirBudget := maxPath - len([]rune(filename)) - 1 // 1 for "…"
|
dirBudget := maxPath - len([]rune(filename)) - 1 // 1 for "…"
|
||||||
if dirBudget > 0 {
|
if dirBudget > 0 {
|
||||||
dir := []rune(path[:lastSlash])
|
dir := []rune(path[:lastSlash])
|
||||||
|
|
@ -1509,10 +1511,10 @@ func compressRepeats(s string) string {
|
||||||
|
|
||||||
// Display layout constants.
|
// Display layout constants.
|
||||||
const (
|
const (
|
||||||
displayPastEntries = 4 // number of compact 1-line past entries
|
displayPastEntries = 4 // number of compact 1-line past entries
|
||||||
displayErrorLines = 5 // content lines inside the error code block
|
displayErrorLines = 5 // content lines inside the error code block
|
||||||
statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n"
|
statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n"
|
||||||
streamingDisplayLines = 17 // line count matching buildRichStatus output
|
streamingDisplayLines = 17 // line count matching buildRichStatus output
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildRichStatus builds a fixed-height terminal-like status display.
|
// buildRichStatus builds a fixed-height terminal-like status display.
|
||||||
|
|
@ -3338,16 +3340,16 @@ func isPlanPreExecution(status string) bool {
|
||||||
// filterInterviewTools uses this to strip tool *definitions* before the LLM call,
|
// filterInterviewTools uses this to strip tool *definitions* before the LLM call,
|
||||||
// while isToolAllowedDuringInterview adds argument-level checks as a second gate.
|
// while isToolAllowedDuringInterview adds argument-level checks as a second gate.
|
||||||
var interviewAllowedTools = map[string]bool{
|
var interviewAllowedTools = map[string]bool{
|
||||||
"readfile": true,
|
"readfile": true,
|
||||||
"listdir": true,
|
"listdir": true,
|
||||||
"websearch": true,
|
"websearch": true,
|
||||||
"webfetch": true,
|
"webfetch": true,
|
||||||
"message": true,
|
"message": true,
|
||||||
"editfile": true,
|
"editfile": true,
|
||||||
"appendfile": true,
|
"appendfile": true,
|
||||||
"writefile": true,
|
"writefile": true,
|
||||||
"exec": true,
|
"exec": true,
|
||||||
"logs": true,
|
"logs": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterInterviewTools removes tool definitions that are not in the
|
// filterInterviewTools removes tool definitions that are not in the
|
||||||
|
|
|
||||||
|
|
@ -1664,8 +1664,8 @@ func TestBuildRichStatus(t *testing.T) {
|
||||||
mustContain := []string{
|
mustContain := []string{
|
||||||
"Task in progress (3/20)",
|
"Task in progress (3/20)",
|
||||||
"my-projects",
|
"my-projects",
|
||||||
"read_file", // latest entry
|
"read_file", // latest entry
|
||||||
"No errors", // no error yet
|
"No errors", // no error yet
|
||||||
}
|
}
|
||||||
for _, s := range mustContain {
|
for _, s := range mustContain {
|
||||||
if !strings.Contains(got, s) {
|
if !strings.Contains(got, s) {
|
||||||
|
|
@ -1833,15 +1833,18 @@ func TestBuildRichStatus_FixedHeight(t *testing.T) {
|
||||||
lines0 := countLines(buildRichStatus(task0, true, "/ws/p"))
|
lines0 := countLines(buildRichStatus(task0, true, "/ws/p"))
|
||||||
|
|
||||||
// 1 entry
|
// 1 entry
|
||||||
task1 := &activeTask{Iteration: 1, MaxIter: 10,
|
task1 := &activeTask{
|
||||||
toolLog: []toolLogEntry{{Name: "exec", ArgsSnip: "ls", Result: "⏳"}}}
|
Iteration: 1, MaxIter: 10,
|
||||||
|
toolLog: []toolLogEntry{{Name: "exec", ArgsSnip: "ls", Result: "⏳"}},
|
||||||
|
}
|
||||||
lines1 := countLines(buildRichStatus(task1, true, "/ws/p"))
|
lines1 := countLines(buildRichStatus(task1, true, "/ws/p"))
|
||||||
|
|
||||||
// 5 entries
|
// 5 entries
|
||||||
task5 := &activeTask{Iteration: 5, MaxIter: 10}
|
task5 := &activeTask{Iteration: 5, MaxIter: 10}
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
task5.toolLog = append(task5.toolLog, toolLogEntry{
|
task5.toolLog = append(task5.toolLog, toolLogEntry{
|
||||||
Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "✓ 1.0s"})
|
Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "✓ 1.0s",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
lines5 := countLines(buildRichStatus(task5, true, "/ws/p"))
|
lines5 := countLines(buildRichStatus(task5, true, "/ws/p"))
|
||||||
|
|
||||||
|
|
@ -1849,10 +1852,13 @@ func TestBuildRichStatus_FixedHeight(t *testing.T) {
|
||||||
task5err := &activeTask{Iteration: 5, MaxIter: 10}
|
task5err := &activeTask{Iteration: 5, MaxIter: 10}
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
task5err.toolLog = append(task5err.toolLog, toolLogEntry{
|
task5err.toolLog = append(task5err.toolLog, toolLogEntry{
|
||||||
Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "✓ 1.0s"})
|
Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "✓ 1.0s",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
errEntry := toolLogEntry{
|
||||||
|
Name: "[3] exec", ArgsSnip: "pytest", Result: "✗ 2.0s",
|
||||||
|
ErrDetail: "FAILED test\nExit code: 1",
|
||||||
}
|
}
|
||||||
errEntry := toolLogEntry{Name: "[3] exec", ArgsSnip: "pytest", Result: "✗ 2.0s",
|
|
||||||
ErrDetail: "FAILED test\nExit code: 1"}
|
|
||||||
task5err.lastError = &errEntry
|
task5err.lastError = &errEntry
|
||||||
lines5err := countLines(buildRichStatus(task5err, true, "/ws/p"))
|
lines5err := countLines(buildRichStatus(task5err, true, "/ws/p"))
|
||||||
|
|
||||||
|
|
@ -2433,9 +2439,9 @@ func TestConsumeStream_OnChunkWithRepetitionDetection(t *testing.T) {
|
||||||
|
|
||||||
// modelCapturingMockProvider records which model was passed to Chat.
|
// modelCapturingMockProvider records which model was passed to Chat.
|
||||||
type modelCapturingMockProvider struct {
|
type modelCapturingMockProvider struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
models []string
|
models []string
|
||||||
response string
|
response string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelCapturingMockProvider) Chat(
|
func (m *modelCapturingMockProvider) Chat(
|
||||||
|
|
|
||||||
|
|
@ -171,16 +171,16 @@ type SessionConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentDefaults struct {
|
type AgentDefaults struct {
|
||||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
||||||
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
||||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||||
PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"`
|
PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"`
|
||||||
PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"`
|
PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||||
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,12 @@ func DefaultConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Agents: AgentsConfig{
|
Agents: AgentsConfig{
|
||||||
Defaults: AgentDefaults{
|
Defaults: AgentDefaults{
|
||||||
Workspace: "~/.picoclaw/workspace",
|
Workspace: "~/.picoclaw/workspace",
|
||||||
RestrictToWorkspace: true,
|
RestrictToWorkspace: true,
|
||||||
Provider: "",
|
Provider: "",
|
||||||
Model: "",
|
Model: "",
|
||||||
MaxTokens: 32768,
|
MaxTokens: 32768,
|
||||||
Temperature: nil, // nil means use provider default
|
Temperature: nil, // nil means use provider default
|
||||||
MaxToolIterations: 50,
|
MaxToolIterations: 50,
|
||||||
TaskReminderInterval: 5,
|
TaskReminderInterval: 5,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -294,10 +294,10 @@ func TestUnsubscribe_ClosesChannel(t *testing.T) {
|
||||||
|
|
||||||
func TestSanitizeFields(t *testing.T) {
|
func TestSanitizeFields(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input map[string]any
|
input map[string]any
|
||||||
maskedK []string // keys that should be "***"
|
maskedK []string // keys that should be "***"
|
||||||
safeK []string // keys that should keep original value
|
safeK []string // keys that should keep original value
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "nil fields",
|
name: "nil fields",
|
||||||
|
|
@ -320,9 +320,9 @@ func TestSanitizeFields(t *testing.T) {
|
||||||
maskedK: []string{"Token", "API_KEY", "Secret", "PASSWORD", "Authorization", "Credential"},
|
maskedK: []string{"Token", "API_KEY", "Secret", "PASSWORD", "Authorization", "Credential"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "safe keys preserved",
|
name: "safe keys preserved",
|
||||||
input: map[string]any{"error": "something failed", "count": 42, "user_id": "12345", "component": "test"},
|
input: map[string]any{"error": "something failed", "count": 42, "user_id": "12345", "component": "test"},
|
||||||
safeK: []string{"error", "count", "user_id", "component"},
|
safeK: []string{"error", "count", "user_id", "component"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mixed keys",
|
name: "mixed keys",
|
||||||
|
|
@ -363,9 +363,9 @@ func TestRecentLogsSanitizesFields(t *testing.T) {
|
||||||
SetLevel(DEBUG)
|
SetLevel(DEBUG)
|
||||||
|
|
||||||
InfoCF("sanitize-test", "log with sensitive fields", map[string]any{
|
InfoCF("sanitize-test", "log with sensitive fields", map[string]any{
|
||||||
"token": "my-secret-token",
|
"token": "my-secret-token",
|
||||||
"api_key": "sk-12345",
|
"api_key": "sk-12345",
|
||||||
"user_id": "safe-value",
|
"user_id": "safe-value",
|
||||||
})
|
})
|
||||||
|
|
||||||
got := RecentLogs(DEBUG, "sanitize-test", 100)
|
got := RecentLogs(DEBUG, "sanitize-test", 100)
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,16 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
func (h *Handler) apiSkills(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiSkills(w http.ResponseWriter, r *http.Request) {
|
||||||
skillsList := h.provider.ListSkills()
|
skillsList := h.provider.ListSkills()
|
||||||
writeJSON(w, skillsList)
|
writeJSON(w, skillsList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (h *Handler) apiPlan(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiPlan(w http.ResponseWriter, r *http.Request) {
|
||||||
info := h.provider.GetPlanInfo()
|
info := h.provider.GetPlanInfo()
|
||||||
writeJSON(w, info)
|
writeJSON(w, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (h *Handler) apiSessions(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
sessions := h.provider.GetActiveSessions()
|
sessions := h.provider.GetActiveSessions()
|
||||||
if sessions == nil {
|
if sessions == nil {
|
||||||
|
|
@ -31,7 +28,6 @@ func (h *Handler) apiSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, sessions)
|
writeJSON(w, sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) {
|
||||||
s := h.provider.GetSessionStats()
|
s := h.provider.GetSessionStats()
|
||||||
if s == nil {
|
if s == nil {
|
||||||
|
|
@ -41,17 +37,14 @@ func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, s)
|
writeJSON(w, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (h *Handler) apiContext(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiContext(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, h.provider.GetContextInfo())
|
writeJSON(w, h.provider.GetContextInfo())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (h *Handler) apiPrompt(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiPrompt(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, map[string]string{"prompt": h.provider.GetSystemPrompt()})
|
writeJSON(w, map[string]string{"prompt": h.provider.GetSystemPrompt()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) {
|
||||||
repo := r.URL.Query().Get("repo")
|
repo := r.URL.Query().Get("repo")
|
||||||
if repo == "" {
|
if repo == "" {
|
||||||
|
|
@ -61,7 +54,6 @@ func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
|
@ -99,7 +91,6 @@ func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
flusher, ok := w.(http.Flusher)
|
flusher, ok := w.(http.Flusher)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -148,7 +139,6 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any, last *[]byte) {
|
func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any, last *[]byte) {
|
||||||
data, _ := json.Marshal(v)
|
data, _ := json.Marshal(v)
|
||||||
if !bytes.Equal(data, *last) {
|
if !bytes.Equal(data, *last) {
|
||||||
|
|
@ -158,11 +148,9 @@ func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// apiDevConsole receives console output from dev preview iframes.
|
// apiDevConsole receives console output from dev preview iframes.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
// validateLocalhostURL parses and validates that a URL targets localhost.
|
// validateLocalhostURL parses and validates that a URL targets localhost.
|
||||||
func validateLocalhostURL(target string) (*url.URL, error) {
|
func validateLocalhostURL(target string) (*url.URL, error) {
|
||||||
u, err := url.Parse(target)
|
u, err := url.Parse(target)
|
||||||
|
|
@ -33,7 +32,6 @@ func validateLocalhostURL(target string) (*url.URL, error) {
|
||||||
|
|
||||||
// RegisterDevTarget registers a new dev server target. Only localhost targets are allowed.
|
// RegisterDevTarget registers a new dev server target. Only localhost targets are allowed.
|
||||||
|
|
||||||
|
|
||||||
// RegisterDevTarget registers a new dev server target. Only localhost targets are allowed.
|
// RegisterDevTarget registers a new dev server target. Only localhost targets are allowed.
|
||||||
func (h *Handler) RegisterDevTarget(name, target string) (string, error) {
|
func (h *Handler) RegisterDevTarget(name, target string) (string, error) {
|
||||||
if _, err := validateLocalhostURL(target); err != nil {
|
if _, err := validateLocalhostURL(target); err != nil {
|
||||||
|
|
@ -55,7 +53,6 @@ func (h *Handler) RegisterDevTarget(name, target string) (string, error) {
|
||||||
|
|
||||||
// UnregisterDevTarget removes a registered target. If it was active, the proxy is disabled.
|
// UnregisterDevTarget removes a registered target. If it was active, the proxy is disabled.
|
||||||
|
|
||||||
|
|
||||||
// UnregisterDevTarget removes a registered target. If it was active, the proxy is disabled.
|
// UnregisterDevTarget removes a registered target. If it was active, the proxy is disabled.
|
||||||
func (h *Handler) UnregisterDevTarget(id string) error {
|
func (h *Handler) UnregisterDevTarget(id string) error {
|
||||||
h.devMu.Lock()
|
h.devMu.Lock()
|
||||||
|
|
@ -79,7 +76,6 @@ func (h *Handler) UnregisterDevTarget(id string) error {
|
||||||
|
|
||||||
// ActivateDevTarget sets the reverse proxy to the registered target with the given ID.
|
// ActivateDevTarget sets the reverse proxy to the registered target with the given ID.
|
||||||
|
|
||||||
|
|
||||||
// ActivateDevTarget sets the reverse proxy to the registered target with the given ID.
|
// ActivateDevTarget sets the reverse proxy to the registered target with the given ID.
|
||||||
func (h *Handler) ActivateDevTarget(id string) error {
|
func (h *Handler) ActivateDevTarget(id string) error {
|
||||||
h.devMu.Lock()
|
h.devMu.Lock()
|
||||||
|
|
@ -148,7 +144,6 @@ p{color:#8e8e93;font-size:14px;margin:0}
|
||||||
|
|
||||||
// DeactivateDevTarget disables the reverse proxy without removing registrations.
|
// DeactivateDevTarget disables the reverse proxy without removing registrations.
|
||||||
|
|
||||||
|
|
||||||
// DeactivateDevTarget disables the reverse proxy without removing registrations.
|
// DeactivateDevTarget disables the reverse proxy without removing registrations.
|
||||||
func (h *Handler) DeactivateDevTarget() error {
|
func (h *Handler) DeactivateDevTarget() error {
|
||||||
h.devMu.Lock()
|
h.devMu.Lock()
|
||||||
|
|
@ -165,7 +160,6 @@ func (h *Handler) DeactivateDevTarget() error {
|
||||||
|
|
||||||
// GetDevTarget returns the current dev proxy target URL, or empty string if disabled.
|
// GetDevTarget returns the current dev proxy target URL, or empty string if disabled.
|
||||||
|
|
||||||
|
|
||||||
// GetDevTarget returns the current dev proxy target URL, or empty string if disabled.
|
// GetDevTarget returns the current dev proxy target URL, or empty string if disabled.
|
||||||
func (h *Handler) GetDevTarget() string {
|
func (h *Handler) GetDevTarget() string {
|
||||||
h.devMu.RLock()
|
h.devMu.RLock()
|
||||||
|
|
@ -178,7 +172,6 @@ func (h *Handler) GetDevTarget() string {
|
||||||
|
|
||||||
// ListDevTargets returns all registered dev targets.
|
// ListDevTargets returns all registered dev targets.
|
||||||
|
|
||||||
|
|
||||||
// ListDevTargets returns all registered dev targets.
|
// ListDevTargets returns all registered dev targets.
|
||||||
func (h *Handler) ListDevTargets() []DevTarget {
|
func (h *Handler) ListDevTargets() []DevTarget {
|
||||||
h.devMu.RLock()
|
h.devMu.RLock()
|
||||||
|
|
@ -198,7 +191,6 @@ func (h *Handler) ListDevTargets() []DevTarget {
|
||||||
// "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount.
|
// "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount.
|
||||||
// It also captures console.log/warn/error/info and forwards them to the server.
|
// It also captures console.log/warn/error/info and forwards them to the server.
|
||||||
|
|
||||||
|
|
||||||
// devProxyScript is the JavaScript injected into HTML responses from the dev proxy.
|
// devProxyScript is the JavaScript injected into HTML responses from the dev proxy.
|
||||||
// It rewrites fetch() and XMLHttpRequest.open() so that absolute paths like
|
// It rewrites fetch() and XMLHttpRequest.open() so that absolute paths like
|
||||||
// "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount.
|
// "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount.
|
||||||
|
|
@ -255,7 +247,6 @@ const devProxyScript = `<script data-dev-proxy>
|
||||||
// injectDevProxyScript inserts the dev proxy rewrite script into an HTML document.
|
// injectDevProxyScript inserts the dev proxy rewrite script into an HTML document.
|
||||||
// Insertion priority: before </head>, after <body...>, or prepend to document.
|
// Insertion priority: before </head>, after <body...>, or prepend to document.
|
||||||
|
|
||||||
|
|
||||||
// injectDevProxyScript inserts the dev proxy rewrite script into an HTML document.
|
// injectDevProxyScript inserts the dev proxy rewrite script into an HTML document.
|
||||||
// Insertion priority: before </head>, after <body...>, or prepend to document.
|
// Insertion priority: before </head>, after <body...>, or prepend to document.
|
||||||
func injectDevProxyScript(html []byte) []byte {
|
func injectDevProxyScript(html []byte) []byte {
|
||||||
|
|
@ -294,7 +285,6 @@ func injectDevProxyScript(html []byte) []byte {
|
||||||
|
|
||||||
// escapeHTMLString escapes HTML special characters in a string.
|
// escapeHTMLString escapes HTML special characters in a string.
|
||||||
|
|
||||||
|
|
||||||
// escapeHTMLString escapes HTML special characters in a string.
|
// escapeHTMLString escapes HTML special characters in a string.
|
||||||
func escapeHTMLString(s string) string {
|
func escapeHTMLString(s string) string {
|
||||||
s = strings.ReplaceAll(s, "&", "&")
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
|
@ -306,7 +296,6 @@ func escapeHTMLString(s string) string {
|
||||||
|
|
||||||
// RegisterRoutes registers Mini App routes on the given mux.
|
// RegisterRoutes registers Mini App routes on the given mux.
|
||||||
|
|
||||||
|
|
||||||
func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
|
@ -359,7 +348,6 @@ func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (h *Handler) serveDevProxy(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) serveDevProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
h.devMu.RLock()
|
h.devMu.RLock()
|
||||||
proxy := h.devProxy
|
proxy := h.devProxy
|
||||||
|
|
@ -381,7 +369,6 @@ func (h *Handler) serveDevProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
// extractUserFromInitData parses user.id from the initData query string.
|
// extractUserFromInitData parses user.id from the initData query string.
|
||||||
// initData contains a "user" param with JSON like {"id":123456,...}.
|
// initData contains a "user" param with JSON like {"id":123456,...}.
|
||||||
|
|
||||||
|
|
||||||
func (h *Handler) devStatus() map[string]any {
|
func (h *Handler) devStatus() map[string]any {
|
||||||
h.devMu.RLock()
|
h.devMu.RLock()
|
||||||
defer h.devMu.RUnlock()
|
defer h.devMu.RUnlock()
|
||||||
|
|
@ -406,7 +393,6 @@ func (h *Handler) devStatus() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// apiDevConsole receives console output from dev preview iframes.
|
// apiDevConsole receives console output from dev preview iframes.
|
||||||
func (h *Handler) apiDevConsole(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiDevConsole(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
|
@ -474,4 +460,3 @@ func (h *Handler) apiDevConsole(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// wsLogs serves a WebSocket endpoint that streams log entries in real time.
|
// wsLogs serves a WebSocket endpoint that streams log entries in real time.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
// apiLogsSnapshot creates a tar.gz snapshot of the current log buffer.
|
// apiLogsSnapshot creates a tar.gz snapshot of the current log buffer.
|
||||||
func (h *Handler) apiLogsSnapshot(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiLogsSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
|
@ -86,7 +85,6 @@ func (h *Handler) apiLogsSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// apiLogsSnapshotDownload serves a snapshot tar.gz file.
|
// apiLogsSnapshotDownload serves a snapshot tar.gz file.
|
||||||
|
|
||||||
|
|
||||||
// apiLogsSnapshotDownload serves a snapshot tar.gz file.
|
// apiLogsSnapshotDownload serves a snapshot tar.gz file.
|
||||||
func (h *Handler) apiLogsSnapshotDownload(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiLogsSnapshotDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
|
|
@ -117,7 +115,6 @@ func (h *Handler) apiLogsSnapshotDownload(w http.ResponseWriter, r *http.Request
|
||||||
|
|
||||||
// cleanOldSnapshots removes snapshot files older than maxAge.
|
// cleanOldSnapshots removes snapshot files older than maxAge.
|
||||||
|
|
||||||
|
|
||||||
// cleanOldSnapshots removes snapshot files older than maxAge.
|
// cleanOldSnapshots removes snapshot files older than maxAge.
|
||||||
func cleanOldSnapshots(dir string, maxAge time.Duration) {
|
func cleanOldSnapshots(dir string, maxAge time.Duration) {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
|
|
@ -140,4 +137,3 @@ func cleanOldSnapshots(dir string, maxAge time.Duration) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// initDataMaxAge is the maximum age of initData before it is considered expired.
|
// initDataMaxAge is the maximum age of initData before it is considered expired.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -175,24 +175,31 @@ type mockDataProvider struct{}
|
||||||
func (m *mockDataProvider) ListSkills() []skills.SkillInfo {
|
func (m *mockDataProvider) ListSkills() []skills.SkillInfo {
|
||||||
return []skills.SkillInfo{{Name: "test-skill", Description: "A test", Source: "local"}}
|
return []skills.SkillInfo{{Name: "test-skill", Description: "A test", Source: "local"}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataProvider) GetPlanInfo() PlanInfo {
|
func (m *mockDataProvider) GetPlanInfo() PlanInfo {
|
||||||
return PlanInfo{HasPlan: false, Status: "none"}
|
return PlanInfo{HasPlan: false, Status: "none"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataProvider) GetSessionStats() *stats.Stats {
|
func (m *mockDataProvider) GetSessionStats() *stats.Stats {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataProvider) GetActiveSessions() []SessionInfo {
|
func (m *mockDataProvider) GetActiveSessions() []SessionInfo {
|
||||||
return []SessionInfo{}
|
return []SessionInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataProvider) GetGitRepos() []GitRepoSummary {
|
func (m *mockDataProvider) GetGitRepos() []GitRepoSummary {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataProvider) GetGitRepoDetail(name string) GitInfo {
|
func (m *mockDataProvider) GetGitRepoDetail(name string) GitInfo {
|
||||||
return GitInfo{Name: name}
|
return GitInfo{Name: name}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataProvider) GetContextInfo() ContextInfo {
|
func (m *mockDataProvider) GetContextInfo() ContextInfo {
|
||||||
return ContextInfo{Workspace: "/mock/workspace"}
|
return ContextInfo{Workspace: "/mock/workspace"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataProvider) GetSystemPrompt() string {
|
func (m *mockDataProvider) GetSystemPrompt() string {
|
||||||
return "mock system prompt"
|
return "mock system prompt"
|
||||||
}
|
}
|
||||||
|
|
@ -464,6 +471,7 @@ type mutatingDataProvider struct {
|
||||||
func (m *mutatingDataProvider) ListSkills() []skills.SkillInfo {
|
func (m *mutatingDataProvider) ListSkills() []skills.SkillInfo {
|
||||||
return []skills.SkillInfo{{Name: "test-skill", Description: "A test", Source: "local"}}
|
return []skills.SkillInfo{{Name: "test-skill", Description: "A test", Source: "local"}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mutatingDataProvider) GetPlanInfo() PlanInfo {
|
func (m *mutatingDataProvider) GetPlanInfo() PlanInfo {
|
||||||
if m.mutated.Load() {
|
if m.mutated.Load() {
|
||||||
return PlanInfo{HasPlan: true, Status: "executing", CurrentPhase: 1, TotalPhases: 2}
|
return PlanInfo{HasPlan: true, Status: "executing", CurrentPhase: 1, TotalPhases: 2}
|
||||||
|
|
@ -474,15 +482,19 @@ func (m *mutatingDataProvider) GetSessionStats() *stats.Stats { return nil }
|
||||||
func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo {
|
func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo {
|
||||||
return []SessionInfo{}
|
return []SessionInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mutatingDataProvider) GetGitRepos() []GitRepoSummary {
|
func (m *mutatingDataProvider) GetGitRepos() []GitRepoSummary {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo {
|
func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo {
|
||||||
return GitInfo{Name: name}
|
return GitInfo{Name: name}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mutatingDataProvider) GetContextInfo() ContextInfo {
|
func (m *mutatingDataProvider) GetContextInfo() ContextInfo {
|
||||||
return ContextInfo{Workspace: "/mock/workspace"}
|
return ContextInfo{Workspace: "/mock/workspace"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mutatingDataProvider) GetSystemPrompt() string {
|
func (m *mutatingDataProvider) GetSystemPrompt() string {
|
||||||
return "mock system prompt"
|
return "mock system prompt"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
const maxWSClients = 4
|
const maxWSClients = 4
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -22,7 +22,6 @@ type wsClient struct {
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var wsUpgrader = websocket.Upgrader{
|
var wsUpgrader = websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
origin := r.Header.Get("Origin")
|
origin := r.Header.Get("Origin")
|
||||||
|
|
@ -43,7 +42,6 @@ var wsUpgrader = websocket.Upgrader{
|
||||||
|
|
||||||
// NewHandler creates a new Mini App handler.
|
// NewHandler creates a new Mini App handler.
|
||||||
|
|
||||||
|
|
||||||
// wsLogs serves a WebSocket endpoint that streams log entries in real time.
|
// wsLogs serves a WebSocket endpoint that streams log entries in real time.
|
||||||
func (h *Handler) wsLogs(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) wsLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
// Parse filter params
|
// Parse filter params
|
||||||
|
|
@ -136,7 +134,7 @@ func (h *Handler) wsLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
entry.Caller = "" // strip for security
|
entry.Caller = "" // strip for security
|
||||||
entry.Fields = logger.SanitizeFields(entry.Fields) // mask sensitive values
|
entry.Fields = logger.SanitizeFields(entry.Fields) // mask sensitive values
|
||||||
if err := conn.WriteJSON(map[string]any{"type": "entry", "entry": entry}); err != nil {
|
if err := conn.WriteJSON(map[string]any{"type": "entry", "entry": entry}); err != nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ type Event struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
Task string `json:"task,omitempty"`
|
Task string `json:"task,omitempty"`
|
||||||
State string `json:"state,omitempty"` // waiting | toolcall | idle
|
State string `json:"state,omitempty"` // waiting | toolcall | idle
|
||||||
Tool string `json:"tool,omitempty"` // tool name during toolcall
|
Tool string `json:"tool,omitempty"` // tool name during toolcall
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -272,7 +272,7 @@ func (p *Provider) ChatStream(
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := p.httpClient.Do(req)
|
resp, err := p.httpClient.Do(req) //nolint:bodyclose // closed in goroutine or error path below
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -626,4 +626,3 @@ type streamToolCallAcc struct {
|
||||||
Name string
|
Name string
|
||||||
Arguments strings.Builder
|
Arguments strings.Builder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ func findToolCallBlock(text string) (blockStart, blockEnd int, content string, f
|
||||||
return 0, 0, "", false
|
return 0, 0, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- XML tool call extraction ---
|
// ExtractXMLToolCalls extracts tool calls from XML-formatted text.
|
||||||
//
|
//
|
||||||
// Expected format:
|
// Expected format:
|
||||||
//
|
//
|
||||||
|
|
@ -195,7 +195,6 @@ func findToolCallBlock(text string) (blockStart, blockEnd int, content string, f
|
||||||
// <parameter name="param">value</parameter>
|
// <parameter name="param">value</parameter>
|
||||||
// </invoke>
|
// </invoke>
|
||||||
// </ns:toolcall>
|
// </ns:toolcall>
|
||||||
// ExtractXMLToolCalls is the exported version for use by the agent loop.
|
|
||||||
func ExtractXMLToolCalls(text string) []ToolCall {
|
func ExtractXMLToolCalls(text string) []ToolCall {
|
||||||
return extractXMLToolCalls(text)
|
return extractXMLToolCalls(text)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ func TestSanitizeHistory_InterleavedMessages(t *testing.T) {
|
||||||
{Role: "assistant", Content: "ok", ToolCalls: []providers.ToolCall{
|
{Role: "assistant", Content: "ok", ToolCalls: []providers.ToolCall{
|
||||||
{ID: "call_1", Name: "exec"},
|
{ID: "call_1", Name: "exec"},
|
||||||
}},
|
}},
|
||||||
{Role: "user", Content: "collision!"}, // ← interleaved from other session
|
{Role: "user", Content: "collision!"}, // ← interleaved from other session
|
||||||
{Role: "tool", Content: "ok", ToolCallID: "call_1"}, // ← out of order
|
{Role: "tool", Content: "ok", ToolCallID: "call_1"}, // ← out of order
|
||||||
{Role: "assistant", Content: "done"},
|
{Role: "assistant", Content: "done"},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
|
|
||||||
// DayStats holds token usage for a single day.
|
// DayStats holds token usage for a single day.
|
||||||
type DayStats struct {
|
type DayStats struct {
|
||||||
Date string `json:"date"` // "2006-01-02"
|
Date string `json:"date"` // "2006-01-02"
|
||||||
PromptTokens int64 `json:"prompt_tokens"`
|
PromptTokens int64 `json:"prompt_tokens"`
|
||||||
CompletionTokens int64 `json:"completion_tokens"`
|
CompletionTokens int64 `json:"completion_tokens"`
|
||||||
TotalTokens int64 `json:"total_tokens"`
|
TotalTokens int64 `json:"total_tokens"`
|
||||||
|
|
@ -43,7 +43,7 @@ type Tracker struct {
|
||||||
// NewTracker creates a tracker that persists to {workspace}/state/stats.json.
|
// NewTracker creates a tracker that persists to {workspace}/state/stats.json.
|
||||||
func NewTracker(workspace string) *Tracker {
|
func NewTracker(workspace string) *Tracker {
|
||||||
stateDir := filepath.Join(workspace, "state")
|
stateDir := filepath.Join(workspace, "state")
|
||||||
os.MkdirAll(stateDir, 0755)
|
os.MkdirAll(stateDir, 0o755)
|
||||||
|
|
||||||
t := &Tracker{
|
t := &Tracker{
|
||||||
stateFile: filepath.Join(stateDir, "stats.json"),
|
stateFile: filepath.Join(stateDir, "stats.json"),
|
||||||
|
|
@ -135,7 +135,7 @@ func (t *Tracker) save() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tmp := t.stateFile + ".tmp"
|
tmp := t.stateFile + ".tmp"
|
||||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmp, t.stateFile); err != nil {
|
if err := os.Rename(tmp, t.stateFile); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -202,8 +202,8 @@ func TestDevPreviewTool_UnregisterNotFound(t *testing.T) {
|
||||||
tool := NewDevPreviewTool(mgr)
|
tool := NewDevPreviewTool(mgr)
|
||||||
|
|
||||||
result := tool.Execute(context.Background(), map[string]any{
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
"action": "unregister",
|
"action": "unregister",
|
||||||
"id": "999",
|
"id": "999",
|
||||||
})
|
})
|
||||||
|
|
||||||
if !result.IsError {
|
if !result.IsError {
|
||||||
|
|
@ -421,7 +421,7 @@ func TestDevPreviewTool_InferName(t *testing.T) {
|
||||||
{"http://127.0.0.1:9000", "127.0.0.1:9000"},
|
{"http://127.0.0.1:9000", "127.0.0.1:9000"},
|
||||||
{"http://localhost", "localhost"},
|
{"http://localhost", "localhost"},
|
||||||
{"http://[::1]:5000", "::1:5000"},
|
{"http://[::1]:5000", "::1:5000"},
|
||||||
{"not-a-url", ""}, // url.Parse succeeds but Hostname() is empty
|
{"not-a-url", ""}, // url.Parse succeeds but Hostname() is empty
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
got := inferName(tc.target)
|
got := inferName(tc.target)
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ func setupTestLogs(t *testing.T) {
|
||||||
logger.WarnC("telegram", "webhook retry")
|
logger.WarnC("telegram", "webhook retry")
|
||||||
logger.ErrorC("discord", "connection timeout")
|
logger.ErrorC("discord", "connection timeout")
|
||||||
logger.WarnCF("wecom", "signature failed", map[string]any{
|
logger.WarnCF("wecom", "signature failed", map[string]any{
|
||||||
"token": "secret-value",
|
"token": "secret-value",
|
||||||
"nonce": "safe-value",
|
"nonce": "safe-value",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,11 @@ type ExecPolicy struct {
|
||||||
|
|
||||||
// SandboxConfig describes the sandbox isolation policy for a preset.
|
// SandboxConfig describes the sandbox isolation policy for a preset.
|
||||||
type SandboxConfig struct {
|
type SandboxConfig struct {
|
||||||
Preset Preset
|
Preset Preset
|
||||||
WriteRoot string // Path restriction for write tools; empty = no writes allowed
|
WriteRoot string // Path restriction for write tools; empty = no writes allowed
|
||||||
AllowedTools map[string]bool // Tools that can be used
|
AllowedTools map[string]bool // Tools that can be used
|
||||||
ExecPolicy *ExecPolicy // nil = exec not allowed
|
ExecPolicy *ExecPolicy // nil = exec not allowed
|
||||||
SpawnablePresets []string // Presets that can be spawned; nil = spawn not allowed
|
SpawnablePresets []string // Presets that can be spawned; nil = spawn not allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubagentEnvironment provides context for subagent execution.
|
// SubagentEnvironment provides context for subagent execution.
|
||||||
|
|
@ -65,11 +65,11 @@ var presetSpawnablePresets = map[Preset][]string{
|
||||||
func AllowedToolsForPreset(p Preset) map[string]bool {
|
func AllowedToolsForPreset(p Preset) map[string]bool {
|
||||||
// Base tools available to all presets
|
// Base tools available to all presets
|
||||||
allowed := map[string]bool{
|
allowed := map[string]bool{
|
||||||
"read_file": true,
|
"read_file": true,
|
||||||
"list_dir": true,
|
"list_dir": true,
|
||||||
"web_search": true,
|
"web_search": true,
|
||||||
"web_fetch": true,
|
"web_fetch": true,
|
||||||
"message": true,
|
"message": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add analyst+ tools (exec, git, etc.)
|
// Add analyst+ tools (exec, git, etc.)
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ import (
|
||||||
// TestAllowedToolsForPreset checks that each preset has appropriate tool access.
|
// TestAllowedToolsForPreset checks that each preset has appropriate tool access.
|
||||||
func TestAllowedToolsForPreset(t *testing.T) {
|
func TestAllowedToolsForPreset(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
preset Preset
|
preset Preset
|
||||||
wantRead bool
|
wantRead bool
|
||||||
wantWrite bool
|
wantWrite bool
|
||||||
wantExec bool
|
wantExec bool
|
||||||
wantSpawn bool
|
wantSpawn bool
|
||||||
wantWebSearch bool
|
wantWebSearch bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "scout",
|
name: "scout",
|
||||||
|
|
|
||||||
|
|
@ -808,7 +808,7 @@ func isExecutable(path string) bool {
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return info.Mode()&0111 != 0
|
return info.Mode()&0o111 != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ExecTool) SetTimeout(timeout time.Duration) {
|
func (t *ExecTool) SetTimeout(timeout time.Duration) {
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,7 @@ func TestGuardCommand_ExecutableBinaryAllowed(t *testing.T) {
|
||||||
|
|
||||||
// Create a fake executable outside the workspace
|
// Create a fake executable outside the workspace
|
||||||
execPath := filepath.Join(externalDir, "mybin")
|
execPath := filepath.Join(externalDir, "mybin")
|
||||||
os.WriteFile(execPath, []byte("#!/bin/sh\necho ok"), 0755)
|
os.WriteFile(execPath, []byte("#!/bin/sh\necho ok"), 0o755)
|
||||||
|
|
||||||
tool, _ := NewExecTool(workspace, true)
|
tool, _ := NewExecTool(workspace, true)
|
||||||
|
|
||||||
|
|
@ -393,7 +393,7 @@ func TestGuardCommand_ExecutableBinaryAllowed_Windows(t *testing.T) {
|
||||||
|
|
||||||
// Create a fake .exe outside the workspace
|
// Create a fake .exe outside the workspace
|
||||||
execPath := filepath.Join(externalDir, "tool.exe")
|
execPath := filepath.Join(externalDir, "tool.exe")
|
||||||
os.WriteFile(execPath, []byte("MZ"), 0644)
|
os.WriteFile(execPath, []byte("MZ"), 0o644)
|
||||||
|
|
||||||
tool, _ := NewExecTool(workspace, true)
|
tool, _ := NewExecTool(workspace, true)
|
||||||
|
|
||||||
|
|
@ -416,7 +416,7 @@ func TestGuardCommand_NonExecutableOutsideBlocked(t *testing.T) {
|
||||||
|
|
||||||
// Create a regular (non-executable) file outside workspace
|
// Create a regular (non-executable) file outside workspace
|
||||||
dataFile := filepath.Join(externalDir, "secret.txt")
|
dataFile := filepath.Join(externalDir, "secret.txt")
|
||||||
os.WriteFile(dataFile, []byte("secret data"), 0644)
|
os.WriteFile(dataFile, []byte("secret data"), 0o644)
|
||||||
|
|
||||||
tool, _ := NewExecTool(workspace, true)
|
tool, _ := NewExecTool(workspace, true)
|
||||||
|
|
||||||
|
|
@ -477,7 +477,7 @@ func TestGuardCommand_AbsolutePathInsideWorkspace(t *testing.T) {
|
||||||
tool, _ := NewExecTool(workspace, true)
|
tool, _ := NewExecTool(workspace, true)
|
||||||
|
|
||||||
innerDir := filepath.Join(workspace, "projects", "myapp")
|
innerDir := filepath.Join(workspace, "projects", "myapp")
|
||||||
os.MkdirAll(innerDir, 0755)
|
os.MkdirAll(innerDir, 0o755)
|
||||||
|
|
||||||
cmd := "ls " + innerDir
|
cmd := "ls " + innerDir
|
||||||
result := tool.guardCommand(cmd, workspace)
|
result := tool.guardCommand(cmd, workspace)
|
||||||
|
|
@ -514,7 +514,7 @@ func TestGuardCommand_PathTraversal(t *testing.T) {
|
||||||
func TestGuardCommand_CdWithAbsoluteWorkspacePath(t *testing.T) {
|
func TestGuardCommand_CdWithAbsoluteWorkspacePath(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
innerDir := filepath.Join(workspace, "projects", "foo")
|
innerDir := filepath.Join(workspace, "projects", "foo")
|
||||||
os.MkdirAll(innerDir, 0755)
|
os.MkdirAll(innerDir, 0o755)
|
||||||
|
|
||||||
tool, _ := NewExecTool(workspace, true)
|
tool, _ := NewExecTool(workspace, true)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,8 @@ func (t *SpawnTool) Parameters() map[string]any {
|
||||||
"description": "Optional target agent ID to delegate the task to",
|
"description": "Optional target agent ID to delegate the task to",
|
||||||
},
|
},
|
||||||
"preset": map[string]any{
|
"preset": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": []string{"scout", "analyst", "coder", "worker", "coordinator"},
|
"enum": []string{"scout", "analyst", "coder", "worker", "coordinator"},
|
||||||
"description": "Optional capability tier: scout (explore), analyst (analyze), coder (code), worker (build), coordinator (orchestrate)",
|
"description": "Optional capability tier: scout (explore), analyst (analyze), coder (code), worker (build), coordinator (orchestrate)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ func (r *reporterSpy) ReportStateChange(id, state, tool string) {
|
||||||
r.calls = append(r.calls, spyCall{state, tool})
|
r.calls = append(r.calls, spyCall{state, tool})
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reporterSpy) snapshot() []spyCall {
|
func (r *reporterSpy) snapshot() []spyCall {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
@ -77,6 +78,7 @@ func (t *echoTool) Description() string { return "echo" }
|
||||||
func (t *echoTool) Parameters() map[string]any {
|
func (t *echoTool) Parameters() map[string]any {
|
||||||
return map[string]any{"type": "object", "properties": map[string]any{}}
|
return map[string]any{"type": "object", "properties": map[string]any{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *echoTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
|
func (t *echoTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
|
||||||
return &ToolResult{ForLLM: "echoed"}
|
return &ToolResult{ForLLM: "echoed"}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type workspaceOverrideKey struct{}
|
type (
|
||||||
type overrideFsKey struct{}
|
workspaceOverrideKey struct{}
|
||||||
|
overrideFsKey struct{}
|
||||||
|
)
|
||||||
|
|
||||||
// WithWorkspaceOverride returns a context carrying a workspace override path
|
// WithWorkspaceOverride returns a context carrying a workspace override path
|
||||||
// and a pre-built sandboxFs for that workspace. Tools will resolve file
|
// and a pre-built sandboxFs for that workspace. Tools will resolve file
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ var (
|
||||||
|
|
||||||
// IsAudioFile checks if a file is an audio file based on its filename extension and content type.
|
// IsAudioFile checks if a file is an audio file based on its filename extension and content type.
|
||||||
func IsAudioFile(filename, contentType string) bool {
|
func IsAudioFile(filename, contentType string) bool {
|
||||||
|
|
||||||
for _, ext := range audioExtensions {
|
for _, ext := range audioExtensions {
|
||||||
if strings.HasSuffix(strings.ToLower(filename), ext) {
|
if strings.HasSuffix(strings.ToLower(filename), ext) {
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue