ltcai 9.0.0 → 9.1.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 (187) hide show
  1. package/README.md +80 -46
  2. package/auto_setup.py +7 -853
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +146 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/TRUST_MODEL.md +5 -2
  13. package/docs/WHY_LATTICE.md +15 -10
  14. package/docs/WORKFLOW_DESIGNER.md +22 -0
  15. package/docs/kg-schema.md +13 -2
  16. package/docs/mcp-tools.md +17 -6
  17. package/docs/privacy.md +19 -3
  18. package/docs/public-deploy.md +32 -3
  19. package/docs/security-model.md +46 -11
  20. package/lattice_brain/__init__.py +1 -1
  21. package/lattice_brain/archive.py +1 -6
  22. package/lattice_brain/context.py +66 -9
  23. package/lattice_brain/graph/_kg_fsutil.py +1 -5
  24. package/lattice_brain/graph/discovery_index.py +174 -20
  25. package/lattice_brain/graph/documents.py +44 -9
  26. package/lattice_brain/graph/ingest.py +47 -20
  27. package/lattice_brain/graph/provenance.py +13 -6
  28. package/lattice_brain/graph/retrieval.py +140 -39
  29. package/lattice_brain/graph/retrieval_docgen.py +37 -4
  30. package/lattice_brain/ingestion.py +4 -7
  31. package/lattice_brain/portability.py +28 -14
  32. package/lattice_brain/runtime/agent_runtime.py +5 -9
  33. package/lattice_brain/runtime/hooks.py +30 -13
  34. package/lattice_brain/runtime/multi_agent.py +27 -8
  35. package/lattice_brain/runtime/statuses.py +10 -0
  36. package/lattice_brain/utils.py +20 -2
  37. package/lattice_brain/workflow.py +1 -5
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +1 -1
  40. package/latticeai/api/agent_registry.py +10 -8
  41. package/latticeai/api/agents.py +22 -3
  42. package/latticeai/api/auth.py +78 -16
  43. package/latticeai/api/browser.py +303 -34
  44. package/latticeai/api/chat.py +320 -850
  45. package/latticeai/api/chat_agent_http.py +356 -0
  46. package/latticeai/api/chat_contracts.py +54 -0
  47. package/latticeai/api/chat_documents.py +256 -0
  48. package/latticeai/api/chat_helpers.py +24 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +302 -0
  51. package/latticeai/api/chat_stream.py +115 -0
  52. package/latticeai/api/computer_use.py +62 -9
  53. package/latticeai/api/health.py +9 -3
  54. package/latticeai/api/hooks.py +17 -6
  55. package/latticeai/api/knowledge_graph.py +78 -14
  56. package/latticeai/api/local_files.py +7 -2
  57. package/latticeai/api/mcp.py +102 -23
  58. package/latticeai/api/models.py +72 -33
  59. package/latticeai/api/network.py +5 -5
  60. package/latticeai/api/permissions.py +67 -26
  61. package/latticeai/api/plugins.py +21 -7
  62. package/latticeai/api/portability.py +5 -5
  63. package/latticeai/api/realtime.py +33 -5
  64. package/latticeai/api/setup.py +2 -2
  65. package/latticeai/api/static_routes.py +123 -24
  66. package/latticeai/api/tools.py +96 -14
  67. package/latticeai/api/workflow_designer.py +37 -0
  68. package/latticeai/api/workspace.py +2 -1
  69. package/latticeai/app_factory.py +110 -52
  70. package/latticeai/core/agent.py +19 -9
  71. package/latticeai/core/agent_registry.py +1 -5
  72. package/latticeai/core/config.py +23 -3
  73. package/latticeai/core/context_builder.py +21 -3
  74. package/latticeai/core/invitations.py +1 -4
  75. package/latticeai/core/io_utils.py +9 -1
  76. package/latticeai/core/legacy_compatibility.py +24 -3
  77. package/latticeai/core/marketplace.py +1 -1
  78. package/latticeai/core/plugins.py +15 -0
  79. package/latticeai/core/policy.py +1 -1
  80. package/latticeai/core/realtime.py +38 -15
  81. package/latticeai/core/sessions.py +7 -2
  82. package/latticeai/core/timeutil.py +10 -0
  83. package/latticeai/core/tool_registry.py +90 -18
  84. package/latticeai/core/users.py +1 -5
  85. package/latticeai/core/workspace_graph_trace.py +31 -5
  86. package/latticeai/core/workspace_memory.py +3 -1
  87. package/latticeai/core/workspace_os.py +18 -7
  88. package/latticeai/core/workspace_os_utils.py +0 -5
  89. package/latticeai/core/workspace_permissions.py +2 -1
  90. package/latticeai/core/workspace_plugins.py +1 -1
  91. package/latticeai/core/workspace_runs.py +14 -5
  92. package/latticeai/core/workspace_skills.py +1 -1
  93. package/latticeai/core/workspace_snapshots.py +2 -1
  94. package/latticeai/core/workspace_timeline.py +2 -1
  95. package/latticeai/integrations/telegram_bot.py +94 -43
  96. package/latticeai/models/router.py +130 -36
  97. package/latticeai/runtime/access_runtime.py +62 -4
  98. package/latticeai/runtime/automation_runtime.py +13 -7
  99. package/latticeai/runtime/brain_runtime.py +19 -7
  100. package/latticeai/runtime/chat_wiring.py +2 -0
  101. package/latticeai/runtime/config_runtime.py +58 -26
  102. package/latticeai/runtime/context_runtime.py +16 -4
  103. package/latticeai/runtime/hooks_runtime.py +1 -1
  104. package/latticeai/runtime/model_wiring.py +33 -5
  105. package/latticeai/runtime/namespace_runtime.py +62 -72
  106. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  107. package/latticeai/runtime/review_wiring.py +1 -1
  108. package/latticeai/runtime/router_registration.py +34 -1
  109. package/latticeai/runtime/security_runtime.py +110 -13
  110. package/latticeai/runtime/stages.py +27 -0
  111. package/latticeai/server_app.py +5 -1
  112. package/latticeai/services/architecture_readiness.py +74 -5
  113. package/latticeai/services/brain_automation.py +3 -26
  114. package/latticeai/services/chat_service.py +205 -15
  115. package/latticeai/services/local_knowledge.py +423 -0
  116. package/latticeai/services/memory_service.py +55 -21
  117. package/latticeai/services/model_engines.py +48 -33
  118. package/latticeai/services/model_errors.py +17 -0
  119. package/latticeai/services/model_loading.py +28 -25
  120. package/latticeai/services/model_runtime.py +228 -162
  121. package/latticeai/services/p_reinforce.py +22 -3
  122. package/latticeai/services/platform_runtime.py +83 -23
  123. package/latticeai/services/product_readiness.py +19 -17
  124. package/latticeai/services/review_queue.py +12 -0
  125. package/latticeai/services/router_context.py +1 -0
  126. package/latticeai/services/run_executor.py +4 -9
  127. package/latticeai/services/search_service.py +203 -28
  128. package/latticeai/services/tool_dispatch.py +28 -5
  129. package/latticeai/services/triggers.py +53 -4
  130. package/latticeai/services/upload_service.py +13 -2
  131. package/latticeai/setup/__init__.py +25 -0
  132. package/latticeai/setup/auto_setup.py +857 -0
  133. package/latticeai/setup/wizard.py +1264 -0
  134. package/latticeai/tools/__init__.py +278 -0
  135. package/{tools → latticeai/tools}/commands.py +67 -7
  136. package/{tools → latticeai/tools}/computer.py +1 -1
  137. package/{tools → latticeai/tools}/documents.py +1 -1
  138. package/{tools → latticeai/tools}/filesystem.py +3 -3
  139. package/latticeai/tools/knowledge.py +178 -0
  140. package/{tools → latticeai/tools}/local_files.py +1 -1
  141. package/{tools → latticeai/tools}/network.py +0 -1
  142. package/local_knowledge_api.py +4 -342
  143. package/package.json +22 -2
  144. package/scripts/brain_quality_eval.py +3 -1
  145. package/scripts/bump_version.py +3 -0
  146. package/scripts/capture_release_evidence.mjs +180 -0
  147. package/scripts/check_current_release_docs.mjs +142 -0
  148. package/scripts/check_i18n_literals.mjs +107 -31
  149. package/scripts/check_openapi_drift.mjs +56 -0
  150. package/scripts/export_openapi.py +67 -7
  151. package/scripts/i18n_literal_allowlist.json +22 -24
  152. package/scripts/run_integration_tests.mjs +72 -10
  153. package/scripts/validate_release_artifacts.py +49 -7
  154. package/scripts/wheel_smoke.py +5 -0
  155. package/setup_wizard.py +3 -1260
  156. package/src-tauri/Cargo.lock +1 -1
  157. package/src-tauri/Cargo.toml +1 -1
  158. package/src-tauri/tauri.conf.json +1 -1
  159. package/static/app/asset-manifest.json +11 -11
  160. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  161. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  162. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  163. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  164. package/static/app/assets/System-80lHW0Ux.js +1 -0
  165. package/static/app/assets/index-BiMofBTM.js +17 -0
  166. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  167. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  168. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  169. package/static/app/index.html +2 -2
  170. package/static/sw.js +1 -1
  171. package/tools/__init__.py +21 -269
  172. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  173. package/latticeai/runtime/app_context_runtime.py +0 -13
  174. package/latticeai/runtime/tail_wiring.py +0 -21
  175. package/scripts/com.pts.claudecode.discord.plist +0 -31
  176. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  177. package/scripts/start-pts-claudecode-discord.sh +0 -51
  178. package/static/app/assets/Act-21lIXx2E.js +0 -1
  179. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  180. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  181. package/static/app/assets/Library-bFMtyni3.js +0 -1
  182. package/static/app/assets/System-K6krGCqn.js +0 -1
  183. package/static/app/assets/index-C4R3ws30.js +0 -17
  184. package/static/app/assets/index-ChSeOB02.css +0 -2
  185. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  186. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  187. package/tools/knowledge.py +0 -95
@@ -34,34 +34,86 @@ class SearchService:
34
34
  raise ValueError("knowledge graph is disabled")
35
35
  return self.graph_store
36
36
 
37
- def _scope(self, matches, allowed_workspaces):
37
+ def _scope(
38
+ self,
39
+ matches,
40
+ allowed_workspaces,
41
+ *,
42
+ include_legacy_global: bool = False,
43
+ ):
38
44
  """Drop matches scoped to workspaces the caller is not a member of
39
- (None = no scoping; legacy-global rows stay visible — documented)."""
45
+ (None = no scoping; legacy-global requires an explicit opt-in)."""
40
46
  if allowed_workspaces is None:
41
47
  return matches
42
48
  graph = self._require_graph()
43
- return graph.filter_scoped_nodes(matches, allowed_workspaces)
44
-
45
- def _graph_search(self, graph, query: str, *, limit: int, allowed_workspaces=None):
46
49
  try:
47
- return graph.search(query, limit, allowed_workspaces=allowed_workspaces)
50
+ return graph.filter_scoped_nodes(
51
+ matches,
52
+ allowed_workspaces,
53
+ include_legacy_global=include_legacy_global,
54
+ )
55
+ except TypeError:
56
+ # Compatibility for pre-opt-in graph implementations. Rebuild the
57
+ # policy here instead of invoking their legacy fail-open filter.
58
+ candidates = list(matches)
59
+ scopes = graph.workspaces_of(
60
+ [item.get("id") or item.get("node_id") for item in candidates]
61
+ )
62
+ allowed = {str(item) for item in allowed_workspaces if item}
63
+ visible = []
64
+ for item in candidates:
65
+ node_id = str(item.get("id") or item.get("node_id") or "")
66
+ if not node_id or node_id not in scopes:
67
+ continue
68
+ scope = scopes[node_id]
69
+ if scope is None and include_legacy_global:
70
+ visible.append(item)
71
+ elif scope is not None and str(scope) in allowed:
72
+ visible.append(item)
73
+ return visible
74
+
75
+ def _graph_search(
76
+ self,
77
+ graph,
78
+ query: str,
79
+ *,
80
+ limit: int,
81
+ allowed_workspaces=None,
82
+ include_legacy_global: bool = False,
83
+ ):
84
+ try:
85
+ return graph.search(
86
+ query,
87
+ limit,
88
+ allowed_workspaces=allowed_workspaces,
89
+ include_legacy_global=include_legacy_global,
90
+ )
48
91
  except TypeError:
49
92
  payload = graph.search(query, limit)
50
93
  if allowed_workspaces is not None:
51
94
  payload = {
52
95
  **payload,
53
- "matches": graph.filter_scoped_nodes(
96
+ "matches": self._scope(
54
97
  payload.get("matches", []),
55
98
  allowed_workspaces,
99
+ include_legacy_global=include_legacy_global,
56
100
  ),
57
101
  }
58
102
  return payload
59
103
 
60
- def _relationship_search(self, graph, *, allowed_workspaces=None, **kwargs):
104
+ def _relationship_search(
105
+ self,
106
+ graph,
107
+ *,
108
+ allowed_workspaces=None,
109
+ include_legacy_global: bool = False,
110
+ **kwargs,
111
+ ):
61
112
  try:
62
113
  return graph.relationship_search(
63
114
  **kwargs,
64
115
  allowed_workspaces=allowed_workspaces,
116
+ include_legacy_global=include_legacy_global,
65
117
  )
66
118
  except TypeError:
67
119
  payload = graph.relationship_search(**kwargs)
@@ -72,25 +124,42 @@ class SearchService:
72
124
  {"id": (rel.get("source") or {}).get("id")},
73
125
  {"id": (rel.get("target") or {}).get("id")},
74
126
  ]
75
- if len(graph.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
127
+ if len(
128
+ self._scope(
129
+ endpoints,
130
+ allowed_workspaces,
131
+ include_legacy_global=include_legacy_global,
132
+ )
133
+ ) == 2:
76
134
  kept.append(rel)
77
135
  payload = {**payload, "relationships": kept}
78
136
  return payload
79
137
 
80
- def _traverse(self, graph, node_id: str, *, depth: int, limit: int, allowed_workspaces=None):
138
+ def _traverse(
139
+ self,
140
+ graph,
141
+ node_id: str,
142
+ *,
143
+ depth: int,
144
+ limit: int,
145
+ allowed_workspaces=None,
146
+ include_legacy_global: bool = False,
147
+ ):
81
148
  try:
82
149
  return graph.traverse(
83
150
  node_id,
84
151
  depth=depth,
85
152
  limit=limit,
86
153
  allowed_workspaces=allowed_workspaces,
154
+ include_legacy_global=include_legacy_global,
87
155
  )
88
156
  except TypeError:
89
157
  neighborhood = graph.traverse(node_id, depth=depth, limit=limit)
90
158
  if allowed_workspaces is not None:
91
- nodes = graph.filter_scoped_nodes(
159
+ nodes = self._scope(
92
160
  neighborhood.get("nodes", []),
93
161
  allowed_workspaces,
162
+ include_legacy_global=include_legacy_global,
94
163
  )
95
164
  kept = {item.get("id") for item in nodes}
96
165
  edges = [
@@ -100,25 +169,48 @@ class SearchService:
100
169
  neighborhood = {**neighborhood, "nodes": nodes, "edges": edges}
101
170
  return neighborhood
102
171
 
103
- def _get_node(self, graph, node_id: str, *, allowed_workspaces=None):
172
+ def _get_node(
173
+ self,
174
+ graph,
175
+ node_id: str,
176
+ *,
177
+ allowed_workspaces=None,
178
+ include_legacy_global: bool = False,
179
+ ):
104
180
  try:
105
- return graph.get_node(node_id, allowed_workspaces=allowed_workspaces)
181
+ return graph.get_node(
182
+ node_id,
183
+ allowed_workspaces=allowed_workspaces,
184
+ include_legacy_global=include_legacy_global,
185
+ )
106
186
  except TypeError:
107
187
  node = graph.get_node(node_id)
108
188
  if allowed_workspaces is not None:
109
- visible = graph.filter_scoped_nodes([node], allowed_workspaces)
189
+ visible = self._scope(
190
+ [node],
191
+ allowed_workspaces,
192
+ include_legacy_global=include_legacy_global,
193
+ )
110
194
  if not visible:
111
195
  raise ValueError(f"graph node not found: {node_id}")
112
196
  return visible[0]
113
197
  return node
114
198
 
115
- def keyword_search(self, query: str, *, limit: int = 30, allowed_workspaces=None) -> Dict[str, Any]:
199
+ def keyword_search(
200
+ self,
201
+ query: str,
202
+ *,
203
+ limit: int = 30,
204
+ allowed_workspaces=None,
205
+ include_legacy_global: bool = False,
206
+ ) -> Dict[str, Any]:
116
207
  graph = self._require_graph()
117
208
  payload = self._graph_search(
118
209
  graph,
119
210
  query,
120
211
  limit=limit,
121
212
  allowed_workspaces=allowed_workspaces,
213
+ include_legacy_global=include_legacy_global,
122
214
  )
123
215
  matches = []
124
216
  for rank, match in enumerate(payload.get("matches", []), start=1):
@@ -136,9 +228,25 @@ class SearchService:
136
228
  "metadata": match.get("metadata") or {},
137
229
  "updated_at": match.get("updated_at"),
138
230
  })
139
- return {"query": query, "mode": "keyword", "matches": self._scope(matches, allowed_workspaces)}
231
+ return {
232
+ "query": query,
233
+ "mode": "keyword",
234
+ "matches": self._scope(
235
+ matches,
236
+ allowed_workspaces,
237
+ include_legacy_global=include_legacy_global,
238
+ ),
239
+ }
140
240
 
141
- def vector_search(self, query: str, *, limit: int = 30, min_score: float = 0.0, allowed_workspaces=None) -> Dict[str, Any]:
241
+ def vector_search(
242
+ self,
243
+ query: str,
244
+ *,
245
+ limit: int = 30,
246
+ min_score: float = 0.0,
247
+ allowed_workspaces=None,
248
+ include_legacy_global: bool = False,
249
+ ) -> Dict[str, Any]:
142
250
  graph = self._require_graph()
143
251
  payload = graph.vector_search(query, limit=limit, min_score=min_score)
144
252
  matches = []
@@ -163,10 +271,22 @@ class SearchService:
163
271
  "mode": "vector",
164
272
  "embedding_model": payload.get("embedding_model"),
165
273
  "embedding_dim": payload.get("embedding_dim"),
166
- "matches": self._scope(matches, allowed_workspaces),
274
+ "matches": self._scope(
275
+ matches,
276
+ allowed_workspaces,
277
+ include_legacy_global=include_legacy_global,
278
+ ),
167
279
  }
168
280
 
169
- def graph_search(self, query: str, *, limit: int = 30, expand_depth: int = 1, allowed_workspaces=None) -> Dict[str, Any]:
281
+ def graph_search(
282
+ self,
283
+ query: str,
284
+ *,
285
+ limit: int = 30,
286
+ expand_depth: int = 1,
287
+ allowed_workspaces=None,
288
+ include_legacy_global: bool = False,
289
+ ) -> Dict[str, Any]:
170
290
  graph = self._require_graph()
171
291
  limit = max(1, min(int(limit or 30), 100))
172
292
  expand_depth = max(0, min(int(expand_depth or 1), 3))
@@ -175,12 +295,14 @@ class SearchService:
175
295
  query,
176
296
  limit=max(limit, 10),
177
297
  allowed_workspaces=allowed_workspaces,
298
+ include_legacy_global=include_legacy_global,
178
299
  ).get("matches", [])
179
300
  relationships = self._relationship_search(
180
301
  graph,
181
302
  query=query,
182
303
  limit=limit,
183
304
  allowed_workspaces=allowed_workspaces,
305
+ include_legacy_global=include_legacy_global,
184
306
  ).get("relationships", [])
185
307
  by_id: Dict[str, Dict[str, Any]] = {}
186
308
 
@@ -229,6 +351,7 @@ class SearchService:
229
351
  depth=expand_depth,
230
352
  limit=limit * 3,
231
353
  allowed_workspaces=allowed_workspaces,
354
+ include_legacy_global=include_legacy_global,
232
355
  )
233
356
  except Exception:
234
357
  neighborhood = {"nodes": [], "edges": []}
@@ -256,7 +379,16 @@ class SearchService:
256
379
  match["rank"] = rank
257
380
  match["score"] = round(float(match["score"]), 6)
258
381
  match["source_scores"]["graph"] = round(float(match["source_scores"]["graph"]), 6)
259
- return {"query": query, "mode": "graph", "expand_depth": expand_depth, "matches": self._scope(matches, allowed_workspaces)}
382
+ return {
383
+ "query": query,
384
+ "mode": "graph",
385
+ "expand_depth": expand_depth,
386
+ "matches": self._scope(
387
+ matches,
388
+ allowed_workspaces,
389
+ include_legacy_global=include_legacy_global,
390
+ ),
391
+ }
260
392
 
261
393
  def hybrid_search(
262
394
  self,
@@ -268,14 +400,30 @@ class SearchService:
268
400
  graph_limit: int = 30,
269
401
  weights: Optional[Mapping[str, float]] = None,
270
402
  allowed_workspaces=None,
403
+ include_legacy_global: bool = False,
271
404
  ) -> Dict[str, Any]:
272
405
  weights = {**DEFAULT_HYBRID_WEIGHTS, **dict(weights or {})}
273
406
  # Scope each channel at the source so out-of-scope rows never enter the
274
407
  # fusion set (defense-in-depth — the fused result is re-scoped below too).
275
408
  channels = {
276
- "keyword": self.keyword_search(query, limit=keyword_limit, allowed_workspaces=allowed_workspaces),
277
- "vector": self.vector_search(query, limit=vector_limit, allowed_workspaces=allowed_workspaces),
278
- "graph": self.graph_search(query, limit=graph_limit, allowed_workspaces=allowed_workspaces),
409
+ "keyword": self.keyword_search(
410
+ query,
411
+ limit=keyword_limit,
412
+ allowed_workspaces=allowed_workspaces,
413
+ include_legacy_global=include_legacy_global,
414
+ ),
415
+ "vector": self.vector_search(
416
+ query,
417
+ limit=vector_limit,
418
+ allowed_workspaces=allowed_workspaces,
419
+ include_legacy_global=include_legacy_global,
420
+ ),
421
+ "graph": self.graph_search(
422
+ query,
423
+ limit=graph_limit,
424
+ allowed_workspaces=allowed_workspaces,
425
+ include_legacy_global=include_legacy_global,
426
+ ),
279
427
  }
280
428
  fused: Dict[str, Dict[str, Any]] = {}
281
429
  for source, payload in channels.items():
@@ -304,7 +452,11 @@ class SearchService:
304
452
  current.setdefault("graph_context", [])
305
453
  current["graph_context"].extend(result.get("graph_context") or [])
306
454
 
307
- matches = self._scope(sorted(fused.values(), key=lambda item: item["score"], reverse=True), allowed_workspaces)[: max(1, min(limit, 100))]
455
+ matches = self._scope(
456
+ sorted(fused.values(), key=lambda item: item["score"], reverse=True),
457
+ allowed_workspaces,
458
+ include_legacy_global=include_legacy_global,
459
+ )[: max(1, min(limit, 100))]
308
460
  for rank, match in enumerate(matches, start=1):
309
461
  match["rank"] = rank
310
462
  match["score"] = round(float(match["score"]), 6)
@@ -327,14 +479,28 @@ class SearchService:
327
479
  "matches": matches,
328
480
  }
329
481
 
330
- def graph(self, *, limit: int = 300, allowed_workspaces=None) -> Dict[str, Any]:
482
+ def graph(
483
+ self,
484
+ *,
485
+ limit: int = 300,
486
+ allowed_workspaces=None,
487
+ include_legacy_global: bool = False,
488
+ ) -> Dict[str, Any]:
331
489
  graph = self._require_graph()
332
490
  try:
333
- return graph.graph(limit=limit, allowed_workspaces=allowed_workspaces)
491
+ return graph.graph(
492
+ limit=limit,
493
+ allowed_workspaces=allowed_workspaces,
494
+ include_legacy_global=include_legacy_global,
495
+ )
334
496
  except TypeError:
335
497
  payload = graph.graph(limit=limit)
336
498
  if allowed_workspaces is not None:
337
- nodes = graph.filter_scoped_nodes(payload.get("nodes", []), allowed_workspaces)
499
+ nodes = self._scope(
500
+ payload.get("nodes", []),
501
+ allowed_workspaces,
502
+ include_legacy_global=include_legacy_global,
503
+ )
338
504
  kept = {node.get("id") for node in nodes}
339
505
  edges = [
340
506
  edge for edge in payload.get("edges", [])
@@ -351,9 +517,15 @@ class SearchService:
351
517
  depth: int = 1,
352
518
  limit: int = 100,
353
519
  allowed_workspaces=None,
520
+ include_legacy_global: bool = False,
354
521
  ) -> Dict[str, Any]:
355
522
  graph = self._require_graph()
356
- node = self._get_node(graph, node_id, allowed_workspaces=allowed_workspaces)
523
+ node = self._get_node(
524
+ graph,
525
+ node_id,
526
+ allowed_workspaces=allowed_workspaces,
527
+ include_legacy_global=include_legacy_global,
528
+ )
357
529
  payload = {"node": node}
358
530
  if include_neighbors:
359
531
  neighborhood = self._traverse(
@@ -362,6 +534,7 @@ class SearchService:
362
534
  depth=depth,
363
535
  limit=limit,
364
536
  allowed_workspaces=allowed_workspaces,
537
+ include_legacy_global=include_legacy_global,
365
538
  )
366
539
  payload["neighborhood"] = neighborhood
367
540
  return payload
@@ -374,6 +547,7 @@ class SearchService:
374
547
  relationship_type: str = "",
375
548
  limit: int = 30,
376
549
  allowed_workspaces=None,
550
+ include_legacy_global: bool = False,
377
551
  ) -> Dict[str, Any]:
378
552
  graph = self._require_graph()
379
553
  payload = self._relationship_search(
@@ -383,6 +557,7 @@ class SearchService:
383
557
  relationship_type=relationship_type,
384
558
  limit=limit,
385
559
  allowed_workspaces=allowed_workspaces,
560
+ include_legacy_global=include_legacy_global,
386
561
  )
387
562
  return payload
388
563
 
@@ -20,8 +20,9 @@ from latticeai.core.agent_prompts import (
20
20
  MEMORY_UPDATER_PROMPT,
21
21
  PLANNER_PROMPT,
22
22
  )
23
+ from latticeai.core.policy import role_has_capability
23
24
  from latticeai.core.tool_registry import ToolPermission, ToolPolicy
24
- from tools import AGENT_ROOT, DEFAULT_TOOL_REGISTRY, ToolError, ensure_agent_root
25
+ from latticeai.tools import AGENT_ROOT, DEFAULT_TOOL_REGISTRY, ToolError, ensure_agent_root
25
26
 
26
27
 
27
28
  def _default_load_users() -> Dict[str, Any]:
@@ -36,6 +37,12 @@ FILE_CREATE_ACTIONS = set(DEFAULT_TOOL_REGISTRY.file_create_actions)
36
37
  TOOL_GOVERNANCE: Dict[str, ToolPolicy] = dict(DEFAULT_TOOL_REGISTRY.governance)
37
38
  TOOL_GOVERNANCE_DEFAULT: ToolPolicy = DEFAULT_TOOL_REGISTRY.default_policy
38
39
  ADMIN_ONLY_TOOLS: frozenset[str] = DEFAULT_TOOL_REGISTRY.admin_only_tools
40
+ EXPLICIT_CONSENT_TOOLS: frozenset[str] = frozenset({
41
+ "local_list",
42
+ "local_read",
43
+ "local_write",
44
+ "read_document",
45
+ })
39
46
  LOCAL_WRITE_BLOCKED_PREFIXES = DEFAULT_TOOL_REGISTRY.local_write_blocked_prefixes
40
47
  RISK_LEVEL_MAP = DEFAULT_TOOL_REGISTRY.risk_level_map
41
48
 
@@ -98,14 +105,22 @@ class ToolDispatchService:
98
105
  return self.registry.manifest()
99
106
 
100
107
  def check_role(self, tool_name: str, current_user: str) -> None:
101
- if tool_name not in self.registry.admin_only_tools:
108
+ admin_only = tool_name in self.registry.admin_only_tools
109
+ capability = self.policy_for(tool_name, {}).get("capability")
110
+ if not admin_only and not capability:
102
111
  return
103
112
  users = self.load_users()
104
- if self.get_user_role(current_user, users) != "admin":
113
+ role = str(self.get_user_role(current_user, users) or "user")
114
+ if admin_only and role not in {"admin", "owner"}:
105
115
  raise HTTPException(
106
116
  status_code=403,
107
117
  detail=f"'{tool_name}' 툴은 관리자 전용입니다.",
108
118
  )
119
+ if capability and not role_has_capability(role, capability):
120
+ raise HTTPException(
121
+ status_code=403,
122
+ detail=f"'{tool_name}' 툴에는 '{capability}' capability가 필요합니다.",
123
+ )
109
124
 
110
125
  def user_role(self, current_user: str) -> str:
111
126
  users = self.load_users()
@@ -132,6 +147,14 @@ class ToolDispatchService:
132
147
  when explicitly excluded from a hardening pass.
133
148
  """
134
149
  policy = self.policy_for(tool_name, args or {})
150
+ if source in {"mcp", "plugin"} and tool_name in EXPLICIT_CONSENT_TOOLS:
151
+ raise HTTPException(
152
+ status_code=403,
153
+ detail=(
154
+ f"'{tool_name}' requires the dedicated local-file approval flow "
155
+ "and cannot run through MCP or plugins."
156
+ ),
157
+ )
135
158
  if not trusted_admin:
136
159
  self.check_role(tool_name, current_user)
137
160
  if policy["destructive"] or policy["risk"] == "destructive":
@@ -143,13 +166,13 @@ class ToolDispatchService:
143
166
  require_auto_approval
144
167
  and not trusted_admin
145
168
  and not policy["auto_approve"]
146
- and self.user_role(current_user) != "admin"
169
+ and self.user_role(current_user) not in {"admin", "owner"}
147
170
  ):
148
171
  raise HTTPException(
149
172
  status_code=403,
150
173
  detail=(
151
174
  f"'{tool_name}' 툴은 명시 승인이 필요합니다. "
152
- "9.0.0에서는 승인 UI가 없는 직접 실행 경로에서 기본 차단됩니다."
175
+ "현재 릴리스에서는 승인 UI가 없는 직접 실행 경로에서 기본 차단됩니다."
153
176
  ),
154
177
  )
155
178
  return policy
@@ -209,13 +209,23 @@ class TriggerService:
209
209
  def on_brain_event(self, event: str, payload: Optional[Dict[str, Any]] = None) -> int:
210
210
  """Fire workflows whose brain_event trigger matches this ingestion."""
211
211
  payload = payload or {}
212
- source_type = str(payload.get("source_type") or event.split(".", 1)[-1] or "")
212
+ source_type = str(payload.get("source_type") or _ingestion_source_type(event, payload) or "")
213
+ event_workspace = str(payload.get("workspace_id") or "personal").strip()
214
+ event_user = str(payload.get("user_email") or "").strip().lower()
213
215
  fired = 0
214
216
  with self._lock:
215
217
  state = self._load_state()
216
218
  for item in self._triggered_workflows():
217
219
  if item["kind"] != "brain_event":
218
220
  continue
221
+ workflow_workspace = str(item["workflow"].get("workspace_id") or "personal").strip()
222
+ workflow_user = str(item["workflow"].get("user_email") or "").strip().lower()
223
+ if event_workspace != workflow_workspace:
224
+ continue
225
+ # Owned workflows only respond to their owner's ingestion.
226
+ # Ownerless legacy workflows remain compatible in personal.
227
+ if workflow_user and workflow_user != event_user:
228
+ continue
219
229
  wanted = str(item["config"].get("source_type") or "").strip()
220
230
  if wanted and wanted != source_type:
221
231
  continue
@@ -237,6 +247,8 @@ class TriggerService:
237
247
  "event": event,
238
248
  "source_type": source_type,
239
249
  "node_id": payload.get("node_id"),
250
+ "user_email": payload.get("user_email"),
251
+ "workspace_id": payload.get("workspace_id"),
240
252
  })
241
253
  self._save_state(state)
242
254
  return fired
@@ -245,15 +257,37 @@ class TriggerService:
245
257
  """A post_tool hook runner: ingestion events fan into triggers."""
246
258
  def runner(context):
247
259
  event = str(getattr(context, "event", "") or "")
248
- if not event.startswith("kg_ingest."):
249
- return {"status": "ok", "output": "not an ingestion event"}
250
260
  payload = context.payload if isinstance(context.payload, dict) else {}
251
- fired = self.on_brain_event(event, payload)
261
+ source_type = _ingestion_source_type(event, payload)
262
+ if not source_type:
263
+ return {"status": "ok", "output": "not an ingestion event"}
264
+ if str(payload.get("status") or "ok") != "ok":
265
+ return {"status": "ok", "output": "ignored failed ingestion event"}
266
+ fired = self.on_brain_event(
267
+ f"kg_ingest.{source_type}",
268
+ {
269
+ **payload,
270
+ "source_type": source_type,
271
+ "user_email": getattr(context, "user_email", None) or payload.get("user_email"),
272
+ "workspace_id": getattr(context, "workspace_id", None) or payload.get("workspace_id"),
273
+ },
274
+ )
252
275
  return {"status": "ok", "output": f"fired {fired} workflow trigger(s)"}
253
276
  return runner
254
277
 
255
278
  # ── execution + lifecycle ──────────────────────────────────────────────
256
279
  def _fire(self, workflow_id: str, trigger_info: Dict[str, Any]) -> None:
280
+ try:
281
+ workflows = list(self._store.load_state().get("workflows") or [])
282
+ workflow = next((wf for wf in workflows if wf.get("id") == workflow_id), None) or {}
283
+ except Exception:
284
+ workflow = {}
285
+ trigger_info = dict(trigger_info)
286
+ if not trigger_info.get("user_email"):
287
+ trigger_info["user_email"] = workflow.get("user_email")
288
+ if not trigger_info.get("workspace_id"):
289
+ trigger_info["workspace_id"] = workflow.get("workspace_id")
290
+
257
291
  def _run():
258
292
  try:
259
293
  result = self._run_workflow(workflow_id, {"__trigger__": trigger_info})
@@ -285,6 +319,8 @@ class TriggerService:
285
319
  source=source,
286
320
  run_result=result,
287
321
  trigger_info=trigger_info,
322
+ user_email=trigger_info.get("user_email") or workflow.get("user_email"),
323
+ workspace_id=trigger_info.get("workspace_id") or workflow.get("workspace_id"),
288
324
  )
289
325
  except Exception as exc:
290
326
  logging.warning("review_sink enqueue failed for %s: %s", workflow_id, exc)
@@ -326,4 +362,17 @@ class TriggerService:
326
362
  self._thread.join(timeout=2)
327
363
 
328
364
 
365
+ def _ingestion_source_type(event: str, payload: Dict[str, Any]) -> str:
366
+ """Normalize the real dispatch event (``tool.kg_ingest.*``) and legacy form."""
367
+ tool = str(payload.get("tool") or "").strip()
368
+ if tool.startswith("kg_ingest."):
369
+ return tool.removeprefix("kg_ingest.").strip()
370
+ normalized = str(event or "").strip()
371
+ if normalized.startswith("tool.kg_ingest."):
372
+ return normalized.removeprefix("tool.kg_ingest.").strip()
373
+ if normalized.startswith("kg_ingest."):
374
+ return normalized.removeprefix("kg_ingest.").strip()
375
+ return ""
376
+
377
+
329
378
  __all__ = ["TriggerService", "TRIGGER_HOOK_NAME", "MIN_INTERVAL_SECONDS"]
@@ -11,7 +11,7 @@ from typing import Optional
11
11
  from fastapi import HTTPException, Request, UploadFile
12
12
 
13
13
  from lattice_brain.ingestion import IngestionItem
14
- from tools import ToolError, read_document
14
+ from latticeai.tools import ToolError, read_document
15
15
 
16
16
 
17
17
  def _workspace_scope_from_request(request: Request) -> Optional[str]:
@@ -35,9 +35,20 @@ async def process_uploaded_document(
35
35
  append_audit_event,
36
36
  enforce_rate_limit,
37
37
  hooks=None,
38
+ workspace_service=None,
38
39
  ) -> dict:
39
40
  enforce_rate_limit(current_user, "upload")
40
- workspace_id = _workspace_scope_from_request(request)
41
+ requested_workspace = _workspace_scope_from_request(request)
42
+ if workspace_service is not None:
43
+ try:
44
+ workspace_id = workspace_service.resolve_write_scope(
45
+ requested_workspace,
46
+ current_user or None,
47
+ )
48
+ except PermissionError as exc:
49
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
50
+ else:
51
+ workspace_id = requested_workspace
41
52
  suffix = Path(file.filename or "upload").suffix.lower()
42
53
  allowed = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
43
54
  if suffix not in allowed:
@@ -0,0 +1,25 @@
1
+ """Setup services owned by the :mod:`latticeai` package."""
2
+
3
+ from .auto_setup import (
4
+ InstallPlan,
5
+ Recommendation,
6
+ SystemProfile,
7
+ plan,
8
+ preset,
9
+ probe,
10
+ recommend,
11
+ run_all,
12
+ verify,
13
+ )
14
+
15
+ __all__ = [
16
+ "InstallPlan",
17
+ "Recommendation",
18
+ "SystemProfile",
19
+ "plan",
20
+ "preset",
21
+ "probe",
22
+ "recommend",
23
+ "run_all",
24
+ "verify",
25
+ ]