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/store.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Persistent knowledge-graph store: a local SQLite ``graph.db``.
|
|
2
|
+
|
|
3
|
+
Layout: ``nodes`` / ``edges`` keyed on composite ids (``"kind:key"``), an
|
|
4
|
+
``extracted_sessions`` ledger for incremental re-extract, and a ``meta`` table
|
|
5
|
+
recording which source DB the graph was built from.
|
|
6
|
+
|
|
7
|
+
Incremental rule: a session is re-extracted only when its
|
|
8
|
+
``last_activity_at`` marker changed (or it is new); sessions deleted from the
|
|
9
|
+
source are removed; shared nodes (project/file/tool) left with no incident
|
|
10
|
+
edges are pruned. Nothing is ever written to the source store — the caller
|
|
11
|
+
hands in a read-only ``SessionsStore``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sqlite3
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from devin_graph import identity
|
|
22
|
+
from devin_graph.extract import Extraction, extract_session
|
|
23
|
+
|
|
24
|
+
_SCHEMA = """
|
|
25
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
kind TEXT NOT NULL,
|
|
28
|
+
key TEXT NOT NULL,
|
|
29
|
+
owner TEXT,
|
|
30
|
+
attrs TEXT NOT NULL DEFAULT '{}'
|
|
31
|
+
);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
|
|
33
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
34
|
+
kind TEXT NOT NULL,
|
|
35
|
+
src TEXT NOT NULL,
|
|
36
|
+
dst TEXT NOT NULL,
|
|
37
|
+
owner TEXT,
|
|
38
|
+
attrs TEXT NOT NULL DEFAULT '{}',
|
|
39
|
+
PRIMARY KEY (kind, src, dst)
|
|
40
|
+
);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst);
|
|
43
|
+
CREATE TABLE IF NOT EXISTS extracted_sessions (
|
|
44
|
+
session_id TEXT PRIMARY KEY,
|
|
45
|
+
last_activity_at INTEGER NOT NULL
|
|
46
|
+
);
|
|
47
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
48
|
+
key TEXT PRIMARY KEY,
|
|
49
|
+
value TEXT NOT NULL
|
|
50
|
+
);
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
# Node kinds owned by a single session (safe to delete on re-extract).
|
|
54
|
+
_OWNED_KINDS = ("session", "tool_call")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class StoredNode:
|
|
59
|
+
id: str # "kind:key"
|
|
60
|
+
kind: str
|
|
61
|
+
key: str
|
|
62
|
+
attrs: dict
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class StoredEdge:
|
|
67
|
+
kind: str
|
|
68
|
+
src: str # "kind:key"
|
|
69
|
+
dst: str
|
|
70
|
+
attrs: dict
|
|
71
|
+
owner: str | None = None # session_id that produced the edge
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class BuildResult:
|
|
76
|
+
extracted: list[str] = field(default_factory=list)
|
|
77
|
+
removed: list[str] = field(default_factory=list)
|
|
78
|
+
skipped: int = 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _node_id(kind: str, key: str) -> str:
|
|
82
|
+
return f"{kind}:{key}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class GraphStore:
|
|
86
|
+
"""Read/write handle on a ``graph.db`` file (created on demand)."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, path: str | Path) -> None:
|
|
89
|
+
self.path = Path(path).expanduser()
|
|
90
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
self._con = sqlite3.connect(self.path)
|
|
92
|
+
self._con.row_factory = sqlite3.Row
|
|
93
|
+
self._con.executescript(_SCHEMA)
|
|
94
|
+
|
|
95
|
+
# -- lifecycle ---------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def close(self) -> None:
|
|
98
|
+
self._con.close()
|
|
99
|
+
|
|
100
|
+
def __enter__(self) -> "GraphStore":
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(self, *exc: object) -> None:
|
|
104
|
+
self.close()
|
|
105
|
+
|
|
106
|
+
# -- read API ------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def nodes(self, kind: str | None = None) -> list[StoredNode]:
|
|
109
|
+
sql = "SELECT id, kind, key, attrs FROM nodes"
|
|
110
|
+
args: tuple = ()
|
|
111
|
+
if kind is not None:
|
|
112
|
+
sql += " WHERE kind = ?"
|
|
113
|
+
args = (kind,)
|
|
114
|
+
return [
|
|
115
|
+
StoredNode(r["id"], r["kind"], r["key"], json.loads(r["attrs"]))
|
|
116
|
+
for r in self._con.execute(sql + " ORDER BY id", args)
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
def edges(self, kind: str | None = None) -> list[StoredEdge]:
|
|
120
|
+
sql = "SELECT kind, src, dst, owner, attrs FROM edges"
|
|
121
|
+
args: tuple = ()
|
|
122
|
+
if kind is not None:
|
|
123
|
+
sql += " WHERE kind = ?"
|
|
124
|
+
args = (kind,)
|
|
125
|
+
return [
|
|
126
|
+
StoredEdge(r["kind"], r["src"], r["dst"],
|
|
127
|
+
json.loads(r["attrs"]), r["owner"])
|
|
128
|
+
for r in self._con.execute(sql + " ORDER BY kind, src, dst", args)
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
def node(self, node_id: str) -> StoredNode | None:
|
|
132
|
+
row = self._con.execute(
|
|
133
|
+
"SELECT id, kind, key, attrs FROM nodes WHERE id = ?",
|
|
134
|
+
(node_id,),
|
|
135
|
+
).fetchone()
|
|
136
|
+
if row is None:
|
|
137
|
+
return None
|
|
138
|
+
return StoredNode(row["id"], row["kind"], row["key"],
|
|
139
|
+
json.loads(row["attrs"]))
|
|
140
|
+
|
|
141
|
+
def out_edges(self, node_id: str) -> list[StoredEdge]:
|
|
142
|
+
return [
|
|
143
|
+
StoredEdge(r["kind"], r["src"], r["dst"],
|
|
144
|
+
json.loads(r["attrs"]), r["owner"])
|
|
145
|
+
for r in self._con.execute(
|
|
146
|
+
"SELECT kind, src, dst, owner, attrs FROM edges WHERE src = ?"
|
|
147
|
+
" ORDER BY kind, dst",
|
|
148
|
+
(node_id,),
|
|
149
|
+
)
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
def in_edges(self, node_id: str) -> list[StoredEdge]:
|
|
153
|
+
return [
|
|
154
|
+
StoredEdge(r["kind"], r["src"], r["dst"],
|
|
155
|
+
json.loads(r["attrs"]), r["owner"])
|
|
156
|
+
for r in self._con.execute(
|
|
157
|
+
"SELECT kind, src, dst, owner, attrs FROM edges WHERE dst = ?"
|
|
158
|
+
" ORDER BY kind, src",
|
|
159
|
+
(node_id,),
|
|
160
|
+
)
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
# -- build ---------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def build(self, sessions_store,
|
|
166
|
+
extras: list[tuple[str, "Extraction"]] | None = None
|
|
167
|
+
) -> BuildResult:
|
|
168
|
+
"""Incrementally (re)extract every session in ``sessions_store``.
|
|
169
|
+
|
|
170
|
+
``extras`` are ``(owner, Extraction)`` pairs merged under their own
|
|
171
|
+
owner key (e.g. ``"vscdb"`` for GUI coverage) so rebuilds replace
|
|
172
|
+
them atomically without touching session-owned rows.
|
|
173
|
+
"""
|
|
174
|
+
result = BuildResult()
|
|
175
|
+
seen = {
|
|
176
|
+
r["session_id"]: r["last_activity_at"]
|
|
177
|
+
for r in self._con.execute(
|
|
178
|
+
"SELECT session_id, last_activity_at FROM extracted_sessions")
|
|
179
|
+
}
|
|
180
|
+
current_ids = set()
|
|
181
|
+
sessions = sessions_store.sessions()
|
|
182
|
+
with self._con:
|
|
183
|
+
for s in sessions:
|
|
184
|
+
current_ids.add(s.id)
|
|
185
|
+
if seen.get(s.id) == s.last_activity_at:
|
|
186
|
+
result.skipped += 1
|
|
187
|
+
continue
|
|
188
|
+
self._delete_owned(s.id)
|
|
189
|
+
self._insert(extract_session(
|
|
190
|
+
s, sessions_store.tool_call_state(s.id)), owner=s.id)
|
|
191
|
+
self._con.execute(
|
|
192
|
+
"INSERT OR REPLACE INTO extracted_sessions"
|
|
193
|
+
" (session_id, last_activity_at) VALUES (?, ?)",
|
|
194
|
+
(s.id, s.last_activity_at),
|
|
195
|
+
)
|
|
196
|
+
result.extracted.append(s.id)
|
|
197
|
+
|
|
198
|
+
for sid in sorted(set(seen) - current_ids):
|
|
199
|
+
self._delete_owned(sid)
|
|
200
|
+
self._con.execute(
|
|
201
|
+
"DELETE FROM extracted_sessions WHERE session_id = ?",
|
|
202
|
+
(sid,))
|
|
203
|
+
result.removed.append(sid)
|
|
204
|
+
|
|
205
|
+
for owner, ex in extras or []:
|
|
206
|
+
self._delete_owned(owner)
|
|
207
|
+
self._insert(ex, owner=owner)
|
|
208
|
+
self._prune_orphans()
|
|
209
|
+
prov = identity.provenance()
|
|
210
|
+
for key, value in (
|
|
211
|
+
("source_db", str(sessions_store.path)),
|
|
212
|
+
("schema_version",
|
|
213
|
+
str(sessions_store.schema_info["schema_version"])),
|
|
214
|
+
("machine_id", prov["machine_id"]),
|
|
215
|
+
("profile", prov["profile"]),
|
|
216
|
+
):
|
|
217
|
+
self._con.execute(
|
|
218
|
+
"INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)",
|
|
219
|
+
(key, value))
|
|
220
|
+
result.extracted.sort()
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
def _delete_owned(self, session_id: str) -> None:
|
|
224
|
+
self._con.execute("DELETE FROM edges WHERE owner = ?", (session_id,))
|
|
225
|
+
self._con.execute("DELETE FROM nodes WHERE owner = ?", (session_id,))
|
|
226
|
+
|
|
227
|
+
def _insert(self, ex: Extraction, owner: str) -> None:
|
|
228
|
+
for n in ex.merged().nodes:
|
|
229
|
+
node_owner = owner if n.kind in _OWNED_KINDS else None
|
|
230
|
+
self._con.execute(
|
|
231
|
+
"INSERT OR REPLACE INTO nodes(id, kind, key, owner, attrs)"
|
|
232
|
+
" VALUES (?, ?, ?, ?, ?)",
|
|
233
|
+
(_node_id(n.kind, n.key), n.kind, n.key, node_owner,
|
|
234
|
+
json.dumps(n.attrs, sort_keys=True)),
|
|
235
|
+
)
|
|
236
|
+
for e in ex.edges:
|
|
237
|
+
self._con.execute(
|
|
238
|
+
"INSERT OR REPLACE INTO edges(kind, src, dst, owner, attrs)"
|
|
239
|
+
" VALUES (?, ?, ?, ?, ?)",
|
|
240
|
+
(e.kind, _node_id(*e.src), _node_id(*e.dst), owner,
|
|
241
|
+
json.dumps(e.attrs, sort_keys=True)),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _prune_orphans(self) -> None:
|
|
245
|
+
"""Drop shared nodes (no owner) left with no incident edges."""
|
|
246
|
+
self._con.execute(
|
|
247
|
+
"DELETE FROM nodes WHERE owner IS NULL"
|
|
248
|
+
" AND id NOT IN (SELECT src FROM edges)"
|
|
249
|
+
" AND id NOT IN (SELECT dst FROM edges)"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# -- export --------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def export(self) -> dict:
|
|
255
|
+
"""Deterministic D3-friendly dump: ``{meta, nodes, edges}``."""
|
|
256
|
+
meta = {
|
|
257
|
+
r["key"]: r["value"]
|
|
258
|
+
for r in self._con.execute("SELECT key, value FROM meta")
|
|
259
|
+
}
|
|
260
|
+
schema_version = meta.get("schema_version")
|
|
261
|
+
if schema_version is not None:
|
|
262
|
+
meta["schema_version"] = int(schema_version)
|
|
263
|
+
return {
|
|
264
|
+
"meta": meta,
|
|
265
|
+
"nodes": [
|
|
266
|
+
{"id": n.id, "kind": n.kind, "key": n.key, **n.attrs}
|
|
267
|
+
for n in self.nodes()
|
|
268
|
+
],
|
|
269
|
+
"edges": [
|
|
270
|
+
{"kind": e.kind, "source": e.src, "target": e.dst, **e.attrs}
|
|
271
|
+
for e in self.edges()
|
|
272
|
+
],
|
|
273
|
+
}
|
devin_graph/view.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""GR-2: self-contained HTML viewer for graph.db.
|
|
2
|
+
|
|
3
|
+
``devin-graph view`` renders the graph (or a filtered slice) into a single
|
|
4
|
+
HTML file: embedded JSON + a small vanilla-JS force layout on SVG. No CDN,
|
|
5
|
+
no external assets — it works offline by design, which is the whole point
|
|
6
|
+
of the ecosystem. Large graphs are downsampled: ``--limit`` caps nodes and
|
|
7
|
+
the viewer warns when it truncated.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import html
|
|
13
|
+
import json
|
|
14
|
+
import random
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
_KIND_COLORS = {
|
|
19
|
+
"session": "#4f8ef7",
|
|
20
|
+
"project": "#f7a94f",
|
|
21
|
+
"file": "#5cb870",
|
|
22
|
+
"tool": "#b06ef7",
|
|
23
|
+
"tool_call": "#7a8599",
|
|
24
|
+
"commit": "#f75f5f",
|
|
25
|
+
"gui_session": "#4fc3c3",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_PAGE = """<!DOCTYPE html>
|
|
29
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
30
|
+
<title>devin-graph view</title>
|
|
31
|
+
<style>
|
|
32
|
+
body{{font-family:system-ui,sans-serif;margin:0;background:#0e1116;color:#dde3ee}}
|
|
33
|
+
header{{padding:10px 16px;border-bottom:1px solid #2a3242;font-size:13px}}
|
|
34
|
+
#stats{{color:#8b96ab}}
|
|
35
|
+
svg{{display:block;width:100vw;height:calc(100vh - 46px)}}
|
|
36
|
+
circle{{stroke:#0e1116;stroke-width:1}}
|
|
37
|
+
line{{stroke:#2a3242;stroke-width:.6;opacity:.7}}
|
|
38
|
+
#tip{{position:fixed;pointer-events:none;background:#1c2330;border:1px solid #3a4458;
|
|
39
|
+
padding:6px 9px;border-radius:4px;font-size:12px;display:none;max-width:420px}}
|
|
40
|
+
#legend span{{display:inline-block;margin-right:12px}}
|
|
41
|
+
.dot{{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px}}
|
|
42
|
+
#filter{{margin-left:14px;background:#1c2330;color:#dde3ee;border:1px solid #3a4458;
|
|
43
|
+
border-radius:4px;padding:3px 6px}}
|
|
44
|
+
</style></head><body>
|
|
45
|
+
<header><b>devin-graph</b> — {title}
|
|
46
|
+
<select id="filter"><option value="">all kinds</option>{options}</select>
|
|
47
|
+
<span id="stats"></span></header>
|
|
48
|
+
<div id="legend" style="padding:6px 16px;font-size:12px">{legend}</div>
|
|
49
|
+
<svg id="g"></svg><div id="tip"></div>
|
|
50
|
+
<script>
|
|
51
|
+
const DATA = {data};
|
|
52
|
+
const nodes = DATA.nodes, edges = DATA.edges;
|
|
53
|
+
const idx = new Map(nodes.map((n,i)=>[n.id,i]));
|
|
54
|
+
const links = edges.filter(e=>idx.has(e.source)&&idx.has(e.target))
|
|
55
|
+
.map(e=>({{s:idx.get(e.source),t:idx.get(e.target)}}));
|
|
56
|
+
const svg = document.getElementById('g'), tip = document.getElementById('tip');
|
|
57
|
+
const W=innerWidth,H=innerHeight-46;
|
|
58
|
+
nodes.forEach((n,i)=>{{n.x=W/2+Math.cos(i)*80+Math.random()*40;
|
|
59
|
+
n.y=H/2+Math.sin(i)*80+Math.random()*40;n.vx=0;n.vy=0;}});
|
|
60
|
+
const NS='http://www.w3.org/2000/svg';
|
|
61
|
+
const gLines=document.createElementNS(NS,'g'),gNodes=document.createElementNS(NS,'g');
|
|
62
|
+
svg.appendChild(gLines);svg.appendChild(gNodes);
|
|
63
|
+
const lEls=links.map(l=>{{const e=document.createElementNS(NS,'line');gLines.appendChild(e);return e;}});
|
|
64
|
+
const nEls=nodes.map((n,i)=>{{const c=document.createElementNS(NS,'circle');
|
|
65
|
+
c.setAttribute('r',n.kind==='session'?6:(n.kind==='project'?7:4));
|
|
66
|
+
c.setAttribute('fill',DATA.colors[n.kind]||'#8b96ab');
|
|
67
|
+
c.onmousemove=ev=>{{tip.style.display='block';tip.style.left=ev.clientX+12+'px';
|
|
68
|
+
tip.style.top=ev.clientY+12+'px';tip.innerHTML=n.tip;}};
|
|
69
|
+
c.onmouseleave=()=>tip.style.display='none';
|
|
70
|
+
gNodes.appendChild(c);return c;}});
|
|
71
|
+
function tick(){{
|
|
72
|
+
for(let i=0;i<nodes.length;i++)for(let j=i+1;j<nodes.length;j++){{
|
|
73
|
+
const a=nodes[i],b=nodes[j];let dx=a.x-b.x,dy=a.y-b.y,d2=dx*dx+dy*dy+0.01;
|
|
74
|
+
if(d2<40000){{const f=30/d2;a.vx+=dx*f;b.vx-=dx*f;a.vy+=dy*f;b.vy-=dy*f;}}}}
|
|
75
|
+
for(const l of links){{const a=nodes[l.s],b=nodes[l.t];
|
|
76
|
+
let dx=b.x-a.x,dy=b.y-a.y,d=Math.sqrt(dx*dx+dy*dy)||1,f=(d-60)/d*0.04;
|
|
77
|
+
a.vx+=dx*f;b.vx-=dx*f;a.vy+=dy*f;b.vy-=dy*f;}}
|
|
78
|
+
for(const n of nodes){{n.vx+=(W/2-n.x)*0.0005;n.vy+=(H/2-n.y)*0.0005;
|
|
79
|
+
n.vx*=0.85;n.vy*=0.85;n.x+=n.vx;n.y+=n.vy;}}
|
|
80
|
+
lEls.forEach((e,i)=>{{const l=links[i];
|
|
81
|
+
e.setAttribute('x1',nodes[l.s].x);e.setAttribute('y1',nodes[l.s].y);
|
|
82
|
+
e.setAttribute('x2',nodes[l.t].x);e.setAttribute('y2',nodes[l.t].y);}});
|
|
83
|
+
nEls.forEach((e,i)=>{{e.setAttribute('cx',nodes[i].x);e.setAttribute('cy',nodes[i].y);}});
|
|
84
|
+
}}
|
|
85
|
+
let frames=0;
|
|
86
|
+
(function loop(){{tick();if(++frames<400)requestAnimationFrame(loop);}})();
|
|
87
|
+
document.getElementById('stats').textContent=
|
|
88
|
+
` · ${{nodes.length}} nodes · ${{links.length}} edges${{DATA.truncated?' · TRUNCATED (--limit)':''}}`;
|
|
89
|
+
document.getElementById('filter').onchange=ev=>{{
|
|
90
|
+
const k=ev.target.value;
|
|
91
|
+
nEls.forEach((e,i)=>{{e.style.opacity=(!k||nodes[i].kind===k)?1:0.08;}});
|
|
92
|
+
lEls.forEach((e,i)=>{{const l=links[i];
|
|
93
|
+
e.style.opacity=(!k||nodes[l.s].kind===k||nodes[l.t].kind===k)?0.7:0.04;}});
|
|
94
|
+
}};
|
|
95
|
+
</script></body></html>"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_view(export: dict[str, Any], *, limit: int = 500,
|
|
99
|
+
seed: int = 42) -> str:
|
|
100
|
+
"""graph.db export dict → self-contained HTML page."""
|
|
101
|
+
nodes = export.get("nodes", [])
|
|
102
|
+
edges = export.get("edges", [])
|
|
103
|
+
truncated = len(nodes) > limit
|
|
104
|
+
if truncated:
|
|
105
|
+
# keep the highest-degree nodes so the slice stays meaningful
|
|
106
|
+
degree: dict[str, int] = {}
|
|
107
|
+
for e in edges:
|
|
108
|
+
degree[e["source"]] = degree.get(e["source"], 0) + 1
|
|
109
|
+
degree[e["target"]] = degree.get(e["target"], 0) + 1
|
|
110
|
+
rng = random.Random(seed)
|
|
111
|
+
keep = {n["id"] for n in sorted(
|
|
112
|
+
nodes, key=lambda n: -degree.get(n["id"], 0))[:limit]}
|
|
113
|
+
nodes = [n for n in nodes if n["id"] in keep]
|
|
114
|
+
edges = [e for e in edges
|
|
115
|
+
if e["source"] in keep and e["target"] in keep]
|
|
116
|
+
|
|
117
|
+
view_nodes = [{
|
|
118
|
+
"id": n["id"], "kind": n["kind"],
|
|
119
|
+
"tip": ("<b>" + html.escape(n["kind"]) + "</b> "
|
|
120
|
+
+ html.escape(str(n.get("key", n["id"]))[:200])),
|
|
121
|
+
} for n in nodes]
|
|
122
|
+
data = {
|
|
123
|
+
"nodes": view_nodes,
|
|
124
|
+
"edges": edges,
|
|
125
|
+
"colors": _KIND_COLORS,
|
|
126
|
+
"truncated": truncated,
|
|
127
|
+
}
|
|
128
|
+
kinds = sorted({n["kind"] for n in view_nodes})
|
|
129
|
+
legend = "".join(
|
|
130
|
+
f'<span><span class="dot" style="background:{_KIND_COLORS.get(k, "#8b96ab")}"></span>{k}</span>'
|
|
131
|
+
for k in kinds)
|
|
132
|
+
options = "".join(f'<option value="{k}">{k}</option>' for k in kinds)
|
|
133
|
+
src = export.get("meta", {}).get("source_db", "graph.db")
|
|
134
|
+
return _PAGE.format(
|
|
135
|
+
title=html.escape(src),
|
|
136
|
+
data=json.dumps(data, separators=(",", ":")),
|
|
137
|
+
legend=legend, options=options)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def write_view(export: dict[str, Any], out: str | Path,
|
|
141
|
+
limit: int = 500) -> Path:
|
|
142
|
+
p = Path(out).expanduser()
|
|
143
|
+
p.write_text(render_view(export, limit=limit), encoding="utf-8")
|
|
144
|
+
return p
|
devin_graph/vscdb.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Extract GUI-side nodes/edges from Devin's ``state.vscdb`` (GR-1).
|
|
2
|
+
|
|
3
|
+
``state.vscdb`` is the Electron store (``ItemTable`` key/value) under
|
|
4
|
+
``User/globalStorage/`` of the Devin config dir. The only keys this reads
|
|
5
|
+
are the observed ``windsurfSpace.*`` family:
|
|
6
|
+
|
|
7
|
+
- ``windsurfSpace.sessionWorkspace/<backend>/<slug>`` — JSON
|
|
8
|
+
``{workspaceId, label, folders[], lastUpdated}`` binding a GUI session
|
|
9
|
+
(identified by a generated slug like ``canyon-newspaper``) to a workspace.
|
|
10
|
+
- ``windsurfSpace.resourceToSpace`` — ``{space_id: [editor URIs]}``; URIs
|
|
11
|
+
look like ``vscode-cascade-editor:///cascade-acp/<backend>/<slug>``.
|
|
12
|
+
- ``windsurfSpace.metadata`` — ``{space_id: {lastAccessed: ms}}``.
|
|
13
|
+
|
|
14
|
+
Coverage is deliberately best-effort: unknown or malformed keys are skipped,
|
|
15
|
+
never fatal, and the store is opened read-only (``mode=ro`` + ``query_only``).
|
|
16
|
+
|
|
17
|
+
Produces ``gui_session`` nodes (key = slug) and ``gui_workspace`` edges
|
|
18
|
+
``gui_session → project``. When a space points at a slug, its ``space_id``
|
|
19
|
+
and ``lastAccessed`` enrich the session node's attrs.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import sqlite3
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from devin_graph.extract import EDGE_KINDS, NODE_KINDS, Edge, Extraction, Node
|
|
30
|
+
|
|
31
|
+
SESSION_WS_PREFIX = "windsurfSpace.sessionWorkspace/"
|
|
32
|
+
RESOURCE_TO_SPACE = "windsurfSpace.resourceToSpace"
|
|
33
|
+
SPACE_METADATA = "windsurfSpace.metadata"
|
|
34
|
+
_EDITOR_URI_PREFIX = "vscode-cascade-editor:///cascade-acp/"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def extract_vscdb(path: str | Path) -> Extraction:
|
|
38
|
+
"""``state.vscdb`` → gui_session/gui_workspace nodes and edges."""
|
|
39
|
+
p = Path(path).expanduser()
|
|
40
|
+
con = sqlite3.connect(f"file:{p}?mode=ro", uri=True)
|
|
41
|
+
try:
|
|
42
|
+
con.execute("PRAGMA query_only = ON")
|
|
43
|
+
rows = dict(
|
|
44
|
+
con.execute("SELECT key, value FROM ItemTable").fetchall())
|
|
45
|
+
except sqlite3.Error as exc:
|
|
46
|
+
raise ValueError(f"{p}: not a readable state.vscdb ({exc})") from exc
|
|
47
|
+
finally:
|
|
48
|
+
con.close()
|
|
49
|
+
|
|
50
|
+
ex = Extraction()
|
|
51
|
+
last_accessed = _space_last_accessed(rows)
|
|
52
|
+
space_of = _space_of_session(rows)
|
|
53
|
+
|
|
54
|
+
for key, raw in rows.items():
|
|
55
|
+
if not key.startswith(SESSION_WS_PREFIX):
|
|
56
|
+
continue
|
|
57
|
+
backend, sep, slug = key[len(SESSION_WS_PREFIX):].rpartition("/")
|
|
58
|
+
if not sep or not slug:
|
|
59
|
+
continue
|
|
60
|
+
data = _json_dict(raw)
|
|
61
|
+
workspace = data.get("workspaceId")
|
|
62
|
+
folders = data.get("folders") or []
|
|
63
|
+
attrs: dict[str, Any] = {
|
|
64
|
+
"backend": backend,
|
|
65
|
+
"label": data.get("label"),
|
|
66
|
+
"workspace": workspace,
|
|
67
|
+
"folders": folders,
|
|
68
|
+
"lastUpdated": data.get("lastUpdated"),
|
|
69
|
+
}
|
|
70
|
+
space = space_of.get(slug)
|
|
71
|
+
if space:
|
|
72
|
+
attrs["space_id"] = space
|
|
73
|
+
if space in last_accessed:
|
|
74
|
+
attrs["lastAccessed"] = last_accessed[space]
|
|
75
|
+
ex.nodes.append(Node("gui_session", slug, attrs))
|
|
76
|
+
if workspace:
|
|
77
|
+
ex.nodes.append(
|
|
78
|
+
Node("project", workspace, {"name": Path(workspace).name}))
|
|
79
|
+
ex.edges.append(
|
|
80
|
+
Edge("gui_workspace", ("gui_session", slug),
|
|
81
|
+
("project", workspace)))
|
|
82
|
+
return ex
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _json_dict(raw: Any) -> dict:
|
|
86
|
+
if isinstance(raw, bytes):
|
|
87
|
+
raw = raw.decode("utf-8", "replace")
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(raw)
|
|
90
|
+
except (TypeError, ValueError):
|
|
91
|
+
return {}
|
|
92
|
+
return data if isinstance(data, dict) else {}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _space_last_accessed(rows: dict) -> dict[str, int]:
|
|
96
|
+
meta = _json_dict(rows.get(SPACE_METADATA))
|
|
97
|
+
out = {}
|
|
98
|
+
for space_id, m in meta.items():
|
|
99
|
+
if isinstance(m, dict) and isinstance(m.get("lastAccessed"), int):
|
|
100
|
+
out[space_id] = m["lastAccessed"]
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _space_of_session(rows: dict) -> dict[str, str]:
|
|
105
|
+
"""session slug → space_id, via resourceToSpace editor URIs."""
|
|
106
|
+
out: dict[str, str] = {}
|
|
107
|
+
rts = _json_dict(rows.get(RESOURCE_TO_SPACE))
|
|
108
|
+
for space_id, uris in rts.items():
|
|
109
|
+
if not isinstance(uris, list):
|
|
110
|
+
continue
|
|
111
|
+
for uri in uris:
|
|
112
|
+
if isinstance(uri, str) and uri.startswith(_EDITOR_URI_PREFIX):
|
|
113
|
+
slug = uri[len(_EDITOR_URI_PREFIX):].rstrip("/").rsplit(
|
|
114
|
+
"/", 1)[-1]
|
|
115
|
+
if slug:
|
|
116
|
+
out.setdefault(slug, space_id)
|
|
117
|
+
return out
|