ltcai 9.6.0 → 9.7.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 (60) hide show
  1. package/README.md +52 -42
  2. package/auto_setup.py +11 -1
  3. package/docs/CHANGELOG.md +54 -0
  4. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  5. package/docs/DEVELOPMENT.md +1 -1
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/PERFORMANCE.md +106 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/kg_schema.py +11 -1
  12. package/knowledge_graph.py +12 -2
  13. package/knowledge_graph_api.py +11 -1
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/graph/proactive.py +583 -0
  16. package/lattice_brain/graph/retrieval.py +211 -7
  17. package/lattice_brain/graph/retrieval_vector.py +102 -0
  18. package/lattice_brain/ingestion.py +360 -4
  19. package/lattice_brain/quality.py +44 -9
  20. package/lattice_brain/runtime/multi_agent.py +38 -2
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/brain_intelligence.py +27 -2
  23. package/latticeai/api/change_proposals.py +32 -3
  24. package/latticeai/api/review_queue.py +66 -2
  25. package/latticeai/app_factory.py +3 -0
  26. package/latticeai/core/agent.py +193 -135
  27. package/latticeai/core/agent_eval.py +155 -4
  28. package/latticeai/core/legacy_compatibility.py +15 -1
  29. package/latticeai/core/marketplace.py +1 -1
  30. package/latticeai/core/workspace_os.py +1 -1
  31. package/latticeai/runtime/router_registration.py +2 -0
  32. package/latticeai/services/architecture_readiness.py +1 -1
  33. package/latticeai/services/brain_intelligence.py +97 -1
  34. package/latticeai/services/change_proposals.py +56 -4
  35. package/latticeai/services/product_readiness.py +1 -1
  36. package/latticeai/services/review_queue.py +43 -2
  37. package/llm_router.py +10 -1
  38. package/local_knowledge_api.py +10 -1
  39. package/ltcai_cli.py +11 -1
  40. package/mcp_registry.py +10 -1
  41. package/p_reinforce.py +10 -1
  42. package/package.json +1 -1
  43. package/scripts/brain_quality_eval.py +1 -1
  44. package/scripts/check_current_release_docs.mjs +1 -1
  45. package/scripts/profile_kg.py +360 -0
  46. package/setup_wizard.py +10 -1
  47. package/src-tauri/Cargo.lock +1 -1
  48. package/src-tauri/Cargo.toml +1 -1
  49. package/src-tauri/tauri.conf.json +1 -1
  50. package/static/app/asset-manifest.json +10 -10
  51. package/static/app/assets/{Act-BkOEmwBi.js → Act-B6c39ays.js} +2 -1
  52. package/static/app/assets/{Brain-C9ITUsQ_.js → Brain-D7Qg4k6M.js} +1 -1
  53. package/static/app/assets/{Capture-C-ppTeud.js → Capture-VF_di68r.js} +1 -1
  54. package/static/app/assets/{Library-CGQbgWTu.js → Library-D_Gis2PA.js} +1 -1
  55. package/static/app/assets/{System-djmj0n2_.js → System-C5s5H2ov.js} +1 -1
  56. package/static/app/assets/{index-AF0-4XVv.js → index-DJC_2oub.js} +3 -3
  57. package/static/app/assets/{primitives-jbb2qv4Q.js → primitives-DL4Nip8C.js} +1 -1
  58. package/static/app/assets/{textarea-CFoo0OxJ.js → textarea-woZfCXHy.js} +1 -1
  59. package/static/app/index.html +1 -1
  60. package/static/sw.js +1 -1
@@ -15,14 +15,36 @@ ingestion exactly as they do on tool calls. The heavy graph construction lives i
15
15
  :class:`knowledge_graph.KnowledgeGraphStore` (``ingest_document`` for files,
16
16
  ``ingest_source`` for text/web), which this module composes rather than
17
17
  re-implements.
18
+
19
+ Web ingestion seam
20
+ ------------------
21
+ The graph layer never fetches or parses the web. Fetching, rendering,
22
+ readability extraction, and parse quality are the responsibility of the
23
+ *upstream* capture surfaces (browser extension, tools layer, MCP servers):
24
+ they hand this module already-extracted text. :meth:`IngestionPipeline.
25
+ ingest_web_page` is the convenience wrapper for that hand-off — it normalizes
26
+ ``(url, extracted_text)`` into an ``IngestionItem(source_type="web_url")`` and
27
+ routes it through the exact same :meth:`IngestionPipeline.ingest` door as every
28
+ other source. If the extracted text is bad, fix the extractor upstream; the
29
+ pipeline will not attempt network access or HTML parsing.
30
+
31
+ Folder ingestion (:meth:`IngestionPipeline.ingest_folder`) walks a local
32
+ directory, honors a gitignore-like ``.latticeignore`` file at the root
33
+ (blank lines, ``#`` comments, ``fnmatch`` glob patterns, ``dir/`` suffix for
34
+ directories), always skips common noise (``.git``, ``node_modules``,
35
+ ``__pycache__``, virtualenvs, ``dist``, hidden entries by default), applies
36
+ size/extension filters, and either ingests inline or schedules through the
37
+ existing :class:`BackgroundIngestionQueue`.
18
38
  """
19
39
 
20
40
  from __future__ import annotations
21
41
 
42
+ import fnmatch
22
43
  import hashlib
44
+ import os
23
45
  from dataclasses import dataclass, field
24
46
  from pathlib import Path
25
- from typing import Any, Dict, List, Optional
47
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
26
48
 
27
49
  from .runtime.hooks import dispatch_tool
28
50
  from .utils import utc_now_iso
@@ -45,6 +67,92 @@ _MEMORY_NODE_TYPES = {"decision": "Decision", "experience": "Experience", "works
45
67
 
46
68
  DEFAULT_MAX_TEXT_BYTES = 5 * 1024 * 1024 # 5 MB of extracted text per item
47
69
 
70
+ # ── Folder ingestion (ingest_folder) filters ─────────────────────────────────
71
+ # Directories that are always pruned regardless of .latticeignore.
72
+ FOLDER_DEFAULT_SKIP_DIRS = frozenset(
73
+ {
74
+ ".git",
75
+ "node_modules",
76
+ "__pycache__",
77
+ ".venv",
78
+ "venv",
79
+ "env",
80
+ ".pytest_cache",
81
+ ".mypy_cache",
82
+ ".ruff_cache",
83
+ "dist",
84
+ "build",
85
+ ".next",
86
+ "target",
87
+ ".cache",
88
+ ".idea",
89
+ ".vscode",
90
+ }
91
+ )
92
+ # Extension filter matching FILE_SOURCE_TYPES conventions: text/markdown/code
93
+ # are read inline (extracted content → chunks); .pdf routes as source_type
94
+ # "pdf" through ingest_document (content extraction is upstream's concern).
95
+ FOLDER_TEXT_EXTENSIONS = frozenset(
96
+ {".txt", ".md", ".markdown", ".rst", ".csv", ".json", ".yaml", ".yml", ".toml", ".ini"}
97
+ )
98
+ FOLDER_CODE_EXTENSIONS = frozenset(
99
+ {
100
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".html", ".css", ".go", ".rs",
101
+ ".java", ".c", ".h", ".cpp", ".hpp", ".rb", ".php", ".swift", ".kt",
102
+ ".sh", ".sql",
103
+ }
104
+ )
105
+ FOLDER_DOCUMENT_EXTENSIONS = frozenset({".pdf"})
106
+ DEFAULT_FOLDER_EXTENSIONS = (
107
+ FOLDER_TEXT_EXTENSIONS | FOLDER_CODE_EXTENSIONS | FOLDER_DOCUMENT_EXTENSIONS
108
+ )
109
+ DEFAULT_MAX_FILE_BYTES = 4_000_000 # matches the local-index text/code budget
110
+ LATTICEIGNORE_FILENAME = ".latticeignore"
111
+ # Opt-out escape hatch for the post-ingest incremental vector sync.
112
+ AUTO_VECTOR_INDEX_ENV = "LATTICEAI_AUTO_VECTOR_INDEX"
113
+
114
+
115
+ def _load_latticeignore(root: Path) -> List[str]:
116
+ """Parse ``root/.latticeignore`` → glob patterns (gitignore-like subset)."""
117
+ ignore_file = root / LATTICEIGNORE_FILENAME
118
+ patterns: List[str] = []
119
+ if not ignore_file.is_file():
120
+ return patterns
121
+ try:
122
+ lines = ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines()
123
+ except OSError:
124
+ return patterns
125
+ for raw in lines:
126
+ line = raw.strip()
127
+ if not line or line.startswith("#"):
128
+ continue
129
+ patterns.append(line)
130
+ return patterns
131
+
132
+
133
+ def _matches_ignore(
134
+ rel_posix: str, name: str, *, is_dir: bool, patterns: Iterable[str]
135
+ ) -> bool:
136
+ """fnmatch-based .latticeignore matching.
137
+
138
+ - ``pattern/`` matches directories only (files under it never appear
139
+ because ignored directories are pruned during the walk).
140
+ - Patterns match against both the root-relative posix path and the
141
+ basename, so ``*.log`` and ``docs/draft.md`` both behave as expected.
142
+ """
143
+ for raw in patterns:
144
+ pattern = raw
145
+ if pattern.endswith("/"):
146
+ if not is_dir:
147
+ continue
148
+ pattern = pattern.rstrip("/")
149
+ pattern = pattern.lstrip("/")
150
+ if not pattern:
151
+ continue
152
+ if fnmatch.fnmatch(rel_posix, pattern) or fnmatch.fnmatch(name, pattern):
153
+ return True
154
+ return False
155
+
48
156
 
49
157
  # --- Large candidate 1 slice: incremental / background ingestion support ---
50
158
  @dataclass
@@ -161,6 +269,7 @@ class IngestionPipeline:
161
269
  max_text_bytes: int = DEFAULT_MAX_TEXT_BYTES,
162
270
  pipeline_name: str = "unified-ingestion",
163
271
  bg_queue: Optional[BackgroundIngestionQueue] = None,
272
+ auto_vector_index: bool = True,
164
273
  ) -> None:
165
274
  self._kg = knowledge_graph
166
275
  self._hooks = hooks
@@ -169,6 +278,13 @@ class IngestionPipeline:
169
278
  self._max_text_bytes = int(max_text_bytes)
170
279
  self._pipeline_name = pipeline_name
171
280
  self._bg_queue = bg_queue or BackgroundIngestionQueue()
281
+ # Incremental vector sync after each successful non-duplicate ingest.
282
+ # Constructor opt-out AND env opt-out (LATTICEAI_AUTO_VECTOR_INDEX=0)
283
+ # both disable it; a vector failure never fails the ingest.
284
+ env_flag = os.getenv(AUTO_VECTOR_INDEX_ENV, "1").strip().lower() not in {
285
+ "0", "false", "no", "off",
286
+ }
287
+ self._auto_vector_index = bool(auto_vector_index) and env_flag
172
288
 
173
289
  def available(self) -> bool:
174
290
  return self._enable and self._kg is not None
@@ -228,9 +344,19 @@ class IngestionPipeline:
228
344
  node_id = raw.get("node_id")
229
345
  content_hash = raw.get("content_hash") or raw.get("sha256")
230
346
  chunk_ids = list(raw.get("chunk_ids") or [])
231
- embedded = bool(self._kg.node_is_embedded(node_id)) if node_id else False
232
347
  title = raw.get("title") or item.title
233
348
 
349
+ # Incremental vector-index sync (opt-in via auto_vector_index +
350
+ # LATTICEAI_AUTO_VECTOR_INDEX). Exception-safe by contract: the graph
351
+ # write above already landed, so a vector failure only downgrades
352
+ # indexing_status to "pending" — index_status()/rebuild_vector_index()
353
+ # discover the same node as backlog and pick it up later.
354
+ indexing_status = "indexed"
355
+ vector_detail: Optional[str] = None
356
+ if node_id and self._auto_vector_index and not bool(raw.get("duplicate")):
357
+ indexing_status, vector_detail = self._sync_vector_index(node_id)
358
+ embedded = bool(self._kg.node_is_embedded(node_id)) if node_id else False
359
+
234
360
  # Provenance capture must never turn an already-persisted ingest into a
235
361
  # caller-visible failure: the graph write above succeeded, so a broken
236
362
  # provenance table degrades the result instead of raising.
@@ -271,6 +397,7 @@ class IngestionPipeline:
271
397
  except Exception: # noqa: BLE001 — audit must never break ingestion
272
398
  pass
273
399
 
400
+ details = [d for d in (provenance_detail, vector_detail) if d]
274
401
  return IngestionResult(
275
402
  status="ok",
276
403
  source_type=source_type,
@@ -282,11 +409,32 @@ class IngestionPipeline:
282
409
  chunk_count=len(chunk_ids),
283
410
  duplicate=bool(raw.get("duplicate")),
284
411
  embedded=embedded,
285
- indexing_status="indexed",
412
+ indexing_status=indexing_status,
286
413
  provenance_id=prov.get("id"),
287
- detail=provenance_detail,
414
+ detail="; ".join(details) if details else None,
288
415
  )
289
416
 
417
+ def _sync_vector_index(self, node_id: str) -> Tuple[str, Optional[str]]:
418
+ """Best-effort incremental vector sync → (indexing_status, detail).
419
+
420
+ Any failure — missing method on older stores, embedding provider down,
421
+ storage error — yields ``("pending", detail)`` so a later
422
+ ``rebuild_vector_index`` run picks the node up from the backlog.
423
+ """
424
+ sync = getattr(self._kg, "index_node_incremental", None)
425
+ if not callable(sync):
426
+ # Older store without the incremental path: the write-side already
427
+ # embeds inline, so nothing extra to do.
428
+ return "indexed", None
429
+ try:
430
+ outcome = sync(node_id) or {}
431
+ except Exception as exc: # noqa: BLE001 — vector sync must never fail the ingest
432
+ return "pending", f"vector index sync failed: {exc}"
433
+ if str(outcome.get("status") or "") == "failed":
434
+ reason = outcome.get("detail") or "unknown error"
435
+ return "pending", f"vector index sync failed: {reason}"
436
+ return "indexed", None
437
+
290
438
  # --- Large candidate #1: background / incremental scheduling (slice) ---
291
439
  def schedule_background(
292
440
  self,
@@ -307,6 +455,214 @@ class IngestionPipeline:
307
455
  def get_background_job(self, job_id: str) -> Optional[BackgroundIngestionJob]:
308
456
  return self._bg_queue.get(job_id)
309
457
 
458
+ def ingest_web_page(
459
+ self,
460
+ url: str,
461
+ extracted_text: str,
462
+ *,
463
+ title: Optional[str] = None,
464
+ metadata: Optional[Dict[str, Any]] = None,
465
+ owner: Optional[str] = None,
466
+ workspace_id: Optional[str] = None,
467
+ captured_at: Optional[str] = None,
468
+ user_email: Optional[str] = None,
469
+ ) -> IngestionResult:
470
+ """Ingest an *already-extracted* web page (see module docstring seam).
471
+
472
+ Fetching/parsing is upstream's responsibility (browser extension /
473
+ tools layer); this wrapper only normalizes ``(url, extracted_text)``
474
+ into an ``IngestionItem(source_type="web_url")`` and routes it through
475
+ the standard :meth:`ingest` door.
476
+ """
477
+ url = str(url or "").strip()
478
+ if not url:
479
+ return IngestionResult(
480
+ status="failed", source_type="web_url",
481
+ indexing_status="skipped", detail="url required",
482
+ )
483
+ text = str(extracted_text or "")
484
+ if not text.strip():
485
+ return IngestionResult(
486
+ status="failed", source_type="web_url",
487
+ indexing_status="skipped",
488
+ detail=(
489
+ "extracted_text required — the graph layer does not fetch or "
490
+ "parse the web; extraction happens upstream."
491
+ ),
492
+ )
493
+ item = IngestionItem(
494
+ source_type="web_url",
495
+ title=title or url,
496
+ text=text,
497
+ source_uri=url,
498
+ owner=owner,
499
+ workspace_id=workspace_id,
500
+ captured_at=captured_at,
501
+ metadata=dict(metadata or {}),
502
+ )
503
+ return self.ingest(item, user_email=user_email or owner)
504
+
505
+ def ingest_folder(
506
+ self,
507
+ root_path: Any,
508
+ *,
509
+ recursive: bool = True,
510
+ background: bool = False,
511
+ extensions: Optional[Iterable[str]] = None,
512
+ max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
513
+ include_hidden: bool = False,
514
+ max_files: int = 1000,
515
+ max_errors: int = 25,
516
+ owner: Optional[str] = None,
517
+ workspace_id: Optional[str] = None,
518
+ user_email: Optional[str] = None,
519
+ ) -> Dict[str, Any]:
520
+ """Walk ``root_path`` and ingest every eligible file through the pipeline.
521
+
522
+ Filtering, in order: hard skip-list directories (``.git`` …), hidden
523
+ entries (unless ``include_hidden``), root ``.latticeignore`` patterns
524
+ (fnmatch globs; ``dir/`` suffix prunes directories), extension
525
+ allow-list, then ``max_file_bytes``. Text/code files are read inline so
526
+ their content is chunked; ``.pdf`` routes through the file door without
527
+ inline extraction.
528
+
529
+ ``background=True`` schedules the built items on the existing
530
+ :class:`BackgroundIngestionQueue` instead of ingesting inline.
531
+ Returns a summary dict with counts and per-file errors (capped at
532
+ ``max_errors``).
533
+ """
534
+ summary: Dict[str, Any] = {
535
+ "root": str(root_path),
536
+ "recursive": bool(recursive),
537
+ "background": bool(background),
538
+ "scanned": 0,
539
+ "matched": 0,
540
+ "ingested": 0,
541
+ "duplicate": 0,
542
+ "failed": 0,
543
+ "skipped": {"ignored": 0, "extension": 0, "too_large": 0, "hidden": 0},
544
+ "truncated": False,
545
+ "errors": [],
546
+ }
547
+ try:
548
+ root = Path(root_path).expanduser()
549
+ except TypeError:
550
+ summary.update(status="failed", detail=f"invalid root path: {root_path!r}")
551
+ return summary
552
+ if not root.is_dir():
553
+ summary.update(status="failed", detail=f"not a directory: {root}")
554
+ return summary
555
+ if not self.available():
556
+ summary.update(
557
+ status="unavailable",
558
+ detail="Knowledge Graph is disabled (LATTICEAI_ENABLE_GRAPH).",
559
+ )
560
+ return summary
561
+ summary["root"] = str(root)
562
+ max_files = max(1, int(max_files))
563
+ max_errors = max(0, int(max_errors))
564
+ max_file_bytes = max(1, int(max_file_bytes))
565
+ allowed_exts = (
566
+ frozenset(str(e).lower() if str(e).startswith(".") else f".{str(e).lower()}" for e in extensions)
567
+ if extensions
568
+ else DEFAULT_FOLDER_EXTENSIONS
569
+ )
570
+ patterns = _load_latticeignore(root)
571
+ errors: List[Dict[str, Any]] = summary["errors"]
572
+ skipped = summary["skipped"]
573
+ items: List[IngestionItem] = []
574
+
575
+ def _record_error(path: Path, detail: str, status: str = "failed") -> None:
576
+ summary["failed"] += 1
577
+ if len(errors) < max_errors:
578
+ errors.append({"path": str(path), "status": status, "detail": detail})
579
+
580
+ for dirpath, dirnames, filenames in os.walk(root):
581
+ current = Path(dirpath)
582
+ rel_dir = current.relative_to(root)
583
+ kept_dirs: List[str] = []
584
+ for name in sorted(dirnames):
585
+ if name in FOLDER_DEFAULT_SKIP_DIRS:
586
+ continue
587
+ if name.startswith(".") and not include_hidden:
588
+ continue
589
+ rel = name if str(rel_dir) == "." else (rel_dir / name).as_posix()
590
+ if _matches_ignore(rel, name, is_dir=True, patterns=patterns):
591
+ skipped["ignored"] += 1
592
+ continue
593
+ kept_dirs.append(name)
594
+ dirnames[:] = kept_dirs if recursive else []
595
+
596
+ for name in sorted(filenames):
597
+ if name == LATTICEIGNORE_FILENAME:
598
+ continue
599
+ summary["scanned"] += 1
600
+ path = current / name
601
+ rel = name if str(rel_dir) == "." else (rel_dir / name).as_posix()
602
+ if name.startswith(".") and not include_hidden:
603
+ skipped["hidden"] += 1
604
+ continue
605
+ if _matches_ignore(rel, name, is_dir=False, patterns=patterns):
606
+ skipped["ignored"] += 1
607
+ continue
608
+ ext = path.suffix.lower()
609
+ if ext not in allowed_exts:
610
+ skipped["extension"] += 1
611
+ continue
612
+ try:
613
+ size = path.stat().st_size
614
+ except OSError as exc:
615
+ _record_error(path, f"stat failed: {exc}")
616
+ continue
617
+ if size > max_file_bytes:
618
+ skipped["too_large"] += 1
619
+ continue
620
+ if len(items) >= max_files:
621
+ summary["truncated"] = True
622
+ break
623
+ item_metadata: Dict[str, Any] = {"relative_path": rel}
624
+ if ext in FOLDER_DOCUMENT_EXTENSIONS:
625
+ source_type = "pdf"
626
+ else:
627
+ source_type = "file"
628
+ try:
629
+ content = path.read_text(encoding="utf-8", errors="ignore")
630
+ except OSError as exc:
631
+ _record_error(path, f"read failed: {exc}")
632
+ continue
633
+ item_metadata["extracted"] = {"content": content, "chars": len(content)}
634
+ items.append(
635
+ IngestionItem(
636
+ source_type=source_type,
637
+ title=name,
638
+ path=str(path),
639
+ source_uri=str(path),
640
+ owner=owner,
641
+ workspace_id=workspace_id,
642
+ metadata=item_metadata,
643
+ )
644
+ )
645
+ if summary["truncated"]:
646
+ break
647
+
648
+ summary["matched"] = len(items)
649
+ if background:
650
+ job = self.schedule_background(items, incremental=True)
651
+ summary.update(status="scheduled", job_id=job.job_id, scheduled=len(items))
652
+ return summary
653
+
654
+ for item in items:
655
+ result = self.ingest(item, user_email=user_email or owner)
656
+ if result.status == "ok":
657
+ if result.duplicate:
658
+ summary["duplicate"] += 1
659
+ else:
660
+ summary["ingested"] += 1
661
+ else:
662
+ _record_error(Path(item.path or ""), result.detail or result.status, result.status)
663
+ summary["status"] = "ok" if summary["failed"] == 0 else "partial"
664
+ return summary
665
+
310
666
  # ── routing helpers ──────────────────────────────────────────────────────
311
667
  def _ingest_text(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
312
668
  text = item.text or ""
@@ -142,6 +142,46 @@ class RerankerInterface:
142
142
  # -----------------------------
143
143
  # 3. Memory Candidate Extraction / Scoring / Dedupe / Merge / Conflict / Retention
144
144
  # -----------------------------
145
+
146
+ # Public content-signature helpers (v9.6.x graph-layer proactive seam).
147
+ # Extracted from MemoryQualityManager so the graph layer
148
+ # (lattice_brain.graph.proactive) and future ingestion gating can reuse the
149
+ # exact same dedupe semantics without instantiating the manager. Behaviour is
150
+ # byte-for-byte identical to the pre-existing private logic.
151
+
152
+ _SIGNATURE_STOPWORDS = {
153
+ "a", "an", "and", "for", "i", "is", "it", "mode", "the", "to",
154
+ "user", "users", "does", "do", "not", "like", "likes", "prefer",
155
+ "prefers", "want", "wants",
156
+ }
157
+
158
+
159
+ def dedupe_key(content: str) -> str:
160
+ """Stable near-exact signature for a piece of content.
161
+
162
+ sha256 prefix over the normalized text head plus a coarse length bucket —
163
+ the same key ``MemoryQualityManager.dedupe`` uses to collapse duplicates.
164
+ Two texts with the same key are treated as exact/near-exact duplicates.
165
+ """
166
+ text = str(content or "")
167
+ norm = " ".join(text.lower().split())[:200]
168
+ return hashlib.sha256((norm + f"|{len(text)//50}").encode()).hexdigest()[:16]
169
+
170
+
171
+ def content_signature(content: str) -> set:
172
+ """Token-set signature (stopword-filtered) for overlap/jaccard comparison.
173
+
174
+ Mirrors ``MemoryQualityManager._content_signature`` — kept public so
175
+ graph-layer duplicate/contradiction detection shares one definition.
176
+ """
177
+ tokens = set(re.findall(r"\w+", str(content or "").lower()))
178
+ return {
179
+ token
180
+ for token in tokens
181
+ if len(token) > 2 and token not in _SIGNATURE_STOPWORDS
182
+ }
183
+
184
+
145
185
  @dataclass
146
186
  class MemoryCandidate:
147
187
  id: str
@@ -193,8 +233,7 @@ class MemoryQualityManager:
193
233
  kept = []
194
234
  seen = set()
195
235
  for c in cands:
196
- norm = " ".join(c.content.lower().split())[:200]
197
- h = hashlib.sha256((norm + f"|{len(c.content)//50}").encode()).hexdigest()[:16]
236
+ h = dedupe_key(c.content)
198
237
  if h not in seen:
199
238
  seen.add(h)
200
239
  kept.append(c)
@@ -247,13 +286,7 @@ class MemoryQualityManager:
247
286
  return any(pattern in lowered for pattern in self._POSITIVE_PATTERNS)
248
287
 
249
288
  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}
289
+ return content_signature(content)
257
290
 
258
291
  # --- Large candidate #4 slice: proactive / temporal contradiction detection ---
259
292
  def detect_temporal_contradictions(self, memories: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@@ -516,6 +549,8 @@ class LatticeBrainQuality:
516
549
  __all__ = [
517
550
  "BM25Scorer",
518
551
  "ContextGuardrails",
552
+ "content_signature",
553
+ "dedupe_key",
519
554
  "EmbeddingFallbackLabeller",
520
555
  "EmbeddingLabel",
521
556
  "GraphEdgeQuality",
@@ -10,6 +10,33 @@ the operational objects first-class: handoffs, context packets, review/retry
10
10
  history, replayable timeline events, and explicit planning records. The default
11
11
  runner is still deterministic and LLM-free so tests, local demos, and Community
12
12
  installations can exercise the full Planner -> Executor -> Reviewer loop.
13
+
14
+ Consistency with the single-agent harness (latticeai.core.agent)
15
+ ----------------------------------------------------------------
16
+ Both runtimes expose the shared ``agent-run-contract/v1`` envelope
17
+ (:mod:`.contracts`), and every terminal status this orchestrator emits
18
+ (``ok`` / ``retried_ok`` / ``failed``) is a member of
19
+ ``statuses.RUN_TERMINAL_STATUSES``. Three differences are intentional design,
20
+ not drift:
21
+
22
+ * **Tool dispatch** — this orchestrator is pure and never executes tools
23
+ itself. Tool work reachable from a multi-agent step flows through the
24
+ *injected* ``workflow_runner`` / ``plugin_runner`` seams (wired in
25
+ ``latticeai.services.platform_runtime``), and those seams route every call
26
+ through the same shared ``hooks.dispatch_tool`` pre_tool/post_tool lifecycle
27
+ the single-agent loop uses.
28
+ * **Change governance** — the single-agent loop stages mutations of existing
29
+ content as review proposals (``AgentDeps.change_governor``); the injected
30
+ workflow tool node instead pauses non-auto-approve tools into
31
+ ``awaiting_approval`` (``ApprovalRequired``). Different mechanisms, same
32
+ fail-closed outcome: no unapproved mutation executes from either runtime.
33
+ * **Tracing** — the single-agent loop records a ``LoopTrace`` event stream;
34
+ this runtime records replayable ``timeline`` events. Both surface uniformly
35
+ as the contract's ``timeline``.
36
+
37
+ Run-level ``pre_run`` / ``post_run`` hooks fire in
38
+ ``lattice_brain.runtime.agent_runtime.AgentRuntime``, which wraps this
39
+ orchestrator for the product ``/agents`` surface.
13
40
  """
14
41
 
15
42
  from __future__ import annotations
@@ -21,7 +48,7 @@ from .contracts import multi_agent_contract
21
48
  from ..utils import now_iso as _now
22
49
 
23
50
 
24
- MULTI_AGENT_VERSION = "9.6.0"
51
+ MULTI_AGENT_VERSION = "9.7.0"
25
52
 
26
53
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
27
54
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -742,7 +769,16 @@ class MultiAgentOrchestrator:
742
769
  # let downstream roles review stale or missing output and could
743
770
  # incorrectly convert a failed run into an approved one.
744
771
  if str(role_result.get("status") or "").lower() == "error":
745
- reason = str(role_result.get("error") or f"{role} role failed")
772
+ # Both role-error result shapes are honored: a raised exception
773
+ # (``_run_role``) carries ``error``; an llm_role_runner failure
774
+ # carries ``reason`` (with the raw model output preserved in
775
+ # ``ctx.review``). Either way the terminal timeline event names
776
+ # the real cause instead of a generic placeholder.
777
+ reason = str(
778
+ role_result.get("error")
779
+ or role_result.get("reason")
780
+ or f"{role} role failed"
781
+ )
746
782
  existing_review = dict(ctx.review or {})
747
783
  ctx.review = existing_review or {
748
784
  "outcome": "reject",
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "9.6.0"
3
+ __version__ = "9.7.0"
@@ -18,6 +18,15 @@ from latticeai.services.brain_intelligence import BrainIntelligenceService
18
18
 
19
19
  class ConsolidateRequest(BaseModel):
20
20
  apply: bool = False
21
+ # v9.6.x additive alias: dry_run=true means apply=false. When provided it
22
+ # takes precedence over ``apply`` (explicit intent wins); omitted keeps the
23
+ # v9.3.0 contract unchanged. Default behaviour is always a dry run.
24
+ dry_run: Optional[bool] = None
25
+
26
+ def effective_apply(self) -> bool:
27
+ if self.dry_run is not None:
28
+ return not self.dry_run
29
+ return self.apply
21
30
 
22
31
 
23
32
  def create_brain_intelligence_router(
@@ -48,11 +57,27 @@ def create_brain_intelligence_router(
48
57
  scope = gate_read(request)
49
58
  return service.contradictions(user_email=user, workspace_id=scope)
50
59
 
60
+ @router.get("/api/brain/duplicates")
61
+ async def brain_duplicates(request: Request):
62
+ """Graph-layer duplicate node candidates (read-only)."""
63
+ user = require_user(request)
64
+ scope = gate_read(request)
65
+ return service.graph_duplicates(user_email=user, workspace_id=scope)
66
+
67
+ @router.get("/api/brain/quality-report")
68
+ async def brain_quality_report(request: Request):
69
+ """Combined graph quality report: duplicates, contradictions, stale
70
+ nodes, edge quality (read-only)."""
71
+ user = require_user(request)
72
+ scope = gate_read(request)
73
+ return service.quality_report(user_email=user, workspace_id=scope)
74
+
51
75
  @router.post("/api/brain/consolidate")
52
76
  async def brain_consolidate(req: ConsolidateRequest, request: Request):
53
77
  user = require_user(request)
54
- scope = gate_write(request) if req.apply else gate_read(request)
55
- result = service.consolidate(apply=req.apply, user_email=user, workspace_id=scope)
78
+ apply = req.effective_apply()
79
+ scope = gate_write(request) if apply else gate_read(request)
80
+ result = service.consolidate(apply=apply, user_email=user, workspace_id=scope)
56
81
  append_audit_event(
57
82
  "brain_consolidate",
58
83
  user_email=user,