
Navigating Chatgpt History
- 53 installs
- 134 repo stars
- Updated July 3, 2026
- letta-ai/skills
Helps with ai & agent building tasks.
About
navigating-chatgpt-history is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- navigating-chatgpt-history
- AI & Agent Building
- AI-coding skill
Navigating Chatgpt History by the numbers
- 53 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #7,039 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/letta-ai/skills --skill navigating-chatgpt-historyAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 53 |
|---|---|
| repo stars | ★ 134 |
| Last updated | July 3, 2026 |
| Repository | letta-ai/skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Navigating Chat History Without Digesting Everything
Use this skill when the goal is referenceable history, not immediate full ingestion.
Good fits
- search my exported ChatGPT history for a topic
- figure out what the old assistant knew about me
- render the conversation where we discussed X
- keep this export around as external memory and only mine it when needed
- seed MemFS from
memories.jsonorprojects.json
Default posture
Treat the export as an archive you can navigate later.
1. read the MemFS archive index first if it exists: reference/chatgpt/index.md 2. inspect the export with scripts/inspect-export.py 3. search or list before rendering broad ranges 4. preserve findings to reference/chatgpt/ first 5. promote to system/human.md only when the fact is durable, current, and worth carrying every turn
Do not re-digest the entire archive unless the user explicitly wants that.
Archive layout in MemFS
Keep the external-memory archive under reference/chatgpt/.
Recommended files:
reference/chatgpt/index.md— source exports, schema notes, known paths, retrieval strategyreference/chatgpt/export-YYYY-MM-DD.md— inventory and sidecar summary for one exportreference/chatgpt/chatgpt-memory-summary-YYYY-MM-DD.md— content frommemories.jsonreference/chatgpt/projects-YYYY-MM-DD.md— projects sidecar summary when usefulreference/chatgpt/transcripts/NNN-slug.md— curated high-signal conversation summariesreference/chatgpt/notes/— topic-specific notes mined later
Prefer progressive memory. Keep active memory small.
Scripts
scripts/inspect-export.py
Use first. It inventories the export and reads sidecars such as memories.json, projects.json, and users.json.
python3 scripts/inspect-export.py <export-path>
python3 scripts/inspect-export.py <export-path> --output /tmp/export-summary.mdscripts/list-conversations.py
Use to browse by title, recency, or message count.
python3 scripts/list-conversations.py <export-path> --limit 25
python3 scripts/list-conversations.py <export-path> --title-contains Letta --sort messagesscripts/search-conversations.py
Use when titles are not enough.
python3 scripts/search-conversations.py <export-path> --query "Recovery Bench"
python3 scripts/search-conversations.py <export-path> --query TFCC --role user --limit 20scripts/render-conversation.py
Use for one conversation once you know the index.
python3 scripts/render-conversation.py <export-path> --index 212
python3 scripts/render-conversation.py <export-path> --index 212 --compact-nontext --output /tmp/chat-212.mdscripts/render-range.py
Use only for focused batches after search narrows the field.
python3 scripts/render-range.py <export-path> --start-index 210 --end-index 220 --output-dir /tmp/chat-rangeWorkflow
1. Anchor yourself in existing MemFS notes
Before touching the raw export, check whether the archive already has:
- an export summary
- a prior project summary
- curated transcripts
- a note on the same topic
If yes, use that first.
2. Inspect before mining
Run inspect-export.py to answer:
- what export shape is this?
- how many conversations are there?
- does
memories.jsonalready contain a synthesized memory block? - does
projects.jsonhold useful background?
For large archives, this often answers the question before raw conversation mining is needed.
3. Narrow, then render
Prefer this sequence:
1. list-conversations.py for browse 2. search-conversations.py for content lookup 3. render-conversation.py for deep read 4. render-range.py only when several adjacent conversations matter
Do not render dozens of chats just because you can.
4. Write findings to progressive memory first
When a conversation matters, summarize it into:
reference/chatgpt/transcripts/for high-signal conversation summariesreference/chatgpt/notes/for topic notes
Only then decide whether anything belongs in system/human.md.
5. Promotion rule
Promote to active memory only when the fact is:
- explicit or strongly evidenced
- current rather than historical-only
- likely useful across many future conversations
- low-risk to keep in context every turn
Everything else can stay in reference/chatgpt/.
Reference files
Read references/repository-layout.md when creating or extending the MemFS archive layout.
Notes on export formats
This skill is designed for newer exports that contain conversations.json with chat_messages, while still handling older shard-based exports with conversations-*.json and mapping graphs.
When in doubt, start with inspect-export.py instead of assuming the schema.
Repository layout for chat history reference memory
Use reference/chatgpt/ as the root of the long-lived archive.
Recommended shape
index.md— master index of known exports, source paths, schema notes, and navigation strategyexport-YYYY-MM-DD.md— one file per export inventorychatgpt-memory-summary-YYYY-MM-DD.md—memories.jsoncontent when presentprojects-YYYY-MM-DD.md—projects.jsonsummary when usefultranscripts/NNN-slug.md— curated conversation summariesnotes/topic-name.md— topic-level notes mined from one or more conversations
Writing rule
Default to progressive memory.
- Put raw or semi-raw findings in
reference/chatgpt/ - Link outward from
system/human.mdonly when a reference file becomes important enough to matter regularly - Promote into
system/human.mdonly when a fact is current, durable, and broadly useful
Retrieval rule
When a question depends on historical nuance:
1. read index.md 2. read the relevant export summary 3. search the raw export if the archive notes are insufficient 4. preserve any new useful findings back into reference/chatgpt/
#!/usr/bin/env python3
from __future__ import annotations
import datetime as dt
import json
import re
import zipfile
from pathlib import Path
from typing import Any
def normalize_iso(value: object) -> str:
if value in (None, ""):
return "-"
if isinstance(value, (int, float)):
try:
return dt.datetime.fromtimestamp(float(value), dt.timezone.utc).isoformat()
except Exception:
return str(value)
text = str(value).strip()
if not text:
return "-"
try:
if text.endswith("Z"):
return dt.datetime.fromisoformat(text.replace("Z", "+00:00")).isoformat()
return dt.datetime.fromisoformat(text).isoformat()
except Exception:
return text
def sort_key_for_time(value: object) -> tuple[int, str]:
text = normalize_iso(value)
return (1 if text != "-" else 0, text)
def load_json(export_path: Path, name: str) -> Any:
export_path = export_path.expanduser()
if export_path.is_dir():
path = export_path / name
if not path.exists():
return None
return json.loads(path.read_text(encoding="utf-8"))
if export_path.is_file() and export_path.suffix.lower() == ".zip":
with zipfile.ZipFile(export_path) as zf:
if name not in zf.namelist():
return None
return json.loads(zf.read(name))
raise SystemExit(f"Unsupported export path: {export_path}")
def load_conversations(export_path: Path) -> tuple[list[dict], str]:
export_path = export_path.expanduser()
if export_path.is_dir():
data = load_json(export_path, "conversations.json")
if not isinstance(data, list):
raise SystemExit(f"Expected conversations.json list in {export_path}")
return data, detect_schema(data)
if export_path.is_file() and export_path.suffix.lower() == ".zip":
with zipfile.ZipFile(export_path) as zf:
names = set(zf.namelist())
if "conversations.json" in names:
data = json.loads(zf.read("conversations.json"))
if not isinstance(data, list):
raise SystemExit(f"Expected conversations.json list in {export_path}")
return data, detect_schema(data)
shard_names = sorted(name for name in names if name.startswith("conversations-") and name.endswith(".json"))
if shard_names:
rows: list[dict] = []
for shard_name in shard_names:
shard = json.loads(zf.read(shard_name))
if isinstance(shard, list):
rows.extend(shard)
return rows, detect_schema(rows)
raise SystemExit(f"Could not find conversation data in {export_path}")
def detect_schema(conversations: list[dict]) -> str:
if not conversations:
return "unknown"
first = conversations[0]
if isinstance(first, dict) and "chat_messages" in first:
return "modern"
if isinstance(first, dict) and "mapping" in first:
return "legacy"
return "unknown"
def conversation_title(conversation: dict, schema: str) -> str:
if schema == "modern":
return conversation.get("name") or "(untitled)"
return conversation.get("title") or "(untitled)"
def conversation_created(conversation: dict, schema: str) -> object:
return conversation.get("created_at") if schema == "modern" else conversation.get("create_time")
def conversation_updated(conversation: dict, schema: str) -> object:
return conversation.get("updated_at") if schema == "modern" else conversation.get("update_time")
def conversation_uuid(conversation: dict, schema: str) -> str:
return str(conversation.get("uuid") or conversation.get("id") or "")
def modern_message_types(message: dict) -> list[str]:
types: list[str] = []
content = message.get("content") or []
if isinstance(content, list):
for item in content:
if isinstance(item, dict):
item_type = item.get("type") or "unknown"
if item_type not in types:
types.append(item_type)
return types
def modern_message_text(message: dict) -> str:
text = message.get("text")
if isinstance(text, str) and text.strip():
return text.strip()
chunks: list[str] = []
content = message.get("content") or []
if isinstance(content, list):
for item in content:
if not isinstance(item, dict):
continue
item_type = item.get("type")
if item_type == "text" and isinstance(item.get("text"), str) and item.get("text").strip():
chunks.append(item["text"].strip())
elif item_type == "voice_note":
transcript = item.get("transcript") or item.get("text")
if isinstance(transcript, str) and transcript.strip():
chunks.append("Voice note:\n" + transcript.strip())
elif isinstance(item.get("message"), str) and item.get("message").strip():
chunks.append(item["message"].strip())
elif isinstance(item.get("display_content"), str) and item.get("display_content").strip():
chunks.append(item["display_content"].strip())
return "\n\n".join(chunks).strip()
def legacy_content_to_text(content: object) -> str:
if not isinstance(content, dict):
return ""
content_type = content.get("content_type")
if content_type == "user_editable_context":
sections: list[str] = []
user_profile = content.get("user_profile")
user_instructions = content.get("user_instructions")
if isinstance(user_profile, str) and user_profile.strip():
sections.append("User profile:\n\n" + user_profile.strip())
if isinstance(user_instructions, str) and user_instructions.strip():
sections.append("User instructions:\n\n" + user_instructions.strip())
return "\n\n".join(sections).strip()
parts = content.get("parts")
if isinstance(parts, list):
chunks: list[str] = []
for part in parts:
if isinstance(part, str):
if part.strip():
chunks.append(part.strip())
elif isinstance(part, dict):
chunks.append(json.dumps(part, ensure_ascii=False))
return "\n\n".join(chunk for chunk in chunks if chunk.strip()).strip()
if isinstance(content.get("text"), str) and content.get("text").strip():
return content["text"].strip()
return ""
def legacy_message_types(message: dict) -> list[str]:
content = message.get("content") or {}
if isinstance(content, dict) and content.get("content_type"):
return [str(content.get("content_type"))]
return []
def normalize_message(message: dict, schema: str, *, node_id: str | None = None) -> dict:
if schema == "modern":
sender = str(message.get("sender") or "unknown")
return {
"sender": canonical_role(sender),
"raw_sender": sender,
"created_at": message.get("created_at"),
"updated_at": message.get("updated_at"),
"text": modern_message_text(message),
"item_types": modern_message_types(message),
"content": message.get("content") or [],
"uuid": message.get("uuid"),
}
author = (message.get("author") or {}) if isinstance(message, dict) else {}
sender = str(author.get("role") or author.get("name") or "unknown")
content = message.get("content") or {}
metadata = message.get("metadata") or {}
return {
"sender": canonical_role(sender),
"raw_sender": sender,
"created_at": message.get("create_time"),
"updated_at": message.get("create_time"),
"text": legacy_content_to_text(content),
"item_types": legacy_message_types(message),
"content": content,
"metadata": metadata,
"node_id": node_id,
}
def conversation_messages(conversation: dict, schema: str) -> list[dict]:
rows: list[dict] = []
if schema == "modern":
for message in conversation.get("chat_messages") or []:
if isinstance(message, dict):
rows.append(normalize_message(message, schema))
rows.sort(key=lambda row: (sort_key_for_time(row.get("created_at")), str(row.get("uuid") or "")))
return rows
for node_id, node in (conversation.get("mapping") or {}).items():
message = (node or {}).get("message") or {}
if not message:
continue
rows.append(normalize_message(message, schema, node_id=node_id))
rows.sort(key=lambda row: (sort_key_for_time(row.get("created_at")), str(row.get("node_id") or "")))
return rows
def canonical_role(role: str) -> str:
lowered = (role or "unknown").lower()
if lowered in {"human", "user"}:
return "user"
return lowered
def role_matches(message_role: str, allowed_roles: set[str] | None) -> bool:
if not allowed_roles:
return True
canonical = canonical_role(message_role)
expanded = {canonical}
if canonical == "user":
expanded.add("human")
return not allowed_roles.isdisjoint(expanded)
def message_has_thinking(message: dict) -> bool:
return "thinking" in set(message.get("item_types") or [])
def message_has_nontext(message: dict) -> bool:
for item_type in message.get("item_types") or []:
if item_type != "text":
return True
return False
def compact_json(data: object, *, limit: int = 1200) -> str:
text = json.dumps(data, ensure_ascii=False, indent=2)
if len(text) <= limit:
return text
return text[:limit].rstrip() + "\n..."
def snippet(text: str, *, length: int = 180) -> str:
cleaned = re.sub(r"\s+", " ", text).strip()
if len(cleaned) <= length:
return cleaned
return cleaned[: length - 1].rstrip() + "…"
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from common import (
conversation_created,
conversation_title,
conversation_updated,
detect_schema,
load_conversations,
load_json,
normalize_iso,
)
def render_markdown(export_path: Path, conversations: list[dict], schema: str) -> str:
created_values = [normalize_iso(conversation_created(c, schema)) for c in conversations if normalize_iso(conversation_created(c, schema)) != "-"]
updated_values = [normalize_iso(conversation_updated(c, schema)) for c in conversations if normalize_iso(conversation_updated(c, schema)) != "-"]
memories = load_json(export_path, "memories.json")
projects = load_json(export_path, "projects.json")
users = load_json(export_path, "users.json")
lines = [
"# Chat history export summary",
"",
f"- Source: `{export_path}`",
f"- Schema: `{schema}`",
f"- Conversations: {len(conversations)}",
f"- Created span: {min(created_values) if created_values else '-'} → {max(created_values) if created_values else '-'}",
f"- Updated span: {min(updated_values) if updated_values else '-'} → {max(updated_values) if updated_values else '-'}",
"",
]
if isinstance(users, list) and users:
lines.extend([
"## Users",
"",
])
for user in users:
if not isinstance(user, dict):
continue
full_name = user.get("full_name") or "(unknown)"
uuid = user.get("uuid") or "-"
lines.append(f"- {full_name} — `{uuid}`")
lines.append("")
if isinstance(memories, list) and memories:
lines.extend([
"## memories.json",
"",
])
for index, entry in enumerate(memories, 1):
lines.append(f"### Entry {index}")
lines.append("")
if isinstance(entry, dict):
text = entry.get("conversations_memory") or json.dumps(entry, ensure_ascii=False, indent=2)
else:
text = json.dumps(entry, ensure_ascii=False, indent=2)
lines.append(text.strip())
lines.append("")
if isinstance(projects, list) and projects:
lines.extend([
"## Projects",
"",
])
for project in projects:
if not isinstance(project, dict):
continue
name = project.get("name") or "(unnamed project)"
updated = project.get("updated_at") or "-"
description = (project.get("description") or "").strip().replace("\n", " ")
lines.append(f"- **{name}** — updated {updated}")
if description:
lines.append(f" - {description}")
lines.append("")
lines.extend([
"## Sample recent conversations",
"",
])
recent = sorted(conversations, key=lambda c: normalize_iso(conversation_updated(c, schema)), reverse=True)[:10]
for conversation in recent:
title = conversation_title(conversation, schema)
updated = normalize_iso(conversation_updated(conversation, schema))
lines.append(f"- {title} — {updated}")
return "\n".join(lines).rstrip() + "\n"
def main() -> None:
parser = argparse.ArgumentParser(description="Inspect a chat history export and summarize sidecar files.")
parser.add_argument("export_path", help="Path to export folder or zip file")
parser.add_argument("--json", action="store_true", help="Emit JSON instead of markdown")
parser.add_argument("--output", help="Write output to this file instead of stdout")
args = parser.parse_args()
export_path = Path(args.export_path).expanduser()
conversations, schema = load_conversations(export_path)
if args.json:
payload = {
"source": str(export_path),
"schema": schema,
"conversation_count": len(conversations),
"users": load_json(export_path, "users.json"),
"memories": load_json(export_path, "memories.json"),
"projects": load_json(export_path, "projects.json"),
}
output = json.dumps(payload, indent=2, ensure_ascii=False)
else:
output = render_markdown(export_path, conversations, schema)
if args.output:
output_path = Path(args.output).expanduser()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(output, encoding="utf-8")
print(f"Wrote {output_path}")
else:
print(output, end="")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from common import (
canonical_role,
conversation_created,
conversation_messages,
conversation_title,
conversation_updated,
load_conversations,
normalize_iso,
)
def main() -> None:
parser = argparse.ArgumentParser(description="List conversations in a chat history export.")
parser.add_argument("export_path", help="Path to export folder or zip file")
parser.add_argument("--title-contains", help="Only show conversations whose title contains this string")
parser.add_argument("--start-index", type=int, help="Only show conversations with index >= this value")
parser.add_argument("--end-index", type=int, help="Only show conversations with index <= this value")
parser.add_argument("--limit", type=int, default=50, help="Maximum rows to print (default: 50)")
parser.add_argument("--sort", choices=["updated", "created", "messages", "title"], default="updated")
parser.add_argument("--oldest-first", action="store_true", help="Print oldest rows first")
parser.add_argument("--json", action="store_true", help="Emit matching rows as JSON instead of a table")
args = parser.parse_args()
export_path = Path(args.export_path).expanduser()
conversations, schema = load_conversations(export_path)
rows: list[dict] = []
for index, conversation in enumerate(conversations):
if args.start_index is not None and index < args.start_index:
continue
if args.end_index is not None and index > args.end_index:
continue
title = conversation_title(conversation, schema)
if args.title_contains and args.title_contains.lower() not in title.lower():
continue
messages = conversation_messages(conversation, schema)
sender_counts = {"user": 0, "assistant": 0, "other": 0}
for message in messages:
role = canonical_role(message.get("sender") or "unknown")
if role in sender_counts:
sender_counts[role] += 1
else:
sender_counts["other"] += 1
rows.append(
{
"index": index,
"title": title,
"created": normalize_iso(conversation_created(conversation, schema)),
"updated": normalize_iso(conversation_updated(conversation, schema)),
"messages": len(messages),
"user_messages": sender_counts["user"],
"assistant_messages": sender_counts["assistant"],
"other_messages": sender_counts["other"],
}
)
reverse = not args.oldest_first
if args.sort == "title":
rows.sort(key=lambda row: row["title"].lower(), reverse=reverse)
elif args.sort == "messages":
rows.sort(key=lambda row: row["messages"], reverse=reverse)
else:
rows.sort(key=lambda row: row[args.sort], reverse=reverse)
total = len(rows)
rows = rows[: args.limit]
if args.json:
print(json.dumps(rows, indent=2, ensure_ascii=False))
return
print(f"Found {total} matching conversations in {export_path}\n")
print(f"{'IDX':>5} {'UPDATED':<25} {'MSGS':>4} {'USR':>3} {'AST':>3} {'OTH':>3} TITLE")
print(f"{'-' * 5} {'-' * 25} {'-' * 4} {'-' * 3} {'-' * 3} {'-' * 3} {'-' * 80}")
for row in rows:
print(
f"{row['index']:>5} {row['updated']:<25} {row['messages']:>4} {row['user_messages']:>3} {row['assistant_messages']:>3} {row['other_messages']:>3} {row['title'][:80]}"
)
if total > len(rows):
print(f"\nShowing {len(rows)} of {total} conversations. Increase --limit to see more.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from common import (
compact_json,
conversation_created,
conversation_messages,
conversation_title,
conversation_updated,
load_conversations,
message_has_nontext,
message_has_thinking,
normalize_iso,
role_matches,
)
def render_nontext(message: dict, *, compact_nontext: bool) -> str:
content = message.get("content")
item_types = message.get("item_types") or []
if not content or not item_types or item_types == ["text"]:
return ""
if compact_nontext:
return "Non-text content types: " + ", ".join(item_types)
return "```json\n" + compact_json(content, limit=4000) + "\n```"
def render_markdown(conversation: dict, *, schema: str, index: int, compact_nontext: bool, skip_thoughts: bool, allowed_roles: set[str] | None) -> str:
title = conversation_title(conversation, schema)
created = normalize_iso(conversation_created(conversation, schema))
updated = normalize_iso(conversation_updated(conversation, schema))
summary = (conversation.get("summary") or "").strip() if schema == "modern" else ""
lines = [
f"# {title}",
"",
f"- Index: {index}",
f"- Schema: `{schema}`",
f"- Created: {created}",
f"- Updated: {updated}",
"",
]
if summary:
lines.extend(["## Summary", "", summary, ""])
messages = conversation_messages(conversation, schema)
rendered_count = 0
for message in messages:
if skip_thoughts and message_has_thinking(message) and not (message.get("text") or "").strip():
continue
if not role_matches(message.get("sender") or "unknown", allowed_roles):
continue
rendered_count += 1
sender = message.get("sender") or "unknown"
created_at = normalize_iso(message.get("created_at"))
text = (message.get("text") or "").strip()
nontext = render_nontext(message, compact_nontext=compact_nontext)
lines.append(f"## {rendered_count:03d}. {sender} — {created_at}")
lines.append("")
if text:
lines.append(text)
lines.append("")
if nontext:
lines.append(nontext)
lines.append("")
if not text and not nontext:
lines.append("(no renderable content)")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def main() -> None:
parser = argparse.ArgumentParser(description="Render one conversation from a chat history export as markdown.")
parser.add_argument("export_path", help="Path to export folder or zip file")
parser.add_argument("--index", type=int, required=True, help="Global conversation index to render")
parser.add_argument("--compact-nontext", action="store_true", help="Summarize non-text payloads instead of dumping them")
parser.add_argument("--skip-thoughts", action="store_true", help="Skip messages that contain only thinking content")
role_filter = parser.add_mutually_exclusive_group()
role_filter.add_argument("--user-only", action="store_true", help="Render only user messages")
role_filter.add_argument("--assistant-only", action="store_true", help="Render only assistant messages")
parser.add_argument("--output", help="Write markdown to this file instead of stdout")
args = parser.parse_args()
export_path = Path(args.export_path).expanduser()
conversations, schema = load_conversations(export_path)
if args.index < 0 or args.index >= len(conversations):
raise SystemExit(f"Conversation index out of range: {args.index} (0-{len(conversations)-1})")
allowed_roles = None
if args.user_only:
allowed_roles = {"user", "human"}
elif args.assistant_only:
allowed_roles = {"assistant"}
output = render_markdown(
conversations[args.index],
schema=schema,
index=args.index,
compact_nontext=args.compact_nontext,
skip_thoughts=args.skip_thoughts,
allowed_roles=allowed_roles,
)
if args.output:
output_path = Path(args.output).expanduser()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(output, encoding="utf-8")
print(f"Wrote {output_path}")
else:
print(output, end="")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import importlib.util
from pathlib import Path
_rc_path = Path(__file__).resolve().parent / "render-conversation.py"
_spec = importlib.util.spec_from_file_location("render_conversation", _rc_path)
_rc = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
_spec.loader.exec_module(_rc) # type: ignore[union-attr]
render_markdown = _rc.render_markdown
load_conversations = _rc.load_conversations
def main() -> None:
parser = argparse.ArgumentParser(description="Render a range of conversations from a chat history export.")
parser.add_argument("export_path", help="Path to export folder or zip file")
parser.add_argument("--start-index", type=int, required=True, help="First global conversation index to render")
parser.add_argument("--end-index", type=int, required=True, help="Last global conversation index to render")
parser.add_argument("--compact-nontext", action="store_true", help="Summarize non-text payloads instead of dumping them")
parser.add_argument("--skip-thoughts", action="store_true", help="Skip messages that contain only thinking content")
role_filter = parser.add_mutually_exclusive_group()
role_filter.add_argument("--user-only", action="store_true", help="Render only user messages")
role_filter.add_argument("--assistant-only", action="store_true", help="Render only assistant messages")
output_group = parser.add_mutually_exclusive_group(required=True)
output_group.add_argument("--output-dir", help="Directory for one markdown file per conversation")
output_group.add_argument("--concat-output", help="Write one concatenated markdown file for the whole range")
args = parser.parse_args()
export_path = Path(args.export_path).expanduser()
conversations, schema = load_conversations(export_path)
if args.end_index < args.start_index:
raise SystemExit("--end-index must be greater than or equal to --start-index")
allowed_roles = None
if args.user_only:
allowed_roles = {"user", "human"}
elif args.assistant_only:
allowed_roles = {"assistant"}
indexes = range(max(0, args.start_index), min(len(conversations) - 1, args.end_index) + 1)
rendered = []
for index in indexes:
rendered.append(
(
index,
render_markdown(
conversations[index],
schema=schema,
index=index,
compact_nontext=args.compact_nontext,
skip_thoughts=args.skip_thoughts,
allowed_roles=allowed_roles,
),
)
)
if args.output_dir:
output_dir = Path(args.output_dir).expanduser()
output_dir.mkdir(parents=True, exist_ok=True)
for index, markdown in rendered:
(output_dir / f"{index:04d}.md").write_text(markdown, encoding="utf-8")
print(f"Wrote {len(rendered)} conversations to {output_dir}")
return
concat_path = Path(args.concat_output).expanduser()
concat_path.parent.mkdir(parents=True, exist_ok=True)
concat_path.write_text("\n\n---\n\n".join(markdown for _, markdown in rendered), encoding="utf-8")
print(f"Wrote {len(rendered)} conversations to {concat_path}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from common import (
conversation_messages,
conversation_title,
conversation_updated,
load_conversations,
normalize_iso,
role_matches,
snippet,
)
def main() -> None:
parser = argparse.ArgumentParser(description="Search conversation contents for keywords or phrases.")
parser.add_argument("export_path", help="Path to export folder or zip file")
parser.add_argument("--query", action="append", required=True, help="Query string to search for (repeatable)")
parser.add_argument("--role", action="append", help="Restrict to roles such as user, human, assistant, tool, or system")
parser.add_argument("--title-contains", help="Only search conversations whose title contains this string")
parser.add_argument("--limit", type=int, default=50, help="Maximum rows to print (default: 50)")
parser.add_argument("--json", action="store_true", help="Emit JSON instead of a table")
parser.add_argument("--progress", action="store_true", help="Print progress to stderr")
args = parser.parse_args()
export_path = Path(args.export_path).expanduser()
conversations, schema = load_conversations(export_path)
queries = [query.lower() for query in args.query]
allowed_roles = {role.lower() for role in (args.role or [])} or None
rows: list[dict] = []
for index, conversation in enumerate(conversations):
if args.progress and index and index % 100 == 0:
print(f"Scanned {index} conversations...", file=sys.stderr)
title = conversation_title(conversation, schema)
if args.title_contains and args.title_contains.lower() not in title.lower():
continue
for message in conversation_messages(conversation, schema):
if not role_matches(message.get("sender") or "unknown", allowed_roles):
continue
text = message.get("text") or ""
if not text:
continue
haystack = text.lower()
matched = [query for query in queries if query in haystack]
if not matched:
continue
rows.append(
{
"index": index,
"title": title,
"updated": normalize_iso(conversation_updated(conversation, schema)),
"sender": message.get("sender") or "unknown",
"created": normalize_iso(message.get("created_at")),
"matched_queries": matched,
"snippet": snippet(text),
}
)
rows.sort(key=lambda row: (row["updated"], row["created"]), reverse=True)
total = len(rows)
rows = rows[: args.limit]
if args.json:
print(json.dumps(rows, indent=2, ensure_ascii=False))
return
print(f"Found {total} matching messages in {export_path}\n")
print(f"{'IDX':>5} {'UPDATED':<25} {'ROLE':<9} {'MATCHED':<20} {'TITLE':<36} SNIPPET")
print(f"{'-' * 5} {'-' * 25} {'-' * 9} {'-' * 20} {'-' * 36} {'-' * 60}")
for row in rows:
matched = ", ".join(row["matched_queries"])[:20]
print(
f"{row['index']:>5} {row['updated']:<25} {row['sender']:<9} {matched:<20} {row['title'][:36]:<36} {row['snippet'][:60]}"
)
if total > len(rows):
print(f"\nShowing {len(rows)} of {total} matches. Increase --limit to see more.")
if __name__ == "__main__":
main()