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/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