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.
- package/README.md +46 -53
- package/docs/CHANGELOG.md +61 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/MULTI_AGENT_RUNTIME.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +6 -2
- package/docs/PERMISSION_MODE.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +2 -2
- package/docs/v11.3.0_PLAN.md +202 -0
- package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common/__init__.py +287 -0
- package/lattice_brain/graph/_kg_common/extraction.py +516 -0
- package/lattice_brain/graph/_kg_common/relations.py +161 -0
- package/lattice_brain/graph/_kg_common/text.py +479 -0
- package/lattice_brain/graph/discovery_index/__init__.py +35 -0
- package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
- package/lattice_brain/graph/discovery_index/extract.py +137 -0
- package/lattice_brain/graph/discovery_index/scan.py +411 -0
- package/lattice_brain/graph/discovery_index/upsert.py +495 -0
- package/lattice_brain/graph/projection/__init__.py +42 -0
- package/lattice_brain/graph/projection/curation.py +500 -0
- package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
- package/lattice_brain/graph/retrieval/__init__.py +54 -0
- package/lattice_brain/graph/retrieval/context.py +197 -0
- package/lattice_brain/graph/retrieval/graph_view.py +319 -0
- package/lattice_brain/graph/retrieval/hybrid.py +488 -0
- package/lattice_brain/graph/retrieval/maintenance.py +121 -0
- package/lattice_brain/graph/retrieval/signals.py +95 -0
- package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
- package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
- package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
- package/lattice_brain/graph/retrieval_vector/search.py +560 -0
- package/lattice_brain/graph/retrieval_vector/status.py +374 -0
- package/lattice_brain/ingestion/__init__.py +130 -0
- package/lattice_brain/ingestion/_contract.py +90 -0
- package/lattice_brain/ingestion/constants.py +127 -0
- package/lattice_brain/ingestion/folder_scan.py +57 -0
- package/lattice_brain/ingestion/folders.py +258 -0
- package/lattice_brain/ingestion/hashing.py +26 -0
- package/lattice_brain/ingestion/jobs_api.py +107 -0
- package/lattice_brain/ingestion/models.py +80 -0
- package/lattice_brain/ingestion/pipeline.py +486 -0
- package/lattice_brain/ingestion/quality.py +209 -0
- package/lattice_brain/ingestion/routing.py +295 -0
- package/lattice_brain/multimodal/__init__.py +164 -0
- package/lattice_brain/multimodal/audio.py +77 -0
- package/lattice_brain/multimodal/common.py +118 -0
- package/lattice_brain/multimodal/images.py +498 -0
- package/lattice_brain/multimodal/ports.py +169 -0
- package/lattice_brain/multimodal/video.py +410 -0
- package/lattice_brain/portability/__init__.py +90 -0
- package/lattice_brain/portability/_contract.py +42 -0
- package/lattice_brain/portability/backups.py +338 -0
- package/lattice_brain/portability/bundles.py +136 -0
- package/lattice_brain/portability/constants.py +93 -0
- package/lattice_brain/portability/fsops.py +138 -0
- package/lattice_brain/portability/service.py +41 -0
- package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
- package/lattice_brain/runtime/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chronicle.py +63 -0
- package/latticeai/core/agent/__init__.py +93 -0
- package/latticeai/core/agent/_contract.py +79 -0
- package/latticeai/core/agent/context.py +57 -0
- package/latticeai/core/agent/deps.py +125 -0
- package/latticeai/core/agent/execution.py +622 -0
- package/latticeai/core/agent/planning.py +145 -0
- package/latticeai/core/agent/recovery.py +157 -0
- package/latticeai/core/agent/runtime.py +210 -0
- package/latticeai/core/agent/verification.py +231 -0
- package/latticeai/core/embedding_providers/__init__.py +151 -0
- package/latticeai/core/embedding_providers/base.py +199 -0
- package/latticeai/core/embedding_providers/captions.py +162 -0
- package/latticeai/core/embedding_providers/profiles.py +126 -0
- package/latticeai/core/embedding_providers/text.py +350 -0
- package/latticeai/core/embedding_providers/vision.py +352 -0
- package/latticeai/core/file_generation/__init__.py +115 -0
- package/latticeai/core/file_generation/bundles.py +76 -0
- package/latticeai/core/file_generation/extraction.py +154 -0
- package/latticeai/core/file_generation/inference.py +235 -0
- package/latticeai/core/file_generation/orchestration.py +152 -0
- package/latticeai/core/file_generation/prompting.py +117 -0
- package/latticeai/core/file_generation/repair.py +114 -0
- package/latticeai/core/file_generation/sanitize.py +61 -0
- package/latticeai/core/file_generation/validation.py +201 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +9 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/integrations/telegram_bot/__init__.py +123 -0
- package/latticeai/integrations/telegram_bot/__main__.py +17 -0
- package/latticeai/integrations/telegram_bot/config.py +86 -0
- package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
- package/latticeai/integrations/telegram_bot/flows.py +478 -0
- package/latticeai/integrations/telegram_bot/helpers.py +322 -0
- package/latticeai/integrations/telegram_bot/screens.py +394 -0
- package/latticeai/models/router/__init__.py +88 -0
- package/latticeai/models/router/_contract.py +66 -0
- package/latticeai/models/router/branding.py +56 -0
- package/latticeai/models/router/catalog.py +69 -0
- package/latticeai/models/router/documents.py +199 -0
- package/latticeai/models/router/errors.py +37 -0
- package/latticeai/models/router/generation.py +258 -0
- package/latticeai/models/router/loading.py +291 -0
- package/latticeai/models/router/local_models.py +85 -0
- package/latticeai/models/router/registry.py +147 -0
- package/latticeai/runtime/build_phases/__init__.py +82 -0
- package/latticeai/runtime/build_phases/features.py +407 -0
- package/latticeai/runtime/build_phases/foundation.py +555 -0
- package/latticeai/runtime/build_phases/web.py +492 -0
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +48 -19
- package/latticeai/services/brain_intelligence/__init__.py +58 -0
- package/latticeai/services/brain_intelligence/_contract.py +71 -0
- package/latticeai/services/brain_intelligence/consistency.py +193 -0
- package/latticeai/services/brain_intelligence/constants.py +47 -0
- package/latticeai/services/brain_intelligence/digest.py +258 -0
- package/latticeai/services/brain_intelligence/health.py +331 -0
- package/latticeai/services/brain_intelligence/proposals.py +264 -0
- package/latticeai/services/brain_intelligence/sampling.py +84 -0
- package/latticeai/services/brain_intelligence/service.py +48 -0
- package/latticeai/services/chronicle.py +557 -0
- package/latticeai/services/memory_service/__init__.py +52 -0
- package/latticeai/services/memory_service/_contract.py +100 -0
- package/latticeai/services/memory_service/brief.py +431 -0
- package/latticeai/services/memory_service/constants.py +57 -0
- package/latticeai/services/memory_service/maintenance.py +138 -0
- package/latticeai/services/memory_service/manager.py +186 -0
- package/latticeai/services/memory_service/proof.py +136 -0
- package/latticeai/services/memory_service/recall.py +225 -0
- package/latticeai/services/memory_service/service.py +48 -0
- package/latticeai/services/memory_service/stores.py +110 -0
- package/latticeai/services/model_runtime/__init__.py +322 -0
- package/latticeai/services/model_runtime/cloud.py +87 -0
- package/latticeai/services/model_runtime/download.py +282 -0
- package/latticeai/services/model_runtime/engines.py +341 -0
- package/latticeai/services/model_runtime/loading.py +178 -0
- package/latticeai/services/model_runtime/service.py +129 -0
- package/latticeai/services/model_runtime/state.py +131 -0
- package/latticeai/services/model_runtime/status.py +255 -0
- package/latticeai/services/product_readiness.py +15 -7
- package/latticeai/setup/wizard/__init__.py +126 -0
- package/latticeai/setup/wizard/catalog.py +172 -0
- package/latticeai/setup/wizard/detect.py +323 -0
- package/latticeai/setup/wizard/install.py +348 -0
- package/latticeai/setup/wizard/paths.py +168 -0
- package/latticeai/setup/wizard/plans.py +74 -0
- package/latticeai/setup/wizard/recommend.py +320 -0
- package/package.json +6 -2
- package/scripts/bump_version.py +14 -0
- package/scripts/capture_release_evidence.mjs +33 -21
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +41 -4
- package/scripts/check_max_file_lines.mjs +102 -0
- package/scripts/check_release_evidence_bound.mjs +30 -15
- package/scripts/check_screenshot_pixel_delta.py +34 -4
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/generate_rust_parity_fixtures.py +562 -0
- package/scripts/lib/mock_server_fingerprint.mjs +94 -0
- package/scripts/release_screen_claims.json +31 -2
- package/src-tauri/Cargo.lock +361 -3
- package/src-tauri/Cargo.toml +6 -1
- package/src-tauri/src/backend.rs +349 -0
- package/src-tauri/src/folder.rs +33 -0
- package/src-tauri/src/main.rs +97 -399
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +41 -37
- package/static/app/assets/Act-yYpYnn0v.js +1 -0
- package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
- package/static/app/assets/{Brain-tuhI4sOC.js → Brain-C1HBN0Wf.js} +2 -2
- package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
- package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
- package/static/app/assets/Capture-CFIRsFNE.js +1 -0
- package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
- package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
- package/static/app/assets/Library-DwO3yZST.js +1 -0
- package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-Jn1GK0-S.js} +1 -1
- package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
- package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
- package/static/app/assets/System-DW8F-2xL.js +1 -0
- package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
- package/static/app/assets/{bot-Cia42c2h.js → bot-IM_E_Y12.js} +1 -1
- package/static/app/assets/brain-Ci1CkWjM.js +1 -0
- package/static/app/assets/{button-2j2Ijzgq.js → button-COwyqfHM.js} +1 -1
- package/static/app/assets/circle-check-DfInj-qD.js +1 -0
- package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-DEM4A1Y5.js} +1 -1
- package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-C9djDuLd.js} +1 -1
- package/static/app/assets/{cpu-k4awryFq.js → cpu-DFdo1gw-.js} +1 -1
- package/static/app/assets/{download-DFbLJ_ig.js → download-SnJL6oqk.js} +1 -1
- package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CqZeDkjE.js} +1 -1
- package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-j1jJXYYf.js} +1 -1
- package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
- package/static/app/assets/index-_u5iUHDr.js +10 -0
- package/static/app/assets/input-B0lPdRQZ.js +1 -0
- package/static/app/assets/link-2-CoFbooHS.js +1 -0
- package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-BsyLxtao.js} +1 -1
- package/static/app/assets/primitives-DEbN-d6p.js +1 -0
- package/static/app/assets/search-BybIWPNd.js +1 -0
- package/static/app/assets/{share-2-BH1M-WNi.js → share-2-CVtZ_ewX.js} +1 -1
- package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-CBi2GNWM.js} +1 -1
- package/static/app/assets/{textarea-CCWbUfFB.js → textarea-DNMpB5ih.js} +1 -1
- package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-C83t3GXF.js} +1 -1
- package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
- package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-Dcp1OChy.js} +1 -1
- package/static/app/assets/utils-BlZr7Pd4.js +4 -0
- package/static/app/assets/workspace-jJY4RuAV.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/lattice_brain/graph/_kg_common.py +0 -1331
- package/lattice_brain/graph/discovery_index.py +0 -1141
- package/lattice_brain/graph/retrieval.py +0 -1120
- package/lattice_brain/graph/retrieval_vector.py +0 -1293
- package/lattice_brain/ingestion.py +0 -1525
- package/lattice_brain/multimodal.py +0 -1258
- package/latticeai/core/agent.py +0 -1465
- package/latticeai/core/embedding_providers.py +0 -1196
- package/latticeai/core/file_generation.py +0 -1047
- package/latticeai/integrations/telegram_bot.py +0 -1390
- package/latticeai/models/router.py +0 -1007
- package/latticeai/runtime/build_phases.py +0 -1450
- package/latticeai/services/brain_intelligence.py +0 -1083
- package/latticeai/services/memory_service.py +0 -1177
- package/latticeai/services/model_runtime.py +0 -1281
- package/latticeai/setup/wizard.py +0 -1310
- package/static/app/assets/Act-AWf0SAKp.js +0 -1
- package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
- package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
- package/static/app/assets/Capture-CqOSzyPr.js +0 -1
- package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
- package/static/app/assets/Library-CX-bbhmK.js +0 -1
- package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
- package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
- package/static/app/assets/System-Bu2t5hn1.js +0 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
- package/static/app/assets/brain-DJMoqrwx.js +0 -1
- package/static/app/assets/index-BpYkzcVm.js +0 -10
- package/static/app/assets/input-DSlJJxRs.js +0 -1
- package/static/app/assets/primitives-BCx6TvfG.js +0 -1
- package/static/app/assets/search-Cgy8cCFJ.js +0 -1
- package/static/app/assets/utils-zqPZJxdx.js +0 -4
- 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
|