Skip to content
Closed
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
45 changes: 45 additions & 0 deletions mempalace/normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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/<proj>/agent-transcripts/<uuid>.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):
Expand Down
142 changes: 141 additions & 1 deletion tests/test_normalize.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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)