ltcai 9.9.5 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +39 -37
  2. package/docs/CHANGELOG.md +234 -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 +3 -1
  7. package/docs/PERMISSION_MODE.md +107 -0
  8. package/docs/SURFACE_PARITY.md +38 -8
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/context.py +39 -4
  14. package/lattice_brain/graph/_kg_common.py +189 -14
  15. package/lattice_brain/graph/curator.py +62 -0
  16. package/lattice_brain/graph/discovery.py +76 -0
  17. package/lattice_brain/graph/ingest.py +37 -7
  18. package/lattice_brain/graph/retrieval_vector.py +10 -0
  19. package/lattice_brain/ingestion.py +9 -106
  20. package/lattice_brain/ingestion_jobs.py +133 -0
  21. package/lattice_brain/runtime/multi_agent.py +1 -1
  22. package/latticeai/__init__.py +1 -1
  23. package/latticeai/api/brain_intelligence.py +8 -0
  24. package/latticeai/api/chat.py +9 -0
  25. package/latticeai/api/chat_agent_http.py +119 -2
  26. package/latticeai/api/chat_contracts.py +9 -0
  27. package/latticeai/api/chat_documents.py +16 -0
  28. package/latticeai/api/evidence_actions.py +48 -0
  29. package/latticeai/api/permission_mode.py +61 -0
  30. package/latticeai/api/project_sessions.py +112 -0
  31. package/latticeai/api/voice_capture.py +89 -0
  32. package/latticeai/app_factory.py +53 -0
  33. package/latticeai/core/agent.py +381 -43
  34. package/latticeai/core/agent_permission.py +160 -0
  35. package/latticeai/core/agent_profiles.py +110 -0
  36. package/latticeai/core/artifact_ledger.py +109 -0
  37. package/latticeai/core/context_builder.py +101 -2
  38. package/latticeai/core/legacy_compatibility.py +1 -1
  39. package/latticeai/core/marketplace.py +1 -1
  40. package/latticeai/core/permission_mode.py +296 -0
  41. package/latticeai/core/project_sessions.py +337 -0
  42. package/latticeai/core/run_explain.py +421 -0
  43. package/latticeai/core/run_store.py +5 -0
  44. package/latticeai/core/workspace_os.py +15 -80
  45. package/latticeai/core/workspace_review_items.py +107 -0
  46. package/latticeai/integrations/telegram_bot.py +188 -0
  47. package/latticeai/runtime/chat_wiring.py +8 -0
  48. package/latticeai/runtime/context_runtime.py +11 -0
  49. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  50. package/latticeai/runtime/router_registration.py +9 -0
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/brain_intelligence.py +105 -0
  54. package/latticeai/services/evidence_actions.py +254 -0
  55. package/latticeai/services/funnel_metrics.py +111 -16
  56. package/latticeai/services/local_knowledge.py +26 -0
  57. package/latticeai/services/memory_service.py +12 -0
  58. package/latticeai/services/permission_mode_service.py +170 -0
  59. package/latticeai/services/product_readiness.py +1 -1
  60. package/latticeai/services/tool_dispatch.py +73 -4
  61. package/latticeai/services/voice_capture.py +189 -0
  62. package/package.json +6 -4
  63. package/scripts/check_bundle_budget.mjs +11 -0
  64. package/scripts/check_current_release_docs.mjs +1 -1
  65. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  66. package/skills/meeting_notes/SKILL.md +82 -0
  67. package/skills/meeting_notes/examples.md +41 -0
  68. package/skills/meeting_notes/risk.json +9 -0
  69. package/skills/meeting_notes/schema.json +55 -0
  70. package/skills/weekly_review/SKILL.md +75 -0
  71. package/skills/weekly_review/examples.md +39 -0
  72. package/skills/weekly_review/risk.json +9 -0
  73. package/skills/weekly_review/schema.json +55 -0
  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 -29
  78. package/static/app/assets/Act-BtCREeN1.js +1 -0
  79. package/static/app/assets/AdminConsole-TPeeN18T.js +1 -0
  80. package/static/app/assets/Brain-BKs6JAp0.js +321 -0
  81. package/static/app/assets/BrainHome-BPGOvSd6.js +2 -0
  82. package/static/app/assets/BrainSignals-CtzQZ15J.js +1 -0
  83. package/static/app/assets/Capture-1_NaHWqB.js +1 -0
  84. package/static/app/assets/CommandPalette-pqvQOXe4.js +1 -0
  85. package/static/app/assets/Library-DhvoPvC7.js +1 -0
  86. package/static/app/assets/{LivingBrain-BVWk58MW.js → LivingBrain-DlQ20Q75.js} +1 -1
  87. package/static/app/assets/ProductFlow-BZvGDRi_.js +1 -0
  88. package/static/app/assets/ReviewCard-BWgI0D2s.js +3 -0
  89. package/static/app/assets/System-CSMdYLMy.js +1 -0
  90. package/static/app/assets/activity-Dlfk8YC7.js +1 -0
  91. package/static/app/assets/{bot-VmP0kkeA.js → bot-CDvUB76P.js} +1 -1
  92. package/static/app/assets/brain-xczrohrt.js +1 -0
  93. package/static/app/assets/button-SOdH3Oyf.js +1 -0
  94. package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause-CG1ythH4.js} +1 -1
  95. package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-HXwvjS6W.js} +1 -1
  96. package/static/app/assets/{cpu-CJhjRtNq.js → cpu-B0d-rGyk.js} +1 -1
  97. package/static/app/assets/download-BGIkTQL6.js +1 -0
  98. package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-Dst_Z0_K.js} +1 -1
  99. package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-D53MsWkV.js} +1 -1
  100. package/static/app/assets/index-C_IrlQMV.js +10 -0
  101. package/static/app/assets/index-FxDusbr0.css +2 -0
  102. package/static/app/assets/{input-CSi6OBJ9.js → input-C5m0riF6.js} +1 -1
  103. package/static/app/assets/{network-Ck1nqc-v.js → network-C5a-E5iS.js} +1 -1
  104. package/static/app/assets/primitives-vNXYf58F.js +1 -0
  105. package/static/app/assets/search-DhbSgW6m.js +1 -0
  106. package/static/app/assets/shield-alert-Cc-WVXqN.js +1 -0
  107. package/static/app/assets/{textarea-C4wM0D-Z.js → textarea-BkZ0EqVO.js} +1 -1
  108. package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-CTtKbAOU.js} +1 -1
  109. package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-Dx1fi4Wu.js} +1 -1
  110. package/static/app/assets/users-BFpQXtEF.js +1 -0
  111. package/static/app/assets/utils-BA_lmW3J.js +7 -0
  112. package/static/app/assets/workspace-DBPB0jkX.js +1 -0
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/Act-DmxfbqBp.js +0 -1
  116. package/static/app/assets/Brain-DK_zKCda.js +0 -321
  117. package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
  118. package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
  119. package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
  120. package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
  121. package/static/app/assets/Library-4As9VUvy.js +0 -1
  122. package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
  123. package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
  124. package/static/app/assets/System-DSR2zKWd.js +0 -1
  125. package/static/app/assets/index-7FAfYm4v.css +0 -2
  126. package/static/app/assets/index-CEu0Wqjl.js +0 -10
  127. package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
  128. package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
  129. package/static/app/assets/utils-BqoznzGP.js +0 -7
@@ -36,6 +36,7 @@ STATUS_URL = f"{BASE_URL}/status"
36
36
  MODELS_URL = f"{BASE_URL}/models"
37
37
  GRAPH_STATS_URL = f"{BASE_URL}/knowledge-graph/stats"
38
38
  UPLOAD_DOC_URL = f"{BASE_URL}/upload/document"
39
+ PROPOSALS_URL = f"{BASE_URL}/api/proposals"
39
40
 
40
41
  AGENT_RESUME_URL = f"{BASE_URL}/agent/resume"
41
42
  AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace")).resolve()
@@ -297,6 +298,9 @@ MAIN_MENU = {
297
298
  {"text": "🔗 웹 UI 열기", "callback_data": "cmd:web"},
298
299
  {"text": "🔌 MCP 도구 목록", "callback_data": "cmd:mcp"},
299
300
  ],
301
+ [
302
+ {"text": "🗂 변경 제안 검토", "callback_data": "cmd:review"},
303
+ ],
300
304
  ]
301
305
  }
302
306
 
@@ -665,6 +669,176 @@ def collect_generated_files(agent_data):
665
669
  files.append((path, target))
666
670
  return files
667
671
 
672
+ def format_grounding_badge(data, *, language: str = "ko") -> str:
673
+ """Answer-grounding badge line for a `/chat` reply (v9.9.7).
674
+
675
+ Telegram used to show only the answer text, so a reply the Brain could not
676
+ ground looked identical to one built on real sources. This renders exactly
677
+ the verdict the server issued — and reports an absent verdict as unknown
678
+ rather than promoting it to "근거 있음".
679
+ """
680
+ if not isinstance(data, dict):
681
+ return ""
682
+ grounding = data.get("grounding")
683
+ if not isinstance(grounding, dict):
684
+ return ""
685
+ status = str(grounding.get("status") or "")
686
+ if not status:
687
+ return ""
688
+ korean = not str(language or "ko").startswith("en")
689
+ cited = [
690
+ str(item.get("title") or "").strip()
691
+ for item in (grounding.get("cited") or [])
692
+ if isinstance(item, dict) and str(item.get("title") or "").strip()
693
+ ]
694
+ if status == "supported":
695
+ head = "✅ 근거 있음" if korean else "✅ grounded"
696
+ if cited:
697
+ head += " — " + ", ".join(cited[:3])
698
+ return head
699
+ if status in {"unsupported", "no_context"}:
700
+ head = "⚠️ 근거 없음" if korean else "⚠️ not grounded"
701
+ reason = str(grounding.get("reason") or "").strip()
702
+ return f"{head} — {reason}" if reason else head
703
+ return "❔ 근거 확인 불가" if korean else "❔ grounding unknown"
704
+
705
+
706
+ async def send_grounding_badge(client, chat_id, data):
707
+ text = format_grounding_badge(data)
708
+ if text:
709
+ await send_message(client, chat_id, text)
710
+
711
+
712
+ def format_proposals(payload) -> list:
713
+ """`GET /api/proposals` → ``[(item_id, label)]`` for the Review Center.
714
+
715
+ Rows without an id are dropped: an un-actionable row is worse than none.
716
+ """
717
+ root = payload if isinstance(payload, dict) else {}
718
+ rows = payload if isinstance(payload, list) else (root.get("items") or [])
719
+ out = []
720
+ for raw in rows if isinstance(rows, list) else []:
721
+ if not isinstance(raw, dict):
722
+ continue
723
+ item_id = str(raw.get("id") or "").strip()
724
+ if not item_id:
725
+ continue
726
+ body = raw.get("payload") if isinstance(raw.get("payload"), dict) else {}
727
+ provenance = raw.get("provenance") if isinstance(raw.get("provenance"), dict) else {}
728
+ path = str(body.get("path") or provenance.get("path") or "")
729
+ title = str(raw.get("title") or path or item_id)
730
+ change_class = str(body.get("change_class") or provenance.get("change_class") or "")
731
+ label = title if not change_class else f"{title} ({change_class})"
732
+ out.append((item_id, label))
733
+ return out
734
+
735
+
736
+ async def show_review_center(client, chat_id):
737
+ """Review Center parity (v9.9.7): staged change proposals with inline
738
+ approve/reject, using the same `/api/proposals` surface as the web app."""
739
+ try:
740
+ async with _server_client() as sc:
741
+ res = await sc.get(PROPOSALS_URL, timeout=30.0)
742
+ if res.status_code != 200:
743
+ await send_message(client, chat_id, f"검토함을 불러오지 못했습니다 ({res.status_code})")
744
+ return
745
+ items = format_proposals(res.json())
746
+ except Exception as exc:
747
+ await send_message(client, chat_id, f"검토함 조회 실패: {exc}")
748
+ return
749
+ if not items:
750
+ await send_message(client, chat_id, "🗂 검토할 변경 제안이 없습니다.")
751
+ return
752
+ lines = ["🗂 변경 제안 (승인해야 적용됩니다)"]
753
+ keyboard = []
754
+ for item_id, label in items[:8]:
755
+ lines.append(f"- {label}")
756
+ keyboard.append([
757
+ {"text": f"✅ 승인 — {label}"[:64], "callback_data": f"proposal:approve:{item_id}"[:64]},
758
+ {"text": "❌ 거절"[:64], "callback_data": f"proposal:reject:{item_id}"[:64]},
759
+ ])
760
+ if len(items) > 8:
761
+ lines.append(f"… 외 {len(items) - 8}건")
762
+ await send_message(
763
+ client, chat_id, "\n".join(lines),
764
+ reply_markup={"inline_keyboard": keyboard},
765
+ )
766
+
767
+
768
+ async def handle_proposal_callback(client, chat_id, data: str):
769
+ """Apply or reject one staged proposal.
770
+
771
+ A 409 means the target file changed since staging — nothing was written,
772
+ and the user is told exactly that instead of a silent retry.
773
+ """
774
+ try:
775
+ _, decision, item_id = data.split(":", 2)
776
+ except ValueError:
777
+ return
778
+ if not item_id:
779
+ return
780
+ suffix = "approve" if decision == "approve" else "reject"
781
+ url = f"{PROPOSALS_URL}/{item_id}/{suffix}"
782
+ try:
783
+ async with _server_client() as sc:
784
+ res = await sc.post(url, json={}, timeout=120.0)
785
+ except Exception as exc:
786
+ await send_message(client, chat_id, f"❌ 처리 실패: {exc}")
787
+ return
788
+ if res.status_code == 409:
789
+ await send_message(
790
+ client, chat_id,
791
+ "⚠️ 제안을 만든 뒤 파일이 바뀌어서 적용하지 않았습니다. 아무것도 쓰지 않았습니다.",
792
+ )
793
+ return
794
+ if res.status_code != 200:
795
+ await send_message(client, chat_id, f"❌ 서버 에러 ({res.status_code})")
796
+ return
797
+ if suffix == "approve":
798
+ try:
799
+ applied = res.json().get("path") or item_id
800
+ except Exception:
801
+ applied = item_id
802
+ await send_message(client, chat_id, f"✅ 적용했습니다: {applied}")
803
+ else:
804
+ await send_message(client, chat_id, "🚫 제안을 거절했습니다.")
805
+
806
+
807
+ def format_run_explanation(agent_data, *, language: str = "ko") -> str:
808
+ """Plain-language outcome line for a finished agent run (v9.9.6).
809
+
810
+ Telegram used to show only the final message, so a NEEDS_REVIEW run read
811
+ exactly like a success. The server already computes the honest sentence
812
+ (`explanation`); this renders it. Returns "" when there is nothing to add
813
+ — a clean, verified run gets no extra noise.
814
+ """
815
+ if not isinstance(agent_data, dict):
816
+ return ""
817
+ explanation = agent_data.get("explanation")
818
+ if not isinstance(explanation, dict):
819
+ return ""
820
+ lang = "ko" if str(language or "ko").startswith("ko") else "en"
821
+
822
+ def pick(entry):
823
+ return str(entry.get(lang) or "") if isinstance(entry, dict) else ""
824
+
825
+ details = [pick(item) for item in explanation.get("details") or []]
826
+ details = [line for line in details if line]
827
+ if explanation.get("ok") and not details:
828
+ return ""
829
+ headline = pick(explanation.get("headline"))
830
+ marker = "✅" if explanation.get("ok") else "⚠️"
831
+ lines = [f"{marker} {headline}" if headline else marker]
832
+ lines.extend(f"· {line}" for line in details[:4])
833
+ return "\n".join(lines)
834
+
835
+
836
+ async def send_run_explanation(client, chat_id, agent_data):
837
+ text = format_run_explanation(agent_data)
838
+ if text:
839
+ await send_message(client, chat_id, text)
840
+
841
+
668
842
  def collect_preview_urls(agent_data):
669
843
  urls, seen = [], set()
670
844
  for step in agent_data.get("steps", []):
@@ -819,6 +993,7 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
819
993
  ans = data_r.get("response", f"❌ 서버 에러 ({res.status_code})")
820
994
  await send_message(client, chat_id, str(ans))
821
995
  if isinstance(data_r, dict):
996
+ await send_run_explanation(client, chat_id, data_r)
822
997
  await send_generated_files(client, chat_id, collect_generated_files(data_r))
823
998
  await send_preview_links(client, chat_id, collect_preview_urls(data_r))
824
999
  except Exception as e:
@@ -862,7 +1037,12 @@ async def process_ai_request(client, chat_id, user_text, image_data=None):
862
1037
  if not ans or not str(ans).strip():
863
1038
  ans = "⚠️ AI가 답변을 생성하지 못했습니다."
864
1039
  await send_message(client, chat_id, str(ans))
1040
+ if isinstance(data, dict):
1041
+ # Recall parity (v9.9.7): the same grounding verdict the web app
1042
+ # badges, never promoted when the server issued none.
1043
+ await send_grounding_badge(client, chat_id, data)
865
1044
  if not image_data and isinstance(data, dict):
1045
+ await send_run_explanation(client, chat_id, data)
866
1046
  await send_generated_files(client, chat_id, collect_generated_files(data))
867
1047
  await send_preview_links(client, chat_id, collect_preview_urls(data))
868
1048
  except Exception as e:
@@ -887,6 +1067,7 @@ HELP_TEXT = """\
887
1067
  /clear [n] — 기록 정리 (마지막 n건 유지)
888
1068
  /web — 웹 UI 링크
889
1069
  /mcp — MCP 도구 목록
1070
+ /review — 변경 제안 검토함 (승인/거절)
890
1071
  /help — 이 도움말
891
1072
 
892
1073
  /agent <작업> — 멀티 LLM 에이전트 (계획 확인 후 실행)
@@ -925,6 +1106,8 @@ async def handle_command(client, chat_id, command: str, args: str):
925
1106
  await send_web_link(client, chat_id)
926
1107
  elif cmd == "mcp":
927
1108
  await send_mcp_tools(client, chat_id)
1109
+ elif cmd in {"review", "proposals"}:
1110
+ await show_review_center(client, chat_id)
928
1111
  elif cmd in {"help", "h"}:
929
1112
  await send_message(client, chat_id, HELP_TEXT)
930
1113
  elif cmd == "agent":
@@ -986,6 +1169,8 @@ async def handle_callback_query(client, callback_query):
986
1169
  await send_web_link(client, chat_id)
987
1170
  elif data == "cmd:mcp":
988
1171
  await send_mcp_tools(client, chat_id)
1172
+ elif data == "cmd:review":
1173
+ await show_review_center(client, chat_id)
989
1174
  elif data == "cmd:menu":
990
1175
  await show_menu(client, chat_id)
991
1176
  elif data.startswith("model:unload:"):
@@ -994,6 +1179,9 @@ async def handle_callback_query(client, callback_query):
994
1179
  elif data.startswith("plan:"):
995
1180
  task = asyncio.create_task(handle_plan_callback(client, chat_id, data))
996
1181
  task.add_done_callback(_log_task_exception)
1182
+ elif data.startswith("proposal:"):
1183
+ task = asyncio.create_task(handle_proposal_callback(client, chat_id, data))
1184
+ task.add_done_callback(_log_task_exception)
997
1185
 
998
1186
  # ── Main loop ─────────────────────────────────────────────────────────────────
999
1187
 
@@ -4,6 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any, Optional
6
6
 
7
+ from latticeai.runtime.permission_mode_wiring import (
8
+ bind_dispatch_permission_mode,
9
+ resolve_active_permission_mode,
10
+ )
7
11
  from latticeai.services.router_context import InteractionRouterContext, ToolRouterContext
8
12
 
9
13
 
@@ -19,6 +23,9 @@ def build_chat_agent_runtime_from_context(
19
23
  hooks: Any,
20
24
  brain_memory: Any,
21
25
  ) -> Any:
26
+ # Ensure dispatch + agent share the same autonomy dial before the runtime
27
+ # is constructed (process-wide service; data_dir refined later at router mount).
28
+ bind_dispatch_permission_mode()
22
29
  return build_agent_runtime(
23
30
  model_router=model_router,
24
31
  execute_tool=execute_tool,
@@ -28,6 +35,7 @@ def build_chat_agent_runtime_from_context(
28
35
  audit=audit,
29
36
  hooks=hooks,
30
37
  brain_memory=brain_memory,
38
+ permission_mode=resolve_active_permission_mode,
31
39
  )
32
40
 
33
41
 
@@ -13,6 +13,7 @@ def build_context_runtime(
13
13
  gardener: Any,
14
14
  require_auth: bool,
15
15
  allowed_scopes_for_user: Callable[[Any], Any],
16
+ artifact_ledger: Any = None,
16
17
  ) -> Dict[str, Any]:
17
18
  """Construct search, brain memory, and context assembly services."""
18
19
 
@@ -41,15 +42,25 @@ def build_context_runtime(
41
42
  allowed = allowed_scopes_for_user(user_email)
42
43
  return gardener.get_relevant_context(q, allowed_workspaces=allowed, **kw)
43
44
 
45
+ # Re-search loop (v9.9.6): files this conversation just produced are a
46
+ # deterministic context section, so "그 파일에 다크모드 넣어줘" works before
47
+ # asynchronous indexing catches up. Optional — absent ledger, absent section.
48
+ if artifact_ledger is None:
49
+ from latticeai.core.artifact_ledger import ArtifactLedger
50
+
51
+ artifact_ledger = ArtifactLedger()
52
+
44
53
  context_assembler = ContextAssembler(
45
54
  memory_recall=memory_service.recall,
46
55
  hybrid_search=scoped_hybrid_search,
47
56
  notes_context=scoped_notes_context,
57
+ recent_artifacts=artifact_ledger.recent,
48
58
  )
49
59
 
50
60
  return {
51
61
  "SEARCH_SERVICE": search_service,
52
62
  "BRAIN_MEMORY": brain_memory,
53
63
  "CONTEXT_ASSEMBLER": context_assembler,
64
+ "ARTIFACT_LEDGER": artifact_ledger,
54
65
  "_scoped_hybrid_search": scoped_hybrid_search,
55
66
  }
@@ -0,0 +1,116 @@
1
+ """Wire PermissionMode into the running app without a large app_factory rewrite.
2
+
3
+ Creates one process-wide :class:`PermissionModeService`, points
4
+ ``ToolDispatchService.permission_mode`` at it, and registers the HTTP dial.
5
+ Called from ``register_review_and_brain_tail_routers`` (which already has
6
+ ``data_dir``, ``require_user``, ``append_audit_event``) and from
7
+ ``build_chat_agent_runtime_from_context`` so the agent loop sees the same dial.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import threading
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Optional
16
+
17
+ from latticeai.core.permission_mode import DEFAULT_MODE, normalize_mode
18
+ from latticeai.services.permission_mode_service import PermissionModeService
19
+
20
+ _LOCK = threading.Lock()
21
+ _SHARED: Optional[PermissionModeService] = None
22
+
23
+
24
+ def _default_data_dir() -> Path:
25
+ raw = os.environ.get("LATTICEAI_DATA_DIR", "").strip()
26
+ if raw:
27
+ return Path(raw)
28
+ return Path.home() / ".ltcai"
29
+
30
+
31
+ def _env_default_mode() -> Any:
32
+ return normalize_mode(os.environ.get("LATTICEAI_PERMISSION_MODE", DEFAULT_MODE.value))
33
+
34
+
35
+ def get_permission_mode_service(
36
+ *,
37
+ data_dir: Optional[Path] = None,
38
+ audit: Optional[Callable[..., None]] = None,
39
+ ) -> PermissionModeService:
40
+ """Process-wide singleton.
41
+
42
+ An early lazy caller (a tool dispatch before routers are mounted) would
43
+ otherwise pin the service to the fallback ``data_dir`` with no audit sink.
44
+ Explicit arguments therefore *rebind* an already-created service instead of
45
+ being silently dropped, so the app's real data dir and audit log always win.
46
+ """
47
+ global _SHARED
48
+ with _LOCK:
49
+ if _SHARED is None:
50
+ _SHARED = PermissionModeService(
51
+ data_dir=Path(data_dir) if data_dir is not None else _default_data_dir(),
52
+ default_mode=_env_default_mode(),
53
+ audit=audit,
54
+ )
55
+ return _SHARED
56
+ if data_dir is not None:
57
+ _SHARED.rebind_data_dir(Path(data_dir))
58
+ if audit is not None:
59
+ _SHARED.rebind_audit(audit)
60
+ return _SHARED
61
+
62
+
63
+ def resolve_active_permission_mode(
64
+ *,
65
+ user_email: Optional[str] = None,
66
+ workspace_id: Optional[str] = None,
67
+ ) -> Any:
68
+ return get_permission_mode_service().resolve(
69
+ user_email=user_email, workspace_id=workspace_id,
70
+ )
71
+
72
+
73
+ def bind_dispatch_permission_mode(dispatch_service: Any = None) -> None:
74
+ """Point tool dispatch at the shared mode resolver.
75
+
76
+ The bound callable accepts scope kwargs so per-user and per-workspace
77
+ overrides actually reach ``enforce_policy`` — an unscoped resolver would
78
+ always return the process-wide default and make the dial a no-op.
79
+ """
80
+ from latticeai.services.tool_dispatch import DEFAULT_TOOL_DISPATCH_SERVICE
81
+
82
+ service = dispatch_service or DEFAULT_TOOL_DISPATCH_SERVICE
83
+ service.permission_mode = resolve_active_permission_mode
84
+
85
+
86
+ def register_permission_mode_router(
87
+ app: Any,
88
+ *,
89
+ require_user: Callable[..., str],
90
+ data_dir: Optional[Path] = None,
91
+ append_audit_event: Optional[Callable[..., None]] = None,
92
+ ) -> Any:
93
+ """Install GET/POST /api/permission-mode on ``app``. Idempotent by route path."""
94
+ from latticeai.api.permission_mode import create_permission_mode_router
95
+
96
+ svc = get_permission_mode_service(data_dir=data_dir, audit=append_audit_event)
97
+ bind_dispatch_permission_mode()
98
+ # Skip if already mounted (re-entrant factory / tests).
99
+ existing = {
100
+ getattr(route, "path", None)
101
+ for route in getattr(app, "routes", ())
102
+ }
103
+ if "/api/permission-mode" in existing:
104
+ return svc
105
+ app.include_router(
106
+ create_permission_mode_router(service=svc, require_user=require_user)
107
+ )
108
+ return svc
109
+
110
+
111
+ __all__ = [
112
+ "get_permission_mode_service",
113
+ "resolve_active_permission_mode",
114
+ "bind_dispatch_permission_mode",
115
+ "register_permission_mode_router",
116
+ ]
@@ -653,4 +653,13 @@ def register_review_and_brain_tail_routers(
653
653
  workspace_service=workspace_service,
654
654
  ),
655
655
  )
656
+ # Permission mode dial (v9.9.8): mount last so data_dir + audit are known.
657
+ from latticeai.runtime.permission_mode_wiring import register_permission_mode_router
658
+
659
+ register_permission_mode_router(
660
+ app,
661
+ require_user=require_user,
662
+ data_dir=data_dir,
663
+ append_audit_event=append_audit_event,
664
+ )
656
665
  return brain_network
@@ -33,6 +33,9 @@ class AppContext:
33
33
  local_kg_watcher: Any = None
34
34
  chat_service: Any = None
35
35
  context_assembler: Any = None
36
+ # Re-search loop (v9.9.6): conversation-scoped ledger of just-written
37
+ # artifacts, so a follow-up turn sees them before indexing catches up.
38
+ artifact_ledger: Any = None
36
39
  brain_memory: Any = None
37
40
  # Unified ingestion gateway (lattice_brain.ingestion.IngestionPipeline);
38
41
  # None when the knowledge graph is disabled.
@@ -17,7 +17,7 @@ from typing import Any, Dict, List
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
19
 
20
- ARCHITECTURE_VERSION_TARGET = "9.9.5"
20
+ ARCHITECTURE_VERSION_TARGET = "10.0.0"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -391,6 +391,111 @@ class BrainIntelligenceService:
391
391
  "generated_at": _now(),
392
392
  }
393
393
 
394
+ def garden_overview(
395
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None,
396
+ limit: int = 8,
397
+ ) -> Dict[str, Any]:
398
+ """The knowledge garden in four beds (v9.9.7).
399
+
400
+ Living Brain answers "how healthy is my knowledge?" in aggregate.
401
+ A gardener asks four concrete questions instead, and this answers all
402
+ four from one workspace-scoped graph sample plus the memory tier:
403
+
404
+ * **recent** — what came in lately (the garden's new growth);
405
+ * **contradictions** — what disagrees with itself (needs weeding);
406
+ * **stale** — what has not been touched in a long time;
407
+ * **frequent** — what the rest of the graph leans on most (by degree).
408
+
409
+ Read-only and honest: when the graph is unavailable every bed is empty
410
+ and ``available`` is false — the view never invents plants.
411
+ """
412
+ # Explicit 0 clamps to 1 — `limit or 8` would silently re-expand it.
413
+ try:
414
+ limit = max(1, min(int(limit), 50))
415
+ except (TypeError, ValueError):
416
+ limit = 8
417
+ sample = self._graph_sample(workspace_id=workspace_id)
418
+ nodes, edges = sample["nodes"], sample["edges"]
419
+ now = datetime.now(timezone.utc)
420
+ recent_cutoff = now - timedelta(days=_RECENT_DAYS)
421
+ stale_cutoff = now - timedelta(days=_STALE_DAYS)
422
+
423
+ def _slim(node: Dict[str, Any], **extra: Any) -> Dict[str, Any]:
424
+ return {
425
+ "id": node.get("id"),
426
+ "type": node.get("type"),
427
+ "title": str(node.get("title") or "")[:120],
428
+ "updated_at": node.get("updated_at"),
429
+ **extra,
430
+ }
431
+
432
+ recent: List[Dict[str, Any]] = []
433
+ stale: List[Dict[str, Any]] = []
434
+ for node in nodes:
435
+ # Chunks are retrieval plumbing, not knowledge a gardener tends.
436
+ if str(node.get("type") or "") == "Chunk":
437
+ continue
438
+ ts = _parse_ts(node.get("updated_at"))
439
+ if ts is None:
440
+ continue
441
+ if ts >= recent_cutoff:
442
+ recent.append(node)
443
+ elif ts < stale_cutoff:
444
+ stale.append(node)
445
+ recent.sort(key=lambda n: str(n.get("updated_at") or ""), reverse=True)
446
+ stale.sort(key=lambda n: str(n.get("updated_at") or ""))
447
+
448
+ # "Frequent" is degree, not a guess: how many relations actually point
449
+ # at a node. Chunks are retrieval plumbing, never garden plants.
450
+ degree: Dict[str, int] = {}
451
+ for edge in edges:
452
+ for key in ("source", "target"):
453
+ node_id = str(edge.get(key) or "")
454
+ if node_id:
455
+ degree[node_id] = degree.get(node_id, 0) + 1
456
+ by_id = {str(node.get("id")): node for node in nodes}
457
+ frequent = [
458
+ _slim(by_id[node_id], degree=count)
459
+ for node_id, count in sorted(degree.items(), key=lambda kv: kv[1], reverse=True)
460
+ if node_id in by_id and str(by_id[node_id].get("type") or "") != "Chunk"
461
+ ][:limit]
462
+
463
+ contradiction_items: List[Dict[str, Any]] = []
464
+ contradiction_count = 0
465
+ try:
466
+ found = self.contradictions(user_email=user_email, workspace_id=workspace_id)
467
+ items = found.get("items") if isinstance(found, dict) else None
468
+ if isinstance(items, list):
469
+ contradiction_count = len(items)
470
+ contradiction_items = items[:limit]
471
+ except Exception: # noqa: BLE001 — one empty bed, never a broken view
472
+ LOGGER.exception("garden overview contradictions failed")
473
+
474
+ return {
475
+ "available": sample["available"],
476
+ "window_days": _RECENT_DAYS,
477
+ "stale_threshold_days": _STALE_DAYS,
478
+ "beds": {
479
+ "recent": {
480
+ "count": len(recent),
481
+ "items": [_slim(node) for node in recent[:limit]],
482
+ },
483
+ "contradictions": {
484
+ "count": contradiction_count,
485
+ "items": contradiction_items,
486
+ },
487
+ "stale": {
488
+ "count": len(stale),
489
+ "items": [_slim(node) for node in stale[:limit]],
490
+ },
491
+ "frequent": {
492
+ "count": len(frequent),
493
+ "items": frequent,
494
+ },
495
+ },
496
+ "generated_at": _now(),
497
+ }
498
+
394
499
  # ── graph-layer proactive quality (v9.6.x) ───────────────────────────
395
500
 
396
501
  def graph_duplicates(