ltcai 12.2.0 → 12.3.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 (79) hide show
  1. package/README.md +92 -84
  2. package/docs/CHANGELOG.md +64 -4
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +11 -11
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/ROADMAP.md +8 -8
  8. package/docs/TRUST_MODEL.md +2 -2
  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/latticeai/__init__.py +1 -1
  13. package/latticeai/core/embedding_providers/autodetect.py +0 -1
  14. package/latticeai/core/model_compat.py +2 -2
  15. package/latticeai/core/vector_index/hnsw.py +49 -10
  16. package/latticeai/core/vector_index/sidecar.py +120 -31
  17. package/latticeai/models/model_providers.py +10 -11
  18. package/latticeai/services/architecture_readiness.py +1 -1
  19. package/latticeai/services/model_capability_registry.py +2 -2
  20. package/latticeai/services/model_catalog.py +20 -12
  21. package/latticeai/services/model_engines.py +26 -14
  22. package/latticeai/services/model_runtime/download.py +53 -6
  23. package/latticeai/services/product_readiness.py +2 -2
  24. package/package.json +4 -6
  25. package/scripts/check_current_release_docs.mjs +1 -1
  26. package/scripts/release_screen_claims.json +24 -0
  27. package/src-tauri/Cargo.lock +10 -10
  28. package/src-tauri/Cargo.toml +1 -1
  29. package/src-tauri/tauri.conf.json +1 -1
  30. package/static/app/asset-manifest.json +44 -44
  31. package/static/app/assets/{Act-BjGgeuQd.js → Act-CZXyMcfJ.js} +2 -2
  32. package/static/app/assets/{AdminConsole-C6hcJGMr.js → AdminConsole-CAkZk0BI.js} +1 -1
  33. package/static/app/assets/{Brain-CNQnIaA8.js → Brain-DN_7_HSV.js} +2 -2
  34. package/static/app/assets/{BrainHome-CGJ6O69l.js → BrainHome-BBP0ouU-.js} +1 -1
  35. package/static/app/assets/{BrainSignals-DONPljxz.js → BrainSignals-nyRNxvQ3.js} +1 -1
  36. package/static/app/assets/{Capture-BMCC5544.js → Capture-DBvFoZKL.js} +1 -1
  37. package/static/app/assets/{Chronicle-DF6sOonx.js → Chronicle-BC5zmR44.js} +1 -1
  38. package/static/app/assets/{CommandPalette-DiWyutdM.js → CommandPalette-Bs-Fk6HT.js} +1 -1
  39. package/static/app/assets/{DigitalBrainExplorer-BL3V6Dqz.js → DigitalBrainExplorer-CNcCktPP.js} +1 -1
  40. package/static/app/assets/{Library-B-ue9hXL.js → Library-CxUA4xai.js} +1 -1
  41. package/static/app/assets/{LivingBrain-b1RiM5Rh.js → LivingBrain-CiyjvwKy.js} +1 -1
  42. package/static/app/assets/ProductFlow-DccERGr3.js +1 -0
  43. package/static/app/assets/{ReviewCard-D1fGTEn7.js → ReviewCard-lawM1khV.js} +1 -1
  44. package/static/app/assets/{RunsListPanel-DAirtLkS.js → RunsListPanel-DQC-zQ7m.js} +1 -1
  45. package/static/app/assets/{System-BEI1eoC4.js → System-DwqYJQaj.js} +1 -1
  46. package/static/app/assets/{WorkflowGraph-qrI6GU4R.js → WorkflowGraph-pJ2eFrhy.js} +1 -1
  47. package/static/app/assets/{WorkflowsPanel-CIMNTw-O.js → WorkflowsPanel-BIGjfM7h.js} +2 -2
  48. package/static/app/assets/{actHelpers-BMukIRnQ.js → actHelpers-DfIqS92u.js} +1 -1
  49. package/static/app/assets/arrow-left-DvwQxqcD.js +1 -0
  50. package/static/app/assets/{bot-Dkd-DbiP.js → bot-CAfN5QD1.js} +1 -1
  51. package/static/app/assets/{brain-BsexlNOA.js → brain-DNNLFQgq.js} +1 -1
  52. package/static/app/assets/{button-BA0N_zId.js → button-D0k7_k6e.js} +1 -1
  53. package/static/app/assets/circle-check-hH6updC-.js +1 -0
  54. package/static/app/assets/{circle-pause-918Kuvk0.js → circle-pause-Dxw6SJuA.js} +1 -1
  55. package/static/app/assets/{circle-play-CS0IbVDV.js → circle-play-DuoLDLEg.js} +1 -1
  56. package/static/app/assets/{cpu-BpvoWsjk.js → cpu-BGOpEdGX.js} +1 -1
  57. package/static/app/assets/{download-BvkMLLLN.js → download-wXD9L5vN.js} +1 -1
  58. package/static/app/assets/{folder-open-YmGugIBf.js → folder-open-CittV8Ey.js} +1 -1
  59. package/static/app/assets/{hard-drive-BOzmS-cq.js → hard-drive-CLQPYYBF.js} +1 -1
  60. package/static/app/assets/{index-CfeqZ9Ri.js → index-C0VEkbAp.js} +3 -3
  61. package/static/app/assets/{input-CFfCfMjO.js → input-RZ8aKj6d.js} +1 -1
  62. package/static/app/assets/{link-2-CcYzeM7M.js → link-2-B7QYeNdJ.js} +1 -1
  63. package/static/app/assets/{permissionCopy-B23r24oX.js → permissionCopy-DFGEuU2d.js} +1 -1
  64. package/static/app/assets/{primitives-BDryQl6H.js → primitives-EKxh12KR.js} +1 -1
  65. package/static/app/assets/search-BA5Ge197.js +1 -0
  66. package/static/app/assets/{share-2-B-gPv2ec.js → share-2-Drg0MPgx.js} +1 -1
  67. package/static/app/assets/{shield-alert-CfyRO1p0.js → shield-alert-CFKdCGM8.js} +1 -1
  68. package/static/app/assets/{sourceMeta-C4LDEy1S.js → sourceMeta-BzqJiEPI.js} +1 -1
  69. package/static/app/assets/{textarea-BdVMYEb1.js → textarea-n_69mQP_.js} +1 -1
  70. package/static/app/assets/{useFocusTrap-CV73DLHn.js → useFocusTrap-B0w4AxK1.js} +1 -1
  71. package/static/app/assets/{useMutation-B8QKq2NW.js → useMutation-Dvcq6J6b.js} +1 -1
  72. package/static/app/assets/workspace-BgBHikYl.js +1 -0
  73. package/static/app/index.html +1 -1
  74. package/static/sw.js +1 -1
  75. package/static/app/assets/ProductFlow-wZIKJW7G.js +0 -1
  76. package/static/app/assets/arrow-left-iVa1goff.js +0 -1
  77. package/static/app/assets/circle-check-BxaDWjPR.js +0 -1
  78. package/static/app/assets/search-DHsPwmPw.js +0 -1
  79. package/static/app/assets/workspace-BmfVs66W.js +0 -1
@@ -118,6 +118,11 @@ class HnswIndex:
118
118
  def model_id(self) -> str:
119
119
  return self._model_id
120
120
 
121
+ @property
122
+ def labels(self) -> List[str]:
123
+ """Live label order — the sidecar's identity, loaded or rebuilt."""
124
+ return list(self._labels)
125
+
121
126
  def _identity_matches(self, dim: int, space: str, model_id: str) -> bool:
122
127
  return (
123
128
  int(dim) == self._dim
@@ -177,10 +182,12 @@ class HnswIndex:
177
182
  doors without guessing.
178
183
 
179
184
  Full rebuild when the embedder identity changed, when any currently
180
- indexed id is missing from the incoming set (a deletion), when
181
- deletes would exceed :data:`DELETE_REBUILD_RATIO`, or when this
182
- instance was loaded from a sidecar and cannot append (no source
183
- vectors to keep the label map honest).
185
+ indexed id is missing from a *full* incoming snapshot (a deletion),
186
+ or when deletes would exceed :data:`DELETE_REBUILD_RATIO`. A sidecar
187
+ that was loaded without source vectors still **appends**: the
188
+ caller passes only the new ids (COUNT+delta) and ``_append_live``
189
+ extends the graph. A disjoint incoming set is that delta, not a
190
+ wipe.
184
191
  """
185
192
  incoming: Dict[str, IndexItem] = {}
186
193
  for item_id, vector, metadata in items:
@@ -197,13 +204,36 @@ class HnswIndex:
197
204
  return "rebuild"
198
205
 
199
206
  if self._loaded:
207
+ current = set(self._labels)
208
+ incoming_ids = set(incoming)
209
+ overlap = current & incoming_ids
210
+ deleted = current - incoming_ids
211
+ # Delta-only: the caller passed new ids, none of the live labels.
212
+ # A full snapshot that dropped ids is a deletion and must rebuild.
213
+ delta_only = bool(incoming_ids) and not overlap
214
+ if deleted and not delta_only:
215
+ self.rebuild(incoming.values())
216
+ return "rebuild"
217
+ new_ids = [item_id for item_id in incoming if item_id not in current]
218
+ if not new_ids:
219
+ return "append"
220
+ for item_id in new_ids:
221
+ _, vector, metadata = incoming[item_id]
222
+ self._vectors[item_id] = [float(value) for value in vector]
223
+ self._metadata[item_id] = dict(metadata or {})
224
+ if self._ann is not None and self._module is not None:
225
+ self._append_live(new_ids)
226
+ return "append"
200
227
  self.rebuild(incoming.values())
201
228
  return "rebuild"
202
229
 
203
230
  current = set(self._vectors)
204
231
  incoming_ids = set(incoming)
205
232
  deleted = current - incoming_ids
206
- if deleted:
233
+ overlap = current & incoming_ids
234
+ # Same discriminant as the loaded path: a disjoint incoming set is
235
+ # COUNT+delta, not a snapshot that deleted every live id.
236
+ if deleted and not (bool(incoming_ids) and not overlap):
207
237
  ratio = len(deleted) / max(1, len(current))
208
238
  if ratio >= DELETE_REBUILD_RATIO or not current:
209
239
  self.rebuild(incoming.values())
@@ -339,18 +369,25 @@ class HnswIndex:
339
369
  return False
340
370
  return True
341
371
 
342
- def load(self, db_path: Any, *, fingerprint: str) -> bool:
343
- """Adopt a sidecar graph when it provably matches ``fingerprint``."""
372
+ def load(self, db_path: Any, *, fingerprint: str, strict: bool = True) -> bool:
373
+ """Adopt a sidecar graph when it matches this embedder identity.
374
+
375
+ ``strict`` (the default) also requires the stored fingerprint — size
376
+ included — so a grown store does not search a stale graph. Relaxed
377
+ load keeps dim/model and leaves COUNT+delta to append the missing
378
+ ids rather than dumping the whole table.
379
+ """
344
380
  module = self._module
345
381
  if module is None:
346
382
  return False
347
383
  index_path, meta_path = sidecar_paths(db_path)
348
384
  try:
349
385
  meta = json.loads(meta_path.read_text(encoding="utf-8"))
350
- matches = str(meta["fingerprint"]) == str(fingerprint)
351
- matches = matches and int(meta["dim"]) == self._dim
386
+ matches = int(meta["dim"]) == self._dim
352
387
  if meta.get("model_id") not in (None, "", self._model_id):
353
388
  matches = False
389
+ if strict:
390
+ matches = matches and str(meta["fingerprint"]) == str(fingerprint)
354
391
  labels = [str(label) for label in meta["labels"]]
355
392
  except Exception: # noqa: BLE001 — absent/corrupt sidecar = rebuild
356
393
  return False
@@ -358,7 +395,9 @@ class HnswIndex:
358
395
  return False
359
396
  try:
360
397
  graph = module.Index(space=self._space, dim=self._dim)
361
- graph.load_index(str(index_path), max_elements=len(labels))
398
+ # Headroom so an in-process append does not have to rebuild
399
+ # just because load_index pinned max_elements to the old size.
400
+ graph.load_index(str(index_path), max_elements=max(len(labels) + 64, 1))
362
401
  except Exception: # noqa: BLE001 — corrupt binary = rebuild
363
402
  return False
364
403
  self._ann = graph
@@ -2,10 +2,10 @@
2
2
 
3
3
  The live graph is cached per ``(db_path, model_id, dim)``. A missing sidecar
4
4
  is ``index: "none"``. When the store has vectors the cache (or a rebuild from
5
- ``vector_embeddings``) can serve, the reply is ``index: "hnsw"``. Staleness is
6
- sidecar size vs ``COUNT(*)`` for that identity — reported honestly, and
7
- refreshed through :meth:`HnswIndex.add_items` so an ingest append does not
8
- rebuild the whole graph.
5
+ ``vector_embeddings``) can serve, the reply is ``index: "hnsw"``. Freshness is
6
+ sidecar size vs ``COUNT(*)`` for that identity. A warm COUNT match never
7
+ opens embedding blobs. Growth loads **missing ids only** and appends; a
8
+ shrink still rebuilds from the remaining rows.
9
9
  """
10
10
 
11
11
  from __future__ import annotations
@@ -81,6 +81,83 @@ def load_store_items(
81
81
  return items
82
82
 
83
83
 
84
+ def load_store_ids(conn: sqlite3.Connection, model_id: str, dim: int) -> List[str]:
85
+ """Item ids only — the COUNT+delta path must not pull embedding blobs."""
86
+ rows = conn.execute(
87
+ "SELECT item_id FROM vector_embeddings "
88
+ "WHERE embedding_model=? AND embedding_dim=? ORDER BY indexed_at ASC, item_id ASC",
89
+ (model_id, int(dim)),
90
+ )
91
+ return [str(item_id) for (item_id,) in rows]
92
+
93
+
94
+ def load_store_items_for(
95
+ conn: sqlite3.Connection,
96
+ model_id: str,
97
+ dim: int,
98
+ item_ids: Sequence[str],
99
+ ) -> List[Tuple[str, List[float], Dict[str, Any]]]:
100
+ """Blobs for ``item_ids`` only, preserving the caller's order."""
101
+ if not item_ids:
102
+ return []
103
+ by_id: Dict[str, List[float]] = {}
104
+ wanted = [str(item_id) for item_id in item_ids]
105
+ # SQLite caps bound parameters; 400 leaves room for the identity pair.
106
+ for start in range(0, len(wanted), 400):
107
+ chunk = wanted[start : start + 400]
108
+ qmarks = ",".join("?" * len(chunk))
109
+ query = "SELECT item_id, embedding FROM vector_embeddings WHERE embedding_model=? AND embedding_dim=? AND item_id IN (%s)" % qmarks # noqa: S608
110
+ for item_id, blob in conn.execute(query, (model_id, int(dim), *chunk)):
111
+ vector = decode_f32le(bytes(blob or b""), int(dim))
112
+ if len(vector) != int(dim):
113
+ continue
114
+ by_id[str(item_id)] = vector
115
+ return [
116
+ (item_id, by_id[item_id], {})
117
+ for item_id in wanted
118
+ if item_id in by_id
119
+ ]
120
+
121
+
122
+ def _open_and_count(path: Path, model_id: str, dim: int) -> int:
123
+ conn = _open_readonly(path)
124
+ if conn is None:
125
+ return 0
126
+ try:
127
+ return store_vector_count(conn, model_id, dim)
128
+ finally:
129
+ conn.close()
130
+
131
+
132
+ def _load_all_items(
133
+ path: Path, model_id: str, dim: int
134
+ ) -> List[Tuple[str, List[float], Dict[str, Any]]]:
135
+ conn = _open_readonly(path)
136
+ if conn is None:
137
+ return []
138
+ try:
139
+ return load_store_items(conn, model_id, dim)
140
+ finally:
141
+ conn.close()
142
+
143
+
144
+ def _load_missing_items(
145
+ path: Path,
146
+ model_id: str,
147
+ dim: int,
148
+ have: Sequence[str],
149
+ ) -> List[Tuple[str, List[float], Dict[str, Any]]]:
150
+ conn = _open_readonly(path)
151
+ if conn is None:
152
+ return []
153
+ try:
154
+ have_set = set(have)
155
+ missing = [item_id for item_id in load_store_ids(conn, model_id, dim) if item_id not in have_set]
156
+ return load_store_items_for(conn, model_id, dim, missing)
157
+ finally:
158
+ conn.close()
159
+
160
+
84
161
  def sidecar_meta_size(db_path: Path) -> Optional[int]:
85
162
  """Label count from the sidecar meta file, or ``None`` when it is absent."""
86
163
  _, meta_path = sidecar_paths(db_path)
@@ -185,17 +262,8 @@ def query_sidecar(
185
262
  if not model_id:
186
263
  return _none_reply(detail="embedding_model is required")
187
264
 
188
- conn = _open_readonly(path)
189
- store_size = 0
190
- items: List[Tuple[str, List[float], Dict[str, Any]]] = []
191
- if conn is not None:
192
- try:
193
- store_size = store_vector_count(conn, model_id, dim)
194
- if store_size > 0:
195
- items = load_store_items(conn, model_id, dim)
196
- store_size = len(items)
197
- finally:
198
- conn.close()
265
+ # COUNT first. Warm queries that already match never open embedding blobs.
266
+ store_size = _open_and_count(path, model_id, dim)
199
267
 
200
268
  key = (str(path), model_id, dim)
201
269
  with _LOCK:
@@ -203,8 +271,20 @@ def query_sidecar(
203
271
  refreshed: Optional[str] = None
204
272
  if index is None:
205
273
  index = HnswIndex(dim=dim, model_id=model_id)
206
- loaded = index.load(path, fingerprint=sidecar_fingerprint(model_id, dim, store_size))
274
+ loaded = index.load(
275
+ path, fingerprint=sidecar_fingerprint(model_id, dim, store_size)
276
+ )
277
+ if not loaded and store_size > 0:
278
+ # Sidecar exists for this identity but the COUNT moved —
279
+ # load the graph and append the missing ids below.
280
+ loaded = index.load(
281
+ path,
282
+ fingerprint=sidecar_fingerprint(model_id, dim, store_size),
283
+ strict=False,
284
+ )
207
285
  if not loaded:
286
+ items = _load_all_items(path, model_id, dim)
287
+ store_size = len(items)
208
288
  if not items:
209
289
  return _none_reply(
210
290
  store_size=store_size,
@@ -216,25 +296,31 @@ def query_sidecar(
216
296
 
217
297
  cached_size = int(index.stats().get("size") or 0)
218
298
  stale = cached_size != store_size
219
- if stale:
220
- if not items and store_size > 0:
221
- conn = _open_readonly(path)
222
- if conn is not None:
223
- try:
224
- items = load_store_items(conn, model_id, dim)
225
- finally:
226
- conn.close()
299
+ if stale and store_size == 0:
300
+ _CACHE.pop(key, None)
301
+ return _none_reply(
302
+ store_size=0,
303
+ detail="vector store is empty for this embedding identity",
304
+ )
305
+ if stale and store_size > cached_size:
306
+ delta = _load_missing_items(path, model_id, dim, index.labels)
307
+ if delta:
308
+ refreshed = _refresh_index(index, delta, model_id, dim)
309
+ cached_size = int(index.stats().get("size") or 0)
310
+ index.save(
311
+ path, fingerprint=sidecar_fingerprint(model_id, dim, cached_size)
312
+ )
313
+ stale = cached_size != store_size
314
+ elif stale:
315
+ items = _load_all_items(path, model_id, dim)
316
+ store_size = len(items)
227
317
  if items:
228
318
  refreshed = _refresh_index(index, items, model_id, dim)
229
- index.save(path, fingerprint=sidecar_fingerprint(model_id, dim, len(items)))
230
319
  cached_size = int(index.stats().get("size") or 0)
231
- stale = cached_size != len(items)
232
- elif store_size == 0:
233
- _CACHE.pop(key, None)
234
- return _none_reply(
235
- store_size=0,
236
- detail="vector store is empty for this embedding identity",
320
+ index.save(
321
+ path, fingerprint=sidecar_fingerprint(model_id, dim, cached_size)
237
322
  )
323
+ stale = cached_size != store_size
238
324
 
239
325
  if cached_size <= 0:
240
326
  return _none_reply(
@@ -326,4 +412,7 @@ __all__ = [
326
412
  "sidecar_freshness",
327
413
  "sidecar_meta_size",
328
414
  "store_vector_count",
415
+ "load_store_ids",
416
+ "load_store_items",
417
+ "load_store_items_for",
329
418
  ]
@@ -27,29 +27,28 @@ OPENAI_COMPATIBLE_PROVIDERS = {
27
27
  "together": {
28
28
  "env_key": "TOGETHER_API_KEY",
29
29
  "base_url": "https://api.together.xyz/v1",
30
- "default_model": "Qwen/Qwen3-VL-32B-Instruct",
30
+ "default_model": "Qwen/Qwen3.6-27B",
31
31
  },
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
+ # a request naming it now 404s at the provider. ``grok-4.6`` is the
37
+ # current flagship on api.x.ai and matches the Rust cloud adapter.
38
+ "default_model": "grok-4.6",
40
39
  },
41
40
  "ollama": {
42
41
  "env_key": "OLLAMA_API_KEY",
43
42
  "base_url_env": "OLLAMA_BASE_URL",
44
43
  "base_url": "http://localhost:11434/v1",
45
- "default_model": "hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M",
44
+ "default_model": "hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_0",
46
45
  "api_key_fallback": "ollama",
47
46
  },
48
47
  "vllm": {
49
48
  "env_key": "VLLM_API_KEY",
50
49
  "base_url_env": "VLLM_BASE_URL",
51
50
  "base_url": "http://localhost:8000/v1",
52
- "default_model": "Qwen/Qwen3-VL-8B-Instruct",
51
+ "default_model": "google/gemma-4-12B-it",
53
52
  "api_key_fallback": "vllm",
54
53
  },
55
54
  "lmstudio": {
@@ -85,9 +84,9 @@ PROVIDER_MODEL_CATALOG = {
85
84
  {"id": "anthropic/claude-opus-4.7", "name": "Claude Opus 4.7 via OpenRouter", "family": "Claude"},
86
85
  {"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6 via OpenRouter", "family": "Claude"},
87
86
  {"id": "anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 via OpenRouter", "family": "Claude"},
88
- {"id": "qwen/qwen3-vl-235b-a22b-instruct", "name": "Qwen3-VL 235B A22B via OpenRouter", "family": "Qwen"},
87
+ {"id": "qwen/qwen3.6-35b-a3b", "name": "Qwen3.6 35B A3B via OpenRouter", "family": "Qwen"},
89
88
  {"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B via OpenRouter", "family": "Gemma"},
90
- {"id": "x-ai/grok-4.5", "name": "Grok 4.5 via OpenRouter", "family": "Grok"},
89
+ {"id": "x-ai/grok-4.6", "name": "Grok 4.6 via OpenRouter", "family": "Grok"},
91
90
  {"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout via OpenRouter", "family": "Llama"},
92
91
  {"id": "google/gemini-2.5-flash", "name": "Gemini 2.5 Flash via OpenRouter", "family": "Gemini"},
93
92
  ],
@@ -95,7 +94,7 @@ PROVIDER_MODEL_CATALOG = {
95
94
  {"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout", "family": "Llama"},
96
95
  ],
97
96
  "together": [
98
- {"id": "Qwen/Qwen3-VL-32B-Instruct", "name": "Qwen3-VL 32B", "family": "Qwen"},
97
+ {"id": "Qwen/Qwen3.6-27B", "name": "Qwen3.6 27B", "family": "Qwen"},
99
98
  {"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B", "family": "Gemma"},
100
99
  {"id": "meta-llama/Llama-4-Scout-17B-16E-Instruct", "name": "Llama 4 Scout", "family": "Llama"},
101
100
  ],
@@ -105,7 +104,7 @@ PROVIDER_MODEL_CATALOG = {
105
104
  # (grok-beta / grok-vision-beta) are not carried as fallbacks: a model
106
105
  # the provider no longer serves is not compatibility, it is a 404 the
107
106
  # user has to discover for themselves.
108
- {"id": "grok-4.5", "name": "Grok 4.5", "family": "Grok"},
107
+ {"id": "grok-4.6", "name": "Grok 4.6", "family": "Grok"},
109
108
  ],
110
109
  }
111
110
 
@@ -19,7 +19,7 @@ from dataclasses import dataclass
19
19
  from pathlib import Path
20
20
  from typing import Any, Dict, List
21
21
 
22
- ARCHITECTURE_VERSION_TARGET = "12.2.0"
22
+ ARCHITECTURE_VERSION_TARGET = "12.3.0"
23
23
 
24
24
  PREFERRED_REFACTORING_ORDER = [
25
25
  "agent-runtime",
@@ -300,7 +300,7 @@ _REGISTRY: List[ModelCapability] = [
300
300
  architecture="qwen3_5",
301
301
  download_size_gb=5.98,
302
302
  quantization="4bit",
303
- provider_hints=["local_mlx", "vllm"],
303
+ provider_hints=["local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"],
304
304
  download_strategy="hf_hub",
305
305
  load_strategy="mlx_vlm",
306
306
  hardware=HardwareProfile(
@@ -381,7 +381,7 @@ _REGISTRY: List[ModelCapability] = [
381
381
  architecture="qwen3_5_moe",
382
382
  download_size_gb=20.43,
383
383
  quantization="4bit",
384
- provider_hints=["local_mlx", "vllm"],
384
+ provider_hints=["local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"],
385
385
  download_strategy="hf_hub",
386
386
  load_strategy="mlx_vlm",
387
387
  hardware=HardwareProfile(
@@ -102,11 +102,17 @@ ENGINE_MODEL_CATALOG: Dict[str, List[Dict[str, Any]]] = {}
102
102
  # gated — the download could never succeed without Hub credentials.
103
103
 
104
104
 
105
- def _gguf_routes(mlx_repo: str, gguf_repo: str, hf_repo: str) -> Dict[str, str]:
105
+ def _gguf_routes(
106
+ mlx_repo: str,
107
+ gguf_repo: str,
108
+ hf_repo: str,
109
+ *,
110
+ ollama_tag: str = "Q4_K_M",
111
+ ) -> Dict[str, str]:
106
112
  """One model's engine map: MLX weights, a GGUF repo, and the upstream HF repo."""
107
113
  return {
108
114
  "local_mlx": mlx_repo,
109
- "ollama": f"hf.co/{gguf_repo}:Q4_K_M",
115
+ "ollama": f"hf.co/{gguf_repo}:{ollama_tag}",
110
116
  "vllm": hf_repo,
111
117
  "lmstudio": gguf_repo,
112
118
  "llamacpp": gguf_repo,
@@ -119,36 +125,38 @@ _ENGINE_ROUTES: Dict[str, Dict[str, str]] = {
119
125
  ),
120
126
  "mlx-community/gemma-4-e2b-it-4bit": _gguf_routes(
121
127
  "mlx-community/gemma-4-e2b-it-4bit", "ggml-org/gemma-4-E2B-it-GGUF", "google/gemma-4-E2B-it",
128
+ ollama_tag="Q4_0",
122
129
  ),
123
130
  "mlx-community/gemma-4-e4b-it-4bit": _gguf_routes(
124
131
  "mlx-community/gemma-4-e4b-it-4bit", "ggml-org/gemma-4-E4B-it-GGUF", "google/gemma-4-E4B-it",
132
+ ollama_tag="Q4_0",
125
133
  ),
126
134
  "mlx-community/gemma-4-12B-it-4bit": _gguf_routes(
127
135
  "mlx-community/gemma-4-12B-it-4bit", "ggml-org/gemma-4-12B-it-GGUF", "google/gemma-4-12B-it",
136
+ ollama_tag="Q4_0",
128
137
  ),
129
138
  "mlx-community/gemma-4-26b-a4b-it-4bit": _gguf_routes(
130
139
  "mlx-community/gemma-4-26b-a4b-it-4bit", "ggml-org/gemma-4-26B-A4B-it-GGUF",
131
140
  "google/gemma-4-26B-A4B-it",
141
+ ollama_tag="Q4_0",
132
142
  ),
133
143
  "mlx-community/gemma-4-31b-it-4bit": _gguf_routes(
134
144
  "mlx-community/gemma-4-31b-it-4bit", "ggml-org/gemma-4-31B-it-GGUF", "google/gemma-4-31B-it",
145
+ ollama_tag="Q4_0",
135
146
  ),
136
147
  "mlx-community/Qwen3.6-27B-4bit": _gguf_routes(
137
148
  "mlx-community/Qwen3.6-27B-4bit", "ggml-org/Qwen3.6-27B-GGUF", "Qwen/Qwen3.6-27B",
138
149
  ),
139
150
  "mlx-community/gpt-oss-20b-MXFP4-Q8": _gguf_routes(
140
151
  "mlx-community/gpt-oss-20b-MXFP4-Q8", "ggml-org/gpt-oss-20b-GGUF", "openai/gpt-oss-20b",
152
+ ollama_tag="MXFP4",
153
+ ),
154
+ "mlx-community/Qwen3.5-9B-MLX-4bit": _gguf_routes(
155
+ "mlx-community/Qwen3.5-9B-MLX-4bit", "unsloth/Qwen3.5-9B-GGUF", "Qwen/Qwen3.5-9B",
156
+ ),
157
+ "mlx-community/Qwen3.6-35B-A3B-4bit": _gguf_routes(
158
+ "mlx-community/Qwen3.6-35B-A3B-4bit", "ggml-org/Qwen3.6-35B-A3B-GGUF", "Qwen/Qwen3.6-35B-A3B",
141
159
  ),
142
- # No community GGUF build verified for these two, so they offer the MLX
143
- # weights and the upstream repo only rather than a route that would 404.
144
- "mlx-community/Qwen3.5-9B-MLX-4bit": {
145
- "local_mlx": "mlx-community/Qwen3.5-9B-MLX-4bit",
146
- "vllm": "Qwen/Qwen3.5-9B",
147
- },
148
- "mlx-community/Qwen3.6-35B-A3B-4bit": {
149
- "local_mlx": "mlx-community/Qwen3.6-35B-A3B-4bit",
150
- "vllm": "Qwen/Qwen3.6-35B-A3B",
151
- },
152
160
  }
153
161
 
154
162
  # Names people actually type that are not the repo's own short name. Kept
@@ -332,6 +332,10 @@ def ensure_llamacpp_server(model_name: str) -> None:
332
332
  hf_model_dir,
333
333
  hf_model_ready,
334
334
  )
335
+ from latticeai.services.model_runtime.download import (
336
+ GGUF_QUANT_PREFERENCE,
337
+ is_auxiliary_gguf,
338
+ )
335
339
 
336
340
  served_models = get_openai_compatible_server_models("llamacpp")
337
341
  if model_name in served_models:
@@ -347,23 +351,31 @@ def ensure_llamacpp_server(model_name: str) -> None:
347
351
  download_hf_model(model_name, "llamacpp")
348
352
 
349
353
  gguf_files = sorted(hf_model_dir(model_name).rglob("*.gguf"))
350
- if not gguf_files:
354
+ primary_ggufs = [p for p in gguf_files if not is_auxiliary_gguf(p.name)]
355
+ if not primary_ggufs:
351
356
  raise ModelRuntimeError(status_code=500, detail="다운로드된 GGUF 파일을 찾지 못했습니다.")
352
357
 
353
- preferred = next((p for p in gguf_files if "q4_k_m" in p.name.lower()), None)
354
- model_file = preferred or gguf_files[0]
358
+ preferred = next(
359
+ (p for pref in GGUF_QUANT_PREFERENCE for p in primary_ggufs if pref in p.name.lower()),
360
+ None,
361
+ )
362
+ model_file = preferred or primary_ggufs[0]
363
+ command = [
364
+ "llama-server",
365
+ "-m",
366
+ str(model_file),
367
+ "--alias",
368
+ model_name,
369
+ "--host",
370
+ "127.0.0.1",
371
+ "--port",
372
+ "8080",
373
+ ]
374
+ mmprojs = [p for p in gguf_files if p.name.lower().startswith("mmproj-")]
375
+ if mmprojs:
376
+ command.extend(["--mmproj", str(mmprojs[0])])
355
377
  LOCAL_SERVER_PROCESSES["llamacpp"] = subprocess.Popen(
356
- [
357
- "llama-server",
358
- "-m",
359
- str(model_file),
360
- "--alias",
361
- model_name,
362
- "--host",
363
- "127.0.0.1",
364
- "--port",
365
- "8080",
366
- ],
378
+ command,
367
379
  stdout=subprocess.DEVNULL,
368
380
  stderr=subprocess.DEVNULL,
369
381
  start_new_session=True,
@@ -10,6 +10,7 @@ differently.
10
10
  from __future__ import annotations
11
11
 
12
12
  import importlib.util
13
+ import json
13
14
  import logging
14
15
  import time
15
16
  from pathlib import Path
@@ -19,6 +20,29 @@ from latticeai.core.quiet import quiet
19
20
  from latticeai.models.router import hf_cache_model_dir, hf_model_dir
20
21
  from latticeai.services.model_errors import ModelRuntimeError
21
22
 
23
+ _AUXILIARY_GGUF_PREFIXES = ("mmproj-", "mtp-", "dflash-", "eagle3-")
24
+ GGUF_QUANT_PREFERENCE = ("q4_k_m", "q4_0", "mxfp4", "q4_k_s", "q3_k_m", "q2_k", "q8_0")
25
+
26
+
27
+ def is_auxiliary_gguf(filename: str) -> bool:
28
+ base = Path(str(filename)).name.lower()
29
+ return base.startswith(_AUXILIARY_GGUF_PREFIXES) or "imatrix" in base
30
+
31
+
32
+ def _shards_complete(model_dir: Path) -> bool:
33
+ index_file = model_dir / "model.safetensors.index.json"
34
+ if not index_file.exists():
35
+ return True
36
+ try:
37
+ payload = json.loads(index_file.read_text(encoding="utf-8"))
38
+ weight_map = payload.get("weight_map") if isinstance(payload, dict) else None
39
+ if isinstance(weight_map, dict) and weight_map:
40
+ shards = {str(shard) for shard in weight_map.values() if shard}
41
+ return all((model_dir / shard).exists() for shard in shards)
42
+ except Exception:
43
+ quiet()
44
+ return True
45
+
22
46
 
23
47
  def hf_model_ready(repo_id: str, provider: str = "local_mlx") -> bool:
24
48
  model_dir = hf_model_dir(repo_id)
@@ -32,9 +56,11 @@ def hf_model_ready(repo_id: str, provider: str = "local_mlx") -> bool:
32
56
  if not model_dir.exists() or not model_dir.is_dir():
33
57
  return False
34
58
  if provider == "llamacpp":
35
- return any(model_dir.rglob("*.gguf"))
59
+ return any(not is_auxiliary_gguf(p.name) for p in model_dir.rglob("*.gguf"))
36
60
  has_config = (model_dir / "config.json").exists()
37
- has_weights = any(model_dir.glob("*.safetensors")) or any(model_dir.glob("*.bin"))
61
+ has_weights = (
62
+ any(model_dir.glob("*.safetensors")) or any(model_dir.glob("*.bin"))
63
+ ) and _shards_complete(model_dir)
38
64
  has_tokenizer = (
39
65
  (model_dir / "tokenizer.json").exists()
40
66
  or (model_dir / "tokenizer.model").exists()
@@ -139,15 +165,36 @@ def download_hf_model(
139
165
  [item for item in all_files if str(item["name"]).lower().endswith(".gguf")],
140
166
  key=lambda item: str(item["name"]),
141
167
  )
142
- if not ggufs:
168
+ primary_ggufs = [item for item in ggufs if not is_auxiliary_gguf(str(item["name"]))]
169
+ if not primary_ggufs:
143
170
  raise RuntimeError("GGUF 파일을 찾지 못했습니다.")
144
- preference = ("q4_k_m", "q4_0", "q4_k_s", "q3_k_m", "q2_k")
145
171
  selected_files = [
146
172
  next(
147
- (item for pref in preference for item in ggufs if pref in str(item["name"]).lower()),
148
- ggufs[0],
173
+ (
174
+ item
175
+ for pref in GGUF_QUANT_PREFERENCE
176
+ for item in primary_ggufs
177
+ if pref in str(item["name"]).lower()
178
+ ),
179
+ primary_ggufs[0],
149
180
  )
150
181
  ]
182
+ mmprojs = [
183
+ item
184
+ for item in ggufs
185
+ if Path(str(item["name"])).name.lower().startswith("mmproj-")
186
+ ]
187
+ if mmprojs:
188
+ preferred_mmproj = next(
189
+ (
190
+ item
191
+ for pref in ("bf16", "f16", "f32")
192
+ for item in mmprojs
193
+ if pref in str(item["name"]).lower()
194
+ ),
195
+ mmprojs[0],
196
+ )
197
+ selected_files.append(preferred_mmproj)
151
198
  else:
152
199
  selected_files = all_files
153
200
 
@@ -28,7 +28,7 @@ from typing import Any, Dict, List
28
28
 
29
29
  from latticeai.services.architecture_readiness import architecture_readiness
30
30
 
31
- PRODUCT_VERSION_TARGET = "12.2.0"
31
+ PRODUCT_VERSION_TARGET = "12.3.0"
32
32
 
33
33
 
34
34
  @dataclass(frozen=True)
@@ -123,7 +123,7 @@ PRODUCT_GATES: List[ProductGate] = [
123
123
  f"vscode-extension/README.md::**{PRODUCT_VERSION_TARGET}",
124
124
  f"docs/CHANGELOG.md::## [{PRODUCT_VERSION_TARGET}]",
125
125
  "FEATURE_STATUS.md",
126
- f"RELEASE_NOTES_v{PRODUCT_VERSION_TARGET}.md",
126
+ f"docs/releases/RELEASE_NOTES_v{PRODUCT_VERSION_TARGET}.md",
127
127
  "rust/lattice-agent/src/kernel/permission.rs::pub fn block_reason_for_tool",
128
128
  "rust/lattice-agent/src/kernel/mod.rs::pub mod agentloop",
129
129
  "latticeai/api/agent_worker_seam.py::create_agent_worker_seam_router",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "12.2.0",
3
+ "version": "12.3.0",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -111,16 +111,14 @@
111
111
  "!TOOLS.md",
112
112
  "!USER.md",
113
113
  "!docs/images/tmp_frames/",
114
- "!**/__pycache__/",
115
- "!**/*.pyc",
116
- "!**/*.map",
114
+ "!{latticeai,lattice_brain,skills,plugins,scripts}/**/__pycache__/",
115
+ "!{latticeai,lattice_brain,skills,plugins,scripts}/**/*.pyc",
116
+ "!static/**/*.map",
117
117
  "README.md",
118
118
  "static/vendor/",
119
119
  "src-tauri/",
120
120
  "!src-tauri/gen/",
121
- "!src-tauri/gen/**",
122
121
  "!src-tauri/target/",
123
- "!src-tauri/target/**",
124
122
  "docs/*.md",
125
123
  "!docs/assets/",
126
124
  "!docs/images/"