-
Notifications
You must be signed in to change notification settings - Fork 248
feat: implement TranscriptAnalyzer for Cursor agent #537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
gtrrz-victor
merged 6 commits into
main
from
gtrrz-victor/fix-commit-message-cursor-shadow-branch
Mar 2, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
f591afd
fix: use agent type in shadow branch fallback commit message
gtrrz-victor 44a7bae
feat: implement TranscriptAnalyzer for Cursor agent
gtrrz-victor f4fbc1a
fix: align test with simplified ExtractModifiedFilesFromOffset
gtrrz-victor 47587f4
Merge branch 'main' of github.com:entireio/cli into gtrrz-victor/fix-…
squishykid e7ce443
cursor: always return 0 extracted files
squishykid 740a4fd
Merge branch 'main' into gtrrz-victor/fix-commit-message-cursor-shado…
gtrrz-victor File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| package cursor | ||
|
|
||
| import ( | ||
| "bufio" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
|
|
||
| "github.com/entireio/cli/cmd/entire/cli/agent" | ||
| "github.com/entireio/cli/cmd/entire/cli/textutil" | ||
| "github.com/entireio/cli/cmd/entire/cli/transcript" | ||
| ) | ||
|
|
||
| // Compile-time interface assertion. | ||
| var _ agent.TranscriptAnalyzer = (*CursorAgent)(nil) | ||
|
|
||
| // GetTranscriptPosition returns the current line count of a Cursor transcript. | ||
| // Cursor uses the same JSONL format as Claude Code, so position is the number of lines. | ||
| // Uses bufio.Reader to handle arbitrarily long lines (no size limit). | ||
| // Returns 0 if the file doesn't exist or is empty. | ||
| func (c *CursorAgent) GetTranscriptPosition(path string) (int, error) { | ||
| if path == "" { | ||
| return 0, nil | ||
| } | ||
|
|
||
| file, err := os.Open(path) //nolint:gosec // Path comes from Cursor transcript location | ||
| if err != nil { | ||
| if os.IsNotExist(err) { | ||
| return 0, nil | ||
| } | ||
| return 0, fmt.Errorf("failed to open transcript file: %w", err) | ||
| } | ||
| defer file.Close() | ||
|
|
||
| reader := bufio.NewReader(file) | ||
| lineCount := 0 | ||
|
|
||
| for { | ||
| line, err := reader.ReadBytes('\n') | ||
| if err != nil { | ||
| if err == io.EOF { | ||
| if len(line) > 0 { | ||
| lineCount++ // Count final line without trailing newline | ||
| } | ||
| break | ||
| } | ||
| return 0, fmt.Errorf("failed to read transcript: %w", err) | ||
| } | ||
| lineCount++ | ||
| } | ||
|
|
||
| return lineCount, nil | ||
| } | ||
|
|
||
| // ExtractPrompts extracts user prompts from the transcript starting at the given line offset. | ||
| // Cursor uses the same JSONL format as Claude Code; the shared transcript package normalizes | ||
| // "role" → "type" and strips <user_query> tags. | ||
| func (c *CursorAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { | ||
| lines, err := transcript.ParseFromFileAtLine(sessionRef, fromOffset) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to parse transcript: %w", err) | ||
| } | ||
|
|
||
| var prompts []string | ||
| for i := range lines { | ||
| if lines[i].Type != transcript.TypeUser { | ||
| continue | ||
| } | ||
| content := transcript.ExtractUserContent(lines[i].Message) | ||
| if content != "" { | ||
| prompts = append(prompts, textutil.StripIDEContextTags(content)) | ||
| } | ||
| } | ||
| return prompts, nil | ||
| } | ||
|
|
||
| // ExtractSummary extracts the last assistant message as a session summary. | ||
| func (c *CursorAgent) ExtractSummary(sessionRef string) (string, error) { | ||
| data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to read transcript: %w", err) | ||
| } | ||
|
|
||
| lines, parseErr := transcript.ParseFromBytes(data) | ||
| if parseErr != nil { | ||
| return "", fmt.Errorf("failed to parse transcript: %w", parseErr) | ||
| } | ||
|
|
||
| // Walk backward to find last assistant text block | ||
| for i := len(lines) - 1; i >= 0; i-- { | ||
| if lines[i].Type != transcript.TypeAssistant { | ||
| continue | ||
| } | ||
| var msg transcript.AssistantMessage | ||
| if err := json.Unmarshal(lines[i].Message, &msg); err != nil { | ||
| continue | ||
| } | ||
| for _, block := range msg.Content { | ||
| if block.Type == transcript.ContentTypeText && block.Text != "" { | ||
| return block.Text, nil | ||
| } | ||
| } | ||
| } | ||
| return "", nil | ||
| } | ||
|
|
||
| // ExtractModifiedFilesFromOffset returns nil, 0, nil because Cursor transcripts | ||
| // do not contain tool_use blocks. File detection relies on git status instead. | ||
| // All call sites are expected to also use git-status-based detection. | ||
| func (c *CursorAgent) ExtractModifiedFilesFromOffset(_ string, _ int) ([]string, int, error) { | ||
| return nil, 0, nil | ||
| } | ||
gtrrz-victor marked this conversation as resolved.
Show resolved
Hide resolved
gtrrz-victor marked this conversation as resolved.
Show resolved
Hide resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,194 @@ | ||
| package cursor | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/entireio/cli/cmd/entire/cli/agent" | ||
| ) | ||
|
|
||
| // Compile-time interface check. | ||
| var _ agent.TranscriptAnalyzer = (*CursorAgent)(nil) | ||
|
|
||
| // --- GetTranscriptPosition --- | ||
|
|
||
| func TestCursorAgent_GetTranscriptPosition(t *testing.T) { | ||
| t.Parallel() | ||
| ag := &CursorAgent{} | ||
|
|
||
| tmpDir := t.TempDir() | ||
| path := writeSampleTranscript(t, tmpDir) | ||
|
|
||
| pos, err := ag.GetTranscriptPosition(path) | ||
| if err != nil { | ||
| t.Fatalf("GetTranscriptPosition() error = %v", err) | ||
| } | ||
| if pos != 4 { | ||
| t.Errorf("GetTranscriptPosition() = %d, want 4", pos) | ||
| } | ||
| } | ||
|
|
||
| func TestCursorAgent_GetTranscriptPosition_EmptyPath(t *testing.T) { | ||
| t.Parallel() | ||
| ag := &CursorAgent{} | ||
|
|
||
| pos, err := ag.GetTranscriptPosition("") | ||
| if err != nil { | ||
| t.Fatalf("GetTranscriptPosition() error = %v", err) | ||
| } | ||
| if pos != 0 { | ||
| t.Errorf("GetTranscriptPosition() = %d, want 0", pos) | ||
| } | ||
| } | ||
|
|
||
| func TestCursorAgent_GetTranscriptPosition_NonexistentFile(t *testing.T) { | ||
| t.Parallel() | ||
| ag := &CursorAgent{} | ||
|
|
||
| pos, err := ag.GetTranscriptPosition("/nonexistent/path/transcript.jsonl") | ||
| if err != nil { | ||
| t.Fatalf("GetTranscriptPosition() error = %v", err) | ||
| } | ||
| if pos != 0 { | ||
| t.Errorf("GetTranscriptPosition() = %d, want 0", pos) | ||
| } | ||
| } | ||
|
|
||
| // --- ExtractPrompts --- | ||
|
|
||
| func TestCursorAgent_ExtractPrompts(t *testing.T) { | ||
| t.Parallel() | ||
| ag := &CursorAgent{} | ||
|
|
||
| tmpDir := t.TempDir() | ||
| path := writeSampleTranscript(t, tmpDir) | ||
|
|
||
| prompts, err := ag.ExtractPrompts(path, 0) | ||
| if err != nil { | ||
| t.Fatalf("ExtractPrompts() error = %v", err) | ||
| } | ||
| if len(prompts) != 2 { | ||
| t.Fatalf("ExtractPrompts() returned %d prompts, want 2", len(prompts)) | ||
| } | ||
| // Verify <user_query> tags are stripped | ||
| if prompts[0] != "hello" { | ||
| t.Errorf("prompts[0] = %q, want %q", prompts[0], "hello") | ||
| } | ||
| if prompts[1] != "add 'one' to a file and commit" { | ||
| t.Errorf("prompts[1] = %q, want %q", prompts[1], "add 'one' to a file and commit") | ||
| } | ||
| } | ||
|
|
||
| func TestCursorAgent_ExtractPrompts_WithOffset(t *testing.T) { | ||
| t.Parallel() | ||
| ag := &CursorAgent{} | ||
|
|
||
| tmpDir := t.TempDir() | ||
| path := writeSampleTranscript(t, tmpDir) | ||
|
|
||
| // Offset 2 skips the first user+assistant pair, leaving 1 user prompt | ||
| prompts, err := ag.ExtractPrompts(path, 2) | ||
| if err != nil { | ||
| t.Fatalf("ExtractPrompts() error = %v", err) | ||
| } | ||
| if len(prompts) != 1 { | ||
| t.Fatalf("ExtractPrompts() returned %d prompts, want 1", len(prompts)) | ||
| } | ||
| if prompts[0] != "add 'one' to a file and commit" { | ||
| t.Errorf("prompts[0] = %q, want %q", prompts[0], "add 'one' to a file and commit") | ||
| } | ||
| } | ||
|
|
||
| func TestCursorAgent_ExtractPrompts_EmptyFile(t *testing.T) { | ||
| t.Parallel() | ||
| ag := &CursorAgent{} | ||
|
|
||
| tmpDir := t.TempDir() | ||
| path := filepath.Join(tmpDir, "empty.jsonl") | ||
| if err := os.WriteFile(path, []byte{}, 0o644); err != nil { | ||
| t.Fatalf("failed to write empty file: %v", err) | ||
| } | ||
|
|
||
| prompts, err := ag.ExtractPrompts(path, 0) | ||
| if err != nil { | ||
| t.Fatalf("ExtractPrompts() error = %v", err) | ||
| } | ||
| if len(prompts) != 0 { | ||
| t.Errorf("ExtractPrompts() returned %d prompts, want 0", len(prompts)) | ||
| } | ||
| } | ||
|
|
||
| // --- ExtractSummary --- | ||
|
|
||
| func TestCursorAgent_ExtractSummary(t *testing.T) { | ||
| t.Parallel() | ||
| ag := &CursorAgent{} | ||
|
|
||
| tmpDir := t.TempDir() | ||
| path := writeSampleTranscript(t, tmpDir) | ||
|
|
||
| summary, err := ag.ExtractSummary(path) | ||
| if err != nil { | ||
| t.Fatalf("ExtractSummary() error = %v", err) | ||
| } | ||
| if summary != "Created one.txt with one and committed." { | ||
| t.Errorf("ExtractSummary() = %q, want %q", summary, "Created one.txt with one and committed.") | ||
| } | ||
| } | ||
|
|
||
| func TestCursorAgent_ExtractSummary_EmptyFile(t *testing.T) { | ||
| t.Parallel() | ||
| ag := &CursorAgent{} | ||
|
|
||
| tmpDir := t.TempDir() | ||
| path := filepath.Join(tmpDir, "empty.jsonl") | ||
| if err := os.WriteFile(path, []byte{}, 0o644); err != nil { | ||
| t.Fatalf("failed to write empty file: %v", err) | ||
| } | ||
|
|
||
| summary, err := ag.ExtractSummary(path) | ||
| if err != nil { | ||
| t.Fatalf("ExtractSummary() error = %v", err) | ||
| } | ||
| if summary != "" { | ||
| t.Errorf("ExtractSummary() = %q, want empty string", summary) | ||
| } | ||
| } | ||
|
|
||
| // --- ExtractModifiedFilesFromOffset --- | ||
|
|
||
| func TestCursorAgent_ExtractModifiedFilesFromOffset(t *testing.T) { | ||
| t.Parallel() | ||
| ag := &CursorAgent{} | ||
|
|
||
| tmpDir := t.TempDir() | ||
| path := writeSampleTranscript(t, tmpDir) | ||
|
|
||
| files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) | ||
| if err != nil { | ||
| t.Fatalf("ExtractModifiedFilesFromOffset() error = %v, want nil", err) | ||
| } | ||
| if files != nil { | ||
| t.Errorf("ExtractModifiedFilesFromOffset() files = %v, want nil", files) | ||
| } | ||
| if pos != 0 { | ||
| t.Errorf("ExtractModifiedFilesFromOffset() pos = %d, want 0", pos) | ||
| } | ||
| } | ||
|
|
||
| func TestCursorAgent_ExtractModifiedFilesFromOffset_EmptyPath(t *testing.T) { | ||
| t.Parallel() | ||
| ag := &CursorAgent{} | ||
|
|
||
| files, pos, err := ag.ExtractModifiedFilesFromOffset("", 0) | ||
| if err != nil { | ||
| t.Fatalf("ExtractModifiedFilesFromOffset() error = %v", err) | ||
| } | ||
| if files != nil { | ||
| t.Errorf("ExtractModifiedFilesFromOffset() files = %v, want nil", files) | ||
| } | ||
| if pos != 0 { | ||
| t.Errorf("ExtractModifiedFilesFromOffset() pos = %d, want 0", pos) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.