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.
- package/README.md +55 -43
- package/docs/CHANGELOG.md +61 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
- package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/PERFORMANCE.md +71 -18
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/architecture.md +6 -2
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/embeddings.py +12 -37
- package/lattice_brain/gates.py +125 -0
- package/lattice_brain/graph/discovery_index.py +30 -32
- package/lattice_brain/graph/fusion.py +35 -4
- package/lattice_brain/graph/image_vectors.py +230 -0
- package/lattice_brain/graph/ingest.py +11 -5
- package/lattice_brain/graph/projection.py +66 -8
- package/lattice_brain/graph/provenance.py +27 -2
- package/lattice_brain/graph/retrieval.py +113 -2
- package/lattice_brain/graph/retrieval_docgen.py +6 -6
- package/lattice_brain/graph/schema.py +18 -0
- package/lattice_brain/graph/store.py +9 -0
- package/lattice_brain/graph/vector_index/selector.py +32 -2
- package/lattice_brain/ingestion.py +363 -10
- package/lattice_brain/multimodal.py +1258 -0
- package/lattice_brain/portability.py +169 -32
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/sealed_box.py +244 -0
- package/lattice_brain/self_model.py +77 -22
- package/lattice_brain/synthesis.py +24 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +4 -0
- package/latticeai/api/chat.py +11 -0
- package/latticeai/api/chat_helpers.py +16 -3
- package/latticeai/api/chat_hybrid.py +32 -1
- package/latticeai/api/features.py +70 -0
- package/latticeai/api/local_files.py +102 -0
- package/latticeai/api/memory.py +128 -1
- package/latticeai/api/portability.py +39 -4
- package/latticeai/api/review_queue.py +126 -0
- package/latticeai/api/search.py +16 -2
- package/latticeai/core/agent.py +59 -2
- package/latticeai/core/agent_prompts.py +66 -0
- package/latticeai/core/config.py +4 -1
- package/latticeai/core/context_builder.py +98 -11
- package/latticeai/core/embedding_providers.py +528 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +180 -0
- package/latticeai/core/model_compat.py +73 -2
- package/latticeai/core/workspace_os.py +43 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_reorganization.py +335 -0
- package/latticeai/models/model_providers.py +12 -4
- package/latticeai/runtime/build_phases.py +33 -2
- package/latticeai/runtime/chat_wiring.py +4 -0
- package/latticeai/runtime/feature_toggle_wiring.py +163 -0
- package/latticeai/runtime/persistence_runtime.py +41 -4
- package/latticeai/runtime/router_registration.py +11 -0
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/app_context.py +8 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +22 -2
- package/latticeai/services/brain_intelligence.py +123 -7
- package/latticeai/services/change_proposals.py +50 -10
- package/latticeai/services/command_center.py +10 -4
- package/latticeai/services/feature_toggles.py +502 -0
- package/latticeai/services/folder_watch.py +122 -1
- package/latticeai/services/hybrid_chat.py +56 -5
- package/latticeai/services/interop_bridges.py +978 -0
- package/latticeai/services/memory_service.py +34 -0
- package/latticeai/services/model_capability_registry.py +434 -261
- package/latticeai/services/model_catalog.py +95 -61
- package/latticeai/services/model_recommendation.py +18 -11
- package/latticeai/services/model_runtime.py +1 -1
- package/latticeai/services/multimodal_ports.py +112 -0
- package/latticeai/services/obsidian_bridge.py +16 -25
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +149 -2
- package/latticeai/services/self_model_service.py +171 -0
- package/latticeai/services/tool_dispatch.py +4 -0
- package/latticeai/services/voice_capture.py +27 -1
- package/latticeai/setup/auto_setup.py +27 -30
- package/latticeai/setup/wizard.py +77 -44
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/release_screen_claims.json +22 -0
- package/scripts/verify_hf_model_registry.py +253 -218
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-D4zSxFR-.js → Act-AWf0SAKp.js} +1 -1
- package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D0u8Tiyj.js} +1 -1
- package/static/app/assets/{Brain-C2EqQg74.js → Brain-tuhI4sOC.js} +1 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
- package/static/app/assets/BrainSignals-jMYgQ2Ar.js +1 -0
- package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CqOSzyPr.js} +1 -1
- package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DC0Bzh-I.js} +1 -1
- package/static/app/assets/{Library-BN0HYOfc.js → Library-CX-bbhmK.js} +1 -1
- package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-DBwhto14.js} +1 -1
- package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-BHA2cfKI.js} +1 -1
- package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-BUhCKRNM.js} +1 -1
- package/static/app/assets/{System-BElUcSSw.js → System-Bu2t5hn1.js} +1 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
- package/static/app/assets/{bot--qYHMtkP.js → bot-Cia42c2h.js} +1 -1
- package/static/app/assets/brain-DJMoqrwx.js +1 -0
- package/static/app/assets/{button-51Z3rsuv.js → button-2j2Ijzgq.js} +1 -1
- package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-BEFeWpVW.js} +1 -1
- package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-ujXMcHxl.js} +1 -1
- package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-k4awryFq.js} +1 -1
- package/static/app/assets/{download-G-2olkWz.js → download-DFbLJ_ig.js} +1 -1
- package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-7y_b6xkM.js} +1 -1
- package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-Bidh02Kr.js} +1 -1
- package/static/app/assets/{index-D7Rr-J2Y.js → index-BpYkzcVm.js} +3 -3
- package/static/app/assets/index-DwDl9-8Y.css +2 -0
- package/static/app/assets/{input-D4w_BZWl.js → input-DSlJJxRs.js} +1 -1
- package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-Bpb83Hx9.js} +1 -1
- package/static/app/assets/{primitives-d0g9pvzS.js → primitives-BCx6TvfG.js} +1 -1
- package/static/app/assets/search-Cgy8cCFJ.js +1 -0
- package/static/app/assets/{share-2-NmD7e_oV.js → share-2-BH1M-WNi.js} +1 -1
- package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-BlKdBXcG.js} +1 -1
- package/static/app/assets/{textarea-BPAJDc-0.js → textarea-CCWbUfFB.js} +1 -1
- package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
- package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-CXQiwbVT.js} +1 -1
- package/static/app/assets/{utils-DG1_ExrP.js → utils-zqPZJxdx.js} +2 -2
- package/static/app/assets/{workspace-CWVf3gsI.js → workspace-DXTihhfU.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
- package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
- package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
- package/static/app/assets/brain-DDCLjRqO.js +0 -1
- package/static/app/assets/index-CkzokZAj.css +0 -2
- 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
|
-
|
|
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-
|
|
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
|
-
|
|
100
|
-
|
|
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=
|
|
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
|
+
]
|