ltcai 9.9.3 → 9.9.5

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 +39 -38
  2. package/docs/CHANGELOG.md +100 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +53 -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/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/discovery_index.py +8 -1
  14. package/lattice_brain/graph/ingest.py +43 -9
  15. package/lattice_brain/graph/projection.py +221 -37
  16. package/lattice_brain/graph/rerank.py +160 -0
  17. package/lattice_brain/graph/retrieval.py +90 -455
  18. package/lattice_brain/graph/retrieval_policy.py +174 -0
  19. package/lattice_brain/graph/retrieval_reads.py +459 -0
  20. package/lattice_brain/graph/retrieval_vector.py +122 -2
  21. package/lattice_brain/graph/store.py +2 -0
  22. package/lattice_brain/portability.py +41 -12
  23. package/lattice_brain/quality.py +26 -6
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/chat.py +6 -0
  27. package/latticeai/api/chat_agent_http.py +262 -52
  28. package/latticeai/api/chat_contracts.py +1 -1
  29. package/latticeai/api/chat_intents.py +37 -18
  30. package/latticeai/api/chat_stream.py +76 -2
  31. package/latticeai/api/knowledge_graph.py +35 -1
  32. package/latticeai/core/agent.py +420 -27
  33. package/latticeai/core/enterprise.py +5 -0
  34. package/latticeai/core/file_generation.py +130 -5
  35. package/latticeai/core/legacy_compatibility.py +1 -1
  36. package/latticeai/core/marketplace.py +1 -1
  37. package/latticeai/core/run_store.py +245 -0
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/integrations/telegram_bot.py +82 -23
  40. package/latticeai/models/router.py +25 -12
  41. package/latticeai/services/architecture_readiness.py +1 -1
  42. package/latticeai/services/command_center.py +90 -1
  43. package/latticeai/services/folder_watch.py +5 -0
  44. package/latticeai/services/funnel_metrics.py +9 -1
  45. package/latticeai/services/product_readiness.py +1 -1
  46. package/latticeai/services/search_service.py +43 -13
  47. package/latticeai/services/tool_dispatch.py +56 -0
  48. package/package.json +2 -1
  49. package/scripts/bench_agent_smoke.py +410 -0
  50. package/scripts/check_current_release_docs.mjs +1 -1
  51. package/scripts/funnel_soft_gate.py +192 -0
  52. package/scripts/run_sidecar_e2e.mjs +159 -0
  53. package/src-tauri/Cargo.lock +1 -1
  54. package/src-tauri/Cargo.toml +1 -1
  55. package/src-tauri/tauri.conf.json +1 -1
  56. package/static/app/asset-manifest.json +29 -28
  57. package/static/app/assets/Act-DmxfbqBp.js +1 -0
  58. package/static/app/assets/{Brain-DEY9jLVt.js → Brain-DK_zKCda.js} +2 -2
  59. package/static/app/assets/BrainHome-CCE_0hni.js +2 -0
  60. package/static/app/assets/BrainSignals-r_J68_zh.js +1 -0
  61. package/static/app/assets/{Capture-CVZ09QXi.js → Capture-CvxwzpJZ.js} +1 -1
  62. package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-CVLiFIQC.js} +1 -1
  63. package/static/app/assets/{Library-Bp0n-HlW.js → Library-4As9VUvy.js} +1 -1
  64. package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-BVWk58MW.js} +1 -1
  65. package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-B-Tz0Um4.js} +1 -1
  66. package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-Va3fPQuj.js} +1 -1
  67. package/static/app/assets/{System-ByQcmJW-.js → System-DSR2zKWd.js} +1 -1
  68. package/static/app/assets/{bot-BNDyZLR7.js → bot-VmP0kkeA.js} +1 -1
  69. package/static/app/assets/circle-pause-T9POo4qy.js +1 -0
  70. package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-Bz5iTD0p.js} +1 -1
  71. package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CJhjRtNq.js} +1 -1
  72. package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-B2K_22VI.js} +1 -1
  73. package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CjJtYqHf.js} +1 -1
  74. package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
  75. package/static/app/assets/{index-CHu7cgj3.js → index-CEu0Wqjl.js} +3 -3
  76. package/static/app/assets/{input-DVDI0YR3.js → input-CSi6OBJ9.js} +1 -1
  77. package/static/app/assets/{navigation-BddhEWA0.js → navigation-BPQqEQwr.js} +1 -1
  78. package/static/app/assets/{network-pYQt5oBu.js → network-Ck1nqc-v.js} +1 -1
  79. package/static/app/assets/{primitives-D7gCdEvS.js → primitives-B2f9N7Dh.js} +1 -1
  80. package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-yA_Y8lOB.js} +1 -1
  81. package/static/app/assets/{textarea-sqQmoBKL.js → textarea-C4wM0D-Z.js} +1 -1
  82. package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-CsnIhsDJ.js} +1 -1
  83. package/static/app/assets/utils-BqoznzGP.js +7 -0
  84. package/static/app/index.html +4 -4
  85. package/static/sw.js +1 -1
  86. package/static/app/assets/Act-DmdruVKV.js +0 -1
  87. package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
  88. package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
  89. package/static/app/assets/utils-uQYKXNeq.js +0 -7
@@ -13,6 +13,78 @@ class KnowledgeGraphVectorMixin:
13
13
  retrieval/write helpers (e.g. self._vector_text_for_node) through the MRO.
14
14
  """
15
15
 
16
+ # ── embedder fingerprint (review Wave 2.2 — stale_embedder) ──────────────
17
+ # vector_search filters on the CURRENT model/dim, so swapping the embedder
18
+ # silently yields zero vector rows. The fingerprint persisted in graph_meta
19
+ # records which embedder actually built the index; a mismatch is surfaced
20
+ # as the honest ``stale_embedder`` signal instead of a silent degradation.
21
+
22
+ _EMBEDDER_FINGERPRINT_KEY = "embedder_fingerprint"
23
+
24
+ def _embedder_fingerprint_record(
25
+ self, conn: sqlite3.Connection
26
+ ) -> Optional[Dict[str, Any]]:
27
+ """Read the recorded embedder fingerprint from graph_meta (or None)."""
28
+ row = conn.execute(
29
+ "SELECT value FROM graph_meta WHERE key=?",
30
+ (self._EMBEDDER_FINGERPRINT_KEY,),
31
+ ).fetchone()
32
+ if not row:
33
+ return None
34
+ payload = _safe_loads(row["value"])
35
+ if not isinstance(payload, dict) or not payload.get("model_id"):
36
+ return None
37
+ try:
38
+ dim = int(payload.get("dim") or 0)
39
+ except (TypeError, ValueError):
40
+ dim = 0
41
+ return {"model_id": str(payload["model_id"]), "dim": dim}
42
+
43
+ def _write_embedder_fingerprint(self, conn: sqlite3.Connection) -> Dict[str, Any]:
44
+ """Persist the CURRENT embedder identity (same transaction as caller)."""
45
+ fingerprint = {
46
+ "model_id": self._embedding_model.model_id,
47
+ "dim": int(self._embedding_model.dim),
48
+ }
49
+ conn.execute(
50
+ "INSERT OR REPLACE INTO graph_meta(key, value) VALUES (?, ?)",
51
+ (self._EMBEDDER_FINGERPRINT_KEY, _json(fingerprint)),
52
+ )
53
+ return fingerprint
54
+
55
+ def record_embedder_fingerprint(self) -> Dict[str, Any]:
56
+ """Record the current embedder (model_id + dim) as the index builder."""
57
+ with self._connect() as conn:
58
+ return self._write_embedder_fingerprint(conn)
59
+
60
+ def embedder_fingerprint_status(self) -> Dict[str, Any]:
61
+ """Compare the current embedder against the recorded index fingerprint.
62
+
63
+ Returns ``{"current": {model_id, dim}, "recorded": {...} | None,
64
+ "stale_embedder": bool}``. ``stale_embedder`` is True only when a
65
+ fingerprint was recorded AND it differs from the current embedder —
66
+ an unrecorded index (legacy DBs, nothing indexed yet) is honestly
67
+ "unknown", never reported stale. Never raises.
68
+ """
69
+ current = {
70
+ "model_id": self._embedding_model.model_id,
71
+ "dim": int(self._embedding_model.dim),
72
+ }
73
+ recorded: Optional[Dict[str, Any]] = None
74
+ try:
75
+ with self._connect() as conn:
76
+ recorded = self._embedder_fingerprint_record(conn)
77
+ except Exception: # noqa: BLE001 — status must degrade, never raise
78
+ recorded = None
79
+ stale = bool(
80
+ recorded is not None
81
+ and (
82
+ recorded.get("model_id") != current["model_id"]
83
+ or recorded.get("dim") != current["dim"]
84
+ )
85
+ )
86
+ return {"current": current, "recorded": recorded, "stale_embedder": stale}
87
+
16
88
  def _iter_vector_source_items(
17
89
  self,
18
90
  conn: sqlite3.Connection,
@@ -153,6 +225,11 @@ class KnowledgeGraphVectorMixin:
153
225
  indexed += 1
154
226
  else:
155
227
  skipped += 1
228
+ if indexed and self._embedder_fingerprint_record(conn) is None:
229
+ # First successful vector write establishes the fingerprint;
230
+ # later incremental writes never overwrite it (only a full
231
+ # rebuild may flip it after an embedder swap).
232
+ self._write_embedder_fingerprint(conn)
156
233
  summary.update(
157
234
  {
158
235
  "items_total": len(items),
@@ -254,6 +331,10 @@ class KnowledgeGraphVectorMixin:
254
331
  op_id,
255
332
  ),
256
333
  )
334
+ # A successful rebuild (re)establishes which embedder built
335
+ # the index — this is the only path that may flip a recorded
336
+ # fingerprint after an embedder swap.
337
+ self._write_embedder_fingerprint(conn)
257
338
  return {
258
339
  "status": "completed",
259
340
  "operation_id": op_id,
@@ -401,8 +482,10 @@ class KnowledgeGraphVectorMixin:
401
482
  "within_target": float(duration_ms) <= 10_000,
402
483
  }
403
484
  )
485
+ embedder_status = self.embedder_fingerprint_status()
404
486
  return {
405
487
  "status": "ready" if pending == 0 else "needs_reindex",
488
+ "embedder": embedder_status,
406
489
  "storage": {
407
490
  "db_path": str(self.db_path),
408
491
  "backend": "sqlite",
@@ -449,7 +532,11 @@ class KnowledgeGraphVectorMixin:
449
532
  "backlog_reasons": backlog_reasons,
450
533
  "backlog_samples": backlog_samples,
451
534
  "incremental_reindex_recommended": pending > 0,
452
- "full_rebuild_recommended": orphaned_items > 0,
535
+ # A stale embedder means every old-model row must be re-embedded;
536
+ # only a full rebuild (which re-records the fingerprint) heals it.
537
+ "full_rebuild_recommended": bool(
538
+ orphaned_items > 0 or embedder_status["stale_embedder"]
539
+ ),
453
540
  "latency_budget": latency_budget,
454
541
  },
455
542
  "operations": [
@@ -475,7 +562,11 @@ class KnowledgeGraphVectorMixin:
475
562
 
476
563
  Reduces :meth:`index_status` (``pending = missing + stale``) to the
477
564
  fixed contract ``{"status", "pending_items", "total_items", "detail"}``
478
- with ``status`` in ``ready`` / ``pending`` / ``unavailable``.
565
+ with ``status`` in ``ready`` / ``pending`` / ``stale_embedder`` /
566
+ ``unavailable``. ``stale_embedder`` (review Wave 2.2) is reported only
567
+ when the recorded embedder fingerprint differs from the current
568
+ embedder AND rows indexed under the old model still exist — the index
569
+ needs a full rebuild, not an incremental sync.
479
570
 
480
571
  Never raises: environments where the embedding provider or index
481
572
  storage cannot be used report ``"unavailable"`` with the cause in
@@ -492,6 +583,35 @@ class KnowledgeGraphVectorMixin:
492
583
  }
493
584
  pending = int(status.get("pending_items") or 0)
494
585
  total = int(status.get("source_items") or 0)
586
+ embedder = status.get("embedder") or {}
587
+ if embedder.get("stale_embedder"):
588
+ old_model_rows = 0
589
+ try:
590
+ with self._connect() as conn:
591
+ old_model_rows = int(
592
+ conn.execute(
593
+ "SELECT COUNT(*) AS c FROM vector_embeddings "
594
+ "WHERE embedding_model<>? OR embedding_dim<>?",
595
+ (
596
+ self._embedding_model.model_id,
597
+ int(self._embedding_model.dim),
598
+ ),
599
+ ).fetchone()["c"]
600
+ )
601
+ except Exception: # noqa: BLE001 — keep the existing statuses on failure
602
+ old_model_rows = 0
603
+ if old_model_rows > 0:
604
+ recorded = embedder.get("recorded") or {}
605
+ return {
606
+ "status": "stale_embedder",
607
+ "pending_items": pending,
608
+ "total_items": total,
609
+ "detail": (
610
+ f"embedding model changed ({recorded.get('model_id')} → "
611
+ f"{self._embedding_model.model_id}); {old_model_rows} indexed "
612
+ "rows still use the previous model — run a full vector index rebuild"
613
+ ),
614
+ }
495
615
  if pending > 0:
496
616
  return {
497
617
  "status": "pending",
@@ -11,6 +11,7 @@ from .projection import KnowledgeGraphProjectionMixin
11
11
  from .provenance import KnowledgeGraphProvenanceMixin
12
12
  from .retrieval import KnowledgeGraphRetrievalMixin
13
13
  from .retrieval_docgen import KnowledgeGraphDocGenMixin
14
+ from .retrieval_reads import KnowledgeGraphReadsMixin
14
15
  from .retrieval_vector import KnowledgeGraphVectorMixin
15
16
  from .write_master import KnowledgeGraphWriteMixin
16
17
 
@@ -24,6 +25,7 @@ class KnowledgeGraphStore(
24
25
  KnowledgeGraphProvenanceMixin,
25
26
  KnowledgeGraphDocumentsMixin,
26
27
  KnowledgeGraphRetrievalMixin,
28
+ KnowledgeGraphReadsMixin,
27
29
  KnowledgeGraphVectorMixin,
28
30
  KnowledgeGraphDocGenMixin,
29
31
  ):
@@ -14,6 +14,7 @@ any cloud service. Two complementary mechanisms, both fully local:
14
14
  from __future__ import annotations
15
15
 
16
16
  import json
17
+ import logging
17
18
  import os
18
19
  import shutil
19
20
  import sqlite3
@@ -74,9 +75,12 @@ def _checkpoint_sqlite(db_path: Path) -> None:
74
75
 
75
76
 
76
77
  def _restore_sibling(path: Path, backup: Path) -> None:
77
- if backup.exists():
78
+ try:
78
79
  shutil.copy2(backup, path)
79
- else:
80
+ except FileNotFoundError:
81
+ # The backup vanished (or never existed — transient -wal/-shm): the
82
+ # honest reconstruction is "no such sibling", never a crash that
83
+ # masks the error that triggered the rollback.
80
84
  path.unlink(missing_ok=True)
81
85
 
82
86
 
@@ -101,10 +105,22 @@ def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
101
105
  sibling.unlink(missing_ok=True)
102
106
  os.replace(tmp, dest)
103
107
  except Exception:
104
- if tmp.exists():
105
- tmp.unlink()
108
+ # Recovery I/O must never replace the swap error being reported —
109
+ # an exception raised here would mask it (the CI-observed
110
+ # "[Errno 2]" over the real failure). Best-effort restore, then
111
+ # always re-raise the original.
112
+ try:
113
+ tmp.unlink(missing_ok=True)
114
+ except OSError as cleanup_exc:
115
+ logging.warning("restore tmp cleanup failed: %s", cleanup_exc)
106
116
  for sibling in _sqlite_siblings(dest):
107
- _restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
117
+ try:
118
+ _restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
119
+ except OSError as rollback_exc:
120
+ logging.warning(
121
+ "restore sibling rollback incomplete for %s: %s",
122
+ sibling, rollback_exc,
123
+ )
108
124
  raise
109
125
 
110
126
 
@@ -127,12 +143,17 @@ def _replace_tree_with_backup(src: Optional[Path], dest: Path, backup_dir: Path)
127
143
  shutil.rmtree(dest)
128
144
  os.replace(staged, dest)
129
145
  except Exception:
130
- if staged.exists():
131
- shutil.rmtree(staged)
132
- if dest.exists():
133
- shutil.rmtree(dest)
134
- if backup.exists():
135
- shutil.copytree(backup, dest)
146
+ # Same masking guard as the sqlite swap: rollback I/O is
147
+ # best-effort and the original failure always propagates.
148
+ try:
149
+ if staged.exists():
150
+ shutil.rmtree(staged)
151
+ if dest.exists():
152
+ shutil.rmtree(dest)
153
+ if backup.exists():
154
+ shutil.copytree(backup, dest)
155
+ except OSError as rollback_exc:
156
+ logging.warning("blob tree rollback incomplete: %s", rollback_exc)
136
157
  raise
137
158
 
138
159
 
@@ -307,7 +328,15 @@ class KGPortabilityService:
307
328
  blob_src = tmp / "blobs"
308
329
  _replace_tree_with_backup(blob_src if blob_src.exists() else None, blob_dest, backup_dir)
309
330
  except Exception:
310
- _rollback_sqlite_from_backup(db_dest, backup_dir)
331
+ # The rollback is best-effort recovery; an I/O slip inside
332
+ # it must never mask the restore failure being reported.
333
+ try:
334
+ _rollback_sqlite_from_backup(db_dest, backup_dir)
335
+ except OSError as rollback_exc:
336
+ logging.warning(
337
+ "pre-restore rollback incomplete (backup kept at %s): %s",
338
+ backup_dir, rollback_exc,
339
+ )
311
340
  raise
312
341
  stats = self._kg.stats()
313
342
  return {
@@ -131,13 +131,33 @@ class HybridFusion:
131
131
  return sorted(results, key=lambda x: x["fused_score"], reverse=True)
132
132
 
133
133
  class RerankerInterface:
134
- """Pluggable reranker interface"""
134
+ """Pluggable reranker interface.
135
+
136
+ Default is identity (fused score). When
137
+ ``LATTICEAI_CROSS_ENCODER_RERANK=1`` and a CrossEncoder is importable,
138
+ delegates to :func:`lattice_brain.graph.rerank.rerank_matches`. Failures
139
+ never raise — always returns a ranked list.
140
+ """
141
+
135
142
  def rerank(self, query: str, candidates: List[Dict], top_k: int = 5) -> List[Dict]:
136
- # Default local-safe fallback: preserve fused ordering without making
137
- # unearned cross-encoder claims.
138
- for c in candidates:
139
- c["rerank_score"] = c.get("fused_score", 0.0)
140
- return sorted(candidates, key=lambda x: x.get("rerank_score", 0), reverse=True)[:top_k]
143
+ try:
144
+ from lattice_brain.graph.rerank import rerank_matches
145
+
146
+ # Map fused_score → score so the shared helper ranks consistently.
147
+ prepared = []
148
+ for c in candidates:
149
+ item = dict(c)
150
+ if "score" not in item:
151
+ item["score"] = item.get("fused_score", 0.0)
152
+ prepared.append(item)
153
+ result = rerank_matches(query, prepared, top_k=top_k)
154
+ return list(result.get("matches") or [])
155
+ except Exception: # noqa: BLE001 — quality path must never raise
156
+ for c in candidates:
157
+ c["rerank_score"] = c.get("fused_score", 0.0)
158
+ return sorted(
159
+ candidates, key=lambda x: x.get("rerank_score", 0), reverse=True
160
+ )[:top_k]
141
161
 
142
162
  # -----------------------------
143
163
  # 3. Memory Candidate Extraction / Scoring / Dedupe / Merge / Conflict / Retention
@@ -48,7 +48,7 @@ from .contracts import multi_agent_contract
48
48
  from ..utils import now_iso as _now
49
49
 
50
50
 
51
- MULTI_AGENT_VERSION = "9.9.3"
51
+ MULTI_AGENT_VERSION = "9.9.5"
52
52
 
53
53
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
54
54
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "9.9.3"
3
+ __version__ = "9.9.5"
@@ -45,6 +45,7 @@ from latticeai.api.chat_helpers import (
45
45
  workspace_scope_from_request,
46
46
  )
47
47
  from latticeai.api.chat_history import HistoryRouteDependencies, register_history_routes
48
+ from latticeai.core.run_store import AgentRunStore
48
49
  from latticeai.api.chat_intents import ChatIntentController
49
50
  from latticeai.api.chat_stream import stream_chat
50
51
  from latticeai.services.app_context import AppContext
@@ -191,6 +192,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
191
192
  agent_root=AGENT_ROOT,
192
193
  ensure_agent_root=ensure_agent_root,
193
194
  funnel_metrics=context.funnel_metrics,
195
+ run_store=(
196
+ AgentRunStore(Path(context.data_dir) / "agent_runs")
197
+ if context.data_dir
198
+ else None
199
+ ),
194
200
  )
195
201
  intent_controller = ChatIntentController(
196
202
  model_router=model_router,