ltcai 8.9.0 → 9.1.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 (208) hide show
  1. package/README.md +81 -58
  2. package/auto_setup.py +7 -904
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +221 -238
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  13. package/docs/TRUST_MODEL.md +5 -2
  14. package/docs/WHY_LATTICE.md +15 -10
  15. package/docs/WORKFLOW_DESIGNER.md +22 -0
  16. package/docs/kg-schema.md +13 -2
  17. package/docs/mcp-tools.md +17 -6
  18. package/docs/privacy.md +19 -3
  19. package/docs/public-deploy.md +32 -3
  20. package/docs/security-model.md +46 -11
  21. package/lattice_brain/__init__.py +1 -1
  22. package/lattice_brain/archive.py +4 -14
  23. package/lattice_brain/context.py +66 -9
  24. package/lattice_brain/embeddings.py +38 -2
  25. package/lattice_brain/graph/_kg_common.py +27 -462
  26. package/lattice_brain/graph/_kg_constants.py +243 -0
  27. package/lattice_brain/graph/_kg_fsutil.py +293 -0
  28. package/lattice_brain/graph/discovery.py +0 -948
  29. package/lattice_brain/graph/discovery_index.py +1126 -0
  30. package/lattice_brain/graph/documents.py +44 -9
  31. package/lattice_brain/graph/ingest.py +47 -20
  32. package/lattice_brain/graph/provenance.py +13 -6
  33. package/lattice_brain/graph/retrieval.py +141 -610
  34. package/lattice_brain/graph/retrieval_docgen.py +243 -0
  35. package/lattice_brain/graph/retrieval_vector.py +460 -0
  36. package/lattice_brain/graph/store.py +6 -0
  37. package/lattice_brain/ingestion.py +69 -4
  38. package/lattice_brain/portability.py +29 -23
  39. package/lattice_brain/quality.py +98 -4
  40. package/lattice_brain/runtime/agent_runtime.py +169 -7
  41. package/lattice_brain/runtime/hooks.py +30 -13
  42. package/lattice_brain/runtime/multi_agent.py +27 -8
  43. package/lattice_brain/runtime/statuses.py +10 -0
  44. package/lattice_brain/utils.py +46 -0
  45. package/lattice_brain/workflow.py +1 -5
  46. package/latticeai/__init__.py +1 -1
  47. package/latticeai/api/admin.py +1 -1
  48. package/latticeai/api/agent_registry.py +10 -8
  49. package/latticeai/api/agents.py +22 -3
  50. package/latticeai/api/auth.py +78 -16
  51. package/latticeai/api/browser.py +303 -34
  52. package/latticeai/api/chat.py +355 -952
  53. package/latticeai/api/chat_agent_http.py +356 -0
  54. package/latticeai/api/chat_contracts.py +54 -0
  55. package/latticeai/api/chat_documents.py +256 -0
  56. package/latticeai/api/chat_helpers.py +250 -0
  57. package/latticeai/api/chat_history.py +99 -0
  58. package/latticeai/api/chat_intents.py +302 -0
  59. package/latticeai/api/chat_stream.py +115 -0
  60. package/latticeai/api/computer_use.py +211 -40
  61. package/latticeai/api/health.py +9 -3
  62. package/latticeai/api/hooks.py +17 -6
  63. package/latticeai/api/knowledge_graph.py +78 -14
  64. package/latticeai/api/local_files.py +7 -2
  65. package/latticeai/api/marketplace.py +11 -0
  66. package/latticeai/api/mcp.py +102 -23
  67. package/latticeai/api/models.py +72 -33
  68. package/latticeai/api/network.py +5 -5
  69. package/latticeai/api/permissions.py +70 -29
  70. package/latticeai/api/plugins.py +21 -7
  71. package/latticeai/api/portability.py +5 -5
  72. package/latticeai/api/realtime.py +33 -5
  73. package/latticeai/api/setup.py +2 -2
  74. package/latticeai/api/static_routes.py +123 -24
  75. package/latticeai/api/tools.py +97 -14
  76. package/latticeai/api/workflow_designer.py +37 -0
  77. package/latticeai/api/workspace.py +2 -1
  78. package/latticeai/app_factory.py +185 -405
  79. package/latticeai/core/agent.py +19 -9
  80. package/latticeai/core/agent_registry.py +1 -5
  81. package/latticeai/core/config.py +23 -3
  82. package/latticeai/core/context_builder.py +21 -3
  83. package/latticeai/core/invitations.py +1 -4
  84. package/latticeai/core/io_utils.py +45 -0
  85. package/latticeai/core/legacy_compatibility.py +24 -3
  86. package/latticeai/core/local_embeddings.py +2 -4
  87. package/latticeai/core/marketplace.py +33 -2
  88. package/latticeai/core/mcp_catalog.py +450 -0
  89. package/latticeai/core/mcp_registry.py +2 -441
  90. package/latticeai/core/plugins.py +15 -0
  91. package/latticeai/core/policy.py +1 -1
  92. package/latticeai/core/realtime.py +38 -15
  93. package/latticeai/core/sessions.py +7 -2
  94. package/latticeai/core/timeutil.py +10 -0
  95. package/latticeai/core/tool_registry.py +90 -18
  96. package/latticeai/core/users.py +5 -14
  97. package/latticeai/core/workspace_graph_trace.py +31 -5
  98. package/latticeai/core/workspace_memory.py +3 -1
  99. package/latticeai/core/workspace_os.py +18 -7
  100. package/latticeai/core/workspace_os_utils.py +2 -21
  101. package/latticeai/core/workspace_permissions.py +2 -1
  102. package/latticeai/core/workspace_plugins.py +1 -1
  103. package/latticeai/core/workspace_runs.py +14 -5
  104. package/latticeai/core/workspace_skills.py +1 -1
  105. package/latticeai/core/workspace_snapshots.py +2 -1
  106. package/latticeai/core/workspace_timeline.py +2 -1
  107. package/latticeai/integrations/telegram_bot.py +96 -40
  108. package/latticeai/models/model_providers.py +111 -0
  109. package/latticeai/models/router.py +189 -173
  110. package/latticeai/runtime/access_runtime.py +62 -4
  111. package/latticeai/runtime/audit_runtime.py +27 -16
  112. package/latticeai/runtime/automation_runtime.py +22 -7
  113. package/latticeai/runtime/brain_runtime.py +19 -7
  114. package/latticeai/runtime/chat_wiring.py +2 -0
  115. package/latticeai/runtime/config_runtime.py +58 -26
  116. package/latticeai/runtime/context_runtime.py +16 -4
  117. package/latticeai/runtime/history_runtime.py +163 -0
  118. package/latticeai/runtime/hooks_runtime.py +1 -1
  119. package/latticeai/runtime/model_wiring.py +33 -5
  120. package/latticeai/runtime/namespace_runtime.py +163 -0
  121. package/latticeai/runtime/network_config_runtime.py +56 -0
  122. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  123. package/latticeai/runtime/review_wiring.py +1 -1
  124. package/latticeai/runtime/router_registration.py +34 -1
  125. package/latticeai/runtime/security_runtime.py +110 -13
  126. package/latticeai/runtime/sso_config_runtime.py +128 -0
  127. package/latticeai/runtime/stages.py +27 -0
  128. package/latticeai/runtime/user_key_runtime.py +106 -0
  129. package/latticeai/server_app.py +5 -1
  130. package/latticeai/services/architecture_readiness.py +74 -5
  131. package/latticeai/services/brain_automation.py +3 -26
  132. package/latticeai/services/chat_service.py +205 -15
  133. package/latticeai/services/local_knowledge.py +423 -0
  134. package/latticeai/services/memory_service.py +268 -21
  135. package/latticeai/services/model_engines.py +48 -33
  136. package/latticeai/services/model_errors.py +17 -0
  137. package/latticeai/services/model_loading.py +28 -25
  138. package/latticeai/services/model_runtime.py +228 -162
  139. package/latticeai/services/p_reinforce.py +22 -3
  140. package/latticeai/services/platform_runtime.py +92 -24
  141. package/latticeai/services/product_readiness.py +19 -17
  142. package/latticeai/services/review_queue.py +76 -11
  143. package/latticeai/services/router_context.py +1 -0
  144. package/latticeai/services/run_executor.py +25 -9
  145. package/latticeai/services/search_service.py +203 -28
  146. package/latticeai/services/setup_detection.py +80 -0
  147. package/latticeai/services/tool_dispatch.py +28 -5
  148. package/latticeai/services/triggers.py +53 -4
  149. package/latticeai/services/upload_service.py +13 -2
  150. package/latticeai/setup/__init__.py +25 -0
  151. package/latticeai/setup/auto_setup.py +857 -0
  152. package/latticeai/setup/wizard.py +1264 -0
  153. package/latticeai/tools/__init__.py +278 -0
  154. package/{tools → latticeai/tools}/commands.py +67 -7
  155. package/{tools → latticeai/tools}/computer.py +1 -1
  156. package/{tools → latticeai/tools}/documents.py +1 -1
  157. package/{tools → latticeai/tools}/filesystem.py +3 -3
  158. package/latticeai/tools/knowledge.py +178 -0
  159. package/{tools → latticeai/tools}/local_files.py +7 -1
  160. package/{tools → latticeai/tools}/network.py +0 -1
  161. package/local_knowledge_api.py +4 -342
  162. package/package.json +22 -2
  163. package/scripts/brain_quality_eval.py +3 -1
  164. package/scripts/bump_version.py +3 -0
  165. package/scripts/capture_release_evidence.mjs +180 -0
  166. package/scripts/check_current_release_docs.mjs +142 -0
  167. package/scripts/check_i18n_literals.mjs +107 -31
  168. package/scripts/check_openapi_drift.mjs +56 -0
  169. package/scripts/export_openapi.py +67 -7
  170. package/scripts/i18n_literal_allowlist.json +22 -24
  171. package/scripts/run_integration_tests.mjs +72 -10
  172. package/scripts/validate_release_artifacts.py +49 -7
  173. package/scripts/wheel_smoke.py +5 -0
  174. package/setup_wizard.py +3 -1304
  175. package/src-tauri/Cargo.lock +1 -1
  176. package/src-tauri/Cargo.toml +1 -1
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +11 -11
  179. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  180. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  181. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  182. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  183. package/static/app/assets/System-80lHW0Ux.js +1 -0
  184. package/static/app/assets/index-BiMofBTM.js +17 -0
  185. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  186. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  187. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  188. package/static/app/index.html +2 -2
  189. package/static/css/tokens.css +4 -2
  190. package/static/sw.js +1 -1
  191. package/tools/__init__.py +21 -269
  192. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  193. package/latticeai/runtime/app_context_runtime.py +0 -13
  194. package/latticeai/runtime/sso_runtime.py +0 -52
  195. package/latticeai/runtime/tail_wiring.py +0 -21
  196. package/scripts/com.pts.claudecode.discord.plist +0 -31
  197. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  198. package/scripts/start-pts-claudecode-discord.sh +0 -51
  199. package/static/app/assets/Act-fZokUnC0.js +0 -1
  200. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  201. package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
  202. package/static/app/assets/Library-C9kyFkSt.js +0 -1
  203. package/static/app/assets/System-VbChmX7r.js +0 -1
  204. package/static/app/assets/index-DCh5AoXt.css +0 -2
  205. package/static/app/assets/index-DPdcPoF0.js +0 -17
  206. package/static/app/assets/primitives-DFeanEV6.js +0 -1
  207. package/static/app/assets/textarea-CD8UNKIy.js +0 -1
  208. package/tools/knowledge.py +0 -95
@@ -13,14 +13,12 @@ any cloud service. Two complementary mechanisms, both fully local:
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- import hashlib
17
16
  import json
18
17
  import os
19
18
  import shutil
20
19
  import sqlite3
21
20
  import tempfile
22
21
  import zipfile
23
- from datetime import datetime, timezone
24
22
  from pathlib import Path, PurePosixPath
25
23
  from typing import Any, Dict, Optional
26
24
 
@@ -30,26 +28,16 @@ from .storage import (
30
28
  PostgresEngine,
31
29
  SQLiteToPostgresMigrator,
32
30
  )
31
+ from .utils import sha256_file as _sha256_file
32
+ from .utils import utc_now_iso
33
33
 
34
34
  FORMAT = "latticeai.kg.export"
35
35
  FORMAT_VERSION = 1
36
36
  BACKUP_FORMAT = "latticeai.kg.backup"
37
37
 
38
38
 
39
- def _now_iso() -> str:
40
- return datetime.now(timezone.utc).isoformat()
41
-
42
-
43
39
  def _stamp() -> str:
44
- return _now_iso().replace(":", "").replace("-", "").replace(".", "")[:15]
45
-
46
-
47
- def _sha256_file(path: Path) -> str:
48
- h = hashlib.sha256()
49
- with open(path, "rb") as fh:
50
- for block in iter(lambda: fh.read(65536), b""):
51
- h.update(block)
52
- return h.hexdigest()
40
+ return utc_now_iso().replace(":", "").replace("-", "").replace(".", "")[:15]
53
41
 
54
42
 
55
43
  def _safe_zip_names(names) -> None:
@@ -168,15 +156,24 @@ class KGPortabilityService:
168
156
  raise RuntimeError("Knowledge Graph is disabled (LATTICEAI_ENABLE_GRAPH).")
169
157
 
170
158
  # ── logical export / import ──────────────────────────────────────────────
171
- def export(self, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
159
+ def export(
160
+ self,
161
+ *,
162
+ workspace_id: Optional[str] = None,
163
+ include_legacy_global: bool = False,
164
+ ) -> Dict[str, Any]:
172
165
  self._require()
173
- data = self._kg.export_graph_data(workspace_id=workspace_id)
166
+ data = self._kg.export_graph_data(
167
+ workspace_id=workspace_id,
168
+ include_legacy_global=include_legacy_global,
169
+ )
174
170
  header = {
175
171
  "format": FORMAT,
176
172
  "format_version": FORMAT_VERSION,
177
173
  **self._kg.schema_versions(),
178
- "exported_at": _now_iso(),
174
+ "exported_at": utc_now_iso(),
179
175
  "workspace_id": workspace_id,
176
+ "include_legacy_global": bool(include_legacy_global),
180
177
  "counts": data.get("counts"),
181
178
  }
182
179
  artifact = {"header": header, **data}
@@ -184,8 +181,17 @@ class KGPortabilityService:
184
181
  artifact["signature"] = self._identity.sign_manifest(header)
185
182
  return artifact
186
183
 
187
- def export_to_file(self, path=None, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
188
- artifact = self.export(workspace_id=workspace_id)
184
+ def export_to_file(
185
+ self,
186
+ path=None,
187
+ *,
188
+ workspace_id: Optional[str] = None,
189
+ include_legacy_global: bool = False,
190
+ ) -> Dict[str, Any]:
191
+ artifact = self.export(
192
+ workspace_id=workspace_id,
193
+ include_legacy_global=include_legacy_global,
194
+ )
189
195
  self._exports_dir.mkdir(parents=True, exist_ok=True)
190
196
  path = Path(path) if path else self._exports_dir / f"kg-export-{_stamp()}.json"
191
197
  path.write_text(json.dumps(artifact, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -212,7 +218,7 @@ class KGPortabilityService:
212
218
  if not dry_run:
213
219
  try:
214
220
  self._kg.record_provenance(
215
- node_id="import:" + str((artifact.get("header") or {}).get("exported_at") or _now_iso()),
221
+ node_id="import:" + str((artifact.get("header") or {}).get("exported_at") or utc_now_iso()),
216
222
  source_type="bundle_import",
217
223
  pipeline="kg-portability",
218
224
  owner=None,
@@ -240,7 +246,7 @@ class KGPortabilityService:
240
246
  "format": BACKUP_FORMAT,
241
247
  "format_version": FORMAT_VERSION,
242
248
  **self._kg.schema_versions(),
243
- "created_at": _now_iso(),
249
+ "created_at": utc_now_iso(),
244
250
  "db_sha256": _sha256_file(db_copy),
245
251
  "has_blobs": Path(self._kg.blob_dir).exists(),
246
252
  }
@@ -341,7 +347,7 @@ class KGPortabilityService:
341
347
  "storage": self.storage_status().get("active", {}),
342
348
  "snapshot": self.snapshot_metadata(),
343
349
  "device_identity": self._identity.describe() if self._identity is not None else {},
344
- "provenance": {"exported_at": _now_iso(), "source": "kg-portability"},
350
+ "provenance": {"exported_at": utc_now_iso(), "source": "kg-portability"},
345
351
  }
346
352
  archive = EncryptedBrainArchive(
347
353
  BrainArchivePaths(
@@ -152,6 +152,28 @@ class MemoryCandidate:
152
152
  conflicts: List[str] = field(default_factory=list)
153
153
 
154
154
  class MemoryQualityManager:
155
+ _NEGATION_PATTERNS = (
156
+ "not",
157
+ "does not",
158
+ "don't",
159
+ "do not",
160
+ "싫어",
161
+ "원하지 않",
162
+ "하지 않",
163
+ "반대",
164
+ )
165
+ _POSITIVE_PATTERNS = (
166
+ "prefers",
167
+ "prefer",
168
+ "likes",
169
+ "like",
170
+ "wants",
171
+ "want",
172
+ "좋아",
173
+ "선호",
174
+ "원해",
175
+ )
176
+
155
177
  def extract_candidates(self, memories: List[Dict]) -> List[MemoryCandidate]:
156
178
  return [MemoryCandidate(m["id"], m["content"], m.get("score", 0.6), m.get("source", "mem")) for m in memories]
157
179
 
@@ -163,10 +185,16 @@ class MemoryQualityManager:
163
185
  return sorted(cands, key=lambda x: x.score, reverse=True)
164
186
 
165
187
  def dedupe(self, cands: List[MemoryCandidate], threshold: float = 0.85) -> List[MemoryCandidate]:
188
+ """Advanced content dedup (KG scale slice).
189
+
190
+ Uses sha256 prefix + normalized length bucket for better collision resistance
191
+ than plain md5. Future: plug embedding cosine or simhash here.
192
+ """
166
193
  kept = []
167
194
  seen = set()
168
195
  for c in cands:
169
- h = hashlib.md5(c.content.encode()).hexdigest()[:16]
196
+ norm = " ".join(c.content.lower().split())[:200]
197
+ h = hashlib.sha256((norm + f"|{len(c.content)//50}").encode()).hexdigest()[:16]
170
198
  if h not in seen:
171
199
  seen.add(h)
172
200
  kept.append(c)
@@ -182,12 +210,78 @@ class MemoryQualityManager:
182
210
  return list(merged.values())
183
211
 
184
212
  def detect_conflicts(self, cands: List[MemoryCandidate]) -> List[MemoryCandidate]:
185
- # Lightweight local heuristic; LLM conflict classifiers can replace it.
186
- for i, c in enumerate(cands):
187
- if "not" in c.content.lower() or "반대" in c.content:
213
+ """Flag local contradiction signals before proactive synthesis.
214
+
215
+ The heuristic stays deterministic/offline: direct negation marks a row
216
+ as risky, and pairwise token overlap catches common preference conflicts
217
+ such as "prefers light mode" vs "does not like light mode".
218
+ """
219
+ for c in cands:
220
+ lowered = c.content.lower()
221
+ if any(pattern in lowered for pattern in self._NEGATION_PATTERNS):
188
222
  c.conflicts.append("conflict:possible_negation")
223
+
224
+ signatures = [(c, self._content_signature(c.content)) for c in cands]
225
+ for left_index, (left, left_sig) in enumerate(signatures):
226
+ for right, right_sig in signatures[left_index + 1:]:
227
+ if not left_sig or len(left_sig & right_sig) < 2:
228
+ continue
229
+ left_negative = self._is_negative(left.content)
230
+ right_negative = self._is_negative(right.content)
231
+ left_positive = self._is_positive(left.content)
232
+ right_positive = self._is_positive(right.content)
233
+ if left_negative == right_negative:
234
+ continue
235
+ if not (left_positive or right_positive):
236
+ continue
237
+ left.conflicts.append(f"conflict:contradicts:{right.id}")
238
+ right.conflicts.append(f"conflict:contradicts:{left.id}")
189
239
  return cands
190
240
 
241
+ def _is_negative(self, content: str) -> bool:
242
+ lowered = content.lower()
243
+ return any(pattern in lowered for pattern in self._NEGATION_PATTERNS)
244
+
245
+ def _is_positive(self, content: str) -> bool:
246
+ lowered = content.lower()
247
+ return any(pattern in lowered for pattern in self._POSITIVE_PATTERNS)
248
+
249
+ def _content_signature(self, content: str) -> set[str]:
250
+ stopwords = {
251
+ "a", "an", "and", "for", "i", "is", "it", "mode", "the", "to",
252
+ "user", "users", "does", "do", "not", "like", "likes", "prefer",
253
+ "prefers", "want", "wants",
254
+ }
255
+ tokens = set(re.findall(r"\w+", content.lower()))
256
+ return {token for token in tokens if len(token) > 2 and token not in stopwords}
257
+
258
+ # --- Large candidate #4 slice: proactive / temporal contradiction detection ---
259
+ def detect_temporal_contradictions(self, memories: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
260
+ """Simple temporal + contradiction detector over memory list (proactive synthesis extension).
261
+
262
+ Looks at negation keywords across similar contents + differing timestamps.
263
+ Real version would walk graph historical projections + LLM judge.
264
+ Returns flagged items with 'contradiction' tag.
265
+ """
266
+ flagged = []
267
+ neg_tokens = ("not ", "반대", "거짓", "no longer", "never", "중단", "사용하지")
268
+ pos_group = []
269
+ neg_group = []
270
+ for m in memories:
271
+ c = (m.get("content") or "").lower()
272
+ if any(t in c for t in neg_tokens):
273
+ neg_group.append(m)
274
+ else:
275
+ pos_group.append(m)
276
+ # cross check: if both pos and neg exist with time spread
277
+ all_t = [m.get("timestamp") or m.get("created_at") or 0 for m in memories]
278
+ if neg_group and pos_group and len(set(all_t)) > 1:
279
+ for g in (pos_group + neg_group):
280
+ gg = dict(g)
281
+ gg["proactive_flag"] = "contradiction:temporal_negation"
282
+ flagged.append(gg)
283
+ return flagged
284
+
191
285
  def apply_retention(self, cands: List[MemoryCandidate], max_age_days: int = 90) -> List[MemoryCandidate]:
192
286
  now = time.time()
193
287
  return [c for c in cands if (now - c.timestamp) < max_age_days * 86400]
@@ -28,7 +28,6 @@ it, the frontend) now depends on this boundary instead of internal paths.
28
28
 
29
29
  from __future__ import annotations
30
30
 
31
- from datetime import datetime
32
31
  from typing import Any, Callable, Dict, List, Optional
33
32
 
34
33
  from .multi_agent import (
@@ -45,6 +44,11 @@ from .contracts import (
45
44
  run_record_contract,
46
45
  runtime_boundary_contract,
47
46
  )
47
+ from .statuses import (
48
+ RUN_ACTIVE_STATUSES as _ACTIVE_STATUSES,
49
+ RUN_TERMINAL_STATUSES as _TERMINAL_STATUSES,
50
+ )
51
+ from ..utils import now_iso as _now
48
52
 
49
53
  ROLE_DESCRIPTIONS = {
50
54
  "researcher": "Gathers workspace context and memory for the goal.",
@@ -57,12 +61,8 @@ ROLE_DESCRIPTIONS = {
57
61
  # Run statuses the orchestrator can emit that mean "still working". The default
58
62
  # orchestrator runs synchronously, so persisted runs are always terminal; this
59
63
  # set lets the runtime report live work if a future async runner lands.
60
- _ACTIVE_STATUSES = {"running", "in_progress", "queued", "retrying", "cancelling"}
61
- _TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
62
-
63
-
64
- def _now() -> str:
65
- return datetime.now().isoformat(timespec="seconds")
64
+ def _compact_text(value: Any, *, limit: int) -> str:
65
+ return " ".join(str(value or "").split())[:limit]
66
66
 
67
67
 
68
68
  class AgentRuntimeUnavailable(RuntimeError):
@@ -80,6 +80,8 @@ class AgentRuntime:
80
80
  max_retries_cap: int = 5,
81
81
  hooks: Any = None,
82
82
  allow_simulation_runs: bool = False,
83
+ memory_ingest: Optional[Callable[..., Dict[str, Any]]] = None,
84
+ review_sink: Any = None,
83
85
  ):
84
86
  self._store = store
85
87
  self._orchestrator_factory = orchestrator_factory
@@ -91,6 +93,11 @@ class AgentRuntime:
91
93
  self._hooks = hooks
92
94
  self._allow_simulation_runs = bool(allow_simulation_runs)
93
95
  self._run_executor: Any = None
96
+ # Optional memory synthesis: successful agent runs produce durable
97
+ # Brain memories (long_term / workspace tier) so users *feel* the results
98
+ # in BrainBrief, MemoryRings, search and graph immediately.
99
+ self._memory_ingest = memory_ingest
100
+ self._review_sink = review_sink
94
101
 
95
102
  def attach_executor(self, executor: Any) -> None:
96
103
  self._run_executor = executor
@@ -415,6 +422,155 @@ class AgentRuntime:
415
422
  "current_role": None,
416
423
  }
417
424
 
425
+ def _synthesize_brain_memory(self, *, goal: str, result: Any, user_email: Optional[str], scope: Optional[str]) -> None:
426
+ """Turn a successful agent run into durable Brain memory + graph nodes.
427
+
428
+ This is the key to users *strongly feeling* the scale of agent work:
429
+ delegated goals don't disappear into Act tab; they become part of the
430
+ Living Brain (searchable, visible in rings/brief, connected in graph).
431
+ """
432
+ if not self._memory_ingest:
433
+ return
434
+ if getattr(result, "status", None) not in ("ok", "retried_ok"):
435
+ return
436
+ try:
437
+ output = _compact_text(getattr(result, "output", ""), limit=2200)
438
+ plan_steps = getattr(result, "plan", None) or []
439
+ sections = self._agent_synthesis_sections(goal=goal, output=output, plan_steps=plan_steps, result=result)
440
+ plan_summary = "; ".join(sections["plan_steps"][:4])
441
+ content = "\n\n".join([
442
+ f"[Agent synthesis] Goal: {_compact_text(goal, limit=240)}",
443
+ f"Outcome: {output}",
444
+ self._format_synthesis_section("Key facts", sections["facts"]),
445
+ self._format_synthesis_section("Decisions", sections["decisions"]),
446
+ self._format_synthesis_section("Follow-ups", sections["followups"]),
447
+ f"Plan: {plan_summary}" if plan_summary else "",
448
+ ]).strip()
449
+ if not content or len(content) < 20:
450
+ return
451
+ tags = ["agent-synthesis", "delegated", "auto"]
452
+ self._memory_ingest(
453
+ kind="long_term",
454
+ content=content,
455
+ user_email=user_email,
456
+ tags=tags,
457
+ metadata={
458
+ "source": "agent_runtime",
459
+ "synthesis_version": 2,
460
+ "goal": _compact_text(goal, limit=200),
461
+ "roles": getattr(result, "roles_run", None),
462
+ "facts": sections["facts"],
463
+ "decisions": sections["decisions"],
464
+ "followups": sections["followups"],
465
+ },
466
+ graph=self._workspace_graph() if hasattr(self, "_workspace_graph") else None,
467
+ workspace_id=scope,
468
+ )
469
+ if sections["decisions"]:
470
+ self._memory_ingest(
471
+ kind="decisions",
472
+ content=f"Agent decision for {_compact_text(goal, limit=120)}: {'; '.join(sections['decisions'][:3])}",
473
+ user_email=user_email,
474
+ tags=["agent", "outcome", "decision"],
475
+ metadata={"source": "agent_runtime_synthesis", "synthesis_version": 2, "goal": _compact_text(goal, limit=200)},
476
+ workspace_id=scope,
477
+ )
478
+ if sections["followups"]:
479
+ self._memory_ingest(
480
+ kind="workspace",
481
+ content=f"Agent follow-ups for {_compact_text(goal, limit=120)}: {'; '.join(sections['followups'][:5])}",
482
+ user_email=user_email,
483
+ tags=["agent", "follow-up", "next-action"],
484
+ metadata={"source": "agent_runtime_followups", "synthesis_version": 2, "goal": _compact_text(goal, limit=200)},
485
+ workspace_id=scope,
486
+ )
487
+ self._enqueue_agent_followups(
488
+ goal=goal,
489
+ followups=sections["followups"],
490
+ result=result,
491
+ user_email=user_email,
492
+ scope=scope,
493
+ output=output,
494
+ )
495
+ except Exception:
496
+ # Synthesis must never break the run record.
497
+ pass
498
+
499
+ @staticmethod
500
+ def _format_synthesis_section(title: str, items: List[str]) -> str:
501
+ if not items:
502
+ return ""
503
+ return f"{title}:\n" + "\n".join(f"- {item}" for item in items[:5])
504
+
505
+ @staticmethod
506
+ def _agent_synthesis_sections(*, goal: str, output: str, plan_steps: List[Dict[str, Any]], result: Any) -> Dict[str, List[str]]:
507
+ plan_descriptions = [
508
+ _compact_text(step.get("description") or step.get("name") or step.get("id"), limit=120)
509
+ for step in plan_steps
510
+ if isinstance(step, dict) and (step.get("description") or step.get("name") or step.get("id"))
511
+ ]
512
+ sentences = [
513
+ sentence.strip(" -•\t")
514
+ for sentence in output.replace("\n", ". ").split(".")
515
+ if sentence.strip(" -•\t")
516
+ ]
517
+ facts = [_compact_text(sentence, limit=180) for sentence in sentences[:4]]
518
+ review = getattr(result, "review", None) if isinstance(getattr(result, "review", None), dict) else {}
519
+ plan_review = getattr(result, "plan_review", None) if isinstance(getattr(result, "plan_review", None), dict) else {}
520
+ decision_seed = review.get("decision") or review.get("status") or plan_review.get("decision") or getattr(result, "status", "")
521
+ decisions = [
522
+ _compact_text(f"Run finished with {decision_seed}", limit=160),
523
+ _compact_text(f"Goal accepted: {goal}", limit=180),
524
+ ]
525
+ followups = [
526
+ item for item in plan_descriptions
527
+ if any(token in item.lower() for token in ("next", "follow", "review", "verify", "test", "ship", "publish", "document", "implement"))
528
+ ][:5]
529
+ if not followups:
530
+ followups = plan_descriptions[:3]
531
+ return {
532
+ "facts": [item for item in facts if item],
533
+ "decisions": [item for item in decisions if item],
534
+ "followups": [item for item in followups if item],
535
+ "plan_steps": plan_descriptions,
536
+ }
537
+
538
+ def _enqueue_agent_followups(
539
+ self,
540
+ *,
541
+ goal: str,
542
+ followups: List[str],
543
+ result: Any,
544
+ user_email: Optional[str],
545
+ scope: Optional[str],
546
+ output: str,
547
+ ) -> None:
548
+ if self._review_sink is None:
549
+ return
550
+ for index, followup in enumerate(followups[:5], start=1):
551
+ try:
552
+ self._review_sink.create(
553
+ title=_compact_text(followup, limit=96) or f"Agent follow-up {index}",
554
+ summary=_compact_text(f"From goal: {goal}", limit=420),
555
+ source="agent_followup",
556
+ kind="task_draft",
557
+ payload={
558
+ "goal": _compact_text(goal, limit=300),
559
+ "followup": _compact_text(followup, limit=300),
560
+ "output_preview": _compact_text(output, limit=800),
561
+ "roles": getattr(result, "roles_run", None),
562
+ },
563
+ provenance={
564
+ "agent_id": getattr(result, "agent_id", ""),
565
+ "source_detail": "agent_runtime_followup",
566
+ "status": getattr(result, "status", ""),
567
+ },
568
+ user_email=user_email,
569
+ workspace_id=scope,
570
+ )
571
+ except Exception:
572
+ continue
573
+
418
574
  def _post_run_hooks(
419
575
  self,
420
576
  *,
@@ -572,6 +728,9 @@ class AgentRuntime:
572
728
  status=updated.get("status") or result.status,
573
729
  retries=result.retries,
574
730
  )
731
+ # Large-scale user-visible impact: successful runs enrich the Brain permanently.
732
+ if (updated.get("status") or result.status) in ("ok", "retried_ok"):
733
+ self._synthesize_brain_memory(goal=goal, result=result, user_email=user_email, scope=scope)
575
734
  post_dispatch = self._post_run_hooks(
576
735
  run_id=run_id,
577
736
  result=result,
@@ -651,6 +810,9 @@ class AgentRuntime:
651
810
  status=result.status,
652
811
  retries=result.retries,
653
812
  )
813
+ # Large-scale user-visible impact: successful runs enrich the Brain permanently.
814
+ if result.status in ("ok", "retried_ok"):
815
+ self._synthesize_brain_memory(goal=goal, result=result, user_email=user_email, scope=scope)
654
816
 
655
817
  run_id = run.get("id") or run.get("run_id") if isinstance(run, dict) else None
656
818
  post_dispatch = self._post_run_hooks(
@@ -25,6 +25,7 @@ order.
25
25
  from __future__ import annotations
26
26
 
27
27
  import json
28
+ import logging
28
29
  import os
29
30
  import shlex
30
31
  import subprocess
@@ -32,10 +33,14 @@ import tempfile
32
33
  import threading
33
34
  import time
34
35
  from collections import deque
35
- from datetime import datetime
36
36
  from pathlib import Path
37
37
  from typing import Any, Callable, Dict, List, Optional
38
38
 
39
+ from ..utils import now_iso as _now
40
+
41
+
42
+ LOGGER = logging.getLogger(__name__)
43
+
39
44
 
40
45
  HOOK_KINDS = (
41
46
  "pre_run",
@@ -63,10 +68,6 @@ LEGACY_KIND_ALIASES = {
63
68
  HOOK_STATUSES = ("ok", "blocked", "error", "skipped", "advisory")
64
69
 
65
70
 
66
- def _now() -> str:
67
- return datetime.now().isoformat(timespec="seconds")
68
-
69
-
70
71
  class HookContext:
71
72
  """Mutable execution context handed to every hook in a dispatch.
72
73
 
@@ -213,7 +214,8 @@ def dispatch_tool(
213
214
  return run_fn()
214
215
  try:
215
216
  arg_keys = list(args.keys()) if isinstance(args, dict) else []
216
- except Exception:
217
+ except Exception as exc:
218
+ LOGGER.debug("tool argument metadata could not be inspected: %s", exc)
217
219
  arg_keys = []
218
220
  pre = hooks.fire_hook(
219
221
  "pre_tool", f"tool.{tool_name}",
@@ -341,8 +343,8 @@ class HooksRegistry:
341
343
  data.setdefault("custom", [])
342
344
  data.setdefault("overrides", {})
343
345
  return data
344
- except Exception:
345
- pass
346
+ except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
347
+ LOGGER.warning("hook registry state is unreadable at %s: %s", self._path, exc)
346
348
  return {"custom": [], "overrides": {}}
347
349
 
348
350
  def _save(self) -> None:
@@ -626,6 +628,7 @@ class HooksRegistry:
626
628
  user_email=user_email, workspace_id=workspace_id, metadata=metadata,
627
629
  )
628
630
  except Exception as exc: # pragma: no cover - defensive
631
+ LOGGER.exception("hook dispatch failed before execution: %s", kind)
629
632
  return {"kind": kind, "event": event or kind, "ran": 0, "blocked": False,
630
633
  "block_reason": "", "error": str(exc), "results": [], "generated_at": _now()}
631
634
 
@@ -694,7 +697,21 @@ class HooksRegistry:
694
697
  if not argv:
695
698
  return "skipped", "empty command", "", False
696
699
  ctx_json = json.dumps(context.as_dict(), ensure_ascii=False)
697
- env = dict(os.environ)
700
+ # Command hooks run with an intentionally small environment. In
701
+ # particular, provider keys, database credentials, session secrets,
702
+ # and arbitrary PYTHON*/NODE* injection variables from the server
703
+ # process must never be inherited by a child process. The retained
704
+ # entries are the minimum needed for executable lookup, home-relative
705
+ # tools, locale handling, temporary files, and Windows process startup.
706
+ allowed_env_keys = {
707
+ "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE",
708
+ "TMPDIR", "TMP", "TEMP", "SYSTEMROOT", "WINDIR", "PATHEXT",
709
+ }
710
+ env = {
711
+ key: value
712
+ for key, value in os.environ.items()
713
+ if key.upper() in allowed_env_keys
714
+ }
698
715
  env.update({
699
716
  "LATTICE_HOOK_KIND": context.kind,
700
717
  "LATTICE_HOOK_EVENT": context.event,
@@ -726,8 +743,8 @@ class HooksRegistry:
726
743
  data = json.load(fh)
727
744
  if isinstance(data, list):
728
745
  return data
729
- except Exception:
730
- pass
746
+ except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
747
+ LOGGER.warning("hook run history is unreadable at %s: %s", self._runs_path, exc)
731
748
  return []
732
749
 
733
750
  def _save_runs(self) -> None:
@@ -737,8 +754,8 @@ class HooksRegistry:
737
754
  with os.fdopen(fd, "w", encoding="utf-8") as fh:
738
755
  json.dump(list(self._runs), fh, ensure_ascii=False, indent=2)
739
756
  os.replace(tmp, self._runs_path)
740
- except Exception: # pragma: no cover - the run log is best-effort
741
- pass
757
+ except OSError as exc: # pragma: no cover - the run log is best-effort
758
+ LOGGER.warning("hook run history write failed at %s: %s", self._runs_path, exc)
742
759
 
743
760
  def _record_run(self, result: HookResult, context: HookContext) -> None:
744
761
  entry = result.as_dict()
@@ -15,13 +15,13 @@ installations can exercise the full Planner -> Executor -> Reviewer loop.
15
15
  from __future__ import annotations
16
16
 
17
17
  from dataclasses import dataclass, field
18
- from datetime import datetime
19
18
  from typing import Any, Callable, Dict, List, Optional
20
19
 
21
20
  from .contracts import multi_agent_contract
21
+ from ..utils import now_iso as _now
22
22
 
23
23
 
24
- MULTI_AGENT_VERSION = "8.9.0"
24
+ MULTI_AGENT_VERSION = "9.1.0"
25
25
 
26
26
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
27
27
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -50,10 +50,6 @@ REVIEW_OUTCOMES = ("approve", "reject", "retry")
50
50
  _SECRET_KEYS = ("secret", "token", "password", "api_key", "apikey", "credential")
51
51
 
52
52
 
53
- def _now() -> str:
54
- return datetime.now().isoformat(timespec="seconds")
55
-
56
-
57
53
  def _redact(value: Any) -> Any:
58
54
  """Return a JSON-safe value with obvious secret fields redacted."""
59
55
  if isinstance(value, dict):
@@ -668,7 +664,7 @@ class MultiAgentOrchestrator:
668
664
  result = self.role_runner(role, ctx) or {}
669
665
  status = result.get("status", "ok")
670
666
  except Exception as exc:
671
- result = {"error": str(exc)}
667
+ result = {"status": "error", "error": str(exc)}
672
668
  status = "error"
673
669
  if role == "reviewer":
674
670
  review = dict(ctx.review or result)
@@ -739,9 +735,32 @@ class MultiAgentOrchestrator:
739
735
  role = pipeline[index]
740
736
  if previous is not None:
741
737
  ctx.handoff(previous, role)
742
- self._run_role(role, ctx)
738
+ role_result = self._run_role(role, ctx)
743
739
  roles_run.append(role)
744
740
 
741
+ # A role exception is terminal for this attempt. Continuing would
742
+ # let downstream roles review stale or missing output and could
743
+ # incorrectly convert a failed run into an approved one.
744
+ if str(role_result.get("status") or "").lower() == "error":
745
+ reason = str(role_result.get("error") or f"{role} role failed")
746
+ existing_review = dict(ctx.review or {})
747
+ ctx.review = existing_review or {
748
+ "outcome": "reject",
749
+ "verdict": "fail",
750
+ "reason": reason,
751
+ "notes": [f"{role} did not complete"],
752
+ "reviewed_at": _now(),
753
+ }
754
+ ctx.timeline.append(
755
+ {
756
+ "event": "execution_failed",
757
+ "role": role,
758
+ "reason": reason,
759
+ "timestamp": _now(),
760
+ }
761
+ )
762
+ break
763
+
745
764
  if role == "reviewer":
746
765
  review = dict(ctx.review or {})
747
766
  outcome = _review_outcome(review)
@@ -0,0 +1,10 @@
1
+ """Canonical lifecycle states shared by persisted agent and workflow runs."""
2
+
3
+ RUN_ACTIVE_STATUSES = frozenset(
4
+ {"queued", "running", "in_progress", "retrying", "cancelling"}
5
+ )
6
+ RUN_TERMINAL_STATUSES = frozenset(
7
+ {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
8
+ )
9
+
10
+ __all__ = ["RUN_ACTIVE_STATUSES", "RUN_TERMINAL_STATUSES"]