ltcai 11.1.0 → 11.4.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 (289) hide show
  1. package/README.md +47 -54
  2. package/docs/CHANGELOG.md +94 -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/MULTI_AGENT_RUNTIME.md +1 -1
  8. package/docs/ONBOARDING.md +1 -1
  9. package/docs/OPERATIONS.md +6 -2
  10. package/docs/PERMISSION_MODE.md +1 -1
  11. package/docs/TRUST_MODEL.md +1 -1
  12. package/docs/WHY_LATTICE.md +1 -1
  13. package/docs/architecture.md +6 -2
  14. package/docs/kg-schema.md +2 -2
  15. package/docs/v11.3.0_PLAN.md +202 -0
  16. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
  17. package/lattice_brain/__init__.py +1 -1
  18. package/lattice_brain/gates.py +125 -0
  19. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  20. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  21. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  22. package/lattice_brain/graph/_kg_common/text.py +479 -0
  23. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  24. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  25. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  26. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  27. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  28. package/lattice_brain/graph/fusion.py +35 -4
  29. package/lattice_brain/graph/projection/__init__.py +42 -0
  30. package/lattice_brain/graph/projection/curation.py +500 -0
  31. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +81 -485
  32. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  33. package/lattice_brain/graph/retrieval/context.py +197 -0
  34. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  35. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  36. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  37. package/lattice_brain/graph/retrieval/signals.py +95 -0
  38. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  39. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  40. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  41. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  42. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  43. package/lattice_brain/graph/schema.py +9 -0
  44. package/lattice_brain/graph/store.py +9 -0
  45. package/lattice_brain/graph/vector_index/selector.py +32 -2
  46. package/lattice_brain/ingestion/__init__.py +130 -0
  47. package/lattice_brain/ingestion/_contract.py +90 -0
  48. package/lattice_brain/ingestion/constants.py +127 -0
  49. package/lattice_brain/ingestion/folder_scan.py +57 -0
  50. package/lattice_brain/ingestion/folders.py +258 -0
  51. package/lattice_brain/ingestion/hashing.py +26 -0
  52. package/lattice_brain/ingestion/jobs_api.py +107 -0
  53. package/lattice_brain/ingestion/models.py +80 -0
  54. package/lattice_brain/ingestion/pipeline.py +486 -0
  55. package/lattice_brain/ingestion/quality.py +209 -0
  56. package/lattice_brain/ingestion/routing.py +295 -0
  57. package/lattice_brain/multimodal/__init__.py +164 -0
  58. package/lattice_brain/multimodal/audio.py +77 -0
  59. package/lattice_brain/multimodal/common.py +118 -0
  60. package/lattice_brain/{multimodal.py → multimodal/images.py} +22 -262
  61. package/lattice_brain/multimodal/ports.py +169 -0
  62. package/lattice_brain/multimodal/video.py +410 -0
  63. package/lattice_brain/portability/__init__.py +90 -0
  64. package/lattice_brain/portability/_contract.py +42 -0
  65. package/lattice_brain/portability/backups.py +338 -0
  66. package/lattice_brain/portability/bundles.py +136 -0
  67. package/lattice_brain/portability/constants.py +93 -0
  68. package/lattice_brain/portability/fsops.py +138 -0
  69. package/lattice_brain/portability/service.py +41 -0
  70. package/lattice_brain/portability/sharing.py +714 -0
  71. package/lattice_brain/runtime/__init__.py +1 -1
  72. package/lattice_brain/runtime/multi_agent.py +1 -1
  73. package/lattice_brain/sealed_box.py +244 -0
  74. package/lattice_brain/synthesis.py +24 -1
  75. package/latticeai/__init__.py +1 -1
  76. package/latticeai/api/brain_intelligence.py +4 -0
  77. package/latticeai/api/chat.py +11 -0
  78. package/latticeai/api/chat_helpers.py +16 -3
  79. package/latticeai/api/chat_hybrid.py +32 -1
  80. package/latticeai/api/chronicle.py +63 -0
  81. package/latticeai/api/features.py +70 -0
  82. package/latticeai/api/local_files.py +102 -0
  83. package/latticeai/api/portability.py +39 -4
  84. package/latticeai/api/review_queue.py +126 -0
  85. package/latticeai/api/search.py +16 -2
  86. package/latticeai/core/agent/__init__.py +93 -0
  87. package/latticeai/core/agent/_contract.py +79 -0
  88. package/latticeai/core/agent/context.py +57 -0
  89. package/latticeai/core/agent/deps.py +125 -0
  90. package/latticeai/core/agent/execution.py +622 -0
  91. package/latticeai/core/agent/planning.py +145 -0
  92. package/latticeai/core/agent/recovery.py +157 -0
  93. package/latticeai/core/agent/runtime.py +210 -0
  94. package/latticeai/core/agent/verification.py +231 -0
  95. package/latticeai/core/config.py +4 -1
  96. package/latticeai/core/context_builder.py +6 -3
  97. package/latticeai/core/embedding_providers/__init__.py +151 -0
  98. package/latticeai/core/embedding_providers/base.py +199 -0
  99. package/latticeai/core/embedding_providers/captions.py +162 -0
  100. package/latticeai/core/embedding_providers/profiles.py +126 -0
  101. package/latticeai/core/embedding_providers/text.py +350 -0
  102. package/latticeai/core/embedding_providers/vision.py +352 -0
  103. package/latticeai/core/file_generation/__init__.py +115 -0
  104. package/latticeai/core/file_generation/bundles.py +76 -0
  105. package/latticeai/core/file_generation/extraction.py +154 -0
  106. package/latticeai/core/file_generation/inference.py +235 -0
  107. package/latticeai/core/file_generation/orchestration.py +152 -0
  108. package/latticeai/core/file_generation/prompting.py +117 -0
  109. package/latticeai/core/file_generation/repair.py +114 -0
  110. package/latticeai/core/file_generation/sanitize.py +61 -0
  111. package/latticeai/core/file_generation/validation.py +201 -0
  112. package/latticeai/core/legacy_compatibility.py +1 -1
  113. package/latticeai/core/marketplace.py +1 -1
  114. package/latticeai/core/messages.py +152 -0
  115. package/latticeai/core/model_compat.py +73 -2
  116. package/latticeai/core/workspace_os_constants.py +1 -1
  117. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  118. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  119. package/latticeai/integrations/telegram_bot/config.py +86 -0
  120. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  121. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  122. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  123. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  124. package/latticeai/models/model_providers.py +12 -4
  125. package/latticeai/models/router/__init__.py +88 -0
  126. package/latticeai/models/router/_contract.py +66 -0
  127. package/latticeai/models/router/branding.py +56 -0
  128. package/latticeai/models/router/catalog.py +69 -0
  129. package/latticeai/models/router/documents.py +199 -0
  130. package/latticeai/models/router/errors.py +37 -0
  131. package/latticeai/models/router/generation.py +258 -0
  132. package/latticeai/models/router/loading.py +291 -0
  133. package/latticeai/models/router/local_models.py +85 -0
  134. package/latticeai/models/router/registry.py +147 -0
  135. package/latticeai/runtime/build_phases/__init__.py +82 -0
  136. package/latticeai/runtime/build_phases/features.py +407 -0
  137. package/latticeai/runtime/build_phases/foundation.py +555 -0
  138. package/latticeai/runtime/build_phases/web.py +492 -0
  139. package/latticeai/runtime/chat_wiring.py +4 -0
  140. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  141. package/latticeai/runtime/router_registration.py +11 -0
  142. package/latticeai/runtime/runtime_context.py +1 -0
  143. package/latticeai/services/app_context.py +8 -0
  144. package/latticeai/services/architecture_readiness.py +48 -19
  145. package/latticeai/services/automation_intelligence.py +22 -2
  146. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  147. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  148. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  149. package/latticeai/services/brain_intelligence/constants.py +47 -0
  150. package/latticeai/services/brain_intelligence/digest.py +258 -0
  151. package/latticeai/services/brain_intelligence/health.py +331 -0
  152. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  153. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  154. package/latticeai/services/brain_intelligence/service.py +48 -0
  155. package/latticeai/services/chronicle.py +557 -0
  156. package/latticeai/services/command_center.py +10 -4
  157. package/latticeai/services/feature_toggles.py +502 -0
  158. package/latticeai/services/folder_watch.py +122 -1
  159. package/latticeai/services/hybrid_chat.py +56 -5
  160. package/latticeai/services/interop_bridges.py +978 -0
  161. package/latticeai/services/memory_service/__init__.py +52 -0
  162. package/latticeai/services/memory_service/_contract.py +100 -0
  163. package/latticeai/services/memory_service/brief.py +431 -0
  164. package/latticeai/services/memory_service/constants.py +57 -0
  165. package/latticeai/services/memory_service/maintenance.py +138 -0
  166. package/latticeai/services/memory_service/manager.py +186 -0
  167. package/latticeai/services/memory_service/proof.py +136 -0
  168. package/latticeai/services/memory_service/recall.py +225 -0
  169. package/latticeai/services/memory_service/service.py +48 -0
  170. package/latticeai/services/memory_service/stores.py +110 -0
  171. package/latticeai/services/model_capability_registry.py +434 -261
  172. package/latticeai/services/model_catalog.py +95 -61
  173. package/latticeai/services/model_recommendation.py +18 -11
  174. package/latticeai/services/model_runtime/__init__.py +322 -0
  175. package/latticeai/services/model_runtime/cloud.py +87 -0
  176. package/latticeai/services/model_runtime/download.py +282 -0
  177. package/latticeai/services/model_runtime/engines.py +341 -0
  178. package/latticeai/services/model_runtime/loading.py +178 -0
  179. package/latticeai/services/model_runtime/service.py +129 -0
  180. package/latticeai/services/model_runtime/state.py +131 -0
  181. package/latticeai/services/model_runtime/status.py +255 -0
  182. package/latticeai/services/multimodal_ports.py +26 -1
  183. package/latticeai/services/obsidian_bridge.py +16 -25
  184. package/latticeai/services/product_readiness.py +15 -7
  185. package/latticeai/services/search_service.py +149 -2
  186. package/latticeai/services/tool_dispatch.py +4 -0
  187. package/latticeai/setup/auto_setup.py +27 -30
  188. package/latticeai/setup/wizard/__init__.py +126 -0
  189. package/latticeai/setup/wizard/catalog.py +172 -0
  190. package/latticeai/setup/wizard/detect.py +323 -0
  191. package/latticeai/setup/wizard/install.py +348 -0
  192. package/latticeai/setup/wizard/paths.py +168 -0
  193. package/latticeai/setup/wizard/plans.py +74 -0
  194. package/latticeai/setup/wizard/recommend.py +320 -0
  195. package/package.json +6 -2
  196. package/scripts/bump_version.py +14 -0
  197. package/scripts/capture_release_evidence.mjs +33 -21
  198. package/scripts/check_current_release_docs.mjs +1 -1
  199. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  200. package/scripts/check_max_file_lines.mjs +102 -0
  201. package/scripts/check_release_evidence_bound.mjs +30 -15
  202. package/scripts/check_screenshot_pixel_delta.py +34 -4
  203. package/scripts/check_server_i18n.mjs +2 -0
  204. package/scripts/generate_rust_parity_fixtures.py +562 -0
  205. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  206. package/scripts/release_screen_claims.json +44 -2
  207. package/scripts/verify_hf_model_registry.py +253 -218
  208. package/src-tauri/Cargo.lock +361 -3
  209. package/src-tauri/Cargo.toml +6 -1
  210. package/src-tauri/src/backend.rs +349 -0
  211. package/src-tauri/src/folder.rs +33 -0
  212. package/src-tauri/src/main.rs +97 -399
  213. package/src-tauri/tauri.conf.json +1 -1
  214. package/static/app/asset-manifest.json +41 -37
  215. package/static/app/assets/Act-yYpYnn0v.js +1 -0
  216. package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
  217. package/static/app/assets/{Brain-CzCsI1mi.js → Brain-C1HBN0Wf.js} +2 -2
  218. package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
  219. package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
  220. package/static/app/assets/Capture-CFIRsFNE.js +1 -0
  221. package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
  222. package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
  223. package/static/app/assets/Library-DwO3yZST.js +1 -0
  224. package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-Jn1GK0-S.js} +1 -1
  225. package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
  226. package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
  227. package/static/app/assets/System-DW8F-2xL.js +1 -0
  228. package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
  229. package/static/app/assets/{bot-4BvN07ux.js → bot-IM_E_Y12.js} +1 -1
  230. package/static/app/assets/brain-Ci1CkWjM.js +1 -0
  231. package/static/app/assets/{button-CDjtnAoU.js → button-COwyqfHM.js} +1 -1
  232. package/static/app/assets/circle-check-DfInj-qD.js +1 -0
  233. package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-DEM4A1Y5.js} +1 -1
  234. package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-C9djDuLd.js} +1 -1
  235. package/static/app/assets/{cpu-BIlWInHf.js → cpu-DFdo1gw-.js} +1 -1
  236. package/static/app/assets/{download-BtjXfL3z.js → download-SnJL6oqk.js} +1 -1
  237. package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-CqZeDkjE.js} +1 -1
  238. package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-j1jJXYYf.js} +1 -1
  239. package/static/app/assets/{index-vtEfYvQY.css → index-BLPb5lmE.css} +1 -1
  240. package/static/app/assets/index-_u5iUHDr.js +10 -0
  241. package/static/app/assets/input-B0lPdRQZ.js +1 -0
  242. package/static/app/assets/link-2-CoFbooHS.js +1 -0
  243. package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-BsyLxtao.js} +1 -1
  244. package/static/app/assets/primitives-DEbN-d6p.js +1 -0
  245. package/static/app/assets/search-BybIWPNd.js +1 -0
  246. package/static/app/assets/{share-2-D5zg_0fY.js → share-2-CVtZ_ewX.js} +1 -1
  247. package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-CBi2GNWM.js} +1 -1
  248. package/static/app/assets/{textarea-nEVIweKY.js → textarea-DNMpB5ih.js} +1 -1
  249. package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-C83t3GXF.js} +1 -1
  250. package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
  251. package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-Dcp1OChy.js} +1 -1
  252. package/static/app/assets/utils-BlZr7Pd4.js +4 -0
  253. package/static/app/assets/workspace-jJY4RuAV.js +1 -0
  254. package/static/app/index.html +4 -4
  255. package/static/sw.js +1 -1
  256. package/lattice_brain/graph/_kg_common.py +0 -1331
  257. package/lattice_brain/graph/discovery_index.py +0 -1141
  258. package/lattice_brain/graph/retrieval.py +0 -1120
  259. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  260. package/lattice_brain/ingestion.py +0 -1377
  261. package/lattice_brain/portability.py +0 -1210
  262. package/latticeai/core/agent.py +0 -1412
  263. package/latticeai/core/embedding_providers.py +0 -1196
  264. package/latticeai/core/file_generation.py +0 -1047
  265. package/latticeai/integrations/telegram_bot.py +0 -1390
  266. package/latticeai/models/router.py +0 -1007
  267. package/latticeai/runtime/build_phases.py +0 -1422
  268. package/latticeai/services/brain_intelligence.py +0 -967
  269. package/latticeai/services/memory_service.py +0 -1177
  270. package/latticeai/services/model_runtime.py +0 -1281
  271. package/latticeai/setup/wizard.py +0 -1277
  272. package/static/app/assets/Act-D0HWqtn0.js +0 -1
  273. package/static/app/assets/AdminConsole-D-QDW-A4.js +0 -1
  274. package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
  275. package/static/app/assets/BrainSignals-2dHQNkns.js +0 -1
  276. package/static/app/assets/Capture-CT8v1StE.js +0 -1
  277. package/static/app/assets/CommandPalette-DoLXC2KH.js +0 -1
  278. package/static/app/assets/Library-DDoxFE5c.js +0 -1
  279. package/static/app/assets/ProductFlow-DOYf7JIs.js +0 -1
  280. package/static/app/assets/ReviewCard-COQsqidK.js +0 -3
  281. package/static/app/assets/System-BRllvYXd.js +0 -1
  282. package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
  283. package/static/app/assets/brain-uMb_5hnO.js +0 -1
  284. package/static/app/assets/index-0AvoEBzJ.js +0 -10
  285. package/static/app/assets/input-B_5ZJ9oy.js +0 -1
  286. package/static/app/assets/primitives-CVwew78r.js +0 -1
  287. package/static/app/assets/search-DkhnOKZt.js +0 -1
  288. package/static/app/assets/utils-DcDMoZIe.js +0 -4
  289. package/static/app/assets/workspace-LtRRSKTf.js +0 -1
@@ -44,7 +44,7 @@ orchestrator, and the agent runtime service. Lazy-loaded so importing
44
44
  /agents/api/runtime/* , POST /agents (start via runtime.start or executor)
45
45
  api/chat.py
46
46
  from latticeai.services.tool_dispatch import build_agent_runtime
47
- -> latticeai/core/agent.py:SingleAgentRuntime (별도 state/plan/transcript 머신, single-agent /agent 경로. dispatch_tool 만 공유)
47
+ -> latticeai/core/agent/:SingleAgentRuntime (별도 state/plan/transcript 머신, single-agent /agent 경로. dispatch_tool 만 공유)
48
48
  latticeai/services/tool_dispatch.py:14
49
49
  from latticeai.core.agent import SingleAgentRuntime
50
50
  (tool governance + core single-agent용)
@@ -49,7 +49,7 @@ from typing import Any, Callable, Dict, List, Optional
49
49
  from ..utils import now_iso as _now
50
50
  from .contracts import multi_agent_contract
51
51
 
52
- MULTI_AGENT_VERSION = "11.1.0"
52
+ MULTI_AGENT_VERSION = "11.4.0"
53
53
 
54
54
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
55
55
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -0,0 +1,244 @@
1
+ """Sealing a bundle to a recipient's public key (v11.2.0).
2
+
3
+ Through 11.1.0 a shared subgraph was encrypted with a passphrase, which means
4
+ the sender and the receiver must first agree on a secret over some *other*
5
+ channel — and whatever that channel is, the passphrase is now in it. This
6
+ module removes that step: the receiver publishes an X25519 public key, the
7
+ sender seals a bundle to it, and only the holder of the matching private key
8
+ can open it. Nothing secret ever travels.
9
+
10
+ The construction is the standard sealed box, assembled from primitives
11
+ ``cryptography`` already ships (it is a core dependency; nothing new is added):
12
+
13
+ 1. the sender generates a **single-use ephemeral X25519 keypair**;
14
+ 2. ``ephemeral_private × recipient_public`` yields a shared secret only the two
15
+ of them can compute;
16
+ 3. **HKDF-SHA256** stretches that secret into a 256-bit key, with both public
17
+ keys mixed into ``info`` so a key derived for one recipient can never be
18
+ reused for another;
19
+ 4. **AES-256-GCM** encrypts the payload under it.
20
+
21
+ Forward secrecy comes free: the ephemeral private key is discarded the moment
22
+ the box is sealed, so a later compromise of the recipient's long-term key still
23
+ does not decrypt an already-sent bundle — nothing else in the envelope can
24
+ reconstruct the shared secret.
25
+
26
+ The sender's Ed25519 signature over the bundle header is **untouched** by all
27
+ of this. Signing says who wrote it; sealing says who may read it. Collapsing
28
+ the two into one keypair is the usual way to end up with neither.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import base64
34
+ import hashlib
35
+ import os
36
+ from pathlib import Path
37
+ from typing import Any, Dict, Optional
38
+
39
+ from cryptography.exceptions import InvalidTag
40
+ from cryptography.hazmat.primitives import hashes, serialization
41
+ from cryptography.hazmat.primitives.asymmetric.x25519 import (
42
+ X25519PrivateKey,
43
+ X25519PublicKey,
44
+ )
45
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
46
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
47
+
48
+ #: Named in the envelope so a future construction can be told apart from this
49
+ #: one instead of being silently mis-decrypted.
50
+ SEALED_BOX_ALGORITHM = "x25519-hkdf-sha256-aes256gcm"
51
+ #: Domain separation for the KDF — this key schedule is for subgraph bundles
52
+ #: and nothing else.
53
+ HKDF_INFO_PREFIX = b"latticeai.subgraph.sealed-box.v1"
54
+ KEY_BYTES = 32
55
+ NONCE_BYTES = 12
56
+ SALT_BYTES = 16
57
+ #: Filename of the recipient key under the data directory (0600, like the
58
+ #: device identity's key file).
59
+ RECIPIENT_KEY_FILENAME = "subgraph_recipient.key"
60
+
61
+
62
+ def _b64(data: bytes) -> str:
63
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
64
+
65
+
66
+ def _unb64(text: str) -> bytes:
67
+ padded = str(text) + "=" * (-len(str(text)) % 4)
68
+ return base64.urlsafe_b64decode(padded.encode("ascii"))
69
+
70
+
71
+ def _raw_public(key: X25519PublicKey) -> bytes:
72
+ return key.public_bytes(
73
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
74
+ )
75
+
76
+
77
+ def public_key_fingerprint(public_key_b64: str) -> str:
78
+ """Human-comparable fingerprint of an X25519 public key.
79
+
80
+ Raises ``ValueError`` when the input is not a usable key — callers use this
81
+ as the validation gate before sealing anything to a pasted string.
82
+ """
83
+ try:
84
+ raw = _unb64(public_key_b64)
85
+ X25519PublicKey.from_public_bytes(raw)
86
+ except Exception as exc: # noqa: BLE001 — any parse failure is one answer
87
+ raise ValueError(f"not a valid X25519 public key: {exc}") from exc
88
+ digest = hashlib.sha256(raw).hexdigest()
89
+ return ":".join(digest[i : i + 4] for i in range(0, 16, 4))
90
+
91
+
92
+ def _derive(shared: bytes, salt: bytes, ephemeral: bytes, recipient: bytes) -> bytes:
93
+ return HKDF(
94
+ algorithm=hashes.SHA256(),
95
+ length=KEY_BYTES,
96
+ salt=salt,
97
+ info=HKDF_INFO_PREFIX + b"|" + ephemeral + b"|" + recipient,
98
+ ).derive(shared)
99
+
100
+
101
+ def seal(plaintext: bytes, *, recipient_public_key: str) -> Dict[str, Any]:
102
+ """Encrypt ``plaintext`` so only the recipient's private key can open it.
103
+
104
+ Returns the JSON-safe crypto block that goes in the bundle envelope. The
105
+ recipient's own public key is echoed back into it so a receiver holding
106
+ several keys can tell at a glance whether this box was meant for them —
107
+ without trying to decrypt it, and without the sender having to be asked.
108
+ """
109
+ try:
110
+ recipient_raw = _unb64(recipient_public_key)
111
+ peer = X25519PublicKey.from_public_bytes(recipient_raw)
112
+ except Exception as exc: # noqa: BLE001 — a bad key is a caller error, said plainly
113
+ raise ValueError(f"not a valid X25519 public key: {exc}") from exc
114
+ ephemeral = X25519PrivateKey.generate()
115
+ ephemeral_raw = _raw_public(ephemeral.public_key())
116
+ salt = os.urandom(SALT_BYTES)
117
+ nonce = os.urandom(NONCE_BYTES)
118
+ key = _derive(ephemeral.exchange(peer), salt, ephemeral_raw, recipient_raw)
119
+ ciphertext = AESGCM(key).encrypt(nonce, plaintext, None)
120
+ return {
121
+ "algorithm": SEALED_BOX_ALGORITHM,
122
+ "ephemeral_public_key": _b64(ephemeral_raw),
123
+ "recipient_public_key": _b64(recipient_raw),
124
+ "recipient_fingerprint": public_key_fingerprint(recipient_public_key),
125
+ "salt": _b64(salt),
126
+ "nonce": _b64(nonce),
127
+ "ciphertext": _b64(ciphertext),
128
+ }
129
+
130
+
131
+ def unseal(block: Dict[str, Any], private_key: X25519PrivateKey) -> bytes:
132
+ """Open a sealed box with the recipient's private key.
133
+
134
+ Fail-closed and specific: an unknown algorithm, a missing field, a box
135
+ addressed to a different key, and a failed tag check each say which one
136
+ happened rather than collapsing into "decryption failed".
137
+ """
138
+ algorithm = str((block or {}).get("algorithm") or "")
139
+ if algorithm != SEALED_BOX_ALGORITHM:
140
+ raise ValueError(
141
+ f"unsupported sealed-box algorithm: {algorithm or 'missing'}"
142
+ )
143
+ try:
144
+ ephemeral_raw = _unb64(block["ephemeral_public_key"])
145
+ salt = _unb64(block["salt"])
146
+ nonce = _unb64(block["nonce"])
147
+ ciphertext = _unb64(block["ciphertext"])
148
+ peer = X25519PublicKey.from_public_bytes(ephemeral_raw)
149
+ except Exception as exc: # noqa: BLE001 — a malformed envelope is one answer
150
+ raise ValueError(f"sealed bundle is missing encryption metadata: {exc}") from exc
151
+ recipient_raw = _raw_public(private_key.public_key())
152
+ addressed = str(block.get("recipient_public_key") or "")
153
+ if addressed and _unb64(addressed) != recipient_raw:
154
+ raise ValueError(
155
+ "this bundle was sealed to a different recipient key "
156
+ f"({block.get('recipient_fingerprint') or 'unknown fingerprint'})."
157
+ )
158
+ key = _derive(private_key.exchange(peer), salt, ephemeral_raw, recipient_raw)
159
+ try:
160
+ return bytes(AESGCM(key).decrypt(nonce, ciphertext, None))
161
+ except InvalidTag as exc:
162
+ raise ValueError(
163
+ "Sealed bundle decryption failed; the recipient key or the bundle is invalid."
164
+ ) from exc
165
+
166
+
167
+ class RecipientIdentity:
168
+ """This Brain's X25519 receiving keypair — loads or creates, file-backed.
169
+
170
+ Deliberately *not* the device's Ed25519 identity. That key signs, and a
171
+ signing key that also decrypts is a key whose compromise costs twice. The
172
+ private half lives in a 0600 file under the data directory and never leaves
173
+ the machine; :meth:`describe` reports exactly that rather than implying a
174
+ keychain this class does not use.
175
+ """
176
+
177
+ def __init__(self, data_dir: Any) -> None:
178
+ self._data_dir = Path(data_dir)
179
+ self._key_file = self._data_dir / RECIPIENT_KEY_FILENAME
180
+ self._private = self._load_or_create()
181
+
182
+ def _load_or_create(self) -> X25519PrivateKey:
183
+ if self._key_file.exists():
184
+ return X25519PrivateKey.from_private_bytes(
185
+ _unb64(self._key_file.read_text(encoding="utf-8").strip())
186
+ )
187
+ key = X25519PrivateKey.generate()
188
+ raw = key.private_bytes(
189
+ serialization.Encoding.Raw,
190
+ serialization.PrivateFormat.Raw,
191
+ serialization.NoEncryption(),
192
+ )
193
+ self._data_dir.mkdir(parents=True, exist_ok=True)
194
+ self._key_file.write_text(_b64(raw), encoding="utf-8")
195
+ os.chmod(self._key_file, 0o600)
196
+ return key
197
+
198
+ @property
199
+ def public_key_b64(self) -> str:
200
+ return _b64(_raw_public(self._private.public_key()))
201
+
202
+ @property
203
+ def fingerprint(self) -> str:
204
+ return public_key_fingerprint(self.public_key_b64)
205
+
206
+ def describe(self) -> Dict[str, Any]:
207
+ return {
208
+ "public_key": self.public_key_b64,
209
+ "fingerprint": self.fingerprint,
210
+ "algorithm": SEALED_BOX_ALGORITHM,
211
+ "storage": "file",
212
+ "path": str(self._key_file),
213
+ }
214
+
215
+ def seal_to_self(self, plaintext: bytes) -> Dict[str, Any]:
216
+ """Seal to this Brain's own key — the honest way to test a round trip."""
217
+ return seal(plaintext, recipient_public_key=self.public_key_b64)
218
+
219
+ def unseal(self, block: Dict[str, Any]) -> bytes:
220
+ return unseal(block, self._private)
221
+
222
+
223
+ def load_recipient_identity(data_dir: Any) -> Optional[RecipientIdentity]:
224
+ """Best-effort recipient key, or ``None`` when the directory is unusable.
225
+
226
+ Sharing is opt-in and a missing key is a *state*, not a crash: the status
227
+ surface reports "no receiving key" rather than failing a status read.
228
+ """
229
+ try:
230
+ return RecipientIdentity(data_dir)
231
+ except (OSError, ValueError): # noqa: BLE001 — unwritable dir / corrupt key file
232
+ return None
233
+
234
+
235
+ __all__ = [
236
+ "HKDF_INFO_PREFIX",
237
+ "RECIPIENT_KEY_FILENAME",
238
+ "SEALED_BOX_ALGORITHM",
239
+ "RecipientIdentity",
240
+ "load_recipient_identity",
241
+ "public_key_fingerprint",
242
+ "seal",
243
+ "unseal",
244
+ ]
@@ -35,6 +35,7 @@ import os
35
35
  from datetime import datetime, timezone
36
36
  from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
37
37
 
38
+ from .gates import FeatureGate
38
39
  from .graph.proactive import ProactiveBrain
39
40
  from .graph.schema import KGStoreV2
40
41
  from .quality import content_signature
@@ -60,6 +61,19 @@ CONTRADICTION_RESOLUTIONS = ("keep_old", "replace", "keep_both_temporal")
60
61
  SYNTHESIS_THRESHOLD_ENV = "LATTICEAI_SYNTHESIS_THRESHOLD"
61
62
  DEFAULT_SYNTHESIS_THRESHOLD = 25
62
63
 
64
+ #: Whether the Brain is allowed to start a pass *by itself*. Default on — this
65
+ #: is the behaviour 11.1.0 shipped — and it governs only the automatic path
66
+ #: (:meth:`BrainSynthesizer.run_if_due`). An explicit run a person asked for is
67
+ #: never gated: turning "tidy up on its own" off means the Brain stops deciding
68
+ #: when, not that the button stops working.
69
+ SYNTHESIS_ENV = "LATTICEAI_SYNTHESIS"
70
+ SYNTHESIS_GATE = FeatureGate(
71
+ SYNTHESIS_ENV,
72
+ default=True,
73
+ name="synthesis",
74
+ detail="The Brain reviews accumulated material on its own and proposes tidy-ups.",
75
+ )
76
+
63
77
  _MIN_SHARED_TOKENS = 3
64
78
  _MIN_CLUSTER_MEMBERS = 3
65
79
  _EDGE_SIMILARITY = 0.35
@@ -516,7 +530,14 @@ class BrainSynthesizer:
516
530
  self, result: Any, *, workspace_id: Optional[str] = None,
517
531
  user_email: Optional[str] = None,
518
532
  ) -> Optional[Dict[str, Any]]:
519
- """Run synthesis only when this ingest pushed the counter over."""
533
+ """Run synthesis only when this ingest pushed the counter over.
534
+
535
+ The gate is checked *before* the counter moves, so "off" means off: a
536
+ long import while the switch is down does not bank a pass that fires the
537
+ instant someone turns it back on.
538
+ """
539
+ if not SYNTHESIS_GATE.enabled():
540
+ return None
520
541
  if not self.observe_ingest(result):
521
542
  return None
522
543
  return self.run(workspace_id=workspace_id, user_email=user_email)
@@ -792,6 +813,8 @@ __all__ = [
792
813
  "ContradictionResolutionError",
793
814
  "EDGE_KIND",
794
815
  "ProposalDesk",
816
+ "SYNTHESIS_ENV",
817
+ "SYNTHESIS_GATE",
795
818
  "SYNTHESIS_REVIEW_SOURCE",
796
819
  "SYNTHESIS_THRESHOLD_ENV",
797
820
  "SynthesisTrigger",
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "11.1.0"
3
+ __version__ = "11.4.0"
@@ -85,6 +85,10 @@ def create_brain_intelligence_router(
85
85
  Fixed contract consumed by the frontend:
86
86
  ``{"status": "ready"|"pending"|"unavailable", "pending_items": int,
87
87
  "total_items": int, "detail": str}``.
88
+
89
+ Additive since v11.2.0: ``breakdown`` splits the backlog into
90
+ ``embedded``/``missing``/``stale``/``queued`` when the store can. The
91
+ four keys above are unchanged, so existing readers see no difference.
88
92
  """
89
93
  user = require_user(request)
90
94
  scope = gate_read(request)
@@ -111,6 +111,16 @@ def create_chat_router(context: AppContext) -> APIRouter:
111
111
  get_history_user=context.require("get_history_user"),
112
112
  )
113
113
 
114
+ def review_sink() -> object:
115
+ """The Review Center queue, resolved per request (may be absent).
116
+
117
+ Cloud-derived knowledge is staged here rather than written, so the
118
+ hybrid turn needs the live queue — which is wired after this router's
119
+ context is built, hence a provider rather than a captured value.
120
+ """
121
+ provider = context.review_queue
122
+ return None if provider is None else provider()
123
+
114
124
  def notify_chat_message(role: str, text: str, source: Optional[str]) -> None:
115
125
  """Mirror persisted web exchanges to an injected bridge, never echoing it."""
116
126
  if context.on_chat_message is None or source == "telegram":
@@ -445,6 +455,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
445
455
  chat_service=chat_service,
446
456
  notify=notify_chat_message,
447
457
  model_id=selected_model_id,
458
+ review_queue=review_sink(),
448
459
  )
449
460
  if hybrid_response is not None:
450
461
  return hybrid_response
@@ -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,63 @@
1
+ """Brain Chronicle API router (v11.3.0, track B).
2
+
3
+ Read-only surface over :class:`~latticeai.services.chronicle.ChronicleService`
4
+ — the first HTTP exposure of the bitemporal data v11.1.0 started recording:
5
+
6
+ * ``GET /api/chronicle/overview`` — the growth curve: totals, the first and
7
+ last thing the Brain ever saw, and one bucket per day.
8
+ * ``GET /api/chronicle/day/{date}`` — one day's story, grouped into what came
9
+ in, what was learned, what was talked about, and what changed.
10
+ * ``GET /api/chronicle/as-of?ts=…`` — the Brain as it stood at an instant.
11
+
12
+ All three follow the same require_user/gate_read contract as the command
13
+ centre, so a workspace member never reads another workspace's timeline. A
14
+ malformed date or timestamp answers 422 through the message catalog rather
15
+ than a 500 or a silently empty day.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Callable, Optional
21
+
22
+ from fastapi import APIRouter, Query, Request
23
+
24
+ from latticeai.core.messages import http_error, resolve_language
25
+ from latticeai.services.chronicle import ChronicleService
26
+
27
+
28
+ def create_chronicle_router(
29
+ *,
30
+ service: ChronicleService,
31
+ require_user: Callable[[Request], str],
32
+ gate_read: Callable[[Request], Optional[str]],
33
+ ) -> APIRouter:
34
+ router = APIRouter()
35
+
36
+ @router.get("/api/chronicle/overview")
37
+ async def chronicle_overview(request: Request):
38
+ user = require_user(request)
39
+ scope = gate_read(request)
40
+ return service.overview(user_email=user, workspace_id=scope)
41
+
42
+ @router.get("/api/chronicle/day/{date}")
43
+ async def chronicle_day(date: str, request: Request):
44
+ user = require_user(request)
45
+ scope = gate_read(request)
46
+ try:
47
+ return service.day(date, user_email=user, workspace_id=scope)
48
+ except ValueError:
49
+ raise http_error(422, "chronicle.bad_date", resolve_language(request))
50
+
51
+ @router.get("/api/chronicle/as-of")
52
+ async def chronicle_as_of(request: Request, ts: str = Query(..., max_length=64)):
53
+ require_user(request)
54
+ scope = gate_read(request)
55
+ try:
56
+ return service.as_of(ts, workspace_id=scope)
57
+ except ValueError:
58
+ raise http_error(422, "chronicle.bad_timestamp", resolve_language(request))
59
+
60
+ return router
61
+
62
+
63
+ __all__ = ["create_chronicle_router"]
@@ -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"]