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
@@ -47,6 +47,22 @@ from pathlib import Path
47
47
  from typing import Any, Dict, Iterable, List, Optional, Tuple
48
48
 
49
49
  from .graph.vector_index import DEFAULT_TICK_LIMIT as VECTOR_TICK_LIMIT
50
+ from .multimodal import (
51
+ AUDIO_EXTENSIONS,
52
+ IMAGE_EXTENSIONS,
53
+ MODALITY_AUDIO,
54
+ MODALITY_IMAGE,
55
+ MODALITY_VIDEO,
56
+ VIDEO_OUT_OF_SCOPE,
57
+ ImageFacts,
58
+ MultimodalPorts,
59
+ audio_quality_score,
60
+ detect_modality,
61
+ extract_image_facts,
62
+ image_quality_score,
63
+ transcribe_audio,
64
+ write_image_memory,
65
+ )
50
66
  from .runtime.hooks import dispatch_tool
51
67
  from .utils import utc_now_iso
52
68
 
@@ -112,6 +128,23 @@ LATTICEIGNORE_FILENAME = ".latticeignore"
112
128
  # Opt-out escape hatch for the post-ingest incremental vector sync.
113
129
  AUTO_VECTOR_INDEX_ENV = "LATTICEAI_AUTO_VECTOR_INDEX"
114
130
 
131
+ # ── Multi-modal ingestion (v11.1.0 Track 3) ──────────────────────────────────
132
+ # Opt-in, default off, on purpose. Turning it on changes what a folder scan
133
+ # *stores* (pictures and recordings, with OCR and — if a model is loaded —
134
+ # captions and vectors), and that is the user's call, not a default. With the
135
+ # flag off every routing decision below is skipped and behaviour is byte-for-
136
+ # byte what it was before this release.
137
+ ALLOW_MULTIMODAL_ENV = "LATTICEAI_ALLOW_MULTIMODAL"
138
+ #: Source types that name a modality outright (a caller who already knows).
139
+ IMAGE_SOURCE_TYPES = frozenset({"image", "screenshot", "photo"})
140
+ AUDIO_SOURCE_TYPES = frozenset({"audio", "voice_memo", "recording"})
141
+ #: Added to the folder-scan allow-list only while multimodal is enabled.
142
+ FOLDER_MULTIMODAL_EXTENSIONS = IMAGE_EXTENSIONS | AUDIO_EXTENSIONS
143
+ #: Graph node type for a recording. ``NodeType.AUDIO`` normalizes this on the
144
+ #: KG v2 write side; the legacy tables keep the label verbatim, which is what
145
+ #: every type-aware read (graph view, context sections, doc-gen) matches on.
146
+ AUDIO_NODE_TYPE = "Audio"
147
+
115
148
  # ── Extraction quality heuristics (v9.8.0 A1) ────────────────────────────────
116
149
  # Pure heuristics over the extracted text — no model calls, no network. The
117
150
  # score is *advisory*: it never blocks an ingest, it only annotates the result
@@ -448,6 +481,8 @@ class IngestionPipeline:
448
481
  pipeline_name: str = "unified-ingestion",
449
482
  bg_queue: Optional[BackgroundIngestionQueue] = None,
450
483
  auto_vector_index: bool = True,
484
+ allow_multimodal: bool = False,
485
+ multimodal: Optional[MultimodalPorts] = None,
451
486
  ) -> None:
452
487
  self._kg = knowledge_graph
453
488
  self._hooks = hooks
@@ -470,10 +505,33 @@ class IngestionPipeline:
470
505
  "0", "false", "no", "off",
471
506
  }
472
507
  self._auto_vector_index = bool(auto_vector_index) and env_flag
508
+ # Multi-modal routing. Off unless the caller asks for it *or* the env
509
+ # flag is set — the env is the escape hatch for an install that has no
510
+ # code path to the constructor (CLI, background worker).
511
+ self._allow_multimodal = bool(allow_multimodal) or os.getenv(
512
+ ALLOW_MULTIMODAL_ENV, "0"
513
+ ).strip().lower() in {"1", "true", "yes", "on"}
514
+ self._multimodal = multimodal or MultimodalPorts()
473
515
 
474
516
  def available(self) -> bool:
475
517
  return self._enable and self._kg is not None
476
518
 
519
+ def multimodal_status(self) -> Dict[str, Any]:
520
+ """What this pipeline will do with a picture or a recording, honestly.
521
+
522
+ ``enabled`` is the flag; the rest is which model-backed capabilities
523
+ were actually injected. Video is listed as unsupported rather than
524
+ omitted, so the surface can say *why* a ``.mov`` was refused.
525
+ """
526
+ return {
527
+ "enabled": self._allow_multimodal,
528
+ "image": self._allow_multimodal,
529
+ "audio": self._allow_multimodal,
530
+ "video": False,
531
+ "video_detail": VIDEO_OUT_OF_SCOPE,
532
+ **self._multimodal.describe(),
533
+ }
534
+
477
535
  # ── public API ───────────────────────────────────────────────────────────
478
536
  def ingest(self, item: IngestionItem, *, user_email: Optional[str] = None) -> IngestionResult:
479
537
  """Normalize, hash, route through dispatch_tool, and record provenance."""
@@ -485,6 +543,15 @@ class IngestionPipeline:
485
543
  detail="Knowledge Graph is disabled (LATTICEAI_ENABLE_GRAPH).",
486
544
  )
487
545
 
546
+ # Modality routing is a no-op while the flag is off: ``modality`` stays
547
+ # "text" and every branch below behaves exactly as it did before.
548
+ modality = self._modality_for(item, source_type)
549
+ if modality == MODALITY_VIDEO:
550
+ return IngestionResult(
551
+ status="unavailable", source_type=source_type,
552
+ indexing_status="skipped", detail=VIDEO_OUT_OF_SCOPE,
553
+ )
554
+
488
555
  captured_at = item.captured_at or utc_now_iso()
489
556
  owner = item.owner or user_email
490
557
  tool_name = f"kg_ingest.{source_type}"
@@ -501,6 +568,10 @@ class IngestionPipeline:
501
568
  return self._ingest_chat(item, source_type=source_type, owner=owner)
502
569
  if source_type in MEMORY_SOURCE_TYPES:
503
570
  return self._ingest_memory_record(item, source_type=source_type, owner=owner)
571
+ if modality == MODALITY_IMAGE:
572
+ return self._ingest_image(item, source_type=source_type, owner=owner, captured_at=captured_at)
573
+ if modality == MODALITY_AUDIO:
574
+ return self._ingest_audio(item, source_type=source_type, owner=owner, captured_at=captured_at)
504
575
  if source_type in FILE_SOURCE_TYPES or (item.path and not item.text):
505
576
  return self._ingest_file(item, source_type=source_type, owner=owner, captured_at=captured_at)
506
577
  return self._ingest_text(item, source_type=source_type, owner=owner, captured_at=captured_at)
@@ -590,7 +661,11 @@ class IngestionPipeline:
590
661
  except Exception: # noqa: BLE001 — audit must never break ingestion
591
662
  quiet()
592
663
 
593
- extraction_quality = self._assess_item_quality(
664
+ # A modality-aware door scores its own extraction (a picture's quality
665
+ # is "how much of it can be retrieved", not "does the text read well"),
666
+ # so its verdict wins. Text/file doors never set the key and keep the
667
+ # historical scoring untouched.
668
+ extraction_quality = raw.get("extraction_quality") or self._assess_item_quality(
594
669
  item, source_type=source_type, text=quality_text, chunk_ids=chunk_ids,
595
670
  )
596
671
  warnings: List[str] = []
@@ -980,7 +1055,7 @@ class IngestionPipeline:
980
1055
  allowed_exts = (
981
1056
  frozenset(str(e).lower() if str(e).startswith(".") else f".{str(e).lower()}" for e in extensions)
982
1057
  if extensions
983
- else DEFAULT_FOLDER_EXTENSIONS
1058
+ else self._folder_extensions()
984
1059
  )
985
1060
  patterns = _load_latticeignore(root)
986
1061
  errors: List[Dict[str, Any]] = summary["errors"]
@@ -1036,7 +1111,11 @@ class IngestionPipeline:
1036
1111
  summary["truncated"] = True
1037
1112
  break
1038
1113
  item_metadata: Dict[str, Any] = {"relative_path": rel}
1039
- if ext in FOLDER_DOCUMENT_EXTENSIONS:
1114
+ if ext in FOLDER_MULTIMODAL_EXTENSIONS and self._allow_multimodal:
1115
+ # Routed by modality inside ``ingest``; reading the bytes as
1116
+ # UTF-8 here would only produce mojibake.
1117
+ source_type = "file"
1118
+ elif ext in FOLDER_DOCUMENT_EXTENSIONS:
1040
1119
  source_type = "pdf"
1041
1120
  else:
1042
1121
  source_type = "file"
@@ -1080,6 +1159,12 @@ class IngestionPipeline:
1080
1159
  summary["status"] = "ok" if summary["failed"] == 0 else "partial"
1081
1160
  return summary
1082
1161
 
1162
+ def _folder_extensions(self) -> frozenset:
1163
+ """Folder-scan allow-list — pictures and recordings only when enabled."""
1164
+ if self._allow_multimodal:
1165
+ return DEFAULT_FOLDER_EXTENSIONS | FOLDER_MULTIMODAL_EXTENSIONS
1166
+ return DEFAULT_FOLDER_EXTENSIONS
1167
+
1083
1168
  # ── routing helpers ──────────────────────────────────────────────────────
1084
1169
  def _ingest_text(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
1085
1170
  text = item.text or ""
@@ -1142,7 +1227,24 @@ class IngestionPipeline:
1142
1227
  result.setdefault("title", item.title)
1143
1228
  return result
1144
1229
 
1145
- def _ingest_file(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
1230
+ # ── multi-modal routing (v11.1.0 Track 3) ────────────────────────────────
1231
+ def _modality_for(self, item: IngestionItem, source_type: str) -> str:
1232
+ """``image`` / ``audio`` / ``video`` / ``text`` for this item.
1233
+
1234
+ Always ``"text"`` while the flag is off, which is what makes "off" mean
1235
+ *unchanged* rather than *slightly different*.
1236
+ """
1237
+ if not self._allow_multimodal:
1238
+ return "text"
1239
+ if source_type in IMAGE_SOURCE_TYPES:
1240
+ return MODALITY_IMAGE
1241
+ if source_type in AUDIO_SOURCE_TYPES:
1242
+ return MODALITY_AUDIO
1243
+ if not item.path:
1244
+ return "text"
1245
+ return detect_modality(item.path, item.mime_type)
1246
+
1247
+ def _resolve_file_path(self, item: IngestionItem) -> Path:
1146
1248
  if not item.path:
1147
1249
  raise ValueError("File ingestion requires a path.")
1148
1250
  path = Path(item.path)
@@ -1150,6 +1252,109 @@ class IngestionPipeline:
1150
1252
  raise FileNotFoundError(f"File not found: {path}")
1151
1253
  if path.is_dir():
1152
1254
  raise ValueError(f"File ingestion requires a file, got a directory: {path}")
1255
+ return path
1256
+
1257
+ def _ingest_image(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
1258
+ """Store one picture as an ``Image`` node — OCR, caption, vector.
1259
+
1260
+ The image vector (when a vision model produced one) goes to its own
1261
+ index; the OCR/caption text rides the ordinary text index. That split
1262
+ is what lets a typed question find a screenshot without ever comparing
1263
+ a text vector to an image vector.
1264
+ """
1265
+ path = self._resolve_file_path(item)
1266
+ facts = extract_image_facts(str(path), ports=self._multimodal)
1267
+ result = write_image_memory(
1268
+ self._kg,
1269
+ path=path,
1270
+ facts=facts,
1271
+ title=item.title or path.name,
1272
+ source_type=source_type if source_type in IMAGE_SOURCE_TYPES else MODALITY_IMAGE,
1273
+ source_uri=item.source_uri,
1274
+ owner=owner,
1275
+ workspace_id=item.workspace_id,
1276
+ conversation_id=item.conversation_id,
1277
+ captured_at=captured_at,
1278
+ modified_at=item.modified_at,
1279
+ permissions=item.permissions,
1280
+ extra_metadata={"mime_type": item.mime_type, **(item.metadata or {})},
1281
+ )
1282
+ self._record_image_vector(result["node_id"], facts)
1283
+ quality = image_quality_score(facts)
1284
+ result["extraction_quality"] = {
1285
+ "score": quality["score"],
1286
+ "level": _quality_level(quality["score"]),
1287
+ "reasons": quality["reasons"],
1288
+ }
1289
+ return result
1290
+
1291
+ def _record_image_vector(self, node_id: str, facts: ImageFacts) -> None:
1292
+ """File the image-space vector, if a vision model actually made one."""
1293
+ if facts.embedding is None:
1294
+ return
1295
+ from .graph.image_vectors import record_image_vector
1296
+
1297
+ record_image_vector(
1298
+ self._kg,
1299
+ node_id=node_id,
1300
+ vector=facts.embedding,
1301
+ model_id=self._multimodal.vision_model_id or "vision:unnamed",
1302
+ space=self._multimodal.vision_space,
1303
+ updated_at=utc_now_iso(),
1304
+ )
1305
+
1306
+ def _ingest_audio(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
1307
+ """Store one recording as an ``Audio`` node, transcribed when possible.
1308
+
1309
+ The transcript is text and rides the ordinary text index — chunks,
1310
+ concepts, provenance, dedupe all unchanged — but the node itself is a
1311
+ recording, because that is what it is whether or not anyone could hear
1312
+ it. The recording's own facts stay in the metadata (``modality``,
1313
+ ``audio_path``, ``transcription``, ``searchable``). Without a
1314
+ transcriber the memory is still kept, and its body says plainly that
1315
+ the words were never recognized instead of leaving a blank note.
1316
+ """
1317
+ path = self._resolve_file_path(item)
1318
+ facts = transcribe_audio(str(path), ports=self._multimodal, transcript=item.text)
1319
+ title = item.title or path.stem
1320
+ body = facts.transcript or (
1321
+ f"[{MODALITY_AUDIO}] {title}\n"
1322
+ "이 녹음은 아직 글로 바뀌지 않았습니다 — 음성 인식기가 없어 내용 검색은 되지 않습니다."
1323
+ )
1324
+ result = self._kg.ingest_source(
1325
+ source_type=source_type,
1326
+ title=title,
1327
+ text=body,
1328
+ source_uri=item.source_uri or str(path),
1329
+ owner=owner,
1330
+ workspace_id=item.workspace_id,
1331
+ permissions=item.permissions,
1332
+ captured_at=captured_at,
1333
+ modified_at=item.modified_at,
1334
+ conversation_id=item.conversation_id,
1335
+ node_type=AUDIO_NODE_TYPE,
1336
+ metadata={
1337
+ "mime_type": item.mime_type,
1338
+ "modality": MODALITY_AUDIO,
1339
+ "audio_path": str(path),
1340
+ "audio_bytes": path.stat().st_size,
1341
+ "transcription": facts.transcription_status,
1342
+ "searchable": facts.searchable,
1343
+ **({"transcription_detail": facts.detail} if facts.detail else {}),
1344
+ **(item.metadata or {}),
1345
+ },
1346
+ )
1347
+ result.setdefault("title", title)
1348
+ quality = audio_quality_score(facts)
1349
+ result["extraction_quality"] = {
1350
+ "score": quality["score"],
1351
+ "level": _quality_level(quality["score"]),
1352
+ "reasons": quality["reasons"],
1353
+ }
1354
+ return result
1355
+
1356
+ def _ingest_file(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
1357
+ path = self._resolve_file_path(item)
1153
1358
  return self._kg.ingest_document(
1154
1359
  path,
1155
1360
  original_filename=item.title or path.name,