feat: import chatgpt conversation data (#9) · basicmachines-co/basic-memory@56f47d6 · GitHub
Skip to content

Commit 56f47d6

Browse files
phernandezphernandez
andauthored
feat: import chatgpt conversation data (#9)
Co-authored-by: phernandez <phernandez@basicmachines.co>
1 parent a15c346 commit 56f47d6

7 files changed

Lines changed: 551 additions & 5 deletions

File tree

README.md

Lines changed: 16 additions & 1 deletion
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
"""Import command for ChatGPT conversations."""
2+
3+
import asyncio
4+
import json
5+
from datetime import datetime
6+
from pathlib import Path
7+
from typing import Dict, Any, List, Annotated, Set, Optional
8+
9+
import typer
10+
from loguru import logger
11+
from rich.console import Console
12+
from rich.panel import Panel
13+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
14+
15+
from basic_memory.cli.app import import_app
16+
from basic_memory.config import config
17+
from basic_memory.markdown import EntityParser, MarkdownProcessor
18+
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter
19+
20+
console = Console()
21+
22+
23+
def clean_filename(text: str) -> str:
24+
"""Convert text to safe filename."""
25+
clean = "".join(c if c.isalnum() else "-" for c in text.lower()).strip("-")
26+
return clean
27+
28+
29+
def format_timestamp(ts: float) -> str:
30+
"""Format Unix timestamp for display."""
31+
dt = datetime.fromtimestamp(ts)
32+
return dt.strftime("%Y-%m-%d %H:%M:%S")
33+
34+
35+
def get_message_content(message: Dict[str, Any]) -> str:
36+
"""Extract clean message content."""
37+
if not message or "content" not in message:
38+
return "" # pragma: no cover
39+
40+
content = message["content"]
41+
if content.get("content_type") == "text":
42+
return "\n".join(content.get("parts", []))
43+
elif content.get("content_type") == "code":
44+
return f"```{content.get('language', '')}\n{content.get('text', '')}\n```"
45+
return "" # pragma: no cover
46+
47+
48+
def traverse_messages(
49+
mapping: Dict[str, Any], root_id: Optional[str], seen: Set[str]
50+
) -> List[Dict[str, Any]]:
51+
"""Traverse message tree and return messages in order."""
52+
messages = []
53+
node = mapping.get(root_id) if root_id else None
54+
55+
while node:
56+
if node["id"] not in seen and node.get("message"):
57+
seen.add(node["id"])
58+
messages.append(node["message"])
59+
60+
# Follow children
61+
children = node.get("children", [])
62+
for child_id in children:
63+
child_msgs = traverse_messages(mapping, child_id, seen)
64+
messages.extend(child_msgs)
65+
66+
break # Don't follow siblings
67+
68+
return messages
69+
70+
71+
def format_chat_markdown(
72+
title: str, mapping: Dict[str, Any], root_id: Optional[str], created_at: float, modified_at: float
73+
) -> str:
74+
"""Format chat as clean markdown."""
75+
76+
# Start with title
77+
lines = [f"# {title}\n"]
78+
79+
# Traverse message tree
80+
seen_msgs = set()
81+
messages = traverse_messages(mapping, root_id, seen_msgs)
82+
83+
# Format each message
84+
for msg in messages:
85+
# Skip hidden messages
86+
if msg.get("metadata", {}).get("is_visually_hidden_from_conversation"):
87+
continue
88+
89+
# Get author and timestamp
90+
author = msg["author"]["role"].title()
91+
ts = format_timestamp(msg["create_time"]) if msg.get("create_time") else ""
92+
93+
# Add message header
94+
lines.append(f"### {author} ({ts})")
95+
96+
# Add message content
97+
content = get_message_content(msg)
98+
if content:
99+
lines.append(content)
100+
101+
# Add spacing
102+
lines.append("")
103+
104+
return "\n".join(lines)
105+
106+
107+
def format_chat_content(folder: str, conversation: Dict[str, Any]) -> EntityMarkdown:
108+
"""Convert chat conversation to Basic Memory entity."""
109+
110+
# Extract timestamps
111+
created_at = conversation["create_time"]
112+
modified_at = conversation["update_time"]
113+
114+
root_id = None
115+
# Find root message
116+
for node_id, node in conversation["mapping"].items():
117+
if node.get("parent") is None:
118+
root_id = node_id
119+
break
120+
121+
# Generate permalink
122+
date_prefix = datetime.fromtimestamp(created_at).strftime("%Y%m%d")
123+
clean_title = clean_filename(conversation["title"])
124+
125+
# Format content
126+
content = format_chat_markdown(
127+
title=conversation["title"],
128+
mapping=conversation["mapping"],
129+
root_id=root_id,
130+
created_at=created_at,
131+
modified_at=modified_at,
132+
)
133+
134+
# Create entity
135+
entity = EntityMarkdown(
136+
frontmatter=EntityFrontmatter(
137+
metadata={
138+
"type": "conversation",
139+
"title": conversation["title"],
140+
"created": format_timestamp(created_at),
141+
"modified": format_timestamp(modified_at),
142+
"permalink": f"{folder}/{date_prefix}-{clean_title}",
143+
}
144+
),
145+
content=content,
146+
)
147+
148+
return entity
149+
150+
151+
async def process_chatgpt_json(
152+
json_path: Path, folder: str, markdown_processor: MarkdownProcessor
153+
) -> Dict[str, int]:
154+
"""Import conversations from ChatGPT JSON format."""
155+
156+
with Progress(
157+
SpinnerColumn(),
158+
TextColumn("[progress.description]{task.description}"),
159+
BarColumn(),
160+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
161+
console=console,
162+
) as progress:
163+
read_task = progress.add_task("Reading chat data...", total=None)
164+
165+
# Read conversations
166+
conversations = json.loads(json_path.read_text())
167+
progress.update(read_task, total=len(conversations))
168+
169+
# Process each conversation
170+
messages_imported = 0
171+
chats_imported = 0
172+
173+
for chat in conversations:
174+
# Convert to entity
175+
entity = format_chat_content(folder, chat)
176+
177+
# Write file
178+
file_path = config.home / f"{entity.frontmatter.metadata['permalink']}.md"
179+
# logger.info(f"Writing file: {file_path.absolute()}")
180+
await markdown_processor.write_file(file_path, entity)
181+
182+
# Count messages
183+
msg_count = sum(
184+
1
185+
for node in chat["mapping"].values()
186+
if node.get("message")
187+
and not node.get("message", {})
188+
.get("metadata", {})
189+
.get("is_visually_hidden_from_conversation")
190+
)
191+
192+
chats_imported += 1
193+
messages_imported += msg_count
194+
progress.update(read_task, advance=1)
195+
196+
return {"conversations": chats_imported, "messages": messages_imported}
197+
198+
199+
async def get_markdown_processor() -> MarkdownProcessor:
200+
"""Get MarkdownProcessor instance."""
201+
entity_parser = EntityParser(config.home)
202+
return MarkdownProcessor(entity_parser)
203+
204+
205+
@import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")
206+
def import_chatgpt(
207+
conversations_json: Annotated[
208+
Path, typer.Option(..., help="Path to ChatGPT conversations.json file")
209+
] = Path("conversations.json"),
210+
folder: Annotated[
211+
str, typer.Option(help="The folder to place the files in.")
212+
] = "conversations",
213+
):
214+
"""Import chat conversations from ChatGPT JSON format.
215+
216+
This command will:
217+
1. Read the complex tree structure of messages
218+
2. Convert them to linear markdown conversations
219+
3. Save as clean, readable markdown files
220+
221+
After importing, run 'basic-memory sync' to index the new files.
222+
"""
223+
224+
try:
225+
if conversations_json:
226+
if not conversations_json.exists():
227+
typer.echo(f"Error: File not found: {conversations_json}", err=True)
228+
raise typer.Exit(1)
229+
230+
# Get markdown processor
231+
markdown_processor = asyncio.run(get_markdown_processor())
232+
233+
# Process the file
234+
base_path = config.home / folder
235+
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
236+
results = asyncio.run(
237+
process_chatgpt_json(conversations_json, folder, markdown_processor)
238+
)
239+
240+
# Show results
241+
console.print(
242+
Panel(
243+
f"[green]Import complete![/green]\n\n"
244+
f"Imported {results['conversations']} conversations\n"
245+
f"Containing {results['messages']} messages",
246+
expand=False,
247+
)
248+
)
249+
250+
console.print("\nRun 'basic-memory sync' to index the new files.")
251+
252+
except Exception as e:
253+
logger.error("Import failed")
254+
typer.echo(f"Error during import: {e}", err=True)
255+
raise typer.Exit(1)

src/basic_memory/cli/commands/import_claude_conversations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,6 @@ def import_claude(
206206
console.print("\nRun 'basic-memory sync' to index the new files.")
207207

208208
except Exception as e:
209-
logger.exception("Import failed")
209+
logger.error("Import failed")
210210
typer.echo(f"Error during import: {e}", err=True)
211211
raise typer.Exit(1)

src/basic_memory/cli/commands/import_claude_projects.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,6 @@ def import_projects(
190190
console.print("\nRun 'basic-memory sync' to index the new files.")
191191

192192
except Exception as e:
193-
logger.exception("Import failed")
193+
logger.error("Import failed")
194194
typer.echo(f"Error during import: {e}", err=True)
195195
raise typer.Exit(1)

src/basic_memory/cli/commands/import_memory_json.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from basic_memory.markdown import EntityParser, MarkdownProcessor
1717
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter, Observation, Relation
1818

19-
2019
console = Console()
2120

2221

@@ -140,6 +139,6 @@ def memory_json(
140139
console.print("\nRun 'basic-memory sync' to index the new files.")
141140

142141
except Exception as e:
143-
logger.exception("Import failed")
142+
logger.error("Import failed")
144143
typer.echo(f"Error during import: {e}", err=True)
145144
raise typer.Exit(1)

src/basic_memory/cli/main.py

Lines changed: 1 addition & 0 deletions

0 commit comments

Comments
 (0)