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.
Files changed (95) hide show
  1. package/README.md +55 -43
  2. package/docs/CHANGELOG.md +28 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/PERFORMANCE.md +71 -18
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/embeddings.py +12 -37
  13. package/lattice_brain/graph/discovery_index.py +30 -32
  14. package/lattice_brain/graph/image_vectors.py +230 -0
  15. package/lattice_brain/graph/ingest.py +11 -5
  16. package/lattice_brain/graph/provenance.py +27 -2
  17. package/lattice_brain/graph/retrieval.py +113 -2
  18. package/lattice_brain/graph/retrieval_docgen.py +6 -6
  19. package/lattice_brain/graph/schema.py +9 -0
  20. package/lattice_brain/ingestion.py +209 -4
  21. package/lattice_brain/multimodal.py +738 -0
  22. package/lattice_brain/runtime/multi_agent.py +1 -1
  23. package/lattice_brain/self_model.py +77 -22
  24. package/latticeai/__init__.py +1 -1
  25. package/latticeai/api/memory.py +128 -1
  26. package/latticeai/core/agent.py +5 -1
  27. package/latticeai/core/agent_prompts.py +66 -0
  28. package/latticeai/core/context_builder.py +92 -8
  29. package/latticeai/core/embedding_providers.py +528 -0
  30. package/latticeai/core/legacy_compatibility.py +1 -1
  31. package/latticeai/core/marketplace.py +1 -1
  32. package/latticeai/core/messages.py +37 -0
  33. package/latticeai/core/workspace_os.py +43 -0
  34. package/latticeai/core/workspace_os_constants.py +1 -1
  35. package/latticeai/core/workspace_reorganization.py +335 -0
  36. package/latticeai/runtime/build_phases.py +5 -2
  37. package/latticeai/runtime/persistence_runtime.py +41 -4
  38. package/latticeai/runtime/runtime_context.py +1 -0
  39. package/latticeai/services/architecture_readiness.py +1 -1
  40. package/latticeai/services/change_proposals.py +50 -10
  41. package/latticeai/services/memory_service.py +34 -0
  42. package/latticeai/services/multimodal_ports.py +87 -0
  43. package/latticeai/services/product_readiness.py +1 -1
  44. package/latticeai/services/self_model_service.py +171 -0
  45. package/latticeai/services/voice_capture.py +27 -1
  46. package/package.json +1 -1
  47. package/scripts/check_current_release_docs.mjs +1 -1
  48. package/scripts/release_screen_claims.json +9 -0
  49. package/src-tauri/Cargo.lock +1 -1
  50. package/src-tauri/Cargo.toml +1 -1
  51. package/src-tauri/tauri.conf.json +1 -1
  52. package/static/app/asset-manifest.json +37 -37
  53. package/static/app/assets/{Act-D4zSxFR-.js → Act-D0HWqtn0.js} +1 -1
  54. package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D-QDW-A4.js} +1 -1
  55. package/static/app/assets/{Brain-C2EqQg74.js → Brain-CzCsI1mi.js} +1 -1
  56. package/static/app/assets/BrainHome-Btns-_TA.js +2 -0
  57. package/static/app/assets/BrainSignals-2dHQNkns.js +1 -0
  58. package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CT8v1StE.js} +1 -1
  59. package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DoLXC2KH.js} +1 -1
  60. package/static/app/assets/{Library-BN0HYOfc.js → Library-DDoxFE5c.js} +1 -1
  61. package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-BXMWIK_2.js} +1 -1
  62. package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-DOYf7JIs.js} +1 -1
  63. package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-COQsqidK.js} +1 -1
  64. package/static/app/assets/{System-BElUcSSw.js → System-BRllvYXd.js} +1 -1
  65. package/static/app/assets/arrow-left-DnyMzss-.js +1 -0
  66. package/static/app/assets/{bot--qYHMtkP.js → bot-4BvN07ux.js} +1 -1
  67. package/static/app/assets/brain-uMb_5hnO.js +1 -0
  68. package/static/app/assets/{button-51Z3rsuv.js → button-CDjtnAoU.js} +1 -1
  69. package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-D_RMn7tp.js} +1 -1
  70. package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-B5OpB8ae.js} +1 -1
  71. package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-BIlWInHf.js} +1 -1
  72. package/static/app/assets/{download-G-2olkWz.js → download-BtjXfL3z.js} +1 -1
  73. package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-DefMpxI2.js} +1 -1
  74. package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-BQ8NZVkw.js} +1 -1
  75. package/static/app/assets/{index-D7Rr-J2Y.js → index-0AvoEBzJ.js} +3 -3
  76. package/static/app/assets/index-vtEfYvQY.css +2 -0
  77. package/static/app/assets/{input-D4w_BZWl.js → input-B_5ZJ9oy.js} +1 -1
  78. package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-BqZ5tsgL.js} +1 -1
  79. package/static/app/assets/{primitives-d0g9pvzS.js → primitives-CVwew78r.js} +1 -1
  80. package/static/app/assets/search-DkhnOKZt.js +1 -0
  81. package/static/app/assets/{share-2-NmD7e_oV.js → share-2-D5zg_0fY.js} +1 -1
  82. package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-B5pZzkUb.js} +1 -1
  83. package/static/app/assets/{textarea-BPAJDc-0.js → textarea-nEVIweKY.js} +1 -1
  84. package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-Cm99AHlz.js} +1 -1
  85. package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-Dm__N6bL.js} +1 -1
  86. package/static/app/assets/{utils-DG1_ExrP.js → utils-DcDMoZIe.js} +1 -1
  87. package/static/app/assets/{workspace-CWVf3gsI.js → workspace-LtRRSKTf.js} +1 -1
  88. package/static/app/index.html +4 -4
  89. package/static/sw.js +1 -1
  90. package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
  91. package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
  92. package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
  93. package/static/app/assets/brain-DDCLjRqO.js +0 -1
  94. package/static/app/assets/index-CkzokZAj.css +0 -2
  95. 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=None,
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
- try:
84
- if action == "kg_ingest":
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
- except Exception: # noqa: BLE001 — metrics must never break ingestion
89
- quiet()
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,
@@ -144,6 +144,7 @@ class RuntimeContext:
144
144
  BRAIN_INTELLIGENCE: Any
145
145
  AUTOMATION_INTELLIGENCE: Any
146
146
  INGESTION_PIPELINE: Any
147
+ MULTIMODAL_PORTS: Any
147
148
  DEVICE_IDENTITY: Any
148
149
  KG_PORTABILITY: Any
149
150
  FUNNEL_METRICS: Any
@@ -16,7 +16,7 @@ from typing import Any, Dict, List
16
16
 
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
- ARCHITECTURE_VERSION_TARGET = "11.0.1"
19
+ ARCHITECTURE_VERSION_TARGET = "11.1.0"
20
20
 
21
21
  PREFERRED_REFACTORING_ORDER = [
22
22
  "agent-runtime",
@@ -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
- self._check_base_unchanged(payload, path=path, kind=kind)
379
-
380
- target = self._resolve_path(path)
381
- if kind == "file_update":
382
- self._atomic_write(target, str(payload.get("new_content") or ""))
383
- else: # file_delete — existence re-verified inside the lock
384
- if target.is_file():
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
- return {"item": approved, "applied": True, "path": path, "kind": kind}
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: