ltcai 3.6.0 → 4.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 (169) hide show
  1. package/README.md +11 -7
  2. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  3. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
  4. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  5. package/docs/kg-schema.md +47 -53
  6. package/kg_schema.py +93 -10
  7. package/knowledge_graph.py +362 -33
  8. package/knowledge_graph_api.py +11 -127
  9. package/latticeai/__init__.py +1 -1
  10. package/latticeai/api/admin.py +1 -1
  11. package/latticeai/api/agents.py +7 -1
  12. package/latticeai/api/auth.py +27 -4
  13. package/latticeai/api/chat.py +112 -76
  14. package/latticeai/api/health.py +1 -1
  15. package/latticeai/api/hooks.py +1 -1
  16. package/latticeai/api/knowledge_graph.py +146 -0
  17. package/latticeai/api/local_files.py +1 -1
  18. package/latticeai/api/mcp.py +23 -11
  19. package/latticeai/api/memory.py +1 -1
  20. package/latticeai/api/models.py +1 -1
  21. package/latticeai/api/network.py +81 -0
  22. package/latticeai/api/realtime.py +1 -1
  23. package/latticeai/api/search.py +26 -2
  24. package/latticeai/api/security_dashboard.py +2 -3
  25. package/latticeai/api/setup.py +2 -2
  26. package/latticeai/api/static_routes.py +2 -4
  27. package/latticeai/api/tools.py +3 -0
  28. package/latticeai/api/workflow_designer.py +46 -0
  29. package/latticeai/api/workspace.py +71 -49
  30. package/latticeai/app_factory.py +1710 -0
  31. package/latticeai/brain/__init__.py +18 -0
  32. package/latticeai/brain/context.py +213 -0
  33. package/latticeai/brain/conversations.py +236 -0
  34. package/latticeai/brain/identity.py +175 -0
  35. package/latticeai/brain/memory.py +102 -0
  36. package/latticeai/brain/network.py +205 -0
  37. package/latticeai/core/agent.py +31 -7
  38. package/latticeai/core/audit.py +0 -7
  39. package/latticeai/core/config.py +1 -1
  40. package/latticeai/core/context_builder.py +1 -2
  41. package/latticeai/core/enterprise.py +1 -1
  42. package/latticeai/core/graph_curator.py +2 -2
  43. package/latticeai/core/marketplace.py +1 -1
  44. package/latticeai/core/mcp_registry.py +791 -0
  45. package/latticeai/core/model_compat.py +1 -1
  46. package/latticeai/core/model_resolution.py +0 -1
  47. package/latticeai/core/multi_agent.py +238 -4
  48. package/latticeai/core/security.py +1 -1
  49. package/latticeai/core/sessions.py +37 -7
  50. package/latticeai/core/workflow_engine.py +114 -2
  51. package/latticeai/core/workspace_os.py +58 -10
  52. package/latticeai/models/__init__.py +7 -0
  53. package/latticeai/models/router.py +779 -0
  54. package/latticeai/server_app.py +29 -1536
  55. package/latticeai/services/agent_runtime.py +1 -0
  56. package/latticeai/services/app_context.py +75 -14
  57. package/latticeai/services/ingestion.py +47 -0
  58. package/latticeai/services/kg_portability.py +33 -3
  59. package/latticeai/services/memory_service.py +39 -11
  60. package/latticeai/services/model_runtime.py +2 -5
  61. package/latticeai/services/platform_runtime.py +100 -23
  62. package/latticeai/services/search_service.py +17 -8
  63. package/latticeai/services/tool_dispatch.py +12 -2
  64. package/latticeai/services/triggers.py +241 -0
  65. package/latticeai/services/upload_service.py +37 -12
  66. package/latticeai/services/workspace_service.py +31 -0
  67. package/llm_router.py +29 -772
  68. package/ltcai_cli.py +1 -2
  69. package/mcp_registry.py +25 -788
  70. package/p_reinforce.py +124 -14
  71. package/package.json +9 -7
  72. package/scripts/bump_version.py +99 -0
  73. package/scripts/generate_diagrams.py +0 -1
  74. package/scripts/lint_v3.mjs +82 -18
  75. package/scripts/validate_release_artifacts.py +0 -1
  76. package/scripts/wheel_smoke.py +142 -0
  77. package/server.py +11 -7
  78. package/setup_wizard.py +1142 -0
  79. package/static/account.html +2 -4
  80. package/static/admin.html +3 -5
  81. package/static/chat.html +3 -6
  82. package/static/graph.html +2 -4
  83. package/static/sw.js +81 -52
  84. package/static/v3/asset-manifest.json +20 -19
  85. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  86. package/static/v3/css/lattice.base.css +1 -1
  87. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  88. package/static/v3/css/lattice.components.css +1 -1
  89. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  90. package/static/v3/css/lattice.shell.css +1 -1
  91. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  92. package/static/v3/css/lattice.tokens.css +3 -0
  93. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  94. package/static/v3/css/lattice.views.css +2 -2
  95. package/static/v3/index.html +3 -4
  96. package/static/v3/js/{app.c541f955.js → app.356e6452.js} +1 -1
  97. package/static/v3/js/core/{api.33d6320e.js → api.7a308b89.js} +1 -1
  98. package/static/v3/js/core/{routes.2ce3815a.js → routes.7222343d.js} +22 -22
  99. package/static/v3/js/core/routes.js +22 -22
  100. package/static/v3/js/core/{shell.8c163e0e.js → shell.a1657f20.js} +4 -4
  101. package/static/v3/js/core/shell.js +1 -1
  102. package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
  103. package/static/v3/js/core/store.js +1 -1
  104. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  105. package/static/v3/js/views/graph-canvas.js +509 -0
  106. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  107. package/static/v3/js/views/hybrid-search.js +1 -2
  108. package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.5e40cbeb.js} +33 -37
  109. package/static/v3/js/views/knowledge-graph.js +33 -37
  110. package/static/vendor/chart.umd.min.js +20 -0
  111. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  112. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  113. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  114. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  115. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  116. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  117. package/static/vendor/fonts/inter.css +44 -0
  118. package/static/vendor/icons/tabler-icons.min.css +4 -0
  119. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  120. package/static/vendor/marked.min.js +69 -0
  121. package/static/workspace.html +2 -2
  122. package/telegram_bot.py +1 -2
  123. package/tools/commands.py +4 -2
  124. package/tools/computer.py +1 -1
  125. package/tools/documents.py +1 -3
  126. package/tools/filesystem.py +0 -4
  127. package/tools/knowledge.py +1 -3
  128. package/tools/network.py +1 -3
  129. package/codex_telegram_bot.py +0 -195
  130. package/docs/assets/v3.4.0/agent-run.png +0 -0
  131. package/docs/assets/v3.4.0/agents.png +0 -0
  132. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  133. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  134. package/docs/assets/v3.4.0/chat.png +0 -0
  135. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  136. package/docs/assets/v3.4.0/files.png +0 -0
  137. package/docs/assets/v3.4.0/home.png +0 -0
  138. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  139. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  140. package/docs/assets/v3.4.0/local-agent.png +0 -0
  141. package/docs/assets/v3.4.0/memory.png +0 -0
  142. package/docs/assets/v3.4.0/settings.png +0 -0
  143. package/docs/assets/v3.4.0/vision-input.png +0 -0
  144. package/docs/assets/v3.4.0/workflows.png +0 -0
  145. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  146. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  147. package/docs/assets/v3.4.1/local-agent.png +0 -0
  148. package/docs/images/admin-dashboard.png +0 -0
  149. package/docs/images/architecture.png +0 -0
  150. package/docs/images/enterprise.png +0 -0
  151. package/docs/images/graph.png +0 -0
  152. package/docs/images/hero.gif +0 -0
  153. package/docs/images/knowledge-graph.png +0 -0
  154. package/docs/images/lattice-ai-demo.gif +0 -0
  155. package/docs/images/lattice-ai-hero.png +0 -0
  156. package/docs/images/logo.svg +0 -33
  157. package/docs/images/mobile-responsive.png +0 -0
  158. package/docs/images/model-recommendation.png +0 -0
  159. package/docs/images/onboarding.png +0 -0
  160. package/docs/images/organization.png +0 -0
  161. package/docs/images/pipeline.png +0 -0
  162. package/docs/images/screenshot-admin.png +0 -0
  163. package/docs/images/screenshot-chat.png +0 -0
  164. package/docs/images/screenshot-graph.png +0 -0
  165. package/docs/images/skills.png +0 -0
  166. package/docs/images/workspace-dark.png +0 -0
  167. package/docs/images/workspace-light.png +0 -0
  168. package/docs/images/workspace.png +0 -0
  169. package/requirements.txt +0 -16
@@ -7,7 +7,7 @@ keyword search into UI-ready contracts without tying routers to store internals.
7
7
  from __future__ import annotations
8
8
 
9
9
  from dataclasses import dataclass
10
- from typing import Any, Dict, List, Mapping, Optional
10
+ from typing import Any, Dict, Mapping, Optional
11
11
 
12
12
 
13
13
  DEFAULT_HYBRID_WEIGHTS = {
@@ -34,7 +34,15 @@ class SearchService:
34
34
  raise ValueError("knowledge graph is disabled")
35
35
  return self.graph_store
36
36
 
37
- def keyword_search(self, query: str, *, limit: int = 30) -> Dict[str, Any]:
37
+ def _scope(self, matches, allowed_workspaces):
38
+ """Drop matches scoped to workspaces the caller is not a member of
39
+ (None = no scoping; legacy-global rows stay visible — documented)."""
40
+ if allowed_workspaces is None:
41
+ return matches
42
+ graph = self._require_graph()
43
+ return graph.filter_scoped_nodes(matches, allowed_workspaces)
44
+
45
+ def keyword_search(self, query: str, *, limit: int = 30, allowed_workspaces=None) -> Dict[str, Any]:
38
46
  graph = self._require_graph()
39
47
  payload = graph.search(query, limit)
40
48
  matches = []
@@ -53,9 +61,9 @@ class SearchService:
53
61
  "metadata": match.get("metadata") or {},
54
62
  "updated_at": match.get("updated_at"),
55
63
  })
56
- return {"query": query, "mode": "keyword", "matches": matches}
64
+ return {"query": query, "mode": "keyword", "matches": self._scope(matches, allowed_workspaces)}
57
65
 
58
- def vector_search(self, query: str, *, limit: int = 30, min_score: float = 0.0) -> Dict[str, Any]:
66
+ def vector_search(self, query: str, *, limit: int = 30, min_score: float = 0.0, allowed_workspaces=None) -> Dict[str, Any]:
59
67
  graph = self._require_graph()
60
68
  payload = graph.vector_search(query, limit=limit, min_score=min_score)
61
69
  matches = []
@@ -80,10 +88,10 @@ class SearchService:
80
88
  "mode": "vector",
81
89
  "embedding_model": payload.get("embedding_model"),
82
90
  "embedding_dim": payload.get("embedding_dim"),
83
- "matches": matches,
91
+ "matches": self._scope(matches, allowed_workspaces),
84
92
  }
85
93
 
86
- def graph_search(self, query: str, *, limit: int = 30, expand_depth: int = 1) -> Dict[str, Any]:
94
+ def graph_search(self, query: str, *, limit: int = 30, expand_depth: int = 1, allowed_workspaces=None) -> Dict[str, Any]:
87
95
  graph = self._require_graph()
88
96
  limit = max(1, min(int(limit or 30), 100))
89
97
  expand_depth = max(0, min(int(expand_depth or 1), 3))
@@ -157,7 +165,7 @@ class SearchService:
157
165
  match["rank"] = rank
158
166
  match["score"] = round(float(match["score"]), 6)
159
167
  match["source_scores"]["graph"] = round(float(match["source_scores"]["graph"]), 6)
160
- return {"query": query, "mode": "graph", "expand_depth": expand_depth, "matches": matches}
168
+ return {"query": query, "mode": "graph", "expand_depth": expand_depth, "matches": self._scope(matches, allowed_workspaces)}
161
169
 
162
170
  def hybrid_search(
163
171
  self,
@@ -168,6 +176,7 @@ class SearchService:
168
176
  vector_limit: int = 30,
169
177
  graph_limit: int = 30,
170
178
  weights: Optional[Mapping[str, float]] = None,
179
+ allowed_workspaces=None,
171
180
  ) -> Dict[str, Any]:
172
181
  weights = {**DEFAULT_HYBRID_WEIGHTS, **dict(weights or {})}
173
182
  channels = {
@@ -202,7 +211,7 @@ class SearchService:
202
211
  current.setdefault("graph_context", [])
203
212
  current["graph_context"].extend(result.get("graph_context") or [])
204
213
 
205
- matches = sorted(fused.values(), key=lambda item: item["score"], reverse=True)[: max(1, min(limit, 100))]
214
+ matches = self._scope(sorted(fused.values(), key=lambda item: item["score"], reverse=True), allowed_workspaces)[: max(1, min(limit, 100))]
206
215
  for rank, match in enumerate(matches, start=1):
207
216
  match["rank"] = rank
208
217
  match["score"] = round(float(match["score"]), 6)
@@ -22,8 +22,16 @@ from latticeai.core.tool_registry import ToolPermission, ToolPolicy
22
22
  from tools import AGENT_ROOT, DEFAULT_TOOL_REGISTRY, ToolError, ensure_agent_root
23
23
 
24
24
 
25
- _load_users: Callable[[], Dict[str, Any]] = lambda: {}
26
- _get_user_role: Callable[..., str] = lambda _email, _users=None: "user"
25
+ def _default_load_users() -> Dict[str, Any]:
26
+ return {}
27
+
28
+
29
+ def _default_get_user_role(_email, _users=None) -> str:
30
+ return "user"
31
+
32
+
33
+ _load_users: Callable[[], Dict[str, Any]] = _default_load_users
34
+ _get_user_role: Callable[..., str] = _default_get_user_role
27
35
 
28
36
  FILE_CREATE_ACTIONS = set(DEFAULT_TOOL_REGISTRY.file_create_actions)
29
37
  TOOL_GOVERNANCE: Dict[str, ToolPolicy] = dict(DEFAULT_TOOL_REGISTRY.governance)
@@ -104,6 +112,7 @@ def build_agent_runtime(
104
112
  knowledge_save: Callable[..., Dict[str, Any]],
105
113
  audit: Callable[..., None],
106
114
  hooks: Any = None,
115
+ brain_memory: Any = None,
107
116
  ) -> AgentRuntime:
108
117
  ensure_agent_root()
109
118
  deps = AgentDeps(
@@ -125,6 +134,7 @@ def build_agent_runtime(
125
134
  memory_updater_prompt=MEMORY_UPDATER_PROMPT,
126
135
  agent_root=AGENT_ROOT,
127
136
  hooks=hooks,
137
+ brain_memory=brain_memory,
128
138
  )
129
139
  return AgentRuntime(deps)
130
140
 
@@ -0,0 +1,241 @@
1
+ """Trigger system (T7d) — workflows fire beyond 'manual'.
2
+
3
+ Two real trigger types:
4
+
5
+ * **interval** — a supervised scheduler loop fires the workflow every
6
+ ``interval_seconds``. Firings missed while the server was down are
7
+ SKIPPED with a recorded skip event (design-review amendment: no silent
8
+ gaps, no thundering catch-up).
9
+ * **brain_event** — the killer Digital Brain feature: "when new knowledge
10
+ enters the brain, run this workflow". Wired through the existing hooks
11
+ bus (the ingestion pipeline fires ``post_tool`` on ``kg_ingest.<source>``);
12
+ an optional ``source_type`` filter narrows it.
13
+
14
+ Trigger-fired runs carry provenance: their inputs include ``__trigger__``
15
+ describing what fired them, persisted in the run record like any other
16
+ input.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import threading
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Dict, List, Optional
27
+
28
+ DEFAULT_TICK_SECONDS = 5.0
29
+ MIN_INTERVAL_SECONDS = 60
30
+
31
+ TRIGGER_HOOK_NAME = "brain-event-triggers"
32
+
33
+
34
+ class TriggerService:
35
+ """Scans workflow definitions for non-manual triggers and fires them."""
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ store: Any,
41
+ run_workflow: Callable[[str, Dict[str, Any]], Dict[str, Any]],
42
+ data_dir: Path,
43
+ clock: Callable[[], float] = time.time,
44
+ tick_seconds: float = DEFAULT_TICK_SECONDS,
45
+ ) -> None:
46
+ self._store = store
47
+ self._run_workflow = run_workflow
48
+ self._state_file = Path(data_dir) / "triggers_state.json"
49
+ self._clock = clock
50
+ self._tick = float(tick_seconds)
51
+ self._stop_event = threading.Event()
52
+ self._thread: Optional[threading.Thread] = None
53
+ self._lock = threading.Lock()
54
+
55
+ # ── durable state ──────────────────────────────────────────────────────
56
+ def _load_state(self) -> Dict[str, Any]:
57
+ if not self._state_file.exists():
58
+ return {}
59
+ try:
60
+ return json.loads(self._state_file.read_text(encoding="utf-8"))
61
+ except Exception:
62
+ return {}
63
+
64
+ def _save_state(self, state: Dict[str, Any]) -> None:
65
+ self._state_file.parent.mkdir(parents=True, exist_ok=True)
66
+ tmp = self._state_file.with_suffix(".tmp")
67
+ tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
68
+ tmp.replace(self._state_file)
69
+
70
+ def _record_event(self, state: Dict[str, Any], workflow_id: str, event: Dict[str, Any]) -> None:
71
+ entry = state.setdefault(workflow_id, {})
72
+ events = entry.setdefault("events", [])
73
+ events.append({**event, "at": self._clock()})
74
+ entry["events"] = events[-50:]
75
+
76
+ # ── definition scanning ────────────────────────────────────────────────
77
+ def _triggered_workflows(self) -> List[Dict[str, Any]]:
78
+ found = []
79
+ try:
80
+ workflows = list(self._store.load_state().get("workflows") or [])
81
+ except Exception:
82
+ return []
83
+ for wf in workflows:
84
+ for node in wf.get("nodes") or []:
85
+ if node.get("type") != "trigger":
86
+ continue
87
+ cfg = node.get("config") or {}
88
+ kind = str(cfg.get("trigger") or "manual")
89
+ if kind in ("interval", "brain_event"):
90
+ found.append({"workflow": wf, "node": node, "kind": kind, "config": cfg})
91
+ return found
92
+
93
+ def describe(self) -> Dict[str, Any]:
94
+ """Honest status surface: what is armed, when it last fired/skipped."""
95
+ state = self._load_state()
96
+ armed = []
97
+ for item in self._triggered_workflows():
98
+ wf_id = item["workflow"].get("id")
99
+ armed.append({
100
+ "workflow_id": wf_id,
101
+ "name": item["workflow"].get("name"),
102
+ "kind": item["kind"],
103
+ "config": {k: v for k, v in item["config"].items() if k != "trigger"},
104
+ "last_fired_at": (state.get(wf_id) or {}).get("last_fired_at"),
105
+ "recent_events": (state.get(wf_id) or {}).get("events", [])[-5:],
106
+ })
107
+ return {
108
+ "running": bool(self._thread and self._thread.is_alive()),
109
+ "tick_seconds": self._tick,
110
+ "armed": armed,
111
+ }
112
+
113
+ # ── interval scheduling ────────────────────────────────────────────────
114
+ def reconcile_missed(self) -> int:
115
+ """Startup pass: record (never replay) firings missed while down."""
116
+ now = self._clock()
117
+ skipped = 0
118
+ with self._lock:
119
+ state = self._load_state()
120
+ for item in self._triggered_workflows():
121
+ if item["kind"] != "interval":
122
+ continue
123
+ wf_id = item["workflow"].get("id")
124
+ interval = max(MIN_INTERVAL_SECONDS, int(item["config"].get("interval_seconds") or 0))
125
+ entry = state.setdefault(wf_id, {})
126
+ last = entry.get("last_fired_at")
127
+ if last is not None and now - float(last) > interval:
128
+ missed = int((now - float(last)) // interval)
129
+ self._record_event(state, wf_id, {
130
+ "type": "skipped",
131
+ "reason": f"{missed} interval firing(s) missed while the server was down",
132
+ })
133
+ skipped += missed
134
+ # Reset the cadence from now — no catch-up storm.
135
+ entry["last_fired_at"] = now if last is not None else entry.get("last_fired_at")
136
+ self._save_state(state)
137
+ return skipped
138
+
139
+ def tick_intervals(self) -> int:
140
+ """One scheduler pass; returns how many workflows fired."""
141
+ now = self._clock()
142
+ fired = 0
143
+ with self._lock:
144
+ state = self._load_state()
145
+ for item in self._triggered_workflows():
146
+ if item["kind"] != "interval":
147
+ continue
148
+ wf_id = item["workflow"].get("id")
149
+ interval = max(MIN_INTERVAL_SECONDS, int(item["config"].get("interval_seconds") or 0))
150
+ entry = state.setdefault(wf_id, {})
151
+ last = entry.get("last_fired_at")
152
+ if last is None:
153
+ # First sighting arms the schedule; it fires one interval later.
154
+ entry["last_fired_at"] = now
155
+ continue
156
+ if now - float(last) < interval:
157
+ continue
158
+ entry["last_fired_at"] = now
159
+ self._record_event(state, wf_id, {"type": "fired", "trigger": "interval"})
160
+ fired += 1
161
+ self._fire(wf_id, {
162
+ "type": "interval",
163
+ "interval_seconds": interval,
164
+ "fired_at": now,
165
+ })
166
+ self._save_state(state)
167
+ return fired
168
+
169
+ # ── brain events ───────────────────────────────────────────────────────
170
+ def on_brain_event(self, event: str, payload: Optional[Dict[str, Any]] = None) -> int:
171
+ """Fire workflows whose brain_event trigger matches this ingestion."""
172
+ payload = payload or {}
173
+ source_type = str(payload.get("source_type") or event.split(".", 1)[-1] or "")
174
+ fired = 0
175
+ with self._lock:
176
+ state = self._load_state()
177
+ for item in self._triggered_workflows():
178
+ if item["kind"] != "brain_event":
179
+ continue
180
+ wanted = str(item["config"].get("source_type") or "").strip()
181
+ if wanted and wanted != source_type:
182
+ continue
183
+ wf_id = item["workflow"].get("id")
184
+ state.setdefault(wf_id, {})["last_fired_at"] = self._clock()
185
+ self._record_event(state, wf_id, {
186
+ "type": "fired", "trigger": "brain_event", "source_type": source_type,
187
+ })
188
+ fired += 1
189
+ self._fire(wf_id, {
190
+ "type": "brain_event",
191
+ "event": event,
192
+ "source_type": source_type,
193
+ "node_id": payload.get("node_id"),
194
+ })
195
+ self._save_state(state)
196
+ return fired
197
+
198
+ def hook_runner(self):
199
+ """A post_tool hook runner: ingestion events fan into triggers."""
200
+ def runner(context):
201
+ event = str(getattr(context, "event", "") or "")
202
+ if not event.startswith("kg_ingest."):
203
+ return {"status": "ok", "output": "not an ingestion event"}
204
+ payload = context.payload if isinstance(context.payload, dict) else {}
205
+ fired = self.on_brain_event(event, payload)
206
+ return {"status": "ok", "output": f"fired {fired} workflow trigger(s)"}
207
+ return runner
208
+
209
+ # ── execution + lifecycle ──────────────────────────────────────────────
210
+ def _fire(self, workflow_id: str, trigger_info: Dict[str, Any]) -> None:
211
+ def _run():
212
+ try:
213
+ self._run_workflow(workflow_id, {"__trigger__": trigger_info})
214
+ except Exception as exc:
215
+ logging.warning("trigger run failed for %s: %s", workflow_id, exc)
216
+
217
+ threading.Thread(target=_run, name=f"trigger-{workflow_id}", daemon=True).start()
218
+
219
+ def start(self) -> None:
220
+ if self._thread and self._thread.is_alive():
221
+ return
222
+ self.reconcile_missed()
223
+ self._stop_event.clear()
224
+
225
+ def _loop():
226
+ while not self._stop_event.wait(self._tick):
227
+ try:
228
+ self.tick_intervals()
229
+ except Exception as exc:
230
+ logging.warning("trigger scheduler tick failed: %s", exc)
231
+
232
+ self._thread = threading.Thread(target=_loop, name="trigger-scheduler", daemon=True)
233
+ self._thread.start()
234
+
235
+ def stop(self) -> None:
236
+ self._stop_event.set()
237
+ if self._thread:
238
+ self._thread.join(timeout=2)
239
+
240
+
241
+ __all__ = ["TriggerService", "TRIGGER_HOOK_NAME", "MIN_INTERVAL_SECONDS"]
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
 
10
10
  from fastapi import HTTPException, Request, UploadFile
11
11
 
12
+ from latticeai.services.ingestion import IngestionItem
12
13
  from tools import ToolError, read_document
13
14
 
14
15
 
@@ -19,6 +20,7 @@ async def process_uploaded_document(
19
20
  current_user: str,
20
21
  enable_graph: bool,
21
22
  knowledge_graph,
23
+ ingestion_pipeline=None,
22
24
  bytes_match_extension,
23
25
  classify_sensitive_message,
24
26
  append_audit_event,
@@ -71,18 +73,41 @@ async def process_uploaded_document(
71
73
  try:
72
74
  if not (enable_graph and knowledge_graph):
73
75
  raise RuntimeError("graph disabled")
74
- graph_result = knowledge_graph.ingest_document(
75
- Path(tmp_path),
76
- original_filename=file.filename,
77
- mime_type=file.content_type,
78
- uploader=current_user,
79
- conversation_id=request.query_params.get("conversation_id"),
80
- extracted=result,
81
- )
82
- result["knowledge_graph"] = {
83
- "node_id": graph_result["node_id"],
84
- "sha256": graph_result["sha256"],
85
- }
76
+ if ingestion_pipeline is not None:
77
+ # v4: uploads enter the brain through the unified ingestion
78
+ # pipeline (provenance + kg_ingest hook lifecycle).
79
+ ingest = ingestion_pipeline.ingest(
80
+ IngestionItem(
81
+ source_type="upload",
82
+ title=file.filename,
83
+ path=tmp_path,
84
+ mime_type=file.content_type,
85
+ owner=current_user,
86
+ conversation_id=request.query_params.get("conversation_id"),
87
+ metadata={"extracted": result},
88
+ ),
89
+ user_email=current_user,
90
+ )
91
+ if ingest.status != "ok":
92
+ raise RuntimeError(ingest.detail or f"ingestion {ingest.status}")
93
+ result["knowledge_graph"] = {
94
+ "node_id": ingest.node_id,
95
+ "sha256": ingest.content_hash,
96
+ "provenance_id": ingest.provenance_id,
97
+ }
98
+ else:
99
+ graph_result = knowledge_graph.ingest_document(
100
+ Path(tmp_path),
101
+ original_filename=file.filename,
102
+ mime_type=file.content_type,
103
+ uploader=current_user,
104
+ conversation_id=request.query_params.get("conversation_id"),
105
+ extracted=result,
106
+ )
107
+ result["knowledge_graph"] = {
108
+ "node_id": graph_result["node_id"],
109
+ "sha256": graph_result["sha256"],
110
+ }
86
111
  except Exception as graph_error:
87
112
  logging.warning("knowledge graph document ingest failed: %s", graph_error)
88
113
  result["knowledge_graph"] = {"error": str(graph_error)}
@@ -72,6 +72,37 @@ class WorkspaceService:
72
72
  def can_write(self, workspace_id: str, user_id: Optional[str]) -> bool:
73
73
  return self.store.has_permission(workspace_id, user_id, "write")
74
74
 
75
+ # ── record-level authorization (by-id access must not bypass gating) ──
76
+
77
+ def authorize_record_read(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
78
+ """Authorize reading a record against ITS OWN workspace.
79
+
80
+ Records predating workspace scoping carry no workspace_id and remain
81
+ readable (legacy-global compatibility); a scoped record requires read
82
+ permission on its workspace regardless of any caller-supplied header.
83
+ """
84
+ workspace_id = (record or {}).get("workspace_id")
85
+ if workspace_id:
86
+ self._ensure_permission(workspace_id, user_id, "read")
87
+
88
+ def authorize_memory_delete(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
89
+ """Delete requires owning the memory or write access to its workspace.
90
+
91
+ Ownerless records with no workspace keep their pre-v4 behaviour
92
+ (deletable by any authenticated local user).
93
+ """
94
+ owner = (record or {}).get("user_email")
95
+ workspace_id = (record or {}).get("workspace_id")
96
+ if owner and owner == user_id:
97
+ return
98
+ if workspace_id:
99
+ self._ensure_permission(workspace_id, user_id, "write")
100
+ return
101
+ if owner and owner != user_id:
102
+ raise PermissionError(
103
+ f"'{user_id or 'anonymous'}' is not the owner of memory '{record.get('id')}'"
104
+ )
105
+
75
106
  # ── workspace registry / summary ─────────────────────────────────────
76
107
 
77
108
  def summary(self, user_id: Optional[str]) -> Dict[str, Any]: