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.
This commit is contained in:
Executable
+224
@@ -0,0 +1,224 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user