ltcai 8.8.0 → 9.0.0

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 (111) hide show
  1. package/README.md +33 -37
  2. package/auto_setup.py +84 -70
  3. package/docs/CHANGELOG.md +113 -237
  4. package/docs/CODE_REVIEW_2026-07-06.md +764 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
  6. package/docs/DEVELOPMENT.md +10 -10
  7. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  8. package/docs/ONBOARDING.md +2 -2
  9. package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
  10. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  11. package/docs/TRUST_MODEL.md +5 -1
  12. package/docs/WHY_LATTICE.md +4 -3
  13. package/docs/architecture.md +4 -0
  14. package/docs/kg-schema.md +1 -1
  15. package/lattice_brain/__init__.py +1 -1
  16. package/lattice_brain/archive.py +4 -9
  17. package/lattice_brain/conversations.py +156 -21
  18. package/lattice_brain/embeddings.py +38 -2
  19. package/lattice_brain/graph/_kg_common.py +39 -496
  20. package/lattice_brain/graph/_kg_constants.py +243 -0
  21. package/lattice_brain/graph/_kg_fsutil.py +297 -0
  22. package/lattice_brain/graph/discovery.py +0 -948
  23. package/lattice_brain/graph/discovery_index.py +972 -0
  24. package/lattice_brain/graph/json_utils.py +25 -0
  25. package/lattice_brain/graph/retrieval.py +66 -597
  26. package/lattice_brain/graph/retrieval_docgen.py +210 -0
  27. package/lattice_brain/graph/retrieval_vector.py +460 -0
  28. package/lattice_brain/graph/runtime.py +16 -0
  29. package/lattice_brain/graph/store.py +6 -0
  30. package/lattice_brain/ingestion.py +68 -0
  31. package/lattice_brain/portability.py +1 -9
  32. package/lattice_brain/quality.py +98 -4
  33. package/lattice_brain/runtime/agent_runtime.py +166 -0
  34. package/lattice_brain/runtime/multi_agent.py +1 -1
  35. package/lattice_brain/utils.py +28 -0
  36. package/latticeai/__init__.py +1 -1
  37. package/latticeai/api/chat.py +368 -418
  38. package/latticeai/api/chat_helpers.py +227 -0
  39. package/latticeai/api/computer_use.py +149 -31
  40. package/latticeai/api/marketplace.py +11 -0
  41. package/latticeai/api/mcp.py +3 -2
  42. package/latticeai/api/models.py +4 -1
  43. package/latticeai/api/permissions.py +72 -33
  44. package/latticeai/api/setup.py +17 -2
  45. package/latticeai/api/tools.py +105 -62
  46. package/latticeai/app_factory.py +101 -296
  47. package/latticeai/core/agent.py +25 -7
  48. package/latticeai/core/io_utils.py +37 -0
  49. package/latticeai/core/legacy_compatibility.py +1 -1
  50. package/latticeai/core/local_embeddings.py +2 -4
  51. package/latticeai/core/marketplace.py +33 -2
  52. package/latticeai/core/mcp_catalog.py +450 -0
  53. package/latticeai/core/mcp_registry.py +2 -441
  54. package/latticeai/core/sessions.py +11 -3
  55. package/latticeai/core/tool_registry.py +15 -4
  56. package/latticeai/core/users.py +4 -9
  57. package/latticeai/core/workspace_os.py +1 -1
  58. package/latticeai/core/workspace_os_utils.py +3 -17
  59. package/latticeai/integrations/telegram_bot.py +7 -2
  60. package/latticeai/models/model_providers.py +111 -0
  61. package/latticeai/models/router.py +58 -136
  62. package/latticeai/runtime/audit_runtime.py +27 -16
  63. package/latticeai/runtime/automation_runtime.py +9 -0
  64. package/latticeai/runtime/bootstrap.py +1 -1
  65. package/latticeai/runtime/history_runtime.py +163 -0
  66. package/latticeai/runtime/namespace_runtime.py +173 -0
  67. package/latticeai/runtime/network_config_runtime.py +56 -0
  68. package/latticeai/runtime/sso_config_runtime.py +128 -0
  69. package/latticeai/runtime/user_key_runtime.py +106 -0
  70. package/latticeai/services/app_context.py +1 -0
  71. package/latticeai/services/architecture_readiness.py +2 -2
  72. package/latticeai/services/memory_service.py +213 -0
  73. package/latticeai/services/model_engines.py +79 -12
  74. package/latticeai/services/model_runtime.py +24 -4
  75. package/latticeai/services/platform_runtime.py +9 -1
  76. package/latticeai/services/process_audit.py +208 -0
  77. package/latticeai/services/product_readiness.py +11 -11
  78. package/latticeai/services/review_queue.py +64 -11
  79. package/latticeai/services/run_executor.py +21 -0
  80. package/latticeai/services/search_service.py +106 -30
  81. package/latticeai/services/setup_detection.py +80 -0
  82. package/latticeai/services/tool_dispatch.py +66 -0
  83. package/latticeai/services/workspace_service.py +15 -0
  84. package/package.json +1 -1
  85. package/scripts/check_i18n_literals.mjs +20 -8
  86. package/scripts/i18n_literal_allowlist.json +34 -0
  87. package/scripts/lint_frontend.mjs +6 -2
  88. package/setup_wizard.py +196 -74
  89. package/src-tauri/Cargo.lock +1 -1
  90. package/src-tauri/Cargo.toml +1 -1
  91. package/src-tauri/tauri.conf.json +1 -1
  92. package/static/app/asset-manifest.json +11 -11
  93. package/static/app/assets/{Act-C7K9wsO9.js → Act-21lIXx2E.js} +1 -1
  94. package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
  95. package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-BA7Z2Q1u.js} +1 -1
  96. package/static/app/assets/{Library-Cgj-EF50.js → Library-bFMtyni3.js} +1 -1
  97. package/static/app/assets/System-K6krGCqn.js +1 -0
  98. package/static/app/assets/index-C4R3ws30.js +17 -0
  99. package/static/app/assets/index-ChSeOB02.css +2 -0
  100. package/static/app/assets/primitives-sQU3it5I.js +1 -0
  101. package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-DK3Fd_lR.js} +1 -1
  102. package/static/app/index.html +2 -2
  103. package/static/css/tokens.css +4 -2
  104. package/static/sw.js +1 -1
  105. package/tools/local_files.py +6 -0
  106. package/latticeai/runtime/sso_runtime.py +0 -52
  107. package/static/app/assets/Brain-I1OSzxJu.js +0 -321
  108. package/static/app/assets/System-D1Lkei3I.js +0 -1
  109. package/static/app/assets/index--P0ksosz.js +0 -17
  110. package/static/app/assets/index-DCh5AoXt.css +0 -2
  111. package/static/app/assets/primitives-BLqaKk5g.js +0 -1
@@ -0,0 +1,208 @@
1
+ """Auditable command plans for installer and local process execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any, Iterable, Mapping, Optional, Sequence
12
+
13
+
14
+ _SECRET_RE = re.compile(
15
+ r"(api[_-]?key|access[_-]?token|auth[_-]?token|bearer|client[_-]?secret|password|secret|token)",
16
+ re.IGNORECASE,
17
+ )
18
+ _SENSITIVE_FLAGS = {
19
+ "--api-key",
20
+ "--access-token",
21
+ "--auth-token",
22
+ "--client-secret",
23
+ "--password",
24
+ "--secret",
25
+ "--token",
26
+ }
27
+
28
+
29
+ class CommandConfirmationError(ValueError):
30
+ """Raised when a process command lacks the expected execution token."""
31
+
32
+
33
+ def _default_audit_file() -> Path:
34
+ base = Path(os.environ.get("LATTICEAI_DATA_DIR") or Path.home() / ".ltcai")
35
+ return base / "process_audit.jsonl"
36
+
37
+
38
+ def _normalize_command(command: Sequence[Any]) -> list[str]:
39
+ return [str(part) for part in command]
40
+
41
+
42
+ def redact_command(command: Sequence[Any]) -> list[str]:
43
+ redacted: list[str] = []
44
+ redact_next = False
45
+ for raw in _normalize_command(command):
46
+ lower = raw.lower()
47
+ if redact_next:
48
+ redacted.append("[REDACTED]")
49
+ redact_next = False
50
+ continue
51
+ if lower in _SENSITIVE_FLAGS:
52
+ redacted.append(raw)
53
+ redact_next = True
54
+ continue
55
+ if "=" in raw:
56
+ key, _value = raw.split("=", 1)
57
+ if _SECRET_RE.search(key):
58
+ redacted.append(f"{key}=[REDACTED]")
59
+ continue
60
+ if _SECRET_RE.search(raw) and len(raw) > 32:
61
+ redacted.append("[REDACTED]")
62
+ continue
63
+ redacted.append(raw)
64
+ return redacted
65
+
66
+
67
+ def _digest_payload(payload: Mapping[str, Any]) -> str:
68
+ encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
69
+ return hashlib.sha256(encoded).hexdigest()
70
+
71
+
72
+ def command_hash(command: Sequence[Any], *, cwd: Optional[str] = None) -> str:
73
+ return _digest_payload({"command": _normalize_command(command), "cwd": cwd or ""})
74
+
75
+
76
+ def confirmation_token(command: Sequence[Any], *, cwd: Optional[str] = None, purpose: str = "execute") -> str:
77
+ digest = _digest_payload(
78
+ {
79
+ "purpose": purpose,
80
+ "command": _normalize_command(command),
81
+ "cwd": cwd or "",
82
+ }
83
+ )
84
+ return digest[:16]
85
+
86
+
87
+ def command_plan(
88
+ command: Sequence[Any],
89
+ *,
90
+ name: str,
91
+ purpose: str = "installer",
92
+ cwd: Optional[str] = None,
93
+ requires_admin: bool = False,
94
+ metadata: Optional[Mapping[str, Any]] = None,
95
+ ) -> dict[str, Any]:
96
+ return {
97
+ "name": name,
98
+ "purpose": purpose,
99
+ "command_preview": redact_command(command),
100
+ "command_hash": command_hash(command, cwd=cwd),
101
+ "confirmation_token": confirmation_token(command, cwd=cwd, purpose=purpose),
102
+ "requires_admin": bool(requires_admin),
103
+ "cwd": cwd,
104
+ "metadata": dict(metadata or {}),
105
+ }
106
+
107
+
108
+ def command_plan_for_commands(
109
+ commands: Iterable[Sequence[Any]],
110
+ *,
111
+ name: str,
112
+ purpose: str = "installer",
113
+ cwd: Optional[str] = None,
114
+ metadata: Optional[Mapping[str, Any]] = None,
115
+ ) -> dict[str, Any]:
116
+ normalized = [_normalize_command(command) for command in commands]
117
+ digest = _digest_payload({"purpose": purpose, "commands": normalized, "cwd": cwd or ""})
118
+ return {
119
+ "name": name,
120
+ "purpose": purpose,
121
+ "command_count": len(normalized),
122
+ "command_hash": digest,
123
+ "confirmation_token": digest[:16],
124
+ "cwd": cwd,
125
+ "metadata": dict(metadata or {}),
126
+ }
127
+
128
+
129
+ def verify_command_confirmation(
130
+ command: Sequence[Any],
131
+ provided_token: Optional[str],
132
+ *,
133
+ cwd: Optional[str] = None,
134
+ purpose: str = "installer",
135
+ ) -> bool:
136
+ if not provided_token:
137
+ return False
138
+ return str(provided_token).strip() == confirmation_token(command, cwd=cwd, purpose=purpose)
139
+
140
+
141
+ def require_command_confirmation(
142
+ command: Sequence[Any],
143
+ provided_token: Optional[str],
144
+ *,
145
+ cwd: Optional[str] = None,
146
+ purpose: str = "installer",
147
+ ) -> None:
148
+ if not verify_command_confirmation(command, provided_token, cwd=cwd, purpose=purpose):
149
+ raise CommandConfirmationError("installer command requires a matching confirmation token")
150
+
151
+
152
+ def _preview_text(text: Optional[str], *, limit: int = 500) -> Optional[str]:
153
+ if text is None:
154
+ return None
155
+ value = str(text)[-limit:]
156
+ value = re.sub(r"(?i)(api[_-]?key|access[_-]?token|auth[_-]?token|password|secret|token)=\S+", r"\1=[REDACTED]", value)
157
+ return value
158
+
159
+
160
+ def _text_hash(text: Optional[str]) -> Optional[str]:
161
+ if text is None:
162
+ return None
163
+ return hashlib.sha256(str(text).encode("utf-8", errors="replace")).hexdigest()
164
+
165
+
166
+ def append_process_audit_event(
167
+ event_type: str,
168
+ *,
169
+ plan: Mapping[str, Any],
170
+ status: str,
171
+ user_email: Optional[str] = None,
172
+ returncode: Optional[int] = None,
173
+ stdout: Optional[str] = None,
174
+ stderr: Optional[str] = None,
175
+ error: Optional[str] = None,
176
+ audit_file: Optional[Path] = None,
177
+ extra: Optional[Mapping[str, Any]] = None,
178
+ ) -> None:
179
+ path = audit_file or _default_audit_file()
180
+ entry = {
181
+ "ts": time.time(),
182
+ "event_type": event_type,
183
+ "status": status,
184
+ "user_email": user_email,
185
+ "plan": {
186
+ "name": plan.get("name"),
187
+ "purpose": plan.get("purpose"),
188
+ "command_preview": plan.get("command_preview"),
189
+ "command_hash": plan.get("command_hash"),
190
+ "requires_admin": plan.get("requires_admin"),
191
+ "cwd": plan.get("cwd"),
192
+ "metadata": plan.get("metadata") or {},
193
+ },
194
+ "returncode": returncode,
195
+ "stdout_hash": _text_hash(stdout),
196
+ "stderr_hash": _text_hash(stderr),
197
+ "stdout_preview": _preview_text(stdout),
198
+ "stderr_preview": _preview_text(stderr),
199
+ "error": _preview_text(error),
200
+ "extra": dict(extra or {}),
201
+ }
202
+ try:
203
+ path.parent.mkdir(parents=True, exist_ok=True)
204
+ with path.open("a", encoding="utf-8") as fh:
205
+ fh.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n")
206
+ except Exception:
207
+ # Auditing must never make setup recovery paths unusable.
208
+ return
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "8.8.0"
21
+ PRODUCT_VERSION_TARGET = "9.0.0"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -76,10 +76,10 @@ PRODUCT_GATES: List[ProductGate] = [
76
76
  evidence=[
77
77
  "package.json::release:artifacts",
78
78
  "package.json::release:validate",
79
- "README.md::dist/ltcai-8.8.0-py3-none-any.whl",
80
- "README.md::dist/ltcai-8.8.0.tar.gz",
81
- "README.md::dist/ltcai-8.8.0.vsix",
82
- "README.md::ltcai-8.8.0.tgz",
79
+ "README.md::dist/ltcai-9.0.0-py3-none-any.whl",
80
+ "README.md::dist/ltcai-9.0.0.tar.gz",
81
+ "README.md::dist/ltcai-9.0.0.vsix",
82
+ "README.md::ltcai-9.0.0.tgz",
83
83
  "scripts/validate_release_artifacts.py",
84
84
  "scripts/release_smoke.py",
85
85
  "Dockerfile",
@@ -95,12 +95,12 @@ PRODUCT_GATES: List[ProductGate] = [
95
95
  title="Release story is documented and honest",
96
96
  evidence=[
97
97
  "README.md",
98
- "README.md::The current release is **8.8.0",
99
- "SECURITY.md::8.8.x (latest)",
100
- "vscode-extension/README.md::**8.8.0",
101
- "docs/CHANGELOG.md::## [8.8.0]",
98
+ "README.md::The current release is **9.0.0",
99
+ "SECURITY.md::9.0.x (latest)",
100
+ "vscode-extension/README.md::**9.0.0",
101
+ "docs/CHANGELOG.md::## [9.0.0]",
102
102
  "FEATURE_STATUS.md",
103
- "RELEASE_NOTES_v8.8.0.md",
103
+ "RELEASE_NOTES_v9.0.0.md",
104
104
  "latticeai/core/agent.py::SingleAgentRuntime",
105
105
  "latticeai/core/agent.py::AgentRuntime = SingleAgentRuntime",
106
106
  "lattice_brain/runtime/contracts.py::runtime-boundary/v1",
@@ -115,7 +115,7 @@ PRODUCT_GATES: List[ProductGate] = [
115
115
  id="ecosystem-path",
116
116
  title="Community and plugin growth path is explicit",
117
117
  evidence=[
118
- "docs/COMMUNITY_AND_PLUGINS.md::8.8.0",
118
+ "docs/COMMUNITY_AND_PLUGINS.md::9.0.0",
119
119
  "docs/PLUGIN_SDK.md",
120
120
  "plugins/README.md",
121
121
  "plugins/hello-world/plugin.json",
@@ -21,12 +21,14 @@ from __future__ import annotations
21
21
  from datetime import datetime
22
22
  from typing import Any, Callable, Dict, Optional
23
23
 
24
+ from latticeai.core.io_utils import parse_iso as _parse_iso
25
+
24
26
  # status: terminal vs. open. Open items can still be acted on.
25
27
  OPEN_STATUSES = {"pending", "snoozed"}
26
28
  TERMINAL_STATUSES = {"approved", "dismissed"}
27
29
  ALL_STATUSES = OPEN_STATUSES | TERMINAL_STATUSES
28
30
 
29
- REVIEW_SOURCES = frozenset({"workflow_run", "trigger", "kg_change_digest"})
31
+ REVIEW_SOURCES = frozenset({"workflow_run", "trigger", "kg_change_digest", "chat_followup", "agent_followup"})
30
32
 
31
33
  # Which source statuses each action is allowed from.
32
34
  _ALLOWED_FROM: Dict[str, set] = {
@@ -59,15 +61,6 @@ class InvalidReviewTransition(Exception):
59
61
  super().__init__(f"cannot {action!r} a review item in status {status!r}")
60
62
 
61
63
 
62
- def _parse_iso(value: Optional[str]) -> Optional[datetime]:
63
- if not value:
64
- return None
65
- try:
66
- return datetime.fromisoformat(str(value))
67
- except (TypeError, ValueError):
68
- return None
69
-
70
-
71
64
  class ReviewQueueService:
72
65
  """Policy layer over the store's workspace-scoped ``review_items``."""
73
66
 
@@ -122,7 +115,20 @@ class ReviewQueueService:
122
115
 
123
116
  # ── transitions ──────────────────────────────────────────────────────
124
117
  def approve(self, item_id: str, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
125
- return self._transition(item_id, "approve", "approved", workspace_id=workspace_id)
118
+ item = self._store.get_review_item(item_id, workspace_id=workspace_id)
119
+ self._guard("approve", item)
120
+ payload = dict(item.get("payload") or {})
121
+ provenance = dict(item.get("provenance") or {})
122
+ promoted = self._promote_agent_followup(item, workspace_id=workspace_id)
123
+ if promoted:
124
+ payload["promoted_workflow_id"] = promoted.get("id")
125
+ provenance["workflow_id"] = promoted.get("id")
126
+ provenance["promotion"] = "workflow_draft"
127
+ patch: Dict[str, Any] = {"status": "approved", "payload": payload, "provenance": provenance}
128
+ if item.get("snoozed_until") is not None:
129
+ patch["snoozed_until"] = None
130
+ updated = self._store.update_review_item(item_id, workspace_id=workspace_id, **patch)
131
+ return self._view(updated)
126
132
 
127
133
  def dismiss(self, item_id: str, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
128
134
  return self._transition(item_id, "dismiss", "dismissed", workspace_id=workspace_id)
@@ -210,6 +216,53 @@ class ReviewQueueService:
210
216
  view["effective_status"] = self._effective_status(item)
211
217
  return view
212
218
 
219
+ def _promote_agent_followup(self, item: Dict[str, Any], *, workspace_id: Optional[str]) -> Optional[Dict[str, Any]]:
220
+ if item.get("source") != "agent_followup" or not hasattr(self._store, "create_workflow"):
221
+ return None
222
+ payload = dict(item.get("payload") or {})
223
+ followup = str(payload.get("followup") or item.get("title") or "").strip()
224
+ if not followup:
225
+ return None
226
+ goal = str(payload.get("goal") or item.get("summary") or followup).strip()
227
+ nodes = [
228
+ {
229
+ "id": "trigger",
230
+ "type": "trigger",
231
+ "config": {"trigger": "manual", "review_queue": False},
232
+ "next": "agent",
233
+ },
234
+ {
235
+ "id": "agent",
236
+ "type": "agent",
237
+ "config": {
238
+ "goal": followup,
239
+ "roles": ["planner", "executor", "reviewer"],
240
+ "source": "agent_followup",
241
+ },
242
+ "next": "output",
243
+ },
244
+ {
245
+ "id": "output",
246
+ "type": "output",
247
+ "config": {"format": "review_followup"},
248
+ "next": None,
249
+ },
250
+ ]
251
+ return self._store.create_workflow(
252
+ name=f"Follow-up: {str(item.get('title') or followup)[:96]}",
253
+ steps=[{"action": "agent", "goal": followup}],
254
+ nodes=nodes,
255
+ metadata={
256
+ "source": "review_center",
257
+ "review_item_id": item.get("id"),
258
+ "agent_followup": followup,
259
+ "goal": goal,
260
+ "draft": True,
261
+ },
262
+ user_email=item.get("user_email"),
263
+ workspace_id=workspace_id or item.get("workspace_id"),
264
+ )
265
+
213
266
 
214
267
  def enqueue_from_automation(
215
268
  sink: "ReviewQueueService",
@@ -129,6 +129,27 @@ class RunExecutor:
129
129
  self._cancel_agent_record(run_id, handle.scope, "cancelled after the final result was persisted")
130
130
  else:
131
131
  self._results[run_id] = payload
132
+ except Exception as exc:
133
+ try:
134
+ run = self.store.get_agent_run(run_id, workspace_id=handle.scope)
135
+ timeline = list(run.get("timeline") or [])
136
+ timeline.append({"event": "execution_failed", "status": "failed", "detail": str(exc), "timestamp": _now()})
137
+ failed = self.store.update_agent_run(
138
+ run_id,
139
+ workspace_id=handle.scope,
140
+ status="failed",
141
+ current_role=None,
142
+ output_text=str(exc),
143
+ timeline=timeline,
144
+ graph=self.workspace_graph(),
145
+ )
146
+ self._results[run_id] = {"run": failed, "result": {"status": "failed", "error": str(exc)}}
147
+ except Exception:
148
+ self._results[run_id] = {"run": {"id": run_id, "status": "failed"}, "result": {"status": "failed", "error": str(exc)}}
149
+ try:
150
+ self.append_audit_event("agent_run_failed", user_email=user_email, run_id=run_id, error=str(exc))
151
+ except Exception:
152
+ pass
132
153
  finally:
133
154
  self._handles.pop(run_id, None)
134
155
 
@@ -42,9 +42,84 @@ class SearchService:
42
42
  graph = self._require_graph()
43
43
  return graph.filter_scoped_nodes(matches, allowed_workspaces)
44
44
 
45
+ def _graph_search(self, graph, query: str, *, limit: int, allowed_workspaces=None):
46
+ try:
47
+ return graph.search(query, limit, allowed_workspaces=allowed_workspaces)
48
+ except TypeError:
49
+ payload = graph.search(query, limit)
50
+ if allowed_workspaces is not None:
51
+ payload = {
52
+ **payload,
53
+ "matches": graph.filter_scoped_nodes(
54
+ payload.get("matches", []),
55
+ allowed_workspaces,
56
+ ),
57
+ }
58
+ return payload
59
+
60
+ def _relationship_search(self, graph, *, allowed_workspaces=None, **kwargs):
61
+ try:
62
+ return graph.relationship_search(
63
+ **kwargs,
64
+ allowed_workspaces=allowed_workspaces,
65
+ )
66
+ except TypeError:
67
+ payload = graph.relationship_search(**kwargs)
68
+ if allowed_workspaces is not None:
69
+ kept = []
70
+ for rel in payload.get("relationships", []):
71
+ endpoints = [
72
+ {"id": (rel.get("source") or {}).get("id")},
73
+ {"id": (rel.get("target") or {}).get("id")},
74
+ ]
75
+ if len(graph.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
76
+ kept.append(rel)
77
+ payload = {**payload, "relationships": kept}
78
+ return payload
79
+
80
+ def _traverse(self, graph, node_id: str, *, depth: int, limit: int, allowed_workspaces=None):
81
+ try:
82
+ return graph.traverse(
83
+ node_id,
84
+ depth=depth,
85
+ limit=limit,
86
+ allowed_workspaces=allowed_workspaces,
87
+ )
88
+ except TypeError:
89
+ neighborhood = graph.traverse(node_id, depth=depth, limit=limit)
90
+ if allowed_workspaces is not None:
91
+ nodes = graph.filter_scoped_nodes(
92
+ neighborhood.get("nodes", []),
93
+ allowed_workspaces,
94
+ )
95
+ kept = {item.get("id") for item in nodes}
96
+ edges = [
97
+ edge for edge in neighborhood.get("edges", [])
98
+ if edge.get("from") in kept and edge.get("to") in kept
99
+ ]
100
+ neighborhood = {**neighborhood, "nodes": nodes, "edges": edges}
101
+ return neighborhood
102
+
103
+ def _get_node(self, graph, node_id: str, *, allowed_workspaces=None):
104
+ try:
105
+ return graph.get_node(node_id, allowed_workspaces=allowed_workspaces)
106
+ except TypeError:
107
+ node = graph.get_node(node_id)
108
+ if allowed_workspaces is not None:
109
+ visible = graph.filter_scoped_nodes([node], allowed_workspaces)
110
+ if not visible:
111
+ raise ValueError(f"graph node not found: {node_id}")
112
+ return visible[0]
113
+ return node
114
+
45
115
  def keyword_search(self, query: str, *, limit: int = 30, allowed_workspaces=None) -> Dict[str, Any]:
46
116
  graph = self._require_graph()
47
- payload = graph.search(query, limit)
117
+ payload = self._graph_search(
118
+ graph,
119
+ query,
120
+ limit=limit,
121
+ allowed_workspaces=allowed_workspaces,
122
+ )
48
123
  matches = []
49
124
  for rank, match in enumerate(payload.get("matches", []), start=1):
50
125
  matches.append({
@@ -95,8 +170,18 @@ class SearchService:
95
170
  graph = self._require_graph()
96
171
  limit = max(1, min(int(limit or 30), 100))
97
172
  expand_depth = max(0, min(int(expand_depth or 1), 3))
98
- direct = graph.search(query, limit=max(limit, 10)).get("matches", [])
99
- relationships = graph.relationship_search(query=query, limit=limit).get("relationships", [])
173
+ direct = self._graph_search(
174
+ graph,
175
+ query,
176
+ limit=max(limit, 10),
177
+ allowed_workspaces=allowed_workspaces,
178
+ ).get("matches", [])
179
+ relationships = self._relationship_search(
180
+ graph,
181
+ query=query,
182
+ limit=limit,
183
+ allowed_workspaces=allowed_workspaces,
184
+ ).get("relationships", [])
100
185
  by_id: Dict[str, Dict[str, Any]] = {}
101
186
 
102
187
  def add_node(node: Mapping[str, Any], score: float, reason: str, edge: Optional[Mapping[str, Any]] = None) -> None:
@@ -138,7 +223,13 @@ class SearchService:
138
223
  if expand_depth <= 0:
139
224
  continue
140
225
  try:
141
- neighborhood = graph.traverse(match["id"], depth=expand_depth, limit=limit * 3)
226
+ neighborhood = self._traverse(
227
+ graph,
228
+ match["id"],
229
+ depth=expand_depth,
230
+ limit=limit * 3,
231
+ allowed_workspaces=allowed_workspaces,
232
+ )
142
233
  except Exception:
143
234
  neighborhood = {"nodes": [], "edges": []}
144
235
  edge_by_pair = {
@@ -262,23 +353,16 @@ class SearchService:
262
353
  allowed_workspaces=None,
263
354
  ) -> Dict[str, Any]:
264
355
  graph = self._require_graph()
265
- node = graph.get_node(node_id)
266
- if allowed_workspaces is not None:
267
- visible = graph.filter_scoped_nodes([node], allowed_workspaces)
268
- if not visible:
269
- raise ValueError(f"graph node not found: {node_id}")
270
- node = visible[0]
356
+ node = self._get_node(graph, node_id, allowed_workspaces=allowed_workspaces)
271
357
  payload = {"node": node}
272
358
  if include_neighbors:
273
- neighborhood = graph.traverse(node_id, depth=depth, limit=limit)
274
- if allowed_workspaces is not None:
275
- nodes = graph.filter_scoped_nodes(neighborhood.get("nodes", []), allowed_workspaces)
276
- kept = {item.get("id") for item in nodes}
277
- edges = [
278
- edge for edge in neighborhood.get("edges", [])
279
- if edge.get("from") in kept and edge.get("to") in kept
280
- ]
281
- neighborhood = {**neighborhood, "nodes": nodes, "edges": edges}
359
+ neighborhood = self._traverse(
360
+ graph,
361
+ node_id,
362
+ depth=depth,
363
+ limit=limit,
364
+ allowed_workspaces=allowed_workspaces,
365
+ )
282
366
  payload["neighborhood"] = neighborhood
283
367
  return payload
284
368
 
@@ -292,22 +376,14 @@ class SearchService:
292
376
  allowed_workspaces=None,
293
377
  ) -> Dict[str, Any]:
294
378
  graph = self._require_graph()
295
- payload = graph.relationship_search(
379
+ payload = self._relationship_search(
380
+ graph,
296
381
  query=query,
297
382
  node_id=node_id,
298
383
  relationship_type=relationship_type,
299
384
  limit=limit,
385
+ allowed_workspaces=allowed_workspaces,
300
386
  )
301
- if allowed_workspaces is not None:
302
- kept = []
303
- for rel in payload.get("relationships", []):
304
- endpoints = [
305
- {"id": (rel.get("source") or {}).get("id")},
306
- {"id": (rel.get("target") or {}).get("id")},
307
- ]
308
- if len(graph.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
309
- kept.append(rel)
310
- payload = {**payload, "relationships": kept}
311
387
  return payload
312
388
 
313
389
  def index_status(self) -> Dict[str, Any]:
@@ -0,0 +1,80 @@
1
+ """Shared setup/environment detection helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
8
+
9
+
10
+ WhichFn = Callable[[str], Optional[str]]
11
+ RunFn = Callable[[List[str]], str]
12
+
13
+
14
+ def parse_windows_video_controllers(raw: str) -> List[Dict[str, Any]]:
15
+ controllers: List[Dict[str, Any]] = []
16
+ if not raw:
17
+ return controllers
18
+ try:
19
+ data = json.loads(raw)
20
+ if isinstance(data, dict):
21
+ data = [data]
22
+ if isinstance(data, list):
23
+ for item in data:
24
+ name = str(item.get("Name") or "").strip()
25
+ if not name:
26
+ continue
27
+ try:
28
+ ram_mb = int(item.get("AdapterRAM") or 0) // (1024 * 1024)
29
+ except Exception:
30
+ ram_mb = 0
31
+ controllers.append({"name": name, "vram_mb": ram_mb})
32
+ if controllers:
33
+ return controllers
34
+ except Exception:
35
+ pass
36
+ current: Dict[str, Any] = {}
37
+ for line in raw.splitlines():
38
+ if line.startswith("Name="):
39
+ if current:
40
+ controllers.append(current)
41
+ current = {"name": line.split("=", 1)[-1].strip(), "vram_mb": 0}
42
+ elif line.startswith("AdapterRAM=") and current:
43
+ try:
44
+ current["vram_mb"] = int(line.split("=", 1)[-1].strip()) // (1024 * 1024)
45
+ except ValueError:
46
+ current["vram_mb"] = 0
47
+ if current:
48
+ controllers.append(current)
49
+ return controllers
50
+
51
+
52
+ def detect_cuda(which: WhichFn, run: RunFn) -> Tuple[bool, str, Optional[str], Optional[str]]:
53
+ nvidia_smi = which("nvidia-smi")
54
+ nvcc = which("nvcc")
55
+ version = ""
56
+ if nvidia_smi:
57
+ raw = run([nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"])
58
+ version = raw.splitlines()[0].strip() if raw.splitlines() else ""
59
+ if nvcc:
60
+ raw = run([nvcc, "--version"])
61
+ match = re.search(r"release\s+([\d.]+)", raw)
62
+ if match:
63
+ version = match.group(1)
64
+ return bool(nvidia_smi or nvcc), version, nvidia_smi, nvcc
65
+
66
+
67
+ def detect_wsl_from_text(prof_os: str, raw: str) -> Tuple[bool, str]:
68
+ if prof_os.lower() != "linux":
69
+ return False, ""
70
+ lowered = (raw or "").lower()
71
+ is_wsl = "microsoft" in lowered or "wsl" in lowered
72
+ version = "2" if "microsoft-standard" in lowered or "wsl2" in lowered else ("1" if is_wsl else "")
73
+ return is_wsl, version
74
+
75
+
76
+ def detect_tools(which: WhichFn, binaries: Iterable[str]) -> Dict[str, Optional[str]]:
77
+ return {binary: which(binary) for binary in binaries}
78
+
79
+
80
+ __all__ = ["detect_cuda", "detect_tools", "detect_wsl_from_text", "parse_windows_video_controllers"]