ltcai 8.9.0 → 9.0.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 (89) hide show
  1. package/README.md +28 -39
  2. package/auto_setup.py +11 -62
  3. package/docs/CHANGELOG.md +76 -237
  4. package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
  5. package/docs/DEVELOPMENT.md +8 -8
  6. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  7. package/docs/ONBOARDING.md +2 -2
  8. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +2 -2
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/archive.py +4 -9
  14. package/lattice_brain/embeddings.py +38 -2
  15. package/lattice_brain/graph/_kg_common.py +27 -462
  16. package/lattice_brain/graph/_kg_constants.py +243 -0
  17. package/lattice_brain/graph/_kg_fsutil.py +297 -0
  18. package/lattice_brain/graph/discovery.py +0 -948
  19. package/lattice_brain/graph/discovery_index.py +972 -0
  20. package/lattice_brain/graph/retrieval.py +0 -570
  21. package/lattice_brain/graph/retrieval_docgen.py +210 -0
  22. package/lattice_brain/graph/retrieval_vector.py +460 -0
  23. package/lattice_brain/graph/store.py +6 -0
  24. package/lattice_brain/ingestion.py +68 -0
  25. package/lattice_brain/portability.py +1 -9
  26. package/lattice_brain/quality.py +98 -4
  27. package/lattice_brain/runtime/agent_runtime.py +166 -0
  28. package/lattice_brain/runtime/multi_agent.py +1 -1
  29. package/lattice_brain/utils.py +28 -0
  30. package/latticeai/__init__.py +1 -1
  31. package/latticeai/api/chat.py +340 -407
  32. package/latticeai/api/chat_helpers.py +227 -0
  33. package/latticeai/api/computer_use.py +149 -31
  34. package/latticeai/api/marketplace.py +11 -0
  35. package/latticeai/api/permissions.py +3 -3
  36. package/latticeai/api/tools.py +1 -0
  37. package/latticeai/app_factory.py +82 -360
  38. package/latticeai/core/io_utils.py +37 -0
  39. package/latticeai/core/legacy_compatibility.py +1 -1
  40. package/latticeai/core/local_embeddings.py +2 -4
  41. package/latticeai/core/marketplace.py +33 -2
  42. package/latticeai/core/mcp_catalog.py +450 -0
  43. package/latticeai/core/mcp_registry.py +2 -441
  44. package/latticeai/core/users.py +4 -9
  45. package/latticeai/core/workspace_os.py +1 -1
  46. package/latticeai/core/workspace_os_utils.py +3 -17
  47. package/latticeai/integrations/telegram_bot.py +7 -2
  48. package/latticeai/models/model_providers.py +111 -0
  49. package/latticeai/models/router.py +58 -136
  50. package/latticeai/runtime/audit_runtime.py +27 -16
  51. package/latticeai/runtime/automation_runtime.py +9 -0
  52. package/latticeai/runtime/history_runtime.py +163 -0
  53. package/latticeai/runtime/namespace_runtime.py +173 -0
  54. package/latticeai/runtime/network_config_runtime.py +56 -0
  55. package/latticeai/runtime/sso_config_runtime.py +128 -0
  56. package/latticeai/runtime/user_key_runtime.py +106 -0
  57. package/latticeai/services/architecture_readiness.py +2 -2
  58. package/latticeai/services/memory_service.py +213 -0
  59. package/latticeai/services/platform_runtime.py +9 -1
  60. package/latticeai/services/product_readiness.py +11 -11
  61. package/latticeai/services/review_queue.py +64 -11
  62. package/latticeai/services/run_executor.py +21 -0
  63. package/latticeai/services/setup_detection.py +80 -0
  64. package/latticeai/services/tool_dispatch.py +1 -1
  65. package/package.json +1 -1
  66. package/setup_wizard.py +11 -55
  67. package/src-tauri/Cargo.lock +1 -1
  68. package/src-tauri/Cargo.toml +1 -1
  69. package/src-tauri/tauri.conf.json +1 -1
  70. package/static/app/asset-manifest.json +11 -11
  71. package/static/app/assets/{Act-fZokUnC0.js → Act-21lIXx2E.js} +1 -1
  72. package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
  73. package/static/app/assets/{Capture-D5KV3Cu7.js → Capture-BA7Z2Q1u.js} +1 -1
  74. package/static/app/assets/{Library-C9kyFkSt.js → Library-bFMtyni3.js} +1 -1
  75. package/static/app/assets/System-K6krGCqn.js +1 -0
  76. package/static/app/assets/index-C4R3ws30.js +17 -0
  77. package/static/app/assets/index-ChSeOB02.css +2 -0
  78. package/static/app/assets/primitives-sQU3it5I.js +1 -0
  79. package/static/app/assets/{textarea-CD8UNKIy.js → textarea-DK3Fd_lR.js} +1 -1
  80. package/static/app/index.html +2 -2
  81. package/static/css/tokens.css +4 -2
  82. package/static/sw.js +1 -1
  83. package/tools/local_files.py +6 -0
  84. package/latticeai/runtime/sso_runtime.py +0 -52
  85. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  86. package/static/app/assets/System-VbChmX7r.js +0 -1
  87. package/static/app/assets/index-DCh5AoXt.css +0 -2
  88. package/static/app/assets/index-DPdcPoF0.js +0 -17
  89. package/static/app/assets/primitives-DFeanEV6.js +0 -1
@@ -5,10 +5,13 @@ from __future__ import annotations
5
5
  from ._kg_common import * # noqa: F403,F401
6
6
  from .documents import KnowledgeGraphDocumentsMixin
7
7
  from .discovery import KnowledgeGraphDiscoveryMixin
8
+ from .discovery_index import KnowledgeGraphLocalIndexMixin
8
9
  from .ingest import KnowledgeGraphIngestMixin
9
10
  from .projection import KnowledgeGraphProjectionMixin
10
11
  from .provenance import KnowledgeGraphProvenanceMixin
11
12
  from .retrieval import KnowledgeGraphRetrievalMixin
13
+ from .retrieval_docgen import KnowledgeGraphDocGenMixin
14
+ from .retrieval_vector import KnowledgeGraphVectorMixin
12
15
  from .write_master import KnowledgeGraphWriteMixin
13
16
 
14
17
 
@@ -16,10 +19,13 @@ class KnowledgeGraphStore(
16
19
  KnowledgeGraphProjectionMixin,
17
20
  KnowledgeGraphWriteMixin,
18
21
  KnowledgeGraphDiscoveryMixin,
22
+ KnowledgeGraphLocalIndexMixin,
19
23
  KnowledgeGraphIngestMixin,
20
24
  KnowledgeGraphProvenanceMixin,
21
25
  KnowledgeGraphDocumentsMixin,
22
26
  KnowledgeGraphRetrievalMixin,
27
+ KnowledgeGraphVectorMixin,
28
+ KnowledgeGraphDocGenMixin,
23
29
  ):
24
30
  def __init__(
25
31
  self,
@@ -50,6 +50,52 @@ def _now_iso() -> str:
50
50
  return datetime.now(timezone.utc).isoformat()
51
51
 
52
52
 
53
+ # --- Large candidate 1 slice: incremental / background ingestion support ---
54
+ @dataclass
55
+ class BackgroundIngestionJob:
56
+ """Job descriptor for background/incremental indexing (KG scale-up slice)."""
57
+ job_id: str
58
+ items: List[IngestionItem]
59
+ status: str = "pending" # pending | running | done | failed
60
+ created_at: str = field(default_factory=_now_iso)
61
+ processed: int = 0
62
+ total: int = 0
63
+ errors: List[str] = field(default_factory=list)
64
+
65
+
66
+ class BackgroundIngestionQueue:
67
+ """Simple in-memory queue for background incremental ingestion.
68
+
69
+ For large corpus: this is the seam where a real scheduler / worker pool
70
+ (celery, rq, or internal thread) can be plugged later without changing callers.
71
+ Supports incremental (skip duplicates) vs force reindex.
72
+ """
73
+ def __init__(self) -> None:
74
+ self._jobs: Dict[str, BackgroundIngestionJob] = {}
75
+ self._counter = 0
76
+
77
+ def schedule(self, items: List[IngestionItem], *, incremental: bool = True) -> BackgroundIngestionJob:
78
+ self._counter += 1
79
+ job_id = f"bg_ingest_{self._counter:04d}"
80
+ job = BackgroundIngestionJob(
81
+ job_id=job_id,
82
+ items=items,
83
+ total=len(items),
84
+ )
85
+ # annotate items for downstream
86
+ for it in job.items:
87
+ # attach flag without breaking dataclass defaults (use metadata)
88
+ it.metadata = {**it.metadata, "incremental": incremental, "bg_job": job_id}
89
+ self._jobs[job_id] = job
90
+ return job
91
+
92
+ def get(self, job_id: str) -> Optional[BackgroundIngestionJob]:
93
+ return self._jobs.get(job_id)
94
+
95
+ def list_pending(self) -> List[BackgroundIngestionJob]:
96
+ return [j for j in self._jobs.values() if j.status == "pending"]
97
+
98
+
53
99
  @dataclass
54
100
  class IngestionItem:
55
101
  """A single thing to ingest, normalized across every source type."""
@@ -118,6 +164,7 @@ class IngestionPipeline:
118
164
  audit: Optional[Any] = None,
119
165
  max_text_bytes: int = DEFAULT_MAX_TEXT_BYTES,
120
166
  pipeline_name: str = "unified-ingestion",
167
+ bg_queue: Optional[BackgroundIngestionQueue] = None,
121
168
  ) -> None:
122
169
  self._kg = knowledge_graph
123
170
  self._hooks = hooks
@@ -125,6 +172,7 @@ class IngestionPipeline:
125
172
  self._audit = audit
126
173
  self._max_text_bytes = int(max_text_bytes)
127
174
  self._pipeline_name = pipeline_name
175
+ self._bg_queue = bg_queue or BackgroundIngestionQueue()
128
176
 
129
177
  def available(self) -> bool:
130
178
  return self._enable and self._kg is not None
@@ -243,6 +291,26 @@ class IngestionPipeline:
243
291
  detail=provenance_detail,
244
292
  )
245
293
 
294
+ # --- Large candidate #1: background / incremental scheduling (slice) ---
295
+ def schedule_background(
296
+ self,
297
+ items: List[IngestionItem],
298
+ *,
299
+ incremental: bool = True,
300
+ ) -> BackgroundIngestionJob:
301
+ """Schedule items for background incremental indexing.
302
+
303
+ Returns a job handle. Actual execution can be driven by caller
304
+ (or future worker) calling pipeline.ingest on each. This seam enables
305
+ large-corpus scale without blocking user requests.
306
+ """
307
+ job = self._bg_queue.schedule(items, incremental=incremental)
308
+ # mark initial status on results concept (jobs track)
309
+ return job
310
+
311
+ def get_background_job(self, job_id: str) -> Optional[BackgroundIngestionJob]:
312
+ return self._bg_queue.get(job_id)
313
+
246
314
  # ── routing helpers ──────────────────────────────────────────────────────
247
315
  def _ingest_text(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
248
316
  text = item.text or ""
@@ -13,7 +13,6 @@ 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
@@ -30,6 +29,7 @@ from .storage import (
30
29
  PostgresEngine,
31
30
  SQLiteToPostgresMigrator,
32
31
  )
32
+ from .utils import sha256_file as _sha256_file
33
33
 
34
34
  FORMAT = "latticeai.kg.export"
35
35
  FORMAT_VERSION = 1
@@ -44,14 +44,6 @@ def _stamp() -> str:
44
44
  return _now_iso().replace(":", "").replace("-", "").replace(".", "")[:15]
45
45
 
46
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()
53
-
54
-
55
47
  def _safe_zip_names(names) -> None:
56
48
  for name in names:
57
49
  path = PurePosixPath(name)
@@ -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]
@@ -65,6 +65,10 @@ def _now() -> str:
65
65
  return datetime.now().isoformat(timespec="seconds")
66
66
 
67
67
 
68
+ def _compact_text(value: Any, *, limit: int) -> str:
69
+ return " ".join(str(value or "").split())[:limit]
70
+
71
+
68
72
  class AgentRuntimeUnavailable(RuntimeError):
69
73
  """Raised when a product run would otherwise persist simulation output."""
70
74
 
@@ -80,6 +84,8 @@ class AgentRuntime:
80
84
  max_retries_cap: int = 5,
81
85
  hooks: Any = None,
82
86
  allow_simulation_runs: bool = False,
87
+ memory_ingest: Optional[Callable[..., Dict[str, Any]]] = None,
88
+ review_sink: Any = None,
83
89
  ):
84
90
  self._store = store
85
91
  self._orchestrator_factory = orchestrator_factory
@@ -91,6 +97,11 @@ class AgentRuntime:
91
97
  self._hooks = hooks
92
98
  self._allow_simulation_runs = bool(allow_simulation_runs)
93
99
  self._run_executor: Any = None
100
+ # Optional memory synthesis: successful agent runs produce durable
101
+ # Brain memories (long_term / workspace tier) so users *feel* the results
102
+ # in BrainBrief, MemoryRings, search and graph immediately.
103
+ self._memory_ingest = memory_ingest
104
+ self._review_sink = review_sink
94
105
 
95
106
  def attach_executor(self, executor: Any) -> None:
96
107
  self._run_executor = executor
@@ -415,6 +426,155 @@ class AgentRuntime:
415
426
  "current_role": None,
416
427
  }
417
428
 
429
+ def _synthesize_brain_memory(self, *, goal: str, result: Any, user_email: Optional[str], scope: Optional[str]) -> None:
430
+ """Turn a successful agent run into durable Brain memory + graph nodes.
431
+
432
+ This is the key to users *strongly feeling* the scale of agent work:
433
+ delegated goals don't disappear into Act tab; they become part of the
434
+ Living Brain (searchable, visible in rings/brief, connected in graph).
435
+ """
436
+ if not self._memory_ingest:
437
+ return
438
+ if getattr(result, "status", None) not in ("ok", "retried_ok"):
439
+ return
440
+ try:
441
+ output = _compact_text(getattr(result, "output", ""), limit=2200)
442
+ plan_steps = getattr(result, "plan", None) or []
443
+ sections = self._agent_synthesis_sections(goal=goal, output=output, plan_steps=plan_steps, result=result)
444
+ plan_summary = "; ".join(sections["plan_steps"][:4])
445
+ content = "\n\n".join([
446
+ f"[Agent synthesis] Goal: {_compact_text(goal, limit=240)}",
447
+ f"Outcome: {output}",
448
+ self._format_synthesis_section("Key facts", sections["facts"]),
449
+ self._format_synthesis_section("Decisions", sections["decisions"]),
450
+ self._format_synthesis_section("Follow-ups", sections["followups"]),
451
+ f"Plan: {plan_summary}" if plan_summary else "",
452
+ ]).strip()
453
+ if not content or len(content) < 20:
454
+ return
455
+ tags = ["agent-synthesis", "delegated", "auto"]
456
+ self._memory_ingest(
457
+ kind="long_term",
458
+ content=content,
459
+ user_email=user_email,
460
+ tags=tags,
461
+ metadata={
462
+ "source": "agent_runtime",
463
+ "synthesis_version": 2,
464
+ "goal": _compact_text(goal, limit=200),
465
+ "roles": getattr(result, "roles_run", None),
466
+ "facts": sections["facts"],
467
+ "decisions": sections["decisions"],
468
+ "followups": sections["followups"],
469
+ },
470
+ graph=self._workspace_graph() if hasattr(self, "_workspace_graph") else None,
471
+ workspace_id=scope,
472
+ )
473
+ if sections["decisions"]:
474
+ self._memory_ingest(
475
+ kind="decisions",
476
+ content=f"Agent decision for {_compact_text(goal, limit=120)}: {'; '.join(sections['decisions'][:3])}",
477
+ user_email=user_email,
478
+ tags=["agent", "outcome", "decision"],
479
+ metadata={"source": "agent_runtime_synthesis", "synthesis_version": 2, "goal": _compact_text(goal, limit=200)},
480
+ workspace_id=scope,
481
+ )
482
+ if sections["followups"]:
483
+ self._memory_ingest(
484
+ kind="workspace",
485
+ content=f"Agent follow-ups for {_compact_text(goal, limit=120)}: {'; '.join(sections['followups'][:5])}",
486
+ user_email=user_email,
487
+ tags=["agent", "follow-up", "next-action"],
488
+ metadata={"source": "agent_runtime_followups", "synthesis_version": 2, "goal": _compact_text(goal, limit=200)},
489
+ workspace_id=scope,
490
+ )
491
+ self._enqueue_agent_followups(
492
+ goal=goal,
493
+ followups=sections["followups"],
494
+ result=result,
495
+ user_email=user_email,
496
+ scope=scope,
497
+ output=output,
498
+ )
499
+ except Exception:
500
+ # Synthesis must never break the run record.
501
+ pass
502
+
503
+ @staticmethod
504
+ def _format_synthesis_section(title: str, items: List[str]) -> str:
505
+ if not items:
506
+ return ""
507
+ return f"{title}:\n" + "\n".join(f"- {item}" for item in items[:5])
508
+
509
+ @staticmethod
510
+ def _agent_synthesis_sections(*, goal: str, output: str, plan_steps: List[Dict[str, Any]], result: Any) -> Dict[str, List[str]]:
511
+ plan_descriptions = [
512
+ _compact_text(step.get("description") or step.get("name") or step.get("id"), limit=120)
513
+ for step in plan_steps
514
+ if isinstance(step, dict) and (step.get("description") or step.get("name") or step.get("id"))
515
+ ]
516
+ sentences = [
517
+ sentence.strip(" -•\t")
518
+ for sentence in output.replace("\n", ". ").split(".")
519
+ if sentence.strip(" -•\t")
520
+ ]
521
+ facts = [_compact_text(sentence, limit=180) for sentence in sentences[:4]]
522
+ review = getattr(result, "review", None) if isinstance(getattr(result, "review", None), dict) else {}
523
+ plan_review = getattr(result, "plan_review", None) if isinstance(getattr(result, "plan_review", None), dict) else {}
524
+ decision_seed = review.get("decision") or review.get("status") or plan_review.get("decision") or getattr(result, "status", "")
525
+ decisions = [
526
+ _compact_text(f"Run finished with {decision_seed}", limit=160),
527
+ _compact_text(f"Goal accepted: {goal}", limit=180),
528
+ ]
529
+ followups = [
530
+ item for item in plan_descriptions
531
+ if any(token in item.lower() for token in ("next", "follow", "review", "verify", "test", "ship", "publish", "document", "implement"))
532
+ ][:5]
533
+ if not followups:
534
+ followups = plan_descriptions[:3]
535
+ return {
536
+ "facts": [item for item in facts if item],
537
+ "decisions": [item for item in decisions if item],
538
+ "followups": [item for item in followups if item],
539
+ "plan_steps": plan_descriptions,
540
+ }
541
+
542
+ def _enqueue_agent_followups(
543
+ self,
544
+ *,
545
+ goal: str,
546
+ followups: List[str],
547
+ result: Any,
548
+ user_email: Optional[str],
549
+ scope: Optional[str],
550
+ output: str,
551
+ ) -> None:
552
+ if self._review_sink is None:
553
+ return
554
+ for index, followup in enumerate(followups[:5], start=1):
555
+ try:
556
+ self._review_sink.create(
557
+ title=_compact_text(followup, limit=96) or f"Agent follow-up {index}",
558
+ summary=_compact_text(f"From goal: {goal}", limit=420),
559
+ source="agent_followup",
560
+ kind="task_draft",
561
+ payload={
562
+ "goal": _compact_text(goal, limit=300),
563
+ "followup": _compact_text(followup, limit=300),
564
+ "output_preview": _compact_text(output, limit=800),
565
+ "roles": getattr(result, "roles_run", None),
566
+ },
567
+ provenance={
568
+ "agent_id": getattr(result, "agent_id", ""),
569
+ "source_detail": "agent_runtime_followup",
570
+ "status": getattr(result, "status", ""),
571
+ },
572
+ user_email=user_email,
573
+ workspace_id=scope,
574
+ )
575
+ except Exception:
576
+ continue
577
+
418
578
  def _post_run_hooks(
419
579
  self,
420
580
  *,
@@ -572,6 +732,9 @@ class AgentRuntime:
572
732
  status=updated.get("status") or result.status,
573
733
  retries=result.retries,
574
734
  )
735
+ # Large-scale user-visible impact: successful runs enrich the Brain permanently.
736
+ if (updated.get("status") or result.status) in ("ok", "retried_ok"):
737
+ self._synthesize_brain_memory(goal=goal, result=result, user_email=user_email, scope=scope)
575
738
  post_dispatch = self._post_run_hooks(
576
739
  run_id=run_id,
577
740
  result=result,
@@ -651,6 +814,9 @@ class AgentRuntime:
651
814
  status=result.status,
652
815
  retries=result.retries,
653
816
  )
817
+ # Large-scale user-visible impact: successful runs enrich the Brain permanently.
818
+ if result.status in ("ok", "retried_ok"):
819
+ self._synthesize_brain_memory(goal=goal, result=result, user_email=user_email, scope=scope)
654
820
 
655
821
  run_id = run.get("id") or run.get("run_id") if isinstance(run, dict) else None
656
822
  post_dispatch = self._post_run_hooks(
@@ -21,7 +21,7 @@ from typing import Any, Callable, Dict, List, Optional
21
21
  from .contracts import multi_agent_contract
22
22
 
23
23
 
24
- MULTI_AGENT_VERSION = "8.9.0"
24
+ MULTI_AGENT_VERSION = "9.0.0"
25
25
 
26
26
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
27
27
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -0,0 +1,28 @@
1
+ """Shared Brain Core utility helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+
11
+ def parse_iso(value: Optional[str]) -> Optional[datetime]:
12
+ if not value:
13
+ return None
14
+ try:
15
+ return datetime.fromisoformat(str(value))
16
+ except (TypeError, ValueError):
17
+ return None
18
+
19
+
20
+ def sha256_file(path: Path) -> str:
21
+ digest = hashlib.sha256()
22
+ with open(path, "rb") as fh:
23
+ for block in iter(lambda: fh.read(65536), b""):
24
+ digest.update(block)
25
+ return digest.hexdigest()
26
+
27
+
28
+ __all__ = ["parse_iso", "sha256_file"]
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "8.9.0"
3
+ __version__ = "9.0.0"