replica-omnisciente/scripts/agent_importers/adapters.py
Raphael Cautus (Maestro) c96b6631ce
Some checks failed
Aurélio Sync & Conscience Upgrade / Upgrade Réplica Conscience (push) Failing after 27s
feat(scripts): agent importer — new adapters, RL trajectory output
Extends sync-agents-to-brain.py with:
- cursor, zed, continue, aider, cline adapters (11 total now)
- RL trajectory extraction (state/action/observation triples)
- Improved content flattening for OpenAI/Anthropic multipart content
- Engine: stable per-message timestamps, session_memory.md rendering

Co-authored-by: Álvaro de Campos <campos@portugalfuturista.org>
2026-07-31 00:08:26 +01:00

1373 lines
56 KiB
Python

"""Source adapters: each agent's on-disk artifacts -> NormalizedSession.
Adapters are intentionally read-only and defensive: a missing store yields
nothing (the agent may simply not be installed on this host). They never raise
on malformed individual records — those are skipped — so one bad file can't
abort a fleet-wide import.
"""
from __future__ import annotations
import hashlib
import json
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Optional
from .engine import (
NormalizedMessage,
NormalizedSession,
flatten_content,
source_id_for,
to_iso_z,
)
HOME = Path.home()
# ---------------------------------------------------------------------------
# small json/jsonl readers
# ---------------------------------------------------------------------------
def _load_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
def _load_jsonl(path: Path) -> list[dict]:
out: list[dict] = []
try:
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
continue
except OSError:
pass
return out
def _file_times(path: Path) -> tuple[str, str]:
try:
st = path.stat()
mod = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z")
birth = getattr(st, "st_birthtime", 0) or 0
cre = (
datetime.fromtimestamp(birth, tz=timezone.utc).isoformat().replace("+00:00", "Z")
if birth > 0 else mod
)
return cre, mod
except OSError:
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
return now, now
# ---------------------------------------------------------------------------
# Hermes (~/.hermes/sessions/saved/*.json + request_dump_*.json)
# ---------------------------------------------------------------------------
def discover_hermes(hermes_dir: Optional[Path] = None) -> Iterable[NormalizedSession]:
root = hermes_dir or (HOME / ".hermes")
sessions_dir = root / "sessions"
if not sessions_dir.exists():
return
# Both saved/hermes_conversation_*.json and sessions/request_dump_*.json share
# the same {session_id, model, messages} shape. Many request_dump files are
# successive dumps of the SAME session, so we key by the in-file session_id
# (falling back to the dump timestamp) rather than the filename — this keeps
# N dumps of one conversation collapsing to a single brain session instead of
# N duplicates.
files: list[Path] = []
saved = sessions_dir / "saved"
if saved.exists():
files += sorted(saved.glob("*.json"))
files += sorted(sessions_dir.glob("request_dump_*.json"))
seen_sessions: dict[str, Path] = {}
for path in files:
data = _load_json(path)
if not isinstance(data, dict):
continue
messages_raw = data.get("messages")
if not isinstance(messages_raw, list) or not messages_raw:
continue
session_id = data.get("session_id") or data.get("id") or path.stem
# Keep the newest dump for a given session_id (files are sorted ascending
# by name; request_dump names embed the dump timestamp so later == newer).
seen_sessions[str(session_id)] = path
for session_id, path in seen_sessions.items():
data = _load_json(path) or {}
messages_raw = data.get("messages", []) if isinstance(data, dict) else []
model = data.get("model") if isinstance(data, dict) else None
created = to_iso_z(data.get("session_start") if isinstance(data, dict) else None) or _file_times(path)[0]
modified = _file_times(path)[1]
msgs: list[NormalizedMessage] = []
for i, m in enumerate(messages_raw):
if not isinstance(m, dict):
continue
role = m.get("role")
if role not in ("user", "assistant", "tool", "system"):
continue
# Hermes assistant messages may carry OpenAI-style tool_calls
if role == "assistant":
tc = m.get("tool_calls")
if isinstance(tc, list) and tc:
for j, t in enumerate(tc):
fn = t.get("function", {}) if isinstance(t, dict) else {}
name = fn.get("name", "?")
args = fn.get("arguments", "")
if isinstance(args, str) and len(args) > 500:
args = args[:500]
msgs.append(NormalizedMessage(
role="tool_call",
content=f"{name}({args})",
timestamp=to_iso_z(m.get("timestamp")),
model=m.get("model") or model,
id=f"hc-{i:06d}-{j}",
))
content = flatten_content(m.get("content"))
if content:
msgs.append(NormalizedMessage(
role="assistant",
content=content,
timestamp=to_iso_z(m.get("timestamp")),
model=m.get("model") or model,
id=f"h-{i:06d}",
))
continue
norm_role = "tool_result" if role == "tool" else role
content = flatten_content(m.get("content"))
if not content and norm_role in ("user", "assistant"):
continue
msgs.append(NormalizedMessage(
role=norm_role,
content=content,
timestamp=to_iso_z(m.get("timestamp")),
model=m.get("model") or (model if norm_role == "assistant" else None),
id=str(m.get("id") or f"h{i:06d}"),
))
if not msgs:
continue
yield NormalizedSession(
source="hermes",
session_id=source_id_for("hermes", str(session_id)),
title="",
created=created,
modified=modified,
workspace_path=(data.get("cwd") or data.get("workspace")) if isinstance(data, dict) else None,
messages=msgs,
source_locator=str(path),
model_type=model or "hermes",
)
# ---------------------------------------------------------------------------
# Claude Code (~/.claude/projects/<hash>/<session>.jsonl, plans/, history.jsonl)
# ---------------------------------------------------------------------------
_CLAUDE_SKIP_TYPES = {
"queue-operation", "file-history-snapshot", "attachment",
"last-prompt", "ai-title",
}
def _claude_project_workspace(project_dir: Path) -> Optional[str]:
# Claude encodes the cwd into the project dir name: -home-user-foo -> /home/user/foo
name = project_dir.name
if name.startswith("-"):
return name.replace("-", "/", 1).replace("-", "/") if False else name.replace("-", "/")
return None
def discover_claude_code(claude_dir: Optional[Path] = None) -> Iterable[NormalizedSession]:
root = claude_dir or (HOME / ".claude")
projects = root / "projects"
if not projects.exists():
return
for jsonl in sorted(projects.rglob("*.jsonl")):
records = _load_jsonl(jsonl)
if not records:
continue
session_id = None
workspace: Optional[str] = None
model: Optional[str] = None
msgs: list[NormalizedMessage] = []
# Subagent files live at <project>/<session-uuid>/subagents/agent-<id>.jsonl
# and share the parent sessionId in their records. They must NOT collapse
# into the parent session — derive a distinct id from the filename when the
# path includes a `subagents` segment.
is_subagent = "subagents" in jsonl.parts
file_agent_id = jsonl.stem # e.g. "agent-a3a32ee38371ef940"
for rec in records:
if not isinstance(rec, dict):
continue
rtype = rec.get("type")
if rtype in _CLAUDE_SKIP_TYPES:
continue
session_id = session_id or rec.get("sessionId")
workspace = workspace or rec.get("cwd")
if rtype not in ("user", "assistant", "system"):
continue
message = rec.get("message")
if not isinstance(message, dict):
continue
role = message.get("role") or rtype
content_raw = message.get("content")
# Claude assistant messages may contain tool_use parts — emit as tool_call
if isinstance(content_raw, list):
tool_use_parts = [p for p in content_raw if isinstance(p, dict) and p.get("type") == "tool_use"]
if tool_use_parts:
for j, part in enumerate(tool_use_parts):
name = part.get("name", "?")
inp = json.dumps(part.get("input", {}), ensure_ascii=False)[:500]
msgs.append(NormalizedMessage(
role="tool_call",
content=f"{name}({inp})",
timestamp=to_iso_z(rec.get("timestamp")),
model=message.get("model"),
id=f"cc-{rec.get('uuid', 'x')}-t{j}",
))
# Also emit any text content as assistant
text_parts = [p.get("text", "") for p in content_raw if isinstance(p, dict) and p.get("type") == "text"]
content = "\n".join(t for t in text_parts if t)
if content:
if role == "assistant":
model = model or message.get("model")
norm_role = "assistant" if role == "assistant" else ("user" if role == "user" else "system")
msgs.append(NormalizedMessage(
role=norm_role,
content=content,
timestamp=to_iso_z(rec.get("timestamp")),
model=message.get("model") if norm_role == "assistant" else None,
id=rec.get("uuid") or None,
))
continue
content = flatten_content(content_raw)
if role == "assistant" and not content:
continue
if role == "assistant":
model = model or message.get("model")
norm_role = "assistant" if role == "assistant" else ("user" if role == "user" else "system")
msgs.append(NormalizedMessage(
role=norm_role,
content=content,
timestamp=to_iso_z(rec.get("timestamp")),
model=message.get("model") if norm_role == "assistant" else None,
id=rec.get("uuid") or None,
))
if not msgs:
continue
if is_subagent:
# Distinct brain id per subagent; keep parent session id in provenance.
brain_session_id = f"{session_id or jsonl.parent.name}-{file_agent_id}"
else:
brain_session_id = str(session_id or jsonl.stem)
created = to_iso_z(next((r.get("timestamp") for r in records if isinstance(r, dict) and r.get("timestamp")), None)) or _file_times(jsonl)[0]
modified = _file_times(jsonl)[1]
workspace = workspace or _claude_project_workspace(jsonl.parents[1] if is_subagent else jsonl.parent)
yield NormalizedSession(
source="claude-code",
session_id=source_id_for("claude", brain_session_id),
title="",
created=created,
modified=modified,
workspace_path=workspace,
messages=msgs,
source_locator=str(jsonl),
model_type=model or "claude-code",
extra=({"parentSessionId": session_id, "agent": file_agent_id} if is_subagent else {}),
)
# ---------------------------------------------------------------------------
# Antigravity CLI (~/.gemini/antigravity-cli/conversations/<id>.db sqlite)
# schema: trajectory_meta(trajectory_id,cascade_id,...) steps(idx, step_type,
# status, task_details, step_payload, ...)
# ---------------------------------------------------------------------------
def _antigravity_extract_json(blob: bytes | str) -> Optional[dict]:
"""Extract the first JSON object from a protobuf blob that embeds JSON."""
if isinstance(blob, bytes):
blob = blob.decode("utf-8", "replace")
start = blob.find("{")
if start < 0:
return None
depth = 0
for i in range(start, len(blob)):
if blob[i] == "{": depth += 1
elif blob[i] == "}":
depth -= 1
if depth == 0:
try:
return json.loads(blob[start:i + 1])
except json.JSONDecodeError:
return None
return None
# Antigravity step_type enum (from protobuf field numbers observed in the wild)
_AG_TOOL_TYPES = {9, 21} # tool call steps (list_dir, run_command, etc.)
_AG_RESPONSE_TYPES = {15, 90} # assistant/planner response steps
_AG_USER_TYPES = {5, 14} # user input steps
def _antigravity_steps_to_messages(db_path: Path) -> list[NormalizedMessage]:
msgs: list[NormalizedMessage] = []
try:
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
except sqlite3.Error:
return msgs
try:
con.row_factory = sqlite3.Row
try:
rows = con.execute("SELECT idx, step_type, task_details, step_payload, status FROM steps ORDER BY idx").fetchall()
except sqlite3.Error:
return msgs
for row in rows:
step_type = row["step_type"]
if not isinstance(step_type, int):
continue
payload_raw = row["step_payload"]
task_raw = row["task_details"]
text = ""
tool_name = ""
tool_input = ""
for blob in (payload_raw, task_raw):
if not blob:
continue
obj = _antigravity_extract_json(blob)
if obj:
# Tool call extraction
if step_type in _AG_TOOL_TYPES:
tool_name = (
obj.get("toolAction", "").split(" ")[0].lower()
or obj.get("name", "")
or obj.get("tool", "")
or obj.get("CommandLine", "").split(" ")[0]
)
tool_input = json.dumps(obj, ensure_ascii=False)[:500]
text = tool_input
else:
text = (
obj.get("text") or obj.get("content")
or obj.get("message") or obj.get("prompt")
or obj.get("plan") or ""
)
if not text and isinstance(obj.get("messages"), list):
text = "\n".join(
flatten_content(m.get("content")) for m in obj["messages"]
if isinstance(m, dict)
)
if text:
break
elif isinstance(blob, (bytes, str)):
# Fallback: raw text extraction
raw = blob.decode("utf-8", "replace") if isinstance(blob, bytes) else blob
stripped = raw.strip()
if stripped.startswith("{") or stripped.startswith("["):
try:
obj = json.loads(stripped)
text = (
obj.get("text") or obj.get("content")
or obj.get("message") or obj.get("prompt")
or obj.get("plan") or ""
)
except json.JSONDecodeError:
text = stripped
else:
# Skip binary protobuf blobs that aren't JSON
if any(c.isprintable() for c in stripped[:50]):
text = stripped
if text:
break
if step_type in _AG_TOOL_TYPES:
role = "tool_call"
if not tool_name:
# Try to extract tool name from the protobuf text
raw = payload_raw.decode("utf-8", "replace") if isinstance(payload_raw, bytes) else str(payload_raw or "")
for marker in ("run_command", "list_dir", "read_file", "write_file", "search", "edit_file"):
if marker in raw:
tool_name = marker
break
content = f"{tool_name}({tool_input})" if tool_input else (tool_name or f"[step {step_type}]")
elif step_type in _AG_USER_TYPES:
role = "user"
content = text[:2000] if text else "[user input]"
elif step_type in _AG_RESPONSE_TYPES:
role = "assistant"
content = text[:2000] if text else f"[step {step_type}]"
else:
role = "assistant"
content = text[:2000] if text else f"[step {step_type}]"
msgs.append(NormalizedMessage(
role=role,
content=content,
id=f"ag-{row['idx']:06d}",
))
finally:
con.close()
return msgs
def discover_antigravity_cli(gemini_dir: Optional[Path] = None) -> Iterable[NormalizedSession]:
root = gemini_dir or (HOME / ".gemini")
for sub in ("antigravity-cli", "antigravity-ide"):
conv_dir = root / sub / "conversations"
if not conv_dir.exists():
continue
for db in sorted(conv_dir.glob("*.db")):
msgs = _antigravity_steps_to_messages(db)
if not msgs:
continue
created, modified = _file_times(db)
source = "antigravity" if sub == "antigravity-cli" else "antigravity-ide"
yield NormalizedSession(
source=source,
session_id=source_id_for("ag", db.stem),
title="",
created=created,
modified=modified,
workspace_path=None,
messages=msgs,
source_locator=str(db),
model_type="antigravity",
)
# ---------------------------------------------------------------------------
# Qwen Code (~/.qwen or ~/.qwen-code). Layout is Kimi-derived (fork) but
# unconfirmed on this host — probe both session.jsonl and context.jsonl.
# ---------------------------------------------------------------------------
def discover_qwen_code(qwen_dir: Optional[Path] = None) -> Iterable[NormalizedSession]:
candidates = [qwen_dir] if qwen_dir else [HOME / ".qwen", HOME / ".qwen-code", HOME / ".config" / "qwen"]
for root in candidates:
if not root or not root.exists():
continue
sess_root = root / "sessions" if (root / "sessions").exists() else root
for jsonl in sorted(list(sess_root.rglob("context.jsonl")) + list(sess_root.rglob("*.jsonl"))):
records = _load_jsonl(jsonl)
if not records:
continue
session_id = jsonl.parent.name if jsonl.name == "context.jsonl" else jsonl.stem
msgs: list[NormalizedMessage] = []
model = None
for i, rec in enumerate(records):
if not isinstance(rec, dict):
continue
role = rec.get("role")
if role in (None, "_system_prompt", "_checkpoint", "_usage"):
continue
if role == "tool_call":
msgs.append(NormalizedMessage(role="tool_call", content=flatten_content(rec.get("content"))[:500], id=f"qc-{i:06d}"))
continue
if role == "tool_result":
msgs.append(NormalizedMessage(role="tool_result", content=flatten_content(rec.get("content"))[:500], id=f"qr-{i:06d}"))
continue
norm_role = "assistant" if role in ("model", "assistant") else ("user" if role == "user" else None)
if not norm_role:
continue
if norm_role == "assistant":
model = model or rec.get("model")
msgs.append(NormalizedMessage(role=norm_role, content=flatten_content(rec.get("content")), timestamp=to_iso_z(rec.get("timestamp")), id=f"q-{i:06d}"))
if not msgs:
continue
created, modified = _file_times(jsonl)
yield NormalizedSession(
source="qwen-code",
session_id=source_id_for("qwen", str(session_id)),
title="",
created=created,
modified=modified,
workspace_path=None,
messages=msgs,
source_locator=str(jsonl),
model_type=model or "qwen-code",
)
# ---------------------------------------------------------------------------
# MiMo Code (./.mimocode/plans + ~/.mimocode). Only plans/ exist on this host;
# import plan markdown as single-message "plan" sessions so they show up in the
# brain, and pick up any session.jsonl the CLI may write later.
# ---------------------------------------------------------------------------
def discover_mimocode(repo_root: Optional[Path] = None, home: Optional[Path] = None) -> Iterable[NormalizedSession]:
roots: list[Path] = []
if repo_root:
roots.append(repo_root / ".mimocode")
roots.append((home or HOME) / ".mimocode")
seen: set[Path] = set()
for root in roots:
if not root.exists():
continue
# Plans (markdown) -> one assistant message each
plans = root / "plans"
if plans.exists():
for md in sorted(plans.glob("*.md")):
if md in seen:
continue
seen.add(md)
try:
text = md.read_text(encoding="utf-8")
except OSError:
continue
if not text.strip():
continue
created, modified = _file_times(md)
yield NormalizedSession(
source="mimocode",
session_id=source_id_for("mimo", md.stem),
title=md.stem.replace("-", " ").title(),
created=created,
modified=modified,
workspace_path=str(root),
messages=[NormalizedMessage(role="assistant", content=text, timestamp=created, id="mimo-plan-0")],
source_locator=str(md),
model_type="mimocode",
extra={"kind": "plan"},
)
# Future: session jsonl if the CLI starts writing them
for jsonl in sorted(root.rglob("*.jsonl")):
if jsonl in seen:
continue
seen.add(jsonl)
records = _load_jsonl(jsonl)
msgs: list[NormalizedMessage] = []
for i, rec in enumerate(records):
if not isinstance(rec, dict):
continue
role = rec.get("role")
if role not in ("user", "assistant", "model"):
continue
msgs.append(NormalizedMessage(
role="assistant" if role in ("assistant", "model") else "user",
content=flatten_content(rec.get("content")),
timestamp=to_iso_z(rec.get("timestamp")),
id=f"mc-{i:06d}",
))
if not msgs:
continue
created, modified = _file_times(jsonl)
yield NormalizedSession(
source="mimocode",
session_id=source_id_for("mimo", jsonl.stem),
title="",
created=created,
modified=modified,
workspace_path=str(root),
messages=msgs,
source_locator=str(jsonl),
model_type="mimocode",
)
# ---------------------------------------------------------------------------
# Pi (~/.pi/agent/sessions/<workspace-hash>/<timestamp>_<uuid>.jsonl)
# ---------------------------------------------------------------------------
def discover_pi(pi_dir: Optional[Path] = None) -> Iterable[NormalizedSession]:
root = pi_dir or (HOME / ".pi" / "agent")
sessions_dir = root / "sessions"
if not sessions_dir.exists():
return
for jsonl in sorted(sessions_dir.rglob("*.jsonl")):
records = _load_jsonl(jsonl)
if not records:
continue
session_id = None
cwd = None
model = None
msgs: list[NormalizedMessage] = []
for rec in records:
if not isinstance(rec, dict):
continue
rtype = rec.get("type")
if rtype == "session":
session_id = rec.get("id")
cwd = rec.get("cwd")
continue
if rtype == "model_change":
provider = rec.get("provider", "")
model_id = rec.get("modelId", "")
model = f"{provider}/{model_id}" if provider and model_id else (model_id or provider or None)
continue
if rtype in ("thinking_level_change",):
continue
if rtype != "message":
continue
message = rec.get("message")
if not isinstance(message, dict):
continue
role = message.get("role")
if role not in ("user", "assistant", "toolResult"):
continue
content_raw = message.get("content")
# Pi assistant messages may contain toolCall content parts
if isinstance(content_raw, list):
tool_use_parts = [p for p in content_raw if isinstance(p, dict) and p.get("type") in ("tool_use", "toolCall")]
if tool_use_parts:
ts = to_iso_z(message.get("timestamp") or rec.get("timestamp"))
for j, part in enumerate(tool_use_parts):
name = part.get("name", "?")
inp_raw = part.get("input") or part.get("arguments", {})
inp = json.dumps(inp_raw, ensure_ascii=False)[:500]
msgs.append(NormalizedMessage(
role="tool_call",
content=f"{name}({inp})",
timestamp=ts,
model=message.get("model") or model,
id=f"pi-{rec.get('id', 'x')}-t{j}",
))
# Also emit text content
text_parts = [p.get("text", "") for p in content_raw if isinstance(p, dict) and p.get("type") == "text"]
content = "\n".join(t for t in text_parts if t)
if content:
norm_role = "assistant" if role == "assistant" else ("user" if role == "user" else "system")
msgs.append(NormalizedMessage(
role=norm_role,
content=content,
timestamp=ts,
model=(message.get("model") or model) if norm_role == "assistant" else None,
id=str(rec.get("id") or f"pi-{len(msgs):06d}"),
))
continue
norm_role = "tool_result" if role == "toolResult" else role
content = flatten_content(content_raw)
if not content and norm_role in ("user", "assistant"):
continue
msg_model = None
if norm_role == "assistant":
msg_model = message.get("model") or model
msgs.append(NormalizedMessage(
role=norm_role,
content=content,
timestamp=to_iso_z(message.get("timestamp") or rec.get("timestamp")),
model=msg_model,
id=str(rec.get("id") or f"pi-{len(msgs):06d}"),
))
if not msgs:
continue
created, modified = _file_times(jsonl)
brain_session_id = str(session_id or jsonl.stem)
yield NormalizedSession(
source="pi",
session_id=source_id_for("pi", brain_session_id),
title="",
created=to_iso_z(next(
(r.get("timestamp") for r in records
if isinstance(r, dict) and r.get("type") == "session"),
None,
)) or created,
modified=modified,
workspace_path=cwd,
messages=msgs,
source_locator=str(jsonl),
model_type=model or "pi",
)
# ---------------------------------------------------------------------------
# OpenCode (~/.local/share/opencode/opencode.db — SQLite)
# ---------------------------------------------------------------------------
def discover_opencode(db_path: Optional[Path] = None) -> Iterable[NormalizedSession]:
db = db_path or (HOME / ".local" / "share" / "opencode" / "opencode.db")
if not db.exists():
return
try:
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
except sqlite3.Error:
return
try:
con.row_factory = sqlite3.Row
try:
sessions = con.execute(
"SELECT id, title, directory, agent, model, parent_id, "
"time_created, time_updated "
"FROM session ORDER BY time_created"
).fetchall()
except sqlite3.Error:
return
for sess in sessions:
sess_id = sess["id"] or ""
parent_id = sess["parent_id"]
title = sess["title"] or ""
directory = sess["directory"] or ""
agent = sess["agent"]
model_raw = sess["model"]
created_ms = sess["time_created"] or 0
updated_ms = sess["time_updated"] or 0
model_name = None
if model_raw:
try:
mobj = json.loads(model_raw) if isinstance(model_raw, str) else model_raw
if isinstance(mobj, dict):
provider = mobj.get("providerID", "")
model_id = mobj.get("id", "")
model_name = f"{provider}/{model_id}" if provider and model_id else (model_id or provider or None)
except (json.JSONDecodeError, TypeError):
pass
created = to_iso_z(created_ms / 1000.0) if created_ms else None
modified = to_iso_z(updated_ms / 1000.0) if updated_ms else None
if not created:
created, modified = _file_times(db)
try:
rows = con.execute(
"SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created",
(sess_id,),
).fetchall()
except sqlite3.Error:
continue
msgs: list[NormalizedMessage] = []
for row in rows:
msg_data_raw = row["data"]
if not msg_data_raw:
continue
try:
msg_data = json.loads(msg_data_raw) if isinstance(msg_data_raw, str) else msg_data_raw
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(msg_data, dict):
continue
role = msg_data.get("role")
if role not in ("user", "assistant", "tool", "toolResult"):
continue
norm_role = "tool_result" if role in ("tool", "toolResult") else role
msg_model = None
if norm_role == "assistant":
m = msg_data.get("model")
if isinstance(m, dict):
msg_model = m.get("id") or m.get("providerID") or model_name
else:
msg_model = model_name
try:
part_rows = con.execute(
"SELECT data FROM part WHERE message_id = ? ORDER BY time_created",
(row["id"],),
).fetchall()
except sqlite3.Error:
part_rows = []
content_parts: list[str] = []
tool_call_parts: list[tuple[str, str, str]] = [] # (name, input_json, output)
for pr in part_rows:
pd = pr["data"]
if not pd:
continue
try:
pdata = json.loads(pd) if isinstance(pd, str) else pd
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(pdata, dict):
continue
ptype = pdata.get("type")
if ptype == "text":
t = pdata.get("text", "")
if t:
content_parts.append(t)
elif ptype == "tool":
# OpenCode native tool format: {type:"tool", tool:"read", state:{input,output}}
name = pdata.get("tool", "?")
state = pdata.get("state", {})
inp = json.dumps(state.get("input", {}), ensure_ascii=False)[:500]
out = str(state.get("output", ""))[:2000]
tool_call_parts.append((name, inp, out))
elif ptype == "tool_use":
name = pdata.get("name", "?")
inp = json.dumps(pdata.get("input", {}), ensure_ascii=False)[:500]
tool_call_parts.append((name, inp, ""))
elif ptype == "tool_result":
tr = pdata.get("content", "")
if isinstance(tr, list):
tr = " ".join(x.get("text", "") for x in tr if isinstance(x, dict))
content_parts.append(f"[tool_result] {str(tr)[:500]}")
# Compute timestamp before tool_call emission
msg_ts = msg_data.get("time", {})
ts = None
if isinstance(msg_ts, dict):
ts = to_iso_z(msg_ts.get("created"))
if not ts:
ts = to_iso_z(row["time_created"] / 1000.0) if row["time_created"] else None
# Emit tool_call messages for OpenCode tool parts
for j, (name, inp, out) in enumerate(tool_call_parts):
msgs.append(NormalizedMessage(
role="tool_call",
content=f"{name}({inp})",
timestamp=ts,
model=msg_model,
id=f"oc-{row['id']}-t{j}",
))
if out:
msgs.append(NormalizedMessage(
role="tool_result",
content=out[:2000],
timestamp=ts,
id=f"oc-{row['id']}-r{j}",
))
content = "\n".join(content_parts)
if not content and norm_role in ("user", "assistant"):
continue
msgs.append(NormalizedMessage(
role=norm_role,
content=content,
timestamp=ts,
model=msg_model,
id=row["id"],
))
if not msgs:
continue
extra: dict = {}
if parent_id:
extra["parentSessionId"] = parent_id
if agent:
extra["agent"] = agent
brain_session_id = sess_id.removeprefix("ses_") if sess_id.startswith("ses_") else sess_id
yield NormalizedSession(
source="opencode",
session_id=source_id_for("oc", brain_session_id),
title=title,
created=created,
modified=modified,
workspace_path=directory or None,
messages=msgs,
source_locator=f"{db}#{sess_id}",
model_type=model_name or "opencode",
extra=extra,
)
finally:
con.close()
# ---------------------------------------------------------------------------
# Kimi (re-export of the proven layout so the unified driver covers it too)
# ---------------------------------------------------------------------------
def discover_kimi(kimi_dir: Optional[Path] = None) -> Iterable[NormalizedSession]:
root = kimi_dir or (HOME / ".kimi")
sessions = root / "sessions"
if not sessions.exists():
return
registry = _load_json(root / "kimi.json") or {}
workspace_map: dict[str, str] = {}
if isinstance(registry, dict):
import hashlib
for entry in registry.get("work_dirs", []):
p = entry.get("path")
if isinstance(p, str) and p:
workspace_map[hashlib.md5(p.encode()).hexdigest()] = p
for ctx in sorted(sessions.rglob("context.jsonl")):
rel = ctx.relative_to(sessions)
parts = rel.parts
if len(parts) < 3:
continue
workspace_hash, session_uuid = parts[0], parts[1]
workspace_path = workspace_map.get(workspace_hash)
if len(parts) >= 5 and parts[-2] != session_uuid:
session_id = f"{session_uuid}-{parts[-2]}"
else:
session_id = session_uuid
records = _load_jsonl(ctx)
msgs: list[NormalizedMessage] = []
for i, rec in enumerate(records):
role = rec.get("role")
if role in (None, "_system_prompt", "_checkpoint", "_usage"):
continue
# Kimi assistant messages may carry tool_calls — emit as tool_call
if role == "assistant":
tc = rec.get("tool_calls")
if isinstance(tc, list) and tc:
for j, t in enumerate(tc):
fn = t.get("function", {}) if isinstance(t, dict) else {}
name = fn.get("name", "?")
args = fn.get("arguments", "")
if isinstance(args, str) and len(args) > 500:
args = args[:500]
msgs.append(NormalizedMessage(
role="tool_call",
content=f"{name}({args})",
id=f"kc-{i:06d}-{j}",
))
# Also emit any text content as assistant
content = flatten_content(rec.get("content"))
if content:
msgs.append(NormalizedMessage(
role="assistant",
content=content,
id=f"k-{i:06d}",
))
continue
# Kimi tool results use role=tool (not tool_result)
if role in ("tool", "tool_result"):
msgs.append(NormalizedMessage(
role="tool_result",
content=flatten_content(rec.get("content"))[:2000],
id=f"kr-{i:06d}",
))
continue
if role not in ("user", "model"):
continue
msgs.append(NormalizedMessage(
role="assistant" if role == "model" else "user",
content=flatten_content(rec.get("content")),
id=f"k-{i:06d}",
))
if not msgs:
continue
created, modified = _file_times(ctx)
yield NormalizedSession(
source="kimi",
session_id=source_id_for("kimi", session_id),
title="",
created=created,
modified=modified,
workspace_path=workspace_path,
messages=msgs,
source_locator=str(ctx),
model_type="kimi",
)
# ---------------------------------------------------------------------------
# Cursor (~/.config/Cursor/User/workspaceStorage/*/state.vscdb + globalStorage)
# Chat sessions are in the globalStorage state.vscdb under
# 'chat.ChatSessionStore.index' and per-workspace state.vscdb keys.
# Composer/agent sessions are in globalStorage/cursor.chatSessions or similar.
# ---------------------------------------------------------------------------
def discover_cursor(cursor_dir: Optional[Path] = None) -> Iterable[NormalizedSession]:
root = cursor_dir or (HOME / ".config" / "Cursor")
if not root.exists():
return
# Cursor stores chat data in workspace-level state.vscdb SQLite DBs.
# The composer (agent) history is in globalStorage.
for db_path in sorted(root.rglob("state.vscdb")):
try:
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
except sqlite3.Error:
continue
try:
con.row_factory = sqlite3.Row
try:
rows = con.execute(
"SELECT key, value FROM ItemTable WHERE "
"key LIKE '%chatSession%' OR key LIKE '%composer%' "
"OR key LIKE '%chat%session%' OR key LIKE '%aichat%'"
).fetchall()
except sqlite3.Error:
continue
for row in rows:
key = row["key"] or ""
raw = row["value"]
if not raw:
continue
try:
data = json.loads(raw) if isinstance(raw, str) else raw
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(data, dict):
continue
# Extract messages from common Cursor chat formats
messages_raw = (
data.get("messages") or data.get("entries")
or data.get("history") or data.get("turns")
)
if not isinstance(messages_raw, list) or not messages_raw:
continue
session_id = (
data.get("sessionId") or data.get("id")
or (hashlib.sha1(raw.encode()).hexdigest()[:16] if isinstance(raw, str) else db_path.stem)
)
workspace = str(db_path.parent.parent) if "workspaceStorage" in str(db_path) else None
msgs: list[NormalizedMessage] = []
for i, m in enumerate(messages_raw):
if not isinstance(m, dict):
continue
role = m.get("role") or m.get("type", "")
content = flatten_content(m.get("content") or m.get("text") or m.get("message"))
if not content:
continue
norm_role = "user" if "user" in role.lower() else (
"tool_call" if "tool" in role.lower() and "result" not in role.lower() else (
"tool_result" if "result" in role.lower() else "assistant"
)
)
msgs.append(NormalizedMessage(
role=norm_role,
content=content,
timestamp=to_iso_z(m.get("timestamp") or m.get("createdAt")),
model=m.get("model") or data.get("model"),
id=m.get("id") or f"cur-{i:06d}",
))
if not msgs:
continue
created, modified = _file_times(db_path)
yield NormalizedSession(
source="cursor",
session_id=source_id_for("cursor", str(session_id)),
title=data.get("title") or "",
created=created,
modified=modified,
workspace_path=workspace,
messages=msgs,
source_locator=f"{db_path}#{key}",
model_type=data.get("model") or "cursor",
)
finally:
con.close()
# ---------------------------------------------------------------------------
# Zed (~/.local/share/zed/threads/*.json + ~/.config/zed/)
# Thread files are JSON with entries[].role + entries[].content.
# Agent conversations in ~/.local/share/zed/assistant2/ or threads/.
# ---------------------------------------------------------------------------
def discover_zed(zed_dir: Optional[Path] = None) -> Iterable[NormalizedSession]:
roots = [zed_dir] if zed_dir else [
HOME / ".local" / "share" / "zed",
HOME / ".config" / "zed",
]
for root in roots:
if not root or not root.exists():
continue
for sub in ("threads", "assistant2", "assistant", "conversations"):
thread_dir = root / sub
if not thread_dir.exists():
continue
for f in sorted(thread_dir.rglob("*.json")):
data = _load_json(f)
if not isinstance(data, dict):
continue
entries = data.get("entries") or data.get("messages") or data.get("turns")
if not isinstance(entries, list) or not entries:
continue
session_id = data.get("id") or data.get("threadId") or f.stem
msgs: list[NormalizedMessage] = []
for i, entry in enumerate(entries):
if not isinstance(entry, dict):
continue
role = entry.get("role", "")
content = flatten_content(entry.get("content") or entry.get("text") or entry.get("message"))
if not content:
continue
norm_role = "user" if role == "user" else (
"tool_call" if "tool" in role.lower() else "assistant"
)
msgs.append(NormalizedMessage(
role=norm_role,
content=content,
timestamp=to_iso_z(entry.get("timestamp")),
model=entry.get("model"),
id=f"zed-{i:06d}",
))
if not msgs:
continue
created, modified = _file_times(f)
yield NormalizedSession(
source="zed",
session_id=source_id_for("zed", str(session_id)),
title=data.get("title") or data.get("summary") or "",
created=created,
modified=modified,
workspace_path=data.get("workspace"),
messages=msgs,
source_locator=str(f),
model_type=data.get("model") or "zed",
)
# ---------------------------------------------------------------------------
# Continue.dev (~/.continue/sessions/*.json)
# Each session is a JSON file with history[] containing {role, content}.
# ---------------------------------------------------------------------------
def discover_continue(continue_dir: Optional[Path] = None) -> Iterable[NormalizedSession]:
root = continue_dir or (HOME / ".continue")
sessions_dir = root / "sessions"
if not sessions_dir.exists():
return
for f in sorted(sessions_dir.rglob("*.json")):
data = _load_json(f)
if not isinstance(data, dict):
continue
history = data.get("history") or data.get("messages")
if not isinstance(history, list) or not history:
continue
session_id = data.get("sessionId") or data.get("id") or f.stem
msgs: list[NormalizedMessage] = []
for i, entry in enumerate(history):
if not isinstance(entry, dict):
continue
role = entry.get("role", "")
content = flatten_content(entry.get("content") or entry.get("message"))
if not content:
continue
norm_role = "user" if role == "user" else (
"tool_call" if "tool" in role.lower() else "assistant"
)
msgs.append(NormalizedMessage(
role=norm_role,
content=content,
timestamp=to_iso_z(entry.get("timestamp")),
model=entry.get("model") or data.get("model"),
id=f"cont-{i:06d}",
))
if not msgs:
continue
created, modified = _file_times(f)
yield NormalizedSession(
source="continue",
session_id=source_id_for("continue", str(session_id)),
title=data.get("title") or "",
created=created,
modified=modified,
workspace_path=data.get("workspaceDirectory"),
messages=msgs,
source_locator=str(f),
model_type=data.get("model") or "continue",
)
# ---------------------------------------------------------------------------
# Aider (.aider.chat.history.md in each repo root)
# Markdown chat log with # aider chat started at ... headers.
# ---------------------------------------------------------------------------
def discover_aider(home: Optional[Path] = None) -> Iterable[NormalizedSession]:
# Aider writes .aider.chat.history.md in the repo root where it runs.
# We scan the home directory for repos with aider history.
# Skip hidden dirs and common non-repo dirs.
_SKIP = {".cache", ".config", ".local", ".npm", ".vscode", ".git",
"node_modules", "__pycache__", ".mozilla", ".thunderbird"}
home_dir = home or HOME
for history_file in sorted(home_dir.rglob(".aider.chat.history.md")):
# Skip if inside a hidden dir (other than the file itself)
parts = history_file.relative_to(home_dir).parts
if any(p.startswith(".") and p != ".aider.chat.history.md" for p in parts[:-1]):
if not any(p in _SKIP for p in parts[:-1]):
# Allow .aurelio, .aider, etc.
pass
else:
continue
try:
text = history_file.read_text(encoding="utf-8")
except OSError:
continue
if not text.strip():
continue
# Parse aider markdown: user messages start with ####, assistant with > or plain
msgs: list[NormalizedMessage] = []
current_role: Optional[str] = None
current_lines: list[str] = []
def flush():
nonlocal current_role, current_lines
if current_role and current_lines:
content = "\n".join(current_lines).strip()
if content:
msgs.append(NormalizedMessage(
role=current_role,
content=content,
id=f"aider-{len(msgs):06d}",
))
current_role = None
current_lines = []
for line in text.splitlines():
if line.startswith("# aider chat started at"):
flush()
continue
if line.startswith("#### "):
flush()
current_role = "user"
current_lines = [line[5:]]
elif line.startswith("> "):
if current_role != "assistant":
flush()
current_role = "assistant"
current_lines.append(line[2:])
elif line.strip() and current_role:
current_lines.append(line)
flush()
if not msgs:
continue
workspace = str(history_file.parent)
created, modified = _file_times(history_file)
session_id = hashlib.sha1(workspace.encode()).hexdigest()[:16]
yield NormalizedSession(
source="aider",
session_id=source_id_for("aider", session_id),
title=f"Aider in {Path(workspace).name}",
created=created,
modified=modified,
workspace_path=workspace,
messages=msgs,
source_locator=str(history_file),
model_type="aider",
)
# ---------------------------------------------------------------------------
# Cline / Roo Code (VS Code globalStorage/<publisher>.<name>/tasks/)
# Task files are JSON with messages[] containing {role, content, toolUses}.
# ---------------------------------------------------------------------------
_CLINE_PUBLISHERS = [
"saoudrizwan.claude-dev",
"rooveterinaryinc.roo-cline",
]
def discover_cline(vscode_storage: Optional[Path] = None) -> Iterable[NormalizedSession]:
roots = [vscode_storage] if vscode_storage else [
HOME / ".config" / "Code" / "User" / "globalStorage",
HOME / ".config" / "Code - Insiders" / "User" / "globalStorage",
HOME / ".vscode" / "extensions",
]
for root in roots:
if not root or not root.exists():
continue
for publisher in _CLINE_PUBLISHERS:
ext_dir = root / publisher
if not ext_dir.exists():
continue
# Cline stores tasks in tasks/ subdirectory
for task_dir_name in ("tasks", "taskHistory", "history"):
task_dir = ext_dir / task_dir_name
if not task_dir.exists():
continue
for f in sorted(task_dir.rglob("*.json")):
data = _load_json(f)
if not isinstance(data, dict):
continue
messages_raw = data.get("messages") or data.get("apiConversationHistory")
if not isinstance(messages_raw, list) or not messages_raw:
continue
session_id = data.get("taskId") or data.get("id") or f.stem
source = "cline" if "claude-dev" in publisher else "roo-code"
msgs: list[NormalizedMessage] = []
for i, m in enumerate(messages_raw):
if not isinstance(m, dict):
continue
role = m.get("role", "")
content = flatten_content(m.get("content"))
if not content:
continue
norm_role = "user" if role == "user" else (
"tool_call" if "tool" in role.lower() and "result" not in role.lower() else (
"tool_result" if "result" in role.lower() else "assistant"
)
)
msgs.append(NormalizedMessage(
role=norm_role,
content=content,
timestamp=to_iso_z(m.get("timestamp")),
model=m.get("model"),
id=f"{source[:2]}-{i:06d}",
))
if not msgs:
continue
created, modified = _file_times(f)
yield NormalizedSession(
source=source,
session_id=source_id_for(source, str(session_id)),
title=data.get("title") or data.get("task", "")[:80],
created=created,
modified=modified,
workspace_path=data.get("workspace"),
messages=msgs,
source_locator=str(f),
model_type=data.get("model") or source,
)
ADAPTERS = {
"kimi": discover_kimi,
"hermes": discover_hermes,
"claude-code": discover_claude_code,
"antigravity": discover_antigravity_cli,
"qwen-code": discover_qwen_code,
"mimocode": discover_mimocode,
"pi": discover_pi,
"opencode": discover_opencode,
"cursor": discover_cursor,
"zed": discover_zed,
"continue": discover_continue,
"aider": discover_aider,
"cline": discover_cline,
}