Some checks failed
Aurélio Sync & Conscience Upgrade / Upgrade Réplica Conscience (push) Failing after 27s
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>
1373 lines
56 KiB
Python
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,
|
|
}
|