diff --git a/README.md b/README.md index f2e467e..8651656 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,38 @@ 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 + +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 # all agents found (or --source copilot|claude|codex) + +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 +``` + +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 +``` + +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 Measured on real repositories, fully deterministic, zero network. Full methodology and reproduction commands in [BENCHMARKS.md](./BENCHMARKS.md). diff --git a/USECASES.md b/USECASES.md index c9cf3c3..b9a6f3c 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,79 @@ 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 +``` + +**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` — 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 `. + 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: + +```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. diff --git a/graphora/cli.py b/graphora/cli.py index a6dadd5..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 @@ -144,6 +147,32 @@ def cmd_install_skill(args: argparse.Namespace) -> int: return 0 +def cmd_sessions_ingest(args: argparse.Namespace) -> int: + from graphora.sessions import ingest_sources + + store = open_store(args.project or "agent-sessions", backend=args.backend, host=args.host, port=args.port) + 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 + + +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,10 +231,27 @@ 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 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") + 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)") 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 @@ -218,6 +264,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) diff --git a/graphora/embedded.py b/graphora/embedded.py index acd8aae..047bf42 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,51 @@ 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, + props.get("agent", "")] + + 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"], diff --git a/graphora/sessions.py b/graphora/sessions.py new file mode 100644 index 0000000..bc87706 --- /dev/null +++ b/graphora/sessions.py @@ -0,0 +1,273 @@ +"""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 session stores that AI coding agents already maintain and writes +work-history nodes: + + (:Session {agent})-[:TOUCHED]->(:WorkFile) + (:Session)-[:IN_REPO]->(:Repo) + (:Session)-[:REFERENCES]->(:Ref {kind: pr|issue|commit}) + +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_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() + + +# --- 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" + 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 dict(_EMPTY) + 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": [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() + + +# --- 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, + {"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) + return { + "sessions": len(data["sessions"]), + "files_touched": len(data["files"]), + "refs": len(data["refs"]), + } + + +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], "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/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/graphora/store.py b/graphora/store.py index e51c577..eebe87f 100644 --- a/graphora/store.py +++ b/graphora/store.py @@ -106,6 +106,55 @@ 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.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, "agent": "", **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, 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, 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, s.agent + ORDER BY s.updated_at DESC""" + return self.query(cypher, {"v": value}) + # --- reads ------------------------------------------------------------ def stats(self) -> dict[str, int]: diff --git a/tests/core/test_core_sessions.py b/tests/core/test_core_sessions.py new file mode 100644 index 0000000..a797c57 --- /dev/null +++ b/tests/core/test_core_sessions.py @@ -0,0 +1,243 @@ +"""Session-store ingestion tests: embedded always, FalkorDB when available on :6379.""" + +import sqlite3 +from pathlib import Path + +import pytest + +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 + + +FALKORDB_UP = _falkordb_available() + + +@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(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): + 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) + # 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) + 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): + 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") + + +@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) 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