#!/usr/bin/env python3 """Import past chat sessions from a chronicle store into the trace format. VS Code's Copilot Chat has no hooks, so nothing observes a session while it runs. What it does keep is a SQLite "chronicle" store - sessions, turns, and the files each turn touched - which is enough to reconstruct the shape of a session after the fact. Copilot CLI keeps a store with the same schema, so this script serves both. What a reconstructed trace can and cannot say is recorded in the events themselves: `session.start` carries the `completeness` list for `vscode-chat`, which names `tool.post` but not `tool.pre`. The store records that a file was touched, not that a tool was about to be called - so a scorer looking for a refused-then-retried pattern will correctly report "not measurable here". The store starts empty. Populating it is `/chronicle reindex`, which also syncs session data to your GitHub account - run it yourself, deliberately, rather than having a script do it for you. tools/import_chronicle.py --dry-run tools/import_chronicle.py --session 0f1e2d3c """ from __future__ import annotations import argparse import os import sqlite3 import sys from datetime import datetime, timezone from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from chemenu import config # noqa: E402 from chemenu.telemetry import schema # noqa: E402 from chemenu.telemetry.writer import trace_path, write_event # noqa: E402 SOURCE = "vscode-chat" # First match wins. Both stores use the same schema. DB_CANDIDATES = ( "~/.copilot/session-store.db", "~/.config/Code/User/globalStorage/github.copilot-chat/session-store.db", "~/Library/Application Support/Code/User/globalStorage/github.copilot-chat/session-store.db", "~/AppData/Roaming/Code/User/globalStorage/github.copilot-chat/session-store.db", ) def default_db() -> Path | None: if os.environ.get("COPILOT_HOME"): candidate = Path(os.environ["COPILOT_HOME"]) / "session-store.db" if candidate.exists(): return candidate for raw in DB_CANDIDATES: candidate = Path(raw).expanduser() if candidate.exists(): return candidate return None def connect(db: Path) -> sqlite3.Connection: """Read-only, always: this store belongs to the editor, not to us.""" connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True) connection.row_factory = sqlite3.Row return connection def iso(value: str | None) -> str | None: """Normalise the store's timestamps to the trace's format. Mixed spellings of the same instant (`...Z` and `...+00:00`) sort differently as strings, and a trace is sorted by `ts`. """ if not value: return None try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: return value if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) return parsed.astimezone(timezone.utc).isoformat(timespec="microseconds") def collect(connection: sqlite3.Connection, session: sqlite3.Row) -> list[tuple]: """Build one session's events as `(ts, tiebreak, event, attrs)` tuples. Ordering is the store's own: sorted by timestamp, with a tiebreak that keeps a turn's prompt ahead of its reply when both carry the same instant. A touched file has its own, usually later timestamp, so it lands after the reply rather than inside the turn. That is a limit of what the store recorded, not a bug - reordering it would invent a sequence nobody wrote down. """ session_id = session["id"] events: list[tuple] = [] started = iso(session["created_at"]) events.append(( started or "", 0, "session.start", { "harness": SOURCE, "completeness": list(schema.HARNESS_CAPABILITIES[SOURCE]), "reconstructed": True, "chronicle_session_id": session_id, "cwd": session["cwd"], "repository": session["repository"], "branch": session["branch"], "agent_name": session["agent_name"], "summary": session["summary"], }, )) turns = connection.execute( "SELECT turn_index, user_message, assistant_response, timestamp " "FROM turns WHERE session_id = ? ORDER BY turn_index", (session_id,), ).fetchall() for turn in turns: ts = iso(turn["timestamp"]) or started or "" if turn["user_message"]: events.append((ts, 1, "prompt.submitted", {"prompt": turn["user_message"], "turn_index": turn["turn_index"]})) if turn["assistant_response"]: events.append((ts, 3, "assistant.message", {"message": turn["assistant_response"], "turn_index": turn["turn_index"]})) files = connection.execute( "SELECT file_path, tool_name, turn_index, first_seen_at " "FROM session_files WHERE session_id = ? ORDER BY id", (session_id,), ).fetchall() for touched in files: events.append(( iso(touched["first_seen_at"]) or started or "", 2, "tool.post", { "tool_name": touched["tool_name"] or "unknown", "file_path": touched["file_path"], "turn_index": touched["turn_index"], }, )) ended = iso(session["updated_at"]) if ended: events.append((ended, 9, "session.end", {"chronicle_session_id": session_id})) events.sort(key=lambda item: (item[0], item[1])) return events def import_session(connection: sqlite3.Connection, session: sqlite3.Row, force: bool, dry_run: bool) -> tuple[str, int]: session_id = session["id"] target = trace_path(session_id) if target.exists() and not force: return "skipped", 0 events = collect(connection, session) if dry_run: return "would import", len(events) if target.exists(): target.unlink() for ts, _, event, attrs in events: write_event(SOURCE, event, attrs, session=session_id, ts=ts or None) return "imported", len(events) def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--db", type=Path, help="Chronicle store to read. Default: the first " "of the Copilot CLI or VS Code stores that exists.") parser.add_argument("--cwd", default=str(config.ROOT), help="Only import sessions whose cwd contains this. Default: this repo.") parser.add_argument("--all", action="store_true", help="Every session, whatever its cwd.") parser.add_argument("--session", help="One session id, or a prefix of one.") parser.add_argument("--force", action="store_true", help="Re-import sessions that already have a trace.") parser.add_argument("--dry-run", action="store_true", help="Report, write nothing.") args = parser.parse_args() db = args.db or default_db() if not db or not Path(db).exists(): print("No chronicle store found. Pass --db, or populate one with " "`/chronicle reindex` in Copilot CLI.", file=sys.stderr) return 1 connection = connect(Path(db)) query = "SELECT * FROM sessions" params: list[str] = [] clauses = [] if args.session: clauses.append("id LIKE ?") params.append(f"{args.session}%") if not args.all and not args.session: clauses.append("cwd LIKE ?") params.append(f"%{args.cwd}%") if clauses: query += " WHERE " + " AND ".join(clauses) query += " ORDER BY created_at" sessions = connection.execute(query, params).fetchall() if not sessions: print(f"No matching sessions in {db}.", file=sys.stderr) return 0 total = 0 for session in sessions: outcome, count = import_session(connection, session, args.force, args.dry_run) total += count print(f"{outcome:12} {session['id'][:12]} {count:4d} events " f"{(session['summary'] or '')[:50]}") print(f"\n{len(sessions)} session(s), {total} event(s). Traces under " f"{trace_path('').parent.parent}") return 0 if __name__ == "__main__": sys.exit(main())