@bendyline/gezel-inference 0.0.0-stage → 1.0.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 (967) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -2
  3. package/dist/cache/adapter.d.ts +115 -0
  4. package/dist/cache/adapter.js +23 -0
  5. package/dist/cache/budget.d.ts +15 -0
  6. package/dist/cache/budget.js +31 -0
  7. package/dist/cache/controller.d.ts +205 -0
  8. package/dist/cache/controller.js +377 -0
  9. package/dist/catalog/LICENSE +21 -0
  10. package/dist/catalog/chat-models/bt/btl4-35b-q4/manifest.json +146 -0
  11. package/dist/catalog/chat-models/bt/btl4-35b-q4/versions/1.0.0/manifest.json +15 -0
  12. package/dist/catalog/chat-models/bt/btl4-35b-q4/versions/1.0.1/manifest.json +15 -0
  13. package/dist/catalog/chat-models/bt/btl4-compact-iq2/manifest.json +146 -0
  14. package/dist/catalog/chat-models/bt/btl4-compact-iq2/versions/1.0.0/manifest.json +15 -0
  15. package/dist/catalog/chat-models/bt/btl4-compact-iq2/versions/1.0.1/manifest.json +15 -0
  16. package/dist/catalog/chat-models/bt/btl4-compact-iq2/versions/1.0.2/manifest.json +15 -0
  17. package/dist/catalog/chat-models/de/deepseek-r1-8b-q4/manifest.json +166 -0
  18. package/dist/catalog/chat-models/de/deepseek-r1-8b-q4/versions/1.1.0/manifest.json +62 -0
  19. package/dist/catalog/chat-models/de/deepseek-r1-8b-q4/versions/1.1.1/manifest.json +61 -0
  20. package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-mxfp4/manifest.json +125 -0
  21. package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-mxfp4/versions/1.0.0/manifest.json +27 -0
  22. package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q2/manifest.json +125 -0
  23. package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q2/versions/1.0.0/manifest.json +27 -0
  24. package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q2/versions/1.1.0/manifest.json +27 -0
  25. package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q2q4/manifest.json +126 -0
  26. package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q2q4/versions/1.0.0/manifest.json +27 -0
  27. package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q4/manifest.json +318 -0
  28. package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q4/versions/1.0.0/manifest.json +225 -0
  29. package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q4/versions/1.1.0/manifest.json +225 -0
  30. package/dist/catalog/chat-models/ge/gemma4-12b-q4/manifest.json +249 -0
  31. package/dist/catalog/chat-models/ge/gemma4-12b-q4/versions/1.0.0/manifest.json +70 -0
  32. package/dist/catalog/chat-models/ge/gemma4-12b-q4/versions/1.1.0/manifest.json +72 -0
  33. package/dist/catalog/chat-models/ge/gemma4-12b-q4/versions/1.1.2/manifest.json +78 -0
  34. package/dist/catalog/chat-models/ge/gemma4-12b-q4/versions/1.1.3/manifest.json +78 -0
  35. package/dist/catalog/chat-models/ge/gemma4-12b-q4/versions/1.2.0/manifest.json +98 -0
  36. package/dist/catalog/chat-models/ge/gemma4-12b-q8/manifest.json +228 -0
  37. package/dist/catalog/chat-models/ge/gemma4-12b-q8/versions/1.0.0/manifest.json +72 -0
  38. package/dist/catalog/chat-models/ge/gemma4-12b-q8/versions/1.0.2/manifest.json +78 -0
  39. package/dist/catalog/chat-models/ge/gemma4-12b-q8/versions/1.0.3/manifest.json +78 -0
  40. package/dist/catalog/chat-models/ge/gemma4-12b-q8/versions/1.1.0/manifest.json +78 -0
  41. package/dist/catalog/chat-models/ge/gemma4-26b-q4/manifest.json +220 -0
  42. package/dist/catalog/chat-models/ge/gemma4-26b-q4/versions/1.1.0/manifest.json +75 -0
  43. package/dist/catalog/chat-models/ge/gemma4-26b-q4/versions/1.2.0/manifest.json +75 -0
  44. package/dist/catalog/chat-models/ge/gemma4-26b-q4/versions/1.2.1/manifest.json +80 -0
  45. package/dist/catalog/chat-models/ge/gemma4-26b-q4/versions/1.2.2/manifest.json +80 -0
  46. package/dist/catalog/chat-models/ge/gemma4-26b-q4/versions/1.3.0/manifest.json +81 -0
  47. package/dist/catalog/chat-models/ge/gemma4-31b-q4/manifest.json +226 -0
  48. package/dist/catalog/chat-models/ge/gemma4-31b-q4/versions/1.1.0/manifest.json +80 -0
  49. package/dist/catalog/chat-models/ge/gemma4-31b-q4/versions/1.1.1/manifest.json +82 -0
  50. package/dist/catalog/chat-models/ge/gemma4-31b-q4/versions/1.2.0/manifest.json +82 -0
  51. package/dist/catalog/chat-models/ge/gemma4-31b-q4/versions/1.2.1/manifest.json +95 -0
  52. package/dist/catalog/chat-models/ge/gemma4-31b-q4/versions/1.2.2/manifest.json +95 -0
  53. package/dist/catalog/chat-models/ge/gemma4-31b-q4/versions/1.3.0/manifest.json +86 -0
  54. package/dist/catalog/chat-models/ge/gemma4-e2b-q4/manifest.json +213 -0
  55. package/dist/catalog/chat-models/ge/gemma4-e2b-q4/versions/1.1.0/manifest.json +67 -0
  56. package/dist/catalog/chat-models/ge/gemma4-e2b-q4/versions/1.1.2/manifest.json +75 -0
  57. package/dist/catalog/chat-models/ge/gemma4-e2b-q4/versions/1.2.0/manifest.json +71 -0
  58. package/dist/catalog/chat-models/ge/gemma4-e2b-q4/versions/1.2.1/manifest.json +71 -0
  59. package/dist/catalog/chat-models/ge/gemma4-e2b-q4/versions/1.3.0/manifest.json +71 -0
  60. package/dist/catalog/chat-models/ge/gemma4-e4b-q4/manifest.json +205 -0
  61. package/dist/catalog/chat-models/ge/gemma4-e4b-q4/versions/1.1.0/manifest.json +72 -0
  62. package/dist/catalog/chat-models/ge/gemma4-e4b-q4/versions/1.1.2/manifest.json +75 -0
  63. package/dist/catalog/chat-models/ge/gemma4-e4b-q4/versions/1.2.0/manifest.json +76 -0
  64. package/dist/catalog/chat-models/ge/gemma4-e4b-q4/versions/1.2.1/manifest.json +76 -0
  65. package/dist/catalog/chat-models/ge/gemma4-e4b-q4/versions/1.3.0/manifest.json +76 -0
  66. package/dist/catalog/chat-models/gl/glm-5.2-754b-q2/manifest.json +127 -0
  67. package/dist/catalog/chat-models/gl/glm-5.2-754b-q2/versions/1.0.0/manifest.json +20 -0
  68. package/dist/catalog/chat-models/gl/glm-5.3-flash-320b-q2/manifest.json +140 -0
  69. package/dist/catalog/chat-models/gl/glm-5.3-flash-320b-q2/versions/1.0.0/manifest.json +23 -0
  70. package/dist/catalog/chat-models/gl/glm-5.3-flash-320b-q2/versions/1.0.1/manifest.json +26 -0
  71. package/dist/catalog/chat-models/gp/gpt-oss-120b-q4/manifest.json +247 -0
  72. package/dist/catalog/chat-models/gp/gpt-oss-120b-q4/versions/1.0.0/manifest.json +137 -0
  73. package/dist/catalog/chat-models/gp/gpt-oss-120b-q4/versions/1.1.0/manifest.json +135 -0
  74. package/dist/catalog/chat-models/gp/gpt-oss-20b-q4/manifest.json +195 -0
  75. package/dist/catalog/chat-models/gp/gpt-oss-20b-q4/versions/1.1.0/manifest.json +72 -0
  76. package/dist/catalog/chat-models/gp/gpt-oss-20b-q4/versions/1.1.1/manifest.json +71 -0
  77. package/dist/catalog/chat-models/gr/granite4.2-30b-q4/manifest.json +200 -0
  78. package/dist/catalog/chat-models/gr/granite4.2-30b-q4/versions/1.0.0/manifest.json +72 -0
  79. package/dist/catalog/chat-models/gr/granite4.2-3b-q4/manifest.json +196 -0
  80. package/dist/catalog/chat-models/gr/granite4.2-3b-q4/versions/1.0.0/manifest.json +57 -0
  81. package/dist/catalog/chat-models/gr/granite4.2-8b-q4/manifest.json +189 -0
  82. package/dist/catalog/chat-models/gr/granite4.2-8b-q4/versions/1.0.0/manifest.json +57 -0
  83. package/dist/catalog/chat-models/index.json +1 -0
  84. package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q4/manifest.json +235 -0
  85. package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q4/versions/1.0.0/manifest.json +124 -0
  86. package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q4/versions/1.0.1/manifest.json +124 -0
  87. package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q4/versions/1.0.2/manifest.json +123 -0
  88. package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q6/manifest.json +257 -0
  89. package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q6/versions/1.0.0/manifest.json +145 -0
  90. package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q6/versions/1.0.1/manifest.json +145 -0
  91. package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q8/manifest.json +285 -0
  92. package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q8/versions/1.0.0/manifest.json +174 -0
  93. package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q8/versions/1.0.1/manifest.json +174 -0
  94. package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q8/versions/1.0.2/manifest.json +173 -0
  95. package/dist/catalog/chat-models/lf/lfm2.5-2.6b-q4/manifest.json +162 -0
  96. package/dist/catalog/chat-models/lf/lfm2.5-2.6b-q4/versions/1.0.0/manifest.json +59 -0
  97. package/dist/catalog/chat-models/lf/lfm2.5-2.6b-q4/versions/1.0.1/manifest.json +58 -0
  98. package/dist/catalog/chat-models/ll/llama3.2-3b-q4/manifest.json +134 -0
  99. package/dist/catalog/chat-models/ll/llama3.2-3b-q4/versions/1.1.0/manifest.json +57 -0
  100. package/dist/catalog/chat-models/ll/llama3.2-3b-q4/versions/1.1.1/manifest.json +56 -0
  101. package/dist/catalog/chat-models/mi/minicpm5-1b-q4/manifest.json +156 -0
  102. package/dist/catalog/chat-models/mi/minicpm5-1b-q4/versions/1.0.0/manifest.json +57 -0
  103. package/dist/catalog/chat-models/mi/minicpm5-2b-q4/manifest.json +161 -0
  104. package/dist/catalog/chat-models/mi/minicpm5-2b-q4/versions/1.0.0/manifest.json +57 -0
  105. package/dist/catalog/chat-models/mi/mistral-7b-q4/manifest.json +159 -0
  106. package/dist/catalog/chat-models/mi/mistral-7b-q4/versions/1.1.0/manifest.json +62 -0
  107. package/dist/catalog/chat-models/mi/mistral-7b-q4/versions/1.1.1/manifest.json +61 -0
  108. package/dist/catalog/chat-models/mi/mistral-medium-3.5-128b-q4/manifest.json +275 -0
  109. package/dist/catalog/chat-models/mi/mistral-medium-3.5-128b-q4/versions/1.0.0/manifest.json +154 -0
  110. package/dist/catalog/chat-models/mi/mistral-medium-3.5-128b-q4/versions/1.0.1/manifest.json +153 -0
  111. package/dist/catalog/chat-models/mu/muse-glimmer-30b-q4/manifest.json +135 -0
  112. package/dist/catalog/chat-models/mu/muse-glimmer-30b-q4/versions/1.0.0/manifest.json +20 -0
  113. package/dist/catalog/chat-models/mu/muse-glimmer-30b-q4/versions/1.0.1/manifest.json +20 -0
  114. package/dist/catalog/chat-models/mu/muse-glimmer-30b-q4/versions/1.0.2/manifest.json +20 -0
  115. package/dist/catalog/chat-models/na/nanbeige4.2-3b-q4/manifest.json +138 -0
  116. package/dist/catalog/chat-models/na/nanbeige4.2-3b-q4/versions/1.0.0/manifest.json +14 -0
  117. package/dist/catalog/chat-models/ne/nemotron3-nano-30b-q4/manifest.json +212 -0
  118. package/dist/catalog/chat-models/ne/nemotron3-nano-30b-q4/versions/1.0.0/manifest.json +94 -0
  119. package/dist/catalog/chat-models/ne/nemotron3-nano-30b-q4/versions/1.0.1/manifest.json +94 -0
  120. package/dist/catalog/chat-models/ne/nemotron3-nano-30b-q4/versions/1.0.2/manifest.json +93 -0
  121. package/dist/catalog/chat-models/ne/nemotron3-super-120b-q4/manifest.json +135 -0
  122. package/dist/catalog/chat-models/ne/nemotron3-super-120b-q4/versions/1.0.0/manifest.json +30 -0
  123. package/dist/catalog/chat-models/ne/nemotron3-super-120b-q4/versions/1.0.1/manifest.json +30 -0
  124. package/dist/catalog/chat-models/ne/nemotron3.5-lightning-30b-q4/manifest.json +192 -0
  125. package/dist/catalog/chat-models/ne/nemotron3.5-lightning-30b-q4/versions/1.0.0/manifest.json +74 -0
  126. package/dist/catalog/chat-models/ne/nemotron3.5-lightning-30b-q4/versions/1.0.1/manifest.json +73 -0
  127. package/dist/catalog/chat-models/ne/nemotron3.5-lightning-30b-q8/manifest.json +207 -0
  128. package/dist/catalog/chat-models/ne/nemotron3.5-lightning-30b-q8/versions/1.0.0/manifest.json +89 -0
  129. package/dist/catalog/chat-models/ne/nemotron3.5-lightning-30b-q8/versions/1.0.1/manifest.json +88 -0
  130. package/dist/catalog/chat-models/or/ornith-35b-q4/manifest.json +224 -0
  131. package/dist/catalog/chat-models/or/ornith-35b-q4/versions/1.0.0/manifest.json +94 -0
  132. package/dist/catalog/chat-models/or/ornith-35b-q4/versions/1.0.1/manifest.json +93 -0
  133. package/dist/catalog/chat-models/or/ornith-35b-q8/manifest.json +244 -0
  134. package/dist/catalog/chat-models/or/ornith-35b-q8/versions/1.0.0/manifest.json +114 -0
  135. package/dist/catalog/chat-models/or/ornith-35b-q8/versions/1.0.1/manifest.json +113 -0
  136. package/dist/catalog/chat-models/or/ornith-9b-q4/manifest.json +215 -0
  137. package/dist/catalog/chat-models/or/ornith-9b-q4/versions/1.0.0/manifest.json +84 -0
  138. package/dist/catalog/chat-models/or/ornith-9b-q4/versions/1.0.1/manifest.json +83 -0
  139. package/dist/catalog/chat-models/or/ornith1.5-35b-a3b-q4/manifest.json +208 -0
  140. package/dist/catalog/chat-models/or/ornith1.5-35b-a3b-q4/versions/1.0.0/manifest.json +74 -0
  141. package/dist/catalog/chat-models/or/ornith1.5-35b-a3b-q8/manifest.json +223 -0
  142. package/dist/catalog/chat-models/or/ornith1.5-35b-a3b-q8/versions/1.0.0/manifest.json +89 -0
  143. package/dist/catalog/chat-models/or/ornith1.5-9b-q4/manifest.json +187 -0
  144. package/dist/catalog/chat-models/or/ornith1.5-9b-q4/versions/1.0.0/manifest.json +53 -0
  145. package/dist/catalog/chat-models/or/ornith1.5-9b-q4/versions/1.0.1/manifest.json +77 -0
  146. package/dist/catalog/chat-models/or/ornith1.5-9b-q8/manifest.json +192 -0
  147. package/dist/catalog/chat-models/or/ornith1.5-9b-q8/versions/1.0.0/manifest.json +58 -0
  148. package/dist/catalog/chat-models/or/ornith1.5-9b-q8/versions/1.0.1/manifest.json +82 -0
  149. package/dist/catalog/chat-models/qw/qwen3.5-0.8b-q4/manifest.json +192 -0
  150. package/dist/catalog/chat-models/qw/qwen3.5-0.8b-q4/versions/1.0.0/manifest.json +75 -0
  151. package/dist/catalog/chat-models/qw/qwen3.5-122b-a10b-q4/manifest.json +315 -0
  152. package/dist/catalog/chat-models/qw/qwen3.5-122b-a10b-q4/versions/1.0.0/manifest.json +164 -0
  153. package/dist/catalog/chat-models/qw/qwen3.5-122b-a10b-q4/versions/1.0.1/manifest.json +164 -0
  154. package/dist/catalog/chat-models/qw/qwen3.5-122b-a10b-q4/versions/1.0.2/manifest.json +164 -0
  155. package/dist/catalog/chat-models/qw/qwen3.5-122b-a10b-q4/versions/1.0.3/manifest.json +163 -0
  156. package/dist/catalog/chat-models/qw/qwen3.5-27b-q4/manifest.json +217 -0
  157. package/dist/catalog/chat-models/qw/qwen3.5-27b-q4/versions/1.0.0/manifest.json +90 -0
  158. package/dist/catalog/chat-models/qw/qwen3.5-2b-q4/manifest.json +194 -0
  159. package/dist/catalog/chat-models/qw/qwen3.5-2b-q4/versions/1.1.0/manifest.json +77 -0
  160. package/dist/catalog/chat-models/qw/qwen3.5-2b-q4/versions/1.1.2/manifest.json +76 -0
  161. package/dist/catalog/chat-models/qw/qwen3.5-2b-q4/versions/1.1.3/manifest.json +76 -0
  162. package/dist/catalog/chat-models/qw/qwen3.5-4b-q4/manifest.json +188 -0
  163. package/dist/catalog/chat-models/qw/qwen3.5-4b-q4/versions/1.1.0/manifest.json +77 -0
  164. package/dist/catalog/chat-models/qw/qwen3.5-4b-q4/versions/1.1.2/manifest.json +76 -0
  165. package/dist/catalog/chat-models/qw/qwen3.5-4b-q4/versions/1.1.3/manifest.json +76 -0
  166. package/dist/catalog/chat-models/qw/qwen3.5-9b-q4/manifest.json +205 -0
  167. package/dist/catalog/chat-models/qw/qwen3.5-9b-q4/versions/1.1.0/manifest.json +82 -0
  168. package/dist/catalog/chat-models/qw/qwen3.5-9b-q4/versions/1.1.2/manifest.json +81 -0
  169. package/dist/catalog/chat-models/qw/qwen3.5-9b-q4/versions/1.1.3/manifest.json +81 -0
  170. package/dist/catalog/chat-models/qw/qwen3.6-27b-q4/manifest.json +230 -0
  171. package/dist/catalog/chat-models/qw/qwen3.6-27b-q4/versions/1.1.0/manifest.json +92 -0
  172. package/dist/catalog/chat-models/qw/qwen3.6-27b-q4/versions/1.1.4/manifest.json +91 -0
  173. package/dist/catalog/chat-models/qw/qwen3.6-27b-q4/versions/1.1.5/manifest.json +91 -0
  174. package/dist/catalog/chat-models/qw/qwen3.6-27b-q4/versions/1.1.6/manifest.json +115 -0
  175. package/dist/catalog/chat-models/qw/qwen3.6-27b-q8/manifest.json +240 -0
  176. package/dist/catalog/chat-models/qw/qwen3.6-27b-q8/versions/1.0.0/manifest.json +102 -0
  177. package/dist/catalog/chat-models/qw/qwen3.6-27b-q8/versions/1.0.2/manifest.json +103 -0
  178. package/dist/catalog/chat-models/qw/qwen3.6-27b-q8/versions/1.0.3/manifest.json +103 -0
  179. package/dist/catalog/chat-models/qw/qwen3.6-27b-q8/versions/1.0.4/manifest.json +127 -0
  180. package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q4/manifest.json +233 -0
  181. package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q4/versions/1.0.0/manifest.json +92 -0
  182. package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q4/versions/1.0.1/manifest.json +93 -0
  183. package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q4/versions/1.0.2/manifest.json +93 -0
  184. package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q4/versions/1.0.3/manifest.json +117 -0
  185. package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q8/manifest.json +252 -0
  186. package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q8/versions/1.0.0/manifest.json +112 -0
  187. package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q8/versions/1.0.1/manifest.json +113 -0
  188. package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q8/versions/1.0.2/manifest.json +113 -0
  189. package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q8/versions/1.0.3/manifest.json +137 -0
  190. package/dist/catalog/chat-models/qw/qwen3.8-27b-iq1-s/manifest.json +195 -0
  191. package/dist/catalog/chat-models/qw/qwen3.8-27b-iq1-s/versions/1.0.0/manifest.json +19 -0
  192. package/dist/catalog/chat-models/qw/qwen3.8-27b-q2/manifest.json +194 -0
  193. package/dist/catalog/chat-models/qw/qwen3.8-27b-q2/versions/1.0.0/manifest.json +19 -0
  194. package/dist/catalog/chat-models/qw/qwen3.8-27b-q3/manifest.json +194 -0
  195. package/dist/catalog/chat-models/qw/qwen3.8-27b-q3/versions/1.0.0/manifest.json +19 -0
  196. package/dist/catalog/chat-models/qw/qwen3.8-27b-q3/versions/1.0.1/manifest.json +19 -0
  197. package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/manifest.json +291 -0
  198. package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/versions/1.0.0/manifest.json +20 -0
  199. package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/versions/1.0.1/manifest.json +93 -0
  200. package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/versions/1.0.2/manifest.json +92 -0
  201. package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/versions/1.0.3/manifest.json +116 -0
  202. package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/versions/1.0.4/manifest.json +116 -0
  203. package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/versions/1.0.5/manifest.json +116 -0
  204. package/dist/catalog/chat-models/qw/qwen3.8-27b-q6/manifest.json +278 -0
  205. package/dist/catalog/chat-models/qw/qwen3.8-27b-q6/versions/1.0.0/manifest.json +20 -0
  206. package/dist/catalog/chat-models/qw/qwen3.8-27b-q6/versions/1.0.1/manifest.json +104 -0
  207. package/dist/catalog/chat-models/qw/qwen3.8-27b-q6/versions/1.0.2/manifest.json +103 -0
  208. package/dist/catalog/chat-models/qw/qwen3.8-27b-q6/versions/1.0.3/manifest.json +127 -0
  209. package/dist/catalog/chat-models/qw/qwen3.8-27b-q8/manifest.json +278 -0
  210. package/dist/catalog/chat-models/qw/qwen3.8-27b-q8/versions/1.0.0/manifest.json +113 -0
  211. package/dist/catalog/chat-models/qw/qwen3.8-27b-q8/versions/1.0.1/manifest.json +113 -0
  212. package/dist/catalog/chat-models/qw/qwen3.8-27b-q8/versions/1.0.2/manifest.json +137 -0
  213. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-iq3/manifest.json +221 -0
  214. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-iq3/versions/1.0.0/manifest.json +36 -0
  215. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-iq4/manifest.json +429 -0
  216. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-iq4/versions/1.0.0/manifest.json +36 -0
  217. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-iq4/versions/1.1.0/manifest.json +205 -0
  218. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-iq4/versions/1.1.1/manifest.json +244 -0
  219. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/manifest.json +203 -0
  220. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/versions/1.0.0/manifest.json +21 -0
  221. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/versions/1.0.1/manifest.json +31 -0
  222. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/versions/1.0.2/manifest.json +30 -0
  223. package/dist/catalog/chat-models/raw-index.json +1 -0
  224. package/dist/catalog/chat-models/ta/talkie-1930-13b-q4/manifest.json +79 -0
  225. package/dist/catalog/chat-models/ta/talkie-1930-13b-q4/versions/1.0.0/manifest.json +15 -0
  226. package/dist/catalog/chat-models/ta/talkie-1930-13b-q4/versions/1.0.1/manifest.json +15 -0
  227. package/dist/catalog/chat-models/te/ternary-bonsai-27b-q2/manifest.json +204 -0
  228. package/dist/catalog/chat-models/te/ternary-bonsai-27b-q2/versions/1.0.0/manifest.json +70 -0
  229. package/dist/catalog/chat-models/te/ternary-bonsai-27b-q2/versions/1.0.1/manifest.json +70 -0
  230. package/dist/catalog/chat-models/un/underdog-saluki-27b-iq2-mix/manifest.json +164 -0
  231. package/dist/catalog/chat-models/un/underdog-saluki-27b-iq2-mix/versions/1.0.0/manifest.json +14 -0
  232. package/dist/catalog/knowledge-catalogs/be/bendyline-how-to-books/manifest.json +26 -0
  233. package/dist/catalog/knowledge-catalogs/be/bendyline-how-to-books/versions/2026.10.8/manifest.json +8950 -0
  234. package/dist/catalog/knowledge-catalogs/ca/caselaw-alaska-part-0001/manifest.json +24 -0
  235. package/dist/catalog/knowledge-catalogs/ca/caselaw-alaska-part-0001/versions/2026.10.1/manifest.json +658 -0
  236. package/dist/catalog/knowledge-catalogs/ca/caselaw-nevada-part-0001/manifest.json +24 -0
  237. package/dist/catalog/knowledge-catalogs/ca/caselaw-nevada-part-0001/versions/2026.10.1/manifest.json +666 -0
  238. package/dist/catalog/knowledge-catalogs/ca/caselaw-us-300-399-part-0001/manifest.json +24 -0
  239. package/dist/catalog/knowledge-catalogs/ca/caselaw-us-300-399-part-0001/versions/2026.10.2/manifest.json +182 -0
  240. package/dist/catalog/knowledge-catalogs/ca/caselaw-us-347-349/manifest.json +25 -0
  241. package/dist/catalog/knowledge-catalogs/ca/caselaw-us-347-349/versions/2026.10.5/manifest.json +38 -0
  242. package/dist/catalog/knowledge-catalogs/ca/caselaw-utah-part-0001/manifest.json +24 -0
  243. package/dist/catalog/knowledge-catalogs/ca/caselaw-utah-part-0001/versions/2026.10.1/manifest.json +558 -0
  244. package/dist/catalog/knowledge-catalogs/ca/caselaw-utah-part-0002/manifest.json +24 -0
  245. package/dist/catalog/knowledge-catalogs/ca/caselaw-utah-part-0002/versions/2026.10.1/manifest.json +258 -0
  246. package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0001/manifest.json +24 -0
  247. package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0001/versions/2026.10.1/manifest.json +318 -0
  248. package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0002/manifest.json +24 -0
  249. package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0002/versions/2026.10.1/manifest.json +266 -0
  250. package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0003/manifest.json +24 -0
  251. package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0003/versions/2026.10.1/manifest.json +186 -0
  252. package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0004/manifest.json +24 -0
  253. package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0004/versions/2026.10.1/manifest.json +138 -0
  254. package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0005/manifest.json +24 -0
  255. package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0005/versions/2026.10.1/manifest.json +138 -0
  256. package/dist/catalog/knowledge-catalogs/ca/caselaw-wyoming-part-0001/manifest.json +24 -0
  257. package/dist/catalog/knowledge-catalogs/ca/caselaw-wyoming-part-0001/versions/2026.10.3/manifest.json +626 -0
  258. package/dist/catalog/knowledge-catalogs/index.json +1 -0
  259. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-aspnet-core-en/manifest.json +22 -0
  260. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-aspnet-core-en/versions/2026.10.16/manifest.json +18270 -0
  261. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-ai-platform-en/manifest.json +23 -0
  262. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-ai-platform-en/versions/2026.10.16/manifest.json +1098 -0
  263. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-apps-compute-en/manifest.json +23 -0
  264. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-apps-compute-en/versions/2026.10.16/manifest.json +722 -0
  265. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-data-aiot-en/manifest.json +23 -0
  266. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-data-aiot-en/versions/2026.10.16/manifest.json +530 -0
  267. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-management-hybrid-en/manifest.json +23 -0
  268. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-management-hybrid-en/versions/2026.10.16/manifest.json +602 -0
  269. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-network-security-en/manifest.json +23 -0
  270. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-network-security-en/versions/2026.10.16/manifest.json +250 -0
  271. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-dotnet-en/manifest.json +22 -0
  272. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-dotnet-en/versions/2026.10.16/manifest.json +16794 -0
  273. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-entity-framework-en/manifest.json +22 -0
  274. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-entity-framework-en/versions/2026.10.16/manifest.json +1106 -0
  275. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-microsoft-365-en/manifest.json +22 -0
  276. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-microsoft-365-en/versions/2026.10.16/manifest.json +234 -0
  277. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-powershell-en/manifest.json +22 -0
  278. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-powershell-en/versions/2026.10.16/manifest.json +522 -0
  279. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-sql-server-en/manifest.json +22 -0
  280. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-sql-server-en/versions/2026.10.16/manifest.json +1850 -0
  281. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-windows-apps-en/manifest.json +22 -0
  282. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-windows-apps-en/versions/2026.10.16/manifest.json +1734 -0
  283. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-windows-server-en/manifest.json +22 -0
  284. package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-windows-server-en/versions/2026.10.16/manifest.json +774 -0
  285. package/dist/catalog/knowledge-catalogs/raw-index.json +1 -0
  286. package/dist/catalog/knowledge-catalogs/re/regional-american-south/art.json +12 -0
  287. package/dist/catalog/knowledge-catalogs/re/regional-american-south/logo.webp +0 -0
  288. package/dist/catalog/knowledge-catalogs/re/regional-american-south/manifest.json +48 -0
  289. package/dist/catalog/knowledge-catalogs/re/regional-american-south/versions/2026.10.3/manifest.json +133 -0
  290. package/dist/catalog/knowledge-catalogs/re/regional-andes-amazon/art.json +12 -0
  291. package/dist/catalog/knowledge-catalogs/re/regional-andes-amazon/logo.webp +0 -0
  292. package/dist/catalog/knowledge-catalogs/re/regional-andes-amazon/manifest.json +48 -0
  293. package/dist/catalog/knowledge-catalogs/re/regional-andes-amazon/versions/2026.10.3/manifest.json +133 -0
  294. package/dist/catalog/knowledge-catalogs/re/regional-australia-nz/art.json +12 -0
  295. package/dist/catalog/knowledge-catalogs/re/regional-australia-nz/logo.webp +0 -0
  296. package/dist/catalog/knowledge-catalogs/re/regional-australia-nz/manifest.json +48 -0
  297. package/dist/catalog/knowledge-catalogs/re/regional-australia-nz/versions/2026.10.3/manifest.json +133 -0
  298. package/dist/catalog/knowledge-catalogs/re/regional-brazil/art.json +12 -0
  299. package/dist/catalog/knowledge-catalogs/re/regional-brazil/logo.webp +0 -0
  300. package/dist/catalog/knowledge-catalogs/re/regional-brazil/manifest.json +48 -0
  301. package/dist/catalog/knowledge-catalogs/re/regional-brazil/versions/2026.10.3/manifest.json +133 -0
  302. package/dist/catalog/knowledge-catalogs/re/regional-britain-ireland/art.json +12 -0
  303. package/dist/catalog/knowledge-catalogs/re/regional-britain-ireland/logo.webp +0 -0
  304. package/dist/catalog/knowledge-catalogs/re/regional-britain-ireland/manifest.json +48 -0
  305. package/dist/catalog/knowledge-catalogs/re/regional-britain-ireland/versions/2026.10.3/manifest.json +133 -0
  306. package/dist/catalog/knowledge-catalogs/re/regional-california-southwest/art.json +12 -0
  307. package/dist/catalog/knowledge-catalogs/re/regional-california-southwest/logo.webp +0 -0
  308. package/dist/catalog/knowledge-catalogs/re/regional-california-southwest/manifest.json +48 -0
  309. package/dist/catalog/knowledge-catalogs/re/regional-california-southwest/versions/2026.10.3/manifest.json +133 -0
  310. package/dist/catalog/knowledge-catalogs/re/regional-canada-alaska/art.json +12 -0
  311. package/dist/catalog/knowledge-catalogs/re/regional-canada-alaska/logo.webp +0 -0
  312. package/dist/catalog/knowledge-catalogs/re/regional-canada-alaska/manifest.json +48 -0
  313. package/dist/catalog/knowledge-catalogs/re/regional-canada-alaska/versions/2026.10.3/manifest.json +133 -0
  314. package/dist/catalog/knowledge-catalogs/re/regional-central-eastern-europe/art.json +12 -0
  315. package/dist/catalog/knowledge-catalogs/re/regional-central-eastern-europe/logo.webp +0 -0
  316. package/dist/catalog/knowledge-catalogs/re/regional-central-eastern-europe/manifest.json +48 -0
  317. package/dist/catalog/knowledge-catalogs/re/regional-central-eastern-europe/versions/2026.10.3/manifest.json +133 -0
  318. package/dist/catalog/knowledge-catalogs/re/regional-east-asia/art.json +12 -0
  319. package/dist/catalog/knowledge-catalogs/re/regional-east-asia/logo.webp +0 -0
  320. package/dist/catalog/knowledge-catalogs/re/regional-east-asia/manifest.json +48 -0
  321. package/dist/catalog/knowledge-catalogs/re/regional-east-asia/versions/2026.10.3/manifest.json +133 -0
  322. package/dist/catalog/knowledge-catalogs/re/regional-east-southern-africa/art.json +12 -0
  323. package/dist/catalog/knowledge-catalogs/re/regional-east-southern-africa/logo.webp +0 -0
  324. package/dist/catalog/knowledge-catalogs/re/regional-east-southern-africa/manifest.json +48 -0
  325. package/dist/catalog/knowledge-catalogs/re/regional-east-southern-africa/versions/2026.10.3/manifest.json +133 -0
  326. package/dist/catalog/knowledge-catalogs/re/regional-mediterranean/art.json +12 -0
  327. package/dist/catalog/knowledge-catalogs/re/regional-mediterranean/logo.webp +0 -0
  328. package/dist/catalog/knowledge-catalogs/re/regional-mediterranean/manifest.json +48 -0
  329. package/dist/catalog/knowledge-catalogs/re/regional-mediterranean/versions/2026.10.3/manifest.json +133 -0
  330. package/dist/catalog/knowledge-catalogs/re/regional-mexico-central-america/art.json +12 -0
  331. package/dist/catalog/knowledge-catalogs/re/regional-mexico-central-america/logo.webp +0 -0
  332. package/dist/catalog/knowledge-catalogs/re/regional-mexico-central-america/manifest.json +48 -0
  333. package/dist/catalog/knowledge-catalogs/re/regional-mexico-central-america/versions/2026.10.3/manifest.json +133 -0
  334. package/dist/catalog/knowledge-catalogs/re/regional-middle-east/art.json +12 -0
  335. package/dist/catalog/knowledge-catalogs/re/regional-middle-east/logo.webp +0 -0
  336. package/dist/catalog/knowledge-catalogs/re/regional-middle-east/manifest.json +48 -0
  337. package/dist/catalog/knowledge-catalogs/re/regional-middle-east/versions/2026.10.3/manifest.json +133 -0
  338. package/dist/catalog/knowledge-catalogs/re/regional-midwest/art.json +12 -0
  339. package/dist/catalog/knowledge-catalogs/re/regional-midwest/logo.webp +0 -0
  340. package/dist/catalog/knowledge-catalogs/re/regional-midwest/manifest.json +48 -0
  341. package/dist/catalog/knowledge-catalogs/re/regional-midwest/versions/2026.10.3/manifest.json +133 -0
  342. package/dist/catalog/knowledge-catalogs/re/regional-north-africa/art.json +12 -0
  343. package/dist/catalog/knowledge-catalogs/re/regional-north-africa/logo.webp +0 -0
  344. package/dist/catalog/knowledge-catalogs/re/regional-north-africa/manifest.json +48 -0
  345. package/dist/catalog/knowledge-catalogs/re/regional-north-africa/versions/2026.10.3/manifest.json +133 -0
  346. package/dist/catalog/knowledge-catalogs/re/regional-northeast/art.json +12 -0
  347. package/dist/catalog/knowledge-catalogs/re/regional-northeast/logo.webp +0 -0
  348. package/dist/catalog/knowledge-catalogs/re/regional-northeast/manifest.json +48 -0
  349. package/dist/catalog/knowledge-catalogs/re/regional-northeast/versions/2026.10.3/manifest.json +133 -0
  350. package/dist/catalog/knowledge-catalogs/re/regional-northern-eurasia/art.json +12 -0
  351. package/dist/catalog/knowledge-catalogs/re/regional-northern-eurasia/logo.webp +0 -0
  352. package/dist/catalog/knowledge-catalogs/re/regional-northern-eurasia/manifest.json +48 -0
  353. package/dist/catalog/knowledge-catalogs/re/regional-northern-eurasia/versions/2026.10.3/manifest.json +133 -0
  354. package/dist/catalog/knowledge-catalogs/re/regional-northern-europe/art.json +12 -0
  355. package/dist/catalog/knowledge-catalogs/re/regional-northern-europe/logo.webp +0 -0
  356. package/dist/catalog/knowledge-catalogs/re/regional-northern-europe/manifest.json +48 -0
  357. package/dist/catalog/knowledge-catalogs/re/regional-northern-europe/versions/2026.10.3/manifest.json +133 -0
  358. package/dist/catalog/knowledge-catalogs/re/regional-pacific-islands/art.json +12 -0
  359. package/dist/catalog/knowledge-catalogs/re/regional-pacific-islands/logo.webp +0 -0
  360. package/dist/catalog/knowledge-catalogs/re/regional-pacific-islands/manifest.json +48 -0
  361. package/dist/catalog/knowledge-catalogs/re/regional-pacific-islands/versions/2026.10.3/manifest.json +133 -0
  362. package/dist/catalog/knowledge-catalogs/re/regional-pacific-northwest/art.json +12 -0
  363. package/dist/catalog/knowledge-catalogs/re/regional-pacific-northwest/logo.webp +0 -0
  364. package/dist/catalog/knowledge-catalogs/re/regional-pacific-northwest/manifest.json +48 -0
  365. package/dist/catalog/knowledge-catalogs/re/regional-pacific-northwest/versions/2026.10.3/manifest.json +133 -0
  366. package/dist/catalog/knowledge-catalogs/re/regional-rockies-plains/art.json +12 -0
  367. package/dist/catalog/knowledge-catalogs/re/regional-rockies-plains/logo.webp +0 -0
  368. package/dist/catalog/knowledge-catalogs/re/regional-rockies-plains/manifest.json +48 -0
  369. package/dist/catalog/knowledge-catalogs/re/regional-rockies-plains/versions/2026.10.3/manifest.json +133 -0
  370. package/dist/catalog/knowledge-catalogs/re/regional-south-asia/art.json +12 -0
  371. package/dist/catalog/knowledge-catalogs/re/regional-south-asia/logo.webp +0 -0
  372. package/dist/catalog/knowledge-catalogs/re/regional-south-asia/manifest.json +48 -0
  373. package/dist/catalog/knowledge-catalogs/re/regional-south-asia/versions/2026.10.3/manifest.json +133 -0
  374. package/dist/catalog/knowledge-catalogs/re/regional-southeast-asia/art.json +12 -0
  375. package/dist/catalog/knowledge-catalogs/re/regional-southeast-asia/logo.webp +0 -0
  376. package/dist/catalog/knowledge-catalogs/re/regional-southeast-asia/manifest.json +48 -0
  377. package/dist/catalog/knowledge-catalogs/re/regional-southeast-asia/versions/2026.10.3/manifest.json +133 -0
  378. package/dist/catalog/knowledge-catalogs/re/regional-southern-cone/art.json +12 -0
  379. package/dist/catalog/knowledge-catalogs/re/regional-southern-cone/logo.webp +0 -0
  380. package/dist/catalog/knowledge-catalogs/re/regional-southern-cone/manifest.json +48 -0
  381. package/dist/catalog/knowledge-catalogs/re/regional-southern-cone/versions/2026.10.3/manifest.json +133 -0
  382. package/dist/catalog/knowledge-catalogs/re/regional-west-central-africa/art.json +12 -0
  383. package/dist/catalog/knowledge-catalogs/re/regional-west-central-africa/logo.webp +0 -0
  384. package/dist/catalog/knowledge-catalogs/re/regional-west-central-africa/manifest.json +48 -0
  385. package/dist/catalog/knowledge-catalogs/re/regional-west-central-africa/versions/2026.10.3/manifest.json +133 -0
  386. package/dist/catalog/knowledge-catalogs/re/regional-western-europe/art.json +12 -0
  387. package/dist/catalog/knowledge-catalogs/re/regional-western-europe/logo.webp +0 -0
  388. package/dist/catalog/knowledge-catalogs/re/regional-western-europe/manifest.json +48 -0
  389. package/dist/catalog/knowledge-catalogs/re/regional-western-europe/versions/2026.10.3/manifest.json +133 -0
  390. package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/art.json +12 -0
  391. package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/logo.webp +0 -0
  392. package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/manifest.json +28 -0
  393. package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/versions/2026.4.2/manifest.json +53 -0
  394. package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/versions/2026.4.3/manifest.json +885 -0
  395. package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/versions/2026.4.5/manifest.json +157 -0
  396. package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/art.json +11 -0
  397. package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/logo.webp +0 -0
  398. package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/manifest.json +32 -0
  399. package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/versions/2026.4.2/manifest.json +69 -0
  400. package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/versions/2026.4.3/manifest.json +741 -0
  401. package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/versions/2026.4.5/manifest.json +173 -0
  402. package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/art.json +12 -0
  403. package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/logo.webp +0 -0
  404. package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/manifest.json +69 -0
  405. package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/versions/2026.4.4/manifest.json +217 -0
  406. package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/versions/2026.4.5/manifest.json +217 -0
  407. package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/art.json +12 -0
  408. package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/logo.webp +0 -0
  409. package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/manifest.json +64 -0
  410. package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/versions/2026.4.4/manifest.json +197 -0
  411. package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/versions/2026.4.5/manifest.json +197 -0
  412. package/dist/catalog/knowledge-catalogs/wi/wikipedia-film-tv/art.json +12 -0
  413. package/dist/catalog/knowledge-catalogs/wi/wikipedia-film-tv/logo.webp +0 -0
  414. package/dist/catalog/knowledge-catalogs/wi/wikipedia-film-tv/manifest.json +64 -0
  415. package/dist/catalog/knowledge-catalogs/wi/wikipedia-film-tv/versions/2026.4.5/manifest.json +197 -0
  416. package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/art.json +11 -0
  417. package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/logo.webp +0 -0
  418. package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/manifest.json +29 -0
  419. package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/versions/2026.4.2/manifest.json +57 -0
  420. package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/versions/2026.4.3/manifest.json +925 -0
  421. package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/versions/2026.4.5/manifest.json +185 -0
  422. package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/art.json +12 -0
  423. package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/logo.webp +0 -0
  424. package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/manifest.json +28 -0
  425. package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/versions/2026.4.2/manifest.json +53 -0
  426. package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/versions/2026.4.3/manifest.json +1665 -0
  427. package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/versions/2026.4.5/manifest.json +133 -0
  428. package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/art.json +12 -0
  429. package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/logo.webp +0 -0
  430. package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/manifest.json +29 -0
  431. package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/versions/2026.4.2/manifest.json +57 -0
  432. package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/versions/2026.4.3/manifest.json +1233 -0
  433. package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/versions/2026.4.5/manifest.json +141 -0
  434. package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/art.json +11 -0
  435. package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/logo.webp +0 -0
  436. package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/manifest.json +29 -0
  437. package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/versions/2026.4.2/manifest.json +57 -0
  438. package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/versions/2026.4.3/manifest.json +493 -0
  439. package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/versions/2026.4.5/manifest.json +185 -0
  440. package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/art.json +12 -0
  441. package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/logo.webp +0 -0
  442. package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/manifest.json +63 -0
  443. package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/versions/2026.4.4/manifest.json +193 -0
  444. package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/versions/2026.4.5/manifest.json +193 -0
  445. package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/art.json +12 -0
  446. package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/logo.webp +0 -0
  447. package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/manifest.json +30 -0
  448. package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/versions/2026.4.2/manifest.json +61 -0
  449. package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/versions/2026.4.3/manifest.json +665 -0
  450. package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/versions/2026.4.5/manifest.json +177 -0
  451. package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/art.json +12 -0
  452. package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/logo.webp +0 -0
  453. package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/manifest.json +29 -0
  454. package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/versions/2026.4.2/manifest.json +57 -0
  455. package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/versions/2026.4.3/manifest.json +745 -0
  456. package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/versions/2026.4.5/manifest.json +185 -0
  457. package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/art.json +12 -0
  458. package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/logo.webp +0 -0
  459. package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/manifest.json +62 -0
  460. package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/versions/2026.4.4/manifest.json +189 -0
  461. package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/versions/2026.4.5/manifest.json +189 -0
  462. package/dist/catalog/knowledge-catalogs/wi/wikipedia-music/art.json +12 -0
  463. package/dist/catalog/knowledge-catalogs/wi/wikipedia-music/logo.webp +0 -0
  464. package/dist/catalog/knowledge-catalogs/wi/wikipedia-music/manifest.json +64 -0
  465. package/dist/catalog/knowledge-catalogs/wi/wikipedia-music/versions/2026.4.5/manifest.json +197 -0
  466. package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/art.json +12 -0
  467. package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/logo.webp +0 -0
  468. package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/manifest.json +64 -0
  469. package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/versions/2026.4.4/manifest.json +197 -0
  470. package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/versions/2026.4.5/manifest.json +197 -0
  471. package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/art.json +12 -0
  472. package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/logo.webp +0 -0
  473. package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/manifest.json +30 -0
  474. package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/versions/2026.4.2/manifest.json +61 -0
  475. package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/versions/2026.4.3/manifest.json +1253 -0
  476. package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/versions/2026.4.5/manifest.json +189 -0
  477. package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/art.json +12 -0
  478. package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/logo.webp +0 -0
  479. package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/manifest.json +29 -0
  480. package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/versions/2026.4.2/manifest.json +57 -0
  481. package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/versions/2026.4.3/manifest.json +1957 -0
  482. package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/versions/2026.4.5/manifest.json +185 -0
  483. package/dist/catalog/knowledge-catalogs/wi/wikipedia-sports/art.json +12 -0
  484. package/dist/catalog/knowledge-catalogs/wi/wikipedia-sports/logo.webp +0 -0
  485. package/dist/catalog/knowledge-catalogs/wi/wikipedia-sports/manifest.json +63 -0
  486. package/dist/catalog/knowledge-catalogs/wi/wikipedia-sports/versions/2026.4.5/manifest.json +193 -0
  487. package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/art.json +11 -0
  488. package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/logo.webp +0 -0
  489. package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/manifest.json +45 -0
  490. package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/versions/2026.4.4/manifest.json +121 -0
  491. package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/versions/2026.4.5/manifest.json +173 -0
  492. package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/art.json +12 -0
  493. package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/logo.webp +0 -0
  494. package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/manifest.json +62 -0
  495. package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/versions/2026.4.4/manifest.json +189 -0
  496. package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/versions/2026.4.5/manifest.json +189 -0
  497. package/dist/catalog/provenance.json +8 -0
  498. package/dist/chat/catalog-model-lookup.d.ts +21 -0
  499. package/dist/chat/catalog-model-lookup.js +48 -0
  500. package/dist/chat/local-model-tier.d.ts +1 -0
  501. package/dist/chat/local-model-tier.js +2 -0
  502. package/dist/chat/vision-capability.d.ts +1 -0
  503. package/dist/chat/vision-capability.js +3 -0
  504. package/dist/engine-components.d.ts +30 -0
  505. package/dist/engine-components.js +144 -0
  506. package/dist/engines/electron-native-reuse.d.ts +65 -0
  507. package/dist/engines/electron-native-reuse.js +305 -0
  508. package/dist/engines/native-file-manifest.json +1320 -0
  509. package/dist/engines/native-manifest.d.ts +70 -0
  510. package/dist/engines/native-manifest.js +98 -0
  511. package/dist/engines/registry.d.ts +88 -0
  512. package/dist/engines/registry.js +217 -0
  513. package/dist/engines/resolver.d.ts +121 -0
  514. package/dist/engines/resolver.js +894 -0
  515. package/dist/engines/signature.d.ts +85 -0
  516. package/dist/engines/signature.js +231 -0
  517. package/dist/fs/atomic.d.ts +53 -0
  518. package/dist/fs/atomic.js +202 -0
  519. package/dist/fs/config-store.d.ts +14 -0
  520. package/dist/fs/config-store.js +101 -0
  521. package/dist/fs/home-permissions.d.ts +18 -0
  522. package/dist/fs/home-permissions.js +27 -0
  523. package/dist/fs/safe-paths.d.ts +107 -0
  524. package/dist/fs/safe-paths.js +264 -0
  525. package/dist/fs/security-json.d.ts +13 -0
  526. package/dist/fs/security-json.js +67 -0
  527. package/dist/http/auth-context.d.ts +23 -0
  528. package/dist/http/auth-context.js +1 -0
  529. package/dist/http/engine-runtime-context.d.ts +30 -0
  530. package/dist/http/engine-runtime-context.js +1 -0
  531. package/dist/http/external-conversation-contract.d.ts +49 -0
  532. package/dist/http/external-conversation-contract.js +1 -0
  533. package/dist/http/external-conversation-headers.d.ts +11 -0
  534. package/dist/http/external-conversation-headers.js +11 -0
  535. package/dist/http/inference-context.d.ts +36 -0
  536. package/dist/http/inference-context.js +1 -0
  537. package/dist/http/model-inventory-cache.d.ts +8 -0
  538. package/dist/http/model-inventory-cache.js +8 -0
  539. package/dist/http/openai-compat/action-ledger.d.ts +15 -0
  540. package/dist/http/openai-compat/action-ledger.js +119 -0
  541. package/dist/http/openai-compat/apple-model.d.ts +3 -0
  542. package/dist/http/openai-compat/apple-model.js +44 -0
  543. package/dist/http/openai-compat/caller-owned-profile.d.ts +7 -0
  544. package/dist/http/openai-compat/caller-owned-profile.js +18 -0
  545. package/dist/http/openai-compat/chat-progress.d.ts +9 -0
  546. package/dist/http/openai-compat/chat-progress.js +69 -0
  547. package/dist/http/openai-compat/chat-target.d.ts +76 -0
  548. package/dist/http/openai-compat/chat-target.js +97 -0
  549. package/dist/http/openai-compat/embeddings.d.ts +37 -0
  550. package/dist/http/openai-compat/embeddings.js +86 -0
  551. package/dist/http/openai-compat/request-tuning.d.ts +35 -0
  552. package/dist/http/openai-compat/request-tuning.js +87 -0
  553. package/dist/http/openai-compat/responses-streaming.d.ts +351 -0
  554. package/dist/http/openai-compat/responses-streaming.js +717 -0
  555. package/dist/http/openai-compat/responses-translate.d.ts +215 -0
  556. package/dist/http/openai-compat/responses-translate.js +377 -0
  557. package/dist/http/openai-compat/streaming-telemetry.d.ts +30 -0
  558. package/dist/http/openai-compat/streaming-telemetry.js +88 -0
  559. package/dist/http/openai-compat/streaming.d.ts +141 -0
  560. package/dist/http/openai-compat/streaming.js +687 -0
  561. package/dist/http/openai-compat/tool-call-stream-filter.d.ts +33 -0
  562. package/dist/http/openai-compat/tool-call-stream-filter.js +110 -0
  563. package/dist/http/openai-compat/translate.d.ts +254 -0
  564. package/dist/http/openai-compat/translate.js +485 -0
  565. package/dist/http/routes/engines.d.ts +13 -0
  566. package/dist/http/routes/engines.js +366 -0
  567. package/dist/http/routes/machine-engine-proxy.d.ts +26 -0
  568. package/dist/http/routes/machine-engine-proxy.js +43 -0
  569. package/dist/http/routes/model-bundles.d.ts +4 -0
  570. package/dist/http/routes/model-bundles.js +190 -0
  571. package/dist/http/routes/v1-chat.d.ts +72 -0
  572. package/dist/http/routes/v1-chat.js +645 -0
  573. package/dist/http/routes/v1-embeddings.d.ts +3 -0
  574. package/dist/http/routes/v1-embeddings.js +82 -0
  575. package/dist/http/routes/v1-knowledge.d.ts +4 -0
  576. package/dist/http/routes/v1-knowledge.js +304 -0
  577. package/dist/http/routes/v1-models-ensure.d.ts +32 -0
  578. package/dist/http/routes/v1-models-ensure.js +122 -0
  579. package/dist/http/routes/v1-models.d.ts +8 -0
  580. package/dist/http/routes/v1-models.js +189 -0
  581. package/dist/http/routes/v1-responses.d.ts +20 -0
  582. package/dist/http/routes/v1-responses.js +327 -0
  583. package/dist/index.d.ts +2 -0
  584. package/dist/index.js +2 -0
  585. package/dist/inference-service.d.ts +28 -0
  586. package/dist/inference-service.js +275 -0
  587. package/dist/knowledge/admission.d.ts +3 -0
  588. package/dist/knowledge/admission.js +22 -0
  589. package/dist/knowledge/bundled-catalog.d.ts +2 -0
  590. package/dist/knowledge/bundled-catalog.js +2 -0
  591. package/dist/knowledge/catalog-host.d.ts +103 -0
  592. package/dist/knowledge/catalog-host.js +180 -0
  593. package/dist/knowledge/catalog-source.d.ts +22 -0
  594. package/dist/knowledge/catalog-source.js +35 -0
  595. package/dist/knowledge/install.d.ts +55 -0
  596. package/dist/knowledge/install.js +274 -0
  597. package/dist/knowledge/manager.d.ts +287 -0
  598. package/dist/knowledge/manager.js +1540 -0
  599. package/dist/knowledge/registry.d.ts +35 -0
  600. package/dist/knowledge/registry.js +123 -0
  601. package/dist/knowledge/search-worker.d.ts +17 -0
  602. package/dist/knowledge/search-worker.js +36 -0
  603. package/dist/knowledge/shared-install-contract.d.ts +19 -0
  604. package/dist/knowledge/shared-install-contract.js +1 -0
  605. package/dist/knowledge/spatial-query.d.ts +11 -0
  606. package/dist/knowledge/spatial-query.js +33 -0
  607. package/dist/knowledge/trust-anchors.d.ts +11 -0
  608. package/dist/knowledge/trust-anchors.js +51 -0
  609. package/dist/knowledge/vector-floors.d.ts +44 -0
  610. package/dist/knowledge/vector-floors.js +92 -0
  611. package/dist/knowledge/worker-host.d.ts +9 -0
  612. package/dist/knowledge/worker-host.js +179 -0
  613. package/dist/kokoro-lexicon/LICENSE +202 -0
  614. package/dist/kokoro-lexicon/lexicon-gb-en.txt.gz +0 -0
  615. package/dist/kokoro-lexicon/lexicon-us-en.txt.gz +0 -0
  616. package/dist/kokoro-lexicon/manifest.json +23 -0
  617. package/dist/machine-engine/inspect-runtime.d.ts +3 -0
  618. package/dist/machine-engine/inspect-runtime.js +48 -0
  619. package/dist/machine-engine/knowledge-assets.d.ts +79 -0
  620. package/dist/machine-engine/knowledge-assets.js +505 -0
  621. package/dist/memory/embed-core.d.ts +131 -0
  622. package/dist/memory/embed-core.js +398 -0
  623. package/dist/memory/embed-worker.d.ts +23 -0
  624. package/dist/memory/embed-worker.js +49 -0
  625. package/dist/memory/embeddings.d.ts +105 -0
  626. package/dist/memory/embeddings.js +467 -0
  627. package/dist/model-profile/behaviors/tools-mlx-template-fix.d.ts +1 -0
  628. package/dist/model-profile/behaviors/tools-mlx-template-fix.js +2 -0
  629. package/dist/model-profile/behaviors/turn-ramble-detection.d.ts +1 -0
  630. package/dist/model-profile/behaviors/turn-ramble-detection.js +2 -0
  631. package/dist/model-profile/defaults.d.ts +1 -0
  632. package/dist/model-profile/defaults.js +2 -0
  633. package/dist/model-profile/index.d.ts +9 -0
  634. package/dist/model-profile/index.js +7 -0
  635. package/dist/model-profile/registry.d.ts +15 -0
  636. package/dist/model-profile/registry.js +18 -0
  637. package/dist/model-profile/runtime.d.ts +48 -0
  638. package/dist/model-profile/runtime.js +131 -0
  639. package/dist/model-profile/tool-grammar.d.ts +1 -0
  640. package/dist/model-profile/tool-grammar.js +2 -0
  641. package/dist/model-profile/tuning.d.ts +19 -0
  642. package/dist/model-profile/tuning.js +21 -0
  643. package/dist/model-profile/types.d.ts +1 -0
  644. package/dist/model-profile/types.js +2 -0
  645. package/dist/models/bundle-storage.d.ts +28 -0
  646. package/dist/models/bundle-storage.js +80 -0
  647. package/dist/models/catalog-drift.d.ts +48 -0
  648. package/dist/models/catalog-drift.js +94 -0
  649. package/dist/models/catalog-payload-identity.d.ts +107 -0
  650. package/dist/models/catalog-payload-identity.js +254 -0
  651. package/dist/models/ensure.d.ts +125 -0
  652. package/dist/models/ensure.js +205 -0
  653. package/dist/models/gezmodel.d.ts +47 -0
  654. package/dist/models/gezmodel.js +742 -0
  655. package/dist/models/install-registry.d.ts +101 -0
  656. package/dist/models/install-registry.js +216 -0
  657. package/dist/models/model-id.d.ts +22 -0
  658. package/dist/models/model-id.js +40 -0
  659. package/dist/models/storage-roots.d.ts +274 -0
  660. package/dist/models/storage-roots.js +865 -0
  661. package/dist/native-release-entry.d.ts +16 -0
  662. package/dist/native-release-entry.js +16 -0
  663. package/dist/perf/redact-path.d.ts +2 -0
  664. package/dist/perf/redact-path.js +27 -0
  665. package/dist/perf/responsiveness.d.ts +70 -0
  666. package/dist/perf/responsiveness.js +560 -0
  667. package/dist/providers/active-install-message.d.ts +42 -0
  668. package/dist/providers/active-install-message.js +49 -0
  669. package/dist/providers/apple-foundation-models/binary.d.ts +4 -0
  670. package/dist/providers/apple-foundation-models/binary.js +18 -0
  671. package/dist/providers/apple-foundation-models/helper.d.ts +66 -0
  672. package/dist/providers/apple-foundation-models/helper.js +262 -0
  673. package/dist/providers/apple-foundation-models/provider.d.ts +82 -0
  674. package/dist/providers/apple-foundation-models/provider.js +389 -0
  675. package/dist/providers/apple-foundation-models/status.d.ts +9 -0
  676. package/dist/providers/apple-foundation-models/status.js +49 -0
  677. package/dist/providers/audio/kokoro-frontend.d.ts +50 -0
  678. package/dist/providers/audio/kokoro-frontend.js +113 -0
  679. package/dist/providers/audio/managed-speech-model.d.ts +9 -0
  680. package/dist/providers/audio/managed-speech-model.js +86 -0
  681. package/dist/providers/audio/types.d.ts +202 -0
  682. package/dist/providers/audio/types.js +17 -0
  683. package/dist/providers/audio/whisper-cpp.d.ts +99 -0
  684. package/dist/providers/audio/whisper-cpp.js +420 -0
  685. package/dist/providers/audio-persister-contract.d.ts +13 -0
  686. package/dist/providers/audio-persister-contract.js +1 -0
  687. package/dist/providers/catalog-model-config.d.ts +63 -0
  688. package/dist/providers/catalog-model-config.js +95 -0
  689. package/dist/providers/code-block-salvage.d.ts +1 -0
  690. package/dist/providers/code-block-salvage.js +2 -0
  691. package/dist/providers/codex-cli/reasoning.d.ts +8 -0
  692. package/dist/providers/codex-cli/reasoning.js +17 -0
  693. package/dist/providers/complete-tool-call.d.ts +16 -0
  694. package/dist/providers/complete-tool-call.js +25 -0
  695. package/dist/providers/constrained-turn.d.ts +1 -0
  696. package/dist/providers/constrained-turn.js +2 -0
  697. package/dist/providers/deliverable-read-pacing.d.ts +1 -0
  698. package/dist/providers/deliverable-read-pacing.js +2 -0
  699. package/dist/providers/direct-file-work-prompt.d.ts +1 -0
  700. package/dist/providers/direct-file-work-prompt.js +2 -0
  701. package/dist/providers/ds4/build-provider.d.ts +100 -0
  702. package/dist/providers/ds4/build-provider.js +539 -0
  703. package/dist/providers/ds4/cache-adapter.d.ts +42 -0
  704. package/dist/providers/ds4/cache-adapter.js +110 -0
  705. package/dist/providers/ds4/dspark.d.ts +83 -0
  706. package/dist/providers/ds4/dspark.js +107 -0
  707. package/dist/providers/ds4/mtp.d.ts +32 -0
  708. package/dist/providers/ds4/mtp.js +46 -0
  709. package/dist/providers/ds4/provider.d.ts +67 -0
  710. package/dist/providers/ds4/provider.js +104 -0
  711. package/dist/providers/ds4/residency.d.ts +143 -0
  712. package/dist/providers/ds4/residency.js +139 -0
  713. package/dist/providers/ds4/stdout-parser.d.ts +47 -0
  714. package/dist/providers/ds4/stdout-parser.js +134 -0
  715. package/dist/providers/duplicate-tool-calls.d.ts +1 -0
  716. package/dist/providers/duplicate-tool-calls.js +2 -0
  717. package/dist/providers/file-repair-policy.d.ts +1 -0
  718. package/dist/providers/file-repair-policy.js +2 -0
  719. package/dist/providers/gpu-arbiter.d.ts +159 -0
  720. package/dist/providers/gpu-arbiter.js +301 -0
  721. package/dist/providers/image-persister-contract.d.ts +15 -0
  722. package/dist/providers/image-persister-contract.js +1 -0
  723. package/dist/providers/llama-cpp/build-metadata.d.ts +13 -0
  724. package/dist/providers/llama-cpp/build-metadata.js +39 -0
  725. package/dist/providers/llama-cpp/build-provider.d.ts +66 -0
  726. package/dist/providers/llama-cpp/build-provider.js +1491 -0
  727. package/dist/providers/llama-cpp/cache-adapter.d.ts +242 -0
  728. package/dist/providers/llama-cpp/cache-adapter.js +689 -0
  729. package/dist/providers/llama-cpp/devices.d.ts +130 -0
  730. package/dist/providers/llama-cpp/devices.js +256 -0
  731. package/dist/providers/llama-cpp/disk-cache.d.ts +8 -0
  732. package/dist/providers/llama-cpp/disk-cache.js +112 -0
  733. package/dist/providers/llama-cpp/engine-flags.d.ts +181 -0
  734. package/dist/providers/llama-cpp/engine-flags.js +279 -0
  735. package/dist/providers/llama-cpp/gguf-metadata-async.d.ts +11 -0
  736. package/dist/providers/llama-cpp/gguf-metadata-async.js +180 -0
  737. package/dist/providers/llama-cpp/gguf-metadata-worker.d.ts +7 -0
  738. package/dist/providers/llama-cpp/gguf-metadata-worker.js +27 -0
  739. package/dist/providers/llama-cpp/gguf-metadata.d.ts +198 -0
  740. package/dist/providers/llama-cpp/gguf-metadata.js +615 -0
  741. package/dist/providers/llama-cpp/gguf-quantization-backfill.d.ts +32 -0
  742. package/dist/providers/llama-cpp/gguf-quantization-backfill.js +75 -0
  743. package/dist/providers/llama-cpp/index.d.ts +10 -0
  744. package/dist/providers/llama-cpp/index.js +13 -0
  745. package/dist/providers/llama-cpp/kv-cache-type.d.ts +118 -0
  746. package/dist/providers/llama-cpp/kv-cache-type.js +140 -0
  747. package/dist/providers/llama-cpp/log.d.ts +92 -0
  748. package/dist/providers/llama-cpp/log.js +280 -0
  749. package/dist/providers/llama-cpp/models.d.ts +389 -0
  750. package/dist/providers/llama-cpp/models.js +1011 -0
  751. package/dist/providers/llama-cpp/offload-planner.d.ts +339 -0
  752. package/dist/providers/llama-cpp/offload-planner.js +524 -0
  753. package/dist/providers/llama-cpp/provider.d.ts +493 -0
  754. package/dist/providers/llama-cpp/provider.js +862 -0
  755. package/dist/providers/llama-cpp/reasoning-launch.d.ts +7 -0
  756. package/dist/providers/llama-cpp/reasoning-launch.js +14 -0
  757. package/dist/providers/llama-cpp/spec-draft.d.ts +44 -0
  758. package/dist/providers/llama-cpp/spec-draft.js +44 -0
  759. package/dist/providers/llama-cpp/stdout-parser.d.ts +70 -0
  760. package/dist/providers/llama-cpp/stdout-parser.js +197 -0
  761. package/dist/providers/local-tool-call-salvage.d.ts +1 -0
  762. package/dist/providers/local-tool-call-salvage.js +2 -0
  763. package/dist/providers/local-turn-policy.d.ts +1 -0
  764. package/dist/providers/local-turn-policy.js +2 -0
  765. package/dist/providers/mlx/async-file-handoff.d.ts +11 -0
  766. package/dist/providers/mlx/async-file-handoff.js +25 -0
  767. package/dist/providers/mlx/build-provider.d.ts +65 -0
  768. package/dist/providers/mlx/build-provider.js +717 -0
  769. package/dist/providers/mlx/cache-adapter.d.ts +161 -0
  770. package/dist/providers/mlx/cache-adapter.js +445 -0
  771. package/dist/providers/mlx/chat-protocol.d.ts +48 -0
  772. package/dist/providers/mlx/chat-protocol.js +8 -0
  773. package/dist/providers/mlx/config-parser.d.ts +29 -0
  774. package/dist/providers/mlx/config-parser.js +81 -0
  775. package/dist/providers/mlx/engine-gate.d.ts +51 -0
  776. package/dist/providers/mlx/engine-gate.js +207 -0
  777. package/dist/providers/mlx/engine-hold.d.ts +41 -0
  778. package/dist/providers/mlx/engine-hold.js +59 -0
  779. package/dist/providers/mlx/engine-log-router.d.ts +84 -0
  780. package/dist/providers/mlx/engine-log-router.js +137 -0
  781. package/dist/providers/mlx/engine-stats-sampler.d.ts +36 -0
  782. package/dist/providers/mlx/engine-stats-sampler.js +59 -0
  783. package/dist/providers/mlx/index.d.ts +10 -0
  784. package/dist/providers/mlx/index.js +10 -0
  785. package/dist/providers/mlx/log.d.ts +55 -0
  786. package/dist/providers/mlx/log.js +157 -0
  787. package/dist/providers/mlx/model-geometry.d.ts +35 -0
  788. package/dist/providers/mlx/model-geometry.js +118 -0
  789. package/dist/providers/mlx/models.d.ts +289 -0
  790. package/dist/providers/mlx/models.js +1079 -0
  791. package/dist/providers/mlx/provider.d.ts +404 -0
  792. package/dist/providers/mlx/provider.js +3017 -0
  793. package/dist/providers/mlx/python/cache_persist.py +348 -0
  794. package/dist/providers/mlx/python/cache_seed.py +402 -0
  795. package/dist/providers/mlx/python/gezel_mlx_server.py +4491 -0
  796. package/dist/providers/mlx/python/lfm2_compat.py +55 -0
  797. package/dist/providers/mlx/python/qwen3_5_text_compat.py +60 -0
  798. package/dist/providers/mlx/python/qwen4_ple.py +159 -0
  799. package/dist/providers/mlx/python/spec_decode.py +775 -0
  800. package/dist/providers/mlx/python/template_stability.py +133 -0
  801. package/dist/providers/mlx/python/think_budget.py +113 -0
  802. package/dist/providers/mlx/python/tool_args_json.py +297 -0
  803. package/dist/providers/mlx/python/tool_call_stream.py +265 -0
  804. package/dist/providers/mlx/python/tool_grammar.py +794 -0
  805. package/dist/providers/mlx/python/vision_inputs.py +64 -0
  806. package/dist/providers/mlx/python/wave_policy.py +147 -0
  807. package/dist/providers/mlx/reasoning-stream.d.ts +85 -0
  808. package/dist/providers/mlx/reasoning-stream.js +196 -0
  809. package/dist/providers/mlx/request-shape.d.ts +34 -0
  810. package/dist/providers/mlx/request-shape.js +42 -0
  811. package/dist/providers/mlx/runtime-diagnostics.d.ts +29 -0
  812. package/dist/providers/mlx/runtime-diagnostics.js +162 -0
  813. package/dist/providers/mlx/spec-drafter.d.ts +25 -0
  814. package/dist/providers/mlx/spec-drafter.js +73 -0
  815. package/dist/providers/mlx/stdout-parser.d.ts +60 -0
  816. package/dist/providers/mlx/stdout-parser.js +254 -0
  817. package/dist/providers/mlx/textual-tool-call-salvage.d.ts +46 -0
  818. package/dist/providers/mlx/textual-tool-call-salvage.js +396 -0
  819. package/dist/providers/mlx/tool-call-protocol.d.ts +65 -0
  820. package/dist/providers/mlx/tool-call-protocol.js +140 -0
  821. package/dist/providers/mlx/tool-call-stripper.d.ts +68 -0
  822. package/dist/providers/mlx/tool-call-stripper.js +153 -0
  823. package/dist/providers/mlx/tool-image-retention.d.ts +23 -0
  824. package/dist/providers/mlx/tool-image-retention.js +43 -0
  825. package/dist/providers/mlx/turn-telemetry.d.ts +22 -0
  826. package/dist/providers/mlx/turn-telemetry.js +45 -0
  827. package/dist/providers/mlx/venv.d.ts +91 -0
  828. package/dist/providers/mlx/venv.js +111 -0
  829. package/dist/providers/mlx/vision-mode.d.ts +92 -0
  830. package/dist/providers/mlx/vision-mode.js +187 -0
  831. package/dist/providers/mlx/vision.d.ts +2 -0
  832. package/dist/providers/mlx/vision.js +12 -0
  833. package/dist/providers/mlx/wire-transcript.d.ts +14 -0
  834. package/dist/providers/mlx/wire-transcript.js +43 -0
  835. package/dist/providers/native/capacity-broker.d.ts +812 -0
  836. package/dist/providers/native/capacity-broker.js +1058 -0
  837. package/dist/providers/native/capacity-budget.d.ts +133 -0
  838. package/dist/providers/native/capacity-budget.js +265 -0
  839. package/dist/providers/native/device-capacity-ledger.d.ts +31 -0
  840. package/dist/providers/native/device-capacity-ledger.js +196 -0
  841. package/dist/providers/native/device-capacity.d.ts +84 -0
  842. package/dist/providers/native/device-capacity.js +431 -0
  843. package/dist/providers/native/engine-api-key.d.ts +57 -0
  844. package/dist/providers/native/engine-api-key.js +72 -0
  845. package/dist/providers/native/engine-key.d.ts +18 -0
  846. package/dist/providers/native/engine-key.js +41 -0
  847. package/dist/providers/native/engine-router.d.ts +145 -0
  848. package/dist/providers/native/engine-router.js +191 -0
  849. package/dist/providers/native/gpu-panic-guard.d.ts +54 -0
  850. package/dist/providers/native/gpu-panic-guard.js +59 -0
  851. package/dist/providers/native/inference.d.ts +33 -0
  852. package/dist/providers/native/inference.js +49 -0
  853. package/dist/providers/native/local-engine-runtime.d.ts +412 -0
  854. package/dist/providers/native/local-engine-runtime.js +1714 -0
  855. package/dist/providers/native/measured-budget.d.ts +32 -0
  856. package/dist/providers/native/measured-budget.js +54 -0
  857. package/dist/providers/native/port.d.ts +11 -0
  858. package/dist/providers/native/port.js +29 -0
  859. package/dist/providers/native/provider-pool.d.ts +474 -0
  860. package/dist/providers/native/provider-pool.js +1166 -0
  861. package/dist/providers/native/supervisor.d.ts +503 -0
  862. package/dist/providers/native/supervisor.js +1466 -0
  863. package/dist/providers/observation-context.d.ts +16 -0
  864. package/dist/providers/observation-context.js +1 -0
  865. package/dist/providers/openai-compatible/sse.d.ts +1 -0
  866. package/dist/providers/openai-compatible/sse.js +2 -0
  867. package/dist/providers/patient-fetch.d.ts +36 -0
  868. package/dist/providers/patient-fetch.js +44 -0
  869. package/dist/providers/project-macro-loop-bail.d.ts +7 -0
  870. package/dist/providers/project-macro-loop-bail.js +21 -0
  871. package/dist/providers/prose-document-salvage.d.ts +1 -0
  872. package/dist/providers/prose-document-salvage.js +2 -0
  873. package/dist/providers/provider-disposal.d.ts +1 -0
  874. package/dist/providers/provider-disposal.js +2 -0
  875. package/dist/providers/queue.d.ts +15 -0
  876. package/dist/providers/queue.js +23 -0
  877. package/dist/providers/ramble-abort-message.d.ts +1 -0
  878. package/dist/providers/ramble-abort-message.js +2 -0
  879. package/dist/providers/ramble-detector.d.ts +1 -0
  880. package/dist/providers/ramble-detector.js +2 -0
  881. package/dist/providers/reasoning-depth.d.ts +1 -0
  882. package/dist/providers/reasoning-depth.js +2 -0
  883. package/dist/providers/required-input-reads.d.ts +1 -0
  884. package/dist/providers/required-input-reads.js +2 -0
  885. package/dist/providers/session-defaults.d.ts +13 -0
  886. package/dist/providers/session-defaults.js +49 -0
  887. package/dist/providers/session-hooks.d.ts +26 -0
  888. package/dist/providers/session-hooks.js +1 -0
  889. package/dist/providers/streaming-session.d.ts +1 -0
  890. package/dist/providers/streaming-session.js +2 -0
  891. package/dist/providers/terminal-tool-policy.d.ts +1 -0
  892. package/dist/providers/terminal-tool-policy.js +2 -0
  893. package/dist/providers/tool-arg-schema-coercion.d.ts +1 -0
  894. package/dist/providers/tool-arg-schema-coercion.js +2 -0
  895. package/dist/providers/tool-executor.d.ts +12 -0
  896. package/dist/providers/tool-executor.js +20 -0
  897. package/dist/providers/tool-failure-tracker.d.ts +1 -0
  898. package/dist/providers/tool-failure-tracker.js +2 -0
  899. package/dist/providers/tool-repeat-tracker.d.ts +1 -0
  900. package/dist/providers/tool-repeat-tracker.js +2 -0
  901. package/dist/providers/turn-timeout.d.ts +1 -0
  902. package/dist/providers/turn-timeout.js +1 -0
  903. package/dist/providers/types.d.ts +834 -0
  904. package/dist/providers/types.js +42 -0
  905. package/dist/providers/usage-builder.d.ts +1 -0
  906. package/dist/providers/usage-builder.js +2 -0
  907. package/dist/providers/vision-capability.d.ts +109 -0
  908. package/dist/providers/vision-capability.js +137 -0
  909. package/dist/python/mlx-runtime-status-bus.d.ts +39 -0
  910. package/dist/python/mlx-runtime-status-bus.js +27 -0
  911. package/dist/python/uv-runtime.d.ts +176 -0
  912. package/dist/python/uv-runtime.js +492 -0
  913. package/dist/relevance/install.d.ts +28 -0
  914. package/dist/relevance/install.js +114 -0
  915. package/dist/relevance/manager.d.ts +79 -0
  916. package/dist/relevance/manager.js +211 -0
  917. package/dist/relevance/registry.d.ts +11 -0
  918. package/dist/relevance/registry.js +158 -0
  919. package/dist/relevance/relevance-core.d.ts +67 -0
  920. package/dist/relevance/relevance-core.js +189 -0
  921. package/dist/relevance/relevance-model.d.ts +42 -0
  922. package/dist/relevance/relevance-model.js +292 -0
  923. package/dist/relevance/relevance-worker.d.ts +20 -0
  924. package/dist/relevance/relevance-worker.js +57 -0
  925. package/dist/relevance/settings.d.ts +30 -0
  926. package/dist/relevance/settings.js +81 -0
  927. package/dist/remotes/identity.d.ts +121 -0
  928. package/dist/remotes/identity.js +348 -0
  929. package/dist/remotes/pinned-fetch.d.ts +45 -0
  930. package/dist/remotes/pinned-fetch.js +193 -0
  931. package/dist/remotes/types.d.ts +54 -0
  932. package/dist/remotes/types.js +1 -0
  933. package/dist/runtime-lock.d.ts +29 -0
  934. package/dist/runtime-lock.js +85 -0
  935. package/dist/search/relevance-stage.d.ts +81 -0
  936. package/dist/search/relevance-stage.js +177 -0
  937. package/dist/secrets/types.d.ts +63 -0
  938. package/dist/secrets/types.js +64 -0
  939. package/dist/speech.d.ts +1 -0
  940. package/dist/speech.js +1 -0
  941. package/dist/system/darwin-memory.d.ts +26 -0
  942. package/dist/system/darwin-memory.js +81 -0
  943. package/dist/system/gezel-process-memory.d.ts +55 -0
  944. package/dist/system/gezel-process-memory.js +156 -0
  945. package/dist/system/memory.d.ts +154 -0
  946. package/dist/system/memory.js +501 -0
  947. package/dist/transformers-cache.d.ts +112 -0
  948. package/dist/transformers-cache.js +392 -0
  949. package/dist/utils/disk-space.d.ts +55 -0
  950. package/dist/utils/disk-space.js +72 -0
  951. package/dist/utils/download-common.d.ts +138 -0
  952. package/dist/utils/download-common.js +196 -0
  953. package/dist/utils/download-with-retry.d.ts +85 -0
  954. package/dist/utils/download-with-retry.js +522 -0
  955. package/dist/utils/extract-zip.d.ts +22 -0
  956. package/dist/utils/extract-zip.js +103 -0
  957. package/dist/utils/retire-model-worker.d.ts +15 -0
  958. package/dist/utils/retire-model-worker.js +24 -0
  959. package/dist/utils/service-worker-entry.d.ts +59 -0
  960. package/dist/utils/service-worker-entry.js +69 -0
  961. package/dist/utils/xet-download.d.ts +40 -0
  962. package/dist/utils/xet-download.js +526 -0
  963. package/dist/utils/xet.d.ts +86 -0
  964. package/dist/utils/xet.js +201 -0
  965. package/package.json +103 -3
  966. package/scripts/embedding-packaging.cjs +264 -0
  967. package/scripts/embedding-packaging.d.cts +16 -0
@@ -0,0 +1,3017 @@
1
+ /**
2
+ * MlxProvider — talks to a local `mlx_lm.server` subprocess over HTTP.
3
+ * The server speaks OpenAI-compatible `/v1/chat/completions` with SSE
4
+ * streaming; tool calling rides the standard OpenAI function-tool
5
+ * shape, same as llama-cpp.
6
+ *
7
+ * Platform: **darwin-arm64 only**. Apple Silicon's unified memory +
8
+ * Metal Performance Shaders are what make MLX fast; there's no MLX
9
+ * backend for Intel Mac, Linux, or Windows. `ChatManager.ensureProvider`
10
+ * gates the branch on platform; this file assumes Apple Silicon.
11
+ *
12
+ * Session model is stateless (same as llama-cpp / Ollama): full
13
+ * transcript lives in memory and is re-sent per turn.
14
+ * `ProviderSessionState` is empty — the session record on disk is the
15
+ * source of truth.
16
+ *
17
+ * Lifecycle of the mlx_lm.server process is managed by a
18
+ * {@link NativeEngineSupervisor}: lazy-start on first turn, idle-stop,
19
+ * health-watch, restart budget. Startup is usually slower than
20
+ * llama-cpp (Python import + PyTorch/MLX init) so we give it a 180s
21
+ * startup budget.
22
+ *
23
+ * First-time install of mlx-lm itself is handled outside this file —
24
+ * `buildMlxProvider` in ChatManager calls `UvRuntime.ensureVenv('mlx',
25
+ * [mlxLmSpec])` before spawning the supervisor, ensuring the venv +
26
+ * packages exist.
27
+ */
28
+ import { AsyncLocalStorage } from 'node:async_hooks';
29
+ import { randomUUID } from 'node:crypto';
30
+ import { AwakeBudget, autoCompactedNotice, createAwakeTimeout, createLogger, leaksUntaggedReasoning, turnCancelledMessage, } from '@bendyline/gezel';
31
+ import { leadingSystemMessages, standaloneTurnMessages, toolSurfaceSize, } from '@bendyline/gezel/local-loop';
32
+ import { computeToolBudgetChars } from '@bendyline/gezel/local-loop';
33
+ import { extractReasoningWithProfile, profileBehaviorConfig, profileHasBehavior, } from '../../model-profile/runtime.js';
34
+ import { prepareSalvagedCodeBlocks } from '../code-block-salvage.js';
35
+ import { hasCompleteToolCallMarkup } from '../complete-tool-call.js';
36
+ import { DeliverableReadPaceTracker } from '../deliverable-read-pacing.js';
37
+ import { collapseDuplicateToolCalls } from '../duplicate-tool-calls.js';
38
+ import { appendCapTruncationHintToRejectedWrite, appendTruncationHintToToolResult, buildUnknownToolNudge, describeMalformation, findTruncatedProseToolCall, findUnrecognizedFunctionMarkup, findUnrecognizedToolEnvelope, foldPreToolPreamble, formatToolMenu, isWriteShapedToolName, parseGemmaNativeToolCall, parseGemmaToolCall, uniqueWantedToolNames, } from '../local-tool-call-salvage.js';
39
+ import { readSseEvents } from '../openai-compatible/sse.js';
40
+ import { PROJECT_MACRO_INTERCEPT_CAP, deriveProjectMacroClosing, } from '../project-macro-loop-bail.js';
41
+ import { prepareSalvagedProseDocument } from '../prose-document-salvage.js';
42
+ import { ProviderDisposedError, runOnLiveProvider } from '../provider-disposal.js';
43
+ import { ProviderQueue, backgroundLaneCap, defaultAmbientQuietMs } from '../queue.js';
44
+ import { buildRambleAbortMessage } from '../ramble-abort-message.js';
45
+ import { RambleDetector, inertRambleDetector, outputIsConstrained } from '../ramble-detector.js';
46
+ import { downgradeReasoningDepthKwargs } from '../reasoning-depth.js';
47
+ import { promptReadInputs, requiredInputsRead, unreadRequiredInputs, } from '../required-input-reads.js';
48
+ import { StreamingSessionBase, } from '../streaming-session.js';
49
+ import { DeliverableReadySteer, TERMINAL_ACTION_SKIPPED_OUTPUT, terminalToolClosingText, } from '../terminal-tool-policy.js';
50
+ import { coerceToolCallArgs } from '../tool-arg-schema-coercion.js';
51
+ import { createToolExecutor } from '../tool-executor.js';
52
+ import { ToolFailureTracker } from '../tool-failure-tracker.js';
53
+ import { ToolRepeatTracker } from '../tool-repeat-tracker.js';
54
+ import { asyncHandoffClosing, immediateFileWriteClosing, isSuccessfulAsyncHandoff, } from './async-file-handoff.js';
55
+ export { isSuccessfulAsyncHandoff };
56
+ import { setChatTemplateKwarg, } from './chat-protocol.js';
57
+ import { MlxEngineGate } from './engine-gate.js';
58
+ import { EngineHold, engineHoldLabelFor } from './engine-hold.js';
59
+ import { EngineLogRouter } from './engine-log-router.js';
60
+ import { MlxEngineStatsSampler } from './engine-stats-sampler.js';
61
+ import { StreamingReasoningSplit, requestOpensReasoning, } from './reasoning-stream.js';
62
+ import { applyMlxRequestShape } from './request-shape.js';
63
+ import { PRE_FIRST_BYTE_BASE_MS, buildMidStreamDropMessage, buildPreFirstByteAbortMessage, computePreFirstByteBudgetMs, estimatePromptTokens, formatTps, isMidStreamConnectionDrop, translateMlxHttpError, } from './runtime-diagnostics.js';
64
+ import { classifyMlxFatalErrorLine, classifyMlxStartupLine, } from './stdout-parser.js';
65
+ import { salvageTextualToolCalls } from './textual-tool-call-salvage.js';
66
+ import { APPEND_TO_FILE_CONTINUATION_TOOL, MlxToolCallAccumulator, applyCallableToolGrammar, callableRequestToolNames, chatCompletionToolName, externalToolsAsChatCompletions, hermesRequiredArgGrammarRequested, missingTopLevelRequiredToolArgs, toChatCompletionsTools, validatorReportedMissingRequiredArgs, } from './tool-call-protocol.js';
67
+ import { LeakyToolCallStripper } from './tool-call-stripper.js';
68
+ import { TOOL_IMAGES_MESSAGE, retireInspectedToolImages } from './tool-image-retention.js';
69
+ import { buildMlxTerminalTelemetry } from './turn-telemetry.js';
70
+ import { MlxVisionMode, unseenToolImagesNote } from './vision-mode.js';
71
+ import { mlxWireTranscript } from './wire-transcript.js';
72
+ export { buildMidStreamDropMessage, computePreFirstByteBudgetMs, estimatePromptTokens, isMidStreamConnectionDrop, };
73
+ import { FILE_REPAIR_MUTATION_TOOLS, FILE_REPAIR_READ_TOOLS, applyConstrainedTurnShape, disableTemplateThinking, fileRepairTargetPath, isSourceFileRepairPrompt, requiredToolOnly, writeFileOnlyTools, } from '../constrained-turn.js';
74
+ import { hasExplicitFullFileRewriteWording } from '../direct-file-work-prompt.js';
75
+ import { buildImmediateFileWriteNoMutationNudge, buildPrerequisiteRepairReadNudge, buildScenarioRepairNoMutationNudge, completeWorkspaceReadPaths, extractPrerequisiteRepairReadPaths, normalizeWorkspacePathForCompare, remainingPrerequisiteRepairReadPaths, } from '../file-repair-policy.js';
76
+ import { LOCAL_TURN_LIMITS, LocalTurnPolicy, compactLocalTurn, planFileTurn, repairTools, turnCheckpoint, } from '../local-turn-policy.js';
77
+ const MAX_TOOL_LOOP_TURNS = LOCAL_TURN_LIMITS.iterations;
78
+ // Hard per-turn ceiling when the caller doesn't pass `opts.timeoutMs`.
79
+ // 10 minutes matches what the manager passes for local providers in
80
+ // short-form sessions; the original 3-minute floor was too tight for
81
+ // 26B+ models doing heavy reasoning. Manager calls always override
82
+ // this with `OLLAMA_TURN_TIMEOUT_MS` (30 min); the constant here is
83
+ // only the fallback for one-shot / test paths.
84
+ const DEFAULT_TIMEOUT_MS = 600_000;
85
+ // Fallback for direct/external provider construction where no catalog model
86
+ // metadata is available. Supervised catalog models pass their native context
87
+ // window explicitly from buildMlxProvider.
88
+ const DEFAULT_NUM_CTX = 65_536;
89
+ // File-producing turns need enough room for the tool arguments themselves.
90
+ // The Developer template selects `thinking-coding`, whose normal 2,048-token
91
+ // budget truncated single-file HTML games mid-script. Treat 4,096 as a floor,
92
+ // matching llama.cpp's immediate-write path; user/model tuning may still grant
93
+ // more. This is intentionally not a cap.
94
+ const FILE_WRITE_MIN_TOKENS = 4_096;
95
+ // Steers issued per send after a cap-truncated payload mutation was
96
+ // refused. Two is enough to convert "re-emit the whole file" into
97
+ // "make targeted edits"; beyond that the model is not reading the
98
+ // steer and more attempts only burn engine time at full context.
99
+ const MAX_CAP_TRUNCATION_STEERS = LOCAL_TURN_LIMITS.capTruncation;
100
+ // Max automatic append_to_file continuations after an immediate-write
101
+ // truncation before we bail with the partial. Each continuation is one
102
+ // bounded turn that writes the next chunk — comfortably above any
103
+ // single-file scenario deliverable while preventing a runaway. Replaces
104
+ // the old "bail after one truncated write and hope the next turn appends"
105
+ // path: the weak model had no append_to_file in its surface and just rambled
106
+ // instead (wild-caught tankcombat: a 5.8 KB game truncated at the former
107
+ // short output budget and the model never continued it).
108
+ const MAX_IMMEDIATE_WRITE_CONTINUATIONS = LOCAL_TURN_LIMITS.writeContinuations;
109
+ const IMMEDIATE_FILE_WRITE_PROMPT_SUFFIX = '\n\n[Local-model rescue: make this a compact first pass. Your entire visible output should be one `write_file` tool call. Prioritize a complete, runnable file over decorative extras; include every requested behavior and any named asset path. Do not include planning prose.]';
110
+ const log = createLogger('mlx');
111
+ /**
112
+ * Cap on how many times we'll nudge the model to retry a malformed
113
+ * tool call within a single turn before giving up. Two attempts is
114
+ * enough that a model with a transient slip can correct itself, low
115
+ * enough that a model that just can't form structured calls (small
116
+ * quants, certain Gemma variants) doesn't burn the whole turn budget.
117
+ */
118
+ const MAX_MALFORMED_RETRIES = LOCAL_TURN_LIMITS.malformed;
119
+ export class MlxProvider {
120
+ name = 'mlx';
121
+ visionMode;
122
+ queue;
123
+ supportsExternalTools = true;
124
+ supportsPriorMessages = true;
125
+ supervisor;
126
+ externalBaseUrl;
127
+ defaultModel;
128
+ numCtx;
129
+ plannedReservation;
130
+ fetchImpl;
131
+ /** Set in {@link shutdown}; a disposed provider must never respawn its engine. */
132
+ disposed = false;
133
+ /**
134
+ * Catalog manager — when set, `listModels()` enumerates every model
135
+ * the user has installed via Settings → This Mac so the model picker
136
+ * in Settings → AI can pick from the full list. Optional because
137
+ * external-baseUrl mode (`GEZEL_MLX_SERVER_URL`) skips the catalog
138
+ * entirely. The picker selection writes back to
139
+ * `config.defaultModel.mlx` as a catalog id; `buildMlxProvider`
140
+ * resolves the id to a `modelDir` on the next provider rebuild.
141
+ */
142
+ modelManager;
143
+ /**
144
+ * Friendly model name for the engine pill — e.g. "Qwen 3.5 8B".
145
+ * Surfaced in `loading_model` phase events as "Loading model
146
+ * (<name>)" so the user knows *which* MLX model is loading. Empty
147
+ * when the catalog manager wasn't wired in (external base URL mode)
148
+ * or when the runtime path doesn't map to a catalog entry — the
149
+ * pill falls back to the bare "Loading model" label in those cases.
150
+ */
151
+ modelDisplayName;
152
+ /**
153
+ * Catalog model id for the on-disk model the provider is serving —
154
+ * `qwen3.6-27b-mlx`, `gemma4-e4b-mlx`, etc. Distinct from
155
+ * `defaultModel`, which holds the absolute model directory path
156
+ * (mlx_vlm.server's PRELOAD_MODEL value). Surfaced via
157
+ * {@link getEffectiveModelId} so the chat manager can resolve a
158
+ * tier for sessions that don't have an explicit model selection
159
+ * (`record.model` undefined AND `config.defaultModel.mlx`
160
+ * undefined). Without this, those sessions classify as
161
+ * `tier:tiny` because the tier resolver gets a path it can't parse.
162
+ *
163
+ * Empty when the provider was constructed in external-baseUrl mode
164
+ * (we don't know what the user's mlx-server is serving) or before
165
+ * the catalog has resolved a default model.
166
+ */
167
+ catalogModelId;
168
+ /** See the constructor option of the same name. */
169
+ templateOpensReasoning;
170
+ activeSessions = new Set();
171
+ /**
172
+ * Engine stdout → per-session phase events, plus the "is this line fatal?"
173
+ * state machine. See {@link EngineLogRouter}; the rules there are load-bearing.
174
+ */
175
+ logRouter = new EngineLogRouter({
176
+ modelDisplayName: () => this.modelDisplayName,
177
+ // A tagged phase belongs to exactly one session; anything untagged is an
178
+ // engine-wide signal every session should see. Without this, a session
179
+ // streaming its reply picks up a neighbour's prefill bar — at the
180
+ // neighbour's token total — because one engine has one stdout.
181
+ deliver: (phase) => {
182
+ // Engine-wide record of who the engine was last seen working FOR.
183
+ // A session that never gets a first byte cannot tell "the engine is
184
+ // sick" from "the engine is busy with someone else" from inside its
185
+ // own request — this is the only place both are visible.
186
+ if (phase.cacheId && phase.phase === 'prefill' && !phase.engineQueue) {
187
+ this.lastTaggedPrefill = {
188
+ cacheId: phase.cacheId,
189
+ detail: phase.detail ?? '',
190
+ at: Date.now(),
191
+ };
192
+ }
193
+ for (const s of this.activeSessions) {
194
+ if (phase.cacheId && !s.ownsCacheId(phase.cacheId))
195
+ continue;
196
+ s.publishEnginePhase(phase);
197
+ }
198
+ },
199
+ onFatal: (fatal) => {
200
+ // If a session is blocked inside `supervisor.ensureRunning()` waiting on
201
+ // `/health`, abortStartup rejects that wait with the real error now
202
+ // instead of letting it burn the full startup budget (up to 5 minutes);
203
+ // the session-level catch turns it into the red bubble in chat.
204
+ const hintSuffix = fatal.hint ? `\n\n${fatal.hint}` : '';
205
+ const err = new Error(`[mlx] engine failed to start: ${fatal.message}${hintSuffix}`);
206
+ this.supervisor?.abortStartup(err);
207
+ // Tear the child down so the next `ensureRunning()` respawns fresh.
208
+ void this.supervisor?.stop().catch(() => { });
209
+ },
210
+ onReady: () => {
211
+ if (this.supervisor)
212
+ this.engineStats.schedule();
213
+ },
214
+ });
215
+ engineStats = new MlxEngineStatsSampler({
216
+ pid: () => this.supervisor?.currentChildPid(),
217
+ publish: (stats) => {
218
+ for (const s of this.activeSessions)
219
+ s.publishEngineStats(stats);
220
+ },
221
+ });
222
+ /** Width-N gate over physical engine requests; see {@link MlxEngineGate}. */
223
+ engineGate;
224
+ batchMaxConcurrency;
225
+ turnProtection = new AsyncLocalStorage();
226
+ constructor(opts) {
227
+ if (!opts.supervisor && !opts.baseUrl) {
228
+ throw new Error('[mlx] need either a supervisor or baseUrl');
229
+ }
230
+ if (opts.supervisor && opts.baseUrl) {
231
+ throw new Error('[mlx] supervisor and baseUrl are mutually exclusive');
232
+ }
233
+ if (opts.supervisor)
234
+ this.supervisor = opts.supervisor;
235
+ if (opts.baseUrl)
236
+ this.externalBaseUrl = opts.baseUrl.replace(/\/+$/, '');
237
+ this.defaultModel = opts.defaultModel ?? 'mlx';
238
+ this.numCtx = opts.numCtx ?? DEFAULT_NUM_CTX;
239
+ this.plannedReservation = opts.plannedReservationBytes;
240
+ this.fetchImpl = opts.fetchImpl ?? fetch;
241
+ if (opts.modelManager)
242
+ this.modelManager = opts.modelManager;
243
+ if (opts.modelDisplayName)
244
+ this.modelDisplayName = opts.modelDisplayName;
245
+ if (opts.catalogModelId)
246
+ this.catalogModelId = opts.catalogModelId;
247
+ if (opts.templateOpensReasoning)
248
+ this.templateOpensReasoning = opts.templateOpensReasoning;
249
+ const batchMax = Math.max(1, opts.batchMaxConcurrency ?? 1);
250
+ this.batchMaxConcurrency = batchMax;
251
+ this.engineGate = new MlxEngineGate(batchMax);
252
+ this.visionMode = opts.vision ?? MlxVisionMode.fixed(opts.visionEnabled === true);
253
+ this.visionMode.bindEngine(this.supervisor, this.engineGate, () => this.disposed);
254
+ // Interactive turns are capped at the memory-safe engine width (`batchMax`);
255
+ // the engine gate below (`acquireExclusiveEngineRequest`) enforces the same
256
+ // bound on real generation. The queue itself, though, must run at least ONE
257
+ // slot ABOVE that cap, reserved for the background lane — otherwise a
258
+ // mid-turn one-shot pinned to THIS provider (in-flight compaction, memory
259
+ // extraction, summarization; all `lane: 'background'`) can't dispatch while
260
+ // the foreground turn awaits it. At width 1 without the reserve, the turn
261
+ // holds the only slot and its own compaction one-shot deadlocks behind it
262
+ // forever: the turn awaits a job that can't start, and the one-shot's
263
+ // timeout only arms AFTER it acquires a slot. That is exactly what wedged
264
+ // sessions "mid-turn" once the memory ceiling clamped a big model to width 1.
265
+ // The +1 admits the chore WITHOUT ever running a 2nd concurrent generation —
266
+ // the engine gate, not queue width, is the OOM guard.
267
+ const interactiveConcurrency = batchMax;
268
+ const queueConcurrency = Math.max(opts.concurrency ?? 1, interactiveConcurrency + 1);
269
+ this.queue = new ProviderQueue({
270
+ concurrency: queueConcurrency,
271
+ interactiveConcurrency,
272
+ // Keyed to the engine gate's width (`batchMax`), not to the queue's
273
+ // deadlock reserve above — see `backgroundLaneCap`.
274
+ backgroundConcurrency: backgroundLaneCap(batchMax),
275
+ ...(opts.affinity !== undefined ? { affinity: opts.affinity } : {}),
276
+ // Single local GPU — same ambient admission control as llama-cpp:
277
+ // housekeeping turns wait for a quiet window so they never wedge
278
+ // the lane right before the user's next message.
279
+ ambientQuietMs: defaultAmbientQuietMs(),
280
+ // The sidecar parks background waves for a waiting person, so a
281
+ // person's turn may pass task steps that fill every slot — the
282
+ // engine gate has the matching overflow (engine-gate.ts).
283
+ engineOverflow: true,
284
+ });
285
+ }
286
+ /**
287
+ * Batch capability advertised by the wrapped MLX BatchGenerator.
288
+ * See {@link LLMProvider.batch}.
289
+ */
290
+ get batch() {
291
+ return { maxConcurrency: this.batchMaxConcurrency };
292
+ }
293
+ /** Can see images now, or after one reload into the vision tower. */
294
+ get supportsImageInput() {
295
+ return this.visionMode.capable;
296
+ }
297
+ /**
298
+ * Cache adapter (Phase 1+). Set by ChatManager after construction.
299
+ * Phase 2 wires the real `MlxCacheAdapter` that talks to the
300
+ * wrapped `gezel_mlx_server.py`. Sessions call its
301
+ * `buildRequestExtras` on every send to derive `cache_id`.
302
+ */
303
+ cacheAdapter = null;
304
+ setCacheAdapter(adapter) {
305
+ this.cacheAdapter = adapter;
306
+ }
307
+ getCacheAdapter() {
308
+ return this.cacheAdapter;
309
+ }
310
+ /**
311
+ * Current engine base URL — `null` when the supervised engine
312
+ * hasn't started yet. Cache adapter polls treat null as "not
313
+ * ready" and return empty stats.
314
+ */
315
+ currentBaseUrl() {
316
+ if (this.externalBaseUrl)
317
+ return this.externalBaseUrl;
318
+ return this.supervisor?.currentBaseUrl() ?? null;
319
+ }
320
+ lastTaggedPrefill = null;
321
+ /**
322
+ * The most recent prefill this engine ran for a DIFFERENT session, if it
323
+ * happened after `sinceMs`. Proof that the engine was alive and working —
324
+ * just not on the caller's turn.
325
+ */
326
+ prefillForOtherSessionSince(ownCacheId, sinceMs) {
327
+ const last = this.lastTaggedPrefill;
328
+ if (!last || last.at < sinceMs)
329
+ return null;
330
+ if (ownCacheId !== null && last.cacheId === ownCacheId)
331
+ return null;
332
+ return { detail: last.detail, at: last.at };
333
+ }
334
+ /** True only while this sidecar is serving or queuing a physical request. */
335
+ isEngineBusy() {
336
+ return this.engineGate.busy;
337
+ }
338
+ /** Hold startup demand through the first request, before the engine gate exists. */
339
+ async withEngineTurn(run, signal) {
340
+ if (!this.supervisor?.coordinatesCapacity || this.turnProtection.getStore())
341
+ return run();
342
+ return this.supervisor.withRequest(() => this.turnProtection.run(true, run), signal);
343
+ }
344
+ engineLifecycleSnapshot() {
345
+ return this.supervisor?.lifecycleSnapshot();
346
+ }
347
+ async acquireExclusiveEngineRequest(label, signal, onWait, capacityProtected = false, priority = 'interactive') {
348
+ if (signal?.aborted)
349
+ throw new DOMException(`MLX engine request ${label} aborted`, 'AbortError');
350
+ if (this.supervisor?.coordinatesCapacity && !capacityProtected)
351
+ await this.supervisor.yieldForWaitingCapacity(signal);
352
+ return this.engineGate.acquire(label, signal, onWait, priority);
353
+ }
354
+ /** Cache warms through the adapter: nobody is waiting on them, so they queue as background. */
355
+ async runExclusiveEngineRequest(label, work) {
356
+ const release = await this.acquireExclusiveEngineRequest(label, undefined, undefined, false, 'background');
357
+ try {
358
+ return await work();
359
+ }
360
+ finally {
361
+ release();
362
+ }
363
+ }
364
+ async initialize() {
365
+ // No-op — supervisor starts the child lazily on first sendAndWait.
366
+ }
367
+ getContextWindow() {
368
+ return this.numCtx;
369
+ }
370
+ /** See `LLMProvider.plannedReservationBytes`. */
371
+ plannedReservationBytes() {
372
+ return this.plannedReservation;
373
+ }
374
+ /**
375
+ * True once {@link shutdown} has been called. A session whose stream dies
376
+ * reads this to tell "Gezel stopped this engine" from "the engine died on
377
+ * its own" — the two need different messages, and the flag is set before
378
+ * the stop so the racing turn always sees it.
379
+ */
380
+ get isDisposed() {
381
+ return this.disposed;
382
+ }
383
+ async shutdown() {
384
+ // Poison FIRST so a turn racing the shutdown can't lazily respawn
385
+ // the engine after the stop below. The engine pool evicts replicas
386
+ // by calling `shutdown()`; a session still holding this instance
387
+ // must error out instead of resurrecting a zombie process outside
388
+ // the pool's capacity accounting.
389
+ this.disposed = true;
390
+ await this.supervisor?.stop();
391
+ }
392
+ async createSession(opts) {
393
+ if (this.disposed) {
394
+ throw new ProviderDisposedError('mlx');
395
+ }
396
+ const bridges = await createToolExecutor(opts, '[mlx]');
397
+ return new MlxSession({
398
+ resolveBaseUrl: () => this.resolveBaseUrl(),
399
+ markUsed: () => this.supervisor?.markUsed(),
400
+ fetchImpl: this.fetchImpl,
401
+ model: opts.model ?? this.defaultModel,
402
+ numCtx: this.numCtx,
403
+ systemMessage: opts.systemMessage,
404
+ ...(opts.systemPromptLayers ? { systemPromptLayers: opts.systemPromptLayers } : {}),
405
+ ...(opts.systemSharedPrefix ? { systemSharedPrefix: opts.systemSharedPrefix } : {}),
406
+ ...(opts.volatileContext ? { volatileContext: opts.volatileContext } : {}),
407
+ // Pass widened priorMessages through — the session translates
408
+ // tool-role entries into ChatMessage tool_calls / role:'tool'
409
+ // entries in its constructor.
410
+ priorMessages: opts.priorMessages ?? [],
411
+ bridges,
412
+ queue: this.queue,
413
+ provider: this,
414
+ ...(opts.externalTools && opts.externalTools.length > 0
415
+ ? { externalTools: opts.externalTools }
416
+ : {}),
417
+ ...(opts.debug ? { debug: opts.debug } : {}),
418
+ ...(opts.requestCompaction ? { requestCompaction: opts.requestCompaction } : {}),
419
+ ...(opts.profile ? { profile: opts.profile } : {}),
420
+ ...(this.templateOpensReasoning
421
+ ? { templateOpensReasoning: this.templateOpensReasoning }
422
+ : {}),
423
+ ...(opts.activeCraftbookStep ? { activeCraftbookStep: opts.activeCraftbookStep } : {}),
424
+ ...(opts.tuning ? { tuning: opts.tuning } : {}),
425
+ ...(opts.forceDirectFileWork ? { forceDirectFileWork: true } : {}),
426
+ ...(opts.singleToolCallTurn ? { singleToolCallTurn: true } : {}),
427
+ ...(opts.terminalToolPolicy ? { terminalToolPolicy: opts.terminalToolPolicy } : {}),
428
+ ...(opts.onWireTranscript ? { onWireTranscript: opts.onWireTranscript } : {}),
429
+ });
430
+ }
431
+ /** Classifier hook wired into the supervisor's `onRawLine`. */
432
+ onStdoutLine(line) {
433
+ this.logRouter.onLine(line);
434
+ }
435
+ /** See {@link EngineLogRouter.takeFatalError}. */
436
+ takeFatalError() {
437
+ return this.logRouter.takeFatalError();
438
+ }
439
+ /** See {@link EngineLogRouter.takeRuntimeError}. */
440
+ takeRuntimeError() {
441
+ return this.logRouter.takeRuntimeError();
442
+ }
443
+ _registerActiveSession(session) {
444
+ this.activeSessions.add(session);
445
+ // Catch a late joiner up on the engine-wide phase only. A tagged phase
446
+ // belongs to one session's prefill; replaying it here is how a fresh chat
447
+ // would inherit a neighbour's progress bar.
448
+ const pending = this.logRouter.lastPhase();
449
+ if (pending && !pending.cacheId)
450
+ session.publishEnginePhase(pending);
451
+ const stats = this.engineStats.latest;
452
+ if (stats)
453
+ session.publishEngineStats(stats);
454
+ }
455
+ _deregisterActiveSession(session) {
456
+ this.activeSessions.delete(session);
457
+ }
458
+ async listModels() {
459
+ if (this.modelManager) {
460
+ try {
461
+ const installed = await this.modelManager.listInstalled();
462
+ if (installed.length > 0) {
463
+ return installed.map((m) => {
464
+ const sizeGb = m.approxSizeBytes / (1024 * 1024 * 1024);
465
+ const sizeLabel = sizeGb >= 0.1 ? ` · ${sizeGb.toFixed(1)} GB` : '';
466
+ const ctxLabel = m.contextWindow ? ` · ${Math.round(m.contextWindow / 1024)}k ctx` : '';
467
+ return {
468
+ id: m.id,
469
+ name: `${m.name}${sizeLabel}${ctxLabel}`,
470
+ supportsTools: true,
471
+ };
472
+ });
473
+ }
474
+ }
475
+ catch {
476
+ // Fall through to the default-only entry below — model
477
+ // enumeration is a UI nicety, not load-bearing for chat.
478
+ }
479
+ }
480
+ return [
481
+ {
482
+ id: this.defaultModel,
483
+ name: this.defaultModel,
484
+ supportsTools: true,
485
+ },
486
+ ];
487
+ }
488
+ getEffectiveModelId() {
489
+ return this.catalogModelId;
490
+ }
491
+ async resolveBaseUrl() {
492
+ if (this.externalBaseUrl)
493
+ return this.externalBaseUrl;
494
+ if (this.disposed) {
495
+ throw new Error('[mlx] provider disposed (engine was evicted) — re-resolve it');
496
+ }
497
+ if (!this.supervisor)
498
+ throw new Error('[mlx] no base URL resolver configured');
499
+ const launch = await this.supervisor.ensureRunning();
500
+ return launch.baseUrl.replace(/\/+$/, '');
501
+ }
502
+ }
503
+ class MlxSession extends StreamingSessionBase {
504
+ deps;
505
+ get supportsImageInput() {
506
+ return this.deps.provider.supportsImageInput;
507
+ }
508
+ messages;
509
+ /**
510
+ * Direct provider callers and ephemeral one-shot work do not always have a
511
+ * persisted chat-session id to put in `opts.queue.sessionId`. The wrapped
512
+ * MLX server treats a missing `cache_id` as an instruction to clear the KV
513
+ * cache after every request, which makes every tool-loop continuation pay a
514
+ * full re-prefill. Keep a stable id for this MlxSession as the fallback;
515
+ * normal chats still override it with their durable Gezel session id.
516
+ */
517
+ fallbackCacheId = `mlx-session-${randomUUID()}`;
518
+ fallbackCacheUsed = false;
519
+ // Monotonic counter across `sendAndWait` calls on this session. Used as a
520
+ // tag in diagnostic logs so the per-turn lifecycle (queue dispatch → fetch
521
+ // → SSE start → SSE end) can be reassembled even when overlapping with
522
+ // other sessions in the daemon log. Helped diagnose mlx_vlm.server SSE
523
+ // termination issues where turn N completed cleanly but turn N+1 hung.
524
+ sendSeq = 0;
525
+ /** See LlamaCppSession.currentTurnStartIdx — same role here. */
526
+ currentTurnStartIdx = 0;
527
+ turnPolicy = new LocalTurnPolicy();
528
+ /**
529
+ * Aggregated `<think>` / `<reasoning>` text captured this turn,
530
+ * across every iteration of the tool loop. Reset at the top of each
531
+ * `sendAndWaitInner`. ChatManager reads it via
532
+ * `getLastTurnReasoning()` after the turn resolves and stashes the
533
+ * trace on the assistant message so the chat bubble can render it
534
+ * behind a collapsed expander.
535
+ */
536
+ lastTurnReasoning = '';
537
+ /**
538
+ * Tool-call bodies the salvage layer couldn't parse on the most
539
+ * recent turn after the retry budget exhausted. Captured at the
540
+ * budget-exhausted branch and read by ChatManager via
541
+ * `getLastTurnAttemptedToolCalls()` to stash on the assistant
542
+ * message for the debug bundle. Reset at the top of `sendAndWaitInner`.
543
+ */
544
+ lastTurnAttemptedToolCalls = [];
545
+ /**
546
+ * Watchdog reset hook owned by the in-flight `sendAndWaitInner`. Set
547
+ * just before the fetch fires; cleared in the turn's cleanup. While
548
+ * non-null, any engine-phase event arriving via `publishEnginePhase`
549
+ * counts as "the model is making progress" and resets the idle timer.
550
+ * Without this, large-prompt prefill (e.g. 30K tokens at 230 tok/s ≈
551
+ * 2 minutes) trips the pre-first-byte watchdog even though the
552
+ * python child is happily chunking through prefill — the SSE stream
553
+ * is silent until the very first generated token.
554
+ */
555
+ currentTurnIdleReset = null;
556
+ /**
557
+ * Handler owned by the in-flight request for the engine's wave-scheduling
558
+ * markers (`waiting` / `paused` / `admitted`). Set beside
559
+ * {@link currentTurnIdleReset} and cleared with it.
560
+ */
561
+ currentTurnEngineQueue = null;
562
+ engineHold = new EngineHold();
563
+ /**
564
+ * Last prefill phase event seen during the current turn — captured
565
+ * for the abort message so the user sees "aborted at prefill 47% /
566
+ * 14336 of 29930 tokens" instead of just "no first byte." Cleared
567
+ * at the start of every send.
568
+ */
569
+ lastPrefillEvent = null;
570
+ /**
571
+ * The `cache_id` this session sends to the engine. Set on every send, and
572
+ * the key the provider routes per-sub engine phases by — see
573
+ * {@link MlxSession.ownsCacheId}.
574
+ */
575
+ currentCacheId = null;
576
+ externalToolNames;
577
+ capturedCalls = [];
578
+ toolSurface;
579
+ /**
580
+ * Session-scoped quarantine for the assisted MTP route. Once an emitted
581
+ * Hermes call proves that decode-time required-field enforcement diverged,
582
+ * every later grammar-armed request from this live session uses ordinary
583
+ * sequential BatchGenerator decoding. The latch preserves speculation for
584
+ * healthy sessions while ensuring one bad call cannot become a five-retry
585
+ * validator loop.
586
+ */
587
+ forceSequentialToolGrammar = false;
588
+ constructor(deps) {
589
+ super();
590
+ this.deps = deps;
591
+ this.externalToolNames = new Set((deps.externalTools ?? []).map((t) => t.name));
592
+ this.messages = [{ role: 'system', content: deps.systemMessage }];
593
+ // Layered prefix caching (flag ON): the volatile band rides as a
594
+ // SEPARATE frozen system message after the stable system message, so
595
+ // the stable `[system][tools]` wire prefix stays reusable.
596
+ if (deps.volatileContext) {
597
+ this.messages.push({ role: 'system', content: deps.volatileContext });
598
+ }
599
+ for (const m of deps.priorMessages) {
600
+ if (m.role === 'tool') {
601
+ this.messages.push({
602
+ role: 'tool',
603
+ content: m.content,
604
+ tool_call_id: m.toolCallId,
605
+ });
606
+ continue;
607
+ }
608
+ if (m.role === 'assistant' && 'toolCalls' in m && m.toolCalls.length > 0) {
609
+ this.messages.push({
610
+ role: 'assistant',
611
+ content: m.content || null,
612
+ tool_calls: m.toolCalls.map((tc) => ({
613
+ id: tc.id,
614
+ type: 'function',
615
+ function: { name: tc.name, arguments: tc.arguments },
616
+ })),
617
+ });
618
+ continue;
619
+ }
620
+ if (m.role === 'user' && m.images?.length && !this.supportsImageInput) {
621
+ throw new Error('[Mac AI] This MLX engine cannot accept image history. Enable its vision tower.');
622
+ }
623
+ this.messages.push({
624
+ role: m.role,
625
+ content: m.content,
626
+ ...(m.role === 'user' && m.images?.length ? { images: m.images } : {}),
627
+ });
628
+ }
629
+ }
630
+ capturedToolCalls() {
631
+ return [...this.capturedCalls];
632
+ }
633
+ getWireTranscript() {
634
+ return mlxWireTranscript(this.messages);
635
+ }
636
+ getToolSurface() {
637
+ return this.toolSurface;
638
+ }
639
+ get numCtx() {
640
+ return this.deps.numCtx;
641
+ }
642
+ estimatePromptChars() {
643
+ let total = 0;
644
+ for (const m of this.messages) {
645
+ if (typeof m.content === 'string')
646
+ total += m.content.length;
647
+ // The sidecar bounds images to 1024px. Reserve patch tokens without
648
+ // counting base64 bytes as language tokens or treating pixels as free.
649
+ total += (m.images?.length ?? 0) * 8192;
650
+ if (m.tool_calls) {
651
+ for (const tc of m.tool_calls)
652
+ total += tc.function.arguments.length + tc.function.name.length;
653
+ }
654
+ }
655
+ if (!this.deps.bridges.isEmpty()) {
656
+ for (const t of this.deps.bridges.getOpenAITools()) {
657
+ total += JSON.stringify(t).length;
658
+ }
659
+ }
660
+ return total;
661
+ }
662
+ /**
663
+ * Prefill the exact standing `[system][tools][transcript]` prefix without
664
+ * appending a user turn or mutating this session. Used by the machine broker
665
+ * after a user daemon prepares `/v1/remote/cache/warm`.
666
+ */
667
+ async prefillOnly(opts) {
668
+ // Vision KV is not eligible for text-prefix warming or persistence.
669
+ if (this.messages.some((message) => message.images?.length))
670
+ return;
671
+ // Focusing a chat must not cold-start MLX. The real turn owns normal lazy
672
+ // startup; warming only accelerates an already-resident server.
673
+ const baseUrl = this.deps.provider.currentBaseUrl();
674
+ if (!baseUrl)
675
+ return;
676
+ // A warm whose roster differs from the real turn's is worse than no warm
677
+ // at all. Qwen-family templates render the tool block at the TOP of the
678
+ // system message, so a no-tools render shares 3 tokens with a 44-tool
679
+ // turn — the shared prefix it saves then forces a full re-prefill on
680
+ // every session that seeds from it, and the next warm overwrites the
681
+ // corrected prefix right back.
682
+ //
683
+ // Wild-caught (koray PR-review fanout): `prefix-0b60345fcefa9ffd`
684
+ // oscillated between a 15,563-token no-tools render and a 24,796-token
685
+ // real one, ~9.2k tokens apart, producing `lcp=3 lcp_frac=0.00` and
686
+ // `mode=fresh reused=0` on turn after turn — 40 full re-prefills and
687
+ // 1.58M tokens re-prefilled against 238k of new work.
688
+ //
689
+ // The bridges spawn lazily, so an early warm sees an empty roster. A
690
+ // cold prefix costs one slow turn; a WRONG one costs every turn. An
691
+ // genuinely tool-less session also reads as empty here and is skipped
692
+ // too — its prompt is small, so the warm it loses is worth little,
693
+ // and that is the safe direction to be wrong in.
694
+ if (this.deps.bridges.isEmpty() && (this.deps.externalTools ?? []).length === 0) {
695
+ log.debug('turn#warm skipped — no tool roster registered yet; warming now would save a no-tools prefix that cannot match a real turn');
696
+ return;
697
+ }
698
+ const bridgeTools = this.deps.bridges.isEmpty()
699
+ ? []
700
+ : toChatCompletionsTools(this.deps.bridges);
701
+ const externalAsChatCompletions = externalToolsAsChatCompletions(this.deps.externalTools);
702
+ const tools = [...bridgeTools, ...externalAsChatCompletions];
703
+ const body = {
704
+ model: this.deps.model,
705
+ messages: this.messages,
706
+ stream: false,
707
+ max_tokens: 1,
708
+ };
709
+ applyMlxRequestShape(body, this.deps, { hasTools: tools.length > 0 });
710
+ // A warm must remain a one-token prefill even when catalog tuning carries
711
+ // a wider output cap.
712
+ body.max_tokens = 1;
713
+ // Nobody reads a warm; it must never hold up a person's turn.
714
+ body.priority = 'background';
715
+ if (tools.length > 0)
716
+ body.tools = tools;
717
+ if (opts?.sessionId) {
718
+ const adapter = this.deps.provider.getCacheAdapter();
719
+ if (adapter) {
720
+ await adapter.prepareForSend(opts.sessionId, this.deps.systemMessage, this.deps.systemPromptLayers,
721
+ // Same roster the body above will actually send. Omitting it made
722
+ // this warm register a prefix identity that could not distinguish
723
+ // a no-tools render from the real turn's — the send path at the
724
+ // bottom of `send()` has always passed it; this one did not.
725
+ body.tools, this.deps.systemSharedPrefix);
726
+ Object.assign(body, adapter.buildRequestExtras(opts.sessionId));
727
+ }
728
+ }
729
+ const requestSignal = AbortSignal.timeout(opts?.timeoutMs ?? 10 * 60_000);
730
+ const releaseEngineRequest = await this.deps.provider.acquireExclusiveEngineRequest(`cache-warm:${opts?.sessionId ?? 'anonymous'}`, requestSignal, undefined, false, 'background');
731
+ try {
732
+ const res = await this.deps.fetchImpl(`${baseUrl}/v1/chat/completions`, {
733
+ method: 'POST',
734
+ headers: { 'Content-Type': 'application/json' },
735
+ body: JSON.stringify(body),
736
+ signal: requestSignal,
737
+ });
738
+ if (!res.ok) {
739
+ const detail = await res.text().catch(() => '');
740
+ throw new Error(`prefillOnly: HTTP ${res.status} ${detail.slice(0, 200)}`);
741
+ }
742
+ await res.text().catch(() => '');
743
+ }
744
+ finally {
745
+ releaseEngineRequest();
746
+ }
747
+ }
748
+ getLastTurnReasoning() {
749
+ return this.lastTurnReasoning.length > 0 ? this.lastTurnReasoning : undefined;
750
+ }
751
+ /**
752
+ * Running length of the same trace {@link getLastTurnReasoning}
753
+ * returns — read when a tool call fires so the persisted call can
754
+ * carry the offset it fired at.
755
+ */
756
+ getCurrentTurnReasoningLength() {
757
+ return this.lastTurnReasoning.length;
758
+ }
759
+ getLastTurnAttemptedToolCalls() {
760
+ return this.lastTurnAttemptedToolCalls.length > 0 ? this.lastTurnAttemptedToolCalls : undefined;
761
+ }
762
+ /**
763
+ * Whether a phase tagged with this engine cache id belongs to this session.
764
+ * The id is the one sent as `cache_id` on every request, so it is exactly
765
+ * what the sidecar stamps onto its per-sub prefill markers.
766
+ */
767
+ ownsCacheId(cacheId) {
768
+ return this.currentCacheId === cacheId;
769
+ }
770
+ publishEnginePhase(ev) {
771
+ if (ev.engineQueue) {
772
+ // Scheduling markers are the in-flight request's business alone: they
773
+ // re-arm its watchdog and relabel it. Outside a request there is
774
+ // nothing to wait on, so they are dropped.
775
+ this.currentTurnEngineQueue?.(ev.engineQueue);
776
+ return;
777
+ }
778
+ // Capture the latest prefill state so the pre-first-byte abort
779
+ // message (when it does fire) can attribute the stall to a real
780
+ // observed point in the prefill arc.
781
+ if (ev.phase === 'prefill' && typeof ev.progress === 'number') {
782
+ this.lastPrefillEvent = {
783
+ progress: ev.progress,
784
+ detail: ev.detail ?? '',
785
+ at: Date.now(),
786
+ };
787
+ }
788
+ // Reset the in-flight turn's idle watchdog. Any engine-phase event
789
+ // (prefill chunk, model-load tick, ready signal) is real evidence
790
+ // the python child is making progress, even though the SSE stream
791
+ // hasn't yielded its first token yet.
792
+ this.currentTurnIdleReset?.();
793
+ this.emitEnginePhase(ev);
794
+ }
795
+ publishEngineStats(ev) {
796
+ this.emitEngineStats(ev);
797
+ }
798
+ async maybeCompactMidLoop(opts) {
799
+ return compactLocalTurn(this.turnPolicy, {
800
+ messages: this.messages,
801
+ turnStart: this.currentTurnStartIdx,
802
+ estimatedTokens: Math.ceil(this.estimatePromptChars() / 4),
803
+ numCtx: this.deps.numCtx,
804
+ request: this.deps.requestCompaction,
805
+ replace: (start, count, content) => {
806
+ this.messages.splice(start, count, { role: 'assistant', content });
807
+ this.currentTurnStartIdx = start + 1;
808
+ },
809
+ warn: (error) => log.warn('Mid-loop compaction failed', { error: String(error) }),
810
+ completed: (removed) => this.emitWarning(autoCompactedNotice(removed, this.deps.numCtx)),
811
+ }, opts?.force);
812
+ }
813
+ get isDisposed() {
814
+ return this.deps.provider.isDisposed;
815
+ }
816
+ async sendAndWait(prompt, opts) {
817
+ this.lastTurnBail = null;
818
+ // Queue-bypass path: sync consultations spawned via ask_specialist /
819
+ // ask_gezel would otherwise deadlock behind the asker's held slot
820
+ // (the asker is parked in `bridges.callTool` waiting for the
821
+ // consultation's reply — chicken-and-egg). The wrapped Python
822
+ // server's BatchEngine and its memory-aware admission still enforce
823
+ // the configured generation width, so bypassing the TS-side FIFO only
824
+ // loses the queue's affinity scoring, not safety.
825
+ return runOnLiveProvider(this.deps.provider, opts, () => this.deps.provider.withEngineTurn(() => this.sendAndWaitInner(prompt, opts), opts?.queue?.signal));
826
+ }
827
+ async sendAndWaitInner(prompt, opts) {
828
+ // Fail fast if the supervisor already saw mlx_lm.server die at
829
+ // startup. The httpd process can keep returning 200 on /health
830
+ // after `_generate` raises, so without this check we'd spend the
831
+ // full turn timeout hanging on an unresponsive
832
+ // /v1/chat/completions endpoint with no user-facing signal.
833
+ const fatal = this.deps.provider.takeFatalError();
834
+ if (fatal) {
835
+ const hintSuffix = fatal.hint ? `\n\n${fatal.hint}` : '';
836
+ throw new Error(`[Mac AI] engine failed to start: ${fatal.message}${hintSuffix}`);
837
+ }
838
+ const totalTimeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
839
+ // Budget in awake time, not wall clock. A laptop that naps mid-turn used
840
+ // to burn the whole budget while frozen and die on the wake-up burst
841
+ // reporting a duration it never got — `afterMs=1002151` against a 180s
842
+ // budget, of which 822s were macOS dark-wake sleep. `AwakeBudget` holds
843
+ // still across a suspension and gives up on its own terms when one nap is
844
+ // long enough to mean the machine was put away for good.
845
+ const budget = new AwakeBudget(totalTimeoutMs);
846
+ const turnTimeoutError = () => new Error(`[Mac AI] timed out after ${Math.round(totalTimeoutMs / 1000)}s${budget.describeSuspension()}`);
847
+ const start = Date.now();
848
+ const seq = ++this.sendSeq;
849
+ const debugOn = this.deps.debug?.isEnabled() === true;
850
+ // Always-on lifecycle tag — low frequency, high signal when piecing
851
+ // together "first turn worked, second never returned". Includes the
852
+ // pre-existing message count so the sequence in the log unambiguously
853
+ // shows whether the prior assistant reply landed in `this.messages`
854
+ // before this turn started.
855
+ log.info(`turn#${seq} START prompt=${prompt.length}c msgs=${this.messages.length}`);
856
+ const continueFromToolResult = opts?.continueFromToolResult === true;
857
+ if (continueFromToolResult) {
858
+ if (prompt.length > 0 || (opts?.attachments?.length ?? 0) > 0) {
859
+ throw new Error('[Mac AI] a tool-result continuation cannot include a new prompt or attachments');
860
+ }
861
+ const last = this.messages.at(-1);
862
+ if (last?.role !== 'tool' && !(last?.role === 'user' && last.images?.length)) {
863
+ throw new Error('[Mac AI] a tool-result continuation requires a trailing tool result');
864
+ }
865
+ }
866
+ const userMsg = { role: 'user', content: prompt };
867
+ if (opts?.attachments && opts.attachments.length > 0) {
868
+ if (!this.supportsImageInput)
869
+ throw new Error('[Mac AI] This MLX runtime supports text only. Enable native vision for an installed vision checkpoint; no image was delivered to the model.');
870
+ userMsg.images = opts.attachments.map((a) => a.base64);
871
+ }
872
+ // Mark this turn's start so mid-loop compaction can split prior
873
+ // history (compactable) from in-flight tool loop (preserve verbatim).
874
+ // See LlamaCppSession.sendAndWaitInner for the rationale.
875
+ this.currentTurnStartIdx = this.messages.length;
876
+ this.turnPolicy = new LocalTurnPolicy(turnCheckpoint(opts?.queue?.signal, () => budget.expired(), turnTimeoutError));
877
+ this.turnPolicy.checkpoint();
878
+ // Reset captured reasoning at the top of each turn — manager
879
+ // reads `getLastTurnReasoning()` after this resolves.
880
+ this.lastTurnReasoning = '';
881
+ // Same lifecycle for the attempted-tool-calls capture: read once
882
+ // by manager after the turn resolves, populated only when the
883
+ // salvage retry budget exhausts below.
884
+ this.lastTurnAttemptedToolCalls = [];
885
+ if (!continueFromToolResult)
886
+ this.messages.push(userMsg);
887
+ // Reset captures — each sendAndWait surfaces only its own externals.
888
+ this.capturedCalls = [];
889
+ const bridgeTools = this.deps.bridges.isEmpty()
890
+ ? []
891
+ : toChatCompletionsTools(this.deps.bridges);
892
+ const externalAsChatCompletions = externalToolsAsChatCompletions(this.deps.externalTools);
893
+ const tools = bridgeTools.length + externalAsChatCompletions.length > 0
894
+ ? [...bridgeTools, ...externalAsChatCompletions]
895
+ : undefined;
896
+ this.toolSurface = toolSurfaceSize(tools ?? []);
897
+ // Names of tools the model actually sees this turn. This must be
898
+ // derived from the advertised request surface, not the raw bridge
899
+ // inventory: role filtering hides tools from builders, but the bridge
900
+ // can still call them internally. Salvage and execution must not
901
+ // promote hidden-tool prose into real side effects.
902
+ const advertisedBridgeToolNames = new Set(bridgeTools.map((t) => chatCompletionToolName(t)).filter((n) => !!n));
903
+ const knownToolNames = new Set((tools ?? []).map((t) => chatCompletionToolName(t)).filter((n) => !!n));
904
+ const requiresToolCall = this.deps.tuning?.toolChoice === 'required' && knownToolNames.size > 0;
905
+ // Tool-name → declared input schema, so salvaged calls can have
906
+ // structural arguments the markup formats flattened into strings
907
+ // reinterpreted before anything else reads them. The Hermes /
908
+ // Claude / GLM / XML shapes are all flat KEY→text maps and cannot
909
+ // carry a nested object or array; without this the model's correct
910
+ // JSON reaches the validator as `got string, expected object` and
911
+ // it loops re-emitting the same call. See tool-arg-schema-coercion.ts.
912
+ const toolArgSchemas = new Map();
913
+ // Param-name → owning-tool-names index, so an unknown-tool nudge can
914
+ // catch the model naming a PARAMETER as the function (e.g. `description`
915
+ // is an argument of `start_project`, not a tool). Built once per send.
916
+ const toolParamIndex = new Map();
917
+ for (const t of tools ?? []) {
918
+ const tname = chatCompletionToolName(t);
919
+ if (!tname)
920
+ continue;
921
+ const schema = t.function.parameters;
922
+ if (schema)
923
+ toolArgSchemas.set(tname, schema);
924
+ const props = schema?.properties;
925
+ if (!props)
926
+ continue;
927
+ for (const param of Object.keys(props)) {
928
+ const arr = toolParamIndex.get(param);
929
+ if (arr) {
930
+ if (!arr.includes(tname))
931
+ arr.push(tname);
932
+ }
933
+ else {
934
+ toolParamIndex.set(param, [tname]);
935
+ }
936
+ }
937
+ }
938
+ // Surgical edit tools on the roster ⇒ the deliverable is a modify of
939
+ // an existing file, so a repeated source-write failure should steer
940
+ // toward a targeted patch rather than "re-emit the whole file."
941
+ const surgicalEditsAvailable = knownToolNames.has('replace_in_file') ||
942
+ knownToolNames.has('insert_at_marker') ||
943
+ knownToolNames.has('replace_lines') ||
944
+ knownToolNames.has('append_to_file');
945
+ // Delegation tools on the roster ⇒ repeated edit failures can hand the
946
+ // file to a more capable model instead of thrashing to a plain abort.
947
+ const delegationAvailable = [...knownToolNames].some((n) => n.startsWith('delegate_'));
948
+ const writerFlags = {
949
+ artifactWriterAvailable: knownToolNames.has('write_artifact'),
950
+ workspaceWriterAvailable: knownToolNames.has('write_file'),
951
+ };
952
+ // Counter for the model self-correction loop: each malformed tool
953
+ // call we nudge the model about counts against this budget. Not
954
+ // reset between iterations — a turn with two consecutive bad calls
955
+ // burns through both retries instead of looping forever.
956
+ let malformedRetries = 0;
957
+ // Signature of the last unparseable/unknown tool-call we nudged about.
958
+ // If the model emits the IDENTICAL bad call again after a nudge, the
959
+ // nudge isn't landing — stop spending retries on a repeat (the actionable
960
+ // nudge already fired once) and end the turn instead of apology-looping.
961
+ let lastBadCallSig = null;
962
+ // Auto-continuation state for truncated immediate-writes. When an
963
+ // immediate-write `write_file` is EOS-flushed (file larger than the
964
+ // per-turn token cap), we keep the turn alive — surfacing
965
+ // `append_to_file` and looping — until the model writes the tail
966
+ // without truncating, or we hit MAX_IMMEDIATE_WRITE_CONTINUATIONS.
967
+ let repairReadCalls = 0;
968
+ const repairReadPaths = [];
969
+ const requiredInputReads = [];
970
+ const requiredInputs = [
971
+ ...(this.deps.activeCraftbookStep?.requiredInputs ?? []),
972
+ ...promptReadInputs(prompt),
973
+ ];
974
+ let requiredInputHoldLogged = false;
975
+ let repairFailedMutations = 0;
976
+ let repairMutationSucceeded = false;
977
+ const requiredReadPaths = opts?.fileTurnIntent?.kind === 'repair-file' && opts.fileTurnIntent.readPaths
978
+ ? opts.fileTurnIntent.readPaths
979
+ : extractPrerequisiteRepairReadPaths(prompt);
980
+ let writeContinuationActive = false;
981
+ let writeContinuations = 0;
982
+ // Per-tool consecutive-failure tracker. Catches the Qwen 3.6 27B
983
+ // pattern of emitting JSON-stringified args, hitting the same Zod
984
+ // validation error every iteration, "diagnosing" the schema in
985
+ // chat, and re-trying the same wrong shape forever. See the
986
+ // tracker's docstring for the threshold rationale.
987
+ const failureTracker = new ToolFailureTracker({
988
+ surgicalEditsAvailable,
989
+ delegationAvailable,
990
+ ...writerFlags,
991
+ });
992
+ // Per-turn same-(name, args) repeat tracker. Catches the
993
+ // "narrative spinning" loop where the model re-reads the same
994
+ // files (read_task_notes, read_file, etc.) iteration after
995
+ // iteration, narrating "let me plan" prose between each call,
996
+ // never reaching write_file. See ToolRepeatTracker docstring for
997
+ // the threshold rationale.
998
+ const repeatTracker = new ToolRepeatTracker();
999
+ const deliverableReadPaceTracker = DeliverableReadPaceTracker.fromUserText(prompt, opts?.fileTurnIntent);
1000
+ const deliverableReadySteer = DeliverableReadySteer.forStep(this.deps.activeCraftbookStep);
1001
+ // Per-turn `ask_user_question` guard. Once a question card lands
1002
+ // successfully, additional ask_user_question calls in the same
1003
+ // turn are intercepted with a synthetic "you already asked, end
1004
+ // your turn" response — without this, verbose-family models
1005
+ // double-post cards (the second one usually re-phrasing the
1006
+ // first; user gets a cluttered Home panel). The flag also
1007
+ // signals `foldPreToolPreamble` to fold trailing wrap-up prose
1008
+ // for the rest of the turn.
1009
+ let askedQuestionThisTurn = false;
1010
+ // Per-turn project-macro guard. `start_project` and `start_job`
1011
+ // are atomic high-stakes macros — each call creates a project +
1012
+ // voorman + kickoff task. Without this, a Gemma 4 cold-start
1013
+ // turn that emits multiple `<|tool_call|>start_project{...}<|/tool_call|>`
1014
+ // envelopes in one response (brainstorming variations of the
1015
+ // same idea) produces N duplicate projects. Wild-caught on a
1016
+ // clean install ("Can you create a space invaders
1017
+ // game?" → 5 projects with iterated names).
1018
+ let startedProjectOrJobThisTurn = null;
1019
+ // Count redundant macro intercepts within this turn. Once the
1020
+ // per-turn guard arms (after the first successful start_project /
1021
+ // start_job), every additional macro call gets intercepted with
1022
+ // a "STOP" message. Some local models (gemma4-26b on MLX, wild-caught)
1023
+ // misread that as "your call was
1024
+ // malformed, retry it" and keep emitting envelopes until
1025
+ // MAX_TOOL_LOOP_TURNS bails — minutes of blank bubble for the
1026
+ // user. After `PROJECT_MACRO_INTERCEPT_CAP` redundant attempts,
1027
+ // we force end-of-turn with a synthesized closing summary
1028
+ // derived from the first successful result.
1029
+ let projectMacroInterceptCount = 0;
1030
+ let forceProjectMacroBail = null;
1031
+ let fullText = '';
1032
+ let lastUsage = null;
1033
+ let firstTokenAt = null;
1034
+ let lastIterationStartedAt = start;
1035
+ let lastIterationFirstTokenAt = null;
1036
+ let lastIterationFinishedAt = null;
1037
+ let turnFailed = false;
1038
+ // Every successful terminal path must publish the same usage + turn
1039
+ // snapshot. Keeping this in the outer finally covers ordinary replies,
1040
+ // external tool capture, synthesized terminal closings, question cards,
1041
+ // and one-shot sessions without duplicating subtly different accounting
1042
+ // at each return site.
1043
+ const emitTerminalTelemetry = () => {
1044
+ const telemetry = buildMlxTerminalTelemetry({
1045
+ lastUsage,
1046
+ model: this.deps.model,
1047
+ start,
1048
+ lastIterationStartedAt,
1049
+ lastIterationFirstTokenAt,
1050
+ lastIterationFinishedAt,
1051
+ firstTokenAt,
1052
+ });
1053
+ if (!telemetry)
1054
+ return;
1055
+ this.emitUsage(telemetry.usage);
1056
+ this.emitTurnStats(telemetry.stats);
1057
+ };
1058
+ this.deps.provider._registerActiveSession(this);
1059
+ // Status heartbeat. The pre-first-token window — cache warm (prepareForSend)
1060
+ // plus prompt prefill — can run for minutes on a large prompt and emits no
1061
+ // stdout phases, so the engine pill would otherwise sit on a stale label
1062
+ // until "First token in …" finally lands. Re-emit a live, descriptive phase
1063
+ // every few seconds so the pill keeps a meaningful label (and its elapsed
1064
+ // clock) the whole time. Crucially this uses `emitEnginePhase` (UI bus only)
1065
+ // and NOT `publishEnginePhase`, so it does NOT reset the stall watchdog — a
1066
+ // genuinely hung engine still trips the idle abort.
1067
+ let prepLabel = 'Preparing…';
1068
+ this.lastPrefillEvent = null;
1069
+ this.engineHold.end();
1070
+ const emitPrep = () => {
1071
+ if (firstTokenAt !== null)
1072
+ return;
1073
+ // Held by the engine behind other work: say so, and show no bar —
1074
+ // nothing of THIS prompt is being processed.
1075
+ const holdLabel = this.engineHold.label;
1076
+ if (holdLabel) {
1077
+ this.emitEnginePhase({ provider: 'mlx', phase: 'prefill', detail: holdLabel });
1078
+ return;
1079
+ }
1080
+ // Carry the last observed prefill progress forward. A phase event
1081
+ // WITHOUT `progress` tells the UI the bar is over (the timeline's
1082
+ // engine_phase handler deletes `thinkingProgress` and its detail), so
1083
+ // a bare heartbeat landing between two `Prefill:` lines blinked the
1084
+ // bar and its token count out every 4s for the whole prefill — which
1085
+ // on a 61K-token prompt is minutes of flicker.
1086
+ const last = this.lastPrefillEvent;
1087
+ this.emitEnginePhase({
1088
+ provider: 'mlx',
1089
+ phase: 'prefill',
1090
+ detail: last?.detail || prepLabel,
1091
+ ...(last ? { progress: last.progress } : {}),
1092
+ });
1093
+ };
1094
+ const statusHeartbeat = setInterval(emitPrep, 4_000);
1095
+ emitPrep();
1096
+ // The physical request lease must outlive all per-iteration setup, but it
1097
+ // must never outlive this send. In particular, a fetch-level AbortError
1098
+ // happens before the SSE-body `finally`; keeping the active release here
1099
+ // lets the outer `finally` recover the slot from every exit path.
1100
+ let releaseActiveEngineRequest = null;
1101
+ // Cap-truncation steers spent this send. Bounded because the steer is
1102
+ // only worth issuing while the model can still act on it: past the
1103
+ // budget the honest outcome is the warning banner and an ended turn,
1104
+ // not another 6k-token attempt at the same over-cap payload.
1105
+ let capTruncationSteers = 0;
1106
+ try {
1107
+ for (let turn = 0; turn < MAX_TOOL_LOOP_TURNS; turn++) {
1108
+ this.turnPolicy.checkpoint();
1109
+ // Shared pressure check preserves all system bands and active tool results.
1110
+ await this.maybeCompactMidLoop();
1111
+ lastIterationStartedAt = Date.now();
1112
+ lastIterationFirstTokenAt = null;
1113
+ lastIterationFinishedAt = null;
1114
+ if (budget.expired())
1115
+ throw turnTimeoutError();
1116
+ // Before the URL: loading the vision tower restarts the engine.
1117
+ // A standalone turn's seed carries the whole state: earlier turns
1118
+ // stay in the transcript but not in the request.
1119
+ const transcript = opts?.standalone
1120
+ ? standaloneTurnMessages(this.messages, this.currentTurnStartIdx)
1121
+ : this.messages;
1122
+ const messages = await this.deps.provider.visionMode.prepareRequest(retireInspectedToolImages(transcript), opts?.standalone ? leadingSystemMessages(this.messages) : this.currentTurnStartIdx, opts?.queue?.signal);
1123
+ const baseUrl = await this.deps.resolveBaseUrl();
1124
+ const body = {
1125
+ model: this.deps.model,
1126
+ messages,
1127
+ stream: true,
1128
+ // Per-turn output cap. mlx-vlm's stream_generate defaults to
1129
+ // a small built-in cap (256 in some versions) — verbose
1130
+ // reasoning models burn through that on `<think>` blocks
1131
+ // alone and truncate mid-tool-call. 16 384 covers any
1132
+ // realistic chat turn; can be overridden per-model via
1133
+ // catalog `tuning.sampling.maxTokens`.
1134
+ max_tokens: 16_384,
1135
+ };
1136
+ applyMlxRequestShape(body, this.deps, { hasTools: Boolean(tools?.length) });
1137
+ // Engine-side scheduling: who is waiting on this turn. Omitted reads
1138
+ // as interactive at the sidecar, so only background is spelled out.
1139
+ const enginePriority = opts?.queue?.enginePriority ?? 'interactive';
1140
+ if (enginePriority === 'background')
1141
+ body.priority = 'background';
1142
+ // Continuation-iteration output cap — see SendAndWaitOpts.
1143
+ // Iteration 0 keeps the catalog cap so a tool call is never cut
1144
+ // off before it starts; wrap-up iterations get the tight cap.
1145
+ if (turn > 0 && opts?.continuationMaxTokens && opts.continuationMaxTokens > 0) {
1146
+ const current = typeof body.max_tokens === 'number' ? body.max_tokens : Number.POSITIVE_INFINITY;
1147
+ body.max_tokens = Math.min(current, opts.continuationMaxTokens);
1148
+ }
1149
+ if (this.forceSequentialToolGrammar && hermesRequiredArgGrammarRequested(body)) {
1150
+ body.disable_speculation = true;
1151
+ }
1152
+ // Tool surface actually sent this turn. Defaults to the full roster;
1153
+ // constrained turns narrow it below.
1154
+ let requestTools = tools;
1155
+ // A turn whose whole job is one call (a game reaction's move): the
1156
+ // first request offers that tool alone, without thinking, as on
1157
+ // llama-cpp. The sidecar has no forced tool choice; one tool on offer
1158
+ // leaves nothing else to reach for.
1159
+ if (turn === 0 && opts?.requiredTool) {
1160
+ const required = requiredToolOnly(tools, opts.requiredTool);
1161
+ if (required.length > 0) {
1162
+ requestTools = required;
1163
+ disableTemplateThinking(body);
1164
+ log.debug(`turn#${seq}.${turn} required-call turn: ${opts.requiredTool} only`);
1165
+ }
1166
+ }
1167
+ const fileTurnPlan = planFileTurn(prompt, tools, opts?.fileTurnIntent);
1168
+ const unreadInputs = unreadRequiredInputs(requiredInputs, requiredInputReads);
1169
+ const immediateFileWriteTurn = fileTurnPlan.kind === 'create-file' && unreadInputs.length === 0;
1170
+ if (fileTurnPlan.kind === 'create-file' &&
1171
+ unreadInputs.length > 0 &&
1172
+ !requiredInputHoldLogged) {
1173
+ requiredInputHoldLogged = true;
1174
+ log.info(`turn#${seq}.${turn} immediate-write held: input(s) unread (${unreadInputs.map((i) => i.path).join(', ')})`);
1175
+ }
1176
+ const fileRepairTurn = fileTurnPlan.kind === 'repair-file';
1177
+ const remainingReadPaths = fileRepairTurn
1178
+ ? remainingPrerequisiteRepairReadPaths(requiredReadPaths, repairReadPaths)
1179
+ : [];
1180
+ if (fileRepairTurn) {
1181
+ if (remainingReadPaths.length &&
1182
+ repairReadCalls > requiredReadPaths.length + LOCAL_TURN_LIMITS.noProgress) {
1183
+ throw new Error('File repair exceeded its bounded prerequisite read allowance.');
1184
+ }
1185
+ const rewrite = opts?.fileTurnIntent?.kind === 'repair-file' && opts.fileTurnIntent.strategy
1186
+ ? opts.fileTurnIntent.strategy === 'rewrite'
1187
+ : hasExplicitFullFileRewriteWording(prompt);
1188
+ requestTools = repairTools(tools, {
1189
+ readSucceeded: repairReadPaths.length > 0,
1190
+ source: isSourceFileRepairPrompt(prompt, opts?.fileTurnIntent),
1191
+ rewrite: rewrite || repairFailedMutations >= 2 || this.turnPolicy.count('noProgress') >= 2,
1192
+ prerequisiteReadsPending: remainingReadPaths.length > 0,
1193
+ });
1194
+ applyConstrainedTurnShape(body);
1195
+ }
1196
+ if (immediateFileWriteTurn) {
1197
+ if (typeof userMsg.content === 'string' &&
1198
+ !userMsg.content.includes('[Local-model rescue:')) {
1199
+ userMsg.content += IMMEDIATE_FILE_WRITE_PROMPT_SUFFIX;
1200
+ }
1201
+ const shaped = applyConstrainedTurnShape(body);
1202
+ // Narrow to write_file alone, matching llama-cpp. Without this the
1203
+ // model keeps the full roster on a turn whose only acceptable move
1204
+ // is the write, and wanders back into reads and delegation.
1205
+ const narrowed = writeFileOnlyTools(tools);
1206
+ if (narrowed.length > 0)
1207
+ requestTools = narrowed;
1208
+ // The switch alone is not enough. Qwen 3.8's HF template resolves
1209
+ // `reasoning_effort|default('xhigh')` inside its thinking branch, so
1210
+ // a turn that isn't fully suppressed reasons at the model's most
1211
+ // expensive setting and truncates the write_file body it was asked
1212
+ // for. llama-cpp has downgraded depth here for a while; MLX did not,
1213
+ // which is the whole of the measured engine divergence.
1214
+ log.debug(`turn#${seq}.${turn} immediate-write mode: write_file-only surface (${narrowed.length} tool), thinking disabled${shaped.reasoningDepthDowngraded.length > 0
1215
+ ? `, reasoning depth -> low (${shaped.reasoningDepthDowngraded.join(', ')})`
1216
+ : ''}, max_tokens=${body.max_tokens}`);
1217
+ }
1218
+ if (this.deps.forceDirectFileWork &&
1219
+ (typeof body.max_tokens !== 'number' || body.max_tokens < FILE_WRITE_MIN_TOKENS)) {
1220
+ body.max_tokens = FILE_WRITE_MIN_TOKENS;
1221
+ }
1222
+ if (requestTools && requestTools.length > 0)
1223
+ body.tools = requestTools;
1224
+ // Tool-schema size breakdown (opt-in: GEZEL_PROMPT_BREAKDOWN=1). The
1225
+ // companion to buildInstructions' system-text breakdown — the tool JSON
1226
+ // schemas are prefilled alongside the system prompt, so this shows the
1227
+ // other half of the per-turn token cost. Once per send (turn 0 only).
1228
+ if (turn === 0 && process.env.GEZEL_PROMPT_BREAKDOWN === '1' && tools && tools.length > 0) {
1229
+ const serialized = JSON.stringify(tools);
1230
+ const top = [...tools]
1231
+ .map((t) => ({ name: chatCompletionToolName(t) ?? '?', ch: JSON.stringify(t).length }))
1232
+ .sort((a, b) => b.ch - a.ch)
1233
+ .slice(0, 12)
1234
+ .map((t) => ` ${String(Math.round(t.ch / 4)).padStart(5)} tok ${String(t.ch).padStart(6)} ch ${t.name}`)
1235
+ .join('\n');
1236
+ log.info(`turn#${seq} [prompt-breakdown] tools: ${tools.length} defs · ` +
1237
+ `~${Math.round(serialized.length / 4)} tok (${serialized.length} ch). Largest:\n${top}`);
1238
+ }
1239
+ // Write-continuation: add `append_to_file` to the surface so the
1240
+ // model can finish a truncated file by appending its tail. Only
1241
+ // active after an immediate-write truncation; the base surface
1242
+ // stays write_file-only so the FIRST write is still focused.
1243
+ if (writeContinuationActive && Array.isArray(body.tools)) {
1244
+ if (!body.tools.some((t) => chatCompletionToolName(t) === 'append_to_file')) {
1245
+ body.tools = [...body.tools, APPEND_TO_FILE_CONTINUATION_TOOL];
1246
+ }
1247
+ }
1248
+ applyCallableToolGrammar(body, this.deps.bridges);
1249
+ // Cache reuse extras from the engine's adapter (Phase 2). Adds
1250
+ // `cache_id: <sessionId>` so our wrapped `gezel_mlx_server.py`
1251
+ // preserves and reuses the prompt cache across turns. Falls
1252
+ // back to no-extras (= upstream behavior, full re-prefill per
1253
+ // turn) when no adapter is wired (tests, no controller setup).
1254
+ // Phase 3 also hands the system prompt to the adapter so it can
1255
+ // register and warm a per-gezel prefix entry — first session
1256
+ // for a new gezel pays cold prefill, sibling sessions inherit
1257
+ // the warm prefix from disk on first turn.
1258
+ const adapter = this.deps.provider.getCacheAdapter();
1259
+ const durableCacheId = opts?.queue?.sessionId;
1260
+ const cacheId = durableCacheId ?? this.fallbackCacheId;
1261
+ // The sidecar stamps per-sub prefill markers with the request's
1262
+ // `cache_id`, so this is the key the provider routes them by. With no
1263
+ // adapter the id never reaches the engine and its markers arrive
1264
+ // untagged — which the fanout treats as engine-wide and broadcasts,
1265
+ // exactly as before.
1266
+ this.currentCacheId = cacheId;
1267
+ if (adapter) {
1268
+ if (!durableCacheId)
1269
+ this.fallbackCacheUsed = true;
1270
+ await adapter.prepareForSend(cacheId, this.deps.systemMessage, this.deps.systemPromptLayers,
1271
+ // The wire tools this turn will actually send. Warming without
1272
+ // them renders a prompt through a different template branch,
1273
+ // which on Qwen shares 3 tokens with the real turn.
1274
+ body.tools, this.deps.systemSharedPrefix);
1275
+ const extras = adapter.buildRequestExtras(cacheId);
1276
+ Object.assign(body, extras);
1277
+ // Diagnostic: one line per request so the next "Stream
1278
+ // finished, cleared cache" investigation is visible from
1279
+ // the TS side without grepping Python stdout. The Python
1280
+ // server prints `[cache] hit/miss cache_id=…` on its end;
1281
+ // cross-referencing both confirms the round-trip works.
1282
+ // Drop to `debug` once cache reuse is confirmed stable.
1283
+ if (turn === 0) {
1284
+ log.info(`turn#${seq} cache extras attached: cache_id=${String(extras.cache_id ?? '')} prefix_cache_id=${String(extras.prefix_cache_id ?? 'none')}`);
1285
+ }
1286
+ }
1287
+ else if (turn === 0) {
1288
+ // The OTHER side of the diagnostic: when cache extras are
1289
+ // skipped, log WHY so we can tell from one line that the adapter
1290
+ // is unwired. Missing queue.sessionId is safe: cacheId falls back
1291
+ // to a stable id owned by this MlxSession. An unwired adapter means
1292
+ // the Python server clears its cache on finish and the next turn
1293
+ // pays full re-prefill — exactly
1294
+ // the prefill-loop the user reported on gemma4-26b.
1295
+ log.warn(`turn#${seq} cache extras NOT attached (full re-prefill expected): ` +
1296
+ `adapter=null cacheId=${cacheId}`);
1297
+ }
1298
+ const externalSignal = opts?.queue?.signal;
1299
+ if (budget.expired())
1300
+ throw turnTimeoutError();
1301
+ // Sleep-aware: `AbortSignal.timeout` fires the instant the host
1302
+ // resumes, which would drop a queued turn that never had its slot.
1303
+ const engineDeadline = createAwakeTimeout(budget.remainingMs());
1304
+ const engineDeadlineSignal = engineDeadline.signal;
1305
+ const engineWaitSignal = externalSignal
1306
+ ? AbortSignal.any([externalSignal, engineDeadlineSignal])
1307
+ : engineDeadlineSignal;
1308
+ let releaseEngineRequest;
1309
+ try {
1310
+ releaseEngineRequest = await this.deps.provider.acquireExclusiveEngineRequest(`turn#${seq}.${turn}`, engineWaitSignal,
1311
+ // To the user, waiting for the queue and waiting for the engine
1312
+ // gate are one state: this turn has not started yet.
1313
+ opts?.queue?.onQueueWait, true, enginePriority);
1314
+ }
1315
+ catch (err) {
1316
+ if (err.name === 'AbortError') {
1317
+ if (externalSignal?.aborted) {
1318
+ throw new Error(turnCancelledMessage());
1319
+ }
1320
+ if (engineDeadlineSignal.aborted)
1321
+ throw turnTimeoutError();
1322
+ }
1323
+ throw err;
1324
+ }
1325
+ finally {
1326
+ // The signal only guards the wait for a slot; the turn's own ticker
1327
+ // owns the deadline from here.
1328
+ engineDeadline.dispose();
1329
+ }
1330
+ let engineRequestReleased = false;
1331
+ const releaseEngineRequestOnce = () => {
1332
+ if (engineRequestReleased)
1333
+ return;
1334
+ engineRequestReleased = true;
1335
+ releaseActiveEngineRequest = null;
1336
+ releaseEngineRequest();
1337
+ };
1338
+ releaseActiveEngineRequest = releaseEngineRequestOnce;
1339
+ if (budget.expired()) {
1340
+ releaseEngineRequestOnce();
1341
+ throw turnTimeoutError();
1342
+ }
1343
+ const ctrl = new AbortController();
1344
+ // Tag which trigger fired the abort — distinguishes the
1345
+ // hard-timer vs caller-cancel paths in the log so a hang
1346
+ // diagnosed via "turn#N FETCH-ABORT timer" reads unambiguously
1347
+ // as a model timeout rather than a user-driven cancel.
1348
+ let abortReason = null;
1349
+ const onExternalAbort = () => {
1350
+ abortReason ??= 'caller';
1351
+ ctrl.abort();
1352
+ };
1353
+ if (externalSignal) {
1354
+ if (externalSignal.aborted)
1355
+ onExternalAbort();
1356
+ else
1357
+ externalSignal.addEventListener('abort', onExternalAbort, { once: true });
1358
+ }
1359
+ // Deadline poll rather than a single armed timer. A `setTimeout`
1360
+ // armed before a suspension fires the instant the host resumes, so
1361
+ // the turn dies on the wake-up burst; polling re-reads the awake
1362
+ // budget each tick, so a nap defers the abort instead of causing it.
1363
+ // Drift is bounded by the poll interval, never by the sleep duration.
1364
+ // `budget.expired()` also covers the give-up case — one nap long
1365
+ // enough that the engine's SSE stream is not worth waiting on.
1366
+ const deadlineTicker = setInterval(() => {
1367
+ if (!budget.expired())
1368
+ return;
1369
+ abortReason ??= 'timer';
1370
+ log.error(`turn#${seq}.${turn} ABORT-FIRED reason=timer afterMs=${Date.now() - start} ` +
1371
+ `awakeMs=${Math.round(totalTimeoutMs - budget.remainingMs())} suspendedMs=${budget.suspendedMs()}`);
1372
+ ctrl.abort();
1373
+ clearInterval(deadlineTicker);
1374
+ }, 2_000);
1375
+ // Stream-idle watchdog. mlx_vlm.server has a known SSE
1376
+ // termination quirk where it stops emitting tokens mid-
1377
+ // stream without sending `[DONE]` and without closing the
1378
+ // connection — the for-await loop just hangs forever.
1379
+ // Without this watchdog the UI shows "still working" until
1380
+ // the hard deadline (3 min default) fires. The idle timer
1381
+ // anchors on every SSE event arrival; if the stream is
1382
+ // silent for `streamingIdleMs`, abort and let the catch
1383
+ // path surface what we have so far + a clear message.
1384
+ //
1385
+ // Pre-first-byte uses a longer budget because cold-start
1386
+ // model load + prefill on a 27B Qwen can legitimately take
1387
+ // minutes before the first token. The 180s budget covers a
1388
+ // cold model load before chunked prefill begins emitting
1389
+ // progress events; once `Prefill: …` lines start flowing,
1390
+ // `currentTurnIdleReset` (wired from `publishEnginePhase`)
1391
+ // anchors the watchdog on every prefill chunk so the timer
1392
+ // tracks real progress instead of wall-clock seconds.
1393
+ // Once tokens are flowing, a much tighter budget detects
1394
+ // mid-stream stalls without false-positive aborts on healthy
1395
+ // generation gaps.
1396
+ // Idle-watchdog budgets. Aligned with ollama / llama-cpp's
1397
+ // 300s defaults — earlier MLX-specific tighter values
1398
+ // (60s streaming, 180s pre-first-byte) were killing legitimate
1399
+ // 26B+ Gemma / Qwen turns that go silent for 60–120s while
1400
+ // doing heavy reasoning between tokens or hitting a slow
1401
+ // generation patch. The 30-min hard turn deadline from the
1402
+ // manager still bounds total runtime.
1403
+ //
1404
+ // - PRE_FIRST_BYTE_IDLE_MS: cold-start budget before any
1405
+ // SSE / progress event arrives. Covers model load +
1406
+ // prompt prefill on a 26B model at heavy quant. Now SCALED
1407
+ // by prompt size (computePreFirstByteBudgetMs) — a 37K-token
1408
+ // turn on a 27B-q8 needs far longer to first token than a 2K
1409
+ // "hello", and the old flat 300s aborted the big ones
1410
+ // mid-prefill (the qwen3.6-27b voorman stall).
1411
+ // - PRE_FIRST_BYTE_PER_PROGRESS_MS: budget to re-arm after a
1412
+ // prefill event arrives pre-first-byte. Unified to the SAME
1413
+ // size-scaled value: the serial path emits tqdm chunks every
1414
+ // couple seconds (each just re-arms the generous bound — fine),
1415
+ // while the batched path emits only a single "prefilling N
1416
+ // tokens" start marker and then blocks the event loop through
1417
+ // the whole prefill, so a tight per-chunk timer would
1418
+ // false-abort it. One generous bound serves both.
1419
+ // - STREAMING_IDLE_MS: budget between two consecutive SSE
1420
+ // content events once tokens are flowing. Generous enough
1421
+ // to cover a heavy reasoning gap on a 26B model.
1422
+ const approxPromptTokens = estimatePromptTokens(this.messages, body.tools);
1423
+ const PRE_FIRST_BYTE_IDLE_MS = computePreFirstByteBudgetMs(approxPromptTokens);
1424
+ const PRE_FIRST_BYTE_PER_PROGRESS_MS = PRE_FIRST_BYTE_IDLE_MS;
1425
+ const STREAMING_IDLE_MS = 300_000;
1426
+ if (turn === 0 || PRE_FIRST_BYTE_IDLE_MS > PRE_FIRST_BYTE_BASE_MS) {
1427
+ log.info(`turn#${seq}.${turn} pre-first-byte budget ${Math.round(PRE_FIRST_BYTE_IDLE_MS / 1000)}s ` +
1428
+ `(~${approxPromptTokens.toLocaleString('en-US')} prompt tokens)`);
1429
+ }
1430
+ let idleAbortKind = null;
1431
+ let lastSseEventAt = null;
1432
+ let firstSseEventAt = null;
1433
+ let idleTimer = setTimeout(() => {
1434
+ idleAbortKind = 'pre-first-byte';
1435
+ abortReason ??= 'idle';
1436
+ log.error(`turn#${seq}.${turn} ABORT-FIRED reason=idle (pre-first-byte) afterMs=${Date.now() - start}`);
1437
+ ctrl.abort();
1438
+ }, PRE_FIRST_BYTE_IDLE_MS);
1439
+ const armStreamingIdle = () => {
1440
+ clearTimeout(idleTimer);
1441
+ idleTimer = setTimeout(() => {
1442
+ idleAbortKind = 'streaming';
1443
+ abortReason ??= 'idle';
1444
+ log.error(`turn#${seq}.${turn} ABORT-FIRED reason=idle (streaming) afterMs=${Date.now() - start}`);
1445
+ ctrl.abort();
1446
+ }, STREAMING_IDLE_MS);
1447
+ };
1448
+ const resetIdle = () => {
1449
+ if (firstSseEventAt === null)
1450
+ firstSseEventAt = Date.now();
1451
+ lastSseEventAt = Date.now();
1452
+ this.engineHold.end();
1453
+ armStreamingIdle();
1454
+ };
1455
+ // Pre-first-byte progress reset — fires on prefill events
1456
+ // (and any other engine phase) arriving via the supervisor's
1457
+ // stdout fanout. Uses a tighter per-progress budget than the
1458
+ // initial cold-start budget: once we've seen one prefill
1459
+ // chunk, we know the engine is alive; the next chunk is
1460
+ // expected within ~PER_PROGRESS_MS at typical 200–250 tok/s
1461
+ // for 2K-token batches.
1462
+ const resetIdleFromPhaseEvent = () => {
1463
+ // Only meaningful before the first SSE byte. Once tokens
1464
+ // flow, `resetIdle()` (called inside the SSE loop) takes
1465
+ // over with the tighter STREAMING_IDLE_MS budget.
1466
+ if (firstSseEventAt !== null)
1467
+ return;
1468
+ clearTimeout(idleTimer);
1469
+ idleTimer = setTimeout(() => {
1470
+ idleAbortKind = 'pre-first-byte';
1471
+ abortReason ??= 'idle';
1472
+ log.error(`turn#${seq}.${turn} ABORT-FIRED reason=idle (pre-first-byte, after progress) afterMs=${Date.now() - start}`);
1473
+ ctrl.abort();
1474
+ }, PRE_FIRST_BYTE_PER_PROGRESS_MS);
1475
+ };
1476
+ this.currentTurnIdleReset = resetIdleFromPhaseEvent;
1477
+ this.lastPrefillEvent = null;
1478
+ // Engine-side holds (see engine-hold.ts): every `waiting` / `paused`
1479
+ // marker proves the engine is alive and busy, so it re-arms whichever
1480
+ // idle bound is live. The turn's own deadline (`budget`) still bounds
1481
+ // the whole wait.
1482
+ this.engineHold.end();
1483
+ this.currentTurnEngineQueue = (queue) => {
1484
+ if (queue.state === 'admitted') {
1485
+ if (this.engineHold.label === null)
1486
+ return;
1487
+ this.engineHold.end();
1488
+ if (firstSseEventAt === null)
1489
+ emitPrep();
1490
+ return;
1491
+ }
1492
+ if (firstSseEventAt === null)
1493
+ resetIdleFromPhaseEvent();
1494
+ else
1495
+ armStreamingIdle();
1496
+ if (firstSseEventAt !== null) {
1497
+ // Paused mid-reply: the stream stops until the chat ahead is
1498
+ // served. The reply so far stays on screen; the pill says why.
1499
+ const detail = engineHoldLabelFor(queue);
1500
+ this.emitEnginePhase({ provider: 'mlx', phase: 'generating', detail });
1501
+ return;
1502
+ }
1503
+ const detail = this.engineHold.begin(queue, opts?.queue?.onQueueWait);
1504
+ this.emitEnginePhase({ provider: 'mlx', phase: 'prefill', detail });
1505
+ };
1506
+ // Anchored at request dispatch: only work the engine did while THIS
1507
+ // turn was already waiting can explain why it never got a first byte.
1508
+ const requestDispatchedAt = Date.now();
1509
+ const busyElsewhere = () => {
1510
+ const other = this.deps.provider.prefillForOtherSessionSince(this.currentCacheId ?? null, requestDispatchedAt);
1511
+ if (!other)
1512
+ return null;
1513
+ return {
1514
+ detail: other.detail,
1515
+ secondsAgo: Math.max(0, Math.round((Date.now() - other.at) / 1000)),
1516
+ };
1517
+ };
1518
+ const cleanupTurn = () => {
1519
+ clearInterval(deadlineTicker);
1520
+ clearTimeout(idleTimer);
1521
+ this.engineHold.end();
1522
+ this.currentTurnEngineQueue = null;
1523
+ this.currentTurnIdleReset = null;
1524
+ releaseEngineRequestOnce();
1525
+ externalSignal?.removeEventListener('abort', onExternalAbort);
1526
+ };
1527
+ // Checkpoint the exact transcript this request sends, so a restart
1528
+ // can reseed the session with the prompt the engine is about to cache
1529
+ // instead of a lossy rebuild that matches only its system head. Taken
1530
+ // here, after every in-iteration edit — the immediate-write suffix
1531
+ // lands on the user message late.
1532
+ if (this.deps.onWireTranscript) {
1533
+ const transcript = mlxWireTranscript(messages);
1534
+ if (transcript)
1535
+ this.deps.onWireTranscript(transcript);
1536
+ }
1537
+ if (debugOn) {
1538
+ log.debug(`turn#${seq}.${turn} FETCH baseUrl=${baseUrl} model=${this.deps.model} ` +
1539
+ `bodyMsgs=${this.messages.length} tools=${tools?.length ?? 0}`);
1540
+ }
1541
+ let res;
1542
+ try {
1543
+ res = await this.deps.fetchImpl(`${baseUrl}/v1/chat/completions`, {
1544
+ method: 'POST',
1545
+ headers: { 'Content-Type': 'application/json' },
1546
+ body: JSON.stringify(body),
1547
+ signal: ctrl.signal,
1548
+ });
1549
+ }
1550
+ catch (err) {
1551
+ cleanupTurn();
1552
+ this.deps.markUsed();
1553
+ log.error(`turn#${seq}.${turn} FETCH-ERR ${err.name}: ${err instanceof Error ? err.message : String(err)}`);
1554
+ if (err.name === 'AbortError') {
1555
+ if (externalSignal?.aborted) {
1556
+ throw new Error(turnCancelledMessage());
1557
+ }
1558
+ if (abortReason === 'idle') {
1559
+ // Idle-fired during the fetch itself (no response body yet).
1560
+ // If we observed prefill chunks, attribute the stall to the
1561
+ // last seen progress point — much more useful than "no
1562
+ // first byte" when the model was clearly working.
1563
+ throw new Error(buildPreFirstByteAbortMessage(this.lastPrefillEvent, busyElsewhere()));
1564
+ }
1565
+ throw turnTimeoutError();
1566
+ }
1567
+ throw new Error(`[Mac AI] couldn't reach the engine at ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
1568
+ }
1569
+ if (!res.ok || !res.body) {
1570
+ cleanupTurn();
1571
+ this.deps.markUsed();
1572
+ const txt = await res.text().catch(() => '');
1573
+ log.error(`turn#${seq}.${turn} HTTP-ERR status=${res.status} ${res.statusText}`);
1574
+ throw new Error(translateMlxHttpError(res.status, res.statusText, txt));
1575
+ }
1576
+ if (debugOn) {
1577
+ log.debug(`turn#${seq}.${turn} HTTP-OK status=${res.status} ` +
1578
+ `headers={content-type:${res.headers.get('content-type') ?? '?'}}`);
1579
+ }
1580
+ if (firstTokenAt === null) {
1581
+ // The request is in; we're now prefilling the prompt. Switch the
1582
+ // heartbeat label and emit once immediately so the pill updates
1583
+ // without waiting for the next heartbeat tick.
1584
+ prepLabel = 'Processing prompt';
1585
+ emitPrep();
1586
+ }
1587
+ let turnContent = '';
1588
+ let finishReason = null;
1589
+ const toolCallAccumulator = new MlxToolCallAccumulator();
1590
+ // Code-block salvage (Phase: gemma-26b orchestration). When the
1591
+ // ramble detector aborts a turn with NO recognizable tool-call
1592
+ // markup but the buffered prose DOES contain a fenced code
1593
+ // block (the "Here's the file: ```html …```" pattern small
1594
+ // models drift into instead of calling write_file), we promote
1595
+ // each block to a synthesized `write_artifact` call. The
1596
+ // accumulator below lives outside the try/catch so the post-
1597
+ // exit merge can include it alongside the other salvage
1598
+ // sources.
1599
+ let codeBlockRepaired = [];
1600
+ // Wall-of-prose detector. Verbose-family models (Qwen 3.6
1601
+ // 27B at heavy quant in particular) emit a tool-call markup
1602
+ // block early in the turn, then continue narrating for
1603
+ // thousands of characters about what they "would" do next —
1604
+ // the failure mode the about.md "stuck planning" rule warns
1605
+ // about. The original boolean version of this detector
1606
+ // disarmed permanently after the first tool-call signal
1607
+ // and missed exactly that pattern; `RambleDetector` measures
1608
+ // prose-since-last-action so the cap re-arms after every
1609
+ // markup block.
1610
+ //
1611
+ // Threshold: 6000 chars (~1500 tokens) of prose past the
1612
+ // last action. Generous enough that legitimate medium-length
1613
+ // tail explanations pass through; tight enough to catch the
1614
+ // "wrote one tool then rambled" wall before the user has
1615
+ // waited 90+ seconds. Gated on profile opt-in via
1616
+ // `turn.ramble-detection`; per-model thresholds come from
1617
+ // the validated config. Absent → detector disabled.
1618
+ //
1619
+ // Read from the request, not only the template: a thinking-off
1620
+ // request renders the block already closed, and seeding from the
1621
+ // template streamed a whole `write_file` call into the reasoning pane.
1622
+ const opensInReasoning = requestOpensReasoning(this.deps.templateOpensReasoning, body);
1623
+ const rambleConfig = profileBehaviorConfig(this.deps.profile, 'turn.ramble-detection');
1624
+ const ramble = outputIsConstrained(this.deps.tuning)
1625
+ ? inertRambleDetector()
1626
+ : rambleConfig
1627
+ ? new RambleDetector({
1628
+ threshold: rambleConfig.coldThreshold,
1629
+ postActionThreshold: rambleConfig.postActionThreshold,
1630
+ enabled: true,
1631
+ // Models that leak untagged reasoning narrate their plan
1632
+ // in the open before acting; give the cold cap room to
1633
+ // reach the tool call. Prefer the profile opt-in, but also
1634
+ // honor the model-id family signal so a verbose family whose
1635
+ // profile is missing `turn.preamble-folding` (config drift)
1636
+ // still gets the leaky budget instead of the tight 6k cap.
1637
+ leakyReasoning: profileHasBehavior(this.deps.profile, 'turn.preamble-folding') ||
1638
+ leaksUntaggedReasoning(this.deps.model),
1639
+ // MLX inlines chain-of-thought in the content deltas, and
1640
+ // a template that opens `<think>` itself never shows the
1641
+ // detector an open marker — so without this the whole
1642
+ // reasoning block is scored as cold prose. Same flag the
1643
+ // StreamingReasoningSplit below is seeded with.
1644
+ opensInReasoning,
1645
+ })
1646
+ : // Repetition guard is safe on any local model (fires only on
1647
+ // degenerate low-novelty loops); arm it even without the
1648
+ // length-cap opt-in. See RambleDetector. Still needs the
1649
+ // reasoning-open seed: the guard exempts reasoning spans, and
1650
+ // a template-opened `<think>` is one.
1651
+ new RambleDetector({
1652
+ threshold: 6000,
1653
+ enabled: false,
1654
+ repetitionGuardEnabled: true,
1655
+ opensInReasoning,
1656
+ });
1657
+ let rambleAborted = false;
1658
+ // Single-call turn: stop the stream once the one usable call is
1659
+ // complete in the VISIBLE text. Reasoning is excluded so a call the
1660
+ // model drafts while thinking can't end the turn early.
1661
+ let singleCallComplete = false;
1662
+ let singleCallVisible = '';
1663
+ // Tool name for the live tool-args channel — only the first
1664
+ // fragment of a streamed tool call carries `function.name`.
1665
+ let liveToolArgsName = '';
1666
+ // Drop chat-template tool-call markers (Gemma 3+ leaks
1667
+ // `<|tool_call|>...<tool_call|>` token text into the content
1668
+ // stream even though it ALSO ships a structured `tool_calls`
1669
+ // delta — the structured one is what the UI renders, the text
1670
+ // version is just noise). Streaming-safe: holds back fragments
1671
+ // that could be a partial marker prefix until disambiguated.
1672
+ const stripper = new LeakyToolCallStripper();
1673
+ // MLX inlines chain-of-thought in the same content deltas as the
1674
+ // reply; only the end-of-turn `extractReasoningWithProfile` pass below
1675
+ // separates them. Everything reading live deltas — the chat bubble and
1676
+ // every connected app through the OpenAI-compatible facade — would
1677
+ // otherwise render thinking as the answer. This is the dedicated
1678
+ // reasoning channel llama.cpp and ds4 get from their engines.
1679
+ const reasoningSplit = new StreamingReasoningSplit({
1680
+ opensInReasoning,
1681
+ enabled: this.deps.profile?.style.reasoningFormat !== 'none',
1682
+ });
1683
+ // Throttle live phase emissions during generation so we don't
1684
+ // saturate the SSE fanout with one phase event per token —
1685
+ // ~3 Hz is fast enough that the tok/s readout feels live, slow
1686
+ // enough that the UI's React reconciler doesn't choke on a
1687
+ // 30-tok/s burst from a small model.
1688
+ let lastPhaseAt = 0;
1689
+ const PHASE_EMIT_MIN_INTERVAL_MS = 300;
1690
+ // Stream-active heartbeat to the daemon LOG (distinct from the UI-bus
1691
+ // phase emits above, which never reach stdout). The eval harness's
1692
+ // soft-progress watchdog digests daemon.log; with no periodic line
1693
+ // during a long MLX decode it reads active generation as a stall and
1694
+ // false-fails big models mid-first-turn. Mirror the llama-cpp
1695
+ // provider's `[<engine>] stream-active` pulse so the watchdog sees the
1696
+ // engine is alive. ~5s cadence keeps the log quiet.
1697
+ let lastStreamPulseAt = 0;
1698
+ const STREAM_PULSE_INTERVAL_MS = 5_000;
1699
+ // SSE-loop exit attribution. The loop can leave four ways:
1700
+ // explicit `[DONE]` marker (healthy), natural stream end with no
1701
+ // `[DONE]` (mlx_vlm.server bug — server closed before sentinel),
1702
+ // exception (caught below), or it never exits (caller sees the
1703
+ // hardTimer abort fire and the catch turns it into AbortError).
1704
+ // The terminator is logged unconditionally on the way out so the
1705
+ // smoking gun for "second turn hangs" shows up in regular logs.
1706
+ let sseChunks = 0;
1707
+ let sseExitReason = 'stream-end';
1708
+ try {
1709
+ for await (const event of readSseEvents(res.body)) {
1710
+ // Anchor the idle watchdog on every event — content
1711
+ // chunks AND framing chunks (usage updates, empty
1712
+ // deltas) all count as "stream is alive."
1713
+ resetIdle();
1714
+ sseChunks++;
1715
+ if (event === '[DONE]') {
1716
+ sseExitReason = 'done';
1717
+ break;
1718
+ }
1719
+ const chunk = event;
1720
+ const choice = chunk.choices?.[0];
1721
+ const delta = choice?.delta;
1722
+ const hasContent = Boolean(delta?.content);
1723
+ const hasToolCalls = Boolean(delta?.tool_calls && delta.tool_calls.length > 0);
1724
+ // mlx_vlm.server emits Responses-API field names; older
1725
+ // OpenAI-compatible servers use the Chat-Completions ones.
1726
+ // Read both so this code works against either shape.
1727
+ const usage = chunk.usage;
1728
+ const promptTokens = usage?.input_tokens ?? usage?.prompt_tokens;
1729
+ const completionTokens = usage?.output_tokens ?? usage?.completion_tokens;
1730
+ const promptTps = usage?.prompt_tps;
1731
+ const generationTps = usage?.generation_tps;
1732
+ const cachedTokens = usage?.cached_tokens;
1733
+ // A structured-tool response may never emit visible `content`.
1734
+ // Count its first argument fragment as model activity so TTFT is
1735
+ // still observable and the engine phase advances to generating.
1736
+ if ((hasContent || hasToolCalls) && lastIterationFirstTokenAt === null) {
1737
+ lastIterationFirstTokenAt = Date.now();
1738
+ }
1739
+ if ((hasContent || hasToolCalls) && firstTokenAt === null) {
1740
+ firstTokenAt = lastIterationFirstTokenAt ?? Date.now();
1741
+ const ttft = firstTokenAt - start;
1742
+ log.info(`TTFT ${ttft}ms (session model=${this.deps.model})`);
1743
+ // First-token detail folds in the prefill speed when
1744
+ // the engine surfaces it. Prefill happens entirely
1745
+ // inside `generate_step`, so this is the first chance
1746
+ // we get to put a number on it.
1747
+ const prefillSuffix = promptTps && promptTps > 0 ? ` · prefill ${formatTps(promptTps)} tok/s` : '';
1748
+ this.emitEnginePhase({
1749
+ provider: 'mlx',
1750
+ phase: 'generating',
1751
+ detail: `First token in ${(ttft / 1000).toFixed(1)}s${prefillSuffix}`,
1752
+ ttftMs: ttft,
1753
+ });
1754
+ lastPhaseAt = firstTokenAt;
1755
+ }
1756
+ if ((hasContent || hasToolCalls) && firstTokenAt !== null) {
1757
+ const now = Date.now();
1758
+ if (now - lastPhaseAt >= PHASE_EMIT_MIN_INTERVAL_MS) {
1759
+ this.emitEnginePhase({
1760
+ provider: 'mlx',
1761
+ phase: 'generating',
1762
+ // No `detail`: the counters are the payload, and the
1763
+ // phase's own copy belongs to the UI. Composing them
1764
+ // into prose here only forced the pill to scrape them
1765
+ // back out — and the scrape left the header reading
1766
+ // "Koray · · 61 tokens".
1767
+ ...(completionTokens !== undefined && completionTokens > 0
1768
+ ? { outputTokens: completionTokens }
1769
+ : {}),
1770
+ ...(generationTps !== undefined && generationTps > 0
1771
+ ? { tokensPerSec: generationTps }
1772
+ : {}),
1773
+ });
1774
+ lastPhaseAt = now;
1775
+ }
1776
+ }
1777
+ if (hasContent) {
1778
+ // Run the raw chunk through the marker stripper before
1779
+ // it lands anywhere user-visible. The TTFT / generating
1780
+ // phase emissions still fire on raw-chunk presence —
1781
+ // they're a "model is producing tokens" signal, not a
1782
+ // "user sees content" signal — so leaked-marker chunks
1783
+ // still tick the engine pill correctly even when the
1784
+ // visible output is empty.
1785
+ const safeContent = stripper.push(delta.content);
1786
+ if (safeContent.length > 0)
1787
+ turnContent += safeContent;
1788
+ {
1789
+ // Watchdog heartbeat to the daemon log (see decl above).
1790
+ const now = Date.now();
1791
+ if (now - lastStreamPulseAt >= STREAM_PULSE_INTERVAL_MS) {
1792
+ const tps = generationTps && generationTps > 0
1793
+ ? ` · ${formatTps(generationTps)} tok/s`
1794
+ : '';
1795
+ log.info(`[mlx] stream-active tokens=${completionTokens ?? 0}${tps}`);
1796
+ lastStreamPulseAt = now;
1797
+ }
1798
+ }
1799
+ if (safeContent.length > 0) {
1800
+ // `turnContent` keeps the raw text on purpose: ramble
1801
+ // detection reads the reasoning phase, and the end-of-turn
1802
+ // split still owns what gets committed. Only what goes out
1803
+ // live is separated here.
1804
+ const live = reasoningSplit.push(safeContent);
1805
+ if (live.visible.length > 0)
1806
+ this.emitDelta(live.visible);
1807
+ if (live.reasoning.length > 0)
1808
+ this.emitReasoningDelta(live.reasoning);
1809
+ if (this.deps.singleToolCallTurn &&
1810
+ !singleCallComplete &&
1811
+ live.visible.length > 0) {
1812
+ singleCallVisible += live.visible;
1813
+ if (hasCompleteToolCallMarkup(singleCallVisible)) {
1814
+ singleCallComplete = true;
1815
+ log.info(`turn#${seq}.${turn} ABORT-FIRED reason=single-call-complete ` +
1816
+ `afterMs=${Date.now() - start}`);
1817
+ ctrl.abort();
1818
+ }
1819
+ }
1820
+ }
1821
+ if (!singleCallComplete && !rambleAborted && ramble.observeContent(turnContent)) {
1822
+ rambleAborted = true;
1823
+ abortReason ??= 'idle';
1824
+ log.error(`turn#${seq}.${turn} ABORT-FIRED reason=ramble ` +
1825
+ `(${ramble.firedOnRepetition ? 'repetition-loop' : ramble.isInsideReasoning ? 'reasoning' : ramble.inPostActionMode ? 'post-action' : 'cold'}; ` +
1826
+ `${ramble.proseSinceLastAction} chars since last signal, cap ${ramble.activeThreshold}) ` +
1827
+ `afterMs=${Date.now() - start}`);
1828
+ ctrl.abort();
1829
+ }
1830
+ }
1831
+ if (hasToolCalls) {
1832
+ // Tool-argument streaming emits no deltas — pulse so long
1833
+ // structured writes read as live activity, not a stall.
1834
+ this.emitWirePulse();
1835
+ ramble.recordStructuredAction(turnContent.length);
1836
+ for (const tc of delta.tool_calls) {
1837
+ toolCallAccumulator.ingest(tc);
1838
+ // Live tool-args channel — see the llama-cpp counterpart.
1839
+ if (tc.function?.name)
1840
+ liveToolArgsName = tc.function.name;
1841
+ const argChunk = tc.function?.arguments ?? '';
1842
+ if (tc.function?.name || argChunk.length > 0) {
1843
+ this.emitToolArgsDelta(liveToolArgsName, argChunk, {
1844
+ index: tc.index,
1845
+ ...(tc.id ? { id: tc.id } : {}),
1846
+ });
1847
+ }
1848
+ }
1849
+ }
1850
+ if (usage && promptTokens !== undefined && completionTokens !== undefined) {
1851
+ lastUsage = {
1852
+ prompt_tokens: promptTokens,
1853
+ completion_tokens: completionTokens,
1854
+ ...(promptTps !== undefined ? { prompt_tps: promptTps } : {}),
1855
+ ...(generationTps !== undefined ? { generation_tps: generationTps } : {}),
1856
+ ...(cachedTokens !== undefined ? { cached_tokens: cachedTokens } : {}),
1857
+ };
1858
+ }
1859
+ if (choice?.finish_reason) {
1860
+ finishReason = choice.finish_reason;
1861
+ }
1862
+ if (!hasContent && !hasToolCalls && !chunk.usage) {
1863
+ this.emitWirePulse();
1864
+ }
1865
+ }
1866
+ }
1867
+ catch (err) {
1868
+ sseExitReason = 'error';
1869
+ cleanupTurn();
1870
+ this.deps.markUsed();
1871
+ const stoppedAfterSingleCall = singleCallComplete && err.name === 'AbortError' && !externalSignal?.aborted;
1872
+ if (stoppedAfterSingleCall) {
1873
+ log.info(`turn#${seq}.${turn} SSE-EXIT reason=single-call-complete chunks=${sseChunks} contentChars=${turnContent.length} afterMs=${Date.now() - start}`);
1874
+ }
1875
+ else {
1876
+ log.error(`turn#${seq}.${turn} SSE-EXIT reason=error chunks=${sseChunks} contentChars=${turnContent.length} afterMs=${Date.now() - start} ${err.name}: ${err instanceof Error ? err.message : String(err)}${abortReason ? ` (abort=${abortReason})` : ''}`);
1877
+ }
1878
+ // Recovery path: a `ramble` abort that has salvageable
1879
+ // tool-call markup falls through to the salvage block
1880
+ // below instead of throwing — keeps the model's queued
1881
+ // tools alive so the next iteration sees results. Set
1882
+ // by the ramble branch when we want the catch to NOT
1883
+ // re-throw `err` at the end. A single-call stop is the same
1884
+ // fall-through with nothing to warn about.
1885
+ let recoveredFromRamble = false;
1886
+ if (err.name === 'AbortError') {
1887
+ if (externalSignal?.aborted) {
1888
+ throw new Error(turnCancelledMessage());
1889
+ }
1890
+ if (stoppedAfterSingleCall) {
1891
+ finishReason ??= 'single-call-complete';
1892
+ recoveredFromRamble = true;
1893
+ }
1894
+ else if (rambleAborted) {
1895
+ const hasSalvageableMarkup = /<tool_call>|<function=/i.test(turnContent);
1896
+ if (hasSalvageableMarkup) {
1897
+ this.emitWarning(ramble.firedOnRepetition
1898
+ ? 'Stopped a repeating output loop — firing the tool calls you queued.'
1899
+ : `Stopped the planning monologue (${turnContent.length} chars without follow-through) — firing the tool calls you queued. Take a smaller next step.`);
1900
+ finishReason ??= 'ramble-recovered';
1901
+ recoveredFromRamble = true;
1902
+ }
1903
+ else {
1904
+ // Last-ditch salvage: small models often inline code in
1905
+ // fenced blocks instead of calling the write tool.
1906
+ // Promote each fenced block to a synthesized write call.
1907
+ //
1908
+ // Prefer `write_file`: a salvaged fenced block is source
1909
+ // the user ships, which belongs in the workspace, not
1910
+ // the artifacts drawer — the abort copy itself says
1911
+ // "don't save source files with write_artifact." Fall
1912
+ // back to `write_artifact` only when the role has no
1913
+ // workspace-write surface (e.g. a research/notes role).
1914
+ // Either way, gate on the chosen tool being wired —
1915
+ // fabricating a call to a tool that doesn't exist would
1916
+ // just trip the validate-ids wrapper. Wild-caught
1917
+ // (qwen3.6 consultation): a write_file-only
1918
+ // Builder role rambled the HTML in chat, but this gate
1919
+ // checked ONLY `write_artifact` and skipped salvage,
1920
+ // so the whole turn (and the drafted file) was thrown
1921
+ // away. Both tools take the same `{path, content}`
1922
+ // shape, so the synthesized args are identical. Only
1923
+ // fires when there's no other salvageable signal (the
1924
+ // structured markup branch above handled the easy cases).
1925
+ const salvageToolName = knownToolNames.has('write_file')
1926
+ ? 'write_file'
1927
+ : knownToolNames.has('write_artifact')
1928
+ ? 'write_artifact'
1929
+ : null;
1930
+ if (salvageToolName) {
1931
+ const blocks = prepareSalvagedCodeBlocks(turnContent, this.deps.activeCraftbookStep?.deliverableFile);
1932
+ if (blocks.length > 0) {
1933
+ codeBlockRepaired = blocks.map((b, i) => ({
1934
+ id: `code-salvage-${seq}-${turn}-${i}`,
1935
+ type: 'function',
1936
+ function: {
1937
+ name: salvageToolName,
1938
+ arguments: JSON.stringify({
1939
+ path: b.filename,
1940
+ content: b.content,
1941
+ }),
1942
+ },
1943
+ }));
1944
+ log.info(`turn#${seq}.${turn} code-block-salvage: extracted ${blocks.length} ` +
1945
+ `fenced block(s) from ${turnContent.length}-char ramble buffer ` +
1946
+ `→ ${salvageToolName} (${blocks.map((b) => `${b.lang}:${b.filename}`).join(', ')})`);
1947
+ this.emitWarning(`The model wrote ${blocks.length === 1 ? 'a code block' : `${blocks.length} code blocks`} in chat instead of calling ${salvageToolName} — promoting them to actual file writes. Ask the model to use tools directly next time.`);
1948
+ finishReason ??= 'ramble-recovered';
1949
+ recoveredFromRamble = true;
1950
+ }
1951
+ }
1952
+ if (!recoveredFromRamble) {
1953
+ // Log a content preview so future regressions are easier
1954
+ // to diagnose: the ramble buffer is otherwise lost when
1955
+ // the throw fires.
1956
+ const head = turnContent.slice(0, 400).replace(/\s+/g, ' ');
1957
+ const tail = turnContent.slice(-200).replace(/\s+/g, ' ');
1958
+ log.warn(`turn#${seq}.${turn} ramble-no-salvage preview head=${JSON.stringify(head)} tail=${JSON.stringify(tail)}`);
1959
+ throw new Error(buildRambleAbortMessage({
1960
+ providerLabel: '[Mac AI]',
1961
+ charCount: turnContent.length,
1962
+ knownToolNames,
1963
+ ...(this.deps.activeCraftbookStep?.deliverableFile
1964
+ ? { deliverableFile: this.deps.activeCraftbookStep.deliverableFile }
1965
+ : {}),
1966
+ ...(this.deps.activeCraftbookStep?.deliverableIsArtifact
1967
+ ? { deliverableIsArtifact: true }
1968
+ : {}),
1969
+ }));
1970
+ }
1971
+ }
1972
+ }
1973
+ else if (abortReason === 'idle') {
1974
+ // Build a stall-aware message. Pre-first-byte vs
1975
+ // streaming idle land on different sentences so the
1976
+ // user can tell whether the model never started or
1977
+ // started then froze. The chars-received counter is
1978
+ // informative when the bubble would otherwise look
1979
+ // empty.
1980
+ if (idleAbortKind === 'pre-first-byte') {
1981
+ throw new Error(buildPreFirstByteAbortMessage(this.lastPrefillEvent, busyElsewhere()));
1982
+ }
1983
+ const sinceFirst = firstSseEventAt !== null ? Math.round((Date.now() - firstSseEventAt) / 1000) : null;
1984
+ const sinceLast = lastSseEventAt !== null ? Math.round((Date.now() - lastSseEventAt) / 1000) : null;
1985
+ const stats = sinceFirst
1986
+ ? `received ${turnContent.length} chars in ${sinceFirst}s before going silent for ${sinceLast}s`
1987
+ : `received ${turnContent.length} chars before stalling`;
1988
+ // Engine facts go to the log; the person gets what happened and
1989
+ // what to do. A stream parked for a waiting chat never lands
1990
+ // here — its `paused` markers re-arm this bound.
1991
+ log.warn(`turn#${seq}.${turn} streaming idle abort: ${stats}`);
1992
+ const quietSeconds = Math.round(STREAMING_IDLE_MS / 1000);
1993
+ throw new Error(turnContent.length === 0
1994
+ ? `[Mac AI] The model went quiet for ${quietSeconds} seconds before writing anything, so this turn was stopped. Try again; if it keeps happening, restart the engine in Settings → On-device.`
1995
+ : `[Mac AI] The model went quiet for ${quietSeconds} seconds partway through its reply, so this turn was stopped. Try again; if it keeps happening, restart the engine in Settings → On-device.`);
1996
+ }
1997
+ else {
1998
+ throw turnTimeoutError();
1999
+ }
2000
+ }
2001
+ // Not one of our aborts (those are AbortErrors, handled above).
2002
+ // A non-abort failure mid-body is almost always the engine
2003
+ // dropping the HTTP stream from under us — undici surfaces a
2004
+ // premature socket close as a bare `TypeError: terminated`,
2005
+ // which is what reached the user before this. Translate it into
2006
+ // something actionable instead of the cryptic one-word message;
2007
+ // the original text still rides along in the persisted warning.
2008
+ if (!recoveredFromRamble && isMidStreamConnectionDrop(err)) {
2009
+ throw new Error(buildMidStreamDropMessage(turnContent.length, this.deps.provider.isDisposed, this.deps.provider.engineLifecycleSnapshot()?.running));
2010
+ }
2011
+ if (!recoveredFromRamble)
2012
+ throw err;
2013
+ }
2014
+ finally {
2015
+ lastIterationFinishedAt = Date.now();
2016
+ cleanupTurn();
2017
+ this.deps.markUsed();
2018
+ }
2019
+ // Drain any safely-buffered tail from the marker stripper. If
2020
+ // the model never closed an open marker `flush()` returns ''
2021
+ // (treats it as truncated noise) — the alternative is leaking
2022
+ // a half-marker into the saved transcript.
2023
+ const tail = stripper.flush();
2024
+ if (tail.length > 0) {
2025
+ turnContent += tail;
2026
+ const live = reasoningSplit.push(tail);
2027
+ if (live.visible.length > 0)
2028
+ this.emitDelta(live.visible);
2029
+ if (live.reasoning.length > 0)
2030
+ this.emitReasoningDelta(live.reasoning);
2031
+ }
2032
+ {
2033
+ // Whatever the split was still holding: a reasoning block the model
2034
+ // never closed stays on the reasoning channel rather than becoming
2035
+ // the reply.
2036
+ const rest = reasoningSplit.flush();
2037
+ if (rest.visible.length > 0)
2038
+ this.emitDelta(rest.visible);
2039
+ if (rest.reasoning.length > 0)
2040
+ this.emitReasoningDelta(rest.reasoning);
2041
+ }
2042
+ // Always-on terminator log. `stream-end` here is the smoking gun
2043
+ // for "engine closed the connection without sending [DONE]" —
2044
+ // benign on a well-behaved server but worth flagging since it
2045
+ // means we'd hang on any keep-alive variant.
2046
+ log.debug(`turn#${seq}.${turn} SSE-EXIT reason=${sseExitReason} chunks=${sseChunks} ` +
2047
+ `contentChars=${turnContent.length} toolCalls=${toolCallAccumulator.size()} ` +
2048
+ `finish=${finishReason ?? 'null'} afterMs=${Date.now() - start}`);
2049
+ const structuredCalls = toolCallAccumulator.finalize();
2050
+ // Ids of synthesized tool calls whose underlying salvage span
2051
+ // was flagged `truncated` by the lenient Hermes parser. The
2052
+ // tool-execution loop reads this to append an auto-
2053
+ // continuation hint to the corresponding tool result, so the
2054
+ // model knows the partial write_file landed and can issue a
2055
+ // follow-up with the remaining bytes.
2056
+ const truncatedCallIds = new Set();
2057
+ // Payload-mutation calls the cap cut mid-content that we refused
2058
+ // to execute, because landing a prefix would destroy a whole-file
2059
+ // target with no append path (see WRITE_SHAPED_TOOL_NAMES). The
2060
+ // refusal is correct; ending the turn on it silently is not —
2061
+ // nothing tells the model WHY, so the next attempt re-emits the
2062
+ // same over-cap payload. Consumed below to steer toward
2063
+ // incremental edits instead of falling through to a blank reply.
2064
+ const droppedTruncatedPayloadCalls = [];
2065
+ // Try to repair any tool-call markers the stripper extracted
2066
+ // but mlx_vlm.server's parser missed. Repair only succeeds when
2067
+ // (1) the body parses cleanly and (2) the named function is in
2068
+ // the bridge's known-tools set — refusing fabricated names is
2069
+ // the safety rail. Failures land in `unrepairedBodies`, which
2070
+ // drives the warning + model self-correction below.
2071
+ const strippedBodies = stripper.getStrippedBodies();
2072
+ const eosFlushedIndices = stripper.getEosFlushedBodyIndices();
2073
+ this.turnPolicy.checkpoint();
2074
+ const repairedCalls = [];
2075
+ const unrepairedBodies = [];
2076
+ for (let bodyIdx = 0; bodyIdx < strippedBodies.length; bodyIdx++) {
2077
+ const body = strippedBodies[bodyIdx];
2078
+ // Two-stage repair: try the legacy JSON-rehab path first
2079
+ // (handles Gemma's loose `{key: 'value'}` flavors), then
2080
+ // fall back to the native Gemma 4 char-by-char parser
2081
+ // (handles the chat-template's true format `{key:<|"|>val<|"|>}`
2082
+ // where string content contains unescaped newlines/quotes
2083
+ // that defeat JSON.parse). The native parser is strictly
2084
+ // additive — it only succeeds on shapes the JSON path would
2085
+ // have rejected anyway, so wiring it in can't regress
2086
+ // previously-working salvage. See
2087
+ // local-tool-call-salvage-gemma-native.test.ts.
2088
+ const parsed = parseGemmaToolCall(body, knownToolNames) ??
2089
+ parseGemmaNativeToolCall(body, knownToolNames);
2090
+ if (parsed) {
2091
+ // An EOS-flushed artifact call is only a prefix of an atomic
2092
+ // whole-file replacement, and the artifact drawer has no append
2093
+ // primitive. Do not destroy the previous artifact with it.
2094
+ if (eosFlushedIndices.has(bodyIdx) &&
2095
+ parsed.name === 'write_artifact' &&
2096
+ typeof parsed.arguments.content === 'string' &&
2097
+ typeof parsed.arguments.path === 'string') {
2098
+ this.emitWarning('The model hit its output limit while replacing an artifact. The incomplete write was skipped and the previous artifact was preserved; split or deterministically assemble the deliverable before retrying.');
2099
+ continue;
2100
+ }
2101
+ const id = `repair-${seq}-${turn}-${repairedCalls.length}`;
2102
+ repairedCalls.push({
2103
+ id,
2104
+ type: 'function',
2105
+ function: {
2106
+ name: parsed.name,
2107
+ arguments: JSON.stringify(parsed.arguments),
2108
+ },
2109
+ });
2110
+ // Layer 3 — if this Gemma marker call came from an
2111
+ // EOS-flushed body (close marker never arrived) AND it's
2112
+ // a write-shaped call with a string `content` arg, tag
2113
+ // it as truncated. parseGemmaNativeToolCall is permissive
2114
+ // about unclosed `<|"|>` boundaries (returns the rest of
2115
+ // the buffer as the string value), so we get a "complete-
2116
+ // looking" call object whose `content` is actually the
2117
+ // bytes received before the truncation point. The
2118
+ // continuation hint tells the model to append the missing
2119
+ // tail rather than re-emit the whole file.
2120
+ if (eosFlushedIndices.has(bodyIdx)) {
2121
+ const args = parsed.arguments;
2122
+ const isWriteShaped = isWriteShapedToolName(parsed.name);
2123
+ if (isWriteShaped &&
2124
+ typeof args.content === 'string' &&
2125
+ typeof args.path === 'string') {
2126
+ truncatedCallIds.add(id);
2127
+ log.info(`turn#${seq}.${turn} Gemma marker call ${parsed.name} (path=${args.path}, partial=${args.content.length} bytes) was EOS-flushed — continuation hint will fire on the tool result`);
2128
+ }
2129
+ }
2130
+ }
2131
+ else {
2132
+ unrepairedBodies.push(body);
2133
+ }
2134
+ }
2135
+ const salvaged = salvageTextualToolCalls({
2136
+ turnContent,
2137
+ knownToolNames,
2138
+ seq,
2139
+ turn,
2140
+ structuredCalls,
2141
+ repairedCalls,
2142
+ codeBlockRepaired,
2143
+ truncatedCallIds,
2144
+ droppedTruncatedPayloadCalls,
2145
+ emitWarning: (message) => this.emitWarning(message),
2146
+ });
2147
+ turnContent = salvaged.turnContent;
2148
+ const { proseRepaired, xmlTagRepaired, claudeInvokeRepaired, glmRepaired, hermesRepaired, shellRepaired, envelopeRepaired, envelopeTruncated, unknownCall, truncatedSalvage, bareInvokeRepaired, } = salvaged;
2149
+ const rawTurnContentBeforeReasoning = turnContent;
2150
+ // Reasoning-tag extraction applies regardless of which (if any)
2151
+ // salvage path fired. Qwen 3.5/3.6 leak `<think>…</think>`
2152
+ // chain-of-thought blocks that read as the assistant's actual
2153
+ // message; smaller DeepSeek-R1 distillates and Gemma 3/4 with
2154
+ // the verbose-family hint do the same. The captured text gets
2155
+ // accumulated into `lastTurnReasoning` so the chat bubble can
2156
+ // render it behind a collapsed expander.
2157
+ {
2158
+ const split = extractReasoningWithProfile(turnContent, this.deps.profile);
2159
+ turnContent = split.visible;
2160
+ if (split.reasoning) {
2161
+ this.lastTurnReasoning =
2162
+ this.lastTurnReasoning.length > 0
2163
+ ? `${this.lastTurnReasoning}\n\n${split.reasoning}`
2164
+ : split.reasoning;
2165
+ }
2166
+ }
2167
+ const mergedCalls = [
2168
+ ...structuredCalls,
2169
+ ...repairedCalls,
2170
+ ...proseRepaired,
2171
+ ...xmlTagRepaired,
2172
+ ...claudeInvokeRepaired,
2173
+ ...glmRepaired,
2174
+ ...hermesRepaired,
2175
+ ...shellRepaired,
2176
+ ...envelopeRepaired,
2177
+ ...truncatedSalvage,
2178
+ ...bareInvokeRepaired,
2179
+ ...codeBlockRepaired,
2180
+ ];
2181
+ // Every markup salvage format above is a flat KEY→text map, so a
2182
+ // parameter declared `object`/`array` arrives as a string. Repair
2183
+ // against the declared schema here, at the one place all the
2184
+ // salvage passes converge, so the structural shape is what gets
2185
+ // logged, recorded, and dispatched. Schema-gated: a genuine
2186
+ // string argument (a JSON file's `content`) is never touched.
2187
+ const coerced = coerceToolCallArgs(mergedCalls, (n) => toolArgSchemas.get(n));
2188
+ for (const r of coerced.repaired) {
2189
+ log.info(`turn#${seq}.${turn} repaired flattened arg(s) on ${r.name}: ${r.paths.join(', ')}`);
2190
+ }
2191
+ // After coercion, so two spellings of the same arguments compare equal.
2192
+ const collapsed = collapseDuplicateToolCalls(coerced.calls);
2193
+ for (const d of collapsed.dropped) {
2194
+ log.info(`turn#${seq}.${turn} collapsed ${d.count} duplicate ${d.name} call(s) emitted in this generation`);
2195
+ }
2196
+ let toolCalls = collapsed.calls;
2197
+ if (rambleAborted && toolCalls.length === 0) {
2198
+ // If ctrl.abort() races with mlx-vlm closing the SSE stream,
2199
+ // the for-await loop can exit cleanly instead of throwing an
2200
+ // AbortError. Without this guard the no-tool ramble falls
2201
+ // through as an empty assistant message after reasoning
2202
+ // extraction, so the gezel never receives the corrective.
2203
+ // Wild-caught (qwen3.6 35B MLX tankcombat):
2204
+ // Meester generated 3.5K tokens, the Python server logged
2205
+ // "client disconnected mid-generation", and the session
2206
+ // persisted a blank assistant turn with no project handoff.
2207
+ const head = rawTurnContentBeforeReasoning.slice(0, 400).replace(/\s+/g, ' ');
2208
+ const tail = rawTurnContentBeforeReasoning.slice(-200).replace(/\s+/g, ' ');
2209
+ log.warn(`turn#${seq}.${turn} ramble-clean-exit-no-salvage preview head=${JSON.stringify(head)} tail=${JSON.stringify(tail)}`);
2210
+ throw new Error(buildRambleAbortMessage({
2211
+ providerLabel: '[Mac AI]',
2212
+ charCount: rawTurnContentBeforeReasoning.length,
2213
+ knownToolNames,
2214
+ ...(this.deps.activeCraftbookStep?.deliverableFile
2215
+ ? { deliverableFile: this.deps.activeCraftbookStep.deliverableFile }
2216
+ : {}),
2217
+ ...(this.deps.activeCraftbookStep?.deliverableIsArtifact
2218
+ ? { deliverableIsArtifact: true }
2219
+ : {}),
2220
+ }));
2221
+ }
2222
+ // Prose-document salvage. Past the guard above, zero tool calls
2223
+ // means the model ENDED ITS OWN TURN — the one condition under
2224
+ // which the buffer is a finished document rather than a cut-off
2225
+ // one. A craftbook step whose deliverable is markdown gets it
2226
+ // promoted to the write tool instead of leaving the text in chat
2227
+ // where the gate will never see it. `turnContent` is already
2228
+ // reasoning-stripped here; `finishReason === 'length'` means the
2229
+ // token cap cut it, which is a truncation like any other.
2230
+ if (toolCalls.length === 0 && finishReason !== 'length') {
2231
+ const deliverable = this.deps.activeCraftbookStep?.deliverableFile;
2232
+ const salvageToolName = this.deps.activeCraftbookStep?.deliverableIsArtifact
2233
+ ? knownToolNames.has('write_artifact')
2234
+ ? 'write_artifact'
2235
+ : null
2236
+ : knownToolNames.has('write_file')
2237
+ ? 'write_file'
2238
+ : null;
2239
+ const document = salvageToolName
2240
+ ? prepareSalvagedProseDocument({
2241
+ text: turnContent,
2242
+ deliverableFile: deliverable,
2243
+ streamComplete: true,
2244
+ })
2245
+ : null;
2246
+ if (document && salvageToolName && deliverable) {
2247
+ toolCalls = [
2248
+ {
2249
+ id: `prose-doc-salvage-${seq}-${turn}`,
2250
+ type: 'function',
2251
+ function: {
2252
+ name: salvageToolName,
2253
+ arguments: JSON.stringify({ path: deliverable, content: document }),
2254
+ },
2255
+ },
2256
+ ];
2257
+ log.info(`turn#${seq}.${turn} prose-document-salvage: promoted ${document.length}-char ` +
2258
+ `markdown buffer to ${salvageToolName} path=${deliverable}`);
2259
+ this.emitWarning(`The model wrote ${deliverable} in chat instead of calling ${salvageToolName} — promoting it to a real write. Ask the model to use the tool directly next time.`);
2260
+ }
2261
+ }
2262
+ // Cap-truncated payload mutation with nothing left to run. The
2263
+ // skip above is right — a partial `write_artifact` would replace
2264
+ // a complete artifact with a prefix, and there is no
2265
+ // `append_artifact` to finish it. But falling through from here
2266
+ // ends the turn with zero tool calls and (after the rumination
2267
+ // fold) zero visible text: the user gets a blank bubble and the
2268
+ // MODEL gets nothing at all, so the next attempt re-emits the
2269
+ // same over-cap payload. llama-cpp already steers this case via
2270
+ // {@link appendCapTruncationHintToRejectedWrite} because its
2271
+ // rejected write comes back as a tool result; on MLX the call
2272
+ // never executes, so synthesize the same steer as a corrective
2273
+ // and spend another loop iteration on it.
2274
+ //
2275
+ // Wild-caught on gezel/49: a `write_artifact` carrying a
2276
+ // 580-path coverage ledger was cut at max_tokens=6144, dropped
2277
+ // here, and the turn ended after 62 minutes with replyChars=0.
2278
+ if (toolCalls.length === 0 &&
2279
+ droppedTruncatedPayloadCalls.length > 0 &&
2280
+ this.turnPolicy.retry('capTruncation')) {
2281
+ capTruncationSteers++;
2282
+ const dropped = droppedTruncatedPayloadCalls[0];
2283
+ const liveToolNames = new Set((Array.isArray(body.tools) ? body.tools : [])
2284
+ .map((tool) => chatCompletionToolName(tool))
2285
+ .filter((name) => typeof name === 'string'));
2286
+ const requestMaxTokens = typeof body.max_tokens === 'number' ? body.max_tokens : null;
2287
+ const steer = appendCapTruncationHintToRejectedWrite(`ERROR: \`${dropped.name}\` was not executed because the call was cut off at the per-turn output token cap before its payload finished.`, dropped.name, dropped.args, requestMaxTokens, {
2288
+ availableToolNames: liveToolNames,
2289
+ taskStep: this.deps.activeCraftbookStep !== undefined,
2290
+ });
2291
+ const noRemedy = steer.includes('No incremental edit tool is wired this turn');
2292
+ log.info(`turn#${seq}.${turn} cap-truncated ${dropped.name} dropped with no other call — ` +
2293
+ `${noRemedy ? 'no incremental tool wired; escalation steer' : 'incremental-edit steer'} ` +
2294
+ `injected (attempt ${capTruncationSteers}/${MAX_CAP_TRUNCATION_STEERS}, max_tokens=${requestMaxTokens ?? 'unset'})`);
2295
+ this.messages.push({ role: 'user', content: `[system] ${steer}` });
2296
+ continue;
2297
+ }
2298
+ // Auto-fold pre-tool preamble for verbose-family models. When
2299
+ // a tool call fired this turn, untagged visible text is
2300
+ // almost always reasoning the model leaked instead of
2301
+ // wrapping in `<think>` ("The user wants…", "Let me…").
2302
+ // Substantive answer comes after the tool runs in the next
2303
+ // tool-loop iteration. See {@link foldPreToolPreamble}.
2304
+ turnContent = foldPreToolPreamble({
2305
+ text: turnContent,
2306
+ toolCallsFired: toolCalls.length > 0,
2307
+ modelLeaksReasoning: profileHasBehavior(this.deps.profile, 'turn.preamble-folding'),
2308
+ askedQuestionThisTurn,
2309
+ });
2310
+ // Append to fullText AFTER every salvage path has had its turn
2311
+ // at stripping — earlier ordering meant the un-stripped prose /
2312
+ // envelope / reasoning text still landed in the persisted
2313
+ // assistant message even though the salvage worked.
2314
+ fullText += turnContent;
2315
+ // External tool capture: when the model invoked any caller-
2316
+ // supplied external tool, halt the loop and surface ALL
2317
+ // pending calls (external + bridge) to the caller. Bridge
2318
+ // calls in the same turn go unexecuted — the `/v1/chat/completions`
2319
+ // route owns the next turn.
2320
+ if (toolCalls.length > 0 &&
2321
+ toolCalls.some((tc) => this.externalToolNames.has(tc.function.name))) {
2322
+ this.capturedCalls = toolCalls.map((tc) => ({
2323
+ id: tc.id,
2324
+ name: tc.function.name,
2325
+ arguments: tc.function.arguments,
2326
+ }));
2327
+ this.messages.push({
2328
+ role: 'assistant',
2329
+ content: turnContent || null,
2330
+ tool_calls: toolCalls,
2331
+ });
2332
+ return fullText;
2333
+ }
2334
+ // Successful salvage paths (`repairedCalls`, `proseRepaired`,
2335
+ // `envelopeRepaired`) used to surface a yellow user-facing
2336
+ // warning — but salvage IS the success case, the tool fires
2337
+ // correctly and the call appears as a normal tool widget.
2338
+ // Annotating every Qwen/etc. turn with a warning trained users
2339
+ // to ignore them. Keep the console log for ops; drop the toast.
2340
+ if (repairedCalls.length > 0) {
2341
+ log.info(`turn#${seq}.${turn} repaired ${repairedCalls.length} ` +
2342
+ `malformed tool call(s): ${repairedCalls.map((c) => c.function.name).join(', ')}`);
2343
+ }
2344
+ if (proseRepaired.length > 0) {
2345
+ log.info(`turn#${seq}.${turn} salvaged ${proseRepaired.length} prose-shaped tool call(s): ${proseRepaired.map((c) => c.function.name).join(', ')}`);
2346
+ }
2347
+ if (xmlTagRepaired.length > 0) {
2348
+ log.info(`turn#${seq}.${turn} salvaged ${xmlTagRepaired.length} XML-tag tool call(s): ${xmlTagRepaired.map((c) => c.function.name).join(', ')}`);
2349
+ }
2350
+ if (claudeInvokeRepaired.length > 0) {
2351
+ log.info(`turn#${seq}.${turn} salvaged ${claudeInvokeRepaired.length} Claude-invoke tool call(s): ${claudeInvokeRepaired.map((c) => c.function.name).join(', ')}`);
2352
+ }
2353
+ if (glmRepaired.length > 0) {
2354
+ log.info(`turn#${seq}.${turn} salvaged ${glmRepaired.length} GLM-style tool call(s): ${glmRepaired.map((c) => c.function.name).join(', ')}`);
2355
+ }
2356
+ if (hermesRepaired.length > 0) {
2357
+ log.info(`turn#${seq}.${turn} salvaged ${hermesRepaired.length} Hermes-style tool call(s): ${hermesRepaired.map((c) => c.function.name).join(', ')}`);
2358
+ }
2359
+ if (shellRepaired.length > 0) {
2360
+ log.info(`turn#${seq}.${turn} salvaged ${shellRepaired.length} shell-style tool call(s): ${shellRepaired.map((c) => c.function.name).join(', ')}`);
2361
+ }
2362
+ if (envelopeRepaired.length > 0) {
2363
+ log.info(`turn#${seq}.${turn} salvaged ${envelopeRepaired.length} JSON-envelope tool call(s): ${envelopeRepaired.map((c) => c.function.name).join(', ')}`);
2364
+ }
2365
+ if (bareInvokeRepaired.length > 0) {
2366
+ log.info(`turn#${seq}.${turn} salvaged ${bareInvokeRepaired.length} bare-invoke tool call(s): ${bareInvokeRepaired.map((c) => c.function.name).join(', ')}`);
2367
+ }
2368
+ if (envelopeTruncated) {
2369
+ log.info(`turn#${seq}.${turn} truncated JSON-envelope tool call: ` +
2370
+ `${envelopeTruncated.wanted ?? '<unknown>'}`);
2371
+ this.emitWarning(`The model's last tool call (\`${envelopeTruncated.wanted ?? 'unknown'}\`) was cut off mid-stream and was skipped. Try again — or raise the output token cap if this happens often.`);
2372
+ }
2373
+ else if (toolCalls.length === 0 && turnContent.length > 0) {
2374
+ // Prose-shaped truncation — `name(args` with no closing paren.
2375
+ // Only check when no other salvage fired and no envelope-truncation
2376
+ // has already been flagged for the same tail.
2377
+ const proseTruncated = findTruncatedProseToolCall(turnContent, knownToolNames);
2378
+ if (proseTruncated) {
2379
+ log.info(`turn#${seq}.${turn} truncated prose tool call: ${proseTruncated.wanted}`);
2380
+ this.emitWarning(`The model started calling \`${proseTruncated.wanted}\` but was cut off mid-args. It likely spent its output budget on reasoning before reaching the tool call. Try again — or raise the output token cap.`);
2381
+ }
2382
+ }
2383
+ // "Did you mean…?" nudge: model emitted a `{tool, args}`
2384
+ // envelope with a tool name we don't recognize (e.g.
2385
+ // `listtasks` instead of `list_tasks`). The salvage above
2386
+ // bailed because the name failed the known-set check; we
2387
+ // can't safely fabricate a call against the wrong name, but
2388
+ // we CAN tell the model what they mistyped and keep the
2389
+ // turn going. Shares the malformedRetries budget so a model
2390
+ // that keeps emitting bad names doesn't loop forever.
2391
+ if (toolCalls.length === 0) {
2392
+ // Cover BOTH unknown-tool shapes: the JSON `{tool,args}`
2393
+ // envelope AND the `<function=NAME>` / `<invoke name=…>`
2394
+ // markup. The latter is how a delegator role (no `write_file`)
2395
+ // "calls" write_file — before this it got no feedback and
2396
+ // believed the write succeeded. `buildUnknownToolNudge` routes
2397
+ // a no-write-access role to delegation instead of "did you
2398
+ // mean…?". Wild-caught (Space Shooter Arcade).
2399
+ const miss = unknownCall ??
2400
+ findUnrecognizedToolEnvelope(turnContent, knownToolNames) ??
2401
+ findUnrecognizedFunctionMarkup(turnContent, knownToolNames);
2402
+ if (miss) {
2403
+ const missSig = `u:${miss.wanted.toLowerCase()}`;
2404
+ if (this.turnPolicy.retry('malformed', missSig)) {
2405
+ malformedRetries++;
2406
+ lastBadCallSig = missSig;
2407
+ log.info(`turn#${seq}.${turn} model called unknown tool "${miss.wanted}"${miss.suggestion ? ` — suggesting "${miss.suggestion}"` : ''} (retry ${malformedRetries}/${MAX_MALFORMED_RETRIES})`);
2408
+ // No user-facing warning here — the unknown-name retry is
2409
+ // an internal recovery, the next loop iteration produces
2410
+ // the real tool call, the user sees a clean tool widget. The
2411
+ // nudge now names valid tools + flags param-as-function slips.
2412
+ this.messages.push({ role: 'assistant', content: turnContent });
2413
+ this.messages.push({
2414
+ role: 'user',
2415
+ content: buildUnknownToolNudge(miss.wanted, miss.suggestion, knownToolNames, toolParamIndex),
2416
+ });
2417
+ continue;
2418
+ }
2419
+ // Repeat of the same unknown tool after a nudge (or budget spent):
2420
+ // the nudge isn't landing, so stop retrying and let the turn end
2421
+ // rather than apology-loop. The actionable nudge already fired.
2422
+ if (missSig === lastBadCallSig) {
2423
+ log.info(`turn#${seq}.${turn} model repeated unknown tool "${miss.wanted}" after a nudge — stopping retries`);
2424
+ }
2425
+ this.emitWarning('The model could not call an available tool after bounded recovery. No tool ran.');
2426
+ return fullText;
2427
+ }
2428
+ }
2429
+ // Self-correction nudge: model emitted tool-call markers but
2430
+ // none parsed AND no structured calls came through. Push the
2431
+ // partial assistant text + a corrective user message describing
2432
+ // what was wrong, then continue the loop so the model gets a
2433
+ // chance to retry with proper structure. Capped via
2434
+ // `malformedRetries` so we don't loop forever on a model that
2435
+ // just can't form structured calls.
2436
+ if (toolCalls.length === 0 && unrepairedBodies.length > 0) {
2437
+ const malSig = `m:${unrepairedBodies[0].replace(/\s+/g, '').toLowerCase().slice(0, 200)}`;
2438
+ if (this.turnPolicy.retry('malformed', malSig)) {
2439
+ malformedRetries++;
2440
+ lastBadCallSig = malSig;
2441
+ const detail = describeMalformation(unrepairedBodies[0]);
2442
+ log.info(`turn#${seq}.${turn} nudging model after malformed tool call ` +
2443
+ `(retry ${malformedRetries}/${MAX_MALFORMED_RETRIES})`);
2444
+ // Suppress the per-retry user-visible warning when the
2445
+ // model's profile knows the family's tool-call quirks
2446
+ // (`parse.gemma-special-token` etc.). For those models a
2447
+ // malformed marker is a recoverable family tic rather than
2448
+ // a user-actionable problem; the internal retry-nudge
2449
+ // still fires on the next loop iteration so behavior
2450
+ // doesn't change, just the noise floor in the chat. Only
2451
+ // the final budget-exhausted warning below stays universal
2452
+ // — that's load-bearing because the turn ends without a
2453
+ // real tool call landing.
2454
+ const suppressPerRetryWarning = profileHasBehavior(this.deps.profile, 'parse.gemma-special-token');
2455
+ if (!suppressPerRetryWarning) {
2456
+ this.emitWarning(`Model attempted a tool call with malformed syntax — asking it to retry (${malformedRetries}/${MAX_MALFORMED_RETRIES}).`);
2457
+ }
2458
+ this.messages.push({ role: 'assistant', content: turnContent });
2459
+ this.messages.push({
2460
+ role: 'user',
2461
+ content: `[system] Your previous response contained a malformed tool-call marker — the syntax didn't match the structured tool-call format the runtime can parse. ${detail} Please retry the tool invocation using the standard structured tool-call format (do not write the literal <|tool_call|> tokens as text — emit a real function call via the tools mechanism). Your available tools are: ${formatToolMenu(knownToolNames)}.`,
2462
+ });
2463
+ continue;
2464
+ }
2465
+ // Either the retry budget is spent OR the model just re-emitted the
2466
+ // identical malformed call — in both cases keep nudging is futile;
2467
+ // fall through to the terminal correction below instead of looping.
2468
+ if (malSig === lastBadCallSig) {
2469
+ log.info(`turn#${seq}.${turn} model repeated the same malformed tool call after a nudge — stopping retries`);
2470
+ }
2471
+ // Retry budget exhausted — fall through to the normal end-of-
2472
+ // turn block but emit a final user-visible warning so the user
2473
+ // knows the chat ended without a real tool call firing.
2474
+ this.emitWarning(`Model attempted ${unrepairedBodies.length} tool call${unrepairedBodies.length === 1 ? '' : 's'} but couldn't form ${unrepairedBodies.length === 1 ? 'it' : 'them'} correctly after ${MAX_MALFORMED_RETRIES} retries. Try rephrasing or use a cloud model for tool-heavy work in this thread.`);
2475
+ // Capture the failed bodies so ChatManager can stash them on
2476
+ // the persisted message for the debug bundle. Truncated per-
2477
+ // body so a runaway fabrication doesn't blow up the session
2478
+ // file. Without this, the bodies live only in provider logs
2479
+ // and the bundle reader sees "tool call attempted but couldn't
2480
+ // form" with no record of what shape was actually emitted.
2481
+ this.lastTurnAttemptedToolCalls = unrepairedBodies.map((body) => ({
2482
+ body: body.length > 600 ? `${body.slice(0, 600)}…` : body,
2483
+ reason: describeMalformation(body),
2484
+ }));
2485
+ // Inject a corrective system note into history so the NEXT
2486
+ // turn doesn't carry forward the model's "I just created the
2487
+ // project" assumption. Without this, the model's own prose
2488
+ // sits in history with no contradicting signal — turn N+1
2489
+ // tries to read the kickoff task of a project that doesn't
2490
+ // exist, hits the strict-IDs guard, and the cascade is on.
2491
+ // Using `role: 'user'` with a `[system]` prefix matches the
2492
+ // pattern the within-turn retries already use; the model
2493
+ // treats it as authoritative correction.
2494
+ const wantedNames = uniqueWantedToolNames(unrepairedBodies, knownToolNames);
2495
+ const wantedSummary = wantedNames.length > 0 ? `\`${wantedNames.join('`, `')}\`` : 'a tool';
2496
+ this.messages.push({
2497
+ role: 'user',
2498
+ content: `[system] Your previous turn attempted to call ${wantedSummary} but the tool-call syntax was malformed; the runtime could not parse it after ${MAX_MALFORMED_RETRIES} retries. NO TOOL RAN this turn. Any state your reasoning described (projects created, gezels recruited, files written, tasks assigned) does NOT exist on disk. Before referring to that state in subsequent turns, call \`list_projects\` / \`list_gezels\` to verify the actual state, OR retry the tool call using the structured function-calling mechanism with valid JSON arguments.`,
2499
+ });
2500
+ }
2501
+ if (toolCalls.length === 0 &&
2502
+ fileTurnPlan.kind !== 'ordinary' &&
2503
+ !repairMutationSucceeded &&
2504
+ !askedQuestionThisTurn) {
2505
+ const noMutationNudges = this.turnPolicy.requireProgressRetry();
2506
+ const nudge = remainingReadPaths.length
2507
+ ? buildPrerequisiteRepairReadNudge({
2508
+ remainingPaths: remainingReadPaths,
2509
+ noProgressNudges: noMutationNudges,
2510
+ })
2511
+ : immediateFileWriteTurn
2512
+ ? buildImmediateFileWriteNoMutationNudge({
2513
+ targetPath: opts?.fileTurnIntent?.path ?? fileRepairTargetPath(prompt),
2514
+ noMutationNudges,
2515
+ })
2516
+ : buildScenarioRepairNoMutationNudge(knownToolNames, {
2517
+ readOnlyCalls: repairReadCalls,
2518
+ readFilePaths: repairReadPaths,
2519
+ failedMutationCalls: repairFailedMutations,
2520
+ noMutationNudges,
2521
+ });
2522
+ this.messages.push({ role: 'user', content: nudge });
2523
+ continue;
2524
+ }
2525
+ if (toolCalls.length === 0 && requiresToolCall) {
2526
+ if (this.turnPolicy.retry('noProgress')) {
2527
+ const attempt = this.turnPolicy.count('noProgress');
2528
+ log.info(`turn#${seq}.${turn} required tool call missing — retrying ` +
2529
+ `(${attempt}/${LOCAL_TURN_LIMITS.noProgress})`);
2530
+ // The first decode is not a valid answer to a required-tool
2531
+ // request. Keep it in provider history as the assistant output
2532
+ // being corrected, but do not leak it into the eventual visible
2533
+ // reply when the retry succeeds.
2534
+ if (turnContent.length > 0) {
2535
+ this.messages.push({ role: 'assistant', content: turnContent });
2536
+ fullText = fullText.slice(0, -turnContent.length);
2537
+ }
2538
+ this.messages.push({
2539
+ role: 'user',
2540
+ content: `[system] This turn requires an actual structured tool call, but no tool ran. Emit exactly one real function call now using one of: ${formatToolMenu(knownToolNames)}. Do not explain, narrate, or print tool-call markup as text.`,
2541
+ });
2542
+ continue;
2543
+ }
2544
+ throw new Error(`The model did not emit a required tool call after ${LOCAL_TURN_LIMITS.noProgress} corrective retries. No tool ran.`);
2545
+ }
2546
+ if (toolCalls.length === 0) {
2547
+ this.messages.push({ role: 'assistant', content: turnContent });
2548
+ if (finishReason === 'length') {
2549
+ this.emitWarning('Model output was cut off at the length limit. The last turn may be incomplete.');
2550
+ }
2551
+ log.info(`turn#${seq} END ok afterMs=${Date.now() - start} ` +
2552
+ `replyChars=${fullText.length} loopTurns=${turn + 1}`);
2553
+ return fullText;
2554
+ }
2555
+ // Mixed-success path: at least one tool call ran (structured or
2556
+ // repaired) but some marker bodies still couldn't be parsed.
2557
+ // Mention the skipped attempts so the user understands why a
2558
+ // tool they expected to fire didn't, without blocking the work
2559
+ // that did succeed.
2560
+ //
2561
+ // Suppressed for profiles that opt into `parse.gemma-special-token` —
2562
+ // for those families a malformed marker alongside successful
2563
+ // calls is a known family tic, not a user-actionable problem.
2564
+ // The successful call is what the user sees in the UI; the
2565
+ // skipped one would just confuse them ("did the tool fire or
2566
+ // not?"). Logged as `info` instead so the diagnostic trail
2567
+ // remains.
2568
+ if (unrepairedBodies.length > 0) {
2569
+ if (profileHasBehavior(this.deps.profile, 'parse.gemma-special-token')) {
2570
+ log.info(`turn#${seq}.${turn} ${unrepairedBodies.length} unrepaired marker body${unrepairedBodies.length === 1 ? '' : 's'} alongside ${toolCalls.length} successful call${toolCalls.length === 1 ? '' : 's'} (suppressed user warning — known family tic)`);
2571
+ }
2572
+ else {
2573
+ this.emitWarning(`${unrepairedBodies.length} additional tool-call attempt` +
2574
+ `${unrepairedBodies.length === 1 ? ' was' : 's were'} malformed and skipped.`);
2575
+ }
2576
+ }
2577
+ this.messages.push({
2578
+ role: 'assistant',
2579
+ content: turnContent || null,
2580
+ tool_calls: toolCalls,
2581
+ });
2582
+ let abortDueToFailureLoop = null;
2583
+ let asyncHandoffCount = 0;
2584
+ let terminalActionClosing = null;
2585
+ const toolImages = [];
2586
+ const immediateFileWritePaths = [];
2587
+ // Set when an immediate-write / continuation write this turn was
2588
+ // EOS-flushed (truncated mid-content) — drives the bail-vs-loop
2589
+ // decision below.
2590
+ let immediateWriteTruncated = false;
2591
+ for (const call of toolCalls) {
2592
+ this.turnPolicy.checkpoint();
2593
+ if (terminalActionClosing) {
2594
+ this.messages.push({
2595
+ role: 'tool',
2596
+ content: TERMINAL_ACTION_SKIPPED_OUTPUT,
2597
+ tool_call_id: call.id,
2598
+ });
2599
+ continue;
2600
+ }
2601
+ let args = {};
2602
+ let malformedArgs = false;
2603
+ try {
2604
+ args = call.function.arguments.length > 0 ? JSON.parse(call.function.arguments) : {};
2605
+ }
2606
+ catch {
2607
+ malformedArgs = true;
2608
+ }
2609
+ malformedArgs ||= !args || typeof args !== 'object' || Array.isArray(args);
2610
+ let output;
2611
+ // Intercept duplicate ask_user_question calls within the
2612
+ // same turn — synthesize a response that tells the model
2613
+ // its first question is already pending and to end the
2614
+ // turn. The second card is suppressed entirely (no API
2615
+ // call, no UI clutter).
2616
+ if (malformedArgs) {
2617
+ if (!this.turnPolicy.retry('malformed', `args:${call.function.name}:${call.function.arguments}`)) {
2618
+ throw new Error('Malformed tool-call recovery budget exhausted; no tool was executed.');
2619
+ }
2620
+ args = {};
2621
+ output = `ERROR: ${call.function.name} was not executed because its arguments are not a valid JSON object. Retry with the declared fields.`;
2622
+ }
2623
+ else if (!this.supportsImageInput &&
2624
+ call.function.name.replaceAll('-', '_') === 'read_image_as_base64') {
2625
+ // Do not execute the bridge: that would emit a successful image-read
2626
+ // receipt although this engine can never load a vision tower and its
2627
+ // pixels would be dropped. A craftbook must not approve fabricated inspection.
2628
+ output =
2629
+ 'ERROR: Image inspection is unavailable in this text-only MLX runtime. No image was delivered. Use a provider with image input support; do not claim visual observations or approve a visual review.';
2630
+ }
2631
+ else if (opts?.fileTurnIntent &&
2632
+ FILE_REPAIR_MUTATION_TOOLS.has(call.function.name) &&
2633
+ (opts.fileTurnIntent.kind === 'create-file' ||
2634
+ opts.fileTurnIntent.mutationPath ||
2635
+ opts.fileTurnIntent.strategy === 'rewrite') &&
2636
+ (typeof args.path !== 'string' ||
2637
+ normalizeWorkspacePathForCompare(args.path) !==
2638
+ normalizeWorkspacePathForCompare(opts.fileTurnIntent.kind === 'repair-file'
2639
+ ? (opts.fileTurnIntent.mutationPath ?? opts.fileTurnIntent.path)
2640
+ : opts.fileTurnIntent.path))) {
2641
+ output = 'ERROR: This mutation must use the requested output path.';
2642
+ }
2643
+ else if (fileRepairTurn &&
2644
+ remainingReadPaths.length &&
2645
+ (call.function.name !== 'read_file' ||
2646
+ typeof args.path !== 'string' ||
2647
+ remainingPrerequisiteRepairReadPaths(remainingReadPaths, [args.path]).length ===
2648
+ remainingReadPaths.length)) {
2649
+ output = 'ERROR: Read the remaining required source paths before editing.';
2650
+ }
2651
+ else if (call.function.name === 'ask_user_question' && askedQuestionThisTurn) {
2652
+ output =
2653
+ "You already posted a question card to the user earlier in this turn. The second `ask_user_question` call was suppressed so the user only sees one card. END YOUR TURN NOW — the user's answer to the first question will arrive as the next user message; this turn produces no further visible content.";
2654
+ }
2655
+ else if ((call.function.name === 'start_project' || call.function.name === 'start_job') &&
2656
+ startedProjectOrJobThisTurn) {
2657
+ // Success-led phrasing — wild-caught on gemma4-26b/MLX,
2658
+ // the previous "DO NOT call..." wording got misread by
2659
+ // the model as "your call was malformed, retry it" and
2660
+ // looped for 10+ iterations before MAX_TOOL_LOOP_TURNS
2661
+ // bailed. Leading with "✓ created" + the original
2662
+ // tool result makes the success unambiguous; the STOP
2663
+ // instruction is the closing emphasis, not the framing.
2664
+ projectMacroInterceptCount++;
2665
+ output = `✓ The project was successfully created on your earlier \`${startedProjectOrJobThisTurn.tool}\` call this turn — kickoff is complete and the lead is on it.\n\nOriginal result:\n${startedProjectOrJobThisTurn.firstResult}\n\nSTOP — do NOT emit any more tool calls this turn. Each project-start call creates a real project, lead, and kickoff task; repeating the macro would create duplicates. END YOUR TURN NOW with a one-sentence summary to the user (e.g. "Project is on it — the voorman is leading"). The lead handles the work from here.`;
2666
+ if (projectMacroInterceptCount >= PROJECT_MACRO_INTERCEPT_CAP) {
2667
+ forceProjectMacroBail = {
2668
+ closingText: deriveProjectMacroClosing(startedProjectOrJobThisTurn.firstResult),
2669
+ };
2670
+ }
2671
+ }
2672
+ else if (advertisedBridgeToolNames.has(call.function.name) &&
2673
+ this.deps.bridges.isRestrictedFromCalling(call.function.name)) {
2674
+ // The grammar keeps a native call inside the callable set; this
2675
+ // catches calls salvaged from text, which the grammar never sees.
2676
+ output = `ERROR: \`${call.function.name}\` is not available for this request. Call one of: ${formatToolMenu(new Set(callableRequestToolNames(tools, this.deps.bridges)))}.`;
2677
+ }
2678
+ else if (advertisedBridgeToolNames.has(call.function.name) &&
2679
+ this.deps.bridges.hasTool(call.function.name)) {
2680
+ const missingRequired = missingTopLevelRequiredToolArgs(toolArgSchemas.get(call.function.name), args);
2681
+ const grammarContractViolation = missingRequired.length > 0 &&
2682
+ hermesRequiredArgGrammarRequested(body) &&
2683
+ !this.forceSequentialToolGrammar;
2684
+ if (grammarContractViolation) {
2685
+ this.forceSequentialToolGrammar = true;
2686
+ log.warn(`turn#${seq}.${turn} required-argument grammar contract violation tool=${call.function.name} missing=${missingRequired.join(',')} — latching this session to sequential decode`);
2687
+ // Do not send a call that the advertised schema proves cannot
2688
+ // pass validation. Keeping it out of the bridge also avoids
2689
+ // poisoning the session-wide unresolved-failure ledger before
2690
+ // the sequential retry gets a chance to recover.
2691
+ output = `ERROR: ${call.function.name} was not executed because the constrained decoder omitted required fields: ${missingRequired.join(', ')}. The runtime switched this session to sequential decoding; retry the call once.`;
2692
+ }
2693
+ else {
2694
+ try {
2695
+ const budgetChars = computeToolBudgetChars(this.deps.numCtx, this.estimatePromptChars());
2696
+ let unseenImages = 0;
2697
+ output = await this.deps.bridges.callTool(call.function.name, args, {
2698
+ budgetChars,
2699
+ numCtxTokens: this.deps.numCtx,
2700
+ onApprovalPending: () => {
2701
+ askedQuestionThisTurn = true;
2702
+ },
2703
+ onImages: (images) => {
2704
+ if (this.supportsImageInput)
2705
+ toolImages.push(...images.map((i) => i.base64));
2706
+ else
2707
+ unseenImages += images.length;
2708
+ },
2709
+ });
2710
+ output += unseenToolImagesNote(unseenImages);
2711
+ }
2712
+ catch (err) {
2713
+ output = `ERROR: ${err instanceof Error ? err.message : String(err)}`;
2714
+ }
2715
+ // Defense in depth for a schema skew between the model-facing
2716
+ // roster and the authoritative MCP validator. The local presence
2717
+ // check above catches the normal case before execution; this arm
2718
+ // catches a validator that knows about a required key absent from
2719
+ // the advertised schema. Sequential decode may still recover on
2720
+ // the next iteration, and the log now makes the skew explicit.
2721
+ if (validatorReportedMissingRequiredArgs(output) &&
2722
+ hermesRequiredArgGrammarRequested(body) &&
2723
+ !this.forceSequentialToolGrammar) {
2724
+ this.forceSequentialToolGrammar = true;
2725
+ log.warn(`turn#${seq}.${turn} MCP validator reported a required-argument violation not visible in the emitted call for tool=${call.function.name} — latching this session to sequential decode`);
2726
+ }
2727
+ }
2728
+ // Successful ask_user_question (output not an ERROR
2729
+ // and not the suppression sentinel above) → arm the
2730
+ // dedup guard and the post-ask fold for the rest of
2731
+ // this turn.
2732
+ if (call.function.name === 'ask_user_question' && !output.startsWith('ERROR:')) {
2733
+ askedQuestionThisTurn = true;
2734
+ }
2735
+ if (isSuccessfulAsyncHandoff(call.function.name, output)) {
2736
+ asyncHandoffCount++;
2737
+ }
2738
+ if ((immediateFileWriteTurn || writeContinuationActive) &&
2739
+ (call.function.name === 'write_file' || call.function.name === 'append_to_file') &&
2740
+ !output.startsWith('ERROR:')) {
2741
+ if (typeof args.path === 'string')
2742
+ immediateFileWritePaths.push(args.path);
2743
+ // If this write was itself truncated, the file still isn't
2744
+ // complete — flag it so the bail block loops a continuation
2745
+ // instead of declaring the file done.
2746
+ if (truncatedCallIds.has(call.id))
2747
+ immediateWriteTruncated = true;
2748
+ }
2749
+ // Successful start_project / start_job → arm the
2750
+ // per-turn macro guard so subsequent variants get
2751
+ // intercepted above before reaching the bridge.
2752
+ if ((call.function.name === 'start_project' || call.function.name === 'start_job') &&
2753
+ !output.startsWith('ERROR:')) {
2754
+ startedProjectOrJobThisTurn = { tool: call.function.name, firstResult: output };
2755
+ }
2756
+ }
2757
+ else {
2758
+ output = `ERROR: tool ${call.function.name} is not available`;
2759
+ }
2760
+ // Auto-continuation hint for truncated write-shaped tool
2761
+ // calls. Wild-caught on Qwen 3.6 27B write_file calls whose
2762
+ // `<parameter=content>…` stream-truncated before the closer
2763
+ // arrived (Hermes shape), and on Gemma 4 26B prose-shape /
2764
+ // JSON-envelope `write_file({...})` calls cut mid-content.
2765
+ // The bridge executed the call with the partial body (an
2766
+ // incomplete file on disk); without this hint the model
2767
+ // just narrates "I wrote the file" on the next turn and
2768
+ // the trial times out. Shared with Ollama + llama-cpp
2769
+ // providers via {@link appendTruncationHintToToolResult}.
2770
+ if (truncatedCallIds.has(call.id)) {
2771
+ const before = output.length;
2772
+ output = appendTruncationHintToToolResult(output, call.function.name, args);
2773
+ if (output.length !== before) {
2774
+ const path = typeof args.path === 'string' ? args.path : '(unknown)';
2775
+ const bytes = typeof args.content === 'string' ? args.content.length : 0;
2776
+ log.info(`turn#${seq} auto-continuation hint appended to tool=${call.function.name} id=${call.id} path=${path} bytes=${bytes}`);
2777
+ }
2778
+ }
2779
+ this.turnPolicy.checkpoint();
2780
+ requiredInputReads.push(...requiredInputsRead(call.function.name, args, output));
2781
+ if (fileRepairTurn) {
2782
+ if (FILE_REPAIR_READ_TOOLS.has(call.function.name))
2783
+ repairReadCalls++;
2784
+ repairReadPaths.push(...completeWorkspaceReadPaths(call.function.name, args, output));
2785
+ if (FILE_REPAIR_MUTATION_TOOLS.has(call.function.name)) {
2786
+ if (output.startsWith('ERROR:'))
2787
+ repairFailedMutations++;
2788
+ else
2789
+ repairMutationSucceeded = true;
2790
+ }
2791
+ }
2792
+ const tracked = failureTracker.recordResult(call.function.name, output);
2793
+ terminalActionClosing ??= terminalToolClosingText(this.deps.terminalToolPolicy, call.function.name, args, output);
2794
+ // Layer the same-args repeat tracker on top of the failure
2795
+ // tracker. The failure tracker catches "tool keeps erroring";
2796
+ // the repeat tracker catches "tool keeps succeeding with the
2797
+ // same args while the model spins on planning prose" — the
2798
+ // narrative-spinning loop the user reported on Ada.
2799
+ const repeated = repeatTracker.recordCall(call.function.name, args, tracked.output);
2800
+ const paced = deliverableReadPaceTracker?.recordCall(call.function.name, repeated.output);
2801
+ const readyFooter = await deliverableReadySteer?.footerFor(call.function.name, args, output, () => knownToolNames);
2802
+ if (readyFooter) {
2803
+ log.info(`turn#${seq} deliverable-ready footer appended tool=${call.function.name} path=${deliverableReadySteer?.deliverableFile} fired=${deliverableReadySteer?.firedCount}`);
2804
+ }
2805
+ // Always push the (possibly-augmented) tool message so the
2806
+ // transcript reflects what the model would see — even on
2807
+ // hard abort, debugging the runaway is easier with the
2808
+ // last context preserved.
2809
+ this.messages.push({
2810
+ role: 'tool',
2811
+ content: readyFooter
2812
+ ? `${paced?.output ?? repeated.output}\n\n${readyFooter}`
2813
+ : (paced?.output ?? repeated.output),
2814
+ tool_call_id: call.id,
2815
+ });
2816
+ if (tracked.shouldAbort) {
2817
+ abortDueToFailureLoop = {
2818
+ tool: call.function.name,
2819
+ count: tracked.count,
2820
+ ...(tracked.sourceFailureKind
2821
+ ? { sourceFailureKind: tracked.sourceFailureKind }
2822
+ : {}),
2823
+ ...(tracked.transportFailure ? { transportFailure: true } : {}),
2824
+ ...(tracked.missingPath ? { missingPath: true } : {}),
2825
+ };
2826
+ break;
2827
+ }
2828
+ if (paced?.shouldAbort && deliverableReadPaceTracker) {
2829
+ throw deliverableReadPaceTracker.buildAbort('Mac AI');
2830
+ }
2831
+ if (repeated.shouldAbort) {
2832
+ log.error(`turn#${seq} END abort-repeat-loop afterMs=${Date.now() - start} ` +
2833
+ `tool=${call.function.name} sameArgsCalls=${repeated.count}`);
2834
+ throw ToolRepeatTracker.buildAbort({
2835
+ providerLabel: 'Mac AI',
2836
+ toolName: call.function.name,
2837
+ args,
2838
+ count: repeated.count,
2839
+ registeredTools: knownToolNames,
2840
+ ...(this.deps.activeCraftbookStep
2841
+ ? { activeStep: this.deps.activeCraftbookStep }
2842
+ : {}),
2843
+ });
2844
+ }
2845
+ }
2846
+ // Keep tool-call/result pairs adjacent, then deliver the actual pixels
2847
+ // as one vision message. Base64 must never be tokenized as tool text.
2848
+ if (toolImages.length)
2849
+ this.messages.push({
2850
+ role: 'user',
2851
+ content: TOOL_IMAGES_MESSAGE,
2852
+ images: toolImages,
2853
+ });
2854
+ if (abortDueToFailureLoop) {
2855
+ const { tool: failedTool, count: failCount } = abortDueToFailureLoop;
2856
+ log.error(`turn#${seq} END abort-failure-loop afterMs=${Date.now() - start} ` +
2857
+ `tool=${failedTool} consecutiveFails=${failCount}`);
2858
+ throw ToolFailureTracker.buildLoopAbort({
2859
+ providerLabel: 'Mac AI',
2860
+ loop: abortDueToFailureLoop,
2861
+ surgicalEditsAvailable,
2862
+ delegationAvailable,
2863
+ ...writerFlags,
2864
+ });
2865
+ }
2866
+ this.turnPolicy.checkpoint();
2867
+ if (fileRepairTurn && repairMutationSucceeded) {
2868
+ const closing = 'Updated the requested file.';
2869
+ this.messages.push({ role: 'assistant', content: closing });
2870
+ return closing;
2871
+ }
2872
+ if (!terminalActionClosing && deliverableReadySteer) {
2873
+ const backstop = await deliverableReadySteer.backstopClosing();
2874
+ if (backstop) {
2875
+ log.info(`turn#${seq} deliverable-ready backstop: ${deliverableReadySteer.deliverableFile} stayed ready through the grace iterations; ending turn for the end-of-turn advance`);
2876
+ terminalActionClosing = backstop;
2877
+ }
2878
+ }
2879
+ if (terminalActionClosing) {
2880
+ // The action itself is the terminal outcome. Do not spend another
2881
+ // generation asking the model to re-analyze a board that changed
2882
+ // under it; persist one short line and release the turn.
2883
+ this.messages.push({ role: 'assistant', content: terminalActionClosing });
2884
+ fullText = terminalActionClosing;
2885
+ log.info(`turn#${seq} END terminal-tool-bail afterMs=${Date.now() - start} loopTurns=${turn + 1}`);
2886
+ return fullText;
2887
+ }
2888
+ if (immediateFileWritePaths.length > 0) {
2889
+ // File was truncated mid-content and we still have continuation
2890
+ // budget — keep the turn alive instead of bailing on a partial.
2891
+ // The tool result already carries the "append the rest" hint;
2892
+ // surfacing `append_to_file` (above) lets the model act on it.
2893
+ if (immediateWriteTruncated && this.turnPolicy.retry('writeContinuations')) {
2894
+ writeContinuationActive = true;
2895
+ writeContinuations++;
2896
+ // Make append_to_file callable + salvageable for the next turn.
2897
+ advertisedBridgeToolNames.add('append_to_file');
2898
+ knownToolNames.add('append_to_file');
2899
+ log.info(`turn#${seq} immediate-write truncated — auto-continuation ${writeContinuations}/${MAX_IMMEDIATE_WRITE_CONTINUATIONS} (append_to_file surfaced) paths=${immediateFileWritePaths.join(',')}`);
2900
+ continue;
2901
+ }
2902
+ const closingText = immediateFileWriteClosing(immediateFileWritePaths);
2903
+ this.messages.push({ role: 'assistant', content: closingText });
2904
+ this.lastTurnBail = 'immediate-write';
2905
+ fullText = closingText;
2906
+ log.info(`turn#${seq} END immediate-write-bail afterMs=${Date.now() - start} ` +
2907
+ `paths=${immediateFileWritePaths.join(',') || '(unknown)'} loopTurns=${turn + 1}` +
2908
+ `${writeContinuations > 0 ? ` continuations=${writeContinuations}` : ''}`);
2909
+ return fullText;
2910
+ }
2911
+ if (asyncHandoffCount > 0) {
2912
+ const closingText = asyncHandoffClosing(asyncHandoffCount);
2913
+ this.messages.push({ role: 'assistant', content: closingText });
2914
+ fullText = closingText;
2915
+ log.info(`turn#${seq} END async-handoff-bail afterMs=${Date.now() - start} ` +
2916
+ `handoffs=${asyncHandoffCount} loopTurns=${turn + 1}`);
2917
+ return fullText;
2918
+ }
2919
+ if (forceProjectMacroBail) {
2920
+ // Model emitted PROJECT_MACRO_INTERCEPT_CAP+ redundant
2921
+ // start_project / start_job envelopes despite the per-turn
2922
+ // guard returning a clear "STOP" message each time. The
2923
+ // project IS real — we hold onto the first call's result
2924
+ // text — but the model can't be talked into wrapping the
2925
+ // turn, so we synthesize a one-line closing and exit. The
2926
+ // user sees a clean bubble instead of minutes of malformed
2927
+ // retry prose.
2928
+ log.warn(`turn#${seq} END force-macro-bail afterMs=${Date.now() - start} ` +
2929
+ `intercepts=${projectMacroInterceptCount} loopTurns=${turn + 1}`);
2930
+ this.messages.push({ role: 'assistant', content: forceProjectMacroBail.closingText });
2931
+ fullText = forceProjectMacroBail.closingText;
2932
+ this.emitWarning(`On-device model emitted ${projectMacroInterceptCount + 1} \`${startedProjectOrJobThisTurn?.tool ?? 'start_project'}\` attempts in one turn after the first one succeeded. The runtime ended the turn for you — the project is real and the lead is on it. Consider switching the Meester to a cloud model if this recurs.`);
2933
+ return fullText;
2934
+ }
2935
+ // Terminal: the model posted a question card to the user — end the
2936
+ // turn (its answer arrives as the next user message). Without this the
2937
+ // loop issues another generation request and can wedge in-flight; see
2938
+ // the llama-cpp provider for the full "stuck session" rationale.
2939
+ if (askedQuestionThisTurn)
2940
+ return fullText;
2941
+ }
2942
+ throw new Error(`[mlx] too many tool-call loops (>${MAX_TOOL_LOOP_TURNS}); aborting to prevent runaway`);
2943
+ }
2944
+ catch (err) {
2945
+ turnFailed = true;
2946
+ log.info(`turn#${seq} END throw afterMs=${Date.now() - start} ` +
2947
+ `${err instanceof Error ? err.message.slice(0, 200) : String(err)}`);
2948
+ throw err;
2949
+ }
2950
+ finally {
2951
+ if (!turnFailed) {
2952
+ try {
2953
+ emitTerminalTelemetry();
2954
+ }
2955
+ catch (err) {
2956
+ // Telemetry must never turn a completed model response into a
2957
+ // failed user turn. Preserve the reply and leave a diagnostic.
2958
+ log.warn(`turn#${seq} terminal telemetry failed: ${err instanceof Error ? err.message : String(err)}`);
2959
+ }
2960
+ }
2961
+ releaseActiveEngineRequest?.();
2962
+ clearInterval(statusHeartbeat);
2963
+ this.deps.provider._deregisterActiveSession(this);
2964
+ }
2965
+ }
2966
+ providerState() {
2967
+ return {};
2968
+ }
2969
+ getRegisteredToolNames() {
2970
+ if (this.deps.bridges.isEmpty())
2971
+ return [];
2972
+ return this.deps.bridges.getOpenAITools().map((t) => t.name);
2973
+ }
2974
+ setSystemMessage(text) {
2975
+ // Replace the system message in-place. The constructor always
2976
+ // installs a `{role: 'system'}` entry at index 0, so this is
2977
+ // safe even after a turn or two have appended user/assistant
2978
+ // messages — those stay where they are.
2979
+ if (this.messages.length === 0 || this.messages[0]?.role !== 'system') {
2980
+ this.messages.unshift({ role: 'system', content: text });
2981
+ this.currentTurnStartIdx += 1;
2982
+ }
2983
+ else {
2984
+ this.messages[0] = { role: 'system', content: text };
2985
+ }
2986
+ // Keep the prefix-cache mapping in sync with the bytes actually sent
2987
+ // (the next prepareForSend hashes deps.systemMessage / the layered
2988
+ // `project` band). Without this a mid-session refresh would key the
2989
+ // cache on the original prompt. The refresh only changes the tools
2990
+ // block (late `project` layer), so update `project`, keep `gezel`.
2991
+ this.deps.systemMessage = text;
2992
+ if (this.deps.systemPromptLayers) {
2993
+ this.deps.systemPromptLayers = {
2994
+ gezel: this.deps.systemPromptLayers.gezel,
2995
+ project: text,
2996
+ };
2997
+ }
2998
+ }
2999
+ async disconnect() {
3000
+ this.clearHandlers();
3001
+ const adapter = this.deps.provider.getCacheAdapter();
3002
+ if (adapter && this.fallbackCacheUsed) {
3003
+ // Ephemeral/one-shot sessions only need their fallback cache while the
3004
+ // MlxSession is alive (especially across tool-loop iterations). Do not
3005
+ // leave one never-resumable disk entry per background completion.
3006
+ await adapter.evict([this.fallbackCacheId]).catch((err) => {
3007
+ log.warn(`failed to evict ephemeral cache ${this.fallbackCacheId}: ${err instanceof Error ? err.message : String(err)}`);
3008
+ });
3009
+ }
3010
+ try {
3011
+ await this.deps.bridges.stop();
3012
+ }
3013
+ catch {
3014
+ /* ignore */
3015
+ }
3016
+ }
3017
+ }