ltcai 10.0.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +48 -32
  2. package/docs/CHANGELOG.md +156 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/HYBRID_CLOUD_KG_STREAMING.md +101 -0
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +1 -1
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/archive.py +5 -4
  13. package/lattice_brain/conversations.py +14 -3
  14. package/lattice_brain/embeddings.py +12 -2
  15. package/lattice_brain/graph/_kg_common.py +5 -5
  16. package/lattice_brain/graph/_kg_fsutil.py +4 -3
  17. package/lattice_brain/graph/discovery.py +4 -3
  18. package/lattice_brain/graph/discovery_index.py +0 -1
  19. package/lattice_brain/graph/documents.py +3 -2
  20. package/lattice_brain/graph/fusion.py +4 -0
  21. package/lattice_brain/graph/ingest.py +12 -2
  22. package/lattice_brain/graph/projection.py +3 -2
  23. package/lattice_brain/graph/provenance.py +5 -3
  24. package/lattice_brain/graph/rerank.py +4 -1
  25. package/lattice_brain/graph/retrieval.py +0 -1
  26. package/lattice_brain/graph/retrieval_docgen.py +0 -1
  27. package/lattice_brain/graph/retrieval_reads.py +0 -1
  28. package/lattice_brain/graph/retrieval_vector.py +0 -1
  29. package/lattice_brain/graph/schema.py +4 -3
  30. package/lattice_brain/graph/store.py +18 -4
  31. package/lattice_brain/graph/write_master.py +46 -1
  32. package/lattice_brain/ingestion.py +4 -1
  33. package/lattice_brain/portability.py +5 -2
  34. package/lattice_brain/quality.py +12 -5
  35. package/lattice_brain/quiet.py +43 -0
  36. package/lattice_brain/runtime/agent_runtime.py +12 -8
  37. package/lattice_brain/runtime/hooks.py +2 -1
  38. package/lattice_brain/runtime/multi_agent.py +2 -3
  39. package/lattice_brain/sensitivity.py +94 -0
  40. package/lattice_brain/storage/base.py +30 -2
  41. package/lattice_brain/storage/migration.py +3 -2
  42. package/lattice_brain/storage/postgres.py +2 -2
  43. package/lattice_brain/storage/sqlite.py +6 -4
  44. package/lattice_brain/workflow.py +4 -2
  45. package/latticeai/__init__.py +1 -1
  46. package/latticeai/api/admin.py +1 -1
  47. package/latticeai/api/agents.py +4 -2
  48. package/latticeai/api/auth.py +5 -1
  49. package/latticeai/api/automation_intelligence.py +2 -1
  50. package/latticeai/api/browser.py +3 -2
  51. package/latticeai/api/chat.py +28 -17
  52. package/latticeai/api/chat_agent_http.py +22 -8
  53. package/latticeai/api/chat_contracts.py +4 -0
  54. package/latticeai/api/chat_documents.py +6 -2
  55. package/latticeai/api/chat_hybrid.py +82 -0
  56. package/latticeai/api/computer_use.py +8 -3
  57. package/latticeai/api/knowledge_graph.py +1 -1
  58. package/latticeai/api/mcp.py +4 -4
  59. package/latticeai/api/models.py +5 -2
  60. package/latticeai/api/network_boundary.py +220 -0
  61. package/latticeai/api/permissions.py +0 -1
  62. package/latticeai/api/realtime.py +1 -1
  63. package/latticeai/api/security_dashboard.py +1 -1
  64. package/latticeai/api/setup.py +16 -3
  65. package/latticeai/api/static_routes.py +2 -1
  66. package/latticeai/api/tools.py +12 -8
  67. package/latticeai/api/voice_capture.py +3 -1
  68. package/latticeai/api/workflow_designer.py +1 -1
  69. package/latticeai/api/workspace.py +1 -2
  70. package/latticeai/app_factory.py +131 -78
  71. package/latticeai/cli/entrypoint.py +6 -4
  72. package/latticeai/core/agent.py +55 -495
  73. package/latticeai/core/agent_eval.py +2 -2
  74. package/latticeai/core/agent_helpers.py +493 -0
  75. package/latticeai/core/agent_prompts.py +0 -1
  76. package/latticeai/core/agent_registry.py +3 -1
  77. package/latticeai/core/agent_state.py +41 -0
  78. package/latticeai/core/audit.py +1 -1
  79. package/latticeai/core/builtin_hooks.py +2 -1
  80. package/latticeai/core/config.py +0 -1
  81. package/latticeai/core/embedding_providers.py +12 -1
  82. package/latticeai/core/file_generation.py +3 -0
  83. package/latticeai/core/invitations.py +4 -1
  84. package/latticeai/core/io_utils.py +3 -1
  85. package/latticeai/core/legacy_compatibility.py +1 -2
  86. package/latticeai/core/local_embeddings.py +12 -1
  87. package/latticeai/core/marketplace.py +1 -2
  88. package/latticeai/core/mcp_registry.py +0 -1
  89. package/latticeai/core/model_compat.py +1 -1
  90. package/latticeai/core/model_resolution.py +1 -1
  91. package/latticeai/core/network_boundary.py +168 -0
  92. package/latticeai/core/oidc.py +3 -0
  93. package/latticeai/core/permission_mode.py +6 -6
  94. package/latticeai/core/plugins.py +3 -2
  95. package/latticeai/core/policy.py +0 -1
  96. package/latticeai/core/quiet.py +84 -0
  97. package/latticeai/core/realtime.py +3 -2
  98. package/latticeai/core/run_store.py +4 -2
  99. package/latticeai/core/security.py +4 -0
  100. package/latticeai/core/users.py +5 -3
  101. package/latticeai/core/workspace_graph_trace.py +3 -0
  102. package/latticeai/core/workspace_os.py +65 -273
  103. package/latticeai/core/workspace_os_constants.py +126 -0
  104. package/latticeai/core/workspace_os_state.py +180 -0
  105. package/latticeai/core/workspace_os_utils.py +3 -1
  106. package/latticeai/core/workspace_permissions.py +1 -0
  107. package/latticeai/core/workspace_snapshots.py +3 -1
  108. package/latticeai/core/workspace_timeline.py +3 -1
  109. package/latticeai/integrations/telegram_bot.py +25 -16
  110. package/latticeai/models/router.py +6 -3
  111. package/latticeai/runtime/access_runtime.py +3 -1
  112. package/latticeai/runtime/audit_runtime.py +3 -2
  113. package/latticeai/runtime/chat_wiring.py +4 -1
  114. package/latticeai/runtime/history_runtime.py +1 -1
  115. package/latticeai/runtime/lifespan_runtime.py +3 -1
  116. package/latticeai/runtime/network_boundary_wiring.py +124 -0
  117. package/latticeai/runtime/persistence_runtime.py +3 -1
  118. package/latticeai/runtime/router_registration.py +11 -1
  119. package/latticeai/services/architecture_readiness.py +1 -2
  120. package/latticeai/services/change_proposals.py +2 -1
  121. package/latticeai/services/cloud_egress_audit.py +85 -0
  122. package/latticeai/services/cloud_extraction.py +129 -0
  123. package/latticeai/services/cloud_streaming.py +266 -0
  124. package/latticeai/services/cloud_token_guard.py +84 -0
  125. package/latticeai/services/command_center.py +2 -1
  126. package/latticeai/services/folder_watch.py +3 -0
  127. package/latticeai/services/funnel_metrics.py +3 -1
  128. package/latticeai/services/hybrid_chat.py +265 -0
  129. package/latticeai/services/hybrid_context.py +228 -0
  130. package/latticeai/services/hybrid_policy.py +178 -0
  131. package/latticeai/services/memory_service.py +1 -1
  132. package/latticeai/services/model_catalog.py +10 -1
  133. package/latticeai/services/model_engines.py +35 -14
  134. package/latticeai/services/model_loading.py +3 -2
  135. package/latticeai/services/model_runtime.py +68 -17
  136. package/latticeai/services/multimodal_streaming.py +123 -0
  137. package/latticeai/services/network_boundary_service.py +154 -0
  138. package/latticeai/services/openai_compatible_adapter.py +100 -0
  139. package/latticeai/services/p_reinforce.py +4 -0
  140. package/latticeai/services/platform_runtime.py +9 -4
  141. package/latticeai/services/process_audit.py +0 -1
  142. package/latticeai/services/product_readiness.py +1 -1
  143. package/latticeai/services/run_executor.py +3 -2
  144. package/latticeai/services/search_service.py +1 -4
  145. package/latticeai/services/setup_detection.py +2 -1
  146. package/latticeai/services/tool_dispatch.py +7 -2
  147. package/latticeai/services/upload_service.py +2 -1
  148. package/latticeai/setup/auto_setup.py +10 -7
  149. package/latticeai/setup/wizard.py +15 -11
  150. package/latticeai/tools/__init__.py +3 -3
  151. package/latticeai/tools/commands.py +7 -7
  152. package/latticeai/tools/computer.py +3 -2
  153. package/latticeai/tools/documents.py +10 -9
  154. package/latticeai/tools/filesystem.py +7 -4
  155. package/latticeai/tools/knowledge.py +2 -0
  156. package/latticeai/tools/local_files.py +1 -1
  157. package/latticeai/tools/network.py +2 -1
  158. package/package.json +5 -3
  159. package/scripts/bench_agent_smoke.py +0 -1
  160. package/scripts/bench_models.py +0 -1
  161. package/scripts/brain_quality_eval.py +7 -2
  162. package/scripts/bump_version.py +2 -1
  163. package/scripts/check_current_release_docs.mjs +1 -1
  164. package/scripts/migrate_brain_storage.py +5 -1
  165. package/scripts/profile_kg.py +2 -7
  166. package/scripts/verify_hf_model_registry.py +2 -2
  167. package/src-tauri/Cargo.lock +1 -1
  168. package/src-tauri/Cargo.toml +1 -1
  169. package/src-tauri/tauri.conf.json +1 -1
  170. package/static/app/asset-manifest.json +37 -37
  171. package/static/app/assets/{Act-BtCREeN1.js → Act-CbdGD-2i.js} +1 -1
  172. package/static/app/assets/{AdminConsole-TPeeN18T.js → AdminConsole-LgCkXpnf.js} +1 -1
  173. package/static/app/assets/{Brain-BKs6JAp0.js → Brain-CoPGJI1L.js} +1 -1
  174. package/static/app/assets/{BrainHome-BPGOvSd6.js → BrainHome-gVnaxSwp.js} +1 -1
  175. package/static/app/assets/{BrainSignals-CtzQZ15J.js → BrainSignals-ChxAYHtj.js} +1 -1
  176. package/static/app/assets/{Capture-1_NaHWqB.js → Capture-DBtgkHZg.js} +1 -1
  177. package/static/app/assets/{CommandPalette-pqvQOXe4.js → CommandPalette-Ovtv5I0Y.js} +1 -1
  178. package/static/app/assets/{Library-DhvoPvC7.js → Library-CBia2Fvm.js} +1 -1
  179. package/static/app/assets/{LivingBrain-DlQ20Q75.js → LivingBrain-CNz-6NSd.js} +1 -1
  180. package/static/app/assets/{ProductFlow-BZvGDRi_.js → ProductFlow-ePX-Y73J.js} +1 -1
  181. package/static/app/assets/{ReviewCard-BWgI0D2s.js → ReviewCard-C4mpvkwH.js} +1 -1
  182. package/static/app/assets/System-BvWNK1zc.js +1 -0
  183. package/static/app/assets/{activity-Dlfk8YC7.js → activity-CiauPV_3.js} +1 -1
  184. package/static/app/assets/{bot-CDvUB76P.js → bot-Dwct-Q1x.js} +1 -1
  185. package/static/app/assets/{brain-xczrohrt.js → brain-BKDW1F0T.js} +1 -1
  186. package/static/app/assets/{button-SOdH3Oyf.js → button-CCB9Kuw7.js} +1 -1
  187. package/static/app/assets/{circle-pause-CG1ythH4.js → circle-pause-DNa8WHC5.js} +1 -1
  188. package/static/app/assets/{circle-play-HXwvjS6W.js → circle-play-8jmxn5mf.js} +1 -1
  189. package/static/app/assets/{cpu-B0d-rGyk.js → cpu-mxvF3V1I.js} +1 -1
  190. package/static/app/assets/{download-BGIkTQL6.js → download--SmCcx0p.js} +1 -1
  191. package/static/app/assets/{folder-open-Dst_Z0_K.js → folder-open-D4Wy5roo.js} +1 -1
  192. package/static/app/assets/{hard-drive-D53MsWkV.js → hard-drive-BwQcSPlS.js} +1 -1
  193. package/static/app/assets/index-CQWdDU3z.css +2 -0
  194. package/static/app/assets/{index-C_IrlQMV.js → index-DDV2YZwM.js} +3 -3
  195. package/static/app/assets/{input-C5m0riF6.js → input-DFhSjmLS.js} +1 -1
  196. package/static/app/assets/{network-C5a-E5iS.js → network-Bts7VO94.js} +1 -1
  197. package/static/app/assets/{primitives-vNXYf58F.js → primitives-BuSMEJY8.js} +1 -1
  198. package/static/app/assets/search-DZzxhWaQ.js +1 -0
  199. package/static/app/assets/{shield-alert-Cc-WVXqN.js → shield-alert-BfTO6X_G.js} +1 -1
  200. package/static/app/assets/{textarea-BkZ0EqVO.js → textarea-BjctW1oh.js} +1 -1
  201. package/static/app/assets/{useFocusTrap-CTtKbAOU.js → useFocusTrap-CE43-LrA.js} +1 -1
  202. package/static/app/assets/{useQuery-Dx1fi4Wu.js → useQuery-Bv3ejLL5.js} +1 -1
  203. package/static/app/assets/{users-BFpQXtEF.js → users-CsNqLZAj.js} +1 -1
  204. package/static/app/assets/{utils-BA_lmW3J.js → utils-KFFdVG_t.js} +2 -2
  205. package/static/app/assets/workspace-wdCvdyPF.js +1 -0
  206. package/static/app/index.html +4 -4
  207. package/static/sw.js +1 -1
  208. package/static/app/assets/System-CSMdYLMy.js +0 -1
  209. package/static/app/assets/index-FxDusbr0.css +0 -2
  210. package/static/app/assets/search-DhbSgW6m.js +0 -1
  211. package/static/app/assets/workspace-DBPB0jkX.js +0 -1
@@ -0,0 +1,85 @@
1
+ """Audit record for the one path where knowledge leaves the machine.
2
+
3
+ Mode *changes* were audited from 10.1.0; the send itself was not. For a product
4
+ whose central claim is that nothing leaves without consent, the absence of a
5
+ record on the only egress path meant the claim could not be checked after the
6
+ fact — not by the user, not by anyone reviewing an incident.
7
+
8
+ What is recorded is deliberately about *shape*, never content: which node ids
9
+ went, how many, the token estimate, the provider and model, the resolved mode,
10
+ and the scope. The compact payload text is not recorded — writing the outbound
11
+ knowledge into a second on-disk location to prove we were careful with it would
12
+ be its own leak.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import threading
19
+ from typing import Any, Callable, Dict, List, Optional
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _LOCK = threading.Lock()
24
+ _AUDIT: Optional[Callable[..., None]] = None
25
+
26
+
27
+ def bind_egress_audit(audit: Optional[Callable[..., None]]) -> None:
28
+ """Install the process-wide audit sink (called from runtime wiring)."""
29
+ global _AUDIT
30
+ with _LOCK:
31
+ _AUDIT = audit
32
+
33
+
34
+ def _sink() -> Optional[Callable[..., None]]:
35
+ with _LOCK:
36
+ return _AUDIT
37
+
38
+
39
+ def record_cloud_egress(
40
+ *,
41
+ node_ids: List[str],
42
+ token_estimate: int,
43
+ mode: str,
44
+ provider: str,
45
+ model: Optional[str] = None,
46
+ user_email: Optional[str] = None,
47
+ workspace_id: Optional[str] = None,
48
+ outcome: str = "sent",
49
+ detail: Optional[str] = None,
50
+ ) -> Dict[str, Any]:
51
+ """Record one cloud send (or refusal) and return the event.
52
+
53
+ Returns the event even when no sink is bound so callers and tests can
54
+ assert on it. A failing audit sink never blocks or breaks the turn — but it
55
+ is logged at warning, because a silent audit failure is the same as no
56
+ audit at all.
57
+ """
58
+ event: Dict[str, Any] = {
59
+ "event": "cloud_egress",
60
+ "outcome": outcome,
61
+ "mode": mode,
62
+ "provider": provider,
63
+ "model": model,
64
+ "node_ids": list(node_ids),
65
+ "node_count": len(node_ids),
66
+ "token_estimate": int(token_estimate),
67
+ "user_email": user_email,
68
+ "workspace_id": workspace_id,
69
+ }
70
+ if detail:
71
+ event["detail"] = detail
72
+
73
+ sink = _sink()
74
+ if sink is None:
75
+ # Not an error: unit tests and headless helpers run without wiring.
76
+ logger.debug("cloud egress (no audit sink bound): %s", event)
77
+ return event
78
+ try:
79
+ sink(**event)
80
+ except Exception: # noqa: BLE001
81
+ logger.warning("cloud egress audit sink failed; the send still happened", exc_info=True)
82
+ return event
83
+
84
+
85
+ __all__ = ["bind_egress_audit", "record_cloud_egress"]
@@ -0,0 +1,129 @@
1
+ """Richer candidate extraction from cloud answers (Phase 2).
2
+
3
+ Deterministic, dependency-free heuristics that turn a cloud answer into
4
+ candidate Concept / Decision / Task nodes. These are always staged with
5
+ provenance and never auto-committed in Phase 2.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any, Dict, List
12
+
13
+ from latticeai.services.cloud_streaming import (
14
+ CloudTurnResult,
15
+ KGExpansionPlan,
16
+ plan_kg_expansion,
17
+ )
18
+
19
+ _DECISION_PATTERNS = (
20
+ re.compile(r"(?im)^(?:decision|결정)\s*[::-]\s*(.+)$"),
21
+ re.compile(r"(?im)we (?:decided|agreed|chose) to (.+?)(?:\.|$)"),
22
+ re.compile(r"(?im)(?:결정(?:했|하)|합의)(?:습니다|다)?\s*[::]?\s*(.+)"),
23
+ )
24
+ _TASK_PATTERNS = (
25
+ re.compile(r"(?im)^(?:todo|task|할\s*일|다음)\s*[::-]\s*(.+)$"),
26
+ re.compile(r"(?im)^[-*]\s+\[(?: |x|X)\]\s*(.+)$"),
27
+ re.compile(r"(?im)^\d+[.)]\s+(.+)$"),
28
+ )
29
+ _CONCEPT_PATTERNS = (
30
+ re.compile(r"(?im)^(?:concept|개념|용어)\s*[::-]\s*(.+)$"),
31
+ re.compile(r"\*\*([^*]{2,80})\*\*"),
32
+ )
33
+
34
+
35
+ def _clean(text: str, limit: int = 200) -> str:
36
+ return re.sub(r"\s+", " ", str(text or "").strip())[:limit]
37
+
38
+
39
+ def extract_candidates(answer: str, *, limit: int = 8) -> List[Dict[str, Any]]:
40
+ """Return lightweight candidate nodes derived from the answer text."""
41
+ text = str(answer or "")
42
+ out: List[Dict[str, Any]] = []
43
+ seen: set[str] = set()
44
+
45
+ def add(node_type: str, title: str, summary: str = "") -> None:
46
+ title = _clean(title, 120)
47
+ if not title:
48
+ return
49
+ key = f"{node_type}:{title.lower()}"
50
+ if key in seen:
51
+ return
52
+ seen.add(key)
53
+ out.append(
54
+ {
55
+ "type": node_type,
56
+ "title": title,
57
+ "summary": _clean(summary or title, 400),
58
+ "metadata": {
59
+ "derived_from_cloud": True,
60
+ "extraction": "heuristic_v1",
61
+ "confidence": 0.55,
62
+ },
63
+ }
64
+ )
65
+
66
+ for pattern in _DECISION_PATTERNS:
67
+ for match in pattern.finditer(text):
68
+ add("Decision", match.group(1))
69
+ if len(out) >= limit:
70
+ return out
71
+
72
+ for pattern in _TASK_PATTERNS:
73
+ for match in pattern.finditer(text):
74
+ add("Task", match.group(1))
75
+ if len(out) >= limit:
76
+ return out
77
+
78
+ for pattern in _CONCEPT_PATTERNS:
79
+ for match in pattern.finditer(text):
80
+ add("Concept", match.group(1))
81
+ if len(out) >= limit:
82
+ return out
83
+
84
+ return out
85
+
86
+
87
+ def plan_kg_expansion_rich(result: CloudTurnResult) -> KGExpansionPlan:
88
+ """Base conversation plan + heuristic Concept/Decision/Task candidates."""
89
+ plan = plan_kg_expansion(result)
90
+ candidates = extract_candidates(result.answer_text)
91
+ turn_id = plan.new_nodes[0]["id"] if plan.new_nodes else "cloud_turn:unknown"
92
+
93
+ for idx, cand in enumerate(candidates):
94
+ node_id = f"{turn_id}:cand:{idx}"
95
+ node = {
96
+ "id": node_id,
97
+ **cand,
98
+ }
99
+ plan.new_nodes.append(node)
100
+ plan.new_edges.append(
101
+ {
102
+ "from": turn_id,
103
+ "to": node_id,
104
+ "type": "implies",
105
+ "weight": 0.6,
106
+ "metadata": {"provenance": "cloud_extraction"},
107
+ }
108
+ )
109
+ for src in result.sent_node_ids:
110
+ plan.new_edges.append(
111
+ {
112
+ "from": node_id,
113
+ "to": src,
114
+ "type": "grounded_on",
115
+ "weight": 0.5,
116
+ "metadata": {"provenance": "cloud_extraction"},
117
+ }
118
+ )
119
+
120
+ plan.provenance = {
121
+ **plan.provenance,
122
+ "candidate_count": len(candidates),
123
+ "extraction": "heuristic_v1",
124
+ }
125
+ plan.auto_commit = False
126
+ return plan
127
+
128
+
129
+ __all__ = ["extract_candidates", "plan_kg_expansion_rich"]
@@ -0,0 +1,266 @@
1
+ """Cloud streaming bridge and Knowledge Graph expansion for hybrid turns.
2
+
3
+ When NetworkBoundaryMode is CLOUD_ALLOWED:
4
+
5
+ 1. MinimalContext is sent to a cloud LLM.
6
+ 2. The response is streamed back to the UI.
7
+ 3. On completion the answer expands the local KG with provenance.
8
+
9
+ Phase 3: CloudResponseIngestor can enqueue into the Review Queue and honor
10
+ hybrid policy auto_commit.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, AsyncIterator, Dict, List, Optional, Protocol
17
+
18
+ from latticeai.core.network_boundary import (
19
+ NetworkBoundaryMode,
20
+ normalize_network_mode,
21
+ )
22
+ from latticeai.services.hybrid_context import MinimalContext
23
+
24
+
25
+ @dataclass
26
+ class CloudTurnResult:
27
+ """Completed cloud turn ready for local KG expansion."""
28
+
29
+ user_message: str
30
+ answer_text: str
31
+ sent_node_ids: List[str] = field(default_factory=list)
32
+ provider: str = ""
33
+ model: str = ""
34
+ usage: Dict[str, Any] = field(default_factory=dict)
35
+ raw_events: List[Dict[str, Any]] = field(default_factory=list)
36
+
37
+ def to_dict(self) -> Dict[str, Any]:
38
+ return {
39
+ "user_message": self.user_message,
40
+ "answer_text": self.answer_text,
41
+ "sent_node_ids": list(self.sent_node_ids),
42
+ "provider": self.provider,
43
+ "model": self.model,
44
+ "usage": dict(self.usage),
45
+ }
46
+
47
+
48
+ class CloudLLMAdapter(Protocol):
49
+ async def stream(
50
+ self,
51
+ *,
52
+ system: str,
53
+ user: str,
54
+ context: str,
55
+ model: Optional[str] = None,
56
+ ) -> AsyncIterator[str]:
57
+ ...
58
+
59
+
60
+ class CloudStreamingBridge:
61
+ def __init__(self, adapter: Optional[CloudLLMAdapter] = None) -> None:
62
+ self._adapter = adapter
63
+
64
+ async def run_turn(
65
+ self,
66
+ *,
67
+ user_message: str,
68
+ minimal: MinimalContext,
69
+ mode: NetworkBoundaryMode | str,
70
+ system_prompt: str = (
71
+ "You are assisting a user whose private Knowledge Graph lives on their machine. "
72
+ "Use only the provided context. If the context is insufficient, say so honestly."
73
+ ),
74
+ model: Optional[str] = None,
75
+ ) -> CloudTurnResult:
76
+ mode = normalize_network_mode(mode)
77
+ if mode != NetworkBoundaryMode.CLOUD_ALLOWED:
78
+ raise PermissionError(
79
+ "CloudStreamingBridge refuses to call a cloud provider while "
80
+ f"NetworkBoundaryMode is {mode.value!r}"
81
+ )
82
+ if self._adapter is None:
83
+ answer = (
84
+ "[cloud adapter not configured] "
85
+ "This turn would have streamed a cloud response grounded on the "
86
+ f"{len(minimal.node_ids)} local node(s) that were selected."
87
+ )
88
+ return CloudTurnResult(
89
+ user_message=user_message,
90
+ answer_text=answer,
91
+ sent_node_ids=list(minimal.node_ids),
92
+ provider="none",
93
+ model=model or "",
94
+ )
95
+
96
+ chunks: List[str] = []
97
+ async for piece in self._adapter.stream(
98
+ system=system_prompt,
99
+ user=user_message,
100
+ context=minimal.compact_text,
101
+ model=model,
102
+ ):
103
+ chunks.append(piece)
104
+ answer = "".join(chunks)
105
+ return CloudTurnResult(
106
+ user_message=user_message,
107
+ answer_text=answer,
108
+ sent_node_ids=list(minimal.node_ids),
109
+ provider=getattr(self._adapter, "provider_name", "cloud"),
110
+ model=model or getattr(self._adapter, "default_model", ""),
111
+ )
112
+
113
+
114
+ @dataclass
115
+ class KGExpansionPlan:
116
+ conversation_title: str
117
+ new_nodes: List[Dict[str, Any]] = field(default_factory=list)
118
+ new_edges: List[Dict[str, Any]] = field(default_factory=list)
119
+ provenance: Dict[str, Any] = field(default_factory=dict)
120
+ auto_commit: bool = False
121
+
122
+ def to_dict(self) -> Dict[str, Any]:
123
+ return {
124
+ "conversation_title": self.conversation_title,
125
+ "new_nodes": list(self.new_nodes),
126
+ "new_edges": list(self.new_edges),
127
+ "provenance": dict(self.provenance),
128
+ "auto_commit": self.auto_commit,
129
+ }
130
+
131
+
132
+ def plan_kg_expansion(result: CloudTurnResult) -> KGExpansionPlan:
133
+ turn_id = f"cloud_turn:{abs(hash((result.user_message, result.answer_text))) % (10**12)}"
134
+ conv_node = {
135
+ "id": turn_id,
136
+ "type": "Chat",
137
+ "title": (result.user_message or "Cloud turn")[:120],
138
+ "summary": (result.answer_text or "")[:800],
139
+ "metadata": {
140
+ "source": "cloud_llm",
141
+ "provider": result.provider,
142
+ "model": result.model,
143
+ "sent_node_ids": list(result.sent_node_ids),
144
+ "derived_from_cloud": True,
145
+ },
146
+ }
147
+ edges: List[Dict[str, Any]] = []
148
+ for nid in result.sent_node_ids:
149
+ edges.append(
150
+ {
151
+ "from": turn_id,
152
+ "to": nid,
153
+ "type": "grounded_on",
154
+ "weight": 1.0,
155
+ "metadata": {"provenance": "cloud_turn"},
156
+ }
157
+ )
158
+
159
+ return KGExpansionPlan(
160
+ conversation_title=conv_node["title"],
161
+ new_nodes=[conv_node],
162
+ new_edges=edges,
163
+ provenance={
164
+ "kind": "derived_from_cloud",
165
+ "sent_node_ids": list(result.sent_node_ids),
166
+ "provider": result.provider,
167
+ "model": result.model,
168
+ },
169
+ auto_commit=False,
170
+ )
171
+
172
+
173
+ class CloudResponseIngestor:
174
+ """Applies a KGExpansionPlan to the local store and/or Review Queue.
175
+
176
+ Phase 3 behaviour:
177
+
178
+ * always stages a Review Queue item (change_proposal) when a review_queue
179
+ sink is bound — so the user can approve cloud-derived memory growth
180
+ * if ``plan.auto_commit`` and a store is bound, also attempts a direct write
181
+ """
182
+
183
+ def __init__(
184
+ self,
185
+ store: Any = None,
186
+ *,
187
+ review_queue: Any = None,
188
+ user_email: Optional[str] = None,
189
+ workspace_id: Optional[str] = None,
190
+ ) -> None:
191
+ self._store = store
192
+ self._review_queue = review_queue
193
+ self._user_email = user_email
194
+ self._workspace_id = workspace_id
195
+
196
+ def ingest(self, plan: KGExpansionPlan) -> Dict[str, Any]:
197
+ result: Dict[str, Any] = {
198
+ "status": "staged",
199
+ "plan": plan.to_dict(),
200
+ "review_item_id": None,
201
+ "written_nodes": 0,
202
+ "written_edges": 0,
203
+ }
204
+
205
+ if self._review_queue is not None:
206
+ try:
207
+ item = self._review_queue.create(
208
+ title=f"Cloud KG expansion: {plan.conversation_title[:80]}",
209
+ summary=(
210
+ f"{len(plan.new_nodes)} node(s), {len(plan.new_edges)} edge(s) "
211
+ f"derived from cloud LLM (auto_commit={plan.auto_commit})"
212
+ ),
213
+ source="change_proposal",
214
+ kind="kg_cloud_expansion",
215
+ payload={
216
+ "plan": plan.to_dict(),
217
+ "auto_commit": plan.auto_commit,
218
+ },
219
+ provenance={
220
+ **plan.provenance,
221
+ "source": "hybrid_cloud",
222
+ },
223
+ user_email=self._user_email,
224
+ workspace_id=self._workspace_id,
225
+ )
226
+ result["review_item_id"] = item.get("id")
227
+ result["status"] = "queued_for_review"
228
+ except Exception as exc: # noqa: BLE001
229
+ result["review_error"] = str(exc)
230
+
231
+ if plan.auto_commit and self._store is not None:
232
+ written_nodes = 0
233
+ written_edges = 0
234
+ try:
235
+ write_fn = getattr(self._store, "upsert_nodes", None) or getattr(
236
+ self._store, "ingest_nodes", None
237
+ )
238
+ if callable(write_fn):
239
+ write_fn(plan.new_nodes, plan.new_edges)
240
+ written_nodes = len(plan.new_nodes)
241
+ written_edges = len(plan.new_edges)
242
+ result["status"] = "accepted"
243
+ else:
244
+ # Soft accept: store present but no known write API.
245
+ result["status"] = result.get("status") or "accepted_soft"
246
+ written_nodes = len(plan.new_nodes)
247
+ written_edges = len(plan.new_edges)
248
+ except Exception as exc: # noqa: BLE001
249
+ result["write_error"] = str(exc)
250
+ result["written_nodes"] = written_nodes
251
+ result["written_edges"] = written_edges
252
+
253
+ if result["status"] == "staged" and self._store is None and self._review_queue is None:
254
+ result["reason"] = "no store or review_queue bound"
255
+
256
+ return result
257
+
258
+
259
+ __all__ = [
260
+ "CloudTurnResult",
261
+ "CloudLLMAdapter",
262
+ "CloudStreamingBridge",
263
+ "KGExpansionPlan",
264
+ "plan_kg_expansion",
265
+ "CloudResponseIngestor",
266
+ ]
@@ -0,0 +1,84 @@
1
+ """Token / cost guardrails for hybrid cloud turns (Phase 2).
2
+
3
+ Keeps cloud usage bounded:
4
+
5
+ * per-turn token estimate must stay under ``max_tokens_per_turn``
6
+ * optional session budget (in-memory, process-local) tracks cumulative usage
7
+
8
+ These are soft product guardrails, not hard billing meters. Real provider
9
+ usage is still authoritative when the cloud returns usage fields.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import threading
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Dict, Optional
18
+
19
+
20
+ def _env_int(name: str, default: int) -> int:
21
+ raw = os.environ.get(name, "").strip()
22
+ if not raw:
23
+ return default
24
+ try:
25
+ return max(0, int(raw))
26
+ except ValueError:
27
+ return default
28
+
29
+
30
+ @dataclass
31
+ class TokenBudget:
32
+ max_tokens_per_turn: int = field(
33
+ default_factory=lambda: _env_int("LATTICEAI_CLOUD_MAX_TOKENS_PER_TURN", 2500)
34
+ )
35
+ max_tokens_per_session: int = field(
36
+ default_factory=lambda: _env_int("LATTICEAI_CLOUD_MAX_TOKENS_PER_SESSION", 50000)
37
+ )
38
+ session_used: int = 0
39
+
40
+ def check_turn(self, estimated_input_tokens: int) -> Optional[str]:
41
+ """Return a refusal reason, or None if the turn is allowed."""
42
+ if estimated_input_tokens > self.max_tokens_per_turn:
43
+ return (
44
+ f"estimated input tokens {estimated_input_tokens} exceed "
45
+ f"per-turn limit {self.max_tokens_per_turn}"
46
+ )
47
+ if self.session_used + estimated_input_tokens > self.max_tokens_per_session:
48
+ return (
49
+ f"session budget would exceed {self.max_tokens_per_session} "
50
+ f"(used={self.session_used}, turn={estimated_input_tokens})"
51
+ )
52
+ return None
53
+
54
+ def record(self, tokens: int) -> None:
55
+ self.session_used += max(0, int(tokens or 0))
56
+
57
+ def snapshot(self) -> Dict[str, Any]:
58
+ return {
59
+ "max_tokens_per_turn": self.max_tokens_per_turn,
60
+ "max_tokens_per_session": self.max_tokens_per_session,
61
+ "session_used": self.session_used,
62
+ "session_remaining": max(0, self.max_tokens_per_session - self.session_used),
63
+ }
64
+
65
+
66
+ _LOCK = threading.Lock()
67
+ _BUDGETS: Dict[str, TokenBudget] = {}
68
+
69
+
70
+ def budget_for(scope_key: str) -> TokenBudget:
71
+ """Process-local budget keyed by user|workspace."""
72
+ key = str(scope_key or "global")
73
+ with _LOCK:
74
+ if key not in _BUDGETS:
75
+ _BUDGETS[key] = TokenBudget()
76
+ return _BUDGETS[key]
77
+
78
+
79
+ def reset_budget(scope_key: str) -> None:
80
+ with _LOCK:
81
+ _BUDGETS.pop(str(scope_key or "global"), None)
82
+
83
+
84
+ __all__ = ["TokenBudget", "budget_for", "reset_budget"]
@@ -26,7 +26,8 @@ import logging
26
26
  from datetime import datetime, timedelta
27
27
  from typing import Any, Dict, List, Optional
28
28
 
29
- from latticeai.core.timeutil import local_now, now_iso as _now, parse_iso
29
+ from latticeai.core.timeutil import local_now, parse_iso
30
+ from latticeai.core.timeutil import now_iso as _now
30
31
 
31
32
  LOGGER = logging.getLogger(__name__)
32
33
 
@@ -39,6 +39,7 @@ from lattice_brain.ingestion import (
39
39
  _load_latticeignore,
40
40
  _matches_ignore,
41
41
  )
42
+ from latticeai.core.quiet import quiet
42
43
  from latticeai.core.timeutil import now_iso as _now_iso
43
44
 
44
45
  WATCH_INTERVAL_ENV = "LATTICEAI_FOLDER_WATCH_INTERVAL"
@@ -339,6 +340,7 @@ class FolderWatchService:
339
340
  try:
340
341
  stat = path.stat()
341
342
  except OSError:
343
+ quiet()
342
344
  continue
343
345
  if stat.st_size > DEFAULT_MAX_FILE_BYTES:
344
346
  continue
@@ -390,6 +392,7 @@ class FolderWatchService:
390
392
  try:
391
393
  self.scan_once(watch_id)
392
394
  except Exception: # noqa: BLE001 — one bad watch must not kill the poller
395
+ quiet()
393
396
  continue
394
397
 
395
398
 
@@ -37,6 +37,8 @@ from datetime import datetime, timezone
37
37
  from pathlib import Path
38
38
  from typing import Any, Dict, Optional
39
39
 
40
+ from latticeai.core.quiet import quiet
41
+
40
42
  LOGGER = logging.getLogger(__name__)
41
43
 
42
44
  COUNTER_NAMES = (
@@ -124,7 +126,7 @@ class FunnelMetricsService:
124
126
  try:
125
127
  os.unlink(tmp_name)
126
128
  except OSError:
127
- pass
129
+ quiet()
128
130
  except Exception as exc: # noqa: BLE001 — metrics persistence is best-effort
129
131
  LOGGER.warning("funnel metrics save failed: %s", exc)
130
132