ltcai 11.1.0 → 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.
Files changed (118) hide show
  1. package/README.md +53 -53
  2. package/docs/CHANGELOG.md +33 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
  6. package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/architecture.md +6 -2
  12. package/docs/kg-schema.md +1 -1
  13. package/lattice_brain/__init__.py +1 -1
  14. package/lattice_brain/gates.py +125 -0
  15. package/lattice_brain/graph/fusion.py +35 -4
  16. package/lattice_brain/graph/projection.py +66 -8
  17. package/lattice_brain/graph/schema.py +9 -0
  18. package/lattice_brain/graph/store.py +9 -0
  19. package/lattice_brain/graph/vector_index/selector.py +32 -2
  20. package/lattice_brain/ingestion.py +175 -27
  21. package/lattice_brain/multimodal.py +525 -5
  22. package/lattice_brain/portability.py +169 -32
  23. package/lattice_brain/runtime/multi_agent.py +1 -1
  24. package/lattice_brain/sealed_box.py +244 -0
  25. package/lattice_brain/synthesis.py +24 -1
  26. package/latticeai/__init__.py +1 -1
  27. package/latticeai/api/brain_intelligence.py +4 -0
  28. package/latticeai/api/chat.py +11 -0
  29. package/latticeai/api/chat_helpers.py +16 -3
  30. package/latticeai/api/chat_hybrid.py +32 -1
  31. package/latticeai/api/features.py +70 -0
  32. package/latticeai/api/local_files.py +102 -0
  33. package/latticeai/api/portability.py +39 -4
  34. package/latticeai/api/review_queue.py +126 -0
  35. package/latticeai/api/search.py +16 -2
  36. package/latticeai/core/agent.py +55 -2
  37. package/latticeai/core/config.py +4 -1
  38. package/latticeai/core/context_builder.py +6 -3
  39. package/latticeai/core/legacy_compatibility.py +1 -1
  40. package/latticeai/core/marketplace.py +1 -1
  41. package/latticeai/core/messages.py +143 -0
  42. package/latticeai/core/model_compat.py +73 -2
  43. package/latticeai/core/workspace_os_constants.py +1 -1
  44. package/latticeai/models/model_providers.py +12 -4
  45. package/latticeai/runtime/build_phases.py +28 -0
  46. package/latticeai/runtime/chat_wiring.py +4 -0
  47. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  48. package/latticeai/runtime/router_registration.py +11 -0
  49. package/latticeai/services/app_context.py +8 -0
  50. package/latticeai/services/architecture_readiness.py +1 -1
  51. package/latticeai/services/automation_intelligence.py +22 -2
  52. package/latticeai/services/brain_intelligence.py +123 -7
  53. package/latticeai/services/command_center.py +10 -4
  54. package/latticeai/services/feature_toggles.py +502 -0
  55. package/latticeai/services/folder_watch.py +122 -1
  56. package/latticeai/services/hybrid_chat.py +56 -5
  57. package/latticeai/services/interop_bridges.py +978 -0
  58. package/latticeai/services/model_capability_registry.py +434 -261
  59. package/latticeai/services/model_catalog.py +95 -61
  60. package/latticeai/services/model_recommendation.py +18 -11
  61. package/latticeai/services/model_runtime.py +1 -1
  62. package/latticeai/services/multimodal_ports.py +26 -1
  63. package/latticeai/services/obsidian_bridge.py +16 -25
  64. package/latticeai/services/product_readiness.py +1 -1
  65. package/latticeai/services/search_service.py +149 -2
  66. package/latticeai/services/tool_dispatch.py +4 -0
  67. package/latticeai/setup/auto_setup.py +27 -30
  68. package/latticeai/setup/wizard.py +77 -44
  69. package/package.json +1 -1
  70. package/scripts/check_current_release_docs.mjs +1 -1
  71. package/scripts/check_server_i18n.mjs +1 -0
  72. package/scripts/release_screen_claims.json +13 -0
  73. package/scripts/verify_hf_model_registry.py +253 -218
  74. package/src-tauri/Cargo.lock +1 -1
  75. package/src-tauri/Cargo.toml +1 -1
  76. package/src-tauri/tauri.conf.json +1 -1
  77. package/static/app/asset-manifest.json +37 -37
  78. package/static/app/assets/{Act-D0HWqtn0.js → Act-AWf0SAKp.js} +1 -1
  79. package/static/app/assets/{AdminConsole-D-QDW-A4.js → AdminConsole-D0u8Tiyj.js} +1 -1
  80. package/static/app/assets/{Brain-CzCsI1mi.js → Brain-tuhI4sOC.js} +1 -1
  81. package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
  82. package/static/app/assets/{BrainSignals-2dHQNkns.js → BrainSignals-jMYgQ2Ar.js} +1 -1
  83. package/static/app/assets/{Capture-CT8v1StE.js → Capture-CqOSzyPr.js} +1 -1
  84. package/static/app/assets/{CommandPalette-DoLXC2KH.js → CommandPalette-DC0Bzh-I.js} +1 -1
  85. package/static/app/assets/{Library-DDoxFE5c.js → Library-CX-bbhmK.js} +1 -1
  86. package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-DBwhto14.js} +1 -1
  87. package/static/app/assets/{ProductFlow-DOYf7JIs.js → ProductFlow-BHA2cfKI.js} +1 -1
  88. package/static/app/assets/{ReviewCard-COQsqidK.js → ReviewCard-BUhCKRNM.js} +1 -1
  89. package/static/app/assets/{System-BRllvYXd.js → System-Bu2t5hn1.js} +1 -1
  90. package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
  91. package/static/app/assets/{bot-4BvN07ux.js → bot-Cia42c2h.js} +1 -1
  92. package/static/app/assets/brain-DJMoqrwx.js +1 -0
  93. package/static/app/assets/{button-CDjtnAoU.js → button-2j2Ijzgq.js} +1 -1
  94. package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-BEFeWpVW.js} +1 -1
  95. package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-ujXMcHxl.js} +1 -1
  96. package/static/app/assets/{cpu-BIlWInHf.js → cpu-k4awryFq.js} +1 -1
  97. package/static/app/assets/{download-BtjXfL3z.js → download-DFbLJ_ig.js} +1 -1
  98. package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-7y_b6xkM.js} +1 -1
  99. package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-Bidh02Kr.js} +1 -1
  100. package/static/app/assets/{index-0AvoEBzJ.js → index-BpYkzcVm.js} +3 -3
  101. package/static/app/assets/{index-vtEfYvQY.css → index-DwDl9-8Y.css} +1 -1
  102. package/static/app/assets/{input-B_5ZJ9oy.js → input-DSlJJxRs.js} +1 -1
  103. package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-Bpb83Hx9.js} +1 -1
  104. package/static/app/assets/{primitives-CVwew78r.js → primitives-BCx6TvfG.js} +1 -1
  105. package/static/app/assets/search-Cgy8cCFJ.js +1 -0
  106. package/static/app/assets/{share-2-D5zg_0fY.js → share-2-BH1M-WNi.js} +1 -1
  107. package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-BlKdBXcG.js} +1 -1
  108. package/static/app/assets/{textarea-nEVIweKY.js → textarea-CCWbUfFB.js} +1 -1
  109. package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
  110. package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-CXQiwbVT.js} +1 -1
  111. package/static/app/assets/{utils-DcDMoZIe.js → utils-zqPZJxdx.js} +2 -2
  112. package/static/app/assets/{workspace-LtRRSKTf.js → workspace-DXTihhfU.js} +1 -1
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
  116. package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
  117. package/static/app/assets/brain-uMb_5hnO.js +0 -1
  118. package/static/app/assets/search-DkhnOKZt.js +0 -1
@@ -212,8 +212,17 @@ def build_context_quality(
212
212
  lexical-only, or when retrieval failed entirely — so the frontend can
213
213
  tell the user the answer is weakly grounded instead of implying rich
214
214
  graph context. Never raises.
215
+
216
+ A fifth key, ``multimodal``, appears only when the matches really include
217
+ picture nodes (v11.2.0). "6 nodes" reads differently when two of them are
218
+ screenshots whose text came out of OCR, and until this call site passed
219
+ ``multimodal=`` the shipped chat surface could not tell the difference the
220
+ signal was built to report.
215
221
  """
216
- from lattice_brain.graph.retrieval import context_quality_signal
222
+ from lattice_brain.graph.retrieval import (
223
+ context_quality_signal,
224
+ multimodal_signal,
225
+ )
217
226
 
218
227
  query = str(query or "").strip()
219
228
  if knowledge_graph is None or not query:
@@ -233,7 +242,9 @@ def build_context_quality(
233
242
  return context_quality_signal("none", 0, reason="그래프 검색에 실패했습니다")
234
243
  matches = result.get("matches") or []
235
244
  return context_quality_signal(
236
- str(result.get("mode") or "hybrid"), len(matches)
245
+ str(result.get("mode") or "hybrid"),
246
+ len(matches),
247
+ multimodal=multimodal_signal(matches),
237
248
  )
238
249
  # Lexical-only stores without the hybrid mixin.
239
250
  try:
@@ -242,7 +253,9 @@ def build_context_quality(
242
253
  ).get("matches") or []
243
254
  except Exception: # noqa: BLE001 — signal building must never fail chat
244
255
  return context_quality_signal("none", 0, reason="그래프 검색에 실패했습니다")
245
- return context_quality_signal("lexical_only", len(matches))
256
+ return context_quality_signal(
257
+ "lexical_only", len(matches), multimodal=multimodal_signal(matches)
258
+ )
246
259
 
247
260
 
248
261
  # ── answer-citation binding (backlog #11, review §7.2 E #4) ──────────────────
@@ -14,7 +14,10 @@ from latticeai.core.network_boundary import (
14
14
  NetworkBoundaryMode,
15
15
  normalize_network_mode,
16
16
  )
17
- from latticeai.runtime.network_boundary_wiring import resolve_active_network_mode
17
+ from latticeai.runtime.network_boundary_wiring import (
18
+ get_hybrid_policy_service,
19
+ resolve_active_network_mode,
20
+ )
18
21
  from latticeai.services.hybrid_chat import stream_hybrid_cloud_turn
19
22
 
20
23
 
@@ -32,6 +35,28 @@ def resolve_request_network_mode(
32
35
  )
33
36
 
34
37
 
38
+ def resolve_hybrid_auto_commit(
39
+ *,
40
+ user_email: Optional[str],
41
+ workspace_id: Optional[str],
42
+ ) -> bool:
43
+ """The scoped hybrid policy's ``auto_commit`` decision for this turn.
44
+
45
+ Sibling of :func:`resolve_request_network_mode`: the dial says whether the
46
+ turn may reach the cloud, this says what may happen to what comes back.
47
+ Default is ``False`` — cloud-derived knowledge waits in the Review Center
48
+ — and a policy that cannot be read is treated as the default rather than
49
+ as permission.
50
+ """
51
+ try:
52
+ policy = get_hybrid_policy_service().resolve(
53
+ user_email=user_email, workspace_id=workspace_id
54
+ )
55
+ except Exception: # noqa: BLE001 — an unreadable policy never grants a write
56
+ return False
57
+ return bool(policy.get("auto_commit", False))
58
+
59
+
35
60
  def maybe_hybrid_stream_response(
36
61
  *,
37
62
  req: Any,
@@ -45,6 +70,7 @@ def maybe_hybrid_stream_response(
45
70
  chat_service: Any,
46
71
  notify: Any,
47
72
  model_id: Optional[str],
73
+ review_queue: Any = None,
48
74
  ) -> Optional[StreamingResponse]:
49
75
  """Return a StreamingResponse when cloud path should run; else None."""
50
76
  if mode != NetworkBoundaryMode.CLOUD_ALLOWED:
@@ -66,6 +92,10 @@ def maybe_hybrid_stream_response(
66
92
  history_user=history_user,
67
93
  notify=notify,
68
94
  source=req.source,
95
+ review_queue=review_queue,
96
+ auto_commit=resolve_hybrid_auto_commit(
97
+ user_email=effective_email, workspace_id=workspace_id
98
+ ),
69
99
  ),
70
100
  media_type="text/event-stream",
71
101
  headers={
@@ -78,5 +108,6 @@ def maybe_hybrid_stream_response(
78
108
 
79
109
  __all__ = [
80
110
  "resolve_request_network_mode",
111
+ "resolve_hybrid_auto_commit",
81
112
  "maybe_hybrid_stream_response",
82
113
  ]
@@ -0,0 +1,70 @@
1
+ """Feature switchboard API — read the catalog, move one switch (v11.2.0).
2
+
3
+ Two routes and no more. The catalog is rendered by the service (labels,
4
+ explanations, defaults, which options are even installable), so this router
5
+ carries no product knowledge at all: adding a feature is a line in
6
+ ``latticeai/services/feature_toggles.CATALOG``, not a change here or in the
7
+ client.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Callable, Union
13
+
14
+ from fastapi import APIRouter, HTTPException, Request
15
+ from pydantic import BaseModel, Field
16
+
17
+ from latticeai.core.messages import http_error, resolve_language
18
+ from latticeai.services.feature_toggles import (
19
+ FeatureToggleService,
20
+ InvalidFeatureValue,
21
+ UnknownFeature,
22
+ )
23
+
24
+
25
+ class SetFeatureRequest(BaseModel):
26
+ """One switch move. ``value`` is a bool for a toggle, a string for a choice."""
27
+
28
+ value: Union[bool, str] = Field(
29
+ ..., description="true/false for a toggle, or the option id for a choice"
30
+ )
31
+
32
+
33
+ def create_features_router(
34
+ *,
35
+ service: FeatureToggleService,
36
+ require_user: Callable[..., str],
37
+ ) -> APIRouter:
38
+ router = APIRouter(tags=["features"])
39
+
40
+ @router.get("/api/features")
41
+ async def list_features(request: Request) -> Any:
42
+ """Every opt-in feature, its live value, and where that value came from."""
43
+ require_user(request)
44
+ return service.catalog(resolve_language(request))
45
+
46
+ @router.post("/api/features/{feature_id}")
47
+ async def set_feature(
48
+ feature_id: str, body: SetFeatureRequest, request: Request
49
+ ) -> Any:
50
+ """Persist one person's choice; the answer is the feature as it now reads."""
51
+ user = require_user(request)
52
+ language = resolve_language(request)
53
+ try:
54
+ return service.set(
55
+ feature_id, body.value, language=language, user_email=user
56
+ )
57
+ except UnknownFeature as exc:
58
+ raise http_error(
59
+ 400, "features.unknown", language, feature=feature_id
60
+ ) from exc
61
+ except InvalidFeatureValue as exc:
62
+ # Pass-through: the service already localized the reason through the
63
+ # same catalog (wrong type, or an option whose optional dependency
64
+ # is not installed), and re-wording it here would only lose it.
65
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
66
+
67
+ return router
68
+
69
+
70
+ __all__ = ["SetFeatureRequest", "create_features_router"]
@@ -60,12 +60,36 @@ class FolderWatchEnableRequest(BaseModel):
60
60
  path: str
61
61
  recursive: bool = True
62
62
  workspace_id: Optional[str] = None
63
+ #: ``folder`` (default, unchanged) or ``vault`` — a watched external
64
+ #: Obsidian vault, which re-runs the whole bridge sync when it changes and
65
+ #: carries its own opt-in gate on top of this approval.
66
+ kind: str = "folder"
63
67
  # Watching continuously reads local disk → same approval dance as
64
68
  # /api/ingestion/folder. This is the explicit opt-in the review requires.
65
69
  approved: bool = False
66
70
  approval_token: Optional[str] = None
67
71
 
68
72
 
73
+ class InteropIngestRequest(BaseModel):
74
+ """Read one local interop source through the one ingestion gate (v11.2.0).
75
+
76
+ ``source`` picks the bridge — ``notion`` (an export directory or ``.zip``),
77
+ ``git`` (a local repository), ``mail`` (``.eml``/``.ics`` files or a folder
78
+ of them). Every one of them reads a path the user approves through the same
79
+ local-read dance as ``/api/ingestion/folder``, and ``dry_run`` reports what
80
+ a real run would touch without writing anything.
81
+ """
82
+
83
+ source: str
84
+ path: str
85
+ workspace_id: Optional[str] = None
86
+ dry_run: bool = False
87
+ #: Git only: how far back to read. Ignored by the other bridges.
88
+ max_commits: Optional[int] = None
89
+ approved: bool = False
90
+ approval_token: Optional[str] = None
91
+
92
+
69
93
  class ObsidianSyncRequest(BaseModel):
70
94
  """One-shot sync of an *external* Obsidian vault (v11.1.0).
71
95
 
@@ -107,10 +131,18 @@ def create_local_files_router(
107
131
  if folder_watch is None and ingestion_pipeline is not None and data_dir is not None:
108
132
  try:
109
133
  from latticeai.services.folder_watch import FolderWatchService
134
+ from latticeai.services.obsidian_bridge import ObsidianVaultBridge
110
135
 
111
136
  folder_watch = FolderWatchService(
112
137
  pipeline=ingestion_pipeline,
113
138
  config_path=Path(data_dir) / "folder_watch.json",
139
+ # A watched vault re-runs the *whole* bridge sync, which is what
140
+ # makes its link edges real; the bridge is handed in rather than
141
+ # imported inside the poller so the watch service keeps knowing
142
+ # nothing about how a vault is parsed.
143
+ vault_bridge=ObsidianVaultBridge(
144
+ pipeline=ingestion_pipeline, knowledge_graph=knowledge_graph,
145
+ ),
114
146
  )
115
147
  folder_watch.restore()
116
148
  except Exception: # noqa: BLE001 — watch mode is optional, never blocks routes
@@ -405,6 +437,66 @@ def create_local_files_router(
405
437
  dry_run=req.dry_run,
406
438
  )
407
439
 
440
+ # ── interop bridges: Notion export / git history / mail + calendar ───────
441
+ @router.get("/api/ingestion/interop")
442
+ async def interop_status(request: Request):
443
+ """Which interop bridges this machine can actually run, and why not."""
444
+ require_user(request)
445
+ from latticeai.services.interop_bridges import bridge_status
446
+
447
+ return bridge_status()
448
+
449
+ @router.post("/api/ingestion/interop")
450
+ async def interop_ingest(req: InteropIngestRequest, request: Request):
451
+ """Read an approved local interop source through the one gate.
452
+
453
+ Same approval dance and the same ``IngestionPipeline`` door as the
454
+ vault bridge: no vendor API, no credentials, nothing leaves the
455
+ machine. ``dry_run`` writes nothing and reports what a real run would.
456
+ """
457
+ current_user = permission_gateway.require_local_user(request)
458
+ _require_pipeline()
459
+ workspace_id = _ingestion_write_workspace(request, req.workspace_id, current_user)
460
+ path = (req.path or "").strip()
461
+ if not path:
462
+ raise http_error(400, "ingestion.interop_path_required", resolve_language(request))
463
+ from latticeai.services.interop_bridges import build_bridge
464
+
465
+ try:
466
+ bridge = build_bridge(
467
+ req.source, pipeline=ingestion_pipeline, knowledge_graph=knowledge_graph,
468
+ )
469
+ except ValueError:
470
+ raise http_error(
471
+ 400,
472
+ "ingestion.interop_unknown_source",
473
+ resolve_language(request),
474
+ source=req.source,
475
+ )
476
+ if not req.approved:
477
+ return permission_gateway.local_permission_response(path, "read", current_user)
478
+ permission_gateway.require_local_approval(
479
+ token=req.approval_token,
480
+ path=path,
481
+ action="read",
482
+ user_email=current_user,
483
+ )
484
+ options: Dict[str, Any] = {}
485
+ if req.max_commits is not None:
486
+ options["max_commits"] = req.max_commits
487
+ # Walking an export and embedding its pages is blocking work; the server
488
+ # owns one event loop and it may not sit here (10.9.0 ASYNC gate).
489
+ return await asyncio.to_thread(
490
+ lambda: bridge.sync(
491
+ path,
492
+ owner=current_user or None,
493
+ workspace_id=workspace_id,
494
+ user_email=current_user or None,
495
+ dry_run=req.dry_run,
496
+ **options,
497
+ )
498
+ )
499
+
408
500
  # ── folder watch mode: opt-in, off by default (backlog #8) ────────────────
409
501
  def _require_folder_watch(request: Request):
410
502
  _require_pipeline()
@@ -446,7 +538,16 @@ def create_local_files_router(
446
538
  owner=current_user or None,
447
539
  workspace_id=workspace_id,
448
540
  recursive=req.recursive,
541
+ kind=req.kind,
449
542
  )
543
+ if result.get("status") == "disabled":
544
+ # Turned off rather than broken: 403 with the reason, the same shape
545
+ # every other opt-in gate in this product answers with.
546
+ raise HTTPException(
547
+ status_code=403,
548
+ detail=result.get("detail")
549
+ or translate("ingestion.vault_watch_disabled", resolve_language(request)),
550
+ )
450
551
  if result.get("status") != "ok":
451
552
  raise HTTPException(
452
553
  status_code=400,
@@ -518,6 +619,7 @@ def create_local_files_router(
518
619
  __all__ = [
519
620
  "FolderIngestRequest",
520
621
  "FolderWatchEnableRequest",
622
+ "InteropIngestRequest",
521
623
  "LocalAccessRequest",
522
624
  "LocalWriteRequest",
523
625
  "ObsidianSyncRequest",
@@ -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(path, passphrase=passphrase, **body)
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 or not req.passphrase:
298
- raise ValueError("Provide an artifact, or a path with its passphrase.")
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)
@@ -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(q: str, request: Request, limit: int = 30) -> Dict[str, Any]:
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:
@@ -114,7 +114,8 @@ class AgentRunContext:
114
114
  __slots__ = ("state", "plan", "transcript", "retry_count",
115
115
  "state_history", "corrections", "final_message", "rollback_log",
116
116
  "executing_model", "reviewing_model", "approved_by_human", "trace",
117
- "on_step", "project_context", "permission_mode")
117
+ "on_step", "project_context", "permission_mode",
118
+ "self_model_summary")
118
119
 
119
120
  def __init__(self) -> None:
120
121
  self.state: AgentState = AgentState.IDLE
@@ -143,6 +144,12 @@ class AgentRunContext:
143
144
  # per-tool gate in the same run agree; ``None`` falls back to the
144
145
  # process-wide resolver on ``deps``.
145
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
146
153
 
147
154
 
148
155
  @dataclass
@@ -242,6 +249,17 @@ class AgentDeps:
242
249
  # which is exactly the pre-9.9.8 behaviour.
243
250
  permission_mode: Any = None
244
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
+
245
263
 
246
264
  class SingleAgentRuntime:
247
265
  """Drives the agent state machine over injected :class:`AgentDeps`."""
@@ -608,6 +626,41 @@ class SingleAgentRuntime:
608
626
 
609
627
  ctx.state = AgentState.VERIFYING
610
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
+
611
664
  def _executor_context(
612
665
  self, ctx: AgentRunContext, req: Any, lang_hint: str,
613
666
  current_user: str, request_workspace: Optional[str],
@@ -650,7 +703,7 @@ class SingleAgentRuntime:
650
703
  # v11.1.0: the executor prompt carries profile-aware file-writing
651
704
  # hints, because "wrote nothing at all" was the weak-model failure
652
705
  # mode the loop could not repair after the fact.
653
- f"{executor_prompt_for(d.executor_prompt, profile=profile)}\n\n"
706
+ f"{executor_prompt_for(d.executor_prompt, profile=profile, self_model_summary=self._self_model_summary(ctx, current_user, request_workspace))}\n\n"
654
707
  f"[LANGUAGE HINT: {lang_hint}]\n"
655
708
  f"Workspace root: {d.agent_root}{self._project_block(ctx)}\n\n"
656
709
  f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}{written_hint}\n\n"