devin-graph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
devin_graph/cli.py ADDED
@@ -0,0 +1,297 @@
1
+ """Thin CLI wrapper — all logic lives in the library modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from devin_internals import SchemaError
11
+ from devin_internals.parsers import SessionsStore
12
+
13
+ from devin_graph.paths import default_state_vscdb
14
+ from devin_graph.vscdb import extract_vscdb
15
+
16
+ from devin_graph import __version__
17
+ from devin_graph.paths import default_sessions_db
18
+ from devin_graph.query import (
19
+ project_detail,
20
+ projects_graph,
21
+ sessions_for_file,
22
+ sessions_for_tool,
23
+ shared_files,
24
+ tools_for_project,
25
+ )
26
+ from devin_graph.store import GraphStore
27
+
28
+ DEFAULT_GRAPH = "graph.db"
29
+
30
+
31
+ def _open_sessions(db_arg: str | None) -> SessionsStore:
32
+ path = Path(db_arg).expanduser() if db_arg else default_sessions_db()
33
+ if path is None:
34
+ print(
35
+ "devin-graph: no sessions.db found in the default locations; "
36
+ "pass --sessions-db",
37
+ file=sys.stderr,
38
+ )
39
+ raise SystemExit(2)
40
+ if not path.exists():
41
+ print(f"devin-graph: {path}: no such file", file=sys.stderr)
42
+ raise SystemExit(2)
43
+ try:
44
+ return SessionsStore(path)
45
+ except SchemaError as exc:
46
+ print(f"devin-graph: {exc}", file=sys.stderr)
47
+ raise SystemExit(2)
48
+
49
+
50
+ def _open_graph(graph_arg: str | None) -> GraphStore:
51
+ path = Path(graph_arg or DEFAULT_GRAPH).expanduser()
52
+ if not path.exists():
53
+ print(
54
+ f"devin-graph: {path}: no such graph file — build one with "
55
+ "'devin-graph build --graph graph.db'",
56
+ file=sys.stderr,
57
+ )
58
+ raise SystemExit(2)
59
+ return GraphStore(path)
60
+
61
+
62
+ def _add_graph(sub: argparse.ArgumentParser) -> None:
63
+ sub.add_argument(
64
+ "--graph", default=DEFAULT_GRAPH,
65
+ help="path to the graph database (default: ./graph.db)")
66
+
67
+
68
+ def build_parser() -> argparse.ArgumentParser:
69
+ p = argparse.ArgumentParser(
70
+ prog="devin-graph",
71
+ description="Knowledge graph over Devin sessions: projects, files "
72
+ "and tools as queryable nodes (read-only on Devin stores).",
73
+ )
74
+ p.add_argument(
75
+ "--version", action="version", version=f"%(prog)s {__version__}")
76
+ sub = p.add_subparsers(dest="command", required=True)
77
+
78
+ b = sub.add_parser("build", help="extract sessions.db → graph.db "
79
+ "(incremental)")
80
+ b.add_argument(
81
+ "--sessions-db",
82
+ help="path to Devin's sessions.db (default: auto-detect "
83
+ "%%APPDATA%%/devin/cli/sessions.db or the Linux/macOS equivalent)")
84
+ _add_graph(b)
85
+ b.add_argument(
86
+ "--vscdb",
87
+ help="path to the GUI state.vscdb for gui_session coverage "
88
+ "(default: auto-detect; pass 'none' to disable)")
89
+ b.add_argument("--json", action="store_true",
90
+ help="machine-readable JSON output")
91
+ b.set_defaults(func=_cmd_build)
92
+
93
+ q = sub.add_parser("query", help="canned queries over the graph")
94
+ qsub = q.add_subparsers(dest="what", required=True)
95
+ for name, helptext, argname in (
96
+ ("file", "which sessions touched a file", "path"),
97
+ ("tool", "which sessions/projects used a tool", "name"),
98
+ ("project", "sessions/tools/files of a project", "name"),
99
+ ):
100
+ sq = qsub.add_parser(name, help=helptext)
101
+ sq.add_argument(argname)
102
+ _add_graph(sq)
103
+ sq.add_argument("--json", action="store_true")
104
+ sq.set_defaults(func=_cmd_query)
105
+ sq = qsub.add_parser("projects-graph",
106
+ help="project adjacency JSON for visualization")
107
+ _add_graph(sq)
108
+ sq.add_argument("--json", action="store_true")
109
+ sq.set_defaults(func=_cmd_query)
110
+ sq = qsub.add_parser("shared-files",
111
+ help="files touched by two or more projects")
112
+ _add_graph(sq)
113
+ sq.add_argument("--json", action="store_true")
114
+ sq.set_defaults(func=_cmd_query)
115
+
116
+ e = sub.add_parser("export", help="dump nodes+edges (D3-friendly)")
117
+ e.add_argument("--format", choices=["json"], default="json")
118
+ _add_graph(e)
119
+ e.add_argument("--out", help="write to file instead of stdout")
120
+ e.set_defaults(func=_cmd_export)
121
+
122
+ sq2 = sub.add_parser(
123
+ "sql", help="read-only SQL over graph.db (SELECT/WITH only)")
124
+ sq2.add_argument("statement", help="a SELECT or WITH ... SELECT statement")
125
+ _add_graph(sq2)
126
+ sq2.add_argument("--json", action="store_true")
127
+ sq2.set_defaults(func=_cmd_sql)
128
+ v = sub.add_parser(
129
+ "view", help="self-contained HTML viewer (offline, no CDN)")
130
+ v.add_argument("--out", default="graph-view.html",
131
+ help="output file (default: graph-view.html)")
132
+ v.add_argument("--limit", type=int, default=500,
133
+ help="max nodes rendered (default 500; highest-degree "
134
+ "nodes are kept when truncating)")
135
+ _add_graph(v)
136
+ v.set_defaults(func=_cmd_view)
137
+ return p
138
+
139
+
140
+ def _cmd_build(args: argparse.Namespace) -> int:
141
+ graph_path = Path(args.graph).expanduser()
142
+ extras = []
143
+ vscdb = args.vscdb
144
+ if vscdb is None:
145
+ found = default_state_vscdb()
146
+ vscdb = str(found) if found else "none"
147
+ if vscdb != "none":
148
+ vpath = Path(vscdb).expanduser()
149
+ if vpath.is_file():
150
+ try:
151
+ extras.append(("vscdb", extract_vscdb(vpath)))
152
+ except ValueError as exc:
153
+ print(f"warning: {exc}", file=sys.stderr)
154
+ else:
155
+ print(f"warning: {vpath}: no such file — skipping GUI coverage",
156
+ file=sys.stderr)
157
+ with _open_sessions(args.sessions_db) as ss, \
158
+ GraphStore(graph_path) as gs:
159
+ res = gs.build(ss, extras=extras)
160
+ nodes = len(gs.nodes())
161
+ edges = len(gs.edges())
162
+ if args.json:
163
+ print(json.dumps({
164
+ "graph": str(graph_path),
165
+ "extracted": len(res.extracted),
166
+ "skipped": res.skipped,
167
+ "removed": len(res.removed),
168
+ "nodes": nodes,
169
+ "edges": edges,
170
+ }, indent=2))
171
+ else:
172
+ print(
173
+ f"extracted: {len(res.extracted)} · skipped: {res.skipped} · "
174
+ f"removed: {len(res.removed)} · nodes: {nodes} · edges: {edges} "
175
+ f"→ {graph_path}")
176
+ return 0
177
+
178
+
179
+ def _cmd_query(args: argparse.Namespace) -> int:
180
+ with _open_graph(args.graph) as gs:
181
+ if args.what == "file":
182
+ data = sessions_for_file(gs, args.path)
183
+ elif args.what == "tool":
184
+ data = sessions_for_tool(gs, args.name)
185
+ elif args.what == "project":
186
+ data = project_detail(gs, args.name)
187
+ elif args.what == "shared-files":
188
+ data = shared_files(gs)
189
+ else:
190
+ data = projects_graph(gs)
191
+ if args.json:
192
+ print(json.dumps(data, ensure_ascii=False, indent=2))
193
+ else:
194
+ _print_query(args.what, data)
195
+ return 0
196
+
197
+
198
+ def _print_query(what: str, data: dict) -> None:
199
+ if what == "shared-files":
200
+ if not data["files"]:
201
+ print("no files shared between projects")
202
+ for f in data["files"]:
203
+ print(f"{f['file']} ({len(f['projects'])} projects)")
204
+ print(f" projects: {', '.join(f['projects'])}")
205
+ print(f" sessions: {', '.join(f['sessions'])}")
206
+ return
207
+ if what == "projects-graph":
208
+ for n in data["nodes"]:
209
+ print(f"{n['id']} ({len(n['sessions'])} sessions)")
210
+ for link in data["links"]:
211
+ print(f"{link['source']} ↔ {link['target']} "
212
+ f"weight={link['weight']}")
213
+ return
214
+ if "files" in data and what == "file":
215
+ for f in data["files"]:
216
+ print(f"file: {f}")
217
+ for s in data.get("sessions", []):
218
+ print(f"{s['id']} {s.get('title') or 'Untitled'} "
219
+ f"({s.get('project') or '-'})")
220
+ if what == "tool" and data.get("projects"):
221
+ print("projects: " + ", ".join(data["projects"]))
222
+ if what == "project":
223
+ if data.get("tools"):
224
+ print("tools: " + ", ".join(data["tools"]))
225
+ for f in data.get("files", []):
226
+ print(f"file: {f}")
227
+ if what == "file":
228
+ for t in data.get("tool_calls", []):
229
+ print(f"call: {t['id']} → {t['file']}")
230
+
231
+
232
+ def _cmd_export(args: argparse.Namespace) -> int:
233
+ with _open_graph(args.graph) as gs:
234
+ data = gs.export()
235
+ text = json.dumps(data, ensure_ascii=False, indent=2)
236
+ if args.out:
237
+ Path(args.out).expanduser().write_text(text + "\n", encoding="utf-8")
238
+ print(f"exported {len(data['nodes'])} nodes, {len(data['edges'])} "
239
+ f"edges → {args.out}")
240
+ else:
241
+ print(text)
242
+ return 0
243
+
244
+
245
+ def _cmd_view(args: argparse.Namespace) -> int:
246
+ from devin_graph.view import write_view
247
+ with _open_graph(args.graph) as gs:
248
+ out = write_view(gs.export(), args.out, limit=args.limit)
249
+ print(f"wrote {out} ({out.stat().st_size:,} bytes) — open it in a browser")
250
+ return 0
251
+
252
+
253
+ def _cmd_sql(args: argparse.Namespace) -> int:
254
+ stmt = args.statement.strip().rstrip(";")
255
+ head = stmt.split(None, 1)[0].lower() if stmt else ""
256
+ if head not in ("select", "with"):
257
+ print("devin-graph: sql accepts SELECT/WITH only", file=sys.stderr)
258
+ return 2
259
+ import sqlite3
260
+
261
+ path = Path(args.graph).expanduser()
262
+ if not path.exists():
263
+ print(f"devin-graph: {path}: no such graph file", file=sys.stderr)
264
+ return 2
265
+ con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
266
+ try:
267
+ con.execute("PRAGMA query_only = ON")
268
+ cur = con.execute(stmt)
269
+ cols = [d[0] for d in cur.description or []]
270
+ rows = cur.fetchall()
271
+ except sqlite3.Error as exc:
272
+ print(f"devin-graph: {exc}", file=sys.stderr)
273
+ return 2
274
+ finally:
275
+ con.close()
276
+ if args.json:
277
+ print(json.dumps(
278
+ [dict(zip(cols, r)) for r in rows], ensure_ascii=False, indent=2))
279
+ else:
280
+ print(" ".join(cols))
281
+ for r in rows:
282
+ print(" ".join("" if v is None else str(v) for v in r))
283
+ return 0
284
+
285
+
286
+ def main(argv: list[str] | None = None) -> int:
287
+ for stream in (sys.stdout, sys.stderr):
288
+ try:
289
+ stream.reconfigure(encoding="utf-8", errors="replace")
290
+ except (AttributeError, ValueError):
291
+ pass
292
+ args = build_parser().parse_args(argv)
293
+ return args.func(args)
294
+
295
+
296
+ if __name__ == "__main__":
297
+ raise SystemExit(main())
devin_graph/extract.py ADDED
@@ -0,0 +1,300 @@
1
+ """Extract graph nodes/edges from a ``sessions.db`` (via ``SessionsStore``).
2
+
3
+ Node kinds: ``session``, ``project`` (the session's ``working_directory``),
4
+ ``file`` (a path touched by a tool call), ``tool`` (tool name),
5
+ ``tool_call`` (one row of ``tool_call_state`` — the ground truth of what the
6
+ agent actually invoked) and ``commit`` (a full git SHA referenced by a
7
+ tool-call payload, via ``devin_internals.commits``).
8
+
9
+ Edge kinds:
10
+
11
+ - ``runs_in`` session → project
12
+ - ``made_call`` session → tool_call
13
+ - ``call_used`` tool_call → tool
14
+ - ``tool_used`` session → tool
15
+ - ``file_touched`` tool_call → file
16
+ - ``produced`` session → commit (SHA seen in a ``git commit``/``git
17
+ push`` tool call — ``method="exact"``)
18
+ - ``referenced`` session → commit (SHA seen elsewhere — ``method="seen"``)
19
+ - ``gui_workspace`` gui_session → project (from ``state.vscdb`` — see
20
+ ``vscdb.py``; GUI sessions are keyed by their generated slug)
21
+
22
+ ``tool_call_json`` is marked *unstable* in SCHEMA.md, so payload decoding is
23
+ deliberately defensive: file paths are collected from any recognised path-ish
24
+ key anywhere in the payload, plus path-looking tokens inside command strings.
25
+ Relative paths resolve against the session's ``working_directory``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import posixpath
32
+ import re
33
+ from dataclasses import dataclass, field
34
+
35
+ from devin_internals.commits import commit_references
36
+ from typing import Any, Iterable, Iterator
37
+
38
+ NODE_KINDS = ("session", "project", "file", "tool", "tool_call", "commit",
39
+ "gui_session")
40
+ EDGE_KINDS = ("runs_in", "made_call", "call_used", "tool_used",
41
+ "file_touched", "produced", "referenced", "gui_workspace")
42
+
43
+ # Payload keys whose string value(s) are file/dir paths.
44
+ _PATH_KEYS = {
45
+ "path", "paths", "file", "files", "filename", "file_path", "filepath",
46
+ "fileName".lower(), "abs_path", "absolute_path", "target_file",
47
+ }
48
+
49
+ # Payload keys whose string value is a shell-like command to mine for paths.
50
+ _COMMAND_KEYS = {"command", "cmd", "script", "shell_command", "input"}
51
+
52
+ # Tool-name candidate keys, in priority order, checked at top level and one
53
+ # level deep (rawInput/raw_input/input carry the real tool input in ACP).
54
+ _TOOL_NAME_KEYS = ("name", "tool_name", "tool", "kind", "type", "title")
55
+ _NESTED_KEYS = ("rawInput", "raw_input", "input", "args")
56
+
57
+ # Path-looking token inside a command string: either contains a separator or
58
+ # is a bare filename with a recognisable code/docs extension.
59
+ _PATH_TOKEN_RE = re.compile(
60
+ r"(?:[A-Za-z]:[\\/]|\.{1,2}/|/)?[\w@+~.-]+(?:[\\/][\w@+~.-]+)+"
61
+ r"|[\w@+~.-]+\.(?:py|pyi|js|jsx|ts|tsx|mjs|cjs|json|toml|ya?ml|md|txt|rst"
62
+ r"|rs|go|c|h|cc|cpp|hpp|java|kt|rb|sh|bash|bat|ps1|sql|html?|css|scss|xml"
63
+ r"|csv|cfg|ini|env|lock|ipynb)\b"
64
+ )
65
+ _TOKEN_STRIP = "\"'`.,;:()[]{}"
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Node:
70
+ kind: str
71
+ key: str
72
+ attrs: dict = field(default_factory=dict, compare=False)
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Edge:
77
+ kind: str
78
+ src: tuple[str, str] # (node kind, node key)
79
+ dst: tuple[str, str]
80
+ attrs: dict = field(default_factory=dict, compare=False)
81
+
82
+
83
+ @dataclass
84
+ class Extraction:
85
+ """Nodes/edges produced from one or more sessions (deduplicated)."""
86
+
87
+ nodes: list[Node] = field(default_factory=list)
88
+ edges: list[Edge] = field(default_factory=list)
89
+
90
+ def merged(self) -> "Extraction":
91
+ seen_n: dict[tuple[str, str], Node] = {}
92
+ seen_e: set[tuple] = set()
93
+ out = Extraction()
94
+ for n in self.nodes:
95
+ seen_n.setdefault((n.kind, n.key), n)
96
+ out.nodes = list(seen_n.values())
97
+ for e in self.edges:
98
+ k = (e.kind, e.src, e.dst)
99
+ if k not in seen_e:
100
+ seen_e.add(k)
101
+ out.edges.append(e)
102
+ return out
103
+
104
+
105
+ def normalize_path(raw: Any) -> str | None:
106
+ """Canonical path string: ``/`` separators, no ``.`` segments, no quotes.
107
+
108
+ Returns ``None`` for empty/non-string input.
109
+ """
110
+ if not isinstance(raw, str):
111
+ return None
112
+ p = raw.strip().strip("\"'").strip()
113
+ if not p:
114
+ return None
115
+ p = p.replace("\\", "/")
116
+ is_abs = p.startswith("/") or re.match(r"^[A-Za-z]:/", p) is not None
117
+ p = posixpath.normpath(p)
118
+ if p == ".":
119
+ return None
120
+ if not is_abs and p.startswith("/"):
121
+ p = p.lstrip("/")
122
+ return p or None
123
+
124
+
125
+ def _is_absolute(p: str) -> bool:
126
+ return p.startswith("/") or re.match(r"^[A-Za-z]:/", p) is not None
127
+
128
+
129
+ def _resolve(path: str, cwd: str | None) -> str:
130
+ """Anchor relative paths to the session's working directory."""
131
+ if _is_absolute(path) or not cwd:
132
+ return path
133
+ base = normalize_path(cwd) or ""
134
+ return posixpath.normpath(f"{base}/{path}") if base else path
135
+
136
+
137
+ def parse_payload(text: str | None) -> dict | None:
138
+ """Decode a ``tool_call_*_json`` payload; ``None`` on anything unusable."""
139
+ if not text:
140
+ return None
141
+ try:
142
+ data = json.loads(text)
143
+ except (TypeError, ValueError):
144
+ return None
145
+ return data if isinstance(data, dict) else None
146
+
147
+
148
+ def _walk_strings(node: Any) -> Iterator[str]:
149
+ if isinstance(node, dict):
150
+ for v in node.values():
151
+ yield from _walk_strings(v)
152
+ elif isinstance(node, list):
153
+ for v in node:
154
+ yield from _walk_strings(v)
155
+ elif isinstance(node, str):
156
+ yield node
157
+
158
+
159
+ def _iter_keyed(node: Any) -> Iterator[tuple[str, Any]]:
160
+ """Yield ``(lowercased_key, value)`` for every dict entry, recursively."""
161
+ if isinstance(node, dict):
162
+ for k, v in node.items():
163
+ yield str(k).lower(), v
164
+ yield from _iter_keyed(v)
165
+ elif isinstance(node, list):
166
+ for v in node:
167
+ yield from _iter_keyed(v)
168
+
169
+
170
+ def extract_tool_name(payload: dict) -> str | None:
171
+ """Best-effort tool name from an unstable payload shape."""
172
+ for key in _TOOL_NAME_KEYS:
173
+ v = payload.get(key)
174
+ if isinstance(v, str) and v.strip():
175
+ return v.strip()
176
+ for nest in _NESTED_KEYS:
177
+ sub = payload.get(nest)
178
+ if isinstance(sub, dict):
179
+ for key in _TOOL_NAME_KEYS:
180
+ v = sub.get(key)
181
+ if isinstance(v, str) and v.strip():
182
+ return v.strip()
183
+ return None
184
+
185
+
186
+ def _paths_from_command(text: str) -> set[str]:
187
+ found = set()
188
+ for m in _PATH_TOKEN_RE.finditer(text):
189
+ tok = m.group(0).strip(_TOKEN_STRIP)
190
+ preceded_by_scheme = re.search(
191
+ r"[A-Za-z][\w+.-]*:/+$", text[: m.start()]) is not None
192
+ if (not tok or "://" in tok or tok.startswith("//")
193
+ or preceded_by_scheme):
194
+ continue
195
+ if tok.lstrip("./").startswith("-"): # a flag, not a path
196
+ continue
197
+ norm = normalize_path(tok)
198
+ if norm:
199
+ found.add(norm)
200
+ return found
201
+
202
+
203
+ def extract_paths(payload: dict) -> set[str]:
204
+ """All file/dir paths referenced by a tool-call payload (normalized)."""
205
+ found: set[str] = set()
206
+ for key, value in _iter_keyed(payload):
207
+ if key in _PATH_KEYS:
208
+ if isinstance(value, str):
209
+ norm = normalize_path(value)
210
+ if norm and "://" not in norm:
211
+ found.add(norm)
212
+ elif isinstance(value, (list, dict)):
213
+ for s in _walk_strings(value):
214
+ norm = normalize_path(s)
215
+ if norm and "://" not in norm:
216
+ found.add(norm)
217
+ elif key in _COMMAND_KEYS and isinstance(value, str):
218
+ found |= _paths_from_command(value)
219
+ return found
220
+
221
+
222
+ def extract_session(session, tool_calls: Iterable) -> Extraction:
223
+ """One session → nodes + edges.
224
+
225
+ ``session`` is a ``devin_internals.parsers.Session``; ``tool_calls`` are
226
+ its ``ToolCallState`` rows.
227
+ """
228
+ tool_calls = list(tool_calls)
229
+ ex = Extraction()
230
+ sid = session.id
231
+ cwd = normalize_path(session.working_directory)
232
+
233
+ ex.nodes.append(Node("session", sid, {
234
+ "title": session.title,
235
+ "model": session.model,
236
+ "created_at": session.created_at,
237
+ "last_activity_at": session.last_activity_at,
238
+ "hidden": session.hidden,
239
+ }))
240
+ if cwd:
241
+ ex.nodes.append(Node("project", cwd, {"name": posixpath.basename(cwd)}))
242
+ ex.edges.append(Edge("runs_in", ("session", sid), ("project", cwd)))
243
+
244
+ for tc in tool_calls:
245
+ call_key = f"{sid}:{tc.tool_call_id}"
246
+ ex.nodes.append(Node("tool_call", call_key, {
247
+ "tool_call_id": tc.tool_call_id,
248
+ "session_id": sid,
249
+ }))
250
+ ex.edges.append(
251
+ Edge("made_call", ("session", sid), ("tool_call", call_key)))
252
+
253
+ # Merge update then call payloads (call wins); tolerate NULL/junk.
254
+ payload = {}
255
+ for raw in (tc.tool_call_update_json, tc.tool_call_json):
256
+ data = parse_payload(raw)
257
+ if data:
258
+ payload = {**payload, **data}
259
+
260
+ tool = extract_tool_name(payload) if payload else None
261
+ if tool:
262
+ ex.nodes.append(Node("tool", tool, {}))
263
+ ex.edges.append(
264
+ Edge("call_used", ("tool_call", call_key), ("tool", tool)))
265
+ ex.edges.append(
266
+ Edge("tool_used", ("session", sid), ("tool", tool)))
267
+
268
+ for path in sorted(extract_paths(payload)):
269
+ resolved = _resolve(path, cwd)
270
+ ex.nodes.append(
271
+ Node("file", resolved, {"name": posixpath.basename(resolved)}))
272
+ ex.edges.append(Edge(
273
+ "file_touched", ("tool_call", call_key), ("file", resolved)))
274
+
275
+ # commit attribution: "produced" only when the SHA shows in a git
276
+ # commit/push tool call (exact); any other mention is "referenced".
277
+ for ref in commit_references(tool_calls):
278
+ text = " ".join(
279
+ p or "" for r in tool_calls if r.tool_call_id == ref.tool_call_id
280
+ for p in (r.tool_call_json, r.tool_call_update_json))
281
+ produced = "git commit" in text or "git push" in text
282
+ ex.nodes.append(Node("commit", ref.sha, {
283
+ "repo_url": ref.repo_url, "short": ref.sha[:7]}))
284
+ ex.edges.append(Edge(
285
+ "produced" if produced else "referenced",
286
+ ("session", sid), ("commit", ref.sha),
287
+ {"method": "exact" if produced else "seen",
288
+ "tool_call_id": ref.tool_call_id}))
289
+
290
+ return ex
291
+
292
+
293
+ def extract_all(store) -> Extraction:
294
+ """Every session in ``store`` → merged, deduplicated graph."""
295
+ combined = Extraction()
296
+ for session in store.sessions():
297
+ part = extract_session(session, store.tool_call_state(session.id))
298
+ combined.nodes.extend(part.nodes)
299
+ combined.edges.extend(part.edges)
300
+ return combined.merged()