ltcai 11.0.1 → 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 (140) hide show
  1. package/README.md +55 -43
  2. package/docs/CHANGELOG.md +61 -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/PERFORMANCE.md +71 -18
  10. package/docs/TRUST_MODEL.md +1 -1
  11. package/docs/WHY_LATTICE.md +1 -1
  12. package/docs/architecture.md +6 -2
  13. package/docs/kg-schema.md +1 -1
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/embeddings.py +12 -37
  16. package/lattice_brain/gates.py +125 -0
  17. package/lattice_brain/graph/discovery_index.py +30 -32
  18. package/lattice_brain/graph/fusion.py +35 -4
  19. package/lattice_brain/graph/image_vectors.py +230 -0
  20. package/lattice_brain/graph/ingest.py +11 -5
  21. package/lattice_brain/graph/projection.py +66 -8
  22. package/lattice_brain/graph/provenance.py +27 -2
  23. package/lattice_brain/graph/retrieval.py +113 -2
  24. package/lattice_brain/graph/retrieval_docgen.py +6 -6
  25. package/lattice_brain/graph/schema.py +18 -0
  26. package/lattice_brain/graph/store.py +9 -0
  27. package/lattice_brain/graph/vector_index/selector.py +32 -2
  28. package/lattice_brain/ingestion.py +363 -10
  29. package/lattice_brain/multimodal.py +1258 -0
  30. package/lattice_brain/portability.py +169 -32
  31. package/lattice_brain/runtime/multi_agent.py +1 -1
  32. package/lattice_brain/sealed_box.py +244 -0
  33. package/lattice_brain/self_model.py +77 -22
  34. package/lattice_brain/synthesis.py +24 -1
  35. package/latticeai/__init__.py +1 -1
  36. package/latticeai/api/brain_intelligence.py +4 -0
  37. package/latticeai/api/chat.py +11 -0
  38. package/latticeai/api/chat_helpers.py +16 -3
  39. package/latticeai/api/chat_hybrid.py +32 -1
  40. package/latticeai/api/features.py +70 -0
  41. package/latticeai/api/local_files.py +102 -0
  42. package/latticeai/api/memory.py +128 -1
  43. package/latticeai/api/portability.py +39 -4
  44. package/latticeai/api/review_queue.py +126 -0
  45. package/latticeai/api/search.py +16 -2
  46. package/latticeai/core/agent.py +59 -2
  47. package/latticeai/core/agent_prompts.py +66 -0
  48. package/latticeai/core/config.py +4 -1
  49. package/latticeai/core/context_builder.py +98 -11
  50. package/latticeai/core/embedding_providers.py +528 -0
  51. package/latticeai/core/legacy_compatibility.py +1 -1
  52. package/latticeai/core/marketplace.py +1 -1
  53. package/latticeai/core/messages.py +180 -0
  54. package/latticeai/core/model_compat.py +73 -2
  55. package/latticeai/core/workspace_os.py +43 -0
  56. package/latticeai/core/workspace_os_constants.py +1 -1
  57. package/latticeai/core/workspace_reorganization.py +335 -0
  58. package/latticeai/models/model_providers.py +12 -4
  59. package/latticeai/runtime/build_phases.py +33 -2
  60. package/latticeai/runtime/chat_wiring.py +4 -0
  61. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  62. package/latticeai/runtime/persistence_runtime.py +41 -4
  63. package/latticeai/runtime/router_registration.py +11 -0
  64. package/latticeai/runtime/runtime_context.py +1 -0
  65. package/latticeai/services/app_context.py +8 -0
  66. package/latticeai/services/architecture_readiness.py +1 -1
  67. package/latticeai/services/automation_intelligence.py +22 -2
  68. package/latticeai/services/brain_intelligence.py +123 -7
  69. package/latticeai/services/change_proposals.py +50 -10
  70. package/latticeai/services/command_center.py +10 -4
  71. package/latticeai/services/feature_toggles.py +502 -0
  72. package/latticeai/services/folder_watch.py +122 -1
  73. package/latticeai/services/hybrid_chat.py +56 -5
  74. package/latticeai/services/interop_bridges.py +978 -0
  75. package/latticeai/services/memory_service.py +34 -0
  76. package/latticeai/services/model_capability_registry.py +434 -261
  77. package/latticeai/services/model_catalog.py +95 -61
  78. package/latticeai/services/model_recommendation.py +18 -11
  79. package/latticeai/services/model_runtime.py +1 -1
  80. package/latticeai/services/multimodal_ports.py +112 -0
  81. package/latticeai/services/obsidian_bridge.py +16 -25
  82. package/latticeai/services/product_readiness.py +1 -1
  83. package/latticeai/services/search_service.py +149 -2
  84. package/latticeai/services/self_model_service.py +171 -0
  85. package/latticeai/services/tool_dispatch.py +4 -0
  86. package/latticeai/services/voice_capture.py +27 -1
  87. package/latticeai/setup/auto_setup.py +27 -30
  88. package/latticeai/setup/wizard.py +77 -44
  89. package/package.json +1 -1
  90. package/scripts/check_current_release_docs.mjs +1 -1
  91. package/scripts/check_server_i18n.mjs +1 -0
  92. package/scripts/release_screen_claims.json +22 -0
  93. package/scripts/verify_hf_model_registry.py +253 -218
  94. package/src-tauri/Cargo.lock +1 -1
  95. package/src-tauri/Cargo.toml +1 -1
  96. package/src-tauri/tauri.conf.json +1 -1
  97. package/static/app/asset-manifest.json +37 -37
  98. package/static/app/assets/{Act-D4zSxFR-.js → Act-AWf0SAKp.js} +1 -1
  99. package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D0u8Tiyj.js} +1 -1
  100. package/static/app/assets/{Brain-C2EqQg74.js → Brain-tuhI4sOC.js} +1 -1
  101. package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
  102. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +1 -0
  103. package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CqOSzyPr.js} +1 -1
  104. package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DC0Bzh-I.js} +1 -1
  105. package/static/app/assets/{Library-BN0HYOfc.js → Library-CX-bbhmK.js} +1 -1
  106. package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-DBwhto14.js} +1 -1
  107. package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-BHA2cfKI.js} +1 -1
  108. package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-BUhCKRNM.js} +1 -1
  109. package/static/app/assets/{System-BElUcSSw.js → System-Bu2t5hn1.js} +1 -1
  110. package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
  111. package/static/app/assets/{bot--qYHMtkP.js → bot-Cia42c2h.js} +1 -1
  112. package/static/app/assets/brain-DJMoqrwx.js +1 -0
  113. package/static/app/assets/{button-51Z3rsuv.js → button-2j2Ijzgq.js} +1 -1
  114. package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-BEFeWpVW.js} +1 -1
  115. package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-ujXMcHxl.js} +1 -1
  116. package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-k4awryFq.js} +1 -1
  117. package/static/app/assets/{download-G-2olkWz.js → download-DFbLJ_ig.js} +1 -1
  118. package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-7y_b6xkM.js} +1 -1
  119. package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-Bidh02Kr.js} +1 -1
  120. package/static/app/assets/{index-D7Rr-J2Y.js → index-BpYkzcVm.js} +3 -3
  121. package/static/app/assets/index-DwDl9-8Y.css +2 -0
  122. package/static/app/assets/{input-D4w_BZWl.js → input-DSlJJxRs.js} +1 -1
  123. package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-Bpb83Hx9.js} +1 -1
  124. package/static/app/assets/{primitives-d0g9pvzS.js → primitives-BCx6TvfG.js} +1 -1
  125. package/static/app/assets/search-Cgy8cCFJ.js +1 -0
  126. package/static/app/assets/{share-2-NmD7e_oV.js → share-2-BH1M-WNi.js} +1 -1
  127. package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-BlKdBXcG.js} +1 -1
  128. package/static/app/assets/{textarea-BPAJDc-0.js → textarea-CCWbUfFB.js} +1 -1
  129. package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
  130. package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-CXQiwbVT.js} +1 -1
  131. package/static/app/assets/{utils-DG1_ExrP.js → utils-zqPZJxdx.js} +2 -2
  132. package/static/app/assets/{workspace-CWVf3gsI.js → workspace-DXTihhfU.js} +1 -1
  133. package/static/app/index.html +4 -4
  134. package/static/sw.js +1 -1
  135. package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
  136. package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
  137. package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
  138. package/static/app/assets/brain-DDCLjRqO.js +0 -1
  139. package/static/app/assets/index-CkzokZAj.css +0 -2
  140. package/static/app/assets/search-BLCYt75v.js +0 -1
@@ -37,7 +37,7 @@ import hashlib
37
37
  import json
38
38
  import logging
39
39
  import re
40
- from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
40
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
41
41
 
42
42
  from .context import approx_tokens
43
43
  from .synthesis import ProposalDesk
@@ -127,7 +127,7 @@ _PATTERNS: Tuple[Tuple[str, "re.Pattern[str]", int, float, str], ...] = (
127
127
  (
128
128
  "preference",
129
129
  re.compile(
130
- r"(?:나는|저는|내가|제가)\s*(?P<v>[^\n.。]+?)"
130
+ r"(?:나는|저는|내가|제가)\s*(?P<v>[^\n.。]+?)\s*(?:을|를|이|가)?"
131
131
  r"\s*(?:좋아|선호|싫어)(?:합니다|한다|해요|해|하고)"
132
132
  ),
133
133
  1,
@@ -145,11 +145,10 @@ _PATTERNS: Tuple[Tuple[str, "re.Pattern[str]", int, float, str], ...] = (
145
145
  "en_first_person_preference",
146
146
  ),
147
147
  (
148
+ # The frequency word stays inside the capture: "회고를 씁니다" is a
149
+ # sentence, "매일 회고를 씁니다" is a habit.
148
150
  "habit",
149
- re.compile(
150
- r"(?:매일|매주|매달|아침마다|항상|늘)\s*(?P<v>[^\n.。]+?)"
151
- r"\s*(?:합니다|한다|해요|해)(?:\b|[\s.。!?]|$)"
152
- ),
151
+ re.compile(r"(?P<v>(?:매일|매주|매달|아침마다|항상|늘)\s*[^\n.。!?]+)"),
153
152
  1,
154
153
  0.65,
155
154
  "ko_routine",
@@ -157,7 +156,8 @@ _PATTERNS: Tuple[Tuple[str, "re.Pattern[str]", int, float, str], ...] = (
157
156
  (
158
157
  "habit",
159
158
  re.compile(
160
- r"\bI\s+(?:always|usually|every\s+(?:morning|day|week))\s+(?P<v>[^\n.!?]+)",
159
+ r"\bI\s+(?P<v>(?:always|usually|every\s+(?:morning|day|week))"
160
+ r"\s+[^\n.!?]+)",
161
161
  re.IGNORECASE,
162
162
  ),
163
163
  1,
@@ -205,7 +205,17 @@ _PATTERNS: Tuple[Tuple[str, "re.Pattern[str]", int, float, str], ...] = (
205
205
 
206
206
 
207
207
  class SelfModelError(ValueError):
208
- """Raised when a Self-Model operation cannot be carried out as asked."""
208
+ """Raised when a Self-Model operation cannot be carried out as asked.
209
+
210
+ Carries a machine-readable ``code`` alongside the developer message: Brain
211
+ Core cannot reach the server's message catalog (isolation), so the API
212
+ layer translates the code instead of forwarding an English sentence to a
213
+ Korean user.
214
+ """
215
+
216
+ def __init__(self, message: str, *, code: str = "invalid") -> None:
217
+ self.code = code
218
+ super().__init__(message)
209
219
 
210
220
 
211
221
  # ── extraction (deterministic) ───────────────────────────────────────────────
@@ -347,12 +357,18 @@ def apply_self_model_proposal(
347
357
  """
348
358
  item = review_queue.get(item_id, workspace_id=workspace_id)
349
359
  if str(item.get("kind")) != SELF_MODEL_KIND:
350
- raise SelfModelError(f"review item {item_id} is not a Self-Model proposal")
360
+ raise SelfModelError(
361
+ f"review item {item_id} is not a Self-Model proposal",
362
+ code="not_a_proposal",
363
+ )
351
364
  fact = dict((item.get("payload") or {}).get("fact") or {})
352
365
  kind = str(fact.get("kind") or "")
353
366
  text = _normalize(str(fact.get("text") or ""))
354
367
  if kind not in FACT_NODE_TYPES or not text:
355
- raise SelfModelError(f"review item {item_id} carries no Self-Model fact")
368
+ raise SelfModelError(
369
+ f"review item {item_id} carries no Self-Model fact",
370
+ code="empty_proposal",
371
+ )
356
372
  approved = review_queue.approve(item_id, workspace_id=workspace_id)
357
373
  node = _write_fact(
358
374
  store,
@@ -386,9 +402,11 @@ def upsert_self_model_fact(
386
402
  kind = str(kind or "").strip().lower()
387
403
  value = _normalize(text)
388
404
  if kind not in FACT_NODE_TYPES:
389
- raise SelfModelError(f"kind must be one of {sorted(FACT_NODE_TYPES)}")
405
+ raise SelfModelError(
406
+ f"kind must be one of {sorted(FACT_NODE_TYPES)}", code="invalid_kind"
407
+ )
390
408
  if not value:
391
- raise SelfModelError("a Self-Model fact needs text")
409
+ raise SelfModelError("a Self-Model fact needs text", code="text_required")
392
410
  return _write_fact(
393
411
  store,
394
412
  kind=kind,
@@ -405,14 +423,14 @@ def delete_self_model_fact(store: Any, node_id: str) -> Dict[str, Any]:
405
423
  """Remove one fact (or the whole root) from the subgraph, permanently."""
406
424
  identifier = str(node_id or "").strip()
407
425
  if not identifier.startswith(SELF_ID_PREFIX):
408
- raise SelfModelError(f"not a Self-Model node: {identifier}")
426
+ raise SelfModelError(f"not a Self-Model node: {identifier}", code="not_self_model")
409
427
  with store._connect() as conn:
410
428
  deleted = conn.execute(
411
429
  "DELETE FROM nodes WHERE id=?", (identifier,)
412
430
  ).rowcount
413
431
  conn.execute("DELETE FROM nodes_v2 WHERE id=?", (identifier,))
414
432
  if not deleted:
415
- raise SelfModelError(f"Self-Model node not found: {identifier}")
433
+ raise SelfModelError(f"Self-Model node not found: {identifier}", code="not_found")
416
434
  return {"status": "ok", "id": identifier, "deleted_at": now_iso()}
417
435
 
418
436
 
@@ -420,13 +438,18 @@ def delete_self_model_fact(store: Any, node_id: str) -> Dict[str, Any]:
420
438
 
421
439
 
422
440
  def list_self_model(
423
- store: Any, *, workspace_id: Optional[str] = None
441
+ store: Any,
442
+ *,
443
+ workspace_id: Optional[str] = None,
444
+ allowed_workspaces: Optional[Iterable[str]] = None,
424
445
  ) -> Dict[str, Any]:
425
446
  """Every fact the Brain holds about its owner, grouped for display."""
426
- facts = _read_facts(store, workspace_id=workspace_id)
447
+ facts = _read_facts(
448
+ store, workspace_id=workspace_id, allowed_workspaces=allowed_workspaces
449
+ )
427
450
  counts: Dict[str, int] = {kind: 0 for kind in KIND_ORDER}
428
451
  for fact in facts:
429
- counts[fact["kind"]] = counts.get(fact["kind"], 0) + 1
452
+ counts[fact["kind"]] += 1
430
453
  return {
431
454
  "available": True,
432
455
  "facts": facts,
@@ -442,6 +465,7 @@ def self_model_summary(
442
465
  *,
443
466
  limit_tokens: int = DEFAULT_SUMMARY_TOKENS,
444
467
  workspace_id: Optional[str] = None,
468
+ allowed_workspaces: Optional[Iterable[str]] = None,
445
469
  ) -> str:
446
470
  """Plain-text Self-Model summary for injection into a model's context.
447
471
 
@@ -450,7 +474,9 @@ def self_model_summary(
450
474
  An empty subgraph returns ``""`` — the caller injects nothing rather than a
451
475
  header with nothing under it.
452
476
  """
453
- facts = _read_facts(store, workspace_id=workspace_id)
477
+ facts = _read_facts(
478
+ store, workspace_id=workspace_id, allowed_workspaces=allowed_workspaces
479
+ )
454
480
  if not facts or limit_tokens <= 0:
455
481
  return ""
456
482
  header = "사용자에 대해 확인된 사실:"
@@ -523,8 +549,28 @@ def _write_fact(
523
549
  }
524
550
 
525
551
 
552
+ def _allowed_set(
553
+ workspace_id: Optional[str], allowed_workspaces: Optional[Iterable[str]]
554
+ ) -> Optional[set]:
555
+ """Normalize the two scoping spellings into one allow-set (``None`` = all).
556
+
557
+ ``allowed_workspaces`` is the scoping vocabulary the graph reads already
558
+ use (``filter_scoped_nodes``); ``workspace_id`` is the single-scope sugar
559
+ the API surfaces speak. A fact with no workspace is personal-global and
560
+ stays visible in both.
561
+ """
562
+ if allowed_workspaces is not None:
563
+ return {str(item) for item in allowed_workspaces if item}
564
+ if workspace_id is None:
565
+ return None
566
+ return {str(workspace_id)}
567
+
568
+
526
569
  def _read_facts(
527
- store: Any, *, workspace_id: Optional[str] = None
570
+ store: Any,
571
+ *,
572
+ workspace_id: Optional[str] = None,
573
+ allowed_workspaces: Optional[Iterable[str]] = None,
528
574
  ) -> List[Dict[str, Any]]:
529
575
  """Fact rows for a workspace, ordered deterministically (kind, then text).
530
576
 
@@ -533,6 +579,10 @@ def _read_facts(
533
579
  both read modes. A store that cannot be read yields no facts rather than
534
580
  raising into a prompt-assembly path.
535
581
  """
582
+ if not hasattr(store, "_connect"):
583
+ # Not a Brain store (a doc-generation seam may be handed a stand-in).
584
+ # There is no profile to read, and that is not an error worth logging.
585
+ return []
536
586
  try:
537
587
  with store._connect() as conn:
538
588
  rows = conn.execute(
@@ -544,11 +594,12 @@ def _read_facts(
544
594
  logger.exception("self-model read failed")
545
595
  return []
546
596
  facts = [fact for fact in (_row_to_fact(row) for row in rows) if fact]
547
- if workspace_id is not None:
597
+ allowed = _allowed_set(workspace_id, allowed_workspaces)
598
+ if allowed is not None:
548
599
  facts = [
549
600
  fact
550
601
  for fact in facts
551
- if fact["workspace_id"] in (None, "", str(workspace_id))
602
+ if not fact["workspace_id"] or str(fact["workspace_id"]) in allowed
552
603
  ]
553
604
  return sorted(facts, key=lambda fact: (KIND_ORDER.index(fact["kind"]), fact["text"]))
554
605
 
@@ -584,6 +635,7 @@ def summary_for_prompt(
584
635
  *,
585
636
  limit_tokens: int = DEFAULT_SUMMARY_TOKENS,
586
637
  workspace_id: Optional[str] = None,
638
+ allowed_workspaces: Optional[Iterable[str]] = None,
587
639
  ) -> str:
588
640
  """:func:`self_model_summary` that never raises — the injection seam.
589
641
 
@@ -592,7 +644,10 @@ def summary_for_prompt(
592
644
  """
593
645
  try:
594
646
  return self_model_summary(
595
- store, limit_tokens=limit_tokens, workspace_id=workspace_id
647
+ store,
648
+ limit_tokens=limit_tokens,
649
+ workspace_id=workspace_id,
650
+ allowed_workspaces=allowed_workspaces,
596
651
  )
597
652
  except Exception: # noqa: BLE001 — see docstring
598
653
  logger.exception("self-model summary failed")
@@ -35,6 +35,7 @@ import os
35
35
  from datetime import datetime, timezone
36
36
  from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
37
37
 
38
+ from .gates import FeatureGate
38
39
  from .graph.proactive import ProactiveBrain
39
40
  from .graph.schema import KGStoreV2
40
41
  from .quality import content_signature
@@ -60,6 +61,19 @@ CONTRADICTION_RESOLUTIONS = ("keep_old", "replace", "keep_both_temporal")
60
61
  SYNTHESIS_THRESHOLD_ENV = "LATTICEAI_SYNTHESIS_THRESHOLD"
61
62
  DEFAULT_SYNTHESIS_THRESHOLD = 25
62
63
 
64
+ #: Whether the Brain is allowed to start a pass *by itself*. Default on — this
65
+ #: is the behaviour 11.1.0 shipped — and it governs only the automatic path
66
+ #: (:meth:`BrainSynthesizer.run_if_due`). An explicit run a person asked for is
67
+ #: never gated: turning "tidy up on its own" off means the Brain stops deciding
68
+ #: when, not that the button stops working.
69
+ SYNTHESIS_ENV = "LATTICEAI_SYNTHESIS"
70
+ SYNTHESIS_GATE = FeatureGate(
71
+ SYNTHESIS_ENV,
72
+ default=True,
73
+ name="synthesis",
74
+ detail="The Brain reviews accumulated material on its own and proposes tidy-ups.",
75
+ )
76
+
63
77
  _MIN_SHARED_TOKENS = 3
64
78
  _MIN_CLUSTER_MEMBERS = 3
65
79
  _EDGE_SIMILARITY = 0.35
@@ -516,7 +530,14 @@ class BrainSynthesizer:
516
530
  self, result: Any, *, workspace_id: Optional[str] = None,
517
531
  user_email: Optional[str] = None,
518
532
  ) -> Optional[Dict[str, Any]]:
519
- """Run synthesis only when this ingest pushed the counter over."""
533
+ """Run synthesis only when this ingest pushed the counter over.
534
+
535
+ The gate is checked *before* the counter moves, so "off" means off: a
536
+ long import while the switch is down does not bank a pass that fires the
537
+ instant someone turns it back on.
538
+ """
539
+ if not SYNTHESIS_GATE.enabled():
540
+ return None
520
541
  if not self.observe_ingest(result):
521
542
  return None
522
543
  return self.run(workspace_id=workspace_id, user_email=user_email)
@@ -792,6 +813,8 @@ __all__ = [
792
813
  "ContradictionResolutionError",
793
814
  "EDGE_KIND",
794
815
  "ProposalDesk",
816
+ "SYNTHESIS_ENV",
817
+ "SYNTHESIS_GATE",
795
818
  "SYNTHESIS_REVIEW_SOURCE",
796
819
  "SYNTHESIS_THRESHOLD_ENV",
797
820
  "SynthesisTrigger",
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "11.0.1"
3
+ __version__ = "11.2.0"
@@ -85,6 +85,10 @@ def create_brain_intelligence_router(
85
85
  Fixed contract consumed by the frontend:
86
86
  ``{"status": "ready"|"pending"|"unavailable", "pending_items": int,
87
87
  "total_items": int, "detail": str}``.
88
+
89
+ Additive since v11.2.0: ``breakdown`` splits the backlog into
90
+ ``embedded``/``missing``/``stale``/``queued`` when the store can. The
91
+ four keys above are unchanged, so existing readers see no difference.
88
92
  """
89
93
  user = require_user(request)
90
94
  scope = gate_read(request)
@@ -111,6 +111,16 @@ def create_chat_router(context: AppContext) -> APIRouter:
111
111
  get_history_user=context.require("get_history_user"),
112
112
  )
113
113
 
114
+ def review_sink() -> object:
115
+ """The Review Center queue, resolved per request (may be absent).
116
+
117
+ Cloud-derived knowledge is staged here rather than written, so the
118
+ hybrid turn needs the live queue — which is wired after this router's
119
+ context is built, hence a provider rather than a captured value.
120
+ """
121
+ provider = context.review_queue
122
+ return None if provider is None else provider()
123
+
114
124
  def notify_chat_message(role: str, text: str, source: Optional[str]) -> None:
115
125
  """Mirror persisted web exchanges to an injected bridge, never echoing it."""
116
126
  if context.on_chat_message is None or source == "telegram":
@@ -445,6 +455,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
445
455
  chat_service=chat_service,
446
456
  notify=notify_chat_message,
447
457
  model_id=selected_model_id,
458
+ review_queue=review_sink(),
448
459
  )
449
460
  if hybrid_response is not None:
450
461
  return hybrid_response
@@ -212,8 +212,17 @@ def build_context_quality(
212
212
  lexical-only, or when retrieval failed entirely — so the frontend can
213
213
  tell the user the answer is weakly grounded instead of implying rich
214
214
  graph context. Never raises.
215
+
216
+ A fifth key, ``multimodal``, appears only when the matches really include
217
+ picture nodes (v11.2.0). "6 nodes" reads differently when two of them are
218
+ screenshots whose text came out of OCR, and until this call site passed
219
+ ``multimodal=`` the shipped chat surface could not tell the difference the
220
+ signal was built to report.
215
221
  """
216
- from lattice_brain.graph.retrieval import context_quality_signal
222
+ from lattice_brain.graph.retrieval import (
223
+ context_quality_signal,
224
+ multimodal_signal,
225
+ )
217
226
 
218
227
  query = str(query or "").strip()
219
228
  if knowledge_graph is None or not query:
@@ -233,7 +242,9 @@ def build_context_quality(
233
242
  return context_quality_signal("none", 0, reason="그래프 검색에 실패했습니다")
234
243
  matches = result.get("matches") or []
235
244
  return context_quality_signal(
236
- str(result.get("mode") or "hybrid"), len(matches)
245
+ str(result.get("mode") or "hybrid"),
246
+ len(matches),
247
+ multimodal=multimodal_signal(matches),
237
248
  )
238
249
  # Lexical-only stores without the hybrid mixin.
239
250
  try:
@@ -242,7 +253,9 @@ def build_context_quality(
242
253
  ).get("matches") or []
243
254
  except Exception: # noqa: BLE001 — signal building must never fail chat
244
255
  return context_quality_signal("none", 0, reason="그래프 검색에 실패했습니다")
245
- return context_quality_signal("lexical_only", len(matches))
256
+ return context_quality_signal(
257
+ "lexical_only", len(matches), multimodal=multimodal_signal(matches)
258
+ )
246
259
 
247
260
 
248
261
  # ── answer-citation binding (backlog #11, review §7.2 E #4) ──────────────────
@@ -14,7 +14,10 @@ from latticeai.core.network_boundary import (
14
14
  NetworkBoundaryMode,
15
15
  normalize_network_mode,
16
16
  )
17
- from latticeai.runtime.network_boundary_wiring import resolve_active_network_mode
17
+ from latticeai.runtime.network_boundary_wiring import (
18
+ get_hybrid_policy_service,
19
+ resolve_active_network_mode,
20
+ )
18
21
  from latticeai.services.hybrid_chat import stream_hybrid_cloud_turn
19
22
 
20
23
 
@@ -32,6 +35,28 @@ def resolve_request_network_mode(
32
35
  )
33
36
 
34
37
 
38
+ def resolve_hybrid_auto_commit(
39
+ *,
40
+ user_email: Optional[str],
41
+ workspace_id: Optional[str],
42
+ ) -> bool:
43
+ """The scoped hybrid policy's ``auto_commit`` decision for this turn.
44
+
45
+ Sibling of :func:`resolve_request_network_mode`: the dial says whether the
46
+ turn may reach the cloud, this says what may happen to what comes back.
47
+ Default is ``False`` — cloud-derived knowledge waits in the Review Center
48
+ — and a policy that cannot be read is treated as the default rather than
49
+ as permission.
50
+ """
51
+ try:
52
+ policy = get_hybrid_policy_service().resolve(
53
+ user_email=user_email, workspace_id=workspace_id
54
+ )
55
+ except Exception: # noqa: BLE001 — an unreadable policy never grants a write
56
+ return False
57
+ return bool(policy.get("auto_commit", False))
58
+
59
+
35
60
  def maybe_hybrid_stream_response(
36
61
  *,
37
62
  req: Any,
@@ -45,6 +70,7 @@ def maybe_hybrid_stream_response(
45
70
  chat_service: Any,
46
71
  notify: Any,
47
72
  model_id: Optional[str],
73
+ review_queue: Any = None,
48
74
  ) -> Optional[StreamingResponse]:
49
75
  """Return a StreamingResponse when cloud path should run; else None."""
50
76
  if mode != NetworkBoundaryMode.CLOUD_ALLOWED:
@@ -66,6 +92,10 @@ def maybe_hybrid_stream_response(
66
92
  history_user=history_user,
67
93
  notify=notify,
68
94
  source=req.source,
95
+ review_queue=review_queue,
96
+ auto_commit=resolve_hybrid_auto_commit(
97
+ user_email=effective_email, workspace_id=workspace_id
98
+ ),
69
99
  ),
70
100
  media_type="text/event-stream",
71
101
  headers={
@@ -78,5 +108,6 @@ def maybe_hybrid_stream_response(
78
108
 
79
109
  __all__ = [
80
110
  "resolve_request_network_mode",
111
+ "resolve_hybrid_auto_commit",
81
112
  "maybe_hybrid_stream_response",
82
113
  ]
@@ -0,0 +1,70 @@
1
+ """Feature switchboard API — read the catalog, move one switch (v11.2.0).
2
+
3
+ Two routes and no more. The catalog is rendered by the service (labels,
4
+ explanations, defaults, which options are even installable), so this router
5
+ carries no product knowledge at all: adding a feature is a line in
6
+ ``latticeai/services/feature_toggles.CATALOG``, not a change here or in the
7
+ client.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Callable, Union
13
+
14
+ from fastapi import APIRouter, HTTPException, Request
15
+ from pydantic import BaseModel, Field
16
+
17
+ from latticeai.core.messages import http_error, resolve_language
18
+ from latticeai.services.feature_toggles import (
19
+ FeatureToggleService,
20
+ InvalidFeatureValue,
21
+ UnknownFeature,
22
+ )
23
+
24
+
25
+ class SetFeatureRequest(BaseModel):
26
+ """One switch move. ``value`` is a bool for a toggle, a string for a choice."""
27
+
28
+ value: Union[bool, str] = Field(
29
+ ..., description="true/false for a toggle, or the option id for a choice"
30
+ )
31
+
32
+
33
+ def create_features_router(
34
+ *,
35
+ service: FeatureToggleService,
36
+ require_user: Callable[..., str],
37
+ ) -> APIRouter:
38
+ router = APIRouter(tags=["features"])
39
+
40
+ @router.get("/api/features")
41
+ async def list_features(request: Request) -> Any:
42
+ """Every opt-in feature, its live value, and where that value came from."""
43
+ require_user(request)
44
+ return service.catalog(resolve_language(request))
45
+
46
+ @router.post("/api/features/{feature_id}")
47
+ async def set_feature(
48
+ feature_id: str, body: SetFeatureRequest, request: Request
49
+ ) -> Any:
50
+ """Persist one person's choice; the answer is the feature as it now reads."""
51
+ user = require_user(request)
52
+ language = resolve_language(request)
53
+ try:
54
+ return service.set(
55
+ feature_id, body.value, language=language, user_email=user
56
+ )
57
+ except UnknownFeature as exc:
58
+ raise http_error(
59
+ 400, "features.unknown", language, feature=feature_id
60
+ ) from exc
61
+ except InvalidFeatureValue as exc:
62
+ # Pass-through: the service already localized the reason through the
63
+ # same catalog (wrong type, or an option whose optional dependency
64
+ # is not installed), and re-wording it here would only lose it.
65
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
66
+
67
+ return router
68
+
69
+
70
+ __all__ = ["SetFeatureRequest", "create_features_router"]
@@ -60,12 +60,36 @@ class FolderWatchEnableRequest(BaseModel):
60
60
  path: str
61
61
  recursive: bool = True
62
62
  workspace_id: Optional[str] = None
63
+ #: ``folder`` (default, unchanged) or ``vault`` — a watched external
64
+ #: Obsidian vault, which re-runs the whole bridge sync when it changes and
65
+ #: carries its own opt-in gate on top of this approval.
66
+ kind: str = "folder"
63
67
  # Watching continuously reads local disk → same approval dance as
64
68
  # /api/ingestion/folder. This is the explicit opt-in the review requires.
65
69
  approved: bool = False
66
70
  approval_token: Optional[str] = None
67
71
 
68
72
 
73
+ class InteropIngestRequest(BaseModel):
74
+ """Read one local interop source through the one ingestion gate (v11.2.0).
75
+
76
+ ``source`` picks the bridge — ``notion`` (an export directory or ``.zip``),
77
+ ``git`` (a local repository), ``mail`` (``.eml``/``.ics`` files or a folder
78
+ of them). Every one of them reads a path the user approves through the same
79
+ local-read dance as ``/api/ingestion/folder``, and ``dry_run`` reports what
80
+ a real run would touch without writing anything.
81
+ """
82
+
83
+ source: str
84
+ path: str
85
+ workspace_id: Optional[str] = None
86
+ dry_run: bool = False
87
+ #: Git only: how far back to read. Ignored by the other bridges.
88
+ max_commits: Optional[int] = None
89
+ approved: bool = False
90
+ approval_token: Optional[str] = None
91
+
92
+
69
93
  class ObsidianSyncRequest(BaseModel):
70
94
  """One-shot sync of an *external* Obsidian vault (v11.1.0).
71
95
 
@@ -107,10 +131,18 @@ def create_local_files_router(
107
131
  if folder_watch is None and ingestion_pipeline is not None and data_dir is not None:
108
132
  try:
109
133
  from latticeai.services.folder_watch import FolderWatchService
134
+ from latticeai.services.obsidian_bridge import ObsidianVaultBridge
110
135
 
111
136
  folder_watch = FolderWatchService(
112
137
  pipeline=ingestion_pipeline,
113
138
  config_path=Path(data_dir) / "folder_watch.json",
139
+ # A watched vault re-runs the *whole* bridge sync, which is what
140
+ # makes its link edges real; the bridge is handed in rather than
141
+ # imported inside the poller so the watch service keeps knowing
142
+ # nothing about how a vault is parsed.
143
+ vault_bridge=ObsidianVaultBridge(
144
+ pipeline=ingestion_pipeline, knowledge_graph=knowledge_graph,
145
+ ),
114
146
  )
115
147
  folder_watch.restore()
116
148
  except Exception: # noqa: BLE001 — watch mode is optional, never blocks routes
@@ -405,6 +437,66 @@ def create_local_files_router(
405
437
  dry_run=req.dry_run,
406
438
  )
407
439
 
440
+ # ── interop bridges: Notion export / git history / mail + calendar ───────
441
+ @router.get("/api/ingestion/interop")
442
+ async def interop_status(request: Request):
443
+ """Which interop bridges this machine can actually run, and why not."""
444
+ require_user(request)
445
+ from latticeai.services.interop_bridges import bridge_status
446
+
447
+ return bridge_status()
448
+
449
+ @router.post("/api/ingestion/interop")
450
+ async def interop_ingest(req: InteropIngestRequest, request: Request):
451
+ """Read an approved local interop source through the one gate.
452
+
453
+ Same approval dance and the same ``IngestionPipeline`` door as the
454
+ vault bridge: no vendor API, no credentials, nothing leaves the
455
+ machine. ``dry_run`` writes nothing and reports what a real run would.
456
+ """
457
+ current_user = permission_gateway.require_local_user(request)
458
+ _require_pipeline()
459
+ workspace_id = _ingestion_write_workspace(request, req.workspace_id, current_user)
460
+ path = (req.path or "").strip()
461
+ if not path:
462
+ raise http_error(400, "ingestion.interop_path_required", resolve_language(request))
463
+ from latticeai.services.interop_bridges import build_bridge
464
+
465
+ try:
466
+ bridge = build_bridge(
467
+ req.source, pipeline=ingestion_pipeline, knowledge_graph=knowledge_graph,
468
+ )
469
+ except ValueError:
470
+ raise http_error(
471
+ 400,
472
+ "ingestion.interop_unknown_source",
473
+ resolve_language(request),
474
+ source=req.source,
475
+ )
476
+ if not req.approved:
477
+ return permission_gateway.local_permission_response(path, "read", current_user)
478
+ permission_gateway.require_local_approval(
479
+ token=req.approval_token,
480
+ path=path,
481
+ action="read",
482
+ user_email=current_user,
483
+ )
484
+ options: Dict[str, Any] = {}
485
+ if req.max_commits is not None:
486
+ options["max_commits"] = req.max_commits
487
+ # Walking an export and embedding its pages is blocking work; the server
488
+ # owns one event loop and it may not sit here (10.9.0 ASYNC gate).
489
+ return await asyncio.to_thread(
490
+ lambda: bridge.sync(
491
+ path,
492
+ owner=current_user or None,
493
+ workspace_id=workspace_id,
494
+ user_email=current_user or None,
495
+ dry_run=req.dry_run,
496
+ **options,
497
+ )
498
+ )
499
+
408
500
  # ── folder watch mode: opt-in, off by default (backlog #8) ────────────────
409
501
  def _require_folder_watch(request: Request):
410
502
  _require_pipeline()
@@ -446,7 +538,16 @@ def create_local_files_router(
446
538
  owner=current_user or None,
447
539
  workspace_id=workspace_id,
448
540
  recursive=req.recursive,
541
+ kind=req.kind,
449
542
  )
543
+ if result.get("status") == "disabled":
544
+ # Turned off rather than broken: 403 with the reason, the same shape
545
+ # every other opt-in gate in this product answers with.
546
+ raise HTTPException(
547
+ status_code=403,
548
+ detail=result.get("detail")
549
+ or translate("ingestion.vault_watch_disabled", resolve_language(request)),
550
+ )
450
551
  if result.get("status") != "ok":
451
552
  raise HTTPException(
452
553
  status_code=400,
@@ -518,6 +619,7 @@ def create_local_files_router(
518
619
  __all__ = [
519
620
  "FolderIngestRequest",
520
621
  "FolderWatchEnableRequest",
622
+ "InteropIngestRequest",
521
623
  "LocalAccessRequest",
522
624
  "LocalWriteRequest",
523
625
  "ObsidianSyncRequest",