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.
- package/README.md +88 -46
- package/auto_setup.py +7 -853
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +178 -2
- package/docs/COMMUNITY_AND_PLUGINS.md +4 -2
- package/docs/DEVELOPMENT.md +53 -14
- package/docs/LEGACY_COMPATIBILITY.md +4 -4
- package/docs/ONBOARDING.md +10 -2
- package/docs/OPERATIONS.md +8 -4
- package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
- package/docs/TRUST_MODEL.md +5 -2
- package/docs/WHY_LATTICE.md +15 -10
- package/docs/WORKFLOW_DESIGNER.md +22 -0
- package/docs/kg-schema.md +13 -2
- package/docs/mcp-tools.md +17 -6
- package/docs/privacy.md +19 -3
- package/docs/public-deploy.md +32 -3
- package/docs/security-model.md +46 -11
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +1 -6
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/graph/_kg_fsutil.py +1 -5
- package/lattice_brain/graph/discovery_index.py +174 -20
- package/lattice_brain/graph/documents.py +44 -9
- package/lattice_brain/graph/ingest.py +47 -20
- package/lattice_brain/graph/provenance.py +13 -6
- package/lattice_brain/graph/retrieval.py +140 -39
- package/lattice_brain/graph/retrieval_docgen.py +37 -4
- package/lattice_brain/ingestion.py +4 -7
- package/lattice_brain/portability.py +28 -14
- package/lattice_brain/runtime/agent_runtime.py +5 -9
- package/lattice_brain/runtime/hooks.py +30 -13
- package/lattice_brain/runtime/multi_agent.py +27 -8
- package/lattice_brain/runtime/statuses.py +10 -0
- package/lattice_brain/utils.py +20 -2
- package/lattice_brain/workflow.py +1 -5
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agent_registry.py +10 -8
- package/latticeai/api/agents.py +22 -3
- package/latticeai/api/auth.py +78 -16
- package/latticeai/api/browser.py +303 -34
- package/latticeai/api/chat.py +320 -850
- package/latticeai/api/chat_agent_http.py +356 -0
- package/latticeai/api/chat_contracts.py +54 -0
- package/latticeai/api/chat_documents.py +256 -0
- package/latticeai/api/chat_helpers.py +28 -1
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +316 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +62 -9
- package/latticeai/api/health.py +9 -3
- package/latticeai/api/hooks.py +17 -6
- package/latticeai/api/knowledge_graph.py +78 -14
- package/latticeai/api/local_files.py +7 -2
- package/latticeai/api/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +67 -26
- package/latticeai/api/plugins.py +21 -7
- package/latticeai/api/portability.py +5 -5
- package/latticeai/api/realtime.py +33 -5
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +123 -24
- package/latticeai/api/tools.py +96 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +110 -52
- package/latticeai/core/agent.py +50 -13
- package/latticeai/core/agent_prompts.py +5 -0
- package/latticeai/core/agent_registry.py +1 -5
- package/latticeai/core/config.py +23 -3
- package/latticeai/core/context_builder.py +21 -3
- package/latticeai/core/file_generation.py +451 -0
- package/latticeai/core/invitations.py +1 -4
- package/latticeai/core/io_utils.py +9 -1
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/plugins.py +15 -0
- package/latticeai/core/policy.py +1 -1
- package/latticeai/core/realtime.py +38 -15
- package/latticeai/core/sessions.py +7 -2
- package/latticeai/core/timeutil.py +10 -0
- package/latticeai/core/tool_registry.py +90 -18
- package/latticeai/core/users.py +1 -5
- package/latticeai/core/workspace_graph_trace.py +31 -5
- package/latticeai/core/workspace_memory.py +3 -1
- package/latticeai/core/workspace_os.py +18 -7
- package/latticeai/core/workspace_os_utils.py +0 -5
- package/latticeai/core/workspace_permissions.py +2 -1
- package/latticeai/core/workspace_plugins.py +1 -1
- package/latticeai/core/workspace_runs.py +14 -5
- package/latticeai/core/workspace_skills.py +1 -1
- package/latticeai/core/workspace_snapshots.py +2 -1
- package/latticeai/core/workspace_timeline.py +2 -1
- package/latticeai/integrations/telegram_bot.py +94 -43
- package/latticeai/models/router.py +130 -36
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/automation_runtime.py +13 -7
- package/latticeai/runtime/brain_runtime.py +19 -7
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/config_runtime.py +58 -26
- package/latticeai/runtime/context_runtime.py +16 -4
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +62 -72
- package/latticeai/runtime/platform_runtime_wiring.py +4 -3
- package/latticeai/runtime/review_wiring.py +1 -1
- package/latticeai/runtime/router_registration.py +34 -1
- package/latticeai/runtime/security_runtime.py +110 -13
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/server_app.py +5 -1
- package/latticeai/services/architecture_readiness.py +74 -5
- package/latticeai/services/brain_automation.py +3 -26
- package/latticeai/services/chat_service.py +205 -15
- package/latticeai/services/local_knowledge.py +423 -0
- package/latticeai/services/memory_service.py +55 -21
- package/latticeai/services/model_engines.py +48 -33
- package/latticeai/services/model_errors.py +17 -0
- package/latticeai/services/model_loading.py +28 -25
- package/latticeai/services/model_runtime.py +228 -162
- package/latticeai/services/p_reinforce.py +22 -3
- package/latticeai/services/platform_runtime.py +83 -23
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +12 -0
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +4 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/tool_dispatch.py +28 -5
- package/latticeai/services/triggers.py +53 -4
- package/latticeai/services/upload_service.py +13 -2
- package/latticeai/setup/__init__.py +25 -0
- package/latticeai/setup/auto_setup.py +857 -0
- package/latticeai/setup/wizard.py +1264 -0
- package/latticeai/tools/__init__.py +278 -0
- package/{tools → latticeai/tools}/commands.py +67 -7
- package/{tools → latticeai/tools}/computer.py +1 -1
- package/{tools → latticeai/tools}/documents.py +1 -1
- package/{tools → latticeai/tools}/filesystem.py +3 -3
- package/latticeai/tools/knowledge.py +178 -0
- package/{tools → latticeai/tools}/local_files.py +1 -1
- package/{tools → latticeai/tools}/network.py +0 -1
- package/local_knowledge_api.py +4 -342
- package/package.json +22 -2
- package/scripts/brain_quality_eval.py +3 -1
- package/scripts/bump_version.py +3 -0
- package/scripts/capture_release_evidence.mjs +180 -0
- package/scripts/check_current_release_docs.mjs +142 -0
- package/scripts/check_i18n_literals.mjs +107 -31
- package/scripts/check_openapi_drift.mjs +56 -0
- package/scripts/export_openapi.py +67 -7
- package/scripts/i18n_literal_allowlist.json +22 -24
- package/scripts/run_integration_tests.mjs +72 -10
- package/scripts/validate_release_artifacts.py +49 -7
- package/scripts/wheel_smoke.py +5 -0
- package/setup_wizard.py +3 -1260
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/Act-C3dBrWE-.js +1 -0
- package/static/app/assets/Brain-DBYgdcjt.js +321 -0
- package/static/app/assets/Capture-Cf3hqRtN.js +1 -0
- package/static/app/assets/Library-CFfkNn3s.js +1 -0
- package/static/app/assets/System-BOurbT-v.js +1 -0
- package/static/app/assets/index-A3M9sElj.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-DcUUmhdC.js +1 -0
- package/static/app/assets/textarea-BklR6zN4.js +1 -0
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +21 -269
- package/docs/CODE_REVIEW_2026-07-06.md +0 -764
- package/latticeai/runtime/app_context_runtime.py +0 -13
- package/latticeai/runtime/tail_wiring.py +0 -21
- package/scripts/com.pts.claudecode.discord.plist +0 -31
- package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
- package/scripts/start-pts-claudecode-discord.sh +0 -51
- package/static/app/assets/Act-21lIXx2E.js +0 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
- package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
- package/static/app/assets/Library-bFMtyni3.js +0 -1
- package/static/app/assets/System-K6krGCqn.js +0 -1
- package/static/app/assets/index-C4R3ws30.js +0 -17
- package/static/app/assets/index-ChSeOB02.css +0 -2
- package/static/app/assets/primitives-sQU3it5I.js +0 -1
- package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
103
|
-
|
|
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
|
-
|
|
871
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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"}:
|