mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
feat(cursor): sync local conversation history to Cursor transcripts
Project sidecar context history into Cursor-compatible JSONL transcripts so previous conversations remain readable when referenced from new chats. Backfill existing sessions on startup and preserve Cursor-managed turn status entries during atomic updates.
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
modeladapter "cursor/internal/backend/agent/model"
|
||||
promptengine "cursor/internal/backend/agent/prompt"
|
||||
)
|
||||
|
||||
var (
|
||||
transcriptContextTagPatterns = compileTranscriptContextTagPatterns([]string{
|
||||
"user_info",
|
||||
"project_layout",
|
||||
"rules",
|
||||
"always_applied_workspace_rules",
|
||||
"agent_requestable_workspace_rules",
|
||||
"user_rules",
|
||||
"agent_skills",
|
||||
"available_skills",
|
||||
"cloud_instructions",
|
||||
"cloud_task_instructions",
|
||||
"open_and_recently_viewed_files",
|
||||
"system_reminder",
|
||||
"system-reminder",
|
||||
"mcp_instructions",
|
||||
"mcp_file_system",
|
||||
"mcp_file_system_servers",
|
||||
"git_status",
|
||||
"agent_transcripts",
|
||||
"cursor_rules_context",
|
||||
"attached_files",
|
||||
"system_notification",
|
||||
"task_notification",
|
||||
"agent_notification",
|
||||
})
|
||||
transcriptThinkingPattern = regexp.MustCompile(`(?is)<(?:think|thinking)>.*?</(?:think|thinking)>`)
|
||||
transcriptBlankLinesPattern = regexp.MustCompile(`\n{3,}`)
|
||||
)
|
||||
|
||||
type cursorTranscriptLine struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Message *cursorTranscriptMessage `json:"message,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type cursorTranscriptMessage struct {
|
||||
Content []cursorTranscriptContent `json:"content"`
|
||||
}
|
||||
|
||||
type cursorTranscriptContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
// projectCursorTranscriptJSONL projects the local semantic history into Cursor's
|
||||
// current agent transcript JSONL contract. context.json remains the source of truth.
|
||||
func projectCursorTranscriptJSONL(conversation *ConversationFile) ([]byte, error) {
|
||||
return projectCursorTranscriptJSONLWithLatestStatus(conversation, false)
|
||||
}
|
||||
|
||||
func projectCursorTranscriptJSONLWithLatestStatus(conversation *ConversationFile, includeLatestStatus bool) ([]byte, error) {
|
||||
if conversation == nil {
|
||||
return nil, nil
|
||||
}
|
||||
lines := make([]cursorTranscriptLine, 0, len(conversation.Entries))
|
||||
maxTurnSeq := int64(0)
|
||||
for _, entry := range conversation.Entries {
|
||||
if entry.TurnSeq > maxTurnSeq {
|
||||
maxTurnSeq = entry.TurnSeq
|
||||
}
|
||||
}
|
||||
currentTurnSeq := int64(0)
|
||||
pendingTurnStatus := cursorTranscriptLine{}
|
||||
flushTurnStatus := func() {
|
||||
if currentTurnSeq > 0 && (includeLatestStatus || currentTurnSeq < maxTurnSeq) && pendingTurnStatus.Type != "" {
|
||||
lines = append(lines, pendingTurnStatus)
|
||||
}
|
||||
pendingTurnStatus = cursorTranscriptLine{}
|
||||
}
|
||||
for _, entry := range conversation.Entries {
|
||||
if entry.TurnSeq > 0 && entry.TurnSeq != currentTurnSeq {
|
||||
flushTurnStatus()
|
||||
currentTurnSeq = entry.TurnSeq
|
||||
}
|
||||
projected, ok, err := projectCursorTranscriptEntry(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
lines = append(lines, projected)
|
||||
}
|
||||
if status, ok := cursorTranscriptTurnStatus(entry); ok {
|
||||
pendingTurnStatus = status
|
||||
}
|
||||
}
|
||||
flushTurnStatus()
|
||||
|
||||
if len(lines) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var output bytes.Buffer
|
||||
encoder := json.NewEncoder(&output)
|
||||
encoder.SetEscapeHTML(false)
|
||||
for _, line := range lines {
|
||||
if err := encoder.Encode(line); err != nil {
|
||||
return nil, fmt.Errorf("encode cursor transcript line: %w", err)
|
||||
}
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
func projectCursorTranscriptEntry(entry HistoryEntry) (cursorTranscriptLine, bool, error) {
|
||||
switch strings.TrimSpace(entry.Kind) {
|
||||
case "user_message":
|
||||
message := &agentv1.UserMessage{}
|
||||
if err := protojson.Unmarshal(entry.Payload, message); err != nil {
|
||||
return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript user_message: %w", err)
|
||||
}
|
||||
text := cleanCursorTranscriptUserText(message.GetText())
|
||||
if text == "" {
|
||||
return cursorTranscriptLine{}, false, nil
|
||||
}
|
||||
return cursorTranscriptTextLine("user", text), true, nil
|
||||
case "assistant_text":
|
||||
var payload assistantTextPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript assistant_text: %w", err)
|
||||
}
|
||||
text := cleanCursorTranscriptAssistantText(payload.Text)
|
||||
thinking := strings.TrimSpace(payload.ReasoningContent)
|
||||
content := joinTranscriptText(text, thinking)
|
||||
if content == "" {
|
||||
return cursorTranscriptLine{}, false, nil
|
||||
}
|
||||
return cursorTranscriptTextLine("assistant", content), true, nil
|
||||
case "tool_call":
|
||||
var payload toolCallEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript tool_call: %w", err)
|
||||
}
|
||||
toolCall := &agentv1.ToolCall{}
|
||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||
return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript tool_call payload: %w", err)
|
||||
}
|
||||
descriptor, ok := promptengine.BuildToolCallReplayDescriptor(firstNonEmpty(payload.ToolCallID, entry.ToolCallID), toolCall)
|
||||
if !ok {
|
||||
return cursorTranscriptLine{}, false, nil
|
||||
}
|
||||
return cursorTranscriptToolCallLine(descriptor.Function.Name, descriptor.Function.Arguments, payload.ReasoningContent), true, nil
|
||||
case "model_message":
|
||||
var payload modelMessageEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript model_message: %w", err)
|
||||
}
|
||||
return projectCursorTranscriptModelMessage(payload.Message)
|
||||
default:
|
||||
return cursorTranscriptLine{}, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func projectCursorTranscriptModelMessage(message modeladapter.Message) (cursorTranscriptLine, bool, error) {
|
||||
role := strings.TrimSpace(message.Role)
|
||||
if role == "" || role == "system" || role == "tool" {
|
||||
return cursorTranscriptLine{}, false, nil
|
||||
}
|
||||
content := make([]cursorTranscriptContent, 0, len(message.ToolCalls)+1)
|
||||
texts := make([]string, 0, len(message.ContentParts)+2)
|
||||
if text := strings.TrimSpace(message.Content); text != "" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
for _, part := range message.ContentParts {
|
||||
switch strings.TrimSpace(strings.ToLower(part.Type)) {
|
||||
case "text", "":
|
||||
if text := strings.TrimSpace(part.Text); text != "" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
case "image":
|
||||
texts = append(texts, "[Image]")
|
||||
}
|
||||
}
|
||||
if thinking := strings.TrimSpace(message.ReasoningContent); thinking != "" {
|
||||
texts = append(texts, thinking)
|
||||
}
|
||||
if len(texts) > 0 {
|
||||
text := strings.Join(texts, "\n\n")
|
||||
if role == "user" {
|
||||
text = cleanCursorTranscriptUserText(text)
|
||||
} else if role == "assistant" {
|
||||
text = cleanCursorTranscriptAssistantText(text)
|
||||
}
|
||||
if text != "" {
|
||||
content = append(content, cursorTranscriptContent{Type: "text", Text: text})
|
||||
}
|
||||
}
|
||||
for _, call := range message.ToolCalls {
|
||||
name := strings.TrimSpace(call.Function.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
content = append(content, cursorTranscriptContent{
|
||||
Type: "tool_use",
|
||||
Name: name,
|
||||
Input: decodeTranscriptToolInput(call.Function.Arguments),
|
||||
})
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return cursorTranscriptLine{}, false, nil
|
||||
}
|
||||
return cursorTranscriptLine{Role: role, Message: &cursorTranscriptMessage{Content: content}}, true, nil
|
||||
}
|
||||
|
||||
func cursorTranscriptTextLine(role string, text string) cursorTranscriptLine {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return cursorTranscriptLine{}
|
||||
}
|
||||
return cursorTranscriptLine{
|
||||
Role: strings.TrimSpace(role),
|
||||
Message: &cursorTranscriptMessage{Content: []cursorTranscriptContent{{
|
||||
Type: "text",
|
||||
Text: text,
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
func cursorTranscriptToolCallLine(name string, arguments string, reasoning string) cursorTranscriptLine {
|
||||
content := make([]cursorTranscriptContent, 0, 2)
|
||||
if thinking := strings.TrimSpace(reasoning); thinking != "" {
|
||||
content = append(content, cursorTranscriptContent{Type: "text", Text: thinking})
|
||||
}
|
||||
content = append(content, cursorTranscriptContent{
|
||||
Type: "tool_use",
|
||||
Name: strings.TrimSpace(name),
|
||||
Input: decodeTranscriptToolInput(arguments),
|
||||
})
|
||||
return cursorTranscriptLine{Role: "assistant", Message: &cursorTranscriptMessage{Content: content}}
|
||||
}
|
||||
|
||||
func decodeTranscriptToolInput(arguments string) any {
|
||||
trimmed := strings.TrimSpace(arguments)
|
||||
if trimmed == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func cursorTranscriptTurnStatus(entry HistoryEntry) (cursorTranscriptLine, bool) {
|
||||
if strings.TrimSpace(entry.Kind) != "metadata" || entry.TurnSeq <= 0 {
|
||||
return cursorTranscriptLine{}, false
|
||||
}
|
||||
var payload metadataPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return cursorTranscriptLine{}, false
|
||||
}
|
||||
switch strings.TrimSpace(payload.Type) {
|
||||
case "turn_completed":
|
||||
return cursorTranscriptLine{Type: "turn_ended", Status: "success"}, true
|
||||
case "provider_error", "failed":
|
||||
return cursorTranscriptLine{
|
||||
Type: "turn_ended",
|
||||
Status: "error",
|
||||
Error: firstNonEmpty(readStringValue(payload.Value["error"]), readStringValue(payload.Value["message"]), "Request failed"),
|
||||
}, true
|
||||
case "control":
|
||||
if strings.TrimSpace(readStringValue(payload.Value["status"])) != "canceled" {
|
||||
return cursorTranscriptLine{}, false
|
||||
}
|
||||
return cursorTranscriptLine{
|
||||
Type: "turn_ended",
|
||||
Status: "aborted",
|
||||
Error: firstNonEmpty(readStringValue(payload.Value["reason"]), readStringValue(payload.Value["message"]), "User aborted request"),
|
||||
}, true
|
||||
default:
|
||||
return cursorTranscriptLine{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func cleanCursorTranscriptUserText(text string) string {
|
||||
return cleanTranscriptContextTags(text)
|
||||
}
|
||||
|
||||
func cleanCursorTranscriptAssistantText(text string) string {
|
||||
cleaned := transcriptThinkingPattern.ReplaceAllString(text, "")
|
||||
return collapseTranscriptBlankLines(cleaned)
|
||||
}
|
||||
|
||||
func cleanTranscriptContextTags(text string) string {
|
||||
cleaned := text
|
||||
for _, pattern := range transcriptContextTagPatterns {
|
||||
cleaned = pattern.ReplaceAllString(cleaned, "")
|
||||
}
|
||||
return collapseTranscriptBlankLines(cleaned)
|
||||
}
|
||||
|
||||
func compileTranscriptContextTagPatterns(tags []string) []*regexp.Regexp {
|
||||
patterns := make([]*regexp.Regexp, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
patterns = append(patterns, regexp.MustCompile(`(?is)<`+regexp.QuoteMeta(tag)+`(?:\s[^>]*)?>.*?</`+regexp.QuoteMeta(tag)+`>`))
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
func collapseTranscriptBlankLines(text string) string {
|
||||
return strings.TrimSpace(transcriptBlankLinesPattern.ReplaceAllString(text, "\n\n"))
|
||||
}
|
||||
|
||||
func joinTranscriptText(text string, thinking string) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if strings.TrimSpace(text) != "" {
|
||||
parts = append(parts, strings.TrimSpace(text))
|
||||
}
|
||||
if strings.TrimSpace(thinking) != "" {
|
||||
parts = append(parts, strings.TrimSpace(thinking))
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
func normalizeAgentTranscriptsFolder(path string) string {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" || !filepath.IsAbs(trimmed) {
|
||||
return ""
|
||||
}
|
||||
cleaned := filepath.Clean(trimmed)
|
||||
if filepath.Base(cleaned) != "agent-transcripts" {
|
||||
return ""
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func agentTranscriptsFolderFromEntries(entries []HistoryEntry) string {
|
||||
for _, entry := range entries {
|
||||
if strings.TrimSpace(entry.Kind) != "request_context" {
|
||||
continue
|
||||
}
|
||||
requestContext := &agentv1.RequestContext{}
|
||||
if err := protojson.Unmarshal(entry.Payload, requestContext); err != nil {
|
||||
continue
|
||||
}
|
||||
if folder := normalizeAgentTranscriptsFolder(requestContext.GetEnv().GetAgentTranscriptsFolder()); folder != "" {
|
||||
return folder
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cursorTranscriptPath(transcriptsFolder string, conversationID string) (string, error) {
|
||||
folder := normalizeAgentTranscriptsFolder(transcriptsFolder)
|
||||
if folder == "" {
|
||||
return "", fmt.Errorf("invalid agent transcripts folder")
|
||||
}
|
||||
id, err := validateConversationID(conversationID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(folder, id, id+".jsonl"), nil
|
||||
}
|
||||
|
||||
func preserveCursorAppendedTurnEnded(path string, projected []byte) []byte {
|
||||
existing, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return projected
|
||||
}
|
||||
lastLine := lastNonEmptyJSONLLine(existing)
|
||||
if len(lastLine) == 0 {
|
||||
return projected
|
||||
}
|
||||
var terminal cursorTranscriptLine
|
||||
if json.Unmarshal(lastLine, &terminal) != nil || terminal.Type != "turn_ended" {
|
||||
return projected
|
||||
}
|
||||
if countTranscriptTurnEnded(existing) <= countTranscriptTurnEnded(projected) {
|
||||
return projected
|
||||
}
|
||||
result := append([]byte(nil), projected...)
|
||||
if len(result) > 0 && result[len(result)-1] != '\n' {
|
||||
result = append(result, '\n')
|
||||
}
|
||||
result = append(result, lastLine...)
|
||||
return append(result, '\n')
|
||||
}
|
||||
|
||||
func lastNonEmptyJSONLLine(data []byte) []byte {
|
||||
lines := bytes.Split(data, []byte{'\n'})
|
||||
for index := len(lines) - 1; index >= 0; index-- {
|
||||
if line := bytes.TrimSpace(lines[index]); len(line) > 0 {
|
||||
return append([]byte(nil), line...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func countTranscriptTurnEnded(data []byte) int {
|
||||
count := 0
|
||||
for _, line := range bytes.Split(data, []byte{'\n'}) {
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if len(trimmed) == 0 {
|
||||
continue
|
||||
}
|
||||
var item cursorTranscriptLine
|
||||
if json.Unmarshal(trimmed, &item) == nil && item.Type == "turn_ended" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func writeCursorTranscriptAtomic(path string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create transcript directory: %w", err)
|
||||
}
|
||||
file, tempPath, err := openUniqueArtifactTempFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open transcript temp file: %w", err)
|
||||
}
|
||||
renamed := false
|
||||
defer func() {
|
||||
if !renamed {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
if _, err := file.Write(data); err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("write transcript temp file: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return fmt.Errorf("close transcript temp file: %w", err)
|
||||
}
|
||||
if err := renameArtifactTempFile(tempPath, path); err != nil {
|
||||
return fmt.Errorf("rename transcript temp file: %w", err)
|
||||
}
|
||||
renamed = true
|
||||
return syncDirectory(filepath.Dir(path))
|
||||
}
|
||||
Reference in New Issue
Block a user