ltcai 11.1.0 → 11.4.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 (289) hide show
  1. package/README.md +47 -54
  2. package/docs/CHANGELOG.md +94 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
  6. package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
  7. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  8. package/docs/ONBOARDING.md +1 -1
  9. package/docs/OPERATIONS.md +6 -2
  10. package/docs/PERMISSION_MODE.md +1 -1
  11. package/docs/TRUST_MODEL.md +1 -1
  12. package/docs/WHY_LATTICE.md +1 -1
  13. package/docs/architecture.md +6 -2
  14. package/docs/kg-schema.md +2 -2
  15. package/docs/v11.3.0_PLAN.md +202 -0
  16. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
  17. package/lattice_brain/__init__.py +1 -1
  18. package/lattice_brain/gates.py +125 -0
  19. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  20. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  21. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  22. package/lattice_brain/graph/_kg_common/text.py +479 -0
  23. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  24. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  25. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  26. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  27. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  28. package/lattice_brain/graph/fusion.py +35 -4
  29. package/lattice_brain/graph/projection/__init__.py +42 -0
  30. package/lattice_brain/graph/projection/curation.py +500 -0
  31. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +81 -485
  32. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  33. package/lattice_brain/graph/retrieval/context.py +197 -0
  34. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  35. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  36. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  37. package/lattice_brain/graph/retrieval/signals.py +95 -0
  38. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  39. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  40. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  41. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  42. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  43. package/lattice_brain/graph/schema.py +9 -0
  44. package/lattice_brain/graph/store.py +9 -0
  45. package/lattice_brain/graph/vector_index/selector.py +32 -2
  46. package/lattice_brain/ingestion/__init__.py +130 -0
  47. package/lattice_brain/ingestion/_contract.py +90 -0
  48. package/lattice_brain/ingestion/constants.py +127 -0
  49. package/lattice_brain/ingestion/folder_scan.py +57 -0
  50. package/lattice_brain/ingestion/folders.py +258 -0
  51. package/lattice_brain/ingestion/hashing.py +26 -0
  52. package/lattice_brain/ingestion/jobs_api.py +107 -0
  53. package/lattice_brain/ingestion/models.py +80 -0
  54. package/lattice_brain/ingestion/pipeline.py +486 -0
  55. package/lattice_brain/ingestion/quality.py +209 -0
  56. package/lattice_brain/ingestion/routing.py +295 -0
  57. package/lattice_brain/multimodal/__init__.py +164 -0
  58. package/lattice_brain/multimodal/audio.py +77 -0
  59. package/lattice_brain/multimodal/common.py +118 -0
  60. package/lattice_brain/{multimodal.py → multimodal/images.py} +22 -262
  61. package/lattice_brain/multimodal/ports.py +169 -0
  62. package/lattice_brain/multimodal/video.py +410 -0
  63. package/lattice_brain/portability/__init__.py +90 -0
  64. package/lattice_brain/portability/_contract.py +42 -0
  65. package/lattice_brain/portability/backups.py +338 -0
  66. package/lattice_brain/portability/bundles.py +136 -0
  67. package/lattice_brain/portability/constants.py +93 -0
  68. package/lattice_brain/portability/fsops.py +138 -0
  69. package/lattice_brain/portability/service.py +41 -0
  70. package/lattice_brain/portability/sharing.py +714 -0
  71. package/lattice_brain/runtime/__init__.py +1 -1
  72. package/lattice_brain/runtime/multi_agent.py +1 -1
  73. package/lattice_brain/sealed_box.py +244 -0
  74. package/lattice_brain/synthesis.py +24 -1
  75. package/latticeai/__init__.py +1 -1
  76. package/latticeai/api/brain_intelligence.py +4 -0
  77. package/latticeai/api/chat.py +11 -0
  78. package/latticeai/api/chat_helpers.py +16 -3
  79. package/latticeai/api/chat_hybrid.py +32 -1
  80. package/latticeai/api/chronicle.py +63 -0
  81. package/latticeai/api/features.py +70 -0
  82. package/latticeai/api/local_files.py +102 -0
  83. package/latticeai/api/portability.py +39 -4
  84. package/latticeai/api/review_queue.py +126 -0
  85. package/latticeai/api/search.py +16 -2
  86. package/latticeai/core/agent/__init__.py +93 -0
  87. package/latticeai/core/agent/_contract.py +79 -0
  88. package/latticeai/core/agent/context.py +57 -0
  89. package/latticeai/core/agent/deps.py +125 -0
  90. package/latticeai/core/agent/execution.py +622 -0
  91. package/latticeai/core/agent/planning.py +145 -0
  92. package/latticeai/core/agent/recovery.py +157 -0
  93. package/latticeai/core/agent/runtime.py +210 -0
  94. package/latticeai/core/agent/verification.py +231 -0
  95. package/latticeai/core/config.py +4 -1
  96. package/latticeai/core/context_builder.py +6 -3
  97. package/latticeai/core/embedding_providers/__init__.py +151 -0
  98. package/latticeai/core/embedding_providers/base.py +199 -0
  99. package/latticeai/core/embedding_providers/captions.py +162 -0
  100. package/latticeai/core/embedding_providers/profiles.py +126 -0
  101. package/latticeai/core/embedding_providers/text.py +350 -0
  102. package/latticeai/core/embedding_providers/vision.py +352 -0
  103. package/latticeai/core/file_generation/__init__.py +115 -0
  104. package/latticeai/core/file_generation/bundles.py +76 -0
  105. package/latticeai/core/file_generation/extraction.py +154 -0
  106. package/latticeai/core/file_generation/inference.py +235 -0
  107. package/latticeai/core/file_generation/orchestration.py +152 -0
  108. package/latticeai/core/file_generation/prompting.py +117 -0
  109. package/latticeai/core/file_generation/repair.py +114 -0
  110. package/latticeai/core/file_generation/sanitize.py +61 -0
  111. package/latticeai/core/file_generation/validation.py +201 -0
  112. package/latticeai/core/legacy_compatibility.py +1 -1
  113. package/latticeai/core/marketplace.py +1 -1
  114. package/latticeai/core/messages.py +152 -0
  115. package/latticeai/core/model_compat.py +73 -2
  116. package/latticeai/core/workspace_os_constants.py +1 -1
  117. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  118. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  119. package/latticeai/integrations/telegram_bot/config.py +86 -0
  120. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  121. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  122. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  123. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  124. package/latticeai/models/model_providers.py +12 -4
  125. package/latticeai/models/router/__init__.py +88 -0
  126. package/latticeai/models/router/_contract.py +66 -0
  127. package/latticeai/models/router/branding.py +56 -0
  128. package/latticeai/models/router/catalog.py +69 -0
  129. package/latticeai/models/router/documents.py +199 -0
  130. package/latticeai/models/router/errors.py +37 -0
  131. package/latticeai/models/router/generation.py +258 -0
  132. package/latticeai/models/router/loading.py +291 -0
  133. package/latticeai/models/router/local_models.py +85 -0
  134. package/latticeai/models/router/registry.py +147 -0
  135. package/latticeai/runtime/build_phases/__init__.py +82 -0
  136. package/latticeai/runtime/build_phases/features.py +407 -0
  137. package/latticeai/runtime/build_phases/foundation.py +555 -0
  138. package/latticeai/runtime/build_phases/web.py +492 -0
  139. package/latticeai/runtime/chat_wiring.py +4 -0
  140. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  141. package/latticeai/runtime/router_registration.py +11 -0
  142. package/latticeai/runtime/runtime_context.py +1 -0
  143. package/latticeai/services/app_context.py +8 -0
  144. package/latticeai/services/architecture_readiness.py +48 -19
  145. package/latticeai/services/automation_intelligence.py +22 -2
  146. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  147. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  148. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  149. package/latticeai/services/brain_intelligence/constants.py +47 -0
  150. package/latticeai/services/brain_intelligence/digest.py +258 -0
  151. package/latticeai/services/brain_intelligence/health.py +331 -0
  152. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  153. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  154. package/latticeai/services/brain_intelligence/service.py +48 -0
  155. package/latticeai/services/chronicle.py +557 -0
  156. package/latticeai/services/command_center.py +10 -4
  157. package/latticeai/services/feature_toggles.py +502 -0
  158. package/latticeai/services/folder_watch.py +122 -1
  159. package/latticeai/services/hybrid_chat.py +56 -5
  160. package/latticeai/services/interop_bridges.py +978 -0
  161. package/latticeai/services/memory_service/__init__.py +52 -0
  162. package/latticeai/services/memory_service/_contract.py +100 -0
  163. package/latticeai/services/memory_service/brief.py +431 -0
  164. package/latticeai/services/memory_service/constants.py +57 -0
  165. package/latticeai/services/memory_service/maintenance.py +138 -0
  166. package/latticeai/services/memory_service/manager.py +186 -0
  167. package/latticeai/services/memory_service/proof.py +136 -0
  168. package/latticeai/services/memory_service/recall.py +225 -0
  169. package/latticeai/services/memory_service/service.py +48 -0
  170. package/latticeai/services/memory_service/stores.py +110 -0
  171. package/latticeai/services/model_capability_registry.py +434 -261
  172. package/latticeai/services/model_catalog.py +95 -61
  173. package/latticeai/services/model_recommendation.py +18 -11
  174. package/latticeai/services/model_runtime/__init__.py +322 -0
  175. package/latticeai/services/model_runtime/cloud.py +87 -0
  176. package/latticeai/services/model_runtime/download.py +282 -0
  177. package/latticeai/services/model_runtime/engines.py +341 -0
  178. package/latticeai/services/model_runtime/loading.py +178 -0
  179. package/latticeai/services/model_runtime/service.py +129 -0
  180. package/latticeai/services/model_runtime/state.py +131 -0
  181. package/latticeai/services/model_runtime/status.py +255 -0
  182. package/latticeai/services/multimodal_ports.py +26 -1
  183. package/latticeai/services/obsidian_bridge.py +16 -25
  184. package/latticeai/services/product_readiness.py +15 -7
  185. package/latticeai/services/search_service.py +149 -2
  186. package/latticeai/services/tool_dispatch.py +4 -0
  187. package/latticeai/setup/auto_setup.py +27 -30
  188. package/latticeai/setup/wizard/__init__.py +126 -0
  189. package/latticeai/setup/wizard/catalog.py +172 -0
  190. package/latticeai/setup/wizard/detect.py +323 -0
  191. package/latticeai/setup/wizard/install.py +348 -0
  192. package/latticeai/setup/wizard/paths.py +168 -0
  193. package/latticeai/setup/wizard/plans.py +74 -0
  194. package/latticeai/setup/wizard/recommend.py +320 -0
  195. package/package.json +6 -2
  196. package/scripts/bump_version.py +14 -0
  197. package/scripts/capture_release_evidence.mjs +33 -21
  198. package/scripts/check_current_release_docs.mjs +1 -1
  199. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  200. package/scripts/check_max_file_lines.mjs +102 -0
  201. package/scripts/check_release_evidence_bound.mjs +30 -15
  202. package/scripts/check_screenshot_pixel_delta.py +34 -4
  203. package/scripts/check_server_i18n.mjs +2 -0
  204. package/scripts/generate_rust_parity_fixtures.py +562 -0
  205. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  206. package/scripts/release_screen_claims.json +44 -2
  207. package/scripts/verify_hf_model_registry.py +253 -218
  208. package/src-tauri/Cargo.lock +361 -3
  209. package/src-tauri/Cargo.toml +6 -1
  210. package/src-tauri/src/backend.rs +349 -0
  211. package/src-tauri/src/folder.rs +33 -0
  212. package/src-tauri/src/main.rs +97 -399
  213. package/src-tauri/tauri.conf.json +1 -1
  214. package/static/app/asset-manifest.json +41 -37
  215. package/static/app/assets/Act-yYpYnn0v.js +1 -0
  216. package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
  217. package/static/app/assets/{Brain-CzCsI1mi.js → Brain-C1HBN0Wf.js} +2 -2
  218. package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
  219. package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
  220. package/static/app/assets/Capture-CFIRsFNE.js +1 -0
  221. package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
  222. package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
  223. package/static/app/assets/Library-DwO3yZST.js +1 -0
  224. package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-Jn1GK0-S.js} +1 -1
  225. package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
  226. package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
  227. package/static/app/assets/System-DW8F-2xL.js +1 -0
  228. package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
  229. package/static/app/assets/{bot-4BvN07ux.js → bot-IM_E_Y12.js} +1 -1
  230. package/static/app/assets/brain-Ci1CkWjM.js +1 -0
  231. package/static/app/assets/{button-CDjtnAoU.js → button-COwyqfHM.js} +1 -1
  232. package/static/app/assets/circle-check-DfInj-qD.js +1 -0
  233. package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-DEM4A1Y5.js} +1 -1
  234. package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-C9djDuLd.js} +1 -1
  235. package/static/app/assets/{cpu-BIlWInHf.js → cpu-DFdo1gw-.js} +1 -1
  236. package/static/app/assets/{download-BtjXfL3z.js → download-SnJL6oqk.js} +1 -1
  237. package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-CqZeDkjE.js} +1 -1
  238. package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-j1jJXYYf.js} +1 -1
  239. package/static/app/assets/{index-vtEfYvQY.css → index-BLPb5lmE.css} +1 -1
  240. package/static/app/assets/index-_u5iUHDr.js +10 -0
  241. package/static/app/assets/input-B0lPdRQZ.js +1 -0
  242. package/static/app/assets/link-2-CoFbooHS.js +1 -0
  243. package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-BsyLxtao.js} +1 -1
  244. package/static/app/assets/primitives-DEbN-d6p.js +1 -0
  245. package/static/app/assets/search-BybIWPNd.js +1 -0
  246. package/static/app/assets/{share-2-D5zg_0fY.js → share-2-CVtZ_ewX.js} +1 -1
  247. package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-CBi2GNWM.js} +1 -1
  248. package/static/app/assets/{textarea-nEVIweKY.js → textarea-DNMpB5ih.js} +1 -1
  249. package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-C83t3GXF.js} +1 -1
  250. package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
  251. package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-Dcp1OChy.js} +1 -1
  252. package/static/app/assets/utils-BlZr7Pd4.js +4 -0
  253. package/static/app/assets/workspace-jJY4RuAV.js +1 -0
  254. package/static/app/index.html +4 -4
  255. package/static/sw.js +1 -1
  256. package/lattice_brain/graph/_kg_common.py +0 -1331
  257. package/lattice_brain/graph/discovery_index.py +0 -1141
  258. package/lattice_brain/graph/retrieval.py +0 -1120
  259. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  260. package/lattice_brain/ingestion.py +0 -1377
  261. package/lattice_brain/portability.py +0 -1210
  262. package/latticeai/core/agent.py +0 -1412
  263. package/latticeai/core/embedding_providers.py +0 -1196
  264. package/latticeai/core/file_generation.py +0 -1047
  265. package/latticeai/integrations/telegram_bot.py +0 -1390
  266. package/latticeai/models/router.py +0 -1007
  267. package/latticeai/runtime/build_phases.py +0 -1422
  268. package/latticeai/services/brain_intelligence.py +0 -967
  269. package/latticeai/services/memory_service.py +0 -1177
  270. package/latticeai/services/model_runtime.py +0 -1281
  271. package/latticeai/setup/wizard.py +0 -1277
  272. package/static/app/assets/Act-D0HWqtn0.js +0 -1
  273. package/static/app/assets/AdminConsole-D-QDW-A4.js +0 -1
  274. package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
  275. package/static/app/assets/BrainSignals-2dHQNkns.js +0 -1
  276. package/static/app/assets/Capture-CT8v1StE.js +0 -1
  277. package/static/app/assets/CommandPalette-DoLXC2KH.js +0 -1
  278. package/static/app/assets/Library-DDoxFE5c.js +0 -1
  279. package/static/app/assets/ProductFlow-DOYf7JIs.js +0 -1
  280. package/static/app/assets/ReviewCard-COQsqidK.js +0 -3
  281. package/static/app/assets/System-BRllvYXd.js +0 -1
  282. package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
  283. package/static/app/assets/brain-uMb_5hnO.js +0 -1
  284. package/static/app/assets/index-0AvoEBzJ.js +0 -10
  285. package/static/app/assets/input-B_5ZJ9oy.js +0 -1
  286. package/static/app/assets/primitives-CVwew78r.js +0 -1
  287. package/static/app/assets/search-DkhnOKZt.js +0 -1
  288. package/static/app/assets/utils-DcDMoZIe.js +0 -4
  289. package/static/app/assets/workspace-LtRRSKTf.js +0 -1
@@ -0,0 +1,114 @@
1
+ """Deterministic scaffolds — step 5, the last resort after the retries.
2
+
3
+ The user asked for a file, so the request must still end in a well-formed file
4
+ of the target type, never an error. Nothing here calls a model: whatever the
5
+ model did produce is salvaged where it can be, and honestly labelled where it
6
+ cannot.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import html as html_lib
13
+ import json
14
+ import re
15
+
16
+ from .extraction import _ext, _slice_html_document, _slice_json_document
17
+ from .validation import looks_like_refusal
18
+
19
+
20
+ def repair_file_content(content: str, target_path: str, user_request: str) -> str:
21
+ """Turn whatever the model produced into a valid file of the target type.
22
+
23
+ This is the last resort after retries: the user asked for a file, so the
24
+ request must still end in a well-formed file, never an error.
25
+ """
26
+ ext = _ext(target_path)
27
+ salvage = content.strip()
28
+ if looks_like_refusal(salvage):
29
+ salvage = ""
30
+
31
+ if ext in (".html", ".htm"):
32
+ return _repair_html(salvage, user_request)
33
+ if ext == ".json":
34
+ sliced = _slice_json_document(salvage)
35
+ if sliced is not None:
36
+ return sliced
37
+ return json.dumps(
38
+ {"request": user_request, "content": salvage},
39
+ ensure_ascii=False,
40
+ indent=2,
41
+ )
42
+ if ext == ".py" and salvage:
43
+ # The repair guarantee for Python is parseability: unparseable output
44
+ # is preserved honestly as a commented-out draft, never as a broken
45
+ # module the user has to debug.
46
+ try:
47
+ ast.parse(salvage)
48
+ return salvage
49
+ except SyntaxError:
50
+ commented = "\n".join(f"# {line}" for line in salvage.splitlines())
51
+ return (
52
+ f"# TODO: model produced invalid Python for: {user_request}\n"
53
+ "# The draft below is preserved as comments — fix and uncomment.\n"
54
+ f"{commented}\n"
55
+ )
56
+ if salvage:
57
+ return salvage
58
+ # Nothing usable at all — leave an honest placeholder in the right format.
59
+ comment = {
60
+ ".py": "# TODO: model produced no usable content for: ",
61
+ ".js": "// TODO: model produced no usable content for: ",
62
+ ".jsx": "// TODO: model produced no usable content for: ",
63
+ ".ts": "// TODO: model produced no usable content for: ",
64
+ ".tsx": "// TODO: model produced no usable content for: ",
65
+ ".css": "/* TODO: model produced no usable content for: ",
66
+ ".sh": "# TODO: model produced no usable content for: ",
67
+ ".sql": "-- TODO: model produced no usable content for: ",
68
+ }.get(ext, "")
69
+ if ext == ".css":
70
+ return f"{comment}{user_request} */\n"
71
+ if comment:
72
+ return f"{comment}{user_request}\n"
73
+ return f"{user_request}\n"
74
+
75
+
76
+ def _repair_html(salvage: str, user_request: str) -> str:
77
+ lower = salvage.lower()
78
+ if "<html" in lower or "<!doctype" in lower:
79
+ # A real document that is merely truncated — close it.
80
+ doc = _slice_html_document(salvage)
81
+ low = doc.lower()
82
+ if "</body>" not in low and "<body" in low:
83
+ doc += "\n</body>"
84
+ if "</html>" not in low:
85
+ doc += "\n</html>"
86
+ return doc
87
+ if re.search(r"<\w+[^>]*>", salvage):
88
+ body = salvage # an HTML fragment — embed as-is
89
+ elif salvage:
90
+ body = "\n".join(
91
+ f" <p>{html_lib.escape(line)}</p>"
92
+ for line in salvage.splitlines()
93
+ if line.strip()
94
+ )
95
+ else:
96
+ body = f" <p>{html_lib.escape(user_request)}</p>"
97
+ title = html_lib.escape(user_request[:60] or "Generated page")
98
+ return (
99
+ "<!DOCTYPE html>\n"
100
+ "<html lang=\"ko\">\n"
101
+ "<head>\n"
102
+ " <meta charset=\"utf-8\">\n"
103
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
104
+ f" <title>{title}</title>\n"
105
+ " <style>\n"
106
+ " body { font-family: system-ui, sans-serif; margin: 2rem auto; "
107
+ "max-width: 720px; line-height: 1.6; padding: 0 1rem; }\n"
108
+ " </style>\n"
109
+ "</head>\n"
110
+ "<body>\n"
111
+ f"{body}\n"
112
+ "</body>\n"
113
+ "</html>"
114
+ )
@@ -0,0 +1,61 @@
1
+ """The single write-side guarantee for model-produced file content.
2
+
3
+ ``sanitize_write_content`` is the one door every write entry point goes
4
+ through — the direct chat path, the agent JSON loop, the tools layer — so a
5
+ fenced or chatty payload can never be persisted verbatim no matter which
6
+ surface produced it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, Tuple
12
+
13
+ from .extraction import extract_file_content
14
+ from .repair import repair_file_content
15
+ from .validation import validate_file_content
16
+
17
+ # Extensions the Brain UI can render inline (preview) after creation.
18
+ PREVIEWABLE_EXTENSIONS = frozenset({
19
+ ".html", ".htm", ".md", ".markdown", ".txt", ".json", ".css", ".js",
20
+ ".csv", ".py", ".yaml", ".yml", ".xml", ".sql", ".sh",
21
+ ".jsx", ".ts", ".tsx", ".vue", ".svelte",
22
+ })
23
+
24
+
25
+ def sanitize_write_content(
26
+ target_path: str,
27
+ content: Any,
28
+ user_request: str = "",
29
+ ) -> Tuple[str, Dict[str, Any]]:
30
+ """Single write-side guarantee for model-produced file content.
31
+
32
+ The direct chat path already runs the full generate→validate→repair
33
+ pipeline, but the agent JSON loop historically wrote ``args.content``
34
+ verbatim — weak models routinely put fenced/chatty payloads there. This
35
+ conservative sanitizer closes that gap for *any* write entry point:
36
+
37
+ 1. content that already validates is returned byte-for-byte unchanged
38
+ (trusted/user-authored content is never mangled);
39
+ 2. otherwise the extraction pass strips fences/think-blocks/chat noise
40
+ and is used only when the extracted payload validates;
41
+ 3. otherwise deterministic repair guarantees a structurally valid file.
42
+
43
+ Empty content is left untouched (creating an empty file is a legitimate,
44
+ intentional action — e.g. ``__init__.py``). Returns ``(content, meta)``
45
+ where meta is ``{"sanitized": bool, "repaired": bool, "reason": str}``.
46
+ """
47
+ raw = str(content or "")
48
+ if not raw.strip():
49
+ return raw, {"sanitized": False, "repaired": False, "reason": "empty"}
50
+ ok, reason = validate_file_content(raw, target_path)
51
+ if ok:
52
+ return raw, {"sanitized": False, "repaired": False, "reason": "ok"}
53
+ extracted = extract_file_content(raw, target_path)
54
+ if extracted:
55
+ extracted_ok, _ = validate_file_content(extracted, target_path)
56
+ if extracted_ok:
57
+ return extracted, {"sanitized": True, "repaired": False, "reason": reason}
58
+ repaired = repair_file_content(
59
+ extracted or raw, target_path, user_request or f"content for {target_path}"
60
+ )
61
+ return repaired, {"sanitized": True, "repaired": True, "reason": reason}
@@ -0,0 +1,201 @@
1
+ """Per-extension structural checks — step 3 of the pipeline.
2
+
3
+ Answers one question about an already-extracted payload: *is this a file of the
4
+ requested type?* HTML document shape, JSON parses, CSS has rule blocks, Python
5
+ parses, braced code is balanced, components close their blocks — and, for prose
6
+ types with no grammar to check, that the reply is not merely talk about the
7
+ file.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import json
14
+ import re
15
+ from typing import List, Tuple
16
+
17
+ from .extraction import _ext
18
+
19
+ _REFUSAL_RE = re.compile(
20
+ r"(i can('|no)?t|i'?m (sorry|unable)|as an ai|cannot assist"
21
+ r"|죄송(하지만|합니다)|할 수 없|불가능합니다|도와드릴 수 없)",
22
+ re.IGNORECASE,
23
+ )
24
+
25
+
26
+ def looks_like_refusal(content: str) -> bool:
27
+ head = content[:300]
28
+ return bool(_REFUSAL_RE.search(head)) and len(content) < 600
29
+
30
+
31
+ # Braced code types validated structurally (balanced delimiters, no fences).
32
+ _BRACED_CODE_EXTENSIONS = frozenset({".js", ".jsx", ".ts", ".tsx"})
33
+ # Single-file components validated by their block tags being closed.
34
+ _COMPONENT_EXTENSIONS = frozenset({".vue", ".svelte"})
35
+
36
+
37
+ def _strip_code_literals(text: str) -> str:
38
+ """Remove string literals and comments so delimiter counting stays honest.
39
+
40
+ A cheap single-pass scanner (not a parser): quotes ('', "", ``), line
41
+ comments (//) and block comments (/* */) commonly contain lone braces
42
+ that would otherwise false-flag valid JS/TS as unbalanced.
43
+ """
44
+ out: List[str] = []
45
+ i, n = 0, len(text)
46
+ while i < n:
47
+ ch = text[i]
48
+ nxt = text[i + 1] if i + 1 < n else ""
49
+ if ch == "\\":
50
+ i += 2 # escaped char (inside or outside a literal — always skip)
51
+ continue
52
+ if ch in ("'", '"', "`"):
53
+ quote = ch
54
+ i += 1
55
+ while i < n:
56
+ if text[i] == "\\":
57
+ i += 2
58
+ continue
59
+ if text[i] == quote:
60
+ i += 1
61
+ break
62
+ i += 1
63
+ continue
64
+ if ch == "/" and nxt == "/":
65
+ while i < n and text[i] != "\n":
66
+ i += 1
67
+ continue
68
+ if ch == "/" and nxt == "*":
69
+ end = text.find("*/", i + 2)
70
+ i = n if end < 0 else end + 2
71
+ continue
72
+ out.append(ch)
73
+ i += 1
74
+ return "".join(out)
75
+
76
+
77
+ def _check_balanced_delimiters(content: str) -> Tuple[bool, str]:
78
+ """Lenient count-based balance check for braced code (js/ts family).
79
+
80
+ Only *counts* are compared, never ordering, so valid-but-unusual code is
81
+ not rejected; a truncated file with a dangling ``{`` still fails.
82
+ """
83
+ stripped = _strip_code_literals(content)
84
+ for opener, closer, label in (("{", "}", "braces"), ("(", ")", "parentheses"), ("[", "]", "brackets")):
85
+ if stripped.count(opener) != stripped.count(closer):
86
+ return False, f"unbalanced {label} ({opener}{closer}) — the file looks truncated"
87
+ return True, "ok"
88
+
89
+
90
+ def _check_component_blocks(content: str) -> Tuple[bool, str]:
91
+ """Vue/Svelte SFC sanity: every opened block tag must be closed."""
92
+ lower = content.lower()
93
+ for tag in ("template", "script", "style"):
94
+ opened = len(re.findall(rf"<{tag}(?:\s[^>]*)?>", lower))
95
+ closed = lower.count(f"</{tag}>")
96
+ if opened != closed:
97
+ return False, f"<{tag}> block is not closed — the component looks truncated"
98
+ return True, "ok"
99
+
100
+
101
+ def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
102
+ """Structural sanity check per file type. Returns (ok, reason)."""
103
+ if not content.strip():
104
+ return False, "empty output"
105
+ if looks_like_refusal(content):
106
+ return False, "the reply was a refusal/chat message, not file content"
107
+
108
+ ext = _ext(target_path)
109
+ if ext in (".html", ".htm"):
110
+ lower = content.lower()
111
+ if "<html" not in lower and "<!doctype" not in lower:
112
+ return False, "not a complete HTML document (missing <!DOCTYPE html>/<html>)"
113
+ if "</html>" not in lower:
114
+ return False, "HTML document is truncated (missing </html>)"
115
+ # A document that merely *contains* html somewhere is not a valid
116
+ # file payload: fenced/chat-wrapped replies must fail here so the
117
+ # extraction pass gets a chance to slice out the real document.
118
+ stripped = content.lstrip()
119
+ if not (stripped.lower().startswith("<!doctype") or stripped.lower().startswith("<html")):
120
+ return False, "HTML document is wrapped in prose or fences"
121
+ if "```" in content:
122
+ return False, "output still contains Markdown fences"
123
+ return True, "ok"
124
+ if ext == ".json":
125
+ try:
126
+ json.loads(content)
127
+ except (ValueError, TypeError) as exc:
128
+ return False, f"invalid JSON: {exc}"
129
+ return True, "ok"
130
+ if ext == ".css":
131
+ if "```" in content:
132
+ return False, "output still contains Markdown fences"
133
+ if "{" not in content or "}" not in content:
134
+ return False, "no CSS rule blocks found"
135
+ return True, "ok"
136
+ if ext == ".py":
137
+ if "```" in content:
138
+ return False, "output still contains Markdown fences"
139
+ try:
140
+ ast.parse(content)
141
+ except SyntaxError as exc:
142
+ return False, f"invalid Python syntax: {exc.msg} (line {exc.lineno})"
143
+ return True, "ok"
144
+ if ext in _BRACED_CODE_EXTENSIONS:
145
+ if "```" in content:
146
+ return False, "output still contains Markdown fences"
147
+ return _check_balanced_delimiters(content)
148
+ if ext in _COMPONENT_EXTENSIONS:
149
+ if "```" in content:
150
+ return False, "output still contains Markdown fences"
151
+ return _check_component_blocks(content)
152
+ if ext in (".sh", ".sql"):
153
+ if "```" in content:
154
+ return False, "output still contains Markdown fences"
155
+ return True, "ok"
156
+ # Prose types (.md, .txt, .csv, …) have no grammar to check, which used to
157
+ # mean *nothing* was checked: a 1–4B model that answered "Sure! Here is the
158
+ # document you asked for:" and stopped had its sentence saved as the file,
159
+ # because the fence stripper only removes conversational lines it can
160
+ # recognise and the length guard on `looks_like_refusal` lets a wordy
161
+ # refusal through. The two checks below are the only ones that generalise
162
+ # without inventing a grammar: it must not still be wearing fences, and it
163
+ # must not be *only* an answer about the file.
164
+ if "```" in content:
165
+ return False, "output still contains Markdown fences"
166
+ if _looks_like_commentary(content):
167
+ return False, "the reply talks about the file instead of being the file"
168
+ return True, "ok"
169
+
170
+
171
+ # Openers that mean "I am about to give you the thing" — if the whole reply is
172
+ # one of these, the thing never arrived.
173
+ _COMMENTARY_RE = re.compile(
174
+ r"^\s*("
175
+ r"(sure|of course|certainly|okay|ok|alright|here|below|the following)\b"
176
+ r"|i('ve| have| will|'ll)\b"
177
+ r"|(물론|네[,!. ]|알겠|다음은|아래(는|의)?|요청하신|원하시는)"
178
+ r")",
179
+ re.IGNORECASE,
180
+ )
181
+
182
+
183
+ def _looks_like_commentary(content: str) -> bool:
184
+ """True when the reply reads as an answer *about* a file, not the file.
185
+
186
+ Deliberately conservative — a long document that merely opens with "The
187
+ following" is a document. Only a short reply that both opens
188
+ conversationally and never grows into content is rejected, so a real file
189
+ is never thrown away to catch a chat line.
190
+ """
191
+ stripped = content.strip()
192
+ if len(stripped) > 400:
193
+ return False
194
+ if not _COMMENTARY_RE.match(stripped):
195
+ return False
196
+ # Structure means content arrived after the preamble: a heading, a list, a
197
+ # table row, a delimiter, or simply several lines of body text.
198
+ body = stripped.split("\n", 1)[1].strip() if "\n" in stripped else ""
199
+ if re.search(r"^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|\||>)", body, re.MULTILINE):
200
+ return False
201
+ return len(body) < 120
@@ -13,7 +13,7 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
- LEGACY_COMPATIBILITY_VERSION = "11.1.0"
16
+ LEGACY_COMPATIBILITY_VERSION = "11.4.0"
17
17
 
18
18
 
19
19
  @dataclass(frozen=True)
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
- MARKETPLACE_VERSION = "11.1.0"
13
+ MARKETPLACE_VERSION = "11.4.0"
14
14
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
15
15
 
16
16
 
@@ -307,6 +307,31 @@ MESSAGES: Dict[str, Dict[str, str]] = {
307
307
  "ko": "'{status}' 상태의 검토 항목은 승인할 수 없습니다.",
308
308
  "en": "A review item in status '{status}' cannot be approved.",
309
309
  },
310
+ "review.bulk_ids_required": {
311
+ "ko": "한꺼번에 처리할 검토 항목을 하나 이상 골라 주세요.",
312
+ "en": "Choose at least one review item to act on.",
313
+ },
314
+ "review.bulk_too_many": {
315
+ "ko": "한 번에 최대 {cap}개까지 처리할 수 있습니다.",
316
+ "en": "At most {cap} items can be handled in one request.",
317
+ },
318
+ # ── interop bridges (Notion export / git / mail / calendar) ──────────
319
+ "ingestion.interop_path_required": {
320
+ "ko": "불러올 파일이나 폴더 경로가 필요합니다.",
321
+ "en": "A file or folder path to read is required.",
322
+ },
323
+ "ingestion.interop_unknown_source": {
324
+ "ko": "알 수 없는 연동 종류입니다: {source}",
325
+ "en": "Unknown interop source: {source}",
326
+ },
327
+ "ingestion.vault_watch_unavailable": {
328
+ "ko": "보관함 자동 확인 기능을 사용할 수 없습니다.",
329
+ "en": "Vault watch is unavailable.",
330
+ },
331
+ "ingestion.vault_watch_disabled": {
332
+ "ko": "보관함 자동 확인은 기본으로 꺼져 있습니다. 설정에서 켜야 사용할 수 있습니다.",
333
+ "en": "Vault watch is off by default; turn it on in settings to use it.",
334
+ },
310
335
  # ── projects ────────────────────────────────────────────────────────
311
336
  "project.not_found": {
312
337
  "ko": "프로젝트를 찾을 수 없습니다.",
@@ -317,6 +342,124 @@ MESSAGES: Dict[str, Dict[str, str]] = {
317
342
  "ko": "혼합 검색 정책 서비스가 설정되지 않았습니다.",
318
343
  "en": "The hybrid policy service is not configured.",
319
344
  },
345
+ # ── feature toggles ─────────────────────────────────────────────────
346
+ # The switchboard renders from the server (see
347
+ # ``latticeai/services/feature_toggles.py``), so every label and one-line
348
+ # explanation a person reads lives here. The rule for the summaries: say
349
+ # what turning it *on* does, in the words someone who never read the
350
+ # environment-variable docs would use.
351
+ "features.note": {
352
+ "ko": "모두 지금 바로 적용됩니다. 다시 시작하지 않아도 됩니다.",
353
+ "en": "Every switch here takes effect right away — no restart needed.",
354
+ },
355
+ "features.unknown": {
356
+ "ko": "그런 기능은 없습니다: {feature}",
357
+ "en": "There is no such feature: {feature}",
358
+ },
359
+ "features.invalid_value": {
360
+ "ko": "이 기능에 쓸 수 없는 값입니다: {value}",
361
+ "en": "That is not a value this feature can take: {value}",
362
+ },
363
+ "features.choice.install_required": {
364
+ "ko": "설치 필요 — {reason}",
365
+ "en": "Install required — {reason}",
366
+ },
367
+ "features.allow_multimodal.label": {
368
+ "ko": "사진·녹음도 기억하기",
369
+ "en": "Remember pictures and recordings",
370
+ },
371
+ "features.allow_multimodal.summary": {
372
+ "ko": "폴더를 읽을 때 글뿐 아니라 사진과 녹음도 함께 저장합니다.",
373
+ "en": "A folder scan stores pictures and recordings too, not just text.",
374
+ },
375
+ "features.video_ingest.label": {
376
+ "ko": "영상도 함께",
377
+ "en": "Include videos",
378
+ },
379
+ "features.video_ingest.summary": {
380
+ "ko": "사진·녹음을 켠 상태에서, 영상은 장면과 자막으로 저장합니다.",
381
+ "en": "With the switch above on, videos are stored as keyframes and subtitles.",
382
+ },
383
+ "features.vault_watch.label": {
384
+ "ko": "노트 보관함 지켜보기",
385
+ "en": "Watch my notes vault",
386
+ },
387
+ "features.vault_watch.summary": {
388
+ "ko": "밖에 있는 노트 보관함이 바뀌면 알아서 다시 읽어옵니다.",
389
+ "en": "When an outside notes vault changes, it is re-read on its own.",
390
+ },
391
+ "features.brain_network.label": {
392
+ "ko": "골라서 나누기",
393
+ "en": "Share selected knowledge",
394
+ },
395
+ "features.brain_network.summary": {
396
+ "ko": "내가 고른 기억 묶음만 다른 기기로 내보내고 받아올 수 있습니다.",
397
+ "en": "Lets you export a hand-picked slice of memory to another device, and receive one.",
398
+ },
399
+ "features.brain_network.caution": {
400
+ "ko": "이 기능만 기억을 이 컴퓨터 밖으로 내보냅니다. 받은 내용은 바로 합쳐지지 않고 검토함으로 갑니다.",
401
+ "en": "This is the one switch that sends memory off this computer. Anything received waits in the review inbox instead of merging.",
402
+ },
403
+ "features.synthesis.label": {
404
+ "ko": "스스로 정리하기",
405
+ "en": "Tidy up on its own",
406
+ },
407
+ "features.synthesis.summary": {
408
+ "ko": "자료가 쌓이면 알아서 훑어보고, 고칠 거리를 검토함에 제안합니다.",
409
+ "en": "As material piles up, the Brain reviews it and proposes tidy-ups in the review inbox.",
410
+ },
411
+ "features.auto_vector_index.label": {
412
+ "ko": "넣자마자 검색 준비",
413
+ "en": "Make new material searchable at once",
414
+ },
415
+ "features.auto_vector_index.summary": {
416
+ "ko": "새 자료를 넣으면 바로 의미 검색까지 준비합니다. 끄면 나중에 한 번에 만듭니다.",
417
+ "en": "New material is prepared for meaning-based search immediately; off means you rebuild later.",
418
+ },
419
+ "features.auto_late_fusion.label": {
420
+ "ko": "글로 사진 찾기",
421
+ "en": "Find pictures by typing",
422
+ },
423
+ "features.auto_late_fusion.summary": {
424
+ "ko": "글로 물어봐도 사진까지 함께 찾습니다. 사진을 읽는 모델이 있어야 켜집니다.",
425
+ "en": "A typed question also searches pictures — needs a vision model that shares the same space.",
426
+ },
427
+ "features.fusion_rrf.label": {
428
+ "ko": "검색 결과 합치는 방식 바꾸기",
429
+ "en": "Blend search results by rank",
430
+ },
431
+ "features.fusion_rrf.summary": {
432
+ "ko": "점수 대신 순위로 합칩니다. 검색 채널마다 점수 크기가 달라도 흔들리지 않습니다.",
433
+ "en": "Combines channels by position instead of score, so mismatched score scales stop skewing results.",
434
+ },
435
+ "features.graph_expansion.label": {
436
+ "ko": "옆에 있는 기억까지 보기",
437
+ "en": "Look at neighbouring memories",
438
+ },
439
+ "features.graph_expansion.summary": {
440
+ "ko": "찾은 기억과 바로 이어진 기억도 후보로 넣습니다. 답은 넓어지고 조금 흐려집니다.",
441
+ "en": "Adds memories one link away from a hit as candidates — wider answers, slightly less focused.",
442
+ },
443
+ "features.vector_backend.label": {
444
+ "ko": "의미 검색 방식",
445
+ "en": "Meaning-search engine",
446
+ },
447
+ "features.vector_backend.summary": {
448
+ "ko": "빠르기와 정확함 사이에서 고릅니다. 기본값은 전부 훑어보는 정확한 방식입니다.",
449
+ "en": "Trade speed against exactness. The default compares everything and is exact.",
450
+ },
451
+ "features.vector_backend.choice.brute": {
452
+ "ko": "전부 비교 (정확)",
453
+ "en": "Compare everything (exact)",
454
+ },
455
+ "features.vector_backend.choice.quantized": {
456
+ "ko": "간추려 비교 (빠름)",
457
+ "en": "Compare compressed (faster)",
458
+ },
459
+ "features.vector_backend.choice.hnsw": {
460
+ "ko": "근사 검색 (가장 빠름)",
461
+ "en": "Approximate search (fastest)",
462
+ },
320
463
  # ── models ──────────────────────────────────────────────────────────
321
464
  "models.other_user_credentials": {
322
465
  "ko": "다른 사용자의 모델 자격 증명을 사용할 수 없습니다.",
@@ -379,6 +522,15 @@ MESSAGES: Dict[str, Dict[str, str]] = {
379
522
  "ko": "디렉터리를 찾을 수 없습니다.",
380
523
  "en": "That directory was not found.",
381
524
  },
525
+ # ── chronicle ───────────────────────────────────────────────────────
526
+ "chronicle.bad_date": {
527
+ "ko": "날짜를 읽을 수 없습니다. 2026-08-11 처럼 연-월-일로 적어 주세요.",
528
+ "en": "That date could not be read. Write it as year-month-day, like 2026-08-11.",
529
+ },
530
+ "chronicle.bad_timestamp": {
531
+ "ko": "시점을 읽을 수 없습니다. 2026-08-11T09:00:00 처럼 적어 주세요.",
532
+ "en": "That moment could not be read. Write it like 2026-08-11T09:00:00.",
533
+ },
382
534
  # ── MCP / setup ─────────────────────────────────────────────────────
383
535
  "mcp.connector_not_found": {
384
536
  "ko": "커넥터를 찾을 수 없습니다.",
@@ -32,14 +32,54 @@ FAMILY_PATTERNS: List[Tuple[str, re.Pattern]] = [
32
32
  ("qwen", re.compile(r"qwen", re.I)),
33
33
  ("llama", re.compile(r"\bllama|meta[-_]?llama", re.I)),
34
34
  ("claude", re.compile(r"claude", re.I)),
35
+ # gpt-oss is its own local family with its own chat format, so it must be
36
+ # matched before the cloud `gpt` pattern rather than falling into it.
37
+ ("gpt_oss", re.compile(r"gpt[-_]?oss", re.I)),
35
38
  ("gpt", re.compile(r"gpt[-_]?(?:4|5)|openai", re.I)),
36
39
  ("gemini", re.compile(r"gemini", re.I)),
37
40
  ("grok", re.compile(r"grok|x[-_]?ai", re.I)),
41
+ ("lfm2", re.compile(r"\blfm[-_]?2", re.I)),
38
42
  ]
39
43
 
44
+ #: HF ``config.json`` ``model_type`` → family code. An id is a name somebody
45
+ #: chose; an architecture is what mlx-lm / mlx-vlm actually dispatches on, so it
46
+ #: is the more reliable signal when we have it. Values here are the architectures
47
+ #: pinned in the model capability registry (11.2.0) plus the ones that came
48
+ #: before, so a model already on disk keeps its profile after its generation
49
+ #: stops being offered.
50
+ ARCHITECTURE_FAMILIES: Dict[str, str] = {
51
+ "gemma2": "gemma",
52
+ "gemma3": "gemma",
53
+ "gemma4": "gemma",
54
+ "gemma4_unified": "gemma",
55
+ "qwen2_5_vl": "qwen",
56
+ "qwen3_vl": "qwen",
57
+ "qwen3_vl_moe": "qwen",
58
+ "qwen3_5": "qwen",
59
+ "qwen3_5_moe": "qwen",
60
+ "llama": "llama",
61
+ "llama4": "llama",
62
+ "mllama": "llama",
63
+ "gpt_oss": "gpt_oss",
64
+ "lfm2": "lfm2",
65
+ }
66
+
67
+
68
+ def family_for_architecture(architecture: Optional[str]) -> str:
69
+ """Map an HF ``model_type`` to a family code (``"unknown"`` when unmapped)."""
70
+ return ARCHITECTURE_FAMILIES.get(str(architecture or "").strip().lower(), "unknown")
71
+
40
72
 
41
- def detect_model_family(model_id: str) -> str:
42
- """주어진 model_id 문자열에서 family 코드를 추론한다."""
73
+ def detect_model_family(model_id: str, architecture: Optional[str] = None) -> str:
74
+ """주어진 model_id 문자열에서 family 코드를 추론한다.
75
+
76
+ ``architecture`` (HF config ``model_type``) 가 주어지고 알려진 값이면 그것을
77
+ 우선한다 — 로더가 실제로 분기하는 값이기 때문이다.
78
+ """
79
+ if architecture:
80
+ by_arch = family_for_architecture(architecture)
81
+ if by_arch != "unknown":
82
+ return by_arch
43
83
  if not model_id:
44
84
  return "unknown"
45
85
  raw = str(model_id)
@@ -96,6 +136,35 @@ FAMILY_PROFILES: Dict[str, Dict[str, Any]] = {
96
136
  "disable_draft": False,
97
137
  "postprocess": ["strip_role_tokens"],
98
138
  },
139
+ # gpt-oss speaks the "harmony" response format: the channel markers are
140
+ # stop sequences, not text, so the reply never has to be salvaged from them.
141
+ "gpt_oss": {
142
+ "family": "gpt_oss",
143
+ "supports_system": True,
144
+ "supports_vision": False,
145
+ "chat_template": "tokenizer_default",
146
+ "preferred_engines": ["local_mlx", "ollama", "llamacpp"],
147
+ "temperature": 0.2,
148
+ "top_p": 0.9,
149
+ "max_tokens": 4096,
150
+ "stop_sequences": ["<|return|>", "<|call|>", "<|endoftext|>"],
151
+ "disable_draft": False,
152
+ "postprocess": ["strip_role_tokens"],
153
+ },
154
+ # LFM2 uses a ChatML-style template; text only, so no vision claim.
155
+ "lfm2": {
156
+ "family": "lfm2",
157
+ "supports_system": True,
158
+ "supports_vision": False,
159
+ "chat_template": "tokenizer_default",
160
+ "preferred_engines": ["local_mlx", "ollama", "llamacpp"],
161
+ "temperature": 0.2,
162
+ "top_p": 0.9,
163
+ "max_tokens": 4096,
164
+ "stop_sequences": ["<|im_end|>", "<|endoftext|>"],
165
+ "disable_draft": False,
166
+ "postprocess": ["strip_role_tokens"],
167
+ },
99
168
  "unknown": {
100
169
  "family": "unknown",
101
170
  "supports_system": True,
@@ -623,9 +692,11 @@ def get_stop_sequences(model_id: str, engine: Optional[str] = None) -> List[str]
623
692
 
624
693
 
625
694
  __all__ = [
695
+ "ARCHITECTURE_FAMILIES",
626
696
  "FAMILY_PROFILES",
627
697
  "CompatProfile",
628
698
  "detect_model_family",
699
+ "family_for_architecture",
629
700
  "friendly_model_runtime_error",
630
701
  "get_model_profile",
631
702
  "model_runtime_compatibility",