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
@@ -0,0 +1,557 @@
1
+ """Brain Chronicle — the Brain's own timeline (v11.3.0, track B).
2
+
3
+ v11.1.0 gave every memory a validity window (``valid_from``/``valid_to``/
4
+ ``superseded_by``), recorded each sighting of a relationship in
5
+ ``edge_occurrences``, and shipped ``store.as_of()``. None of it had a reader:
6
+ the promise "your Brain remembers *when*" was true in the database and
7
+ invisible in the product.
8
+
9
+ This service is that reader, and nothing more. Three answers, all derived
10
+ from rows that already exist:
11
+
12
+ * :meth:`overview` — how the Brain grew, one bucket per day.
13
+ * :meth:`day` — what actually happened on one day, grouped the way a person
14
+ would tell it: what came in, what was learned, what was talked about, what
15
+ changed its mind.
16
+ * :meth:`as_of` — the Brain as it stood at an instant, read through
17
+ ``store.as_of()`` so the temporal predicate has exactly one implementation.
18
+
19
+ Three rules hold the whole module:
20
+
21
+ **Read-only.** No writes, no schema changes, no model calls. Every number is
22
+ a re-arrangement of stored facts, so the chronicle can never invent a memory.
23
+
24
+ **One timezone.** Day buckets come from :mod:`latticeai.core.timezones`, the
25
+ same helper the audit log and "events today" already use. A stamp that
26
+ carries an offset is converted into that zone; a naive stamp is taken as
27
+ already being in it, because that is what the store writes
28
+ (``lattice_brain.utils.now_iso``). Without this, a Seoul user's midnight
29
+ uploads would file themselves under yesterday.
30
+
31
+ **Workspace scoping, fail-closed.** ``workspace_id=None`` means the unscoped
32
+ single-user read (what ``CommandCenterService`` expresses as
33
+ ``allowed_workspaces=None``); a workspace id restricts every lane to that
34
+ workspace and *excludes* legacy-global rows, exactly as
35
+ ``_scope_kwargs`` does there. The predicate is bound as a parameter rather
36
+ than interpolated, so there is no query in this module whose text depends on
37
+ input.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import logging
43
+ import re
44
+ import sqlite3
45
+ from datetime import date as date_type
46
+ from datetime import datetime
47
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
48
+
49
+ from latticeai.core.timeutil import parse_iso
50
+ from latticeai.core.timezones import get_timezone
51
+
52
+ LOGGER = logging.getLogger(__name__)
53
+
54
+ _TAG = re.compile(r"<[^>]*>")
55
+ _DAY = re.compile(r"\d{4}-\d{2}-\d{2}")
56
+
57
+ #: Conversation previews are one line, plain text, and short. The chronicle
58
+ #: renders them as cards, never as HTML.
59
+ _PREVIEW_LIMIT = 140
60
+
61
+ #: A single day can carry thousands of rows. ``counts`` stays truthful about
62
+ #: the whole day; the listed items stop here so one busy day cannot return an
63
+ #: unbounded payload.
64
+ _GROUP_LIMIT = 200
65
+
66
+ #: "What was important then" is a short list by construction.
67
+ _TOP_ENTITY_LIMIT = 12
68
+
69
+ #: ``store.as_of()`` clamps its own limit at 2000. Asking for the ceiling
70
+ #: keeps the rewind slice as complete as the store is willing to return.
71
+ _AS_OF_LIMIT = 2000
72
+
73
+ _SOURCES_SQL = """
74
+ SELECT id,
75
+ node_id,
76
+ COALESCE(title, '') AS title,
77
+ source_type,
78
+ COALESCE(captured_at, created_at) AS at
79
+ FROM ingestion_provenance
80
+ WHERE (:workspace IS NULL OR workspace_id = :workspace)
81
+ ORDER BY at ASC, id ASC
82
+ """
83
+
84
+ # The excluded types are containers of raw material rather than things the
85
+ # Brain learned: they are already counted in the ``sources`` and
86
+ # ``conversations`` lanes, and counting them again as "concepts" would make
87
+ # every upload look like two memories. ``nodes_v2.type`` is always the
88
+ # canonical upper-case type; ``legacy_type`` keeps the label the writer used,
89
+ # which is what a reader should see.
90
+ _ENTITIES_SQL = """
91
+ SELECT id,
92
+ label,
93
+ COALESCE(NULLIF(legacy_type, ''), type) AS type,
94
+ created_at AS at
95
+ FROM nodes_v2
96
+ WHERE (:workspace IS NULL OR workspace_id = :workspace)
97
+ AND type NOT IN ('DOCUMENT', 'CHUNK', 'FILE', 'MESSAGE', 'CONVERSATION')
98
+ ORDER BY at ASC, id ASC
99
+ """
100
+
101
+ # A relationship is filed under the day it was first *observed*
102
+ # (``edge_occurrences.observed_at``), falling back to the row's own
103
+ # ``created_at`` for edges written by paths that record no occurrence.
104
+ _CONNECTIONS_SQL = """
105
+ SELECT e.id AS id,
106
+ COALESCE(MIN(o.observed_at), e.created_at) AS at
107
+ FROM edges_v2 e
108
+ LEFT JOIN edge_occurrences o ON o.edge_id = e.id
109
+ WHERE e.source IN (
110
+ SELECT id FROM nodes_v2
111
+ WHERE (:workspace IS NULL OR workspace_id = :workspace))
112
+ AND e.target IN (
113
+ SELECT id FROM nodes_v2
114
+ WHERE (:workspace IS NULL OR workspace_id = :workspace))
115
+ GROUP BY e.id, e.created_at
116
+ ORDER BY at ASC, e.id ASC
117
+ """
118
+
119
+ # Rows with no ``user_email`` are pre-auth / single-user history: excluding
120
+ # them would empty the chronicle of every conversation a solo Brain ever had.
121
+ # This is the same allowance ``ConversationStore._scope_sql`` makes.
122
+ _MESSAGES_SQL = """
123
+ SELECT COALESCE(conversation_id, '') AS conversation_id,
124
+ role,
125
+ content,
126
+ timestamp AS at
127
+ FROM conversation_messages
128
+ WHERE (:workspace IS NULL OR workspace_id = :workspace)
129
+ AND (:user IS NULL OR user_email = :user
130
+ OR user_email IS NULL OR user_email = '')
131
+ ORDER BY id ASC
132
+ """
133
+
134
+ _CHANGED_NODES_SQL = """
135
+ SELECT id,
136
+ label,
137
+ superseded_by,
138
+ COALESCE(valid_to, updated_at) AS at
139
+ FROM nodes_v2
140
+ WHERE (:workspace IS NULL OR workspace_id = :workspace)
141
+ AND (valid_to IS NOT NULL OR superseded_by IS NOT NULL)
142
+ ORDER BY at ASC, id ASC
143
+ """
144
+
145
+ # ``edges_v2`` has no ``updated_at``, so an edge that was superseded without
146
+ # a ``valid_to`` carries no instant to file the change under. Rather than
147
+ # inventing one from ``created_at`` — which would date the change to when the
148
+ # relationship was *made* — such a row is left out and said so here.
149
+ #
150
+ # No product path stamps an edge's ``valid_to`` yet (v11.3.0), so this lane is
151
+ # empty on an ordinary Brain, and that zero is honest rather than hidden. It
152
+ # is read anyway because ``store.as_of()`` already honours the column: an edge
153
+ # outside its window disappears from a rewind, and a chronicle that ignored
154
+ # the same column would contradict the panel sitting next to it.
155
+ _CHANGED_EDGES_SQL = """
156
+ SELECT e.id AS id,
157
+ e.source AS node_id,
158
+ s.label AS source_label,
159
+ t.label AS target_label,
160
+ e.superseded_by AS superseded_by,
161
+ e.valid_to AS at
162
+ FROM edges_v2 e
163
+ JOIN nodes_v2 s ON s.id = e.source
164
+ JOIN nodes_v2 t ON t.id = e.target
165
+ WHERE (:workspace IS NULL OR s.workspace_id = :workspace)
166
+ AND (:workspace IS NULL OR t.workspace_id = :workspace)
167
+ AND e.valid_to IS NOT NULL
168
+ ORDER BY at ASC, e.id ASC
169
+ """
170
+
171
+ _LANES = ("sources", "entities", "connections", "conversations")
172
+
173
+
174
+ # ── time ────────────────────────────────────────────────────────────────────
175
+
176
+
177
+ def _local(moment: datetime) -> datetime:
178
+ """``moment`` as naive wall-clock time in the configured timezone."""
179
+ if moment.tzinfo is None:
180
+ return moment
181
+ return moment.astimezone(get_timezone()).replace(tzinfo=None)
182
+
183
+
184
+ def _moment(value: Any) -> Optional[datetime]:
185
+ """Parse a stored stamp, or ``None`` when it is unreadable.
186
+
187
+ Unreadable stamps exist: the conversation store writes
188
+ ``str(item.get("timestamp") or "")``, so an import with no timestamp
189
+ lands as an empty string. Such a row has no day to belong to and is
190
+ dropped from every count rather than being filed under today.
191
+ """
192
+ parsed = parse_iso(str(value or "").strip())
193
+ if parsed is None:
194
+ return None
195
+ return _local(parsed)
196
+
197
+
198
+ def _day_of(value: Any) -> Optional[str]:
199
+ """``YYYY-MM-DD`` in the configured timezone, or ``None``."""
200
+ moment = _moment(value)
201
+ if moment is None:
202
+ return None
203
+ return moment.date().isoformat()
204
+
205
+
206
+ def parse_day(value: Any) -> str:
207
+ """Validate a ``YYYY-MM-DD`` path segment, returning it normalized.
208
+
209
+ Raises ``ValueError`` for anything else — including a well-shaped but
210
+ impossible date such as ``2026-13-45`` — which the router turns into 422.
211
+ """
212
+ text = str(value or "").strip()
213
+ if not _DAY.fullmatch(text):
214
+ raise ValueError(f"chronicle date must be YYYY-MM-DD: {value!r}")
215
+ try:
216
+ parsed = date_type.fromisoformat(text)
217
+ except ValueError:
218
+ raise ValueError(f"chronicle date is not a real date: {value!r}") from None
219
+ return parsed.isoformat()
220
+
221
+
222
+ def parse_timestamp(value: Any) -> str:
223
+ """Normalize an ISO-8601 instant into the store's own stamp format.
224
+
225
+ The store writes naive local seconds, so an offset-aware input is moved
226
+ into the configured timezone *here* rather than being handed to
227
+ ``store.as_of()`` with a suffix no stored row carries.
228
+ """
229
+ parsed = parse_iso(str(value or "").strip())
230
+ if parsed is None:
231
+ raise ValueError(f"chronicle timestamp must be ISO-8601: {value!r}")
232
+ return _local(parsed).isoformat(timespec="seconds")
233
+
234
+
235
+ # ── shaping ─────────────────────────────────────────────────────────────────
236
+
237
+
238
+ def _preview(text: Any) -> str:
239
+ """First non-empty line, tags stripped, whitespace collapsed, truncated."""
240
+ flat = _TAG.sub(" ", str(text or ""))
241
+ first = next((line for line in (raw.strip() for raw in flat.splitlines()) if line), "")
242
+ collapsed = " ".join(first.split())
243
+ if len(collapsed) > _PREVIEW_LIMIT:
244
+ return collapsed[: _PREVIEW_LIMIT - 1].rstrip() + "…"
245
+ return collapsed
246
+
247
+
248
+ def _empty_lanes() -> Dict[str, int]:
249
+ return {lane: 0 for lane in _LANES}
250
+
251
+
252
+ def _scope_kwargs(workspace_id: Optional[str]) -> Dict[str, Any]:
253
+ """The scoped-read kwargs the graph store expects (house pattern)."""
254
+ return {
255
+ "allowed_workspaces": {workspace_id} if workspace_id is not None else None,
256
+ "include_legacy_global": workspace_id is None,
257
+ }
258
+
259
+
260
+ def _on_day(rows: Sequence[Any], day: str) -> List[Any]:
261
+ return [row for row in rows if _day_of(row["at"]) == day]
262
+
263
+
264
+ def _conversation_cards(rows: Sequence[Any]) -> List[Dict[str, Any]]:
265
+ """One card per conversation that had messages on the day.
266
+
267
+ Messages with no ``conversation_id`` (imported legacy history) share the
268
+ empty id, so a day's loose messages become one card instead of vanishing.
269
+ """
270
+ grouped: Dict[str, List[Any]] = {}
271
+ for row in rows:
272
+ grouped.setdefault(str(row["conversation_id"]), []).append(row)
273
+ cards: List[Dict[str, Any]] = []
274
+ for conversation_id, items in grouped.items():
275
+ asked = [item for item in items if item["role"] == "user"]
276
+ lead = asked[0] if asked else items[0]
277
+ cards.append(
278
+ {
279
+ "conversation_id": conversation_id,
280
+ "preview": _preview(lead["content"]),
281
+ "messages": len(items),
282
+ "started_at": items[0]["at"],
283
+ }
284
+ )
285
+ return cards
286
+
287
+
288
+ def _change_cards(nodes: Sequence[Any], edges: Sequence[Any]) -> List[Dict[str, Any]]:
289
+ """What stopped being true on the day, facts first then relationships.
290
+
291
+ A resolved contradiction lands here without a table of its own:
292
+ ``lattice_brain.synthesis.resolve_contradiction`` settles a pair by
293
+ stamping ``valid_to`` (and ``superseded_by`` for replace/keep_old) on
294
+ ``nodes_v2``, which is precisely what ``_CHANGED_NODES_SQL`` selects.
295
+ """
296
+ cards: List[Dict[str, Any]] = []
297
+ for row in nodes:
298
+ cards.append(
299
+ {
300
+ "kind": "fact_superseded" if row["superseded_by"] else "fact_retired",
301
+ "label": row["label"],
302
+ "at": row["at"],
303
+ "node_id": row["id"],
304
+ }
305
+ )
306
+ for row in edges:
307
+ kind = "connection_superseded" if row["superseded_by"] else "connection_ended"
308
+ cards.append(
309
+ {
310
+ "kind": kind,
311
+ "label": f"{row['source_label']} → {row['target_label']}",
312
+ "at": row["at"],
313
+ "node_id": row["node_id"],
314
+ }
315
+ )
316
+ cards.sort(key=lambda card: (str(card["at"]), str(card["node_id"])))
317
+ return cards
318
+
319
+
320
+ class ChronicleService:
321
+ """Read-only timeline over the Brain's own storage."""
322
+
323
+ def __init__(
324
+ self,
325
+ *,
326
+ knowledge_graph: Any = None,
327
+ conversations: Any = None,
328
+ enable_graph: bool = True,
329
+ ) -> None:
330
+ self._kg = knowledge_graph
331
+ self._conversations = conversations
332
+ self._enable_graph = bool(enable_graph and knowledge_graph is not None)
333
+
334
+ # ── storage ─────────────────────────────────────────────────────────
335
+
336
+ @property
337
+ def _graph_db(self) -> Any:
338
+ return getattr(self._kg, "db_path", None) if self._enable_graph else None
339
+
340
+ @property
341
+ def _conversation_db(self) -> Any:
342
+ return getattr(self._conversations, "db_path", None)
343
+
344
+ @staticmethod
345
+ def _rows(db_path: Any, sql: str, params: Mapping[str, Any]) -> List[Any]:
346
+ """One read query. An unreachable database answers "nothing".
347
+
348
+ A Brain that was never built has no ``nodes_v2``, and a graph-disabled
349
+ install has no graph database at all. Either way the chronicle shows
350
+ an honest empty timeline instead of a 500.
351
+ """
352
+ if db_path is None:
353
+ return []
354
+ conn: Optional[sqlite3.Connection] = None
355
+ try:
356
+ conn = sqlite3.connect(str(db_path))
357
+ conn.row_factory = sqlite3.Row
358
+ return list(conn.execute(sql, params).fetchall())
359
+ except sqlite3.Error:
360
+ LOGGER.exception("chronicle read failed against %s", db_path)
361
+ return []
362
+ finally:
363
+ if conn is not None:
364
+ conn.close()
365
+
366
+ def _sources(self, workspace_id: Optional[str]) -> List[Any]:
367
+ return self._rows(self._graph_db, _SOURCES_SQL, {"workspace": workspace_id})
368
+
369
+ def _entities(self, workspace_id: Optional[str]) -> List[Any]:
370
+ return self._rows(self._graph_db, _ENTITIES_SQL, {"workspace": workspace_id})
371
+
372
+ def _connections(self, workspace_id: Optional[str]) -> List[Any]:
373
+ return self._rows(self._graph_db, _CONNECTIONS_SQL, {"workspace": workspace_id})
374
+
375
+ def _changed_nodes(self, workspace_id: Optional[str]) -> List[Any]:
376
+ return self._rows(self._graph_db, _CHANGED_NODES_SQL, {"workspace": workspace_id})
377
+
378
+ def _changed_edges(self, workspace_id: Optional[str]) -> List[Any]:
379
+ return self._rows(self._graph_db, _CHANGED_EDGES_SQL, {"workspace": workspace_id})
380
+
381
+ def _messages(
382
+ self, user_email: Optional[str], workspace_id: Optional[str]
383
+ ) -> List[Any]:
384
+ return self._rows(
385
+ self._conversation_db,
386
+ _MESSAGES_SQL,
387
+ # An empty email is nobody, not a user whose address is "".
388
+ {"workspace": workspace_id, "user": (user_email or "").strip() or None},
389
+ )
390
+
391
+ # ── overview ────────────────────────────────────────────────────────
392
+
393
+ def overview(
394
+ self,
395
+ *,
396
+ user_email: Optional[str] = None,
397
+ workspace_id: Optional[str] = None,
398
+ ) -> Dict[str, Any]:
399
+ """Day buckets for the whole history, plus totals and the endpoints.
400
+
401
+ ``series`` is sparse: only days that carry something appear, because a
402
+ Brain with a two-year gap should not answer with 700 rows of zeros.
403
+ The growth curve reads it as steps; the heat-map fills its own grid.
404
+
405
+ ``first_activity_at``/``last_activity_at`` are normalized into the
406
+ configured timezone rather than echoed raw, so two rows written in
407
+ different formats stay comparable to each other and to ``series``.
408
+ """
409
+ days: Dict[str, Dict[str, int]] = {}
410
+ totals = _empty_lanes()
411
+ moments: List[datetime] = []
412
+ lanes: Tuple[Tuple[str, Iterable[Any]], ...] = (
413
+ ("sources", self._sources(workspace_id)),
414
+ ("entities", self._entities(workspace_id)),
415
+ ("connections", self._connections(workspace_id)),
416
+ )
417
+ for lane, rows in lanes:
418
+ for row in rows:
419
+ moment = _moment(row["at"])
420
+ if moment is None:
421
+ continue
422
+ moments.append(moment)
423
+ totals[lane] += 1
424
+ days.setdefault(moment.date().isoformat(), _empty_lanes())[lane] += 1
425
+
426
+ per_day: Dict[str, set] = {}
427
+ seen: set = set()
428
+ for row in self._messages(user_email, workspace_id):
429
+ moment = _moment(row["at"])
430
+ if moment is None:
431
+ continue
432
+ moments.append(moment)
433
+ key = moment.date().isoformat()
434
+ days.setdefault(key, _empty_lanes())
435
+ per_day.setdefault(key, set()).add(str(row["conversation_id"]))
436
+ seen.add(str(row["conversation_id"]))
437
+ for key, conversation_ids in per_day.items():
438
+ days[key]["conversations"] = len(conversation_ids)
439
+ totals["conversations"] = len(seen)
440
+
441
+ return {
442
+ "first_activity_at": min(moments).isoformat(timespec="seconds") if moments else None,
443
+ "last_activity_at": max(moments).isoformat(timespec="seconds") if moments else None,
444
+ "totals": totals,
445
+ "series": [{"date": key, **days[key]} for key in sorted(days)],
446
+ }
447
+
448
+ # ── one day ─────────────────────────────────────────────────────────
449
+
450
+ def day(
451
+ self,
452
+ date: Any,
453
+ *,
454
+ user_email: Optional[str] = None,
455
+ workspace_id: Optional[str] = None,
456
+ ) -> Dict[str, Any]:
457
+ """What entered, formed, was said, and changed on one calendar day.
458
+
459
+ A day with nothing in it is a real answer: every group comes back
460
+ empty rather than 404, because "nothing happened" is a fact about the
461
+ Brain and the screen says so.
462
+ """
463
+ day = parse_day(date)
464
+ sources = _on_day(self._sources(workspace_id), day)
465
+ entities = _on_day(self._entities(workspace_id), day)
466
+ messages = _on_day(self._messages(user_email, workspace_id), day)
467
+ conversations = _conversation_cards(messages)
468
+ changes = _change_cards(
469
+ _on_day(self._changed_nodes(workspace_id), day),
470
+ _on_day(self._changed_edges(workspace_id), day),
471
+ )
472
+ return {
473
+ "date": day,
474
+ "counts": {
475
+ "sources": len(sources),
476
+ "entities": len(entities),
477
+ "conversations": len(conversations),
478
+ "changes": len(changes),
479
+ },
480
+ "groups": {
481
+ "sources": [
482
+ {
483
+ "id": row["id"],
484
+ "title": row["title"],
485
+ "source_type": row["source_type"],
486
+ "captured_at": row["at"],
487
+ "node_id": row["node_id"],
488
+ }
489
+ for row in sources[:_GROUP_LIMIT]
490
+ ],
491
+ "entities": [
492
+ {
493
+ "id": row["id"],
494
+ "label": row["label"],
495
+ "type": row["type"],
496
+ "created_at": row["at"],
497
+ }
498
+ for row in entities[:_GROUP_LIMIT]
499
+ ],
500
+ "conversations": conversations[:_GROUP_LIMIT],
501
+ "changes": changes[:_GROUP_LIMIT],
502
+ },
503
+ }
504
+
505
+ # ── rewind ──────────────────────────────────────────────────────────
506
+
507
+ def _importance(self, node_ids: Sequence[str]) -> Dict[str, float]:
508
+ """``importance_score`` per node, read through the store's own API."""
509
+ if not node_ids:
510
+ return {}
511
+ stats = self._kg.access_stats(node_ids)
512
+ return {
513
+ str(key): float((value or {}).get("accesses") or 0.0)
514
+ for key, value in stats.items()
515
+ }
516
+
517
+ def as_of(self, ts: Any, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
518
+ """The Brain as it stood at ``ts``.
519
+
520
+ The slice comes from ``store.as_of()``: the ``[valid_from, valid_to)``
521
+ predicate has one implementation in this repo and it is not this one.
522
+ ``stats`` therefore reports what that slice contains — the store
523
+ clamps at 2000 nodes, which is the ceiling asked for here.
524
+ """
525
+ stamp = parse_timestamp(ts)
526
+ if not self._enable_graph:
527
+ return {
528
+ "ts": stamp,
529
+ "stats": {"entities": 0, "connections": 0},
530
+ "top_entities": [],
531
+ }
532
+ window = self._kg.as_of(stamp, limit=_AS_OF_LIMIT, **_scope_kwargs(workspace_id))
533
+ nodes = list(window.get("nodes") or [])
534
+ scores = self._importance([str(node.get("id")) for node in nodes])
535
+ ranked = sorted(
536
+ nodes,
537
+ key=lambda node: (-scores.get(str(node.get("id")), 0.0), str(node.get("id"))),
538
+ )
539
+ return {
540
+ "ts": stamp,
541
+ "stats": {
542
+ "entities": int(window.get("node_count") or 0),
543
+ "connections": int(window.get("edge_count") or 0),
544
+ },
545
+ "top_entities": [
546
+ {
547
+ "id": node.get("id"),
548
+ "label": node.get("title"),
549
+ "type": node.get("type"),
550
+ "importance_score": scores.get(str(node.get("id")), 0.0),
551
+ }
552
+ for node in ranked[:_TOP_ENTITY_LIMIT]
553
+ ],
554
+ }
555
+
556
+
557
+ __all__ = ["ChronicleService", "parse_day", "parse_timestamp"]
@@ -214,11 +214,17 @@ class CommandCenterService:
214
214
  except Exception:
215
215
  LOGGER.exception("command center health read failed")
216
216
  return {"available": False}
217
- overall = report.get("overall") or {}
217
+ # ``BrainIntelligenceService.health_report()`` publishes the score and
218
+ # the grade at the top level. This used to read them out of a nested
219
+ # ``overall`` block that no producer has ever written, so the section
220
+ # was permanently unavailable: Today's Briefing showed a dash with no
221
+ # reason beside it, and the "check-health" quick action below could
222
+ # never fire. Read the shape the real collaborator returns.
223
+ score = report.get("overall_score")
218
224
  return {
219
- "available": overall.get("score") is not None,
220
- "grade": overall.get("grade"),
221
- "score": overall.get("score"),
225
+ "available": score is not None,
226
+ "grade": report.get("grade"),
227
+ "score": score,
222
228
  "recommended_actions": list(report.get("recommended_actions") or [])[:3],
223
229
  }
224
230