ltcai 4.3.3 → 4.5.1

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 (138) hide show
  1. package/README.md +53 -20
  2. package/docs/CHANGELOG.md +122 -0
  3. package/docs/V4_4_0_EXTRACTION_REPORT.md +239 -0
  4. package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +49 -0
  5. package/docs/V4_5_0_GRAPH_UX_REPORT.md +34 -0
  6. package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +40 -0
  7. package/docs/V4_5_0_ONBOARDING_REPORT.md +31 -0
  8. package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +49 -0
  9. package/docs/V4_5_0_VALIDATION_REPORT.md +60 -0
  10. package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +33 -0
  11. package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +37 -0
  12. package/docs/V4_5_1_NAVIGATION_REPORT.md +37 -0
  13. package/docs/V4_5_1_ONBOARDING_REPORT.md +29 -0
  14. package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +61 -0
  15. package/docs/V4_5_1_RC_ARTIFACTS.md +44 -0
  16. package/docs/V4_5_1_UX_REPORT.md +45 -0
  17. package/docs/V4_5_1_VALIDATION_REPORT.md +54 -0
  18. package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +30 -0
  19. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +16 -16
  20. package/docs/architecture.md +8 -4
  21. package/frontend/src/App.tsx +152 -91
  22. package/frontend/src/api/client.ts +83 -1
  23. package/frontend/src/components/FirstRunGuide.tsx +99 -0
  24. package/frontend/src/components/primitives.tsx +131 -25
  25. package/frontend/src/components/ui/badge.tsx +2 -2
  26. package/frontend/src/components/ui/button.tsx +7 -7
  27. package/frontend/src/components/ui/card.tsx +5 -5
  28. package/frontend/src/components/ui/input.tsx +1 -1
  29. package/frontend/src/components/ui/textarea.tsx +1 -1
  30. package/frontend/src/pages/Act.tsx +58 -28
  31. package/frontend/src/pages/Ask.tsx +51 -19
  32. package/frontend/src/pages/Brain.tsx +60 -42
  33. package/frontend/src/pages/Capture.tsx +24 -24
  34. package/frontend/src/pages/Library.tsx +222 -32
  35. package/frontend/src/pages/System.tsx +56 -34
  36. package/frontend/src/routes.ts +15 -13
  37. package/frontend/src/store/appStore.ts +8 -1
  38. package/frontend/src/styles.css +666 -36
  39. package/lattice_brain/__init__.py +38 -23
  40. package/lattice_brain/_kg_common.py +11 -1
  41. package/lattice_brain/context.py +212 -2
  42. package/lattice_brain/conversations.py +234 -1
  43. package/lattice_brain/discovery.py +11 -1
  44. package/lattice_brain/documents.py +11 -1
  45. package/lattice_brain/graph/__init__.py +28 -0
  46. package/lattice_brain/graph/_kg_common.py +1123 -0
  47. package/lattice_brain/graph/curator.py +473 -0
  48. package/lattice_brain/graph/discovery.py +1455 -0
  49. package/lattice_brain/graph/documents.py +218 -0
  50. package/lattice_brain/graph/identity.py +175 -0
  51. package/lattice_brain/graph/ingest.py +644 -0
  52. package/lattice_brain/graph/network.py +205 -0
  53. package/lattice_brain/graph/projection.py +571 -0
  54. package/lattice_brain/graph/provenance.py +401 -0
  55. package/lattice_brain/graph/retrieval.py +1341 -0
  56. package/lattice_brain/graph/schema.py +640 -0
  57. package/lattice_brain/graph/store.py +237 -0
  58. package/lattice_brain/graph/write_master.py +225 -0
  59. package/lattice_brain/identity.py +11 -13
  60. package/lattice_brain/ingest.py +11 -1
  61. package/lattice_brain/ingestion.py +318 -0
  62. package/lattice_brain/memory.py +100 -1
  63. package/lattice_brain/network.py +11 -1
  64. package/lattice_brain/portability.py +431 -0
  65. package/lattice_brain/projection.py +11 -1
  66. package/lattice_brain/provenance.py +11 -1
  67. package/lattice_brain/retrieval.py +11 -1
  68. package/lattice_brain/runtime/__init__.py +32 -0
  69. package/lattice_brain/runtime/agent_runtime.py +569 -0
  70. package/lattice_brain/runtime/hooks.py +754 -0
  71. package/lattice_brain/runtime/multi_agent.py +795 -0
  72. package/lattice_brain/schema.py +11 -1
  73. package/lattice_brain/store.py +10 -2
  74. package/lattice_brain/workflow.py +461 -0
  75. package/lattice_brain/write_master.py +11 -1
  76. package/latticeai/__init__.py +1 -1
  77. package/latticeai/api/agents.py +2 -2
  78. package/latticeai/api/browser.py +1 -1
  79. package/latticeai/api/chat.py +1 -1
  80. package/latticeai/api/computer_use.py +1 -1
  81. package/latticeai/api/hooks.py +2 -2
  82. package/latticeai/api/mcp.py +1 -1
  83. package/latticeai/api/models.py +107 -18
  84. package/latticeai/api/tools.py +1 -1
  85. package/latticeai/api/workflow_designer.py +2 -2
  86. package/latticeai/app_factory.py +4 -4
  87. package/latticeai/brain/__init__.py +24 -6
  88. package/latticeai/brain/_kg_common.py +11 -1117
  89. package/latticeai/brain/context.py +12 -208
  90. package/latticeai/brain/conversations.py +12 -231
  91. package/latticeai/brain/discovery.py +13 -1451
  92. package/latticeai/brain/documents.py +13 -214
  93. package/latticeai/brain/identity.py +11 -169
  94. package/latticeai/brain/ingest.py +13 -640
  95. package/latticeai/brain/memory.py +12 -97
  96. package/latticeai/brain/network.py +12 -200
  97. package/latticeai/brain/projection.py +13 -567
  98. package/latticeai/brain/provenance.py +13 -397
  99. package/latticeai/brain/retrieval.py +13 -1337
  100. package/latticeai/brain/schema.py +12 -635
  101. package/latticeai/brain/store.py +13 -233
  102. package/latticeai/brain/write_master.py +13 -221
  103. package/latticeai/core/agent.py +1 -1
  104. package/latticeai/core/agent_registry.py +2 -2
  105. package/latticeai/core/builtin_hooks.py +2 -2
  106. package/latticeai/core/graph_curator.py +6 -468
  107. package/latticeai/core/hooks.py +6 -749
  108. package/latticeai/core/marketplace.py +1 -1
  109. package/latticeai/core/model_compat.py +250 -0
  110. package/latticeai/core/multi_agent.py +6 -790
  111. package/latticeai/core/workflow_engine.py +6 -456
  112. package/latticeai/core/workspace_os.py +1 -1
  113. package/latticeai/models/router.py +136 -32
  114. package/latticeai/services/agent_runtime.py +6 -564
  115. package/latticeai/services/ingestion.py +6 -313
  116. package/latticeai/services/kg_portability.py +6 -426
  117. package/latticeai/services/model_catalog.py +2 -2
  118. package/latticeai/services/model_recommendation.py +8 -1
  119. package/latticeai/services/model_runtime.py +18 -3
  120. package/latticeai/services/platform_runtime.py +3 -3
  121. package/latticeai/services/run_executor.py +1 -1
  122. package/latticeai/services/upload_service.py +1 -1
  123. package/p_reinforce.py +1 -1
  124. package/package.json +1 -1
  125. package/scripts/build_frontend_assets.mjs +12 -1
  126. package/scripts/bump_version.py +1 -1
  127. package/scripts/wheel_smoke.py +7 -0
  128. package/src-tauri/Cargo.lock +1 -1
  129. package/src-tauri/Cargo.toml +1 -1
  130. package/src-tauri/tauri.conf.json +1 -1
  131. package/static/app/asset-manifest.json +5 -5
  132. package/static/app/assets/index-3G8qcrIS.js +336 -0
  133. package/static/app/assets/index-3G8qcrIS.js.map +1 -0
  134. package/static/app/assets/index-C0wYZp7k.css +2 -0
  135. package/static/app/index.html +2 -2
  136. package/static/app/assets/index-CHHal8Zl.css +0 -2
  137. package/static/app/assets/index-pdzil9ac.js +0 -333
  138. package/static/app/assets/index-pdzil9ac.js.map +0 -1
@@ -1,754 +1,11 @@
1
- """Hooks platform a persisted registry of lifecycle extension points.
1
+ """Compatibility shim: physically moved to ``lattice_brain.runtime.hooks``.
2
2
 
3
- Lattice AI runs several behaviours at well-defined points in the agent / tool /
4
- workflow lifecycle (audit logging, secret redaction, sensitive-data
5
- classification, tool-permission gating, memory snapshots, workflow replay
6
- logging). v3.2.0 makes those points *first-class and inspectable*: every hook is
7
- listed, ordered, and individually enable/disable-able, and users can register
8
- their own custom hooks alongside the built-ins.
9
-
10
- The registry owns metadata, ordering and the enabled flag (persisted to
11
- ``data_dir/hooks.json``). Built-in hooks carry ``source="builtin"`` and map onto
12
- behaviour the platform already performs; ``managed`` records whether the
13
- behaviour is enforced by the platform (``platform``) or is an advisory hook the
14
- user has registered (``user``). The registry never silently drops a hook: the
15
- full set is always returned so the UI can show exactly what runs and in which
16
- order.
3
+ Aliases itself to the physical module so identity, module-level state, and
4
+ monkeypatching keep working through the old import path.
17
5
  """
18
6
 
19
- from __future__ import annotations
20
-
21
- import json
22
- import os
23
- import shlex
24
- import subprocess
25
- import tempfile
26
- import threading
27
- import time
28
- from collections import deque
29
- from datetime import datetime
30
- from pathlib import Path
31
- from typing import Any, Callable, Dict, List, Optional
32
-
33
-
34
- HOOK_KINDS = (
35
- "pre_run",
36
- "post_run",
37
- "pre_tool",
38
- "post_tool",
39
- "pre_workflow",
40
- "post_workflow",
41
- "pre_upload",
42
- "post_upload",
43
- "pre_index",
44
- "post_index",
45
- "agent",
46
- )
47
-
48
- # Kinds retired in v3.4.1 in favour of the explicit pre_/post_ lifecycle pairs.
49
- # Accepted on input and mapped forward so older callers / persisted custom hooks
50
- # never break.
51
- LEGACY_KIND_ALIASES = {
52
- "workflow": "post_workflow",
53
- "pipeline": "post_index",
54
- }
55
-
56
- # Hook statuses a dispatch can record.
57
- HOOK_STATUSES = ("ok", "blocked", "error", "skipped", "advisory")
58
-
59
-
60
- def _now() -> str:
61
- return datetime.now().isoformat(timespec="seconds")
62
-
63
-
64
- class HookContext:
65
- """Mutable execution context handed to every hook in a dispatch.
66
-
67
- A bound hook runner may inspect or modify :attr:`payload` (e.g. redact
68
- secrets in place), attach data via :meth:`set`, or halt a pending action
69
- with :meth:`block`. Blocking is honoured for the ``pre_*`` kinds that gate
70
- real work (a blocked ``pre_run`` stops the agent run; a blocked ``pre_tool``
71
- stops the tool call).
72
- """
73
-
74
- __slots__ = (
75
- "kind", "event", "payload", "metadata",
76
- "user_email", "workspace_id", "blocked", "block_reason", "notes",
77
- )
78
-
79
- def __init__(
80
- self,
81
- kind: str,
82
- event: str = "",
83
- payload: Optional[Dict[str, Any]] = None,
84
- *,
85
- user_email: Optional[str] = None,
86
- workspace_id: Optional[str] = None,
87
- metadata: Optional[Dict[str, Any]] = None,
88
- ):
89
- self.kind = kind
90
- self.event = event or kind
91
- self.payload = dict(payload or {})
92
- self.metadata = dict(metadata or {})
93
- self.user_email = user_email
94
- self.workspace_id = workspace_id
95
- self.blocked = False
96
- self.block_reason = ""
97
- self.notes: List[str] = []
98
-
99
- def set(self, key: str, value: Any) -> "HookContext":
100
- self.payload[key] = value
101
- return self
102
-
103
- def block(self, reason: str = "") -> "HookContext":
104
- self.blocked = True
105
- self.block_reason = reason or "blocked by hook"
106
- return self
107
-
108
- def note(self, message: str) -> "HookContext":
109
- self.notes.append(str(message))
110
- return self
111
-
112
- def as_dict(self) -> Dict[str, Any]:
113
- return {
114
- "kind": self.kind,
115
- "event": self.event,
116
- "payload": self.payload,
117
- "metadata": self.metadata,
118
- "user_email": self.user_email,
119
- "workspace_id": self.workspace_id,
120
- "blocked": self.blocked,
121
- "block_reason": self.block_reason,
122
- "notes": list(self.notes),
123
- }
124
-
125
-
126
- class HookResult:
127
- """The outcome of running a single hook (one entry in a dispatch)."""
128
-
129
- __slots__ = (
130
- "hook_id", "name", "kind", "status", "detail", "output",
131
- "duration_ms", "blocked", "source", "binding", "started_at",
132
- )
133
-
134
- def __init__(
135
- self,
136
- *,
137
- hook_id: str,
138
- name: str = "",
139
- kind: str = "",
140
- status: str = "ok",
141
- detail: str = "",
142
- output: str = "",
143
- duration_ms: int = 0,
144
- blocked: bool = False,
145
- source: str = "",
146
- binding: str = "",
147
- started_at: Optional[str] = None,
148
- ):
149
- self.hook_id = hook_id
150
- self.name = name
151
- self.kind = kind
152
- self.status = status # ok | blocked | error | skipped | advisory
153
- self.detail = detail
154
- self.output = output
155
- self.duration_ms = duration_ms
156
- self.blocked = blocked
157
- self.source = source
158
- self.binding = binding
159
- self.started_at = started_at or _now()
160
-
161
- def as_dict(self) -> Dict[str, Any]:
162
- return {
163
- "hook_id": self.hook_id,
164
- "name": self.name,
165
- "kind": self.kind,
166
- "status": self.status,
167
- "detail": self.detail,
168
- "output": self.output,
169
- "duration_ms": self.duration_ms,
170
- "blocked": self.blocked,
171
- "source": self.source,
172
- "binding": self.binding,
173
- "started_at": self.started_at,
174
- }
175
-
176
-
177
- def hook_context(kind: str, event: str = "", **kwargs: Any) -> HookContext:
178
- """Factory for a :class:`HookContext` (matches the public hook vocabulary)."""
179
- return HookContext(kind, event, **kwargs)
180
-
181
-
182
- def hook_result(**kwargs: Any) -> HookResult:
183
- """Factory for a :class:`HookResult`."""
184
- return HookResult(**kwargs)
185
-
186
-
187
- def dispatch_tool(
188
- hooks: Any,
189
- tool_name: str,
190
- args: Any,
191
- run_fn: Callable[[], Any],
192
- *,
193
- user_email: Optional[str] = None,
194
- workspace_id: Optional[str] = None,
195
- source: str = "",
196
- ) -> Any:
197
- """Run a tool through the shared ``pre_tool`` → execute → ``post_tool`` lifecycle.
198
-
199
- This is the single tool-dispatch path so every caller (the HTTP ``/tools/*``
200
- routes, the single-agent runtime in :mod:`latticeai.core.agent`, and the
201
- workflow tool node) fires the same hooks. A blocking ``pre_tool`` hook raises
202
- :class:`PermissionError`; a tool error still fires ``post_tool`` (status
203
- ``error``) before re-raising. With ``hooks=None`` it is a transparent
204
- pass-through, so the tool path is unchanged when hooks are absent.
205
- """
206
- if hooks is None:
207
- return run_fn()
208
- try:
209
- arg_keys = list(args.keys()) if isinstance(args, dict) else []
210
- except Exception:
211
- arg_keys = []
212
- pre = hooks.fire_hook(
213
- "pre_tool", f"tool.{tool_name}",
214
- payload={"tool": tool_name, "args_keys": arg_keys, "source": source},
215
- user_email=user_email, workspace_id=workspace_id,
216
- )
217
- if pre.get("blocked"):
218
- raise PermissionError(pre.get("block_reason") or f"Tool '{tool_name}' blocked by a pre_tool hook.")
219
- try:
220
- result = run_fn()
221
- except Exception as exc:
222
- hooks.fire_hook(
223
- "post_tool", f"tool.{tool_name}",
224
- payload={"tool": tool_name, "status": "error", "detail": str(exc), "source": source},
225
- user_email=user_email, workspace_id=workspace_id,
226
- )
227
- raise
228
- hooks.fire_hook(
229
- "post_tool", f"tool.{tool_name}",
230
- payload={"tool": tool_name, "status": "ok", "source": source},
231
- user_email=user_email, workspace_id=workspace_id,
232
- )
233
- return result
234
-
235
-
236
- # Built-in hooks describe lifecycle points the platform already exercises. They
237
- # are honest reflections of existing behaviour (see the `binding` field), made
238
- # visible and orderable here. Disabling a `managed="platform"` hook is recorded
239
- # and surfaced, but core safety behaviours remain enforced by their owning
240
- # subsystem — the UI states this explicitly so nothing is misrepresented.
241
- BUILTIN_HOOKS: List[Dict[str, Any]] = [
242
- {
243
- "id": "builtin:redact-secrets",
244
- "name": "Redact secrets",
245
- "kind": "pre_run",
246
- "order": 10,
247
- "description": "Strip secret-like fields (token, password, api_key…) from agent context packets before a run.",
248
- "binding": "latticeai.core.multi_agent._redact",
249
- "managed": "platform",
250
- },
251
- {
252
- "id": "builtin:research-memory-snapshot",
253
- "name": "Research memory snapshot",
254
- "kind": "agent",
255
- "order": 20,
256
- "description": "Capture a short-term memory snapshot after the researcher stage gathers context.",
257
- "binding": "latticeai.core.multi_agent.default_role_runner",
258
- "managed": "platform",
259
- },
260
- {
261
- "id": "builtin:tool-permission-gate",
262
- "name": "Tool permission gate",
263
- "kind": "pre_tool",
264
- "order": 10,
265
- "description": "Require explicit approval for tools whose governance policy is not auto-approve.",
266
- "binding": "latticeai.core.tool_registry.ToolRegistry.permission",
267
- "managed": "platform",
268
- },
269
- {
270
- "id": "builtin:sensitive-data-guard",
271
- "name": "Sensitive-data guard",
272
- "kind": "pre_tool",
273
- "order": 20,
274
- "description": "Classify outgoing content for sensitive data before tool execution.",
275
- "binding": "server_app.classify_sensitive_message",
276
- "managed": "platform",
277
- },
278
- {
279
- "id": "builtin:audit-agent-run",
280
- "name": "Audit agent run",
281
- "kind": "post_run",
282
- "order": 10,
283
- "description": "Append every completed agent run to the workspace audit log.",
284
- "binding": "latticeai.services.agent_runtime.AgentRuntime.start",
285
- "managed": "platform",
286
- },
287
- {
288
- "id": "builtin:workflow-replay-log",
289
- "name": "Workflow replay log",
290
- "kind": "workflow",
291
- "order": 10,
292
- "description": "Record each workflow run's timeline so it can be replayed step by step.",
293
- "binding": "latticeai.api.workflow_designer",
294
- "managed": "platform",
295
- },
296
- {
297
- "id": "builtin:pipeline-index-status",
298
- "name": "Pipeline index status",
299
- "kind": "pipeline",
300
- "order": 10,
301
- "description": "Publish ingest / embed / graph-build pipeline state to the retrieval index status.",
302
- "binding": "latticeai.api.search",
303
- "managed": "platform",
304
- },
305
- ]
306
-
307
- # Built-in hooks now bucket onto the v3.4.1 lifecycle pairs.
308
- for _hook in BUILTIN_HOOKS:
309
- _hook["kind"] = LEGACY_KIND_ALIASES.get(_hook["kind"], _hook["kind"])
310
- del _hook
311
-
312
-
313
- class HooksRegistry:
314
- """Persisted registry of lifecycle hooks (built-in + user-registered)."""
315
-
316
- def __init__(self, path: Path, *, command_timeout: float = 20.0, run_log_limit: int = 100):
317
- self.path = Path(path)
318
- self._state: Dict[str, Any] = self._load()
319
- # Runtime dispatch state: in-process runners bound to hook ids, plus a
320
- # bounded, persisted log of recent executions so the UI can show that
321
- # hooks actually ran (not just that they are registered).
322
- self._runtime_runners: Dict[str, Callable[[HookContext], Any]] = {}
323
- self._command_timeout = float(command_timeout)
324
- self._run_lock = threading.Lock()
325
- self._runs_path = self.path.parent / "hooks_runs.json"
326
- self._runs: deque = deque(self._load_runs(), maxlen=int(run_log_limit))
327
-
328
- # ── persistence ───────────────────────────────────────────────────────
329
- def _load(self) -> Dict[str, Any]:
330
- if self.path.exists():
331
- try:
332
- with open(self.path, "r", encoding="utf-8") as fh:
333
- data = json.load(fh)
334
- if isinstance(data, dict):
335
- data.setdefault("custom", [])
336
- data.setdefault("overrides", {})
337
- return data
338
- except Exception:
339
- pass
340
- return {"custom": [], "overrides": {}}
341
-
342
- def _save(self) -> None:
343
- self.path.parent.mkdir(parents=True, exist_ok=True)
344
- fd, tmp = tempfile.mkstemp(dir=str(self.path.parent), suffix=".tmp")
345
- try:
346
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
347
- json.dump(self._state, fh, ensure_ascii=False, indent=2)
348
- os.replace(tmp, self.path)
349
- finally:
350
- if os.path.exists(tmp):
351
- os.unlink(tmp)
352
-
353
- # ── views ─────────────────────────────────────────────────────────────
354
- def _materialize(self) -> List[Dict[str, Any]]:
355
- overrides: Dict[str, Any] = self._state.get("overrides", {})
356
- hooks: List[Dict[str, Any]] = []
357
- for base in BUILTIN_HOOKS:
358
- ov = overrides.get(base["id"], {})
359
- hook = dict(base)
360
- hook["source"] = "builtin"
361
- hook["enabled"] = bool(ov.get("enabled", True))
362
- if "order" in ov:
363
- hook["order"] = ov["order"]
364
- hook["removable"] = False
365
- hooks.append(hook)
366
- for custom in self._state.get("custom", []):
367
- hook = dict(custom)
368
- hook["source"] = "user"
369
- hook.setdefault("managed", "user")
370
- hook.setdefault("binding", "advisory")
371
- hook["enabled"] = bool(custom.get("enabled", True))
372
- hook["removable"] = True
373
- hooks.append(hook)
374
- # Honest execution flag: a hook actually runs only if a runner is bound
375
- # (built-ins) or it carries a command (user hooks); otherwise it is
376
- # advisory (listed + ordered, but a no-op when fired).
377
- for hook in hooks:
378
- hook["executable"] = self.has_runner(hook["id"]) or bool(str(hook.get("command") or "").strip())
379
- hook["advisory"] = not hook["executable"]
380
- hooks.sort(key=lambda h: (HOOK_KINDS.index(h["kind"]) if h["kind"] in HOOK_KINDS else 99, h.get("order", 100), h["id"]))
381
- return hooks
382
-
383
- def list(self, kind: Optional[str] = None) -> Dict[str, Any]:
384
- hooks = self._materialize()
385
- if kind:
386
- hooks = [h for h in hooks if h["kind"] == kind]
387
- counts: Dict[str, Dict[str, int]] = {}
388
- for h in self._materialize():
389
- bucket = counts.setdefault(h["kind"], {"total": 0, "enabled": 0})
390
- bucket["total"] += 1
391
- if h["enabled"]:
392
- bucket["enabled"] += 1
393
- return {
394
- "hooks": hooks,
395
- "kinds": list(HOOK_KINDS),
396
- "counts": counts,
397
- "total": len(hooks),
398
- "enabled": sum(1 for h in hooks if h["enabled"]),
399
- "generated_at": _now(),
400
- }
401
-
402
- def get(self, hook_id: str) -> Optional[Dict[str, Any]]:
403
- return next((h for h in self._materialize() if h["id"] == hook_id), None)
404
-
405
- def inspect(self, hook_id: str) -> Dict[str, Any]:
406
- hook = self.get(hook_id)
407
- if hook is None:
408
- raise KeyError(hook_id)
409
- detail = dict(hook)
410
- detail["advisory"] = hook.get("managed") != "platform"
411
- detail["note"] = (
412
- "Enforced by its owning subsystem; the registry controls visibility and ordering."
413
- if hook.get("managed") == "platform"
414
- else "User-registered hook: listed, ordered and inspectable; runs advisory in this build."
415
- )
416
- return detail
417
-
418
- # ── mutations ─────────────────────────────────────────────────────────
419
- def set_enabled(self, hook_id: str, enabled: bool) -> Dict[str, Any]:
420
- if self.get(hook_id) is None:
421
- raise KeyError(hook_id)
422
- if hook_id.startswith("builtin:"):
423
- self._state.setdefault("overrides", {}).setdefault(hook_id, {})["enabled"] = bool(enabled)
424
- else:
425
- for custom in self._state.get("custom", []):
426
- if custom["id"] == hook_id:
427
- custom["enabled"] = bool(enabled)
428
- self._save()
429
- return self.get(hook_id) # type: ignore[return-value]
430
-
431
- def set_order(self, hook_id: str, order: int) -> Dict[str, Any]:
432
- if self.get(hook_id) is None:
433
- raise KeyError(hook_id)
434
- order = int(order)
435
- if hook_id.startswith("builtin:"):
436
- self._state.setdefault("overrides", {}).setdefault(hook_id, {})["order"] = order
437
- else:
438
- for custom in self._state.get("custom", []):
439
- if custom["id"] == hook_id:
440
- custom["order"] = order
441
- self._save()
442
- return self.get(hook_id) # type: ignore[return-value]
443
-
444
- def reorder(self, kind: str, ordered_ids: List[str]) -> Dict[str, Any]:
445
- for idx, hook_id in enumerate(ordered_ids):
446
- try:
447
- self.set_order(hook_id, (idx + 1) * 10)
448
- except KeyError:
449
- continue
450
- return self.list(kind=kind)
451
-
452
- def register(
453
- self,
454
- *,
455
- name: str,
456
- kind: str,
457
- description: str = "",
458
- command: str = "",
459
- order: Optional[int] = None,
460
- enabled: bool = True,
461
- ) -> Dict[str, Any]:
462
- if not str(name).strip():
463
- raise ValueError("name is required")
464
- kind = LEGACY_KIND_ALIASES.get(kind, kind)
465
- if kind not in HOOK_KINDS:
466
- raise ValueError(f"kind must be one of {', '.join(HOOK_KINDS)}")
467
- slug = str(name).strip().lower().replace(" ", "-")
468
- hook_id = f"user:{slug}"
469
- existing = {c["id"] for c in self._state.get("custom", [])}
470
- if hook_id in existing:
471
- hook_id = f"user:{slug}-{len(existing) + 1}"
472
- entry = {
473
- "id": hook_id,
474
- "name": str(name).strip(),
475
- "kind": kind,
476
- "description": str(description or "").strip(),
477
- "command": str(command or "").strip(),
478
- "order": int(order) if order is not None else 100,
479
- "enabled": bool(enabled),
480
- "managed": "user",
481
- "binding": "advisory",
482
- "created_at": _now(),
483
- }
484
- self._state.setdefault("custom", []).append(entry)
485
- self._save()
486
- return entry
487
-
488
- def remove(self, hook_id: str) -> Dict[str, Any]:
489
- if hook_id.startswith("builtin:"):
490
- raise ValueError("Built-in hooks cannot be removed; disable them instead.")
491
- before = len(self._state.get("custom", []))
492
- self._state["custom"] = [c for c in self._state.get("custom", []) if c["id"] != hook_id]
493
- if len(self._state["custom"]) == before:
494
- raise KeyError(hook_id)
495
- self._save()
496
- return {"removed": hook_id}
497
-
498
- # ── execution engine ──────────────────────────────────────────────────
499
- # The registry above owns *what* runs and in *what order*. The methods below
500
- # own *running* it: a hook executes either via an in-process runner bound by
501
- # its owning subsystem (built-ins) or, for user hooks, by running their
502
- # ``command`` as a subprocess. ``pre_*`` hooks can block the pending action.
503
-
504
- def register_hook(self, hook_id: str, runner: Callable[[HookContext], Any]) -> "HooksRegistry":
505
- """Bind a callable that *executes* when ``hook_id`` fires.
506
-
507
- Built-in hooks bind their owning subsystem's real behaviour here at app
508
- startup so dispatch performs actual work rather than a placeholder.
509
- Returns ``self`` so registrations can be chained.
510
- """
511
- if not callable(runner):
512
- raise TypeError("runner must be callable")
513
- self._runtime_runners[hook_id] = runner
514
- return self
515
-
516
- # Alias — descriptive name used by the wiring layer.
517
- register_runtime_hook = register_hook
518
-
519
- def unregister_hook(self, hook_id: str) -> None:
520
- self._runtime_runners.pop(hook_id, None)
521
-
522
- def has_runner(self, hook_id: str) -> bool:
523
- return hook_id in self._runtime_runners
524
-
525
- def run_hooks(
526
- self,
527
- kind: str,
528
- context: Optional[HookContext] = None,
529
- *,
530
- event: Optional[str] = None,
531
- payload: Optional[Dict[str, Any]] = None,
532
- user_email: Optional[str] = None,
533
- workspace_id: Optional[str] = None,
534
- metadata: Optional[Dict[str, Any]] = None,
535
- ) -> Dict[str, Any]:
536
- """Run every enabled hook of ``kind``, in order, against one context.
537
-
538
- Returns the dispatch record: which hooks ran, whether the pending action
539
- was blocked, and a per-hook result list. A ``pre_*`` hook that blocks
540
- short-circuits the remaining hooks (fail-closed gate semantics).
541
- """
542
- kind = LEGACY_KIND_ALIASES.get(kind, kind)
543
- if kind not in HOOK_KINDS:
544
- raise ValueError(f"kind must be one of {', '.join(HOOK_KINDS)}")
545
- if context is None:
546
- context = HookContext(
547
- kind, event=event or kind, payload=payload,
548
- user_email=user_email, workspace_id=workspace_id, metadata=metadata,
549
- )
550
- hooks = [h for h in self._materialize() if h["kind"] == kind and h.get("enabled")]
551
- results: List[HookResult] = []
552
- for hook in hooks:
553
- res = self._run_one(hook, context)
554
- results.append(res)
555
- self._record_run(res, context)
556
- if res.blocked:
557
- context.block(res.detail or f"{hook['id']} blocked {context.event}")
558
- break
559
- return {
560
- "kind": kind,
561
- "event": context.event,
562
- "ran": len(results),
563
- "blocked": context.blocked,
564
- "block_reason": context.block_reason,
565
- "results": [r.as_dict() for r in results],
566
- "generated_at": _now(),
567
- }
568
-
569
- def run_hook(
570
- self,
571
- hook_id: str,
572
- context: Optional[HookContext] = None,
573
- *,
574
- event: Optional[str] = None,
575
- payload: Optional[Dict[str, Any]] = None,
576
- user_email: Optional[str] = None,
577
- workspace_id: Optional[str] = None,
578
- metadata: Optional[Dict[str, Any]] = None,
579
- ) -> Dict[str, Any]:
580
- """Run a single hook by id (regardless of kind ordering)."""
581
- hook = self.get(hook_id)
582
- if hook is None:
583
- raise KeyError(hook_id)
584
- if context is None:
585
- context = HookContext(
586
- hook["kind"], event=event or hook_id, payload=payload,
587
- user_email=user_email, workspace_id=workspace_id, metadata=metadata,
588
- )
589
- if not hook.get("enabled"):
590
- res = HookResult(
591
- hook_id=hook_id, name=hook.get("name", hook_id), kind=hook["kind"],
592
- status="skipped", detail="hook disabled",
593
- source=hook.get("source", ""), binding=hook.get("binding", ""),
594
- )
595
- else:
596
- res = self._run_one(hook, context)
597
- self._record_run(res, context)
598
- return res.as_dict()
599
-
600
- def fire_hook(
601
- self,
602
- kind: str,
603
- event: str = "",
604
- *,
605
- payload: Optional[Dict[str, Any]] = None,
606
- user_email: Optional[str] = None,
607
- workspace_id: Optional[str] = None,
608
- metadata: Optional[Dict[str, Any]] = None,
609
- context: Optional[HookContext] = None,
610
- ) -> Dict[str, Any]:
611
- """Fire-and-forget convenience over :meth:`run_hooks`.
612
-
613
- Never raises — a dispatch failure is captured in the returned record so
614
- a hook misconfiguration can never crash the lifecycle point that fired
615
- it. Callers that need to *gate* on a block should read ``["blocked"]``.
616
- """
617
- try:
618
- return self.run_hooks(
619
- kind, context, event=event or kind, payload=payload,
620
- user_email=user_email, workspace_id=workspace_id, metadata=metadata,
621
- )
622
- except Exception as exc: # pragma: no cover - defensive
623
- return {"kind": kind, "event": event or kind, "ran": 0, "blocked": False,
624
- "block_reason": "", "error": str(exc), "results": [], "generated_at": _now()}
625
-
626
- # ── single-hook execution ─────────────────────────────────────────────
627
- def _run_one(self, hook: Dict[str, Any], context: HookContext) -> HookResult:
628
- hook_id = hook["id"]
629
- start = time.perf_counter()
630
- runner = self._runtime_runners.get(hook_id)
631
- try:
632
- if runner is not None:
633
- out = runner(context)
634
- status, detail, output, blocked = self._interpret_runner_output(out, context)
635
- elif str(hook.get("command") or "").strip():
636
- status, detail, output, blocked = self._run_command(hook, context)
637
- else:
638
- # No bound runner and no command → advisory (listed + ordered only).
639
- status, detail, output, blocked = "advisory", "", "", False
640
- except Exception as exc: # a misbehaving hook never breaks the dispatch
641
- status, detail, output, blocked = "error", str(exc)[:500], "", False
642
- duration_ms = int((time.perf_counter() - start) * 1000)
643
- return HookResult(
644
- hook_id=hook_id,
645
- name=hook.get("name", hook_id),
646
- kind=hook["kind"],
647
- status=status,
648
- detail=detail,
649
- output=output,
650
- duration_ms=duration_ms,
651
- blocked=blocked,
652
- source=hook.get("source", ""),
653
- binding=hook.get("binding", ""),
654
- )
655
-
656
- @staticmethod
657
- def _interpret_runner_output(out: Any, context: HookContext):
658
- """Normalize a runner's return value into (status, detail, output, blocked).
659
-
660
- A runner may return ``None`` (ok), a ``str`` (ok + output text), or a
661
- dict ``{status?, detail?, output?, block?}``. Calling ``context.block()``
662
- also blocks, regardless of the return value.
663
- """
664
- blocked = bool(getattr(context, "blocked", False))
665
- if isinstance(out, dict):
666
- status = str(out.get("status") or ("blocked" if (out.get("block") or blocked) else "ok"))
667
- block = bool(out.get("block")) or blocked or status == "blocked"
668
- detail = str(out.get("detail") or context.block_reason or "")[:500]
669
- return status, detail, str(out.get("output") or "")[:4000], block
670
- if isinstance(out, str):
671
- return ("blocked" if blocked else "ok", context.block_reason if blocked else "", out[:4000], blocked)
672
- # None or anything else → ok (or blocked if the runner called context.block()).
673
- return ("blocked" if blocked else "ok", context.block_reason if blocked else "", "", blocked)
674
-
675
- def _run_command(self, hook: Dict[str, Any], context: HookContext):
676
- """Run a user hook's ``command`` as a subprocess with the context on stdin.
677
-
678
- The full context is provided both as the ``LATTICE_HOOK_CONTEXT`` env var
679
- and on stdin (JSON). Exit code 0 = ok. A non-zero exit from a ``pre_*``
680
- hook gates (blocks) the pending action; from any other kind it is
681
- recorded as an error without blocking. A timeout fails closed for gates.
682
- """
683
- cmd = str(hook.get("command") or "").strip()
684
- try:
685
- argv = shlex.split(cmd)
686
- except ValueError as exc:
687
- return "error", f"invalid command: {exc}", "", False
688
- if not argv:
689
- return "skipped", "empty command", "", False
690
- ctx_json = json.dumps(context.as_dict(), ensure_ascii=False)
691
- env = dict(os.environ)
692
- env.update({
693
- "LATTICE_HOOK_KIND": context.kind,
694
- "LATTICE_HOOK_EVENT": context.event,
695
- "LATTICE_HOOK_ID": str(hook.get("id", "")),
696
- "LATTICE_HOOK_CONTEXT": ctx_json,
697
- })
698
- is_gate = context.kind.startswith("pre_")
699
- try:
700
- proc = subprocess.run(
701
- argv, input=ctx_json, capture_output=True, text=True,
702
- timeout=self._command_timeout, env=env,
703
- )
704
- except subprocess.TimeoutExpired:
705
- return ("blocked" if is_gate else "error",
706
- f"timed out after {self._command_timeout:.0f}s", "", is_gate)
707
- except Exception as exc:
708
- return "error", str(exc)[:500], "", False
709
- output = (proc.stdout or "")[:4000]
710
- if proc.returncode == 0:
711
- return "ok", "", output, False
712
- detail = (proc.stderr or proc.stdout or f"exit code {proc.returncode}").strip()[:500]
713
- return ("blocked" if is_gate else "error", detail, output, is_gate)
714
-
715
- # ── run log ────────────────────────────────────────────────────────────
716
- def _load_runs(self) -> List[Dict[str, Any]]:
717
- try:
718
- if self._runs_path.exists():
719
- with open(self._runs_path, "r", encoding="utf-8") as fh:
720
- data = json.load(fh)
721
- if isinstance(data, list):
722
- return data
723
- except Exception:
724
- pass
725
- return []
726
-
727
- def _save_runs(self) -> None:
728
- try:
729
- self._runs_path.parent.mkdir(parents=True, exist_ok=True)
730
- fd, tmp = tempfile.mkstemp(dir=str(self._runs_path.parent), suffix=".tmp")
731
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
732
- json.dump(list(self._runs), fh, ensure_ascii=False, indent=2)
733
- os.replace(tmp, self._runs_path)
734
- except Exception: # pragma: no cover - the run log is best-effort
735
- pass
7
+ import sys
736
8
 
737
- def _record_run(self, result: HookResult, context: HookContext) -> None:
738
- entry = result.as_dict()
739
- entry["target_event"] = context.event
740
- entry["target_kind"] = context.kind
741
- with self._run_lock:
742
- self._runs.appendleft(entry)
743
- self._save_runs()
9
+ import lattice_brain.runtime.hooks as _impl
744
10
 
745
- def recent_runs(self, limit: int = 50, kind: Optional[str] = None) -> Dict[str, Any]:
746
- with self._run_lock:
747
- runs = list(self._runs)
748
- if kind:
749
- runs = [r for r in runs if r.get("target_kind") == kind or r.get("kind") == kind]
750
- return {
751
- "runs": runs[: max(0, int(limit))],
752
- "total": len(runs),
753
- "generated_at": _now(),
754
- }
11
+ sys.modules[__name__] = _impl