ltcai 9.0.0 → 9.2.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 (189) hide show
  1. package/README.md +88 -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 +178 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +4 -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 +28 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +316 -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 +50 -13
  71. package/latticeai/core/agent_prompts.py +5 -0
  72. package/latticeai/core/agent_registry.py +1 -5
  73. package/latticeai/core/config.py +23 -3
  74. package/latticeai/core/context_builder.py +21 -3
  75. package/latticeai/core/file_generation.py +451 -0
  76. package/latticeai/core/invitations.py +1 -4
  77. package/latticeai/core/io_utils.py +9 -1
  78. package/latticeai/core/legacy_compatibility.py +24 -3
  79. package/latticeai/core/marketplace.py +1 -1
  80. package/latticeai/core/plugins.py +15 -0
  81. package/latticeai/core/policy.py +1 -1
  82. package/latticeai/core/realtime.py +38 -15
  83. package/latticeai/core/sessions.py +7 -2
  84. package/latticeai/core/timeutil.py +10 -0
  85. package/latticeai/core/tool_registry.py +90 -18
  86. package/latticeai/core/users.py +1 -5
  87. package/latticeai/core/workspace_graph_trace.py +31 -5
  88. package/latticeai/core/workspace_memory.py +3 -1
  89. package/latticeai/core/workspace_os.py +18 -7
  90. package/latticeai/core/workspace_os_utils.py +0 -5
  91. package/latticeai/core/workspace_permissions.py +2 -1
  92. package/latticeai/core/workspace_plugins.py +1 -1
  93. package/latticeai/core/workspace_runs.py +14 -5
  94. package/latticeai/core/workspace_skills.py +1 -1
  95. package/latticeai/core/workspace_snapshots.py +2 -1
  96. package/latticeai/core/workspace_timeline.py +2 -1
  97. package/latticeai/integrations/telegram_bot.py +94 -43
  98. package/latticeai/models/router.py +130 -36
  99. package/latticeai/runtime/access_runtime.py +62 -4
  100. package/latticeai/runtime/automation_runtime.py +13 -7
  101. package/latticeai/runtime/brain_runtime.py +19 -7
  102. package/latticeai/runtime/chat_wiring.py +2 -0
  103. package/latticeai/runtime/config_runtime.py +58 -26
  104. package/latticeai/runtime/context_runtime.py +16 -4
  105. package/latticeai/runtime/hooks_runtime.py +1 -1
  106. package/latticeai/runtime/model_wiring.py +33 -5
  107. package/latticeai/runtime/namespace_runtime.py +62 -72
  108. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  109. package/latticeai/runtime/review_wiring.py +1 -1
  110. package/latticeai/runtime/router_registration.py +34 -1
  111. package/latticeai/runtime/security_runtime.py +110 -13
  112. package/latticeai/runtime/stages.py +27 -0
  113. package/latticeai/server_app.py +5 -1
  114. package/latticeai/services/architecture_readiness.py +74 -5
  115. package/latticeai/services/brain_automation.py +3 -26
  116. package/latticeai/services/chat_service.py +205 -15
  117. package/latticeai/services/local_knowledge.py +423 -0
  118. package/latticeai/services/memory_service.py +55 -21
  119. package/latticeai/services/model_engines.py +48 -33
  120. package/latticeai/services/model_errors.py +17 -0
  121. package/latticeai/services/model_loading.py +28 -25
  122. package/latticeai/services/model_runtime.py +228 -162
  123. package/latticeai/services/p_reinforce.py +22 -3
  124. package/latticeai/services/platform_runtime.py +83 -23
  125. package/latticeai/services/product_readiness.py +19 -17
  126. package/latticeai/services/review_queue.py +12 -0
  127. package/latticeai/services/router_context.py +1 -0
  128. package/latticeai/services/run_executor.py +4 -9
  129. package/latticeai/services/search_service.py +203 -28
  130. package/latticeai/services/tool_dispatch.py +28 -5
  131. package/latticeai/services/triggers.py +53 -4
  132. package/latticeai/services/upload_service.py +13 -2
  133. package/latticeai/setup/__init__.py +25 -0
  134. package/latticeai/setup/auto_setup.py +857 -0
  135. package/latticeai/setup/wizard.py +1264 -0
  136. package/latticeai/tools/__init__.py +278 -0
  137. package/{tools → latticeai/tools}/commands.py +67 -7
  138. package/{tools → latticeai/tools}/computer.py +1 -1
  139. package/{tools → latticeai/tools}/documents.py +1 -1
  140. package/{tools → latticeai/tools}/filesystem.py +3 -3
  141. package/latticeai/tools/knowledge.py +178 -0
  142. package/{tools → latticeai/tools}/local_files.py +1 -1
  143. package/{tools → latticeai/tools}/network.py +0 -1
  144. package/local_knowledge_api.py +4 -342
  145. package/package.json +22 -2
  146. package/scripts/brain_quality_eval.py +3 -1
  147. package/scripts/bump_version.py +3 -0
  148. package/scripts/capture_release_evidence.mjs +180 -0
  149. package/scripts/check_current_release_docs.mjs +142 -0
  150. package/scripts/check_i18n_literals.mjs +107 -31
  151. package/scripts/check_openapi_drift.mjs +56 -0
  152. package/scripts/export_openapi.py +67 -7
  153. package/scripts/i18n_literal_allowlist.json +22 -24
  154. package/scripts/run_integration_tests.mjs +72 -10
  155. package/scripts/validate_release_artifacts.py +49 -7
  156. package/scripts/wheel_smoke.py +5 -0
  157. package/setup_wizard.py +3 -1260
  158. package/src-tauri/Cargo.lock +1 -1
  159. package/src-tauri/Cargo.toml +1 -1
  160. package/src-tauri/tauri.conf.json +1 -1
  161. package/static/app/asset-manifest.json +11 -11
  162. package/static/app/assets/Act-C3dBrWE-.js +1 -0
  163. package/static/app/assets/Brain-DBYgdcjt.js +321 -0
  164. package/static/app/assets/Capture-Cf3hqRtN.js +1 -0
  165. package/static/app/assets/Library-CFfkNn3s.js +1 -0
  166. package/static/app/assets/System-BOurbT-v.js +1 -0
  167. package/static/app/assets/index-A3M9sElj.js +17 -0
  168. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  169. package/static/app/assets/primitives-DcUUmhdC.js +1 -0
  170. package/static/app/assets/textarea-BklR6zN4.js +1 -0
  171. package/static/app/index.html +2 -2
  172. package/static/sw.js +1 -1
  173. package/tools/__init__.py +21 -269
  174. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  175. package/latticeai/runtime/app_context_runtime.py +0 -13
  176. package/latticeai/runtime/tail_wiring.py +0 -21
  177. package/scripts/com.pts.claudecode.discord.plist +0 -31
  178. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  179. package/scripts/start-pts-claudecode-discord.sh +0 -51
  180. package/static/app/assets/Act-21lIXx2E.js +0 -1
  181. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  182. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  183. package/static/app/assets/Library-bFMtyni3.js +0 -1
  184. package/static/app/assets/System-K6krGCqn.js +0 -1
  185. package/static/app/assets/index-C4R3ws30.js +0 -17
  186. package/static/app/assets/index-ChSeOB02.css +0 -2
  187. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  188. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  189. package/tools/knowledge.py +0 -95
@@ -0,0 +1,423 @@
1
+ """Local folder knowledge-source API and optional filesystem watcher."""
2
+
3
+ import logging
4
+ import threading
5
+ from pathlib import Path
6
+ from typing import Any, Callable, Dict, Optional
7
+
8
+ from fastapi import APIRouter, HTTPException, Request
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class LocalTreeRequest(BaseModel):
13
+ path: str
14
+ max_items: int = 200
15
+ approved: bool = False
16
+ approval_token: Optional[str] = None
17
+
18
+
19
+ class LocalKnowledgeAuditRequest(BaseModel):
20
+ path: str
21
+ include_ocr: bool = False
22
+ max_files: int = 50_000
23
+ approved: bool = False
24
+ approval_token: Optional[str] = None
25
+
26
+
27
+ class LocalKnowledgeIndexRequest(BaseModel):
28
+ path: str
29
+ include_ocr: bool = False
30
+ watch_enabled: bool = False
31
+ max_files: int = 5_000
32
+ consent: Dict[str, Any] = Field(default_factory=dict)
33
+ approved: bool = False
34
+ approval_token: Optional[str] = None
35
+
36
+
37
+ class LocalKnowledgeWatchRequest(BaseModel):
38
+ source_id: str
39
+
40
+
41
+ class _LocalWatchHandler:
42
+ def __init__(self, schedule_change: Callable[[], None]):
43
+ self._schedule_change = schedule_change
44
+
45
+ def on_any_event(self, event): # pragma: no cover - exercised by OS watcher
46
+ if getattr(event, "is_directory", False):
47
+ return
48
+ self._schedule_change()
49
+
50
+
51
+ class LocalKnowledgeWatcher:
52
+ """Debounced watchdog wrapper for approved local knowledge sources."""
53
+
54
+ def __init__(self, get_graph: Callable[[], Any], *, debounce_seconds: float = 5.0, hooks: Any = None):
55
+ self._get_graph = get_graph
56
+ self._debounce_seconds = debounce_seconds
57
+ self._hooks = hooks
58
+ self._lock = threading.Lock()
59
+ self._watched: Dict[str, Dict[str, Any]] = {}
60
+ self._observer_cls = None
61
+ self._event_handler_base = None
62
+ self._import_error = ""
63
+ try:
64
+ from watchdog.events import FileSystemEventHandler
65
+ from watchdog.observers import Observer
66
+
67
+ self._observer_cls = Observer
68
+ self._event_handler_base = FileSystemEventHandler
69
+ except Exception as exc: # pragma: no cover - depends on optional dependency
70
+ self._import_error = str(exc)
71
+
72
+ @property
73
+ def available(self) -> bool:
74
+ return self._observer_cls is not None and self._event_handler_base is not None
75
+
76
+ def status(self) -> Dict[str, Any]:
77
+ with self._lock:
78
+ active = {
79
+ source_id: {
80
+ "root_path": item["source"].get("root_path"),
81
+ "last_event_at": item.get("last_event_at"),
82
+ "last_indexed_at": item.get("last_indexed_at"),
83
+ "last_error": item.get("last_error"),
84
+ }
85
+ for source_id, item in self._watched.items()
86
+ }
87
+ return {
88
+ "available": self.available,
89
+ "error": "" if self.available else self._import_error or "watchdog is not installed",
90
+ "debounce_seconds": self._debounce_seconds,
91
+ "active": active,
92
+ }
93
+
94
+ def restore_enabled_sources(self) -> Dict[str, Any]:
95
+ graph = self._get_graph()
96
+ if graph is None:
97
+ return {"restored": 0, "available": self.available}
98
+ restored = 0
99
+ try:
100
+ for source in graph.local_sources().get("sources", []):
101
+ if source.get("watch_enabled"):
102
+ consent = source.get("consent") or {}
103
+ restored_source = {
104
+ **source,
105
+ # Older sources had no explicit scope. Their local
106
+ # knowledge belongs to the personal Brain, never every
107
+ # workspace.
108
+ "workspace_id": source.get("workspace_id") or consent.get("workspace_id") or "personal",
109
+ }
110
+ result = self.start_source(restored_source)
111
+ if result.get("watching"):
112
+ restored += 1
113
+ except Exception as exc:
114
+ logging.warning("local knowledge watcher restore failed: %s", exc)
115
+ return {"restored": restored, "available": self.available}
116
+
117
+ def start_source(self, source: Dict[str, Any]) -> Dict[str, Any]:
118
+ source_id = str(source.get("id") or "")
119
+ root_path = str(source.get("root_path") or "")
120
+ if not source_id or not root_path:
121
+ return {"watching": False, "error": "source_id and root_path are required"}
122
+ if not self.available:
123
+ return {"watching": False, "source_id": source_id, "error": self._import_error or "watchdog is not installed"}
124
+ root = Path(root_path).expanduser().resolve()
125
+ if not root.exists() or not root.is_dir():
126
+ return {"watching": False, "source_id": source_id, "error": "source folder is not available"}
127
+
128
+ self.stop_source(source_id)
129
+
130
+ class Handler(_LocalWatchHandler, self._event_handler_base): # type: ignore[misc, valid-type]
131
+ def __init__(handler_self):
132
+ self._event_handler_base.__init__(handler_self)
133
+ _LocalWatchHandler.__init__(handler_self, lambda: self._schedule(source_id))
134
+
135
+ observer = self._observer_cls()
136
+ try:
137
+ observer.schedule(Handler(), str(root), recursive=True)
138
+ observer.start()
139
+ except Exception as exc:
140
+ logging.warning("local knowledge watcher start failed for %s: %s", root, exc)
141
+ return {"watching": False, "source_id": source_id, "error": str(exc)}
142
+
143
+ with self._lock:
144
+ self._watched[source_id] = {
145
+ "observer": observer,
146
+ "timer": None,
147
+ "source": dict(source),
148
+ "last_event_at": None,
149
+ "last_indexed_at": None,
150
+ "last_error": None,
151
+ }
152
+ return {"watching": True, "source_id": source_id, "root_path": str(root)}
153
+
154
+ def stop_source(self, source_id: str) -> Dict[str, Any]:
155
+ with self._lock:
156
+ item = self._watched.pop(source_id, None)
157
+ if not item:
158
+ return {"stopped": False, "source_id": source_id}
159
+ timer = item.get("timer")
160
+ if timer:
161
+ timer.cancel()
162
+ observer = item.get("observer")
163
+ try:
164
+ observer.stop()
165
+ observer.join(timeout=3)
166
+ except Exception as exc:
167
+ logging.warning("local knowledge watcher stop failed for %s: %s", source_id, exc)
168
+ return {"stopped": True, "source_id": source_id}
169
+
170
+ def stop_all(self) -> None:
171
+ for source_id in list(self.status().get("active", {}).keys()):
172
+ self.stop_source(source_id)
173
+
174
+ def _schedule(self, source_id: str) -> None:
175
+ with self._lock:
176
+ item = self._watched.get(source_id)
177
+ if not item:
178
+ return
179
+ timer = item.get("timer")
180
+ if timer:
181
+ timer.cancel()
182
+ item["last_event_at"] = _now_seconds()
183
+ timer = threading.Timer(self._debounce_seconds, self._run_index, args=(source_id,))
184
+ timer.daemon = True
185
+ item["timer"] = timer
186
+ timer.start()
187
+
188
+ def _run_index(self, source_id: str) -> None:
189
+ with self._lock:
190
+ item = self._watched.get(source_id)
191
+ if not item:
192
+ return
193
+ source = dict(item["source"])
194
+ item["timer"] = None
195
+ graph = self._get_graph()
196
+ if graph is None:
197
+ return
198
+ consent = source.get("consent") or {}
199
+ root = source.get("root_path")
200
+ if self._hooks is not None:
201
+ self._hooks.fire_hook("pre_index", "folder.reindex",
202
+ payload={"source_id": source_id, "root_path": root, "trigger": "watch"})
203
+ try:
204
+ result = graph.index_local_folder(
205
+ Path(source["root_path"]),
206
+ include_ocr=bool(source.get("include_ocr")),
207
+ watch_enabled=True,
208
+ user_email=consent.get("approved_by"),
209
+ workspace_id=source.get("workspace_id") or consent.get("workspace_id") or "personal",
210
+ consent=consent,
211
+ source_id_override=source_id,
212
+ )
213
+ with self._lock:
214
+ if source_id in self._watched:
215
+ self._watched[source_id]["last_indexed_at"] = _now_seconds()
216
+ self._watched[source_id]["last_error"] = None
217
+ if self._hooks is not None:
218
+ self._hooks.fire_hook("post_index", "folder.reindex",
219
+ payload={"source_id": source_id, "root_path": root, "trigger": "watch", "status": "ok"})
220
+ counts = (result.get("counts") or {}) if isinstance(result, dict) else {}
221
+ if int(counts.get("indexed") or 0) + int(counts.get("deleted") or 0) > 0:
222
+ self._hooks.fire_hook(
223
+ "post_tool",
224
+ "tool.kg_ingest.local_folder",
225
+ payload={
226
+ "tool": "kg_ingest.local_folder",
227
+ "status": "ok",
228
+ "source": "ingestion",
229
+ "source_type": "local_folder",
230
+ "source_id": source_id,
231
+ },
232
+ user_email=consent.get("approved_by"),
233
+ workspace_id=source.get("workspace_id"),
234
+ )
235
+ except Exception as exc:
236
+ logging.warning("local knowledge watcher reindex failed for %s: %s", source_id, exc)
237
+ with self._lock:
238
+ if source_id in self._watched:
239
+ self._watched[source_id]["last_error"] = str(exc)
240
+ if self._hooks is not None:
241
+ self._hooks.fire_hook("post_index", "folder.reindex",
242
+ payload={"source_id": source_id, "root_path": root, "trigger": "watch", "status": "error", "error": str(exc)})
243
+
244
+
245
+ def _now_seconds() -> float:
246
+ import time
247
+
248
+ return time.time()
249
+
250
+
251
+ def create_local_knowledge_router(
252
+ *,
253
+ get_graph: Callable[[], Any],
254
+ require_graph: Callable[[], None],
255
+ require_user: Callable[[Request], str],
256
+ require_local_user: Callable[[Request], str],
257
+ local_permission_response: Callable[..., dict],
258
+ require_local_approval: Callable[..., None],
259
+ watcher: Optional[LocalKnowledgeWatcher] = None,
260
+ hooks: Any = None,
261
+ workspace_service: Any = None,
262
+ ) -> APIRouter:
263
+ router = APIRouter()
264
+
265
+ def graph():
266
+ require_graph()
267
+ return get_graph()
268
+
269
+ def write_workspace(request: Request, user: str) -> Optional[str]:
270
+ requested = request.headers.get("X-Workspace-Id")
271
+ requested = requested.strip() if requested and requested.strip() else None
272
+ if workspace_service is None:
273
+ return requested
274
+ try:
275
+ return workspace_service.resolve_write_scope(requested, user or None)
276
+ except PermissionError as exc:
277
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
278
+
279
+ @router.get("/knowledge-graph/local/roots")
280
+ async def knowledge_graph_local_roots(request: Request):
281
+ require_user(request)
282
+ return graph().discover_local_roots()
283
+
284
+ @router.get("/knowledge-graph/local/sources")
285
+ async def knowledge_graph_local_sources(request: Request):
286
+ require_user(request)
287
+ payload = graph().local_sources()
288
+ watch_status = watcher.status() if watcher else {"available": False, "active": {}}
289
+ active = watch_status.get("active", {})
290
+ for source in payload.get("sources", []):
291
+ source["watch_active"] = source.get("id") in active
292
+ source["watch_status"] = active.get(source.get("id"))
293
+ payload["watch"] = watch_status
294
+ return payload
295
+
296
+ @router.get("/knowledge-graph/local/watch/status")
297
+ async def knowledge_graph_local_watch_status(request: Request):
298
+ require_user(request)
299
+ graph()
300
+ return watcher.status() if watcher else {"available": False, "active": {}, "error": "watcher unavailable"}
301
+
302
+ @router.post("/knowledge-graph/local/watch/stop")
303
+ async def knowledge_graph_local_watch_stop(req: LocalKnowledgeWatchRequest, request: Request):
304
+ require_user(request)
305
+ kg = graph()
306
+ try:
307
+ kg.set_local_source_watch(req.source_id, False)
308
+ except ValueError as exc:
309
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
310
+ result = watcher.stop_source(req.source_id) if watcher else {"stopped": False, "source_id": req.source_id}
311
+ return {"status": "ok", "watch": result}
312
+
313
+ @router.post("/knowledge-graph/local/tree")
314
+ async def knowledge_graph_local_tree(req: LocalTreeRequest, request: Request):
315
+ current_user = require_local_user(request)
316
+ kg = graph()
317
+ if not req.approved:
318
+ return local_permission_response(req.path, "list", current_user)
319
+ require_local_approval(token=req.approval_token, path=req.path, action="list", user_email=current_user)
320
+ try:
321
+ return kg.preview_local_tree(Path(req.path), max_items=req.max_items)
322
+ except ValueError as exc:
323
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
324
+
325
+ @router.post("/knowledge-graph/local/audit")
326
+ async def knowledge_graph_local_audit(req: LocalKnowledgeAuditRequest, request: Request):
327
+ current_user = require_local_user(request)
328
+ kg = graph()
329
+ if not req.approved:
330
+ return local_permission_response(req.path, "list", current_user)
331
+ require_local_approval(token=req.approval_token, path=req.path, action="list", user_email=current_user)
332
+ try:
333
+ return kg.audit_local_folder(Path(req.path), include_ocr=req.include_ocr, max_files=req.max_files)
334
+ except ValueError as exc:
335
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
336
+
337
+ @router.post("/knowledge-graph/local/index")
338
+ async def knowledge_graph_local_index(req: LocalKnowledgeIndexRequest, request: Request):
339
+ current_user = require_local_user(request)
340
+ workspace_id = write_workspace(request, current_user)
341
+ kg = graph()
342
+ if not req.approved:
343
+ return local_permission_response(req.path, "read", current_user)
344
+ require_local_approval(token=req.approval_token, path=req.path, action="read", user_email=current_user)
345
+ consent = {
346
+ **(req.consent or {}),
347
+ "approved_by": current_user,
348
+ "workspace_id": workspace_id or "personal",
349
+ }
350
+ source_id_override = None
351
+ try:
352
+ target_root = Path(req.path).expanduser().resolve()
353
+ target_scope = workspace_id or "personal"
354
+ for source in kg.local_sources().get("sources", []):
355
+ source_consent = source.get("consent") or {}
356
+ source_scope = source_consent.get("workspace_id") or "personal"
357
+ source_root_value = str(source.get("root_path") or "").strip()
358
+ if not source_root_value:
359
+ continue
360
+ source_root = Path(source_root_value).expanduser().resolve()
361
+ if source_scope == target_scope and source_root == target_root:
362
+ source_id_override = source.get("id")
363
+ break
364
+ except Exception:
365
+ # Source reuse is a compatibility optimization; indexing still has
366
+ # a deterministic workspace-scoped ID when discovery is unavailable.
367
+ source_id_override = None
368
+ if hooks is not None:
369
+ hooks.fire_hook("pre_index", "folder.index",
370
+ payload={"root_path": req.path, "trigger": "connect", "watch": req.watch_enabled},
371
+ user_email=current_user, workspace_id=workspace_id)
372
+ try:
373
+ result = kg.index_local_folder(
374
+ Path(req.path),
375
+ include_ocr=req.include_ocr,
376
+ watch_enabled=req.watch_enabled,
377
+ user_email=current_user,
378
+ workspace_id=workspace_id or "personal",
379
+ consent=consent,
380
+ max_files=req.max_files,
381
+ source_id_override=source_id_override,
382
+ )
383
+ except ValueError as exc:
384
+ if hooks is not None:
385
+ hooks.fire_hook("post_index", "folder.index",
386
+ payload={"root_path": req.path, "trigger": "connect", "status": "error", "error": str(exc)},
387
+ user_email=current_user, workspace_id=workspace_id)
388
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
389
+ if hooks is not None:
390
+ _idx = (result.get("index") or {}) if isinstance(result, dict) else {}
391
+ hooks.fire_hook("post_index", "folder.index",
392
+ payload={"root_path": req.path, "trigger": "connect", "status": "ok",
393
+ "indexed": _idx.get("indexed") or (result or {}).get("indexed")},
394
+ user_email=current_user, workspace_id=workspace_id)
395
+ counts = (result.get("counts") or {}) if isinstance(result, dict) else {}
396
+ if int(counts.get("indexed") or 0) + int(counts.get("deleted") or 0) > 0:
397
+ hooks.fire_hook(
398
+ "post_tool",
399
+ "tool.kg_ingest.local_folder",
400
+ payload={
401
+ "tool": "kg_ingest.local_folder",
402
+ "status": "ok",
403
+ "source": "ingestion",
404
+ "source_type": "local_folder",
405
+ "source_id": (result.get("source") or {}).get("id") if isinstance(result, dict) else None,
406
+ },
407
+ user_email=current_user,
408
+ workspace_id=workspace_id,
409
+ )
410
+
411
+ if watcher:
412
+ if req.watch_enabled:
413
+ source_payload = {
414
+ **result.get("source", {}),
415
+ "workspace_id": workspace_id or "personal",
416
+ "consent": consent,
417
+ }
418
+ result["watch"] = watcher.start_source(source_payload)
419
+ else:
420
+ result["watch"] = watcher.stop_source(result.get("source", {}).get("id", ""))
421
+ return result
422
+
423
+ return router
@@ -22,11 +22,12 @@ underlying store, and missing stores surface as ``unavailable``.
22
22
  from __future__ import annotations
23
23
 
24
24
  import json
25
- from datetime import datetime
25
+ import logging
26
26
  from pathlib import Path
27
27
  from typing import Any, Dict, List, Optional
28
28
 
29
29
  from latticeai.core.workspace_os_utils import _file_size
30
+ from latticeai.core.timeutil import now_iso as _now
30
31
 
31
32
  # Personal workspace memory kinds (from WorkspaceOS.MEMORY_KINDS).
32
33
  WORKSPACE_KINDS = (
@@ -40,10 +41,11 @@ WORKSPACE_KINDS = (
40
41
  )
41
42
 
42
43
  TIERS = ("workspace", "project", "agent", "conversation", "graph", "vector")
44
+ LOGGER = logging.getLogger(__name__)
43
45
 
44
46
 
45
- def _now() -> str:
46
- return datetime.now().isoformat(timespec="seconds")
47
+ class MemoryServiceError(RuntimeError):
48
+ """Raised when a configured memory backend cannot be read reliably."""
47
49
 
48
50
 
49
51
  class MemoryService:
@@ -70,20 +72,23 @@ class MemoryService:
70
72
  def _workspace_memories(self, *, user_email: Optional[str], workspace_id: Optional[str]) -> List[Dict[str, Any]]:
71
73
  try:
72
74
  return list(self._store.list_memories(user_email=user_email, workspace_id=workspace_id).get("memories", []))
73
- except Exception:
74
- return []
75
+ except Exception as exc:
76
+ LOGGER.exception("workspace memory read failed")
77
+ raise MemoryServiceError("workspace memory backend unavailable") from exc
75
78
 
76
79
  def _all_memories(self) -> List[Dict[str, Any]]:
77
80
  try:
78
81
  return list(self._store.list_memories().get("memories", []))
79
- except Exception:
80
- return []
82
+ except Exception as exc:
83
+ LOGGER.exception("global memory read failed")
84
+ raise MemoryServiceError("memory backend unavailable") from exc
81
85
 
82
86
  def _snapshots(self, *, workspace_id: Optional[str]) -> List[Dict[str, Any]]:
83
87
  try:
84
88
  return list(self._store.list_memory_snapshots(workspace_id=workspace_id, limit=200).get("snapshots", []))
85
- except Exception:
86
- return []
89
+ except Exception as exc:
90
+ LOGGER.exception("memory snapshot read failed")
91
+ raise MemoryServiceError("memory snapshot backend unavailable") from exc
87
92
 
88
93
  def _conversations(self) -> List[Dict[str, Any]]:
89
94
  if self._conversation_store is not None:
@@ -92,15 +97,17 @@ class MemoryService:
92
97
  for item in self._conversation_store.history():
93
98
  grouped.setdefault(item.get("conversation_id") or "legacy-previous-history", []).append(item)
94
99
  return [{"id": conv_id, "messages": msgs} for conv_id, msgs in grouped.items()]
95
- except Exception:
96
- return []
100
+ except Exception as exc:
101
+ LOGGER.exception("conversation store read failed")
102
+ raise MemoryServiceError("conversation backend unavailable") from exc
97
103
  if not self._history_file.exists():
98
104
  return []
99
105
  try:
100
106
  with open(self._history_file, "r", encoding="utf-8") as fh:
101
107
  data = json.load(fh)
102
- except Exception:
103
- return []
108
+ except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
109
+ LOGGER.exception("legacy conversation history read failed")
110
+ raise MemoryServiceError("conversation history is unreadable") from exc
104
111
  if isinstance(data, dict):
105
112
  convs = data.get("conversations")
106
113
  if isinstance(convs, list):
@@ -136,6 +143,7 @@ class MemoryService:
136
143
  try:
137
144
  return self._kg.stats()
138
145
  except Exception:
146
+ LOGGER.exception("knowledge graph stats read failed")
139
147
  return None
140
148
 
141
149
  def _kg_index(self) -> Optional[Dict[str, Any]]:
@@ -144,6 +152,7 @@ class MemoryService:
144
152
  try:
145
153
  return self._kg.index_status()
146
154
  except Exception:
155
+ LOGGER.exception("knowledge graph index status read failed")
147
156
  return None
148
157
 
149
158
  # ── Memory Manager: sources / usage / health ──────────────────────────
@@ -847,9 +856,12 @@ class MemoryService:
847
856
 
848
857
  results: List[Dict[str, Any]] = []
849
858
 
859
+ errors: List[Dict[str, str]] = []
850
860
  try:
851
861
  mem = self._store.search_memories(q, user_email=user_email, limit=limit, workspace_id=workspace_id).get("memories", [])
852
- except Exception:
862
+ except Exception as exc:
863
+ LOGGER.exception("workspace memory search failed")
864
+ errors.append({"source": "workspace", "detail": str(exc)})
853
865
  mem = []
854
866
  for m in mem:
855
867
  matched = _matched_terms(m.get("content"), " ".join(m.get("tags") or []), m.get("kind"))
@@ -867,8 +879,15 @@ class MemoryService:
867
879
  if self._enable_graph and q:
868
880
  try:
869
881
  # KnowledgeGraph.search returns {"query": ..., "matches": [...]}.
870
- hits = self._kg.search(q, limit).get("matches", [])
871
- except Exception:
882
+ search_kwargs = (
883
+ {"allowed_workspaces": {workspace_id}}
884
+ if workspace_id is not None
885
+ else {}
886
+ )
887
+ hits = self._kg.search(q, limit, **search_kwargs).get("matches", [])
888
+ except Exception as exc:
889
+ LOGGER.exception("knowledge graph memory search failed")
890
+ errors.append({"source": "graph", "detail": str(exc)})
872
891
  hits = []
873
892
  for hit in hits[:limit]:
874
893
  matched = _matched_terms(hit.get("title"), hit.get("name"), hit.get("summary"), hit.get("content"))
@@ -898,6 +917,8 @@ class MemoryService:
898
917
  "results": results[: max(1, min(limit, 100))],
899
918
  "count": len(results),
900
919
  "source": "live",
920
+ "status": "degraded" if errors else "ok",
921
+ "errors": errors,
901
922
  "quality_gate": {
902
923
  "candidates": candidates,
903
924
  "passed": len(results),
@@ -949,6 +970,7 @@ class MemoryService:
949
970
  if m.get("id")
950
971
  }
951
972
  removed: List[str] = []
973
+ failed: List[Dict[str, str]] = []
952
974
  skipped: List[str] = []
953
975
  target_ids: List[str] = []
954
976
  seen: set = set()
@@ -969,11 +991,15 @@ class MemoryService:
969
991
  try:
970
992
  self._store.delete_memory(mid)
971
993
  removed.append(mid)
972
- except Exception:
973
- continue
994
+ except Exception as exc:
995
+ LOGGER.exception("memory deletion failed for %s", mid)
996
+ failed.append({"id": mid, "detail": str(exc)})
974
997
  result: Dict[str, Any] = {"removed": removed, "count": len(removed)}
975
998
  if skipped:
976
999
  result["skipped"] = skipped
1000
+ if failed:
1001
+ result["failed"] = failed
1002
+ result["status"] = "partial" if removed else "error"
977
1003
  return result
978
1004
 
979
1005
  def compact(
@@ -985,6 +1011,7 @@ class MemoryService:
985
1011
  """Dedupe workspace memories with identical (kind, content)."""
986
1012
  seen: set = set()
987
1013
  removed: List[str] = []
1014
+ failed: List[Dict[str, str]] = []
988
1015
  # Oldest first so the first occurrence (oldest) is kept.
989
1016
  memories = list(reversed(self._workspace_memories(user_email=user_email, workspace_id=workspace_id)))
990
1017
  for m in memories:
@@ -994,11 +1021,18 @@ class MemoryService:
994
1021
  try:
995
1022
  self._store.delete_memory(m["id"])
996
1023
  removed.append(m["id"])
997
- except Exception:
998
- continue
1024
+ except Exception as exc:
1025
+ LOGGER.exception("memory compaction deletion failed for %s", m["id"])
1026
+ failed.append({"id": m["id"], "detail": str(exc)})
999
1027
  else:
1000
1028
  seen.add(key)
1001
- return {"compacted": len(removed), "removed": removed, "remaining": len(seen)}
1029
+ return {
1030
+ "compacted": len(removed),
1031
+ "removed": removed,
1032
+ "remaining": len(seen),
1033
+ "failed": failed,
1034
+ "status": "partial" if failed and removed else "error" if failed else "ok",
1035
+ }
1002
1036
 
1003
1037
  def rebuild(self, target: str = "vector") -> Dict[str, Any]:
1004
1038
  if target in {"vector", "index", "vector_index"}: