mlx-bun 0.5.0 → 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 (640) hide show
  1. package/README.md +1449 -109
  2. package/THIRD_PARTY_NOTICES.md +882 -0
  3. package/bin/mlx-bun.convert.mjs +4 -0
  4. package/bin/mlx-bun.fuse.mjs +4 -0
  5. package/bin/mlx-bun.generate.mjs +4 -0
  6. package/bin/mlx-bun.lora.mjs +4 -0
  7. package/bin/mlx-bun.mjs +13 -21
  8. package/bin/mlx-bun.server.mjs +4 -0
  9. package/bin/mlx-bun.upload.mjs +4 -0
  10. package/dist/web/app.js +9584 -0
  11. package/package.json +50 -53
  12. package/src/cli/ane-install.ts +76 -0
  13. package/src/cli/args.ts +179 -0
  14. package/src/cli/engine-entry.ts +68 -0
  15. package/src/cli/inference.ts +214 -0
  16. package/src/cli/job-entry.ts +97 -0
  17. package/src/cli/library-host.ts +117 -0
  18. package/src/cli/main.ts +57 -0
  19. package/src/cli/memory-engine.ts +163 -0
  20. package/src/cli/memory.ts +36 -0
  21. package/src/cli/mlx-lm-aliases.ts +309 -0
  22. package/src/cli/model-choice.ts +33 -0
  23. package/src/cli/model-selection.ts +84 -0
  24. package/src/cli/module-host.ts +11 -0
  25. package/src/cli/module-verbs.ts +50 -0
  26. package/src/cli/numerical-policy.ts +12 -0
  27. package/src/cli/perplexity.ts +89 -0
  28. package/src/cli/serve-host.ts +423 -0
  29. package/src/cli/serve-isolated.ts +217 -0
  30. package/src/cli/serve-options.ts +106 -0
  31. package/src/cli/serve-state.ts +205 -0
  32. package/src/cli/serve.ts +500 -0
  33. package/src/cli/served-model-host.ts +102 -0
  34. package/src/cli/server-entry.ts +148 -0
  35. package/src/cli/serving-unit.ts +177 -0
  36. package/src/{tui.ts → cli/terminal.ts} +13 -8
  37. package/src/cli/worker-adapters.ts +34 -0
  38. package/src/cli/worker-entry.ts +272 -0
  39. package/src/cli/worker-unit.ts +179 -0
  40. package/src/engine/adapter-operation.ts +26 -0
  41. package/src/engine/cache-services.ts +114 -0
  42. package/src/engine/completion.ts +39 -0
  43. package/src/engine/generation-gateway.ts +358 -0
  44. package/src/engine/index.ts +59 -0
  45. package/src/engine/kv-budget.ts +120 -0
  46. package/src/engine/media-preparation.ts +232 -0
  47. package/src/engine/model-binding.ts +24 -0
  48. package/src/engine/model-host.ts +513 -0
  49. package/src/engine/model-serving.ts +89 -0
  50. package/src/{serve → engine}/preparation.ts +7 -5
  51. package/src/{serve → engine}/session-completion-engine.ts +6 -6
  52. package/src/engine/telemetry.ts +109 -0
  53. package/src/hub/downloads.ts +116 -0
  54. package/src/jobs/db.ts +16 -7
  55. package/src/jobs/events.ts +29 -0
  56. package/src/jobs/executable.ts +5 -0
  57. package/src/jobs/host.ts +51 -0
  58. package/src/jobs/in-process.ts +55 -0
  59. package/src/{contracts/jobs.ts → jobs/protocol.ts} +7 -9
  60. package/src/jobs/runner.ts +150 -231
  61. package/src/jobs/service.ts +75 -0
  62. package/src/jobs/sse.ts +59 -23
  63. package/src/jobs/worker-memory.ts +46 -0
  64. package/src/jobs/worker-process.ts +163 -0
  65. package/src/jobs/worker-supervisor.ts +258 -0
  66. package/src/modules.ts +58 -0
  67. package/src/publishing/catalog-hub.ts +18 -0
  68. package/src/publishing/credentials.ts +31 -0
  69. package/src/publishing/upload.ts +37 -0
  70. package/src/residency/leased-adapters.ts +21 -0
  71. package/src/residency/local-records.ts +16 -0
  72. package/src/residency/model-residency.ts +405 -0
  73. package/src/residency/record-index.ts +28 -0
  74. package/src/residency/resident-estimate.ts +50 -0
  75. package/src/{anthropic.ts → server/anthropic.ts} +3 -81
  76. package/src/server/cache-routes.ts +36 -0
  77. package/src/{serve → server}/chat-request.ts +10 -10
  78. package/src/{serve → server}/chat-stage.ts +14 -14
  79. package/src/{client.ts → server/client.ts} +28 -2
  80. package/src/{serve → server}/completion-executor.ts +33 -41
  81. package/src/server/decision-body.ts +55 -0
  82. package/src/server/decision-routes.ts +134 -0
  83. package/src/server/decision-wire.ts +228 -0
  84. package/src/{serve → server}/discovery-routes.ts +25 -82
  85. package/src/{serve → server}/http.ts +25 -13
  86. package/src/{serve → server}/inference-request.ts +5 -5
  87. package/src/server/job-routes.ts +20 -0
  88. package/src/server/media-prompt.ts +63 -0
  89. package/src/server/memory-completion-client.ts +46 -0
  90. package/src/server/model-routes.ts +192 -0
  91. package/src/{serve → server}/openai-wire.ts +3 -2
  92. package/src/{serve → server}/pipeline.ts +1 -1
  93. package/src/server/prompt-builder.ts +13 -0
  94. package/src/server/prompt-contracts.ts +17 -0
  95. package/src/server/proxy-routes.ts +187 -0
  96. package/src/server/publishing-routes.ts +41 -0
  97. package/src/{serve → server}/request-plan.ts +6 -8
  98. package/src/{serve → server}/request-prep.ts +47 -50
  99. package/src/{serve → server}/responses-client.ts +30 -13
  100. package/src/{responses.ts → server/responses.ts} +12 -93
  101. package/src/server/routes.ts +205 -0
  102. package/src/server/start.ts +72 -0
  103. package/src/server/status-routes.ts +108 -0
  104. package/src/{serve → server}/text-completion-stage.ts +25 -4
  105. package/src/server/text-prompt.ts +12 -0
  106. package/src/{serve → server}/token-streams.ts +36 -38
  107. package/src/server/worker-routes.ts +311 -0
  108. package/src/storage/paths.ts +61 -0
  109. package/src/web/assets.ts +37 -0
  110. package/src/web/{src → browser}/api.ts +5 -23
  111. package/src/web/browser/chat-host.ts +17 -0
  112. package/src/web/browser/installed-panels.ts +6 -0
  113. package/src/web/browser/main.ts +55 -0
  114. package/src/web/browser/markdown.ts +279 -0
  115. package/src/web/browser/memory-panel.ts +9 -0
  116. package/src/web/browser/palette.ts +64 -0
  117. package/src/web/browser/protocol.ts +13 -0
  118. package/src/web/browser/shell.ts +467 -0
  119. package/src/web/{src → browser}/status.ts +32 -36
  120. package/src/web/build.ts +51 -0
  121. package/src/web/public/app.html +1054 -0
  122. package/src/web/{sw.js → public/sw.js} +4 -3
  123. package/src/web/{vendor → public/vendor}/hljs-theme.css +2 -2
  124. package/src/web/{vendor → public/vendor}/hljs.js +2 -2
  125. package/THIRD_PARTY_LICENSES.md +0 -51
  126. package/docs/README.md +0 -107
  127. package/docs/archive/hlg-sampling.md +0 -350
  128. package/docs/archive/investigations/audio-input-plan.md +0 -356
  129. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +0 -3617
  130. package/docs/archive/investigations/chunk-finetune-experiment.md +0 -84
  131. package/docs/archive/investigations/colibri-glm52-port.md +0 -1486
  132. package/docs/archive/investigations/colibri-oracle-pin.md +0 -1284
  133. package/docs/archive/investigations/curve-bisector-routing.md +0 -123
  134. package/docs/archive/investigations/decode-roofline-lookagain.md +0 -348
  135. package/docs/archive/investigations/diffusion-gemma-port.md +0 -602
  136. package/docs/archive/investigations/dspark-handoff.md +0 -224
  137. package/docs/archive/investigations/expert-offload-single-user-moe.md +0 -292
  138. package/docs/archive/investigations/faithful-l1-consolidation.md +0 -310
  139. package/docs/archive/investigations/hlg-sampling-investigation.md +0 -970
  140. package/docs/archive/investigations/indexshare-performance-spike.md +0 -357
  141. package/docs/archive/investigations/kernel-perf-review-2026-07.md +0 -171
  142. package/docs/archive/investigations/lab-build-journal.md +0 -157
  143. package/docs/archive/investigations/minicpm5-decode-megakernel.md +0 -489
  144. package/docs/archive/investigations/openjarvis-fit.md +0 -198
  145. package/docs/archive/investigations/openwiki-evaluation.md +0 -249
  146. package/docs/archive/investigations/optimization_plan.md +0 -346
  147. package/docs/archive/investigations/orpo-base-uf-experiment-and-directions.md +0 -120
  148. package/docs/archive/investigations/orpo-flash-cce-pin-leak.md +0 -167
  149. package/docs/archive/investigations/orpo-uf-testing-handoff.md +0 -126
  150. package/docs/archive/investigations/pareto-specialized-runtime-findings.md +0 -92
  151. package/docs/archive/investigations/pi-builtin-investigation.md +0 -308
  152. package/docs/archive/investigations/pre-colibri-stabilization.md +0 -329
  153. package/docs/archive/investigations/qwen38-27b-campaign-log.md +0 -1092
  154. package/docs/archive/investigations/segmented-backward-handoff.md +0 -148
  155. package/docs/archive/investigations/starter-model-port-handoff.md +0 -342
  156. package/docs/archive/investigations/steel-flash-cce-handoff.md +0 -489
  157. package/docs/archive/investigations/the-dreaming-handoff.md +0 -141
  158. package/docs/archive/investigations/trainer-validation-experiment.md +0 -173
  159. package/docs/archive/planning/chat-ui-vision-memory-first.md +0 -199
  160. package/docs/archive/planning/chat-ui-vision.md +0 -50
  161. package/docs/archive/planning/chunk-task-roadmap.md +0 -77
  162. package/docs/archive/planning/curve-sampler-research-plan.md +0 -331
  163. package/docs/archive/planning/journal.md +0 -316
  164. package/docs/archive/planning/memory-docs-and-dag-plan.md +0 -194
  165. package/docs/archive/planning/pi-integration-review.md +0 -178
  166. package/docs/archive/planning/release-notes-v0.0.10.md +0 -57
  167. package/docs/archive/planning/release-notes-v0.0.11.md +0 -239
  168. package/docs/archive/planning/release-notes-v0.0.12.md +0 -80
  169. package/docs/archive/planning/release-notes-v0.0.13.md +0 -77
  170. package/docs/archive/planning/release-notes-v0.0.9.md +0 -122
  171. package/docs/archive/planning/release-notes-v0.2.0.md +0 -49
  172. package/docs/archive/planning/release-notes-v0.3.0.md +0 -35
  173. package/docs/archive/planning/release-notes-v0.4.0.md +0 -45
  174. package/docs/archive/planning/release-notes-v0.5.0.md +0 -37
  175. package/docs/archive/planning/web-ui-pass-plan.md +0 -213
  176. package/docs/archive/planning/website-readme-pass-plan.md +0 -224
  177. package/docs/design/batching.md +0 -2054
  178. package/docs/design/decode-speed-program.md +0 -2404
  179. package/docs/design/dreaming-nightly-pipeline.md +0 -684
  180. package/docs/design/generic-model-support.md +0 -821
  181. package/docs/design/kv-cache.md +0 -993
  182. package/docs/design/orpo-training.md +0 -680
  183. package/docs/design/speculative-decoding.md +0 -2129
  184. package/docs/design/turboquant.md +0 -2225
  185. package/docs/design/unified-engine-frontier-plan.md +0 -1987
  186. package/docs/design/web-chat-redesign.md +0 -695
  187. package/docs/planning/PRODUCT_ROADMAP.md +0 -593
  188. package/docs/planning/ResearchTopics.md +0 -119
  189. package/docs/planning/public-readiness-handoff.md +0 -244
  190. package/docs/planning/public-readiness.md +0 -149
  191. package/docs/planning/video-series.md +0 -421
  192. package/docs/reference/benchmarks.md +0 -3587
  193. package/docs/reference/cli.md +0 -646
  194. package/docs/reference/distribution.md +0 -380
  195. package/docs/reference/environment.md +0 -494
  196. package/docs/reference/glossary.md +0 -39
  197. package/docs/reference/library-api.md +0 -688
  198. package/docs/reference/memory.md +0 -260
  199. package/docs/reference/models.md +0 -346
  200. package/docs/reference/server-api.md +0 -1945
  201. package/docs/reference/server-config.md +0 -973
  202. package/docs/reference/training.md +0 -462
  203. package/docs/reference/troubleshooting.md +0 -158
  204. package/src/admission-plan.ts +0 -18
  205. package/src/audio/audiotoolbox.ts +0 -112
  206. package/src/audio/conformer.ts +0 -655
  207. package/src/audio/decode.ts +0 -133
  208. package/src/audio/features.ts +0 -202
  209. package/src/audio/mic-capture.ts +0 -98
  210. package/src/audio/silero-vad.ts +0 -379
  211. package/src/audio/transcode.ts +0 -67
  212. package/src/audio/whisper-decode.ts +0 -774
  213. package/src/audio/whisper-fast.ts +0 -732
  214. package/src/audio/whisper-format.ts +0 -73
  215. package/src/audio/whisper-mel.ts +0 -185
  216. package/src/audio/whisper-timing.ts +0 -274
  217. package/src/audio/whisper-tokenizer.ts +0 -239
  218. package/src/audio/whisper-transcribe.ts +0 -410
  219. package/src/backends/mlx/assistant-target.ts +0 -46
  220. package/src/backends/mlx/autoregressive-draft-rows.ts +0 -120
  221. package/src/backends/mlx/autoregressive.ts +0 -129
  222. package/src/backends/mlx/batch-group.ts +0 -1874
  223. package/src/backends/mlx/cache-attachment.ts +0 -67
  224. package/src/backends/mlx/cache-layout.ts +0 -31
  225. package/src/backends/mlx/checkpoint-state.ts +0 -53
  226. package/src/backends/mlx/continuation-persistence.ts +0 -107
  227. package/src/backends/mlx/continuation-request.ts +0 -43
  228. package/src/backends/mlx/continuation.ts +0 -77
  229. package/src/backends/mlx/diffusion.ts +0 -39
  230. package/src/backends/mlx/draft-target.ts +0 -32
  231. package/src/backends/mlx/encoder-cache.ts +0 -25
  232. package/src/backends/mlx/evaluation.ts +0 -105
  233. package/src/backends/mlx/fill-append.ts +0 -58
  234. package/src/backends/mlx/fill-group.ts +0 -497
  235. package/src/backends/mlx/full-kv-row-append.ts +0 -32
  236. package/src/backends/mlx/full-kv-row-donor.ts +0 -54
  237. package/src/backends/mlx/full-transitioning-kv-rows.ts +0 -65
  238. package/src/backends/mlx/gateway-binding.ts +0 -174
  239. package/src/backends/mlx/graph.ts +0 -38
  240. package/src/backends/mlx/kv-codec.ts +0 -9
  241. package/src/backends/mlx/kv-maintenance.ts +0 -161
  242. package/src/backends/mlx/kv-tensor-rows.ts +0 -212
  243. package/src/backends/mlx/memory-client.ts +0 -189
  244. package/src/backends/mlx/methods.ts +0 -90
  245. package/src/backends/mlx/mixed-iteration.ts +0 -62
  246. package/src/backends/mlx/model-prompt.ts +0 -178
  247. package/src/backends/mlx/model-serving.ts +0 -80
  248. package/src/backends/mlx/paged-cache-codec.ts +0 -27
  249. package/src/backends/mlx/prefill-cohort.ts +0 -100
  250. package/src/backends/mlx/prefill-policy.ts +0 -26
  251. package/src/backends/mlx/prefill-rows.ts +0 -244
  252. package/src/backends/mlx/prefill.ts +0 -33
  253. package/src/backends/mlx/projected-context-rows.ts +0 -72
  254. package/src/backends/mlx/prompt-input.ts +0 -29
  255. package/src/backends/mlx/qwen-prompt-input.ts +0 -40
  256. package/src/backends/mlx/request-policy.ts +0 -20
  257. package/src/backends/mlx/request-state-policy.ts +0 -54
  258. package/src/backends/mlx/rollback.ts +0 -92
  259. package/src/backends/mlx/row-sampling.ts +0 -49
  260. package/src/backends/mlx/serial-executor.ts +0 -251
  261. package/src/backends/mlx/speculative-group.ts +0 -438
  262. package/src/backends/mlx/speculative-round.ts +0 -161
  263. package/src/backends/mlx/speculative.ts +0 -131
  264. package/src/backends/mlx/state-rows.ts +0 -104
  265. package/src/backends/mlx/state-views.ts +0 -46
  266. package/src/backends/mlx/target-layout-capability.ts +0 -35
  267. package/src/backends/mlx/text-engine.ts +0 -73
  268. package/src/backends/mlx/transitioning-kv-rows.ts +0 -173
  269. package/src/chat-template.ts +0 -382
  270. package/src/cli.ts +0 -3490
  271. package/src/config.ts +0 -399
  272. package/src/contracts/completion.ts +0 -16
  273. package/src/contracts/execution.ts +0 -70
  274. package/src/contracts/generation.ts +0 -103
  275. package/src/contracts/host.ts +0 -7
  276. package/src/contracts/object-cache.ts +0 -8
  277. package/src/contracts/pi-provider.ts +0 -30
  278. package/src/contracts/pi.ts +0 -261
  279. package/src/contracts/prefix-cache.ts +0 -33
  280. package/src/contracts/resources.ts +0 -25
  281. package/src/contracts/sampling.ts +0 -23
  282. package/src/contracts/scheduling.ts +0 -54
  283. package/src/contracts/tsconfig.json +0 -14
  284. package/src/dataset/generators.ts +0 -1087
  285. package/src/dataset/index.ts +0 -12
  286. package/src/dataset/job.ts +0 -81
  287. package/src/dataset/llm.ts +0 -71
  288. package/src/dataset/registry.ts +0 -528
  289. package/src/diffusion/diffusion-generate.ts +0 -507
  290. package/src/download.ts +0 -505
  291. package/src/embed.ts +0 -77
  292. package/src/engine/admission.ts +0 -59
  293. package/src/engine/cancellation.ts +0 -38
  294. package/src/engine/engine.ts +0 -56
  295. package/src/engine/execution-coordinator.ts +0 -70
  296. package/src/engine/execution-plan.ts +0 -64
  297. package/src/engine/execution-tasks.ts +0 -44
  298. package/src/engine/namespaced-cache.ts +0 -13
  299. package/src/engine/resources.ts +0 -53
  300. package/src/engine/scheduler.ts +0 -92
  301. package/src/engine/session.ts +0 -247
  302. package/src/eval/README.md +0 -130
  303. package/src/eval/ifeval.ts +0 -81
  304. package/src/eval/kl-prompts.ts +0 -29
  305. package/src/eval/kl.ts +0 -590
  306. package/src/eval/perplexity.ts +0 -164
  307. package/src/eval/quality-db.ts +0 -92
  308. package/src/eval/reference-logits.ts +0 -207
  309. package/src/eval/runner.ts +0 -124
  310. package/src/eval/score.ts +0 -49
  311. package/src/eval/tasks/bfcl.ts +0 -535
  312. package/src/eval/tasks/gsm8k.ts +0 -98
  313. package/src/eval/tasks/hashhop.ts +0 -139
  314. package/src/eval/tasks/humaneval.ts +0 -275
  315. package/src/eval/tasks/ifeval.ts +0 -525
  316. package/src/eval/tasks/mmlu.ts +0 -157
  317. package/src/eval/ultrafeedback.ts +0 -60
  318. package/src/evaldb.ts +0 -199
  319. package/src/expert-io.ts +0 -393
  320. package/src/expert-offload-build.ts +0 -77
  321. package/src/expert-offload.ts +0 -76
  322. package/src/expert-residency.ts +0 -1134
  323. package/src/expert-usage.ts +0 -415
  324. package/src/fill/echo-index.ts +0 -188
  325. package/src/fill/fill-session.ts +0 -463
  326. package/src/fill/proposal.ts +0 -68
  327. package/src/fill/schema-rows.ts +0 -506
  328. package/src/fill/tool-boundary.ts +0 -103
  329. package/src/fit.ts +0 -249
  330. package/src/generate.ts +0 -1352
  331. package/src/grammar.ts +0 -604
  332. package/src/harness-pi.ts +0 -184
  333. package/src/hf-push.ts +0 -628
  334. package/src/hub-rest.ts +0 -242
  335. package/src/index.ts +0 -63
  336. package/src/inference/denoising.ts +0 -17
  337. package/src/inference/draft-acceptance.ts +0 -41
  338. package/src/inference/draft-output.ts +0 -49
  339. package/src/inference/graph.ts +0 -33
  340. package/src/inference/prefill.ts +0 -45
  341. package/src/inference/rollback.ts +0 -7
  342. package/src/jobs/index.ts +0 -31
  343. package/src/jobs/job-entry.ts +0 -107
  344. package/src/jobs/task-client.ts +0 -19
  345. package/src/jobs/types.ts +0 -2
  346. package/src/kv-scheme.ts +0 -279
  347. package/src/kv-store.ts +0 -1135
  348. package/src/lab/README.md +0 -22
  349. package/src/lab/curve/curve-designer.html +0 -303
  350. package/src/lab/curve/curve-sampler.ts +0 -122
  351. package/src/lab/expert-trace/expert-trace.ts +0 -69
  352. package/src/lab/paged-kv/paged-attention.ts +0 -117
  353. package/src/lab/paged-kv/paged-kv-rows.ts +0 -111
  354. package/src/lab/paged-kv/paged-kv.ts +0 -336
  355. package/src/library.ts +0 -40
  356. package/src/lora.ts +0 -408
  357. package/src/md-modules.d.ts +0 -54
  358. package/src/media-fetch.ts +0 -442
  359. package/src/memory/article.ts +0 -558
  360. package/src/memory/chunk-validation.ts +0 -150
  361. package/src/memory/chunk.ts +0 -318
  362. package/src/memory/cluster.ts +0 -351
  363. package/src/memory/coverage.ts +0 -134
  364. package/src/memory/crosslink.ts +0 -0
  365. package/src/memory/db.ts +0 -253
  366. package/src/memory/entity.ts +0 -203
  367. package/src/memory/gate.ts +0 -187
  368. package/src/memory/history.ts +0 -152
  369. package/src/memory/ingest.ts +0 -335
  370. package/src/memory/model.ts +0 -183
  371. package/src/memory/normalize.ts +0 -671
  372. package/src/memory/parse.ts +0 -22
  373. package/src/memory/pipeline.ts +0 -275
  374. package/src/memory/prompts.ts +0 -65
  375. package/src/memory/query.ts +0 -334
  376. package/src/memory/reconcile.ts +0 -734
  377. package/src/memory/redirect.ts +0 -108
  378. package/src/memory/reindex.ts +0 -325
  379. package/src/memory/resolve.ts +0 -307
  380. package/src/memory/rest.ts +0 -245
  381. package/src/memory/route.ts +0 -272
  382. package/src/memory/schedule.ts +0 -165
  383. package/src/memory/stages.ts +0 -777
  384. package/src/memory/synthesize.ts +0 -1638
  385. package/src/memory/tools.ts +0 -749
  386. package/src/memory/vault.ts +0 -1194
  387. package/src/memory/wikify.ts +0 -1064
  388. package/src/mlx/array.ts +0 -425
  389. package/src/mlx/autograd.ts +0 -304
  390. package/src/mlx/checkpoint.ts +0 -149
  391. package/src/mlx/compile.ts +0 -168
  392. package/src/mlx/custom-vjp.ts +0 -188
  393. package/src/mlx/ffi.ts +0 -531
  394. package/src/mlx/materialize.ts +0 -23
  395. package/src/mlx/metal-kernel.ts +0 -159
  396. package/src/mlx/normalized-argmax.ts +0 -116
  397. package/src/mlx/ops.ts +0 -1108
  398. package/src/mlx/quantized-rows.ts +0 -36
  399. package/src/mlx/token-bitmask.ts +0 -52
  400. package/src/mlx/turboquant-kv-decode.ts +0 -82
  401. package/src/mlx/turboquant-kv-inverse.ts +0 -122
  402. package/src/mlx/turboquant-ops.ts +0 -861
  403. package/src/mlx/turboquant-tables.ts +0 -237
  404. package/src/mmap.ts +0 -90
  405. package/src/model/artifact-identity.ts +0 -73
  406. package/src/model/batched-kv.ts +0 -235
  407. package/src/model/batched-mask.ts +0 -279
  408. package/src/model/batched-quant.ts +0 -212
  409. package/src/model/batched-quantized-kv.ts +0 -144
  410. package/src/model/batched-rotating-quant.ts +0 -353
  411. package/src/model/batched-rotating-state.ts +0 -149
  412. package/src/model/batched-rotating.ts +0 -338
  413. package/src/model/batched-row-storage.ts +0 -179
  414. package/src/model/batched-ssm.ts +0 -52
  415. package/src/model/batched-turboquant-kv.ts +0 -94
  416. package/src/model/compiled-decode.ts +0 -712
  417. package/src/model/decoded-kv-donor.ts +0 -12
  418. package/src/model/delayed-quantized-kv.ts +0 -72
  419. package/src/model/delayed-rotating-quantized-kv.ts +0 -93
  420. package/src/model/delayed-turboquant-kv.ts +0 -57
  421. package/src/model/diffusion-gemma.ts +0 -1026
  422. package/src/model/factory.ts +0 -215
  423. package/src/model/fingerprint.ts +0 -45
  424. package/src/model/flash-attention.ts +0 -694
  425. package/src/model/full-prefill-padding.ts +0 -56
  426. package/src/model/full-prefill-row.ts +0 -105
  427. package/src/model/gemma4-base.ts +0 -2370
  428. package/src/model/gemma4.ts +0 -1395
  429. package/src/model/generated/gemma4-12b.ts +0 -429
  430. package/src/model/generated/gemma4-26b.ts +0 -448
  431. package/src/model/generated/gemma4-e4b.ts +0 -717
  432. package/src/model/generated/index.ts +0 -18
  433. package/src/model/glm52-atlas.ts +0 -511
  434. package/src/model/glm52-cache.ts +0 -792
  435. package/src/model/glm52-config.ts +0 -262
  436. package/src/model/glm52-container.ts +0 -305
  437. package/src/model/glm52-coupling.ts +0 -388
  438. package/src/model/glm52-dsa.ts +0 -606
  439. package/src/model/glm52-expert-layout.ts +0 -253
  440. package/src/model/glm52-layout.ts +0 -152
  441. package/src/model/glm52-memory.ts +0 -500
  442. package/src/model/glm52-mla.ts +0 -792
  443. package/src/model/glm52-moe.ts +0 -223
  444. package/src/model/glm52-pilot.ts +0 -537
  445. package/src/model/glm52-reference.ts +0 -445
  446. package/src/model/glm52-residency.ts +0 -557
  447. package/src/model/glm52-resident-weights.ts +0 -135
  448. package/src/model/glm52-streamed-experts.ts +0 -453
  449. package/src/model/glm52-streamed-metal.ts +0 -610
  450. package/src/model/glm52-weights.ts +0 -503
  451. package/src/model/glm52.ts +0 -1132
  452. package/src/model/implementation.ts +0 -69
  453. package/src/model/kv-attention-view.ts +0 -70
  454. package/src/model/minicpm5.ts +0 -376
  455. package/src/model/profile.ts +0 -468
  456. package/src/model/quantized-append-attention.ts +0 -31
  457. package/src/model/qwen-append.ts +0 -12
  458. package/src/model/qwen-conv.ts +0 -52
  459. package/src/model/qwen3-delta-state.ts +0 -74
  460. package/src/model/qwen3-delta.ts +0 -451
  461. package/src/model/qwen3-moe.ts +0 -404
  462. package/src/model/qwen3-mrope.ts +0 -250
  463. package/src/model/qwen3.ts +0 -277
  464. package/src/model/qwen3_5-checkpoint.ts +0 -164
  465. package/src/model/qwen3_5.ts +0 -881
  466. package/src/model/rotating-kv-donor.ts +0 -35
  467. package/src/model/rotating-kv-layout.ts +0 -262
  468. package/src/model/rotating-row-transaction.ts +0 -35
  469. package/src/model/speculative-rotating-kv.ts +0 -101
  470. package/src/model/ssm-prefill-padding.ts +0 -39
  471. package/src/model/support.ts +0 -110
  472. package/src/model/token-groups.ts +0 -66
  473. package/src/model/trellis-balanced-scatter.ts +0 -51
  474. package/src/model/trellis-linear.ts +0 -672
  475. package/src/model/trellis-shared-m.ts +0 -151
  476. package/src/model/trellis-shared-scatter.ts +0 -144
  477. package/src/model/trellis-splitk-prefill.ts +0 -158
  478. package/src/model/trellis-tiled-prefill.ts +0 -136
  479. package/src/model/trellis-vector-expand.ts +0 -62
  480. package/src/model/trellis-wide-prefill.ts +0 -94
  481. package/src/model/turboquant-codec.ts +0 -89
  482. package/src/model/universal/archs.ts +0 -332
  483. package/src/model/universal/dense.ts +0 -612
  484. package/src/model/universal/modules.ts +0 -170
  485. package/src/model/universal/rope.ts +0 -381
  486. package/src/model/whisper.ts +0 -435
  487. package/src/native/expert_io.c +0 -523
  488. package/src/native/frame_extract.swift +0 -140
  489. package/src/native/mic_capture.swift +0 -92
  490. package/src/native-pack.ts +0 -163
  491. package/src/pi-provider.ts +0 -53
  492. package/src/pi-session.ts +0 -81
  493. package/src/pi-terminal.ts +0 -274
  494. package/src/pi-web.ts +0 -1940
  495. package/src/preflight.ts +0 -158
  496. package/src/prompt-cache.ts +0 -682
  497. package/src/quantize/allocator.ts +0 -627
  498. package/src/quantize/atomic-output.ts +0 -38
  499. package/src/quantize/calibration.ts +0 -181
  500. package/src/quantize/config-writer.ts +0 -145
  501. package/src/quantize/index.ts +0 -84
  502. package/src/quantize/job.ts +0 -162
  503. package/src/quantize/quantizer.ts +0 -666
  504. package/src/quantize/rotate.ts +0 -603
  505. package/src/quantize/safetensors-writer.ts +0 -249
  506. package/src/quantize/sensitivity.ts +0 -216
  507. package/src/quantize/trellis.ts +0 -586
  508. package/src/quantize/weight-transform.ts +0 -292
  509. package/src/registry.ts +0 -602
  510. package/src/runtime-config.ts +0 -77
  511. package/src/safetensors.ts +0 -165
  512. package/src/sampler.ts +0 -1012
  513. package/src/serve/admin-routes.ts +0 -184
  514. package/src/serve/assets/curve-terrain.html +0 -105
  515. package/src/serve/assets/training-inference-map.html +0 -575
  516. package/src/serve/audio-routes.ts +0 -323
  517. package/src/serve/aux-routes.ts +0 -132
  518. package/src/serve/batch-scheduler.ts +0 -8
  519. package/src/serve/checkpoint-identity.ts +0 -64
  520. package/src/serve/generation-gateway.ts +0 -537
  521. package/src/serve/isolate.ts +0 -609
  522. package/src/serve/kv-budget.ts +0 -14
  523. package/src/serve/lab-routes.ts +0 -341
  524. package/src/serve/lane-registry.ts +0 -45
  525. package/src/serve/model-admin-routes.ts +0 -155
  526. package/src/serve/model-binding.ts +0 -49
  527. package/src/serve/model-host.ts +0 -511
  528. package/src/serve/parent-application.ts +0 -56
  529. package/src/serve/prompt-response-trace.ts +0 -161
  530. package/src/serve/response-lease.ts +0 -24
  531. package/src/serve/session-search.ts +0 -326
  532. package/src/serve/static-routes.ts +0 -101
  533. package/src/serve/transcription-server.ts +0 -64
  534. package/src/serve/transcription-service.ts +0 -502
  535. package/src/server.ts +0 -1282
  536. package/src/spec/assistant-rows.ts +0 -93
  537. package/src/spec/assistant-source.ts +0 -126
  538. package/src/spec/cache-identity.ts +0 -22
  539. package/src/spec/deepspec-rows.ts +0 -29
  540. package/src/spec/deepspec-source.ts +0 -145
  541. package/src/spec/dflash-source.ts +0 -149
  542. package/src/spec/drafter.ts +0 -304
  543. package/src/spec/dspark/ab-stats.ts +0 -136
  544. package/src/spec/dspark/calibration.ts +0 -112
  545. package/src/spec/dspark/data-dflash.ts +0 -156
  546. package/src/spec/dspark/data.ts +0 -201
  547. package/src/spec/dspark/deepspec-module.ts +0 -776
  548. package/src/spec/dspark/generate-dflash.ts +0 -245
  549. package/src/spec/dspark/generate.ts +0 -290
  550. package/src/spec/dspark/loader.ts +0 -23
  551. package/src/spec/dspark/loss.ts +0 -213
  552. package/src/spec/dspark/module-dflash.ts +0 -560
  553. package/src/spec/dspark/module.ts +0 -540
  554. package/src/spec/dspark/quantize-drafter.ts +0 -91
  555. package/src/spec/dspark/sample.ts +0 -112
  556. package/src/spec/generate.ts +0 -276
  557. package/src/spec/glm52-mtp-graph.ts +0 -29
  558. package/src/spec/glm52-mtp-rows.ts +0 -132
  559. package/src/spec/glm52-mtp-source.ts +0 -318
  560. package/src/spec/glm52-mtp-state.ts +0 -33
  561. package/src/spec/ngram-source.ts +0 -226
  562. package/src/spec/projected-draft-rows.ts +0 -73
  563. package/src/spec/qwen-mtp-module.ts +0 -171
  564. package/src/spec/qwen-mtp-rows.ts +0 -230
  565. package/src/spec/qwen-mtp-source.ts +0 -386
  566. package/src/spec/qwen-mtp-state.ts +0 -30
  567. package/src/spec/serve-loop.ts +0 -587
  568. package/src/spec/source.ts +0 -228
  569. package/src/spec/two-model-rows.ts +0 -62
  570. package/src/spec/two-model.ts +0 -113
  571. package/src/ssd-cache.ts +0 -431
  572. package/src/ssd-durability.ts +0 -209
  573. package/src/storage/host-buffer.ts +0 -24
  574. package/src/storage/kv-writer.ts +0 -83
  575. package/src/storage/kv-writer.worker.js +0 -264
  576. package/src/storage/retention-policy.ts +0 -37
  577. package/src/tiered-prompt-cache.ts +0 -45
  578. package/src/tokenizer.ts +0 -335
  579. package/src/tool-approvals.ts +0 -131
  580. package/src/tool-call.ts +0 -695
  581. package/src/train/dataset.ts +0 -484
  582. package/src/train/diffusion-lora.ts +0 -216
  583. package/src/train/export.ts +0 -32
  584. package/src/train/flash-cce.ts +0 -1234
  585. package/src/train/forward.ts +0 -266
  586. package/src/train/fuse.ts +0 -243
  587. package/src/train/index.ts +0 -46
  588. package/src/train/job.ts +0 -205
  589. package/src/train/lora-params.ts +0 -254
  590. package/src/train/loss.ts +0 -1591
  591. package/src/train/merge.ts +0 -195
  592. package/src/train/optimizer.ts +0 -183
  593. package/src/train/prefix-shared.ts +0 -490
  594. package/src/train/rank.ts +0 -137
  595. package/src/train/segmented.ts +0 -1828
  596. package/src/train/steel-qmm-header.ts +0 -1461
  597. package/src/train/trainer.ts +0 -1221
  598. package/src/train/watch.ts +0 -542
  599. package/src/vision/diffusion-vision.ts +0 -620
  600. package/src/vision/embedder.ts +0 -176
  601. package/src/vision/preprocess.ts +0 -247
  602. package/src/vision/prompt.ts +0 -409
  603. package/src/vision/qwen3vl-preprocess.ts +0 -409
  604. package/src/vision/qwen3vl-prompt.ts +0 -233
  605. package/src/vision/qwen3vl-tower.ts +0 -566
  606. package/src/vision/siglip.ts +0 -607
  607. package/src/vision/video-frames.ts +0 -148
  608. package/src/web/app.html +0 -2155
  609. package/src/web/app.js +0 -5863
  610. package/src/web/skills/memory/SKILL.md +0 -120
  611. package/src/web/skills/web-research/SKILL.md +0 -46
  612. package/src/web/skills.ts +0 -97
  613. package/src/web/src/adapters-panel.ts +0 -288
  614. package/src/web/src/assistant.ts +0 -451
  615. package/src/web/src/chat.ts +0 -1060
  616. package/src/web/src/composer.ts +0 -962
  617. package/src/web/src/dataset.ts +0 -132
  618. package/src/web/src/finetune.ts +0 -267
  619. package/src/web/src/hub.ts +0 -428
  620. package/src/web/src/main.ts +0 -98
  621. package/src/web/src/markdown.ts +0 -0
  622. package/src/web/src/memory-panel.ts +0 -572
  623. package/src/web/src/model-picker.ts +0 -159
  624. package/src/web/src/palette.ts +0 -346
  625. package/src/web/src/protocol.ts +0 -26
  626. package/src/web/src/quantize.ts +0 -167
  627. package/src/web/src/rag.ts +0 -274
  628. package/src/web/src/sessions.ts +0 -469
  629. package/src/web/src/shell.ts +0 -804
  630. package/src/web/src/ui-catalog.ts +0 -78
  631. package/src/web/src/voice.ts +0 -141
  632. package/src/web/vendor/README.md +0 -61
  633. package/src/web-assets.ts +0 -47
  634. package/src/web-tools.ts +0 -478
  635. package/src/weights.ts +0 -255
  636. /package/src/{serve → server}/completion-sink.ts +0 -0
  637. /package/src/{serve → server}/generated-token-history.ts +0 -0
  638. /package/src/web/{icon.svg → public/icon.svg} +0 -0
  639. /package/src/web/{manifest.webmanifest → public/manifest.webmanifest} +0 -0
  640. /package/src/web/{vendor → public/vendor}/hljs-LICENSE +0 -0
package/src/pi-web.ts DELETED
@@ -1,1940 +0,0 @@
1
- import { APP_ROUTE_IDS } from "./contracts/pi";
2
- export { APP_ROUTE_IDS } from "./contracts/pi";
3
- import type { ApprovalDecision, ImageAttachment, ClientMessage, UiSnapshotElement, UiSnapshot, WizardStep, AppUiContext, AppRouteId, ReadyGenDefaults, ServerMessage, HistoryToolItem, HistoryItem, SessionListItem, SamplingOverrides, Lane } from "./contracts/pi";
4
- export type { ApprovalDecision, ImageAttachment, ClientMessage, UiSnapshotElement, UiSnapshot, WizardStep, AppUiContext, AppRouteId, ReadyGenDefaults, ServerMessage, HistoryToolItem, HistoryItem, SessionListItem, SamplingOverrides } from "./contracts/pi";
5
- // mlx-bun pi-web — server-side embed of the pi agent SDK that bridges a
6
- // browser WebSocket to an in-process AgentSession driving mlx-bun's own
7
- // local model (Phase 16, web half).
8
- //
9
- // Each browser WS gets its own PiWebSession: an in-memory pi AgentSession
10
- // configured with the full coding toolset, pointed at our loopback /v1 so
11
- // model calls serialize through the server's generation queue. Session
12
- // events are translated to the WS protocol below; tool calls that mutate
13
- // (bash/edit/write) are gated through the browser via a pre-execution
14
- // approval handshake.
15
- //
16
- // PROVIDER WIRING mirrors src/harness-pi.ts exactly (the subprocess path):
17
- // provider id "mlx-bun", apiKey "sk-mlx-bun-local", model id "local",
18
- // api "openai-completions", baseUrl "http://127.0.0.1:<port>/v1". The
19
- // context window comes from the server (ctx.model.config.text
20
- // .maxPositionEmbeddings, surfaced via /v1/models -> context_window).
21
- //
22
- // APPROVAL GATE: implemented for real (NOT the documented fallback). The
23
- // pre-execution gate is the inline extension `pi.on("tool_call", ...)`
24
- // handler, wired into createAgentSession via DefaultResourceLoader's
25
- // `extensionFactories`. The handler is async, so it awaits the browser's
26
- // allow/deny decision before the tool runs and returns { block: true,
27
- // reason } to deny. Read-only tools (read/grep/find/ls) auto-allow.
28
- // tool_execution_* events still drive the tool cards (start/update/end).
29
-
30
- import { createHash } from "node:crypto";
31
- import { mkdirSync, rmSync } from "node:fs";
32
- import { homedir } from "node:os";
33
- import { join, resolve } from "node:path";
34
- import { runtimeValue } from "./runtime-config";
35
- import type { ServerWebSocket, WebSocketHandler } from "bun";
36
- import {
37
- createAgentSessionFromServices,
38
- createAgentSessionRuntime,
39
- createAgentSessionServices,
40
- defineTool,
41
- SessionManager,
42
- type AgentSession,
43
- type AgentSessionEvent,
44
- type AgentSessionRuntime,
45
- type CreateAgentSessionRuntimeFactory,
46
- type ExtensionAPI,
47
- type SessionEntry,
48
- type SessionInfo,
49
- type ToolCallEvent,
50
- type ToolDefinition,
51
- } from "@earendil-works/pi-coding-agent";
52
- import type { ImageContent } from "@earendil-works/pi-ai";
53
- import { Type } from "typebox";
54
- import { WEB_TOOL_NAMES } from "./web-tools";
55
- import { MEMORY_TOOL_NAMES, REFERENCE_TOOL_NAMES } from "./memory/tools";
56
- import { buildPiAgentSurface } from "./pi-session";
57
- import { buildPiProvider, DEFAULT_CONTEXT_WINDOW, PI_LOCAL_MODEL_ID } from "./pi-provider";
58
- import { downloadsSnapshot } from "./download";
59
- import { getLane } from "./serve/lane-registry";
60
- import { isToolAlwaysAllowed, setToolAlwaysAllowed, listAlwaysAllowedTools } from "./tool-approvals";
61
-
62
- /**
63
- * Tools that never mutate the user's machine; auto-allowed without a browser
64
- * round-trip. The web tools make outbound network requests but change nothing
65
- * locally, so they're auto-allowed too (and remain usable in read-only mode).
66
- * The app-aware assistant's three tools (get_current_app_context/
67
- * navigate_app/spotlight_ui, plan §6.6) join this set for the same reason:
68
- * navigation and spotlighting are reversible UI actions on the user's OWN
69
- * open browser tab, never a machine mutation — see createAppAwareTools'
70
- * doc comment below for the full classification rationale.
71
- */
72
- export const APP_AWARE_TOOL_NAMES = ["get_current_app_context", "navigate_app", "spotlight_ui"] as const;
73
- const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOL_NAMES, ...MEMORY_TOOL_NAMES, ...REFERENCE_TOOL_NAMES, ...APP_AWARE_TOOL_NAMES]);
74
- /** Tools that require explicit per-call browser approval. */
75
- const GATED_TOOLS = new Set(["bash", "edit", "write"]);
76
-
77
- /**
78
- * The welcome assistant's tool allowlist: exactly `read` (a local file the user
79
- * points to) and `web_search` (current/external facts). Both are in
80
- * READ_ONLY_TOOLS, so neither triggers the approval gate. Kept to two because a
81
- * 1B model over-calls a larger toolset; widen this list (not the system prompt)
82
- * to grant more.
83
- */
84
- const WELCOME_TOOLS = ["read", "web_search"] as const;
85
-
86
- /**
87
- * The web chat session's tool allowlist: the two welcome tools, plus the
88
- * memory/reference read tools whenever the surface has memory enabled, plus
89
- * — opt-in only (plan §5.4/§6.5/§9 Phase 2's "let the agent touch files on
90
- * this machine" toggle, default OFF) — the three GATED_TOOLS (bash/edit/
91
- * write) and the read-only grep/find/ls that make them useful. The system
92
- * prompt (surface.memoryHint) and the bundled memory skill instruct the
93
- * model to call memory_resolve/memory_read/… — pi treats `tools` as an
94
- * allowlist, so leaving them out makes every such call fail. All additions
95
- * except the coding tools are in READ_ONLY_TOOLS (no approval round-trip);
96
- * bash/edit/write ARE gated — this is precisely what makes the
97
- * already-built approval card fire for the first time once codingTools is
98
- * on (previously dead code: nothing in the default surface could ever
99
- * reach GATED_TOOLS). APP_AWARE_TOOL_NAMES ride along unconditionally
100
- * (like WELCOME_TOOLS) — they're read-only-on-the-machine regardless of
101
- * memory/codingTools/read-only-server state (see APP_AWARE_TOOL_NAMES'
102
- * doc comment).
103
- */
104
- export function webChatToolAllowlist(memoryEnabled: boolean, codingTools = false): string[] {
105
- return [
106
- ...WELCOME_TOOLS,
107
- ...APP_AWARE_TOOL_NAMES,
108
- ...(memoryEnabled ? [...MEMORY_TOOL_NAMES, ...REFERENCE_TOOL_NAMES] : []),
109
- ...(codingTools ? ["grep", "find", "ls", ...GATED_TOOLS] : []),
110
- ];
111
- }
112
-
113
- /** Auto-deny a pending approval after this long with no browser decision. */
114
- const APPROVAL_TIMEOUT_MS = 120_000;
115
-
116
- // ---- Loop hygiene (beat matrix Axis 7 "Tool-loop hygiene" row) --------
117
- //
118
- // What the pi SDK already provides (investigated in
119
- // node_modules/@earendil-works/pi-agent-core/dist/agent-loop.js and
120
- // pi-coding-agent/dist/core/agent-session.js before adding any of this):
121
- // - Malformed tool-call ARGS: pi-ai's `parseJsonWithRepair`/
122
- // `parseStreamingJson` (dist/utils/json-parse.js) already repairs
123
- // invalid backslash escapes/control chars and tolerates truncated
124
- // streaming JSON; `validateToolArguments` (utils/validation.js)
125
- // coerces JSON-Schema primitive types. Neither touches OUR OWN
126
- // format-specific tool-call text parsers (gemma4 `call:name{...}`,
127
- // the OpenAI/Qwen/MiniCPM5 decoded-text shapes) — that repair layer
128
- // is added in src/tool-call.ts instead (see RepairKind there).
129
- // - Repeated identical calls: NOT deduped anywhere in the SDK. Every
130
- // tool_call the model emits executes, full stop.
131
- // - Failed-call retries: NOT budgeted. A tool error becomes a
132
- // toolResult with isError:true and the loop just continues; nothing
133
- // nudges the model to stop repeating a failing call.
134
- // - Max turns / loop cap: NOT implemented by AgentSession at all (only
135
- // agent-loop.js's raw runAgentLoop is hook-driven via
136
- // config.shouldStopAfterTurn, which AgentSession never wires up or
137
- // exposes to extensions). There is no cap, so a small model that
138
- // keeps calling tools can loop until the user manually stops it.
139
- // All three gaps are closed here using the ONE mechanism AgentSession
140
- // does expose to extensions: pi.on("tool_call", ...) (before execution,
141
- // can only `block`) and pi.on("tool_result", ...) (after execution, can
142
- // reshape the result) — see installLoopHygieneHooks below. Turn-index
143
- // bookkeeping rides pi.on("turn_start"/"turn_end") (AgentSession's own
144
- // _turnIndex, which resets to 0 once per agent_start i.e. once per
145
- // session.prompt() call — see agent-session.js's _emitExtensionEvent).
146
- //
147
- // KNOWN SDK LIMITATION (documented, not silently worked around): a
148
- // `tool_call` handler's block path (ToolCallEventResult) can only
149
- // produce an ERROR toolResult (agent-loop.js's createErrorToolResult —
150
- // there is no "block but still succeed" return shape). So "dedup: skip
151
- // re-execution, return cached result" cannot literally inject a
152
- // non-error result without running the tool again — the closest honest
153
- // equivalent is blocking the repeat with the PRIOR call's actual result
154
- // text inlined into the (error-flagged) block reason, so the model
155
- // still sees the real answer instead of a bare "blocked" message and
156
- // can move on, rather than getting a second identical error with no
157
- // information gain.
158
-
159
- /** Consecutive-identical-successful-call dedup, retry budget, and
160
- * tool-turn cap. Mutable per-connection (reset at prompt time) so re-
161
- * export as a class keeps all three concerns in one bookkeeping object
162
- * that's trivial to unit test via its pure decision methods. */
163
- export const LOOP_HYGIENE = {
164
- /** After this many consecutive FAILED calls with the same signature
165
- * (name + JSON-stable args), the nudge text asks the model to change
166
- * approach instead of repeating verbatim. optiq Lab's documented
167
- * budget (beat matrix Axis 7) is 3. */
168
- MAX_CONSECUTIVE_FAILURES: 3,
169
- /** Cap on AgentSession's turnIndex (one assistant-response round, per
170
- * agent-session.js's _turnIndex) within a single user prompt before
171
- * every further tool call is blocked with a force-finish nudge.
172
- * optiq Lab's documented cap (beat matrix Axis 7) is 25. */
173
- MAX_TOOL_TURNS: 25,
174
- } as const;
175
-
176
- /** Stable key for "the same call" — used by both the dedup check and the
177
- * failure-streak counter. JSON.stringify on a plain object from parsed
178
- * tool-call args is key-order-dependent; that's fine here since we're
179
- * comparing the SDK's own re-delivered `event.input` (same object shape
180
- * each time for a literal repeat, which is the only case that matters —
181
- * semantically-equal-but-differently-ordered args are not "identical"
182
- * for dedup purposes, only byte-identical repeats are). Exported for
183
- * unit testing. */
184
- export function toolCallSignature(toolName: string, args: unknown): string {
185
- return `${toolName}:${stableStringify(args)}`;
186
- }
187
-
188
- /** Deterministic (key-sorted) JSON.stringify so argument key order never
189
- * causes two semantically-identical calls to hash differently. */
190
- function stableStringify(value: unknown): string {
191
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
192
- if (value && typeof value === "object") {
193
- const keys = Object.keys(value as Record<string, unknown>).sort();
194
- return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`).join(",")}}`;
195
- }
196
- return JSON.stringify(value);
197
- }
198
-
199
- /** Pure decision for the tool_call (before) hook: given the current loop
200
- * state and the incoming call's signature, decide whether to block it
201
- * outright (dedup or turn-cap) and, if so, why. Returns undefined when
202
- * the call should proceed to normal execution. Exported + pure so the
203
- * three policies (dedup / turn cap / [failure budget is a tool_result-
204
- * side concern, see nextFailureNudge]) are unit-testable without a live
205
- * AgentSession. */
206
- export interface LoopHygieneState {
207
- /** Signature + result text of the last call that completed successfully
208
- * (undefined if none yet this prompt). */
209
- lastSuccess?: { signature: string; resultText: string };
210
- /** Consecutive failure count keyed by call signature. */
211
- failureStreaks: Map<string, number>;
212
- /** Current AgentSession turnIndex (updated on every turn_start). */
213
- turnIndex: number;
214
- }
215
-
216
- export function initialLoopHygieneState(): LoopHygieneState {
217
- return { lastSuccess: undefined, failureStreaks: new Map(), turnIndex: 0 };
218
- }
219
-
220
- export interface LoopHygieneBlock {
221
- reason: string;
222
- }
223
-
224
- /** Decide whether to block a tool call BEFORE it runs. Checked in this
225
- * order: turn cap first (a model that's been looping for 25 rounds gets
226
- * force-finish treatment regardless of what it's calling next), then
227
- * consecutive-identical-successful-call dedup. */
228
- export function decideBeforeToolCall(
229
- state: LoopHygieneState,
230
- toolName: string,
231
- args: unknown,
232
- ): LoopHygieneBlock | undefined {
233
- if (state.turnIndex >= LOOP_HYGIENE.MAX_TOOL_TURNS) {
234
- return {
235
- reason:
236
- `Tool-turn limit (${LOOP_HYGIENE.MAX_TOOL_TURNS}) reached for this message. ` +
237
- "Stop calling tools now and answer the user directly with what you have so far.",
238
- };
239
- }
240
- const signature = toolCallSignature(toolName, args);
241
- if (state.lastSuccess && state.lastSuccess.signature === signature) {
242
- return {
243
- reason:
244
- "This exact tool call already ran and succeeded; skipping re-execution. " +
245
- `Its result was:\n\n${state.lastSuccess.resultText}\n\n` +
246
- "Use that result instead of calling it again.",
247
- };
248
- }
249
- return undefined;
250
- }
251
-
252
- /** Update loop-hygiene bookkeeping AFTER a call executes (or was
253
- * immediately blocked/errored) and, on repeated failure, return nudge
254
- * text to append to the error the model sees. `resultText` is the
255
- * flattened text content of the tool's result (used to seed
256
- * `lastSuccess` for the dedup check above). Exported + pure. */
257
- export function recordToolCallOutcome(
258
- state: LoopHygieneState,
259
- toolName: string,
260
- args: unknown,
261
- outcome: { isError: boolean; resultText: string },
262
- ): string | undefined {
263
- const signature = toolCallSignature(toolName, args);
264
- if (outcome.isError) {
265
- const streak = (state.failureStreaks.get(signature) ?? 0) + 1;
266
- state.failureStreaks.set(signature, streak);
267
- if (streak >= LOOP_HYGIENE.MAX_CONSECUTIVE_FAILURES) {
268
- return (
269
- `\n\n(This exact call has now failed ${streak} times in a row. ` +
270
- "Stop repeating it verbatim — try a different tool, different arguments, " +
271
- "or explain to the user what's blocking you.)"
272
- );
273
- }
274
- return undefined;
275
- }
276
- state.failureStreaks.delete(signature);
277
- state.lastSuccess = { signature, resultText: outcome.resultText };
278
- return undefined;
279
- }
280
-
281
- /** Flatten a tool result's content (TextContent[] shape) to plain text for
282
- * dedup-cache display and diagnostics. Mirrors contentText's tolerance
283
- * for non-array/string shapes. Exported for unit testing. */
284
- export function toolResultText(content: unknown): string {
285
- if (typeof content === "string") return content;
286
- if (Array.isArray(content)) {
287
- return content
288
- .filter((p): p is { type: string; text: string } =>
289
- !!p && (p as { type?: string }).type === "text" && typeof (p as { text?: unknown }).text === "string")
290
- .map((p) => p.text)
291
- .join("");
292
- }
293
- return "";
294
- }
295
-
296
- /**
297
- * System prompt for the web chat assistant.
298
- *
299
- * This fully REPLACES pi's default coding-agent prompt (it flows to
300
- * buildSystemPrompt's `customPrompt` via DefaultResourceLoader.systemPrompt),
301
- * dropping pi's "operating inside a coding-agent harness" framing and its block
302
- * of internal documentation paths. pi still auto-appends the current date and
303
- * working directory, so we don't repeat them here.
304
- *
305
- * Deliberately SHORT. The default served model is a ~1B local model; a long,
306
- * "make the user feel welcome / here is everything mlx-bun does" prompt made it
307
- * fixate on greeting and ignore the user's actual message (verified against the
308
- * live server: the same model answers correctly with a short prompt and drowns
309
- * with the long one). So: state identity + privacy in one breath, then tell it
310
- * plainly to answer what was asked and not to greet or recite capabilities.
311
- */
312
- export const WEB_CHAT_PROMPT_VERSION = "2026-07-07-app-aware-v1";
313
-
314
- export function webChatPromptFingerprint(prompt: string): string {
315
- return createHash("sha256").update(prompt).digest("hex").slice(0, 12);
316
- }
317
-
318
- export function buildWebChatSystemPrompt(
319
- readOnly: boolean,
320
- about?: { modelId?: string; downloadingModel?: string | null },
321
- opts?: { hasTools?: boolean },
322
- ): string {
323
- const servedModel =
324
- about?.modelId && about.modelId !== PI_LOCAL_MODEL_ID ? about.modelId : null;
325
- const modelLine = servedModel ? ` You are running on the local model \`${servedModel}\`.` : "";
326
-
327
- // A concise mlx-bun blurb so the welcome assistant can answer product
328
- // questions from its own knowledge (no tool, no fragile cwd-relative reads).
329
- // Kept SHORT on purpose — the old multi-paragraph product wall drowned the 1B
330
- // model. The blurb + the two key commands cover "what is it / how do I start".
331
- const aboutLine = ` mlx-bun runs open LLMs locally with MLX + Bun: a built-in chat, an OpenAI/Anthropic-compatible API, model download and serving, quantization, LoRA fine-tuning, and adapters — all on-device and private. Key commands: \`mlx-bun serve <model>\` to serve one, \`mlx-bun get <repo-id>\` to download one. You already know this, so answer questions about mlx-bun directly.`;
332
-
333
- // The tool guidance MUST match the session's actual surface (read +
334
- // web_search), or the model promises actions it can't take. Naming exactly
335
- // those two — and telling it to answer from knowledge first — is what keeps a
336
- // small model from reaching for a tool on math/writing/general questions.
337
- const hasTools = opts?.hasTools ?? true;
338
- // App-aware assistant (plan §6.6): tell the model these tools exist and
339
- // when to reach for them instead of describing the UI blind — "when the
340
- // user asks where/how in the app, navigate or spotlight instead of
341
- // describing blind" is the literal steer the task calls for. Included
342
- // whenever the session has tools at all (these three are always in the
343
- // allowlist alongside WELCOME_TOOLS — see webChatToolAllowlist).
344
- const appAwareLine = hasTools
345
- ? ` You can see and act on the app: \`navigate_app\` switches the view, \`spotlight_ui\` highlights a control (use \`get_current_app_context\` first for its ref). When asked where/how in the app, navigate or spotlight instead of describing it blind.`
346
- : "";
347
- const toolsLine = hasTools
348
- ? ` Answer directly from your own knowledge whenever you can. Only call a tool when you truly need information you don't have: \`web_search\` for current or external facts (news, current events, prices, latest docs), \`read\` for a specific local file the user points you to. Never use a tool for general questions, explanations, math, or writing — just answer. When a tool returns results, pull the answer out of the result text and state it directly — never tell the user to go open the links or check the sources themselves; reading them is your job.${appAwareLine}`
349
- : ` You have no tools in this session, so answer from your own knowledge; if something needs current or external data you can't reach, say so briefly instead of pretending to look it up.`;
350
-
351
- return `You are mlx-bun's built-in assistant, running entirely on the user's own Apple-silicon Mac — nothing they type leaves the machine.${modelLine}${aboutLine}${toolsLine}
352
-
353
- Respond to what the user actually said, concisely. Don't open with a generic greeting or recite your capabilities unless asked — just answer. If a request is genuinely ambiguous, ask one short clarifying question. Format with Markdown when it helps.`;
354
- }
355
-
356
- /** Per-connection data the server attaches at upgrade time. */
357
- export interface PiWsData {
358
- sessionId: string;
359
- }
360
-
361
- type PiWs = ServerWebSocket<PiWsData>;
362
-
363
- /** Validate assistant navigation against the shared browser/server route IDs. */
364
- export function isAppRouteId(v: string): v is AppRouteId {
365
- return (APP_ROUTE_IDS as readonly string[]).includes(v);
366
- }
367
-
368
- /** Resolve a free-form route/page string the way navigate_app's tool
369
- * describes its param to the model: either a bare route id ("quantize")
370
- * or a "#/quantize"-style hash. Returns null for anything else. */
371
- export function resolveAppRoute(routeOrPage: string): AppRouteId | null {
372
- const bare = routeOrPage.trim().replace(/^#\/?/, "");
373
- return isAppRouteId(bare) ? bare : null;
374
- }
375
-
376
- /** Compact one-line ambient context (§6.6 "never answer blind") mined from
377
- * the stored AppUiContext — e.g. "[user is on: Quantize · step 2/4]".
378
- * Never a snapshot dump; this is the ONLY thing auto-attached to every
379
- * turn (the full snapshot stays on-demand via get_current_app_context).
380
- * Returns null when there's no context yet (nothing pushed this
381
- * connection — e.g. a non-browser WS client, or the very first frame
382
- * race before chat.ts's first context push lands). Pure + exported for
383
- * unit testing; mirrors assistant.ts's ambientLine so the two stay in
384
- * lockstep even though the server never imports the browser module. */
385
- const APP_ROUTE_LABELS: Record<AppRouteId, string> = {
386
- chat: "Chat", quantize: "Quantize", finetune: "Fine-tune", dataset: "Build Dataset", status: "Status",
387
- };
388
-
389
- export function ambientContextLine(ctx: AppUiContext | null | undefined): string | null {
390
- if (!ctx || !ctx.route) return null;
391
- const place = ctx.view ?? APP_ROUTE_LABELS[ctx.route as AppRouteId] ?? ctx.route;
392
- const stepPart = ctx.step ? ` · step ${ctx.step.index + 1}/${ctx.step.count}` : "";
393
- return `[user is on: ${place}${stepPart}]`;
394
- }
395
-
396
- /** Flatten a content value (string | content-parts) to its text. */
397
- function contentText(content: unknown): string {
398
- if (typeof content === "string") return content;
399
- if (Array.isArray(content)) {
400
- return content
401
- .filter((p): p is { type: string; text: string } =>
402
- !!p && (p as { type?: string }).type === "text" && typeof (p as { text?: unknown }).text === "string")
403
- .map((p) => p.text)
404
- .join("");
405
- }
406
- return "";
407
- }
408
-
409
- function contentThinking(content: unknown): string {
410
- if (!Array.isArray(content)) return "";
411
- return content
412
- .filter((p): p is { type: string; thinking: string } =>
413
- !!p && (p as { type?: string }).type === "thinking" && typeof (p as { thinking?: unknown }).thinking === "string")
414
- .map((p) => p.thinking)
415
- .join("");
416
- }
417
-
418
- /**
419
- * Turn a session's entries into a flat, browser-renderable transcript.
420
- *
421
- * Walks message entries in order: user/assistant text become items, the
422
- * assistant's toolCall parts become tool items, and each later toolResult
423
- * is merged back onto its tool by callId. Non-message entries (model
424
- * changes, compaction, thinking) are skipped. Pure so it's unit-tested
425
- * without a live session.
426
- */
427
- export function serializeHistory(entries: readonly SessionEntry[]): HistoryItem[] {
428
- const items: HistoryItem[] = [];
429
- for (const entry of entries) {
430
- if (entry.type !== "message") continue;
431
- const m = (entry as { message?: unknown }).message as
432
- | { role?: string; content?: unknown; toolCallId?: string }
433
- | undefined;
434
- if (!m) continue;
435
- if (m.role === "user") {
436
- const text = contentText(m.content);
437
- if (text.trim()) items.push({ role: "user", text, tools: [], entryId: entry.id });
438
- } else if (m.role === "assistant") {
439
- const parts = Array.isArray(m.content) ? (m.content as unknown[]) : [];
440
- const text = contentText(parts);
441
- const thinking = contentThinking(parts);
442
- const tools: HistoryToolItem[] = parts
443
- .filter((p): p is { type: string; id?: unknown; name?: unknown; arguments?: unknown } =>
444
- !!p && (p as { type?: string }).type === "toolCall")
445
- .map((p) => ({ callId: String(p.id ?? ""), name: String(p.name ?? "tool"), args: p.arguments, result: "" }));
446
- if (text.trim() || thinking.trim() || tools.length > 0)
447
- items.push({ role: "assistant", text, ...(thinking.trim() ? { thinking } : {}), tools });
448
- } else if (m.role === "toolResult") {
449
- const callId = String(m.toolCallId ?? "");
450
- const result = contentText(m.content);
451
- for (let i = items.length - 1; i >= 0; i--) {
452
- const it = items[i];
453
- if (!it) continue;
454
- const tool = it.tools.find((t) => t.callId === callId);
455
- if (tool) {
456
- tool.result = result;
457
- break;
458
- }
459
- }
460
- }
461
- }
462
- return items;
463
- }
464
-
465
- /** Map browser image attachments to pi's ImageContent shape (or undefined). */
466
- function toPiImages(images?: ImageAttachment[]): ImageContent[] | undefined {
467
- if (!images || images.length === 0) return undefined;
468
- return images.map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType }));
469
- }
470
-
471
- // ---- Message actions: regenerate / edit-and-resend (pure helpers) ----
472
- //
473
- // Both features reuse ONE pi SDK primitive: AgentSession.navigateTree(entryId)
474
- // on a user-message entry moves the session's leaf pointer to that entry's
475
- // PARENT (without deleting anything) and returns the message's own text in
476
- // `editorText`. Calling session.prompt(...) right after appends a NEW child
477
- // under that same parent — i.e. a sibling of the original message, in the
478
- // SAME session file. This is exactly "regenerate" (re-send the identical
479
- // content) and "edit-and-resend" (re-send edited content) without any
480
- // file-level fork/session spam (plan §5.2's explicit constraint).
481
- //
482
- // navigateTree's own editorText is TEXT-ONLY (pi's editor-reopen use case
483
- // doesn't carry images). We extract the full original `content` ourselves
484
- // (text + images) from the entry directly so regenerate/edit-resend don't
485
- // silently drop image attachments — a small improvement over what the SDK
486
- // primitive alone would give us.
487
-
488
- /** One user message entry as found by findLastUserMessageEntry/userMessageSiblings. */
489
- export interface UserMessageEntryInfo {
490
- id: string;
491
- parentId: string | null;
492
- text: string;
493
- images: ImageContent[];
494
- }
495
-
496
- /** Extract {text, images} from a user message's raw `content` field
497
- * (string | (TextContent|ImageContent)[]). Pure. */
498
- function extractUserContent(content: unknown): { text: string; images: ImageContent[] } {
499
- if (typeof content === "string") return { text: content, images: [] };
500
- if (Array.isArray(content)) {
501
- const text = contentText(content);
502
- const images = (content as unknown[])
503
- .filter((p): p is { type: string; data: string; mimeType: string } =>
504
- !!p && (p as { type?: string }).type === "image" &&
505
- typeof (p as { data?: unknown }).data === "string" && typeof (p as { mimeType?: unknown }).mimeType === "string")
506
- .map((p) => ({ type: "image" as const, data: p.data, mimeType: p.mimeType }));
507
- return { text, images };
508
- }
509
- return { text: "", images: [] };
510
- }
511
-
512
- /** Find the LAST user-message entry in a session's entries (in append order).
513
- * Used by both `regenerate` (re-send it as-is) and as the sibling group's
514
- * anchor for `edit_resend` (always edits the last user message — plan §5.2
515
- * scopes this to the last message, not any earlier one). Pure. */
516
- export function findLastUserMessageEntry(entries: readonly SessionEntry[]): UserMessageEntryInfo | undefined {
517
- for (let i = entries.length - 1; i >= 0; i--) {
518
- const e = entries[i];
519
- if (!e || e.type !== "message") continue;
520
- const m = (e as { message?: { role?: string; content?: unknown } }).message;
521
- if (m?.role !== "user") continue;
522
- const { text, images } = extractUserContent(m.content);
523
- return { id: e.id, parentId: e.parentId, text, images };
524
- }
525
- return undefined;
526
- }
527
-
528
- /** Sibling group (edit history) for a user message: every user-message entry
529
- * sharing the same parentId, in append order, plus the 1-based index of
530
- * `entryId` within that group. Drives the browser's `< i/n >` toggle. Pure. */
531
- export interface SiblingGroup {
532
- parentId: string | null;
533
- siblingIds: string[];
534
- index: number; // 1-based position of the queried entryId within siblingIds
535
- }
536
-
537
- export function userMessageSiblings(entries: readonly SessionEntry[], entryId: string): SiblingGroup | undefined {
538
- const target = entries.find((e) => e.id === entryId);
539
- if (!target) return undefined;
540
- const parentId = target.parentId;
541
- const siblingIds = entries
542
- .filter((e): e is SessionEntry & { type: "message" } =>
543
- e.type === "message" && e.parentId === parentId &&
544
- (e as { message?: { role?: string } }).message?.role === "user")
545
- .map((e) => e.id);
546
- const index = siblingIds.indexOf(entryId) + 1;
547
- if (index === 0) return undefined;
548
- return { parentId, siblingIds, index };
549
- }
550
-
551
- /** Walk DOWN from an entry to the deepest descendant, always following the
552
- * MOST RECENTLY APPENDED child at each level (last in getChildren's
553
- * insertion order) — i.e. "what would this branch show if it were the
554
- * active leaf". Used by `switch_sibling` to resolve a sibling's own leaf
555
- * (which may itself have further edits/regenerations under it) rather than
556
- * just landing on the sibling's own root entry. Pure given a children-lookup
557
- * function so it's unit-testable without a live SessionManager. */
558
- export function deepestLeafFrom(fromId: string, getChildren: (parentId: string) => SessionEntry[]): string {
559
- let cur = fromId;
560
- for (;;) {
561
- const children = getChildren(cur);
562
- if (children.length === 0) return cur;
563
- cur = children[children.length - 1]!.id;
564
- }
565
- }
566
-
567
- /** Map pi's SessionInfo[] to sidebar rows, newest first. */
568
- export function toSessionListItems(infos: readonly SessionInfo[]): SessionListItem[] {
569
- return infos
570
- .map((s) => ({
571
- path: s.path,
572
- id: s.id,
573
- title: ((s.name && s.name.trim()) || (s.firstMessage && s.firstMessage.trim()) || "New chat").slice(0, 80),
574
- modified: s.modified instanceof Date ? s.modified.getTime() : new Date(s.modified as unknown as string).getTime(),
575
- messageCount: s.messageCount,
576
- forked: !!s.parentSessionPath,
577
- }))
578
- .sort((a, b) => b.modified - a.modified);
579
- }
580
-
581
- // ---- Pure helper: event mapping (unit-tested) ------------------------
582
-
583
- /**
584
- * Translate one pi AgentSessionEvent into zero or more WS frames.
585
- *
586
- * Pure and side-effect-free so it can be unit-tested without a live
587
- * session. The approval gate is handled separately (in the tool_call
588
- * extension handler), so the tool_execution_start event here simply
589
- * renders the tool card; it does not itself request approval.
590
- *
591
- * Returns [] for events that have no browser-facing representation.
592
- */
593
- export function mapEventToFrames(event: AgentSessionEvent): ServerMessage[] {
594
- switch (event.type) {
595
- case "turn_start":
596
- return [{ type: "turn_start" }];
597
- case "turn_end": {
598
- // A turn can complete with stopReason "error" (e.g. the model request
599
- // 400'd) WITHOUT any error being thrown up to the WS message handler, so
600
- // it would otherwise vanish — the browser just sees an empty turn ("no
601
- // messages"). Surface it as an error frame so the UI can show it.
602
- const msg = (event as { message?: { stopReason?: string; errorMessage?: string; responseId?: string } }).message;
603
- // Lane correlation (docs/design/web-chat-redesign.md §2.3 caveat / risk
604
- // #5): the pi SDK's AssistantMessage.responseId is the chat-completion
605
- // response's own `id` (chatcmpl-…), which server.ts records against the
606
- // lane the moment it decides one. Looked up here — never inferred — so
607
- // a mismatch or absence just omits the field rather than guessing.
608
- const lane = msg?.responseId ? getLane(msg.responseId) : undefined;
609
- if (msg?.stopReason === "error") {
610
- return [
611
- { type: "error", message: msg.errorMessage || "the model request failed" },
612
- { type: "turn_end", ...(lane ? { lane } : {}) },
613
- ];
614
- }
615
- return [{ type: "turn_end", ...(lane ? { lane } : {}) }];
616
- }
617
- case "message_update": {
618
- const ame = event.assistantMessageEvent;
619
- if (ame.type === "text_delta") return [{ type: "text_delta", delta: ame.delta }];
620
- if (ame.type === "thinking_delta") return [{ type: "thinking_delta", delta: ame.delta }];
621
- return [];
622
- }
623
- case "tool_execution_start":
624
- return [
625
- {
626
- type: "tool_start",
627
- callId: event.toolCallId,
628
- tool: event.toolName,
629
- args: event.args,
630
- },
631
- ];
632
- case "tool_execution_update":
633
- return [
634
- {
635
- type: "tool_update",
636
- callId: event.toolCallId,
637
- chunk: event.partialResult,
638
- },
639
- ];
640
- case "tool_execution_end":
641
- return [
642
- {
643
- type: "tool_end",
644
- callId: event.toolCallId,
645
- ok: !event.isError,
646
- result: event.result,
647
- },
648
- ];
649
- case "queue_update":
650
- return [
651
- {
652
- type: "queue_update",
653
- steering: event.steering,
654
- followUp: event.followUp,
655
- },
656
- ];
657
- default:
658
- return [];
659
- }
660
- }
661
-
662
- // ---- Session pool ----------------------------------------------------
663
-
664
- /** Live sessions keyed by their owning WebSocket. */
665
- const sessions = new Map<PiWs, PiWebSession>();
666
-
667
- /** Body of the `before_provider_request` hook: inject the selected LoRA adapter
668
- * into the outgoing provider payload. null/empty selection → return undefined so
669
- * Pi keeps the payload unchanged (base model); Pi replaces the payload only when a
670
- * handler returns a value. Pure + exported for unit testing. */
671
- export function injectAdapter(
672
- payload: Record<string, unknown>,
673
- selected: string | null,
674
- ): Record<string, unknown> | undefined {
675
- if (!selected) return undefined;
676
- return { ...payload, adapter: selected };
677
- }
678
-
679
- /** Every SamplingOverrides field name, in wire order — the single source of
680
- * truth `injectSampling` and the `set_sampling` handler both walk, so a new
681
- * field only needs adding here + the interface above. */
682
- const SAMPLING_FIELDS = [
683
- "temperature", "top_p", "top_k", "min_p", "xtc_probability", "xtc_threshold",
684
- "repetition_penalty", "repetition_context_size", "presence_penalty",
685
- "frequency_penalty", "seed",
686
- ] as const satisfies readonly (keyof SamplingOverrides)[];
687
-
688
- /** Inject the user's sampling overrides into the outgoing chat-completions
689
- * payload (which reaches the server's toOptions, where an explicit
690
- * request field always wins). Only finite numbers are injected; a
691
- * null/undefined/unset field is left off so the server falls back to its
692
- * mode-aware recommended default. Returns undefined when nothing is set so the
693
- * hook can keep the payload unchanged. Pure + exported for unit testing. */
694
- export function injectSampling(
695
- payload: Record<string, unknown>,
696
- s: SamplingOverrides | undefined,
697
- ): Record<string, unknown> | undefined {
698
- if (!s) return undefined;
699
- const out: Record<string, unknown> = { ...payload };
700
- let changed = false;
701
- for (const field of SAMPLING_FIELDS) {
702
- const v = s[field];
703
- if (typeof v === "number" && Number.isFinite(v)) {
704
- out[field] = v;
705
- changed = true;
706
- }
707
- }
708
- return changed ? out : undefined;
709
- }
710
-
711
- // ---- Per-message sampling scope (beat matrix Axis 4 / plan §9 Phase 3) --
712
- //
713
- // set_sampling's optional `scope` field: "session" (default, today's
714
- // behavior — persists on the connection until changed) or "next_turn" (a
715
- // one-shot override for exactly the next prompt, then cleared). The
716
- // composition rule is "next_turn OVER session": a next_turn field that's
717
- // a real number wins over the session-level value for that SAME field;
718
- // a next_turn field left null/undefined falls through to the session
719
- // override (which itself falls through to the server default per
720
- // injectSampling's existing null-means-unset contract). This mirrors
721
- // optiq Lab's per-message temp/max-tokens/thinking granularity cited in
722
- // the beat matrix, layered onto (not replacing) Phase 1's session-level
723
- // overrides.
724
- //
725
- // Lifecycle: armed by the set_sampling handler when scope is "next_turn"
726
- // (stored separately from the session-level `sampling` field so a
727
- // one-shot override never permanently clobbers the session default it's
728
- // laid over). Consumed at the FIRST before_provider_request of the very
729
- // next prompt — before_agent_start fires exactly once per session.prompt()
730
- // call (see agent-session.js's prompt() body), which is the natural
731
- // "one prompt" boundary, but the actual sampling injection happens in
732
- // before_provider_request (fired once per model call, i.e. once per
733
- // tool-loop turn) — so the override is consumed (cleared) the first time
734
- // before_provider_request fires after being armed, guaranteeing it rides
735
- // on exactly the next outgoing provider request and never leaks into a
736
- // second turn within the same prompt, let alone the next user message.
737
-
738
- /** Compose the effective sampling overrides for one outgoing provider
739
- * request: every field in `nextTurn` that's a real number wins; anything
740
- * left null/undefined in `nextTurn` falls back to `session`'s value for
741
- * that field. Pure + exported for unit testing. When `nextTurn` is
742
- * undefined (no one-shot override armed), returns `session` unchanged. */
743
- export function composeSampling(
744
- session: SamplingOverrides,
745
- nextTurn: SamplingOverrides | undefined,
746
- ): SamplingOverrides {
747
- if (!nextTurn) return session;
748
- const out: SamplingOverrides = { ...session };
749
- for (const field of SAMPLING_FIELDS) {
750
- const v = nextTurn[field];
751
- if (typeof v === "number" && Number.isFinite(v)) out[field] = v;
752
- }
753
- return out;
754
- }
755
-
756
- /** Pure state machine for the arm/consume lifecycle above, factored out of
757
- * PiWebSession so "set → one turn → cleared" is unit-testable without a
758
- * live session. `applySetSampling` is the set_sampling handler body;
759
- * `consumeForRequest` is the before_provider_request handler body — both
760
- * mutate and return a NEW state object (no hidden mutation) so tests can
761
- * assert on each step's output directly. */
762
- export interface SamplingScopeState {
763
- session: SamplingOverrides;
764
- nextTurn: SamplingOverrides | undefined;
765
- }
766
-
767
- export function initialSamplingScopeState(): SamplingScopeState {
768
- return { session: {}, nextTurn: undefined };
769
- }
770
-
771
- /** Apply a set_sampling message to scope state: scope "next_turn" arms the
772
- * one-shot override (leaving `session` untouched); scope "session" or
773
- * omitted replaces the session-level override (today's behavior). Pure. */
774
- export function applySetSampling(
775
- state: SamplingScopeState,
776
- overrides: SamplingOverrides,
777
- scope: "session" | "next_turn" | undefined,
778
- ): SamplingScopeState {
779
- return scope === "next_turn"
780
- ? { session: state.session, nextTurn: overrides }
781
- : { session: overrides, nextTurn: state.nextTurn };
782
- }
783
-
784
- /** Compute the effective overrides for ONE outgoing provider request and
785
- * return the post-consumption state (nextTurn always cleared, whether or
786
- * not one was armed — consuming "nothing armed" is a no-op clear). Pure. */
787
- export function consumeForRequest(
788
- state: SamplingScopeState,
789
- ): { effective: SamplingOverrides; nextState: SamplingScopeState } {
790
- return {
791
- effective: composeSampling(state.session, state.nextTurn),
792
- nextState: { session: state.session, nextTurn: undefined },
793
- };
794
- }
795
-
796
- /**
797
- * Layer the user's custom system prompt onto (not replacing) the built-in
798
- * surface prompt for one turn. Called from the `before_agent_start` hook
799
- * body with `event.systemPrompt` (the fully-assembled base: identity +
800
- * memoryHint) and the connection's stored override. Returns undefined when
801
- * there's nothing to layer (null/empty override), so the hook can leave
802
- * pi's base prompt alone — mirrors injectAdapter/injectSampling's "undefined
803
- * means unchanged" contract. Pure + exported for unit testing.
804
- */
805
- export function injectSystemPrompt(base: string, custom: string | null | undefined): string | undefined {
806
- const trimmed = custom?.trim();
807
- if (!trimmed) return undefined;
808
- return `${base}\n\n---\n\nThe user has set a custom instruction for this chat. Follow it alongside (not instead of) the guidance above:\n\n${trimmed}`;
809
- }
810
-
811
- /**
812
- * Apply the browser's edited tool arguments onto pi's mutable
813
- * ToolCallEvent.input IN PLACE, per the SDK's documented tool_call
814
- * mutation contract (installApprovalGate's own doc comment quotes it: no
815
- * return-value channel exists for modified args). A no-op when
816
- * `editedArgs` is undefined (the common "approved as proposed" case).
817
- * Replaces the input's keys wholesale (delete-then-assign) rather than a
818
- * shallow merge, so a key the user removed from the textarea actually
819
- * disappears from the call instead of lingering from the original
820
- * proposal. Pure enough to unit test the mutation shape without a live
821
- * ExtensionAPI/ToolCallEvent — takes any mutable record, not the SDK type.
822
- */
823
- export function applyEditedArgs(input: Record<string, unknown>, editedArgs: Record<string, unknown> | undefined): void {
824
- if (!editedArgs) return;
825
- for (const key of Object.keys(input)) delete input[key];
826
- Object.assign(input, editedArgs);
827
- }
828
-
829
- // ---- App-aware assistant tools (plan §6.6, §9 Phase 3, beat matrix ----
830
- // Axis 12): get_current_app_context / navigate_app / spotlight_ui.
831
- //
832
- // Same shape as PortfolioManager's createPortfolioTools(getAppContext,
833
- // clientId, notify) — the reference implementation this ports (read in
834
- // full: server/src/agent/portfolio-tools.ts). Built here (not in
835
- // pi-session.ts's shared buildPiAgentSurface) because these three tools are
836
- // PER-CONNECTION: they close over this session's currentAppContext getter
837
- // and its `notify` (send) function, whereas buildPiAgentSurface's tools are
838
- // stateless singletons shared across every session. Read-only/navigate
839
- // class (no approval gate, see APP_AWARE_TOOL_NAMES above): navigation and
840
- // spotlighting are reversible UI actions, not machine mutations — exactly
841
- // the same "auto-allow" reasoning READ_ONLY_TOOLS already applies to
842
- // memory/web tools. approval-gated ui_act (fill a form, start a job) is
843
- // explicitly Phase 5 per §6.6's scope guard, not implemented here.
844
-
845
- function appAwareTextResult(text: string, details: unknown = {}): { content: [{ type: "text"; text: string }]; details: unknown } {
846
- return { content: [{ type: "text", text }], details };
847
- }
848
-
849
- /** Build the three app-aware tools for one connection. `getContext` reads
850
- * the connection's currentAppContext (last `context` ClientMessage, or
851
- * null before the first one arrives); `notify` sends a ServerMessage
852
- * (ui_navigate/ui_spotlight) to that same browser tab. Pure enough to
853
- * unit test the tool -> frame mapping via a fake notify collecting sent
854
- * frames (tests/research/pi-web.test.ts). */
855
- export function createAppAwareTools(
856
- getContext: () => AppUiContext | null,
857
- notify: (msg: ServerMessage) => void,
858
- ): ToolDefinition[] {
859
- const getCurrentAppContext = defineTool({
860
- name: "get_current_app_context",
861
- label: "Get Current App Context",
862
- description:
863
- "Returns the current web-app UI context: route, view, wizard step (if on one), and a uiSnapshot of visible interactive elements (ref, label, selector, spotlightId). Call this before spotlight_ui to pick the correct ref, label, or target — do not guess selectors blind.",
864
- parameters: Type.Object({}),
865
- execute: async () => {
866
- const ctx = getContext();
867
- if (!ctx) {
868
- return appAwareTextResult(
869
- "No app context received yet — the browser hasn't reported its current view. Ask the user what page they're on, or proceed without UI tools.",
870
- );
871
- }
872
- return appAwareTextResult(JSON.stringify(ctx, null, 2), { context: ctx });
873
- },
874
- });
875
-
876
- const navigateApp = defineTool({
877
- name: "navigate_app",
878
- label: "Navigate App",
879
- description:
880
- "Navigate the user's browser to a different page in the app. Use when they ask to be taken somewhere (e.g. \"take me to Quantize\", \"open the fine-tune wizard\"). Pass route or page as one of: chat, quantize, finetune, dataset, status (or \"#/quantize\"-style). Reversible and never needs approval. Does not highlight a specific control — use spotlight_ui separately for that.",
881
- parameters: Type.Object({
882
- route: Type.Optional(Type.String({ description: "chat | quantize | finetune | dataset | status (or #/quantize)" })),
883
- page: Type.Optional(Type.String({ description: "Same as route — alternate param name" })),
884
- }),
885
- execute: async (_id, params) => {
886
- const resolved = resolveAppRoute(params.route ?? params.page ?? "");
887
- if (!resolved) {
888
- return {
889
- ...appAwareTextResult(`Unknown route. Valid routes: ${APP_ROUTE_IDS.join(", ")}.`),
890
- isError: true,
891
- };
892
- }
893
- notify({ type: "ui_navigate", route: resolved });
894
- return appAwareTextResult(
895
- `Navigating the user to ${resolved}. They can see the page now — use spotlight_ui if you need to point at a specific control.`,
896
- { route: resolved },
897
- );
898
- },
899
- });
900
-
901
- const spotlightUi = defineTool({
902
- name: "spotlight_ui",
903
- label: "Spotlight UI",
904
- description:
905
- "Highlight a visible UI control with a brief, auto-dismissing spotlight (never blocks the user — they can keep working through it). Call get_current_app_context first and prefer ref from its uiSnapshot.elements; or pass label (visible text, e.g. \"Source model\"); or selector; or target (a curated catalog id, e.g. hub-browse, quantize-source). Optional route navigates there first. Optional message is a short hint shown in the popover.",
906
- parameters: Type.Object({
907
- ref: Type.Optional(Type.String({ description: "Element ref from uiSnapshot.elements[].ref (preferred)" })),
908
- label: Type.Optional(Type.String({ description: "Visible label/text to match, e.g. Source model, New chat" })),
909
- selector: Type.Optional(Type.String({ description: "CSS selector, when ref/label are unknown" })),
910
- target: Type.Optional(Type.String({ description: "Curated catalog id, e.g. hub-browse, quantize-source, sampling-pill" })),
911
- route: Type.Optional(Type.String({ description: "Navigate to this route first (chat, quantize, finetune, dataset, status)" })),
912
- message: Type.Optional(Type.String({ description: "Short hint shown in the spotlight popover" })),
913
- }),
914
- execute: async (_id, params) => {
915
- const hasLocator = !!params.ref?.trim() || !!params.label?.trim() || !!params.selector?.trim() || !!params.target?.trim();
916
- if (!hasLocator) {
917
- return { ...appAwareTextResult("Provide at least one of: ref (from uiSnapshot), label, selector, or target."), isError: true };
918
- }
919
- let route: AppRouteId | undefined;
920
- if (params.route) {
921
- route = resolveAppRoute(params.route) ?? undefined;
922
- if (!route) {
923
- return { ...appAwareTextResult(`Unknown route "${params.route}". Valid routes: ${APP_ROUTE_IDS.join(", ")}.`), isError: true };
924
- }
925
- }
926
- notify({
927
- type: "ui_spotlight",
928
- ref: params.ref, label: params.label, selector: params.selector, target: params.target,
929
- route, message: params.message,
930
- });
931
- const desc = params.ref ?? params.label ?? params.selector ?? params.target ?? "element";
932
- return appAwareTextResult(
933
- `Highlighting ${desc}${params.message ? `: ${params.message}` : ""}. The spotlight fades on its own in a few seconds.`,
934
- { ...params, route },
935
- );
936
- },
937
- });
938
-
939
- return [getCurrentAppContext, navigateApp, spotlightUi];
940
- }
941
-
942
- /**
943
- * One browser connection's pi agent. Owns the AgentSession, the event
944
- * subscription, and the pending tool-approval handshakes.
945
- */
946
- class PiWebSession {
947
- private runtime?: AgentSessionRuntime;
948
- private session?: AgentSession;
949
- /** SessionManager backing the active AgentSession (disk-persisted). */
950
- private sessionManager?: SessionManager;
951
- private unsubscribe?: () => void;
952
- private disposed = false;
953
- /** Active LoRA adapter id for this connection (null = none/base model).
954
- * Read by the before_provider_request hook; set via the set_adapter msg. */
955
- private selectedAdapter: string | null = null;
956
- /** Session-level + one-shot-next-turn sampling overrides for this
957
- * connection (set via set_sampling; scope "session"/omitted mutates
958
- * .session, scope "next_turn" arms .nextTurn). Read and consumed by
959
- * the before_provider_request hook via consumeForRequest — see the
960
- * "Per-message sampling scope" block comment above composeSampling for
961
- * the full lifecycle. Wrapped in SamplingScopeState (not two loose
962
- * fields) so the exact same applySetSampling/consumeForRequest pure
963
- * functions run here and in tests/research/pi-web.test.ts. */
964
- private samplingScope: SamplingScopeState = initialSamplingScopeState();
965
- /** User's custom system-prompt text for this connection (set via
966
- * set_system_prompt), null = none. Read by the before_agent_start hook
967
- * and layered onto the built-in surface prompt on the NEXT turn — see
968
- * installSystemPromptHook/injectSystemPrompt. */
969
- private systemPrompt: string | null = null;
970
- /** Requested codingTools state (set via set_coding_tools), read fresh by
971
- * createRuntimeFactory every time the SDK invokes it. Starts false — the
972
- * toggle in settings defaults OFF, and a connection that never sends
973
- * set_coding_tools must never get bash/edit/write. */
974
- private codingToolsRequested = false;
975
- /** What the CURRENT (already-built) session's tool surface actually
976
- * contains — set only inside createRuntimeFactory, so it always reflects
977
- * reality rather than the latest request. Read by the `coding_tools`
978
- * ServerMessage and by installApprovalGate (whether GATED_TOOLS is even
979
- * reachable this session, informational only — the allowlist itself is
980
- * the real enforcement). */
981
- private codingToolsActive = false;
982
-
983
- /** Last `context` ClientMessage the browser pushed (route/view/step/
984
- * snapshot, plan §6.6) — null before the first one arrives (a non-
985
- * browser WS client, or the race before chat.ts's first push). Read by
986
- * get_current_app_context's tool and by installAppContextHook's
987
- * ambient-line injection. Set wholesale (not merged) on every "context"
988
- * ClientMessage — the browser always sends the full current picture. */
989
- private currentAppContext: AppUiContext | null = null;
990
-
991
- /** Dedup/retry-budget/turn-cap bookkeeping (see LOOP_HYGIENE above).
992
- * Reset at the start of every new agent turn (turn_start with
993
- * turnIndex 0) so a dedup hit or failure streak from a PREVIOUS user
994
- * message never bleeds into the next one. */
995
- private loopHygiene: LoopHygieneState = initialLoopHygieneState();
996
-
997
- /** Per-connection invariants, built once in start() and reused across
998
- * session switches (new chat / resume / fork). */
999
- private provider?: ReturnType<typeof buildPiProvider>;
1000
- private readonly cwd = process.cwd();
1001
- private readonly agentDir = join(homedir(), ".mlx-bun", "pi-sessions");
1002
- /** Where web-chat session files live (pi's own JSONL format). Shared by
1003
- * create/continueRecent/open/fork/list/delete so they all see one set.
1004
- * This is the durable transcript store the nightly memory pipeline reads. */
1005
- private readonly sessionDir = join(homedir(), ".mlx-bun", "sessions");
1006
-
1007
- /** callId -> resolve(decision). Pending browser approvals in flight. */
1008
- private readonly pendingApprovals = new Map<string, (decision: ApprovalDecision, editedArgs?: Record<string, unknown>, alwaysAllow?: boolean) => void>();
1009
- /** callId -> timer handle, so we can clear on resolve/dispose. */
1010
- private readonly approvalTimers = new Map<string, ReturnType<typeof setTimeout>>();
1011
-
1012
- constructor(
1013
- private readonly ws: PiWs,
1014
- private readonly opts: {
1015
- port: number | (() => number); modelId: string; contextWindow: number; readOnly: boolean;
1016
- vision: boolean; audio: boolean; thinking: boolean; genDefaults: ReadyGenDefaults;
1017
- transcription: () => Promise<boolean>;
1018
- },
1019
- ) {}
1020
-
1021
- /** Build the provider, resume the most recent chat, and start streaming. */
1022
- async start(): Promise<void> {
1023
- // Resolve the port lazily: the WS handler is constructed before
1024
- // Bun.serve() binds, so an ephemeral (0) port is only known at the
1025
- // first connection. createServer passes `() => server.port`.
1026
- const port = typeof this.opts.port === "function" ? this.opts.port() : this.opts.port;
1027
- const baseUrl = `http://127.0.0.1:${port}/v1`;
1028
-
1029
- // In-memory auth + registry shared with the terminal embed (pi-provider.ts
1030
- // so the wiring can't drift); built once and reused across session swaps.
1031
- this.provider = buildPiProvider(baseUrl, {
1032
- contextWindow: this.opts.contextWindow,
1033
- reasoning: this.opts.thinking,
1034
- vision: this.opts.vision,
1035
- });
1036
- mkdirSync(this.sessionDir, { recursive: true });
1037
-
1038
- // Each connection starts its OWN fresh session. A prior continueRecent
1039
- // here meant every new WebSocket (another browser tab, a reconnect, or an
1040
- // external client) appended to the most-recent session — so tabs and even
1041
- // test clients wrote into each other's chats. Isolation is the correct
1042
- // model (and essential once concurrent slots land). Resuming a specific
1043
- // chat is explicit via the sidebar (open_session); the frontend re-opens
1044
- // its own session on a transient reconnect so a blip doesn't strand it on
1045
- // a blank backend session. Sessions persist to disk in pi's own format —
1046
- // the substrate for the recent-chats sidebar and the nightly memory pipeline.
1047
- await this.replaceRuntime(SessionManager.create(this.cwd, this.sessionDir));
1048
- if (this.disposed) return;
1049
-
1050
- let transcription = false;
1051
- try { transcription = await this.opts.transcription(); } catch { /* no whisper → no mic button */ }
1052
- this.send({
1053
- type: "ready", model: this.opts.modelId, vision: this.opts.vision, audio: this.opts.audio,
1054
- thinking: this.opts.thinking, genDefaults: this.opts.genDefaults, transcription,
1055
- });
1056
- this.sendCodingToolsState();
1057
- this.sendToolApprovals();
1058
- this.sendHistory();
1059
- await this.sendSessions();
1060
- }
1061
-
1062
- /** Runtime factory used for initial session creation and SDK-managed replacements. */
1063
- private createRuntimeFactory(): CreateAgentSessionRuntimeFactory {
1064
- return async ({ cwd, sessionManager, sessionStartEvent }) => {
1065
- const provider = this.provider;
1066
- if (!provider) throw new Error("provider not initialized");
1067
-
1068
- // Welcome-assistant tool surface: exactly two read-only tools —
1069
- // `web_search` (current/external facts) and `read` (a local file the user
1070
- // points to) — UNLESS the user opted into codingTools (settings toggle,
1071
- // default OFF), in which case bash/edit/write/grep/find/ls join the
1072
- // allowlist too. We deliberately do NOT expose web_fetch/weather: a 1B
1073
- // model over-calls a big toolset. With thinking ON (the web chat
1074
- // default) it uses these appropriately; build the web tools (for the
1075
- // web_search definition) then restrict the allowlist below.
1076
- //
1077
- // codingTools:false here is NOT the coding-tools gate (that lives in
1078
- // webChatToolAllowlist below, which is the array pi actually receives
1079
- // as `tools:` — see the comment at that call site). buildPiAgentSurface's
1080
- // own codingTools flag only affects surface.tools, which pi-web.ts never
1081
- // reads; kept false so surface.tools (unused here) doesn't silently
1082
- // imply a wider allowlist than webChatToolAllowlist actually grants.
1083
- const surface = await buildPiAgentSurface({ webTools: true, codingTools: false });
1084
- // Snapshot what THIS session is actually being built with — read fresh
1085
- // here (not at set_coding_tools time) so `coding_tools`'s `active`
1086
- // field is always true reality, never a stale request.
1087
- this.codingToolsActive = this.codingToolsRequested;
1088
- const webPrompt = buildWebChatSystemPrompt(this.opts.readOnly, {
1089
- modelId: this.opts.modelId,
1090
- downloadingModel: downloadsSnapshot().find(
1091
- (d) => d.state === "active" && d.repoId !== this.opts.modelId,
1092
- )?.repoId ?? null,
1093
- }, { hasTools: WELCOME_TOOLS.length > 0 }) + surface.memoryHint;
1094
- if (runtimeValue("MLX_BUN_PI_DEBUG")) {
1095
- console.error(`[pi-web] prompt ${WEB_CHAT_PROMPT_VERSION} sha=${webChatPromptFingerprint(webPrompt)} memory=${surface.memoryEnabled ? "on" : "off"} codingTools=${this.codingToolsActive ? "on" : "off"}`);
1096
- }
1097
- const services = await createAgentSessionServices({
1098
- cwd,
1099
- agentDir: this.agentDir,
1100
- authStorage: provider.authStorage,
1101
- modelRegistry: provider.modelRegistry,
1102
- resourceLoaderOptions: {
1103
- noExtensions: true,
1104
- noSkills: true,
1105
- noPromptTemplates: true,
1106
- noThemes: true,
1107
- noContextFiles: true,
1108
- additionalSkillPaths: surface.skillPaths,
1109
- systemPrompt: webPrompt,
1110
- extensionFactories: [
1111
- // Loop hygiene runs FIRST: a deduped/turn-capped call is
1112
- // blocked before the approval gate ever sees it (see
1113
- // installLoopHygieneHooks's doc comment).
1114
- (pi) => this.installLoopHygieneHooks(pi),
1115
- (pi) => this.installApprovalGate(pi),
1116
- (pi) => this.installAdapterHook(pi),
1117
- (pi) => this.installSystemPromptHook(pi),
1118
- (pi) => this.installAppContextHook(pi),
1119
- ],
1120
- },
1121
- });
1122
-
1123
- return {
1124
- ...(await createAgentSessionFromServices({
1125
- services,
1126
- sessionManager,
1127
- sessionStartEvent,
1128
- model: provider.model,
1129
- // Allowlist (not surface.tools): `read` is a pi built-in enabled by
1130
- // name; `web_search` is the custom tool from surface.customTools. pi
1131
- // exposes only names in this list, so web_fetch/weather stay defined
1132
- // but hidden. Memory/reference tools ride along when the surface has
1133
- // memory enabled — the prompt + memory skill advertise them, so they
1134
- // must be callable (webChatToolAllowlist). codingTools is further
1135
- // ANDed with !readOnly here: a read-only server (--read-only) never
1136
- // advertises bash/edit/write even if the browser opted in, since
1137
- // installApprovalGate would deny every one of them anyway — no
1138
- // point offering (and prompting the model to attempt) a tool that
1139
- // can only ever fail.
1140
- tools: webChatToolAllowlist(surface.memoryEnabled, this.codingToolsActive && !this.opts.readOnly),
1141
- // App-aware assistant tools (plan §6.6) are PER-CONNECTION (close
1142
- // over this.currentAppContext + this.send), unlike surface.customTools
1143
- // which is a stateless singleton shared across every session — see
1144
- // createAppAwareTools' doc comment.
1145
- customTools: [...surface.customTools, ...createAppAwareTools(() => this.currentAppContext, (m) => this.send(m))],
1146
- })),
1147
- services,
1148
- diagnostics: services.diagnostics,
1149
- };
1150
- };
1151
- }
1152
-
1153
- /** Tear down UI/session bindings owned by this WebSocket. Runtime.dispose()
1154
- * owns the AgentSession itself; this method only detaches browser state. */
1155
- private teardownBindings(): void {
1156
- for (const settle of this.pendingApprovals.values()) settle("deny");
1157
- this.pendingApprovals.clear();
1158
- for (const timer of this.approvalTimers.values()) clearTimeout(timer);
1159
- this.approvalTimers.clear();
1160
- this.unsubscribe?.();
1161
- this.unsubscribe = undefined;
1162
- this.session = undefined;
1163
- this.sessionManager = undefined;
1164
- }
1165
-
1166
- /** Bind browser event plumbing to the current runtime.session. */
1167
- private async bindRuntimeSession(): Promise<void> {
1168
- const session = this.runtime?.session;
1169
- if (!session) return;
1170
- this.unsubscribe?.();
1171
- this.session = session;
1172
- this.sessionManager = session.sessionManager;
1173
- await session.bindExtensions({ mode: "rpc" });
1174
- this.unsubscribe = session.subscribe((event) => this.onSessionEvent(event));
1175
- this.sendContextUsage();
1176
- }
1177
-
1178
- /** Replace the whole pi runtime with a target SessionManager. Used for the
1179
- * initial session and for file-level fork, while open/new use runtime APIs. */
1180
- private async replaceRuntime(sm: SessionManager): Promise<void> {
1181
- try {
1182
- await this.session?.abort();
1183
- } catch {
1184
- // Old turn may already be done; ignore.
1185
- }
1186
- const next = await createAgentSessionRuntime(this.createRuntimeFactory(), {
1187
- cwd: this.cwd,
1188
- agentDir: this.agentDir,
1189
- sessionManager: sm,
1190
- });
1191
- next.setRebindSession(async () => this.bindRuntimeSession());
1192
- if (this.disposed) {
1193
- await next.dispose();
1194
- return;
1195
- }
1196
- const previous = this.runtime;
1197
- this.teardownBindings();
1198
- this.runtime = next;
1199
- await this.bindRuntimeSession();
1200
- if (previous) await previous.dispose();
1201
- }
1202
-
1203
- /** Replay the active session's transcript to the browser (rebuilds thread).
1204
- * Always followed by sendSiblings(): the sibling group for "the last user
1205
- * message" is a derived view of the same entries and goes stale under
1206
- * exactly the same conditions history does (new session, switch, fork). */
1207
- private sendHistory(): void {
1208
- const entries = this.sessionManager?.getEntries() ?? [];
1209
- this.send({ type: "history", items: serializeHistory(entries) });
1210
- this.sendSiblings();
1211
- }
1212
-
1213
- /** Push the sibling-group info for the current last-user-message (drives
1214
- * the browser's `< i/n >` edit toggle). Called by sendHistory() and after
1215
- * each completed turn, since either can change which message is "last" or
1216
- * how many siblings it has. */
1217
- private sendSiblings(): void {
1218
- const entries = this.sessionManager?.getEntries() ?? [];
1219
- const last = findLastUserMessageEntry(entries);
1220
- if (!last) {
1221
- this.send({ type: "siblings", index: 0, count: 0, siblingIds: [] });
1222
- return;
1223
- }
1224
- const group = userMessageSiblings(entries, last.id);
1225
- this.send({
1226
- type: "siblings",
1227
- entryId: last.id,
1228
- index: group?.index ?? 1,
1229
- count: group?.siblingIds.length ?? 1,
1230
- siblingIds: group?.siblingIds ?? [last.id],
1231
- });
1232
- }
1233
-
1234
- /** Push current context-window usage to the browser (for the indicator). */
1235
- private sendContextUsage(): void {
1236
- const usage = this.session?.getContextUsage();
1237
- if (usage) {
1238
- this.send({ type: "context", tokens: usage.tokens, contextWindow: usage.contextWindow, percent: usage.percent });
1239
- }
1240
- }
1241
-
1242
- /** Send the recent-chats list (sidebar), marking the active session. */
1243
- private async sendSessions(): Promise<void> {
1244
- let infos: SessionInfo[] = [];
1245
- try {
1246
- infos = await SessionManager.list(this.cwd, this.sessionDir);
1247
- } catch {
1248
- infos = [];
1249
- }
1250
- this.send({
1251
- type: "sessions",
1252
- items: toSessionListItems(infos),
1253
- activePath: this.sessionManager?.getSessionFile(),
1254
- });
1255
- }
1256
-
1257
- /** Guard: only operate on session files under our session dir. */
1258
- private isUnderSessionDir(path: string): boolean {
1259
- const root = resolve(this.sessionDir);
1260
- const p = resolve(path);
1261
- return p === root || p.startsWith(root + "/");
1262
- }
1263
-
1264
- /** Start a fresh chat (old one stays on disk, resumable from the sidebar).
1265
- * Re-invokes createRuntimeFactory (either directly via replaceRuntime, or
1266
- * through AgentSessionRuntime.newSession()'s own internal call to the
1267
- * SAME factory) — so this is the earliest point a codingTools toggle
1268
- * flipped mid-session actually takes effect (see set_coding_tools). */
1269
- private async newSession(): Promise<void> {
1270
- if (!this.runtime) {
1271
- await this.replaceRuntime(SessionManager.create(this.cwd, this.sessionDir));
1272
- } else {
1273
- await this.runtime.newSession();
1274
- }
1275
- this.sendCodingToolsState();
1276
- this.sendHistory();
1277
- await this.sendSessions();
1278
- }
1279
-
1280
- /** Resume an existing chat by file path. Same "factory re-runs, codingTools
1281
- * becomes active if pending" note as newSession above. */
1282
- private async openSession(path: string): Promise<void> {
1283
- if (!this.isUnderSessionDir(path)) {
1284
- this.send({ type: "error", message: "invalid session path" });
1285
- return;
1286
- }
1287
- if (!this.runtime) await this.replaceRuntime(SessionManager.open(path, this.sessionDir));
1288
- else await this.runtime.switchSession(path);
1289
- this.sendCodingToolsState();
1290
- this.sendHistory();
1291
- await this.sendSessions();
1292
- }
1293
-
1294
- /** Branch a new chat from an existing one (original stays untouched). */
1295
- private async forkSession(path: string): Promise<void> {
1296
- if (!this.isUnderSessionDir(path)) {
1297
- this.send({ type: "error", message: "invalid session path" });
1298
- return;
1299
- }
1300
- // File-level fork is not a runtime primitive, so create the target
1301
- // SessionManager then replace the runtime through the same SDK factory.
1302
- await this.replaceRuntime(SessionManager.forkFrom(path, this.cwd, this.sessionDir));
1303
- this.sendCodingToolsState();
1304
- this.sendHistory();
1305
- await this.sendSessions();
1306
- }
1307
-
1308
- /** Delete a session file; if it was active, start a fresh chat. */
1309
- private deleteSession(path: string): void {
1310
- if (!this.isUnderSessionDir(path)) {
1311
- this.send({ type: "error", message: "invalid session path" });
1312
- return;
1313
- }
1314
- const active = this.sessionManager?.getSessionFile();
1315
- const wasActive = !!active && resolve(active) === resolve(path);
1316
- try {
1317
- rmSync(path, { force: true });
1318
- } catch {
1319
- // Already gone or unreadable; the refreshed list will reflect reality.
1320
- }
1321
- if (wasActive) void this.newSession();
1322
- else void this.sendSessions();
1323
- }
1324
-
1325
- /** Clear an armed one-shot next_turn sampling override after a prompt()
1326
- * call throws before ever reaching before_provider_request (the hook
1327
- * that normally consumes it via consumeForRequest — see the
1328
- * "Per-message sampling scope" comment above composeSampling). Reuses
1329
- * that same pure helper so the clearing logic has exactly one
1330
- * implementation; discards its `effective` composition since there's no
1331
- * outgoing request to apply it to. A no-op when nothing was armed. */
1332
- private clearArmedOneShotOnFailedPrompt(): void {
1333
- this.samplingScope = consumeForRequest(this.samplingScope).nextState;
1334
- }
1335
-
1336
- /** Move the session leaf to `entryId`'s parent via navigateTree, then
1337
- * re-prompt with {text, images}. This is the shared mechanism behind
1338
- * regenerate and edit_resend: navigateTree does NOT delete the old
1339
- * message/reply (they stay on disk as a sibling branch), and pi resyncs
1340
- * agent.state.messages from the new leaf internally, so the next
1341
- * session.prompt() call appends cleanly. Returns false (and sends an
1342
- * error frame) if the entry can't be found or a turn is already
1343
- * streaming (mirrors prompt()'s own idle precondition — regenerate/edit
1344
- * are not queueable follow-ups, unlike a normal composer submit). */
1345
- private async resendFrom(entryId: string, text: string, images: ImageContent[]): Promise<boolean> {
1346
- const session = this.session;
1347
- if (!session) {
1348
- this.send({ type: "error", message: "session not ready" });
1349
- return false;
1350
- }
1351
- if (session.isStreaming) {
1352
- this.send({ type: "error", message: "wait for the current reply to finish first" });
1353
- return false;
1354
- }
1355
- if (!text.trim()) {
1356
- this.send({ type: "error", message: "message text can't be empty" });
1357
- return false;
1358
- }
1359
- await session.navigateTree(entryId);
1360
- try {
1361
- await session.prompt(text, images.length ? { images } : {});
1362
- } catch (err) {
1363
- // Same pre-flight-failure guard as the "prompt" case above: prompt()
1364
- // can throw before before_provider_request ever fires (no model
1365
- // selected, auth failure), which is the only place a one-shot
1366
- // next_turn sampling override normally gets consumed. Without this,
1367
- // an override armed right before a failed regenerate/edit-resend
1368
- // would silently ride along on a later, unrelated successful turn.
1369
- this.clearArmedOneShotOnFailedPrompt();
1370
- throw err;
1371
- }
1372
- return true;
1373
- }
1374
-
1375
- /** Regenerate: re-send the last user message's ORIGINAL content verbatim
1376
- * as a new sibling branch. Works after abort/stop too — an aborted turn
1377
- * still leaves the triggering user message entry in place to find. */
1378
- private async regenerate(): Promise<void> {
1379
- const entries = this.sessionManager?.getEntries() ?? [];
1380
- const last = findLastUserMessageEntry(entries);
1381
- if (!last) {
1382
- this.send({ type: "error", message: "no message to regenerate" });
1383
- return;
1384
- }
1385
- await this.resendFrom(last.id, last.text, last.images);
1386
- }
1387
-
1388
- /** Edit-and-resend: re-send the LAST user message with edited text (plan
1389
- * §5.2 scopes editing to the last message only — the `< i/n >` toggle is
1390
- * a linear sibling list, not a tree view). Original images are preserved
1391
- * since editing text shouldn't silently drop an attachment. */
1392
- private async editResend(text: string): Promise<void> {
1393
- const entries = this.sessionManager?.getEntries() ?? [];
1394
- const last = findLastUserMessageEntry(entries);
1395
- if (!last) {
1396
- this.send({ type: "error", message: "no message to edit" });
1397
- return;
1398
- }
1399
- await this.resendFrom(last.id, text, last.images);
1400
- }
1401
-
1402
- /** Switch the active leaf to a previously-created sibling (an earlier
1403
- * edit/regeneration of the last user message) WITHOUT sending anything
1404
- * new — pure navigation. Walks down to that sibling's own deepest
1405
- * descendant (its assistant reply, if any) so the replayed history shows
1406
- * that branch's actual conversation, not just the bare user message. */
1407
- private switchSibling(entryId: string): void {
1408
- const sm = this.sessionManager;
1409
- const session = this.session;
1410
- if (!sm || !session) {
1411
- this.send({ type: "error", message: "session not ready" });
1412
- return;
1413
- }
1414
- if (session.isStreaming) {
1415
- this.send({ type: "error", message: "wait for the current reply to finish first" });
1416
- return;
1417
- }
1418
- const entry = sm.getEntry(entryId);
1419
- if (!entry) {
1420
- this.send({ type: "error", message: "message not found" });
1421
- return;
1422
- }
1423
- const leaf = deepestLeafFrom(entryId, (parentId) => sm.getChildren(parentId));
1424
- sm.branch(leaf);
1425
- // Mirror what AgentSession.navigateTree() does internally after moving
1426
- // the leaf: resync agent.state.messages from the new leaf's path so the
1427
- // in-memory agent state and the persisted leaf pointer never diverge.
1428
- session.agent.state.messages = sm.buildSessionContext().messages;
1429
- this.sendHistory();
1430
- this.sendContextUsage();
1431
- }
1432
-
1433
- /** Register the before_provider_request hook that injects the selected LoRA
1434
- * adapter and the effective sampling overrides into every provider
1435
- * request (Pi-native adapter control, mirrors the CLI extension).
1436
- * Default none = no injection (base model). */
1437
- private installAdapterHook(pi: ExtensionAPI): void {
1438
- pi.on("before_provider_request", (event) => {
1439
- let payload = event.payload as Record<string, unknown>;
1440
- // Layer both injections: adapter selection, then sampling overrides.
1441
- // Each returns undefined when it has nothing to change, so we keep the
1442
- // prior payload in that case.
1443
- payload = injectAdapter(payload, this.selectedAdapter) ?? payload;
1444
- // Compose next_turn OVER session, then CONSUME the one-shot override
1445
- // immediately — this is the very first outgoing provider request
1446
- // since it was armed (before_provider_request fires once per model
1447
- // call), so clearing it here guarantees it rides on exactly one
1448
- // request and never leaks into a second tool-loop turn of the same
1449
- // prompt, let alone the next user message.
1450
- const { effective, nextState } = consumeForRequest(this.samplingScope);
1451
- this.samplingScope = nextState;
1452
- payload = injectSampling(payload, effective) ?? payload;
1453
- return payload;
1454
- });
1455
- }
1456
-
1457
- /** Register the before_agent_start hook that layers the user's custom
1458
- * system prompt onto the built-in surface prompt. This fires fresh inside
1459
- * EVERY session.prompt() call (pi passes the freshly-built base prompt
1460
- * each time, see agent-session.js's emitBeforeAgentStart call site), so a
1461
- * set_system_prompt sent mid-session takes effect starting with the very
1462
- * next turn — there is no "only at session creation" limitation here, and
1463
- * the UI copy should say so plainly (it applies going forward, not
1464
- * retroactively to already-sent turns). Returning undefined leaves pi's
1465
- * base prompt (identity + memoryHint) untouched. */
1466
- private installSystemPromptHook(pi: ExtensionAPI): void {
1467
- pi.on("before_agent_start", (event) => {
1468
- const merged = injectSystemPrompt(event.systemPrompt, this.systemPrompt);
1469
- return merged !== undefined ? { systemPrompt: merged } : undefined;
1470
- });
1471
- }
1472
-
1473
- /** Register the before_agent_start hook that auto-prepends the compact
1474
- * one-line ambient context (plan §6.6 "never answer blind") to every
1475
- * turn's system prompt — the SAME layering mechanism
1476
- * installSystemPromptHook uses for the user's custom prompt (pi's
1477
- * BeforeAgentStartEventResult can only replace systemPrompt, chained
1478
- * across multiple extensions — there's no channel to rewrite the raw
1479
- * user prompt text itself). Fires fresh on every session.prompt() call,
1480
- * so it always reflects the LATEST context push, never a stale one from
1481
- * session-creation time. Returns undefined (no-op) when nothing has
1482
- * been pushed yet — a non-browser WS client, or the race before chat.ts's
1483
- * first `context` frame lands, must never inject a made-up location. */
1484
- private installAppContextHook(pi: ExtensionAPI): void {
1485
- pi.on("before_agent_start", (event) => {
1486
- const line = ambientContextLine(this.currentAppContext);
1487
- if (!line) return undefined;
1488
- return { systemPrompt: `${event.systemPrompt}\n\n${line}` };
1489
- });
1490
- }
1491
-
1492
- /**
1493
- * Register the tool_call approval gate on the inline extension.
1494
- *
1495
- * Approval-fatigue defaults (web-chat-redesign.md §5.4 matrix — verified
1496
- * here, not just asserted): READ_ONLY_TOOLS NEVER prompt, checked first
1497
- * and unconditionally — no config, always-allow list, or codingTools
1498
- * state can make a read-only tool gated, and no combination of state can
1499
- * make a GATED_TOOLS member skip the card except the durable
1500
- * tool-approvals config (isToolAlwaysAllowed) or read-only-mode's
1501
- * outright denial. This keeps the fatigue budget spent only on tools
1502
- * that actually mutate the machine.
1503
- */
1504
- /**
1505
- * Register loop-hygiene bookkeeping: turnIndex tracking (turn_start),
1506
- * the before-execution dedup/turn-cap block (tool_call), and the
1507
- * after-execution failure-streak nudge (tool_result). Registered FIRST
1508
- * among the tool_call handlers (see extensionFactories order in
1509
- * createRuntimeFactory) so a deduped or turn-capped call never even
1510
- * reaches the approval gate — no point prompting the user to approve
1511
- * a call we're about to skip. See the LOOP_HYGIENE block comment above
1512
- * for what the SDK already does vs. what this closes.
1513
- */
1514
- private installLoopHygieneHooks(pi: ExtensionAPI): void {
1515
- pi.on("turn_start", (event) => {
1516
- this.loopHygiene.turnIndex = event.turnIndex;
1517
- // A fresh user prompt always restarts at turnIndex 0 (AgentSession
1518
- // resets _turnIndex on agent_start, before the first turn_start) —
1519
- // treat that as "new tool loop" and clear dedup/failure state so a
1520
- // previous message's history never suppresses/nudges this one.
1521
- if (event.turnIndex === 0) this.loopHygiene = initialLoopHygieneState();
1522
- });
1523
-
1524
- pi.on("tool_call", (event: ToolCallEvent) => {
1525
- const block = decideBeforeToolCall(this.loopHygiene, event.toolName, event.input);
1526
- if (block && runtimeValue("MLX_BUN_PI_DEBUG")) {
1527
- console.error(`[pi-web] loop-hygiene blocked ${event.toolName}: ${block.reason.split("\n")[0]}`);
1528
- }
1529
- return block ? { block: true, reason: block.reason } : undefined;
1530
- });
1531
-
1532
- pi.on("tool_result", (event) => {
1533
- const nudge = recordToolCallOutcome(this.loopHygiene, event.toolName, event.input, {
1534
- isError: event.isError,
1535
- resultText: toolResultText(event.content),
1536
- });
1537
- if (!nudge) return undefined;
1538
- // Append the course-correction nudge to the existing error content
1539
- // rather than replacing it, so the model still sees the real
1540
- // failure reason plus the "stop repeating this" instruction.
1541
- const existingText = toolResultText(event.content);
1542
- return {
1543
- content: [{ type: "text", text: existingText + nudge }],
1544
- isError: event.isError,
1545
- };
1546
- });
1547
- }
1548
-
1549
- private installApprovalGate(pi: ExtensionAPI): void {
1550
- pi.on("tool_call", async (event: ToolCallEvent) => {
1551
- const tool = event.toolName;
1552
- if (runtimeValue("MLX_BUN_PI_DEBUG")) {
1553
- console.error(`[pi-web] tool_call ${tool} args=${JSON.stringify(event.input)}`);
1554
- }
1555
-
1556
- // Read-only tools never need approval.
1557
- if (READ_ONLY_TOOLS.has(tool)) return undefined;
1558
-
1559
- // Anything mutating is denied outright in read-only mode — before the
1560
- // durable always-allow list is even consulted, since --read-only is a
1561
- // server operator's hard constraint, not a per-tool user preference.
1562
- if (this.opts.readOnly && GATED_TOOLS.has(tool)) {
1563
- return { block: true, reason: "Read-only session: mutating tools are disabled." };
1564
- }
1565
-
1566
- // Non-gated, non-read-only tools (shouldn't happen with the shared pi
1567
- // surface, but be safe): allow.
1568
- if (!GATED_TOOLS.has(tool)) return undefined;
1569
-
1570
- // Durable "always allow this tool" (risk #6): a prior approval card on
1571
- // THIS machine checked the box, persisting to ~/.mlx-bun/tool-
1572
- // approvals.json (src/tool-approvals.ts). Skip the round-trip entirely
1573
- // — no card, no wait, matching every session and every browser tab.
1574
- if (isToolAlwaysAllowed(tool)) return undefined;
1575
-
1576
- const decision = await this.requestApproval(event);
1577
- if (decision.decision === "deny") {
1578
- return { block: true, reason: "Denied by user." };
1579
- }
1580
- // Editable arguments (LM Studio's pattern): the SDK's documented
1581
- // mutation contract is "mutate event.input in place before returning"
1582
- // — there is no return-value channel for modified args (ToolCallEventResult
1583
- // is `{block?, reason?}` only). event.input is asserted as a mutable
1584
- // Record here since ToolCallEvent's per-tool variants type it as their
1585
- // specific input shape, but the mutation contract is untyped-record at
1586
- // the wire level (this mirrors how pi's own bundled permission-gate
1587
- // example extension does it).
1588
- applyEditedArgs(event.input as Record<string, unknown>, decision.editedArgs);
1589
- if (decision.alwaysAllow) {
1590
- setToolAlwaysAllowed(tool);
1591
- this.sendToolApprovals();
1592
- }
1593
- return undefined; // allow
1594
- });
1595
- }
1596
-
1597
- /**
1598
- * Ask the browser to approve a gated tool call and await its decision.
1599
- * Auto-denies after APPROVAL_TIMEOUT_MS or if the connection drops.
1600
- */
1601
- private requestApproval(event: ToolCallEvent): Promise<{ decision: ApprovalDecision; editedArgs?: Record<string, unknown>; alwaysAllow?: boolean }> {
1602
- const callId = event.toolCallId;
1603
- return new Promise((resolve) => {
1604
- const settle = (decision: ApprovalDecision, editedArgs?: Record<string, unknown>, alwaysAllow?: boolean) => {
1605
- const timer = this.approvalTimers.get(callId);
1606
- if (timer) clearTimeout(timer);
1607
- this.approvalTimers.delete(callId);
1608
- this.pendingApprovals.delete(callId);
1609
- resolve({ decision, editedArgs, alwaysAllow });
1610
- };
1611
-
1612
- this.pendingApprovals.set(callId, settle);
1613
- const timer = setTimeout(() => settle("deny"), APPROVAL_TIMEOUT_MS);
1614
- this.approvalTimers.set(callId, timer);
1615
-
1616
- this.send({
1617
- type: "tool_approval_request",
1618
- callId,
1619
- tool: event.toolName,
1620
- args: event.input,
1621
- });
1622
- });
1623
- }
1624
-
1625
- /** Resolve a pending approval from a browser `approval` frame. */
1626
- resolveApproval(callId: string, decision: ApprovalDecision, editedArgs?: Record<string, unknown>, alwaysAllow?: boolean): void {
1627
- const settle = this.pendingApprovals.get(callId);
1628
- if (settle) settle(decision, editedArgs, alwaysAllow);
1629
- }
1630
-
1631
- /** Push the effective-vs-pending codingTools state (sent on ready and
1632
- * after every set_coding_tools). */
1633
- private sendCodingToolsState(): void {
1634
- this.send({ type: "coding_tools", active: this.codingToolsActive, pending: this.codingToolsRequested });
1635
- }
1636
-
1637
- /** Push the full durable always-allow set (sent on ready and after any
1638
- * change), so the settings panel can list/forget approvals without a
1639
- * separate REST round-trip. */
1640
- private sendToolApprovals(): void {
1641
- this.send({ type: "tool_approvals", alwaysAllow: listAlwaysAllowedTools() });
1642
- }
1643
-
1644
- private onSessionEvent(event: AgentSessionEvent): void {
1645
- if (runtimeValue("MLX_BUN_PI_DEBUG")) {
1646
- const extra = event.type === "message_update"
1647
- ? `/${(event as { assistantMessageEvent?: { type?: string } }).assistantMessageEvent?.type}` : "";
1648
- console.error(`[pi-event] ${event.type}${extra}`);
1649
- }
1650
- for (const frame of mapEventToFrames(event)) this.send(frame);
1651
- // A completed turn is when pi flushes the session to disk (it defers
1652
- // writing until the first assistant reply), so refresh the sidebar then:
1653
- // a brand-new chat appears, and the active row's title/time update.
1654
- // Also refresh the context-usage indicator (it grows each turn, and
1655
- // drops sharply when auto-compaction fires).
1656
- if (event.type === "turn_end") {
1657
- void this.sendSessions();
1658
- this.sendContextUsage();
1659
- // A completed turn (including regenerate/edit_resend's re-prompt) can
1660
- // change the last user message's sibling count/index.
1661
- this.sendSiblings();
1662
- }
1663
- }
1664
-
1665
- /** Handle one parsed client frame. */
1666
- async handle(msg: ClientMessage): Promise<void> {
1667
- // Session-management frames don't require (and may replace) the active
1668
- // session, so handle them before the readiness guard.
1669
- switch (msg.type) {
1670
- case "new_session":
1671
- await this.newSession();
1672
- return;
1673
- case "list_sessions":
1674
- await this.sendSessions();
1675
- return;
1676
- case "open_session":
1677
- await this.openSession(msg.path);
1678
- return;
1679
- case "fork_session":
1680
- await this.forkSession(msg.path);
1681
- return;
1682
- case "delete_session":
1683
- this.deleteSession(msg.path);
1684
- return;
1685
- case "set_coding_tools":
1686
- // Record the request; doesn't touch the ALREADY-BUILT session's tool
1687
- // surface (pi bakes tools/customTools in at createAgentSessionServices
1688
- // time — there is no live "add a tool to this running session" hook).
1689
- // Applied by createRuntimeFactory the next time the SDK calls it:
1690
- // new_session, open_session, fork_session, or a reconnect's initial
1691
- // start(). sendCodingToolsState reports both active (this session)
1692
- // and pending (the request just recorded) so the UI can be honest
1693
- // about "applies to your next new chat" rather than implying it's
1694
- // live now.
1695
- this.codingToolsRequested = !!msg.enabled;
1696
- this.sendCodingToolsState();
1697
- return;
1698
- case "context":
1699
- // App-aware assistant (plan §6.6): stored wholesale, no session
1700
- // required — chat.ts pushes this on route/step change, which can
1701
- // race the very first `ready` frame on initial connect. Read by
1702
- // get_current_app_context (live) and installAppContextHook (the
1703
- // ambient one-liner auto-prepended to the NEXT turn).
1704
- this.currentAppContext = msg.context;
1705
- return;
1706
- }
1707
-
1708
- const session = this.session;
1709
- if (!session) {
1710
- this.send({ type: "error", message: "session not ready" });
1711
- return;
1712
- }
1713
-
1714
- switch (msg.type) {
1715
- // The whole chat is just this: hand the user's message to pi's
1716
- // AgentSession and let pi run the turn (reply or tool calls, which pi
1717
- // executes). The ONLY decision we make is the canonical idle-vs-streaming
1718
- // branch from pi's own prompt() contract (core/agent-session.ts): an idle
1719
- // session runs a normal turn; if a turn is already streaming (the user
1720
- // typed again before it finished), the message is queued as a follow-up
1721
- // so it becomes the next turn instead of being dropped. pi re-checks
1722
- // isStreaming atomically inside prompt(), so this read can't strand a
1723
- // message at the turn boundary.
1724
- case "prompt": {
1725
- if (runtimeValue("MLX_BUN_PI_DEBUG")) {
1726
- console.error(`[pi-web] prompt text=${JSON.stringify(msg.text.slice(0, 500))} images=${msg.images?.length ?? 0} streaming=${session.isStreaming} adapter=${this.selectedAdapter ?? "base"}`);
1727
- }
1728
- const images = toPiImages(msg.images);
1729
- // Guard a one-shot next_turn override against prompt() throwing
1730
- // BEFORE it ever issues a provider request (no model selected, auth
1731
- // failure, etc.) — the normal consumer, installAdapterHook's
1732
- // before_provider_request hook, never fires in that case, so
1733
- // without this the armed override would silently survive to land
1734
- // on some later, unrelated successful turn instead of this one.
1735
- // sendError (the WS layer's outer catch) still reports the error;
1736
- // this only prevents the override from outliving its intended turn.
1737
- try {
1738
- if (session.isStreaming) await session.prompt(msg.text, { streamingBehavior: "followUp", images });
1739
- else await session.prompt(msg.text, { images });
1740
- } catch (err) {
1741
- this.clearArmedOneShotOnFailedPrompt();
1742
- throw err;
1743
- }
1744
- return;
1745
- }
1746
- case "abort":
1747
- await session.abort();
1748
- return;
1749
- case "approval":
1750
- this.resolveApproval(msg.callId, msg.decision, msg.editedArgs, msg.alwaysAllow);
1751
- return;
1752
- case "set_thinking":
1753
- // Pi clamps to the model's available levels; a no-op for models
1754
- // without a switchable reasoning channel.
1755
- session.setThinkingLevel(msg.enabled ? "medium" : "off");
1756
- return;
1757
- case "set_adapter":
1758
- // Pi-native adapter control: record the selection; the
1759
- // before_provider_request hook injects it into the outgoing payload.
1760
- // app.html has already mounted it server-side (POST /v1/adapters).
1761
- this.selectedAdapter = msg.id;
1762
- return;
1763
- case "set_sampling": {
1764
- // The full mlx_lm.server sampler extension set (min_p/XTC/penalty
1765
- // families/seed), not just temperature/top_p/top_k
1766
- // (web-ui-pass-plan.md #8), for either scope.
1767
- const overrides: SamplingOverrides = {
1768
- temperature: msg.temperature ?? null,
1769
- top_p: msg.top_p ?? null,
1770
- top_k: msg.top_k ?? null,
1771
- min_p: msg.min_p ?? null,
1772
- xtc_probability: msg.xtc_probability ?? null,
1773
- xtc_threshold: msg.xtc_threshold ?? null,
1774
- repetition_penalty: msg.repetition_penalty ?? null,
1775
- repetition_context_size: msg.repetition_context_size ?? null,
1776
- presence_penalty: msg.presence_penalty ?? null,
1777
- frequency_penalty: msg.frequency_penalty ?? null,
1778
- seed: msg.seed ?? null,
1779
- };
1780
- // scope "next_turn": one-shot, armed via applySetSampling and
1781
- // consumed by installAdapterHook's before_provider_request handler
1782
- // on the very next outgoing provider request — never touches the
1783
- // session-level value, so a later plain set_sampling (scope
1784
- // "session" or omitted) still sees whatever was there before this
1785
- // message. scope "session"/omitted: today's behavior, persists on
1786
- // the connection until changed; a null/undefined field clears the
1787
- // override so the server's mode-aware default applies again.
1788
- this.samplingScope = applySetSampling(this.samplingScope, overrides, msg.scope);
1789
- return;
1790
- }
1791
- case "set_system_prompt":
1792
- // Store verbatim (including whitespace-only/empty -> effectively
1793
- // cleared by injectSystemPrompt's trim+empty check); applied by the
1794
- // before_agent_start hook starting with the next turn.
1795
- this.systemPrompt = msg.text;
1796
- return;
1797
- case "regenerate":
1798
- await this.regenerate();
1799
- return;
1800
- case "edit_resend":
1801
- await this.editResend(msg.text);
1802
- return;
1803
- case "switch_sibling":
1804
- this.switchSibling(msg.entryId);
1805
- return;
1806
- }
1807
- }
1808
-
1809
- /** Tear down the session and reject any in-flight approvals. */
1810
- dispose(): void {
1811
- this.disposed = true;
1812
- this.teardownBindings();
1813
- void this.runtime?.dispose();
1814
- this.runtime = undefined;
1815
- }
1816
-
1817
- /** Send a frame, swallowing errors on a closed socket. */
1818
- private send(msg: ServerMessage): void {
1819
- try {
1820
- this.ws.send(JSON.stringify(msg));
1821
- } catch {
1822
- // Socket closed mid-send; nothing actionable.
1823
- }
1824
- }
1825
- }
1826
-
1827
- // ---- Public handler factory ------------------------------------------
1828
-
1829
- /**
1830
- * Build a Bun WebSocket handler that bridges browser connections to
1831
- * in-process pi AgentSessions driving mlx-bun's local model.
1832
- *
1833
- * @param opts.port Port the mlx-bun server listens on; the pi
1834
- * provider points at http://127.0.0.1:<port>/v1.
1835
- * @param opts.modelId Model id reported in the `ready` frame.
1836
- * Default: "local".
1837
- * @param opts.contextWindow Context window advertised to pi. Default: 32768.
1838
- * Source: ctx.model.config.text.maxPositionEmbeddings.
1839
- * @param opts.readOnly When true, bash/edit/write are denied outright.
1840
- * @param opts.genDefaults Model-author sampling defaults, sent once on `ready`.
1841
- */
1842
- export function makePiWsHandler(opts: {
1843
- port: number | (() => number);
1844
- modelId?: string;
1845
- contextWindow?: number;
1846
- readOnly?: boolean;
1847
- /** Whether the loaded model can accept images (server's ctx.vision != null). */
1848
- vision?: boolean;
1849
- /** Whether the loaded model can accept `input_audio` parts (server's audio
1850
- * tower loaded or loadable). Rides only the `ready` frame — pi's
1851
- * Model.input type has no "audio" modality to declare. */
1852
- audio?: boolean;
1853
- /** Whether the model has a switchable reasoning channel
1854
- * (server's ctx.template.supportsThinking). Drives the thinking toggle. */
1855
- thinking?: boolean;
1856
- /** Model-author sampling defaults (generation_config.json + server-CLI
1857
- * overrides), sent once on the `ready` frame. Default: all-null (the
1858
- * sampling popover falls back to its own hardcoded shape). */
1859
- genDefaults?: ReadyGenDefaults;
1860
- /** Whether /v1/audio/sessions can serve this browser (a Whisper checkpoint
1861
- * is configured or resolvable). Async: the default checkpoint resolves
1862
- * lazily through the registry. */
1863
- transcription?: () => Promise<boolean>;
1864
- }): WebSocketHandler<PiWsData> {
1865
- const resolved = {
1866
- port: opts.port,
1867
- modelId: opts.modelId ?? PI_LOCAL_MODEL_ID,
1868
- contextWindow: opts.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
1869
- readOnly: opts.readOnly ?? false,
1870
- vision: opts.vision ?? false,
1871
- audio: opts.audio ?? false,
1872
- thinking: opts.thinking ?? false,
1873
- genDefaults: opts.genDefaults ?? { temperature: null, topP: null, topK: null },
1874
- transcription: opts.transcription ?? (async () => false),
1875
- };
1876
-
1877
- return {
1878
- async open(ws) {
1879
- const session = new PiWebSession(ws, resolved);
1880
- sessions.set(ws, session);
1881
- try {
1882
- await session.start();
1883
- } catch (err) {
1884
- // Model not reachable yet (or any build failure): report and let
1885
- // the client retry. Keep the entry so close() still cleans up.
1886
- sessions.delete(ws);
1887
- session.dispose();
1888
- sendError(ws, err);
1889
- }
1890
- },
1891
-
1892
- async message(ws, raw) {
1893
- const session = sessions.get(ws);
1894
- if (!session) {
1895
- sendError(ws, "no active session");
1896
- return;
1897
- }
1898
- let msg: ClientMessage;
1899
- try {
1900
- msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8")) as ClientMessage;
1901
- } catch {
1902
- sendError(ws, "invalid JSON");
1903
- return;
1904
- }
1905
- try {
1906
- await session.handle(msg);
1907
- } catch (err) {
1908
- sendError(ws, err);
1909
- }
1910
- },
1911
-
1912
- close(ws) {
1913
- const session = sessions.get(ws);
1914
- sessions.delete(ws);
1915
- session?.dispose();
1916
- },
1917
- };
1918
- }
1919
-
1920
- /** Dispose every live pi session. Call on server shutdown. */
1921
- export async function disposeAllPiSessions(): Promise<void> {
1922
- for (const session of sessions.values()) {
1923
- try {
1924
- session.dispose();
1925
- } catch {
1926
- // Best-effort; keep disposing the rest.
1927
- }
1928
- }
1929
- sessions.clear();
1930
- }
1931
-
1932
- /** Send a best-effort `error` frame from any thrown value. */
1933
- function sendError(ws: PiWs, err: unknown): void {
1934
- const message = err instanceof Error ? err.message : String(err);
1935
- try {
1936
- ws.send(JSON.stringify({ type: "error", message } satisfies ServerMessage));
1937
- } catch {
1938
- // Socket already gone.
1939
- }
1940
- }