ltcai 11.2.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 (247) hide show
  1. package/README.md +46 -53
  2. package/docs/CHANGELOG.md +61 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +6 -2
  8. package/docs/PERMISSION_MODE.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +2 -2
  12. package/docs/v11.3.0_PLAN.md +202 -0
  13. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  16. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  17. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  18. package/lattice_brain/graph/_kg_common/text.py +479 -0
  19. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  20. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  21. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  22. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  23. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  24. package/lattice_brain/graph/projection/__init__.py +42 -0
  25. package/lattice_brain/graph/projection/curation.py +500 -0
  26. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
  27. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  28. package/lattice_brain/graph/retrieval/context.py +197 -0
  29. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  30. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  31. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  32. package/lattice_brain/graph/retrieval/signals.py +95 -0
  33. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  34. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  35. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  36. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  37. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  38. package/lattice_brain/ingestion/__init__.py +130 -0
  39. package/lattice_brain/ingestion/_contract.py +90 -0
  40. package/lattice_brain/ingestion/constants.py +127 -0
  41. package/lattice_brain/ingestion/folder_scan.py +57 -0
  42. package/lattice_brain/ingestion/folders.py +258 -0
  43. package/lattice_brain/ingestion/hashing.py +26 -0
  44. package/lattice_brain/ingestion/jobs_api.py +107 -0
  45. package/lattice_brain/ingestion/models.py +80 -0
  46. package/lattice_brain/ingestion/pipeline.py +486 -0
  47. package/lattice_brain/ingestion/quality.py +209 -0
  48. package/lattice_brain/ingestion/routing.py +295 -0
  49. package/lattice_brain/multimodal/__init__.py +164 -0
  50. package/lattice_brain/multimodal/audio.py +77 -0
  51. package/lattice_brain/multimodal/common.py +118 -0
  52. package/lattice_brain/multimodal/images.py +498 -0
  53. package/lattice_brain/multimodal/ports.py +169 -0
  54. package/lattice_brain/multimodal/video.py +410 -0
  55. package/lattice_brain/portability/__init__.py +90 -0
  56. package/lattice_brain/portability/_contract.py +42 -0
  57. package/lattice_brain/portability/backups.py +338 -0
  58. package/lattice_brain/portability/bundles.py +136 -0
  59. package/lattice_brain/portability/constants.py +93 -0
  60. package/lattice_brain/portability/fsops.py +138 -0
  61. package/lattice_brain/portability/service.py +41 -0
  62. package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
  63. package/lattice_brain/runtime/__init__.py +1 -1
  64. package/lattice_brain/runtime/multi_agent.py +1 -1
  65. package/latticeai/__init__.py +1 -1
  66. package/latticeai/api/chronicle.py +63 -0
  67. package/latticeai/core/agent/__init__.py +93 -0
  68. package/latticeai/core/agent/_contract.py +79 -0
  69. package/latticeai/core/agent/context.py +57 -0
  70. package/latticeai/core/agent/deps.py +125 -0
  71. package/latticeai/core/agent/execution.py +622 -0
  72. package/latticeai/core/agent/planning.py +145 -0
  73. package/latticeai/core/agent/recovery.py +157 -0
  74. package/latticeai/core/agent/runtime.py +210 -0
  75. package/latticeai/core/agent/verification.py +231 -0
  76. package/latticeai/core/embedding_providers/__init__.py +151 -0
  77. package/latticeai/core/embedding_providers/base.py +199 -0
  78. package/latticeai/core/embedding_providers/captions.py +162 -0
  79. package/latticeai/core/embedding_providers/profiles.py +126 -0
  80. package/latticeai/core/embedding_providers/text.py +350 -0
  81. package/latticeai/core/embedding_providers/vision.py +352 -0
  82. package/latticeai/core/file_generation/__init__.py +115 -0
  83. package/latticeai/core/file_generation/bundles.py +76 -0
  84. package/latticeai/core/file_generation/extraction.py +154 -0
  85. package/latticeai/core/file_generation/inference.py +235 -0
  86. package/latticeai/core/file_generation/orchestration.py +152 -0
  87. package/latticeai/core/file_generation/prompting.py +117 -0
  88. package/latticeai/core/file_generation/repair.py +114 -0
  89. package/latticeai/core/file_generation/sanitize.py +61 -0
  90. package/latticeai/core/file_generation/validation.py +201 -0
  91. package/latticeai/core/legacy_compatibility.py +1 -1
  92. package/latticeai/core/marketplace.py +1 -1
  93. package/latticeai/core/messages.py +9 -0
  94. package/latticeai/core/workspace_os_constants.py +1 -1
  95. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  96. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  97. package/latticeai/integrations/telegram_bot/config.py +86 -0
  98. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  99. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  100. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  101. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  102. package/latticeai/models/router/__init__.py +88 -0
  103. package/latticeai/models/router/_contract.py +66 -0
  104. package/latticeai/models/router/branding.py +56 -0
  105. package/latticeai/models/router/catalog.py +69 -0
  106. package/latticeai/models/router/documents.py +199 -0
  107. package/latticeai/models/router/errors.py +37 -0
  108. package/latticeai/models/router/generation.py +258 -0
  109. package/latticeai/models/router/loading.py +291 -0
  110. package/latticeai/models/router/local_models.py +85 -0
  111. package/latticeai/models/router/registry.py +147 -0
  112. package/latticeai/runtime/build_phases/__init__.py +82 -0
  113. package/latticeai/runtime/build_phases/features.py +407 -0
  114. package/latticeai/runtime/build_phases/foundation.py +555 -0
  115. package/latticeai/runtime/build_phases/web.py +492 -0
  116. package/latticeai/runtime/runtime_context.py +1 -0
  117. package/latticeai/services/architecture_readiness.py +48 -19
  118. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  119. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  120. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  121. package/latticeai/services/brain_intelligence/constants.py +47 -0
  122. package/latticeai/services/brain_intelligence/digest.py +258 -0
  123. package/latticeai/services/brain_intelligence/health.py +331 -0
  124. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  125. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  126. package/latticeai/services/brain_intelligence/service.py +48 -0
  127. package/latticeai/services/chronicle.py +557 -0
  128. package/latticeai/services/memory_service/__init__.py +52 -0
  129. package/latticeai/services/memory_service/_contract.py +100 -0
  130. package/latticeai/services/memory_service/brief.py +431 -0
  131. package/latticeai/services/memory_service/constants.py +57 -0
  132. package/latticeai/services/memory_service/maintenance.py +138 -0
  133. package/latticeai/services/memory_service/manager.py +186 -0
  134. package/latticeai/services/memory_service/proof.py +136 -0
  135. package/latticeai/services/memory_service/recall.py +225 -0
  136. package/latticeai/services/memory_service/service.py +48 -0
  137. package/latticeai/services/memory_service/stores.py +110 -0
  138. package/latticeai/services/model_runtime/__init__.py +322 -0
  139. package/latticeai/services/model_runtime/cloud.py +87 -0
  140. package/latticeai/services/model_runtime/download.py +282 -0
  141. package/latticeai/services/model_runtime/engines.py +341 -0
  142. package/latticeai/services/model_runtime/loading.py +178 -0
  143. package/latticeai/services/model_runtime/service.py +129 -0
  144. package/latticeai/services/model_runtime/state.py +131 -0
  145. package/latticeai/services/model_runtime/status.py +255 -0
  146. package/latticeai/services/product_readiness.py +15 -7
  147. package/latticeai/setup/wizard/__init__.py +126 -0
  148. package/latticeai/setup/wizard/catalog.py +172 -0
  149. package/latticeai/setup/wizard/detect.py +323 -0
  150. package/latticeai/setup/wizard/install.py +348 -0
  151. package/latticeai/setup/wizard/paths.py +168 -0
  152. package/latticeai/setup/wizard/plans.py +74 -0
  153. package/latticeai/setup/wizard/recommend.py +320 -0
  154. package/package.json +6 -2
  155. package/scripts/bump_version.py +14 -0
  156. package/scripts/capture_release_evidence.mjs +33 -21
  157. package/scripts/check_current_release_docs.mjs +1 -1
  158. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  159. package/scripts/check_max_file_lines.mjs +102 -0
  160. package/scripts/check_release_evidence_bound.mjs +30 -15
  161. package/scripts/check_screenshot_pixel_delta.py +34 -4
  162. package/scripts/check_server_i18n.mjs +1 -0
  163. package/scripts/generate_rust_parity_fixtures.py +562 -0
  164. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  165. package/scripts/release_screen_claims.json +31 -2
  166. package/src-tauri/Cargo.lock +361 -3
  167. package/src-tauri/Cargo.toml +6 -1
  168. package/src-tauri/src/backend.rs +349 -0
  169. package/src-tauri/src/folder.rs +33 -0
  170. package/src-tauri/src/main.rs +97 -399
  171. package/src-tauri/tauri.conf.json +1 -1
  172. package/static/app/asset-manifest.json +41 -37
  173. package/static/app/assets/Act-yYpYnn0v.js +1 -0
  174. package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
  175. package/static/app/assets/{Brain-tuhI4sOC.js → Brain-C1HBN0Wf.js} +2 -2
  176. package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
  177. package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
  178. package/static/app/assets/Capture-CFIRsFNE.js +1 -0
  179. package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
  180. package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
  181. package/static/app/assets/Library-DwO3yZST.js +1 -0
  182. package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-Jn1GK0-S.js} +1 -1
  183. package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
  184. package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
  185. package/static/app/assets/System-DW8F-2xL.js +1 -0
  186. package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
  187. package/static/app/assets/{bot-Cia42c2h.js → bot-IM_E_Y12.js} +1 -1
  188. package/static/app/assets/brain-Ci1CkWjM.js +1 -0
  189. package/static/app/assets/{button-2j2Ijzgq.js → button-COwyqfHM.js} +1 -1
  190. package/static/app/assets/circle-check-DfInj-qD.js +1 -0
  191. package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-DEM4A1Y5.js} +1 -1
  192. package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-C9djDuLd.js} +1 -1
  193. package/static/app/assets/{cpu-k4awryFq.js → cpu-DFdo1gw-.js} +1 -1
  194. package/static/app/assets/{download-DFbLJ_ig.js → download-SnJL6oqk.js} +1 -1
  195. package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CqZeDkjE.js} +1 -1
  196. package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-j1jJXYYf.js} +1 -1
  197. package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
  198. package/static/app/assets/index-_u5iUHDr.js +10 -0
  199. package/static/app/assets/input-B0lPdRQZ.js +1 -0
  200. package/static/app/assets/link-2-CoFbooHS.js +1 -0
  201. package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-BsyLxtao.js} +1 -1
  202. package/static/app/assets/primitives-DEbN-d6p.js +1 -0
  203. package/static/app/assets/search-BybIWPNd.js +1 -0
  204. package/static/app/assets/{share-2-BH1M-WNi.js → share-2-CVtZ_ewX.js} +1 -1
  205. package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-CBi2GNWM.js} +1 -1
  206. package/static/app/assets/{textarea-CCWbUfFB.js → textarea-DNMpB5ih.js} +1 -1
  207. package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-C83t3GXF.js} +1 -1
  208. package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
  209. package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-Dcp1OChy.js} +1 -1
  210. package/static/app/assets/utils-BlZr7Pd4.js +4 -0
  211. package/static/app/assets/workspace-jJY4RuAV.js +1 -0
  212. package/static/app/index.html +4 -4
  213. package/static/sw.js +1 -1
  214. package/lattice_brain/graph/_kg_common.py +0 -1331
  215. package/lattice_brain/graph/discovery_index.py +0 -1141
  216. package/lattice_brain/graph/retrieval.py +0 -1120
  217. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  218. package/lattice_brain/ingestion.py +0 -1525
  219. package/lattice_brain/multimodal.py +0 -1258
  220. package/latticeai/core/agent.py +0 -1465
  221. package/latticeai/core/embedding_providers.py +0 -1196
  222. package/latticeai/core/file_generation.py +0 -1047
  223. package/latticeai/integrations/telegram_bot.py +0 -1390
  224. package/latticeai/models/router.py +0 -1007
  225. package/latticeai/runtime/build_phases.py +0 -1450
  226. package/latticeai/services/brain_intelligence.py +0 -1083
  227. package/latticeai/services/memory_service.py +0 -1177
  228. package/latticeai/services/model_runtime.py +0 -1281
  229. package/latticeai/setup/wizard.py +0 -1310
  230. package/static/app/assets/Act-AWf0SAKp.js +0 -1
  231. package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
  232. package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
  233. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
  234. package/static/app/assets/Capture-CqOSzyPr.js +0 -1
  235. package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
  236. package/static/app/assets/Library-CX-bbhmK.js +0 -1
  237. package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
  238. package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
  239. package/static/app/assets/System-Bu2t5hn1.js +0 -1
  240. package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
  241. package/static/app/assets/brain-DJMoqrwx.js +0 -1
  242. package/static/app/assets/index-BpYkzcVm.js +0 -10
  243. package/static/app/assets/input-DSlJJxRs.js +0 -1
  244. package/static/app/assets/primitives-BCx6TvfG.js +0 -1
  245. package/static/app/assets/search-Cgy8cCFJ.js +0 -1
  246. package/static/app/assets/utils-zqPZJxdx.js +0 -4
  247. package/static/app/assets/workspace-DXTihhfU.js +0 -1
@@ -0,0 +1,48 @@
1
+ """The composed Brain Intelligence service — injected collaborators, five surfaces.
2
+
3
+ ``BrainIntelligenceService`` owns the state every mixin reads (the Knowledge
4
+ Graph, the memory service, the two quality managers, and the lazily built
5
+ proactive brain / synthesizer) and inherits its behaviour from the proposal,
6
+ sampling, health, digest and consistency mixins. Same public surface, same
7
+ method resolution, in six readable files instead of one eleven-hundred-line
8
+ one.
9
+
10
+ Pure service: no FastAPI, no globals. Collaborators are injected.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from lattice_brain.quality import GraphEdgeQualityManager, MemoryQualityManager
18
+
19
+ from .consistency import BrainConsistencyMixin
20
+ from .digest import BrainDigestMixin
21
+ from .health import BrainHealthMixin
22
+ from .proposals import BrainProposalsMixin
23
+ from .sampling import BrainSamplingMixin
24
+
25
+
26
+ class BrainIntelligenceService(
27
+ BrainProposalsMixin,
28
+ BrainSamplingMixin,
29
+ BrainHealthMixin,
30
+ BrainDigestMixin,
31
+ BrainConsistencyMixin,
32
+ ):
33
+ def __init__(
34
+ self,
35
+ *,
36
+ knowledge_graph: Any = None,
37
+ memory_service: Any = None,
38
+ enable_graph: bool = True,
39
+ review_queue: Any = None,
40
+ ) -> None:
41
+ self._kg = knowledge_graph
42
+ self._memory = memory_service
43
+ self._enable_graph = bool(enable_graph and knowledge_graph is not None)
44
+ self._memory_quality = MemoryQualityManager()
45
+ self._edge_quality = GraphEdgeQualityManager()
46
+ self._proactive_brain: Any = None
47
+ self._review_queue_service = review_queue
48
+ self._synthesizer: Any = None
@@ -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"]
@@ -0,0 +1,52 @@
1
+ """Long-Term Memory platform + Memory Manager (v3.2.0).
2
+
3
+ Parts 7, 8 and 13. Lattice AI already persists memory in several real stores;
4
+ before this service they were unrelated. ``MemoryService`` unifies them behind
5
+ one façade and adds a Memory Manager that reports usage / sources / health /
6
+ size / type and supports recall / inspect / prune / compact / rebuild / clear.
7
+
8
+ Memory tiers and their real backing store (nothing is fabricated — a tier with
9
+ no backing reports ``unavailable``):
10
+
11
+ * **workspace** — personal workspace memories (``WorkspaceOS`` memories)
12
+ * **project** — memories scoped to a non-personal (organization) workspace
13
+ * **agent** — agent memory snapshots captured during runs
14
+ * **conversation** — chat history conversations
15
+ * **graph** — Knowledge Graph nodes (entities + relations)
16
+ * **vector** — local embedding vector index
17
+
18
+ The service never invents counts or health: every number is read from the
19
+ underlying store, and missing stores surface as ``unavailable``.
20
+
21
+ Split into cohesive submodules in v11.3.0 (no behaviour change): ``constants``
22
+ (tier vocabulary + the visual passthrough), ``stores`` (reads over the real
23
+ backends), ``manager`` (the cross-tier report), ``brief`` and ``proof`` (the
24
+ home screen's briefing and its evidence), ``recall`` (unified retrieval and
25
+ per-tier inspection), ``maintenance`` (the mutating half), and ``service`` (the
26
+ composed class). This module re-exports every name the single file exposed, so
27
+ ``latticeai.services.memory_service.X`` keeps working.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ # The single file had no ``__all__``, so its public surface was "every module
33
+ # global" — including the names it imported for its own use. The redundant-alias
34
+ # form reproduces exactly that surface and marks each name as a deliberate
35
+ # re-export rather than a leftover import.
36
+ #
37
+ # Stubbing note: rebinding one of these *here* changes only this module's name.
38
+ # The submodule that calls it holds its own reference, so a test standing in for
39
+ # ``LOGGER`` or ``_now`` patches the submodule that uses it.
40
+ # noqa on the next line, not a redundant alias: the single file exposed this
41
+ # name *renamed* (``memory_service._now``), and reproducing that surface exactly
42
+ # is the point — importing it as ``now_iso`` would add a name the module never had.
43
+ from latticeai.core.timeutil import now_iso as _now # noqa: F401
44
+ from latticeai.core.workspace_os_utils import _file_size as _file_size
45
+
46
+ from .constants import LOGGER as LOGGER
47
+ from .constants import MAX_RECALL_THUMBNAIL_CHARS as MAX_RECALL_THUMBNAIL_CHARS
48
+ from .constants import TIERS as TIERS
49
+ from .constants import WORKSPACE_KINDS as WORKSPACE_KINDS
50
+ from .constants import MemoryServiceError as MemoryServiceError
51
+ from .constants import _visual_fields as _visual_fields
52
+ from .service import MemoryService as MemoryService