agent-memory-brain 0.2.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.
Files changed (54) hide show
  1. agent_memory_brain-0.2.0.dist-info/METADATA +98 -0
  2. agent_memory_brain-0.2.0.dist-info/RECORD +54 -0
  3. agent_memory_brain-0.2.0.dist-info/WHEEL +4 -0
  4. agent_memory_brain-0.2.0.dist-info/entry_points.txt +3 -0
  5. agent_memory_brain-0.2.0.dist-info/licenses/LICENSE +67 -0
  6. devmem/__init__.py +3 -0
  7. devmem/ado.py +433 -0
  8. devmem/agent_colors.py +74 -0
  9. devmem/cli.py +500 -0
  10. devmem/db.py +1069 -0
  11. devmem/dispatch.py +478 -0
  12. devmem/embed.py +111 -0
  13. devmem/filelock.py +53 -0
  14. devmem/git_sync.py +261 -0
  15. devmem/guard.py +214 -0
  16. devmem/hook_log.py +49 -0
  17. devmem/ingest/__init__.py +1 -0
  18. devmem/ingest/claude.py +87 -0
  19. devmem/ingest/codex.py +175 -0
  20. devmem/ingest/runner.py +318 -0
  21. devmem/ingest/subagents.py +250 -0
  22. devmem/maintenance.py +801 -0
  23. devmem/mcp_server.py +542 -0
  24. devmem/messages.py +684 -0
  25. devmem/panel/__init__.py +1 -0
  26. devmem/panel/app.py +971 -0
  27. devmem/panel/state.py +358 -0
  28. devmem/panel/static/base.css +27 -0
  29. devmem/panel/static/brain.css +473 -0
  30. devmem/panel/static/brain.js +1118 -0
  31. devmem/panel/static/brain3d.js +600 -0
  32. devmem/panel/static/chat.js +18 -0
  33. devmem/panel/static/common.js +208 -0
  34. devmem/panel/static/site.css +508 -0
  35. devmem/panel/static/tokens.css +19 -0
  36. devmem/panel/templates/ado.html +62 -0
  37. devmem/panel/templates/agents.html +35 -0
  38. devmem/panel/templates/audit.html +19 -0
  39. devmem/panel/templates/base.html +42 -0
  40. devmem/panel/templates/brain.html +101 -0
  41. devmem/panel/templates/maintenance.html +32 -0
  42. devmem/panel/templates/memory.html +71 -0
  43. devmem/panel/templates/message_thread.html +49 -0
  44. devmem/panel/templates/messages.html +8 -0
  45. devmem/panel/templates/overview.html +109 -0
  46. devmem/panel/templates/project.html +48 -0
  47. devmem/panel/templates/projects.html +21 -0
  48. devmem/panel/templates/session.html +49 -0
  49. devmem/panel/templates/sessions.html +67 -0
  50. devmem/panel/templates/subagent.html +27 -0
  51. devmem/procs.py +10 -0
  52. devmem/projects.py +17 -0
  53. devmem/secrets.py +9 -0
  54. devmem/skills.py +105 -0
devmem/ado.py ADDED
@@ -0,0 +1,433 @@
1
+ """Strictly read-only Azure DevOps data for the local panel.
2
+
3
+ This module deliberately has a very small network surface. Keep every ADO
4
+ request in :meth:`AdoClient._request`; that makes the allowlist auditable and
5
+ prevents a future caller from accidentally gaining write access.
6
+ The allowlist is rebuilt for every request from the organization/project that
7
+ the refresh resolved once; the host, methods, and endpoint shapes remain fixed.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ import logging
14
+ import os
15
+ import re
16
+ import socket
17
+ import traceback
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+ from typing import Any, Callable
21
+ from urllib.error import HTTPError, URLError
22
+ from urllib.parse import quote, urlencode, urlsplit
23
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
24
+
25
+
26
+ TOKEN_PATH = Path.home() / ".devmem" / "ado_token"
27
+ CONFIG_PATH = Path.home() / ".devmem" / "ado.json"
28
+ TIMEOUT_S = 15
29
+ API_VERSION = "7.1"
30
+ _ORGANIZATION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,49}$")
31
+ _PROJECT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")
32
+ log = logging.getLogger(__name__)
33
+
34
+
35
+ class AdoError(RuntimeError):
36
+ """A safe-to-display ADO failure (its message never includes credentials)."""
37
+
38
+
39
+ class AdoForbidden(AdoError):
40
+ """Raised without network I/O for a method, origin, path, or body outside the allowlist."""
41
+
42
+
43
+ class _NoRedirect(HTTPRedirectHandler):
44
+ """Redirects could escape an otherwise safe URL; reject every one of them."""
45
+
46
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
47
+ return None
48
+
49
+
50
+ _OPENER = build_opener(_NoRedirect())
51
+
52
+
53
+ def _utcnow() -> datetime:
54
+ return datetime.now(UTC)
55
+
56
+
57
+ def _iso_now() -> str:
58
+ return _utcnow().isoformat().replace("+00:00", "Z")
59
+
60
+
61
+ def _age_label(seconds: int | None) -> str:
62
+ if seconds is None:
63
+ return "age unknown"
64
+ if seconds < 3600:
65
+ return f"{max(1, seconds // 60)}m old"
66
+ if seconds < 86400:
67
+ return f"{seconds // 3600}h old"
68
+ return f"{seconds // 86400}d old"
69
+
70
+
71
+ def _read_token_status() -> tuple[str | None, str | None]:
72
+ """Load the local token at runtime without exposing its contents."""
73
+ try:
74
+ value = TOKEN_PATH.read_text(encoding="utf-8").strip()
75
+ except FileNotFoundError:
76
+ return None, None
77
+ except UnicodeDecodeError:
78
+ return None, "ado_token: not UTF-8 text"
79
+ except OSError as exc:
80
+ return None, f"ado_token: cannot be read ({type(exc).__name__})"
81
+ return value or None, None
82
+
83
+
84
+ def _read_token() -> str | None:
85
+ """Default token loader: the token only, without the read-error reason."""
86
+ return _read_token_status()[0]
87
+
88
+
89
+ def _validate_config(value: object, token: str | None = None, sources: dict[str, str] | None = None) -> tuple[tuple[str, str] | None, str | None]:
90
+ """Validate a pair without ever including its values in an error."""
91
+ if not isinstance(value, tuple) or len(value) != 2:
92
+ return None, "config must be an organization/project tuple"
93
+ organization, project = value
94
+ for name, item, pattern, rule in (
95
+ ("organization", organization, _ORGANIZATION_RE, "letters, digits, hyphens; must start with a letter or digit; max 50"),
96
+ ("project", project, _PROJECT_RE, "letters, digits, spaces, . _ -; must start with a letter or digit; max 64"),
97
+ ):
98
+ prefix = f"{(sources or {}).get(name, '')} " if (sources or {}).get(name) else ""
99
+ if not isinstance(item, str):
100
+ return None, f"{prefix}{name} must be a string"
101
+ if token is not None and item == token:
102
+ return None, f"{prefix}{name} must not be the token"
103
+ if not pattern.fullmatch(item):
104
+ return None, f"{prefix}{name} is not a valid ADO {name} name ({rule})"
105
+ return (organization, project), None
106
+
107
+
108
+ def _read_config() -> tuple[tuple[str, str] | None, str | None]:
109
+ """Read env fields with file fallback, including an expandable path override and safe reasons.
110
+
111
+ Each non-empty env field overrides only that field. Missing fields fall back
112
+ to ``DEVMEM_ADO_CONFIG`` (or the default file); all malformed input is
113
+ reported as a reason without exposing configured values.
114
+ """
115
+ organization = os.environ.get("DEVMEM_ADO_ORG") or None
116
+ project = os.environ.get("DEVMEM_ADO_PROJECT") or None
117
+ override = os.environ.get("DEVMEM_ADO_CONFIG") or None
118
+ path = Path(override).expanduser() if override else CONFIG_PATH
119
+ if override and not path.exists():
120
+ return None, "config file not found (DEVMEM_ADO_CONFIG)"
121
+ value: object = {}
122
+ if organization is None or project is None:
123
+ try:
124
+ text = path.read_text(encoding="utf-8")
125
+ value = json.loads(text)
126
+ except FileNotFoundError:
127
+ if override:
128
+ return None, "config file not found (DEVMEM_ADO_CONFIG)"
129
+ if organization is None and project is None:
130
+ return None, None
131
+ missing = "DEVMEM_ADO_ORG" if organization is None else "DEVMEM_ADO_PROJECT"
132
+ return None, f"{missing} is not set and no config file exists"
133
+ except json.JSONDecodeError as exc:
134
+ return None, f"config file: invalid JSON ({exc.msg} at line {exc.lineno} column {exc.colno})"
135
+ except (OSError, UnicodeDecodeError, ValueError, RecursionError) as exc:
136
+ return None, f"config file: cannot be read ({type(exc).__name__})"
137
+ if not isinstance(value, dict):
138
+ return None, "config file: not a JSON object"
139
+ unknown = set(value) - {"organization", "project"}
140
+ if unknown:
141
+ return None, f"config file: {len(unknown)} unknown key(s) (allowed: organization, project)"
142
+ if organization is None:
143
+ if "organization" not in value:
144
+ return None, "config file: missing key organization"
145
+ organization = value["organization"]
146
+ if project is None:
147
+ if "project" not in value:
148
+ return None, "config file: missing key project"
149
+ project = value["project"]
150
+ sources = {
151
+ "organization": "DEVMEM_ADO_ORG" if os.environ.get("DEVMEM_ADO_ORG") else "config file:",
152
+ "project": "DEVMEM_ADO_PROJECT" if os.environ.get("DEVMEM_ADO_PROJECT") else "config file:",
153
+ }
154
+ return _validate_config((organization, project), sources=sources)
155
+
156
+
157
+ def empty_snapshot(configured: bool = False, config_error: str | None = None, missing: list[str] | None = None,
158
+ token_error: str | None = None) -> dict[str, Any]:
159
+ """The stable public shape used before the first successful refresh too."""
160
+ return {
161
+ "configured": configured,
162
+ "config_error": config_error,
163
+ "token_error": token_error,
164
+ "missing": missing or [],
165
+ "scope": None,
166
+ "pending": False,
167
+ "refreshed_at": None,
168
+ "stale": False,
169
+ "error": None,
170
+ "me": None,
171
+ "project_active_count": 0,
172
+ "to_review": [],
173
+ "my_changes_requested": [],
174
+ "my_approved_not_completed": [],
175
+ "my_waiting_on_others": [],
176
+ "open_tasks": [],
177
+ "open_tasks_by_state": {},
178
+ }
179
+
180
+
181
+ class AdoClient:
182
+ """Read-only client for exactly the panel's allowed ADO endpoints."""
183
+
184
+ def __init__(self, token_loader: Callable[[], str | None] = _read_token,
185
+ config_loader: Callable[[], object] = _read_config):
186
+ self._token_loader = token_loader
187
+ self._config_loader = config_loader
188
+ self._config_loader_failures: set[type[BaseException]] = set()
189
+
190
+ def _token(self) -> tuple[str | None, str | None]:
191
+ if self._token_loader is _read_token:
192
+ return _read_token_status()
193
+ return self._token_loader(), None
194
+
195
+ def _config(self, token: str | None = None) -> tuple[tuple[str, str] | None, str | None]:
196
+ """Re-validate loader output; loader errors are logged by type and disable ADO.
197
+
198
+ Loaders return only the normal ``(config, reason)`` result.
199
+ """
200
+ try:
201
+ value = self._config_loader()
202
+ except Exception as exc:
203
+ if type(exc) not in self._config_loader_failures:
204
+ # Frames only: the exception message could contain a config value.
205
+ log.error("ADO config loader failed (%s)\n%s", type(exc).__name__,
206
+ "".join(traceback.format_tb(exc.__traceback__)).rstrip())
207
+ self._config_loader_failures.add(type(exc))
208
+ return None, f"config loader error ({type(exc).__name__})"
209
+ if (isinstance(value, tuple) and len(value) == 2 and
210
+ (value[0] is None or isinstance(value[0], tuple))):
211
+ config, reason = value
212
+ if config is None:
213
+ # (None, None) is the normal "nothing configured" result, not an error.
214
+ return None, reason if reason is None or isinstance(reason, str) else "config loader returned an invalid result"
215
+ validated, validation_reason = _validate_config(config, token)
216
+ if reason is not None and not isinstance(reason, str):
217
+ return None, "config loader returned an invalid result"
218
+ return validated, validation_reason or reason
219
+ return None, "config loader returned an invalid result"
220
+
221
+ @staticmethod
222
+ def _wiql(project: str) -> str:
223
+ # Defence in depth: validation already rejects apostrophes.
224
+ project = project.replace("'", "''")
225
+ return ("SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = '" + project + "' "
226
+ "AND [System.AssignedTo] = @Me AND [System.State] NOT IN "
227
+ "('Closed','Done','Removed') ORDER BY [System.ChangedDate] DESC")
228
+
229
+ @staticmethod
230
+ def _paths(config: tuple[str, str]) -> tuple[frozenset[str], frozenset[str]]:
231
+ organization, project = config
232
+ prefix = f"/{organization}/{quote(project, safe='')}"
233
+ return (frozenset({f"/{organization}/_apis/connectionData", f"{prefix}/_apis/wit/workitems",
234
+ f"{prefix}/_apis/git/pullrequests"}),
235
+ frozenset({f"{prefix}/_apis/wit/wiql"}))
236
+
237
+ def configured(self) -> bool:
238
+ """Whether both a token and valid organization/project are present, without exposing them."""
239
+ token, _ = self._token()
240
+ config, _ = self._config(token)
241
+ return bool(token and config)
242
+
243
+ def _request(self, method: str, url: str, body: bytes | None = None, *, config: tuple[str, str] | None = None) -> dict[str, Any]:
244
+ """Validate first, then make one authenticated request with no redirects."""
245
+ token, _ = self._token()
246
+ if config is None:
247
+ config, reason = self._config(token)
248
+ else:
249
+ reason = None
250
+ if not token or config is None:
251
+ raise AdoError(reason or "ADO not configured (missing token or organization/project)")
252
+ method = method.upper()
253
+ parts = urlsplit(url)
254
+ # netloc rather than hostname rejects userinfo and alternate ports as well.
255
+ valid_origin = parts.scheme == "https" and parts.netloc == "dev.azure.com"
256
+ get_paths, post_paths = self._paths(config)
257
+ valid_path = ((method == "GET" and parts.path in get_paths)
258
+ or (method == "POST" and parts.path in post_paths))
259
+ if not valid_origin or not valid_path:
260
+ raise AdoForbidden("ADO request is outside the read-only allowlist")
261
+ if method == "GET" and body is not None:
262
+ raise AdoForbidden("ADO GET requests cannot have a body")
263
+ if method == "POST" and (parts.path not in post_paths or body is None):
264
+ raise AdoForbidden("ADO POST is limited to the WIQL query")
265
+ if method == "POST":
266
+ try:
267
+ is_wiql = json.loads(body.decode("utf-8")) == {"query": self._wiql(config[1])}
268
+ except (UnicodeDecodeError, json.JSONDecodeError):
269
+ is_wiql = False
270
+ if not is_wiql:
271
+ raise AdoForbidden("ADO POST is limited to the fixed WIQL query")
272
+
273
+ authorization = base64.b64encode((":" + token).encode("utf-8")).decode("ascii")
274
+ headers = {"Authorization": "Basic " + authorization, "Accept": "application/json"}
275
+ if body is not None:
276
+ headers["Content-Type"] = "application/json"
277
+ request = Request(url, data=body, headers=headers, method=method)
278
+ try:
279
+ with _OPENER.open(request, timeout=TIMEOUT_S) as response:
280
+ status = getattr(response, "status", None)
281
+ getcode = getattr(response, "getcode", None)
282
+ if status is None and getcode:
283
+ status = getcode()
284
+ content_type = getattr(response, "headers", {}).get("Content-Type", "")
285
+ if status == 203 and "html" in content_type.lower():
286
+ raise AdoError("HTTP 203 — token expired or missing scope?")
287
+ return json.loads(response.read().decode("utf-8"))
288
+ except HTTPError as exc:
289
+ message = f"HTTP {exc.code}"
290
+ if exc.code == 401:
291
+ message += " — token expired, missing scope, or wrong organization/project?"
292
+ elif exc.code in (203, 403):
293
+ message += " — token expired or missing scope?"
294
+ if exc.code in (302, 404):
295
+ message += " — check organization/project in the ADO config"
296
+ raise AdoError(message) from None
297
+ except (TimeoutError, socket.timeout):
298
+ raise AdoError("timeout") from None
299
+ except URLError as exc:
300
+ if isinstance(exc.reason, (TimeoutError, socket.timeout)):
301
+ raise AdoError("timeout") from None
302
+ raise AdoError(f"network error ({type(exc).__name__})") from None
303
+ except OSError as exc:
304
+ raise AdoError(f"network error ({type(exc).__name__})") from None
305
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
306
+ raise AdoError("invalid JSON") from None
307
+
308
+ @staticmethod
309
+ def _url(path: str, **query: Any) -> str:
310
+ # Paths already start with /<organization>, as the allowlist expects.
311
+ query.setdefault("api-version", API_VERSION)
312
+ return "https://dev.azure.com" + path + "?" + urlencode(query, doseq=True)
313
+
314
+ def _get(self, path: str, *, config: tuple[str, str], **query: Any) -> dict[str, Any]:
315
+ return self._request("GET", self._url(path, **query), config=config)
316
+
317
+ def _post_wiql(self, config: tuple[str, str]) -> dict[str, Any]:
318
+ organization, project = config
319
+ body = json.dumps({"query": self._wiql(project)}, separators=(",", ":")).encode("utf-8")
320
+ path = f"/{organization}/{quote(project, safe='')}/_apis/wit/wiql"
321
+ return self._request("POST", self._url(path), body, config=config)
322
+
323
+ def snapshot(self) -> dict[str, Any]:
324
+ """Fetch and classify ADO data, or report disabled without network I/O."""
325
+ token, token_error = self._token()
326
+ config, reason = self._config(token)
327
+ # A specific token/config error already says what to fix; "missing" is only for absent ones.
328
+ missing = ([] if token or token_error else ["token"]) + ([] if config or reason else ["config"])
329
+ if not token or config is None:
330
+ return empty_snapshot(config_error=reason, token_error=token_error, missing=missing)
331
+
332
+ organization, project = config
333
+ project_path = quote(project, safe="")
334
+
335
+ connection = self._get(f"/{organization}/_apis/connectionData", config=config, **{"api-version": API_VERSION + "-preview"}) # preview-only API
336
+ person = connection.get("authenticatedUser") or {}
337
+ my_id = person.get("id")
338
+ if not my_id:
339
+ raise AdoError("Azure DevOps identity was unavailable")
340
+ me = {"id": my_id, "name": person.get("providerDisplayName") or connection.get("providerDisplayName")
341
+ or person.get("displayName") or "Me"}
342
+
343
+ wiql = self._post_wiql(config)
344
+ ids = [str(row["id"]) for row in wiql.get("workItems", []) if row.get("id") is not None]
345
+ work_items: list[dict[str, Any]] = []
346
+ fields = "System.Id,System.Title,System.State,System.WorkItemType,System.ChangedDate"
347
+ for start in range(0, len(ids), 200):
348
+ data = self._get(f"/{organization}/{project_path}/_apis/wit/workitems", config=config, ids=",".join(ids[start:start + 200]), fields=fields)
349
+ work_items.extend(self._work_item(item, organization, project) for item in data.get("value", []))
350
+
351
+ prs = self._get(f"/{organization}/{project_path}/_apis/git/pullrequests", config=config, **{"searchCriteria.status": "active", "$top": 500})
352
+ classified = self._classify_prs(prs.get("value", []), my_id, organization, project)
353
+ tasks_by_state: dict[str, list[dict[str, Any]]] = {}
354
+ for item in work_items:
355
+ tasks_by_state.setdefault(item["state"] or "Unknown", []).append(item)
356
+ return {
357
+ "configured": True,
358
+ "config_error": None,
359
+ "token_error": None,
360
+ "missing": [],
361
+ "scope": f"{organization}/{project}",
362
+ "refreshed_at": _iso_now(),
363
+ "stale": False,
364
+ "error": None,
365
+ "me": me,
366
+ "project_active_count": len(prs.get("value", [])),
367
+ **classified,
368
+ "open_tasks": work_items,
369
+ "open_tasks_by_state": tasks_by_state,
370
+ }
371
+
372
+ def _work_item(self, item: dict[str, Any], organization: str, project: str) -> dict[str, Any]:
373
+ fields = item.get("fields") or {}
374
+ work_id = item.get("id") or fields.get("System.Id")
375
+ return {
376
+ "id": work_id,
377
+ "title": fields.get("System.Title") or "Untitled work item",
378
+ "type": fields.get("System.WorkItemType") or "",
379
+ "state": fields.get("System.State") or "",
380
+ "changed_date": fields.get("System.ChangedDate"),
381
+ "url": f"https://dev.azure.com/{organization}/{quote(project, safe='')}/_workitems/edit/{work_id}",
382
+ }
383
+
384
+ def _pr(self, raw: dict[str, Any], organization: str, project: str) -> dict[str, Any]:
385
+ pr_id = raw.get("pullRequestId")
386
+ repo = (raw.get("repository") or {}).get("name") or ""
387
+ reviewers = [{
388
+ "id": reviewer.get("id"),
389
+ "name": reviewer.get("displayName") or reviewer.get("uniqueName") or "Unknown reviewer",
390
+ "vote": int(reviewer.get("vote") or 0),
391
+ } for reviewer in raw.get("reviewers", [])]
392
+ created = raw.get("creationDate")
393
+ age_seconds = None
394
+ if isinstance(created, str):
395
+ try:
396
+ parsed = datetime.fromisoformat(created.replace("Z", "+00:00"))
397
+ age_seconds = max(0, int((_utcnow() - parsed).total_seconds()))
398
+ except ValueError:
399
+ pass
400
+ return {
401
+ "id": pr_id,
402
+ "title": raw.get("title") or "Untitled pull request",
403
+ "repo": repo,
404
+ "url": f"https://dev.azure.com/{organization}/{quote(project, safe='')}/_git/{quote(repo, safe='')}/pullrequest/{pr_id}",
405
+ "created_date": created,
406
+ "age_seconds": age_seconds,
407
+ "age_label": _age_label(age_seconds),
408
+ "reviewers": reviewers,
409
+ }
410
+
411
+ def _classify_prs(self, values: list[dict[str, Any]], my_id: str, organization: str, project: str) -> dict[str, list[dict[str, Any]]]:
412
+ buckets = {
413
+ "to_review": [], "my_changes_requested": [], "my_approved_not_completed": [], "my_waiting_on_others": [],
414
+ }
415
+ for raw in values:
416
+ author_id = (raw.get("createdBy") or {}).get("id")
417
+ reviewers = raw.get("reviewers") or []
418
+ mine = author_id == my_id
419
+ my_review = next((reviewer for reviewer in reviewers if reviewer.get("id") == my_id), None)
420
+ pr = self._pr(raw, organization, project)
421
+ if not mine and my_review is not None and int(my_review.get("vote") or 0) == 0:
422
+ buckets["to_review"].append(pr)
423
+ if not mine:
424
+ continue
425
+ votes = [int(reviewer.get("vote") or 0) for reviewer in reviewers]
426
+ if any(vote < 0 for vote in votes):
427
+ buckets["my_changes_requested"].append(pr)
428
+ elif any(vote >= 5 for vote in votes):
429
+ buckets["my_approved_not_completed"].append(pr)
430
+ else:
431
+ pr["pending_reviewers"] = [reviewer["name"] for reviewer in pr["reviewers"] if reviewer["vote"] == 0]
432
+ buckets["my_waiting_on_others"].append(pr)
433
+ return buckets
devmem/agent_colors.py ADDED
@@ -0,0 +1,74 @@
1
+ """Stable, lightweight colours for live agent identities."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import UTC, datetime, timedelta
5
+
6
+
7
+ PALETTE = (
8
+ ("Orange", "#FF8C42"), ("Teal", "#2DD4BF"), ("Pink", "#F472B6"),
9
+ ("Yellow", "#FACC15"), ("Blue", "#60A5FA"), ("Green", "#4ADE80"),
10
+ ("Purple", "#C084FC"), ("Red", "#F87171"), ("White", "#E5E7EB"),
11
+ ("Lime", "#A3E635"), ("Brown", "#C08457"), ("Cyan", "#22D3EE"),
12
+ )
13
+ RECENT_FOR = timedelta(hours=24)
14
+ PRUNE_AFTER = timedelta(days=30)
15
+ REFRESH_AFTER = timedelta(minutes=5)
16
+ _BY_NAME = dict(PALETTE)
17
+
18
+
19
+ def _time(value: str) -> datetime | None:
20
+ try:
21
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
22
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
23
+ except (AttributeError, ValueError):
24
+ return None
25
+
26
+
27
+ def color_for(conn, agent_key: str) -> dict[str, str] | None:
28
+ """Get an agent's durable palette entry, assigning one when needed."""
29
+ if not agent_key:
30
+ return None
31
+ current = datetime.now(UTC)
32
+ stamp = current.isoformat()
33
+
34
+ def result(row):
35
+ return {"name": row[0], "hex": _BY_NAME[row[0]]} if row[0] in _BY_NAME else None
36
+
37
+ row = conn.execute("SELECT color,last_seen FROM agent_colors WHERE agent_key=?", (agent_key,)).fetchone()
38
+ if row and (seen := _time(row[1])) is not None and current - seen <= REFRESH_AFTER:
39
+ return result(row)
40
+
41
+ owns_transaction = not conn.in_transaction
42
+ if owns_transaction:
43
+ conn.execute("BEGIN IMMEDIATE")
44
+ else:
45
+ conn.execute("SAVEPOINT agent_colors")
46
+ try:
47
+ # Another session may have assigned the colour while this one waited for the lock.
48
+ row = conn.execute("SELECT color,last_seen FROM agent_colors WHERE agent_key=?", (agent_key,)).fetchone()
49
+ if row:
50
+ seen = _time(row[1])
51
+ if seen is None or current - seen > REFRESH_AFTER:
52
+ conn.execute("UPDATE agent_colors SET last_seen=? WHERE agent_key=?", (stamp, agent_key))
53
+ value = result(row)
54
+ else:
55
+ conn.execute("DELETE FROM agent_colors WHERE last_seen<?", ((current - PRUNE_AFTER).isoformat(),))
56
+ held = {row[0] for row in conn.execute("SELECT color FROM agent_colors WHERE last_seen>=?", ((current - RECENT_FOR).isoformat(),))}
57
+ available = next((name for name, _ in PALETTE if name not in held), None)
58
+ if available is None:
59
+ oldest = conn.execute("SELECT color FROM agent_colors ORDER BY last_seen LIMIT 1").fetchone()
60
+ available = oldest[0] if oldest and oldest[0] in _BY_NAME else PALETTE[0][0]
61
+ conn.execute("INSERT INTO agent_colors(agent_key,color,last_seen) VALUES(?,?,?)", (agent_key, available, stamp))
62
+ value = {"name": available, "hex": _BY_NAME[available]}
63
+ if owns_transaction:
64
+ conn.commit()
65
+ else:
66
+ conn.execute("RELEASE agent_colors")
67
+ return value
68
+ except Exception:
69
+ if owns_transaction:
70
+ conn.rollback()
71
+ else:
72
+ conn.execute("ROLLBACK TO agent_colors")
73
+ conn.execute("RELEASE agent_colors")
74
+ raise