diff --git a/mempalace/normalize.py b/mempalace/normalize.py index ac114692d3..4990bff443 100644 --- a/mempalace/normalize.py +++ b/mempalace/normalize.py @@ -8,6 +8,7 @@ - ChatGPT conversations.json - Claude Code JSONL - OpenAI Codex CLI JSONL + - Cursor agent transcript JSONL - Slack JSON export - Plain text (pass through for paragraph chunking) @@ -60,6 +61,10 @@ def _try_normalize_json(content: str) -> Optional[str]: if normalized: return normalized + normalized = _try_cursor_jsonl(content) + if normalized: + return normalized + try: data = json.loads(content) except json.JSONDecodeError: @@ -147,6 +152,46 @@ def _try_codex_jsonl(content: str) -> Optional[str]: return None +def _try_cursor_jsonl(content: str) -> Optional[str]: + """Cursor agent transcript JSONL (~/.cursor/projects//agent-transcripts/.jsonl). + + Each line is {"role": "user"|"assistant", "message": {"content": [...]}} where + content blocks follow the standard {"type": "text", "text": "..."} shape. + Discriminated from Claude Code JSONL (which uses top-level "type" instead of "role") + and from Codex JSONL (which uses "event_msg" / "session_meta" wrapper). + """ + lines = [line.strip() for line in content.strip().split("\n") if line.strip()] + messages = [] + has_cursor_structure = False + for line in lines: + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(entry, dict): + continue + + role = entry.get("role", "") + if role not in ("user", "assistant"): + continue + + message = entry.get("message") + if not isinstance(message, dict): + continue + raw_content = message.get("content") + + text = _extract_content(raw_content) + if not text: + continue + if isinstance(raw_content, list): + has_cursor_structure = True + messages.append((role, text)) + + if len(messages) >= 2 and has_cursor_structure: + return _messages_to_transcript(messages) + return None + + def _try_claude_ai_json(data) -> Optional[str]: """Claude.ai JSON export: flat messages list or privacy export with chat_messages.""" if isinstance(data, dict): diff --git a/tests/test_normalize.py b/tests/test_normalize.py index c304c9d97e..6186b51f78 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -1,7 +1,7 @@ import os import json import tempfile -from mempalace.normalize import normalize +from mempalace.normalize import normalize, _try_cursor_jsonl def test_plain_text(): @@ -29,3 +29,143 @@ def test_empty(): result = normalize(f.name) assert result.strip() == "" os.unlink(f.name) + + +# --- Cursor agent transcript JSONL --- + + +def _write_jsonl(lines, suffix=".jsonl"): + """Helper: write a list of dicts as JSONL to a temp file.""" + f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) + for entry in lines: + f.write(json.dumps(entry) + "\n") + f.close() + return f.name + + +def test_cursor_jsonl_basic(): + """Minimal two-turn Cursor agent transcript round-trips correctly.""" + path = _write_jsonl([ + {"role": "user", "message": {"content": [{"type": "text", "text": "How do I reverse a list?"}]}}, + {"role": "assistant", "message": {"content": [{"type": "text", "text": "Use list.reverse() or slicing [::-1]."}]}}, + ]) + result = normalize(path) + assert "> How do I reverse a list?" in result + assert "Use list.reverse() or slicing [::-1]." in result + os.unlink(path) + + +def test_cursor_jsonl_multi_turn(): + """Multi-turn conversation preserves all turns in order.""" + path = _write_jsonl([ + {"role": "user", "message": {"content": [{"type": "text", "text": "What is Python?"}]}}, + {"role": "assistant", "message": {"content": [{"type": "text", "text": "A programming language."}]}}, + {"role": "user", "message": {"content": [{"type": "text", "text": "Is it compiled?"}]}}, + {"role": "assistant", "message": {"content": [{"type": "text", "text": "It is interpreted."}]}}, + ]) + result = normalize(path) + lines = result.split("\n") + user_lines = [line for line in lines if line.startswith(">")] + assert len(user_lines) == 2 + assert "> What is Python?" in result + assert "> Is it compiled?" in result + assert "A programming language." in result + assert "It is interpreted." in result + os.unlink(path) + + +def test_cursor_jsonl_skips_empty_content(): + """Entries with empty text are silently skipped.""" + path = _write_jsonl([ + {"role": "user", "message": {"content": [{"type": "text", "text": "Hi"}]}}, + {"role": "assistant", "message": {"content": [{"type": "text", "text": ""}]}}, + {"role": "assistant", "message": {"content": [{"type": "text", "text": "Hello!"}]}}, + ]) + result = normalize(path) + assert "> Hi" in result + assert "Hello!" in result + os.unlink(path) + + +def test_cursor_jsonl_rejects_single_message(): + """A file with fewer than 2 messages is not recognized as Cursor JSONL.""" + path = _write_jsonl([ + {"role": "user", "message": {"content": [{"type": "text", "text": "lonely message"}]}}, + ]) + result = normalize(path) + assert not result.strip().startswith(">") + os.unlink(path) + + +def test_cursor_jsonl_skips_entries_without_message_key(): + """Entries with role but no message dict are skipped.""" + path = _write_jsonl([ + {"role": "user"}, + {"role": "user", "message": {"content": [{"type": "text", "text": "Q"}]}}, + {"role": "assistant", "message": {"content": [{"type": "text", "text": "A"}]}}, + ]) + result = normalize(path) + assert "> Q" in result + assert "A" in result + os.unlink(path) + + +def test_cursor_jsonl_rejects_claude_code_jsonl(): + """Claude Code JSONL (top-level 'type' key) must not match the Cursor parser.""" + content = "\n".join([ + json.dumps({"type": "human", "message": {"content": "Hi from Claude Code"}}), + json.dumps({"type": "assistant", "message": {"content": "Hello back"}}), + ]) + assert _try_cursor_jsonl(content) is None + + +def test_cursor_jsonl_ignores_non_cursor_jsonl(): + """Claude Code JSONL is handled by its own parser (integration check).""" + path = _write_jsonl([ + {"type": "human", "message": {"content": "Hi from Claude Code"}}, + {"type": "assistant", "message": {"content": "Hello back"}}, + ]) + result = normalize(path) + assert result.strip() # some parser picks it up + os.unlink(path) + + +def test_cursor_jsonl_multiple_content_blocks(): + """Content arrays with several text blocks are joined.""" + path = _write_jsonl([ + {"role": "user", "message": {"content": [ + {"type": "text", "text": "First part."}, + {"type": "text", "text": "Second part."}, + ]}}, + {"role": "assistant", "message": {"content": [{"type": "text", "text": "Got it."}]}}, + ]) + result = normalize(path) + assert "First part." in result + assert "Second part." in result + assert "Got it." in result + os.unlink(path) + + +def test_cursor_jsonl_tolerates_malformed_lines(): + """Malformed JSON lines and unexpected entry shapes are skipped gracefully.""" + f = tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) + f.write("NOT VALID JSON\n") + f.write(json.dumps({"role": "user", "message": {"content": [{"type": "text", "text": "Q1"}]}}) + "\n") + f.write(json.dumps({"unexpected": "shape"}) + "\n") + f.write(json.dumps({"role": "assistant", "message": {"content": [{"type": "text", "text": "A1"}]}}) + "\n") + f.close() + result = normalize(f.name) + assert "> Q1" in result + assert "A1" in result + os.unlink(f.name) + + +def test_cursor_jsonl_requires_list_content(): + """Entries where message.content is a plain string (not list) don't set the Cursor flag.""" + path = _write_jsonl([ + {"role": "user", "message": {"content": "plain string"}}, + {"role": "assistant", "message": {"content": "also plain"}}, + ]) + result = normalize(path) + assert not result.strip().startswith(">") + os.unlink(path)