ltcai 3.6.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
- package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
- package/docs/kg-schema.md +47 -53
- package/kg_schema.py +93 -10
- package/knowledge_graph.py +362 -33
- package/knowledge_graph_api.py +11 -127
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agents.py +7 -1
- package/latticeai/api/auth.py +27 -4
- package/latticeai/api/chat.py +112 -76
- package/latticeai/api/health.py +1 -1
- package/latticeai/api/hooks.py +1 -1
- package/latticeai/api/knowledge_graph.py +146 -0
- package/latticeai/api/local_files.py +1 -1
- package/latticeai/api/mcp.py +23 -11
- package/latticeai/api/memory.py +1 -1
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/network.py +81 -0
- package/latticeai/api/realtime.py +1 -1
- package/latticeai/api/search.py +26 -2
- package/latticeai/api/security_dashboard.py +2 -3
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +2 -4
- package/latticeai/api/tools.py +3 -0
- package/latticeai/api/workflow_designer.py +46 -0
- package/latticeai/api/workspace.py +71 -49
- package/latticeai/app_factory.py +1710 -0
- package/latticeai/brain/__init__.py +18 -0
- package/latticeai/brain/context.py +213 -0
- package/latticeai/brain/conversations.py +236 -0
- package/latticeai/brain/identity.py +175 -0
- package/latticeai/brain/memory.py +102 -0
- package/latticeai/brain/network.py +205 -0
- package/latticeai/core/agent.py +31 -7
- package/latticeai/core/audit.py +0 -7
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/context_builder.py +1 -2
- package/latticeai/core/enterprise.py +1 -1
- package/latticeai/core/graph_curator.py +2 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +791 -0
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +0 -1
- package/latticeai/core/multi_agent.py +238 -4
- package/latticeai/core/security.py +1 -1
- package/latticeai/core/sessions.py +37 -7
- package/latticeai/core/workflow_engine.py +114 -2
- package/latticeai/core/workspace_os.py +58 -10
- package/latticeai/models/__init__.py +7 -0
- package/latticeai/models/router.py +779 -0
- package/latticeai/server_app.py +29 -1536
- package/latticeai/services/agent_runtime.py +1 -0
- package/latticeai/services/app_context.py +75 -14
- package/latticeai/services/ingestion.py +47 -0
- package/latticeai/services/kg_portability.py +33 -3
- package/latticeai/services/memory_service.py +39 -11
- package/latticeai/services/model_runtime.py +2 -5
- package/latticeai/services/platform_runtime.py +100 -23
- package/latticeai/services/search_service.py +17 -8
- package/latticeai/services/tool_dispatch.py +12 -2
- package/latticeai/services/triggers.py +241 -0
- package/latticeai/services/upload_service.py +37 -12
- package/latticeai/services/workspace_service.py +31 -0
- package/llm_router.py +29 -772
- package/ltcai_cli.py +1 -2
- package/mcp_registry.py +25 -788
- package/p_reinforce.py +124 -14
- package/package.json +9 -7
- package/scripts/bump_version.py +99 -0
- package/scripts/generate_diagrams.py +0 -1
- package/scripts/lint_v3.mjs +82 -18
- package/scripts/validate_release_artifacts.py +0 -1
- package/scripts/wheel_smoke.py +142 -0
- package/server.py +11 -7
- package/setup_wizard.py +1142 -0
- package/static/account.html +2 -4
- package/static/admin.html +3 -5
- package/static/chat.html +3 -6
- package/static/graph.html +2 -4
- package/static/sw.js +81 -52
- package/static/v3/asset-manifest.json +20 -19
- package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
- package/static/v3/css/lattice.base.css +1 -1
- package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
- package/static/v3/css/lattice.components.css +1 -1
- package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
- package/static/v3/css/lattice.shell.css +1 -1
- package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
- package/static/v3/css/lattice.tokens.css +3 -0
- package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
- package/static/v3/css/lattice.views.css +2 -2
- package/static/v3/index.html +3 -4
- package/static/v3/js/{app.c541f955.js → app.356e6452.js} +1 -1
- package/static/v3/js/core/{api.33d6320e.js → api.7a308b89.js} +1 -1
- package/static/v3/js/core/{routes.2ce3815a.js → routes.7222343d.js} +22 -22
- package/static/v3/js/core/routes.js +22 -22
- package/static/v3/js/core/{shell.8c163e0e.js → shell.a1657f20.js} +4 -4
- package/static/v3/js/core/shell.js +1 -1
- package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
- package/static/v3/js/core/store.js +1 -1
- package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
- package/static/v3/js/views/graph-canvas.js +509 -0
- package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
- package/static/v3/js/views/hybrid-search.js +1 -2
- package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.5e40cbeb.js} +33 -37
- package/static/v3/js/views/knowledge-graph.js +33 -37
- package/static/vendor/chart.umd.min.js +20 -0
- package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
- package/static/vendor/fonts/inter.css +44 -0
- package/static/vendor/icons/tabler-icons.min.css +4 -0
- package/static/vendor/icons/tabler-icons.woff2 +0 -0
- package/static/vendor/marked.min.js +69 -0
- package/static/workspace.html +2 -2
- package/telegram_bot.py +1 -2
- package/tools/commands.py +4 -2
- package/tools/computer.py +1 -1
- package/tools/documents.py +1 -3
- package/tools/filesystem.py +0 -4
- package/tools/knowledge.py +1 -3
- package/tools/network.py +1 -3
- package/codex_telegram_bot.py +0 -195
- package/docs/assets/v3.4.0/agent-run.png +0 -0
- package/docs/assets/v3.4.0/agents.png +0 -0
- package/docs/assets/v3.4.0/before/chat-before.png +0 -0
- package/docs/assets/v3.4.0/before/files-before.png +0 -0
- package/docs/assets/v3.4.0/chat.png +0 -0
- package/docs/assets/v3.4.0/connect-folder.png +0 -0
- package/docs/assets/v3.4.0/files.png +0 -0
- package/docs/assets/v3.4.0/home.png +0 -0
- package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
- package/docs/assets/v3.4.0/local-agent.png +0 -0
- package/docs/assets/v3.4.0/memory.png +0 -0
- package/docs/assets/v3.4.0/settings.png +0 -0
- package/docs/assets/v3.4.0/vision-input.png +0 -0
- package/docs/assets/v3.4.0/workflows.png +0 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/docs/images/admin-dashboard.png +0 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/enterprise.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/knowledge-graph.png +0 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/lattice-ai-hero.png +0 -0
- package/docs/images/logo.svg +0 -33
- package/docs/images/mobile-responsive.png +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/pipeline.png +0 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images/workspace-dark.png +0 -0
- package/docs/images/workspace-light.png +0 -0
- package/docs/images/workspace.png +0 -0
- package/requirements.txt +0 -16
|
@@ -1,27 +1,88 @@
|
|
|
1
1
|
"""Application dependency context for router assembly.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
``latticeai.app_factory.create_app`` builds one ``AppContext`` per app and
|
|
4
|
+
hands it to router factories, replacing the historical 25-30-kwarg closure
|
|
5
|
+
wiring. Every field defaults to ``None``-ish so tests can construct a context
|
|
6
|
+
carrying only the dependencies a router actually touches.
|
|
7
|
+
|
|
8
|
+
Fields are grouped by the consumer that motivated them; routers must treat the
|
|
9
|
+
context as read-only.
|
|
6
10
|
"""
|
|
7
11
|
|
|
8
12
|
from __future__ import annotations
|
|
9
13
|
|
|
10
14
|
from dataclasses import dataclass
|
|
11
15
|
from pathlib import Path
|
|
12
|
-
from typing import Any, Callable
|
|
16
|
+
from typing import Any, Callable, Optional
|
|
13
17
|
|
|
14
18
|
|
|
15
19
|
@dataclass(frozen=True)
|
|
16
20
|
class AppContext:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
# ── core configuration / paths ────────────────────────────────────────
|
|
22
|
+
config: Any = None
|
|
23
|
+
data_dir: Optional[Path] = None
|
|
24
|
+
static_dir: Optional[Path] = None
|
|
25
|
+
base_dir: Optional[Path] = None
|
|
26
|
+
skills_dir: Optional[Path] = None
|
|
27
|
+
|
|
28
|
+
# ── singletons ────────────────────────────────────────────────────────
|
|
29
|
+
model_router: Any = None
|
|
30
|
+
workspace_store: Any = None
|
|
31
|
+
workspace_service: Any = None
|
|
32
|
+
knowledge_graph: Any = None
|
|
33
|
+
local_kg_watcher: Any = None
|
|
34
|
+
chat_service: Any = None
|
|
35
|
+
context_assembler: Any = None
|
|
36
|
+
brain_memory: Any = None
|
|
37
|
+
gardener: Any = None
|
|
38
|
+
hooks: Any = None
|
|
39
|
+
realtime_bus: Any = None
|
|
40
|
+
capability_registry: Any = None
|
|
41
|
+
|
|
42
|
+
# ── auth / session callables ──────────────────────────────────────────
|
|
43
|
+
require_user: Optional[Callable[..., str]] = None
|
|
44
|
+
require_admin: Optional[Callable[..., tuple]] = None
|
|
45
|
+
get_current_user: Optional[Callable[..., Optional[str]]] = None
|
|
46
|
+
load_users: Optional[Callable[[], dict]] = None
|
|
47
|
+
get_user_role: Optional[Callable[..., str]] = None
|
|
48
|
+
enforce_rate_limit: Optional[Callable[..., None]] = None
|
|
49
|
+
|
|
50
|
+
# ── audit / history callables ─────────────────────────────────────────
|
|
51
|
+
append_audit_event: Optional[Callable[..., None]] = None
|
|
52
|
+
get_audit_log: Optional[Callable[[], list]] = None
|
|
53
|
+
get_history: Optional[Callable[[], list]] = None
|
|
54
|
+
get_history_user: Optional[Callable[..., dict]] = None
|
|
55
|
+
save_to_history: Optional[Callable[..., None]] = None
|
|
56
|
+
clear_history: Optional[Callable[..., dict]] = None
|
|
57
|
+
clear_conversation: Optional[Callable[..., dict]] = None
|
|
58
|
+
group_history_conversations: Optional[Callable[..., list]] = None
|
|
59
|
+
get_conversation_messages: Optional[Callable[..., list]] = None
|
|
60
|
+
conversation_title: Optional[Callable[..., str]] = None
|
|
61
|
+
|
|
62
|
+
# ── knowledge graph access ────────────────────────────────────────────
|
|
63
|
+
enable_graph: bool = False
|
|
64
|
+
require_graph: Optional[Callable[[], None]] = None
|
|
65
|
+
workspace_graph: Optional[Callable[[], Any]] = None
|
|
66
|
+
graph_stats: Optional[Callable[[], dict]] = None
|
|
67
|
+
|
|
68
|
+
# ── workspace payload providers / skills ──────────────────────────────
|
|
69
|
+
workspace_models: Optional[Callable[[], dict]] = None
|
|
70
|
+
workspace_settings: Optional[Callable[[], dict]] = None
|
|
71
|
+
scan_environment: Optional[Callable[[], Any]] = None
|
|
72
|
+
local_sysinfo: Optional[Callable[..., Any]] = None
|
|
73
|
+
get_recommendations: Optional[Callable[..., Any]] = None
|
|
74
|
+
fetch_skills_marketplace: Optional[Callable[..., Any]] = None
|
|
75
|
+
install_skill: Optional[Callable[..., Any]] = None
|
|
76
|
+
remove_skill_directory: Optional[Callable[..., dict]] = None
|
|
77
|
+
redact_secret_text: Optional[Callable[[str], str]] = None
|
|
78
|
+
ui_file_response: Optional[Callable[..., Any]] = None
|
|
79
|
+
|
|
80
|
+
# ── models ────────────────────────────────────────────────────────────
|
|
81
|
+
public_model: str = ""
|
|
82
|
+
local_model: str = ""
|
|
27
83
|
|
|
84
|
+
# ── integrations ──────────────────────────────────────────────────────
|
|
85
|
+
# Fired as on_chat_message(role, text, source) after a chat exchange is
|
|
86
|
+
# persisted; ``None`` means no external chat mirror is registered. The
|
|
87
|
+
# telegram bridge subscribes here only when ENABLE_TELEGRAM is truthy.
|
|
88
|
+
on_chat_message: Optional[Callable[..., None]] = None
|
|
@@ -33,6 +33,15 @@ FILE_SOURCE_TYPES = frozenset({"file", "local_file", "upload", "pdf"})
|
|
|
33
33
|
TEXT_SOURCE_TYPES = frozenset(
|
|
34
34
|
{"web_url", "browser_tab", "text", "markdown", "note", "code", "clipboard"}
|
|
35
35
|
)
|
|
36
|
+
# Conversational exchanges (read via ingest_message — role/content semantics,
|
|
37
|
+
# conversation chaining). v4: chat and MCP messages stop bypassing the
|
|
38
|
+
# pipeline, so they carry provenance and fire the hook lifecycle like every
|
|
39
|
+
# other source.
|
|
40
|
+
CHAT_SOURCE_TYPES = frozenset({"chat_message", "mcp_message"})
|
|
41
|
+
# Typed memory records (read via ingest_event → Decision/Experience/Event
|
|
42
|
+
# nodes). The Memory System writes through the same door as everything else.
|
|
43
|
+
MEMORY_SOURCE_TYPES = frozenset({"decision", "experience", "workspace_event"})
|
|
44
|
+
_MEMORY_NODE_TYPES = {"decision": "Decision", "experience": "Experience", "workspace_event": "Event"}
|
|
36
45
|
|
|
37
46
|
DEFAULT_MAX_TEXT_BYTES = 5 * 1024 * 1024 # 5 MB of extracted text per item
|
|
38
47
|
|
|
@@ -143,6 +152,10 @@ class IngestionPipeline:
|
|
|
143
152
|
}
|
|
144
153
|
|
|
145
154
|
def _run() -> Dict[str, Any]:
|
|
155
|
+
if source_type in CHAT_SOURCE_TYPES:
|
|
156
|
+
return self._ingest_chat(item, source_type=source_type, owner=owner)
|
|
157
|
+
if source_type in MEMORY_SOURCE_TYPES:
|
|
158
|
+
return self._ingest_memory_record(item, source_type=source_type, owner=owner)
|
|
146
159
|
if source_type in FILE_SOURCE_TYPES or (item.path and not item.text):
|
|
147
160
|
return self._ingest_file(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
148
161
|
return self._ingest_text(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
@@ -243,6 +256,40 @@ class IngestionPipeline:
|
|
|
243
256
|
metadata={"mime_type": item.mime_type, **(item.metadata or {})},
|
|
244
257
|
)
|
|
245
258
|
|
|
259
|
+
def _ingest_chat(self, item, *, source_type, owner) -> Dict[str, Any]:
|
|
260
|
+
text = item.text or ""
|
|
261
|
+
meta = item.metadata or {}
|
|
262
|
+
role = str(meta.get("role") or "user")
|
|
263
|
+
result = self._kg.ingest_message(
|
|
264
|
+
role,
|
|
265
|
+
text,
|
|
266
|
+
user_email=owner,
|
|
267
|
+
user_nickname=meta.get("user_nickname"),
|
|
268
|
+
source=meta.get("source") or source_type,
|
|
269
|
+
conversation_id=item.conversation_id,
|
|
270
|
+
raw=meta.get("raw"),
|
|
271
|
+
)
|
|
272
|
+
# ingest_message reports message/response node ids; normalize the keys
|
|
273
|
+
# the provenance step expects.
|
|
274
|
+
result.setdefault("node_id", result.get("node_id") or result.get("message_node_id") or result.get("id"))
|
|
275
|
+
result.setdefault("title", item.title or text[:80])
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
def _ingest_memory_record(self, item, *, source_type, owner) -> Dict[str, Any]:
|
|
279
|
+
node_type = _MEMORY_NODE_TYPES[source_type]
|
|
280
|
+
meta = item.metadata or {}
|
|
281
|
+
result = self._kg.ingest_event(
|
|
282
|
+
node_type,
|
|
283
|
+
item.title or (item.text or node_type)[:120],
|
|
284
|
+
user_email=owner,
|
|
285
|
+
source=meta.get("source") or source_type,
|
|
286
|
+
conversation_id=item.conversation_id,
|
|
287
|
+
metadata={**meta, "detail": (item.text or "")[:2000]},
|
|
288
|
+
)
|
|
289
|
+
result.setdefault("node_id", result.get("node_id") or result.get("id"))
|
|
290
|
+
result.setdefault("title", item.title)
|
|
291
|
+
return result
|
|
292
|
+
|
|
246
293
|
def _ingest_file(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
|
|
247
294
|
if not item.path:
|
|
248
295
|
raise ValueError("File ingestion requires a path.")
|
|
@@ -44,11 +44,16 @@ def _sha256_file(path: Path) -> str:
|
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
class KGPortabilityService:
|
|
47
|
-
def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True) -> None:
|
|
47
|
+
def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True, device_identity: Any = None) -> None:
|
|
48
48
|
self._kg = knowledge_graph
|
|
49
49
|
self._data_dir = Path(data_dir)
|
|
50
50
|
self._enable = bool(enable_graph)
|
|
51
51
|
self._exports_dir = self._data_dir / "workspace_exports"
|
|
52
|
+
# v4 sovereignty: when a DeviceIdentity is wired, exports are signed
|
|
53
|
+
# and imports record origin provenance. Pre-v4 unsigned bundles stay
|
|
54
|
+
# importable locally (origin='unsigned-legacy') — signatures are
|
|
55
|
+
# mandatory only on the Brain Network peer path.
|
|
56
|
+
self._identity = device_identity
|
|
52
57
|
|
|
53
58
|
def available(self) -> bool:
|
|
54
59
|
return self._enable and self._kg is not None
|
|
@@ -60,7 +65,7 @@ class KGPortabilityService:
|
|
|
60
65
|
# ── logical export / import ──────────────────────────────────────────────
|
|
61
66
|
def export(self, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
62
67
|
self._require()
|
|
63
|
-
data = self._kg.export_graph_data()
|
|
68
|
+
data = self._kg.export_graph_data(workspace_id=workspace_id)
|
|
64
69
|
header = {
|
|
65
70
|
"format": FORMAT,
|
|
66
71
|
"format_version": FORMAT_VERSION,
|
|
@@ -69,7 +74,10 @@ class KGPortabilityService:
|
|
|
69
74
|
"workspace_id": workspace_id,
|
|
70
75
|
"counts": data.get("counts"),
|
|
71
76
|
}
|
|
72
|
-
|
|
77
|
+
artifact = {"header": header, **data}
|
|
78
|
+
if self._identity is not None:
|
|
79
|
+
artifact["signature"] = self._identity.sign_manifest(header)
|
|
80
|
+
return artifact
|
|
73
81
|
|
|
74
82
|
def export_to_file(self, path=None, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
75
83
|
artifact = self.export(workspace_id=workspace_id)
|
|
@@ -84,8 +92,30 @@ class KGPortabilityService:
|
|
|
84
92
|
raise ValueError("Invalid Knowledge Graph export artifact.")
|
|
85
93
|
if mode not in ("merge", "replace"):
|
|
86
94
|
raise ValueError("mode must be 'merge' or 'replace'.")
|
|
95
|
+
origin = "unsigned-legacy"
|
|
96
|
+
signature = artifact.get("signature")
|
|
97
|
+
if signature:
|
|
98
|
+
from latticeai.brain.identity import verify_manifest
|
|
99
|
+
|
|
100
|
+
if not verify_manifest(artifact.get("header") or {}, signature):
|
|
101
|
+
raise ValueError("Bundle signature verification failed — refusing to import.")
|
|
102
|
+
origin = f"device:{signature.get('fingerprint') or 'unknown'}"
|
|
87
103
|
result = self._kg.import_graph_data(artifact, mode=mode, dry_run=dry_run)
|
|
88
104
|
result["header"] = artifact.get("header")
|
|
105
|
+
result["origin"] = origin
|
|
106
|
+
result["signed"] = bool(signature)
|
|
107
|
+
if not dry_run:
|
|
108
|
+
try:
|
|
109
|
+
self._kg.record_provenance(
|
|
110
|
+
node_id="import:" + str((artifact.get("header") or {}).get("exported_at") or _now_iso()),
|
|
111
|
+
source_type="bundle_import",
|
|
112
|
+
pipeline="kg-portability",
|
|
113
|
+
owner=None,
|
|
114
|
+
metadata={"origin": origin, "mode": mode,
|
|
115
|
+
"counts": (artifact.get("header") or {}).get("counts")},
|
|
116
|
+
)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
89
119
|
return result
|
|
90
120
|
|
|
91
121
|
def import_from_file(self, path, *, mode: str = "merge", dry_run: bool = False) -> Dict[str, Any]:
|
|
@@ -24,7 +24,7 @@ from __future__ import annotations
|
|
|
24
24
|
import json
|
|
25
25
|
from datetime import datetime
|
|
26
26
|
from pathlib import Path
|
|
27
|
-
from typing import Any,
|
|
27
|
+
from typing import Any, Dict, List, Optional
|
|
28
28
|
|
|
29
29
|
# Personal workspace memory kinds (from WorkspaceOS.MEMORY_KINDS).
|
|
30
30
|
WORKSPACE_KINDS = (
|
|
@@ -60,12 +60,16 @@ class MemoryService:
|
|
|
60
60
|
knowledge_graph: Any = None,
|
|
61
61
|
enable_graph: bool = True,
|
|
62
62
|
history_file: Optional[Path] = None,
|
|
63
|
+
conversation_store: Any = None,
|
|
63
64
|
):
|
|
64
65
|
self._store = store
|
|
65
66
|
self._kg = knowledge_graph
|
|
66
67
|
self._enable_graph = bool(enable_graph and knowledge_graph is not None)
|
|
67
68
|
self._data_dir = Path(data_dir)
|
|
68
69
|
self._history_file = Path(history_file) if history_file else (self._data_dir / "chat_history.json")
|
|
70
|
+
# v4: the durable SQLite conversation store supersedes the JSON file
|
|
71
|
+
# as the conversation tier's backing store when provided.
|
|
72
|
+
self._conversation_store = conversation_store
|
|
69
73
|
|
|
70
74
|
# ── helpers over the underlying stores ────────────────────────────────
|
|
71
75
|
def _workspace_memories(self, *, user_email: Optional[str], workspace_id: Optional[str]) -> List[Dict[str, Any]]:
|
|
@@ -87,6 +91,14 @@ class MemoryService:
|
|
|
87
91
|
return []
|
|
88
92
|
|
|
89
93
|
def _conversations(self) -> List[Dict[str, Any]]:
|
|
94
|
+
if self._conversation_store is not None:
|
|
95
|
+
try:
|
|
96
|
+
grouped: Dict[str, List[Dict[str, Any]]] = {}
|
|
97
|
+
for item in self._conversation_store.history():
|
|
98
|
+
grouped.setdefault(item.get("conversation_id") or "legacy-previous-history", []).append(item)
|
|
99
|
+
return [{"id": conv_id, "messages": msgs} for conv_id, msgs in grouped.items()]
|
|
100
|
+
except Exception:
|
|
101
|
+
return []
|
|
90
102
|
if not self._history_file.exists():
|
|
91
103
|
return []
|
|
92
104
|
try:
|
|
@@ -130,7 +142,10 @@ class MemoryService:
|
|
|
130
142
|
|
|
131
143
|
ws_bytes = _file_size(self._data_dir / "workspace_os.json")
|
|
132
144
|
kg_bytes = _file_size(self._data_dir / "knowledge_graph.sqlite")
|
|
133
|
-
|
|
145
|
+
if self._conversation_store is not None:
|
|
146
|
+
conv_bytes = int(getattr(self._conversation_store, "size_bytes", lambda: 0)())
|
|
147
|
+
else:
|
|
148
|
+
conv_bytes = _file_size(self._history_file)
|
|
134
149
|
|
|
135
150
|
node_total = sum((kg_stats or {}).get("nodes", {}).values()) if kg_stats else None
|
|
136
151
|
edge_total = sum((kg_stats or {}).get("edges", {}).values()) if kg_stats else None
|
|
@@ -159,7 +174,7 @@ class MemoryService:
|
|
|
159
174
|
{
|
|
160
175
|
"id": "conversation", "type": "conversation", "label": "Conversation Memory",
|
|
161
176
|
"count": len(convs), "size_bytes": conv_bytes,
|
|
162
|
-
"health": "ok" if self._history_file.exists() else "empty",
|
|
177
|
+
"health": "ok" if (self._conversation_store is not None or self._history_file.exists()) else "empty",
|
|
163
178
|
"detail": "Historical interaction memory from chat.",
|
|
164
179
|
},
|
|
165
180
|
{
|
|
@@ -202,6 +217,18 @@ class MemoryService:
|
|
|
202
217
|
limit: int = 20,
|
|
203
218
|
) -> Dict[str, Any]:
|
|
204
219
|
q = str(query or "").strip()
|
|
220
|
+
query_tokens = [tok for tok in q.lower().split() if tok]
|
|
221
|
+
|
|
222
|
+
def _lexical_score(*texts: Any) -> float:
|
|
223
|
+
# Honest, comparable relevance: fraction of query tokens present.
|
|
224
|
+
# Both tiers share this scorer so the cross-tier ranking is real,
|
|
225
|
+
# not an artifact of per-tier constants.
|
|
226
|
+
if not query_tokens:
|
|
227
|
+
return 0.0
|
|
228
|
+
haystack = " ".join(str(t or "") for t in texts).lower()
|
|
229
|
+
hits = sum(1 for tok in query_tokens if tok in haystack)
|
|
230
|
+
return round(hits / len(query_tokens), 4)
|
|
231
|
+
|
|
205
232
|
results: List[Dict[str, Any]] = []
|
|
206
233
|
|
|
207
234
|
try:
|
|
@@ -215,23 +242,24 @@ class MemoryService:
|
|
|
215
242
|
"title": (m.get("kind") or "memory"),
|
|
216
243
|
"snippet": str(m.get("content") or "")[:240],
|
|
217
244
|
"kind": m.get("kind"),
|
|
218
|
-
"score":
|
|
245
|
+
"score": _lexical_score(m.get("content"), " ".join(m.get("tags") or []), m.get("kind")),
|
|
219
246
|
"tags": m.get("tags") or [],
|
|
220
247
|
})
|
|
221
248
|
|
|
222
249
|
if self._enable_graph and q:
|
|
223
250
|
try:
|
|
224
|
-
|
|
251
|
+
# KnowledgeGraph.search returns {"query": ..., "matches": [...]}.
|
|
252
|
+
hits = self._kg.search(q, limit).get("matches", [])
|
|
225
253
|
except Exception:
|
|
226
254
|
hits = []
|
|
227
|
-
for
|
|
255
|
+
for hit in hits[:limit]:
|
|
228
256
|
results.append({
|
|
229
257
|
"source": "graph",
|
|
230
|
-
"id":
|
|
231
|
-
"title":
|
|
232
|
-
"snippet": str(
|
|
233
|
-
"kind":
|
|
234
|
-
"score":
|
|
258
|
+
"id": hit.get("id") or hit.get("node_id"),
|
|
259
|
+
"title": hit.get("title") or hit.get("name") or "node",
|
|
260
|
+
"snippet": str(hit.get("summary") or hit.get("content") or "")[:240],
|
|
261
|
+
"kind": hit.get("type") or "node",
|
|
262
|
+
"score": _lexical_score(hit.get("title"), hit.get("name"), hit.get("summary"), hit.get("content")),
|
|
235
263
|
})
|
|
236
264
|
|
|
237
265
|
results.sort(key=lambda r: r.get("score", 0), reverse=True)
|
|
@@ -18,7 +18,6 @@ import re
|
|
|
18
18
|
import shutil
|
|
19
19
|
import subprocess
|
|
20
20
|
import sys
|
|
21
|
-
import tempfile
|
|
22
21
|
import threading
|
|
23
22
|
import time
|
|
24
23
|
import urllib.error
|
|
@@ -26,16 +25,14 @@ import urllib.request
|
|
|
26
25
|
from pathlib import Path
|
|
27
26
|
from typing import AsyncIterator, Dict, List, Optional
|
|
28
27
|
|
|
29
|
-
import httpx
|
|
30
28
|
from fastapi import HTTPException, Request
|
|
31
29
|
|
|
32
|
-
from
|
|
30
|
+
from latticeai.models.router import (
|
|
33
31
|
AsyncOpenAI,
|
|
34
32
|
HF_MODELS_ROOT,
|
|
35
33
|
OPENAI_COMPATIBLE_PROVIDERS,
|
|
36
34
|
ensure_mlx_runtime,
|
|
37
35
|
hf_model_dir,
|
|
38
|
-
normalize_branding,
|
|
39
36
|
parse_model_ref,
|
|
40
37
|
)
|
|
41
38
|
from latticeai.core.model_compat import (
|
|
@@ -89,7 +86,7 @@ def configure_model_runtime(**deps) -> None:
|
|
|
89
86
|
# Catalog data + version-dedup helpers live in ``model_catalog``; re-exported
|
|
90
87
|
# here so existing ``from ...model_runtime import ENGINE_MODEL_CATALOG`` imports
|
|
91
88
|
# keep working.
|
|
92
|
-
from latticeai.services.model_catalog import ( # noqa: F401
|
|
89
|
+
from latticeai.services.model_catalog import ( # noqa: E402, F401 (re-export after the module globals it documents)
|
|
93
90
|
ENGINE_INSTALLERS,
|
|
94
91
|
ENGINE_MODEL_CATALOG,
|
|
95
92
|
MODEL_ENGINE_ALIASES,
|
|
@@ -18,8 +18,9 @@ from typing import Any, Callable, Dict, Optional, Set
|
|
|
18
18
|
from fastapi import HTTPException, Request
|
|
19
19
|
|
|
20
20
|
from latticeai.core.hooks import dispatch_tool
|
|
21
|
-
from latticeai.core.multi_agent import MultiAgentOrchestrator, default_role_runner
|
|
22
|
-
from latticeai.core.workflow_engine import WorkflowEngine
|
|
21
|
+
from latticeai.core.multi_agent import MultiAgentOrchestrator, default_role_runner, llm_role_runner
|
|
22
|
+
from latticeai.core.workflow_engine import ApprovalRequired, WorkflowEngine
|
|
23
|
+
from tools import execute_tool
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
class PlatformRuntime:
|
|
@@ -34,6 +35,9 @@ class PlatformRuntime:
|
|
|
34
35
|
workspace_scope_from_request: Callable[[Request], Optional[str]],
|
|
35
36
|
get_tool_permission: Callable[..., Dict[str, Any]],
|
|
36
37
|
hooks: Any = None,
|
|
38
|
+
llm_generate: Optional[Callable[..., str]] = None,
|
|
39
|
+
llm_available: Optional[Callable[[], bool]] = None,
|
|
40
|
+
agent_registry: Any = None,
|
|
37
41
|
):
|
|
38
42
|
self.store = store
|
|
39
43
|
self.svc = workspace_service
|
|
@@ -45,6 +49,12 @@ class PlatformRuntime:
|
|
|
45
49
|
# Lifecycle hooks registry — wires the workflow runtime + workflow tool
|
|
46
50
|
# nodes into the same pre_*/post_* lifecycle as the HTTP + agent paths.
|
|
47
51
|
self.hooks = hooks
|
|
52
|
+
# v4 (T7b): a synchronous model bridge. When a model is loaded,
|
|
53
|
+
# build_orchestrator returns the REAL (mode='llm') runner; otherwise
|
|
54
|
+
# the deterministic runner, honestly labeled mode='simulation'.
|
|
55
|
+
self.llm_generate = llm_generate
|
|
56
|
+
self.llm_available = llm_available or (lambda: False)
|
|
57
|
+
self.agent_registry = agent_registry
|
|
48
58
|
|
|
49
59
|
# ── request gating ────────────────────────────────────────────────────
|
|
50
60
|
|
|
@@ -77,31 +87,57 @@ class PlatformRuntime:
|
|
|
77
87
|
# ── shared node runners ───────────────────────────────────────────────
|
|
78
88
|
|
|
79
89
|
def _tool_node_runner(self):
|
|
80
|
-
"""Workflow tool node:
|
|
81
|
-
|
|
90
|
+
"""Workflow tool node: EXECUTES the tool under governance (v4).
|
|
91
|
+
|
|
92
|
+
Auto-approve tools run immediately through the shared dispatch_tool
|
|
93
|
+
lifecycle. Tools whose policy requires approval raise
|
|
94
|
+
:class:`ApprovalRequired` so the engine pauses the run into
|
|
95
|
+
``awaiting_approval`` — never a silent ``{recorded: true}`` success,
|
|
96
|
+
never an unapproved execution. A resumed run carries the approved
|
|
97
|
+
node id in ``context['__approved_nodes__']``.
|
|
98
|
+
"""
|
|
82
99
|
def runner(*, node, context):
|
|
83
100
|
cfg = node.get("config") or {}
|
|
84
101
|
name = cfg.get("tool") or ""
|
|
85
102
|
args = cfg.get("args") or {}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
if not name:
|
|
104
|
+
raise ValueError("tool node has no tool configured")
|
|
105
|
+
try:
|
|
106
|
+
permission = dict(self.get_tool_permission(name, args))
|
|
107
|
+
except TypeError:
|
|
108
|
+
permission = dict(self.get_tool_permission(name))
|
|
109
|
+
approved_nodes = set(context.get("__approved_nodes__") or [])
|
|
110
|
+
if permission.get("requires_approval") and node.get("id") not in approved_nodes:
|
|
111
|
+
raise ApprovalRequired(
|
|
112
|
+
f"tool '{name}' requires explicit approval before a workflow may run it",
|
|
113
|
+
tool=name, args=args, permission=permission,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _execute():
|
|
117
|
+
return execute_tool(name, args)
|
|
93
118
|
|
|
94
119
|
# Same tool lifecycle as the HTTP + agent paths (a pre_tool block
|
|
95
120
|
# raises PermissionError, surfaced as the node error by the engine).
|
|
96
|
-
|
|
121
|
+
result = dispatch_tool(self.hooks, name, args, _execute, source="workflow")
|
|
122
|
+
return {"tool": name, "args": args, "executed": True,
|
|
123
|
+
"permission": permission, "result": result}
|
|
97
124
|
return runner
|
|
98
125
|
|
|
99
126
|
def _skill_node_runner(self):
|
|
127
|
+
"""Skill nodes refuse honestly: a skill is an instruction package for
|
|
128
|
+
an LLM; without a model-driven executor there is nothing to run, and
|
|
129
|
+
pretending otherwise (the pre-v4 existence check that reported 'ok')
|
|
130
|
+
is exactly the fake functionality v4 bans."""
|
|
100
131
|
def runner(*, node, context):
|
|
101
132
|
cfg = node.get("config") or {}
|
|
102
133
|
name = cfg.get("skill") or ""
|
|
103
134
|
entry = self.store.load_state().get("skill_registry", {}).get(name) or {}
|
|
104
|
-
|
|
135
|
+
if not entry:
|
|
136
|
+
raise ValueError(f"skill '{name}' is not installed")
|
|
137
|
+
raise RuntimeError(
|
|
138
|
+
f"skill '{name}' requires LLM-driven execution, which workflow "
|
|
139
|
+
"skill nodes do not provide in this build — refusing to fake a result"
|
|
140
|
+
)
|
|
105
141
|
return runner
|
|
106
142
|
|
|
107
143
|
def _context_provider(self, user, scope):
|
|
@@ -116,15 +152,25 @@ class PlatformRuntime:
|
|
|
116
152
|
def plugin_capability_runners(self, user, scope) -> Dict[str, Callable[..., Any]]:
|
|
117
153
|
"""Runners the Plugin SDK boundary dispatches to (one per capability)."""
|
|
118
154
|
def run_skill(*, plugin_id, action, args, manifest):
|
|
119
|
-
|
|
155
|
+
raise RuntimeError(
|
|
156
|
+
f"plugin '{plugin_id}' skill execution requires an LLM-driven "
|
|
157
|
+
"runner, which this build does not provide — refusing to fake a result"
|
|
158
|
+
)
|
|
120
159
|
|
|
121
160
|
def run_tool(*, plugin_id, action, args, manifest):
|
|
122
161
|
tool = args.get("tool") or (manifest.provides.get("tools") or [None])[0]
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
162
|
+
if not tool:
|
|
163
|
+
raise ValueError(f"plugin '{plugin_id}' run_tool needs a tool name")
|
|
164
|
+
permission = dict(self.get_tool_permission(tool))
|
|
165
|
+
if permission.get("requires_approval"):
|
|
166
|
+
raise ApprovalRequired(
|
|
167
|
+
f"plugin tool '{tool}' requires explicit approval",
|
|
168
|
+
tool=tool, args=args, permission=permission,
|
|
169
|
+
)
|
|
170
|
+
result = dispatch_tool(self.hooks, tool, args, lambda: execute_tool(tool, args),
|
|
171
|
+
source=f"plugin:{plugin_id}")
|
|
172
|
+
return {"plugin": plugin_id, "tool": tool, "permission": permission,
|
|
173
|
+
"executed": True, "result": result}
|
|
128
174
|
|
|
129
175
|
def run_workflow(*, plugin_id, action, args, manifest):
|
|
130
176
|
wf_id = args.get("workflow_id")
|
|
@@ -175,6 +221,9 @@ class PlatformRuntime:
|
|
|
175
221
|
workflow_id=workflow_id, name=workflow.get("name") or "workflow",
|
|
176
222
|
status=result.status, timeline=result.timeline, outputs=result.outputs,
|
|
177
223
|
user_email=user, graph=self.workspace_graph(), workspace_id=scope,
|
|
224
|
+
mode="live",
|
|
225
|
+
pause={"node": result.paused_node, "pending": result.pending_approval,
|
|
226
|
+
"context": result.paused_context} if result.status == "awaiting_approval" else None,
|
|
178
227
|
)
|
|
179
228
|
return {"workflow_run_id": run["id"], "status": result.status}
|
|
180
229
|
|
|
@@ -209,8 +258,36 @@ class PlatformRuntime:
|
|
|
209
258
|
}
|
|
210
259
|
|
|
211
260
|
def build_orchestrator(self, user, scope) -> MultiAgentOrchestrator:
|
|
261
|
+
workflow_runner = lambda wf_ref, ctx: self.run_workflow_by_id(wf_ref, user, scope, with_agent=False, inputs=ctx.inputs) # noqa: E731
|
|
262
|
+
plugin_runner = lambda pid, ctx: self.registry.execute_action(pid, "run_skill", {}, runners=self.plugin_capability_runners(user, scope), workspace_id=scope).as_dict() # noqa: E731
|
|
263
|
+
context_provider = self._context_provider(user, scope)
|
|
264
|
+
custom_agents = {}
|
|
265
|
+
if self.agent_registry is not None:
|
|
266
|
+
try:
|
|
267
|
+
custom_agents = {
|
|
268
|
+
a["id"]: a for a in self.agent_registry.all()
|
|
269
|
+
if str(a.get("id", "")).startswith("agent:custom:") and a.get("enabled", True)
|
|
270
|
+
}
|
|
271
|
+
except Exception:
|
|
272
|
+
custom_agents = {}
|
|
273
|
+
if self.llm_generate is not None and self.llm_available():
|
|
274
|
+
from latticeai.core.agent_prompts import CRITIC_PROMPT, PLANNER_PROMPT
|
|
275
|
+
|
|
276
|
+
return MultiAgentOrchestrator(
|
|
277
|
+
role_runner=llm_role_runner(
|
|
278
|
+
generate=self.llm_generate,
|
|
279
|
+
planner_prompt=PLANNER_PROMPT,
|
|
280
|
+
critic_prompt=CRITIC_PROMPT,
|
|
281
|
+
context_provider=context_provider,
|
|
282
|
+
workflow_runner=workflow_runner,
|
|
283
|
+
plugin_runner=plugin_runner,
|
|
284
|
+
custom_agents=custom_agents,
|
|
285
|
+
),
|
|
286
|
+
mode="llm",
|
|
287
|
+
custom_agents=custom_agents,
|
|
288
|
+
)
|
|
212
289
|
return MultiAgentOrchestrator(role_runner=default_role_runner(
|
|
213
|
-
workflow_runner=
|
|
214
|
-
plugin_runner=
|
|
215
|
-
context_provider=
|
|
216
|
-
))
|
|
290
|
+
workflow_runner=workflow_runner,
|
|
291
|
+
plugin_runner=plugin_runner,
|
|
292
|
+
context_provider=context_provider,
|
|
293
|
+
), mode="simulation", custom_agents=custom_agents)
|