diff --git a/specstory-cli/pkg/providers/claudecode/agent_session.go b/specstory-cli/pkg/providers/claudecode/agent_session.go index 77c3b154..94a5ccea 100644 --- a/specstory-cli/pkg/providers/claudecode/agent_session.go +++ b/specstory-cli/pkg/providers/claudecode/agent_session.go @@ -1,8 +1,11 @@ package claudecode import ( + "bufio" + "encoding/json" "fmt" "log/slog" + "os" "strings" "time" @@ -69,6 +72,7 @@ func GenerateAgentSession(session Session, workspaceRoot string) (*SessionData, }, SessionID: session.SessionUuid, CreatedAt: createdAt, + Title: latestAiTitle(session.Records), WorkspaceRoot: workspaceRoot, Exchanges: exchanges, } @@ -76,6 +80,51 @@ func GenerateAgentSession(session Session, workspaceRoot string) (*SessionData, return sessionData, nil } +// latestAiTitle returns the most recent Claude Code `ai-title` value for the +// session, or "" if none. Claude re-emits the title (unchanged) once per turn and +// it has no uuid, so it's dropped from the parsed records — we read it straight +// from the source .jsonl. Cheap: the title line is tiny; we scan and keep the last. +func latestAiTitle(records []JSONLRecord) string { + seen := make(map[string]bool) + title := "" + for _, record := range records { + if record.File == "" || seen[record.File] { + continue + } + seen[record.File] = true + if t := aiTitleFromFile(record.File); t != "" { + title = t // later files win (chronological); keeps the latest + } + } + return title +} + +func aiTitleFromFile(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer func() { _ = f.Close() }() + + title := "" + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*KB), maxReasonableLineSize) + for scanner.Scan() { + line := scanner.Bytes() + if !strings.Contains(string(line), `"ai-title"`) { + continue + } + var rec struct { + Type string `json:"type"` + AiTitle string `json:"aiTitle"` + } + if err := json.Unmarshal(line, &rec); err == nil && rec.Type == "ai-title" && rec.AiTitle != "" { + title = rec.AiTitle // keep scanning; last one wins + } + } + return title +} + // extractCreatedAtFromRecords extracts the earliest timestamp from JSONL records func extractCreatedAtFromRecords(records []JSONLRecord) string { if len(records) == 0 { @@ -178,6 +227,22 @@ func buildExchangesFromRecords(records []JSONLRecord, workspaceRoot string) ([]E msg := buildAgentMessage(record, workspaceRoot, isSidechain) currentExchange.Messages = append(currentExchange.Messages, msg) currentExchange.EndTime = timestamp + + case "system": + // Render the idle "/recap" (away_summary) the user actually saw on + // screen. Other system records (e.g. compact_boundary) aren't shown. + if subtype, _ := record.Data["subtype"].(string); subtype != "away_summary" { + continue + } + content, _ := record.Data["content"].(string) + if strings.TrimSpace(content) == "" { + continue + } + if currentExchange == nil { + currentExchange = &Exchange{StartTime: timestamp, Messages: []Message{}} + } + currentExchange.Messages = append(currentExchange.Messages, buildRecapMessage(record)) + currentExchange.EndTime = timestamp } } @@ -304,6 +369,22 @@ func buildUserMessage(record JSONLRecord, isSidechain bool) Message { return msg } +// buildRecapMessage builds a Message for an idle "/recap" (away_summary). Tagged +// via Metadata so the renderer marks it distinctly; role is agent (Claude-authored). +func buildRecapMessage(record JSONLRecord) Message { + uuid, _ := record.Data["uuid"].(string) + timestamp, _ := record.Data["timestamp"].(string) + content, _ := record.Data["content"].(string) + + return Message{ + ID: uuid, + Timestamp: timestamp, + Role: "agent", + Content: []ContentPart{{Type: "text", Text: content}}, + Metadata: map[string]interface{}{"recap": true}, + } +} + // buildAgentMessage creates a Message from an assistant JSONL record func buildAgentMessage(record JSONLRecord, workspaceRoot string, isSidechain bool) Message { uuid, _ := record.Data["uuid"].(string) diff --git a/specstory-cli/pkg/providers/claudecode/aititle_test.go b/specstory-cli/pkg/providers/claudecode/aititle_test.go new file mode 100644 index 00000000..fa4648ed --- /dev/null +++ b/specstory-cli/pkg/providers/claudecode/aititle_test.go @@ -0,0 +1,37 @@ +package claudecode + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAiTitleFromFile_LatestWins(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "s.jsonl") + lines := `{"type":"user","uuid":"u1","sessionId":"s1","message":{"role":"user","content":"hi"}} +{"type":"ai-title","aiTitle":"First Title","sessionId":"s1"} +{"type":"assistant","uuid":"a1","sessionId":"s1","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}} +{"type":"ai-title","aiTitle":"Latest Title","sessionId":"s1"} +` + if err := os.WriteFile(p, []byte(lines), 0644); err != nil { + t.Fatal(err) + } + if got := aiTitleFromFile(p); got != "Latest Title" { + t.Errorf("aiTitleFromFile = %q, want %q", got, "Latest Title") + } +} + +func TestAiTitleFromFile_BlankWhenAbsent(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "n.jsonl") + if err := os.WriteFile(p, []byte(`{"type":"user","uuid":"u1","sessionId":"s1"}`+"\n"), 0644); err != nil { + t.Fatal(err) + } + if got := aiTitleFromFile(p); got != "" { + t.Errorf("aiTitleFromFile = %q, want empty", got) + } + if got := aiTitleFromFile(filepath.Join(dir, "does-not-exist.jsonl")); got != "" { + t.Errorf("missing file should yield empty, got %q", got) + } +} diff --git a/specstory-cli/pkg/providers/claudecode/recap_test.go b/specstory-cli/pkg/providers/claudecode/recap_test.go new file mode 100644 index 00000000..88d2b6b8 --- /dev/null +++ b/specstory-cli/pkg/providers/claudecode/recap_test.go @@ -0,0 +1,51 @@ +package claudecode + +import ( + "strings" + "testing" +) + +func TestBuildExchanges_RendersAwaySummaryRecap(t *testing.T) { + records := []JSONLRecord{ + {Data: map[string]interface{}{ + "type": "user", "uuid": "u1", "timestamp": "2026-06-16T04:08:00Z", "sessionId": "s1", + "message": map[string]interface{}{"role": "user", "content": "hi"}, + }}, + {Data: map[string]interface{}{ + "type": "assistant", "uuid": "a1", "timestamp": "2026-06-16T04:08:05Z", "sessionId": "s1", + "message": map[string]interface{}{"role": "assistant", "content": []interface{}{ + map[string]interface{}{"type": "text", "text": "ok"}, + }}, + }}, + {Data: map[string]interface{}{ + "type": "system", "subtype": "away_summary", "uuid": "sys1", "parentUuid": "a1", + "timestamp": "2026-06-16T04:12:00Z", "sessionId": "s1", + "content": "You are setting up MCPs in Claude Code.", + }}, + // A non-recap system record must NOT be rendered. + {Data: map[string]interface{}{ + "type": "system", "subtype": "compact_boundary", "uuid": "sys2", "parentUuid": "sys1", + "timestamp": "2026-06-16T04:13:00Z", "sessionId": "s1", + }}, + } + + exchanges, err := buildExchangesFromRecords(records, "") + if err != nil { + t.Fatal(err) + } + + recapCount := 0 + for _, e := range exchanges { + for _, m := range e.Messages { + if r, _ := m.Metadata["recap"].(bool); r { + recapCount++ + if len(m.Content) == 0 || !strings.Contains(m.Content[0].Text, "setting up MCPs") { + t.Errorf("recap message missing content: %+v", m.Content) + } + } + } + } + if recapCount != 1 { + t.Errorf("expected exactly 1 recap message (away_summary only), got %d", recapCount) + } +} diff --git a/specstory-cli/pkg/session/markdown.go b/specstory-cli/pkg/session/markdown.go index 75c5c737..cafeebaa 100644 --- a/specstory-cli/pkg/session/markdown.go +++ b/specstory-cli/pkg/session/markdown.go @@ -56,6 +56,11 @@ func GenerateMarkdownFromAgentSession(sessionData *SessionData, includeMessageID sessionData.SessionID, commentTimestamp) + // Agent-generated session title, alongside the session id. Blank if unavailable. + if strings.TrimSpace(sessionData.Title) != "" { + fmt.Fprintf(&markdown, "**Title:** %s\n\n", sessionData.Title) + } + // Render each exchange, tracking role across exchanges for proper separators prevRole := "" for _, exchange := range sessionData.Exchanges { @@ -133,6 +138,14 @@ func renderMessage(msg Message, prevRole string, includeMessageIDs bool, useUTC // renderRoleHeader creates the role header for a message func renderRoleHeader(msg Message, useUTC bool) string { + // Idle "/recap" (away_summary): label distinctly from a normal agent turn. + if recap, ok := msg.Metadata["recap"].(bool); ok && recap { + if msg.Timestamp != "" { + return fmt.Sprintf("_**Recap (%s)**_\n\n", formatTimestamp(msg.Timestamp, useUTC)) + } + return "_**Recap**_\n\n" + } + // Check if this is a sidechain message (subagent conversation) sidechainMarker := "" if isSidechain, ok := msg.Metadata["isSidechain"].(bool); ok && isSidechain { diff --git a/specstory-cli/pkg/session/recap_header_test.go b/specstory-cli/pkg/session/recap_header_test.go new file mode 100644 index 00000000..5f95bdbe --- /dev/null +++ b/specstory-cli/pkg/session/recap_header_test.go @@ -0,0 +1,22 @@ +package session + +import ( + "strings" + "testing" +) + +func TestRenderRoleHeader_Recap(t *testing.T) { + recap := Message{ + Role: "agent", + Timestamp: "2026-06-16T04:12:16Z", + Metadata: map[string]interface{}{"recap": true}, + } + if got := renderRoleHeader(recap, true); got != "_**Recap (2026-06-16 04:12:16Z)**_\n\n" { + t.Errorf("recap header = %q", got) + } + + normal := Message{Role: "agent", Timestamp: "2026-06-16T04:12:16Z", Model: "claude"} + if got := renderRoleHeader(normal, true); strings.Contains(got, "Recap") { + t.Errorf("non-recap agent header should not say Recap, got %q", got) + } +} diff --git a/specstory-cli/pkg/session/title_header_test.go b/specstory-cli/pkg/session/title_header_test.go new file mode 100644 index 00000000..942bb671 --- /dev/null +++ b/specstory-cli/pkg/session/title_header_test.go @@ -0,0 +1,37 @@ +package session + +import ( + "strings" + "testing" +) + +func TestGenerateMarkdown_TitleHeader(t *testing.T) { + base := &SessionData{ + SchemaVersion: "1.0", + Provider: ProviderInfo{Name: "Claude Code"}, + SessionID: "s1", + CreatedAt: "2026-06-17T08:33:43Z", + Exchanges: []Exchange{{ + StartTime: "2026-06-17T08:33:43Z", + Messages: []Message{{Role: "user", Timestamp: "2026-06-17T08:33:43Z", Content: []ContentPart{{Type: "text", Text: "hi"}}}}, + }}, + } + + withTitle := *base + withTitle.Title = "Understand Wispr ASR pipeline" + md, err := GenerateMarkdownFromAgentSession(&withTitle, false, true) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(md, "**Title:** Understand Wispr ASR pipeline") { + t.Errorf("expected title metadata line, got:\n%s", md) + } + + md2, err := GenerateMarkdownFromAgentSession(base, false, true) + if err != nil { + t.Fatal(err) + } + if strings.Contains(md2, "**Title:**") { + t.Error("no title line expected when Title is blank") + } +} diff --git a/specstory-cli/pkg/spi/schema/session-data-v1.json b/specstory-cli/pkg/spi/schema/session-data-v1.json index fc048410..3f53f12c 100644 --- a/specstory-cli/pkg/spi/schema/session-data-v1.json +++ b/specstory-cli/pkg/spi/schema/session-data-v1.json @@ -21,6 +21,7 @@ "createdAt": { "type": "string", "format": "date-time" }, "updatedAt": { "type": "string", "format": "date-time" }, "slug": { "type": "string" }, + "title": { "type": "string" }, "workspaceRoot": { "type": "string" }, "exchanges": { "type": "array", diff --git a/specstory-cli/pkg/spi/schema/types.go b/specstory-cli/pkg/spi/schema/types.go index 2fe29487..aa9f85cc 100644 --- a/specstory-cli/pkg/spi/schema/types.go +++ b/specstory-cli/pkg/spi/schema/types.go @@ -35,6 +35,7 @@ type SessionData struct { CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt,omitempty"` Slug string `json:"slug,omitempty"` + Title string `json:"title,omitempty"` // agent-generated session title (e.g. Claude Code's aiTitle); blank if none WorkspaceRoot string `json:"workspaceRoot"` Exchanges []Exchange `json:"exchanges"` }