ltcai 4.3.3 → 4.5.1

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 (138) hide show
  1. package/README.md +53 -20
  2. package/docs/CHANGELOG.md +122 -0
  3. package/docs/V4_4_0_EXTRACTION_REPORT.md +239 -0
  4. package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +49 -0
  5. package/docs/V4_5_0_GRAPH_UX_REPORT.md +34 -0
  6. package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +40 -0
  7. package/docs/V4_5_0_ONBOARDING_REPORT.md +31 -0
  8. package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +49 -0
  9. package/docs/V4_5_0_VALIDATION_REPORT.md +60 -0
  10. package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +33 -0
  11. package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +37 -0
  12. package/docs/V4_5_1_NAVIGATION_REPORT.md +37 -0
  13. package/docs/V4_5_1_ONBOARDING_REPORT.md +29 -0
  14. package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +61 -0
  15. package/docs/V4_5_1_RC_ARTIFACTS.md +44 -0
  16. package/docs/V4_5_1_UX_REPORT.md +45 -0
  17. package/docs/V4_5_1_VALIDATION_REPORT.md +54 -0
  18. package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +30 -0
  19. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +16 -16
  20. package/docs/architecture.md +8 -4
  21. package/frontend/src/App.tsx +152 -91
  22. package/frontend/src/api/client.ts +83 -1
  23. package/frontend/src/components/FirstRunGuide.tsx +99 -0
  24. package/frontend/src/components/primitives.tsx +131 -25
  25. package/frontend/src/components/ui/badge.tsx +2 -2
  26. package/frontend/src/components/ui/button.tsx +7 -7
  27. package/frontend/src/components/ui/card.tsx +5 -5
  28. package/frontend/src/components/ui/input.tsx +1 -1
  29. package/frontend/src/components/ui/textarea.tsx +1 -1
  30. package/frontend/src/pages/Act.tsx +58 -28
  31. package/frontend/src/pages/Ask.tsx +51 -19
  32. package/frontend/src/pages/Brain.tsx +60 -42
  33. package/frontend/src/pages/Capture.tsx +24 -24
  34. package/frontend/src/pages/Library.tsx +222 -32
  35. package/frontend/src/pages/System.tsx +56 -34
  36. package/frontend/src/routes.ts +15 -13
  37. package/frontend/src/store/appStore.ts +8 -1
  38. package/frontend/src/styles.css +666 -36
  39. package/lattice_brain/__init__.py +38 -23
  40. package/lattice_brain/_kg_common.py +11 -1
  41. package/lattice_brain/context.py +212 -2
  42. package/lattice_brain/conversations.py +234 -1
  43. package/lattice_brain/discovery.py +11 -1
  44. package/lattice_brain/documents.py +11 -1
  45. package/lattice_brain/graph/__init__.py +28 -0
  46. package/lattice_brain/graph/_kg_common.py +1123 -0
  47. package/lattice_brain/graph/curator.py +473 -0
  48. package/lattice_brain/graph/discovery.py +1455 -0
  49. package/lattice_brain/graph/documents.py +218 -0
  50. package/lattice_brain/graph/identity.py +175 -0
  51. package/lattice_brain/graph/ingest.py +644 -0
  52. package/lattice_brain/graph/network.py +205 -0
  53. package/lattice_brain/graph/projection.py +571 -0
  54. package/lattice_brain/graph/provenance.py +401 -0
  55. package/lattice_brain/graph/retrieval.py +1341 -0
  56. package/lattice_brain/graph/schema.py +640 -0
  57. package/lattice_brain/graph/store.py +237 -0
  58. package/lattice_brain/graph/write_master.py +225 -0
  59. package/lattice_brain/identity.py +11 -13
  60. package/lattice_brain/ingest.py +11 -1
  61. package/lattice_brain/ingestion.py +318 -0
  62. package/lattice_brain/memory.py +100 -1
  63. package/lattice_brain/network.py +11 -1
  64. package/lattice_brain/portability.py +431 -0
  65. package/lattice_brain/projection.py +11 -1
  66. package/lattice_brain/provenance.py +11 -1
  67. package/lattice_brain/retrieval.py +11 -1
  68. package/lattice_brain/runtime/__init__.py +32 -0
  69. package/lattice_brain/runtime/agent_runtime.py +569 -0
  70. package/lattice_brain/runtime/hooks.py +754 -0
  71. package/lattice_brain/runtime/multi_agent.py +795 -0
  72. package/lattice_brain/schema.py +11 -1
  73. package/lattice_brain/store.py +10 -2
  74. package/lattice_brain/workflow.py +461 -0
  75. package/lattice_brain/write_master.py +11 -1
  76. package/latticeai/__init__.py +1 -1
  77. package/latticeai/api/agents.py +2 -2
  78. package/latticeai/api/browser.py +1 -1
  79. package/latticeai/api/chat.py +1 -1
  80. package/latticeai/api/computer_use.py +1 -1
  81. package/latticeai/api/hooks.py +2 -2
  82. package/latticeai/api/mcp.py +1 -1
  83. package/latticeai/api/models.py +107 -18
  84. package/latticeai/api/tools.py +1 -1
  85. package/latticeai/api/workflow_designer.py +2 -2
  86. package/latticeai/app_factory.py +4 -4
  87. package/latticeai/brain/__init__.py +24 -6
  88. package/latticeai/brain/_kg_common.py +11 -1117
  89. package/latticeai/brain/context.py +12 -208
  90. package/latticeai/brain/conversations.py +12 -231
  91. package/latticeai/brain/discovery.py +13 -1451
  92. package/latticeai/brain/documents.py +13 -214
  93. package/latticeai/brain/identity.py +11 -169
  94. package/latticeai/brain/ingest.py +13 -640
  95. package/latticeai/brain/memory.py +12 -97
  96. package/latticeai/brain/network.py +12 -200
  97. package/latticeai/brain/projection.py +13 -567
  98. package/latticeai/brain/provenance.py +13 -397
  99. package/latticeai/brain/retrieval.py +13 -1337
  100. package/latticeai/brain/schema.py +12 -635
  101. package/latticeai/brain/store.py +13 -233
  102. package/latticeai/brain/write_master.py +13 -221
  103. package/latticeai/core/agent.py +1 -1
  104. package/latticeai/core/agent_registry.py +2 -2
  105. package/latticeai/core/builtin_hooks.py +2 -2
  106. package/latticeai/core/graph_curator.py +6 -468
  107. package/latticeai/core/hooks.py +6 -749
  108. package/latticeai/core/marketplace.py +1 -1
  109. package/latticeai/core/model_compat.py +250 -0
  110. package/latticeai/core/multi_agent.py +6 -790
  111. package/latticeai/core/workflow_engine.py +6 -456
  112. package/latticeai/core/workspace_os.py +1 -1
  113. package/latticeai/models/router.py +136 -32
  114. package/latticeai/services/agent_runtime.py +6 -564
  115. package/latticeai/services/ingestion.py +6 -313
  116. package/latticeai/services/kg_portability.py +6 -426
  117. package/latticeai/services/model_catalog.py +2 -2
  118. package/latticeai/services/model_recommendation.py +8 -1
  119. package/latticeai/services/model_runtime.py +18 -3
  120. package/latticeai/services/platform_runtime.py +3 -3
  121. package/latticeai/services/run_executor.py +1 -1
  122. package/latticeai/services/upload_service.py +1 -1
  123. package/p_reinforce.py +1 -1
  124. package/package.json +1 -1
  125. package/scripts/build_frontend_assets.mjs +12 -1
  126. package/scripts/bump_version.py +1 -1
  127. package/scripts/wheel_smoke.py +7 -0
  128. package/src-tauri/Cargo.lock +1 -1
  129. package/src-tauri/Cargo.toml +1 -1
  130. package/src-tauri/tauri.conf.json +1 -1
  131. package/static/app/asset-manifest.json +5 -5
  132. package/static/app/assets/index-3G8qcrIS.js +336 -0
  133. package/static/app/assets/index-3G8qcrIS.js.map +1 -0
  134. package/static/app/assets/index-C0wYZp7k.css +2 -0
  135. package/static/app/index.html +2 -2
  136. package/static/app/assets/index-CHHal8Zl.css +0 -2
  137. package/static/app/assets/index-pdzil9ac.js +0 -333
  138. package/static/app/assets/index-pdzil9ac.js.map +0 -1
@@ -0,0 +1,318 @@
1
+ """Unified ingestion pipeline — the single write-side seam into the Knowledge Graph.
2
+
3
+ v3.6.0 Knowledge Graph First principle: *no data source bypasses the Knowledge
4
+ Graph and no source creates an isolated silo*. Every source — local files,
5
+ connected folders, PDFs/Markdown/text/code, web URLs, browser tabs — is
6
+ normalized into one :class:`IngestionItem` and pushed through one
7
+ :meth:`IngestionPipeline.ingest` entrypoint:
8
+
9
+ Source → normalize → content hash → (file | text) ingest → provenance
10
+
11
+ The pipeline is deliberately thin. It owns normalization, idempotency reporting,
12
+ provenance capture, and — crucially — routing every ingest through the shared
13
+ ``dispatch_tool`` lifecycle so ``pre_tool``/``post_tool`` hooks fire on data
14
+ ingestion exactly as they do on tool calls. The heavy graph construction lives in
15
+ :class:`knowledge_graph.KnowledgeGraphStore` (``ingest_document`` for files,
16
+ ``ingest_source`` for text/web), which this module composes rather than
17
+ re-implements.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional
27
+
28
+ from .runtime.hooks import dispatch_tool
29
+
30
+ # Source types that arrive as a file on disk (read via ingest_document).
31
+ FILE_SOURCE_TYPES = frozenset({"file", "local_file", "upload", "pdf"})
32
+ # Source types that arrive as extracted text (read via ingest_source).
33
+ TEXT_SOURCE_TYPES = frozenset(
34
+ {"web_url", "browser_tab", "text", "markdown", "note", "code", "clipboard"}
35
+ )
36
+ # Conversational exchanges (read via ingest_message — role/content semantics,
37
+ # conversation chaining). v4: chat and MCP messages stop bypassing the
38
+ # pipeline, so they carry provenance and fire the hook lifecycle like every
39
+ # other source.
40
+ CHAT_SOURCE_TYPES = frozenset({"chat_message", "mcp_message"})
41
+ # Typed memory records (read via ingest_event → Decision/Experience/Event
42
+ # nodes). The Memory System writes through the same door as everything else.
43
+ MEMORY_SOURCE_TYPES = frozenset({"decision", "experience", "workspace_event"})
44
+ _MEMORY_NODE_TYPES = {"decision": "Decision", "experience": "Experience", "workspace_event": "Event"}
45
+
46
+ DEFAULT_MAX_TEXT_BYTES = 5 * 1024 * 1024 # 5 MB of extracted text per item
47
+
48
+
49
+ def _now_iso() -> str:
50
+ return datetime.now(timezone.utc).isoformat()
51
+
52
+
53
+ @dataclass
54
+ class IngestionItem:
55
+ """A single thing to ingest, normalized across every source type."""
56
+
57
+ source_type: str
58
+ title: Optional[str] = None
59
+ text: Optional[str] = None # text/web sources
60
+ path: Optional[str] = None # file sources
61
+ source_uri: Optional[str] = None
62
+ mime_type: Optional[str] = None
63
+ owner: Optional[str] = None
64
+ workspace_id: Optional[str] = None
65
+ permissions: Optional[Dict[str, Any]] = None
66
+ captured_at: Optional[str] = None
67
+ modified_at: Optional[str] = None
68
+ conversation_id: Optional[str] = None
69
+ agent_used: Optional[str] = None
70
+ metadata: Dict[str, Any] = field(default_factory=dict)
71
+
72
+
73
+ @dataclass
74
+ class IngestionResult:
75
+ """The outcome of one ingestion, including provenance and idempotency."""
76
+
77
+ status: str # ok | unavailable | blocked | failed
78
+ source_type: str
79
+ node_id: Optional[str] = None
80
+ source_node_id: Optional[str] = None
81
+ content_hash: Optional[str] = None
82
+ title: Optional[str] = None
83
+ chunk_ids: List[str] = field(default_factory=list)
84
+ chunk_count: int = 0
85
+ duplicate: bool = False
86
+ embedded: bool = False
87
+ indexing_status: str = "pending" # indexed | skipped | failed | pending
88
+ provenance_id: Optional[str] = None
89
+ detail: Optional[str] = None
90
+
91
+ def as_dict(self) -> Dict[str, Any]:
92
+ return {
93
+ "status": self.status,
94
+ "source_type": self.source_type,
95
+ "node_id": self.node_id,
96
+ "source_node_id": self.source_node_id,
97
+ "content_hash": self.content_hash,
98
+ "title": self.title,
99
+ "chunk_ids": self.chunk_ids,
100
+ "chunk_count": self.chunk_count,
101
+ "duplicate": self.duplicate,
102
+ "embedded": self.embedded,
103
+ "indexing_status": self.indexing_status,
104
+ "provenance_id": self.provenance_id,
105
+ "detail": self.detail,
106
+ }
107
+
108
+
109
+ class IngestionPipeline:
110
+ """Single normalized entrypoint that feeds every source into the graph."""
111
+
112
+ def __init__(
113
+ self,
114
+ knowledge_graph: Any,
115
+ *,
116
+ hooks: Any = None,
117
+ enable_graph: bool = True,
118
+ audit: Optional[Any] = None,
119
+ max_text_bytes: int = DEFAULT_MAX_TEXT_BYTES,
120
+ pipeline_name: str = "unified-ingestion",
121
+ ) -> None:
122
+ self._kg = knowledge_graph
123
+ self._hooks = hooks
124
+ self._enable = bool(enable_graph)
125
+ self._audit = audit
126
+ self._max_text_bytes = int(max_text_bytes)
127
+ self._pipeline_name = pipeline_name
128
+
129
+ def available(self) -> bool:
130
+ return self._enable and self._kg is not None
131
+
132
+ # ── public API ───────────────────────────────────────────────────────────
133
+ def ingest(self, item: IngestionItem, *, user_email: Optional[str] = None) -> IngestionResult:
134
+ """Normalize, hash, route through dispatch_tool, and record provenance."""
135
+ source_type = str(item.source_type or "text").strip()
136
+ if not self.available():
137
+ return IngestionResult(
138
+ status="unavailable", source_type=source_type,
139
+ indexing_status="skipped",
140
+ detail="Knowledge Graph is disabled (LATTICEAI_ENABLE_GRAPH).",
141
+ )
142
+
143
+ captured_at = item.captured_at or _now_iso()
144
+ owner = item.owner or user_email
145
+ tool_name = f"kg_ingest.{source_type}"
146
+ # Only the keys are read by the hook payload, so this dict is safe/cheap.
147
+ args = {
148
+ "source_type": source_type,
149
+ "source_uri": item.source_uri,
150
+ "owner": owner,
151
+ "workspace_id": item.workspace_id,
152
+ }
153
+
154
+ def _run() -> Dict[str, Any]:
155
+ if source_type in CHAT_SOURCE_TYPES:
156
+ return self._ingest_chat(item, source_type=source_type, owner=owner)
157
+ if source_type in MEMORY_SOURCE_TYPES:
158
+ return self._ingest_memory_record(item, source_type=source_type, owner=owner)
159
+ if source_type in FILE_SOURCE_TYPES or (item.path and not item.text):
160
+ return self._ingest_file(item, source_type=source_type, owner=owner, captured_at=captured_at)
161
+ return self._ingest_text(item, source_type=source_type, owner=owner, captured_at=captured_at)
162
+
163
+ try:
164
+ raw = dispatch_tool(
165
+ self._hooks, tool_name, args, _run,
166
+ user_email=user_email, workspace_id=item.workspace_id, source="ingestion",
167
+ )
168
+ except PermissionError as exc:
169
+ return IngestionResult(
170
+ status="blocked", source_type=source_type,
171
+ indexing_status="skipped", detail=str(exc),
172
+ )
173
+ except FileNotFoundError as exc:
174
+ return IngestionResult(
175
+ status="failed", source_type=source_type,
176
+ indexing_status="failed", detail=str(exc),
177
+ )
178
+ except Exception as exc: # noqa: BLE001 — surface as a failed result, never crash the caller
179
+ return IngestionResult(
180
+ status="failed", source_type=source_type,
181
+ indexing_status="failed", detail=str(exc),
182
+ )
183
+
184
+ node_id = raw.get("node_id")
185
+ content_hash = raw.get("content_hash") or raw.get("sha256")
186
+ chunk_ids = list(raw.get("chunk_ids") or [])
187
+ embedded = bool(self._kg.node_is_embedded(node_id)) if node_id else False
188
+ title = raw.get("title") or item.title
189
+
190
+ prov = self._kg.record_provenance(
191
+ node_id=node_id,
192
+ source_type=source_type,
193
+ pipeline=self._pipeline_name,
194
+ source_uri=item.source_uri,
195
+ content_hash=content_hash,
196
+ title=title,
197
+ owner=owner,
198
+ workspace_id=item.workspace_id,
199
+ captured_at=captured_at,
200
+ modified_at=item.modified_at,
201
+ embedded=embedded,
202
+ linked=bool(raw.get("source_node_id")),
203
+ duplicate=bool(raw.get("duplicate")),
204
+ agent_used=item.agent_used,
205
+ chunk_count=len(chunk_ids),
206
+ permissions=item.permissions,
207
+ metadata=item.metadata,
208
+ )
209
+ if self._audit is not None:
210
+ try:
211
+ self._audit(
212
+ "kg_ingest",
213
+ {
214
+ "source_type": source_type, "node_id": node_id,
215
+ "content_hash": content_hash, "duplicate": bool(raw.get("duplicate")),
216
+ },
217
+ user_email,
218
+ )
219
+ except Exception: # noqa: BLE001 — audit must never break ingestion
220
+ pass
221
+
222
+ return IngestionResult(
223
+ status="ok",
224
+ source_type=source_type,
225
+ node_id=node_id,
226
+ source_node_id=raw.get("source_node_id"),
227
+ content_hash=content_hash,
228
+ title=title,
229
+ chunk_ids=chunk_ids,
230
+ chunk_count=len(chunk_ids),
231
+ duplicate=bool(raw.get("duplicate")),
232
+ embedded=embedded,
233
+ indexing_status="indexed",
234
+ provenance_id=prov.get("id"),
235
+ )
236
+
237
+ # ── routing helpers ──────────────────────────────────────────────────────
238
+ def _ingest_text(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
239
+ text = item.text or ""
240
+ if len(text.encode("utf-8", "ignore")) > self._max_text_bytes:
241
+ raise ValueError(
242
+ f"Text payload exceeds the {self._max_text_bytes // (1024 * 1024)}MB ingestion limit."
243
+ )
244
+ title = item.title or item.source_uri or source_type
245
+ return self._kg.ingest_source(
246
+ source_type=source_type,
247
+ title=title,
248
+ text=text,
249
+ source_uri=item.source_uri,
250
+ owner=owner,
251
+ workspace_id=item.workspace_id,
252
+ permissions=item.permissions,
253
+ captured_at=captured_at,
254
+ modified_at=item.modified_at,
255
+ conversation_id=item.conversation_id,
256
+ metadata={"mime_type": item.mime_type, **(item.metadata or {})},
257
+ )
258
+
259
+ def _ingest_chat(self, item, *, source_type, owner) -> Dict[str, Any]:
260
+ text = item.text or ""
261
+ meta = item.metadata or {}
262
+ role = str(meta.get("role") or "user")
263
+ result = self._kg.ingest_message(
264
+ role,
265
+ text,
266
+ user_email=owner,
267
+ user_nickname=meta.get("user_nickname"),
268
+ source=meta.get("source") or source_type,
269
+ conversation_id=item.conversation_id,
270
+ raw=meta.get("raw"),
271
+ )
272
+ # ingest_message reports message/response node ids; normalize the keys
273
+ # the provenance step expects.
274
+ result.setdefault("node_id", result.get("node_id") or result.get("message_node_id") or result.get("id"))
275
+ result.setdefault("title", item.title or text[:80])
276
+ return result
277
+
278
+ def _ingest_memory_record(self, item, *, source_type, owner) -> Dict[str, Any]:
279
+ node_type = _MEMORY_NODE_TYPES[source_type]
280
+ meta = item.metadata or {}
281
+ result = self._kg.ingest_event(
282
+ node_type,
283
+ item.title or (item.text or node_type)[:120],
284
+ user_email=owner,
285
+ source=meta.get("source") or source_type,
286
+ conversation_id=item.conversation_id,
287
+ metadata={**meta, "detail": (item.text or "")[:2000]},
288
+ )
289
+ result.setdefault("node_id", result.get("node_id") or result.get("id"))
290
+ result.setdefault("title", item.title)
291
+ return result
292
+
293
+ def _ingest_file(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
294
+ if not item.path:
295
+ raise ValueError("File ingestion requires a path.")
296
+ path = Path(item.path)
297
+ if not path.exists():
298
+ raise FileNotFoundError(f"File not found: {path}")
299
+ return self._kg.ingest_document(
300
+ path,
301
+ original_filename=item.title or path.name,
302
+ mime_type=item.mime_type,
303
+ uploader=owner,
304
+ conversation_id=item.conversation_id,
305
+ extracted=item.metadata.get("extracted") if item.metadata else None,
306
+ source_type=source_type,
307
+ source_uri=item.source_uri or str(path),
308
+ captured_at=captured_at,
309
+ modified_at=item.modified_at,
310
+ owner=owner,
311
+ workspace_id=item.workspace_id,
312
+ permissions=item.permissions,
313
+ )
314
+
315
+
316
+ def content_hash_text(text: str) -> str:
317
+ """Canonical content hash for a text payload (matches store hashing scheme)."""
318
+ return hashlib.sha256((text or "").encode("utf-8", "ignore")).hexdigest()
@@ -1,3 +1,102 @@
1
- from latticeai.brain.memory import BrainMemory
1
+ """Memory System — typed, durable memory records on the brain substrate.
2
+
3
+ Decision and Experience records become first-class graph nodes through the
4
+ unified ingestion pipeline (provenance + hooks), instead of markdown dumps
5
+ with swallowed errors. Episodic memory is the conversation store; semantic
6
+ memory is the workspace MEMORY_KINDS records — this module adds the typed
7
+ record kinds the schema always had but never populated.
8
+
9
+ Only REAL events become memories: simulation runs are rejected at this
10
+ boundary (the run record's own mode field is checked — fabricated artifacts
11
+ must never enter the brain as experience).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Dict, Optional
17
+
18
+ from .ingestion import IngestionItem
19
+
20
+
21
+ class BrainMemory:
22
+ """Writes Decision / Experience records through the ingestion pipeline."""
23
+
24
+ def __init__(self, ingestion_pipeline: Any):
25
+ self._pipeline = ingestion_pipeline
26
+
27
+ def available(self) -> bool:
28
+ return self._pipeline is not None and self._pipeline.available()
29
+
30
+ def record_decision(
31
+ self,
32
+ title: str,
33
+ detail: str = "",
34
+ *,
35
+ user_email: Optional[str] = None,
36
+ workspace_id: Optional[str] = None,
37
+ conversation_id: Optional[str] = None,
38
+ decided_by: Optional[str] = None,
39
+ metadata: Optional[Dict[str, Any]] = None,
40
+ ) -> Dict[str, Any]:
41
+ if not str(title or "").strip():
42
+ raise ValueError("a decision needs a title")
43
+ result = self._pipeline.ingest(
44
+ IngestionItem(
45
+ source_type="decision",
46
+ title=title.strip(),
47
+ text=detail,
48
+ owner=user_email,
49
+ workspace_id=workspace_id,
50
+ conversation_id=conversation_id,
51
+ metadata={"decided_by": decided_by or user_email, **(metadata or {})},
52
+ ),
53
+ user_email=user_email,
54
+ )
55
+ return result.as_dict()
56
+
57
+ def record_experience(
58
+ self,
59
+ title: str,
60
+ detail: str = "",
61
+ *,
62
+ run: Optional[Dict[str, Any]] = None,
63
+ user_email: Optional[str] = None,
64
+ workspace_id: Optional[str] = None,
65
+ metadata: Optional[Dict[str, Any]] = None,
66
+ ) -> Dict[str, Any]:
67
+ """Persist a completed run/action as an Experience node.
68
+
69
+ ``run`` is the persisted run record; simulated runs are refused —
70
+ a simulation is replay scaffolding, not something that happened.
71
+ """
72
+ if run is not None and run.get("mode", "simulation") == "simulation":
73
+ return {
74
+ "status": "rejected",
75
+ "detail": "simulation runs are not experiences and never enter the brain",
76
+ }
77
+ if not str(title or "").strip():
78
+ raise ValueError("an experience needs a title")
79
+ run_meta = {}
80
+ if run is not None:
81
+ run_meta = {
82
+ "run_id": run.get("id"),
83
+ "agent_id": run.get("agent_id"),
84
+ "run_status": run.get("status"),
85
+ "mode": run.get("mode"),
86
+ "retries": run.get("retries"),
87
+ }
88
+ result = self._pipeline.ingest(
89
+ IngestionItem(
90
+ source_type="experience",
91
+ title=title.strip(),
92
+ text=detail,
93
+ owner=user_email,
94
+ workspace_id=workspace_id,
95
+ metadata={**run_meta, **(metadata or {})},
96
+ ),
97
+ user_email=user_email,
98
+ )
99
+ return result.as_dict()
100
+
2
101
 
3
102
  __all__ = ["BrainMemory"]
@@ -1 +1,11 @@
1
- from latticeai.brain.network import * # noqa: F401,F403
1
+ """Compatibility shim: implementation moved to lattice_brain.graph.network.
2
+
3
+ This module aliases itself to the physical module so identity, singletons,
4
+ and monkeypatching behave as if the old flat path were the real module.
5
+ """
6
+
7
+ import sys
8
+
9
+ from .graph import network as _impl
10
+
11
+ sys.modules[__name__] = _impl