Skip to content

Commit 8d69816

Browse files
committed
storing the first meaningful prompt in sessions.slug
1 parent e79dff6 commit 8d69816

5 files changed

Lines changed: 173 additions & 54 deletions

File tree

internal/app/ingest.go

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7-
"os"
87
"path/filepath"
98
"strings"
109

@@ -50,22 +49,16 @@ func IngestClaudeEvent(ctx context.Context, st *store.Store, payload map[string]
5049
projectID = id
5150
}
5251

53-
if err := q.UpsertSession(ctx, parsed.SessionID, "", projectID, parsed.Slug, "claude", parsed.Metadata, parsed.Timestamp, parsed.TranscriptPath); err != nil {
54-
return err
52+
sessionSlug := ""
53+
if parsed.Subtype == "UserPromptSubmit" {
54+
candidate := claudePromptSlug(str(parsed.Metadata["prompt"]))
55+
if candidate != "" && (existingSession == nil || existingSession.Slug == "" || existingSession.Slug == parsed.Slug) {
56+
sessionSlug = candidate
57+
}
5558
}
5659

57-
if parsed.TranscriptPath != "" {
58-
session, err := q.GetSessionByID(ctx, parsed.SessionID)
59-
if err != nil {
60-
return err
61-
}
62-
if session != nil && session.Slug == "" {
63-
if slug := loadSessionSlug(parsed.TranscriptPath); slug != "" {
64-
if err := q.UpdateSessionSlug(ctx, parsed.SessionID, slug); err != nil {
65-
return err
66-
}
67-
}
68-
}
60+
if err := q.UpsertSession(ctx, parsed.SessionID, "", projectID, sessionSlug, "claude", parsed.Metadata, parsed.Timestamp, parsed.TranscriptPath); err != nil {
61+
return err
6962
}
7063

7164
rootAgentID := parsed.SessionID
@@ -734,25 +727,29 @@ func deriveSlugCandidates(pathOrDir string) []string {
734727
return candidates
735728
}
736729

737-
func loadSessionSlug(transcriptPath string) string {
738-
data, err := os.ReadFile(transcriptPath)
739-
if err != nil {
730+
func claudePromptSlug(prompt string) string {
731+
prompt = strings.TrimSpace(prompt)
732+
if prompt == "" {
740733
return ""
741734
}
742-
for _, line := range strings.Split(string(data), "\n") {
743-
line = strings.TrimSpace(line)
744-
if line == "" || !strings.Contains(line, `"slug"`) {
745-
continue
746-
}
747-
var entry map[string]any
748-
if err := json.Unmarshal([]byte(line), &entry); err != nil {
749-
continue
750-
}
751-
if slug := str(entry["slug"]); slug != "" {
752-
return slug
753-
}
735+
if strings.Contains(prompt, "<local-command-caveat>") {
736+
return ""
754737
}
755-
return ""
738+
first := strings.TrimSpace(firstLineText(prompt))
739+
if first == "" {
740+
return ""
741+
}
742+
if strings.HasPrefix(first, "/") || strings.HasPrefix(first, "<command-name>/") {
743+
return ""
744+
}
745+
return first
746+
}
747+
748+
func firstLineText(s string) string {
749+
if i := strings.IndexByte(s, '\n'); i >= 0 {
750+
return s[:i]
751+
}
752+
return s
756753
}
757754

758755
func pick(vals ...string) string {

internal/app/ingest_test.go

Lines changed: 117 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package app
33
import (
44
"context"
55
"encoding/json"
6-
"os"
76
"path/filepath"
87
"testing"
98

@@ -154,6 +153,123 @@ func TestIngestSessionEnd(t *testing.T) {
154153
}
155154
}
156155

156+
func TestIngestClaudeDisplaySlugUsesFirstMeaningfulPrompt(t *testing.T) {
157+
st := testStore(t)
158+
ctx := context.Background()
159+
transcriptPath := filepath.Join(t.TempDir(), "claude-session.jsonl")
160+
161+
_, err := IngestClaudeEvent(ctx, st, map[string]any{
162+
"hook_event_name": "SessionStart",
163+
"session_id": "claude-1",
164+
"slug": "elegant-giggling-flame",
165+
"transcript_path": transcriptPath,
166+
"cwd": "/home/user/project",
167+
"meta": map[string]any{"timestamp": float64(1712700000000)},
168+
})
169+
if err != nil {
170+
t.Fatal(err)
171+
}
172+
173+
_, err = IngestClaudeEvent(ctx, st, map[string]any{
174+
"hook_event_name": "UserPromptSubmit",
175+
"session_id": "claude-1",
176+
"slug": "elegant-giggling-flame",
177+
"transcript_path": transcriptPath,
178+
"cwd": "/home/user/project",
179+
"prompt": "<command-name>/clear</command-name>\n<command-message>clear</command-message>",
180+
"meta": map[string]any{"timestamp": float64(1712700001000)},
181+
})
182+
if err != nil {
183+
t.Fatal(err)
184+
}
185+
186+
_, err = IngestClaudeEvent(ctx, st, map[string]any{
187+
"hook_event_name": "UserPromptSubmit",
188+
"session_id": "claude-1",
189+
"slug": "elegant-giggling-flame",
190+
"transcript_path": transcriptPath,
191+
"cwd": "/home/user/project",
192+
"prompt": "investigate the pagination bug\nwith full context",
193+
"meta": map[string]any{"timestamp": float64(1712700002000)},
194+
})
195+
if err != nil {
196+
t.Fatal(err)
197+
}
198+
199+
_, err = IngestClaudeEvent(ctx, st, map[string]any{
200+
"hook_event_name": "UserPromptSubmit",
201+
"session_id": "claude-1",
202+
"slug": "elegant-giggling-flame",
203+
"transcript_path": transcriptPath,
204+
"cwd": "/home/user/project",
205+
"prompt": "later follow-up prompt",
206+
"meta": map[string]any{"timestamp": float64(1712700003000)},
207+
})
208+
if err != nil {
209+
t.Fatal(err)
210+
}
211+
212+
session, err := st.Read().GetSessionByID(ctx, "claude-1")
213+
if err != nil {
214+
t.Fatal(err)
215+
}
216+
if session == nil {
217+
t.Fatal("session not found")
218+
}
219+
if session.Slug != "investigate the pagination bug" {
220+
t.Fatalf("display slug=%q, want first meaningful prompt", session.Slug)
221+
}
222+
223+
events, err := st.Read().ListEventsForSession(ctx, "claude-1", model.EventFilter{})
224+
if err != nil {
225+
t.Fatal(err)
226+
}
227+
if len(events) != 4 {
228+
t.Fatalf("got %d events, want 4", len(events))
229+
}
230+
}
231+
232+
func TestIngestClaudeMeaningfulPromptReplacesLegacyRandomSlug(t *testing.T) {
233+
st := testStore(t)
234+
ctx := context.Background()
235+
236+
var projectID int64
237+
err := st.WithTx(ctx, func(q *store.Queries) error {
238+
var err error
239+
projectID, err = q.CreateProject(ctx, "proj", "Project", "/home/user/project", "")
240+
if err != nil {
241+
return err
242+
}
243+
return q.UpsertSession(ctx, "claude-legacy", "", projectID, "quirky-doodling-zephyr", "claude", nil, 1712700000000, "")
244+
})
245+
if err != nil {
246+
t.Fatal(err)
247+
}
248+
249+
_, err = IngestClaudeEvent(ctx, st, map[string]any{
250+
"hook_event_name": "UserPromptSubmit",
251+
"session_id": "claude-legacy",
252+
"slug": "quirky-doodling-zephyr",
253+
"cwd": "/home/user/project",
254+
"prompt": "real bug report title",
255+
"meta": map[string]any{"timestamp": float64(1712700001000)},
256+
})
257+
if err != nil {
258+
t.Fatal(err)
259+
}
260+
261+
session, err := st.Read().GetSessionByID(ctx, "claude-legacy")
262+
if err != nil {
263+
t.Fatal(err)
264+
}
265+
if session == nil {
266+
t.Fatal("session not found")
267+
}
268+
if session.Slug != "real bug report title" {
269+
t.Fatalf("display slug=%q, want replaced display slug", session.Slug)
270+
}
271+
}
272+
157273
func TestIngestOpenCodeSessionIdleMarksStopped(t *testing.T) {
158274
st := testStore(t)
159275
ctx := context.Background()
@@ -1232,19 +1348,6 @@ func TestExtractProjectDir(t *testing.T) {
12321348
}
12331349
}
12341350

1235-
func TestLoadSessionSlug(t *testing.T) {
1236-
dir := t.TempDir()
1237-
path := filepath.Join(dir, "session.jsonl")
1238-
os.WriteFile(path, []byte(`{"type":"init"}
1239-
{"slug":"my-session-slug","type":"meta"}
1240-
`), 0o644)
1241-
1242-
slug := loadSessionSlug(path)
1243-
if slug != "my-session-slug" {
1244-
t.Fatalf("got slug=%q", slug)
1245-
}
1246-
}
1247-
12481351
func TestUpsertSessionParentIDUpdatedOnConflict(t *testing.T) {
12491352
st := testStore(t)
12501353
ctx := context.Background()

internal/claude/parser.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ func ParseRawEvent(raw map[string]any) model.ParsedEvent {
3232
p.Metadata[k] = v
3333
}
3434
}
35+
if prompt := str(raw["prompt"]); prompt != "" {
36+
p.Metadata["prompt"] = prompt
37+
}
3538

3639
return p
3740
}

internal/claude/parser_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,19 @@ func TestParseMetadataExtraction(t *testing.T) {
122122
t.Fatalf("got version=%v", p.Metadata["version"])
123123
}
124124
}
125+
126+
func TestParseUserPromptSubmitStoresPrompt(t *testing.T) {
127+
raw := map[string]any{
128+
"hook_event_name": "UserPromptSubmit",
129+
"session_id": "sess-1",
130+
"prompt": "investigate the pagination bug",
131+
"meta": map[string]any{"timestamp": float64(1712700000000)},
132+
}
133+
p := ParseRawEvent(raw)
134+
if p.Type != "user" || p.Subtype != "UserPromptSubmit" {
135+
t.Fatalf("got type=%q subtype=%q", p.Type, p.Subtype)
136+
}
137+
if p.Metadata["prompt"] != "investigate the pagination bug" {
138+
t.Fatalf("got prompt=%v", p.Metadata["prompt"])
139+
}
140+
}

internal/model/types.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,15 @@ type Session struct {
4646
Slug string
4747
Status string
4848
Runtime string
49-
StartedAt int64
50-
StoppedAt int64
51-
TranscriptPath string
52-
Metadata string
53-
EventCount int64
54-
AgentCount int64
55-
LastActivity int64
56-
CreatedAt int64
57-
UpdatedAt int64
49+
StartedAt int64
50+
StoppedAt int64
51+
TranscriptPath string
52+
Metadata string
53+
EventCount int64
54+
AgentCount int64
55+
LastActivity int64
56+
CreatedAt int64
57+
UpdatedAt int64
5858
}
5959

6060
type Agent struct {

0 commit comments

Comments
 (0)