From dd857d2756e0169d435a6d8f60aaf3fa45cc2843 Mon Sep 17 00:00:00 2001 From: Kevin Miller Date: Wed, 18 Feb 2026 11:26:57 -0600 Subject: [PATCH 1/3] build failed when swag not local --- Taskfile.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index ef115d3..2f769b2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -69,7 +69,6 @@ tasks: go-tidy: desc: Tidy Go modules - deps: [server:swag-v2] cmds: - go mod tidy @@ -128,7 +127,12 @@ tasks: server:swag-v2: dir: ./internal/server cmds: - - swag init --parseDependency --parseDepth 2 -o ./docs -g ./api.go + - | + if command -v swag >/dev/null 2>&1; then + swag init --parseDependency --parseDepth 2 -o ./docs -g ./api.go + else + go run github.com/swaggo/swag/cmd/swag@v1.16.6 init --parseDependency --parseDepth 2 -o ./docs -g ./api.go + fi sources: - "**/*.go" generates: From 71a11355e272b437ad7ffba0dfc56580f320c891 Mon Sep 17 00:00:00 2001 From: Kevin Miller Date: Wed, 18 Feb 2026 11:57:29 -0600 Subject: [PATCH 2/3] fix(codex): dedupe duplicate assistant/thinking entries from event_msg and response_item logs --- .gitignore | 3 + internal/sources/codex/parser.go | 141 +++++++++++++++++++++++--- internal/sources/codex/parser_test.go | 70 +++++++++++++ 3 files changed, 198 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index acd0900..2e3dc28 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ Thumbs.db # Local settings (keep .claude/settings.json for hooks) .claude/settings.local.json + +# Agent sandbox test workaround +.tmp/gocache \ No newline at end of file diff --git a/internal/sources/codex/parser.go b/internal/sources/codex/parser.go index 12ad830..2d74307 100644 --- a/internal/sources/codex/parser.go +++ b/internal/sources/codex/parser.go @@ -13,9 +13,11 @@ import ( // Parser reads Codex session JSONL entries from an io.Reader. type Parser struct { - scanner *bufio.Scanner - sessionID string - lineNo int + scanner *bufio.Scanner + sessionID string + lineNo int + pendingEvent *parsedEntry + queued *parsedEntry } type logLine struct { @@ -24,6 +26,12 @@ type logLine struct { Payload json.RawMessage `json:"payload"` } +type parsedEntry struct { + entry *thinkt.Entry + kind string + fromEvent bool +} + // NewParser creates a new Codex parser. func NewParser(r io.Reader, sessionID string) *Parser { scanner := thinkt.NewScannerWithMaxCapacityCustom(r, 64*1024, thinkt.MaxScannerCapacity) @@ -35,6 +43,12 @@ func NewParser(r io.Reader, sessionID string) *Parser { // NextEntry reads the next convertible entry from the JSONL stream. func (p *Parser) NextEntry() (*thinkt.Entry, error) { + if p.queued != nil { + out := p.queued.entry + p.queued = nil + return out, nil + } + for p.scanner.Scan() { p.lineNo++ line := strings.TrimSpace(p.scanner.Text()) @@ -42,10 +56,38 @@ func (p *Parser) NextEntry() (*thinkt.Entry, error) { continue } - entry := p.convertLine([]byte(line)) - if entry != nil { - return entry, nil + parsed := p.convertLine([]byte(line)) + if parsed == nil || parsed.entry == nil { + continue + } + + if p.pendingEvent == nil { + if isEventMessageCandidate(parsed) { + p.pendingEvent = parsed + continue + } + return parsed.entry, nil + } + + if isDuplicateEventResponsePair(p.pendingEvent, parsed) { + p.pendingEvent = nil + return parsed.entry, nil } + + out := p.pendingEvent.entry + p.pendingEvent = nil + if isEventMessageCandidate(parsed) { + p.pendingEvent = parsed + } else { + p.queued = parsed + } + return out, nil + } + + if p.pendingEvent != nil { + out := p.pendingEvent.entry + p.pendingEvent = nil + return out, nil } if err := p.scanner.Err(); err != nil { @@ -54,7 +96,7 @@ func (p *Parser) NextEntry() (*thinkt.Entry, error) { return nil, io.EOF } -func (p *Parser) convertLine(line []byte) *thinkt.Entry { +func (p *Parser) convertLine(line []byte) *parsedEntry { var l logLine if err := json.Unmarshal(line, &l); err != nil { return nil @@ -71,7 +113,7 @@ func (p *Parser) convertLine(line []byte) *thinkt.Entry { } } -func (p *Parser) convertEventMsg(raw json.RawMessage, timestamp time.Time) *thinkt.Entry { +func (p *Parser) convertEventMsg(raw json.RawMessage, timestamp time.Time) *parsedEntry { var payload map[string]any if err := json.Unmarshal(raw, &payload); err != nil { return nil @@ -84,13 +126,21 @@ func (p *Parser) convertEventMsg(raw json.RawMessage, timestamp time.Time) *thin if text == "" { return nil } - return p.newEntry(thinkt.RoleUser, timestamp, eventType, text) + return &parsedEntry{ + entry: p.newEntry(thinkt.RoleUser, timestamp, eventType, text), + kind: eventType, + fromEvent: true, + } case "agent_message": text := readString(payload, "message") if text == "" { return nil } - return p.newEntry(thinkt.RoleAssistant, timestamp, eventType, text) + return &parsedEntry{ + entry: p.newEntry(thinkt.RoleAssistant, timestamp, eventType, text), + kind: eventType, + fromEvent: true, + } case "agent_reasoning": thinking := readString(payload, "text") if thinking == "" { @@ -98,13 +148,13 @@ func (p *Parser) convertEventMsg(raw json.RawMessage, timestamp time.Time) *thin } e := p.newEntry(thinkt.RoleAssistant, timestamp, eventType, "") e.ContentBlocks = []thinkt.ContentBlock{{Type: "thinking", Thinking: thinking}} - return e + return &parsedEntry{entry: e, kind: eventType, fromEvent: true} default: return nil } } -func (p *Parser) convertResponseItem(raw json.RawMessage, timestamp time.Time) *thinkt.Entry { +func (p *Parser) convertResponseItem(raw json.RawMessage, timestamp time.Time) *parsedEntry { var payload map[string]any if err := json.Unmarshal(raw, &payload); err != nil { return nil @@ -118,7 +168,11 @@ func (p *Parser) convertResponseItem(raw json.RawMessage, timestamp time.Time) * if text == "" { return nil } - return p.newEntry(role, timestamp, itemType, text) + return &parsedEntry{ + entry: p.newEntry(role, timestamp, itemType, text), + kind: itemType, + fromEvent: false, + } case "reasoning": thinking := extractReasoningText(payload) @@ -127,7 +181,7 @@ func (p *Parser) convertResponseItem(raw json.RawMessage, timestamp time.Time) * } e := p.newEntry(thinkt.RoleAssistant, timestamp, itemType, "") e.ContentBlocks = []thinkt.ContentBlock{{Type: "thinking", Thinking: thinking}} - return e + return &parsedEntry{entry: e, kind: itemType, fromEvent: false} case "function_call", "custom_tool_call": callID := readString(payload, "call_id") @@ -143,7 +197,7 @@ func (p *Parser) convertResponseItem(raw json.RawMessage, timestamp time.Time) * ToolName: toolName, ToolInput: parseToolInput(payload), }} - return e + return &parsedEntry{entry: e, kind: itemType, fromEvent: false} case "function_call_output", "custom_tool_call_output": callID := readString(payload, "call_id") @@ -158,13 +212,68 @@ func (p *Parser) convertResponseItem(raw json.RawMessage, timestamp time.Time) * ToolUseID: callID, ToolResult: output, }} - return e + return &parsedEntry{entry: e, kind: itemType, fromEvent: false} default: return nil } } +func isEventMessageCandidate(p *parsedEntry) bool { + if p == nil || !p.fromEvent || p.entry == nil { + return false + } + switch p.kind { + case "user_message", "agent_message", "agent_reasoning": + return comparableEntryText(p.entry) != "" + default: + return false + } +} + +func isDuplicateEventResponsePair(event, current *parsedEntry) bool { + if !isEventMessageCandidate(event) || current == nil || current.entry == nil { + return false + } + if current.fromEvent { + return false + } + if event.entry.Role != current.entry.Role { + return false + } + eventText := comparableEntryText(event.entry) + currentText := comparableEntryText(current.entry) + if eventText == "" || currentText == "" || eventText != currentText { + return false + } + + switch event.kind { + case "user_message", "agent_message": + return current.kind == "message" + case "agent_reasoning": + return current.kind == "reasoning" + default: + return false + } +} + +func comparableEntryText(entry *thinkt.Entry) string { + if entry == nil { + return "" + } + if text := strings.TrimSpace(entry.Text); text != "" { + return text + } + for _, block := range entry.ContentBlocks { + if block.Type == "thinking" { + if text := strings.TrimSpace(block.Thinking); text != "" { + return text + } + } + } + return "" +} + func (p *Parser) newEntry(role thinkt.Role, timestamp time.Time, kind, text string) *thinkt.Entry { return &thinkt.Entry{ UUID: composeUUID(p.sessionID, p.lineNo, kind, ""), diff --git a/internal/sources/codex/parser_test.go b/internal/sources/codex/parser_test.go index 930356e..6eee660 100644 --- a/internal/sources/codex/parser_test.go +++ b/internal/sources/codex/parser_test.go @@ -1,6 +1,7 @@ package codex import ( + "io" "strings" "testing" @@ -53,3 +54,72 @@ func TestParser_NextEntry(t *testing.T) { t.Fatalf("unexpected fourth entry: %+v", e4) } } + +func TestParser_DeduplicatesEventMessageWhenResponseMessageMatches(t *testing.T) { + input := strings.Join([]string{ + `{"timestamp":"2026-02-10T00:00:00Z","type":"event_msg","payload":{"type":"user_message","message":"same text"}}`, + `{"timestamp":"2026-02-10T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"same text"}]}}`, + `{"timestamp":"2026-02-10T00:00:02Z","type":"event_msg","payload":{"type":"agent_message","message":"assistant line"}}`, + `{"timestamp":"2026-02-10T00:00:03Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"assistant line"}]}}`, + `{"timestamp":"2026-02-10T00:00:04Z","type":"response_item","payload":{"type":"reasoning","text":"thinking..."}}`, + }, "\n") + + p := NewParser(strings.NewReader(input), "sess-dup") + + var entries []*thinkt.Entry + for { + e, err := p.NextEntry() + if err != nil { + if err != io.EOF { + t.Fatalf("NextEntry returned unexpected error: %v", err) + } + break + } + entries = append(entries, e) + } + + if len(entries) != 3 { + t.Fatalf("expected 3 deduplicated entries, got %d", len(entries)) + } + if entries[0].Role != thinkt.RoleUser || entries[0].Text != "same text" { + t.Fatalf("unexpected first entry: %+v", entries[0]) + } + if entries[1].Role != thinkt.RoleAssistant || entries[1].Text != "assistant line" { + t.Fatalf("unexpected second entry: %+v", entries[1]) + } + if len(entries[2].ContentBlocks) != 1 || entries[2].ContentBlocks[0].Type != "thinking" { + t.Fatalf("unexpected third entry: %+v", entries[2]) + } +} + +func TestParser_DeduplicatesEventReasoningWhenResponseReasoningMatches(t *testing.T) { + input := strings.Join([]string{ + `{"timestamp":"2026-02-10T00:00:00Z","type":"event_msg","payload":{"type":"agent_reasoning","text":"thinking once"}}`, + `{"timestamp":"2026-02-10T00:00:01Z","type":"response_item","payload":{"type":"reasoning","text":"thinking once"}}`, + `{"timestamp":"2026-02-10T00:00:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}}`, + }, "\n") + + p := NewParser(strings.NewReader(input), "sess-reasoning-dup") + + var entries []*thinkt.Entry + for { + e, err := p.NextEntry() + if err != nil { + if err != io.EOF { + t.Fatalf("NextEntry returned unexpected error: %v", err) + } + break + } + entries = append(entries, e) + } + + if len(entries) != 2 { + t.Fatalf("expected 2 deduplicated entries, got %d", len(entries)) + } + if len(entries[0].ContentBlocks) != 1 || entries[0].ContentBlocks[0].Type != "thinking" || entries[0].ContentBlocks[0].Thinking != "thinking once" { + t.Fatalf("unexpected first entry: %+v", entries[0]) + } + if entries[1].Role != thinkt.RoleAssistant || entries[1].Text != "answer" { + t.Fatalf("unexpected second entry: %+v", entries[1]) + } +} From e6b0063e1f21f539f36341e9441718d7fc224c42 Mon Sep 17 00:00:00 2001 From: Kevin Miller Date: Sat, 2 May 2026 15:36:11 -0500 Subject: [PATCH 3/3] fix: prevent panic when project directory name shorter than 8 chars Gemini and Kimi stores were slicing directory names with [:8] without checking length first. Short names like "kevm" (4 chars) would panic with "slice bounds out of range [:8] with length 4". Add length checks in both stores before truncating display strings. Co-Authored-By: Claude Haiku 4.5 --- internal/sources/gemini/store.go | 6 +++++- internal/sources/kimi/store.go | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/sources/gemini/store.go b/internal/sources/gemini/store.go index f2aa6e8..e8ca35a 100644 --- a/internal/sources/gemini/store.go +++ b/internal/sources/gemini/store.go @@ -134,11 +134,15 @@ func (s *Store) ListProjects(ctx context.Context) ([]thinkt.Project, error) { } if sessionCount > 0 { + displayHash := projectHash + if len(displayHash) > 8 { + displayHash = displayHash[:8] + } projects = append(projects, thinkt.Project{ ID: projectHash, Name: projectName, Path: projectPath, - DisplayPath: "gemini://" + projectHash[:8], + DisplayPath: "gemini://" + displayHash, SessionCount: sessionCount, LastModified: lastMod, Source: thinkt.SourceGemini, diff --git a/internal/sources/kimi/store.go b/internal/sources/kimi/store.go index c015e5b..1d0304d 100644 --- a/internal/sources/kimi/store.go +++ b/internal/sources/kimi/store.go @@ -882,11 +882,16 @@ func (s *Store) scanProjects(sessionsDir string) ([]thinkt.Project, error) { info, _ := entry.Info() + displayHash := hash + if len(displayHash) > 8 { + displayHash = displayHash[:8] + } + projects = append(projects, thinkt.Project{ ID: hash, - Name: hash[:8], // Show first 8 chars of hash + Name: displayHash, // Show first 8 chars of hash Path: hash, - DisplayPath: hash[:8] + "...", + DisplayPath: displayHash + "...", SessionCount: len(sessions), LastModified: info.ModTime(), Source: thinkt.SourceKimi,