diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index f754abc65..5f0420522 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -33,6 +33,11 @@ func agentCmd(message, sessionKey, model string, debug bool) error { return fmt.Errorf("error loading config: %w", err) } + // Apply logging configuration from config file + if err := cfg.ApplyLoggingConfig(); err != nil { + fmt.Printf("Warning: Failed to apply logging config: %v\n", err) + } + if model != "" { cfg.Agents.Defaults.ModelName = model } diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 747f7d44e..df5290300 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -49,6 +49,11 @@ func gatewayCmd(debug bool) error { return fmt.Errorf("error loading config: %w", err) } + // Apply logging configuration from config file + if err := cfg.ApplyLoggingConfig(); err != nil { + fmt.Printf("Warning: Failed to apply logging config: %v\n", err) + } + provider, modelID, err := providers.CreateProvider(cfg) if err != nil { return fmt.Errorf("error creating provider: %w", err) @@ -198,8 +203,6 @@ func gatewayCmd(debug bool) error { // since the original ctx is already canceled. shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) defer shutdownCancel() - - channelManager.StopAll(shutdownCtx) deviceService.Stop() heartbeatService.Stop() cronService.Stop() diff --git a/cmd/picoclaw/internal/status/helpers.go b/cmd/picoclaw/internal/status/helpers.go index ab28f4885..e3617ad07 100644 --- a/cmd/picoclaw/internal/status/helpers.go +++ b/cmd/picoclaw/internal/status/helpers.go @@ -15,6 +15,11 @@ func statusCmd() { return } + // Apply logging configuration from config file + if err := cfg.ApplyLoggingConfig(); err != nil { + fmt.Printf("Warning: Failed to apply logging config: %v\n", err) + } + configPath := internal.GetConfigPath() fmt.Printf("%s picoclaw Status\n", internal.Logo) diff --git a/pkg/config/config.go b/pkg/config/config.go index f40e05e1c..ad941f480 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,11 +4,14 @@ import ( "encoding/json" "fmt" "os" + "strings" + "log" "sync/atomic" "github.com/caarlos0/env/v11" "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/logger" ) // rrCounter is a global counter for round-robin load balancing across models. @@ -594,6 +597,8 @@ type ToolsConfig struct { Skills SkillsToolsConfig `json:"skills"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MCP MCPConfig `json:"mcp"` + Logging LoggingConfig `json:"logging"` + } type SkillsToolsConfig struct { @@ -651,6 +656,16 @@ type MCPConfig struct { Servers map[string]MCPServerConfig `json:"servers,omitempty"` } + + +// LoggingConfig holds the logging configuration. +type LoggingConfig struct { + Level string `json:"level" env:"PICOCLAW_LOGGING_LEVEL"` + Format string `json:"format" env:"PICOCLAW_LOGGING_FORMAT"` + Output string `json:"output" env:"PICOCLAW_LOGGING_OUTPUT"` + FilePath string `json:"file_path" env:"PICOCLAW_LOGGING_FILE_PATH"` +} + func LoadConfig(path string) (*Config, error) { cfg := DefaultConfig() @@ -832,3 +847,49 @@ func (c *Config) ValidateModelList() error { } return nil } + + +// ApplyLoggingConfig applies the logging configuration from the tools section +func (c *Config) ApplyLoggingConfig() error { + // Set log level from config + if c.Tools.Logging.Level != "" { + switch strings.ToLower(c.Tools.Logging.Level) { + case "debug": + logger.SetLevel(logger.DEBUG) + case "info": + logger.SetLevel(logger.INFO) + case "warn": + logger.SetLevel(logger.WARN) + case "error": + logger.SetLevel(logger.ERROR) + case "fatal": + logger.SetLevel(logger.FATAL) + default: + logger.SetLevel(logger.INFO) // default + } + } + + // Set log format from config + if c.Tools.Logging.Format != "" { + switch strings.ToLower(c.Tools.Logging.Format) { + case "json": + logger.SetFormat(logger.JSONFormat) + case "text", "human": + logger.SetFormat(logger.TextFormat) + default: + logger.SetFormat(logger.JSONFormat) // default + } + } + + // Set file logging from config + if c.Tools.Logging.Output == "file" || c.Tools.Logging.Output == "both" { + if c.Tools.Logging.FilePath != "" { + if err := logger.EnableFileLogging(c.Tools.Logging.FilePath); err != nil { + log.Printf("Failed to enable file logging to %s: %v", c.Tools.Logging.FilePath, err) + return err + } + } + } + return nil +} + diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 56dc87a53..526025c7b 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -21,6 +21,17 @@ const ( FATAL ) +type LogFormat string + +const ( + TextFormat LogFormat = "text" + JSONFormat LogFormat = "json" +) + +var ( + logFormat = JSONFormat // Default format +) + var ( logLevelNames = map[LogLevel]string{ DEBUG: "DEBUG", @@ -66,6 +77,18 @@ func GetLevel() LogLevel { defer mu.RUnlock() return currentLevel } +func SetFormat(format LogFormat) { + mu.Lock() + defer mu.Unlock() + logFormat = format +} + +func GetFormat() LogFormat { + mu.RLock() + defer mu.RUnlock() + return logFormat +} + func EnableFileLogging(filePath string) error { mu.Lock() @@ -116,35 +139,24 @@ func logMessage(level LogLevel, component string, message string, fields map[str } } + outputData := formatLogEntry(entry) if logger.file != nil { - jsonData, err := json.Marshal(entry) - if err == nil { - logger.file.Write(append(jsonData, '\n')) - } + logger.file.Write(outputData) } - var fieldStr string - if len(fields) > 0 { - fieldStr = " " + formatFields(fields) - } else { - fieldStr = "" + // For console/terminal output, use the same format as configured + consoleOutput := formatLogForConsole(entry) + if len(consoleOutput) > 0 { + logLine := string(consoleOutput[:len(consoleOutput)-1]) // Remove trailing newline + log.Println(logLine) } - logLine := fmt.Sprintf("[%s] [%s]%s %s%s", - entry.Timestamp, - logLevelNames[level], - formatComponent(component), - message, - fieldStr, - ) - - log.Println(logLine) - if level == FATAL { os.Exit(1) } } + func formatComponent(component string) string { if component == "" { return "" @@ -160,10 +172,75 @@ func formatFields(fields map[string]any) string { return fmt.Sprintf("{%s}", strings.Join(parts, ", ")) } +func formatLogEntryText(entry LogEntry) []byte { + var fieldStr string + if len(entry.Fields) > 0 { + fieldStr = " " + formatFields(entry.Fields) + } else { + fieldStr = "" + } + + logLine := fmt.Sprintf("[%s] [%s]%s %s%s", + entry.Timestamp, + entry.Level, + formatComponent(entry.Component), + entry.Message, + fieldStr, + ) + return []byte(fmt.Sprintf("%s\n", logLine)) +} + + +func formatLogEntry(entry LogEntry) []byte { + switch logFormat { + case JSONFormat: + jsonData, err := json.Marshal(entry) + if err != nil { + // Fallback to text format if JSON marshal fails + return []byte(fmt.Sprintf("%s\n", entry.Message)) + } + return append(jsonData, '\n') + default: + // Text format + var fieldStr string + if len(entry.Fields) > 0 { + fieldStr = " " + formatFields(entry.Fields) + } else { + fieldStr = "" + } + + logLine := fmt.Sprintf("[%s] [%s]%s %s%s", + entry.Timestamp, + entry.Level, + formatComponent(entry.Component), + entry.Message, + fieldStr, + ) + return []byte(fmt.Sprintf("%s\n", logLine)) + } +} + +func formatLogForConsole(entry LogEntry) []byte { + switch logFormat { + case JSONFormat: + jsonData, err := json.Marshal(entry) + if err != nil { + return []byte(fmt.Sprintf("%s\n", entry.Message)) + } + return append(jsonData, '\n') + default: + // Use text format for console output + return formatLogEntryText(entry) + } +} + + + func Debug(message string) { logMessage(DEBUG, "", message, nil) } + func DebugC(component string, message string) { logMessage(DEBUG, component, message, nil) }