package main import ( "encoding/json" "fmt" "os" "sync" "time" ) // ChatLogger appends one JSON line per tool invocation to a file. Thread-safe. // Format per line: {"ts":"...","tool":"...","input":{...},"ok":bool,"error":"...","result_summary":"..."} type ChatLogger struct { path string mu sync.Mutex } func newChatLogger(path string) *ChatLogger { return &ChatLogger{path: path} } type ChatLogEntry struct { TS string `json:"ts"` Tool string `json:"tool"` Input json.RawMessage `json:"input"` OK bool `json:"ok"` Error string `json:"error,omitempty"` ResultSummary string `json:"result_summary,omitempty"` } func (l *ChatLogger) Log(tool string, input json.RawMessage, res ToolResult) { if l == nil || l.path == "" { return } entry := ChatLogEntry{ TS: time.Now().UTC().Format(time.RFC3339Nano), Tool: tool, Input: input, OK: res.OK, Error: res.Error, } if res.OK && res.Result != nil { entry.ResultSummary = summarizeResult(res.Result) } line, err := json.Marshal(entry) if err != nil { return } l.mu.Lock() defer l.mu.Unlock() f, err := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return } defer f.Close() f.Write(line) f.Write([]byte("\n")) } // summarizeResult produces a short description of a tool result for the log. // Keeps the log line compact: full payloads can be reconstructed from operations.db. func summarizeResult(v any) string { switch r := v.(type) { case *Column: return fmt.Sprintf("column %s name=%q", r.ID, r.Name) case *Card: return fmt.Sprintf("card %s title=%q col=%s", r.ID, r.Title, r.ColumnID) case []Card: return fmt.Sprintf("%d cards", len(r)) case []HistoryEntry: return fmt.Sprintf("%d history entries", len(r)) case map[string]any: // list_board shape cols, _ := r["columns"].([]Column) cards, _ := r["cards"].([]Card) return fmt.Sprintf("board: %d cols, %d cards", len(cols), len(cards)) } b, err := json.Marshal(v) if err != nil || len(b) == 0 { return "" } if len(b) > 200 { return string(b[:200]) + "..." } return string(b) }