Files
chemenu/tools/import_chronicle.py
T
torben 18ae28f918
CI / verify (push) Failing after 32s
Release / release (push) Successful in 38s
Chemenu 2.1.0 - deterministischer Wissenskompiler
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki:
raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht
tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren
Grenzen in Code durchgesetzt sind statt im Prompt.

Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige
Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses
Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44
Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist.

Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den
Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er
dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs.

Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte.
Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet -
siehe NOTICE.
2026-09-01 16:26:14 +02:00

225 lines
8.3 KiB
Python
Executable File

#!/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('<session>').parent.parent}")
return 0
if __name__ == "__main__":
sys.exit(main())