ltcai 11.0.1 → 11.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -43
- package/docs/CHANGELOG.md +28 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -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/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/embeddings.py +12 -37
- package/lattice_brain/graph/discovery_index.py +30 -32
- package/lattice_brain/graph/image_vectors.py +230 -0
- package/lattice_brain/graph/ingest.py +11 -5
- 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 +9 -0
- package/lattice_brain/ingestion.py +209 -4
- package/lattice_brain/multimodal.py +738 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/self_model.py +77 -22
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/memory.py +128 -1
- package/latticeai/core/agent.py +5 -1
- package/latticeai/core/agent_prompts.py +66 -0
- package/latticeai/core/context_builder.py +92 -8
- 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 +37 -0
- 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/runtime/build_phases.py +5 -2
- package/latticeai/runtime/persistence_runtime.py +41 -4
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/change_proposals.py +50 -10
- package/latticeai/services/memory_service.py +34 -0
- package/latticeai/services/multimodal_ports.py +87 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/self_model_service.py +171 -0
- package/latticeai/services/voice_capture.py +27 -1
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/release_screen_claims.json +9 -0
- 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-D0HWqtn0.js} +1 -1
- package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D-QDW-A4.js} +1 -1
- package/static/app/assets/{Brain-C2EqQg74.js → Brain-CzCsI1mi.js} +1 -1
- package/static/app/assets/BrainHome-Btns-_TA.js +2 -0
- package/static/app/assets/BrainSignals-2dHQNkns.js +1 -0
- package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CT8v1StE.js} +1 -1
- package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DoLXC2KH.js} +1 -1
- package/static/app/assets/{Library-BN0HYOfc.js → Library-DDoxFE5c.js} +1 -1
- package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-BXMWIK_2.js} +1 -1
- package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-DOYf7JIs.js} +1 -1
- package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-COQsqidK.js} +1 -1
- package/static/app/assets/{System-BElUcSSw.js → System-BRllvYXd.js} +1 -1
- package/static/app/assets/arrow-left-DnyMzss-.js +1 -0
- package/static/app/assets/{bot--qYHMtkP.js → bot-4BvN07ux.js} +1 -1
- package/static/app/assets/brain-uMb_5hnO.js +1 -0
- package/static/app/assets/{button-51Z3rsuv.js → button-CDjtnAoU.js} +1 -1
- package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-D_RMn7tp.js} +1 -1
- package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-B5OpB8ae.js} +1 -1
- package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-BIlWInHf.js} +1 -1
- package/static/app/assets/{download-G-2olkWz.js → download-BtjXfL3z.js} +1 -1
- package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-DefMpxI2.js} +1 -1
- package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-BQ8NZVkw.js} +1 -1
- package/static/app/assets/{index-D7Rr-J2Y.js → index-0AvoEBzJ.js} +3 -3
- package/static/app/assets/index-vtEfYvQY.css +2 -0
- package/static/app/assets/{input-D4w_BZWl.js → input-B_5ZJ9oy.js} +1 -1
- package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-BqZ5tsgL.js} +1 -1
- package/static/app/assets/{primitives-d0g9pvzS.js → primitives-CVwew78r.js} +1 -1
- package/static/app/assets/search-DkhnOKZt.js +1 -0
- package/static/app/assets/{share-2-NmD7e_oV.js → share-2-D5zg_0fY.js} +1 -1
- package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-B5pZzkUb.js} +1 -1
- package/static/app/assets/{textarea-BPAJDc-0.js → textarea-nEVIweKY.js} +1 -1
- package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-Cm99AHlz.js} +1 -1
- package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-Dm__N6bL.js} +1 -1
- package/static/app/assets/{utils-DG1_ExrP.js → utils-DcDMoZIe.js} +1 -1
- package/static/app/assets/{workspace-CWVf3gsI.js → workspace-LtRRSKTf.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
|
+
]
|
|
@@ -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",
|
|
@@ -1197,11 +1198,13 @@ def phase_platform_features(ctx: RuntimeContext) -> None:
|
|
|
1197
1198
|
|
|
1198
1199
|
# Voice memo capture (v9.9.7): the shortest path from a thought to the
|
|
1199
1200
|
# Brain. Transcription is an optional local port — absent, the memo is
|
|
1200
|
-
# still stored and the response says it is not searchable.
|
|
1201
|
+
# still stored and the response says it is not searchable. v11.1.0 shares
|
|
1202
|
+
# that one port with multi-modal ingestion, so a memo and a scanned
|
|
1203
|
+
# recording can never disagree about whether this machine can hear.
|
|
1201
1204
|
ctx.set(
|
|
1202
1205
|
VOICE_CAPTURE=VoiceCaptureService(
|
|
1203
1206
|
pipeline=ctx.INGESTION_PIPELINE if ctx.ENABLE_GRAPH else None,
|
|
1204
|
-
transcriber=
|
|
1207
|
+
transcriber=ctx.MULTIMODAL_PORTS.transcriber,
|
|
1205
1208
|
)
|
|
1206
1209
|
)
|
|
1207
1210
|
ctx.app.include_router(
|
|
@@ -42,6 +42,10 @@ def build_persistence_runtime(
|
|
|
42
42
|
from latticeai.services.brain_intelligence import BrainIntelligenceService
|
|
43
43
|
from latticeai.services.funnel_metrics import FunnelMetricsService
|
|
44
44
|
from latticeai.services.memory_service import MemoryService
|
|
45
|
+
from latticeai.services.multimodal_ports import (
|
|
46
|
+
build_multimodal_ports,
|
|
47
|
+
multimodal_enabled,
|
|
48
|
+
)
|
|
45
49
|
from latticeai.services.workspace_service import WorkspaceService
|
|
46
50
|
|
|
47
51
|
realtime_bus = RealtimeBus()
|
|
@@ -80,21 +84,53 @@ def build_persistence_runtime(
|
|
|
80
84
|
funnel_metrics = FunnelMetricsService(Path(data_dir) / "funnel_metrics.json")
|
|
81
85
|
|
|
82
86
|
def _funnel_audit(action, detail, user):
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
"""Best-effort sinks for a landed ingest, then the real audit.
|
|
88
|
+
|
|
89
|
+
Both sinks are isolated separately: one failing must not cost the
|
|
90
|
+
other, and neither may cost the ingest that is already persisted.
|
|
91
|
+
"""
|
|
92
|
+
if action == "kg_ingest":
|
|
93
|
+
try:
|
|
85
94
|
funnel_metrics.record_ingest(
|
|
86
95
|
duplicate=bool((detail or {}).get("duplicate"))
|
|
87
96
|
)
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
except Exception: # noqa: BLE001 — metrics must never break ingestion
|
|
98
|
+
quiet()
|
|
99
|
+
# v11.1.0: the same seam drives synthesis. The pipeline audits an
|
|
100
|
+
# ingest only after the graph write landed, so ``status: "ok"`` is
|
|
101
|
+
# a fact about this call site rather than a guess; ``duplicate``
|
|
102
|
+
# travels in the detail and is what keeps a re-import from pushing
|
|
103
|
+
# the Brain toward a pass it has nothing new to say in.
|
|
104
|
+
try:
|
|
105
|
+
brain_intelligence.note_ingest(
|
|
106
|
+
{"status": "ok", **(detail or {})}, user_email=user
|
|
107
|
+
)
|
|
108
|
+
except Exception: # noqa: BLE001 — synthesis must never break ingestion
|
|
109
|
+
quiet()
|
|
90
110
|
audit(action, detail, user)
|
|
91
111
|
|
|
112
|
+
# Multi-modal capture (v11.1.0): off unless the user turned it on, and
|
|
113
|
+
# even then only as capable as the models actually present. With nothing
|
|
114
|
+
# configured this resolves to an empty bundle without importing or loading
|
|
115
|
+
# anything.
|
|
116
|
+
multimodal_ports = build_multimodal_ports()
|
|
92
117
|
ingestion_pipeline = IngestionPipeline(
|
|
93
118
|
knowledge_graph,
|
|
94
119
|
hooks=hooks_registry,
|
|
95
120
|
enable_graph=enable_graph,
|
|
96
121
|
audit=_funnel_audit,
|
|
122
|
+
allow_multimodal=multimodal_enabled(),
|
|
123
|
+
multimodal=multimodal_ports,
|
|
97
124
|
)
|
|
125
|
+
# The local folder scanner reads images through the same ports, so a
|
|
126
|
+
# caption in the Evidence panel and a caption on a scanned screenshot come
|
|
127
|
+
# from one model or from none. A disabled/absent graph has nothing to
|
|
128
|
+
# attach them to, and a store that refuses new attributes is not a reason
|
|
129
|
+
# to fail startup.
|
|
130
|
+
try:
|
|
131
|
+
knowledge_graph.multimodal_ports = multimodal_ports
|
|
132
|
+
except AttributeError:
|
|
133
|
+
quiet()
|
|
98
134
|
device_identity = DeviceIdentity(data_dir)
|
|
99
135
|
kg_portability = KGPortabilityService(
|
|
100
136
|
knowledge_graph=knowledge_graph,
|
|
@@ -116,6 +152,7 @@ def build_persistence_runtime(
|
|
|
116
152
|
"BRAIN_INTELLIGENCE": brain_intelligence,
|
|
117
153
|
"AUTOMATION_INTELLIGENCE": automation_intelligence,
|
|
118
154
|
"INGESTION_PIPELINE": ingestion_pipeline,
|
|
155
|
+
"MULTIMODAL_PORTS": multimodal_ports,
|
|
119
156
|
"DEVICE_IDENTITY": device_identity,
|
|
120
157
|
"KG_PORTABILITY": kg_portability,
|
|
121
158
|
"FUNNEL_METRICS": funnel_metrics,
|
|
@@ -31,6 +31,11 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
31
31
|
|
|
32
32
|
from latticeai.core.quiet import quiet
|
|
33
33
|
from latticeai.core.tool_governor import classify_tool_call
|
|
34
|
+
from latticeai.core.workspace_reorganization import (
|
|
35
|
+
REORG_KIND,
|
|
36
|
+
apply_reorganization,
|
|
37
|
+
propose_reorganization,
|
|
38
|
+
)
|
|
34
39
|
|
|
35
40
|
LOGGER = logging.getLogger(__name__)
|
|
36
41
|
|
|
@@ -301,6 +306,33 @@ class ChangeProposalService:
|
|
|
301
306
|
)
|
|
302
307
|
return item
|
|
303
308
|
|
|
309
|
+
def propose_reorganization(
|
|
310
|
+
self,
|
|
311
|
+
*,
|
|
312
|
+
root: str = "",
|
|
313
|
+
graph: Any = None,
|
|
314
|
+
user_email: Optional[str] = None,
|
|
315
|
+
workspace_id: Optional[str] = None,
|
|
316
|
+
max_moves: int = 20,
|
|
317
|
+
) -> Dict[str, Any]:
|
|
318
|
+
"""Stage a whole-folder reorganization as one reviewable proposal.
|
|
319
|
+
|
|
320
|
+
Moves only — see :mod:`latticeai.core.workspace_reorganization`. It
|
|
321
|
+
goes through this service (rather than the review queue directly) so
|
|
322
|
+
approving it from the Review Center applies it the same way every
|
|
323
|
+
other staged change is applied.
|
|
324
|
+
"""
|
|
325
|
+
return propose_reorganization(
|
|
326
|
+
root=root,
|
|
327
|
+
resolve_path=self._resolve_path,
|
|
328
|
+
review_queue=self._review_queue,
|
|
329
|
+
graph=graph,
|
|
330
|
+
user_email=user_email,
|
|
331
|
+
workspace_id=workspace_id,
|
|
332
|
+
max_moves=max_moves,
|
|
333
|
+
audit=self._audit,
|
|
334
|
+
)
|
|
335
|
+
|
|
304
336
|
# ── listing / apply / reject ─────────────────────────────────────────
|
|
305
337
|
|
|
306
338
|
def pending(
|
|
@@ -362,8 +394,8 @@ class ChangeProposalService:
|
|
|
362
394
|
raise KeyError(f"not a change proposal: {item_id}")
|
|
363
395
|
payload = item.get("payload") or {}
|
|
364
396
|
kind = str(item.get("kind") or "")
|
|
365
|
-
path = str(payload.get("path") or "")
|
|
366
|
-
if kind not in ("file_update", "file_delete"):
|
|
397
|
+
path = str(payload.get("path") or payload.get("root") or "")
|
|
398
|
+
if kind not in ("file_update", "file_delete", REORG_KIND):
|
|
367
399
|
raise ValueError(f"unknown change proposal kind: {kind}")
|
|
368
400
|
|
|
369
401
|
# Duplicate/concurrent approval guard: a resolved proposal must
|
|
@@ -375,20 +407,28 @@ class ChangeProposalService:
|
|
|
375
407
|
rebase_hint="이미 처리된 제안입니다. 다시 적용할 수 없습니다.",
|
|
376
408
|
)
|
|
377
409
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
410
|
+
moves: Optional[Dict[str, Any]] = None
|
|
411
|
+
if kind == REORG_KIND:
|
|
412
|
+
# A reorganization has no single base file to hash: each move
|
|
413
|
+
# is re-checked at apply time and a drifted one is skipped,
|
|
414
|
+
# never forced (see workspace_reorganization.apply_*).
|
|
415
|
+
moves = apply_reorganization(payload, resolve_path=self._resolve_path)
|
|
416
|
+
else:
|
|
417
|
+
self._check_base_unchanged(payload, path=path, kind=kind)
|
|
418
|
+
target = self._resolve_path(path)
|
|
419
|
+
if kind == "file_update":
|
|
420
|
+
self._atomic_write(target, str(payload.get("new_content") or ""))
|
|
421
|
+
elif target.is_file(): # file_delete — re-verified inside the lock
|
|
385
422
|
target.unlink()
|
|
386
423
|
approved = self._review_queue.approve(item_id, workspace_id=workspace_id)
|
|
387
424
|
self._audit(
|
|
388
425
|
"change_proposal_applied", user_email=user_email,
|
|
389
426
|
proposal_id=item_id, path=path, kind=kind,
|
|
390
427
|
)
|
|
391
|
-
|
|
428
|
+
result = {"item": approved, "applied": True, "path": path, "kind": kind}
|
|
429
|
+
if moves is not None:
|
|
430
|
+
result["moves"] = moves
|
|
431
|
+
return result
|
|
392
432
|
|
|
393
433
|
def _check_base_unchanged(
|
|
394
434
|
self, payload: Dict[str, Any], *, path: str, kind: str
|
|
@@ -44,6 +44,33 @@ TIERS = ("workspace", "project", "agent", "conversation", "graph", "vector")
|
|
|
44
44
|
LOGGER = logging.getLogger(__name__)
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
#: Longest inline thumbnail a recall row will carry (see ``_visual_fields``).
|
|
48
|
+
MAX_RECALL_THUMBNAIL_CHARS = 24_000
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _visual_fields(hit: Dict[str, Any]) -> Dict[str, Any]:
|
|
52
|
+
"""Caption + inline thumbnail for a recall row, when the node has them.
|
|
53
|
+
|
|
54
|
+
v11.1.0: an ``Image`` node stores a 96px ``data:`` thumbnail and — only if
|
|
55
|
+
a vision-language model produced one — a caption. Passing them through here
|
|
56
|
+
is what lets the Evidence panel show the picture it is citing without a new
|
|
57
|
+
static route over the user's disk and without going around the local-file
|
|
58
|
+
approval gate: the bytes are already inside the graph payload the user
|
|
59
|
+
asked for. Absent keys stay absent, so every non-image row is unchanged.
|
|
60
|
+
"""
|
|
61
|
+
metadata = hit.get("metadata")
|
|
62
|
+
if not isinstance(metadata, dict):
|
|
63
|
+
return {}
|
|
64
|
+
fields: Dict[str, Any] = {}
|
|
65
|
+
caption = str(metadata.get("caption") or "").strip()
|
|
66
|
+
if caption:
|
|
67
|
+
fields["caption"] = caption[:400]
|
|
68
|
+
thumbnail = str(metadata.get("thumbnail") or "")
|
|
69
|
+
if thumbnail.startswith("data:image/") and len(thumbnail) <= MAX_RECALL_THUMBNAIL_CHARS:
|
|
70
|
+
fields["thumbnail"] = thumbnail
|
|
71
|
+
return fields
|
|
72
|
+
|
|
73
|
+
|
|
47
74
|
class MemoryServiceError(RuntimeError):
|
|
48
75
|
"""Raised when a configured memory backend cannot be read reliably."""
|
|
49
76
|
|
|
@@ -763,6 +790,11 @@ class MemoryService:
|
|
|
763
790
|
# citation UI can show the matched terms instead of a bare score.
|
|
764
791
|
"matched_terms": item.get("matched_terms") or [],
|
|
765
792
|
"confidence": item.get("confidence") or "low",
|
|
793
|
+
# v11.1.0: what kind of memory this is, and — for a picture —
|
|
794
|
+
# the caption and inline thumbnail the Evidence panel renders.
|
|
795
|
+
"kind": item.get("kind") or "",
|
|
796
|
+
**({"caption": item["caption"]} if item.get("caption") else {}),
|
|
797
|
+
**({"thumbnail": item["thumbnail"]} if item.get("thumbnail") else {}),
|
|
766
798
|
}
|
|
767
799
|
for item in list(recall.get("results", []))[: max(1, min(limit, 8))]
|
|
768
800
|
]
|
|
@@ -903,6 +935,7 @@ class MemoryService:
|
|
|
903
935
|
"kind": hit.get("type") or "node",
|
|
904
936
|
"score": _lexical_score(matched),
|
|
905
937
|
"matched_terms": matched,
|
|
938
|
+
**_visual_fields(hit),
|
|
906
939
|
})
|
|
907
940
|
|
|
908
941
|
# v9.3.0 hybrid recall: blend semantic similarity from the vector
|
|
@@ -964,6 +997,7 @@ class MemoryService:
|
|
|
964
997
|
"matched_terms": matched,
|
|
965
998
|
"vector_score": similarity,
|
|
966
999
|
**({"locator": locator} if locator else {}),
|
|
1000
|
+
**_visual_fields(hit),
|
|
967
1001
|
}
|
|
968
1002
|
results.append(row)
|
|
969
1003
|
if node_id:
|