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.
- devin_graph/__init__.py +1 -0
- devin_graph/cli.py +297 -0
- devin_graph/extract.py +300 -0
- devin_graph/identity.py +126 -0
- devin_graph/paths.py +92 -0
- devin_graph/query.py +234 -0
- devin_graph/store.py +273 -0
- devin_graph/view.py +144 -0
- devin_graph/vscdb.py +117 -0
- devin_graph-0.1.0.dist-info/METADATA +207 -0
- devin_graph-0.1.0.dist-info/RECORD +15 -0
- devin_graph-0.1.0.dist-info/WHEEL +5 -0
- devin_graph-0.1.0.dist-info/entry_points.txt +2 -0
- devin_graph-0.1.0.dist-info/licenses/LICENSE +21 -0
- devin_graph-0.1.0.dist-info/top_level.txt +1 -0
devin_graph/__init__.py
ADDED
|
@@ -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()
|