ltcai 8.7.0 → 8.9.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 (103) hide show
  1. package/README.md +38 -26
  2. package/auto_setup.py +73 -8
  3. package/docs/CHANGELOG.md +63 -0
  4. package/docs/CODE_REVIEW_2026-07-06.md +764 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +4 -3
  6. package/docs/DEVELOPMENT.md +10 -10
  7. package/docs/LEGACY_COMPATIBILITY.md +33 -14
  8. package/docs/ONBOARDING.md +2 -2
  9. package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
  10. package/docs/TRUST_MODEL.md +5 -1
  11. package/docs/WHY_LATTICE.md +4 -3
  12. package/docs/architecture.md +4 -0
  13. package/docs/kg-schema.md +1 -1
  14. package/kg_schema.py +1 -1
  15. package/knowledge_graph.py +2 -2
  16. package/lattice_brain/__init__.py +1 -1
  17. package/lattice_brain/conversations.py +156 -21
  18. package/lattice_brain/core.py +1 -1
  19. package/lattice_brain/graph/_kg_common.py +12 -34
  20. package/lattice_brain/graph/json_utils.py +25 -0
  21. package/lattice_brain/graph/retrieval.py +66 -27
  22. package/lattice_brain/graph/runtime.py +16 -0
  23. package/lattice_brain/ingestion.py +35 -20
  24. package/lattice_brain/runtime/agent_runtime.py +37 -3
  25. package/lattice_brain/runtime/multi_agent.py +1 -1
  26. package/latticeai/__init__.py +1 -1
  27. package/latticeai/api/chat.py +31 -14
  28. package/latticeai/api/mcp.py +3 -2
  29. package/latticeai/api/models.py +4 -1
  30. package/latticeai/api/permissions.py +69 -30
  31. package/latticeai/api/setup.py +17 -2
  32. package/latticeai/api/tools.py +104 -62
  33. package/latticeai/app_factory.py +93 -10
  34. package/latticeai/core/agent.py +25 -7
  35. package/latticeai/core/legacy_compatibility.py +36 -25
  36. package/latticeai/core/marketplace.py +1 -1
  37. package/latticeai/core/sessions.py +11 -3
  38. package/latticeai/core/tool_registry.py +15 -4
  39. package/latticeai/core/workspace_os.py +1 -1
  40. package/latticeai/runtime/bootstrap.py +1 -1
  41. package/latticeai/runtime/persistence_runtime.py +1 -1
  42. package/latticeai/runtime/platform_services_runtime.py +1 -1
  43. package/latticeai/services/app_context.py +1 -0
  44. package/latticeai/services/architecture_readiness.py +2 -2
  45. package/latticeai/services/memory_service.py +38 -7
  46. package/latticeai/services/model_engines.py +79 -12
  47. package/latticeai/services/model_runtime.py +24 -4
  48. package/latticeai/services/process_audit.py +208 -0
  49. package/latticeai/services/product_readiness.py +11 -11
  50. package/latticeai/services/search_service.py +106 -30
  51. package/latticeai/services/tool_dispatch.py +66 -0
  52. package/latticeai/services/workspace_service.py +15 -0
  53. package/package.json +1 -1
  54. package/scripts/check_i18n_literals.mjs +20 -8
  55. package/scripts/i18n_literal_allowlist.json +34 -0
  56. package/scripts/lint_frontend.mjs +6 -2
  57. package/setup_wizard.py +185 -19
  58. package/src-tauri/Cargo.lock +1 -1
  59. package/src-tauri/Cargo.toml +1 -1
  60. package/src-tauri/tauri.conf.json +1 -1
  61. package/static/app/asset-manifest.json +11 -11
  62. package/static/app/assets/{Act-_U7mhXir.js → Act-fZokUnC0.js} +1 -1
  63. package/static/app/assets/{Brain-BxyTHZ21.js → Brain-DtyuWubr.js} +2 -2
  64. package/static/app/assets/{Capture-DyDKWNh9.js → Capture-D5KV3Cu7.js} +1 -1
  65. package/static/app/assets/{Library-DJ8KioFM.js → Library-C9kyFkSt.js} +1 -1
  66. package/static/app/assets/{System-C0FIb3OO.js → System-VbChmX7r.js} +1 -1
  67. package/static/app/assets/index-DCh5AoXt.css +2 -0
  68. package/static/app/assets/index-DPdcPoF0.js +17 -0
  69. package/static/app/assets/{primitives-BywkNS3f.js → primitives-DFeanEV6.js} +1 -1
  70. package/static/app/assets/{textarea-CQ61Rycp.js → textarea-CD8UNKIy.js} +1 -1
  71. package/static/app/index.html +2 -2
  72. package/static/sw.js +1 -1
  73. package/lattice_brain/_kg_common.py +0 -11
  74. package/lattice_brain/discovery.py +0 -11
  75. package/lattice_brain/documents.py +0 -11
  76. package/lattice_brain/identity.py +0 -11
  77. package/lattice_brain/ingest.py +0 -11
  78. package/lattice_brain/network.py +0 -11
  79. package/lattice_brain/projection.py +0 -11
  80. package/lattice_brain/provenance.py +0 -11
  81. package/lattice_brain/retrieval.py +0 -11
  82. package/lattice_brain/schema.py +0 -11
  83. package/lattice_brain/store.py +0 -11
  84. package/lattice_brain/write_master.py +0 -11
  85. package/latticeai/brain/__init__.py +0 -36
  86. package/latticeai/brain/_kg_common.py +0 -17
  87. package/latticeai/brain/context.py +0 -17
  88. package/latticeai/brain/conversations.py +0 -17
  89. package/latticeai/brain/discovery.py +0 -17
  90. package/latticeai/brain/documents.py +0 -17
  91. package/latticeai/brain/identity.py +0 -17
  92. package/latticeai/brain/ingest.py +0 -17
  93. package/latticeai/brain/memory.py +0 -17
  94. package/latticeai/brain/network.py +0 -17
  95. package/latticeai/brain/projection.py +0 -17
  96. package/latticeai/brain/provenance.py +0 -17
  97. package/latticeai/brain/retrieval.py +0 -17
  98. package/latticeai/brain/schema.py +0 -17
  99. package/latticeai/brain/store.py +0 -17
  100. package/latticeai/brain/write_master.py +0 -17
  101. package/latticeai/services/agent_runtime.py +0 -11
  102. package/static/app/assets/index-Bh7IIlyY.js +0 -16
  103. package/static/app/assets/index-_M5aCv21.css +0 -2
@@ -15,6 +15,7 @@ lazily via module ``__getattr__`` for backwards compatibility.
15
15
  from __future__ import annotations
16
16
 
17
17
  import threading
18
+ from dataclasses import dataclass
18
19
  from typing import TYPE_CHECKING, Any, Dict, List, Optional
19
20
 
20
21
  from latticeai.runtime.app_context_runtime import build_app_context
@@ -94,7 +95,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
94
95
  from pydantic import BaseModel
95
96
 
96
97
  from latticeai.models.router import LLMRouter, normalize_branding
97
- from lattice_brain._kg_common import set_llm_router
98
+ from lattice_brain.graph.runtime import set_llm_router
98
99
  from lattice_brain.graph.schema import set_embed_dim
99
100
  from latticeai.core.security import (
100
101
  hash_password,
@@ -234,6 +235,8 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
234
235
  ENABLE_GRAPH = _config_runtime["ENABLE_GRAPH"]
235
236
  AUTOLOAD_MODELS = _config_runtime["AUTOLOAD_MODELS"]
236
237
  MODEL_IDLE_UNLOAD_SECONDS = _config_runtime["MODEL_IDLE_UNLOAD_SECONDS"]
238
+ ALLOW_MODEL_DOWNLOADS = _config_runtime["ALLOW_MODEL_DOWNLOADS"]
239
+ MODEL_DOWNLOAD_TIMEOUT = _config_runtime["MODEL_DOWNLOAD_TIMEOUT"]
237
240
  ALLOW_LOCAL_MODELS = _config_runtime["ALLOW_LOCAL_MODELS"]
238
241
  REQUIRE_AUTH = _config_runtime["REQUIRE_AUTH"]
239
242
  ALLOW_PLAINTEXT_API_KEYS = _config_runtime["ALLOW_PLAINTEXT_API_KEYS"]
@@ -636,9 +639,33 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
636
639
  get_audit_log = _audit_rt["get_audit_log"]
637
640
  append_audit_event = _audit_rt["append_audit_event"]
638
641
 
639
- def get_history():
642
+ def _history_allowed_workspaces_for(user_email: Optional[str]):
643
+ if not REQUIRE_AUTH or not user_email:
644
+ return None
645
+ try:
646
+ return set(WORKSPACE_SERVICE.readable_workspaces(user_email))
647
+ except Exception as exc:
648
+ logging.warning("history workspace scope resolution failed for %s: %s", user_email, exc)
649
+ return set()
650
+
651
+ def _history_include_legacy_global(user_email: Optional[str]) -> bool:
652
+ return not REQUIRE_AUTH or not user_email
653
+
654
+ def get_history(
655
+ user_email: Optional[str] = None,
656
+ allowed_workspaces=None,
657
+ include_legacy_global: Optional[bool] = None,
658
+ ):
640
659
  try:
641
- return CONVERSATIONS.history()
660
+ if allowed_workspaces is None and user_email:
661
+ allowed_workspaces = _history_allowed_workspaces_for(user_email)
662
+ if include_legacy_global is None:
663
+ include_legacy_global = _history_include_legacy_global(user_email)
664
+ return CONVERSATIONS.history(
665
+ user_email=user_email,
666
+ allowed_workspaces=allowed_workspaces,
667
+ include_legacy_global=include_legacy_global,
668
+ )
642
669
  except Exception as e:
643
670
  logging.warning("get_history failed: %s", e)
644
671
  return []
@@ -683,17 +710,59 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
683
710
 
684
711
  return sorted((conversations[key] for key in order), key=lambda item: item.get("updated_at") or "", reverse=True)
685
712
 
686
- def get_conversation_messages(conversation_id: str) -> List[Dict]:
687
- history = get_history()
713
+ def get_conversation_messages(
714
+ conversation_id: str,
715
+ *,
716
+ user_email: Optional[str] = None,
717
+ allowed_workspaces=None,
718
+ include_legacy_global: Optional[bool] = None,
719
+ ) -> List[Dict]:
720
+ history = get_history(
721
+ user_email=user_email,
722
+ allowed_workspaces=allowed_workspaces,
723
+ include_legacy_global=include_legacy_global,
724
+ )
688
725
  if conversation_id == "legacy-previous-history":
689
726
  return [item for item in history if not item.get("conversation_id")]
690
727
  return [item for item in history if item.get("conversation_id") == conversation_id]
691
728
 
692
- def clear_history(keep_last: int = 0) -> Dict:
693
- return CONVERSATIONS.clear_all(keep_last=keep_last)
729
+ def clear_history(
730
+ keep_last: int = 0,
731
+ *,
732
+ user_email: Optional[str] = None,
733
+ allowed_workspaces=None,
734
+ include_legacy_global: Optional[bool] = None,
735
+ ) -> Dict:
736
+ if allowed_workspaces is None and user_email:
737
+ allowed_workspaces = _history_allowed_workspaces_for(user_email)
738
+ if include_legacy_global is None:
739
+ include_legacy_global = _history_include_legacy_global(user_email)
740
+ return CONVERSATIONS.clear_all(
741
+ keep_last=keep_last,
742
+ user_email=user_email,
743
+ allowed_workspaces=allowed_workspaces,
744
+ include_legacy_global=include_legacy_global,
745
+ )
694
746
 
695
- def clear_conversation(conversation_id: str, started_at: Optional[str] = None) -> Dict:
696
- return CONVERSATIONS.clear_conversation(conversation_id, started_at=started_at)
747
+ def clear_conversation(
748
+ conversation_id: str,
749
+ started_at: Optional[str] = None,
750
+ *,
751
+ user_email: Optional[str] = None,
752
+ allowed_workspaces=None,
753
+ include_legacy_global: Optional[bool] = None,
754
+ ) -> Dict:
755
+ if allowed_workspaces is None and user_email:
756
+ allowed_workspaces = _history_allowed_workspaces_for(user_email)
757
+ if include_legacy_global is None:
758
+ include_legacy_global = _history_include_legacy_global(user_email)
759
+ return CONVERSATIONS.clear_conversation(
760
+ conversation_id,
761
+ started_at=started_at,
762
+ user_email=user_email,
763
+ allowed_workspaces=allowed_workspaces,
764
+ include_legacy_global=include_legacy_global,
765
+ )
697
766
 
698
767
  _access_runtime = build_access_runtime(
699
768
  config=CONFIG,
@@ -942,6 +1011,8 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
942
1011
  ENABLE_GRAPH=ENABLE_GRAPH,
943
1012
  AUTOLOAD_MODELS=AUTOLOAD_MODELS,
944
1013
  MODEL_IDLE_UNLOAD_SECONDS=MODEL_IDLE_UNLOAD_SECONDS,
1014
+ ALLOW_MODEL_DOWNLOADS=ALLOW_MODEL_DOWNLOADS,
1015
+ MODEL_DOWNLOAD_TIMEOUT=MODEL_DOWNLOAD_TIMEOUT,
945
1016
  ALLOW_LOCAL_MODELS=ALLOW_LOCAL_MODELS,
946
1017
  REQUIRE_AUTH=REQUIRE_AUTH,
947
1018
  INVITE_GATE_ENABLED=INVITE_GATE_ENABLED,
@@ -1136,6 +1207,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1136
1207
  load_users=load_users,
1137
1208
  get_user_role=get_user_role,
1138
1209
  enforce_rate_limit=enforce_rate_limit,
1210
+ allowed_workspaces_for=_history_allowed_workspaces_for,
1139
1211
  append_audit_event=append_audit_event,
1140
1212
  get_audit_log=get_audit_log,
1141
1213
  get_history=get_history,
@@ -1383,6 +1455,16 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1383
1455
  return dict(locals())
1384
1456
 
1385
1457
 
1458
+ @dataclass(frozen=True)
1459
+ class LegacyRuntimeNamespace:
1460
+ """Compatibility adapter for the historical module-level runtime surface."""
1461
+
1462
+ namespace: Dict[str, Any]
1463
+
1464
+ def bind(self, runtime: "AppRuntime") -> None:
1465
+ runtime.__dict__.update(self.namespace)
1466
+
1467
+
1386
1468
  class AppRuntime:
1387
1469
  """The constructed application namespace.
1388
1470
 
@@ -1391,7 +1473,8 @@ class AppRuntime:
1391
1473
  """
1392
1474
 
1393
1475
  def __init__(self, namespace: Dict[str, Any]) -> None:
1394
- self.__dict__.update(namespace)
1476
+ self._legacy_namespace = LegacyRuntimeNamespace(namespace)
1477
+ self._legacy_namespace.bind(self)
1395
1478
 
1396
1479
 
1397
1480
  _runtime_lock = threading.RLock()
@@ -52,7 +52,7 @@ class AgentRunContext:
52
52
  """Mutable state carrier passed through all agent phases."""
53
53
  __slots__ = ("state", "plan", "transcript", "retry_count",
54
54
  "state_history", "corrections", "final_message", "rollback_log",
55
- "executing_model", "reviewing_model")
55
+ "executing_model", "reviewing_model", "approved_by_human")
56
56
 
57
57
  def __init__(self) -> None:
58
58
  self.state: AgentState = AgentState.IDLE
@@ -65,6 +65,7 @@ class AgentRunContext:
65
65
  self.rollback_log: list = []
66
66
  self.executing_model: Optional[str] = None
67
67
  self.reviewing_model: Optional[str] = None
68
+ self.approved_by_human: bool = False
68
69
 
69
70
 
70
71
  def extract_action(raw: str) -> Dict:
@@ -207,7 +208,7 @@ class SingleAgentRuntime:
207
208
  ctx.state = AgentState.WAITING_APPROVAL
208
209
 
209
210
  # ── APPROVAL ─────────────────────────────────────────────────────
210
- def approve(self, ctx: AgentRunContext, current_user: str) -> None:
211
+ def approve(self, ctx: AgentRunContext, current_user: str, *, approved_by_human: bool = False) -> None:
211
212
  """APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
212
213
  d = self.deps
213
214
  auto_approve_tools = {name for name, p in d.tool_governance.items() if p["auto_approve"]}
@@ -219,12 +220,22 @@ class SingleAgentRuntime:
219
220
  "state": AgentState.WAITING_APPROVAL.value,
220
221
  "requires_approval": requires,
221
222
  "non_auto_approve_steps": non_auto,
222
- "decision": "auto_approved",
223
+ "decision": "human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved"),
223
224
  })
224
225
  d.audit(
225
226
  "agent_approval", user_email=current_user,
226
- requires_approval=requires, non_auto_steps=non_auto, decision="auto_approved",
227
+ requires_approval=requires,
228
+ non_auto_steps=non_auto,
229
+ decision="human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved"),
227
230
  )
231
+ if requires and not approved_by_human:
232
+ ctx.final_message = (
233
+ "이 작업에는 명시 승인이 필요한 도구가 포함되어 있어 자동 실행을 중단했습니다. "
234
+ "human_in_loop 승인 흐름으로 다시 실행해 주세요."
235
+ )
236
+ ctx.state = AgentState.FAILED
237
+ return
238
+ ctx.approved_by_human = bool(approved_by_human)
228
239
  ctx.state = AgentState.EXECUTING
229
240
 
230
241
  # ── EXECUTE ──────────────────────────────────────────────────────
@@ -317,7 +328,7 @@ class SingleAgentRuntime:
317
328
  )
318
329
  continue
319
330
 
320
- if not policy["auto_approve"]:
331
+ if not policy["auto_approve"] and not ctx.approved_by_human:
321
332
  d.audit(
322
333
  "agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
323
334
  state=AgentState.EXECUTING.value, action=name, risk=risk,
@@ -326,6 +337,13 @@ class SingleAgentRuntime:
326
337
  rollback=policy["rollback"],
327
338
  args={k: v for k, v in args.items() if k != "content"},
328
339
  )
340
+ ctx.transcript.append({
341
+ "state": AgentState.EXECUTING.value, "action": name,
342
+ "thoughts": thoughts, "args": args, "risk": risk,
343
+ "governance": dict(policy),
344
+ "error": f"BLOCKED: action '{name}' requires explicit approval.",
345
+ })
346
+ continue
329
347
 
330
348
  try:
331
349
  d.check_role(name, current_user)
@@ -422,8 +440,8 @@ class SingleAgentRuntime:
422
440
  if gov.get("rollback") != "git":
423
441
  continue
424
442
  result = step.get("result", {})
425
- if not (isinstance(result, dict) and result.get("success")):
426
- continue
443
+ if not isinstance(result, dict):
444
+ result = {}
427
445
  path = result.get("path") or (step.get("args") or {}).get("path", "")
428
446
  if not path:
429
447
  continue
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
16
 
17
- LEGACY_COMPATIBILITY_VERSION = "8.5.0"
17
+ LEGACY_COMPATIBILITY_VERSION = "8.9.0"
18
18
 
19
19
 
20
20
  @dataclass(frozen=True)
@@ -43,7 +43,7 @@ LEGACY_SHIMS: List[LegacyShim] = [
43
43
  LegacyShim(
44
44
  path="knowledge_graph.py",
45
45
  owner="lattice_brain.graph",
46
- replacement="from lattice_brain.store import KnowledgeGraphStore",
46
+ replacement="from lattice_brain.graph.store import KnowledgeGraphStore",
47
47
  reason="Historical scripts imported the graph store from the repo root.",
48
48
  removal_phase="major-release-after-8.x",
49
49
  ),
@@ -57,7 +57,7 @@ LEGACY_SHIMS: List[LegacyShim] = [
57
57
  LegacyShim(
58
58
  path="kg_schema.py",
59
59
  owner="lattice_brain.graph.schema",
60
- replacement="from lattice_brain.schema import ...",
60
+ replacement="from lattice_brain.graph.schema import ...",
61
61
  reason="Historical graph schema tests and tools referenced root schema symbols.",
62
62
  removal_phase="major-release-after-8.x",
63
63
  ),
@@ -96,53 +96,59 @@ LEGACY_SHIMS: List[LegacyShim] = [
96
96
  reason="Local folder ingestion integrations used the root local knowledge API.",
97
97
  removal_phase="requires-api-route-migration",
98
98
  ),
99
+ ]
100
+
101
+ # Shim layers that have completed their compatibility window and were
102
+ # physically deleted. Kept in the report so consumers (docs, tests, release
103
+ # notes) can tell "removed on purpose" apart from "missing by accident".
104
+ # Removing the internal-only layers is the first step of extracting
105
+ # ``lattice_brain`` as a standalone Brain Core package: the package now has
106
+ # exactly one import surface (``lattice_brain.*`` physical paths).
107
+ REMOVED_SHIMS: List[LegacyShim] = [
99
108
  LegacyShim(
100
109
  path="lattice_brain/store.py",
101
110
  owner="lattice_brain.graph.store",
102
111
  replacement="from lattice_brain.graph.store import KnowledgeGraphStore",
103
- reason="Pre-graph-package imports used the flat Brain store module.",
104
- removal_phase="major-release-after-8.x",
112
+ reason="Pre-graph-package flat modules were internal-only; removed in 8.8.0.",
113
+ removal_phase="removed-8.8.0",
105
114
  layer="brain-flat",
115
+ status="removed",
106
116
  ),
107
117
  LegacyShim(
108
118
  path="lattice_brain/ingest.py",
109
119
  owner="lattice_brain.graph.ingest",
110
120
  replacement="from lattice_brain.graph.ingest import KnowledgeGraphIngestMixin",
111
- reason="Pre-graph-package imports used the flat Brain ingest module.",
112
- removal_phase="major-release-after-8.x",
121
+ reason="Pre-graph-package flat modules were internal-only; removed in 8.8.0.",
122
+ removal_phase="removed-8.8.0",
113
123
  layer="brain-flat",
124
+ status="removed",
114
125
  ),
115
126
  LegacyShim(
116
127
  path="lattice_brain/retrieval.py",
117
128
  owner="lattice_brain.graph.retrieval",
118
129
  replacement="from lattice_brain.graph.retrieval import KnowledgeGraphRetrievalMixin",
119
- reason="Pre-graph-package imports used the flat Brain retrieval module.",
120
- removal_phase="major-release-after-8.x",
130
+ reason="Pre-graph-package flat modules were internal-only; removed in 8.8.0.",
131
+ removal_phase="removed-8.8.0",
121
132
  layer="brain-flat",
133
+ status="removed",
122
134
  ),
123
135
  LegacyShim(
124
- path="latticeai/brain/store.py",
125
- owner="lattice_brain.graph.store",
126
- replacement="from lattice_brain.graph.store import KnowledgeGraphStore",
127
- reason="The deprecated latticeai.brain namespace remains for package users.",
128
- removal_phase="major-release-after-8.x",
129
- layer="deprecated-namespace",
130
- ),
131
- LegacyShim(
132
- path="latticeai/brain/ingest.py",
133
- owner="lattice_brain.graph.ingest",
134
- replacement="from lattice_brain.graph.ingest import KnowledgeGraphIngestMixin",
135
- reason="The deprecated latticeai.brain namespace remains for package users.",
136
- removal_phase="major-release-after-8.x",
136
+ path="latticeai/brain/",
137
+ owner="lattice_brain",
138
+ replacement="import lattice_brain",
139
+ reason="The deprecated latticeai.brain namespace completed its window; removed in 8.8.0.",
140
+ removal_phase="removed-8.8.0",
137
141
  layer="deprecated-namespace",
142
+ status="removed",
138
143
  ),
139
144
  LegacyShim(
140
145
  path="latticeai/services/agent_runtime.py",
141
146
  owner="lattice_brain.runtime.agent_runtime",
142
147
  replacement="from lattice_brain.runtime.agent_runtime import AgentRuntime",
143
- reason="Service-layer imports existed before AgentRuntime moved into Brain runtime.",
144
- removal_phase="major-release-after-8.x",
148
+ reason="The service-layer alias completed its window; removed in 8.8.0.",
149
+ removal_phase="removed-8.8.0",
145
150
  layer="service-alias",
151
+ status="removed",
146
152
  ),
147
153
  ]
148
154
 
@@ -152,23 +158,28 @@ def legacy_shim_report(root: Path | None = None) -> Dict[str, Any]:
152
158
  root = Path(__file__).resolve().parents[2]
153
159
  entries = [shim.as_dict() for shim in LEGACY_SHIMS]
154
160
  missing = [shim.path for shim in LEGACY_SHIMS if not (root / shim.path).exists()]
161
+ lingering = [shim.path for shim in REMOVED_SHIMS if (root / shim.path).exists()]
155
162
  phases = sorted({shim.removal_phase for shim in LEGACY_SHIMS})
156
163
  layers = sorted({shim.layer for shim in LEGACY_SHIMS})
157
164
  return {
158
165
  "schema_version": "legacy-compatibility/v1",
159
166
  "version_target": LEGACY_COMPATIBILITY_VERSION,
160
- "status": "managed" if not missing else "incomplete",
167
+ "status": "managed" if not missing and not lingering else "incomplete",
161
168
  "remaining_count": len(LEGACY_SHIMS),
169
+ "removed_count": len(REMOVED_SHIMS),
162
170
  "missing": missing,
171
+ "lingering": lingering,
163
172
  "removal_phases": phases,
164
173
  "layers": layers,
165
174
  "shims": entries,
175
+ "removed": [shim.as_dict() for shim in REMOVED_SHIMS],
166
176
  }
167
177
 
168
178
 
169
179
  __all__ = [
170
180
  "LEGACY_COMPATIBILITY_VERSION",
171
181
  "LEGACY_SHIMS",
182
+ "REMOVED_SHIMS",
172
183
  "LegacyShim",
173
184
  "legacy_shim_report",
174
185
  ]
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "8.7.0"
14
+ MARKETPLACE_VERSION = "8.9.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
@@ -90,8 +90,16 @@ def _entry_created_at(entry: tuple) -> float:
90
90
 
91
91
 
92
92
  class SessionStore:
93
- def __init__(self, data_dir: Optional[Path] = None):
93
+ def __init__(
94
+ self,
95
+ data_dir: Optional[Path] = None,
96
+ *,
97
+ ttl_seconds: int = SESSION_TTL,
98
+ refresh_threshold_seconds: int = SESSION_REFRESH_THRESHOLD,
99
+ ):
94
100
  self._data_dir = data_dir
101
+ self._ttl_seconds = int(ttl_seconds or SESSION_TTL)
102
+ self._refresh_threshold_seconds = int(refresh_threshold_seconds or SESSION_REFRESH_THRESHOLD)
95
103
  self._sessions: Dict[str, tuple] = load_sessions(data_dir)
96
104
 
97
105
  def create(self, subject: str, *, email: Optional[str] = None) -> str:
@@ -117,11 +125,11 @@ class SessionStore:
117
125
  if entry is None:
118
126
  return None
119
127
  created_at = _entry_created_at(entry)
120
- if now - created_at > SESSION_TTL:
128
+ if now - created_at > self._ttl_seconds:
121
129
  self._sessions.pop(key, None)
122
130
  persist_sessions(self._sessions, self._data_dir)
123
131
  return None
124
- if now - created_at > SESSION_REFRESH_THRESHOLD:
132
+ if now - created_at > self._refresh_threshold_seconds:
125
133
  refreshed = (_entry_subject(entry), now, _entry_email(entry))
126
134
  self._sessions[key] = refreshed
127
135
  persist_sessions(self._sessions, self._data_dir)
@@ -109,6 +109,13 @@ def _w(sandbox: str = "workspace", rollback: str = "none") -> ToolPolicy:
109
109
  )
110
110
 
111
111
 
112
+ def _wa(sandbox: str = "workspace", rollback: str = "none") -> ToolPolicy:
113
+ return ToolPolicy(
114
+ risk="write", destructive=False, shell=False, network=False,
115
+ auto_approve=True, sandbox=sandbox, rollback=rollback,
116
+ )
117
+
118
+
112
119
  def _e(sandbox: str = "workspace", rollback: str = "none") -> ToolPolicy:
113
120
  return ToolPolicy(
114
121
  risk="exec", destructive=False, shell=True, network=False,
@@ -137,6 +144,7 @@ TOOL_GOVERNANCE: Dict[str, ToolPolicy] = {
137
144
  "search_files": _r(),
138
145
  "grep": _r(),
139
146
  "inspect_html": _r(),
147
+ "preview_url": _r(),
140
148
  "todo_read": _r(),
141
149
  "local_list": _r(sandbox="home"),
142
150
  "local_read": _r(sandbox="home"),
@@ -161,7 +169,6 @@ TOOL_GOVERNANCE: Dict[str, ToolPolicy] = {
161
169
  "create_xlsx": _w(),
162
170
  "create_pptx": _w(),
163
171
  "create_pdf": _w(),
164
- "preview_url": _w(),
165
172
  "todo_write": _w(),
166
173
  "knowledge_save": _w(sandbox="home"),
167
174
  "obsidian_save": _w(sandbox="home"),
@@ -325,9 +332,13 @@ class ToolRegistry:
325
332
 
326
333
  def policy_for(self, action_name: str, args: Optional[dict] = None) -> ToolPolicy:
327
334
  policy = self.governance.get(action_name, self.default_policy)
328
- if action_name == "local_write":
329
- path = str((args or {}).get("path", ""))
330
- if any(path.startswith(prefix) for prefix in self.local_write_blocked_prefixes):
335
+ if action_name in {"local_write", "write_file", "edit_file"}:
336
+ path = str((args or {}).get("path", "")).replace("\\", "/")
337
+ for prefix in self.local_write_blocked_prefixes:
338
+ normalized_prefix = str(prefix).rstrip("/")
339
+ blocked = path == normalized_prefix or path.startswith(f"{normalized_prefix}/")
340
+ if not blocked:
341
+ continue
331
342
  return ToolPolicy(
332
343
  risk="destructive", destructive=True, shell=False, network=False,
333
344
  auto_approve=False, sandbox="system", rollback="none",
@@ -49,7 +49,7 @@ __all__ = [
49
49
  "remove_skill_directory",
50
50
  ]
51
51
 
52
- WORKSPACE_OS_VERSION = "8.7.0"
52
+ WORKSPACE_OS_VERSION = "8.9.0"
53
53
 
54
54
  # Workspace types separate single-user Personal workspaces from shared
55
55
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -24,7 +24,7 @@ def build_session_runtime(
24
24
 
25
25
  from latticeai.core.sessions import SessionStore
26
26
 
27
- session_store = SessionStore()
27
+ session_store = SessionStore(ttl_seconds=ttl_seconds)
28
28
 
29
29
  def create_session(email: str) -> str:
30
30
  return session_store.create(user_id_resolver(email) or email, email=email)
@@ -27,7 +27,7 @@ def build_persistence_runtime(
27
27
  import os
28
28
  from pathlib import Path
29
29
 
30
- from lattice_brain.identity import DeviceIdentity
30
+ from lattice_brain.graph.identity import DeviceIdentity
31
31
  from lattice_brain.ingestion import IngestionPipeline
32
32
  from lattice_brain.portability import KGPortabilityService
33
33
  from latticeai.core.agent_registry import AgentRegistry
@@ -30,7 +30,7 @@ def build_brain_network(
30
30
  ) -> Any:
31
31
  """Construct peer sync/network service for brain portability routes."""
32
32
 
33
- from lattice_brain.network import BrainNetwork
33
+ from lattice_brain.graph.network import BrainNetwork
34
34
 
35
35
  return BrainNetwork(
36
36
  identity=identity,
@@ -47,6 +47,7 @@ class AppContext:
47
47
  load_users: Optional[Callable[[], dict]] = None
48
48
  get_user_role: Optional[Callable[..., str]] = None
49
49
  enforce_rate_limit: Optional[Callable[..., None]] = None
50
+ allowed_workspaces_for: Optional[Callable[..., Any]] = None
50
51
 
51
52
  # ── audit / history callables ─────────────────────────────────────────
52
53
  append_audit_event: Optional[Callable[..., None]] = None
@@ -1,6 +1,6 @@
1
1
  """Machine-checkable architecture readiness gates for release work.
2
2
 
3
- 8.7.0 keeps the major architecture priorities under an explicit release
3
+ 8.9.0 keeps the major architecture priorities under an explicit release
4
4
  contract while product maturity work reduces visible beta seams. AgentRuntime, ToolRegistry,
5
5
  central Config, decomposed server runtime, and Knowledge Graph stabilization
6
6
  must remain discoverable, ordered, and backed by tests before the release can be
@@ -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 = "8.7.0"
20
+ ARCHITECTURE_VERSION_TARGET = "8.9.0"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -533,6 +533,10 @@ class MemoryService:
533
533
  "title": item.get("title"),
534
534
  "snippet": item.get("snippet"),
535
535
  "score": item.get("score", 0),
536
+ # Evidence explainability: why this item was recalled, so the
537
+ # citation UI can show the matched terms instead of a bare score.
538
+ "matched_terms": item.get("matched_terms") or [],
539
+ "confidence": item.get("confidence") or "low",
536
540
  }
537
541
  for item in recall.get("results", [])[: max(1, min(limit, 8))]
538
542
  ]
@@ -616,15 +620,17 @@ class MemoryService:
616
620
  q = str(query or "").strip()
617
621
  query_tokens = [tok for tok in q.lower().split() if tok]
618
622
 
619
- def _lexical_score(*texts: Any) -> float:
623
+ def _matched_terms(*texts: Any) -> List[str]:
624
+ haystack = " ".join(str(t or "") for t in texts).lower()
625
+ return [tok for tok in query_tokens if tok in haystack]
626
+
627
+ def _lexical_score(matched: List[str]) -> float:
620
628
  # Honest, comparable relevance: fraction of query tokens present.
621
629
  # Both tiers share this scorer so the cross-tier ranking is real,
622
630
  # not an artifact of per-tier constants.
623
631
  if not query_tokens:
624
632
  return 0.0
625
- haystack = " ".join(str(t or "") for t in texts).lower()
626
- hits = sum(1 for tok in query_tokens if tok in haystack)
627
- return round(hits / len(query_tokens), 4)
633
+ return round(len(matched) / len(query_tokens), 4)
628
634
 
629
635
  results: List[Dict[str, Any]] = []
630
636
 
@@ -633,13 +639,15 @@ class MemoryService:
633
639
  except Exception:
634
640
  mem = []
635
641
  for m in mem:
642
+ matched = _matched_terms(m.get("content"), " ".join(m.get("tags") or []), m.get("kind"))
636
643
  results.append({
637
644
  "source": "workspace",
638
645
  "id": m.get("id"),
639
646
  "title": (m.get("kind") or "memory"),
640
647
  "snippet": str(m.get("content") or "")[:240],
641
648
  "kind": m.get("kind"),
642
- "score": _lexical_score(m.get("content"), " ".join(m.get("tags") or []), m.get("kind")),
649
+ "score": _lexical_score(matched),
650
+ "matched_terms": matched,
643
651
  "tags": m.get("tags") or [],
644
652
  })
645
653
 
@@ -650,17 +658,40 @@ class MemoryService:
650
658
  except Exception:
651
659
  hits = []
652
660
  for hit in hits[:limit]:
661
+ matched = _matched_terms(hit.get("title"), hit.get("name"), hit.get("summary"), hit.get("content"))
653
662
  results.append({
654
663
  "source": "graph",
655
664
  "id": hit.get("id") or hit.get("node_id"),
656
665
  "title": hit.get("title") or hit.get("name") or "node",
657
666
  "snippet": str(hit.get("summary") or hit.get("content") or "")[:240],
658
667
  "kind": hit.get("type") or "node",
659
- "score": _lexical_score(hit.get("title"), hit.get("name"), hit.get("summary"), hit.get("content")),
668
+ "score": _lexical_score(matched),
669
+ "matched_terms": matched,
660
670
  })
661
671
 
672
+ # Quality gate: when at least one result carries real lexical evidence,
673
+ # zero-score rows are noise relative to it and are dropped. When nothing
674
+ # scores (e.g. tokenization mismatch), everything is kept so the tiers'
675
+ # own search filters still decide — the gate never empties a recall.
676
+ candidates = len(results)
677
+ if query_tokens and any(r.get("score", 0) > 0 for r in results):
678
+ results = [r for r in results if r.get("score", 0) > 0]
679
+ for r in results:
680
+ r["confidence"] = "high" if r.get("score", 0) >= 0.65 else "medium" if r.get("score", 0) >= 0.3 else "low"
681
+
662
682
  results.sort(key=lambda r: r.get("score", 0), reverse=True)
663
- return {"query": q, "results": results[: max(1, min(limit, 100))], "count": len(results), "source": "live"}
683
+ return {
684
+ "query": q,
685
+ "results": results[: max(1, min(limit, 100))],
686
+ "count": len(results),
687
+ "source": "live",
688
+ "quality_gate": {
689
+ "candidates": candidates,
690
+ "passed": len(results),
691
+ "filtered": candidates - len(results),
692
+ "gate": "lexical-evidence/v1",
693
+ },
694
+ }
664
695
 
665
696
  # ── inspect a single tier ─────────────────────────────────────────────
666
697
  def inspect(self, source: str, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: