From b73ab0c54dfd1a4587d9cfad2b87e9b507648827 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:49:36 +0300 Subject: [PATCH 01/13] Add session-store ingestion adapter (agent sessions as a graph) --- graphora/sessions.py | 127 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 graphora/sessions.py diff --git a/graphora/sessions.py b/graphora/sessions.py new file mode 100644 index 0000000..4953652 --- /dev/null +++ b/graphora/sessions.py @@ -0,0 +1,127 @@ +"""Agent-session ingestion: load a Copilot CLI session store into the graph. + +A different data source for the same graph: instead of parsing code, this +reads the SQLite database the Copilot CLI already maintains +(`~/.copilot/session-store.db`) and writes work-history nodes: + + (:Session)-[:TOUCHED]->(:WorkFile) + (:Session)-[:IN_REPO]->(:Repo) + (:Session)-[:REFERENCES]->(:Ref {kind: pr|issue|commit}) + +Everything is deterministic and read-only on the source: no LLM, no writes +to the session store. Sessions across terminals become connected the moment +they touch the same file, repo, or PR. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +DEFAULT_DB = Path.home() / ".copilot" / "session-store.db" + + +def _read_source(db_path: Path, days: int) -> dict[str, list[tuple]]: + uri = f"file:{db_path}?mode=ro" + con = sqlite3.connect(uri, uri=True) + try: + cutoff = f"-{int(days)} days" + sessions = con.execute( + """SELECT id, COALESCE(summary,''), COALESCE(cwd,''), COALESCE(repository,''), + COALESCE(branch,''), created_at, updated_at + FROM sessions WHERE updated_at > datetime('now', ?)""", + (cutoff,), + ).fetchall() + ids = [row[0] for row in sessions] + if not ids: + return {"sessions": [], "files": [], "refs": [], "last_turns": []} + marks = ",".join("?" * len(ids)) + files = con.execute( + f"""SELECT session_id, file_path, COALESCE(tool_name,'') + FROM session_files WHERE session_id IN ({marks})""", + ids, + ).fetchall() + refs = con.execute( + f"""SELECT session_id, ref_type, ref_value + FROM session_refs WHERE session_id IN ({marks})""", + ids, + ).fetchall() + last_turns = con.execute( + f"""SELECT t.session_id, substr(COALESCE(t.user_message,''),1,300) + FROM turns t + JOIN (SELECT session_id, MAX(turn_index) AS mi FROM turns + WHERE user_message IS NOT NULL GROUP BY session_id) m + ON m.session_id = t.session_id AND m.mi = t.turn_index + WHERE t.session_id IN ({marks})""", + ids, + ).fetchall() + return {"sessions": sessions, "files": files, "refs": refs, "last_turns": last_turns} + finally: + con.close() + + +def ingest_session_store(store, db_path: str | Path | None = None, days: int = 30) -> dict[str, int]: + """Ingest agent sessions into `store`. Returns node/edge counts.""" + db = Path(db_path) if db_path else DEFAULT_DB + if not db.exists(): + raise FileNotFoundError(f"Session store not found: {db}") + data = _read_source(db, days) + last_ask = dict(data["last_turns"]) + + for sid, summary, cwd, repo, branch, created, updated in data["sessions"]: + store.query( + """MERGE (s:Session {id: $id}) + SET s.summary = $summary, s.cwd = $cwd, s.branch = $branch, + s.created_at = $created, s.updated_at = $updated, s.last_ask = $ask""", + {"id": sid, "summary": summary, "cwd": cwd, "branch": branch, + "created": created, "updated": updated, "ask": last_ask.get(sid, "")}, + ) + repo_name = repo or (Path(cwd).name if cwd else "") + if repo_name: + store.query( + """MERGE (r:Repo {name: $repo}) + WITH r MATCH (s:Session {id: $id}) MERGE (s)-[:IN_REPO]->(r)""", + {"repo": repo_name, "id": sid}, + ) + + for sid, path, tool in data["files"]: + store.query( + """MERGE (f:WorkFile {path: $path}) + WITH f MATCH (s:Session {id: $id}) + MERGE (s)-[t:TOUCHED]->(f) SET t.tool = $tool""", + {"path": path, "id": sid, "tool": tool}, + ) + + for sid, ref_type, ref_value in data["refs"]: + store.query( + """MERGE (x:Ref {kind: $kind, value: $value}) + WITH x MATCH (s:Session {id: $id}) MERGE (s)-[:REFERENCES]->(x)""", + {"kind": ref_type, "value": ref_value, "id": sid}, + ) + + return { + "sessions": len(data["sessions"]), + "files_touched": len(data["files"]), + "refs": len(data["refs"]), + } + + +def connected(store, kind: str, value: str) -> list[dict]: + """Sessions connected to a file path, repo, or ref value (e.g. a PR number).""" + if kind == "file": + cypher = """MATCH (s:Session)-[:TOUCHED]->(f:WorkFile) + WHERE f.path ENDS WITH $v + RETURN s.id, s.summary, s.updated_at, f.path ORDER BY s.updated_at DESC""" + elif kind == "repo": + cypher = """MATCH (s:Session)-[:IN_REPO]->(r:Repo {name: $v}) + RETURN s.id, s.summary, s.updated_at, r.name ORDER BY s.updated_at DESC""" + else: # ref: pr / issue / commit value + cypher = """MATCH (s:Session)-[:REFERENCES]->(x:Ref) + WHERE x.value = $v OR x.value ENDS WITH $v + RETURN s.id, s.summary, s.updated_at, x.kind + ' ' + x.value + ORDER BY s.updated_at DESC""" + rows = store.query(cypher, {"v": value}) + return [ + {"session": r[0][:8], "summary": r[1], "updated_at": r[2], "via": r[3]} + for r in rows + ] From c0b3e9a7c9f5e13a3ef16fdb750ef04ae3a8a569 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:49:36 +0300 Subject: [PATCH 02/13] Wire sessions ingest/connected subcommands into the CLI --- graphora/cli.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/graphora/cli.py b/graphora/cli.py index a6dadd5..6c76b99 100644 --- a/graphora/cli.py +++ b/graphora/cli.py @@ -144,6 +144,24 @@ def cmd_install_skill(args: argparse.Namespace) -> int: return 0 +def cmd_sessions_ingest(args: argparse.Namespace) -> int: + from graphora.sessions import ingest_session_store + + store = open_store(args.project or "agent-sessions", backend=args.backend, host=args.host, port=args.port) + counts = ingest_session_store(store, db_path=args.db, days=args.days) + print(json.dumps({"project": store.project, "graph": store.graph_name, **counts}, indent=2)) + return 0 + + +def cmd_sessions_connected(args: argparse.Namespace) -> int: + from graphora.sessions import connected + + store = open_store(args.project or "agent-sessions", backend=args.backend, host=args.host, port=args.port) + rows = connected(store, args.kind, args.value) + print(json.dumps(rows, indent=2)) + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="graphora", description="Deterministic code knowledge graph tool") parser.add_argument("--version", action="version", version=f"graphora {__version__}") @@ -202,6 +220,17 @@ def build_parser() -> argparse.ArgumentParser: p_mcp = sub.add_parser("serve-mcp", parents=[common], help="Serve the graph as an MCP stdio server") p_mcp.set_defaults(func=cmd_serve_mcp) + p_sess = sub.add_parser("sessions", parents=[common], help="Agent session-history graph commands") + sess_sub = p_sess.add_subparsers(dest="sessions_command", required=True) + p_si = sess_sub.add_parser("ingest", parents=[common], help="Ingest the Copilot CLI session store into the graph") + p_si.add_argument("--db", default=None, help="Path to session-store.db (default: ~/.copilot/session-store.db)") + p_si.add_argument("--days", type=int, default=30, help="Only sessions updated in the last N days") + p_si.set_defaults(func=cmd_sessions_ingest) + p_sc = sess_sub.add_parser("connected", parents=[common], help="Sessions connected to a file, repo, or PR/issue/commit") + p_sc.add_argument("kind", choices=["file", "repo", "ref"]) + p_sc.add_argument("value") + p_sc.set_defaults(func=cmd_sessions_connected) + p_skill = sub.add_parser("install-skill", help="Install the Graphora skill/rule for AI coding agents") p_skill.add_argument("agents", nargs="*", help="Agent names, or 'all' (default: all)") p_skill.add_argument("--repo", default=".", help="Repository root to install into (default: cwd)") @@ -218,6 +247,8 @@ def main(argv: list[str] | None = None) -> int: args.project = Path(args.repo).resolve().name elif args.command == "risk" and getattr(args, "path", None): args.project = Path(args.path).resolve().name + elif args.command == "sessions": + args.project = "agent-sessions" else: args.project = Path.cwd().name return args.func(args) From aab42042adf584f10c7886a2096946e4e51ac427 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:49:36 +0300 Subject: [PATCH 03/13] Add integration tests for session-store ingestion --- tests/core/test_core_sessions.py | 116 +++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/core/test_core_sessions.py diff --git a/tests/core/test_core_sessions.py b/tests/core/test_core_sessions.py new file mode 100644 index 0000000..70d6ef8 --- /dev/null +++ b/tests/core/test_core_sessions.py @@ -0,0 +1,116 @@ +"""Session-store ingestion tests (integration, needs FalkorDB on :6379).""" + +import sqlite3 +from pathlib import Path + +import pytest + +pytest.importorskip("falkordb") +from falkordb import FalkorDB # noqa: E402 + +from graphora.sessions import connected, ingest_session_store # noqa: E402 +from graphora.store import GraphStore # noqa: E402 + + +def _falkordb_available() -> bool: + try: + FalkorDB(host="localhost", port=6379).select_graph("graphora:ping").query("RETURN 1") + return True + except Exception: + return False + + +pytestmark = pytest.mark.skipif(not _falkordb_available(), reason="FalkorDB not running on localhost:6379") + + +@pytest.fixture() +def session_db(tmp_path: Path) -> Path: + db = tmp_path / "session-store.db" + con = sqlite3.connect(db) + con.executescript( + """ + CREATE TABLE sessions (id TEXT PRIMARY KEY, cwd TEXT, repository TEXT, + host_type TEXT, branch TEXT, summary TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now'))); + CREATE TABLE turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, + turn_index INTEGER, user_message TEXT, assistant_response TEXT, + timestamp TEXT DEFAULT (datetime('now'))); + CREATE TABLE session_files (id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, file_path TEXT, tool_name TEXT, turn_index INTEGER, + first_seen_at TEXT DEFAULT (datetime('now'))); + CREATE TABLE session_refs (id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, ref_type TEXT, ref_value TEXT, turn_index INTEGER, + created_at TEXT DEFAULT (datetime('now'))); + + INSERT INTO sessions (id, cwd, repository, branch, summary) VALUES + ('aaa11111-1111', '/home/u/proj', 'org/proj', 'main', 'Fix build'), + ('bbb22222-2222', '/home/u/proj', 'org/proj', 'main', 'Review PR'), + ('ccc33333-3333', '/home/u/other', 'org/other', '', 'Other work'); + INSERT INTO turns (session_id, turn_index, user_message) VALUES + ('aaa11111-1111', 0, 'fix the docker build'), + ('aaa11111-1111', 1, 'add a healthcheck'), + ('bbb22222-2222', 0, 'review my pr'); + INSERT INTO session_files (session_id, file_path, tool_name) VALUES + ('aaa11111-1111', '/home/u/proj/build.yml', 'edit'), + ('bbb22222-2222', '/home/u/proj/build.yml', 'view'), + ('ccc33333-3333', '/home/u/other/main.py', 'edit'); + INSERT INTO session_refs (session_id, ref_type, ref_value) VALUES + ('aaa11111-1111', 'pr', '275'), + ('bbb22222-2222', 'pr', '275'); + """ + ) + con.commit() + con.close() + return db + + +@pytest.fixture() +def store(): + store = GraphStore("sessions-test") + yield store + store.delete_graph() + + +def test_ingest_counts(store, session_db): + counts = ingest_session_store(store, db_path=session_db, days=7) + assert counts == {"sessions": 3, "files_touched": 3, "refs": 2} + + +def test_ingest_is_idempotent(store, session_db): + ingest_session_store(store, db_path=session_db, days=7) + ingest_session_store(store, db_path=session_db, days=7) + rows = store.query("MATCH (s:Session) RETURN count(s)") + assert rows[0][0] == 3 + rows = store.query("MATCH (:Session)-[t:TOUCHED]->(:WorkFile) RETURN count(t)") + assert rows[0][0] == 3 + + +def test_session_node_has_last_ask(store, session_db): + ingest_session_store(store, db_path=session_db, days=7) + rows = store.query("MATCH (s:Session {id: 'aaa11111-1111'}) RETURN s.last_ask") + assert rows[0][0] == "add a healthcheck" + + +def test_connected_by_file(store, session_db): + ingest_session_store(store, db_path=session_db, days=7) + hits = connected(store, "file", "build.yml") + ids = {h["session"] for h in hits} + assert ids == {"aaa11111", "bbb22222"} + + +def test_connected_by_ref(store, session_db): + ingest_session_store(store, db_path=session_db, days=7) + hits = connected(store, "ref", "275") + assert len(hits) == 2 + + +def test_connected_by_repo(store, session_db): + ingest_session_store(store, db_path=session_db, days=7) + hits = connected(store, "repo", "org/other") + assert [h["session"] for h in hits] == ["ccc33333"] + + +def test_missing_db_raises(store, tmp_path): + with pytest.raises(FileNotFoundError): + ingest_session_store(store, db_path=tmp_path / "nope.db") From 75ae21428098756c0fb16a20b6995209e7e6fec2 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:49:36 +0300 Subject: [PATCH 04/13] Document use case 6: agent session memory as a graph --- USECASES.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/USECASES.md b/USECASES.md index c9cf3c3..f66bec2 100644 --- a/USECASES.md +++ b/USECASES.md @@ -1,6 +1,7 @@ # Graphora Use Cases -Five real scenarios, all executed locally and captured verbatim on 2026-07-14. +Six real scenarios, all executed locally and captured verbatim (use cases 1–5 on +2026-07-14, use case 6 on 2026-07-16). Environment: macOS, Python 3.11, FalkorDB in Docker on `localhost:6379`. Setup used for all of them: @@ -168,6 +169,64 @@ trustworthy enough to gate merges on. --- +## Use case 6: agent session memory as a graph + +**Scenario**: you work with AI coding agents in many terminal tabs at once. Each tab has +its own context, and after a day you no longer remember which window did what, which +sessions touched the same file, or which ones relate to the PR you're reviewing. + +The Copilot CLI already records every session in a local SQLite store +(`~/.copilot/session-store.db`): summaries, messages, files touched, PR/issue/commit +references. Graphora ingests it as just another data source — no code parsing, same +graph, same tooling: + +```bash +graphora sessions ingest --days 7 +``` + +Captured output against a real workstation store (2026-07-16): + +```json +{ + "project": "agent-sessions", + "graph": "graphora:agent-sessions", + "sessions": 19, + "files_touched": 158, + "refs": 24 +} +``` + +The graph is `(:Session)-[:TOUCHED]->(:WorkFile)`, `(:Session)-[:IN_REPO]->(:Repo)`, +`(:Session)-[:REFERENCES]->(:Ref)`. Sessions from different terminals become connected +the moment they touch the same file, repo, or PR. Then ask relationship questions that +are painful in SQL and trivial in Cypher: + +```bash +graphora sessions connected file build.yml # which windows touched this file? +graphora sessions connected ref 275 # which sessions relate to PR 275? +graphora sessions connected repo org/proj # everything that happened in one repo +``` + +Captured output: + +```json +[ + { + "session": "007d10dd", + "summary": "Add Docker Build Completion Check", + "updated_at": "2026-07-14T07:47:52.822Z", + "via": "/Users/naseemali/Documents/GitHub/FalkorDB/.github/workflows/build.yml" + } +] +``` + +**Why it matters**: tools like graphify map your *code*; Graphora also maps your *work*. +Ingestion is deterministic and read-only on the source (no LLM, no writes to the session +store), idempotent (re-ingesting never duplicates nodes), and reuses the live FalkorDB +graph, so the MCP server exposes your work history to any agent for free. + +--- + ## How it was tested Test-first at every phase; the full suite had to pass before the next phase started. From 5464bb4759e92afc0bfac3746839631f4b30a712 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:50:31 +0300 Subject: [PATCH 05/13] Document agent session memory commands in the README --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index f2e467e..bd042fc 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,23 @@ A symbol fixed often and recently scores near 1.0. A quiet area decays by half e The longer Graphora runs on a repository, the smarter it gets. That compounds. +## Agent session memory: graph your work, not just your code + +If you use the GitHub Copilot CLI across many terminal tabs, it already records every +session locally (`~/.copilot/session-store.db`): summaries, files touched, PR references. +Graphora ingests that as another data source, so sessions from different terminals become +connected the moment they touch the same file, repo, or PR: + +```bash +graphora sessions ingest --days 7 # load your session history into the graph + +graphora sessions connected file build.yml # which windows touched this file? +graphora sessions connected ref 275 # which sessions relate to PR 275? +graphora sessions connected repo org/proj # everything that happened in one repo +``` + +Read-only on the source, no LLM, idempotent. See [use case 6](USECASES.md). + ## Benchmarks Measured on real repositories, fully deterministic, zero network. Full methodology and reproduction commands in [BENCHMARKS.md](./BENCHMARKS.md). From 69da8188d7a04debff3804aff456083c0ff23e04 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:57:50 +0300 Subject: [PATCH 06/13] Move session graph writes behind backend-agnostic store methods --- graphora/sessions.py | 48 +++++++++++--------------------------------- graphora/store.py | 47 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 36 deletions(-) diff --git a/graphora/sessions.py b/graphora/sessions.py index 4953652..094270e 100644 --- a/graphora/sessions.py +++ b/graphora/sessions.py @@ -61,7 +61,7 @@ def _read_source(db_path: Path, days: int) -> dict[str, list[tuple]]: def ingest_session_store(store, db_path: str | Path | None = None, days: int = 30) -> dict[str, int]: - """Ingest agent sessions into `store`. Returns node/edge counts.""" + """Ingest agent sessions into `store` (FalkorDB or embedded). Returns counts.""" db = Path(db_path) if db_path else DEFAULT_DB if not db.exists(): raise FileNotFoundError(f"Session store not found: {db}") @@ -69,35 +69,23 @@ def ingest_session_store(store, db_path: str | Path | None = None, days: int = 3 last_ask = dict(data["last_turns"]) for sid, summary, cwd, repo, branch, created, updated in data["sessions"]: - store.query( - """MERGE (s:Session {id: $id}) - SET s.summary = $summary, s.cwd = $cwd, s.branch = $branch, - s.created_at = $created, s.updated_at = $updated, s.last_ask = $ask""", - {"id": sid, "summary": summary, "cwd": cwd, "branch": branch, - "created": created, "updated": updated, "ask": last_ask.get(sid, "")}, + store.upsert_session( + sid, + {"summary": summary, "cwd": cwd, "branch": branch, + "created_at": created, "updated_at": updated, "last_ask": last_ask.get(sid, "")}, ) repo_name = repo or (Path(cwd).name if cwd else "") if repo_name: - store.query( - """MERGE (r:Repo {name: $repo}) - WITH r MATCH (s:Session {id: $id}) MERGE (s)-[:IN_REPO]->(r)""", - {"repo": repo_name, "id": sid}, - ) + store.link_session_repo(sid, repo_name) for sid, path, tool in data["files"]: - store.query( - """MERGE (f:WorkFile {path: $path}) - WITH f MATCH (s:Session {id: $id}) - MERGE (s)-[t:TOUCHED]->(f) SET t.tool = $tool""", - {"path": path, "id": sid, "tool": tool}, - ) + store.link_session_file(sid, path, tool) for sid, ref_type, ref_value in data["refs"]: - store.query( - """MERGE (x:Ref {kind: $kind, value: $value}) - WITH x MATCH (s:Session {id: $id}) MERGE (s)-[:REFERENCES]->(x)""", - {"kind": ref_type, "value": ref_value, "id": sid}, - ) + store.link_session_ref(sid, ref_type, ref_value) + + if hasattr(store, "save"): + store.save() return { "sessions": len(data["sessions"]), @@ -108,19 +96,7 @@ def ingest_session_store(store, db_path: str | Path | None = None, days: int = 3 def connected(store, kind: str, value: str) -> list[dict]: """Sessions connected to a file path, repo, or ref value (e.g. a PR number).""" - if kind == "file": - cypher = """MATCH (s:Session)-[:TOUCHED]->(f:WorkFile) - WHERE f.path ENDS WITH $v - RETURN s.id, s.summary, s.updated_at, f.path ORDER BY s.updated_at DESC""" - elif kind == "repo": - cypher = """MATCH (s:Session)-[:IN_REPO]->(r:Repo {name: $v}) - RETURN s.id, s.summary, s.updated_at, r.name ORDER BY s.updated_at DESC""" - else: # ref: pr / issue / commit value - cypher = """MATCH (s:Session)-[:REFERENCES]->(x:Ref) - WHERE x.value = $v OR x.value ENDS WITH $v - RETURN s.id, s.summary, s.updated_at, x.kind + ' ' + x.value - ORDER BY s.updated_at DESC""" - rows = store.query(cypher, {"v": value}) + rows = store.sessions_connected(kind, value) return [ {"session": r[0][:8], "summary": r[1], "updated_at": r[2], "via": r[3]} for r in rows diff --git a/graphora/store.py b/graphora/store.py index e51c577..234871b 100644 --- a/graphora/store.py +++ b/graphora/store.py @@ -106,6 +106,53 @@ def delete_graph(self) -> None: except Exception: pass + # --- agent sessions (see graphora/sessions.py) ------------------------- + + def upsert_session(self, sid: str, props: dict) -> None: + self.query( + """MERGE (s:Session {id: $id}) + SET s.summary = $summary, s.cwd = $cwd, s.branch = $branch, + s.created_at = $created_at, s.updated_at = $updated_at, s.last_ask = $last_ask""", + {"id": sid, **props}, + ) + + def link_session_repo(self, sid: str, repo: str) -> None: + self.query( + """MERGE (r:Repo {name: $repo}) + WITH r MATCH (s:Session {id: $id}) MERGE (s)-[:IN_REPO]->(r)""", + {"repo": repo, "id": sid}, + ) + + def link_session_file(self, sid: str, path: str, tool: str) -> None: + self.query( + """MERGE (f:WorkFile {path: $path}) + WITH f MATCH (s:Session {id: $id}) + MERGE (s)-[t:TOUCHED]->(f) SET t.tool = $tool""", + {"path": path, "id": sid, "tool": tool}, + ) + + def link_session_ref(self, sid: str, kind: str, value: str) -> None: + self.query( + """MERGE (x:Ref {kind: $kind, value: $value}) + WITH x MATCH (s:Session {id: $id}) MERGE (s)-[:REFERENCES]->(x)""", + {"kind": kind, "value": value, "id": sid}, + ) + + def sessions_connected(self, kind: str, value: str) -> list[list]: + if kind == "file": + cypher = """MATCH (s:Session)-[:TOUCHED]->(f:WorkFile) + WHERE f.path ENDS WITH $v + RETURN s.id, s.summary, s.updated_at, f.path ORDER BY s.updated_at DESC""" + elif kind == "repo": + cypher = """MATCH (s:Session)-[:IN_REPO]->(r:Repo {name: $v}) + RETURN s.id, s.summary, s.updated_at, r.name ORDER BY s.updated_at DESC""" + else: + cypher = """MATCH (s:Session)-[:REFERENCES]->(x:Ref) + WHERE x.value = $v OR x.value ENDS WITH $v + RETURN s.id, s.summary, s.updated_at, x.kind + ' ' + x.value + ORDER BY s.updated_at DESC""" + return self.query(cypher, {"v": value}) + # --- reads ------------------------------------------------------------ def stats(self) -> dict[str, int]: From 4ccba53de214d2362046ea299ea683d01d4914c7 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:57:50 +0300 Subject: [PATCH 07/13] Support agent sessions in the embedded JSON backend (no Docker) --- graphora/embedded.py | 48 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/graphora/embedded.py b/graphora/embedded.py index acd8aae..0766a68 100644 --- a/graphora/embedded.py +++ b/graphora/embedded.py @@ -52,6 +52,10 @@ def _empty() -> dict: "fix_commits": {}, # sha -> {date, subject, kind} "touched": [], # [sha, path] "fixed": [], # [sha, stable_key] + "sessions": {}, # sid -> {summary, cwd, branch, created_at, updated_at, last_ask} + "session_repos": [], # [sid, repo] + "session_files": [], # {sid, path, tool} + "session_refs": [], # [sid, kind, value] } @property @@ -184,6 +188,50 @@ def stats(self) -> dict[str, int]: def has_files(self) -> bool: return bool(self._data["files"]) + # --- agent sessions (see graphora/sessions.py) ------------------------- + + def upsert_session(self, sid: str, props: dict) -> None: + self._data.setdefault("sessions", {})[sid] = dict(props) + + def link_session_repo(self, sid: str, repo: str) -> None: + edges = self._data.setdefault("session_repos", []) + if [sid, repo] not in edges: + edges.append([sid, repo]) + + def link_session_file(self, sid: str, path: str, tool: str) -> None: + edges = self._data.setdefault("session_files", []) + for edge in edges: + if edge["sid"] == sid and edge["path"] == path: + edge["tool"] = tool + return + edges.append({"sid": sid, "path": path, "tool": tool}) + + def link_session_ref(self, sid: str, kind: str, value: str) -> None: + edges = self._data.setdefault("session_refs", []) + if [sid, kind, value] not in edges: + edges.append([sid, kind, value]) + + def sessions_connected(self, kind: str, value: str) -> list[list]: + sessions = self._data.get("sessions", {}) + + def row(sid: str, via: str) -> list: + props = sessions.get(sid, {}) + return [sid, props.get("summary", ""), props.get("updated_at", ""), via] + + if kind == "file": + hits = [row(e["sid"], e["path"]) + for e in self._data.get("session_files", []) + if e["path"].endswith(value)] + elif kind == "repo": + hits = [row(sid, repo) + for sid, repo in self._data.get("session_repos", []) + if repo == value] + else: + hits = [row(sid, f"{k} {v}") + for sid, k, v in self._data.get("session_refs", []) + if v == value or v.endswith(value)] + return sorted(hits, key=lambda r: r[2], reverse=True) + def find_definitions(self, name: str) -> list[list]: return [ [s["name"], s["kind"], s["path"], s["line"], s["signature"], From 270255546e3727f9b3a9912a0ab4e761cabba985 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:57:50 +0300 Subject: [PATCH 08/13] Run session ingestion tests against both backends --- tests/core/test_core_sessions.py | 47 ++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/tests/core/test_core_sessions.py b/tests/core/test_core_sessions.py index 70d6ef8..acfeb3b 100644 --- a/tests/core/test_core_sessions.py +++ b/tests/core/test_core_sessions.py @@ -1,26 +1,25 @@ -"""Session-store ingestion tests (integration, needs FalkorDB on :6379).""" +"""Session-store ingestion tests: embedded always, FalkorDB when available on :6379.""" import sqlite3 from pathlib import Path import pytest -pytest.importorskip("falkordb") -from falkordb import FalkorDB # noqa: E402 - -from graphora.sessions import connected, ingest_session_store # noqa: E402 -from graphora.store import GraphStore # noqa: E402 +from graphora.embedded import EmbeddedGraphStore +from graphora.sessions import connected, ingest_session_store def _falkordb_available() -> bool: try: + from falkordb import FalkorDB + FalkorDB(host="localhost", port=6379).select_graph("graphora:ping").query("RETURN 1") return True except Exception: return False -pytestmark = pytest.mark.skipif(not _falkordb_available(), reason="FalkorDB not running on localhost:6379") +FALKORDB_UP = _falkordb_available() @pytest.fixture() @@ -65,11 +64,20 @@ def session_db(tmp_path: Path) -> Path: return db -@pytest.fixture() -def store(): - store = GraphStore("sessions-test") - yield store - store.delete_graph() +@pytest.fixture(params=["embedded", "falkordb"]) +def store(request, tmp_path): + if request.param == "embedded": + store = EmbeddedGraphStore("sessions-test", data_dir=tmp_path / "graphs") + yield store + store.delete_graph() + else: + if not FALKORDB_UP: + pytest.skip("FalkorDB not running on localhost:6379") + from graphora.store import GraphStore + + store = GraphStore("sessions-test") + yield store + store.delete_graph() def test_ingest_counts(store, session_db): @@ -80,16 +88,19 @@ def test_ingest_counts(store, session_db): def test_ingest_is_idempotent(store, session_db): ingest_session_store(store, db_path=session_db, days=7) ingest_session_store(store, db_path=session_db, days=7) - rows = store.query("MATCH (s:Session) RETURN count(s)") - assert rows[0][0] == 3 - rows = store.query("MATCH (:Session)-[t:TOUCHED]->(:WorkFile) RETURN count(t)") - assert rows[0][0] == 3 + # re-ingesting must not duplicate sessions or TOUCHED edges + assert len(connected(store, "file", "build.yml")) == 2 + assert len(connected(store, "repo", "org/proj")) == 2 + assert len(connected(store, "ref", "275")) == 2 def test_session_node_has_last_ask(store, session_db): ingest_session_store(store, db_path=session_db, days=7) - rows = store.query("MATCH (s:Session {id: 'aaa11111-1111'}) RETURN s.last_ask") - assert rows[0][0] == "add a healthcheck" + if hasattr(store, "_data"): # embedded + ask = store._data["sessions"]["aaa11111-1111"]["last_ask"] + else: # falkordb + ask = store.query("MATCH (s:Session {id: 'aaa11111-1111'}) RETURN s.last_ask")[0][0] + assert ask == "add a healthcheck" def test_connected_by_file(store, session_db): From 80e3743e5cf59014c2e88b5ef1c3227a0923ac98 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:57:50 +0300 Subject: [PATCH 09/13] Document embedded mode and step-by-step usage for session memory --- README.md | 7 +++++++ USECASES.md | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/README.md b/README.md index bd042fc..0879533 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,13 @@ graphora sessions connected ref 275 # which sessions relate to PR 275? graphora sessions connected repo org/proj # everything that happened in one repo ``` +No FalkorDB container? Same commands work with the embedded JSON backend: + +```bash +graphora sessions ingest --days 7 --backend embedded +graphora sessions connected file build.yml --backend embedded +``` + Read-only on the source, no LLM, idempotent. See [use case 6](USECASES.md). ## Benchmarks diff --git a/USECASES.md b/USECASES.md index f66bec2..6a809fb 100644 --- a/USECASES.md +++ b/USECASES.md @@ -207,6 +207,16 @@ graphora sessions connected ref 275 # which sessions relate to PR 275? graphora sessions connected repo org/proj # everything that happened in one repo ``` +**How to use it** (step by step): + +1. `pip install graphora-kg` +2. Have FalkorDB running (`docker run -d -p 6379:6379 falkordb/falkordb`), **or** skip + Docker entirely and add `--backend embedded` to every command below. +3. `graphora sessions ingest --days 7` — safe to re-run anytime; it's idempotent. +4. Ask away: `graphora sessions connected file ` / `ref ` / `repo `. +5. Optional: `graphora serve-mcp --project agent-sessions` exposes your work history to + any MCP-capable agent. + Captured output: ```json From 83271f025c9eed0b4564a118719902a33eb0e7ee Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:06:39 +0300 Subject: [PATCH 10/13] Add multi-agent session readers: Copilot, Claude Code, Codex CLI --- graphora/embedded.py | 3 +- graphora/sessions.py | 222 +++++++++++++++++++++++++++---- graphora/store.py | 12 +- tests/core/test_core_sessions.py | 116 ++++++++++++++++ 4 files changed, 321 insertions(+), 32 deletions(-) diff --git a/graphora/embedded.py b/graphora/embedded.py index 0766a68..047bf42 100644 --- a/graphora/embedded.py +++ b/graphora/embedded.py @@ -216,7 +216,8 @@ def sessions_connected(self, kind: str, value: str) -> list[list]: def row(sid: str, via: str) -> list: props = sessions.get(sid, {}) - return [sid, props.get("summary", ""), props.get("updated_at", ""), via] + return [sid, props.get("summary", ""), props.get("updated_at", ""), via, + props.get("agent", "")] if kind == "file": hits = [row(e["sid"], e["path"]) diff --git a/graphora/sessions.py b/graphora/sessions.py index 094270e..bc87706 100644 --- a/graphora/sessions.py +++ b/graphora/sessions.py @@ -1,28 +1,54 @@ -"""Agent-session ingestion: load a Copilot CLI session store into the graph. +"""Agent-session ingestion: load AI-agent session histories into the graph. A different data source for the same graph: instead of parsing code, this -reads the SQLite database the Copilot CLI already maintains -(`~/.copilot/session-store.db`) and writes work-history nodes: +reads the session stores that AI coding agents already maintain and writes +work-history nodes: - (:Session)-[:TOUCHED]->(:WorkFile) + (:Session {agent})-[:TOUCHED]->(:WorkFile) (:Session)-[:IN_REPO]->(:Repo) (:Session)-[:REFERENCES]->(:Ref {kind: pr|issue|commit}) -Everything is deterministic and read-only on the source: no LLM, no writes -to the session store. Sessions across terminals become connected the moment +Supported sources, one reader per agent, all emitting the same shape: + +- copilot: GitHub Copilot CLI, SQLite at ~/.copilot/session-store.db +- claude: Claude Code, JSONL files under ~/.claude/projects/ +- codex: Codex CLI, JSONL rollouts under ~/.codex/sessions/ + +Everything is deterministic and read-only on the sources: no LLM, no writes. +Sessions across terminals — and across agents — become connected the moment they touch the same file, repo, or PR. """ from __future__ import annotations +import json import sqlite3 +from datetime import datetime, timedelta, timezone from pathlib import Path -DEFAULT_DB = Path.home() / ".copilot" / "session-store.db" +DEFAULT_COPILOT_DB = Path.home() / ".copilot" / "session-store.db" +DEFAULT_CLAUDE_ROOT = Path.home() / ".claude" / "projects" +DEFAULT_CODEX_ROOT = Path.home() / ".codex" / "sessions" + +# Back-compat alias (pre-multi-source name) +DEFAULT_DB = DEFAULT_COPILOT_DB + +_EMPTY = {"sessions": [], "files": [], "refs": [], "last_turns": []} + + +def _cutoff(days: int) -> str: + return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() -def _read_source(db_path: Path, days: int) -> dict[str, list[tuple]]: - uri = f"file:{db_path}?mode=ro" +# --- copilot --------------------------------------------------------------- + + +def read_copilot(db_path: str | Path | None = None, days: int = 30) -> dict: + """Read the GitHub Copilot CLI session store (SQLite).""" + db = Path(db_path) if db_path else DEFAULT_COPILOT_DB + if not db.exists(): + raise FileNotFoundError(f"Session store not found: {db}") + uri = f"file:{db}?mode=ro" con = sqlite3.connect(uri, uri=True) try: cutoff = f"-{int(days)} days" @@ -34,7 +60,7 @@ def _read_source(db_path: Path, days: int) -> dict[str, list[tuple]]: ).fetchall() ids = [row[0] for row in sessions] if not ids: - return {"sessions": [], "files": [], "refs": [], "last_turns": []} + return dict(_EMPTY) marks = ",".join("?" * len(ids)) files = con.execute( f"""SELECT session_id, file_path, COALESCE(tool_name,'') @@ -55,38 +81,138 @@ def _read_source(db_path: Path, days: int) -> dict[str, list[tuple]]: WHERE t.session_id IN ({marks})""", ids, ).fetchall() - return {"sessions": sessions, "files": files, "refs": refs, "last_turns": last_turns} + return {"sessions": sessions, "files": [list(f) for f in files], + "refs": [list(r) for r in refs], "last_turns": [list(t) for t in last_turns]} finally: con.close() -def ingest_session_store(store, db_path: str | Path | None = None, days: int = 30) -> dict[str, int]: - """Ingest agent sessions into `store` (FalkorDB or embedded). Returns counts.""" - db = Path(db_path) if db_path else DEFAULT_DB - if not db.exists(): - raise FileNotFoundError(f"Session store not found: {db}") - data = _read_source(db, days) - last_ask = dict(data["last_turns"]) +# --- claude code ----------------------------------------------------------- + + +def read_claude_code(root: str | Path | None = None, days: int = 30) -> dict: + """Read Claude Code session transcripts (JSONL under ~/.claude/projects/).""" + base = Path(root) if root else DEFAULT_CLAUDE_ROOT + if not base.exists(): + raise FileNotFoundError(f"Claude Code projects dir not found: {base}") + cutoff = _cutoff(days) + out = {"sessions": [], "files": [], "refs": [], "last_turns": []} + for jsonl in sorted(base.glob("*/*.jsonl")): + sid = jsonl.stem + cwd = branch = summary = "" + first_ts = last_ts = "" + last_ask = "" + touched: dict[str, str] = {} + try: + lines = jsonl.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + for line in lines: + try: + obj = json.loads(line) + except (json.JSONDecodeError, ValueError): + continue + kind = obj.get("type") + ts = obj.get("timestamp") or "" + if ts: + first_ts = first_ts or ts + last_ts = max(last_ts, ts) + if kind == "summary" and obj.get("summary"): + summary = str(obj["summary"])[:120] + elif kind == "last-prompt" and obj.get("lastPrompt") and not summary: + summary = str(obj["lastPrompt"])[:120] + elif kind == "user": + cwd = cwd or obj.get("cwd", "") + branch = branch or obj.get("gitBranch", "") + content = obj.get("message", {}).get("content") + if isinstance(content, str) and content and not content.startswith("<"): + last_ask = content[:300] + elif kind == "assistant": + content = obj.get("message", {}).get("content") + if isinstance(content, list): + for block in content: + if not isinstance(block, dict) or block.get("type") != "tool_use": + continue + path = (block.get("input") or {}).get("file_path") + if path: + touched[str(path)] = str(block.get("name", "")).lower() + if not last_ts or last_ts < cutoff: + continue + repo = Path(cwd).name if cwd else "" + out["sessions"].append((sid, summary, cwd, repo, branch, first_ts, last_ts)) + out["files"].extend([sid, path, tool] for path, tool in touched.items()) + if last_ask: + out["last_turns"].append([sid, last_ask]) + return out + + +# --- codex cli --------------------------------------------------------------- + + +def read_codex(root: str | Path | None = None, days: int = 30) -> dict: + """Read Codex CLI rollouts (JSONL under ~/.codex/sessions/). Best-effort.""" + base = Path(root) if root else DEFAULT_CODEX_ROOT + if not base.exists(): + raise FileNotFoundError(f"Codex sessions dir not found: {base}") + cutoff = _cutoff(days) + out = {"sessions": [], "files": [], "refs": [], "last_turns": []} + for jsonl in sorted(base.rglob("*.jsonl")): + sid = jsonl.stem + cwd = summary = "" + first_ts = last_ts = "" + last_ask = "" + try: + lines = jsonl.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + for line in lines: + try: + obj = json.loads(line) + except (json.JSONDecodeError, ValueError): + continue + ts = obj.get("timestamp") or "" + if ts: + first_ts = first_ts or ts + last_ts = max(last_ts, ts) + payload = obj.get("payload") or {} + if obj.get("type") == "session_meta": + sid = payload.get("id", sid) + cwd = payload.get("cwd", "") + elif payload.get("role") == "user": + for item in payload.get("content") or []: + text = item.get("text", "") if isinstance(item, dict) else "" + if text and not text.startswith("<"): + last_ask = text[:300] + if not last_ts or last_ts < cutoff: + continue + repo = Path(cwd).name if cwd else "" + out["sessions"].append((sid, summary or last_ask[:120], cwd, repo, "", first_ts, last_ts)) + if last_ask: + out["last_turns"].append([sid, last_ask]) + return out + +SOURCES = {"copilot": read_copilot, "claude": read_claude_code, "codex": read_codex} + + +# --- ingestion --------------------------------------------------------------- + + +def _write(store, agent: str, data: dict) -> dict[str, int]: + last_ask = dict(data["last_turns"]) for sid, summary, cwd, repo, branch, created, updated in data["sessions"]: store.upsert_session( sid, - {"summary": summary, "cwd": cwd, "branch": branch, + {"agent": agent, "summary": summary, "cwd": cwd, "branch": branch, "created_at": created, "updated_at": updated, "last_ask": last_ask.get(sid, "")}, ) repo_name = repo or (Path(cwd).name if cwd else "") if repo_name: store.link_session_repo(sid, repo_name) - for sid, path, tool in data["files"]: store.link_session_file(sid, path, tool) - for sid, ref_type, ref_value in data["refs"]: store.link_session_ref(sid, ref_type, ref_value) - - if hasattr(store, "save"): - store.save() - return { "sessions": len(data["sessions"]), "files_touched": len(data["files"]), @@ -94,10 +220,54 @@ def ingest_session_store(store, db_path: str | Path | None = None, days: int = 3 } +def ingest_sources( + store, + sources: list[str] | None = None, + days: int = 30, + paths: dict[str, str | Path] | None = None, +) -> dict: + """Ingest one or more agent session stores into `store`. + + `sources`: subset of {"copilot", "claude", "codex"} or None for all. + `paths`: optional per-source location override, e.g. {"copilot": "/tmp/db"}. + Missing sources are skipped (reported as {"skipped": reason}) when + ingesting "all"; explicitly requested sources raise instead. + """ + wanted = sources or list(SOURCES) + unknown = set(wanted) - set(SOURCES) + if unknown: + raise ValueError(f"Unknown sources: {sorted(unknown)}. Known: {sorted(SOURCES)}") + explicit = sources is not None + paths = paths or {} + results: dict[str, dict] = {} + for name in wanted: + reader = SOURCES[name] + try: + data = reader(paths.get(name), days=days) + except FileNotFoundError as exc: + if explicit: + raise + results[name] = {"skipped": str(exc)} + continue + results[name] = _write(store, name, data) + if hasattr(store, "save"): + store.save() + return results + + +def ingest_session_store(store, db_path: str | Path | None = None, days: int = 30) -> dict[str, int]: + """Back-compat: ingest only the Copilot CLI store. Returns counts.""" + counts = _write(store, "copilot", read_copilot(db_path, days=days)) + if hasattr(store, "save"): + store.save() + return counts + + def connected(store, kind: str, value: str) -> list[dict]: """Sessions connected to a file path, repo, or ref value (e.g. a PR number).""" rows = store.sessions_connected(kind, value) return [ - {"session": r[0][:8], "summary": r[1], "updated_at": r[2], "via": r[3]} + {"session": r[0][:8], "agent": r[4] if len(r) > 4 else "", + "summary": r[1], "updated_at": r[2], "via": r[3]} for r in rows ] diff --git a/graphora/store.py b/graphora/store.py index 234871b..eebe87f 100644 --- a/graphora/store.py +++ b/graphora/store.py @@ -111,9 +111,9 @@ def delete_graph(self) -> None: def upsert_session(self, sid: str, props: dict) -> None: self.query( """MERGE (s:Session {id: $id}) - SET s.summary = $summary, s.cwd = $cwd, s.branch = $branch, + SET s.agent = $agent, s.summary = $summary, s.cwd = $cwd, s.branch = $branch, s.created_at = $created_at, s.updated_at = $updated_at, s.last_ask = $last_ask""", - {"id": sid, **props}, + {"id": sid, "agent": "", **props}, ) def link_session_repo(self, sid: str, repo: str) -> None: @@ -142,14 +142,16 @@ def sessions_connected(self, kind: str, value: str) -> list[list]: if kind == "file": cypher = """MATCH (s:Session)-[:TOUCHED]->(f:WorkFile) WHERE f.path ENDS WITH $v - RETURN s.id, s.summary, s.updated_at, f.path ORDER BY s.updated_at DESC""" + RETURN s.id, s.summary, s.updated_at, f.path, s.agent + ORDER BY s.updated_at DESC""" elif kind == "repo": cypher = """MATCH (s:Session)-[:IN_REPO]->(r:Repo {name: $v}) - RETURN s.id, s.summary, s.updated_at, r.name ORDER BY s.updated_at DESC""" + RETURN s.id, s.summary, s.updated_at, r.name, s.agent + ORDER BY s.updated_at DESC""" else: cypher = """MATCH (s:Session)-[:REFERENCES]->(x:Ref) WHERE x.value = $v OR x.value ENDS WITH $v - RETURN s.id, s.summary, s.updated_at, x.kind + ' ' + x.value + RETURN s.id, s.summary, s.updated_at, x.kind + ' ' + x.value, s.agent ORDER BY s.updated_at DESC""" return self.query(cypher, {"v": value}) diff --git a/tests/core/test_core_sessions.py b/tests/core/test_core_sessions.py index acfeb3b..a797c57 100644 --- a/tests/core/test_core_sessions.py +++ b/tests/core/test_core_sessions.py @@ -125,3 +125,119 @@ def test_connected_by_repo(store, session_db): def test_missing_db_raises(store, tmp_path): with pytest.raises(FileNotFoundError): ingest_session_store(store, db_path=tmp_path / "nope.db") + + +@pytest.fixture() +def claude_root(tmp_path: Path) -> Path: + import json as _json + + proj = tmp_path / "claude-projects" / "-Users-u-proj" + proj.mkdir(parents=True) + from datetime import datetime, timezone + + now = datetime.now(timezone.utc).isoformat() + lines = [ + {"type": "last-prompt", "lastPrompt": "fix the flaky test", "sessionId": "cl-1"}, + {"type": "user", "cwd": "/home/u/proj", "gitBranch": "main", "timestamp": now, + "message": {"content": "fix the flaky test"}}, + {"type": "assistant", "timestamp": now, "message": {"content": [ + {"type": "tool_use", "name": "Edit", "input": {"file_path": "/home/u/proj/build.yml"}}, + {"type": "tool_use", "name": "Read", "input": {"file_path": "/home/u/proj/main.py"}}, + ]}}, + {"type": "user", "cwd": "/home/u/proj", "timestamp": now, + "message": {"content": "also add a retry"}}, + ] + (proj / "claude-session-1.jsonl").write_text( + "\n".join(_json.dumps(o) for o in lines), encoding="utf-8" + ) + return tmp_path / "claude-projects" + + +def test_read_claude_code(claude_root): + from graphora.sessions import read_claude_code + + data = read_claude_code(claude_root, days=7) + assert len(data["sessions"]) == 1 + sid, summary, cwd, repo, branch, _, _ = data["sessions"][0] + assert sid == "claude-session-1" + assert summary == "fix the flaky test" + assert (cwd, repo, branch) == ("/home/u/proj", "proj", "main") + assert sorted(p for _, p, _ in data["files"]) == ["/home/u/proj/build.yml", "/home/u/proj/main.py"] + assert data["last_turns"] == [["claude-session-1", "also add a retry"]] + + +def test_read_claude_code_skips_old_sessions(claude_root, tmp_path): + import json as _json + + old = claude_root / "-Users-u-old" + old.mkdir() + (old / "ancient.jsonl").write_text(_json.dumps( + {"type": "user", "cwd": "/home/u/old", "timestamp": "2020-01-01T00:00:00Z", + "message": {"content": "old stuff"}}), encoding="utf-8") + from graphora.sessions import read_claude_code + + data = read_claude_code(claude_root, days=7) + assert [s[0] for s in data["sessions"]] == ["claude-session-1"] + + +def test_read_codex(tmp_path): + import json as _json + from datetime import datetime, timezone + + root = tmp_path / "codex-sessions" / "2026" / "07" + root.mkdir(parents=True) + now = datetime.now(timezone.utc).isoformat() + lines = [ + {"type": "session_meta", "timestamp": now, "payload": {"id": "cx-1", "cwd": "/home/u/proj"}}, + {"type": "response_item", "timestamp": now, + "payload": {"role": "user", "content": [{"type": "input_text", "text": "refactor the parser"}]}}, + ] + (root / "rollout-1.jsonl").write_text("\n".join(_json.dumps(o) for o in lines), encoding="utf-8") + from graphora.sessions import read_codex + + data = read_codex(tmp_path / "codex-sessions", days=7) + assert [s[0] for s in data["sessions"]] == ["cx-1"] + assert data["sessions"][0][2] == "/home/u/proj" + assert data["last_turns"] == [["cx-1", "refactor the parser"]] + + +def test_ingest_sources_cross_agent(store, session_db, claude_root): + from graphora.sessions import ingest_sources + + results = ingest_sources( + store, sources=["copilot", "claude"], days=7, + paths={"copilot": session_db, "claude": claude_root}, + ) + assert results["copilot"]["sessions"] == 3 + assert results["claude"]["sessions"] == 1 + # cross-agent memory: both agents touched build.yml + hits = connected(store, "file", "build.yml") + agents = {h["agent"] for h in hits} + assert agents == {"copilot", "claude"} + + +def test_ingest_sources_skips_missing_when_all(store, session_db, tmp_path): + from graphora.sessions import ingest_sources + + results = ingest_sources( + store, sources=None, days=7, + paths={"copilot": session_db, + "claude": tmp_path / "nope-claude", + "codex": tmp_path / "nope-codex"}, + ) + assert results["copilot"]["sessions"] == 3 + assert "skipped" in results["claude"] + assert "skipped" in results["codex"] + + +def test_ingest_sources_unknown_source_raises(store): + from graphora.sessions import ingest_sources + + with pytest.raises(ValueError): + ingest_sources(store, sources=["gemini"]) + + +def test_connected_reports_agent(store, session_db): + ingest_session_store(store, db_path=session_db, days=7) + hits = connected(store, "file", "build.yml") + assert all(h["agent"] == "copilot" for h in hits) From 8a3bfc9d4d6dda0e4575cadf7304abe0b8a850c2 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:07:26 +0300 Subject: [PATCH 11/13] Add --source flag: ingest all agents' sessions in one command --- graphora/cli.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/graphora/cli.py b/graphora/cli.py index 6c76b99..a1aad41 100644 --- a/graphora/cli.py +++ b/graphora/cli.py @@ -145,11 +145,19 @@ def cmd_install_skill(args: argparse.Namespace) -> int: def cmd_sessions_ingest(args: argparse.Namespace) -> int: - from graphora.sessions import ingest_session_store + from graphora.sessions import ingest_sources store = open_store(args.project or "agent-sessions", backend=args.backend, host=args.host, port=args.port) - counts = ingest_session_store(store, db_path=args.db, days=args.days) - print(json.dumps({"project": store.project, "graph": store.graph_name, **counts}, indent=2)) + sources = None if args.source == "all" else [args.source] + paths = {} + if args.db: + paths["copilot"] = args.db + if args.claude_root: + paths["claude"] = args.claude_root + if args.codex_root: + paths["codex"] = args.codex_root + results = ingest_sources(store, sources=sources, days=args.days, paths=paths) + print(json.dumps({"project": store.project, "graph": store.graph_name, "sources": results}, indent=2)) return 0 @@ -222,8 +230,12 @@ def build_parser() -> argparse.ArgumentParser: p_sess = sub.add_parser("sessions", parents=[common], help="Agent session-history graph commands") sess_sub = p_sess.add_subparsers(dest="sessions_command", required=True) - p_si = sess_sub.add_parser("ingest", parents=[common], help="Ingest the Copilot CLI session store into the graph") - p_si.add_argument("--db", default=None, help="Path to session-store.db (default: ~/.copilot/session-store.db)") + p_si = sess_sub.add_parser("ingest", parents=[common], help="Ingest agent session stores into the graph") + p_si.add_argument("--source", choices=["all", "copilot", "claude", "codex"], default="all", + help="Which agent's sessions to ingest (default: all found)") + p_si.add_argument("--db", default=None, help="Copilot session-store.db path (default: ~/.copilot/session-store.db)") + p_si.add_argument("--claude-root", default=None, help="Claude Code projects dir (default: ~/.claude/projects)") + p_si.add_argument("--codex-root", default=None, help="Codex CLI sessions dir (default: ~/.codex/sessions)") p_si.add_argument("--days", type=int, default=30, help="Only sessions updated in the last N days") p_si.set_defaults(func=cmd_sessions_ingest) p_sc = sess_sub.add_parser("connected", parents=[common], help="Sessions connected to a file, repo, or PR/issue/commit") From 6f569b9dc50f28e78896752f938105bda2a07d60 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:09:29 +0300 Subject: [PATCH 12/13] Add installable session-recall skill for 22 agents --- graphora/cli.py | 7 ++- graphora/skills.py | 81 +++++++++++++++++++++++++++++----- tests/core/test_core_skills.py | 37 ++++++++++++++++ 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/graphora/cli.py b/graphora/cli.py index a1aad41..5f2db00 100644 --- a/graphora/cli.py +++ b/graphora/cli.py @@ -135,8 +135,11 @@ def cmd_install_skill(args: argparse.Namespace) -> int: print("\n".join(list_agents())) return 0 agents = None if not args.agents or args.agents == ["all"] else args.agents + kinds = ["code", "sessions"] if args.skill == "all" else [args.skill] try: - written = install_skill(args.repo, agents) + written = [] + for kind in kinds: + written.extend(install_skill(args.repo, agents, skill=kind)) except ValueError as exc: print(str(exc), file=sys.stderr) return 1 @@ -247,6 +250,8 @@ def build_parser() -> argparse.ArgumentParser: p_skill.add_argument("agents", nargs="*", help="Agent names, or 'all' (default: all)") p_skill.add_argument("--repo", default=".", help="Repository root to install into (default: cwd)") p_skill.add_argument("--list", action="store_true", help="List supported agents") + p_skill.add_argument("--skill", choices=["code", "sessions", "all"], default="code", + help="Which skill to install: code graph workflow, session recall, or both") p_skill.set_defaults(func=cmd_install_skill) return parser diff --git a/graphora/skills.py b/graphora/skills.py index be1bd5b..36cfc70 100644 --- a/graphora/skills.py +++ b/graphora/skills.py @@ -13,6 +13,8 @@ MARK_START = "" MARK_END = "" +SESSIONS_MARK_START = "" +SESSIONS_MARK_END = "" SKILL_BODY = """\ # Graphora: code graph, blast radius, and risk memory @@ -44,6 +46,41 @@ risk_top, find_symbol, and graph_stats as MCP tools. """ +SESSIONS_SKILL_BODY = """\ +# Graphora session recall: cross-agent work memory as a graph + +This machine keeps a Graphora graph of AI-agent work history: every session +(GitHub Copilot CLI, Claude Code, Codex CLI) with the files it touched, the +repo it worked in, and the PRs/issues/commits it referenced. Use it whenever +the user asks about past or parallel work, in ANY terminal, e.g.: + +- "which windows/sessions touched ?" +- "what's connected to PR ?" +- "what did I do in across all my agents?" +- "did Claude or Copilot work on this?" + +## Workflow + +1. **Refresh first** (fast, idempotent, safe to run every time): + `graphora sessions ingest --days 7` + Ingests every agent store found (Copilot, Claude Code, Codex) and skips + missing ones. No Docker required: with no FalkorDB server running it + automatically uses the embedded JSON backend. +2. **Then query**: + - `graphora sessions connected file ` — sessions that touched a file + - `graphora sessions connected ref ` — sessions around a PR/issue/commit + - `graphora sessions connected repo ` — everything in one repo +3. **Answer with a short digest**, one line per session: agent, summary, + relative time, and what connected it. Do not dump raw JSON. + +## Notes + +- Read-only on the agents' own stores; deterministic; no LLM in the pipeline. +- Sessions from different agents connect the moment they share a file, repo, + or PR — that is the point: cross-agent memory. +- Resume a Copilot session with `copilot --resume `. +""" + @dataclass(frozen=True) class SkillTarget: @@ -108,12 +145,31 @@ def list_agents() -> list[str]: return [t.agent for t in SKILL_TARGETS] -def install_skill(repo_root: str | Path, agents: list[str] | None = None) -> list[str]: - """Write the Graphora skill for the given agents (default: all). +SKILL_KINDS = { + "code": { + "body": SKILL_BODY, + "marks": (MARK_START, MARK_END), + "slug": "graphora", + "description": "Check the Graphora code graph (blast radius, risk memory, grounded review) before editing or committing code in this repository.", + }, + "sessions": { + "body": SESSIONS_SKILL_BODY, + "marks": (SESSIONS_MARK_START, SESSIONS_MARK_END), + "slug": "graphora-sessions", + "description": "Recall AI-agent work history (Copilot, Claude Code, Codex) from the Graphora session graph when asked about past or parallel sessions, files touched, or PRs.", + }, +} + + +def install_skill(repo_root: str | Path, agents: list[str] | None = None, skill: str = "code") -> list[str]: + """Write a Graphora skill ("code" or "sessions") for the given agents (default: all). Returns the list of files written, relative to the repo root. Idempotent: own files are overwritten, marked blocks are replaced in place. """ + if skill not in SKILL_KINDS: + raise ValueError(f"Unknown skill: {skill}. Known: {sorted(SKILL_KINDS)}") + kind = SKILL_KINDS[skill] root = Path(repo_root).resolve() wanted = set(agents) if agents else set(list_agents()) unknown = wanted - set(list_agents()) @@ -124,25 +180,28 @@ def install_skill(repo_root: str | Path, agents: list[str] | None = None) -> lis for target in SKILL_TARGETS: if target.agent not in wanted: continue - path = root / target.path + rel = target.path.replace("graphora", kind["slug"]) if target.style == "file" else target.path + path = root / rel if target.style == "file": + frontmatter = target.frontmatter.replace("graphora\n", f"{kind['slug']}\n") + frontmatter = frontmatter.replace(SKILL_KINDS["code"]["description"], kind["description"]) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(target.frontmatter + SKILL_BODY, encoding="utf-8") + path.write_text(frontmatter + kind["body"], encoding="utf-8") else: - _write_block(path) - rel = target.path + _write_block(path, kind["body"], kind["marks"]) if rel not in written: written.append(rel) return written -def _write_block(path: Path) -> None: - block = f"{MARK_START}\n{SKILL_BODY}{MARK_END}\n" +def _write_block(path: Path, body: str, marks: tuple[str, str]) -> None: + mark_start, mark_end = marks + block = f"{mark_start}\n{body}{mark_end}\n" if path.exists(): text = path.read_text(encoding="utf-8") - if MARK_START in text and MARK_END in text: - head, rest = text.split(MARK_START, 1) - _, tail = rest.split(MARK_END, 1) + if mark_start in text and mark_end in text: + head, rest = text.split(mark_start, 1) + _, tail = rest.split(mark_end, 1) path.write_text(head + block.rstrip("\n") + tail, encoding="utf-8") return separator = "" if text.endswith("\n\n") else ("\n" if text.endswith("\n") else "\n\n") diff --git a/tests/core/test_core_skills.py b/tests/core/test_core_skills.py index 38119c7..bcba543 100644 --- a/tests/core/test_core_skills.py +++ b/tests/core/test_core_skills.py @@ -93,3 +93,40 @@ def test_cli_install_skill_unknown_agent_fails(tmp_path: Path, capsys): from graphora.cli import main assert main(["install-skill", "clippy", "--repo", str(tmp_path)]) == 1 + + +def test_install_sessions_skill_writes_own_files(tmp_path: Path): + written = install_skill(tmp_path, ["claude-code", "cursor"], skill="sessions") + assert ".claude/skills/graphora-sessions/SKILL.md" in written + assert ".cursor/rules/graphora-sessions.mdc" in written + text = (tmp_path / ".claude/skills/graphora-sessions/SKILL.md").read_text() + assert "name: graphora-sessions" in text + assert "sessions ingest" in text + assert "cross-agent" in text.lower() + + +def test_sessions_and_code_skills_coexist_in_shared_file(tmp_path: Path): + install_skill(tmp_path, ["codex"], skill="code") + install_skill(tmp_path, ["codex"], skill="sessions") + text = (tmp_path / "AGENTS.md").read_text() + assert "" in text + assert "" in text + # re-install must not duplicate + install_skill(tmp_path, ["codex"], skill="sessions") + assert text.count("graphora-sessions:start") == (tmp_path / "AGENTS.md").read_text().count("graphora-sessions:start") + + +def test_unknown_skill_kind_raises(tmp_path: Path): + import pytest as _pytest + + with _pytest.raises(ValueError): + install_skill(tmp_path, ["codex"], skill="nope") + + +def test_cli_install_both_skills(tmp_path: Path, capsys): + from graphora.cli import main + + assert main(["install-skill", "copilot-cli", "--repo", str(tmp_path), "--skill", "all"]) == 0 + text = (tmp_path / "AGENTS.md").read_text() + assert "" in text + assert "" in text From d8162fe0267f1a77c8b86e5d64c99d11d3ba4524 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:10:11 +0300 Subject: [PATCH 13/13] Document multi-agent ingestion and the session-recall skill --- README.md | 22 +++++++++++++++------- USECASES.md | 9 +++++++-- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0879533..8651656 100644 --- a/README.md +++ b/README.md @@ -138,15 +138,16 @@ The longer Graphora runs on a repository, the smarter it gets. That compounds. ## Agent session memory: graph your work, not just your code -If you use the GitHub Copilot CLI across many terminal tabs, it already records every -session locally (`~/.copilot/session-store.db`): summaries, files touched, PR references. -Graphora ingests that as another data source, so sessions from different terminals become -connected the moment they touch the same file, repo, or PR: +AI coding agents already record every session locally: GitHub Copilot CLI +(`~/.copilot/session-store.db`), Claude Code (`~/.claude/projects/`), Codex CLI +(`~/.codex/sessions/`). Graphora ingests them all into **one graph**, so sessions from +different terminals · and different agents · become connected the moment they touch the +same file, repo, or PR. Cross-agent memory: "which agent touched build.yml?" ```bash -graphora sessions ingest --days 7 # load your session history into the graph +graphora sessions ingest --days 7 # all agents found (or --source copilot|claude|codex) -graphora sessions connected file build.yml # which windows touched this file? +graphora sessions connected file build.yml # which windows/agents touched this file? graphora sessions connected ref 275 # which sessions relate to PR 275? graphora sessions connected repo org/proj # everything that happened in one repo ``` @@ -158,7 +159,14 @@ graphora sessions ingest --days 7 --backend embedded graphora sessions connected file build.yml --backend embedded ``` -Read-only on the source, no LLM, idempotent. See [use case 6](USECASES.md). +Make it zero-effort: install the session-recall skill so your agents run these +commands themselves when you ask "which sessions touched this file?": + +```bash +graphora install-skill all --skill sessions # or --skill all for code + sessions +``` + +Read-only on the sources, no LLM, idempotent. See [use case 6](USECASES.md). ## Benchmarks diff --git a/USECASES.md b/USECASES.md index 6a809fb..b9a6f3c 100644 --- a/USECASES.md +++ b/USECASES.md @@ -212,9 +212,14 @@ graphora sessions connected repo org/proj # everything that happened in one 1. `pip install graphora-kg` 2. Have FalkorDB running (`docker run -d -p 6379:6379 falkordb/falkordb`), **or** skip Docker entirely and add `--backend embedded` to every command below. -3. `graphora sessions ingest --days 7` — safe to re-run anytime; it's idempotent. +3. `graphora sessions ingest --days 7` — ingests every agent store found on the machine + (Copilot CLI, Claude Code, Codex CLI), skips missing ones, safe to re-run anytime. + Use `--source copilot|claude|codex` to ingest just one. 4. Ask away: `graphora sessions connected file ` / `ref ` / `repo `. -5. Optional: `graphora serve-mcp --project agent-sessions` exposes your work history to + Each hit is tagged with the agent that did the work — cross-agent memory. +5. Zero-effort mode: `graphora install-skill all --skill sessions` teaches your agents + (22 supported) to run these commands themselves when you ask about past work. +6. Optional: `graphora serve-mcp --project agent-sessions` exposes your work history to any MCP-capable agent. Captured output: