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
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
16
 
17
- LEGACY_COMPATIBILITY_VERSION = "9.0.0"
17
+ LEGACY_COMPATIBILITY_VERSION = "9.1.0"
18
18
 
19
19
 
20
20
  @dataclass(frozen=True)
@@ -89,13 +89,34 @@ LEGACY_SHIMS: List[LegacyShim] = [
89
89
  reason="Deployment docs and local launch scripts historically targeted server.py.",
90
90
  removal_phase="major-release-after-8.x",
91
91
  ),
92
+ LegacyShim(
93
+ path="tools/",
94
+ owner="latticeai.tools",
95
+ replacement="from latticeai.tools import execute_tool",
96
+ reason="Existing integrations and tests import the historical root tools package.",
97
+ removal_phase="major-release-after-9.x",
98
+ ),
92
99
  LegacyShim(
93
100
  path="local_knowledge_api.py",
94
- owner="latticeai.api.local_files",
95
- replacement="from latticeai.api.local_files import create_local_files_router",
101
+ owner="latticeai.services.local_knowledge",
102
+ replacement="from latticeai.services.local_knowledge import create_local_knowledge_router",
96
103
  reason="Local folder ingestion integrations used the root local knowledge API.",
97
104
  removal_phase="requires-api-route-migration",
98
105
  ),
106
+ LegacyShim(
107
+ path="auto_setup.py",
108
+ owner="latticeai.setup.auto_setup",
109
+ replacement="from latticeai.setup.auto_setup import probe, recommend",
110
+ reason="Historical scripts invoke the zero-config setup module from the repo root.",
111
+ removal_phase="major-release-after-9.x",
112
+ ),
113
+ LegacyShim(
114
+ path="setup_wizard.py",
115
+ owner="latticeai.setup.wizard",
116
+ replacement="from latticeai.setup.wizard import scan_environment",
117
+ reason="Older integrations imported the setup wizard from the repo root.",
118
+ removal_phase="major-release-after-9.x",
119
+ ),
99
120
  ]
100
121
 
101
122
  # Shim layers that have completed their compatibility window and were
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "9.0.0"
14
+ MARKETPLACE_VERSION = "9.1.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
16
16
 
17
17
 
@@ -403,6 +403,21 @@ class PluginRegistry:
403
403
  reason=f"permission '{permission}' not granted at install time",
404
404
  ))
405
405
 
406
+ if capability == "tools":
407
+ tool_name = str(args.get("tool") or "").strip()
408
+ declared_tools = {
409
+ str(item).strip()
410
+ for item in (manifest.provides.get("tools") or [])
411
+ if str(item).strip()
412
+ }
413
+ if not tool_name or tool_name not in declared_tools:
414
+ return finish(PluginExecutionResult(
415
+ plugin_id,
416
+ action,
417
+ "blocked",
418
+ reason=f"tool '{tool_name or '<missing>'}' is not declared in provides.tools",
419
+ ))
420
+
406
421
  runner = runners.get(capability)
407
422
  if runner is None:
408
423
  return finish(PluginExecutionResult(
@@ -21,6 +21,7 @@ ROLE_CAPABILITIES: Dict[str, Set[str]] = {
21
21
  "search",
22
22
  "files",
23
23
  "pipeline",
24
+ "desktop:control",
24
25
  },
25
26
  "member": {"workspace:read", "workspace:write", "chat", "search", "files", "pipeline"},
26
27
  "user": {"workspace:read", "workspace:write", "chat", "search", "files", "pipeline"},
@@ -51,4 +52,3 @@ def require_capability(role: str, capability: str) -> None:
51
52
  def policy_matrix(roles: Iterable[str] | None = None) -> List[Dict[str, object]]:
52
53
  selected = list(roles or ROLE_CAPABILITIES.keys())
53
54
  return [{"role": normalize_role(role), "caps": capabilities_for_role(role)} for role in selected]
54
-
@@ -29,10 +29,10 @@ from __future__ import annotations
29
29
  import asyncio
30
30
  import json
31
31
  import threading
32
- from datetime import datetime
33
- from typing import Any, AsyncIterator, Dict, List, Optional, Set
32
+ from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Set
34
33
 
35
34
  from lattice_brain.runtime.contracts import realtime_event_contract
35
+ from .timeutil import now_iso as _now
36
36
 
37
37
 
38
38
  REALTIME_VERSION = "2.2.0"
@@ -40,10 +40,6 @@ _FEED_LIMIT = 200
40
40
  _QUEUE_MAX = 100
41
41
 
42
42
 
43
- def _now() -> str:
44
- return datetime.now().isoformat(timespec="seconds")
45
-
46
-
47
43
  def sse_format(event: Dict[str, Any]) -> str:
48
44
  """Encode an event as an SSE ``data:`` frame."""
49
45
  return f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n"
@@ -64,11 +60,11 @@ class _Subscriber:
64
60
  self.loop = None
65
61
 
66
62
  def accepts(self, workspace_id: Optional[str]) -> bool:
67
- # ``None`` scope = see everything the local user can (personal/unscoped).
63
+ # Only the explicit no-auth/local ``None`` scope sees unscoped events.
64
+ # Any concrete set, including the authenticated fail-closed empty set,
65
+ # requires an exact workspace match.
68
66
  if self.workspace_scope is None:
69
67
  return True
70
- if workspace_id is None:
71
- return True
72
68
  return workspace_id in self.workspace_scope
73
69
 
74
70
 
@@ -142,21 +138,42 @@ class RealtimeBus:
142
138
  with self._lock:
143
139
  self._subscribers.pop(sub_id, None)
144
140
 
145
- async def stream(self, sub: _Subscriber, *, heartbeat: float = 15.0) -> AsyncIterator[str]:
141
+ async def stream(
142
+ self,
143
+ sub: _Subscriber,
144
+ *,
145
+ heartbeat: float = 15.0,
146
+ refresh_authorization: Optional[Callable[[_Subscriber], bool]] = None,
147
+ ) -> AsyncIterator[str]:
146
148
  """Yield SSE frames for a subscriber until the client disconnects.
147
149
 
148
150
  Emits a periodic heartbeat comment so proxies keep the connection open
149
151
  and single-user local mode never looks "stuck" with no events.
150
152
  """
151
153
  # Replay a small tail so a fresh subscriber has immediate context.
154
+ if refresh_authorization is not None and not refresh_authorization(sub):
155
+ self.remove_subscriber(sub.id)
156
+ return
152
157
  for event in self.recent(limit=10, workspace_scope=sub.workspace_scope):
153
- yield sse_format(event)
158
+ if refresh_authorization is not None and not refresh_authorization(sub):
159
+ self.remove_subscriber(sub.id)
160
+ return
161
+ if sub.accepts(event.get("workspace_id")):
162
+ yield sse_format(event)
154
163
  try:
155
164
  while True:
156
165
  try:
157
166
  event = await asyncio.wait_for(sub.queue.get(), timeout=heartbeat)
158
- yield sse_format(event)
167
+ if refresh_authorization is not None and not refresh_authorization(sub):
168
+ break
169
+ # The event may have been queued under an older membership
170
+ # snapshot. Recheck it against the refreshed scope before
171
+ # any bytes leave the process.
172
+ if sub.accepts(event.get("workspace_id")):
173
+ yield sse_format(event)
159
174
  except asyncio.TimeoutError:
175
+ if refresh_authorization is not None and not refresh_authorization(sub):
176
+ break
160
177
  yield ": heartbeat\n\n"
161
178
  finally:
162
179
  self.remove_subscriber(sub.id)
@@ -167,7 +184,7 @@ class RealtimeBus:
167
184
  with self._lock:
168
185
  events = list(self._feed)
169
186
  if workspace_scope is not None:
170
- events = [e for e in events if e.get("workspace_id") is None or e.get("workspace_id") in workspace_scope]
187
+ events = [e for e in events if e.get("workspace_id") in workspace_scope]
171
188
  return list(reversed(events[-max(1, min(limit, _FEED_LIMIT)):]))
172
189
 
173
190
  def join(self, client_id: str, *, user: Optional[str], workspace_id: Optional[str]) -> Dict[str, Any]:
@@ -179,6 +196,9 @@ class RealtimeBus:
179
196
  "last_seen": _now(),
180
197
  }
181
198
  with self._lock:
199
+ existing = self._presence.get(client_id)
200
+ if existing is not None and existing.get("user") != user:
201
+ raise PermissionError("Presence client belongs to another user.")
182
202
  self._presence[client_id] = record
183
203
  self.publish({"area": "presence", "event_type": "join", "workspace_id": workspace_id, "payload": {"user": user, "client_id": client_id}})
184
204
  return record
@@ -190,8 +210,11 @@ class RealtimeBus:
190
210
  record["last_seen"] = _now()
191
211
  return record
192
212
 
193
- def leave(self, client_id: str) -> None:
213
+ def leave(self, client_id: str, *, user: Optional[str] = None) -> None:
194
214
  with self._lock:
215
+ record = self._presence.get(client_id)
216
+ if record is not None and user is not None and record.get("user") != user:
217
+ raise PermissionError("Presence client belongs to another user.")
195
218
  record = self._presence.pop(client_id, None)
196
219
  if record:
197
220
  self.publish({"area": "presence", "event_type": "leave", "workspace_id": record.get("workspace_id"), "payload": {"client_id": client_id}})
@@ -200,7 +223,7 @@ class RealtimeBus:
200
223
  with self._lock:
201
224
  records = list(self._presence.values())
202
225
  if workspace_scope is not None:
203
- records = [r for r in records if r.get("workspace_id") is None or r.get("workspace_id") in workspace_scope]
226
+ records = [r for r in records if r.get("workspace_id") in workspace_scope]
204
227
  return records
205
228
 
206
229
  def stats(self) -> Dict[str, Any]:
@@ -15,6 +15,8 @@ import time
15
15
  from pathlib import Path
16
16
  from typing import Dict, Optional
17
17
 
18
+ from latticeai.core.io_utils import atomic_write_json
19
+
18
20
  SESSION_TTL = 60 * 60 * 24 # 24 hours
19
21
  SESSION_REFRESH_THRESHOLD = 60 * 15 # only persist if >15 min since last bump
20
22
  _lock = threading.Lock()
@@ -39,7 +41,7 @@ def _sessions_file(data_dir: Optional[Path] = None) -> Path:
39
41
  import os
40
42
  data_dir = Path(os.getenv("LATTICEAI_DATA_DIR") or (Path.home() / ".ltcai"))
41
43
  d = data_dir
42
- d.mkdir(parents=True, exist_ok=True)
44
+ d.mkdir(parents=True, exist_ok=True, mode=0o700)
43
45
  return d / "sessions.json"
44
46
 
45
47
 
@@ -68,7 +70,10 @@ def load_sessions(data_dir: Optional[Path] = None) -> Dict[str, tuple]:
68
70
 
69
71
  def persist_sessions(sessions: Dict[str, tuple], data_dir: Optional[Path] = None) -> None:
70
72
  try:
71
- _sessions_file(data_dir).write_text(json.dumps({k: list(v) for k, v in sessions.items()}, ensure_ascii=False))
73
+ atomic_write_json(
74
+ _sessions_file(data_dir),
75
+ {key: list(value) for key, value in sessions.items()},
76
+ )
72
77
  except Exception as e:
73
78
  logging.warning("persist_sessions failed: %s", e)
74
79
 
@@ -0,0 +1,10 @@
1
+ """Canonical application timestamp helpers.
2
+
3
+ Brain Core owns the dependency-free implementation so it remains independently
4
+ importable; the application layer exposes the same helpers from this stable
5
+ location for all LatticeAI services.
6
+ """
7
+
8
+ from lattice_brain.utils import local_now, now_iso, parse_iso, utc_now_iso
9
+
10
+ __all__ = ["local_now", "now_iso", "parse_iso", "utc_now_iso"]
@@ -16,11 +16,13 @@ __all__ = [
16
16
  "FILE_CREATE_ACTIONS",
17
17
  "LOCAL_WRITE_BLOCKED_PREFIXES",
18
18
  "RISK_LEVEL_MAP",
19
+ "SCOPED_KNOWLEDGE_TOOLS",
20
+ "KNOWLEDGE_WRITE_TOOLS",
19
21
  "ToolRegistry",
20
22
  ]
21
23
 
22
24
  from dataclasses import dataclass, field
23
- from typing import Any, Callable, Dict, Mapping, Optional, TypedDict
25
+ from typing import Any, Callable, Dict, Mapping, NotRequired, Optional, TypedDict
24
26
 
25
27
 
26
28
  class ToolPolicy(TypedDict):
@@ -31,6 +33,8 @@ class ToolPolicy(TypedDict):
31
33
  auto_approve: bool
32
34
  sandbox: str
33
35
  rollback: str
36
+ capability: NotRequired[str]
37
+ scope: NotRequired[str]
34
38
 
35
39
 
36
40
  class ToolPermission(TypedDict):
@@ -38,6 +42,8 @@ class ToolPermission(TypedDict):
38
42
  risk: str
39
43
  requires_approval: bool
40
44
  network: bool
45
+ capability: NotRequired[str]
46
+ scope: NotRequired[str]
41
47
 
42
48
 
43
49
  TOOL_CATALOG_BRIEF = """
@@ -80,6 +86,17 @@ RISK_LEVEL_MAP = {
80
86
  "destructive": "high",
81
87
  }
82
88
 
89
+ SCOPED_KNOWLEDGE_TOOLS = frozenset({
90
+ "knowledge_save",
91
+ "knowledge_search",
92
+ "knowledge_tree",
93
+ "obsidian_save",
94
+ "obsidian_search",
95
+ "obsidian_tree",
96
+ })
97
+
98
+ KNOWLEDGE_WRITE_TOOLS = frozenset({"knowledge_save", "obsidian_save"})
99
+
83
100
 
84
101
  def _r(sandbox: str = "workspace", rollback: str = "none") -> ToolPolicy:
85
102
  return ToolPolicy(
@@ -88,6 +105,25 @@ def _r(sandbox: str = "workspace", rollback: str = "none") -> ToolPolicy:
88
105
  )
89
106
 
90
107
 
108
+ def _rc(
109
+ sandbox: str = "home",
110
+ rollback: str = "none",
111
+ *,
112
+ capability: str = "",
113
+ scope: str = "",
114
+ ) -> ToolPolicy:
115
+ """Read operation that still requires explicit human consent."""
116
+ policy = ToolPolicy(
117
+ risk="read", destructive=False, shell=False, network=False,
118
+ auto_approve=False, sandbox=sandbox, rollback=rollback,
119
+ )
120
+ if capability:
121
+ policy["capability"] = capability
122
+ if scope:
123
+ policy["scope"] = scope
124
+ return policy
125
+
126
+
91
127
  def _rs(sandbox: str = "workspace", rollback: str = "none") -> ToolPolicy:
92
128
  return ToolPolicy(
93
129
  risk="read", destructive=False, shell=True, network=False,
@@ -102,11 +138,22 @@ def _rn(sandbox: str = "system", rollback: str = "none") -> ToolPolicy:
102
138
  )
103
139
 
104
140
 
105
- def _w(sandbox: str = "workspace", rollback: str = "none") -> ToolPolicy:
106
- return ToolPolicy(
141
+ def _w(
142
+ sandbox: str = "workspace",
143
+ rollback: str = "none",
144
+ *,
145
+ capability: str = "",
146
+ scope: str = "",
147
+ ) -> ToolPolicy:
148
+ policy = ToolPolicy(
107
149
  risk="write", destructive=False, shell=False, network=False,
108
150
  auto_approve=False, sandbox=sandbox, rollback=rollback,
109
151
  )
152
+ if capability:
153
+ policy["capability"] = capability
154
+ if scope:
155
+ policy["scope"] = scope
156
+ return policy
110
157
 
111
158
 
112
159
  def _wa(sandbox: str = "workspace", rollback: str = "none") -> ToolPolicy:
@@ -146,21 +193,37 @@ TOOL_GOVERNANCE: Dict[str, ToolPolicy] = {
146
193
  "inspect_html": _r(),
147
194
  "preview_url": _r(),
148
195
  "todo_read": _r(),
149
- "local_list": _r(sandbox="home"),
150
- "local_read": _r(sandbox="home"),
151
- "read_document": _r(sandbox="home"),
196
+ "local_list": _rc(sandbox="home"),
197
+ "local_read": _rc(sandbox="home"),
198
+ "read_document": _rc(sandbox="home"),
152
199
  "git_status": _rs(),
153
200
  "git_diff": _rs(),
154
201
  "git_log": _rs(),
155
202
  "git_show": _rs(),
156
- "knowledge_search": _r(sandbox="home"),
157
- "knowledge_tree": _r(sandbox="home"),
158
- "obsidian_search": _r(sandbox="home"),
159
- "obsidian_tree": _r(sandbox="home"),
160
- "computer_screenshot": _r(sandbox="system"),
161
- "computer_status": _r(sandbox="system"),
162
- "chrome_status": _r(sandbox="system"),
163
- "computer_use_status": _r(sandbox="system"),
203
+ "knowledge_search": _rc(
204
+ sandbox="workspace", capability="workspace:read", scope="workspace_user"
205
+ ),
206
+ "knowledge_tree": _rc(
207
+ sandbox="workspace", capability="workspace:read", scope="workspace_user"
208
+ ),
209
+ "obsidian_search": _rc(
210
+ sandbox="workspace", capability="workspace:read", scope="workspace_user"
211
+ ),
212
+ "obsidian_tree": _rc(
213
+ sandbox="workspace", capability="workspace:read", scope="workspace_user"
214
+ ),
215
+ "computer_screenshot": _rc(
216
+ sandbox="system", capability="desktop:control", scope="host"
217
+ ),
218
+ "computer_status": _rc(
219
+ sandbox="system", capability="desktop:control", scope="host"
220
+ ),
221
+ "chrome_status": _rc(
222
+ sandbox="system", capability="desktop:control", scope="host"
223
+ ),
224
+ "computer_use_status": _rc(
225
+ sandbox="system", capability="desktop:control", scope="host"
226
+ ),
164
227
  "network_status": _rn(),
165
228
  "write_file": _w(rollback="git"),
166
229
  "edit_file": _w(rollback="git"),
@@ -170,8 +233,12 @@ TOOL_GOVERNANCE: Dict[str, ToolPolicy] = {
170
233
  "create_pptx": _w(),
171
234
  "create_pdf": _w(),
172
235
  "todo_write": _w(),
173
- "knowledge_save": _w(sandbox="home"),
174
- "obsidian_save": _w(sandbox="home"),
236
+ "knowledge_save": _w(
237
+ sandbox="workspace", capability="workspace:write", scope="workspace_user"
238
+ ),
239
+ "obsidian_save": _w(
240
+ sandbox="workspace", capability="workspace:write", scope="workspace_user"
241
+ ),
175
242
  "local_write": _w(sandbox="home"),
176
243
  "run_command": _e(),
177
244
  "build_project": _e(),
@@ -315,7 +382,7 @@ class ToolRegistry:
315
382
  "status": "ok" if diagnostics["ready"] else "degraded",
316
383
  "boundary": {
317
384
  "owner": "latticeai.core.tool_registry.ToolRegistry",
318
- "dispatch_owner": "tools.DEFAULT_TOOL_REGISTRY",
385
+ "dispatch_owner": "latticeai.tools.DEFAULT_TOOL_REGISTRY",
319
386
  "policy_owner": "latticeai.core.tool_registry.ToolRegistry",
320
387
  "permission_owner": "latticeai.services.tool_dispatch.ToolDispatchService",
321
388
  },
@@ -354,12 +421,17 @@ class ToolRegistry:
354
421
 
355
422
  def permission(self, name: str, args: Optional[dict] = None) -> ToolPermission:
356
423
  policy = self.policy_for(name, args or {})
357
- return ToolPermission(
424
+ permission = ToolPermission(
358
425
  tool=name,
359
426
  risk=self.risk_level(policy),
360
427
  requires_approval=not policy["auto_approve"],
361
428
  network=policy["network"],
362
429
  )
430
+ if policy.get("capability"):
431
+ permission["capability"] = policy["capability"]
432
+ if policy.get("scope"):
433
+ permission["scope"] = policy["scope"]
434
+ return permission
363
435
 
364
436
  def permissions(self) -> list[ToolPermission]:
365
437
  return [self.permission(name) for name in sorted(self.governance.keys())]
@@ -6,20 +6,16 @@ import json
6
6
  import shutil
7
7
  import sqlite3
8
8
  import uuid
9
- from datetime import datetime
10
9
  from pathlib import Path
11
10
  from typing import Any, Dict, Optional
12
11
 
13
12
  from .io_utils import atomic_write_json
13
+ from .timeutil import now_iso as _now
14
14
 
15
15
 
16
16
  USER_NAMESPACE = uuid.UUID("5d6d4480-cf79-49c3-a6d0-4c6eec3224d6")
17
17
 
18
18
 
19
- def _now() -> str:
20
- return datetime.now().isoformat(timespec="seconds")
21
-
22
-
23
19
  def normalize_email(email: str) -> str:
24
20
  return str(email or "").strip().lower()
25
21
 
@@ -3,7 +3,8 @@ from __future__ import annotations
3
3
 
4
4
  from typing import Any, Dict, List, Optional
5
5
 
6
- from .workspace_os_utils import _json_hash, _listify, _now
6
+ from .timeutil import now_iso as _now
7
+ from .workspace_os_utils import _json_hash, _listify
7
8
 
8
9
 
9
10
  class WorkspaceGraphTrace:
@@ -13,7 +14,15 @@ class WorkspaceGraphTrace:
13
14
  def __getattr__(self, name: str) -> Any:
14
15
  return getattr(self._store, name)
15
16
 
16
- def build_graph_trace(self, question: str, graph: Any, context: str = "", *, limit: int = 8) -> Dict[str, Any]:
17
+ def build_graph_trace(
18
+ self,
19
+ question: str,
20
+ graph: Any,
21
+ context: str = "",
22
+ *,
23
+ limit: int = 8,
24
+ allowed_workspaces=None,
25
+ ) -> Dict[str, Any]:
17
26
  if graph is None:
18
27
  return {
19
28
  "source_files": [],
@@ -31,7 +40,16 @@ class WorkspaceGraphTrace:
31
40
  matches: List[Dict[str, Any]] = []
32
41
  search_error = ""
33
42
  try:
34
- matches = graph.search(question, limit=limit).get("matches", [])
43
+ scope_kwargs = (
44
+ {"allowed_workspaces": allowed_workspaces}
45
+ if allowed_workspaces is not None
46
+ else {}
47
+ )
48
+ matches = graph.search(
49
+ question,
50
+ limit=limit,
51
+ **scope_kwargs,
52
+ ).get("matches", [])
35
53
  except Exception as exc:
36
54
  search_error = str(exc)
37
55
  matches = []
@@ -67,7 +85,10 @@ class WorkspaceGraphTrace:
67
85
  if not node_id:
68
86
  continue
69
87
  try:
70
- for edge in graph.neighbors(node_id).get("edges", []):
88
+ for edge in graph.neighbors(
89
+ node_id,
90
+ **scope_kwargs,
91
+ ).get("edges", []):
71
92
  key = (edge.get("from"), edge.get("to"), edge.get("type"))
72
93
  if key in edge_seen:
73
94
  continue
@@ -122,7 +143,12 @@ class WorkspaceGraphTrace:
122
143
  }
123
144
  state.setdefault("traces", []).append(record)
124
145
  self.save_state(state)
125
- self.record_timeline_event("graph", "answer_trace", {"trace_id": trace_id, "conversation_id": conversation_id})
146
+ self.record_timeline_event(
147
+ "graph",
148
+ "answer_trace",
149
+ {"trace_id": trace_id, "conversation_id": conversation_id},
150
+ workspace_id=record["workspace_id"],
151
+ )
126
152
  return record
127
153
 
128
154
  def list_traces(self, conversation_id: Optional[str] = None, limit: int = 50, workspace_id: Optional[str] = None) -> Dict[str, Any]:
@@ -6,7 +6,8 @@ from __future__ import annotations
6
6
 
7
7
  from typing import Any, Dict, List, Optional
8
8
 
9
- from .workspace_os_utils import _json_hash, _listify, _now
9
+ from .timeutil import now_iso as _now
10
+ from .workspace_os_utils import _json_hash, _listify
10
11
 
11
12
 
12
13
  class WorkspaceMemory:
@@ -52,6 +53,7 @@ class WorkspaceMemory:
52
53
  f"{kind}: {content[:80]}",
53
54
  user_email=user_email,
54
55
  source="workspace_os",
56
+ workspace_id=record["workspace_id"],
55
57
  metadata={"memory_id": memory_id, "kind": kind, "tags": tags or []},
56
58
  )
57
59
  record["graph_node_id"] = ingested.get("node_id")
@@ -21,10 +21,10 @@ from .workspace_os_utils import (
21
21
  _deep_merge,
22
22
  _json_hash,
23
23
  _listify,
24
- _now,
25
24
  _safe_slug,
26
25
  remove_skill_directory,
27
26
  )
27
+ from .timeutil import now_iso as _now
28
28
  from .workspace_permissions import WorkspacePermissionManager, _member_role # type: ignore
29
29
  from .workspace_timeline import WorkspaceTimeline
30
30
  from .workspace_plugins import WorkspacePluginManager
@@ -49,7 +49,7 @@ __all__ = [
49
49
  "remove_skill_directory",
50
50
  ]
51
51
 
52
- WORKSPACE_OS_VERSION = "9.0.0"
52
+ WORKSPACE_OS_VERSION = "9.1.0"
53
53
 
54
54
  # Workspace types separate single-user Personal workspaces from shared
55
55
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -126,9 +126,6 @@ EXECUTION_EVENT_TYPES = {
126
126
  "execution_interrupted",
127
127
  }
128
128
 
129
- RUN_ACTIVE_STATUSES = {"queued", "running", "in_progress", "retrying", "cancelling"}
130
- RUN_TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
131
-
132
129
  DEFAULT_AGENTS = [
133
130
  {
134
131
  "id": "agent:planner",
@@ -830,8 +827,22 @@ class WorkspaceOSStore:
830
827
  # Graph answer traces
831
828
  # ------------------------------------------------------------------
832
829
 
833
- def build_graph_trace(self, question: str, graph: Any, context: str = "", *, limit: int = 8) -> Dict[str, Any]:
834
- return self.graph_trace.build_graph_trace(question, graph, context, limit=limit)
830
+ def build_graph_trace(
831
+ self,
832
+ question: str,
833
+ graph: Any,
834
+ context: str = "",
835
+ *,
836
+ limit: int = 8,
837
+ allowed_workspaces=None,
838
+ ) -> Dict[str, Any]:
839
+ return self.graph_trace.build_graph_trace(
840
+ question,
841
+ graph,
842
+ context,
843
+ limit=limit,
844
+ allowed_workspaces=allowed_workspaces,
845
+ )
835
846
 
836
847
  def record_trace(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
837
848
  return self.graph_trace.record_trace(*args, **kwargs)
@@ -8,7 +8,6 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import shutil
11
- from datetime import datetime
12
11
  from pathlib import Path
13
12
  from typing import Any, Dict, List, Optional
14
13
 
@@ -16,10 +15,6 @@ from .io_utils import atomic_write_json as _atomic_write_json # noqa: F401 - le
16
15
  from .io_utils import parse_iso as _parse_iso # noqa: F401 - legacy helper re-export
17
16
 
18
17
 
19
- def _now() -> str:
20
- return datetime.now().isoformat(timespec="seconds")
21
-
22
-
23
18
  def _safe_slug(raw: str) -> str:
24
19
  value = "".join(ch if ch.isalnum() or ch in "-_." else "-" for ch in str(raw or "").strip())
25
20
  value = "-".join(part for part in value.split("-") if part)
@@ -7,7 +7,8 @@ from __future__ import annotations
7
7
 
8
8
  from typing import Any, Dict, Optional
9
9
 
10
- from .workspace_os_utils import _listify, _now
10
+ from .timeutil import now_iso as _now
11
+ from .workspace_os_utils import _listify
11
12
 
12
13
  # Avoid circular at import time: pull constants lazily from parent module.
13
14
  def _get_role_permissions():
@@ -6,7 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  from typing import Any, Dict, Optional
8
8
 
9
- from .workspace_os_utils import _now
9
+ from .timeutil import now_iso as _now
10
10
 
11
11
 
12
12
  class WorkspacePluginManager: