ltcai 11.1.0 → 11.2.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 (118) hide show
  1. package/README.md +53 -53
  2. package/docs/CHANGELOG.md +33 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
  6. package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/architecture.md +6 -2
  12. package/docs/kg-schema.md +1 -1
  13. package/lattice_brain/__init__.py +1 -1
  14. package/lattice_brain/gates.py +125 -0
  15. package/lattice_brain/graph/fusion.py +35 -4
  16. package/lattice_brain/graph/projection.py +66 -8
  17. package/lattice_brain/graph/schema.py +9 -0
  18. package/lattice_brain/graph/store.py +9 -0
  19. package/lattice_brain/graph/vector_index/selector.py +32 -2
  20. package/lattice_brain/ingestion.py +175 -27
  21. package/lattice_brain/multimodal.py +525 -5
  22. package/lattice_brain/portability.py +169 -32
  23. package/lattice_brain/runtime/multi_agent.py +1 -1
  24. package/lattice_brain/sealed_box.py +244 -0
  25. package/lattice_brain/synthesis.py +24 -1
  26. package/latticeai/__init__.py +1 -1
  27. package/latticeai/api/brain_intelligence.py +4 -0
  28. package/latticeai/api/chat.py +11 -0
  29. package/latticeai/api/chat_helpers.py +16 -3
  30. package/latticeai/api/chat_hybrid.py +32 -1
  31. package/latticeai/api/features.py +70 -0
  32. package/latticeai/api/local_files.py +102 -0
  33. package/latticeai/api/portability.py +39 -4
  34. package/latticeai/api/review_queue.py +126 -0
  35. package/latticeai/api/search.py +16 -2
  36. package/latticeai/core/agent.py +55 -2
  37. package/latticeai/core/config.py +4 -1
  38. package/latticeai/core/context_builder.py +6 -3
  39. package/latticeai/core/legacy_compatibility.py +1 -1
  40. package/latticeai/core/marketplace.py +1 -1
  41. package/latticeai/core/messages.py +143 -0
  42. package/latticeai/core/model_compat.py +73 -2
  43. package/latticeai/core/workspace_os_constants.py +1 -1
  44. package/latticeai/models/model_providers.py +12 -4
  45. package/latticeai/runtime/build_phases.py +28 -0
  46. package/latticeai/runtime/chat_wiring.py +4 -0
  47. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  48. package/latticeai/runtime/router_registration.py +11 -0
  49. package/latticeai/services/app_context.py +8 -0
  50. package/latticeai/services/architecture_readiness.py +1 -1
  51. package/latticeai/services/automation_intelligence.py +22 -2
  52. package/latticeai/services/brain_intelligence.py +123 -7
  53. package/latticeai/services/command_center.py +10 -4
  54. package/latticeai/services/feature_toggles.py +502 -0
  55. package/latticeai/services/folder_watch.py +122 -1
  56. package/latticeai/services/hybrid_chat.py +56 -5
  57. package/latticeai/services/interop_bridges.py +978 -0
  58. package/latticeai/services/model_capability_registry.py +434 -261
  59. package/latticeai/services/model_catalog.py +95 -61
  60. package/latticeai/services/model_recommendation.py +18 -11
  61. package/latticeai/services/model_runtime.py +1 -1
  62. package/latticeai/services/multimodal_ports.py +26 -1
  63. package/latticeai/services/obsidian_bridge.py +16 -25
  64. package/latticeai/services/product_readiness.py +1 -1
  65. package/latticeai/services/search_service.py +149 -2
  66. package/latticeai/services/tool_dispatch.py +4 -0
  67. package/latticeai/setup/auto_setup.py +27 -30
  68. package/latticeai/setup/wizard.py +77 -44
  69. package/package.json +1 -1
  70. package/scripts/check_current_release_docs.mjs +1 -1
  71. package/scripts/check_server_i18n.mjs +1 -0
  72. package/scripts/release_screen_claims.json +13 -0
  73. package/scripts/verify_hf_model_registry.py +253 -218
  74. package/src-tauri/Cargo.lock +1 -1
  75. package/src-tauri/Cargo.toml +1 -1
  76. package/src-tauri/tauri.conf.json +1 -1
  77. package/static/app/asset-manifest.json +37 -37
  78. package/static/app/assets/{Act-D0HWqtn0.js → Act-AWf0SAKp.js} +1 -1
  79. package/static/app/assets/{AdminConsole-D-QDW-A4.js → AdminConsole-D0u8Tiyj.js} +1 -1
  80. package/static/app/assets/{Brain-CzCsI1mi.js → Brain-tuhI4sOC.js} +1 -1
  81. package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
  82. package/static/app/assets/{BrainSignals-2dHQNkns.js → BrainSignals-jMYgQ2Ar.js} +1 -1
  83. package/static/app/assets/{Capture-CT8v1StE.js → Capture-CqOSzyPr.js} +1 -1
  84. package/static/app/assets/{CommandPalette-DoLXC2KH.js → CommandPalette-DC0Bzh-I.js} +1 -1
  85. package/static/app/assets/{Library-DDoxFE5c.js → Library-CX-bbhmK.js} +1 -1
  86. package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-DBwhto14.js} +1 -1
  87. package/static/app/assets/{ProductFlow-DOYf7JIs.js → ProductFlow-BHA2cfKI.js} +1 -1
  88. package/static/app/assets/{ReviewCard-COQsqidK.js → ReviewCard-BUhCKRNM.js} +1 -1
  89. package/static/app/assets/{System-BRllvYXd.js → System-Bu2t5hn1.js} +1 -1
  90. package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
  91. package/static/app/assets/{bot-4BvN07ux.js → bot-Cia42c2h.js} +1 -1
  92. package/static/app/assets/brain-DJMoqrwx.js +1 -0
  93. package/static/app/assets/{button-CDjtnAoU.js → button-2j2Ijzgq.js} +1 -1
  94. package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-BEFeWpVW.js} +1 -1
  95. package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-ujXMcHxl.js} +1 -1
  96. package/static/app/assets/{cpu-BIlWInHf.js → cpu-k4awryFq.js} +1 -1
  97. package/static/app/assets/{download-BtjXfL3z.js → download-DFbLJ_ig.js} +1 -1
  98. package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-7y_b6xkM.js} +1 -1
  99. package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-Bidh02Kr.js} +1 -1
  100. package/static/app/assets/{index-0AvoEBzJ.js → index-BpYkzcVm.js} +3 -3
  101. package/static/app/assets/{index-vtEfYvQY.css → index-DwDl9-8Y.css} +1 -1
  102. package/static/app/assets/{input-B_5ZJ9oy.js → input-DSlJJxRs.js} +1 -1
  103. package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-Bpb83Hx9.js} +1 -1
  104. package/static/app/assets/{primitives-CVwew78r.js → primitives-BCx6TvfG.js} +1 -1
  105. package/static/app/assets/search-Cgy8cCFJ.js +1 -0
  106. package/static/app/assets/{share-2-D5zg_0fY.js → share-2-BH1M-WNi.js} +1 -1
  107. package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-BlKdBXcG.js} +1 -1
  108. package/static/app/assets/{textarea-nEVIweKY.js → textarea-CCWbUfFB.js} +1 -1
  109. package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
  110. package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-CXQiwbVT.js} +1 -1
  111. package/static/app/assets/{utils-DcDMoZIe.js → utils-zqPZJxdx.js} +2 -2
  112. package/static/app/assets/{workspace-LtRRSKTf.js → workspace-DXTihhfU.js} +1 -1
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
  116. package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
  117. package/static/app/assets/brain-uMb_5hnO.js +0 -1
  118. package/static/app/assets/search-DkhnOKZt.js +0 -1
@@ -29,6 +29,7 @@ import uuid
29
29
  from pathlib import Path
30
30
  from typing import Any, Dict, List, Optional
31
31
 
32
+ from lattice_brain.gates import FeatureGate
32
33
  from lattice_brain.ingestion import (
33
34
  DEFAULT_FOLDER_EXTENSIONS,
34
35
  DEFAULT_MAX_FILE_BYTES,
@@ -46,6 +47,33 @@ WATCH_INTERVAL_ENV = "LATTICEAI_FOLDER_WATCH_INTERVAL"
46
47
  DEFAULT_WATCH_INTERVAL_SECONDS = 30.0
47
48
  MAX_FILES_PER_SCAN = 200 # per-watch per-scan ingest cap (thrash guard)
48
49
 
50
+ # ── vault watch (v11.2.0) ────────────────────────────────────────────────────
51
+ #: A watched *vault* is not a watched folder. 11.1.0 refused to schedule vault
52
+ #: syncs for a real reason — link edges need the node ids that only a completed
53
+ #: inline ingest has, so a per-file incremental pass would report edges it never
54
+ #: wrote. The resolution is not to make the bridge incremental: it is to notice
55
+ #: that the vault changed and then run the **whole** bridge sync, which does
56
+ #: produce those edges and is idempotent by content hash. What the snapshot buys
57
+ #: is skipping the sync entirely when nothing moved.
58
+ WATCH_KIND_FOLDER = "folder"
59
+ WATCH_KIND_VAULT = "vault"
60
+ WATCH_KINDS = (WATCH_KIND_FOLDER, WATCH_KIND_VAULT)
61
+ VAULT_WATCH_ENV = "LATTICEAI_VAULT_WATCH"
62
+ #: Opt-in, default off, resolved when asked so a settings toggle can move it.
63
+ VAULT_WATCH_GATE = FeatureGate(
64
+ VAULT_WATCH_ENV,
65
+ default=False,
66
+ name="vault_watch",
67
+ detail=(
68
+ "Watching an external vault re-syncs it in the background; it is off "
69
+ "until you turn it on."
70
+ ),
71
+ )
72
+ VAULT_WATCH_DISABLED_DETAIL = (
73
+ "vault watch is off by default; turn it on to let an external vault "
74
+ f"re-sync in the background ({VAULT_WATCH_ENV})"
75
+ )
76
+
49
77
 
50
78
  def _default_interval() -> float:
51
79
  raw = os.getenv(WATCH_INTERVAL_ENV, "").strip()
@@ -65,8 +93,13 @@ class FolderWatchService:
65
93
  pipeline: Any,
66
94
  config_path: Path,
67
95
  interval_seconds: Optional[float] = None,
96
+ vault_bridge: Optional[Any] = None,
68
97
  ) -> None:
69
98
  self._pipeline = pipeline
99
+ # Injected rather than imported at call time so a vault watch can be
100
+ # driven by a test double, and so this module keeps knowing nothing
101
+ # about how a vault is parsed.
102
+ self._vault_bridge = vault_bridge
70
103
  self._config_path = Path(config_path)
71
104
  self._interval = float(interval_seconds) if interval_seconds else _default_interval()
72
105
  self._lock = threading.Lock()
@@ -100,13 +133,23 @@ class FolderWatchService:
100
133
  owner: Optional[str] = None,
101
134
  workspace_id: Optional[str] = None,
102
135
  recursive: bool = True,
136
+ kind: str = WATCH_KIND_FOLDER,
103
137
  ) -> Dict[str, Any]:
104
138
  """Explicit opt-in: snapshot the folder now, ingest only future changes.
105
139
 
106
140
  The folder is assumed to have been ingested already (the UI offers
107
141
  watch after a folder ingest); the enable-time snapshot is the baseline,
108
142
  so enabling never re-ingests the whole folder.
143
+
144
+ ``kind="vault"`` watches an external Obsidian vault instead, re-running
145
+ the whole bridge sync when the snapshot moves. That mode carries its own
146
+ opt-in gate on top of this one and refuses while it is off.
109
147
  """
148
+ kind = str(kind or WATCH_KIND_FOLDER).strip().lower()
149
+ if kind not in WATCH_KINDS:
150
+ return {"status": "failed", "detail": f"unknown watch kind: {kind}"}
151
+ if kind == WATCH_KIND_VAULT and not VAULT_WATCH_GATE.enabled():
152
+ return {"status": "disabled", "detail": VAULT_WATCH_DISABLED_DETAIL}
110
153
  root = Path(path).expanduser()
111
154
  if not root.is_dir():
112
155
  return {"status": "failed", "detail": f"not a directory: {root}"}
@@ -116,6 +159,7 @@ class FolderWatchService:
116
159
  same_scope = (watch.get("workspace_id") or None) == (workspace_id or None)
117
160
  if watch.get("path") == str(root) and same_scope:
118
161
  watch["enabled"] = True
162
+ watch["kind"] = kind
119
163
  watch["snapshot"] = self._snapshot(root, recursive=bool(watch.get("recursive", True)))
120
164
  self._save_config()
121
165
  self._ensure_thread()
@@ -127,6 +171,7 @@ class FolderWatchService:
127
171
  "owner": owner,
128
172
  "workspace_id": workspace_id,
129
173
  "recursive": bool(recursive),
174
+ "kind": kind,
130
175
  "enabled": True,
131
176
  "created_at": _now_iso(),
132
177
  "last_scan_at": None,
@@ -172,6 +217,11 @@ class FolderWatchService:
172
217
  "polling": polling,
173
218
  "interval_seconds": self._interval,
174
219
  "watches": watches,
220
+ "kinds": list(WATCH_KINDS),
221
+ "vault_watch": {
222
+ **VAULT_WATCH_GATE.describe(),
223
+ "bridge_wired": self._vault_bridge is not None,
224
+ },
175
225
  "note": (
176
226
  "Watch mode is opt-in and off by default. Deleted files are "
177
227
  "counted but never auto-removed from the Brain."
@@ -208,10 +258,12 @@ class FolderWatchService:
208
258
  recursive = bool(watch.get("recursive", True))
209
259
  owner = watch.get("owner")
210
260
  workspace_id = watch.get("workspace_id")
261
+ kind = str(watch.get("kind") or WATCH_KIND_FOLDER)
211
262
 
212
263
  result: Dict[str, Any] = {
213
264
  "status": "ok",
214
265
  "watch_id": watch_id,
266
+ "kind": kind,
215
267
  "new": 0,
216
268
  "changed": 0,
217
269
  "removed": 0,
@@ -237,6 +289,17 @@ class FolderWatchService:
237
289
  changed_rel.append(rel)
238
290
  result["removed"] = sum(1 for rel in snapshot if rel not in current)
239
291
 
292
+ if kind == WATCH_KIND_VAULT:
293
+ self._vault_scan(
294
+ result,
295
+ root=root,
296
+ changed=bool(changed_rel) or bool(result["removed"]),
297
+ owner=owner,
298
+ workspace_id=workspace_id,
299
+ )
300
+ self._record_scan(watch_id, result, snapshot=current)
301
+ return result
302
+
240
303
  for rel in changed_rel[:MAX_FILES_PER_SCAN]:
241
304
  path = root / rel
242
305
  ext = path.suffix.lower()
@@ -287,6 +350,53 @@ class FolderWatchService:
287
350
  return result
288
351
 
289
352
  # ── internals ────────────────────────────────────────────────────────────
353
+ def _vault_scan(
354
+ self,
355
+ result: Dict[str, Any],
356
+ *,
357
+ root: Path,
358
+ changed: bool,
359
+ owner: Optional[str],
360
+ workspace_id: Optional[str],
361
+ ) -> None:
362
+ """Re-run the whole vault sync — but only when something actually moved.
363
+
364
+ A full sync is what makes the link edges real; the snapshot is only
365
+ there to avoid paying for one when the vault is untouched. The gate is
366
+ re-checked here, not just at enable time, so turning vault watch off
367
+ stops the background work immediately instead of at the next restart.
368
+ """
369
+ if not VAULT_WATCH_GATE.enabled():
370
+ result.update(status="disabled", detail=VAULT_WATCH_DISABLED_DETAIL)
371
+ return
372
+ if not changed:
373
+ result["synced"] = False
374
+ result["detail"] = "vault unchanged since the last scan"
375
+ return
376
+ if self._vault_bridge is None:
377
+ result.update(
378
+ status="failed",
379
+ synced=False,
380
+ detail="no vault bridge is wired; the vault was not re-synced",
381
+ )
382
+ return
383
+ try:
384
+ summary = self._vault_bridge.sync(
385
+ str(root), owner=owner, workspace_id=workspace_id, user_email=owner,
386
+ )
387
+ except Exception as exc: # noqa: BLE001 — one bad sync must not kill the poller
388
+ result.update(status="failed", synced=False, detail=f"vault sync failed: {exc}")
389
+ return
390
+ result["synced"] = True
391
+ result["ingested"] = int(summary.get("ingested") or 0)
392
+ result["duplicate"] = int(summary.get("duplicate") or 0)
393
+ result["failed"] = int(summary.get("failed") or 0)
394
+ result["errors"] = list(summary.get("errors") or [])[:20]
395
+ result["edges"] = summary.get("edges")
396
+ result["status"] = "ok" if summary.get("status") in ("ok", "dry_run") else str(
397
+ summary.get("status") or "ok"
398
+ )
399
+
290
400
  def _record_scan(
291
401
  self,
292
402
  watch_id: str,
@@ -355,6 +465,7 @@ class FolderWatchService:
355
465
  "owner": watch.get("owner"),
356
466
  "workspace_id": watch.get("workspace_id"),
357
467
  "recursive": bool(watch.get("recursive", True)),
468
+ "kind": str(watch.get("kind") or WATCH_KIND_FOLDER),
358
469
  "enabled": bool(watch.get("enabled")),
359
470
  "created_at": watch.get("created_at"),
360
471
  "last_scan_at": watch.get("last_scan_at"),
@@ -396,4 +507,14 @@ class FolderWatchService:
396
507
  continue
397
508
 
398
509
 
399
- __all__ = ["FolderWatchService", "DEFAULT_WATCH_INTERVAL_SECONDS", "WATCH_INTERVAL_ENV"]
510
+ __all__ = [
511
+ "DEFAULT_WATCH_INTERVAL_SECONDS",
512
+ "VAULT_WATCH_DISABLED_DETAIL",
513
+ "VAULT_WATCH_ENV",
514
+ "VAULT_WATCH_GATE",
515
+ "WATCH_INTERVAL_ENV",
516
+ "WATCH_KINDS",
517
+ "WATCH_KIND_FOLDER",
518
+ "WATCH_KIND_VAULT",
519
+ "FolderWatchService",
520
+ ]
@@ -2,6 +2,13 @@
2
2
 
3
3
  Phase 1–2 entry point used by the chat path when NetworkBoundaryMode is
4
4
  CLOUD_ALLOWED. Local-only sessions never enter this module's cloud path.
5
+
6
+ Both turn functions take the Review Center sink and the hybrid policy's
7
+ ``auto_commit`` decision as arguments rather than reaching for them: the
8
+ caller that knows *whose* turn this is is the only one that can resolve a
9
+ per-user policy, and a service that reads a process singleton cannot be
10
+ tested against both branches. Defaults are the safe ones — no sink, no
11
+ auto-commit — so a headless caller stages nothing rather than writing.
5
12
  """
6
13
 
7
14
  from __future__ import annotations
@@ -36,6 +43,35 @@ def _scope_key(user_email: Optional[str], workspace_id: Optional[str]) -> str:
36
43
  return f"{user_email or 'anon'}|{workspace_id or 'global'}"
37
44
 
38
45
 
46
+ def _ingest_cloud_expansion(
47
+ result: CloudTurnResult,
48
+ *,
49
+ knowledge_graph: Any,
50
+ review_queue: Any,
51
+ auto_commit: bool,
52
+ user_email: Optional[str],
53
+ workspace_id: Optional[str],
54
+ ) -> Dict[str, Any]:
55
+ """Stage what the cloud answer taught the Brain (v11.2.0 wiring).
56
+
57
+ ``plan_kg_expansion_rich`` builds every plan with ``auto_commit=False``
58
+ because extraction has no idea what the user consented to; the policy dial
59
+ does, and this is where the two meet. With the sink bound the plan lands in
60
+ the Review Center as a ``change_proposal``, which is what makes
61
+ cloud-derived memory growth a thing the user approves rather than a thing
62
+ that happens to them.
63
+ """
64
+ plan = plan_kg_expansion_rich(result)
65
+ plan.auto_commit = bool(auto_commit)
66
+ ingestor = CloudResponseIngestor(
67
+ store=knowledge_graph,
68
+ review_queue=review_queue,
69
+ user_email=user_email,
70
+ workspace_id=workspace_id,
71
+ )
72
+ return ingestor.ingest(plan)
73
+
74
+
39
75
  async def run_hybrid_cloud_turn(
40
76
  *,
41
77
  user_message: str,
@@ -46,6 +82,8 @@ async def run_hybrid_cloud_turn(
46
82
  model: Optional[str] = None,
47
83
  top_k: int = 6,
48
84
  adapter: Optional[Any] = None,
85
+ review_queue: Any = None,
86
+ auto_commit: bool = False,
49
87
  ) -> CloudTurnResult:
50
88
  """Non-streaming helper: full answer + expansion plan."""
51
89
  mode = normalize_network_mode(mode)
@@ -85,9 +123,14 @@ async def run_hybrid_cloud_turn(
85
123
  mode=mode,
86
124
  model=model,
87
125
  )
88
- plan = plan_kg_expansion_rich(result)
89
- ingestor = CloudResponseIngestor(store=knowledge_graph)
90
- ingest_status = ingestor.ingest(plan)
126
+ ingest_status = _ingest_cloud_expansion(
127
+ result,
128
+ knowledge_graph=knowledge_graph,
129
+ review_queue=review_queue,
130
+ auto_commit=auto_commit,
131
+ user_email=user_email,
132
+ workspace_id=workspace_id,
133
+ )
91
134
  used = minimal.token_estimate + max(1, len(result.answer_text) // 4)
92
135
  budget.record(used)
93
136
  result.usage = {
@@ -116,6 +159,8 @@ async def stream_hybrid_cloud_turn(
116
159
  history_user: Optional[Dict[str, Any]] = None,
117
160
  notify: Any = None,
118
161
  source: Optional[str] = None,
162
+ review_queue: Any = None,
163
+ auto_commit: bool = False,
119
164
  ) -> AsyncIterator[str]:
120
165
  """SSE generator for a hybrid cloud turn (Phase 2 chat path).
121
166
 
@@ -220,8 +265,14 @@ async def stream_hybrid_cloud_turn(
220
265
  }
221
266
  )
222
267
 
223
- plan = plan_kg_expansion_rich(result)
224
- ingest_status = CloudResponseIngestor(store=knowledge_graph).ingest(plan)
268
+ ingest_status = _ingest_cloud_expansion(
269
+ result,
270
+ knowledge_graph=knowledge_graph,
271
+ review_queue=review_queue,
272
+ auto_commit=auto_commit,
273
+ user_email=user_email,
274
+ workspace_id=workspace_id,
275
+ )
225
276
  used = minimal.token_estimate + max(1, len(result.answer_text) // 4)
226
277
  budget.record(used)
227
278