Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,6 @@ Thumbs.db

# Local settings (keep .claude/settings.json for hooks)
.claude/settings.local.json

# Agent sandbox test workaround
.tmp/gocache
8 changes: 6 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ tasks:

go-tidy:
desc: Tidy Go modules
deps: [server:swag-v2]
cmds:
- go mod tidy

Expand Down Expand Up @@ -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:
Expand Down
141 changes: 125 additions & 16 deletions internal/sources/codex/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -35,17 +43,51 @@ 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())
if line == "" {
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 {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -84,27 +126,35 @@ 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 == "" {
return nil
}
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
Expand All @@ -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)
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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, ""),
Expand Down
70 changes: 70 additions & 0 deletions internal/sources/codex/parser_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package codex

import (
"io"
"strings"
"testing"

Expand Down Expand Up @@ -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])
}
}
6 changes: 5 additions & 1 deletion internal/sources/gemini/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions internal/sources/kimi/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down