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: