ltcai 9.0.0 → 9.1.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 (187) hide show
  1. package/README.md +80 -46
  2. package/auto_setup.py +7 -853
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +146 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/TRUST_MODEL.md +5 -2
  13. package/docs/WHY_LATTICE.md +15 -10
  14. package/docs/WORKFLOW_DESIGNER.md +22 -0
  15. package/docs/kg-schema.md +13 -2
  16. package/docs/mcp-tools.md +17 -6
  17. package/docs/privacy.md +19 -3
  18. package/docs/public-deploy.md +32 -3
  19. package/docs/security-model.md +46 -11
  20. package/lattice_brain/__init__.py +1 -1
  21. package/lattice_brain/archive.py +1 -6
  22. package/lattice_brain/context.py +66 -9
  23. package/lattice_brain/graph/_kg_fsutil.py +1 -5
  24. package/lattice_brain/graph/discovery_index.py +174 -20
  25. package/lattice_brain/graph/documents.py +44 -9
  26. package/lattice_brain/graph/ingest.py +47 -20
  27. package/lattice_brain/graph/provenance.py +13 -6
  28. package/lattice_brain/graph/retrieval.py +140 -39
  29. package/lattice_brain/graph/retrieval_docgen.py +37 -4
  30. package/lattice_brain/ingestion.py +4 -7
  31. package/lattice_brain/portability.py +28 -14
  32. package/lattice_brain/runtime/agent_runtime.py +5 -9
  33. package/lattice_brain/runtime/hooks.py +30 -13
  34. package/lattice_brain/runtime/multi_agent.py +27 -8
  35. package/lattice_brain/runtime/statuses.py +10 -0
  36. package/lattice_brain/utils.py +20 -2
  37. package/lattice_brain/workflow.py +1 -5
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +1 -1
  40. package/latticeai/api/agent_registry.py +10 -8
  41. package/latticeai/api/agents.py +22 -3
  42. package/latticeai/api/auth.py +78 -16
  43. package/latticeai/api/browser.py +303 -34
  44. package/latticeai/api/chat.py +320 -850
  45. package/latticeai/api/chat_agent_http.py +356 -0
  46. package/latticeai/api/chat_contracts.py +54 -0
  47. package/latticeai/api/chat_documents.py +256 -0
  48. package/latticeai/api/chat_helpers.py +24 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +302 -0
  51. package/latticeai/api/chat_stream.py +115 -0
  52. package/latticeai/api/computer_use.py +62 -9
  53. package/latticeai/api/health.py +9 -3
  54. package/latticeai/api/hooks.py +17 -6
  55. package/latticeai/api/knowledge_graph.py +78 -14
  56. package/latticeai/api/local_files.py +7 -2
  57. package/latticeai/api/mcp.py +102 -23
  58. package/latticeai/api/models.py +72 -33
  59. package/latticeai/api/network.py +5 -5
  60. package/latticeai/api/permissions.py +67 -26
  61. package/latticeai/api/plugins.py +21 -7
  62. package/latticeai/api/portability.py +5 -5
  63. package/latticeai/api/realtime.py +33 -5
  64. package/latticeai/api/setup.py +2 -2
  65. package/latticeai/api/static_routes.py +123 -24
  66. package/latticeai/api/tools.py +96 -14
  67. package/latticeai/api/workflow_designer.py +37 -0
  68. package/latticeai/api/workspace.py +2 -1
  69. package/latticeai/app_factory.py +110 -52
  70. package/latticeai/core/agent.py +19 -9
  71. package/latticeai/core/agent_registry.py +1 -5
  72. package/latticeai/core/config.py +23 -3
  73. package/latticeai/core/context_builder.py +21 -3
  74. package/latticeai/core/invitations.py +1 -4
  75. package/latticeai/core/io_utils.py +9 -1
  76. package/latticeai/core/legacy_compatibility.py +24 -3
  77. package/latticeai/core/marketplace.py +1 -1
  78. package/latticeai/core/plugins.py +15 -0
  79. package/latticeai/core/policy.py +1 -1
  80. package/latticeai/core/realtime.py +38 -15
  81. package/latticeai/core/sessions.py +7 -2
  82. package/latticeai/core/timeutil.py +10 -0
  83. package/latticeai/core/tool_registry.py +90 -18
  84. package/latticeai/core/users.py +1 -5
  85. package/latticeai/core/workspace_graph_trace.py +31 -5
  86. package/latticeai/core/workspace_memory.py +3 -1
  87. package/latticeai/core/workspace_os.py +18 -7
  88. package/latticeai/core/workspace_os_utils.py +0 -5
  89. package/latticeai/core/workspace_permissions.py +2 -1
  90. package/latticeai/core/workspace_plugins.py +1 -1
  91. package/latticeai/core/workspace_runs.py +14 -5
  92. package/latticeai/core/workspace_skills.py +1 -1
  93. package/latticeai/core/workspace_snapshots.py +2 -1
  94. package/latticeai/core/workspace_timeline.py +2 -1
  95. package/latticeai/integrations/telegram_bot.py +94 -43
  96. package/latticeai/models/router.py +130 -36
  97. package/latticeai/runtime/access_runtime.py +62 -4
  98. package/latticeai/runtime/automation_runtime.py +13 -7
  99. package/latticeai/runtime/brain_runtime.py +19 -7
  100. package/latticeai/runtime/chat_wiring.py +2 -0
  101. package/latticeai/runtime/config_runtime.py +58 -26
  102. package/latticeai/runtime/context_runtime.py +16 -4
  103. package/latticeai/runtime/hooks_runtime.py +1 -1
  104. package/latticeai/runtime/model_wiring.py +33 -5
  105. package/latticeai/runtime/namespace_runtime.py +62 -72
  106. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  107. package/latticeai/runtime/review_wiring.py +1 -1
  108. package/latticeai/runtime/router_registration.py +34 -1
  109. package/latticeai/runtime/security_runtime.py +110 -13
  110. package/latticeai/runtime/stages.py +27 -0
  111. package/latticeai/server_app.py +5 -1
  112. package/latticeai/services/architecture_readiness.py +74 -5
  113. package/latticeai/services/brain_automation.py +3 -26
  114. package/latticeai/services/chat_service.py +205 -15
  115. package/latticeai/services/local_knowledge.py +423 -0
  116. package/latticeai/services/memory_service.py +55 -21
  117. package/latticeai/services/model_engines.py +48 -33
  118. package/latticeai/services/model_errors.py +17 -0
  119. package/latticeai/services/model_loading.py +28 -25
  120. package/latticeai/services/model_runtime.py +228 -162
  121. package/latticeai/services/p_reinforce.py +22 -3
  122. package/latticeai/services/platform_runtime.py +83 -23
  123. package/latticeai/services/product_readiness.py +19 -17
  124. package/latticeai/services/review_queue.py +12 -0
  125. package/latticeai/services/router_context.py +1 -0
  126. package/latticeai/services/run_executor.py +4 -9
  127. package/latticeai/services/search_service.py +203 -28
  128. package/latticeai/services/tool_dispatch.py +28 -5
  129. package/latticeai/services/triggers.py +53 -4
  130. package/latticeai/services/upload_service.py +13 -2
  131. package/latticeai/setup/__init__.py +25 -0
  132. package/latticeai/setup/auto_setup.py +857 -0
  133. package/latticeai/setup/wizard.py +1264 -0
  134. package/latticeai/tools/__init__.py +278 -0
  135. package/{tools → latticeai/tools}/commands.py +67 -7
  136. package/{tools → latticeai/tools}/computer.py +1 -1
  137. package/{tools → latticeai/tools}/documents.py +1 -1
  138. package/{tools → latticeai/tools}/filesystem.py +3 -3
  139. package/latticeai/tools/knowledge.py +178 -0
  140. package/{tools → latticeai/tools}/local_files.py +1 -1
  141. package/{tools → latticeai/tools}/network.py +0 -1
  142. package/local_knowledge_api.py +4 -342
  143. package/package.json +22 -2
  144. package/scripts/brain_quality_eval.py +3 -1
  145. package/scripts/bump_version.py +3 -0
  146. package/scripts/capture_release_evidence.mjs +180 -0
  147. package/scripts/check_current_release_docs.mjs +142 -0
  148. package/scripts/check_i18n_literals.mjs +107 -31
  149. package/scripts/check_openapi_drift.mjs +56 -0
  150. package/scripts/export_openapi.py +67 -7
  151. package/scripts/i18n_literal_allowlist.json +22 -24
  152. package/scripts/run_integration_tests.mjs +72 -10
  153. package/scripts/validate_release_artifacts.py +49 -7
  154. package/scripts/wheel_smoke.py +5 -0
  155. package/setup_wizard.py +3 -1260
  156. package/src-tauri/Cargo.lock +1 -1
  157. package/src-tauri/Cargo.toml +1 -1
  158. package/src-tauri/tauri.conf.json +1 -1
  159. package/static/app/asset-manifest.json +11 -11
  160. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  161. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  162. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  163. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  164. package/static/app/assets/System-80lHW0Ux.js +1 -0
  165. package/static/app/assets/index-BiMofBTM.js +17 -0
  166. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  167. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  168. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  169. package/static/app/index.html +2 -2
  170. package/static/sw.js +1 -1
  171. package/tools/__init__.py +21 -269
  172. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  173. package/latticeai/runtime/app_context_runtime.py +0 -13
  174. package/latticeai/runtime/tail_wiring.py +0 -21
  175. package/scripts/com.pts.claudecode.discord.plist +0 -31
  176. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  177. package/scripts/start-pts-claudecode-discord.sh +0 -51
  178. package/static/app/assets/Act-21lIXx2E.js +0 -1
  179. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  180. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  181. package/static/app/assets/Library-bFMtyni3.js +0 -1
  182. package/static/app/assets/System-K6krGCqn.js +0 -1
  183. package/static/app/assets/index-C4R3ws30.js +0 -17
  184. package/static/app/assets/index-ChSeOB02.css +0 -2
  185. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  186. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  187. package/tools/knowledge.py +0 -95
@@ -2,26 +2,123 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING, Any, Dict
5
+ import json
6
+ import logging
7
+ import secrets
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Dict
11
+
12
+ from latticeai.runtime.stages import RuntimeStage
6
13
 
7
14
  if TYPE_CHECKING:
8
15
  from latticeai.core.config import Config
9
16
 
10
17
 
11
- def build_security_runtime(config: "Config") -> Dict[str, Any]:
18
+ _SECURITY_SECRETS_FILE = "security_secrets.json"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class SecurityRuntime(RuntimeStage):
23
+ SSO_DISCOVERY_URL: str
24
+ SSO_CLIENT_ID: str
25
+ SSO_CLIENT_SECRET: str
26
+ SSO_REDIRECT_URI: str
27
+ SSO_PROVIDER_NAME: str
28
+ RATE_LIMIT_ENABLED: bool
29
+ OPEN_REGISTRATION: bool
30
+ INVITE_CODE: str
31
+ INVITE_COOKIE_SECRET: str
32
+ INVITE_GATE_ENABLED: bool
33
+ SECURE_COOKIES: bool
34
+
35
+
36
+ def _stored_security_secrets(data_dir: Path) -> Dict[str, str]:
37
+ path = data_dir / _SECURITY_SECRETS_FILE
38
+ if not path.exists():
39
+ return {}
40
+ try:
41
+ payload = json.loads(path.read_text(encoding="utf-8"))
42
+ except Exception as exc:
43
+ # A corrupt secret store invalidates old invite cookies, but must never
44
+ # make startup fall back to a shared or predictable signing key.
45
+ logging.warning("invite-gate secret store is unreadable; rotating secrets: %s", exc)
46
+ return {}
47
+ if not isinstance(payload, dict):
48
+ return {}
49
+ return {
50
+ key: str(value)
51
+ for key, value in payload.items()
52
+ if key in {"invite_code", "invite_cookie_secret"} and value
53
+ }
54
+
55
+
56
+ def _resolve_invite_gate_secrets(config: "Config") -> tuple[str, str]:
57
+ """Resolve stable, per-install invitation secrets.
58
+
59
+ No file is created while the invitation gate is disabled. Once enabled,
60
+ missing values are generated with the OS CSPRNG and persisted atomically
61
+ with mode 0600 so restarts do not invalidate links or signed cookies.
62
+ Explicit environment values remain authoritative and are not copied to the
63
+ local secret file unnecessarily.
64
+ """
65
+
66
+ if not config.invite_gate_enabled:
67
+ return config.invite_code, config.invite_cookie_secret
68
+
69
+ from latticeai.core.io_utils import atomic_write_json
70
+
71
+ data_dir = Path(config.data_dir)
72
+ stored = _stored_security_secrets(data_dir)
73
+ invite_code = config.invite_code or stored.get("invite_code") or secrets.token_urlsafe(24)
74
+ cookie_secret = (
75
+ config.invite_cookie_secret
76
+ or stored.get("invite_cookie_secret")
77
+ or secrets.token_urlsafe(48)
78
+ )
79
+
80
+ persisted = dict(stored)
81
+ if not config.invite_code:
82
+ persisted["invite_code"] = invite_code
83
+ if not config.invite_cookie_secret:
84
+ persisted["invite_cookie_secret"] = cookie_secret
85
+ if persisted != stored:
86
+ atomic_write_json(data_dir / _SECURITY_SECRETS_FILE, persisted)
87
+ return invite_code, cookie_secret
88
+
89
+
90
+ def build_security_runtime(config: "Config") -> SecurityRuntime:
12
91
  """Build auth/security-derived runtime settings from the central config."""
13
92
 
14
93
  from latticeai.core.security import configure_trusted_proxies
15
94
 
16
95
  configure_trusted_proxies(config.trusted_proxies)
17
- return {
18
- "SSO_DISCOVERY_URL": config.sso_discovery_url,
19
- "SSO_CLIENT_ID": config.sso_client_id,
20
- "SSO_CLIENT_SECRET": config.sso_client_secret,
21
- "SSO_REDIRECT_URI": config.sso_redirect_uri,
22
- "SSO_PROVIDER_NAME": config.sso_provider_name,
23
- "RATE_LIMIT_ENABLED": config.rate_limit_enabled,
24
- "OPEN_REGISTRATION": config.open_registration,
25
- "INVITE_CODE": config.invite_code,
26
- "INVITE_GATE_ENABLED": config.invite_gate_enabled,
27
- }
96
+ invite_code, invite_cookie_secret = _resolve_invite_gate_secrets(config)
97
+ secure_cookies = bool(config.is_public or config.network_exposed)
98
+ if secure_cookies:
99
+ logging.warning(
100
+ "Public/non-loopback mode: authentication and Secure cookies are forced; "
101
+ "serve Lattice AI through HTTPS/TLS or browser sessions will be rejected."
102
+ )
103
+ if config.invite_gate_enabled and not config.invite_code:
104
+ logging.warning(
105
+ "No LATTICEAI_INVITE_CODE was configured; generated a private per-install "
106
+ "invite code in %s.",
107
+ Path(config.data_dir) / _SECURITY_SECRETS_FILE,
108
+ )
109
+ return SecurityRuntime(
110
+ SSO_DISCOVERY_URL=config.sso_discovery_url,
111
+ SSO_CLIENT_ID=config.sso_client_id,
112
+ SSO_CLIENT_SECRET=config.sso_client_secret,
113
+ SSO_REDIRECT_URI=config.sso_redirect_uri,
114
+ SSO_PROVIDER_NAME=config.sso_provider_name,
115
+ RATE_LIMIT_ENABLED=config.rate_limit_enabled,
116
+ OPEN_REGISTRATION=config.open_registration,
117
+ INVITE_CODE=invite_code,
118
+ INVITE_COOKIE_SECRET=invite_cookie_secret,
119
+ INVITE_GATE_ENABLED=config.invite_gate_enabled,
120
+ SECURE_COOKIES=secure_cookies,
121
+ )
122
+
123
+
124
+ __all__ = ["SecurityRuntime", "build_security_runtime"]
@@ -0,0 +1,27 @@
1
+ """Typed mapping base for application assembly stages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import fields
6
+ from typing import Any, Iterator, Mapping
7
+
8
+
9
+ class RuntimeStage(Mapping[str, Any]):
10
+ """Dataclass mixin that preserves the legacy mapping access during DI migration."""
11
+
12
+ def __getitem__(self, key: str) -> Any:
13
+ if key not in self:
14
+ raise KeyError(key)
15
+ return getattr(self, key)
16
+
17
+ def __iter__(self) -> Iterator[str]:
18
+ return (field.name for field in fields(self))
19
+
20
+ def __len__(self) -> int:
21
+ return len(fields(self))
22
+
23
+ def __contains__(self, key: object) -> bool:
24
+ return isinstance(key, str) and any(field.name == key for field in fields(self))
25
+
26
+
27
+ __all__ = ["RuntimeStage"]
@@ -13,6 +13,8 @@ from __future__ import annotations
13
13
 
14
14
  from typing import Any, List
15
15
 
16
+ from latticeai.runtime.namespace_runtime import SERVER_APP_EXPORTS
17
+
16
18
 
17
19
  def _runtime():
18
20
  from latticeai.app_factory import get_shared_runtime
@@ -25,6 +27,8 @@ def __getattr__(name: str) -> Any:
25
27
  # Never let dunder probes (importlib, inspect, pickling) trigger the
26
28
  # full application construction.
27
29
  raise AttributeError(name)
30
+ if name not in SERVER_APP_EXPORTS:
31
+ raise AttributeError(f"module 'latticeai.server_app' has no attribute '{name}'")
28
32
  try:
29
33
  return getattr(_runtime(), name)
30
34
  except AttributeError as exc:
@@ -34,7 +38,7 @@ def __getattr__(name: str) -> Any:
34
38
 
35
39
 
36
40
  def __dir__() -> List[str]:
37
- return sorted(set(globals()) | set(vars(_runtime())))
41
+ return sorted(set(globals()) | set(SERVER_APP_EXPORTS))
38
42
 
39
43
 
40
44
  def main() -> None:
@@ -1,6 +1,6 @@
1
1
  """Machine-checkable architecture readiness gates for release work.
2
2
 
3
- 9.0.0 keeps the major architecture priorities under an explicit release
3
+ The current release 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 = "9.0.0"
20
+ ARCHITECTURE_VERSION_TARGET = "9.1.0"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -54,16 +54,64 @@ def _symbol_exists(dotted: str) -> bool:
54
54
  return False
55
55
 
56
56
 
57
+ def _forbidden_patterns(root: Path, relative_path: str, patterns: List[str]) -> List[str]:
58
+ path = root / relative_path
59
+ try:
60
+ source = path.read_text(encoding="utf-8")
61
+ except OSError:
62
+ return [f"missing:{relative_path}"]
63
+ return [pattern for pattern in patterns if pattern in source]
64
+
65
+
57
66
  def architecture_readiness(root: Path | None = None) -> Dict[str, Any]:
58
67
  if root is None:
59
68
  root = Path(__file__).resolve().parents[2]
60
69
  shim_report = legacy_shim_report(root)
70
+ factory_forbidden = _forbidden_patterns(
71
+ root,
72
+ "latticeai/app_factory.py",
73
+ ["build_runtime_namespace(locals", "dict(locals())"],
74
+ )
75
+ model_runtime_forbidden = _forbidden_patterns(
76
+ root,
77
+ "latticeai/services/model_runtime.py",
78
+ [
79
+ "def _sync_globals",
80
+ "global router",
81
+ "STATE = ModelRuntimeState",
82
+ "_STATE_EXPORTS",
83
+ "def __getattr__(name:",
84
+ "from fastapi import HTTPException",
85
+ ],
86
+ )
87
+ model_runtime_forbidden.extend(
88
+ _forbidden_patterns(
89
+ root,
90
+ "latticeai/services/model_loading.py",
91
+ ["from .model_runtime import STATE", "from fastapi import HTTPException"],
92
+ )
93
+ )
94
+ model_runtime_forbidden.extend(
95
+ _forbidden_patterns(
96
+ root,
97
+ "latticeai/services/model_engines.py",
98
+ ["from fastapi import HTTPException", "raise HTTPException"],
99
+ )
100
+ )
101
+ agent_alias_forbidden = _forbidden_patterns(
102
+ root,
103
+ "latticeai/core/agent.py",
104
+ ["AgentRuntime = SingleAgentRuntime"],
105
+ )
61
106
 
62
107
  gates = [
63
108
  ArchitectureGate(
64
109
  id="agent-runtime",
65
110
  title="AgentRuntime boundary",
66
- status="complete" if _symbol_exists("lattice_brain.runtime.agent_runtime.AgentRuntime") else "incomplete",
111
+ status="complete" if (
112
+ _symbol_exists("lattice_brain.runtime.agent_runtime.AgentRuntime")
113
+ and not agent_alias_forbidden
114
+ ) else "incomplete",
67
115
  evidence=[
68
116
  "lattice_brain.runtime.agent_runtime.AgentRuntime",
69
117
  "latticeai.api.agents.create_agents_router(agent_runtime=...)",
@@ -83,7 +131,11 @@ def architecture_readiness(root: Path | None = None) -> Dict[str, Any]:
83
131
  ArchitectureGate(
84
132
  id="config-centralization",
85
133
  title="Central app Config",
86
- status="complete" if _symbol_exists("latticeai.core.config.Config") else "incomplete",
134
+ status="complete" if (
135
+ _symbol_exists("latticeai.core.config.Config")
136
+ and _symbol_exists("latticeai.runtime.config_runtime.ConfigRuntime")
137
+ and not model_runtime_forbidden
138
+ ) else "incomplete",
87
139
  evidence=[
88
140
  "latticeai.core.config.Config.from_env",
89
141
  "latticeai.runtime.config_runtime.ConfigRuntime",
@@ -93,7 +145,19 @@ def architecture_readiness(root: Path | None = None) -> Dict[str, Any]:
93
145
  ArchitectureGate(
94
146
  id="server-decomposition",
95
147
  title="Server decomposition",
96
- status="complete",
148
+ status="complete" if (
149
+ all(
150
+ _symbol_exists(symbol)
151
+ for symbol in [
152
+ "latticeai.runtime.config_runtime.ConfigRuntime",
153
+ "latticeai.runtime.security_runtime.SecurityRuntime",
154
+ "latticeai.runtime.brain_runtime.BrainRuntime",
155
+ "latticeai.runtime.model_wiring.ModelRuntime",
156
+ "latticeai.runtime.router_registration.RouterBundle",
157
+ ]
158
+ )
159
+ and not factory_forbidden
160
+ ) else "incomplete",
97
161
  evidence=[
98
162
  "latticeai.app_factory.create_app composition root",
99
163
  "latticeai.api.* domain routers",
@@ -192,6 +256,11 @@ def architecture_readiness(root: Path | None = None) -> Dict[str, Any]:
192
256
  "runtime_modules": runtime_module_count,
193
257
  "architecture_gates": len(gates),
194
258
  "legacy_shims_remaining": shim_report["remaining_count"],
259
+ "forbidden_patterns": {
260
+ "app_factory": factory_forbidden,
261
+ "model_runtime": model_runtime_forbidden,
262
+ "agent_alias": agent_alias_forbidden,
263
+ },
195
264
  },
196
265
  "legacy_compatibility": shim_report,
197
266
  }
@@ -136,6 +136,7 @@ def build_brain_automation_workflow(recipe_id: str, *, enabled: bool = False) ->
136
136
  trigger_config = {
137
137
  **deepcopy(recipe.trigger),
138
138
  "enabled": bool(enabled),
139
+ "review_queue": True,
139
140
  "consent_required": True,
140
141
  "local_only": True,
141
142
  "external_actions": False,
@@ -156,7 +157,9 @@ def build_brain_automation_workflow(recipe_id: str, *, enabled: bool = False) ->
156
157
  "name": "Draft Brain review",
157
158
  "config": {
158
159
  "agent": "agent:planner",
160
+ "goal": recipe.prompt,
159
161
  "prompt": recipe.prompt,
162
+ "roles": ["researcher", "planner", "executor", "reviewer"],
160
163
  "mode": "draft",
161
164
  "local_only": True,
162
165
  "external_actions": False,
@@ -186,29 +189,3 @@ def build_brain_automation_workflow(recipe_id: str, *, enabled: bool = False) ->
186
189
  "creates": list(recipe.creates),
187
190
  },
188
191
  }
189
-
190
-
191
- # === A방향 (Act/automation + BrainAutomationPanel) E2E 시나리오 초안 ===
192
- # (backend 인터페이스 list_brain_automation_recipes / find_installed... / build_... 완료 후 즉시 작성)
193
- # 1. Recipe 목록 노출: frontend BrainAutomationPanel이 list_brain_automation_recipes() 호출 → recipes + consent metadata 표시.
194
- # 2. "Create reviewable draft" 클릭:
195
- # - build_brain_automation_workflow(recipe_id, enabled=False) 로 draft 생성 (metadata.recipe_id + created_from=brain_automation_recipe)
196
- # - find_installed_recipe_workflow 로 사전 dedup 체크 → 이미 있으면 기존 반환, UI disabled + "✓ Reviewable draft ready" 피드백.
197
- # - 생성된 workflow는 automation_state="draft_disabled", trigger.enabled=False → TriggerService 무시.
198
- # 3. Dedup guard (UI + backend): metadata.recipe_id + created_from 기준. 중복 클릭 가드 (no-dup) 로 double submit 방지.
199
- # 4. User가 draft를 review/수정 후 enabled=True 로 전환 → TriggerService._triggered_workflows 에서 enabled True인 것만 arm.
200
- # 5. Interval trigger E2E:
201
- # - reconcile_missed() : 다운타임 중 missed → "skipped" 이벤트 기록 (catch-up storm 없음).
202
- # - tick_intervals() : last_fired_at + interval + last_attempt_at dedup 가드 (10s cooldown) 로 중복 실행 방지.
203
- # - LATTICE_TZ 환경변수: describe()에 "tz" 노출, 이벤트 at 값은 epoch이지만 클라이언트가 LATTICE_TZ로 현지화.
204
- # 6. Brain event trigger E2E: kg_ingest.* post_tool hook → on_brain_event → matching source_type 필터 → _fire (dedup 5s).
205
- # 7. Failure degraded:
206
- # - _fire 에서 run_workflow 예외 → _record_fire_outcome → consecutive_failures++ , describe() "status":"degraded" ( >=3 ).
207
- # - 성공 시 reset. (실행 내부 실패는 workflow run record에 남음, scheduler는 launch health만).
208
- # 8. Run provenance: fired run의 inputs["__trigger__"] = {"type": "interval"|"brain_event", ...} 로 감사/디버그 가능.
209
- # 9. Consent-first: draft_disabled 기본, user enable 전까지 절대 실행 안 됨. "review_before_run": True.
210
- # 10. End-to-end Act: draft → enable → trigger fire → agent:planner "Draft Brain review" 노드 → output (requires_review) → user review → save or discard.
211
- #
212
- # 다음: 실제 API (e.g. POST /brain/automation/install-draft) 가 UI에서 호출되면 위 시나리오에 대한 통합 테스트 + RunExecutor 경로 검증 즉시 추가.
213
- # 현재 상태: backend recipe 인터페이스 + TriggerService edge 하드닝 + AgentRuntime wiring 완료. UI (App.tsx + styles) + test_brain_automation.py 는 별도 완료 보고됨.
214
-
@@ -1,37 +1,191 @@
1
- """Chat / session service seam.
1
+ """Non-HTTP chat orchestration: history persistence and answer traces.
2
2
 
3
- The streaming chat path in ``server_app`` is intentionally left in place — its
4
- generator and SSE behaviour are sensitive. This service provides a stable seam
5
- for the *bookkeeping* around answers (conversation history access and
6
- Workspace-OS answer-trace recording) so those concerns are named and wrapped
7
- rather than reaching into the store directly from the streaming handler.
3
+ The API layer owns FastAPI requests/responses and SSE framing. This service
4
+ owns the reusable bookkeeping that must be identical across normal answers,
5
+ streamed answers, document generation, and fast-path intents:
8
6
 
9
- It is a behaviour-preserving façade: methods forward to the injected history
10
- accessor and :class:`WorkspaceOSStore`, so wiring the chat path through it
11
- cannot change streaming output.
7
+ * workspace/user-scoped history reads;
8
+ * asynchronous persistence of user/assistant exchanges;
9
+ * Graph-RAG answer trace construction and recording;
10
+ * history search/grouping independent of HTTP.
11
+
12
+ ``coerce`` preserves lightweight test/embedding contexts that provide the old
13
+ ``build_graph_trace``/``record_trace`` façade without requiring them to grow a
14
+ full service implementation.
12
15
  """
13
16
 
14
17
  from __future__ import annotations
15
18
 
19
+ import asyncio
16
20
  from typing import Any, Callable, Dict, List, Optional
17
21
 
18
22
  from latticeai.core.workspace_os import WorkspaceOSStore
19
23
 
20
24
 
21
25
  class ChatService:
22
- def __init__(self, *, store: WorkspaceOSStore, get_history: Callable[[], List[Dict[str, Any]]]):
26
+ def __init__(
27
+ self,
28
+ *,
29
+ store: WorkspaceOSStore,
30
+ get_history: Callable[..., List[Dict[str, Any]]],
31
+ save_to_history: Optional[Callable[..., None]] = None,
32
+ get_history_user: Optional[Callable[..., Dict[str, Any]]] = None,
33
+ trace_delegate: Any = None,
34
+ ):
23
35
  self._store = store
24
36
  self._get_history = get_history
37
+ self._save_to_history = save_to_history
38
+ self._get_history_user = get_history_user
39
+ self._trace_delegate = trace_delegate
40
+
41
+ @classmethod
42
+ def coerce(
43
+ cls,
44
+ candidate: Any,
45
+ *,
46
+ store: Any,
47
+ get_history: Callable[..., List[Dict[str, Any]]],
48
+ save_to_history: Callable[..., None],
49
+ get_history_user: Callable[..., Dict[str, Any]],
50
+ ) -> "ChatService":
51
+ """Return a fully wired service while preserving legacy trace fakes."""
52
+
53
+ if isinstance(candidate, cls):
54
+ candidate._get_history = get_history
55
+ candidate._save_to_history = save_to_history
56
+ candidate._get_history_user = get_history_user
57
+ return candidate
58
+ return cls(
59
+ store=store,
60
+ get_history=get_history,
61
+ save_to_history=save_to_history,
62
+ get_history_user=get_history_user,
63
+ trace_delegate=candidate,
64
+ )
25
65
 
26
66
  # ── conversation history ─────────────────────────────────────────────
27
67
 
28
- def history(self) -> List[Dict[str, Any]]:
29
- return self._get_history()
68
+ def history(self, **scope: Any) -> List[Dict[str, Any]]:
69
+ return self._get_history(**scope)
70
+
71
+ def history_scope(
72
+ self,
73
+ user_email: Optional[str],
74
+ *,
75
+ require_auth: bool,
76
+ allowed_workspaces_for: Optional[Callable[[str], Any]] = None,
77
+ ) -> Dict[str, Any]:
78
+ scoped_user = user_email if require_auth else None
79
+ allowed = None
80
+ if require_auth and scoped_user and allowed_workspaces_for is not None:
81
+ allowed = allowed_workspaces_for(scoped_user)
82
+ return {
83
+ "user_email": scoped_user,
84
+ "allowed_workspaces": allowed,
85
+ "include_legacy_global": not require_auth,
86
+ }
87
+
88
+ def history_user(
89
+ self,
90
+ user_email: Optional[str],
91
+ user_nickname: Optional[str],
92
+ ) -> Dict[str, Any]:
93
+ if self._get_history_user is None:
94
+ return {}
95
+ return self._get_history_user(user_email, user_nickname)
96
+
97
+ async def persist_entry(
98
+ self,
99
+ role: str,
100
+ content: str,
101
+ *,
102
+ history_meta: Optional[Dict[str, Any]] = None,
103
+ history_user: Optional[Dict[str, Any]] = None,
104
+ ) -> None:
105
+ if self._save_to_history is None:
106
+ raise RuntimeError("chat history writer is not configured")
107
+ await asyncio.to_thread(
108
+ self._save_to_history,
109
+ role,
110
+ content,
111
+ **(history_meta or {}),
112
+ **(history_user or {}),
113
+ )
114
+
115
+ async def persist_exchange(
116
+ self,
117
+ *,
118
+ request_message: str,
119
+ stored_user_message: str,
120
+ answer: str,
121
+ source: Optional[str],
122
+ history_meta: Dict[str, Any],
123
+ history_user: Dict[str, Any],
124
+ notify: Optional[Callable[[str, str, Optional[str]], None]] = None,
125
+ ) -> None:
126
+ await self.persist_entry(
127
+ "user",
128
+ stored_user_message,
129
+ history_meta=history_meta,
130
+ history_user=history_user,
131
+ )
132
+ await self.persist_entry(
133
+ "assistant",
134
+ answer,
135
+ history_meta=history_meta,
136
+ history_user=history_user,
137
+ )
138
+ if notify is not None:
139
+ notify("user", request_message, source)
140
+ notify("assistant", answer, source)
141
+
142
+ def search_history(
143
+ self,
144
+ query: str,
145
+ *,
146
+ scope: Dict[str, Any],
147
+ conversation_title: Callable[[Dict[str, Any]], str],
148
+ limit: int = 30,
149
+ ) -> List[Dict[str, Any]]:
150
+ q_lower = str(query or "").strip().lower()
151
+ if not q_lower:
152
+ return []
153
+ matches = [
154
+ item
155
+ for item in self.history(**scope)
156
+ if q_lower in str(item.get("content") or "").lower()
157
+ ]
158
+ grouped: Dict[str, Dict[str, Any]] = {}
159
+ for item in matches:
160
+ conversation_id = item.get("conversation_id") or "legacy"
161
+ if conversation_id not in grouped:
162
+ grouped[conversation_id] = {
163
+ "conversation_id": conversation_id,
164
+ "title": conversation_title(item),
165
+ "messages": [],
166
+ }
167
+ grouped[conversation_id]["messages"].append(item)
168
+ return list(grouped.values())[-max(1, int(limit or 30)) :]
30
169
 
31
170
  # ── answer-trace recording (Graph RAG) ───────────────────────────────
32
171
 
33
- def build_graph_trace(self, question: str, graph: Any, context: str = "", *, limit: int = 8) -> Dict[str, Any]:
34
- return self._store.build_graph_trace(question, graph, context, limit=limit)
172
+ def build_graph_trace(
173
+ self,
174
+ question: str,
175
+ graph: Any,
176
+ context: str = "",
177
+ *,
178
+ limit: int = 8,
179
+ allowed_workspaces=None,
180
+ ) -> Dict[str, Any]:
181
+ target = self._trace_delegate or self._store
182
+ return target.build_graph_trace(
183
+ question,
184
+ graph,
185
+ context,
186
+ limit=limit,
187
+ allowed_workspaces=allowed_workspaces,
188
+ )
35
189
 
36
190
  def record_trace(
37
191
  self,
@@ -43,7 +197,37 @@ class ChatService:
43
197
  trace: Dict[str, Any],
44
198
  workspace_id: Optional[str] = None,
45
199
  ) -> Dict[str, Any]:
46
- return self._store.record_trace(
200
+ target = self._trace_delegate or self._store
201
+ return target.record_trace(
202
+ question=question,
203
+ response=response,
204
+ conversation_id=conversation_id,
205
+ user_email=user_email,
206
+ trace=trace,
207
+ workspace_id=workspace_id,
208
+ )
209
+
210
+ async def persist_answer(
211
+ self,
212
+ *,
213
+ question: str,
214
+ response: str,
215
+ conversation_id: Optional[str],
216
+ user_email: Optional[str],
217
+ user_nickname: Optional[str],
218
+ source: Optional[str],
219
+ trace: Dict[str, Any],
220
+ workspace_id: Optional[str],
221
+ history_meta: Dict[str, Any],
222
+ notify: Optional[Callable[[str, str, Optional[str]], None]] = None,
223
+ ) -> Dict[str, Any]:
224
+ await self.persist_entry(
225
+ "assistant",
226
+ response,
227
+ history_meta=history_meta,
228
+ history_user=self.history_user(user_email, user_nickname),
229
+ )
230
+ trace_record = self.record_trace(
47
231
  question=question,
48
232
  response=response,
49
233
  conversation_id=conversation_id,
@@ -51,3 +235,9 @@ class ChatService:
51
235
  trace=trace,
52
236
  workspace_id=workspace_id,
53
237
  )
238
+ if notify is not None:
239
+ notify("assistant", response, source)
240
+ return trace_record
241
+
242
+
243
+ __all__ = ["ChatService"]