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
81 changes: 81 additions & 0 deletions specstory-cli/pkg/providers/claudecode/agent_session.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package claudecode

import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"time"

Expand Down Expand Up @@ -69,13 +72,59 @@ func GenerateAgentSession(session Session, workspaceRoot string) (*SessionData,
},
SessionID: session.SessionUuid,
CreatedAt: createdAt,
Title: latestAiTitle(session.Records),
WorkspaceRoot: workspaceRoot,
Exchanges: exchanges,
}

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 {
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions specstory-cli/pkg/providers/claudecode/aititle_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
51 changes: 51 additions & 0 deletions specstory-cli/pkg/providers/claudecode/recap_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 13 additions & 0 deletions specstory-cli/pkg/session/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions specstory-cli/pkg/session/recap_header_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
37 changes: 37 additions & 0 deletions specstory-cli/pkg/session/title_header_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions specstory-cli/pkg/spi/schema/session-data-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions specstory-cli/pkg/spi/schema/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down