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,126 @@
1
+ """Opaque machine identity + profile for provenance metadata.
2
+
3
+ Artifacts written by this tool carry ``machine_id`` + ``profile`` so output
4
+ produced on different machines (e.g. personal vs corporate) can be told
5
+ apart — without leaking hostnames, usernames or MAC addresses.
6
+
7
+ Resolution order:
8
+
9
+ - ``machine_id``: the ``DEVIN_MACHINE_ID`` env var wins; otherwise a random
10
+ ``secrets.token_hex(8)`` persisted at
11
+ ``<devin-config-dir>/.devin-ecosystem/machine-id`` (mode 0600, created on
12
+ first use). Never derived from hostname, username or hardware ids.
13
+ - ``profile``: the ``DEVIN_ECOSYSTEM_PROFILE`` env var
14
+ (``"corporate"`` | ``"personal"``), else ``devin-profile.json`` in the
15
+ Devin config dir, else ``"corporate"`` — fail-closed.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import secrets
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ MACHINE_ID_ENV = "DEVIN_MACHINE_ID"
27
+ PROFILE_ENV = "DEVIN_ECOSYSTEM_PROFILE"
28
+ CONFIG_DIR_ENV = "DEVIN_CONFIG_DIR"
29
+ ECOSYSTEM_DIRNAME = ".devin-ecosystem"
30
+ MACHINE_ID_FILENAME = "machine-id"
31
+ PROFILE_FILENAME = "devin-profile.json"
32
+ PROFILES = ("corporate", "personal")
33
+ DEFAULT_PROFILE = "corporate" # fail-closed
34
+
35
+
36
+ def devin_config_dir(
37
+ environ: dict[str, str] | None = None, platform: str | None = None
38
+ ) -> Path:
39
+ """Devin Desktop's config root (``%APPDATA%/Devin``, ``~/.config/Devin``).
40
+
41
+ Same rule devin-backup's ``default_config_dir`` uses, so every devin-*
42
+ tool resolves the same ``.devin-ecosystem/machine-id`` on one machine.
43
+ """
44
+ env = os.environ if environ is None else environ
45
+ plat = sys.platform if platform is None else platform
46
+ override = env.get(CONFIG_DIR_ENV)
47
+ if override:
48
+ return Path(override).expanduser()
49
+ if plat == "nt" or plat.startswith("win"):
50
+ appdata = env.get("APPDATA")
51
+ if appdata:
52
+ return Path(appdata) / "Devin"
53
+ return Path.home() / "AppData" / "Roaming" / "Devin"
54
+ if plat == "darwin":
55
+ return Path.home() / "Library" / "Application Support" / "devin"
56
+ config_home = Path(env.get("XDG_CONFIG_HOME") or Path.home() / ".config")
57
+ candidates = [config_home / "Devin", config_home / "devin"]
58
+ return next(
59
+ (c for c in candidates if (c / "User").is_dir()), candidates[0]
60
+ )
61
+
62
+
63
+ def machine_id(
64
+ environ: dict[str, str] | None = None, platform: str | None = None
65
+ ) -> str:
66
+ """Stable opaque id for this machine (16 hex chars)."""
67
+ env = os.environ if environ is None else environ
68
+ override = (env.get(MACHINE_ID_ENV) or "").strip()
69
+ if override:
70
+ return override
71
+ path = (
72
+ devin_config_dir(env, platform)
73
+ / ECOSYSTEM_DIRNAME
74
+ / MACHINE_ID_FILENAME
75
+ )
76
+ try:
77
+ existing = path.read_text(encoding="utf-8").strip()
78
+ except OSError:
79
+ existing = ""
80
+ if existing:
81
+ return existing
82
+ value = secrets.token_hex(8)
83
+ path.parent.mkdir(parents=True, exist_ok=True)
84
+ path.write_text(value + "\n", encoding="utf-8")
85
+ try:
86
+ path.chmod(0o600)
87
+ except OSError: # filesystems without POSIX modes
88
+ pass
89
+ return value
90
+
91
+
92
+ def _profile_from_file(
93
+ env: dict[str, str], plat: str
94
+ ) -> str | None:
95
+ path = devin_config_dir(env, plat) / PROFILE_FILENAME
96
+ try:
97
+ data = json.loads(path.read_text(encoding="utf-8"))
98
+ except (OSError, ValueError):
99
+ return None
100
+ raw = data.get("profile") if isinstance(data, dict) else data
101
+ raw = str(raw or "").strip().lower()
102
+ return raw if raw in PROFILES else None
103
+
104
+
105
+ def profile(
106
+ environ: dict[str, str] | None = None, platform: str | None = None
107
+ ) -> str:
108
+ """``"corporate"`` | ``"personal"`` — unknown values fail closed."""
109
+ env = os.environ if environ is None else environ
110
+ raw = (env.get(PROFILE_ENV) or "").strip().lower()
111
+ if raw in PROFILES:
112
+ return raw
113
+ if raw:
114
+ return DEFAULT_PROFILE # set but unrecognized — fail closed
115
+ plat = sys.platform if platform is None else platform
116
+ return _profile_from_file(env, plat) or DEFAULT_PROFILE
117
+
118
+
119
+ def provenance(
120
+ environ: dict[str, str] | None = None, platform: str | None = None
121
+ ) -> dict[str, str]:
122
+ """``{"machine_id": ..., "profile": ...}`` for embedding in outputs."""
123
+ return {
124
+ "machine_id": machine_id(environ, platform),
125
+ "profile": profile(environ, platform),
126
+ }
devin_graph/paths.py ADDED
@@ -0,0 +1,92 @@
1
+ """Default locations for Devin's local session database.
2
+
3
+ Linux uses ``$XDG_DATA_HOME/devin/cli/sessions.db`` (default
4
+ ``~/.local/share/devin/cli/sessions.db``), with older config-dir candidates as
5
+ fallbacks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ APP_DIRNAME = "devin"
15
+ SESSIONS_DB_RELPATH = ("cli", "sessions.db")
16
+
17
+
18
+ def _roots(environ: dict[str, str], platform: str) -> list[Path]:
19
+ if platform.startswith("win"):
20
+ roots = []
21
+ if environ.get("APPDATA"):
22
+ roots.append(Path(environ["APPDATA"]))
23
+ roots.append(Path.home() / "AppData" / "Roaming")
24
+ elif platform == "darwin":
25
+ roots = [Path.home() / "Library" / "Application Support"]
26
+ else:
27
+ roots = [
28
+ Path(environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"),
29
+ Path(environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"),
30
+ Path.home(),
31
+ ]
32
+ return list(dict.fromkeys(roots))
33
+
34
+
35
+ def sessions_db_candidates(
36
+ environ: dict[str, str] | None = None, platform: str | None = None
37
+ ) -> list[Path]:
38
+ """Candidate paths for the CLI ``sessions.db``, newest layout first."""
39
+ env = os.environ if environ is None else environ
40
+ plat = sys.platform if platform is None else platform
41
+ return [
42
+ root.joinpath(APP_DIRNAME, *SESSIONS_DB_RELPATH)
43
+ for root in _roots(env, plat)
44
+ ]
45
+
46
+
47
+ VSCDB_RELPATH = ("User", "globalStorage", "state.vscdb")
48
+
49
+
50
+ def state_vscdb_candidates(
51
+ environ: dict[str, str] | None = None, platform: str | None = None
52
+ ) -> list[Path]:
53
+ """Candidate paths for the GUI ``state.vscdb`` (Electron storage)."""
54
+ env = os.environ if environ is None else environ
55
+ plat = sys.platform if platform is None else platform
56
+ if plat.startswith("win"):
57
+ roots = []
58
+ if env.get("APPDATA"):
59
+ roots.append(Path(env["APPDATA"]))
60
+ roots.append(Path.home() / "AppData" / "Roaming")
61
+ elif plat == "darwin":
62
+ roots = [Path.home() / "Library" / "Application Support"]
63
+ else:
64
+ roots = [
65
+ Path(env.get("XDG_CONFIG_HOME") or Path.home() / ".config"),
66
+ Path.home(),
67
+ ]
68
+ names = ("Devin", "devin")
69
+ return [
70
+ r.joinpath(n, *VSCDB_RELPATH)
71
+ for r in dict.fromkeys(roots) for n in names
72
+ ]
73
+
74
+
75
+ def default_state_vscdb(
76
+ environ: dict[str, str] | None = None, platform: str | None = None
77
+ ) -> Path | None:
78
+ """First existing state.vscdb candidate, or ``None``."""
79
+ for candidate in state_vscdb_candidates(environ=environ, platform=platform):
80
+ if candidate.is_file():
81
+ return candidate
82
+ return None
83
+
84
+
85
+ def default_sessions_db(
86
+ environ: dict[str, str] | None = None, platform: str | None = None
87
+ ) -> Path | None:
88
+ """First existing candidate, or ``None`` when no store is present."""
89
+ for candidate in sessions_db_candidates(environ=environ, platform=platform):
90
+ if candidate.is_file():
91
+ return candidate
92
+ return None
devin_graph/query.py ADDED
@@ -0,0 +1,234 @@
1
+ """Canned queries over a built ``graph.db``.
2
+
3
+ Matching is forgiving: exact key first, then case-insensitive, then suffix
4
+ (so ``src/app.py`` finds ``/repo/alpha/src/app.py``). All functions return
5
+ plain JSON-ready dicts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from itertools import combinations
11
+
12
+ from devin_graph.extract import normalize_path
13
+
14
+
15
+ def _session_dict(gs, node) -> dict:
16
+ project = None
17
+ for e in gs.out_edges(node.id):
18
+ if e.kind == "runs_in":
19
+ project = e.dst.split(":", 1)[1]
20
+ return {
21
+ "id": node.key,
22
+ "title": node.attrs.get("title"),
23
+ "project": project,
24
+ "last_activity_at": node.attrs.get("last_activity_at"),
25
+ }
26
+
27
+
28
+ def _match_file_keys(gs, query: str) -> list[str]:
29
+ q = normalize_path(query) or query.strip()
30
+ keys = [n.key for n in gs.nodes("file")]
31
+ exact = [k for k in keys if k == q]
32
+ if exact:
33
+ return sorted(exact)
34
+ ci = [k for k in keys if k.casefold() == q.casefold()]
35
+ if ci:
36
+ return sorted(ci)
37
+ return sorted(k for k in keys if k.endswith("/" + q) or k == q)
38
+
39
+
40
+ def _match_project_keys(gs, query: str) -> list[str]:
41
+ q = normalize_path(query) or query.strip()
42
+ nodes = gs.nodes("project")
43
+ exact = [n for n in nodes if n.key == q]
44
+ if exact:
45
+ return sorted(n.key for n in exact)
46
+ named = [n for n in nodes if n.attrs.get("name") == query.strip()]
47
+ ci = [n for n in nodes
48
+ if n.key.casefold() == q.casefold()
49
+ or (n.attrs.get("name") or "").casefold() == query.strip().casefold()]
50
+ found = {n.key for n in named + ci}
51
+ if found:
52
+ return sorted(found)
53
+ return sorted(n.key for n in nodes
54
+ if n.key.endswith("/" + q) or n.key == q)
55
+
56
+
57
+ def _match_tool_keys(gs, query: str) -> list[str]:
58
+ q = query.strip()
59
+ return sorted(n.key for n in gs.nodes("tool")
60
+ if n.key == q or n.key.casefold() == q.casefold())
61
+
62
+
63
+ def _sessions_of_project(gs, project_key: str) -> list[str]:
64
+ return sorted(
65
+ e.src.split(":", 1)[1]
66
+ for e in gs.in_edges(f"project:{project_key}")
67
+ if e.kind == "runs_in"
68
+ )
69
+
70
+
71
+ def sessions_for_file(gs, path: str) -> dict:
72
+ """Which sessions touched ``path``, via which tool calls."""
73
+ file_keys = _match_file_keys(gs, path)
74
+ tool_calls = []
75
+ session_ids: set[str] = set()
76
+ for fk in file_keys:
77
+ for e in gs.in_edges(f"file:{fk}"):
78
+ if e.kind != "file_touched":
79
+ continue
80
+ tool_calls.append({
81
+ "id": e.src.split(":", 1)[1],
82
+ "session_id": e.owner,
83
+ "file": fk,
84
+ })
85
+ if e.owner:
86
+ session_ids.add(e.owner)
87
+ tool_calls.sort(key=lambda t: t["id"])
88
+ sessions = [
89
+ _session_dict(gs, gs.node(f"session:{sid}"))
90
+ for sid in sorted(session_ids)
91
+ if gs.node(f"session:{sid}") is not None
92
+ ]
93
+ return {"query": path, "files": file_keys, "sessions": sessions,
94
+ "tool_calls": tool_calls}
95
+
96
+
97
+ def sessions_for_tool(gs, name: str) -> dict:
98
+ """Which sessions/projects used tool ``name``."""
99
+ session_ids: set[str] = set()
100
+ projects: set[str] = set()
101
+ for key in _match_tool_keys(gs, name):
102
+ for e in gs.in_edges(f"tool:{key}"):
103
+ if e.kind == "tool_used" and e.owner:
104
+ session_ids.add(e.owner)
105
+ sessions = [
106
+ _session_dict(gs, gs.node(f"session:{sid}"))
107
+ for sid in sorted(session_ids)
108
+ if gs.node(f"session:{sid}") is not None
109
+ ]
110
+ for s in sessions:
111
+ if s["project"]:
112
+ projects.add(s["project"])
113
+ return {"tool": name, "sessions": sessions,
114
+ "projects": sorted(projects)}
115
+
116
+
117
+ def tools_for_project(gs, query: str) -> dict:
118
+ """Tools used by sessions running in project ``query``."""
119
+ project_keys = _match_project_keys(gs, query)
120
+ session_ids = sorted({
121
+ sid for pk in project_keys for sid in _sessions_of_project(gs, pk)
122
+ })
123
+ tools: set[str] = set()
124
+ for sid in session_ids:
125
+ for e in gs.out_edges(f"session:{sid}"):
126
+ if e.kind == "tool_used":
127
+ tools.add(e.dst.split(":", 1)[1])
128
+ sessions = [
129
+ _session_dict(gs, gs.node(f"session:{sid}"))
130
+ for sid in session_ids
131
+ if gs.node(f"session:{sid}") is not None
132
+ ]
133
+ return {"query": query, "projects": project_keys, "tools": sorted(tools),
134
+ "sessions": sessions}
135
+
136
+
137
+ def project_detail(gs, query: str) -> dict:
138
+ """One project: its sessions, the tools they used, the files they touched."""
139
+ keys = _match_project_keys(gs, query)
140
+ if not keys:
141
+ return {"project": None, "sessions": [], "tools": [], "files": []}
142
+ project_key = keys[0]
143
+ base = tools_for_project(gs, project_key)
144
+ session_ids = {s["id"] for s in base["sessions"]}
145
+ files = sorted({
146
+ e.dst.split(":", 1)[1]
147
+ for e in gs.edges("file_touched")
148
+ if e.owner in session_ids
149
+ })
150
+ return {"project": project_key, "sessions": base["sessions"],
151
+ "tools": base["tools"], "files": files}
152
+
153
+
154
+ def projects_graph(gs) -> dict:
155
+ """Project-to-project adjacency for visualization (D3-style).
156
+
157
+ Two projects link when they share a tool (both used it) or a file (both
158
+ touched it); ``weight`` = number of shared entities.
159
+ """
160
+ projects = gs.nodes("project")
161
+ tools_by_project: dict[str, set[str]] = {p.key: set() for p in projects}
162
+ files_by_project: dict[str, set[str]] = {p.key: set() for p in projects}
163
+
164
+ for e in gs.edges("tool_used"):
165
+ proj = _project_of_session(gs, e.src.split(":", 1)[1])
166
+ if proj in tools_by_project:
167
+ tools_by_project[proj].add(e.dst.split(":", 1)[1])
168
+ for e in gs.edges("file_touched"):
169
+ proj = _project_of_session(gs, e.owner)
170
+ if proj in files_by_project:
171
+ files_by_project[proj].add(e.dst.split(":", 1)[1])
172
+
173
+ nodes = [{
174
+ "id": p.key,
175
+ "name": p.attrs.get("name"),
176
+ "sessions": _sessions_of_project(gs, p.key),
177
+ } for p in projects]
178
+
179
+ links = []
180
+ for a, b in combinations(sorted(tools_by_project), 2):
181
+ shared_tools = sorted(tools_by_project[a] & tools_by_project[b])
182
+ shared_files = sorted(files_by_project[a] & files_by_project[b])
183
+ if shared_tools or shared_files:
184
+ links.append({
185
+ "source": a, "target": b,
186
+ "weight": len(shared_tools) + len(shared_files),
187
+ "shared_tools": shared_tools,
188
+ "shared_files": shared_files,
189
+ })
190
+ return {"nodes": nodes, "links": links}
191
+
192
+
193
+ def _project_of_session(gs, session_id: str | None) -> str | None:
194
+ if session_id is None:
195
+ return None
196
+ for e in gs.out_edges(f"session:{session_id}"):
197
+ if e.kind == "runs_in":
198
+ return e.dst.split(":", 1)[1]
199
+ return None
200
+
201
+
202
+ def shared_files(gs) -> dict:
203
+ """Files touched by two or more distinct projects.
204
+
205
+ A file counts when ``file_touched`` edges link it to tool calls owned by
206
+ sessions running in different projects (``working_directory``). Each
207
+ entry carries the sorted project list and the session ids that touched
208
+ the file — useful both for collision audits and D3-style highlighting.
209
+ """
210
+ projects_of_file: dict[str, set[str]] = {}
211
+ sessions_of_file: dict[str, set[str]] = {}
212
+ for e in gs.edges("file_touched"):
213
+ if not e.owner:
214
+ continue
215
+ project = _project_of_session(gs, e.owner)
216
+ if project is None:
217
+ continue
218
+ file_key = e.dst.split(":", 1)[1]
219
+ projects_of_file.setdefault(file_key, set()).add(project)
220
+ sessions_of_file.setdefault(file_key, set()).add(e.owner)
221
+
222
+ files = []
223
+ for file_key in sorted(projects_of_file):
224
+ projects = sorted(projects_of_file[file_key])
225
+ if len(projects) < 2:
226
+ continue
227
+ node = gs.node(f"file:{file_key}")
228
+ files.append({
229
+ "file": file_key,
230
+ "name": node.attrs.get("name") if node else None,
231
+ "projects": projects,
232
+ "sessions": sorted(sessions_of_file[file_key]),
233
+ })
234
+ return {"count": len(files), "files": files}