ltcai 11.0.1 → 11.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 +55 -43
- package/docs/CHANGELOG.md +61 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
- package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/PERFORMANCE.md +71 -18
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/architecture.md +6 -2
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/embeddings.py +12 -37
- package/lattice_brain/gates.py +125 -0
- package/lattice_brain/graph/discovery_index.py +30 -32
- package/lattice_brain/graph/fusion.py +35 -4
- package/lattice_brain/graph/image_vectors.py +230 -0
- package/lattice_brain/graph/ingest.py +11 -5
- package/lattice_brain/graph/projection.py +66 -8
- package/lattice_brain/graph/provenance.py +27 -2
- package/lattice_brain/graph/retrieval.py +113 -2
- package/lattice_brain/graph/retrieval_docgen.py +6 -6
- package/lattice_brain/graph/schema.py +18 -0
- package/lattice_brain/graph/store.py +9 -0
- package/lattice_brain/graph/vector_index/selector.py +32 -2
- package/lattice_brain/ingestion.py +363 -10
- package/lattice_brain/multimodal.py +1258 -0
- package/lattice_brain/portability.py +169 -32
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/sealed_box.py +244 -0
- package/lattice_brain/self_model.py +77 -22
- package/lattice_brain/synthesis.py +24 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +4 -0
- package/latticeai/api/chat.py +11 -0
- package/latticeai/api/chat_helpers.py +16 -3
- package/latticeai/api/chat_hybrid.py +32 -1
- package/latticeai/api/features.py +70 -0
- package/latticeai/api/local_files.py +102 -0
- package/latticeai/api/memory.py +128 -1
- package/latticeai/api/portability.py +39 -4
- package/latticeai/api/review_queue.py +126 -0
- package/latticeai/api/search.py +16 -2
- package/latticeai/core/agent.py +59 -2
- package/latticeai/core/agent_prompts.py +66 -0
- package/latticeai/core/config.py +4 -1
- package/latticeai/core/context_builder.py +98 -11
- package/latticeai/core/embedding_providers.py +528 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +180 -0
- package/latticeai/core/model_compat.py +73 -2
- package/latticeai/core/workspace_os.py +43 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_reorganization.py +335 -0
- package/latticeai/models/model_providers.py +12 -4
- package/latticeai/runtime/build_phases.py +33 -2
- package/latticeai/runtime/chat_wiring.py +4 -0
- package/latticeai/runtime/feature_toggle_wiring.py +163 -0
- package/latticeai/runtime/persistence_runtime.py +41 -4
- package/latticeai/runtime/router_registration.py +11 -0
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/app_context.py +8 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +22 -2
- package/latticeai/services/brain_intelligence.py +123 -7
- package/latticeai/services/change_proposals.py +50 -10
- package/latticeai/services/command_center.py +10 -4
- package/latticeai/services/feature_toggles.py +502 -0
- package/latticeai/services/folder_watch.py +122 -1
- package/latticeai/services/hybrid_chat.py +56 -5
- package/latticeai/services/interop_bridges.py +978 -0
- package/latticeai/services/memory_service.py +34 -0
- package/latticeai/services/model_capability_registry.py +434 -261
- package/latticeai/services/model_catalog.py +95 -61
- package/latticeai/services/model_recommendation.py +18 -11
- package/latticeai/services/model_runtime.py +1 -1
- package/latticeai/services/multimodal_ports.py +112 -0
- package/latticeai/services/obsidian_bridge.py +16 -25
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +149 -2
- package/latticeai/services/self_model_service.py +171 -0
- package/latticeai/services/tool_dispatch.py +4 -0
- package/latticeai/services/voice_capture.py +27 -1
- package/latticeai/setup/auto_setup.py +27 -30
- package/latticeai/setup/wizard.py +77 -44
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/release_screen_claims.json +22 -0
- package/scripts/verify_hf_model_registry.py +253 -218
- 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 +37 -37
- package/static/app/assets/{Act-D4zSxFR-.js → Act-AWf0SAKp.js} +1 -1
- package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D0u8Tiyj.js} +1 -1
- package/static/app/assets/{Brain-C2EqQg74.js → Brain-tuhI4sOC.js} +1 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
- package/static/app/assets/BrainSignals-jMYgQ2Ar.js +1 -0
- package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CqOSzyPr.js} +1 -1
- package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DC0Bzh-I.js} +1 -1
- package/static/app/assets/{Library-BN0HYOfc.js → Library-CX-bbhmK.js} +1 -1
- package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-DBwhto14.js} +1 -1
- package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-BHA2cfKI.js} +1 -1
- package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-BUhCKRNM.js} +1 -1
- package/static/app/assets/{System-BElUcSSw.js → System-Bu2t5hn1.js} +1 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
- package/static/app/assets/{bot--qYHMtkP.js → bot-Cia42c2h.js} +1 -1
- package/static/app/assets/brain-DJMoqrwx.js +1 -0
- package/static/app/assets/{button-51Z3rsuv.js → button-2j2Ijzgq.js} +1 -1
- package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-BEFeWpVW.js} +1 -1
- package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-ujXMcHxl.js} +1 -1
- package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-k4awryFq.js} +1 -1
- package/static/app/assets/{download-G-2olkWz.js → download-DFbLJ_ig.js} +1 -1
- package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-7y_b6xkM.js} +1 -1
- package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-Bidh02Kr.js} +1 -1
- package/static/app/assets/{index-D7Rr-J2Y.js → index-BpYkzcVm.js} +3 -3
- package/static/app/assets/index-DwDl9-8Y.css +2 -0
- package/static/app/assets/{input-D4w_BZWl.js → input-DSlJJxRs.js} +1 -1
- package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-Bpb83Hx9.js} +1 -1
- package/static/app/assets/{primitives-d0g9pvzS.js → primitives-BCx6TvfG.js} +1 -1
- package/static/app/assets/search-Cgy8cCFJ.js +1 -0
- package/static/app/assets/{share-2-NmD7e_oV.js → share-2-BH1M-WNi.js} +1 -1
- package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-BlKdBXcG.js} +1 -1
- package/static/app/assets/{textarea-BPAJDc-0.js → textarea-CCWbUfFB.js} +1 -1
- package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
- package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-CXQiwbVT.js} +1 -1
- package/static/app/assets/{utils-DG1_ExrP.js → utils-zqPZJxdx.js} +2 -2
- package/static/app/assets/{workspace-CWVf3gsI.js → workspace-DXTihhfU.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
- package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
- package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
- package/static/app/assets/brain-DDCLjRqO.js +0 -1
- package/static/app/assets/index-CkzokZAj.css +0 -2
- package/static/app/assets/search-BLCYt75v.js +0 -1
package/latticeai/api/memory.py
CHANGED
|
@@ -3,17 +3,26 @@
|
|
|
3
3
|
Exposes :class:`~latticeai.services.memory_service.MemoryService` so the /app
|
|
4
4
|
Memory view can inspect every memory tier, recall across them, and run manager
|
|
5
5
|
operations (prune / compact / rebuild / clear). Full paths in decorators.
|
|
6
|
+
|
|
7
|
+
v11.1.0 adds the Self-Model surface (Track 4) on the same factory: the profile
|
|
8
|
+
the Brain holds about its owner is *memory*, so it is read, edited and deleted
|
|
9
|
+
here rather than behind a router of its own. Ownership is the whole point —
|
|
10
|
+
extraction only ever proposes (``/self-model/propose`` → Review Center →
|
|
11
|
+
``/self-model/apply``), while the user's own edits and deletions write
|
|
12
|
+
directly.
|
|
6
13
|
"""
|
|
7
14
|
|
|
8
15
|
from __future__ import annotations
|
|
9
16
|
|
|
10
|
-
from typing import Callable, List, Optional
|
|
17
|
+
from typing import Any, Callable, List, Optional
|
|
11
18
|
|
|
12
19
|
from fastapi import APIRouter, HTTPException, Request
|
|
13
20
|
from pydantic import BaseModel
|
|
14
21
|
|
|
22
|
+
from lattice_brain.self_model import SelfModelError
|
|
15
23
|
from latticeai.core.messages import http_error, resolve_language
|
|
16
24
|
from latticeai.services.memory_service import MemoryService
|
|
25
|
+
from latticeai.services.self_model_service import SelfModelService
|
|
17
26
|
|
|
18
27
|
|
|
19
28
|
class RecallRequest(BaseModel):
|
|
@@ -35,6 +44,49 @@ class ClearRequest(BaseModel):
|
|
|
35
44
|
confirm: bool = False
|
|
36
45
|
|
|
37
46
|
|
|
47
|
+
#: Self-Model failure codes → catalog ids. Brain Core raises codes (it cannot
|
|
48
|
+
#: import the catalog); the answer a person reads is chosen here, in their
|
|
49
|
+
#: language. An unknown code degrades to the generic entry rather than leaking
|
|
50
|
+
#: an English developer sentence.
|
|
51
|
+
SELF_MODEL_MESSAGES = {
|
|
52
|
+
"invalid_kind": "self_model.invalid_kind",
|
|
53
|
+
"text_required": "self_model.text_required",
|
|
54
|
+
"not_found": "self_model.not_found",
|
|
55
|
+
"not_self_model": "self_model.not_self_model",
|
|
56
|
+
"not_a_proposal": "self_model.not_a_proposal",
|
|
57
|
+
"empty_proposal": "self_model.empty_proposal",
|
|
58
|
+
"graph_unavailable": "self_model.graph_unavailable",
|
|
59
|
+
"queue_unavailable": "self_model.queue_unavailable",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _self_model_error(exc: SelfModelError, request: Request) -> HTTPException:
|
|
64
|
+
status = 404 if exc.code == "not_found" else 400
|
|
65
|
+
message_id = SELF_MODEL_MESSAGES.get(exc.code, "self_model.invalid")
|
|
66
|
+
return http_error(status, message_id, resolve_language(request))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SelfModelFactRequest(BaseModel):
|
|
70
|
+
"""A fact the user states about themselves (user-initiated direct write)."""
|
|
71
|
+
|
|
72
|
+
kind: str
|
|
73
|
+
text: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SelfModelProposeRequest(BaseModel):
|
|
77
|
+
"""Text to read for candidate facts. Output is proposals, never writes."""
|
|
78
|
+
|
|
79
|
+
text: str = ""
|
|
80
|
+
source: str = ""
|
|
81
|
+
max_proposals: int = 5
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SelfModelApplyRequest(BaseModel):
|
|
85
|
+
"""Approve one Self-Model proposal and write the fact it carries."""
|
|
86
|
+
|
|
87
|
+
item_id: str
|
|
88
|
+
|
|
89
|
+
|
|
38
90
|
def create_memory_router(
|
|
39
91
|
*,
|
|
40
92
|
service: MemoryService,
|
|
@@ -44,8 +96,13 @@ def create_memory_router(
|
|
|
44
96
|
gate_write: Callable[[Request], Optional[str]],
|
|
45
97
|
append_audit_event: Callable[..., None],
|
|
46
98
|
active_model_getter: Callable[[], str] | None = None,
|
|
99
|
+
self_model: Any = None,
|
|
47
100
|
) -> APIRouter:
|
|
48
101
|
router = APIRouter()
|
|
102
|
+
# Derived from the memory service by default (graph + review queue), so the
|
|
103
|
+
# Self-Model routes exist on every build and report honestly when this
|
|
104
|
+
# Brain has no graph rather than disappearing from the API surface.
|
|
105
|
+
profile = self_model or SelfModelService(memory_service=service)
|
|
49
106
|
|
|
50
107
|
@router.get("/api/memory/manager")
|
|
51
108
|
async def memory_manager(request: Request):
|
|
@@ -140,4 +197,74 @@ def create_memory_router(
|
|
|
140
197
|
append_audit_event("memory_clear", user_email=user, scope=req.scope)
|
|
141
198
|
return result
|
|
142
199
|
|
|
200
|
+
# ── Self-Model (v11.1.0) ─────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
@router.get("/api/memory/self-model")
|
|
203
|
+
async def self_model_profile(request: Request):
|
|
204
|
+
"""What the Brain believes about its owner, and the injected summary."""
|
|
205
|
+
user = require_user(request)
|
|
206
|
+
scope = gate_read(request)
|
|
207
|
+
return profile.profile(user_email=user, workspace_id=scope)
|
|
208
|
+
|
|
209
|
+
@router.post("/api/memory/self-model")
|
|
210
|
+
async def self_model_upsert(req: SelfModelFactRequest, request: Request):
|
|
211
|
+
"""Add or correct one fact directly — the user owns their profile."""
|
|
212
|
+
user = require_user(request)
|
|
213
|
+
scope = gate_write(request)
|
|
214
|
+
try:
|
|
215
|
+
fact = profile.upsert(kind=req.kind, text=req.text, workspace_id=scope)
|
|
216
|
+
except SelfModelError as exc:
|
|
217
|
+
raise _self_model_error(exc, request) from exc
|
|
218
|
+
append_audit_event(
|
|
219
|
+
"self_model_upsert", user_email=user, node_id=fact["id"], kind=fact["kind"]
|
|
220
|
+
)
|
|
221
|
+
return fact
|
|
222
|
+
|
|
223
|
+
@router.delete("/api/memory/self-model/{node_id}")
|
|
224
|
+
async def self_model_delete(node_id: str, request: Request):
|
|
225
|
+
"""Forget one fact about the user, permanently."""
|
|
226
|
+
user = require_user(request)
|
|
227
|
+
gate_write(request)
|
|
228
|
+
try:
|
|
229
|
+
result = profile.delete(node_id)
|
|
230
|
+
except SelfModelError as exc:
|
|
231
|
+
raise _self_model_error(exc, request) from exc
|
|
232
|
+
append_audit_event("self_model_delete", user_email=user, node_id=node_id)
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
@router.post("/api/memory/self-model/propose")
|
|
236
|
+
async def self_model_propose(req: SelfModelProposeRequest, request: Request):
|
|
237
|
+
"""Read text for candidate facts; every hit becomes a review proposal."""
|
|
238
|
+
user = require_user(request)
|
|
239
|
+
scope = gate_write(request)
|
|
240
|
+
result = profile.propose(
|
|
241
|
+
req.text,
|
|
242
|
+
source=req.source or None,
|
|
243
|
+
user_email=user,
|
|
244
|
+
workspace_id=scope,
|
|
245
|
+
max_proposals=req.max_proposals,
|
|
246
|
+
)
|
|
247
|
+
append_audit_event(
|
|
248
|
+
"self_model_proposed",
|
|
249
|
+
user_email=user,
|
|
250
|
+
proposed=result.get("proposed_count", 0),
|
|
251
|
+
)
|
|
252
|
+
return result
|
|
253
|
+
|
|
254
|
+
@router.post("/api/memory/self-model/apply")
|
|
255
|
+
async def self_model_apply(req: SelfModelApplyRequest, request: Request):
|
|
256
|
+
"""Approve a proposal and write the fact it carries into the graph."""
|
|
257
|
+
user = require_user(request)
|
|
258
|
+
scope = gate_write(request)
|
|
259
|
+
try:
|
|
260
|
+
result = profile.apply(req.item_id, workspace_id=scope)
|
|
261
|
+
except SelfModelError as exc:
|
|
262
|
+
raise _self_model_error(exc, request) from exc
|
|
263
|
+
except (KeyError, FileNotFoundError) as exc:
|
|
264
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
265
|
+
append_audit_event(
|
|
266
|
+
"self_model_applied", user_email=user, item_id=req.item_id
|
|
267
|
+
)
|
|
268
|
+
return result
|
|
269
|
+
|
|
143
270
|
return router
|
|
@@ -87,8 +87,16 @@ class SubgraphSelection(BaseModel):
|
|
|
87
87
|
|
|
88
88
|
|
|
89
89
|
class SubgraphArchiveRequest(SubgraphSelection):
|
|
90
|
+
"""Exactly one recipient mechanism — a passphrase, or a public key.
|
|
91
|
+
|
|
92
|
+
Both optional at the schema level and validated in the service, so the
|
|
93
|
+
error a caller gets names the rule ("choose one") instead of a field.
|
|
94
|
+
"""
|
|
95
|
+
|
|
90
96
|
path: Optional[str] = None
|
|
91
|
-
passphrase: str
|
|
97
|
+
passphrase: Optional[str] = None
|
|
98
|
+
#: The recipient's X25519 public key, from their ``/share/recipient-key``.
|
|
99
|
+
recipient_public_key: Optional[str] = None
|
|
92
100
|
|
|
93
101
|
|
|
94
102
|
class SubgraphImportRequest(BaseModel):
|
|
@@ -269,6 +277,22 @@ def create_portability_router(
|
|
|
269
277
|
except (ValueError, RuntimeError) as exc:
|
|
270
278
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
271
279
|
|
|
280
|
+
@router.get("/api/knowledge-graph/share/recipient-key")
|
|
281
|
+
async def share_recipient_key(request: Request):
|
|
282
|
+
"""This Brain's X25519 receiving key, for a sender to seal a bundle to.
|
|
283
|
+
|
|
284
|
+
Public by construction — a public key encrypts, it does not decrypt —
|
|
285
|
+
so handing it out is the whole point. Still admin-gated and still
|
|
286
|
+
behind the share flag, because minting the key is a decision to
|
|
287
|
+
participate in sharing at all.
|
|
288
|
+
"""
|
|
289
|
+
require_admin(request)
|
|
290
|
+
_require_service(request)
|
|
291
|
+
try:
|
|
292
|
+
return service.recipient_key()
|
|
293
|
+
except PermissionError:
|
|
294
|
+
raise http_error(403, "portability.brain_network_disabled", resolve_language(request))
|
|
295
|
+
|
|
272
296
|
@router.post("/api/knowledge-graph/share/archive")
|
|
273
297
|
async def share_archive(req: SubgraphArchiveRequest, request: Request):
|
|
274
298
|
"""Same bundle, written as an encrypted ``.latticebrain`` file."""
|
|
@@ -277,8 +301,14 @@ def create_portability_router(
|
|
|
277
301
|
body = req.model_dump()
|
|
278
302
|
path = body.pop("path")
|
|
279
303
|
passphrase = body.pop("passphrase")
|
|
304
|
+
recipient_public_key = body.pop("recipient_public_key")
|
|
280
305
|
try:
|
|
281
|
-
return service.export_subgraph_archive(
|
|
306
|
+
return service.export_subgraph_archive(
|
|
307
|
+
path,
|
|
308
|
+
passphrase=passphrase,
|
|
309
|
+
recipient_public_key=recipient_public_key,
|
|
310
|
+
**body,
|
|
311
|
+
)
|
|
282
312
|
except PermissionError:
|
|
283
313
|
raise http_error(403, "portability.brain_network_disabled", resolve_language(request))
|
|
284
314
|
except (ValueError, RuntimeError) as exc:
|
|
@@ -294,8 +324,13 @@ def create_portability_router(
|
|
|
294
324
|
try:
|
|
295
325
|
artifact = req.artifact
|
|
296
326
|
if artifact is None:
|
|
297
|
-
if not req.path
|
|
298
|
-
raise ValueError(
|
|
327
|
+
if not req.path:
|
|
328
|
+
raise ValueError(
|
|
329
|
+
"Provide an artifact, or the path of a bundle on disk."
|
|
330
|
+
)
|
|
331
|
+
# A sealed bundle needs no passphrase — the envelope says which
|
|
332
|
+
# mechanism it used and the service refuses if the wrong one is
|
|
333
|
+
# supplied, so the route does not have to guess here.
|
|
299
334
|
artifact = service.read_subgraph_archive(req.path, passphrase=req.passphrase)
|
|
300
335
|
return service.import_subgraph_proposals(
|
|
301
336
|
artifact,
|
|
@@ -70,6 +70,39 @@ class ReviewCounts(BaseModel):
|
|
|
70
70
|
pending_by_source: Dict[str, int] = Field(default_factory=dict)
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
class BulkActionRequest(BaseModel):
|
|
74
|
+
"""Decide several review items at once, named explicitly.
|
|
75
|
+
|
|
76
|
+
``ids`` is required and never defaults to "everything pending": a bulk
|
|
77
|
+
action whose scope is implicit is how an inbox gets emptied by accident.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
ids: List[str] = Field(default_factory=list)
|
|
81
|
+
reason: str = ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class BulkActionOutcome(BaseModel):
|
|
85
|
+
"""Per-item verdict, so a partial success is legible rather than a total."""
|
|
86
|
+
|
|
87
|
+
id: str
|
|
88
|
+
status: str # ok | not_found | conflict | failed
|
|
89
|
+
item_status: Optional[str] = None
|
|
90
|
+
detail: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class BulkActionResult(BaseModel):
|
|
94
|
+
action: str
|
|
95
|
+
requested: int
|
|
96
|
+
succeeded: int
|
|
97
|
+
failed: int
|
|
98
|
+
results: List[BulkActionOutcome] = Field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
#: Ceiling on one bulk call. Large enough for "clear today's inbox", small
|
|
102
|
+
#: enough that one request cannot walk the whole table inside a web worker.
|
|
103
|
+
BULK_ACTION_CAP = 200
|
|
104
|
+
|
|
105
|
+
|
|
73
106
|
def create_review_queue_router(
|
|
74
107
|
*,
|
|
75
108
|
service: ReviewQueueService,
|
|
@@ -123,6 +156,19 @@ def create_review_queue_router(
|
|
|
123
156
|
scope = gate_read(request)
|
|
124
157
|
return service.counts(workspace_id=scope, user_email=user)
|
|
125
158
|
|
|
159
|
+
# NOTE: declared *before* /automation/reviews/{item_id} for the same reason
|
|
160
|
+
# "counts" is — routes match in declaration order, so a later /bulk/... would
|
|
161
|
+
# be swallowed by the {item_id} matcher and answer 404 for a missing item.
|
|
162
|
+
@router.post("/automation/reviews/bulk/approve", response_model=BulkActionResult)
|
|
163
|
+
async def bulk_approve(req: BulkActionRequest, request: Request):
|
|
164
|
+
"""Approve several items, reporting each one's outcome individually."""
|
|
165
|
+
return _bulk(request, req, "approve")
|
|
166
|
+
|
|
167
|
+
@router.post("/automation/reviews/bulk/dismiss", response_model=BulkActionResult)
|
|
168
|
+
async def bulk_dismiss(req: BulkActionRequest, request: Request):
|
|
169
|
+
"""Dismiss several items, reporting each one's outcome individually."""
|
|
170
|
+
return _bulk(request, req, "dismiss")
|
|
171
|
+
|
|
126
172
|
@router.get("/automation/reviews/{item_id}", response_model=ReviewItem)
|
|
127
173
|
async def get_item(item_id: str, request: Request):
|
|
128
174
|
require_user(request)
|
|
@@ -205,6 +251,86 @@ def create_review_queue_router(
|
|
|
205
251
|
async def unsnooze_item(item_id: str, request: Request):
|
|
206
252
|
return _act(request, item_id, "unsnooze")
|
|
207
253
|
|
|
254
|
+
def _bulk(request: Request, req: BulkActionRequest, action: str) -> Dict[str, Any]:
|
|
255
|
+
"""One transition applied N times through the *same* single-item guard.
|
|
256
|
+
|
|
257
|
+
Deliberately not a bulk UPDATE. Every id goes through the ordinary
|
|
258
|
+
service call, so an already-approved item still 409s, a
|
|
259
|
+
``change_proposal`` still applies its staged content, and the audit
|
|
260
|
+
trail records N decisions — because that is what happened. A failure
|
|
261
|
+
on one id never aborts the rest, and the response says exactly which
|
|
262
|
+
ones did not land instead of returning a single number the caller has
|
|
263
|
+
to interpret.
|
|
264
|
+
"""
|
|
265
|
+
user = require_user(request)
|
|
266
|
+
scope = gate_write(request)
|
|
267
|
+
language = resolve_language(request)
|
|
268
|
+
wanted = [str(item).strip() for item in req.ids if str(item).strip()]
|
|
269
|
+
if not wanted:
|
|
270
|
+
raise http_error(422, "review.bulk_ids_required", language)
|
|
271
|
+
if len(wanted) > BULK_ACTION_CAP:
|
|
272
|
+
raise http_error(422, "review.bulk_too_many", language, cap=BULK_ACTION_CAP)
|
|
273
|
+
results: List[Dict[str, Any]] = []
|
|
274
|
+
succeeded = 0
|
|
275
|
+
for item_id in wanted:
|
|
276
|
+
outcome = _bulk_one(request, item_id, action, user=user, scope=scope, reason=req.reason)
|
|
277
|
+
if outcome["status"] == "ok":
|
|
278
|
+
succeeded += 1
|
|
279
|
+
results.append(outcome)
|
|
280
|
+
return {
|
|
281
|
+
"action": action,
|
|
282
|
+
"requested": len(wanted),
|
|
283
|
+
"succeeded": succeeded,
|
|
284
|
+
"failed": len(wanted) - succeeded,
|
|
285
|
+
"results": results,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
def _bulk_one(
|
|
289
|
+
request: Request,
|
|
290
|
+
item_id: str,
|
|
291
|
+
action: str,
|
|
292
|
+
*,
|
|
293
|
+
user: str,
|
|
294
|
+
scope: Optional[str],
|
|
295
|
+
reason: str,
|
|
296
|
+
) -> Dict[str, Any]:
|
|
297
|
+
try:
|
|
298
|
+
if action == "dismiss":
|
|
299
|
+
item = service.dismiss(item_id, workspace_id=scope, reason=reason or None)
|
|
300
|
+
else:
|
|
301
|
+
item = _approve_one(request, item_id, user=user, scope=scope)
|
|
302
|
+
except (FileNotFoundError, KeyError):
|
|
303
|
+
return {"id": item_id, "status": "not_found", "detail": None}
|
|
304
|
+
except InvalidReviewTransition as exc:
|
|
305
|
+
return {"id": item_id, "status": "conflict", "detail": str(exc)}
|
|
306
|
+
except ProposalConflictError as exc:
|
|
307
|
+
return {"id": item_id, "status": "conflict", "detail": str(exc)}
|
|
308
|
+
except HTTPException as exc:
|
|
309
|
+
return {
|
|
310
|
+
"id": item_id,
|
|
311
|
+
"status": "conflict" if exc.status_code == 409 else "failed",
|
|
312
|
+
"detail": str(exc.detail),
|
|
313
|
+
}
|
|
314
|
+
except ValueError as exc:
|
|
315
|
+
return {"id": item_id, "status": "failed", "detail": str(exc)}
|
|
316
|
+
append_audit_event(f"review_item_{action}", user_email=user, item_id=item_id)
|
|
317
|
+
return {"id": item_id, "status": "ok", "item_status": item.get("status"), "detail": None}
|
|
318
|
+
|
|
319
|
+
def _approve_one(
|
|
320
|
+
request: Request, item_id: str, *, user: str, scope: Optional[str]
|
|
321
|
+
) -> Dict[str, Any]:
|
|
322
|
+
"""Approve exactly the way the single-item route does, staged content included."""
|
|
323
|
+
if change_proposals is not None:
|
|
324
|
+
stored = service.get(item_id, workspace_id=scope)
|
|
325
|
+
if stored.get("source") == "change_proposal":
|
|
326
|
+
if stored.get("effective_status") not in ("pending", "snoozed"):
|
|
327
|
+
raise InvalidReviewTransition("approve", str(stored.get("status")))
|
|
328
|
+
applied = change_proposals.approve_and_apply(
|
|
329
|
+
item_id, user_email=user, workspace_id=scope
|
|
330
|
+
)
|
|
331
|
+
return dict(applied["item"])
|
|
332
|
+
return service.approve(item_id, workspace_id=scope)
|
|
333
|
+
|
|
208
334
|
@router.post("/automation/reviews/{item_id}/run_now", response_model=ReviewItem)
|
|
209
335
|
async def run_now_item(item_id: str, request: Request):
|
|
210
336
|
user = require_user(request)
|
package/latticeai/api/search.py
CHANGED
|
@@ -25,6 +25,11 @@ class HybridSearchRequest(SearchRequest):
|
|
|
25
25
|
vector_limit: int = 30
|
|
26
26
|
graph_limit: int = 30
|
|
27
27
|
weights: Optional[Dict[str, float]] = None
|
|
28
|
+
#: Opt in to scoring this text query against the *image* index too
|
|
29
|
+
#: (v11.2.0). Only possible when a shared-space vision model is
|
|
30
|
+
#: configured; when it is not, the response says so instead of quietly
|
|
31
|
+
#: returning the text-only ranking as if fusion had happened.
|
|
32
|
+
image_fusion: bool = False
|
|
28
33
|
|
|
29
34
|
|
|
30
35
|
class GraphNodeRequest(BaseModel):
|
|
@@ -106,17 +111,26 @@ def create_search_router(
|
|
|
106
111
|
vector_limit=req.vector_limit,
|
|
107
112
|
graph_limit=req.graph_limit,
|
|
108
113
|
weights=req.weights or DEFAULT_HYBRID_WEIGHTS,
|
|
114
|
+
image_fusion=req.image_fusion,
|
|
109
115
|
)
|
|
110
116
|
except ValueError as exc:
|
|
111
117
|
_raise_graph_error(exc)
|
|
112
118
|
|
|
113
119
|
@router.get("/api/search/hybrid")
|
|
114
|
-
async def hybrid_search_get(
|
|
120
|
+
async def hybrid_search_get(
|
|
121
|
+
q: str, request: Request, limit: int = 30, image_fusion: bool = False,
|
|
122
|
+
) -> Dict[str, Any]:
|
|
115
123
|
try:
|
|
116
|
-
return _guarded(request).hybrid_search(q, limit=limit)
|
|
124
|
+
return _guarded(request).hybrid_search(q, limit=limit, image_fusion=image_fusion)
|
|
117
125
|
except ValueError as exc:
|
|
118
126
|
_raise_graph_error(exc)
|
|
119
127
|
|
|
128
|
+
@router.get("/api/search/image-query")
|
|
129
|
+
async def image_query_status(request: Request) -> Dict[str, Any]:
|
|
130
|
+
"""Whether a typed question can reach the image index on this install."""
|
|
131
|
+
require_user(request)
|
|
132
|
+
return service.image_query_status()
|
|
133
|
+
|
|
120
134
|
@router.post("/api/search/keyword")
|
|
121
135
|
async def keyword_search(req: SearchRequest, request: Request) -> Dict[str, Any]:
|
|
122
136
|
try:
|
package/latticeai/core/agent.py
CHANGED
|
@@ -62,6 +62,7 @@ from latticeai.core.agent_permission import (
|
|
|
62
62
|
resolve_deps_mode,
|
|
63
63
|
)
|
|
64
64
|
from latticeai.core.agent_profiles import AgentProfile, profile_for_model
|
|
65
|
+
from latticeai.core.agent_prompts import executor_prompt_for
|
|
65
66
|
|
|
66
67
|
# The state vocabulary and the pure helpers live in sibling modules so this one
|
|
67
68
|
# holds only the loop. They are re-exported (see ``__all__``) because callers —
|
|
@@ -113,7 +114,8 @@ class AgentRunContext:
|
|
|
113
114
|
__slots__ = ("state", "plan", "transcript", "retry_count",
|
|
114
115
|
"state_history", "corrections", "final_message", "rollback_log",
|
|
115
116
|
"executing_model", "reviewing_model", "approved_by_human", "trace",
|
|
116
|
-
"on_step", "project_context", "permission_mode"
|
|
117
|
+
"on_step", "project_context", "permission_mode",
|
|
118
|
+
"self_model_summary")
|
|
117
119
|
|
|
118
120
|
def __init__(self) -> None:
|
|
119
121
|
self.state: AgentState = AgentState.IDLE
|
|
@@ -142,6 +144,12 @@ class AgentRunContext:
|
|
|
142
144
|
# per-tool gate in the same run agree; ``None`` falls back to the
|
|
143
145
|
# process-wide resolver on ``deps``.
|
|
144
146
|
self.permission_mode: Optional[str] = None
|
|
147
|
+
# Self-Model summary resolved once per run (v11.2.0). The executor
|
|
148
|
+
# prompt is rebuilt on every turn of the loop; reading the profile
|
|
149
|
+
# once and reusing it keeps a graph query off that hot path — and
|
|
150
|
+
# keeps every turn of one run describing the same person. ``None``
|
|
151
|
+
# means "not resolved yet"; ``""`` is a resolved, empty profile.
|
|
152
|
+
self.self_model_summary: Optional[str] = None
|
|
145
153
|
|
|
146
154
|
|
|
147
155
|
@dataclass
|
|
@@ -241,6 +249,17 @@ class AgentDeps:
|
|
|
241
249
|
# which is exactly the pre-9.9.8 behaviour.
|
|
242
250
|
permission_mode: Any = None
|
|
243
251
|
|
|
252
|
+
# ── Self-Model port (optional, v11.2.0) ──────────────────────────
|
|
253
|
+
# What the Brain has learned about its owner, injected into the executor
|
|
254
|
+
# prompt. Either a fixed string or a resolver callable taking
|
|
255
|
+
# ``user_email``/``workspace_id`` scope kwargs (or none at all). 11.1.0
|
|
256
|
+
# built ``executor_prompt_for(self_model_summary=…)`` and then had nothing
|
|
257
|
+
# to pass it, because the runtime held its executor prompt as a fixed
|
|
258
|
+
# string; this is the port that was missing. ``None`` — and an empty
|
|
259
|
+
# summary — produce exactly the prompt bytes the loop produced before it
|
|
260
|
+
# existed.
|
|
261
|
+
self_model_summary: Any = None
|
|
262
|
+
|
|
244
263
|
|
|
245
264
|
class SingleAgentRuntime:
|
|
246
265
|
"""Drives the agent state machine over injected :class:`AgentDeps`."""
|
|
@@ -607,6 +626,41 @@ class SingleAgentRuntime:
|
|
|
607
626
|
|
|
608
627
|
ctx.state = AgentState.VERIFYING
|
|
609
628
|
|
|
629
|
+
def _self_model_summary(
|
|
630
|
+
self, ctx: AgentRunContext, current_user: str, request_workspace: Optional[str]
|
|
631
|
+
) -> str:
|
|
632
|
+
"""What this Brain knows about its owner, resolved once per run.
|
|
633
|
+
|
|
634
|
+
The port may be a plain string or a resolver taking scope kwargs — the
|
|
635
|
+
same two shapes ``permission_mode`` accepts, so there is one convention
|
|
636
|
+
for "a value or a way to get one". Anything that goes wrong yields an
|
|
637
|
+
empty summary: prompt assembly must not fail because a profile could
|
|
638
|
+
not be read, and an empty summary is byte-identical to having no port
|
|
639
|
+
at all.
|
|
640
|
+
"""
|
|
641
|
+
if ctx.self_model_summary is not None:
|
|
642
|
+
return ctx.self_model_summary
|
|
643
|
+
source = self.deps.self_model_summary
|
|
644
|
+
summary = ""
|
|
645
|
+
if source is not None:
|
|
646
|
+
try:
|
|
647
|
+
if callable(source):
|
|
648
|
+
try:
|
|
649
|
+
summary = source(
|
|
650
|
+
user_email=current_user or None,
|
|
651
|
+
workspace_id=request_workspace,
|
|
652
|
+
)
|
|
653
|
+
except TypeError:
|
|
654
|
+
# A resolver that takes no scope arguments is allowed.
|
|
655
|
+
summary = source()
|
|
656
|
+
else:
|
|
657
|
+
summary = source
|
|
658
|
+
except Exception: # noqa: BLE001 — a profile is never worth a failed run
|
|
659
|
+
logging.debug("agent: self-model summary unavailable", exc_info=True)
|
|
660
|
+
summary = ""
|
|
661
|
+
ctx.self_model_summary = str(summary or "").strip()
|
|
662
|
+
return ctx.self_model_summary
|
|
663
|
+
|
|
610
664
|
def _executor_context(
|
|
611
665
|
self, ctx: AgentRunContext, req: Any, lang_hint: str,
|
|
612
666
|
current_user: str, request_workspace: Optional[str],
|
|
@@ -646,7 +700,10 @@ class SingleAgentRuntime:
|
|
|
646
700
|
+ "\n".join(f"- {path}" for path in written)
|
|
647
701
|
) if written else ""
|
|
648
702
|
return (
|
|
649
|
-
|
|
703
|
+
# v11.1.0: the executor prompt carries profile-aware file-writing
|
|
704
|
+
# hints, because "wrote nothing at all" was the weak-model failure
|
|
705
|
+
# mode the loop could not repair after the fact.
|
|
706
|
+
f"{executor_prompt_for(d.executor_prompt, profile=profile, self_model_summary=self._self_model_summary(ctx, current_user, request_workspace))}\n\n"
|
|
650
707
|
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
651
708
|
f"Workspace root: {d.agent_root}{self._project_block(ctx)}\n\n"
|
|
652
709
|
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}{written_hint}\n\n"
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
5
7
|
from latticeai.core.tool_registry import TOOL_CATALOG_BRIEF
|
|
6
8
|
|
|
7
9
|
PLANNER_PROMPT = """You are the PLANNER role in Lattice AI's multi-role agent harness.
|
|
@@ -103,3 +105,67 @@ Rules:
|
|
|
103
105
|
|
|
104
106
|
|
|
105
107
|
AGENT_SYSTEM_PROMPT = EXECUTOR_PROMPT
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── file-writing hints (v11.1.0) ────────────────────────────────────────────
|
|
111
|
+
#
|
|
112
|
+
# A 1–4B local model does not fail at *writing a file*; it fails at deciding
|
|
113
|
+
# it is allowed to write one yet. The compact profile already gives that model
|
|
114
|
+
# a shorter transcript and an earlier escalation; these hints give it the two
|
|
115
|
+
# things the transcripts showed it missing most: a concrete order of
|
|
116
|
+
# operations, and permission to write the file on the very next turn.
|
|
117
|
+
#
|
|
118
|
+
# The strings are deliberately additive — ``EXECUTOR_PROMPT`` is untouched, so
|
|
119
|
+
# every caller that wants today's prompt keeps getting exactly today's prompt.
|
|
120
|
+
|
|
121
|
+
FILE_TASK_HINTS = """
|
|
122
|
+
FILE TASKS — how to actually finish one:
|
|
123
|
+
- If the request names a file to create, your FIRST action is write_file with
|
|
124
|
+
the complete content. Do not read, list, or plan around it first.
|
|
125
|
+
- args.path is a workspace-relative path (e.g. "report.md"), never absolute.
|
|
126
|
+
- args.content is the WHOLE file, not a fragment and not a description of it.
|
|
127
|
+
- After the write result appears in the transcript, verify once (read_file),
|
|
128
|
+
then finish with {"action": "final", ...}.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
COMPACT_FILE_TASK_HINTS = """
|
|
132
|
+
FILE TASKS (short form):
|
|
133
|
+
1. write_file {"path": "<name>", "content": "<the whole file>"}
|
|
134
|
+
2. read_file to confirm it exists
|
|
135
|
+
3. {"action": "final", "message": "..."}
|
|
136
|
+
Write the file on your next turn. Do not explain first.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def file_task_hints(profile: Any = None) -> str:
|
|
141
|
+
"""The file-writing hints for a loop profile.
|
|
142
|
+
|
|
143
|
+
``profile`` is an :class:`~latticeai.core.agent_profiles.AgentProfile`, its
|
|
144
|
+
name, or ``None``. The compact profile gets the numbered short form (small
|
|
145
|
+
models follow a list better than prose); everything else gets the standard
|
|
146
|
+
hints, so a strong model's prompt grows by four sentences and no rules.
|
|
147
|
+
"""
|
|
148
|
+
name = getattr(profile, "name", profile)
|
|
149
|
+
if str(name or "").strip().lower() == "compact":
|
|
150
|
+
return COMPACT_FILE_TASK_HINTS.strip()
|
|
151
|
+
return FILE_TASK_HINTS.strip()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def executor_prompt_for(
|
|
155
|
+
base: str = EXECUTOR_PROMPT,
|
|
156
|
+
*,
|
|
157
|
+
profile: Any = None,
|
|
158
|
+
self_model_summary: Optional[str] = None,
|
|
159
|
+
) -> str:
|
|
160
|
+
"""``base`` plus the file hints, and the Self-Model summary when there is one.
|
|
161
|
+
|
|
162
|
+
The injection rule is the same one the context builder follows: an empty
|
|
163
|
+
(or missing) Self-Model adds nothing at all — no header, no placeholder —
|
|
164
|
+
so a Brain that knows nothing about its owner produces the prompt it
|
|
165
|
+
always produced.
|
|
166
|
+
"""
|
|
167
|
+
parts = [str(base or "").rstrip(), file_task_hints(profile)]
|
|
168
|
+
summary = str(self_model_summary or "").strip()
|
|
169
|
+
if summary:
|
|
170
|
+
parts.append(f"ABOUT THE USER (from their Brain — honour it, do not restate it):\n{summary}")
|
|
171
|
+
return "\n\n".join(parts)
|
package/latticeai/core/config.py
CHANGED
|
@@ -172,7 +172,10 @@ class Config:
|
|
|
172
172
|
trusted_proxies = [item.strip() for item in _value(env, "LATTICEAI_TRUSTED_PROXIES", "").split(",") if item.strip()]
|
|
173
173
|
|
|
174
174
|
public_model = _value(env, "LATTICEAI_PUBLIC_MODEL", _value(env, "LATTICEAI_DEFAULT_MODEL", "openai:gpt-4o-mini"))
|
|
175
|
-
|
|
175
|
+
# Canonical HF casing (the Hub answers `gemma-4-12b-it-4bit` with
|
|
176
|
+
# `gemma-4-12B-it-4bit`); keeping it identical everywhere means the
|
|
177
|
+
# download path, the on-disk cache dir and the catalog key all agree.
|
|
178
|
+
local_model = _value(env, "LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-12B-it-4bit")
|
|
176
179
|
|
|
177
180
|
data_dir = Path(_value(env, "LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
|
|
178
181
|
static_dir = Path(_value(env, "LATTICEAI_STATIC_DIR", str(base_dir / "static")))
|