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.
- package/README.md +1449 -109
- package/THIRD_PARTY_NOTICES.md +882 -0
- package/bin/mlx-bun.convert.mjs +4 -0
- package/bin/mlx-bun.fuse.mjs +4 -0
- package/bin/mlx-bun.generate.mjs +4 -0
- package/bin/mlx-bun.lora.mjs +4 -0
- package/bin/mlx-bun.mjs +13 -21
- package/bin/mlx-bun.server.mjs +4 -0
- package/bin/mlx-bun.upload.mjs +4 -0
- package/dist/web/app.js +9584 -0
- package/package.json +50 -53
- package/src/cli/ane-install.ts +76 -0
- package/src/cli/args.ts +179 -0
- package/src/cli/engine-entry.ts +68 -0
- package/src/cli/inference.ts +214 -0
- package/src/cli/job-entry.ts +97 -0
- package/src/cli/library-host.ts +117 -0
- package/src/cli/main.ts +57 -0
- package/src/cli/memory-engine.ts +163 -0
- package/src/cli/memory.ts +36 -0
- package/src/cli/mlx-lm-aliases.ts +309 -0
- package/src/cli/model-choice.ts +33 -0
- package/src/cli/model-selection.ts +84 -0
- package/src/cli/module-host.ts +11 -0
- package/src/cli/module-verbs.ts +50 -0
- package/src/cli/numerical-policy.ts +12 -0
- package/src/cli/perplexity.ts +89 -0
- package/src/cli/serve-host.ts +423 -0
- package/src/cli/serve-isolated.ts +217 -0
- package/src/cli/serve-options.ts +106 -0
- package/src/cli/serve-state.ts +205 -0
- package/src/cli/serve.ts +500 -0
- package/src/cli/served-model-host.ts +102 -0
- package/src/cli/server-entry.ts +148 -0
- package/src/cli/serving-unit.ts +177 -0
- package/src/{tui.ts → cli/terminal.ts} +13 -8
- package/src/cli/worker-adapters.ts +34 -0
- package/src/cli/worker-entry.ts +272 -0
- package/src/cli/worker-unit.ts +179 -0
- package/src/engine/adapter-operation.ts +26 -0
- package/src/engine/cache-services.ts +114 -0
- package/src/engine/completion.ts +39 -0
- package/src/engine/generation-gateway.ts +358 -0
- package/src/engine/index.ts +59 -0
- package/src/engine/kv-budget.ts +120 -0
- package/src/engine/media-preparation.ts +232 -0
- package/src/engine/model-binding.ts +24 -0
- package/src/engine/model-host.ts +513 -0
- package/src/engine/model-serving.ts +89 -0
- package/src/{serve → engine}/preparation.ts +7 -5
- package/src/{serve → engine}/session-completion-engine.ts +6 -6
- package/src/engine/telemetry.ts +109 -0
- package/src/hub/downloads.ts +116 -0
- package/src/jobs/db.ts +16 -7
- package/src/jobs/events.ts +29 -0
- package/src/jobs/executable.ts +5 -0
- package/src/jobs/host.ts +51 -0
- package/src/jobs/in-process.ts +55 -0
- package/src/{contracts/jobs.ts → jobs/protocol.ts} +7 -9
- package/src/jobs/runner.ts +150 -231
- package/src/jobs/service.ts +75 -0
- package/src/jobs/sse.ts +59 -23
- package/src/jobs/worker-memory.ts +46 -0
- package/src/jobs/worker-process.ts +163 -0
- package/src/jobs/worker-supervisor.ts +258 -0
- package/src/modules.ts +58 -0
- package/src/publishing/catalog-hub.ts +18 -0
- package/src/publishing/credentials.ts +31 -0
- package/src/publishing/upload.ts +37 -0
- package/src/residency/leased-adapters.ts +21 -0
- package/src/residency/local-records.ts +16 -0
- package/src/residency/model-residency.ts +405 -0
- package/src/residency/record-index.ts +28 -0
- package/src/residency/resident-estimate.ts +50 -0
- package/src/{anthropic.ts → server/anthropic.ts} +3 -81
- package/src/server/cache-routes.ts +36 -0
- package/src/{serve → server}/chat-request.ts +10 -10
- package/src/{serve → server}/chat-stage.ts +14 -14
- package/src/{client.ts → server/client.ts} +28 -2
- package/src/{serve → server}/completion-executor.ts +33 -41
- package/src/server/decision-body.ts +55 -0
- package/src/server/decision-routes.ts +134 -0
- package/src/server/decision-wire.ts +228 -0
- package/src/{serve → server}/discovery-routes.ts +25 -82
- package/src/{serve → server}/http.ts +25 -13
- package/src/{serve → server}/inference-request.ts +5 -5
- package/src/server/job-routes.ts +20 -0
- package/src/server/media-prompt.ts +63 -0
- package/src/server/memory-completion-client.ts +46 -0
- package/src/server/model-routes.ts +192 -0
- package/src/{serve → server}/openai-wire.ts +3 -2
- package/src/{serve → server}/pipeline.ts +1 -1
- package/src/server/prompt-builder.ts +13 -0
- package/src/server/prompt-contracts.ts +17 -0
- package/src/server/proxy-routes.ts +187 -0
- package/src/server/publishing-routes.ts +41 -0
- package/src/{serve → server}/request-plan.ts +6 -8
- package/src/{serve → server}/request-prep.ts +47 -50
- package/src/{serve → server}/responses-client.ts +30 -13
- package/src/{responses.ts → server/responses.ts} +12 -93
- package/src/server/routes.ts +205 -0
- package/src/server/start.ts +72 -0
- package/src/server/status-routes.ts +108 -0
- package/src/{serve → server}/text-completion-stage.ts +25 -4
- package/src/server/text-prompt.ts +12 -0
- package/src/{serve → server}/token-streams.ts +36 -38
- package/src/server/worker-routes.ts +311 -0
- package/src/storage/paths.ts +61 -0
- package/src/web/assets.ts +37 -0
- package/src/web/{src → browser}/api.ts +5 -23
- package/src/web/browser/chat-host.ts +17 -0
- package/src/web/browser/installed-panels.ts +6 -0
- package/src/web/browser/main.ts +55 -0
- package/src/web/browser/markdown.ts +279 -0
- package/src/web/browser/memory-panel.ts +9 -0
- package/src/web/browser/palette.ts +64 -0
- package/src/web/browser/protocol.ts +13 -0
- package/src/web/browser/shell.ts +467 -0
- package/src/web/{src → browser}/status.ts +32 -36
- package/src/web/build.ts +51 -0
- package/src/web/public/app.html +1054 -0
- package/src/web/{sw.js → public/sw.js} +4 -3
- package/src/web/{vendor → public/vendor}/hljs-theme.css +2 -2
- package/src/web/{vendor → public/vendor}/hljs.js +2 -2
- package/THIRD_PARTY_LICENSES.md +0 -51
- package/docs/README.md +0 -107
- package/docs/archive/hlg-sampling.md +0 -350
- package/docs/archive/investigations/audio-input-plan.md +0 -356
- package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +0 -3617
- package/docs/archive/investigations/chunk-finetune-experiment.md +0 -84
- package/docs/archive/investigations/colibri-glm52-port.md +0 -1486
- package/docs/archive/investigations/colibri-oracle-pin.md +0 -1284
- package/docs/archive/investigations/curve-bisector-routing.md +0 -123
- package/docs/archive/investigations/decode-roofline-lookagain.md +0 -348
- package/docs/archive/investigations/diffusion-gemma-port.md +0 -602
- package/docs/archive/investigations/dspark-handoff.md +0 -224
- package/docs/archive/investigations/expert-offload-single-user-moe.md +0 -292
- package/docs/archive/investigations/faithful-l1-consolidation.md +0 -310
- package/docs/archive/investigations/hlg-sampling-investigation.md +0 -970
- package/docs/archive/investigations/indexshare-performance-spike.md +0 -357
- package/docs/archive/investigations/kernel-perf-review-2026-07.md +0 -171
- package/docs/archive/investigations/lab-build-journal.md +0 -157
- package/docs/archive/investigations/minicpm5-decode-megakernel.md +0 -489
- package/docs/archive/investigations/openjarvis-fit.md +0 -198
- package/docs/archive/investigations/openwiki-evaluation.md +0 -249
- package/docs/archive/investigations/optimization_plan.md +0 -346
- package/docs/archive/investigations/orpo-base-uf-experiment-and-directions.md +0 -120
- package/docs/archive/investigations/orpo-flash-cce-pin-leak.md +0 -167
- package/docs/archive/investigations/orpo-uf-testing-handoff.md +0 -126
- package/docs/archive/investigations/pareto-specialized-runtime-findings.md +0 -92
- package/docs/archive/investigations/pi-builtin-investigation.md +0 -308
- package/docs/archive/investigations/pre-colibri-stabilization.md +0 -329
- package/docs/archive/investigations/qwen38-27b-campaign-log.md +0 -1092
- package/docs/archive/investigations/segmented-backward-handoff.md +0 -148
- package/docs/archive/investigations/starter-model-port-handoff.md +0 -342
- package/docs/archive/investigations/steel-flash-cce-handoff.md +0 -489
- package/docs/archive/investigations/the-dreaming-handoff.md +0 -141
- package/docs/archive/investigations/trainer-validation-experiment.md +0 -173
- package/docs/archive/planning/chat-ui-vision-memory-first.md +0 -199
- package/docs/archive/planning/chat-ui-vision.md +0 -50
- package/docs/archive/planning/chunk-task-roadmap.md +0 -77
- package/docs/archive/planning/curve-sampler-research-plan.md +0 -331
- package/docs/archive/planning/journal.md +0 -316
- package/docs/archive/planning/memory-docs-and-dag-plan.md +0 -194
- package/docs/archive/planning/pi-integration-review.md +0 -178
- package/docs/archive/planning/release-notes-v0.0.10.md +0 -57
- package/docs/archive/planning/release-notes-v0.0.11.md +0 -239
- package/docs/archive/planning/release-notes-v0.0.12.md +0 -80
- package/docs/archive/planning/release-notes-v0.0.13.md +0 -77
- package/docs/archive/planning/release-notes-v0.0.9.md +0 -122
- package/docs/archive/planning/release-notes-v0.2.0.md +0 -49
- package/docs/archive/planning/release-notes-v0.3.0.md +0 -35
- package/docs/archive/planning/release-notes-v0.4.0.md +0 -45
- package/docs/archive/planning/release-notes-v0.5.0.md +0 -37
- package/docs/archive/planning/web-ui-pass-plan.md +0 -213
- package/docs/archive/planning/website-readme-pass-plan.md +0 -224
- package/docs/design/batching.md +0 -2054
- package/docs/design/decode-speed-program.md +0 -2404
- package/docs/design/dreaming-nightly-pipeline.md +0 -684
- package/docs/design/generic-model-support.md +0 -821
- package/docs/design/kv-cache.md +0 -993
- package/docs/design/orpo-training.md +0 -680
- package/docs/design/speculative-decoding.md +0 -2129
- package/docs/design/turboquant.md +0 -2225
- package/docs/design/unified-engine-frontier-plan.md +0 -1987
- package/docs/design/web-chat-redesign.md +0 -695
- package/docs/planning/PRODUCT_ROADMAP.md +0 -593
- package/docs/planning/ResearchTopics.md +0 -119
- package/docs/planning/public-readiness-handoff.md +0 -244
- package/docs/planning/public-readiness.md +0 -149
- package/docs/planning/video-series.md +0 -421
- package/docs/reference/benchmarks.md +0 -3587
- package/docs/reference/cli.md +0 -646
- package/docs/reference/distribution.md +0 -380
- package/docs/reference/environment.md +0 -494
- package/docs/reference/glossary.md +0 -39
- package/docs/reference/library-api.md +0 -688
- package/docs/reference/memory.md +0 -260
- package/docs/reference/models.md +0 -346
- package/docs/reference/server-api.md +0 -1945
- package/docs/reference/server-config.md +0 -973
- package/docs/reference/training.md +0 -462
- package/docs/reference/troubleshooting.md +0 -158
- package/src/admission-plan.ts +0 -18
- package/src/audio/audiotoolbox.ts +0 -112
- package/src/audio/conformer.ts +0 -655
- package/src/audio/decode.ts +0 -133
- package/src/audio/features.ts +0 -202
- package/src/audio/mic-capture.ts +0 -98
- package/src/audio/silero-vad.ts +0 -379
- package/src/audio/transcode.ts +0 -67
- package/src/audio/whisper-decode.ts +0 -774
- package/src/audio/whisper-fast.ts +0 -732
- package/src/audio/whisper-format.ts +0 -73
- package/src/audio/whisper-mel.ts +0 -185
- package/src/audio/whisper-timing.ts +0 -274
- package/src/audio/whisper-tokenizer.ts +0 -239
- package/src/audio/whisper-transcribe.ts +0 -410
- package/src/backends/mlx/assistant-target.ts +0 -46
- package/src/backends/mlx/autoregressive-draft-rows.ts +0 -120
- package/src/backends/mlx/autoregressive.ts +0 -129
- package/src/backends/mlx/batch-group.ts +0 -1874
- package/src/backends/mlx/cache-attachment.ts +0 -67
- package/src/backends/mlx/cache-layout.ts +0 -31
- package/src/backends/mlx/checkpoint-state.ts +0 -53
- package/src/backends/mlx/continuation-persistence.ts +0 -107
- package/src/backends/mlx/continuation-request.ts +0 -43
- package/src/backends/mlx/continuation.ts +0 -77
- package/src/backends/mlx/diffusion.ts +0 -39
- package/src/backends/mlx/draft-target.ts +0 -32
- package/src/backends/mlx/encoder-cache.ts +0 -25
- package/src/backends/mlx/evaluation.ts +0 -105
- package/src/backends/mlx/fill-append.ts +0 -58
- package/src/backends/mlx/fill-group.ts +0 -497
- package/src/backends/mlx/full-kv-row-append.ts +0 -32
- package/src/backends/mlx/full-kv-row-donor.ts +0 -54
- package/src/backends/mlx/full-transitioning-kv-rows.ts +0 -65
- package/src/backends/mlx/gateway-binding.ts +0 -174
- package/src/backends/mlx/graph.ts +0 -38
- package/src/backends/mlx/kv-codec.ts +0 -9
- package/src/backends/mlx/kv-maintenance.ts +0 -161
- package/src/backends/mlx/kv-tensor-rows.ts +0 -212
- package/src/backends/mlx/memory-client.ts +0 -189
- package/src/backends/mlx/methods.ts +0 -90
- package/src/backends/mlx/mixed-iteration.ts +0 -62
- package/src/backends/mlx/model-prompt.ts +0 -178
- package/src/backends/mlx/model-serving.ts +0 -80
- package/src/backends/mlx/paged-cache-codec.ts +0 -27
- package/src/backends/mlx/prefill-cohort.ts +0 -100
- package/src/backends/mlx/prefill-policy.ts +0 -26
- package/src/backends/mlx/prefill-rows.ts +0 -244
- package/src/backends/mlx/prefill.ts +0 -33
- package/src/backends/mlx/projected-context-rows.ts +0 -72
- package/src/backends/mlx/prompt-input.ts +0 -29
- package/src/backends/mlx/qwen-prompt-input.ts +0 -40
- package/src/backends/mlx/request-policy.ts +0 -20
- package/src/backends/mlx/request-state-policy.ts +0 -54
- package/src/backends/mlx/rollback.ts +0 -92
- package/src/backends/mlx/row-sampling.ts +0 -49
- package/src/backends/mlx/serial-executor.ts +0 -251
- package/src/backends/mlx/speculative-group.ts +0 -438
- package/src/backends/mlx/speculative-round.ts +0 -161
- package/src/backends/mlx/speculative.ts +0 -131
- package/src/backends/mlx/state-rows.ts +0 -104
- package/src/backends/mlx/state-views.ts +0 -46
- package/src/backends/mlx/target-layout-capability.ts +0 -35
- package/src/backends/mlx/text-engine.ts +0 -73
- package/src/backends/mlx/transitioning-kv-rows.ts +0 -173
- package/src/chat-template.ts +0 -382
- package/src/cli.ts +0 -3490
- package/src/config.ts +0 -399
- package/src/contracts/completion.ts +0 -16
- package/src/contracts/execution.ts +0 -70
- package/src/contracts/generation.ts +0 -103
- package/src/contracts/host.ts +0 -7
- package/src/contracts/object-cache.ts +0 -8
- package/src/contracts/pi-provider.ts +0 -30
- package/src/contracts/pi.ts +0 -261
- package/src/contracts/prefix-cache.ts +0 -33
- package/src/contracts/resources.ts +0 -25
- package/src/contracts/sampling.ts +0 -23
- package/src/contracts/scheduling.ts +0 -54
- package/src/contracts/tsconfig.json +0 -14
- package/src/dataset/generators.ts +0 -1087
- package/src/dataset/index.ts +0 -12
- package/src/dataset/job.ts +0 -81
- package/src/dataset/llm.ts +0 -71
- package/src/dataset/registry.ts +0 -528
- package/src/diffusion/diffusion-generate.ts +0 -507
- package/src/download.ts +0 -505
- package/src/embed.ts +0 -77
- package/src/engine/admission.ts +0 -59
- package/src/engine/cancellation.ts +0 -38
- package/src/engine/engine.ts +0 -56
- package/src/engine/execution-coordinator.ts +0 -70
- package/src/engine/execution-plan.ts +0 -64
- package/src/engine/execution-tasks.ts +0 -44
- package/src/engine/namespaced-cache.ts +0 -13
- package/src/engine/resources.ts +0 -53
- package/src/engine/scheduler.ts +0 -92
- package/src/engine/session.ts +0 -247
- package/src/eval/README.md +0 -130
- package/src/eval/ifeval.ts +0 -81
- package/src/eval/kl-prompts.ts +0 -29
- package/src/eval/kl.ts +0 -590
- package/src/eval/perplexity.ts +0 -164
- package/src/eval/quality-db.ts +0 -92
- package/src/eval/reference-logits.ts +0 -207
- package/src/eval/runner.ts +0 -124
- package/src/eval/score.ts +0 -49
- package/src/eval/tasks/bfcl.ts +0 -535
- package/src/eval/tasks/gsm8k.ts +0 -98
- package/src/eval/tasks/hashhop.ts +0 -139
- package/src/eval/tasks/humaneval.ts +0 -275
- package/src/eval/tasks/ifeval.ts +0 -525
- package/src/eval/tasks/mmlu.ts +0 -157
- package/src/eval/ultrafeedback.ts +0 -60
- package/src/evaldb.ts +0 -199
- package/src/expert-io.ts +0 -393
- package/src/expert-offload-build.ts +0 -77
- package/src/expert-offload.ts +0 -76
- package/src/expert-residency.ts +0 -1134
- package/src/expert-usage.ts +0 -415
- package/src/fill/echo-index.ts +0 -188
- package/src/fill/fill-session.ts +0 -463
- package/src/fill/proposal.ts +0 -68
- package/src/fill/schema-rows.ts +0 -506
- package/src/fill/tool-boundary.ts +0 -103
- package/src/fit.ts +0 -249
- package/src/generate.ts +0 -1352
- package/src/grammar.ts +0 -604
- package/src/harness-pi.ts +0 -184
- package/src/hf-push.ts +0 -628
- package/src/hub-rest.ts +0 -242
- package/src/index.ts +0 -63
- package/src/inference/denoising.ts +0 -17
- package/src/inference/draft-acceptance.ts +0 -41
- package/src/inference/draft-output.ts +0 -49
- package/src/inference/graph.ts +0 -33
- package/src/inference/prefill.ts +0 -45
- package/src/inference/rollback.ts +0 -7
- package/src/jobs/index.ts +0 -31
- package/src/jobs/job-entry.ts +0 -107
- package/src/jobs/task-client.ts +0 -19
- package/src/jobs/types.ts +0 -2
- package/src/kv-scheme.ts +0 -279
- package/src/kv-store.ts +0 -1135
- package/src/lab/README.md +0 -22
- package/src/lab/curve/curve-designer.html +0 -303
- package/src/lab/curve/curve-sampler.ts +0 -122
- package/src/lab/expert-trace/expert-trace.ts +0 -69
- package/src/lab/paged-kv/paged-attention.ts +0 -117
- package/src/lab/paged-kv/paged-kv-rows.ts +0 -111
- package/src/lab/paged-kv/paged-kv.ts +0 -336
- package/src/library.ts +0 -40
- package/src/lora.ts +0 -408
- package/src/md-modules.d.ts +0 -54
- package/src/media-fetch.ts +0 -442
- package/src/memory/article.ts +0 -558
- package/src/memory/chunk-validation.ts +0 -150
- package/src/memory/chunk.ts +0 -318
- package/src/memory/cluster.ts +0 -351
- package/src/memory/coverage.ts +0 -134
- package/src/memory/crosslink.ts +0 -0
- package/src/memory/db.ts +0 -253
- package/src/memory/entity.ts +0 -203
- package/src/memory/gate.ts +0 -187
- package/src/memory/history.ts +0 -152
- package/src/memory/ingest.ts +0 -335
- package/src/memory/model.ts +0 -183
- package/src/memory/normalize.ts +0 -671
- package/src/memory/parse.ts +0 -22
- package/src/memory/pipeline.ts +0 -275
- package/src/memory/prompts.ts +0 -65
- package/src/memory/query.ts +0 -334
- package/src/memory/reconcile.ts +0 -734
- package/src/memory/redirect.ts +0 -108
- package/src/memory/reindex.ts +0 -325
- package/src/memory/resolve.ts +0 -307
- package/src/memory/rest.ts +0 -245
- package/src/memory/route.ts +0 -272
- package/src/memory/schedule.ts +0 -165
- package/src/memory/stages.ts +0 -777
- package/src/memory/synthesize.ts +0 -1638
- package/src/memory/tools.ts +0 -749
- package/src/memory/vault.ts +0 -1194
- package/src/memory/wikify.ts +0 -1064
- package/src/mlx/array.ts +0 -425
- package/src/mlx/autograd.ts +0 -304
- package/src/mlx/checkpoint.ts +0 -149
- package/src/mlx/compile.ts +0 -168
- package/src/mlx/custom-vjp.ts +0 -188
- package/src/mlx/ffi.ts +0 -531
- package/src/mlx/materialize.ts +0 -23
- package/src/mlx/metal-kernel.ts +0 -159
- package/src/mlx/normalized-argmax.ts +0 -116
- package/src/mlx/ops.ts +0 -1108
- package/src/mlx/quantized-rows.ts +0 -36
- package/src/mlx/token-bitmask.ts +0 -52
- package/src/mlx/turboquant-kv-decode.ts +0 -82
- package/src/mlx/turboquant-kv-inverse.ts +0 -122
- package/src/mlx/turboquant-ops.ts +0 -861
- package/src/mlx/turboquant-tables.ts +0 -237
- package/src/mmap.ts +0 -90
- package/src/model/artifact-identity.ts +0 -73
- package/src/model/batched-kv.ts +0 -235
- package/src/model/batched-mask.ts +0 -279
- package/src/model/batched-quant.ts +0 -212
- package/src/model/batched-quantized-kv.ts +0 -144
- package/src/model/batched-rotating-quant.ts +0 -353
- package/src/model/batched-rotating-state.ts +0 -149
- package/src/model/batched-rotating.ts +0 -338
- package/src/model/batched-row-storage.ts +0 -179
- package/src/model/batched-ssm.ts +0 -52
- package/src/model/batched-turboquant-kv.ts +0 -94
- package/src/model/compiled-decode.ts +0 -712
- package/src/model/decoded-kv-donor.ts +0 -12
- package/src/model/delayed-quantized-kv.ts +0 -72
- package/src/model/delayed-rotating-quantized-kv.ts +0 -93
- package/src/model/delayed-turboquant-kv.ts +0 -57
- package/src/model/diffusion-gemma.ts +0 -1026
- package/src/model/factory.ts +0 -215
- package/src/model/fingerprint.ts +0 -45
- package/src/model/flash-attention.ts +0 -694
- package/src/model/full-prefill-padding.ts +0 -56
- package/src/model/full-prefill-row.ts +0 -105
- package/src/model/gemma4-base.ts +0 -2370
- package/src/model/gemma4.ts +0 -1395
- package/src/model/generated/gemma4-12b.ts +0 -429
- package/src/model/generated/gemma4-26b.ts +0 -448
- package/src/model/generated/gemma4-e4b.ts +0 -717
- package/src/model/generated/index.ts +0 -18
- package/src/model/glm52-atlas.ts +0 -511
- package/src/model/glm52-cache.ts +0 -792
- package/src/model/glm52-config.ts +0 -262
- package/src/model/glm52-container.ts +0 -305
- package/src/model/glm52-coupling.ts +0 -388
- package/src/model/glm52-dsa.ts +0 -606
- package/src/model/glm52-expert-layout.ts +0 -253
- package/src/model/glm52-layout.ts +0 -152
- package/src/model/glm52-memory.ts +0 -500
- package/src/model/glm52-mla.ts +0 -792
- package/src/model/glm52-moe.ts +0 -223
- package/src/model/glm52-pilot.ts +0 -537
- package/src/model/glm52-reference.ts +0 -445
- package/src/model/glm52-residency.ts +0 -557
- package/src/model/glm52-resident-weights.ts +0 -135
- package/src/model/glm52-streamed-experts.ts +0 -453
- package/src/model/glm52-streamed-metal.ts +0 -610
- package/src/model/glm52-weights.ts +0 -503
- package/src/model/glm52.ts +0 -1132
- package/src/model/implementation.ts +0 -69
- package/src/model/kv-attention-view.ts +0 -70
- package/src/model/minicpm5.ts +0 -376
- package/src/model/profile.ts +0 -468
- package/src/model/quantized-append-attention.ts +0 -31
- package/src/model/qwen-append.ts +0 -12
- package/src/model/qwen-conv.ts +0 -52
- package/src/model/qwen3-delta-state.ts +0 -74
- package/src/model/qwen3-delta.ts +0 -451
- package/src/model/qwen3-moe.ts +0 -404
- package/src/model/qwen3-mrope.ts +0 -250
- package/src/model/qwen3.ts +0 -277
- package/src/model/qwen3_5-checkpoint.ts +0 -164
- package/src/model/qwen3_5.ts +0 -881
- package/src/model/rotating-kv-donor.ts +0 -35
- package/src/model/rotating-kv-layout.ts +0 -262
- package/src/model/rotating-row-transaction.ts +0 -35
- package/src/model/speculative-rotating-kv.ts +0 -101
- package/src/model/ssm-prefill-padding.ts +0 -39
- package/src/model/support.ts +0 -110
- package/src/model/token-groups.ts +0 -66
- package/src/model/trellis-balanced-scatter.ts +0 -51
- package/src/model/trellis-linear.ts +0 -672
- package/src/model/trellis-shared-m.ts +0 -151
- package/src/model/trellis-shared-scatter.ts +0 -144
- package/src/model/trellis-splitk-prefill.ts +0 -158
- package/src/model/trellis-tiled-prefill.ts +0 -136
- package/src/model/trellis-vector-expand.ts +0 -62
- package/src/model/trellis-wide-prefill.ts +0 -94
- package/src/model/turboquant-codec.ts +0 -89
- package/src/model/universal/archs.ts +0 -332
- package/src/model/universal/dense.ts +0 -612
- package/src/model/universal/modules.ts +0 -170
- package/src/model/universal/rope.ts +0 -381
- package/src/model/whisper.ts +0 -435
- package/src/native/expert_io.c +0 -523
- package/src/native/frame_extract.swift +0 -140
- package/src/native/mic_capture.swift +0 -92
- package/src/native-pack.ts +0 -163
- package/src/pi-provider.ts +0 -53
- package/src/pi-session.ts +0 -81
- package/src/pi-terminal.ts +0 -274
- package/src/pi-web.ts +0 -1940
- package/src/preflight.ts +0 -158
- package/src/prompt-cache.ts +0 -682
- package/src/quantize/allocator.ts +0 -627
- package/src/quantize/atomic-output.ts +0 -38
- package/src/quantize/calibration.ts +0 -181
- package/src/quantize/config-writer.ts +0 -145
- package/src/quantize/index.ts +0 -84
- package/src/quantize/job.ts +0 -162
- package/src/quantize/quantizer.ts +0 -666
- package/src/quantize/rotate.ts +0 -603
- package/src/quantize/safetensors-writer.ts +0 -249
- package/src/quantize/sensitivity.ts +0 -216
- package/src/quantize/trellis.ts +0 -586
- package/src/quantize/weight-transform.ts +0 -292
- package/src/registry.ts +0 -602
- package/src/runtime-config.ts +0 -77
- package/src/safetensors.ts +0 -165
- package/src/sampler.ts +0 -1012
- package/src/serve/admin-routes.ts +0 -184
- package/src/serve/assets/curve-terrain.html +0 -105
- package/src/serve/assets/training-inference-map.html +0 -575
- package/src/serve/audio-routes.ts +0 -323
- package/src/serve/aux-routes.ts +0 -132
- package/src/serve/batch-scheduler.ts +0 -8
- package/src/serve/checkpoint-identity.ts +0 -64
- package/src/serve/generation-gateway.ts +0 -537
- package/src/serve/isolate.ts +0 -609
- package/src/serve/kv-budget.ts +0 -14
- package/src/serve/lab-routes.ts +0 -341
- package/src/serve/lane-registry.ts +0 -45
- package/src/serve/model-admin-routes.ts +0 -155
- package/src/serve/model-binding.ts +0 -49
- package/src/serve/model-host.ts +0 -511
- package/src/serve/parent-application.ts +0 -56
- package/src/serve/prompt-response-trace.ts +0 -161
- package/src/serve/response-lease.ts +0 -24
- package/src/serve/session-search.ts +0 -326
- package/src/serve/static-routes.ts +0 -101
- package/src/serve/transcription-server.ts +0 -64
- package/src/serve/transcription-service.ts +0 -502
- package/src/server.ts +0 -1282
- package/src/spec/assistant-rows.ts +0 -93
- package/src/spec/assistant-source.ts +0 -126
- package/src/spec/cache-identity.ts +0 -22
- package/src/spec/deepspec-rows.ts +0 -29
- package/src/spec/deepspec-source.ts +0 -145
- package/src/spec/dflash-source.ts +0 -149
- package/src/spec/drafter.ts +0 -304
- package/src/spec/dspark/ab-stats.ts +0 -136
- package/src/spec/dspark/calibration.ts +0 -112
- package/src/spec/dspark/data-dflash.ts +0 -156
- package/src/spec/dspark/data.ts +0 -201
- package/src/spec/dspark/deepspec-module.ts +0 -776
- package/src/spec/dspark/generate-dflash.ts +0 -245
- package/src/spec/dspark/generate.ts +0 -290
- package/src/spec/dspark/loader.ts +0 -23
- package/src/spec/dspark/loss.ts +0 -213
- package/src/spec/dspark/module-dflash.ts +0 -560
- package/src/spec/dspark/module.ts +0 -540
- package/src/spec/dspark/quantize-drafter.ts +0 -91
- package/src/spec/dspark/sample.ts +0 -112
- package/src/spec/generate.ts +0 -276
- package/src/spec/glm52-mtp-graph.ts +0 -29
- package/src/spec/glm52-mtp-rows.ts +0 -132
- package/src/spec/glm52-mtp-source.ts +0 -318
- package/src/spec/glm52-mtp-state.ts +0 -33
- package/src/spec/ngram-source.ts +0 -226
- package/src/spec/projected-draft-rows.ts +0 -73
- package/src/spec/qwen-mtp-module.ts +0 -171
- package/src/spec/qwen-mtp-rows.ts +0 -230
- package/src/spec/qwen-mtp-source.ts +0 -386
- package/src/spec/qwen-mtp-state.ts +0 -30
- package/src/spec/serve-loop.ts +0 -587
- package/src/spec/source.ts +0 -228
- package/src/spec/two-model-rows.ts +0 -62
- package/src/spec/two-model.ts +0 -113
- package/src/ssd-cache.ts +0 -431
- package/src/ssd-durability.ts +0 -209
- package/src/storage/host-buffer.ts +0 -24
- package/src/storage/kv-writer.ts +0 -83
- package/src/storage/kv-writer.worker.js +0 -264
- package/src/storage/retention-policy.ts +0 -37
- package/src/tiered-prompt-cache.ts +0 -45
- package/src/tokenizer.ts +0 -335
- package/src/tool-approvals.ts +0 -131
- package/src/tool-call.ts +0 -695
- package/src/train/dataset.ts +0 -484
- package/src/train/diffusion-lora.ts +0 -216
- package/src/train/export.ts +0 -32
- package/src/train/flash-cce.ts +0 -1234
- package/src/train/forward.ts +0 -266
- package/src/train/fuse.ts +0 -243
- package/src/train/index.ts +0 -46
- package/src/train/job.ts +0 -205
- package/src/train/lora-params.ts +0 -254
- package/src/train/loss.ts +0 -1591
- package/src/train/merge.ts +0 -195
- package/src/train/optimizer.ts +0 -183
- package/src/train/prefix-shared.ts +0 -490
- package/src/train/rank.ts +0 -137
- package/src/train/segmented.ts +0 -1828
- package/src/train/steel-qmm-header.ts +0 -1461
- package/src/train/trainer.ts +0 -1221
- package/src/train/watch.ts +0 -542
- package/src/vision/diffusion-vision.ts +0 -620
- package/src/vision/embedder.ts +0 -176
- package/src/vision/preprocess.ts +0 -247
- package/src/vision/prompt.ts +0 -409
- package/src/vision/qwen3vl-preprocess.ts +0 -409
- package/src/vision/qwen3vl-prompt.ts +0 -233
- package/src/vision/qwen3vl-tower.ts +0 -566
- package/src/vision/siglip.ts +0 -607
- package/src/vision/video-frames.ts +0 -148
- package/src/web/app.html +0 -2155
- package/src/web/app.js +0 -5863
- package/src/web/skills/memory/SKILL.md +0 -120
- package/src/web/skills/web-research/SKILL.md +0 -46
- package/src/web/skills.ts +0 -97
- package/src/web/src/adapters-panel.ts +0 -288
- package/src/web/src/assistant.ts +0 -451
- package/src/web/src/chat.ts +0 -1060
- package/src/web/src/composer.ts +0 -962
- package/src/web/src/dataset.ts +0 -132
- package/src/web/src/finetune.ts +0 -267
- package/src/web/src/hub.ts +0 -428
- package/src/web/src/main.ts +0 -98
- package/src/web/src/markdown.ts +0 -0
- package/src/web/src/memory-panel.ts +0 -572
- package/src/web/src/model-picker.ts +0 -159
- package/src/web/src/palette.ts +0 -346
- package/src/web/src/protocol.ts +0 -26
- package/src/web/src/quantize.ts +0 -167
- package/src/web/src/rag.ts +0 -274
- package/src/web/src/sessions.ts +0 -469
- package/src/web/src/shell.ts +0 -804
- package/src/web/src/ui-catalog.ts +0 -78
- package/src/web/src/voice.ts +0 -141
- package/src/web/vendor/README.md +0 -61
- package/src/web-assets.ts +0 -47
- package/src/web-tools.ts +0 -478
- package/src/weights.ts +0 -255
- /package/src/{serve → server}/completion-sink.ts +0 -0
- /package/src/{serve → server}/generated-token-history.ts +0 -0
- /package/src/web/{icon.svg → public/icon.svg} +0 -0
- /package/src/web/{manifest.webmanifest → public/manifest.webmanifest} +0 -0
- /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
|
-
}
|