ltcai 11.0.1 → 11.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +55 -43
  2. package/docs/CHANGELOG.md +61 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
  6. package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/PERFORMANCE.md +71 -18
  10. package/docs/TRUST_MODEL.md +1 -1
  11. package/docs/WHY_LATTICE.md +1 -1
  12. package/docs/architecture.md +6 -2
  13. package/docs/kg-schema.md +1 -1
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/embeddings.py +12 -37
  16. package/lattice_brain/gates.py +125 -0
  17. package/lattice_brain/graph/discovery_index.py +30 -32
  18. package/lattice_brain/graph/fusion.py +35 -4
  19. package/lattice_brain/graph/image_vectors.py +230 -0
  20. package/lattice_brain/graph/ingest.py +11 -5
  21. package/lattice_brain/graph/projection.py +66 -8
  22. package/lattice_brain/graph/provenance.py +27 -2
  23. package/lattice_brain/graph/retrieval.py +113 -2
  24. package/lattice_brain/graph/retrieval_docgen.py +6 -6
  25. package/lattice_brain/graph/schema.py +18 -0
  26. package/lattice_brain/graph/store.py +9 -0
  27. package/lattice_brain/graph/vector_index/selector.py +32 -2
  28. package/lattice_brain/ingestion.py +363 -10
  29. package/lattice_brain/multimodal.py +1258 -0
  30. package/lattice_brain/portability.py +169 -32
  31. package/lattice_brain/runtime/multi_agent.py +1 -1
  32. package/lattice_brain/sealed_box.py +244 -0
  33. package/lattice_brain/self_model.py +77 -22
  34. package/lattice_brain/synthesis.py +24 -1
  35. package/latticeai/__init__.py +1 -1
  36. package/latticeai/api/brain_intelligence.py +4 -0
  37. package/latticeai/api/chat.py +11 -0
  38. package/latticeai/api/chat_helpers.py +16 -3
  39. package/latticeai/api/chat_hybrid.py +32 -1
  40. package/latticeai/api/features.py +70 -0
  41. package/latticeai/api/local_files.py +102 -0
  42. package/latticeai/api/memory.py +128 -1
  43. package/latticeai/api/portability.py +39 -4
  44. package/latticeai/api/review_queue.py +126 -0
  45. package/latticeai/api/search.py +16 -2
  46. package/latticeai/core/agent.py +59 -2
  47. package/latticeai/core/agent_prompts.py +66 -0
  48. package/latticeai/core/config.py +4 -1
  49. package/latticeai/core/context_builder.py +98 -11
  50. package/latticeai/core/embedding_providers.py +528 -0
  51. package/latticeai/core/legacy_compatibility.py +1 -1
  52. package/latticeai/core/marketplace.py +1 -1
  53. package/latticeai/core/messages.py +180 -0
  54. package/latticeai/core/model_compat.py +73 -2
  55. package/latticeai/core/workspace_os.py +43 -0
  56. package/latticeai/core/workspace_os_constants.py +1 -1
  57. package/latticeai/core/workspace_reorganization.py +335 -0
  58. package/latticeai/models/model_providers.py +12 -4
  59. package/latticeai/runtime/build_phases.py +33 -2
  60. package/latticeai/runtime/chat_wiring.py +4 -0
  61. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  62. package/latticeai/runtime/persistence_runtime.py +41 -4
  63. package/latticeai/runtime/router_registration.py +11 -0
  64. package/latticeai/runtime/runtime_context.py +1 -0
  65. package/latticeai/services/app_context.py +8 -0
  66. package/latticeai/services/architecture_readiness.py +1 -1
  67. package/latticeai/services/automation_intelligence.py +22 -2
  68. package/latticeai/services/brain_intelligence.py +123 -7
  69. package/latticeai/services/change_proposals.py +50 -10
  70. package/latticeai/services/command_center.py +10 -4
  71. package/latticeai/services/feature_toggles.py +502 -0
  72. package/latticeai/services/folder_watch.py +122 -1
  73. package/latticeai/services/hybrid_chat.py +56 -5
  74. package/latticeai/services/interop_bridges.py +978 -0
  75. package/latticeai/services/memory_service.py +34 -0
  76. package/latticeai/services/model_capability_registry.py +434 -261
  77. package/latticeai/services/model_catalog.py +95 -61
  78. package/latticeai/services/model_recommendation.py +18 -11
  79. package/latticeai/services/model_runtime.py +1 -1
  80. package/latticeai/services/multimodal_ports.py +112 -0
  81. package/latticeai/services/obsidian_bridge.py +16 -25
  82. package/latticeai/services/product_readiness.py +1 -1
  83. package/latticeai/services/search_service.py +149 -2
  84. package/latticeai/services/self_model_service.py +171 -0
  85. package/latticeai/services/tool_dispatch.py +4 -0
  86. package/latticeai/services/voice_capture.py +27 -1
  87. package/latticeai/setup/auto_setup.py +27 -30
  88. package/latticeai/setup/wizard.py +77 -44
  89. package/package.json +1 -1
  90. package/scripts/check_current_release_docs.mjs +1 -1
  91. package/scripts/check_server_i18n.mjs +1 -0
  92. package/scripts/release_screen_claims.json +22 -0
  93. package/scripts/verify_hf_model_registry.py +253 -218
  94. package/src-tauri/Cargo.lock +1 -1
  95. package/src-tauri/Cargo.toml +1 -1
  96. package/src-tauri/tauri.conf.json +1 -1
  97. package/static/app/asset-manifest.json +37 -37
  98. package/static/app/assets/{Act-D4zSxFR-.js → Act-AWf0SAKp.js} +1 -1
  99. package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D0u8Tiyj.js} +1 -1
  100. package/static/app/assets/{Brain-C2EqQg74.js → Brain-tuhI4sOC.js} +1 -1
  101. package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
  102. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +1 -0
  103. package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CqOSzyPr.js} +1 -1
  104. package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DC0Bzh-I.js} +1 -1
  105. package/static/app/assets/{Library-BN0HYOfc.js → Library-CX-bbhmK.js} +1 -1
  106. package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-DBwhto14.js} +1 -1
  107. package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-BHA2cfKI.js} +1 -1
  108. package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-BUhCKRNM.js} +1 -1
  109. package/static/app/assets/{System-BElUcSSw.js → System-Bu2t5hn1.js} +1 -1
  110. package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
  111. package/static/app/assets/{bot--qYHMtkP.js → bot-Cia42c2h.js} +1 -1
  112. package/static/app/assets/brain-DJMoqrwx.js +1 -0
  113. package/static/app/assets/{button-51Z3rsuv.js → button-2j2Ijzgq.js} +1 -1
  114. package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-BEFeWpVW.js} +1 -1
  115. package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-ujXMcHxl.js} +1 -1
  116. package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-k4awryFq.js} +1 -1
  117. package/static/app/assets/{download-G-2olkWz.js → download-DFbLJ_ig.js} +1 -1
  118. package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-7y_b6xkM.js} +1 -1
  119. package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-Bidh02Kr.js} +1 -1
  120. package/static/app/assets/{index-D7Rr-J2Y.js → index-BpYkzcVm.js} +3 -3
  121. package/static/app/assets/index-DwDl9-8Y.css +2 -0
  122. package/static/app/assets/{input-D4w_BZWl.js → input-DSlJJxRs.js} +1 -1
  123. package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-Bpb83Hx9.js} +1 -1
  124. package/static/app/assets/{primitives-d0g9pvzS.js → primitives-BCx6TvfG.js} +1 -1
  125. package/static/app/assets/search-Cgy8cCFJ.js +1 -0
  126. package/static/app/assets/{share-2-NmD7e_oV.js → share-2-BH1M-WNi.js} +1 -1
  127. package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-BlKdBXcG.js} +1 -1
  128. package/static/app/assets/{textarea-BPAJDc-0.js → textarea-CCWbUfFB.js} +1 -1
  129. package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
  130. package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-CXQiwbVT.js} +1 -1
  131. package/static/app/assets/{utils-DG1_ExrP.js → utils-zqPZJxdx.js} +2 -2
  132. package/static/app/assets/{workspace-CWVf3gsI.js → workspace-DXTihhfU.js} +1 -1
  133. package/static/app/index.html +4 -4
  134. package/static/sw.js +1 -1
  135. package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
  136. package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
  137. package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
  138. package/static/app/assets/brain-DDCLjRqO.js +0 -1
  139. package/static/app/assets/index-CkzokZAj.css +0 -2
  140. package/static/app/assets/search-BLCYt75v.js +0 -1
@@ -0,0 +1,335 @@
1
+ """Agent-native workspace reorganization — proposals, never a silent tidy-up.
2
+
3
+ "이 프로젝트를 정리해줘" is a request an agent can now answer without being
4
+ trusted with the filesystem. The Brain looks at a folder, asks the graph what
5
+ each file is *about*, and stages one review proposal describing every move it
6
+ would make. Nothing on disk changes until a person approves it, and approving
7
+ applies exactly the moves that were reviewed.
8
+
9
+ Three rules make this safe enough to hand to a long-running session:
10
+
11
+ * **No deletions, ever.** The planner emits moves only. There is no code path
12
+ here that removes a file, so the worst outcome of a bad proposal is a file
13
+ in a folder you did not want (recoverable) rather than a file that is gone
14
+ (not). Plan §10's conservative mitigation, made structural.
15
+ * **Only what the Brain actually knows.** A file is moved because the graph
16
+ links it to a topic — never because of a guess from its extension. Files the
17
+ Brain has nothing to say about are reported as ``unplaced`` with a reason
18
+ instead of being swept somewhere plausible.
19
+ * **Deterministic.** The same folder and the same graph produce the same plan,
20
+ in the same order: candidates are sorted, topics are chosen by
21
+ (weight, title), and collisions resolve to the first path alphabetically.
22
+
23
+ Every path in and out of this module is workspace-relative and resolved
24
+ through the caller's sandboxed ``resolve_path`` (the agent-root resolver), so
25
+ a proposal can never name a target outside the workspace.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ import os
32
+ import re
33
+ from pathlib import Path
34
+ from typing import Any, Callable, Dict, List, Optional, Tuple
35
+
36
+ from latticeai.core.timeutil import now_iso as _now
37
+
38
+ LOGGER = logging.getLogger(__name__)
39
+
40
+ #: Review-queue kind for a reorganization proposal. Source stays
41
+ #: ``change_proposal`` so the Review Center's approve path applies it through
42
+ #: :class:`~latticeai.services.change_proposals.ChangeProposalService`.
43
+ REORG_KIND = "folder_reorganization"
44
+
45
+ #: Folder the plan groups into. One level, named after the topic, so the result
46
+ #: is legible without a map.
47
+ TOPIC_ROOT = "topics"
48
+
49
+ #: Node types that stand for a file on disk, and the ones that stand for what a
50
+ #: file is about.
51
+ FILE_NODE_TYPES = frozenset(
52
+ {
53
+ "File", "CodeFile", "Document", "Image", "Spreadsheet", "SlideDeck",
54
+ "ImageText", "Audio",
55
+ }
56
+ )
57
+ TOPIC_NODE_TYPES = frozenset({"Topic", "Concept", "Project", "Feature"})
58
+
59
+ DEFAULT_MAX_MOVES = 20
60
+ DEFAULT_SCAN_LIMIT = 400
61
+ DEFAULT_GRAPH_LIMIT = 800
62
+
63
+ _SLUG_RE = re.compile(r"[^0-9A-Za-z가-힣._-]+")
64
+
65
+
66
+ def _slug(value: str) -> str:
67
+ """Folder-safe topic name (Korean kept — the user reads these folders)."""
68
+ return _SLUG_RE.sub("-", str(value or "").strip()).strip("-.").lower()[:48]
69
+
70
+
71
+ def _rel(path: Path, base: Path) -> str:
72
+ return path.relative_to(base).as_posix()
73
+
74
+
75
+ def _scan(base: Path, limit: int) -> List[Path]:
76
+ """Visible files under ``base``, deepest-stable order, capped at ``limit``."""
77
+ found: List[Path] = []
78
+ for path in sorted(base.rglob("*")):
79
+ if len(found) >= limit:
80
+ break
81
+ if not path.is_file():
82
+ continue
83
+ if any(part.startswith(".") for part in path.relative_to(base).parts):
84
+ continue
85
+ found.append(path)
86
+ return found
87
+
88
+
89
+ def _file_node_index(graph: Any, limit: int) -> Tuple[Dict[str, str], str]:
90
+ """``{file name → graph node id}`` plus the basis for the plan.
91
+
92
+ A missing or unreadable graph yields an empty index and a basis that says
93
+ so, which is what makes an empty plan honest rather than mysterious.
94
+ """
95
+ if graph is None:
96
+ return {}, "no_graph"
97
+ try:
98
+ data = graph.graph(limit)
99
+ except Exception: # noqa: BLE001 — a broken read proposes nothing
100
+ LOGGER.exception("workspace reorganization graph read failed")
101
+ return {}, "graph_unavailable"
102
+ index: Dict[str, str] = {}
103
+ for node in data.get("nodes") or []:
104
+ if str(node.get("type")) not in FILE_NODE_TYPES:
105
+ continue
106
+ for key in _file_keys(node):
107
+ index.setdefault(key, str(node.get("id")))
108
+ return index, "graph"
109
+
110
+
111
+ def _topic_for(graph: Any, node_id: str) -> str:
112
+ """The topic a file node belongs to, or ``""`` when the Brain has none.
113
+
114
+ One hop, strongest edge first; ties break on the topic title so the same
115
+ graph always produces the same folder.
116
+ """
117
+ try:
118
+ data = graph.neighbors(node_id)
119
+ except Exception: # noqa: BLE001 — one unreadable node is not a failure
120
+ LOGGER.exception("workspace reorganization neighbour read failed")
121
+ return ""
122
+ by_id = {str(node.get("id")): node for node in data.get("neighbors") or []}
123
+ best: Optional[Tuple[float, str]] = None
124
+ for edge in data.get("edges") or []:
125
+ far = edge.get("to") if str(edge.get("from")) == node_id else edge.get("from")
126
+ node = by_id.get(str(far))
127
+ if node is None or str(node.get("type")) not in TOPIC_NODE_TYPES:
128
+ continue
129
+ title = str(node.get("title") or "").strip()
130
+ if not title:
131
+ continue
132
+ candidate = (-float(edge.get("weight") or 1.0), title)
133
+ if best is None or candidate < best:
134
+ best = candidate
135
+ return best[1] if best is not None else ""
136
+
137
+
138
+ def _file_keys(node: Dict[str, Any]) -> List[str]:
139
+ """Every name a graph file node might be matched by (lowercased)."""
140
+ metadata = node.get("metadata") or {}
141
+ candidates = [
142
+ metadata.get("relative_path"),
143
+ metadata.get("filename"),
144
+ metadata.get("path"),
145
+ node.get("title"),
146
+ ]
147
+ keys: List[str] = []
148
+ for candidate in candidates:
149
+ text = str(candidate or "").strip()
150
+ if not text:
151
+ continue
152
+ for key in (text.lower(), Path(text).name.lower()):
153
+ if key and key not in keys:
154
+ keys.append(key)
155
+ return keys
156
+
157
+
158
+ def plan_reorganization(
159
+ *,
160
+ root: str = "",
161
+ resolve_path: Callable[[str], Path],
162
+ graph: Any = None,
163
+ max_moves: int = DEFAULT_MAX_MOVES,
164
+ scan_limit: int = DEFAULT_SCAN_LIMIT,
165
+ graph_limit: int = DEFAULT_GRAPH_LIMIT,
166
+ ) -> Dict[str, Any]:
167
+ """What the Brain would move, and what it deliberately would not.
168
+
169
+ Read-only: this touches no file and creates no proposal.
170
+ """
171
+ base = resolve_path(root)
172
+ if not base.is_dir():
173
+ return _empty_plan(root, "folder_missing")
174
+ index, basis = _file_node_index(graph, graph_limit)
175
+ moves: List[Dict[str, Any]] = []
176
+ unplaced: List[Dict[str, Any]] = []
177
+ claimed: set = set()
178
+ for path in _scan(base, scan_limit):
179
+ source = _rel(path, base)
180
+ node_id = index.get(source.lower()) or index.get(path.name.lower())
181
+ topic = _topic_for(graph, node_id) if node_id else ""
182
+ if not topic:
183
+ unplaced.append({"path": source, "reason": "brain_has_no_topic"})
184
+ continue
185
+ target = f"{TOPIC_ROOT}/{_slug(topic)}/{path.name}"
186
+ if target == source:
187
+ unplaced.append({"path": source, "reason": "already_in_place"})
188
+ continue
189
+ if target in claimed or (base / target).exists():
190
+ unplaced.append({"path": source, "reason": "target_taken"})
191
+ continue
192
+ claimed.add(target)
193
+ moves.append({
194
+ "source": source,
195
+ "target": target,
196
+ "topic": topic,
197
+ "reason": f"'{topic}' 주제와 이어져 있습니다",
198
+ })
199
+ trimmed = len(moves) > max_moves
200
+ return {
201
+ "available": True,
202
+ "root": root,
203
+ "basis": basis,
204
+ "moves": moves[: max(0, int(max_moves))],
205
+ "move_count": len(moves[: max(0, int(max_moves))]),
206
+ "truncated": trimmed,
207
+ "unplaced": unplaced,
208
+ "unplaced_count": len(unplaced),
209
+ # Structural, not a policy toggle: this planner has no delete path.
210
+ "deletes": [],
211
+ "generated_at": _now(),
212
+ }
213
+
214
+
215
+ def _empty_plan(root: str, reason: str) -> Dict[str, Any]:
216
+ return {
217
+ "available": False,
218
+ "root": root,
219
+ "basis": reason,
220
+ "moves": [],
221
+ "move_count": 0,
222
+ "truncated": False,
223
+ "unplaced": [],
224
+ "unplaced_count": 0,
225
+ "deletes": [],
226
+ "generated_at": _now(),
227
+ }
228
+
229
+
230
+ def propose_reorganization(
231
+ *,
232
+ root: str = "",
233
+ resolve_path: Callable[[str], Path],
234
+ review_queue: Any,
235
+ graph: Any = None,
236
+ user_email: Optional[str] = None,
237
+ workspace_id: Optional[str] = None,
238
+ max_moves: int = DEFAULT_MAX_MOVES,
239
+ audit: Optional[Callable[..., None]] = None,
240
+ ) -> Dict[str, Any]:
241
+ """Stage one review proposal describing the whole reorganization.
242
+
243
+ Returns ``{"proposed": None, ...}`` when there is nothing to propose — an
244
+ empty proposal is noise, and a Brain that knows nothing about a folder
245
+ should say so rather than invent a structure.
246
+ """
247
+ plan = plan_reorganization(
248
+ root=root, resolve_path=resolve_path, graph=graph, max_moves=max_moves
249
+ )
250
+ if not plan["moves"]:
251
+ return {"proposed": None, "plan": plan, "reason": plan["basis"]}
252
+ preview = ", ".join(move["source"] for move in plan["moves"][:3])
253
+ item = review_queue.create(
254
+ title=f"폴더 정리 제안: {root or '작업 폴더'} 파일 {plan['move_count']}개",
255
+ summary=(
256
+ f"{preview} 등 {plan['move_count']}개 파일을 주제별 폴더로 옮기는 제안입니다. "
257
+ "삭제는 제안하지 않으며, 승인하기 전에는 아무것도 움직이지 않습니다."
258
+ ),
259
+ source="change_proposal",
260
+ kind=REORG_KIND,
261
+ payload={
262
+ "root": root,
263
+ "moves": plan["moves"],
264
+ "unplaced": plan["unplaced"],
265
+ "basis": plan["basis"],
266
+ "contract": {"moves": "proposal", "deletions": "never_proposed"},
267
+ },
268
+ provenance={
269
+ "proposed_by": "agent",
270
+ "reason": "workspace reorganization",
271
+ "source_detail": "brain topic grouping",
272
+ },
273
+ user_email=user_email,
274
+ workspace_id=workspace_id,
275
+ )
276
+ if audit is not None:
277
+ audit(
278
+ "workspace_reorg_proposed",
279
+ user_email=user_email,
280
+ proposal_id=item.get("id"),
281
+ moves=plan["move_count"],
282
+ )
283
+ return {"proposed": item, "plan": plan, "reason": ""}
284
+
285
+
286
+ def apply_reorganization(
287
+ payload: Dict[str, Any], *, resolve_path: Callable[[str], Path]
288
+ ) -> Dict[str, Any]:
289
+ """Carry out the reviewed moves. Nothing is ever deleted or overwritten.
290
+
291
+ Each move is checked again at apply time: a source that vanished and a
292
+ target that appeared are both reported as skipped rather than forced, so a
293
+ stale proposal degrades into an honest partial result.
294
+ """
295
+ applied: List[Dict[str, Any]] = []
296
+ skipped: List[Dict[str, Any]] = []
297
+ for move in payload.get("moves") or []:
298
+ source_rel = str(move.get("source") or "")
299
+ target_rel = str(move.get("target") or "")
300
+ if not source_rel or not target_rel:
301
+ skipped.append({"source": source_rel, "reason": "incomplete_move"})
302
+ continue
303
+ source = resolve_path(_join(payload, source_rel))
304
+ target = resolve_path(_join(payload, target_rel))
305
+ if not source.is_file():
306
+ skipped.append({"source": source_rel, "reason": "source_missing"})
307
+ continue
308
+ if target.exists():
309
+ skipped.append({"source": source_rel, "reason": "target_exists"})
310
+ continue
311
+ target.parent.mkdir(parents=True, exist_ok=True)
312
+ os.replace(source, target)
313
+ applied.append({"source": source_rel, "target": target_rel})
314
+ return {
315
+ "applied": applied,
316
+ "applied_count": len(applied),
317
+ "skipped": skipped,
318
+ "skipped_count": len(skipped),
319
+ "deleted": 0,
320
+ }
321
+
322
+
323
+ def _join(payload: Dict[str, Any], relative: str) -> str:
324
+ root = str(payload.get("root") or "").strip("/")
325
+ return f"{root}/{relative}" if root else relative
326
+
327
+
328
+ __all__ = [
329
+ "DEFAULT_MAX_MOVES",
330
+ "REORG_KIND",
331
+ "TOPIC_ROOT",
332
+ "apply_reorganization",
333
+ "plan_reorganization",
334
+ "propose_reorganization",
335
+ ]
@@ -32,7 +32,11 @@ OPENAI_COMPATIBLE_PROVIDERS = {
32
32
  "xai": {
33
33
  "env_key": "XAI_API_KEY",
34
34
  "base_url": "https://api.x.ai/v1",
35
- "default_model": "grok-beta",
35
+ # ``grok-beta`` was xAI's 2024 preview id and has been decommissioned;
36
+ # a request naming it now 404s at the provider. ``grok-4.5`` is the
37
+ # current flagship on api.x.ai and is multimodal, which is also why the
38
+ # separate ``grok-vision-beta`` row below is gone rather than renamed.
39
+ "default_model": "grok-4.5",
36
40
  },
37
41
  "ollama": {
38
42
  "env_key": "OLLAMA_API_KEY",
@@ -83,7 +87,7 @@ PROVIDER_MODEL_CATALOG = {
83
87
  {"id": "anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 via OpenRouter", "family": "Claude"},
84
88
  {"id": "qwen/qwen3-vl-235b-a22b-instruct", "name": "Qwen3-VL 235B A22B via OpenRouter", "family": "Qwen"},
85
89
  {"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B via OpenRouter", "family": "Gemma"},
86
- {"id": "x-ai/grok-2", "name": "Grok 2 via OpenRouter", "family": "Grok"},
90
+ {"id": "x-ai/grok-4.5", "name": "Grok 4.5 via OpenRouter", "family": "Grok"},
87
91
  {"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout via OpenRouter", "family": "Llama"},
88
92
  {"id": "google/gemini-2.5-flash", "name": "Gemini 2.5 Flash via OpenRouter", "family": "Gemini"},
89
93
  ],
@@ -96,8 +100,12 @@ PROVIDER_MODEL_CATALOG = {
96
100
  {"id": "meta-llama/Llama-4-Scout-17B-16E-Instruct", "name": "Llama 4 Scout", "family": "Llama"},
97
101
  ],
98
102
  "xai": [
99
- {"id": "grok-beta", "name": "Grok Beta", "family": "Grok"},
100
- {"id": "grok-vision-beta", "name": "Grok Vision Beta", "family": "Grok"},
103
+ # One current generation only, per MODEL_POLICY's "do not keep old
104
+ # same-family generations" rule. The retired preview ids
105
+ # (grok-beta / grok-vision-beta) are not carried as fallbacks: a model
106
+ # the provider no longer serves is not compatibility, it is a 404 the
107
+ # user has to discover for themselves.
108
+ {"id": "grok-4.5", "name": "Grok 4.5", "family": "Grok"},
101
109
  ],
102
110
  }
103
111
 
@@ -542,6 +542,7 @@ def phase_brain(ctx: RuntimeContext) -> None:
542
542
  "BRAIN_INTELLIGENCE",
543
543
  "AUTOMATION_INTELLIGENCE",
544
544
  "INGESTION_PIPELINE",
545
+ "MULTIMODAL_PORTS",
545
546
  "DEVICE_IDENTITY",
546
547
  "KG_PORTABILITY",
547
548
  "FUNNEL_METRICS",
@@ -620,6 +621,29 @@ def phase_domain(ctx: RuntimeContext) -> None:
620
621
  )
621
622
 
622
623
 
624
+ def self_model_port(workspace_graph: Any) -> Any:
625
+ """The agent loop's Self-Model port (v11.2.0).
626
+
627
+ 11.1.0 built ``executor_prompt_for(self_model_summary=…)`` and had nothing
628
+ to pass it; this is what the composition root passes. ``summary_for_prompt``
629
+ never raises and answers ``""`` for a Brain that knows nothing about its
630
+ owner — which produces exactly the pre-11.2.0 prompt bytes.
631
+
632
+ A module-level factory rather than a closure inside ``phase_services``
633
+ because a port that cannot be called on its own cannot be tested on its own.
634
+ """
635
+
636
+ def _summary(*, user_email: Any = None, workspace_id: Any = None) -> str:
637
+ from lattice_brain.self_model import summary_for_prompt
638
+
639
+ graph = workspace_graph()
640
+ if graph is None:
641
+ return ""
642
+ return summary_for_prompt(graph, workspace_id=workspace_id)
643
+
644
+ return _summary
645
+
646
+
623
647
  # ── phase 6b: retrieval, agent runtime, and the typed AppContext ─────────────
624
648
  def phase_services(ctx: RuntimeContext) -> None:
625
649
  """Retrieval/context assembly, the chat agent runtime, and the AppContext.
@@ -755,6 +779,9 @@ def phase_services(ctx: RuntimeContext) -> None:
755
779
  audit=ctx.append_audit_event,
756
780
  hooks=ctx.HOOKS_REGISTRY,
757
781
  brain_memory=ctx.BRAIN_MEMORY,
782
+ self_model_summary=self_model_port(
783
+ lambda: ctx.KNOWLEDGE_GRAPH if ctx.ENABLE_GRAPH else None
784
+ ),
758
785
  )
759
786
  )
760
787
 
@@ -802,6 +829,8 @@ def phase_services(ctx: RuntimeContext) -> None:
802
829
  require_graph=ctx._require_graph,
803
830
  workspace_graph=ctx._workspace_graph,
804
831
  graph_stats=ctx._graph_stats_safe,
832
+ # Provider, not a value: REVIEW_QUEUE lands two phases later.
833
+ review_queue=lambda: ctx.REVIEW_QUEUE,
805
834
  workspace_models=_workspace_models_payload,
806
835
  workspace_settings=_workspace_settings_payload,
807
836
  scan_environment=scan_environment,
@@ -1197,11 +1226,13 @@ def phase_platform_features(ctx: RuntimeContext) -> None:
1197
1226
 
1198
1227
  # Voice memo capture (v9.9.7): the shortest path from a thought to the
1199
1228
  # Brain. Transcription is an optional local port — absent, the memo is
1200
- # still stored and the response says it is not searchable.
1229
+ # still stored and the response says it is not searchable. v11.1.0 shares
1230
+ # that one port with multi-modal ingestion, so a memo and a scanned
1231
+ # recording can never disagree about whether this machine can hear.
1201
1232
  ctx.set(
1202
1233
  VOICE_CAPTURE=VoiceCaptureService(
1203
1234
  pipeline=ctx.INGESTION_PIPELINE if ctx.ENABLE_GRAPH else None,
1204
- transcriber=None,
1235
+ transcriber=ctx.MULTIMODAL_PORTS.transcriber,
1205
1236
  )
1206
1237
  )
1207
1238
  ctx.app.include_router(
@@ -25,6 +25,7 @@ def build_chat_agent_runtime_from_context(
25
25
  audit: Any,
26
26
  hooks: Any,
27
27
  brain_memory: Any,
28
+ self_model_summary: Any = None,
28
29
  ) -> Any:
29
30
  # Ensure dispatch + agent share the same autonomy dial before the runtime
30
31
  # is constructed (process-wide service; data_dir refined later at router mount).
@@ -39,6 +40,9 @@ def build_chat_agent_runtime_from_context(
39
40
  hooks=hooks,
40
41
  brain_memory=brain_memory,
41
42
  permission_mode=resolve_active_permission_mode,
43
+ # v11.2.0: a scoped resolver, not a snapshot — the profile a run sees is
44
+ # the one the Brain holds when the run starts, per user and workspace.
45
+ self_model_summary=self_model_summary,
42
46
  )
43
47
 
44
48
 
@@ -0,0 +1,163 @@
1
+ """Wire the feature switchboard into the running app (v11.2.0).
2
+
3
+ Same shape as ``permission_mode_wiring``: one process-wide service, an
4
+ idempotent router mount guarded on ``app.state``, and explicit arguments that
5
+ *rebind* an already-created service rather than being dropped — a lazy first
6
+ caller must not pin the store to a fallback data dir with no audit sink.
7
+
8
+ The one job unique to this module is :func:`bind_feature_gates`: it points each
9
+ opt-in gate's resolver at the service, which is the step that turns a persisted
10
+ preference into behaviour. Every gate is imported inside the function so
11
+ importing this module costs nothing; ``lattice_brain.synthesis`` in particular
12
+ drags in the graph layer.
13
+
14
+ Unbinding is a supported operation (:func:`unbind_feature_gates`) because these
15
+ gates are module-level singletons: a test that bound them must be able to give
16
+ them back, and "the environment answers again" has to be reachable.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import threading
23
+ from pathlib import Path
24
+ from typing import Any, Callable, List, Optional, Tuple
25
+
26
+ from latticeai.services.feature_toggles import FeatureToggleService
27
+
28
+ _LOCK = threading.Lock()
29
+ _SHARED: Optional[FeatureToggleService] = None
30
+
31
+ #: ``feature id -> (module, attribute)`` for every boolean gate. Resolved lazily
32
+ #: by :func:`_boolean_gates` so the table stays readable next to the catalog it
33
+ #: mirrors, and a rename that breaks the pairing fails loudly at wiring time.
34
+ GATE_BINDINGS: Tuple[Tuple[str, str, str], ...] = (
35
+ ("allow_multimodal", "lattice_brain.ingestion", "MULTIMODAL_GATE"),
36
+ ("video_ingest", "lattice_brain.ingestion", "VIDEO_GATE"),
37
+ ("auto_vector_index", "lattice_brain.ingestion", "AUTO_VECTOR_INDEX_GATE"),
38
+ ("brain_network", "lattice_brain.portability", "BRAIN_NETWORK_GATE"),
39
+ ("synthesis", "lattice_brain.synthesis", "SYNTHESIS_GATE"),
40
+ ("fusion_rrf", "lattice_brain.graph.fusion", "FUSION_RRF_GATE"),
41
+ ("graph_expansion", "lattice_brain.graph.fusion", "GRAPH_EXPANSION_GATE"),
42
+ ("vault_watch", "latticeai.services.folder_watch", "VAULT_WATCH_GATE"),
43
+ ("auto_late_fusion", "latticeai.services.search_service", "IMAGE_QUERY_FUSION_GATE"),
44
+ )
45
+
46
+ #: The one non-boolean seam: the vector backend is a pick-one-of-three.
47
+ CHOICE_FEATURE = "vector_backend"
48
+
49
+
50
+ def _default_data_dir() -> Path:
51
+ raw = os.environ.get("LATTICEAI_DATA_DIR", "").strip()
52
+ if raw:
53
+ return Path(raw)
54
+ return Path.home() / ".ltcai"
55
+
56
+
57
+ def _boolean_gates() -> List[Tuple[str, Any]]:
58
+ """``[(feature id, gate)]`` — imports are here, not at module import."""
59
+ from importlib import import_module
60
+
61
+ return [
62
+ (feature_id, getattr(import_module(module), attribute))
63
+ for feature_id, module, attribute in GATE_BINDINGS
64
+ ]
65
+
66
+
67
+ def _bind_vector_index(resolver: Any) -> None:
68
+ from lattice_brain.graph.vector_index.selector import bind_vector_index_resolver
69
+
70
+ bind_vector_index_resolver(resolver)
71
+
72
+
73
+ def get_feature_toggle_service(
74
+ *,
75
+ data_dir: Optional[Path] = None,
76
+ audit: Optional[Callable[..., None]] = None,
77
+ ) -> FeatureToggleService:
78
+ """Process-wide singleton; explicit arguments rebind an existing one."""
79
+ global _SHARED
80
+ with _LOCK:
81
+ if _SHARED is None:
82
+ _SHARED = FeatureToggleService(
83
+ data_dir=Path(data_dir) if data_dir is not None else _default_data_dir(),
84
+ audit=audit,
85
+ )
86
+ return _SHARED
87
+ if data_dir is not None:
88
+ _SHARED.rebind_data_dir(Path(data_dir))
89
+ if audit is not None:
90
+ _SHARED.rebind_audit(audit)
91
+ return _SHARED
92
+
93
+
94
+ def bind_feature_gates(service: Optional[FeatureToggleService] = None) -> None:
95
+ """Point every opt-in gate at the switchboard.
96
+
97
+ After this, a persisted preference beats the environment variable for every
98
+ feature in the catalog — which is the whole point, since the panel is the
99
+ only control surface most people will ever use.
100
+ """
101
+ resolved = service or get_feature_toggle_service()
102
+ for feature_id, gate in _boolean_gates():
103
+ # ``gate.local`` is the fallback on purpose: the switchboard speaks only
104
+ # for switches this person actually moved. Everything else still answers
105
+ # from the gate's own override → env → default, so binding changes
106
+ # nothing at all for an install that never opened the panel.
107
+ gate.bind(resolved.resolver(feature_id, gate.local))
108
+ _bind_vector_index(resolved.choice_resolver(CHOICE_FEATURE))
109
+
110
+
111
+ def unbind_feature_gates() -> None:
112
+ """Hand every gate back to its environment variable."""
113
+ for _feature_id, gate in _boolean_gates():
114
+ gate.bind(None)
115
+ _bind_vector_index(None)
116
+
117
+
118
+ def reset_feature_toggle_service() -> None:
119
+ """Drop the singleton *and* the bindings that pointed at it (tests)."""
120
+ global _SHARED
121
+ unbind_feature_gates()
122
+ with _LOCK:
123
+ _SHARED = None
124
+
125
+
126
+ def register_features_router(
127
+ app: Any,
128
+ *,
129
+ require_user: Callable[..., str],
130
+ data_dir: Optional[Path] = None,
131
+ append_audit_event: Optional[Callable[..., None]] = None,
132
+ ) -> FeatureToggleService:
133
+ """Install GET/POST /api/features on ``app`` and bind the gates. Idempotent.
134
+
135
+ The guard is a flag on ``app.state`` rather than a scan of route paths: a
136
+ router included through fastapi >= 0.140 has no flat ``path`` to introspect,
137
+ so path scanning silently stopped seeing the mount (see
138
+ ``permission_mode_wiring`` for the same note).
139
+ """
140
+ from latticeai.api.features import create_features_router
141
+
142
+ service = get_feature_toggle_service(data_dir=data_dir, audit=append_audit_event)
143
+ bind_feature_gates(service)
144
+ state = getattr(app, "state", None)
145
+ if state is not None and getattr(state, "_ltcai_features_mounted", False):
146
+ return service
147
+ app.include_router(
148
+ create_features_router(service=service, require_user=require_user)
149
+ )
150
+ if state is not None:
151
+ state._ltcai_features_mounted = True
152
+ return service
153
+
154
+
155
+ __all__ = [
156
+ "CHOICE_FEATURE",
157
+ "GATE_BINDINGS",
158
+ "bind_feature_gates",
159
+ "get_feature_toggle_service",
160
+ "register_features_router",
161
+ "reset_feature_toggle_service",
162
+ "unbind_feature_gates",
163
+ ]